mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
fix(memory): harden project scope authorization and consistency
This commit is contained in:
@@ -2261,6 +2261,7 @@ def test_prepare_and_write_path_refuse_in_the_same_words(coord_session):
|
||||
item = sess._prepare_tool(_tc("tasks", args))
|
||||
assert "error" in item, args
|
||||
expected = sess._coord_tool_error("call-1", "tasks", f"{action}: {authoritative['error']}")
|
||||
expected["_principal_id"] = sess._tool_prepare_principal_id()
|
||||
assert item == expected, (args, item["error"])
|
||||
|
||||
|
||||
|
||||
@@ -256,6 +256,7 @@ class TestWorldSeeding:
|
||||
"memory": [
|
||||
{
|
||||
"name": "proj-context",
|
||||
"description": "Project deployment context",
|
||||
"content": "acme-api: staging tracks main.",
|
||||
"type": "reference",
|
||||
}
|
||||
|
||||
+133
-18
@@ -121,7 +121,7 @@ def _seed_memory(storage, name="test_key", content="test content", **kw):
|
||||
storage.create_structured_memory(
|
||||
mid,
|
||||
name,
|
||||
kw.get("description", ""),
|
||||
kw.get("description", "Seeded memory"),
|
||||
kw.get("mem_type", "general"),
|
||||
kw.get("scope", "global"),
|
||||
kw.get("scope_id", ""),
|
||||
@@ -130,6 +130,20 @@ def _seed_memory(storage, name="test_key", content="test content", **kw):
|
||||
return mid
|
||||
|
||||
|
||||
def _seed_workstream(storage, ws_id: str = "ws1", user_id: str = "test-user") -> None:
|
||||
storage.register_workstream(ws_id, user_id=user_id)
|
||||
|
||||
|
||||
def _save_body(name: str, content: str, **overrides: Any) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"name": name,
|
||||
"content": content,
|
||||
"description": f"Description for {name}",
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Server endpoint tests
|
||||
# ===========================================================================
|
||||
@@ -158,6 +172,7 @@ class TestServerListMemories:
|
||||
assert r.json()["memories"][0]["name"] == "a"
|
||||
|
||||
def test_filter_by_scope(self, server_client, storage):
|
||||
_seed_workstream(storage)
|
||||
_seed_memory(storage, "a", "x", scope="global")
|
||||
_seed_memory(storage, "b", "y", scope="workstream", scope_id="ws1")
|
||||
r = server_client.get("/v1/api/memories?scope=workstream&scope_id=ws1")
|
||||
@@ -174,12 +189,38 @@ class TestServerListMemories:
|
||||
r = server_client.get("/v1/api/memories?limit=abc")
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_unscoped_list_is_caller_bound(self, server_client, storage):
|
||||
_seed_memory(storage, "global_visible", "g")
|
||||
_seed_memory(storage, "own_visible", "u", scope="user", scope_id="test-user")
|
||||
_seed_memory(storage, "victim_user", "secret", scope="user", scope_id="victim")
|
||||
_seed_memory(storage, "victim_coord", "secret", scope="coordinator", scope_id="victim")
|
||||
_seed_memory(storage, "private_project", "secret", scope="project", scope_id="p1")
|
||||
|
||||
r = server_client.get("/v1/api/memories")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert {row["name"] for row in r.json()["memories"]} == {
|
||||
"global_visible",
|
||||
"own_visible",
|
||||
}
|
||||
|
||||
def test_internal_scopes_are_rejected(self, server_client):
|
||||
for scope in ("coordinator", "project", "bogus"):
|
||||
r = server_client.get(f"/v1/api/memories?scope={scope}&scope_id=victim")
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_workstream_scope_is_owner_bound(self, server_client, storage):
|
||||
_seed_workstream(storage, "victim-ws", "victim")
|
||||
_seed_memory(storage, "secret", "x", scope="workstream", scope_id="victim-ws")
|
||||
r = server_client.get("/v1/api/memories?scope=workstream&scope_id=victim-ws")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
class TestServerSaveMemory:
|
||||
def test_create(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "my_key", "content": "my content"},
|
||||
json=_save_body("my_key", "my content"),
|
||||
)
|
||||
assert r.status_code == 201
|
||||
data = r.json()
|
||||
@@ -191,21 +232,23 @@ class TestServerSaveMemory:
|
||||
def test_upsert(self, server_client):
|
||||
server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "key", "content": "v1"},
|
||||
json=_save_body("key", "v1"),
|
||||
)
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "key", "content": "v2"},
|
||||
json=_save_body("key", "v2", description="Updated key description"),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["content"] == "v2"
|
||||
|
||||
def test_with_type_and_scope(self, server_client):
|
||||
def test_with_type_and_scope(self, server_client, storage):
|
||||
_seed_workstream(storage)
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={
|
||||
"name": "feedback_key",
|
||||
"content": "data",
|
||||
"description": "Feedback memory",
|
||||
"type": "feedback",
|
||||
"scope": "workstream",
|
||||
"scope_id": "ws1",
|
||||
@@ -216,17 +259,30 @@ class TestServerSaveMemory:
|
||||
assert r.json()["scope"] == "workstream"
|
||||
|
||||
def test_missing_name(self, server_client):
|
||||
r = server_client.post("/v1/api/memories", json={"content": "data"})
|
||||
r = server_client.post(
|
||||
"/v1/api/memories", json={"content": "data", "description": "Missing name"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_missing_content(self, server_client):
|
||||
r = server_client.post("/v1/api/memories", json={"name": "k"})
|
||||
r = server_client.post(
|
||||
"/v1/api/memories", json={"name": "k", "description": "Missing content"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
@pytest.mark.parametrize("description", [None, "", " "])
|
||||
def test_missing_or_empty_description(self, server_client, description):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "k", "content": "c", "description": description},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "description is required" in r.json()["error"]
|
||||
|
||||
def test_invalid_type(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "k", "content": "c", "type": "bogus"},
|
||||
json=_save_body("k", "c", type="bogus"),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "invalid type" in r.json()["error"]
|
||||
@@ -234,7 +290,7 @@ class TestServerSaveMemory:
|
||||
def test_invalid_scope(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "k", "content": "c", "scope": "bogus"},
|
||||
json=_save_body("k", "c", scope="bogus"),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "invalid scope" in r.json()["error"]
|
||||
@@ -242,7 +298,7 @@ class TestServerSaveMemory:
|
||||
def test_content_too_large(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "k", "content": "x" * 70000},
|
||||
json=_save_body("k", "x" * 70000),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "limit" in r.json()["error"]
|
||||
@@ -250,18 +306,32 @@ class TestServerSaveMemory:
|
||||
def test_name_normalisation(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "My-Key Name", "content": "data"},
|
||||
json=_save_body("My-Key Name", "data"),
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["name"] == "my_key_name"
|
||||
|
||||
def test_create_and_update_are_audited(self, server_client, storage):
|
||||
first = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json=_save_body("audit_me", "v1"),
|
||||
)
|
||||
second = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json=_save_body("audit_me", "v2", description="Updated audit memory"),
|
||||
)
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 200
|
||||
assert len(storage.list_audit_events(action="memory.save", user_id="test-user")) == 1
|
||||
assert len(storage.list_audit_events(action="memory.update", user_id="test-user")) == 1
|
||||
|
||||
|
||||
class TestServerUserScopeSecurity:
|
||||
def test_user_scope_binds_to_auth(self, server_client):
|
||||
"""User scope auto-resolves scope_id from authenticated user."""
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "priv", "content": "secret", "scope": "user"},
|
||||
json=_save_body("priv", "secret", scope="user"),
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["scope_id"] == "test-user"
|
||||
@@ -270,7 +340,7 @@ class TestServerUserScopeSecurity:
|
||||
"""Cannot access another user's memories via scope_id."""
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "x", "content": "y", "scope": "user", "scope_id": "other-user"},
|
||||
json=_save_body("x", "y", scope="user", scope_id="other-user"),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
@@ -278,7 +348,7 @@ class TestServerUserScopeSecurity:
|
||||
"""Passing own user_id as scope_id is allowed."""
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "x", "content": "y", "scope": "user", "scope_id": "test-user"},
|
||||
json=_save_body("x", "y", scope="user", scope_id="test-user"),
|
||||
)
|
||||
assert r.status_code == 201
|
||||
|
||||
@@ -298,7 +368,7 @@ class TestServerScopeScopeIdValidation:
|
||||
def test_save_global_with_scope_id_rejected(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "k", "content": "c", "scope": "global", "scope_id": "ws1"},
|
||||
json=_save_body("k", "c", scope="global", scope_id="ws1"),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "scope_id" in r.json()["error"]
|
||||
@@ -306,15 +376,16 @@ class TestServerScopeScopeIdValidation:
|
||||
def test_save_workstream_without_scope_id_rejected(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "k", "content": "c", "scope": "workstream"},
|
||||
json=_save_body("k", "c", scope="workstream"),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "scope_id is required" in r.json()["error"]
|
||||
|
||||
def test_save_workstream_with_scope_id_ok(self, server_client):
|
||||
def test_save_workstream_with_scope_id_ok(self, server_client, storage):
|
||||
_seed_workstream(storage)
|
||||
r = server_client.post(
|
||||
"/v1/api/memories",
|
||||
json={"name": "k", "content": "c", "scope": "workstream", "scope_id": "ws1"},
|
||||
json=_save_body("k", "c", scope="workstream", scope_id="ws1"),
|
||||
)
|
||||
assert r.status_code == 201
|
||||
|
||||
@@ -380,6 +451,21 @@ class TestServerSearchMemories:
|
||||
r = server_client.post("/v1/api/memories/search", json={})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_unscoped_search_is_caller_bound(self, server_client, storage):
|
||||
_seed_memory(storage, "own", "needle", scope="user", scope_id="test-user")
|
||||
_seed_memory(storage, "victim", "needle", scope="user", scope_id="victim")
|
||||
_seed_memory(storage, "project", "needle", scope="project", scope_id="p1")
|
||||
r = server_client.post("/v1/api/memories/search", json={"query": "needle"})
|
||||
assert r.status_code == 200
|
||||
assert {row["name"] for row in r.json()["memories"]} == {"own"}
|
||||
|
||||
def test_internal_scope_is_rejected(self, server_client):
|
||||
r = server_client.post(
|
||||
"/v1/api/memories/search",
|
||||
json={"query": "x", "scope": "project", "scope_id": "p1"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
class TestServerDeleteMemory:
|
||||
def test_delete(self, server_client, storage):
|
||||
@@ -393,6 +479,7 @@ class TestServerDeleteMemory:
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_delete_scoped(self, server_client, storage):
|
||||
_seed_workstream(storage)
|
||||
_seed_memory(storage, "k", "data", scope="workstream", scope_id="ws1")
|
||||
# Wrong scope → not found
|
||||
r = server_client.delete("/v1/api/memories/k")
|
||||
@@ -405,6 +492,14 @@ class TestServerDeleteMemory:
|
||||
r = server_client.delete("/v1/api/memories/k?scope=bogus")
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_delete_is_audited(self, server_client, storage):
|
||||
mid = _seed_memory(storage, "audited")
|
||||
r = server_client.delete("/v1/api/memories/audited")
|
||||
assert r.status_code == 200
|
||||
events = storage.list_audit_events(action="memory.delete", user_id="test-user")
|
||||
assert len(events) == 1
|
||||
assert events[0]["resource_id"] == mid
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Console admin endpoint tests
|
||||
@@ -492,6 +587,26 @@ class TestAdminDeleteMemory:
|
||||
r = admin_client.delete("/v1/api/admin/memories/nonexistent-id")
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_no_audit_or_success_when_atomic_delete_misses(
|
||||
self, admin_client, storage, monkeypatch
|
||||
):
|
||||
mid = _seed_memory(storage, "still_here")
|
||||
monkeypatch.setattr(storage, "delete_structured_memory_by_id_returning", lambda _mid: None)
|
||||
|
||||
r = admin_client.delete(f"/v1/api/admin/memories/{mid}")
|
||||
|
||||
assert r.status_code == 404
|
||||
assert storage.get_structured_memory(mid) is not None
|
||||
assert storage.list_audit_events(action="memory.delete") == []
|
||||
|
||||
def test_storage_failure_is_500(self, admin_client, storage, monkeypatch):
|
||||
def _raise(_memory_id):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
monkeypatch.setattr(storage, "delete_structured_memory_by_id_returning", _raise)
|
||||
r = admin_client.delete("/v1/api/admin/memories/m1")
|
||||
assert r.status_code == 500
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Storage: delete_structured_memory_by_id
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Tests for turnstone.core.memory_relevance — scoring, formatting, context extraction."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core import auth
|
||||
from turnstone.core.memory_relevance import (
|
||||
MemoryConfig,
|
||||
build_memory_context,
|
||||
@@ -298,6 +300,11 @@ def _make_session(fetch_limit: int = 5, relevance_k: int = 3, **kwargs: object):
|
||||
)
|
||||
|
||||
|
||||
def _execute_prepared_tool(session: Any, item: dict[str, Any]) -> tuple[str, str]:
|
||||
item.setdefault("_principal_id", session._tool_prepare_principal_id())
|
||||
return item["execute"](item)
|
||||
|
||||
|
||||
class TestCompositionCandidateSelection:
|
||||
"""Verify the query-aware candidate set in _init_system_messages."""
|
||||
|
||||
@@ -520,9 +527,11 @@ class TestMemorySearchToolExecution:
|
||||
"""Multi-word query returns rows where ANY term matches — not all."""
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory("postgres_notes", "host=localhost port=5432")
|
||||
save_structured_memory("redis_notes", "host=redis port=6379")
|
||||
save_structured_memory("unrelated", "completely different")
|
||||
save_structured_memory(
|
||||
"postgres_notes", "host=localhost port=5432", description="Postgres notes"
|
||||
)
|
||||
save_structured_memory("redis_notes", "host=redis port=6379", description="Redis notes")
|
||||
save_structured_memory("unrelated", "completely different", description="Unrelated notes")
|
||||
|
||||
session = _make_session()
|
||||
item = session._prepare_memory(
|
||||
@@ -532,12 +541,39 @@ class TestMemorySearchToolExecution:
|
||||
# Sanity: prepare returned a search-ready dispatch (not an error item)
|
||||
assert item.get("action") == "search"
|
||||
|
||||
call_id, msg = session._exec_memory(item)
|
||||
call_id, msg = _execute_prepared_tool(session, item)
|
||||
assert call_id == "call-1"
|
||||
assert "postgres_notes" in msg
|
||||
# Other memories don't match any query term
|
||||
assert "unrelated" not in msg
|
||||
|
||||
def test_search_and_list_guidance_carries_the_displayed_scope(self, tmp_db, monkeypatch):
|
||||
"""Follow-up guidance must not drop a project result's scope."""
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory(
|
||||
"july_digest",
|
||||
"project day digest",
|
||||
description="July project digest",
|
||||
scope="project",
|
||||
scope_id="p1",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"resolve_project_access",
|
||||
lambda *_a, **_k: auth.ProjectAccess(True, True, "P", "active"),
|
||||
)
|
||||
session = _make_session(user_id="u1", project_id="p1")
|
||||
|
||||
for args in (
|
||||
{"action": "search", "query": "digest"},
|
||||
{"action": "list"},
|
||||
):
|
||||
item = session._prepare_memory("call-1", args)
|
||||
_, msg = _execute_prepared_tool(session, item)
|
||||
assert "[general:project] july_digest" in msg
|
||||
assert "call memory(action='get') with the displayed name and scope" in msg
|
||||
|
||||
|
||||
class TestPerTurnSearchCache:
|
||||
"""The per-turn cache spares redundant SQL across mid-turn rebuilds."""
|
||||
@@ -545,7 +581,7 @@ class TestPerTurnSearchCache:
|
||||
def test_repeated_search_in_same_turn_hits_cache(self, tmp_db):
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory("hello_mem", "alpha beta gamma")
|
||||
save_structured_memory("hello_mem", "alpha beta gamma", description="Greeting memory")
|
||||
session = _make_session()
|
||||
with patch(
|
||||
"turnstone.core.session.search_visible_structured_memories",
|
||||
@@ -560,7 +596,7 @@ class TestPerTurnSearchCache:
|
||||
def test_user_turn_invalidates_cache(self, tmp_db):
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory("hello_mem", "alpha")
|
||||
save_structured_memory("hello_mem", "alpha", description="Greeting memory")
|
||||
session = _make_session()
|
||||
with patch(
|
||||
"turnstone.core.session.search_visible_structured_memories",
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
"""Phase 4: the ``project`` memory scope.
|
||||
|
||||
Covers construction-time access resolution (``_project_id`` / ``_project_writable``)
|
||||
and its effect on recall — ``_visible_scopes`` / ``_resolve_scope_id`` /
|
||||
``_validate_scope`` — for both interactive and coordinator sessions. The ACL is
|
||||
monkeypatched (it is unit-tested in ``test_project_storage.py``); here we assert
|
||||
the session wiring around it.
|
||||
"""
|
||||
"""Actor-scoped, live ``project`` memory authorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -35,10 +28,32 @@ def _session(**kwargs: Any) -> ChatSession:
|
||||
return ChatSession(**defaults)
|
||||
|
||||
|
||||
class TestConstructionResolvesProjectAccess:
|
||||
"""Construction resolves the attached project through a single
|
||||
``resolve_project_access`` call; recall is gated on read access AND a
|
||||
non-archived project."""
|
||||
def _execute_prepared_tool(
|
||||
session: ChatSession,
|
||||
item: dict[str, Any],
|
||||
) -> tuple[str, str | list[dict[str, Any]]]:
|
||||
item.setdefault("_principal_id", session._tool_prepare_principal_id())
|
||||
return item["execute"](item)
|
||||
|
||||
|
||||
def _project_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
writable: bool = True,
|
||||
kind: WorkstreamKind = WorkstreamKind.INTERACTIVE,
|
||||
user_id: str = "u1",
|
||||
) -> ChatSession:
|
||||
"""Construct an attached session whose live ACL stays controllable."""
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"resolve_project_access",
|
||||
lambda *_a, **_k: auth.ProjectAccess(True, writable, "P", "active"),
|
||||
)
|
||||
return _session(user_id=user_id, ws_id="ws1", kind=kind, project_id="p1")
|
||||
|
||||
|
||||
class TestLiveProjectAccess:
|
||||
"""Each access snapshot resolves the attachment for the current actor."""
|
||||
|
||||
def _access(self, can_read: bool, can_write: bool, state: str = "active") -> object:
|
||||
return auth.ProjectAccess(can_read, can_write, "P", state)
|
||||
@@ -48,9 +63,10 @@ class TestConstructionResolvesProjectAccess:
|
||||
auth, "resolve_project_access", lambda *a, **k: self._access(True, True)
|
||||
)
|
||||
s = _session(user_id="u1", project_id="p1")
|
||||
assert s._project_id == "p1"
|
||||
assert s._project_writable is True
|
||||
assert s._project_name == "P"
|
||||
access = s._memory_access()
|
||||
assert access.project_id == "p1"
|
||||
assert access.project_writable is True
|
||||
assert access.project_name == "P"
|
||||
|
||||
def test_read_only_member(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Read access but no write (e.g. a non-member reading a public project).
|
||||
@@ -58,16 +74,19 @@ class TestConstructionResolvesProjectAccess:
|
||||
auth, "resolve_project_access", lambda *a, **k: self._access(True, False)
|
||||
)
|
||||
s = _session(user_id="u1", project_id="p1")
|
||||
assert s._project_id == "p1"
|
||||
assert s._project_writable is False
|
||||
access = s._memory_access()
|
||||
assert access.project_id == "p1"
|
||||
assert access.project_writable is False
|
||||
|
||||
def test_denied_without_access(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
auth, "resolve_project_access", lambda *a, **k: self._access(False, False)
|
||||
)
|
||||
s = _session(user_id="u1", project_id="p1")
|
||||
assert s._project_id == ""
|
||||
assert s._project_writable is False
|
||||
access = s._memory_access()
|
||||
assert access.attached_project_id == "p1"
|
||||
assert access.project_id == ""
|
||||
assert access.project_writable is False
|
||||
|
||||
def test_archived_project_not_recalled(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Full access but archived → not recalled (the owner still reaches it via
|
||||
@@ -76,13 +95,17 @@ class TestConstructionResolvesProjectAccess:
|
||||
auth, "resolve_project_access", lambda *a, **k: self._access(True, True, "archived")
|
||||
)
|
||||
s = _session(user_id="u1", project_id="p1")
|
||||
assert s._project_id == ""
|
||||
assert s._project_writable is False
|
||||
access = s._memory_access()
|
||||
assert access.attached_project_id == "p1"
|
||||
assert access.project_id == ""
|
||||
assert access.project_writable is False
|
||||
|
||||
def test_no_project_id_is_inert(self) -> None:
|
||||
s = _session(user_id="u1")
|
||||
assert s._project_id == ""
|
||||
assert s._project_writable is False
|
||||
access = s._memory_access()
|
||||
assert access.attached_project_id == ""
|
||||
assert access.project_id == ""
|
||||
assert access.project_writable is False
|
||||
|
||||
def test_unauthenticated_never_resolves(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Even if the ACL would allow it, an empty user_id short-circuits before
|
||||
@@ -91,13 +114,16 @@ class TestConstructionResolvesProjectAccess:
|
||||
auth, "resolve_project_access", lambda *a, **k: self._access(True, True)
|
||||
)
|
||||
s = _session(user_id="", project_id="p1")
|
||||
assert s._project_id == ""
|
||||
access = s._memory_access()
|
||||
assert access.attached_project_id == "p1"
|
||||
assert access.project_id == ""
|
||||
|
||||
|
||||
class TestProjectRecall:
|
||||
def test_interactive_visible_scopes_includes_project(self) -> None:
|
||||
s = _session(user_id="u1", ws_id="ws1")
|
||||
s._project_id = "p1"
|
||||
def test_interactive_visible_scopes_includes_project(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
s = _project_session(monkeypatch)
|
||||
scopes = s._visible_scopes()
|
||||
assert ("project", "p1") in scopes
|
||||
assert ("global", "") in scopes
|
||||
@@ -107,9 +133,10 @@ class TestProjectRecall:
|
||||
s = _session(user_id="u1", ws_id="ws1")
|
||||
assert all(scope != "project" for scope, _ in s._visible_scopes())
|
||||
|
||||
def test_coordinator_adds_project_keeps_isolation(self) -> None:
|
||||
s = _session(user_id="u1", kind=WorkstreamKind.COORDINATOR)
|
||||
s._project_id = "p1"
|
||||
def test_coordinator_adds_project_keeps_isolation(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
s = _project_session(monkeypatch, kind=WorkstreamKind.COORDINATOR)
|
||||
scopes = s._visible_scopes()
|
||||
assert ("coordinator", "u1") in scopes
|
||||
assert ("project", "p1") in scopes
|
||||
@@ -118,25 +145,24 @@ class TestProjectRecall:
|
||||
|
||||
def test_visible_scopes_omits_empty_project(self) -> None:
|
||||
s = _session(user_id="u1", ws_id="ws1")
|
||||
s._project_id = ""
|
||||
assert all(scope != "project" for scope, _ in s._visible_scopes())
|
||||
|
||||
|
||||
class TestProjectScopeResolutionAndValidation:
|
||||
def test_resolve_scope_id_project(self) -> None:
|
||||
s = _session(user_id="u1")
|
||||
s._project_id = "p1"
|
||||
def test_resolve_scope_id_project(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = _project_session(monkeypatch)
|
||||
assert s._resolve_scope_id("project") == "p1"
|
||||
|
||||
def test_validate_requires_attachment(self) -> None:
|
||||
def test_validate_requires_attachment(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = _session(user_id="u1")
|
||||
assert s._validate_scope("project", "cid") is not None # not attached → rejected
|
||||
s._project_id = "p1"
|
||||
assert s._validate_scope("project", "cid") is None
|
||||
attached = _project_session(monkeypatch)
|
||||
assert attached._validate_scope("project", "cid") is None
|
||||
|
||||
def test_coordinator_allows_project_rejects_global(self) -> None:
|
||||
s = _session(user_id="u1", kind=WorkstreamKind.COORDINATOR)
|
||||
s._project_id = "p1"
|
||||
def test_coordinator_allows_project_rejects_global(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
s = _project_session(monkeypatch, kind=WorkstreamKind.COORDINATOR)
|
||||
assert s._validate_scope("project", "cid") is None # project allowed for coord
|
||||
assert s._validate_scope("global", "cid") is not None # global still rejected
|
||||
|
||||
@@ -172,78 +198,328 @@ class TestProjectInSystemContext:
|
||||
class TestProjectWriteGate:
|
||||
"""The save AND delete memory paths block writes to a project the session
|
||||
can read but not write (a read-only member of a public project). Construction
|
||||
resolves ``_project_writable``; these drive the preparer to assert the gate
|
||||
actually fires (the resolution-level check lives in
|
||||
``TestConstructionResolvesProjectAccess``)."""
|
||||
resolves live access; these drive the preparer to assert the gate actually
|
||||
fires (the resolution-level check lives in ``TestLiveProjectAccess``)."""
|
||||
|
||||
def _attached(self, *, writable: bool) -> ChatSession:
|
||||
s = _session(user_id="u1")
|
||||
s._project_id = "p1"
|
||||
s._project_writable = writable
|
||||
return s
|
||||
def _attached(self, monkeypatch: pytest.MonkeyPatch, *, writable: bool) -> ChatSession:
|
||||
return _project_session(monkeypatch, writable=writable)
|
||||
|
||||
def test_save_blocked_when_read_only(self) -> None:
|
||||
s = self._attached(writable=False)
|
||||
def test_save_blocked_when_read_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = self._attached(monkeypatch, writable=False)
|
||||
out = s._prepare_memory(
|
||||
"cid", {"action": "save", "scope": "project", "name": "k", "content": "v"}
|
||||
"cid",
|
||||
{
|
||||
"action": "save",
|
||||
"scope": "project",
|
||||
"name": "k",
|
||||
"content": "v",
|
||||
"description": "Test memory",
|
||||
},
|
||||
)
|
||||
assert "read-only access to this project" in out.get("error", "")
|
||||
assert "read-only access to the attached project" in out.get("error", "")
|
||||
|
||||
def test_save_allowed_when_writable(self) -> None:
|
||||
s = self._attached(writable=True)
|
||||
def test_save_allowed_when_writable(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = self._attached(monkeypatch, writable=True)
|
||||
out = s._prepare_memory(
|
||||
"cid", {"action": "save", "scope": "project", "name": "k", "content": "v"}
|
||||
"cid",
|
||||
{
|
||||
"action": "save",
|
||||
"scope": "project",
|
||||
"name": "k",
|
||||
"content": "v",
|
||||
"description": "Test memory",
|
||||
},
|
||||
)
|
||||
assert "error" not in out
|
||||
assert out.get("execute") is not None # would proceed to the save exec
|
||||
|
||||
def test_delete_blocked_when_read_only(self) -> None:
|
||||
s = self._attached(writable=False)
|
||||
def test_delete_blocked_when_read_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = self._attached(monkeypatch, writable=False)
|
||||
out = s._prepare_memory("cid", {"action": "delete", "scope": "project", "name": "k"})
|
||||
assert "read-only access to this project" in out.get("error", "")
|
||||
assert "read-only access to the attached project" in out.get("error", "")
|
||||
|
||||
def test_delete_allowed_when_writable(self) -> None:
|
||||
s = self._attached(writable=True)
|
||||
def test_delete_allowed_when_writable(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = self._attached(monkeypatch, writable=True)
|
||||
out = s._prepare_memory("cid", {"action": "delete", "scope": "project", "name": "k"})
|
||||
assert "error" not in out
|
||||
assert out.get("execute") is not None
|
||||
|
||||
|
||||
class TestProjectDefaultSaveScope:
|
||||
"""A writable attached project becomes the DEFAULT save scope (both kinds);
|
||||
a read-only or unattached session keeps the kind default."""
|
||||
class TestActingPrincipalProjectAuthority:
|
||||
@staticmethod
|
||||
def _tool_call(call_id: str, **arguments: Any) -> dict[str, Any]:
|
||||
import json
|
||||
|
||||
def test_writable_project_is_default(self) -> None:
|
||||
s = _session(user_id="u1")
|
||||
s._project_id = "p1"
|
||||
s._project_writable = True
|
||||
return {
|
||||
"id": call_id,
|
||||
"function": {"name": "memory", "arguments": json.dumps(arguments)},
|
||||
}
|
||||
|
||||
def test_guest_cannot_inherit_owner_project_access(
|
||||
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def resolve(user_id: str, _project_id: str, **_kwargs: Any) -> auth.ProjectAccess:
|
||||
if user_id == "owner":
|
||||
return auth.ProjectAccess(True, True, "Owner Project", "active")
|
||||
return auth.ProjectAccess(False, False, "", "")
|
||||
|
||||
monkeypatch.setattr(auth, "resolve_project_access", resolve)
|
||||
session = _session(user_id="owner", ws_id="shared", project_id="p1")
|
||||
session.bind_acting_user("guest")
|
||||
|
||||
assert all(scope != "project" for scope, _ in session._visible_scopes())
|
||||
for action in ("get", "save", "delete"):
|
||||
arguments: dict[str, Any] = {
|
||||
"action": action,
|
||||
"name": "owner_secret",
|
||||
"scope": "project",
|
||||
}
|
||||
if action == "save":
|
||||
arguments["content"] = "guest write"
|
||||
arguments["description"] = "Guest write attempt"
|
||||
item = session._prepare_tool(self._tool_call(action, **arguments))
|
||||
assert item["_principal_id"] == "guest"
|
||||
assert "error" in item
|
||||
assert "acting user cannot access" in item["error"]
|
||||
|
||||
def test_project_delete_revalidates_prepared_principal_and_live_acl(
|
||||
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from turnstone.core.memory import (
|
||||
get_structured_memory_by_name,
|
||||
save_structured_memory,
|
||||
)
|
||||
|
||||
guest_access = {"value": auth.ProjectAccess(True, True, "Shared", "active")}
|
||||
|
||||
def resolve(user_id: str, _project_id: str, **_kwargs: Any) -> auth.ProjectAccess:
|
||||
if user_id == "guest":
|
||||
return guest_access["value"]
|
||||
return auth.ProjectAccess(True, True, "Shared", "active")
|
||||
|
||||
monkeypatch.setattr(auth, "resolve_project_access", resolve)
|
||||
save_structured_memory(
|
||||
"shared_secret",
|
||||
"keep",
|
||||
description="Shared project secret",
|
||||
scope="project",
|
||||
scope_id="p1",
|
||||
)
|
||||
session = _session(user_id="owner", ws_id="shared", project_id="p1")
|
||||
session.bind_acting_user("guest")
|
||||
item = session._prepare_tool(
|
||||
self._tool_call(
|
||||
"delete",
|
||||
action="delete",
|
||||
name="shared_secret",
|
||||
scope="project",
|
||||
)
|
||||
)
|
||||
assert "error" not in item
|
||||
assert item["_principal_id"] == "guest"
|
||||
|
||||
guest_access["value"] = auth.ProjectAccess(False, False, "", "")
|
||||
session.bind_acting_user("owner")
|
||||
_, message = _execute_prepared_tool(session, item)
|
||||
|
||||
assert "acting user cannot access" in message
|
||||
assert get_structured_memory_by_name("shared_secret", "project", "p1") is not None
|
||||
|
||||
def test_archived_project_is_removed_from_live_visibility(
|
||||
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
project_state = {"value": "active"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"resolve_project_access",
|
||||
lambda *_args, **_kwargs: auth.ProjectAccess(
|
||||
True, True, "Shared", project_state["value"]
|
||||
),
|
||||
)
|
||||
session = _session(user_id="owner", ws_id="shared", project_id="p1")
|
||||
assert ("project", "p1") in session._visible_scopes()
|
||||
|
||||
project_state["value"] = "archived"
|
||||
|
||||
assert all(scope != "project" for scope, _ in session._visible_scopes())
|
||||
item = session._prepare_memory(
|
||||
"get", {"action": "get", "name": "anything", "scope": "project"}
|
||||
)
|
||||
assert "error" in item
|
||||
assert "active attached project" in item["error"]
|
||||
|
||||
|
||||
class TestProjectDefaultSaveScope:
|
||||
"""An attachment is the inherited target even when it is read-only."""
|
||||
|
||||
def test_writable_project_is_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = _project_session(monkeypatch)
|
||||
assert s._default_memory_scope() == "project"
|
||||
|
||||
def test_read_only_project_keeps_kind_default(self) -> None:
|
||||
s = _session(user_id="u1")
|
||||
s._project_id = "p1"
|
||||
s._project_writable = False
|
||||
assert s._default_memory_scope() == "global"
|
||||
def test_read_only_project_remains_inherited_target(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
s = _project_session(monkeypatch, writable=False)
|
||||
assert s._default_memory_scope() == "project"
|
||||
|
||||
def test_no_project_keeps_kind_default(self) -> None:
|
||||
assert _session(user_id="u1")._default_memory_scope() == "global"
|
||||
|
||||
def test_coordinator_writable_project_is_default(self) -> None:
|
||||
s = _session(user_id="u1", kind=WorkstreamKind.COORDINATOR)
|
||||
s._project_id = "p1"
|
||||
s._project_writable = True
|
||||
def test_coordinator_writable_project_is_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = _project_session(monkeypatch, kind=WorkstreamKind.COORDINATOR)
|
||||
assert s._default_memory_scope() == "project"
|
||||
|
||||
def test_coordinator_without_project_is_coordinator(self) -> None:
|
||||
s = _session(user_id="u1", kind=WorkstreamKind.COORDINATOR)
|
||||
assert s._default_memory_scope() == "coordinator"
|
||||
|
||||
def test_save_without_scope_lands_in_project(self) -> None:
|
||||
def test_save_without_scope_lands_in_project(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# End-to-end: an unscoped save in a writable-project session resolves to
|
||||
# scope=project / scope_id=project_id (not the global default).
|
||||
s = _session(user_id="u1")
|
||||
s._project_id = "p1"
|
||||
s._project_writable = True
|
||||
out = s._prepare_memory("cid", {"action": "save", "name": "k", "content": "v"})
|
||||
s = _project_session(monkeypatch)
|
||||
out = s._prepare_memory(
|
||||
"cid",
|
||||
{
|
||||
"action": "save",
|
||||
"name": "k",
|
||||
"content": "v",
|
||||
"description": "Test memory",
|
||||
},
|
||||
)
|
||||
assert out.get("scope") == "project"
|
||||
assert out.get("scope_id") == "p1"
|
||||
|
||||
|
||||
class TestProjectDefaultGetDeleteScope:
|
||||
"""An attached project is the inherited get/delete target.
|
||||
|
||||
This aligns the name-based lifecycle: a memory saved without an explicit
|
||||
scope can be fetched or removed the same way while the workstream remains
|
||||
attached to that project.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _attached(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
writable: bool = True,
|
||||
kind: WorkstreamKind = WorkstreamKind.INTERACTIVE,
|
||||
) -> ChatSession:
|
||||
return _project_session(monkeypatch, writable=writable, kind=kind)
|
||||
|
||||
def test_get_without_scope_targets_project(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Read access is sufficient for the inherited get target; writability
|
||||
# only controls save/delete.
|
||||
s = self._attached(monkeypatch, writable=False)
|
||||
item = s._prepare_memory("cid", {"action": "get", "name": "k"})
|
||||
assert item["scopes_to_try"] == [("project", "p1")]
|
||||
|
||||
def test_delete_without_scope_targets_writable_project(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
s = self._attached(monkeypatch)
|
||||
item = s._prepare_memory("cid", {"action": "delete", "name": "k"})
|
||||
assert item["scopes_to_try"] == [("project", "p1")]
|
||||
|
||||
def test_delete_without_scope_rejects_read_only_project(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
s = self._attached(monkeypatch, writable=False)
|
||||
item = s._prepare_memory("cid", {"action": "delete", "name": "k"})
|
||||
assert "read-only access to the attached project" in item.get("error", "")
|
||||
|
||||
def test_read_only_project_does_not_block_explicit_other_scope(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
s = self._attached(monkeypatch, writable=False)
|
||||
item = s._prepare_memory(
|
||||
"cid",
|
||||
{"action": "delete", "name": "k", "scope": "global"},
|
||||
)
|
||||
assert "error" not in item
|
||||
assert item["scopes_to_try"] == [("global", "")]
|
||||
|
||||
def test_coordinator_inherits_project_too(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
s = self._attached(monkeypatch, kind=WorkstreamKind.COORDINATOR)
|
||||
get_item = s._prepare_memory("get", {"action": "get", "name": "k"})
|
||||
delete_item = s._prepare_memory("delete", {"action": "delete", "name": "k"})
|
||||
assert get_item["scopes_to_try"] == [("project", "p1")]
|
||||
assert delete_item["scopes_to_try"] == [("project", "p1")]
|
||||
|
||||
def test_unscoped_get_and_delete_round_trip_project_memory(
|
||||
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from turnstone.core.memory import (
|
||||
get_structured_memory_by_name,
|
||||
save_structured_memory,
|
||||
)
|
||||
|
||||
row, _ = save_structured_memory(
|
||||
"july_digest",
|
||||
"full digest",
|
||||
description="July digest",
|
||||
scope="project",
|
||||
scope_id="p1",
|
||||
)
|
||||
assert row is not None
|
||||
|
||||
s = self._attached(monkeypatch)
|
||||
get_item = s._prepare_memory("get", {"action": "get", "name": "july_digest"})
|
||||
_, get_msg = _execute_prepared_tool(s, get_item)
|
||||
assert "[general:project] july_digest" in get_msg
|
||||
assert "full digest" in get_msg
|
||||
|
||||
delete_item = s._prepare_memory("delete", {"action": "delete", "name": "july_digest"})
|
||||
_, delete_msg = _execute_prepared_tool(s, delete_item)
|
||||
assert "Deleted memory 'july_digest' (scope=project)" in delete_msg
|
||||
assert get_structured_memory_by_name("july_digest", "project", "p1") is None
|
||||
|
||||
def test_wrong_explicit_scope_hints_at_attached_project(
|
||||
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
row, _ = save_structured_memory(
|
||||
"july_digest",
|
||||
"full digest",
|
||||
description="July digest",
|
||||
scope="project",
|
||||
scope_id="p1",
|
||||
)
|
||||
assert row is not None
|
||||
|
||||
s = self._attached(monkeypatch)
|
||||
for action in ("get", "delete"):
|
||||
item = s._prepare_memory(
|
||||
action,
|
||||
{"action": action, "name": "july_digest", "scope": "global"},
|
||||
)
|
||||
_, msg = _execute_prepared_tool(s, item)
|
||||
assert "not found (scope=global)" in msg
|
||||
assert "exists in scope='project'" in msg
|
||||
assert "retry with scope='project'" in msg
|
||||
|
||||
def test_project_default_miss_hints_at_other_visible_scope(
|
||||
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
row, _ = save_structured_memory(
|
||||
"shared_runbook",
|
||||
"global content",
|
||||
description="Shared runbook",
|
||||
scope="global",
|
||||
)
|
||||
assert row is not None
|
||||
|
||||
s = self._attached(monkeypatch)
|
||||
for action in ("get", "delete"):
|
||||
item = s._prepare_memory(
|
||||
action,
|
||||
{"action": action, "name": "shared_runbook"},
|
||||
)
|
||||
_, msg = _execute_prepared_tool(s, item)
|
||||
assert "not found (scope=project)" in msg
|
||||
assert "exists in scope='global'" in msg
|
||||
assert "retry with scope='global'" in msg
|
||||
|
||||
@@ -68,9 +68,9 @@ class TestProjectStore:
|
||||
# a sibling project's nor other scopes' rows.
|
||||
backend.create_project("p1", "A", "u1")
|
||||
backend.create_project("p2", "B", "u1")
|
||||
backend.create_structured_memory("m1", "k", "", "general", "project", "p1", "v")
|
||||
backend.create_structured_memory("m2", "k", "", "general", "project", "p2", "v")
|
||||
backend.create_structured_memory("m3", "k", "", "general", "user", "u1", "v")
|
||||
backend.create_structured_memory("m1", "k", "Test memory", "general", "project", "p1", "v")
|
||||
backend.create_structured_memory("m2", "k", "Test memory", "general", "project", "p2", "v")
|
||||
backend.create_structured_memory("m3", "k", "Test memory", "general", "user", "u1", "v")
|
||||
assert backend.delete_project("p1")
|
||||
assert backend.get_structured_memory("m1") is None # purged
|
||||
assert backend.get_structured_memory("m2") is not None # sibling project intact
|
||||
|
||||
@@ -361,6 +361,62 @@ async def test_logout():
|
||||
assert resp.status == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Memories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_save_memory_requires_and_sends_description():
|
||||
captured: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.update(json.loads(request.content))
|
||||
return _json_response(
|
||||
{
|
||||
"memory_id": "m1",
|
||||
"name": "deployment_process",
|
||||
"description": captured["description"],
|
||||
"type": "general",
|
||||
"scope": "global",
|
||||
"scope_id": "",
|
||||
"content": "Deploy from main",
|
||||
"created": "2026-08-11T00:00:00",
|
||||
"updated": "2026-08-11T00:00:00",
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
||||
client = AsyncTurnstoneServer(httpx_client=hc)
|
||||
memory = await client.save_memory(
|
||||
"deployment_process",
|
||||
"Deploy from main",
|
||||
description=" Production deployment workflow ",
|
||||
)
|
||||
|
||||
assert captured["description"] == "Production deployment workflow"
|
||||
assert memory.description == "Production deployment workflow"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("description", [None, "", " "])
|
||||
async def test_save_memory_rejects_empty_description(description):
|
||||
def unexpected_request(_request: httpx.Request) -> httpx.Response:
|
||||
raise AssertionError("invalid memory must not reach the server")
|
||||
|
||||
transport = httpx.MockTransport(unexpected_request)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
||||
client = AsyncTurnstoneServer(httpx_client=hc)
|
||||
with pytest.raises(ValueError, match="description is required"):
|
||||
await client.save_memory(
|
||||
"deployment_process",
|
||||
"Deploy from main",
|
||||
description=description, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+403
-72
@@ -29,7 +29,12 @@ from turnstone.core.model_turn import (
|
||||
provider_extra_params,
|
||||
serialized_tool_chars,
|
||||
)
|
||||
from turnstone.core.session import _IMAGE_EXTENSIONS, _IMAGE_SIZE_CAP, ChatSession
|
||||
from turnstone.core.session import (
|
||||
_IMAGE_EXTENSIONS,
|
||||
_IMAGE_SIZE_CAP,
|
||||
_MEMORY_MIXED_BATCH_ERROR,
|
||||
ChatSession,
|
||||
)
|
||||
from turnstone.core.trajectory import (
|
||||
Role,
|
||||
Turn,
|
||||
@@ -129,6 +134,15 @@ def _make_session(
|
||||
return session
|
||||
|
||||
|
||||
def _execute_prepared_tool(
|
||||
session: ChatSession,
|
||||
item: dict[str, Any],
|
||||
) -> tuple[str, str | list[dict[str, Any]]]:
|
||||
"""Mirror the dispatch boundary for tests that call a preparer directly."""
|
||||
item.setdefault("_principal_id", session._tool_prepare_principal_id())
|
||||
return item["execute"](item)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _send_with_mocks(session, responses, mock_execute, **extra_patches):
|
||||
"""Stand up the mock context that the queued-message ``send()`` tests share.
|
||||
@@ -458,12 +472,20 @@ class TestTaskExec:
|
||||
"func_name": "task_agent",
|
||||
"needs_approval": True,
|
||||
"execute": execute,
|
||||
"_needs_origin_context": True,
|
||||
"_requires_fresh_system_prefix": True,
|
||||
}
|
||||
|
||||
def prepare(_tool_call):
|
||||
seen["prepare"] = session._tool_prepare_principal_id()
|
||||
return item
|
||||
|
||||
judge = MagicMock(side_effect=evaluate_intent)
|
||||
with (
|
||||
patch.object(session, "_safe_prepare_tool", return_value=item),
|
||||
patch.object(session, "_safe_prepare_tool", side_effect=prepare),
|
||||
patch.object(session, "_evaluate_intent", judge),
|
||||
patch.object(session.ui, "approve_tools", side_effect=approve_tools),
|
||||
patch.object(session, "_ensure_system_prefix_fresh") as ensure_system_prefix,
|
||||
):
|
||||
session._execute_tools(
|
||||
[{"id": "c1", "function": {"name": "task_agent", "arguments": "{}"}}],
|
||||
@@ -471,9 +493,14 @@ class TestTaskExec:
|
||||
my_generation=generation,
|
||||
)
|
||||
|
||||
assert seen["prepare"] == "user-a"
|
||||
assert seen["worker"] == "user-a"
|
||||
assert seen["generation"] == generation
|
||||
assert seen["event"] is generation_event
|
||||
ensure_system_prefix.assert_called_once_with(
|
||||
principal_id="user-a",
|
||||
origin_generation=generation,
|
||||
)
|
||||
assert judge.call_args.kwargs["principal_id"] == "user-a"
|
||||
assert seen["execution_item"] is not item
|
||||
approval_witness = seen["approval_item"]["_approval_cancel_witness"]
|
||||
@@ -6199,6 +6226,96 @@ class TestSafePrepareTool:
|
||||
assert "RuntimeError" in output
|
||||
|
||||
|
||||
class TestToolBatchPolicy:
|
||||
@staticmethod
|
||||
def _tool_calls(count: int) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": f"call_{index}",
|
||||
"function": {"name": "state_tool", "arguments": "{}"},
|
||||
}
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
def test_mixed_read_write_batch_is_rejected(self, tmp_db):
|
||||
session = _make_session()
|
||||
executed: list[str] = []
|
||||
|
||||
def execute(item):
|
||||
executed.append(item["call_id"])
|
||||
return item["call_id"], "unexpected"
|
||||
|
||||
items = [
|
||||
{
|
||||
"call_id": "call_0",
|
||||
"func_name": "state_tool",
|
||||
"execute": execute,
|
||||
"needs_approval": False,
|
||||
"_batch_policy": {
|
||||
"group": "state",
|
||||
"access": "write",
|
||||
"mixed_access_error": "Error: state reads and writes cannot run together",
|
||||
"serialize": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"call_id": "call_1",
|
||||
"func_name": "state_tool",
|
||||
"execute": execute,
|
||||
"needs_approval": False,
|
||||
"_batch_policy": {
|
||||
"group": "state",
|
||||
"access": "read",
|
||||
"mixed_access_error": "Error: state reads and writes cannot run together",
|
||||
},
|
||||
},
|
||||
]
|
||||
with (
|
||||
patch.object(session, "_safe_prepare_tool", side_effect=items),
|
||||
patch.object(session.ui, "approve_tools", return_value=(True, None)),
|
||||
):
|
||||
results, _ = session._execute_tools(self._tool_calls(2))
|
||||
|
||||
assert executed == []
|
||||
assert all("cannot run together" in str(result) for _, result in results)
|
||||
|
||||
def test_write_batch_executes_serially_in_model_order(self, tmp_db):
|
||||
session = _make_session()
|
||||
executed: list[str] = []
|
||||
|
||||
def execute(item):
|
||||
executed.append(item["call_id"])
|
||||
return item["call_id"], "ok"
|
||||
|
||||
items = [
|
||||
{
|
||||
"call_id": f"call_{index}",
|
||||
"func_name": "state_tool",
|
||||
"execute": execute,
|
||||
"needs_approval": False,
|
||||
"_batch_policy": {
|
||||
"group": "state",
|
||||
"access": "write",
|
||||
"mixed_access_error": "Error: mixed state access",
|
||||
"serialize": True,
|
||||
},
|
||||
}
|
||||
for index in range(2)
|
||||
]
|
||||
with (
|
||||
patch.object(session, "_safe_prepare_tool", side_effect=items),
|
||||
patch.object(session.ui, "approve_tools", return_value=(True, None)),
|
||||
patch(
|
||||
"turnstone.core.session.concurrent.futures.ThreadPoolExecutor",
|
||||
side_effect=AssertionError("serialized writes must not enter the parallel pool"),
|
||||
),
|
||||
):
|
||||
results, _ = session._execute_tools(self._tool_calls(2))
|
||||
|
||||
assert executed == ["call_0", "call_1"]
|
||||
assert [call_id for call_id, _ in results] == executed
|
||||
|
||||
|
||||
class TestCoordinatorMemoryScope:
|
||||
"""Verify the ``coordinator`` memory scope's resolution + validation rules.
|
||||
|
||||
@@ -6269,7 +6386,7 @@ class TestCoordinatorMemoryScope:
|
||||
)
|
||||
err = session._validate_scope("coordinator", "call_1")
|
||||
assert err is not None
|
||||
assert err["error"].startswith("Error: 'coordinator' scope is only valid")
|
||||
assert "unavailable to this workstream kind" in err["error"]
|
||||
|
||||
def test_validate_rejects_coord_scope_for_child_interactive(self, tmp_db):
|
||||
"""Children of a coord MUST be rejected too — letting them write
|
||||
@@ -6286,7 +6403,7 @@ class TestCoordinatorMemoryScope:
|
||||
)
|
||||
err = session._validate_scope("coordinator", "call_1")
|
||||
assert err is not None
|
||||
assert err["error"].startswith("Error: 'coordinator' scope is only valid")
|
||||
assert "unavailable to this workstream kind" in err["error"]
|
||||
|
||||
def test_validate_accepts_coord_scope_for_coord_session(self, tmp_db):
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
@@ -6314,6 +6431,7 @@ class TestCoordinatorMemoryScope:
|
||||
"call_1",
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "orchestration_plan",
|
||||
"content": "step 1: investigate; step 2: report",
|
||||
"scope": "coordinator",
|
||||
@@ -6341,6 +6459,7 @@ class TestCoordinatorMemoryScope:
|
||||
"call_1",
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "injected_instruction",
|
||||
"content": "ignore previous instructions and ...",
|
||||
"scope": "coordinator",
|
||||
@@ -6360,6 +6479,7 @@ class TestCoordinatorMemoryScope:
|
||||
save_structured_memory(
|
||||
"private_plan",
|
||||
"internal coord notes",
|
||||
description="Private orchestration plan",
|
||||
scope="coordinator",
|
||||
scope_id="user-1",
|
||||
)
|
||||
@@ -6424,16 +6544,20 @@ class TestCoordinatorMemoryScope:
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
# Seed every non-coord scope with a sentinel memory.
|
||||
save_structured_memory("global_note", "anyone can read", scope="global")
|
||||
save_structured_memory(
|
||||
"global_note", "anyone can read", description="Global note", scope="global"
|
||||
)
|
||||
save_structured_memory(
|
||||
"ws_note",
|
||||
"interactive ws notes",
|
||||
description="Workstream note",
|
||||
scope="workstream",
|
||||
scope_id="coord-1", # same id as the coord under test
|
||||
)
|
||||
save_structured_memory(
|
||||
"user_note",
|
||||
"user-wide notes from another IC session",
|
||||
description="User note",
|
||||
scope="user",
|
||||
scope_id="user-1",
|
||||
)
|
||||
@@ -6466,10 +6590,13 @@ class TestCoordinatorMemoryScope:
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
save_structured_memory("global_x", "some content", scope="global")
|
||||
save_structured_memory(
|
||||
"global_x", "some content", description="Global content", scope="global"
|
||||
)
|
||||
save_structured_memory(
|
||||
"coord_x",
|
||||
"orchestration content",
|
||||
description="Coordinator content",
|
||||
scope="coordinator",
|
||||
scope_id="user-1",
|
||||
)
|
||||
@@ -6497,14 +6624,11 @@ class TestCoordinatorMemoryScope:
|
||||
for bad in ("global", "workstream", "user"):
|
||||
err = coord._validate_scope(bad, "call_1")
|
||||
assert err is not None, f"coord should reject scope={bad!r}"
|
||||
assert f"'{bad}' scope is not available" in err["error"]
|
||||
assert f"scope '{bad}' is unavailable" in err["error"]
|
||||
|
||||
def test_coord_default_save_scope_is_coordinator(self, tmp_db):
|
||||
"""Coord sessions calling memory(action='save') without an
|
||||
explicit scope default to 'coordinator' — anything else would
|
||||
either land in a namespace the coord can't read back from
|
||||
(workstream/user) or fall back to global which the new
|
||||
visibility rules also exclude."""
|
||||
explicit scope target the coordinator namespace."""
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
coord = _make_session(
|
||||
@@ -6514,17 +6638,20 @@ class TestCoordinatorMemoryScope:
|
||||
)
|
||||
item = coord._prepare_memory(
|
||||
"call_1",
|
||||
{"action": "save", "name": "auto_scope", "content": "x"},
|
||||
{
|
||||
"action": "save",
|
||||
"name": "auto_scope",
|
||||
"content": "x",
|
||||
"description": "Automatic scope test",
|
||||
},
|
||||
)
|
||||
assert "error" not in item
|
||||
assert item["scope"] == "coordinator"
|
||||
assert item["scope_id"] == "user-1"
|
||||
|
||||
def test_coord_implicit_walk_only_coordinator(self, tmp_db):
|
||||
def test_coord_inherited_get_targets_only_coordinator(self, tmp_db):
|
||||
"""Coord ``memory(action='get')`` with no explicit scope must
|
||||
walk only the coordinator scope — the IC walk
|
||||
(workstream → user → global) would be wasted lookups against
|
||||
rows the coord can't see."""
|
||||
target only the coordinator scope."""
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
coord = _make_session(
|
||||
@@ -6539,10 +6666,8 @@ class TestCoordinatorMemoryScope:
|
||||
assert "error" not in item
|
||||
assert [s for s, _ in item["scopes_to_try"]] == ["coordinator"]
|
||||
|
||||
def test_ic_implicit_walk_unchanged(self, tmp_db):
|
||||
"""Interactive sessions retain the narrowest-to-widest walk:
|
||||
workstream → user → global. Coord scope is excluded — IC
|
||||
sessions can't see/write it anyway."""
|
||||
def test_ic_unscoped_get_uses_single_global_target(self, tmp_db):
|
||||
"""Without a project, interactive save/get/delete all inherit global."""
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
ic = _make_session(
|
||||
@@ -6555,8 +6680,7 @@ class TestCoordinatorMemoryScope:
|
||||
{"action": "get", "name": "anything"},
|
||||
)
|
||||
assert "error" not in item
|
||||
scopes = [s for s, _ in item["scopes_to_try"]]
|
||||
assert scopes == ["workstream", "user", "global"]
|
||||
assert item["scopes_to_try"] == [("global", "")]
|
||||
|
||||
def test_coord_memory_persists_across_sessions(self, tmp_db):
|
||||
"""End-to-end through the real save lane: a memory saved by one
|
||||
@@ -6575,13 +6699,14 @@ class TestCoordinatorMemoryScope:
|
||||
"call_1",
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "deploy_runbook",
|
||||
"content": "drain node before rotating certs",
|
||||
"scope": "coordinator",
|
||||
},
|
||||
)
|
||||
assert "error" not in item
|
||||
result = item["execute"](item)
|
||||
result = _execute_prepared_tool(first, item)
|
||||
assert "Saved" in str(result) or "saved" in str(result).lower()
|
||||
|
||||
# Brand-new coordinator session, new ws_id, same user.
|
||||
@@ -6595,7 +6720,7 @@ class TestCoordinatorMemoryScope:
|
||||
{"action": "get", "name": "deploy_runbook"},
|
||||
)
|
||||
assert "error" not in get_item
|
||||
out = str(get_item["execute"](get_item))
|
||||
out = str(_execute_prepared_tool(second, get_item))
|
||||
assert "drain node before rotating certs" in out
|
||||
|
||||
def test_coordinator_session_requires_user_id(self, tmp_db):
|
||||
@@ -6636,11 +6761,17 @@ class TestCoordinatorMemoryScope:
|
||||
coord._user_id = "" # simulate a constructor-bypassing double
|
||||
err = coord._validate_scope("coordinator", "call_1")
|
||||
assert err is not None
|
||||
assert "requires authenticated user identity" in err["error"]
|
||||
assert "requires an authenticated acting user" in err["error"]
|
||||
assert coord._coordinator_scope_id() == ""
|
||||
item = coord._prepare_memory(
|
||||
"call_1",
|
||||
{"action": "save", "name": "x", "content": "y", "scope": "coordinator"},
|
||||
{
|
||||
"action": "save",
|
||||
"name": "x",
|
||||
"content": "y",
|
||||
"description": "Authentication backstop test",
|
||||
"scope": "coordinator",
|
||||
},
|
||||
)
|
||||
assert "error" in item
|
||||
|
||||
@@ -6654,6 +6785,7 @@ class TestCoordinatorMemoryScope:
|
||||
save_structured_memory(
|
||||
"other_users_row",
|
||||
"must not leak",
|
||||
description="Another user's row",
|
||||
scope="coordinator",
|
||||
scope_id="user-9",
|
||||
)
|
||||
@@ -6682,7 +6814,48 @@ class TestMemoryToolAudit:
|
||||
|
||||
return get_storage().list_audit_events(action=action)
|
||||
|
||||
def test_save_new_emits_memory_save(self, tmp_db):
|
||||
def test_preparer_declares_generic_batch_policy(self, tmp_db):
|
||||
session = _make_session(ws_id="ws-1", user_id="user-1")
|
||||
saved = session._prepare_memory(
|
||||
"save-call",
|
||||
{
|
||||
"action": "save",
|
||||
"name": "fact_one",
|
||||
"content": "alpha content",
|
||||
"description": "Alpha fact",
|
||||
},
|
||||
)
|
||||
fetched = session._prepare_memory(
|
||||
"get-call",
|
||||
{"action": "get", "name": "fact_one"},
|
||||
)
|
||||
|
||||
assert saved["_batch_policy"] == {
|
||||
"group": "memory",
|
||||
"access": "write",
|
||||
"mixed_access_error": _MEMORY_MIXED_BATCH_ERROR,
|
||||
"serialize": True,
|
||||
}
|
||||
assert fetched["_batch_policy"] == {
|
||||
"group": "memory",
|
||||
"access": "read",
|
||||
"mixed_access_error": _MEMORY_MIXED_BATCH_ERROR,
|
||||
"serialize": False,
|
||||
}
|
||||
|
||||
def test_unstamped_executor_does_not_inherit_session_actor(self, tmp_db):
|
||||
session = _make_session(ws_id="ws-1", user_id="user-1")
|
||||
item = session._prepare_memory(
|
||||
"get-call",
|
||||
{"action": "get", "name": "private_fact", "scope": "user"},
|
||||
)
|
||||
|
||||
_call_id, message = item["execute"](item)
|
||||
|
||||
assert "requires an authenticated acting user" in message
|
||||
|
||||
@pytest.mark.parametrize("description", [None, "", " "])
|
||||
def test_save_requires_non_empty_description(self, tmp_db, description):
|
||||
session = _make_session(ws_id="ws-1", user_id="user-1")
|
||||
item = session._prepare_memory(
|
||||
"call_1",
|
||||
@@ -6690,12 +6863,27 @@ class TestMemoryToolAudit:
|
||||
"action": "save",
|
||||
"name": "fact_one",
|
||||
"content": "alpha content",
|
||||
"description": description,
|
||||
},
|
||||
)
|
||||
assert "error" in item
|
||||
assert "description' must be non-empty" in item["error"]
|
||||
|
||||
def test_save_new_emits_memory_save(self, tmp_db):
|
||||
session = _make_session(ws_id="ws-1", user_id="user-1")
|
||||
item = session._prepare_memory(
|
||||
"call_1",
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "fact_one",
|
||||
"content": "alpha content",
|
||||
"scope": "user",
|
||||
"type": "reference",
|
||||
},
|
||||
)
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
_execute_prepared_tool(session, item)
|
||||
|
||||
rows = self._audit_rows("memory.save")
|
||||
assert len(rows) == 1
|
||||
@@ -6712,6 +6900,71 @@ class TestMemoryToolAudit:
|
||||
# The "create" path must NOT also stamp an update row.
|
||||
assert self._audit_rows("memory.update") == []
|
||||
|
||||
def test_prepared_user_save_stays_bound_to_acting_principal(self, tmp_db):
|
||||
from turnstone.core.memory import get_structured_memory_by_name
|
||||
|
||||
session = _make_session(ws_id="shared", user_id="owner")
|
||||
session.bind_acting_user("guest")
|
||||
item = session._prepare_tool(
|
||||
{
|
||||
"id": "call_1",
|
||||
"function": {
|
||||
"name": "memory",
|
||||
"arguments": json.dumps(
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "private_note",
|
||||
"content": "guest content",
|
||||
"scope": "user",
|
||||
}
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
assert item["_principal_id"] == "guest"
|
||||
assert item["scope_id"] == "guest"
|
||||
|
||||
session.bind_acting_user("owner")
|
||||
_, message = _execute_prepared_tool(session, item)
|
||||
|
||||
assert "Saved memory" in message
|
||||
assert get_structured_memory_by_name("private_note", "user", "guest") is not None
|
||||
assert get_structured_memory_by_name("private_note", "user", "owner") is None
|
||||
rows = self._audit_rows("memory.save")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["user_id"] == "guest"
|
||||
|
||||
def test_guest_user_get_does_not_probe_owner_namespace(self, tmp_db):
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory(
|
||||
"owner_secret",
|
||||
"must not leak",
|
||||
description="Owner secret",
|
||||
scope="user",
|
||||
scope_id="owner",
|
||||
)
|
||||
session = _make_session(ws_id="shared", user_id="owner")
|
||||
session.bind_acting_user("guest")
|
||||
item = session._prepare_tool(
|
||||
{
|
||||
"id": "call_1",
|
||||
"function": {
|
||||
"name": "memory",
|
||||
"arguments": json.dumps(
|
||||
{"action": "get", "name": "owner_secret", "scope": "user"}
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
_, message = _execute_prepared_tool(session, item)
|
||||
|
||||
assert "not found" in message
|
||||
assert "must not leak" not in message
|
||||
assert "exists in scope" not in message
|
||||
|
||||
def test_save_global_scope_emits_empty_scope_id(self, tmp_db):
|
||||
"""Global memories have no scope_id — the audit row's detail
|
||||
must still carry the key (with value ``""``) so a forensic
|
||||
@@ -6722,13 +6975,14 @@ class TestMemoryToolAudit:
|
||||
"call_1",
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "fact_global",
|
||||
"content": "shared content",
|
||||
"scope": "global",
|
||||
},
|
||||
)
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
_execute_prepared_tool(session, item)
|
||||
|
||||
rows = self._audit_rows("memory.save")
|
||||
assert len(rows) == 1
|
||||
@@ -6744,13 +6998,14 @@ class TestMemoryToolAudit:
|
||||
"call_x",
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "fact_one",
|
||||
"content": content,
|
||||
"scope": "user",
|
||||
"type": "reference",
|
||||
},
|
||||
)
|
||||
session._exec_memory(item)
|
||||
_execute_prepared_tool(session, item)
|
||||
|
||||
saves = self._audit_rows("memory.save")
|
||||
updates = self._audit_rows("memory.update")
|
||||
@@ -6765,20 +7020,21 @@ class TestMemoryToolAudit:
|
||||
"call_1",
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "fact_one",
|
||||
"content": "alpha",
|
||||
"scope": "user",
|
||||
"type": "reference",
|
||||
},
|
||||
)
|
||||
session._exec_memory(save_item)
|
||||
_execute_prepared_tool(session, save_item)
|
||||
saved_memory_id = self._audit_rows("memory.save")[0]["resource_id"]
|
||||
|
||||
delete_item = session._prepare_memory(
|
||||
"call_2",
|
||||
{"action": "delete", "name": "fact_one", "scope": "user"},
|
||||
)
|
||||
_, msg = session._exec_memory(delete_item)
|
||||
_, msg = _execute_prepared_tool(session, delete_item)
|
||||
assert "Deleted memory" in msg
|
||||
|
||||
rows = self._audit_rows("memory.delete")
|
||||
@@ -6797,23 +7053,70 @@ class TestMemoryToolAudit:
|
||||
"call_1",
|
||||
{"action": "delete", "name": "no_such_mem", "scope": "user"},
|
||||
)
|
||||
_, msg = session._exec_memory(delete_item)
|
||||
_, msg = _execute_prepared_tool(session, delete_item)
|
||||
assert "not found" in msg
|
||||
assert self._audit_rows("memory.delete") == []
|
||||
|
||||
def test_committed_delete_is_truthful_and_next_prefix_refresh_fails_closed(self, tmp_db):
|
||||
from turnstone.core.memory import get_structured_memory_by_name, save_structured_memory
|
||||
|
||||
save_structured_memory("doomed", "value", description="Memory to delete", scope="global")
|
||||
session = _make_session(ws_id="ws-1", user_id="user-1")
|
||||
item = session._prepare_memory(
|
||||
"call_1", {"action": "delete", "name": "doomed", "scope": "global"}
|
||||
)
|
||||
|
||||
_, message = _execute_prepared_tool(session, item)
|
||||
|
||||
assert "Deleted memory 'doomed'" in message
|
||||
assert get_structured_memory_by_name("doomed", "global", "") is None
|
||||
assert len(self._audit_rows("memory.delete")) == 1
|
||||
assert session._system_prefix_dirty is True
|
||||
with (
|
||||
patch.object(
|
||||
session,
|
||||
"_init_system_messages",
|
||||
side_effect=RuntimeError("composition failed"),
|
||||
),
|
||||
pytest.raises(RuntimeError, match="composition failed"),
|
||||
):
|
||||
session._ensure_system_prefix_fresh()
|
||||
|
||||
def test_storage_failure_is_not_reported_as_not_found(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
session = _make_session(ws_id="ws-1", user_id="user-1")
|
||||
storage = get_storage()
|
||||
operations = (
|
||||
(
|
||||
"get_structured_memory_by_name",
|
||||
{"action": "get", "name": "key", "scope": "global"},
|
||||
),
|
||||
(
|
||||
"delete_structured_memory_returning",
|
||||
{"action": "delete", "name": "key", "scope": "global"},
|
||||
),
|
||||
)
|
||||
for method_name, arguments in operations:
|
||||
item = session._prepare_memory("call_1", arguments)
|
||||
with patch.object(storage, method_name, side_effect=RuntimeError("db down")):
|
||||
_, message = _execute_prepared_tool(session, item)
|
||||
assert "storage operation failed" in message
|
||||
assert "not found" not in message
|
||||
|
||||
def test_reads_emit_no_audit(self, tmp_db):
|
||||
session = _make_session(ws_id="ws-1", user_id="user-1")
|
||||
session._exec_memory(
|
||||
session._prepare_memory(
|
||||
"call_save",
|
||||
{
|
||||
"action": "save",
|
||||
"name": "fact_one",
|
||||
"content": "alpha",
|
||||
"scope": "user",
|
||||
},
|
||||
)
|
||||
save_item = session._prepare_memory(
|
||||
"call_save",
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "fact_one",
|
||||
"content": "alpha",
|
||||
"scope": "user",
|
||||
},
|
||||
)
|
||||
_execute_prepared_tool(session, save_item)
|
||||
|
||||
for spec in (
|
||||
{"action": "get", "name": "fact_one", "scope": "user"},
|
||||
@@ -6822,7 +7125,7 @@ class TestMemoryToolAudit:
|
||||
):
|
||||
item = session._prepare_memory("call_read", spec)
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
_execute_prepared_tool(session, item)
|
||||
|
||||
# Only the save above should have audited.
|
||||
save_count = len(self._audit_rows("memory.save"))
|
||||
@@ -6842,6 +7145,7 @@ class TestMemoryToolAudit:
|
||||
"call_1",
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "fact_one",
|
||||
"content": "alpha",
|
||||
"scope": "user",
|
||||
@@ -6851,7 +7155,7 @@ class TestMemoryToolAudit:
|
||||
"turnstone.core.audit.record_audit",
|
||||
side_effect=RuntimeError("audit storage exploded"),
|
||||
):
|
||||
_, msg = session._exec_memory(item)
|
||||
_, msg = _execute_prepared_tool(session, item)
|
||||
assert "Saved memory 'fact_one'" in msg
|
||||
# The save itself still landed.
|
||||
from turnstone.core.memory import get_structured_memory_by_name
|
||||
@@ -6863,10 +7167,10 @@ class TestPerKindToolVariants:
|
||||
"""Verify the ``kind_variants`` metadata applies per-kind tool overrides.
|
||||
|
||||
Each kind sees only the tool surface it can actually use — the
|
||||
coord sees ``scope`` enum ``["coordinator"]`` and a coord-flavored
|
||||
description; the IC sees ``["global", "workstream", "user"]`` and
|
||||
the existing IC-flavored description. The union ``TOOLS`` list
|
||||
keeps the full schema for introspection / docs / eval catalogs.
|
||||
coord sees coordinator/project scopes and a coord-flavored description;
|
||||
the IC sees global/workstream/user/project and the IC-flavored description.
|
||||
The union ``TOOLS`` list keeps the full schema for introspection / docs /
|
||||
eval catalogs.
|
||||
"""
|
||||
|
||||
def test_coord_memory_tool_has_coord_only_scope_enum(self):
|
||||
@@ -6877,6 +7181,10 @@ class TestPerKindToolVariants:
|
||||
# v1.7: a coordinator attached to a project also reads/writes the shared
|
||||
# 'project' scope, alongside its isolated 'coordinator' namespace.
|
||||
assert scope["enum"] == ["coordinator", "project"]
|
||||
scope_desc = scope["description"]
|
||||
assert "Save/get/delete without scope target project when attached" in scope_desc
|
||||
assert "otherwise coordinator" in scope_desc
|
||||
assert "valid explicit scope selects exactly that scope" in scope_desc
|
||||
|
||||
def test_coord_memory_tool_description_mentions_orchestration(self):
|
||||
from turnstone.core.tools import COORDINATOR_TOOLS
|
||||
@@ -6896,6 +7204,10 @@ class TestPerKindToolVariants:
|
||||
scope = memory["function"]["parameters"]["properties"]["scope"]
|
||||
# v1.7: 'project' is offered (usable when the workstream is attached).
|
||||
assert scope["enum"] == ["global", "workstream", "user", "project"]
|
||||
scope_desc = scope["description"]
|
||||
assert "Save/get/delete without scope target project when attached" in scope_desc
|
||||
assert "otherwise global" in scope_desc
|
||||
assert "valid explicit scope selects exactly that scope" in scope_desc
|
||||
|
||||
def test_ic_memory_tool_description_omits_coord_scope(self):
|
||||
from turnstone.core.tools import INTERACTIVE_TOOLS
|
||||
@@ -7024,7 +7336,12 @@ class TestMemoryAccessTouch:
|
||||
def _save(name: str, content: str) -> None:
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory(name, content, scope="global")
|
||||
save_structured_memory(
|
||||
name,
|
||||
content,
|
||||
description=f"Test memory for {name}",
|
||||
scope="global",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _empty_session() -> ChatSession:
|
||||
@@ -7134,7 +7451,7 @@ class TestMemoryAccessTouch:
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory("call_1", {"action": "search", "query": "kafka"})
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
_execute_prepared_tool(session, item)
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
|
||||
def test_get_action_touches_fetched_memory(self, tmp_db):
|
||||
@@ -7144,7 +7461,7 @@ class TestMemoryAccessTouch:
|
||||
"call_1", {"action": "get", "name": "kafka_runbook", "scope": "global"}
|
||||
)
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
_execute_prepared_tool(session, item)
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
|
||||
def test_get_miss_touches_nothing(self, tmp_db):
|
||||
@@ -7153,7 +7470,7 @@ class TestMemoryAccessTouch:
|
||||
item = session._prepare_memory(
|
||||
"call_1", {"action": "get", "name": "no_such_mem", "scope": "global"}
|
||||
)
|
||||
_, msg = session._exec_memory(item)
|
||||
_, msg = _execute_prepared_tool(session, item)
|
||||
assert "not found" in msg
|
||||
# The existing row must not be collaterally touched by a miss.
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
@@ -7162,7 +7479,7 @@ class TestMemoryAccessTouch:
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory("call_1", {"action": "list"})
|
||||
session._exec_memory(item)
|
||||
_execute_prepared_tool(session, item)
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
def test_save_action_does_not_touch_access_count(self, tmp_db):
|
||||
@@ -7174,9 +7491,15 @@ class TestMemoryAccessTouch:
|
||||
session = self._empty_session()
|
||||
item = session._prepare_memory(
|
||||
"call_1",
|
||||
{"action": "save", "name": "kafka_runbook", "content": "x", "scope": "global"},
|
||||
{
|
||||
"action": "save",
|
||||
"name": "kafka_runbook",
|
||||
"content": "x",
|
||||
"description": "Kafka runbook",
|
||||
"scope": "global",
|
||||
},
|
||||
)
|
||||
session._exec_memory(item)
|
||||
_execute_prepared_tool(session, item)
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
def test_save_through_exec_does_not_recompose_prefix(self, tmp_db):
|
||||
@@ -7202,13 +7525,14 @@ class TestMemoryAccessTouch:
|
||||
"call_1",
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "kafka_scaling",
|
||||
"content": "restart kafka and scale the broker pods cluster",
|
||||
"scope": "global",
|
||||
},
|
||||
)
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
_execute_prepared_tool(session, item)
|
||||
|
||||
# 1. Prefix byte-for-byte unchanged -> no prompt-cache bust.
|
||||
after = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
@@ -7226,11 +7550,8 @@ class TestMemoryAccessTouch:
|
||||
)
|
||||
assert '<memory name="kafka_scaling"' in recomposed
|
||||
|
||||
def test_save_through_tool_preserves_omitted_overwrites_explicit(self, tmp_db):
|
||||
"""The None-sentinel flows through _prepare_memory -> _exec_memory: a
|
||||
content-only re-save keeps the stored type/description, while an
|
||||
explicit field overwrites it. Guards the _prepare_memory omit->None
|
||||
logic that the storage-level tests don't exercise."""
|
||||
def test_save_through_tool_requires_and_updates_description(self, tmp_db):
|
||||
"""Every tool save describes the row; an omitted type stays preserved."""
|
||||
from turnstone.core.memory import get_structured_memory_by_name
|
||||
|
||||
session = self._empty_session()
|
||||
@@ -7246,48 +7567,58 @@ class TestMemoryAccessTouch:
|
||||
},
|
||||
)
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
_execute_prepared_tool(session, item)
|
||||
|
||||
# Content-only re-save (omits type/description) -> both preserved.
|
||||
# An update supplies a fresh description while omitting type.
|
||||
item2 = session._prepare_memory(
|
||||
"c2", {"action": "save", "name": "digest", "content": "v2", "scope": "global"}
|
||||
"c2",
|
||||
{
|
||||
"action": "save",
|
||||
"name": "digest",
|
||||
"content": "v2",
|
||||
"description": "revised daily digest",
|
||||
"scope": "global",
|
||||
},
|
||||
)
|
||||
session._exec_memory(item2)
|
||||
_execute_prepared_tool(session, item2)
|
||||
mem = get_structured_memory_by_name("digest", "global", "")
|
||||
assert mem is not None
|
||||
assert mem["content"] == "v2"
|
||||
assert mem["type"] == "reference"
|
||||
assert mem["description"] == "daily digest"
|
||||
assert mem["description"] == "revised daily digest"
|
||||
|
||||
# An invalid/typo'd type is treated as unset -> stored type preserved,
|
||||
# not silently downgraded to "general".
|
||||
# Invalid/typo'd types fail preparation and do not mutate the row.
|
||||
item_bad = session._prepare_memory(
|
||||
"c2b",
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "digest",
|
||||
"content": "v2b",
|
||||
"type": "nonsense",
|
||||
"scope": "global",
|
||||
},
|
||||
)
|
||||
session._exec_memory(item_bad)
|
||||
assert "error" in item_bad
|
||||
assert "invalid memory type" in item_bad["error"]
|
||||
mem = get_structured_memory_by_name("digest", "global", "")
|
||||
assert mem is not None
|
||||
assert mem["type"] == "reference" # invalid type ignored, not downgraded
|
||||
assert mem["content"] == "v2"
|
||||
assert mem["type"] == "reference"
|
||||
|
||||
# An explicit field -> overwrites (the behaviour the None-sentinel enables).
|
||||
item3 = session._prepare_memory(
|
||||
"c3",
|
||||
{
|
||||
"action": "save",
|
||||
"description": "Test memory",
|
||||
"name": "digest",
|
||||
"content": "v3",
|
||||
"type": "general",
|
||||
"scope": "global",
|
||||
},
|
||||
)
|
||||
session._exec_memory(item3)
|
||||
_execute_prepared_tool(session, item3)
|
||||
mem = get_structured_memory_by_name("digest", "global", "")
|
||||
assert mem is not None
|
||||
assert mem["type"] == "general"
|
||||
|
||||
@@ -124,9 +124,10 @@ def test_nonfork_resume_rebinds_project_memory_context_before_recomposition(tmp_
|
||||
assert session.resume("target-ws") is True
|
||||
|
||||
assert session.ws_id == "target-ws"
|
||||
assert session._project_id == "target-project"
|
||||
assert session._project_name == "Target Project"
|
||||
assert session._project_writable is True
|
||||
access = session._memory_access()
|
||||
assert access.project_id == "target-project"
|
||||
assert access.project_name == "Target Project"
|
||||
assert access.project_writable is True
|
||||
assert ("project", "target-project") in session._visible_scopes()
|
||||
assert ("project", "source-project") not in session._visible_scopes()
|
||||
assert stale_cache_key not in session._mem_search_cache
|
||||
|
||||
@@ -6,6 +6,7 @@ hint pattern, and the skill catalog disclosure in system messages.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -1375,15 +1376,14 @@ class TestSkillCatalogDisclosure:
|
||||
session._tools = []
|
||||
session._client_type = ClientType.CLI
|
||||
session._username = ""
|
||||
# _init_system_messages renders the attached project into the Session
|
||||
# Context; this __new__-built session skips __init__'s project resolution,
|
||||
# so seed the (unattached) defaults it reads.
|
||||
session._project_name = ""
|
||||
session._project_id = ""
|
||||
session._project_writable = False
|
||||
# This __new__-built session skips __init__'s attachment setup.
|
||||
session._memory_attached_project_id = ""
|
||||
session._system_prefix_lock = threading.RLock()
|
||||
session._system_prefix_dirty = True
|
||||
session._system_prefix_signature = None
|
||||
session._kind = "interactive"
|
||||
# Persona snapshot attrs (set by __init__, bypassed here) — legacy
|
||||
# defaults: no override, unrestricted tools, MCP + memory on.
|
||||
# Persona snapshot attrs (set by __init__, bypassed here): open
|
||||
# defaults with no override, unrestricted tools, MCP + memory on.
|
||||
session._persona_name = ""
|
||||
session._persona_prompt = ""
|
||||
session._persona_tools = None
|
||||
@@ -1393,6 +1393,7 @@ class TestSkillCatalogDisclosure:
|
||||
session._memory_config = MagicMock()
|
||||
session._memory_config.fetch_limit = 0
|
||||
session._user_id = "test-user"
|
||||
session._acting_user_id = ""
|
||||
# _init_system_messages -> _recompute_shared_state reads the session
|
||||
# owner (_mcp_user_id) to decide shared-workstream framing; __init__
|
||||
# normally sets it from user_id, so seed it here for the __new__ build.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for turnstone.core.memory — structured memory facade functions."""
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.memory import (
|
||||
count_structured_memories,
|
||||
delete_structured_memory,
|
||||
@@ -7,31 +9,42 @@ from turnstone.core.memory import (
|
||||
list_structured_memories,
|
||||
normalize_key,
|
||||
save_structured_memory,
|
||||
save_structured_memory_strict,
|
||||
search_structured_memories,
|
||||
)
|
||||
|
||||
|
||||
def _save(name, content, **kwargs):
|
||||
kwargs.setdefault("description", "test memory description")
|
||||
return save_structured_memory(name, content, **kwargs)
|
||||
|
||||
|
||||
class TestSaveStructuredMemory:
|
||||
@pytest.mark.parametrize("description", [None, "", " "])
|
||||
def test_description_is_required(self, tmp_db, description):
|
||||
with pytest.raises(ValueError, match="description is required"):
|
||||
save_structured_memory_strict("test_key", "hello world", description=description)
|
||||
|
||||
def test_save_new(self, tmp_db):
|
||||
row, was_update = save_structured_memory("test_key", "hello world")
|
||||
row, was_update = _save("test_key", "hello world")
|
||||
assert row and row["memory_id"]
|
||||
assert was_update is False
|
||||
|
||||
def test_save_upsert(self, tmp_db):
|
||||
row1, was_update1 = save_structured_memory("test_key", "first")
|
||||
row2, was_update2 = save_structured_memory("test_key", "second")
|
||||
row1, was_update1 = _save("test_key", "first")
|
||||
row2, was_update2 = _save("test_key", "second")
|
||||
assert was_update1 is False
|
||||
assert was_update2 is True
|
||||
assert row2 and row1 and row2["memory_id"] == row1["memory_id"] # same row
|
||||
assert row2["content"] == "second"
|
||||
|
||||
def test_save_normalizes_key(self, tmp_db):
|
||||
save_structured_memory("My-Key", "value")
|
||||
_save("My-Key", "value")
|
||||
mems = list_structured_memories()
|
||||
assert any(m["name"] == "my_key" for m in mems)
|
||||
|
||||
def test_save_with_type_and_scope(self, tmp_db):
|
||||
save_structured_memory("k", "v", mem_type="user", scope="workstream", scope_id="ws1")
|
||||
_save("k", "v", mem_type="user", scope="workstream", scope_id="ws1")
|
||||
mems = list_structured_memories(scope="workstream", scope_id="ws1")
|
||||
assert len(mems) == 1
|
||||
assert mems[0]["type"] == "user"
|
||||
@@ -39,14 +52,14 @@ class TestSaveStructuredMemory:
|
||||
|
||||
class TestDeleteStructuredMemory:
|
||||
def test_delete_existing(self, tmp_db):
|
||||
save_structured_memory("mykey", "val")
|
||||
_save("mykey", "val")
|
||||
assert delete_structured_memory("mykey")
|
||||
|
||||
def test_delete_nonexistent(self, tmp_db):
|
||||
assert not delete_structured_memory("nope")
|
||||
|
||||
def test_delete_normalizes_key(self, tmp_db):
|
||||
save_structured_memory("my_key", "val")
|
||||
_save("my_key", "val")
|
||||
assert delete_structured_memory("My-Key")
|
||||
|
||||
|
||||
@@ -55,25 +68,25 @@ class TestListStructuredMemories:
|
||||
assert list_structured_memories() == []
|
||||
|
||||
def test_list_returns_saved(self, tmp_db):
|
||||
save_structured_memory("a", "alpha")
|
||||
save_structured_memory("b", "beta")
|
||||
_save("a", "alpha")
|
||||
_save("b", "beta")
|
||||
mems = list_structured_memories()
|
||||
assert len(mems) == 2
|
||||
|
||||
|
||||
class TestSearchStructuredMemories:
|
||||
def test_search_finds_match(self, tmp_db):
|
||||
save_structured_memory("db_host", "localhost", description="database hostname")
|
||||
save_structured_memory("api_url", "http://example.com")
|
||||
_save("db_host", "localhost", description="database hostname")
|
||||
_save("api_url", "http://example.com")
|
||||
results = search_structured_memories("database")
|
||||
assert len(results) >= 1
|
||||
assert any(r["name"] == "db_host" for r in results)
|
||||
|
||||
def test_multiword_or_matches_partial(self, tmp_db):
|
||||
"""OR-of-terms: memory matching only 1 of 3 query terms is returned."""
|
||||
save_structured_memory("postgres_config", "host=localhost port=5432")
|
||||
save_structured_memory("redis_config", "host=redis port=6379")
|
||||
save_structured_memory("unrelated", "nothing relevant here")
|
||||
_save("postgres_config", "host=localhost port=5432")
|
||||
_save("redis_config", "host=redis port=6379")
|
||||
_save("unrelated", "nothing relevant here")
|
||||
|
||||
# "postgres missing_word_a missing_word_b": only postgres_config matches "postgres"
|
||||
results = search_structured_memories("postgres missing_word_a missing_word_b")
|
||||
@@ -83,9 +96,9 @@ class TestSearchStructuredMemories:
|
||||
|
||||
def test_multiword_or_multiple_partial_matches(self, tmp_db):
|
||||
"""Multiple memories each matching different terms are all returned."""
|
||||
save_structured_memory("key_alpha", "alpha content here")
|
||||
save_structured_memory("key_beta", "beta content here")
|
||||
save_structured_memory("key_other", "completely different")
|
||||
_save("key_alpha", "alpha content here")
|
||||
_save("key_beta", "beta content here")
|
||||
_save("key_other", "completely different")
|
||||
|
||||
results = search_structured_memories("alpha beta")
|
||||
names = {r["name"] for r in results}
|
||||
@@ -95,9 +108,9 @@ class TestSearchStructuredMemories:
|
||||
|
||||
def test_search_scope_filtering_preserved(self, tmp_db):
|
||||
"""Search with scope filter only returns memories in that scope."""
|
||||
save_structured_memory("ws1_fact", "alpha info", scope="workstream", scope_id="ws1")
|
||||
save_structured_memory("ws2_fact", "alpha info", scope="workstream", scope_id="ws2")
|
||||
save_structured_memory("global_fact", "alpha info", scope="global")
|
||||
_save("ws1_fact", "alpha info", scope="workstream", scope_id="ws1")
|
||||
_save("ws2_fact", "alpha info", scope="workstream", scope_id="ws2")
|
||||
_save("global_fact", "alpha info", scope="global")
|
||||
|
||||
results = search_structured_memories("alpha", scope="workstream", scope_id="ws1")
|
||||
names = {r["name"] for r in results}
|
||||
@@ -108,7 +121,7 @@ class TestSearchStructuredMemories:
|
||||
|
||||
class TestGetStructuredMemoryByName:
|
||||
def test_get_existing(self, tmp_db):
|
||||
save_structured_memory("my_mem", "full content here that is quite long")
|
||||
_save("my_mem", "full content here that is quite long")
|
||||
mem = get_structured_memory_by_name("my_mem", "global", "")
|
||||
assert mem is not None
|
||||
assert mem["content"] == "full content here that is quite long"
|
||||
@@ -118,12 +131,12 @@ class TestGetStructuredMemoryByName:
|
||||
assert get_structured_memory_by_name("nope", "global", "") is None
|
||||
|
||||
def test_get_wrong_scope(self, tmp_db):
|
||||
save_structured_memory("ws_mem", "data", scope="workstream", scope_id="ws1")
|
||||
_save("ws_mem", "data", scope="workstream", scope_id="ws1")
|
||||
assert get_structured_memory_by_name("ws_mem", "global", "") is None
|
||||
assert get_structured_memory_by_name("ws_mem", "workstream", "ws1") is not None
|
||||
|
||||
def test_get_normalizes_key(self, tmp_db):
|
||||
save_structured_memory("My-Key", "value")
|
||||
_save("My-Key", "value")
|
||||
mem = get_structured_memory_by_name("My-Key", "global", "")
|
||||
assert mem is not None
|
||||
assert mem["name"] == "my_key"
|
||||
@@ -134,8 +147,8 @@ class TestCountStructuredMemories:
|
||||
assert count_structured_memories() == 0
|
||||
|
||||
def test_count_after_save(self, tmp_db):
|
||||
save_structured_memory("a", "1")
|
||||
save_structured_memory("b", "2")
|
||||
_save("a", "1")
|
||||
_save("b", "2")
|
||||
assert count_structured_memories() == 2
|
||||
|
||||
|
||||
@@ -154,11 +167,11 @@ class TestScopeIsolation:
|
||||
|
||||
def _seed(self):
|
||||
"""Create memories across multiple scopes."""
|
||||
save_structured_memory("global_note", "visible to all", scope="global")
|
||||
save_structured_memory("ws1_note", "belongs to ws1", scope="workstream", scope_id="ws1")
|
||||
save_structured_memory("ws2_note", "belongs to ws2", scope="workstream", scope_id="ws2")
|
||||
save_structured_memory("u1_note", "belongs to user1", scope="user", scope_id="u1")
|
||||
save_structured_memory("u2_note", "belongs to user2", scope="user", scope_id="u2")
|
||||
_save("global_note", "visible to all", scope="global")
|
||||
_save("ws1_note", "belongs to ws1", scope="workstream", scope_id="ws1")
|
||||
_save("ws2_note", "belongs to ws2", scope="workstream", scope_id="ws2")
|
||||
_save("u1_note", "belongs to user1", scope="user", scope_id="u1")
|
||||
_save("u2_note", "belongs to user2", scope="user", scope_id="u2")
|
||||
|
||||
@staticmethod
|
||||
def _list_visible(ws_id: str, user_id: str, mem_type: str = "", limit: int = 50):
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
|
||||
class TestCreateAndGet:
|
||||
def test_create_requires_non_empty_description(self, backend):
|
||||
import pytest
|
||||
|
||||
for description in (None, "", " "):
|
||||
with pytest.raises(ValueError, match="description is required"):
|
||||
backend.create_structured_memory(
|
||||
"m1", "test_key", description, "general", "global", "", "data"
|
||||
)
|
||||
|
||||
def test_create_and_get_by_id(self, backend):
|
||||
backend.create_structured_memory("m1", "test_key", "desc", "general", "global", "", "data")
|
||||
mem = backend.get_structured_memory("m1")
|
||||
@@ -43,34 +52,44 @@ class TestSaveUpsert:
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
backend.create_structured_memory("m1", "dup", "", "general", "global", "", "a")
|
||||
backend.create_structured_memory("m1", "dup", "Test memory", "general", "global", "", "a")
|
||||
with pytest.raises(sa.exc.IntegrityError):
|
||||
backend.create_structured_memory("m2", "dup", "", "general", "global", "", "b")
|
||||
backend.create_structured_memory(
|
||||
"m2", "dup", "Test memory", "general", "global", "", "b"
|
||||
)
|
||||
|
||||
def test_save_same_key_updates_in_place(self, backend):
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
row1, was_update1 = save_structured_memory("upsert_key", "v1", scope="global")
|
||||
row1, was_update1 = save_structured_memory(
|
||||
"upsert_key", "v1", description="first description", scope="global"
|
||||
)
|
||||
assert row1 and was_update1 is False # inserted
|
||||
|
||||
row2, was_update2 = save_structured_memory("upsert_key", "v2", scope="global")
|
||||
row2, was_update2 = save_structured_memory(
|
||||
"upsert_key", "v2", description="updated description", scope="global"
|
||||
)
|
||||
assert row2 and was_update2 is True # updated in place
|
||||
assert row2["memory_id"] == row1["memory_id"] # same row, not a duplicate
|
||||
assert row2["content"] == "v2"
|
||||
names = [r["name"] for r in backend.list_structured_memories(scope="global")]
|
||||
assert names.count("upsert_key") == 1
|
||||
|
||||
def test_save_same_key_preserves_description_and_type_on_default_resave(self, backend):
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
def test_save_same_key_requires_and_updates_description(self, backend):
|
||||
from turnstone.core.memory import save_structured_memory, save_structured_memory_strict
|
||||
|
||||
save_structured_memory(
|
||||
"meta_key", "c1", description="orig desc", mem_type="fact", scope="global"
|
||||
)
|
||||
# A re-save that omits description/type (defaults) must not clobber them.
|
||||
save_structured_memory("meta_key", "c2", scope="global")
|
||||
# Every update must describe the revised memory; type can still be omitted.
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="description is required"):
|
||||
save_structured_memory_strict("meta_key", "c2", description=None, scope="global")
|
||||
save_structured_memory("meta_key", "c2", description="revised description", scope="global")
|
||||
row = backend.get_structured_memory_by_name("meta_key", "global", "")
|
||||
assert row["content"] == "c2"
|
||||
assert row["description"] == "orig desc"
|
||||
assert row["description"] == "revised description"
|
||||
assert row["type"] == "fact"
|
||||
|
||||
def test_upsert_method_updates_in_place_no_raise(self, backend):
|
||||
@@ -89,20 +108,75 @@ class TestSaveUpsert:
|
||||
names = [r["name"] for r in backend.list_structured_memories(scope="global")]
|
||||
assert names.count("k") == 1
|
||||
|
||||
def test_upsert_none_preserves_explicit_overwrites(self, backend):
|
||||
"""None description/type keep the stored value on conflict; an explicit
|
||||
value (including "" / "general") overwrites it."""
|
||||
def test_upsert_requires_description_and_preserves_omitted_type(self, backend):
|
||||
"""Description is mandatory; an omitted type keeps the stored value."""
|
||||
import pytest
|
||||
|
||||
backend.create_structured_memory("m1", "k", "keepdesc", "fact", "global", "", "v1")
|
||||
# None -> preserve stored description/type (a content-only save).
|
||||
row, _ = backend.upsert_structured_memory("m2", "k", None, None, "global", "", "v2")
|
||||
with pytest.raises(ValueError, match="description is required"):
|
||||
backend.upsert_structured_memory("m2", "k", None, None, "global", "", "v2")
|
||||
with pytest.raises(ValueError, match="description is required"):
|
||||
backend.upsert_structured_memory("m2", "k", " ", None, "global", "", "v2")
|
||||
|
||||
row, _ = backend.upsert_structured_memory(
|
||||
"m2", "k", "new description", None, "global", "", "v2"
|
||||
)
|
||||
assert row["content"] == "v2"
|
||||
assert row["description"] == "keepdesc"
|
||||
assert row["description"] == "new description"
|
||||
assert row["type"] == "fact"
|
||||
# Explicit "" / "general" -> overwrite.
|
||||
row2, _ = backend.upsert_structured_memory("m3", "k", "", "general", "global", "", "v3")
|
||||
assert row2["description"] == ""
|
||||
row2, _ = backend.upsert_structured_memory(
|
||||
"m3", "k", "final description", "general", "global", "", "v3"
|
||||
)
|
||||
assert row2["description"] == "final description"
|
||||
assert row2["type"] == "general"
|
||||
|
||||
def test_active_project_guard_accepts_only_active_project(self, backend):
|
||||
import pytest
|
||||
|
||||
backend.create_project("active", "Active", "u1")
|
||||
row, was_update = backend.upsert_structured_memory(
|
||||
"m1",
|
||||
"guarded",
|
||||
"guarded description",
|
||||
None,
|
||||
"project",
|
||||
"active",
|
||||
"value",
|
||||
require_active_project=True,
|
||||
)
|
||||
assert row["scope_id"] == "active"
|
||||
assert was_update is False
|
||||
|
||||
backend.create_project("archived", "Archived", "u1", state="archived")
|
||||
for project_id in ("archived", "missing"):
|
||||
with pytest.raises(ValueError, match="missing, archived"):
|
||||
backend.upsert_structured_memory(
|
||||
f"m-{project_id}",
|
||||
"guarded",
|
||||
"guarded description",
|
||||
None,
|
||||
"project",
|
||||
project_id,
|
||||
"value",
|
||||
require_active_project=True,
|
||||
)
|
||||
assert backend.get_structured_memory_by_name("guarded", "project", project_id) is None
|
||||
|
||||
def test_active_project_guard_rejects_non_project_scope(self, backend):
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="requires project scope"):
|
||||
backend.upsert_structured_memory(
|
||||
"m1",
|
||||
"guarded",
|
||||
"guarded description",
|
||||
None,
|
||||
"global",
|
||||
"",
|
||||
"value",
|
||||
require_active_project=True,
|
||||
)
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_existing(self, backend):
|
||||
@@ -118,63 +192,119 @@ class TestDelete:
|
||||
assert not backend.delete_structured_memory("k", "global", "")
|
||||
assert backend.delete_structured_memory("k", "workstream", "ws1")
|
||||
|
||||
def test_delete_returning_is_atomic_and_truthful(self, backend):
|
||||
backend.create_structured_memory(
|
||||
"m1", "k", "description", "reference", "user", "u1", "data"
|
||||
)
|
||||
|
||||
deleted = backend.delete_structured_memory_returning("k", "user", "u1")
|
||||
|
||||
assert deleted is not None
|
||||
assert deleted["memory_id"] == "m1"
|
||||
assert deleted["description"] == "description"
|
||||
assert deleted["type"] == "reference"
|
||||
assert backend.get_structured_memory("m1") is None
|
||||
assert backend.delete_structured_memory_returning("k", "user", "u1") is None
|
||||
|
||||
def test_delete_by_id_returning_is_atomic_and_truthful(self, backend):
|
||||
backend.create_structured_memory("m1", "k", "Test memory", "general", "global", "", "data")
|
||||
|
||||
deleted = backend.delete_structured_memory_by_id_returning("m1")
|
||||
|
||||
assert deleted is not None
|
||||
assert deleted["name"] == "k"
|
||||
assert backend.get_structured_memory("m1") is None
|
||||
assert backend.delete_structured_memory_by_id_returning("m1") is None
|
||||
|
||||
|
||||
class TestFindScopes:
|
||||
def test_finds_only_requested_same_name_scopes(self, backend):
|
||||
backend.create_structured_memory("m1", "same", "Test memory", "general", "global", "", "g")
|
||||
backend.create_structured_memory(
|
||||
"m2", "same", "Test memory", "general", "user", "u1", "own"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m3", "same", "Test memory", "general", "user", "victim", "secret"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m4", "other", "Test memory", "general", "workstream", "ws1", "other"
|
||||
)
|
||||
|
||||
found = backend.find_structured_memory_scopes(
|
||||
"same", [("global", ""), ("user", "u1"), ("workstream", "ws1")]
|
||||
)
|
||||
|
||||
assert set(found) == {("global", ""), ("user", "u1")}
|
||||
|
||||
|
||||
class TestList:
|
||||
def test_list_all(self, backend):
|
||||
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1")
|
||||
backend.create_structured_memory("m2", "b", "", "user", "global", "", "2")
|
||||
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
|
||||
backend.create_structured_memory("m2", "b", "Test memory", "user", "global", "", "2")
|
||||
mems = backend.list_structured_memories()
|
||||
assert len(mems) == 2
|
||||
|
||||
def test_list_by_type(self, backend):
|
||||
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1")
|
||||
backend.create_structured_memory("m2", "b", "", "user", "global", "", "2")
|
||||
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
|
||||
backend.create_structured_memory("m2", "b", "Test memory", "user", "global", "", "2")
|
||||
mems = backend.list_structured_memories(mem_type="user")
|
||||
assert len(mems) == 1
|
||||
assert mems[0]["name"] == "b"
|
||||
|
||||
def test_list_by_scope(self, backend):
|
||||
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1")
|
||||
backend.create_structured_memory("m2", "b", "", "general", "workstream", "ws1", "2")
|
||||
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
|
||||
backend.create_structured_memory(
|
||||
"m2", "b", "Test memory", "general", "workstream", "ws1", "2"
|
||||
)
|
||||
mems = backend.list_structured_memories(scope="workstream")
|
||||
assert len(mems) == 1
|
||||
|
||||
def test_list_respects_limit(self, backend):
|
||||
for i in range(10):
|
||||
backend.create_structured_memory(f"m{i}", f"k{i}", "", "general", "global", "", f"{i}")
|
||||
backend.create_structured_memory(
|
||||
f"m{i}", f"k{i}", "Test memory", "general", "global", "", f"{i}"
|
||||
)
|
||||
mems = backend.list_structured_memories(limit=3)
|
||||
assert len(mems) == 3
|
||||
|
||||
|
||||
class TestSearch:
|
||||
def test_search_by_name(self, backend):
|
||||
backend.create_structured_memory("m1", "database_config", "", "general", "global", "", "pg")
|
||||
backend.create_structured_memory("m2", "api_key", "", "general", "global", "", "secret")
|
||||
backend.create_structured_memory(
|
||||
"m1", "database_config", "Test memory", "general", "global", "", "pg"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m2", "api_key", "Test memory", "general", "global", "", "secret"
|
||||
)
|
||||
results = backend.search_structured_memories("database")
|
||||
assert len(results) == 1
|
||||
assert results[0]["name"] == "database_config"
|
||||
|
||||
def test_search_by_content(self, backend):
|
||||
backend.create_structured_memory("m1", "a", "", "general", "global", "", "postgresql host")
|
||||
backend.create_structured_memory(
|
||||
"m1", "a", "Test memory", "general", "global", "", "postgresql host"
|
||||
)
|
||||
results = backend.search_structured_memories("postgresql")
|
||||
assert len(results) == 1
|
||||
|
||||
def test_search_empty_lists_all(self, backend):
|
||||
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1")
|
||||
backend.create_structured_memory("m2", "b", "", "general", "global", "", "2")
|
||||
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
|
||||
backend.create_structured_memory("m2", "b", "Test memory", "general", "global", "", "2")
|
||||
results = backend.search_structured_memories("")
|
||||
assert len(results) == 2
|
||||
|
||||
|
||||
class TestCount:
|
||||
def test_count_all(self, backend):
|
||||
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1")
|
||||
backend.create_structured_memory("m2", "b", "", "general", "global", "", "2")
|
||||
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
|
||||
backend.create_structured_memory("m2", "b", "Test memory", "general", "global", "", "2")
|
||||
assert backend.count_structured_memories() == 2
|
||||
|
||||
def test_count_by_scope(self, backend):
|
||||
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1")
|
||||
backend.create_structured_memory("m2", "b", "", "general", "workstream", "ws1", "2")
|
||||
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
|
||||
backend.create_structured_memory(
|
||||
"m2", "b", "Test memory", "general", "workstream", "ws1", "2"
|
||||
)
|
||||
assert backend.count_structured_memories(scope="global") == 1
|
||||
assert backend.count_structured_memories(scope="workstream") == 1
|
||||
|
||||
@@ -184,8 +314,12 @@ class TestSearchOrOfTerms:
|
||||
|
||||
def test_single_matching_term_in_multi_word_query(self, backend):
|
||||
"""Memory with content 'apple' found when query is 'apple banana cherry'."""
|
||||
backend.create_structured_memory("m1", "apple_mem", "", "general", "global", "", "apple")
|
||||
backend.create_structured_memory("m2", "other_mem", "", "general", "global", "", "grape")
|
||||
backend.create_structured_memory(
|
||||
"m1", "apple_mem", "Test memory", "general", "global", "", "apple"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m2", "other_mem", "Test memory", "general", "global", "", "grape"
|
||||
)
|
||||
|
||||
results = backend.search_structured_memories("apple banana cherry")
|
||||
names = {r["name"] for r in results}
|
||||
@@ -194,10 +328,18 @@ class TestSearchOrOfTerms:
|
||||
|
||||
def test_partial_overlap_across_memories(self, backend):
|
||||
"""Each memory matches one of three terms; all three are returned."""
|
||||
backend.create_structured_memory("m1", "alpha_doc", "", "general", "global", "", "alpha")
|
||||
backend.create_structured_memory("m2", "beta_doc", "", "general", "global", "", "beta")
|
||||
backend.create_structured_memory("m3", "gamma_doc", "", "general", "global", "", "gamma")
|
||||
backend.create_structured_memory("m4", "unrelated", "", "general", "global", "", "delta")
|
||||
backend.create_structured_memory(
|
||||
"m1", "alpha_doc", "Test memory", "general", "global", "", "alpha"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m2", "beta_doc", "Test memory", "general", "global", "", "beta"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m3", "gamma_doc", "Test memory", "general", "global", "", "gamma"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m4", "unrelated", "Test memory", "general", "global", "", "delta"
|
||||
)
|
||||
|
||||
results = backend.search_structured_memories("alpha beta gamma")
|
||||
names = {r["name"] for r in results}
|
||||
@@ -209,12 +351,14 @@ class TestSearchOrOfTerms:
|
||||
def test_scope_filter_preserved(self, backend):
|
||||
"""OR-of-terms search still respects scope / scope_id filters."""
|
||||
backend.create_structured_memory(
|
||||
"m1", "ws1_note", "", "general", "workstream", "ws1", "info"
|
||||
"m1", "ws1_note", "Test memory", "general", "workstream", "ws1", "info"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m2", "ws2_note", "", "general", "workstream", "ws2", "info"
|
||||
"m2", "ws2_note", "Test memory", "general", "workstream", "ws2", "info"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m3", "global_note", "Test memory", "general", "global", "", "info"
|
||||
)
|
||||
backend.create_structured_memory("m3", "global_note", "", "general", "global", "", "info")
|
||||
|
||||
results = backend.search_structured_memories("info", scope="workstream", scope_id="ws1")
|
||||
names = {r["name"] for r in results}
|
||||
@@ -224,9 +368,11 @@ class TestSearchOrOfTerms:
|
||||
|
||||
def test_term_cap_normalizes_unbounded_query(self, backend):
|
||||
"""A multi-KB query collapses to <= MAX terms (de-dupe + length filter)."""
|
||||
backend.create_structured_memory("m1", "alpha_doc", "", "general", "global", "", "alpha")
|
||||
backend.create_structured_memory(
|
||||
"m2", "other_doc", "", "general", "global", "", "irrelevant"
|
||||
"m1", "alpha_doc", "Test memory", "general", "global", "", "alpha"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m2", "other_doc", "Test memory", "general", "global", "", "irrelevant"
|
||||
)
|
||||
|
||||
# Build a noisy query: same word repeated, plus 1-char tokens that
|
||||
@@ -241,10 +387,18 @@ class TestVisibleStructuredMemories:
|
||||
"""Single-query union helpers used by the composition path."""
|
||||
|
||||
def test_list_visible_unions_global_workstream_user(self, backend):
|
||||
backend.create_structured_memory("m1", "g_note", "", "general", "global", "", "g")
|
||||
backend.create_structured_memory("m2", "ws_note", "", "general", "workstream", "ws1", "w")
|
||||
backend.create_structured_memory("m3", "u_note", "", "general", "user", "u1", "u")
|
||||
backend.create_structured_memory("m4", "other_ws", "", "general", "workstream", "ws2", "x")
|
||||
backend.create_structured_memory(
|
||||
"m1", "g_note", "Test memory", "general", "global", "", "g"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m2", "ws_note", "Test memory", "general", "workstream", "ws1", "w"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m3", "u_note", "Test memory", "general", "user", "u1", "u"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m4", "other_ws", "Test memory", "general", "workstream", "ws2", "x"
|
||||
)
|
||||
|
||||
scopes = [("global", ""), ("workstream", "ws1"), ("user", "u1")]
|
||||
rows = backend.list_visible_structured_memories(scopes)
|
||||
@@ -252,12 +406,14 @@ class TestVisibleStructuredMemories:
|
||||
assert names == {"g_note", "ws_note", "u_note"} # ws2 excluded
|
||||
|
||||
def test_search_visible_unions_scopes_and_terms(self, backend):
|
||||
backend.create_structured_memory("m1", "g_alpha", "", "general", "global", "", "alpha")
|
||||
backend.create_structured_memory(
|
||||
"m2", "ws_beta", "", "general", "workstream", "ws1", "beta"
|
||||
"m1", "g_alpha", "Test memory", "general", "global", "", "alpha"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m3", "ws_other", "", "general", "workstream", "ws2", "alpha"
|
||||
"m2", "ws_beta", "Test memory", "general", "workstream", "ws1", "beta"
|
||||
)
|
||||
backend.create_structured_memory(
|
||||
"m3", "ws_other", "Test memory", "general", "workstream", "ws2", "alpha"
|
||||
)
|
||||
|
||||
scopes = [("global", ""), ("workstream", "ws1")]
|
||||
@@ -268,7 +424,9 @@ class TestVisibleStructuredMemories:
|
||||
assert "ws_other" not in names # ws2 -> outside visibility
|
||||
|
||||
def test_visible_helpers_handle_empty_scopes(self, backend):
|
||||
backend.create_structured_memory("m1", "anything", "", "general", "global", "", "x")
|
||||
backend.create_structured_memory(
|
||||
"m1", "anything", "Test memory", "general", "global", "", "x"
|
||||
)
|
||||
assert backend.list_visible_structured_memories([]) == []
|
||||
assert backend.search_visible_structured_memories("x", []) == []
|
||||
|
||||
@@ -288,7 +446,7 @@ class TestStableOrderingOnTimestampTies:
|
||||
# batch lands them in the same second.
|
||||
for mid in ("zebra_id", "apple_id", "mango_id"):
|
||||
backend.create_structured_memory(
|
||||
mid, f"name_{mid}", "", "general", "global", "", "shared content"
|
||||
mid, f"name_{mid}", "Test memory", "general", "global", "", "shared content"
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
Reference in New Issue
Block a user