|
| 1 | +"""SEP-2549 cache-hint tests (Phase 19, L2) + deterministic-ordering guard. |
| 2 | +
|
| 3 | +RED phase (TDD): written BEFORE the config key + stamping land. The config |
| 4 | +and gate tests fail with AttributeError / missing-SystemExit; the two SDK |
| 5 | +-shape guard tests (write tools never carry hints; deterministic order) |
| 6 | +pass by design in RED — they pin installed-SDK invariants the lever must |
| 7 | +not break. |
| 8 | +
|
| 9 | +PROBED API (installed mcp 2.0.0 — recorded per 19-03-PLAN Task 3 PROBE |
| 10 | +FIRST): cache hints are a PER-METHOD constructor map, |
| 11 | +``MCPServer(cache_hints={method: CacheHint(ttl_ms=..., scope=...)})`` from |
| 12 | +``mcp.server.caching``. ``CacheableMethod`` covers list/read/discovery |
| 13 | +methods only — ``tools/call`` is NOT cacheable (``validate_cache_hints`` |
| 14 | +rejects the key; ``CallToolResult`` extends ``Result``, not |
| 15 | +``CacheableResult``, and carries no ``ttl_ms``/``cache_scope`` fields; the |
| 16 | +``@tool()`` decorator has no per-tool hint parameter). supamem serves no |
| 17 | +prompts/resources, so the one usable stampable surface is ``tools/list``: |
| 18 | +a TTL there lets 2026-era clients cache the tool registry (d5 ordering |
| 19 | +hygiene makes that cache meaningful). Per-tool search-result stamping — |
| 20 | +the shape RESEARCH §3.3 L2 assumed — does not exist in this SDK version; |
| 21 | +the write-tool exclusion guarantee is therefore airtight by construction |
| 22 | +(no tools/call result is ever stampable at all). |
| 23 | +""" |
| 24 | +from __future__ import annotations |
| 25 | + |
| 26 | +from pathlib import Path |
| 27 | + |
| 28 | +import pytest |
| 29 | +from mcp.server.mcpserver import Context |
| 30 | + |
| 31 | +from tests.conftest import _cfg_with_caps, _mock_backend_with_long_chunks |
| 32 | + |
| 33 | +from supamem.mcp_server import build_app |
| 34 | + |
| 35 | +EXPECTED_TOOL_ORDER = [ |
| 36 | + "dual_memory_search", |
| 37 | + "dual_memory_write", |
| 38 | + "qdrant_find", |
| 39 | + "qdrant_store", |
| 40 | +] |
| 41 | + |
| 42 | + |
| 43 | +def _server_cache_hints(app: object) -> dict: |
| 44 | + """Read the validated per-method hint map off the installed MCPServer.""" |
| 45 | + server = getattr(app, "_lowlevel_server") |
| 46 | + return dict(getattr(server, "cache_hints")) |
| 47 | + |
| 48 | + |
| 49 | +# ── 1. Default OFF: no cache-hint surface anywhere ────────────────────────── |
| 50 | + |
| 51 | + |
| 52 | +def test_default_ttl_zero_no_hints() -> None: |
| 53 | + from supamem.config import ResolvedConfig |
| 54 | + |
| 55 | + assert ResolvedConfig().mcp_cache_ttl_ms == 0, "cache_ttl_ms must default to 0 (off)" |
| 56 | + |
| 57 | + app = build_app(_cfg_with_caps()) |
| 58 | + assert _server_cache_hints(app) == {}, ( |
| 59 | + f"ttl=0 must leave the server's cache-hint map empty, got " |
| 60 | + f"{_server_cache_hints(app)!r}" |
| 61 | + ) |
| 62 | + |
| 63 | + |
| 64 | +# ── 2. Enabled: the stampable surface carries the configured TTL ─────────── |
| 65 | + |
| 66 | + |
| 67 | +def test_ttl_enabled_stamps_cacheable_surface() -> None: |
| 68 | + from mcp.server.caching import CacheHint |
| 69 | + |
| 70 | + cfg = _cfg_with_caps(mcp_cache_ttl_ms=300) |
| 71 | + app = build_app(cfg) |
| 72 | + hints = _server_cache_hints(app) |
| 73 | + assert hints == {"tools/list": CacheHint(ttl_ms=300, scope="private")}, ( |
| 74 | + f"ttl=300 must stamp the one cacheable method supamem serves " |
| 75 | + f"(tools/list, private scope), got: {hints!r}" |
| 76 | + ) |
| 77 | + |
| 78 | + |
| 79 | +# ── 3. Write tools never carry cache hints (stale write-then-read guard) ─── |
| 80 | + |
| 81 | + |
| 82 | +def test_tools_call_results_never_carry_hints() -> None: |
| 83 | + """tools/call is not stampable at all: SDK rejects the key; results have |
| 84 | + no ttl fields. A cached write result could mask write-then-read |
| 85 | + visibility — the SDK shape makes the exclusion airtight for every tool.""" |
| 86 | + from mcp.server.caching import CacheHint, validate_cache_hints |
| 87 | + |
| 88 | + with pytest.raises(ValueError, match="cacheable methods"): |
| 89 | + validate_cache_hints({"tools/call": CacheHint(ttl_ms=300)}) |
| 90 | + |
| 91 | + |
| 92 | +@pytest.mark.asyncio |
| 93 | +async def test_no_tool_result_exposes_ttl_fields( |
| 94 | + monkeypatch: pytest.MonkeyPatch, |
| 95 | +) -> None: |
| 96 | + from supamem.memory_writer import WriteResult |
| 97 | + |
| 98 | + _mock_backend_with_long_chunks(monkeypatch, n_hits=1, text_len=80) |
| 99 | + |
| 100 | + def _fake_write_memory(**_kwargs: object) -> WriteResult: |
| 101 | + return WriteResult( |
| 102 | + summary="saved", |
| 103 | + path="/tmp/x.md", |
| 104 | + topic="x", |
| 105 | + slug="x", |
| 106 | + indexed=True, |
| 107 | + points_added=1, |
| 108 | + error=None, |
| 109 | + ) |
| 110 | + |
| 111 | + from supamem import memory_writer |
| 112 | + |
| 113 | + monkeypatch.setattr(memory_writer, "write_memory", _fake_write_memory) |
| 114 | + |
| 115 | + cfg = _cfg_with_caps(mcp_cache_ttl_ms=300) |
| 116 | + app = build_app(cfg) |
| 117 | + calls = { |
| 118 | + "dual_memory_search": {"query": "hi", "top_k": 1}, |
| 119 | + "qdrant_find": {"query": "hi", "top_k": 1}, |
| 120 | + "dual_memory_write": {"topic": "x", "content": "body"}, |
| 121 | + "qdrant_store": {"topic": "x", "content": "body"}, |
| 122 | + } |
| 123 | + for name, args in calls.items(): |
| 124 | + result = await app._tool_manager.call_tool( # type: ignore[attr-defined] |
| 125 | + name, args, Context(), convert_result=True |
| 126 | + ) |
| 127 | + assert not hasattr(result, "ttl_ms"), ( |
| 128 | + f"{name}: tools/call result must not expose ttl_ms (no stale " |
| 129 | + f"write-then-read window)" |
| 130 | + ) |
| 131 | + assert not hasattr(result, "cache_scope"), ( |
| 132 | + f"{name}: tools/call result must not expose cache_scope" |
| 133 | + ) |
| 134 | + |
| 135 | + |
| 136 | +# ── 4. d5 guard: deterministic registration order across invocations ─────── |
| 137 | + |
| 138 | + |
| 139 | +def test_deterministic_tool_registration_order( |
| 140 | + monkeypatch: pytest.MonkeyPatch, |
| 141 | +) -> None: |
| 142 | + monkeypatch.setenv("SUPAMEM_QDRANT_ALIASES", "1") |
| 143 | + app_a = build_app(_cfg_with_caps()) |
| 144 | + app_b = build_app(_cfg_with_caps()) |
| 145 | + order_a = list(app_a._tool_manager._tools) # type: ignore[attr-defined] |
| 146 | + order_b = list(app_b._tool_manager._tools) # type: ignore[attr-defined] |
| 147 | + assert order_a == order_b, ( |
| 148 | + f"registration order must be deterministic (prompt-cache hygiene d5): " |
| 149 | + f"{order_a} != {order_b}" |
| 150 | + ) |
| 151 | + assert order_a == EXPECTED_TOOL_ORDER, ( |
| 152 | + f"fixed module registration order expected, got: {order_a}" |
| 153 | + ) |
| 154 | + |
| 155 | + |
| 156 | +# ── 5. Validation gate: negative ttl fails closed at boot ────────────────── |
| 157 | + |
| 158 | + |
| 159 | +def test_load_config_rejects_negative_ttl( |
| 160 | + tmp_path: Path, |
| 161 | + monkeypatch: pytest.MonkeyPatch, |
| 162 | + capsys: pytest.CaptureFixture[str], |
| 163 | +) -> None: |
| 164 | + from supamem.config import load_config |
| 165 | + |
| 166 | + monkeypatch.delenv("SUPAMEM_CONFIG", raising=False) |
| 167 | + cfg_dir = tmp_path / ".supamem" |
| 168 | + cfg_dir.mkdir() |
| 169 | + (cfg_dir / "config.toml").write_text( |
| 170 | + "[supamem.mcp]\ncache_ttl_ms = -1\n", |
| 171 | + encoding="utf-8", |
| 172 | + ) |
| 173 | + with pytest.raises(SystemExit) as exc: |
| 174 | + load_config(tmp_path) |
| 175 | + assert exc.value.code == 2 |
| 176 | + captured = capsys.readouterr() |
| 177 | + assert "cache_ttl_ms" in captured.err, ( |
| 178 | + f"error must name the offending key, got: {captured.err!r}" |
| 179 | + ) |
0 commit comments