Make MCP writes retry-safe and coordination state visible to agents
- Send Idempotency-Key on node create/patch/update writes (including bulk,
link, move, kanban and agent-node tools) and return the key used, so an
ambiguous failure can be retried without duplicating the write.
- Record a non-secret marker when login renewal is rejected; nap_whoami
reports auth_required ahead of gateway state, and local-index answers
carry stale warnings instead of silently serving old memory.
- Add nap_kanban_claim_next: pick and claim in one call, skipping cards
held by another operation, never replaying an ambiguous claim.
- Add nap_changes_since over /v1/projects/{id}/changes with a
per-worktree cursor that only advances after a successful read.
- Report real links_count/backlinks_count from indexed edges.
- Accept optional tool_profiles in .napseer/project.json when
NAPSEER_TOOL_PROFILES is unset.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c427e2432a
commit
7eaaffb63f
@@ -0,0 +1,593 @@
|
||||
"""Regressions for write idempotency, stale local reads, atomic claim-next,
|
||||
indexed link counts, and locator-declared tool profiles."""
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.error
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def load_module():
|
||||
script_path = (
|
||||
pathlib.Path(__file__).resolve().parents[1]
|
||||
/ "resources"
|
||||
/ "scripts"
|
||||
/ "napseer_mcp_server.py"
|
||||
)
|
||||
sys.path.insert(0, str(script_path.parent))
|
||||
spec = importlib.util.spec_from_file_location("napseer_mcp_reliability_test", script_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mod(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("NAPSEER_TOOL_PROFILES", raising=False)
|
||||
module = load_module()
|
||||
module.DEFAULT_PROJECT_ID = "project-1"
|
||||
module.TOKEN = "synthetic-access"
|
||||
module.REFRESH_TOKEN = "synthetic-refresh"
|
||||
module.TOKEN_EXPIRES_AT = "2099-01-01T00:00:00Z"
|
||||
module.REFRESH_EXPIRES_AT = None
|
||||
module.AUTH = {**module.AUTH, "account_mode": "operator_account", "project_id": "project-1"}
|
||||
module.refresh_public_auth_state = lambda: module.AUTH
|
||||
module.project_memory_encryption_active = lambda _project_id: False
|
||||
module.AUTH_DIR = tmp_path / "state"
|
||||
module.INDEX_PATH = tmp_path / "state" / "index.sqlite"
|
||||
module.INDEX_LOCK_PATH = tmp_path / "state" / "index.lock"
|
||||
module.resolve_project_id = lambda _args: "project-1"
|
||||
return module
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self._body = json.dumps(payload).encode("utf-8")
|
||||
|
||||
def read(self, *_args):
|
||||
return self._body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc):
|
||||
return False
|
||||
|
||||
|
||||
def http_error(status, body):
|
||||
return urllib.error.HTTPError(
|
||||
"https://api.example.test/x", status, "error", {}, io.BytesIO(json.dumps(body).encode("utf-8"))
|
||||
)
|
||||
|
||||
|
||||
def stub_project_lock(mod):
|
||||
mod.acquire_project_lock = lambda args=None, **kwargs: {"id": "lock-1", "lease_token": "lease-1"}
|
||||
mod.cleanup_project_lock = lambda project_id, lock: None
|
||||
|
||||
|
||||
# -- 1. Idempotency keys ----------------------------------------------------
|
||||
|
||||
|
||||
def test_create_node_idempotency_key_survives_auth_retry_and_is_returned(mod):
|
||||
stub_project_lock(mod)
|
||||
seen = []
|
||||
|
||||
def fake_urlopen(request, timeout=None):
|
||||
seen.append((request.get_method(), request.full_url, request.get_header("Idempotency-key"),
|
||||
request.get_header("Authorization")))
|
||||
if len(seen) == 1:
|
||||
raise http_error(401, {"error": {"code": "unauthorized"}})
|
||||
return FakeResponse({"id": "node-new", "project_id": "project-1", "full_path": "/notes/a",
|
||||
"folder_path": "/notes", "name": "a", "type": "note", "content_text": "x"})
|
||||
|
||||
def fake_renew(stale_token=None):
|
||||
mod.TOKEN = "renewed-access"
|
||||
return {"status": "renewed"}
|
||||
|
||||
mod.api_urlopen = fake_urlopen
|
||||
mod.renew_auth = fake_renew
|
||||
|
||||
result = mod.call_tool_impl("nap_create_node", {"folder_path": "/notes", "name": "a", "content_text": "x"})
|
||||
|
||||
assert len(seen) == 2
|
||||
generated = seen[0][2]
|
||||
assert generated and str(uuid.UUID(generated)) == generated
|
||||
# One logical request: the internal auth replay reuses the same key.
|
||||
assert seen[1][2] == generated
|
||||
assert result["idempotency_key"] == generated
|
||||
|
||||
seen.clear()
|
||||
mod.api_urlopen = lambda request, timeout=None: seen.append(request.get_header("Idempotency-key")) or FakeResponse(
|
||||
{"id": "node-new", "project_id": "project-1", "full_path": "/notes/a"}
|
||||
)
|
||||
result = mod.call_tool_impl(
|
||||
"nap_create_node", {"folder_path": "/notes", "name": "a", "content_text": "x", "idempotency_key": "caller-key-1"}
|
||||
)
|
||||
assert seen == ["caller-key-1"]
|
||||
assert result["idempotency_key"] == "caller-key-1"
|
||||
|
||||
|
||||
def test_node_patch_sends_header_not_body_and_bulk_items_get_keys(mod):
|
||||
stub_project_lock(mod)
|
||||
existing = {"id": "node-123", "project_id": "project-1", "full_path": "/notes/current",
|
||||
"folder_path": "/notes", "name": "current", "type": "note", "content_text": "old"}
|
||||
mod.get_node_by_id = lambda args: existing
|
||||
mod.try_get_node_by_path = lambda path, allow_agent=False: None
|
||||
mod.index_node = lambda node: None
|
||||
writes = []
|
||||
|
||||
def fake_request(method, path, payload=None, **kwargs):
|
||||
writes.append((method, path, payload, dict(kwargs.get("extra_headers") or {})))
|
||||
if path.endswith("/patch"):
|
||||
return {"ok": True, "data": {"node": existing, "revision": "r2", "read_fingerprint": "f2"}}
|
||||
return {**existing, "id": "node-new" if method == "POST" else existing["id"]}
|
||||
|
||||
mod.request_json = fake_request
|
||||
|
||||
patched = mod.guarded_node_patch({
|
||||
"node_id": "node-123",
|
||||
"precondition": {"revision": "r1", "read_fingerprint": "f1"},
|
||||
"merge_metadata": {"status": "active"},
|
||||
"idempotency_key": "patch-key-1",
|
||||
})
|
||||
method, path, payload, headers = writes[-1]
|
||||
assert (method, path) == ("PATCH", "/v1/projects/project-1/nodes/node-123/patch")
|
||||
assert "idempotency_key" not in payload
|
||||
assert headers["Idempotency-Key"] == "patch-key-1"
|
||||
assert headers["X-Napseer-Lock-Id"] == "lock-1"
|
||||
assert patched["idempotency_key"] == "patch-key-1"
|
||||
|
||||
generated = mod.guarded_node_patch({
|
||||
"node_id": "node-123",
|
||||
"precondition": {"revision": "r1", "read_fingerprint": "f1"},
|
||||
"merge_metadata": {"status": "active"},
|
||||
})
|
||||
assert generated["idempotency_key"] == writes[-1][3]["Idempotency-Key"]
|
||||
uuid.UUID(generated["idempotency_key"])
|
||||
|
||||
writes.clear()
|
||||
bulk = mod.bulk_upsert_nodes({
|
||||
"idempotency_key": "bulk-base",
|
||||
"nodes": [
|
||||
{"path": "/notes/new", "content_text": "new"},
|
||||
{"node_id": "node-123", "content_text": "updated", "idempotency_key": "item-own"},
|
||||
],
|
||||
})
|
||||
assert [(w[0], w[3]["Idempotency-Key"]) for w in writes] == [("POST", "bulk-base:0"), ("PATCH", "item-own")]
|
||||
assert [item["idempotency_key"] for item in bulk["items"]] == ["bulk-base:0", "item-own"]
|
||||
|
||||
for name in ("nap_create_node", "nap_node_patch", "nap_bulk"):
|
||||
assert mod.tool_contract_metadata(name)["idempotency"] == "same-idempotency-key"
|
||||
tools = {tool["name"]: tool for tool in mod.raw_tools()}
|
||||
assert "idempotency_key" in tools["nap_create_node"]["inputSchema"]["properties"]
|
||||
assert "idempotency_key" in tools["nap_bulk"]["inputSchema"]["properties"]
|
||||
assert "idempotency_key" in tools["nap_bulk"]["inputSchema"]["properties"]["nodes"]["items"]["properties"]
|
||||
|
||||
|
||||
# -- 2. Stale local reads and whoami auth precedence -----------------------
|
||||
|
||||
|
||||
def indexed(path, links=None):
|
||||
folder, name = path.rsplit("/", 1)
|
||||
return {
|
||||
"id": f"id-{name}",
|
||||
"project_id": "project-1",
|
||||
"full_path": path,
|
||||
"folder_path": folder,
|
||||
"name": name,
|
||||
"type": "note",
|
||||
"tags": [],
|
||||
"aliases": [],
|
||||
"links": links or [],
|
||||
"metadata": {"status": "active"},
|
||||
"content_text": f"body of {name} gateway reliability",
|
||||
"encryption_state": "legacy_plaintext",
|
||||
"updated_at": "2026-08-22T00:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
def build_complete_index(mod):
|
||||
mod.index_node(indexed("/plans/alpha", links=[{"path": "/plans/beta", "relation": "references"}]))
|
||||
mod.index_node(indexed("/plans/beta"))
|
||||
mod.set_local_graph_index_complete("project-1", True)
|
||||
|
||||
|
||||
def record_revoked_grant(mod):
|
||||
def rejected(*_args, **_kwargs):
|
||||
raise mod.SafeToolError("http_401", "rejected", status=401)
|
||||
|
||||
mod.request_form_json = rejected
|
||||
with pytest.raises(mod.SafeToolError) as caught:
|
||||
mod.renew_auth()
|
||||
assert caught.value.code == "auth_required"
|
||||
assert "expired or revoked" in caught.value.safe_message
|
||||
|
||||
|
||||
def test_local_answers_warn_when_login_grant_is_revoked(mod):
|
||||
build_complete_index(mod)
|
||||
mod.request_json = lambda *a, **k: (_ for _ in ()).throw(AssertionError("no HTTP expected"))
|
||||
|
||||
healthy = mod.discover_memory({"folder_path": "/plans", "view": "paths"})
|
||||
assert not healthy.get("warnings")
|
||||
assert not (healthy.get("freshness") or {}).get("stale")
|
||||
|
||||
record_revoked_grant(mod)
|
||||
state_text = (mod.AUTH_DIR / "auth-failure.json").read_text()
|
||||
assert "synthetic-refresh" not in state_text and "synthetic-access" not in state_text
|
||||
|
||||
browsed = mod.discover_memory({"folder_path": "/plans", "view": "paths"})
|
||||
assert [w["code"] for w in browsed["warnings"]] == ["auth_required"]
|
||||
assert browsed["warnings"][0]["indexed_at"]
|
||||
assert browsed["freshness"]["stale"] is True
|
||||
assert browsed["freshness"]["graph_complete"] is True
|
||||
|
||||
context = mod.get_memory_context({"node_id": "id-alpha", "depth": 1})
|
||||
codes = [w["code"] for w in context["warnings"] if isinstance(w, dict)]
|
||||
assert codes == ["auth_required"]
|
||||
assert context["freshness"]["stale"] is True
|
||||
assert context["edge_count"] >= 1
|
||||
|
||||
status = mod.gateway_status()
|
||||
assert status["status"] == "auth_required"
|
||||
assert status["auth_problem"]["reason"] == "auth_required"
|
||||
|
||||
# A new login invalidates the recorded failure without clearing files.
|
||||
mod.TOKEN = "fresh-access"
|
||||
mod.REFRESH_TOKEN = "fresh-refresh"
|
||||
assert mod.current_auth_problem() is None
|
||||
assert mod.gateway_status()["status"] != "auth_required"
|
||||
|
||||
|
||||
def test_hybrid_search_falls_back_to_local_with_server_unavailable_warning(mod):
|
||||
build_complete_index(mod)
|
||||
|
||||
def server_down(*_args, **_kwargs):
|
||||
raise mod.SafeToolError("http_503", "Napseer GET request failed with HTTP 503.", status=503)
|
||||
|
||||
mod.request_json = server_down
|
||||
result = mod.discover_memory({"q": "gateway reliability", "limit": 25})
|
||||
assert {item["full_path"] for item in result["items"]} == {"/plans/alpha", "/plans/beta"}
|
||||
assert [w["code"] for w in result["warnings"]] == ["server_unavailable"]
|
||||
assert result["freshness"]["stale"] is True
|
||||
assert result["freshness"]["reason"] == "server_unavailable"
|
||||
|
||||
# A non-recoverable server error is not masked by the local answer.
|
||||
mod.request_json = lambda *a, **k: (_ for _ in ()).throw(mod.SafeToolError("http_403", "forbidden", status=403))
|
||||
with pytest.raises(mod.SafeToolError):
|
||||
mod.discover_memory({"q": "gateway reliability", "limit": 25})
|
||||
|
||||
|
||||
def test_whoami_reports_expired_access_without_refresh_ahead_of_gateway_state(mod):
|
||||
mod.REFRESH_TOKEN = None
|
||||
mod.TOKEN_EXPIRES_AT = "2020-01-01T00:00:00Z"
|
||||
status = mod.gateway_status()
|
||||
assert status["status"] == "auth_required"
|
||||
assert status["auth_problem"]["reason"] == "access_token_expired_without_refresh"
|
||||
|
||||
|
||||
# -- 3. Atomic claim-next ---------------------------------------------------
|
||||
|
||||
|
||||
def kanban_card(name, priority, rank):
|
||||
return {
|
||||
"id": f"id-{name}",
|
||||
"project_id": "project-1",
|
||||
"full_path": f"/kanban/todo/{name}",
|
||||
"folder_path": "/kanban/todo",
|
||||
"name": name,
|
||||
"type": "kanban_card",
|
||||
"tags": ["kanban"],
|
||||
"links": [],
|
||||
"metadata": {"status": "todo", "column": "todo", "priority": priority, "rank": rank, "blocked": False},
|
||||
"content_text": "card",
|
||||
"updated_at": "2026-05-06T12:00:00Z",
|
||||
"archived_at": None,
|
||||
"encryption_state": "plaintext",
|
||||
}
|
||||
|
||||
|
||||
def kanban_backend(mod, claim_outcomes):
|
||||
cards = [kanban_card("first", "urgent", "00001024"), kanban_card("second", "high", "00001024"),
|
||||
kanban_card("third", "normal", "00001024")]
|
||||
claims = []
|
||||
|
||||
def fake_request(method, path, payload=None, **kwargs):
|
||||
if method == "GET":
|
||||
return {"items": cards, "next_cursor": None}
|
||||
assert (method, path) == ("POST", "/v1/projects/project-1/operations")
|
||||
claims.append(payload)
|
||||
outcome = claim_outcomes[len(claims) - 1]
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return {**outcome, "id": payload["id"], "node_id": payload["node_id"]}
|
||||
|
||||
mod.request_json = fake_request
|
||||
return claims
|
||||
|
||||
|
||||
def held_error(mod, legacy=False):
|
||||
mod_error = mod.safe_http_error(
|
||||
http_error(409, {}),
|
||||
operation="POST request",
|
||||
body_text=json.dumps(
|
||||
{"error": {"code": "conflict", "message": "Another execution holds this work until it expires."}}
|
||||
if legacy else
|
||||
{"error": {"code": "operation_held", "message": "held", "details": {"active_operation_id": "op-active"}}}
|
||||
),
|
||||
)
|
||||
return mod_error
|
||||
|
||||
|
||||
def test_claim_next_skips_held_cards_and_claims_in_pick_order(mod):
|
||||
claims = kanban_backend(mod, [held_error(mod), held_error(mod, legacy=True), {"state": "running"}])
|
||||
mod.CONFIGURED_TOOL_PROFILES = {"workflow"}
|
||||
|
||||
result = mod.call_tool_impl("nap_kanban_claim_next", {"holder": "agent-a", "ttl_seconds": 120})
|
||||
|
||||
assert [c["node_id"] for c in claims] == ["id-first", "id-second", "id-third"]
|
||||
assert len({c["id"] for c in claims}) == 3
|
||||
assert all(c["holder"] == "agent-a" and c["ttl_seconds"] == 120 for c in claims)
|
||||
assert result["claimed"]["full_path"] == "/kanban/todo/third"
|
||||
assert result["operation"]["state"] == "running"
|
||||
assert result["operation_id"] == claims[-1]["id"]
|
||||
assert [s["reason"] for s in result["skipped"]] == ["operation_held", "operation_held"]
|
||||
assert result["skipped"][0]["active_operation_id"] == "op-active"
|
||||
|
||||
|
||||
def test_claim_next_returns_null_when_all_held_and_stops_on_ambiguous_error(mod):
|
||||
kanban_backend(mod, [held_error(mod)] * 3)
|
||||
none = mod.claim_next_kanban_card({"holder": "agent-a"})
|
||||
assert none["ok"] is True and none["claimed"] is None
|
||||
assert none["reason"] == "all_candidates_held"
|
||||
|
||||
claims = kanban_backend(mod, [held_error(mod), mod.SafeToolError("request_timeout", "timed out")])
|
||||
with pytest.raises(mod.SafeToolError) as caught:
|
||||
mod.claim_next_kanban_card({"holder": "agent-a"})
|
||||
# Stops at the ambiguous claim; never retries or moves on.
|
||||
assert len(claims) == 2
|
||||
payload = mod.safe_exception_payload(caught.value)
|
||||
assert payload["code"] == "kanban_claim_unresolved"
|
||||
assert payload["details"]["attempted_operation_id"] == claims[1]["id"]
|
||||
assert payload["details"]["cause"]["code"] == "request_timeout"
|
||||
assert claims[1]["id"] in payload["message"]
|
||||
|
||||
claims = kanban_backend(mod, [held_error(mod), held_error(mod)])
|
||||
capped = mod.claim_next_kanban_card({"holder": "agent-a", "max_candidates": 2})
|
||||
assert len(claims) == 2 and capped["reason"] == "max_candidates_held"
|
||||
|
||||
with pytest.raises(mod.SafeToolError):
|
||||
mod.claim_next_kanban_card({})
|
||||
|
||||
|
||||
def test_claim_next_is_registered_as_workflow_mutation(mod):
|
||||
assert "nap_kanban_claim_next" in mod.CANONICAL_TOOL_NAMES
|
||||
assert mod.tool_profile("nap_kanban_claim_next") == "workflow"
|
||||
assert mod.tool_category("nap_kanban_claim_next") == "kanban"
|
||||
metadata = mod.tool_contract_metadata("nap_kanban_claim_next")
|
||||
assert metadata["side_effect"] == "remote-write"
|
||||
assert metadata["lock_policy"] == "atomic-execution-lease"
|
||||
assert "nap_kanban_claim_next" not in mod.READ_ONLY_TOOLS
|
||||
|
||||
|
||||
# -- 4. links_count from indexed edges -------------------------------------
|
||||
|
||||
|
||||
def test_local_discovery_rows_report_indexed_link_counts(mod):
|
||||
build_complete_index(mod)
|
||||
mod.request_json = lambda *a, **k: (_ for _ in ()).throw(AssertionError("no HTTP expected"))
|
||||
|
||||
searched = mod.search_local_index({"q": "gateway reliability", "limit": 10})
|
||||
by_path = {item["full_path"]: item for item in searched["items"]}
|
||||
assert by_path["/plans/alpha"]["links_count"] == 1
|
||||
assert by_path["/plans/beta"]["backlinks_count"] == 1
|
||||
|
||||
rows = mod.discover_memory({"q": "gateway reliability", "view": "summary", "mode": "local"})["items"]
|
||||
rows = {item["full_path"]: item for item in rows}
|
||||
assert rows["/plans/alpha"]["links_count"] == 1
|
||||
assert rows["/plans/beta"]["links_count"] == 0
|
||||
assert rows["/plans/beta"]["backlinks_count"] == 1
|
||||
|
||||
# Unknown counts are omitted rather than reported as zero.
|
||||
summary = mod.summarize_node({"id": "x", "full_path": "/a/x", "name": "x"}, view="summary")
|
||||
assert "links_count" not in summary
|
||||
|
||||
|
||||
# -- 5. Tool profiles from the committed locator ---------------------------
|
||||
|
||||
|
||||
def write_locator(mod, **extra):
|
||||
path = mod.project_locator_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({
|
||||
"schema": "napseer.project.v1",
|
||||
"base_url": "https://api.example.test",
|
||||
"project_id": "11111111-1111-4111-8111-111111111111",
|
||||
"project_slug": "example",
|
||||
**extra,
|
||||
}))
|
||||
|
||||
|
||||
def test_locator_tool_profiles_apply_when_env_unset(mod, monkeypatch):
|
||||
mod.CONFIGURED_TOOL_PROFILES = None
|
||||
assert mod.configured_tool_profiles() == set()
|
||||
|
||||
write_locator(mod, tool_profiles=["workflow", "operations", "bogus"])
|
||||
assert mod.load_project_locator()["project_slug"] == "example"
|
||||
assert mod.configured_tool_profiles() == {"workflow", "operations"}
|
||||
assert "nap_kanban_claim_next" in mod.enabled_tool_names()
|
||||
|
||||
apropos = mod.explore_tools({})
|
||||
assert apropos["profile_source"] == "project_locator"
|
||||
assert apropos["warnings"][0]["code"] == "unknown_tool_profile"
|
||||
assert apropos["warnings"][0]["ignored"] == ["bogus"]
|
||||
mod.BASE_URL = "https://api.example.test"
|
||||
mod.local_anchor_integrity = lambda: {}
|
||||
mod.index_status = lambda args: {}
|
||||
doctor = mod.doctor({})
|
||||
assert doctor["warnings"][0]["code"] == "unknown_tool_profile"
|
||||
|
||||
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "maintenance")
|
||||
assert mod.configured_tool_profiles() == {"maintenance"}
|
||||
assert mod.explore_tools({})["profile_source"] == "environment"
|
||||
|
||||
monkeypatch.delenv("NAPSEER_TOOL_PROFILES")
|
||||
write_locator(mod, tool_profiles="workflow")
|
||||
assert mod.configured_tool_profiles() == set()
|
||||
assert mod.explore_tools({})["warnings"][0]["code"] == "invalid_tool_profiles"
|
||||
|
||||
|
||||
# -- 6. Change feed cursor --------------------------------------------------
|
||||
|
||||
|
||||
def changes_backend(mod, responses):
|
||||
calls = []
|
||||
|
||||
def fake_request(method, path, payload=None, **kwargs):
|
||||
calls.append((method, path))
|
||||
outcome = responses[len(calls) - 1]
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
mod.request_json = fake_request
|
||||
return calls
|
||||
|
||||
|
||||
def test_changes_since_starts_at_head_then_advances_only_on_success(mod, tmp_path):
|
||||
change = {"type": "node_updated", "cursor": "c1.5.9", "event_id": "e1", "project_id": "project-1",
|
||||
"node_id": "n1", "full_path": "/plans/a", "changed_fields": ["content_text"]}
|
||||
calls = changes_backend(mod, [
|
||||
{"items": [], "next_cursor": "c1.5.8", "has_more": False},
|
||||
{"items": [change], "next_cursor": "c1.5.9", "has_more": True},
|
||||
mod.SafeToolError("http_503", "unavailable", status=503),
|
||||
{"items": [], "next_cursor": "c1.9.9", "has_more": False},
|
||||
])
|
||||
|
||||
first = mod.call_tool_impl("nap_changes_since", {})
|
||||
assert calls[0] == ("GET", "/v1/projects/project-1/changes?after=head&limit=100")
|
||||
assert first["started_at_head"] is True and first["items"] == []
|
||||
stored = json.loads((mod.AUTH_DIR / "changes-cursor.json").read_text())
|
||||
assert stored["project_id"] == "project-1" and stored["cursor"] == "c1.5.8"
|
||||
assert not (tmp_path / ".napseer" / "changes-cursor.json").exists()
|
||||
|
||||
second = mod.changes_since({"limit": 50})
|
||||
assert calls[1] == ("GET", "/v1/projects/project-1/changes?after=c1.5.8&limit=50")
|
||||
assert second["items"] == [change] and second["has_more"] is True and second["cursor_advanced"] is True
|
||||
|
||||
with pytest.raises(mod.SafeToolError) as caught:
|
||||
mod.changes_since({})
|
||||
assert caught.value.details["cursor_advanced"] is False
|
||||
assert mod.read_changes_cursor("project-1") == "c1.5.9"
|
||||
|
||||
peek = mod.changes_since({"advance": False})
|
||||
assert peek["cursor_advanced"] is False
|
||||
assert mod.read_changes_cursor("project-1") == "c1.5.9"
|
||||
|
||||
|
||||
def test_changes_since_errors_project_binding_and_reset(mod):
|
||||
mod.write_changes_cursor("other-project", "c1.1.1")
|
||||
calls = changes_backend(mod, [
|
||||
mod.safe_http_error(http_error(404, {}), body_text="{}"),
|
||||
mod.safe_http_error(http_error(400, {}), body_text=json.dumps({"error": {"code": "validation_error"}})),
|
||||
{"items": [], "next_cursor": "c1.2.2", "has_more": False},
|
||||
])
|
||||
with pytest.raises(mod.SafeToolError) as unsupported:
|
||||
mod.changes_since({})
|
||||
# A cursor stored for another project is never reused.
|
||||
assert "after=head" in calls[0][1]
|
||||
assert unsupported.value.code == "unsupported_server"
|
||||
|
||||
with pytest.raises(mod.SafeToolError) as invalid:
|
||||
mod.changes_since({"after": "bogus"})
|
||||
assert invalid.value.code == "invalid_changes_cursor"
|
||||
|
||||
reset = mod.changes_since({"reset": True})
|
||||
assert "after=head" in calls[2][1] and reset["started_at_head"] is True
|
||||
assert mod.read_changes_cursor("project-1") == "c1.2.2"
|
||||
|
||||
mod.REFRESH_TOKEN = None
|
||||
mod.TOKEN_EXPIRES_AT = "2020-01-01T00:00:00Z"
|
||||
with pytest.raises(mod.SafeToolError) as auth:
|
||||
mod.changes_since({})
|
||||
assert auth.value.code == "auth_required"
|
||||
assert auth.value.details["auth_problem"]["reason"] == "access_token_expired_without_refresh"
|
||||
assert len(calls) == 3
|
||||
assert mod.read_changes_cursor("project-1") == "c1.2.2"
|
||||
|
||||
|
||||
def test_changes_since_is_a_core_read_tool(mod):
|
||||
assert "nap_changes_since" in mod.CORE_TOOL_NAMES
|
||||
assert mod.tool_category("nap_changes_since") == "memory"
|
||||
assert mod.tool_contract_metadata("nap_changes_since")["side_effect"] == "none"
|
||||
|
||||
|
||||
# -- 7. Idempotency keys on remaining node writes --------------------------
|
||||
|
||||
|
||||
def node_write_backend(mod, nodes):
|
||||
stub_project_lock(mod)
|
||||
mod.CONFIGURED_TOOL_PROFILES = {"all"}
|
||||
mod.get_node_by_id = lambda args: nodes[args.get("node_id") or args.get("id")]
|
||||
mod.index_node = lambda node: None
|
||||
mod.remove_indexed_node = lambda node_id: None
|
||||
writes = []
|
||||
|
||||
def fake_request(method, path, payload=None, **kwargs):
|
||||
if method == "GET":
|
||||
return {"items": list(nodes.values()) if "/nodes?" in path else [], "next_cursor": None}
|
||||
writes.append((method, path, dict(kwargs.get("extra_headers") or {})))
|
||||
node_id = path.rsplit("/", 1)[-1]
|
||||
return {**nodes.get(node_id, next(iter(nodes.values()))), **(payload or {})}
|
||||
|
||||
mod.request_json = fake_request
|
||||
return writes
|
||||
|
||||
|
||||
def test_link_and_kanban_writes_send_scoped_idempotency_keys(mod):
|
||||
card = kanban_card("first", "high", "00001024")
|
||||
card["id"] = "card-1"
|
||||
target = {"id": "target-1", "project_id": "project-1", "full_path": "/plans/t", "links": []}
|
||||
writes = node_write_backend(mod, {"card-1": card, "target-1": target})
|
||||
|
||||
linked = mod.call_tool_impl("nap_ln", {"source_node_id": "card-1", "target_node_id": "target-1"})
|
||||
assert writes[-1][0:2] == ("PATCH", "/v1/projects/project-1/nodes/card-1")
|
||||
assert linked["idempotency_key"] == writes[-1][2]["Idempotency-Key"]
|
||||
uuid.UUID(linked["idempotency_key"])
|
||||
|
||||
started = mod.call_tool_impl("nap_kanban_start", {"node_id": "card-1", "idempotency_key": "start-1"})
|
||||
assert writes[-1][2]["Idempotency-Key"] == "start-1"
|
||||
assert started["idempotency_key"] == "start-1"
|
||||
|
||||
tools = {tool["name"]: tool for tool in mod.all_tools()}
|
||||
for name in ("nap_ln", "nap_kanban_start", "nap_kanban_update", "nap_agent_tee", "nap_mv_folder"):
|
||||
assert "idempotency_key" in tools[name]["inputSchema"]["properties"], name
|
||||
assert mod.tool_contract_metadata(name)["idempotency"] == "same-idempotency-key"
|
||||
|
||||
|
||||
def test_multi_write_tools_derive_keys_and_surface_them_on_failure(mod):
|
||||
nodes = {
|
||||
"a": {"id": "a", "project_id": "project-1", "full_path": "/old/a", "folder_path": "/old", "name": "a"},
|
||||
"b": {"id": "b", "project_id": "project-1", "full_path": "/old/b", "folder_path": "/old", "name": "b"},
|
||||
}
|
||||
writes = node_write_backend(mod, nodes)
|
||||
moved = mod.call_tool_impl("nap_mv_folder", {"from_folder": "/old", "to_folder": "/new", "idempotency_key": "mvf"})
|
||||
assert [w[2]["Idempotency-Key"] for w in writes] == ["mvf", "mvf:1"]
|
||||
assert moved["idempotency_key"] == "mvf" and moved["idempotency_keys"] == ["mvf", "mvf:1"]
|
||||
|
||||
def ambiguous(method, path, payload=None, **kwargs):
|
||||
raise mod.SafeToolError("request_timeout", "timed out")
|
||||
|
||||
mod.request_json = ambiguous
|
||||
with pytest.raises(mod.SafeToolError) as caught:
|
||||
mod.call_tool_impl("nap_create_node", {"folder_path": "/notes", "name": "x", "content_text": "x"})
|
||||
uuid.UUID(caught.value.details["idempotency_key"])
|
||||
|
||||
assert mod.node_write_idempotency_headers("POST", "/v1/projects/p/nodes/n/restore") == {}
|
||||
assert mod.node_write_idempotency_headers("DELETE", "/v1/projects/p/nodes/n") == {}
|
||||
assert "Idempotency-Key" in mod.node_write_idempotency_headers("PATCH", "/v1/projects/p/nodes/n/patch")
|
||||
Reference in New Issue
Block a user