- Track a feed cursor in the local index: reindex takes the head cursor before listing and commits it with the snapshot; reads refresh from /changes (bounded, throttled) and apply creates, updates, archives, restores and hard deletes atomically with the cursor. Existing cursorless indexes bootstrap tracking on first read. Failures answer locally with explicit freshness warnings; 400 triggers a full reindex, 404 falls back to timestamp sync. - Rank by a weighted score: exact identity (including aliases and paths), term coverage including body, intent, bm25, authority, a status penalty for superseded/completed records and mild recency. - Return query-centred FTS5 snippets (never for E2E nodes). - FTS schema v2: porter stemming, prefix index, title column, rowid deletes, two-character terms, domain words no longer stopwords; v1 indexes upgrade in place. - Hyphenated alias/path literals match as adjacent-token phrases and rank as exact identity. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1383 lines
56 KiB
Python
1383 lines
56 KiB
Python
#!/usr/bin/env python3
|
|
"""Smoke-test active plan, kanban, and plan lifecycle MCP helpers."""
|
|
|
|
import importlib.util
|
|
import pathlib
|
|
import sys
|
|
import tempfile
|
|
|
|
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_server_ergonomics_test", script_path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec and spec.loader
|
|
spec.loader.exec_module(module)
|
|
# Keep project resolution independent from any real .napseer/auth.json in
|
|
# the checkout. Store the handle on the module so the directory remains
|
|
# alive for the complete test invocation.
|
|
module._test_state = tempfile.TemporaryDirectory()
|
|
module.AUTH_PATH = pathlib.Path(module._test_state.name) / "auth.json"
|
|
module.AUTH = {"account_id": "acct-1", "token": "token", "token_expires_at": "later"}
|
|
module.DEFAULT_PROJECT_ID = "project-1"
|
|
# Keep broad workflow coverage in this legacy suite. Dedicated profile
|
|
# tests exercise the production core-only default.
|
|
module.CONFIGURED_TOOL_PROFILES = {"all"}
|
|
module.project_memory_encryption_active = lambda project_id: False
|
|
return module
|
|
|
|
|
|
@pytest.fixture
|
|
def mod():
|
|
return load_module()
|
|
|
|
|
|
def node(path, status="planned", metadata=None, tags=None, content="body text", archived_at=None, node_type="plan", links=None):
|
|
folder, name = path.rsplit("/", 1)
|
|
merged_metadata = {"status": status}
|
|
if metadata:
|
|
merged_metadata.update(metadata)
|
|
return {
|
|
"id": f"id-{name}",
|
|
"project_id": "project-1",
|
|
"full_path": path,
|
|
"folder_path": folder,
|
|
"name": name,
|
|
"type": node_type,
|
|
"tags": tags or [],
|
|
"links": links or [],
|
|
"metadata": merged_metadata,
|
|
"content_text": content,
|
|
"updated_at": "2026-05-06T12:00:00Z",
|
|
"archived_at": archived_at,
|
|
"encryption_state": "plaintext",
|
|
}
|
|
|
|
|
|
def test_active_plan_listing(mod):
|
|
active = node("/plans/active", "in_progress", tags=["plan"])
|
|
completed = node("/plans/completed", "completed", tags=["plan", "completed"])
|
|
archived = node("/plans/archived", "planned", tags=["plan"], archived_at="2026-05-06T12:01:00Z")
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert "/nodes?" in path
|
|
assert "folder_path=%2Fplans" in path
|
|
return {"items": [active, completed, archived], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.list_active_plans({})
|
|
assert result["ok"] is True
|
|
assert result["view"] == "summary"
|
|
assert [item["full_path"] for item in result["items"]] == ["/plans/active"]
|
|
assert "content_text" not in result["items"][0]
|
|
assert result["items"][0]["status"] == "in_progress"
|
|
|
|
|
|
def test_node_discovery_allows_large_requested_limit(mod):
|
|
calls = []
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
calls.append(path)
|
|
assert method == "GET"
|
|
assert "limit=250" in path
|
|
return {"items": [node(f"/plans/item-{index}") for index in range(250)], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.list_project_nodes({"folder_path": "/plans", "limit": 250})
|
|
assert result["ok"] is True
|
|
assert len(result["items"]) == 250
|
|
assert result["budget"]["limit"] == 250
|
|
assert result["budget"]["max_limit"] > 200
|
|
assert "content_text" not in result["items"][0]
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_node_by_path_returns_canonical_identity_uri(mod):
|
|
mod.AUTH["project_slug"] = "napseer"
|
|
found = node("/documentation/product/prd", "active", node_type="product_spec")
|
|
found["id"] = "node-123"
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert "/nodes/by-path?" in path
|
|
assert "path=%2Fdocumentation%2Fproduct%2Fprd" in path
|
|
assert "view=render" not in path
|
|
return {
|
|
"identity": {
|
|
"node_id": "node-123",
|
|
"canonical_uri": "nap://napseer/project-1/node-123",
|
|
"project_id": "project-1",
|
|
"project_name": "napseer",
|
|
"lookup_kind": "path",
|
|
"lookup_value": "/documentation/product/prd",
|
|
"legacy_full_path": "/documentation/product/prd",
|
|
},
|
|
"route": {
|
|
"id": "node-123",
|
|
"project_id": "project-1",
|
|
"full_path": "/documentation/product/prd",
|
|
"type": "product_spec",
|
|
"name": "prd",
|
|
"folder_path": "/documentation/product",
|
|
},
|
|
"warnings": ["Path lookup is index/route resolution only."],
|
|
}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.node_by_path({"path": "documentation/product/prd"})
|
|
assert result["ok"] is True
|
|
assert result["status"] == "resolved"
|
|
assert result["identity"] == {
|
|
"node_id": "node-123",
|
|
"canonical_uri": "nap://napseer/project-1/node-123",
|
|
"project_id": "project-1",
|
|
"project_name": "napseer",
|
|
"lookup_kind": "path",
|
|
"lookup_value": "/documentation/product/prd",
|
|
"legacy_full_path": "/documentation/product/prd",
|
|
}
|
|
assert result["route"]["full_path"] == "/documentation/product/prd"
|
|
assert "content_text" not in result["route"]
|
|
assert "preview" not in result["route"]
|
|
assert "read_fingerprint" not in result["route"]
|
|
assert "Path lookup is index/route resolution only" in result["warnings"][0]
|
|
|
|
|
|
def test_node_by_path_resolves_project_anchor_shortcut(mod):
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert path.endswith("/nodes/by-path?path=%40project")
|
|
return {
|
|
"identity": {
|
|
"node_id": "anchor-1",
|
|
"canonical_uri": "nap://napseer/project-1/anchor-1",
|
|
"project_id": "project-1",
|
|
"lookup_kind": "shortcut",
|
|
"lookup_value": "@project",
|
|
"legacy_full_path": "/routes/project/core",
|
|
},
|
|
"route": {
|
|
"id": "anchor-1",
|
|
"project_id": "project-1",
|
|
"full_path": "/routes/project/core",
|
|
},
|
|
}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.node_by_path({"shortcut": "@Project"})
|
|
|
|
assert result["identity"]["lookup_kind"] == "shortcut"
|
|
assert result["identity"]["lookup_value"] == "@project"
|
|
assert result["route"]["full_path"] == "/routes/project/core"
|
|
|
|
|
|
def test_path_resolver_then_read_uses_node_id_route(mod):
|
|
calls = []
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
calls.append(path)
|
|
assert method == "GET"
|
|
if "/nodes/by-path?" in path:
|
|
assert "view=" not in path
|
|
return {
|
|
"identity": {"node_id": "node-123", "project_id": "project-1"},
|
|
"route": {"id": "node-123", "project_id": "project-1", "full_path": "/documentation/product/prd"},
|
|
}
|
|
assert path == "/v1/projects/project-1/nodes/node-123"
|
|
return {**node("/documentation/product/prd", "active", node_type="product_spec"), "id": "node-123"}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.read_node_by_path({"path": "/documentation/product/prd"})
|
|
assert result["id"] == "node-123"
|
|
assert calls == [
|
|
"/v1/projects/project-1/nodes/by-path?path=%2Fdocumentation%2Fproduct%2Fprd",
|
|
"/v1/projects/project-1/nodes/node-123",
|
|
]
|
|
|
|
|
|
def test_node_by_id_returns_canonical_identity_uri(mod):
|
|
mod.AUTH["project_slug"] = "napseer"
|
|
found = node("/documentation/product/prd", "active", node_type="product_spec")
|
|
found["id"] = "node-123"
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert path == "/v1/projects/project-1/nodes/node-123"
|
|
return found
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.node_by_id({"node_id": "node-123"})
|
|
assert result["ok"] is True
|
|
assert result["identity"]["lookup_kind"] == "node_id"
|
|
assert result["identity"]["canonical_uri"] == "nap://napseer/project-1/node-123"
|
|
assert result["node"]["id"] == "node-123"
|
|
|
|
|
|
def test_node_by_uri_resolves_canonical_uri(mod):
|
|
mod.AUTH["project_slug"] = "napseer"
|
|
found = node("/documentation/product/prd", "active", node_type="product_spec")
|
|
found["id"] = "node-123"
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert path == "/v1/projects/project-1/nodes/node-123"
|
|
return found
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.node_by_uri({"uri": "nap://napseer/project-1/node-123"})
|
|
assert result["ok"] is True
|
|
assert result["identity"]["lookup_kind"] == "uri"
|
|
assert result["identity"]["lookup_value"] == "nap://napseer/project-1/node-123"
|
|
assert result["identity"]["canonical_uri"] == "nap://napseer/project-1/node-123"
|
|
|
|
|
|
def test_node_get_accepts_node_id_without_path(mod):
|
|
found = node("/documentation/product/prd", "active", node_type="product_spec")
|
|
found["id"] = "node-123"
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert path == "/v1/projects/project-1/nodes/node-123"
|
|
return found
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.node_get({"node_id": "node-123", "view": "content"})
|
|
assert result["id"] == "node-123"
|
|
assert result["full_path"] == "/documentation/product/prd"
|
|
|
|
|
|
def test_node_get_edit_view_uses_id_route_not_by_path(mod):
|
|
edit_payload = {
|
|
"node": node("/documentation/product/prd", "active", node_type="product_spec"),
|
|
"revision": "2026-05-06T12:00:00Z",
|
|
"read_fingerprint": "fingerprint-1",
|
|
"content_lines": ["body text"],
|
|
"line_count": 1,
|
|
"ends_with_newline": False,
|
|
}
|
|
edit_payload["node"]["id"] = "node-123"
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert path == "/v1/projects/project-1/nodes/node-123?view=edit"
|
|
return edit_payload
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.node_get({"node_id": "node-123", "view": "edit"})
|
|
assert result["revision"] == "2026-05-06T12:00:00Z"
|
|
assert result["node"]["id"] == "node-123"
|
|
|
|
|
|
def test_node_get_rejects_path_reads(mod):
|
|
try:
|
|
mod.node_get({"path": "/documentation/product/prd", "view": "content"})
|
|
except RuntimeError as exc:
|
|
assert "path is an index/route only" in str(exc)
|
|
else:
|
|
raise AssertionError("path reads should be rejected")
|
|
|
|
|
|
def test_tools_include_node_by_path_descriptor(mod):
|
|
tool_by_name = {item["name"]: item for item in mod.tools()}
|
|
assert len(tool_by_name) == 60
|
|
assert len(tool_by_name) == len(mod.tools())
|
|
|
|
get_tool = next(item for item in mod.tools() if item["name"] == "nap_node_get")
|
|
assert get_tool["inputSchema"].get("required") is None
|
|
assert "node_id" in get_tool["inputSchema"]["properties"]
|
|
assert "uri" in get_tool["inputSchema"]["properties"]
|
|
assert "path" not in get_tool["inputSchema"]["properties"]
|
|
assert "node_id returned by nap_discover" in get_tool["description"]
|
|
|
|
discover_tool = next(item for item in mod.tools() if item["name"] == "nap_discover")
|
|
assert "node_id on every row" in discover_tool["description"]
|
|
assert "directly to nap_node_get" in discover_tool["description"]
|
|
|
|
tool = next(item for item in mod.tools() if item["name"] == "nap_node_by_path")
|
|
assert tool["inputSchema"].get("required") is None
|
|
assert tool["inputSchema"]["oneOf"] == [
|
|
{"required": ["path"]},
|
|
{"required": ["shortcut"]},
|
|
]
|
|
assert "shortcut" in tool["inputSchema"]["properties"]
|
|
assert "preview_chars" not in tool["inputSchema"]["properties"]
|
|
assert "does not read full node content" in tool["description"]
|
|
|
|
for hidden_name in [
|
|
"nap_cat",
|
|
"nap_node_by_id",
|
|
"nap_node_by_uri",
|
|
"nap_tee",
|
|
"nap_patch",
|
|
"nap_find",
|
|
"nap_index_sync",
|
|
"nap_recent",
|
|
"nap_find_related",
|
|
"nap_backlinks",
|
|
"nap_ls_folders",
|
|
"nap_ls_tags",
|
|
]:
|
|
assert hidden_name not in tool_by_name
|
|
|
|
for name in ["nap_discover", "nap_context"]:
|
|
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
|
|
view = schema["properties"].get("view")
|
|
if view:
|
|
assert "full" not in view["enum"]
|
|
|
|
for name in ["nap_node_patch", "nap_rm"]:
|
|
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
|
|
assert "node_id" in schema["properties"]
|
|
assert "uri" in schema["properties"]
|
|
assert "path" not in schema["properties"]
|
|
|
|
bulk_node_schema = next(item for item in mod.tools() if item["name"] == "nap_bulk")["inputSchema"]["properties"]["nodes"]["items"]
|
|
assert bulk_node_schema.get("required") is None
|
|
assert "path" in bulk_node_schema["properties"]
|
|
assert "node_id" in bulk_node_schema["properties"]
|
|
assert "uri" in bulk_node_schema["properties"]
|
|
|
|
for required_name in [
|
|
"nap_library_ls",
|
|
"nap_library_attach",
|
|
"nap_library_detach",
|
|
"nap_library_nodes",
|
|
"nap_library_node_get",
|
|
"nap_auth_refresh",
|
|
"nap_node_restore",
|
|
"nap_plan_transition",
|
|
"nap_index_status",
|
|
]:
|
|
assert required_name in tool_by_name
|
|
|
|
|
|
def test_mutation_tools_reject_path_and_patch_by_node_id(mod):
|
|
found = node("/notes/current", "active", node_type="note")
|
|
found["id"] = "node-123"
|
|
writes = []
|
|
|
|
mod.get_node_by_id = lambda args: found
|
|
mod.request_project_write = lambda method, path, payload, *args, **kwargs: writes.append((method, path, payload, args)) or {
|
|
**found,
|
|
"content_text": payload.get("content_text", found["content_text"]) if payload else found["content_text"],
|
|
}
|
|
mod.index_node = lambda node: None
|
|
mod.remove_indexed_node = lambda node_id: None
|
|
|
|
updated = mod.update_node_by_path({"node_id": "node-123", "content_text": "updated"})
|
|
assert updated["updated"] is True
|
|
assert writes[-1][0] == "PATCH"
|
|
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123"
|
|
assert writes[-1][3][2:] == ("node", "node-123")
|
|
|
|
patched = mod.guarded_node_patch({
|
|
"node_id": "node-123",
|
|
"precondition": {"revision": "r1", "read_fingerprint": "fp1"},
|
|
"set": {"metadata": {"status": "active"}},
|
|
})
|
|
assert patched["id"] == "node-123"
|
|
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123/patch"
|
|
|
|
archived = mod.archive_node_by_path({"node_id": "node-123"})
|
|
assert archived["archived"] is True
|
|
assert writes[-1][0] == "DELETE"
|
|
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123"
|
|
|
|
with pytest.raises(RuntimeError, match="path is an index/route only"):
|
|
mod.update_node_by_path({"path": "/notes/current", "content_text": "bad"})
|
|
|
|
|
|
def test_graph_tools_use_node_identity_instead_of_paths(mod):
|
|
source = node("/notes/source", "active", node_type="note")
|
|
source["id"] = "source-123"
|
|
target = node("/notes/target", "active", node_type="note")
|
|
target["id"] = "target-123"
|
|
writes = []
|
|
reads = []
|
|
|
|
def fake_get_node_by_id(args):
|
|
reads.append(args)
|
|
node_id = args.get("node_id")
|
|
if node_id == "source-123":
|
|
return source
|
|
if node_id == "target-123":
|
|
return target
|
|
raise AssertionError(f"unexpected node_id {node_id}")
|
|
|
|
def fake_write(method, path, payload, *args, **kwargs):
|
|
writes.append((method, path, payload, args))
|
|
return {**source, "full_path": "/notes/source-renamed" if payload.get("name") == "source-renamed" else source["full_path"], "links": payload.get("links", source["links"])}
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert path == "/v1/projects/project-1/nodes/target-123/backlinks"
|
|
return {"items": [source]}
|
|
|
|
mod.get_node_by_id = fake_get_node_by_id
|
|
mod.request_project_write = fake_write
|
|
mod.request_json = fake_request
|
|
mod.index_node = lambda node: None
|
|
mod.inbound_reference_plan = lambda path: [{"id": "other-1", "path": "/notes/other"}]
|
|
mod.remove_indexed_node = lambda node_id: None
|
|
|
|
linked = mod.link_nodes({"source_node_id": "source-123", "target_node_id": "target-123", "relation": "references"})
|
|
assert linked["created"] is True
|
|
assert writes[-1][0] == "PATCH"
|
|
assert writes[-1][1] == "/v1/projects/project-1/nodes/source-123"
|
|
assert writes[-1][2]["links"] == [{"path": "/notes/target", "relation": "references"}]
|
|
assert writes[-1][3][2:] == ("node", "source-123")
|
|
|
|
backlinks = mod.get_backlinks_by_path({"node_id": "target-123"})
|
|
assert backlinks["target"]["id"] == "target-123"
|
|
|
|
moved = mod.move_node({"node_id": "source-123", "new_path": "/notes/source-renamed"})
|
|
assert moved["moved"] is True
|
|
assert writes[-1][1] == "/v1/projects/project-1/nodes/source-123"
|
|
assert writes[-1][2]["folder_path"] == "/notes"
|
|
assert writes[-1][2]["name"] == "source-renamed"
|
|
assert writes[-1][3][2:] == ("node", "source-123")
|
|
|
|
with pytest.raises(RuntimeError, match="paths are index/routes only"):
|
|
mod.link_nodes({"source_path": "/notes/source", "target_path": "/notes/target"})
|
|
with pytest.raises(RuntimeError, match="path is an index/route only"):
|
|
mod.get_backlinks_by_path({"path": "/notes/target"})
|
|
with pytest.raises(RuntimeError, match="path is an index/route only"):
|
|
mod.move_node({"path": "/notes/source", "new_path": "/notes/source-renamed"})
|
|
|
|
|
|
def test_tee_and_bulk_create_by_path_update_by_identity(mod):
|
|
existing = node("/notes/current", "active", node_type="note")
|
|
existing["id"] = "node-123"
|
|
writes = []
|
|
|
|
mod.try_get_node_by_path = lambda path, allow_agent=False: existing if path == "/notes/current" else None
|
|
mod.get_node_by_id = lambda args: existing
|
|
mod.request_project_write = lambda method, path, payload, *args, **kwargs: writes.append((method, path, payload, args)) or {
|
|
**existing,
|
|
"id": "node-123" if method == "PATCH" else "node-new",
|
|
"full_path": "/notes/new" if method == "POST" else existing["full_path"],
|
|
"content_text": payload.get("content_text", existing["content_text"]),
|
|
}
|
|
mod.index_node = lambda node: None
|
|
mod.acquire_project_lock = lambda args, **kwargs: {"id": "lock-1", "lease_token": "lease-1"}
|
|
mod.lock_headers = lambda lock: {"X-Lock": lock["id"]}
|
|
mod.release_project_lock = lambda args, **kwargs: None
|
|
mod.request_json = lambda method, path, payload=None, **kwargs: writes.append((method, path, payload, ())) or {
|
|
**existing,
|
|
"content_text": payload.get("content_text", existing["content_text"]) if payload else existing["content_text"],
|
|
}
|
|
|
|
created = mod.upsert_node({"path": "/notes/new", "content_text": "new"})
|
|
assert created["created"] is True
|
|
assert writes[-1][0] == "POST"
|
|
|
|
updated = mod.upsert_node({"node_id": "node-123", "content_text": "updated"})
|
|
assert updated["updated"] is True
|
|
assert writes[-1][0] == "PATCH"
|
|
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123"
|
|
|
|
compatibility_update = mod.upsert_node(
|
|
{"path": "/notes/current", "content_text": "compatibility update"}
|
|
)
|
|
assert compatibility_update["updated"] is True
|
|
assert writes[-1][0] == "PATCH"
|
|
|
|
bulk = mod.bulk_upsert_nodes({"nodes": [{"node_id": "node-123", "content_text": "bulk update"}]})
|
|
assert bulk["items"][0]["created"] is False
|
|
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123"
|
|
|
|
compatibility_bulk_update = mod.bulk_upsert_nodes(
|
|
{"nodes": [{"path": "/notes/current", "content_text": "compatibility update"}]}
|
|
)
|
|
assert compatibility_bulk_update["items"][0]["created"] is False
|
|
assert writes[-1][0] == "PATCH"
|
|
|
|
|
|
def test_agent_tools_create_by_route_and_update_by_identity(mod):
|
|
agent_file = node("/agents/alice/memory/current", "active", node_type="agent-file")
|
|
agent_file["id"] = "agent-node-123"
|
|
target = node("/notes/target", "active", node_type="note")
|
|
target["id"] = "target-123"
|
|
writes = []
|
|
|
|
def fake_get_node_by_id(args):
|
|
node_id = args.get("node_id")
|
|
if node_id == "agent-node-123":
|
|
return agent_file
|
|
if node_id == "target-123":
|
|
return target
|
|
raise AssertionError(f"unexpected node_id {node_id}")
|
|
|
|
mod.get_node_by_id = fake_get_node_by_id
|
|
mod.try_get_node_by_path = lambda path, allow_agent=False: agent_file if path == "/agents/alice/memory/current" else None
|
|
mod.request_project_write = lambda method, path, payload, *args, **kwargs: writes.append((method, path, payload, args)) or {
|
|
**agent_file,
|
|
"id": "agent-node-new" if method == "POST" else agent_file["id"],
|
|
"full_path": f"{payload.get('folder_path')}/{payload.get('name')}" if method == "POST" else agent_file["full_path"],
|
|
"content_text": payload.get("content_text", agent_file["content_text"]) if payload else agent_file["content_text"],
|
|
"links": payload.get("links", agent_file["links"]) if payload else agent_file["links"],
|
|
}
|
|
mod.index_node = lambda node: None
|
|
mod.remove_indexed_node = lambda node_id: None
|
|
|
|
read = mod.get_agent_node({"agent_slug": "alice", "node_id": "agent-node-123"})
|
|
assert read["id"] == "agent-node-123"
|
|
|
|
created = mod.upsert_agent_node({"agent_slug": "alice", "path": "/memory/new", "content_text": "new"})
|
|
assert created["created"] is True
|
|
assert writes[-1][0] == "POST"
|
|
assert writes[-1][2]["folder_path"] == "/agents/alice/memory"
|
|
|
|
updated = mod.upsert_agent_node({"agent_slug": "alice", "node_id": "agent-node-123", "content_text": "updated"})
|
|
assert updated["updated"] is True
|
|
assert writes[-1][1] == "/v1/projects/project-1/nodes/agent-node-123"
|
|
assert writes[-1][3][2:] == ("node", "agent-node-123")
|
|
|
|
patched = mod.update_agent_node({"agent_slug": "alice", "node_id": "agent-node-123", "content_text": "patched"})
|
|
assert patched["updated"] is True
|
|
|
|
archived = mod.archive_agent_node({"agent_slug": "alice", "node_id": "agent-node-123"})
|
|
assert archived["archived"] is True
|
|
assert writes[-1][0] == "DELETE"
|
|
assert writes[-1][3][2:] == ("node", "agent-node-123")
|
|
|
|
linked = mod.link_agent_node({"agent_slug": "alice", "source_node_id": "agent-node-123", "target_node_id": "target-123"})
|
|
assert linked["created"] is True
|
|
assert writes[-1][2]["links"] == [{"path": "/notes/target", "relation": "references"}]
|
|
assert writes[-1][3][2:] == ("node", "agent-node-123")
|
|
|
|
with pytest.raises(RuntimeError, match="path already resolves"):
|
|
mod.upsert_agent_node({"agent_slug": "alice", "path": "/memory/current", "content_text": "bad"})
|
|
with pytest.raises(RuntimeError, match="path is an index/route only"):
|
|
mod.update_agent_node({"agent_slug": "alice", "path": "/memory/current", "content_text": "bad"})
|
|
with pytest.raises(RuntimeError, match="paths are index/routes only"):
|
|
mod.link_agent_node({"agent_slug": "alice", "source_path": "/memory/current", "target_path": "/notes/target"})
|
|
|
|
|
|
def test_context_and_related_use_identity_inputs(mod):
|
|
root = node("/notes/root", "active", node_type="note", links=[{"path": "/notes/linked", "relation": "references"}], tags=["topic"])
|
|
root["id"] = "root-123"
|
|
linked = node("/notes/linked", "active", node_type="note", tags=["topic"])
|
|
linked["id"] = "linked-123"
|
|
backlink = node("/notes/backlink", "active", node_type="note")
|
|
backlink["id"] = "backlink-123"
|
|
reads = []
|
|
|
|
def fake_get_node_by_id(args):
|
|
reads.append(args)
|
|
node_id = args.get("node_id")
|
|
if node_id == "root-123":
|
|
return root
|
|
if node_id == "linked-123":
|
|
return linked
|
|
raise AssertionError(f"unexpected node_id {node_id}")
|
|
|
|
def fake_read_by_path(args, allow_agent=False):
|
|
if args["path"] == "/notes/root":
|
|
return root
|
|
if args["path"] == "/notes/linked":
|
|
return linked
|
|
if args["path"] == "/notes/backlink":
|
|
return backlink
|
|
raise RuntimeError("HTTP 404")
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
if "/nodes/root-123/neighborhood?" in path:
|
|
return {
|
|
"nodes": [
|
|
{"id": "root-123", "full_path": "/notes/root", "title": "root", "depth": 0},
|
|
{"id": "linked-123", "full_path": "/notes/linked", "title": "linked", "depth": 1},
|
|
{"id": "backlink-123", "full_path": "/notes/backlink", "title": "backlink", "depth": 1},
|
|
],
|
|
"edges": [
|
|
{"source": "root-123", "target": "linked-123", "relation": "references"},
|
|
{"source": "backlink-123", "target": "root-123", "relation": "references"},
|
|
],
|
|
"levels": [{"depth": 0, "node_ids": ["root-123"]}, {"depth": 1, "node_ids": ["linked-123", "backlink-123"]}],
|
|
"frontier": [],
|
|
"truncated": False,
|
|
}
|
|
if path == "/v1/projects/project-1/nodes/root-123/backlinks":
|
|
return {"items": [backlink]}
|
|
if path == "/v1/projects/project-1/nodes/linked-123/backlinks":
|
|
return {"items": []}
|
|
raise AssertionError(path)
|
|
|
|
mod.get_node_by_id = fake_get_node_by_id
|
|
mod.read_node_by_path = fake_read_by_path
|
|
mod.request_json = fake_request
|
|
mod.node_by_path = lambda args: {"identity": {"node_id": "root-123"}}
|
|
mod.list_project_nodes = lambda args: {"items": [root, linked]}
|
|
|
|
context = mod.get_memory_context({"node_id": "root-123", "depth": 1, "mode": "server"})
|
|
assert [item["full_path"] for item in context["items"]] == ["/notes/root", "/notes/linked", "/notes/backlink"]
|
|
assert {"source": "root-123", "target": "linked-123", "relation": "references"} in context["edges"]
|
|
assert {"source": "backlink-123", "target": "root-123", "relation": "references"} in context["edges"]
|
|
|
|
related = mod.find_related_nodes({"node_id": "root-123", "view": "paths"})
|
|
assert related["target"]["full_path"] == "/notes/root"
|
|
assert {item["full_path"] for item in related["items"]} >= {"/notes/backlink", "/notes/linked"}
|
|
|
|
path_context = mod.get_memory_context({"path": "/notes/root", "depth": 0, "mode": "server"})
|
|
assert path_context["items"][0]["full_path"] == "/notes/root"
|
|
with pytest.raises(RuntimeError, match="path is an index/route only"):
|
|
mod.find_related_nodes({"path": "/notes/root"})
|
|
|
|
|
|
def test_hybrid_context_resolves_path_inside_complete_local_snapshot(mod):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
state_dir = pathlib.Path(tmp)
|
|
mod.AUTH_DIR = state_dir
|
|
mod.INDEX_PATH = state_dir / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = state_dir / "index.lock"
|
|
indexed = node("/notes/local-root", "active", node_type="note")
|
|
indexed["id"] = "local-root-123"
|
|
mod.index_node(indexed)
|
|
mod.set_local_graph_index_complete("project-1", True)
|
|
|
|
def unexpected_request(*_args, **_kwargs):
|
|
raise AssertionError("complete local context must not make an HTTP request")
|
|
|
|
mod.request_json = unexpected_request
|
|
result = mod.get_memory_context({
|
|
"path": "/notes/local-root",
|
|
"depth": 0,
|
|
"mode": "hybrid",
|
|
})
|
|
|
|
assert result["source"] == "local_index"
|
|
assert result["seed_node_ids"] == ["local-root-123"]
|
|
assert result["items"][0]["full_path"] == "/notes/local-root"
|
|
with mod.index_connect() as conn:
|
|
plan = conn.execute(
|
|
"EXPLAIN QUERY PLAN SELECT node_id FROM local_index_nodes "
|
|
"WHERE project_id = ? AND full_path = ? AND archived = 0",
|
|
("project-1", "/notes/local-root"),
|
|
).fetchall()
|
|
assert any("local_index_nodes_project_path" in row["detail"] for row in plan)
|
|
|
|
|
|
def test_local_helper_ui_forms_submit_identity_for_existing_node_actions():
|
|
source = (pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py").read_text()
|
|
assert "name='node_id' value='{html.escape(item.get('id') or '')}'" in source
|
|
assert 'name="source_node_id"' in source
|
|
assert 'name="target_node_id"' in source
|
|
assert 'archive_node_by_path({"node_id": form.get("node_id", [""])[0]})' in source
|
|
assert '"source_node_id": form.get("source_node_id", [""])[0]' in source
|
|
assert '"target_node_id": form.get("target_node_id", [""])[0]' in source
|
|
assert 'name="source_path"' not in source
|
|
assert 'name="target_path"' not in source
|
|
|
|
|
|
def test_discovery_rejects_full_content_view(mod):
|
|
with pytest.raises(RuntimeError, match="full node bodies require nap_node_get"):
|
|
mod.list_project_nodes({"view": "full"})
|
|
|
|
|
|
def test_include_content_is_legacy_noop(mod):
|
|
found = node("/plans/found", "planned", content="body must not be returned")
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert "view=paths" in path
|
|
return {"items": [found], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.list_project_nodes({"include_content": True, "view": "paths"})
|
|
assert result["view"] == "paths"
|
|
assert "content_text" not in result["items"][0]
|
|
|
|
|
|
def test_recent_memory_defaults_to_paths(mod):
|
|
recent = node(
|
|
"/notes/recent-title",
|
|
"active",
|
|
metadata={"title": "Readable Recent Title", "priority": "high"},
|
|
tags=["recent"],
|
|
content="large body that should not appear in the default recent response",
|
|
node_type="note",
|
|
)
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert "/nodes?" in path
|
|
assert "view=paths" in path
|
|
return {"items": [recent], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.recent_memory({})
|
|
assert result["ok"] is True
|
|
assert result["view"] == "paths"
|
|
assert result["items"] == [{
|
|
"node_id": "id-recent-title",
|
|
"full_path": "/notes/recent-title",
|
|
"type": "note",
|
|
"status": "active",
|
|
"updated_at": "2026-05-06T12:00:00Z",
|
|
}]
|
|
assert "content_text" not in result["items"][0]
|
|
assert "metadata_summary" not in result["items"][0]
|
|
|
|
|
|
def test_recent_memory_accepts_date_aliases_and_returns_dates(mod):
|
|
recent = node(
|
|
"/notes/recent-title",
|
|
"active",
|
|
metadata={"date": "2026-05-06"},
|
|
tags=["recent"],
|
|
node_type="note",
|
|
)
|
|
recent["created_at"] = "2026-05-05T10:00:00Z"
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert "updated_after=2026-05-05T00%3A00%3A00Z" in path
|
|
assert "updated_before=2026-05-06T23%3A59%3A59Z" in path
|
|
return {"items": [recent], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.recent_memory({"since": "2026-05-05", "until": "2026-05-06"})
|
|
assert result["items"] == [{
|
|
"node_id": "id-recent-title",
|
|
"full_path": "/notes/recent-title",
|
|
"type": "note",
|
|
"status": "active",
|
|
"date": "2026-05-06",
|
|
"created_at": "2026-05-05T10:00:00Z",
|
|
"updated_at": "2026-05-06T12:00:00Z",
|
|
}]
|
|
|
|
|
|
def test_find_defaults_to_paths_and_requests_compact_rest_view(mod):
|
|
found = node(
|
|
"/plans/found",
|
|
"planned",
|
|
content="large body that should not be fetched by default",
|
|
)
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert "/nodes?" in path
|
|
assert "folder_path=%2Fplans" in path
|
|
assert "view=paths" in path
|
|
return {"items": [found], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.list_project_nodes({"folder_path": "/plans"})
|
|
assert result["ok"] is True
|
|
assert result["view"] == "paths"
|
|
assert result["items"] == [{
|
|
"node_id": "id-found",
|
|
"full_path": "/plans/found",
|
|
"type": "plan",
|
|
"status": "planned",
|
|
"updated_at": "2026-05-06T12:00:00Z",
|
|
}]
|
|
assert "content_text" not in result["items"][0]
|
|
|
|
|
|
def test_discover_uses_search_when_keywords_present(mod):
|
|
calls = []
|
|
|
|
def fake_search(args):
|
|
calls.append(args)
|
|
return {"ok": True, "items": [], "view": args.get("view")}
|
|
|
|
mod.search_memory = fake_search
|
|
result = mod.discover_memory({"keywords": "gateway relay", "since": "2026-05-01"})
|
|
assert result["tool_intent"] == "search"
|
|
assert result["range"]["updated_after"] == "2026-05-01T00:00:00Z"
|
|
assert calls and calls[-1]["q"] == "gateway relay"
|
|
assert calls[-1]["view"] == "summary"
|
|
assert "date" in result["awareness"]["date_fields_in_rows"]
|
|
assert "item.node_id" in result["awareness"]["next_step"]
|
|
assert "outside discovery" in result["awareness"]["next_step"]
|
|
|
|
|
|
def test_classify_query_points_exact_paths_to_context(mod):
|
|
result = mod.classify_memory_query({"q": "explain /documentation/security/overview since May", "since": "2026-05-01"})
|
|
assert result["classification"]["primary_tool"] == "nap_context"
|
|
assert result["classification"]["exact_paths"] == ["/documentation/security/overview"]
|
|
assert result["classification"]["range"]["updated_after"] == "2026-05-01T00:00:00Z"
|
|
|
|
|
|
def test_title_view_requests_summary_rest_view_without_content(mod):
|
|
found = node(
|
|
"/notes/titled",
|
|
"active",
|
|
metadata={"title": "Readable Title"},
|
|
content="large body that should not be fetched by title view",
|
|
)
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert "/nodes?" in path
|
|
assert "view=summary" in path
|
|
return {"items": [found], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.list_project_nodes({"view": "titles"})
|
|
assert result["ok"] is True
|
|
assert result["view"] == "titles"
|
|
assert result["items"] == [{
|
|
"node_id": "id-titled",
|
|
"title": "Readable Title",
|
|
"full_path": "/notes/titled",
|
|
}]
|
|
assert "content_text" not in result["items"][0]
|
|
|
|
|
|
def test_rest_query_string_serializes_booleans_lowercase(mod):
|
|
query = mod.rest_query_string({
|
|
"folder_path": "/kanban",
|
|
"include_archived": False,
|
|
"archived_only": False,
|
|
"active_only": True,
|
|
"status": ["todo", "doing"],
|
|
})
|
|
|
|
assert "include_archived=false" in query
|
|
assert "archived_only=false" in query
|
|
assert "active_only=true" in query
|
|
assert "include_archived=False" not in query
|
|
assert "active_only=True" not in query
|
|
assert "status=todo" in query
|
|
assert "status=doing" in query
|
|
|
|
|
|
def test_index_status_reports_sync_high_water_mark(mod):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
state_dir = pathlib.Path(tmp)
|
|
mod.AUTH_DIR = state_dir
|
|
mod.INDEX_PATH = state_dir / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = state_dir / "index.lock"
|
|
mod.index_node(node("/notes/old", "active", content="old body"))
|
|
|
|
status = mod.index_status({})
|
|
assert status["count"] == 1
|
|
assert status["latest_node_updated_at"] == "2026-05-06T12:00:00Z"
|
|
assert status["graph_complete"] is False
|
|
assert status["sync_recommended"] is True
|
|
|
|
|
|
def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
state_dir = pathlib.Path(tmp)
|
|
mod.AUTH_DIR = state_dir
|
|
mod.INDEX_PATH = state_dir / "index.sqlite"
|
|
mod.INDEX_LOCK_PATH = state_dir / "index.lock"
|
|
existing = node("/notes/existing", "active", content="existing body")
|
|
existing["id"] = "existing-node"
|
|
existing["updated_at"] = "2026-05-06T12:00:00Z"
|
|
mod.index_node(existing)
|
|
mod.set_local_graph_index_complete("project-1", True)
|
|
|
|
fresh = node("/notes/fresh", "active", content="fresh sync needle")
|
|
fresh["id"] = "fresh-node"
|
|
fresh["updated_at"] = "2026-05-06T12:05:00Z"
|
|
archived = node("/notes/existing", "active", content="archived body", archived_at="2026-05-06T12:06:00Z")
|
|
archived["id"] = "existing-node"
|
|
archived["updated_at"] = "2026-05-06T12:06:00Z"
|
|
calls = []
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
if "/changes?" in path:
|
|
# Server without the change feed: sync uses the timestamp path.
|
|
raise mod.SafeToolError("not_found", "no change feed", status=404)
|
|
calls.append(path)
|
|
assert method == "GET"
|
|
assert "view=full" in path
|
|
assert "include_archived=true" in path
|
|
assert "updated_after=2026-05-06T11%3A59%3A55Z" in path
|
|
return {"items": [fresh, archived], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.sync_local_index({"lookback_seconds": 5})
|
|
assert result["status"] == "synced"
|
|
assert result["seen"] == 2
|
|
assert result["indexed"] == 1
|
|
assert result["removed"] == 1
|
|
assert result["mode"] == "incremental"
|
|
assert result["index"]["graph_complete"] is True
|
|
assert calls
|
|
local = mod.search_local_index({"q": "fresh needle"})
|
|
assert [item["id"] for item in local["items"]] == ["fresh-node"]
|
|
assert mod.search_local_index({"q": "existing"})["items"] == []
|
|
|
|
|
|
def test_tools_include_read_only_index_status_descriptor(mod):
|
|
tool = next(item for item in mod.tools() if item["name"] == "nap_index_status")
|
|
assert "local search index" in tool["description"]
|
|
assert tool["inputSchema"]["properties"] == {}
|
|
contract = mod.tool_contract_metadata("nap_index_status")
|
|
assert contract["side_effect"] == "none"
|
|
assert contract["auth_mode"] == "local-only"
|
|
|
|
|
|
def test_kanban_list_grouping_and_filters(mod):
|
|
doing = node(
|
|
"/kanban/doing/fix-wrapper",
|
|
"doing",
|
|
metadata={"column": "doing", "priority": "high", "owner": "codex", "blocked": True},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
todo = node(
|
|
"/kanban/todo/add-tests",
|
|
"todo",
|
|
metadata={"column": "todo", "priority": "normal", "owner": "codex", "blocked": False},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
done = node(
|
|
"/kanban/done/old-card",
|
|
"done",
|
|
metadata={"column": "done", "owner": "codex"},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
outside = node("/plans/not-kanban", "planned")
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert "q=" in path
|
|
assert "include_archived=false" in path
|
|
assert "archived_only=false" in path
|
|
return {"items": [doing, todo, done, outside], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.list_kanban_cards({"owner": "codex", "blocked": True, "group_by": "column"})
|
|
assert result["ok"] is True
|
|
assert result["kanban_root"] == "/kanban"
|
|
assert [item["full_path"] for item in result["items"]] == ["/kanban/doing/fix-wrapper"]
|
|
assert "content_text" not in result["items"][0]
|
|
assert result["items"][0]["column"] == "doing"
|
|
assert result["items"][0]["blocked"] is True
|
|
assert list(result["groups"]) == ["doing"]
|
|
|
|
|
|
def test_kanban_dependency_summary_filters_ready_and_blocking(mod):
|
|
plan = node(
|
|
"/kanban/backlog/plan",
|
|
"backlog",
|
|
metadata={"column": "backlog", "priority": "urgent", "blocked": False},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
links=[{"path": "/kanban/backlog/implementation", "relation": "blocks"}],
|
|
)
|
|
implementation = node(
|
|
"/kanban/todo/implementation",
|
|
"todo",
|
|
metadata={
|
|
"column": "todo",
|
|
"priority": "high",
|
|
"blocked_by": "/kanban/backlog/plan",
|
|
"external_blockers": [{"id": "deploy", "state": "open", "strength": "hard"}],
|
|
},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
links=[
|
|
{"path": "/kanban/backlog/plan", "relation": "blocked-by"},
|
|
{"path": "/kanban/backlog/context", "relation": "relates-to"},
|
|
{"path": "/kanban/backlog/child", "relation": "parent_of"},
|
|
{"path": "/kanban/backlog/canonical", "relation": "duplicate-of"},
|
|
],
|
|
)
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
return {"items": [plan, implementation], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
|
|
blocked = mod.list_kanban_cards({"dependency_view": "blocked", "limit": 10})
|
|
assert [item["full_path"] for item in blocked["items"]] == ["/kanban/todo/implementation"]
|
|
assert blocked["items"][0]["dependency_summary"]["hard_blockers"] == 1
|
|
assert blocked["items"][0]["dependency_summary"]["external_blockers"] == 1
|
|
assert blocked["items"][0]["hard_blockers"] == ["/kanban/backlog/plan"]
|
|
assert blocked["items"][0]["related"] == ["/kanban/backlog/context"]
|
|
assert blocked["items"][0]["children"] == ["/kanban/backlog/child"]
|
|
assert blocked["items"][0]["duplicates"] == ["/kanban/backlog/canonical"]
|
|
|
|
ready = mod.list_kanban_cards({"dependency_view": "ready", "limit": 10})
|
|
assert [item["full_path"] for item in ready["items"]] == ["/kanban/backlog/plan"]
|
|
|
|
blocking = mod.list_kanban_cards({"dependency_view": "blocking", "limit": 10})
|
|
assert blocking["items"][0]["blocking"] == ["/kanban/backlog/implementation"]
|
|
|
|
|
|
def test_kanban_list_reads_beyond_first_source_page(mod):
|
|
first_page = [
|
|
node(
|
|
"/kanban/todo/other-card",
|
|
"todo",
|
|
metadata={"column": "todo", "owner": "codex", "blocked": False},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
]
|
|
second_page = [
|
|
node(
|
|
"/kanban/doing/blocked-card",
|
|
"doing",
|
|
metadata={"column": "doing", "owner": "codex", "blocked": True},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
]
|
|
calls = []
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
calls.append(path)
|
|
assert method == "GET"
|
|
assert "q=%2Fkanban" in path
|
|
assert "limit=10000" in path
|
|
assert "include_archived=false" in path
|
|
assert "archived_only=false" in path
|
|
if "cursor=page-2" in path:
|
|
return {"items": second_page, "next_cursor": None}
|
|
return {"items": first_page, "next_cursor": "page-2"}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.list_kanban_cards({"blocked": True, "limit": 10})
|
|
assert [item["full_path"] for item in result["items"]] == ["/kanban/doing/blocked-card"]
|
|
assert result["truncated"] is False
|
|
assert len(calls) == 2
|
|
|
|
|
|
def test_kanban_workflow_reads_pending_and_pick_next(mod):
|
|
backlog_urgent = node(
|
|
"/kanban/backlog/later-urgent",
|
|
"backlog",
|
|
metadata={"column": "backlog", "priority": "urgent", "rank": "00001024", "blocked": False},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
todo_high = node(
|
|
"/kanban/todo/next-high",
|
|
"todo",
|
|
metadata={"column": "todo", "priority": "high", "rank": "00002048", "blocked": False},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
todo_blocked = node(
|
|
"/kanban/todo/blocked",
|
|
"todo",
|
|
metadata={"column": "todo", "priority": "urgent", "rank": "00001024", "blocked": True},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
return {"items": [backlog_urgent, todo_high, todo_blocked], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
pending = mod.get_pending_kanban_cards({})
|
|
assert [item["full_path"] for item in pending["items"]] == ["/kanban/todo/next-high", "/kanban/backlog/later-urgent"]
|
|
|
|
picked = mod.pick_next_kanban_card({})
|
|
assert picked["selected"]["full_path"] == "/kanban/todo/next-high"
|
|
assert picked["selection"]["blocked_excluded"] is True
|
|
|
|
|
|
def test_kanban_workflow_create_move_and_block(mod):
|
|
writes = []
|
|
existing = node(
|
|
"/kanban/todo/add-mcp-commands",
|
|
"todo",
|
|
metadata={"column": "todo", "priority": "normal", "rank": "00001024", "blocked": False, "title": "Add MCP commands"},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
|
|
mod.list_kanban_cards = lambda args: {"items": []}
|
|
mod.request_project_write = lambda method, path, payload, *args, **kwargs: writes.append((method, path, payload)) or {
|
|
**existing,
|
|
"id": "id-add-mcp-commands",
|
|
"full_path": f"{payload.get('folder_path', '/kanban/todo')}/{payload.get('name', 'add-mcp-commands')}".replace("//", "/"),
|
|
"folder_path": payload.get("folder_path", "/kanban/todo"),
|
|
"name": payload.get("name", "add-mcp-commands"),
|
|
"metadata": payload.get("metadata", existing["metadata"]),
|
|
"content_text": payload.get("content_text", existing["content_text"]),
|
|
"tags": payload.get("tags", existing["tags"]),
|
|
}
|
|
mod.get_node_by_id = lambda args: {**existing, "id": "id-add-mcp-commands"}
|
|
mod.index_node = lambda node: None
|
|
mod.remove_indexed_node = lambda node_id: None
|
|
|
|
created = mod.create_kanban_card({"title": "Add MCP commands", "priority": "HIGH"})
|
|
assert created["created"] is True
|
|
assert "node" not in created
|
|
assert writes[0][0] == "POST"
|
|
assert writes[0][2]["folder_path"] == "/kanban/todo"
|
|
assert writes[0][2]["metadata"]["priority"] == "high"
|
|
assert writes[0][2]["metadata"]["rank"] == "00001024"
|
|
|
|
moved = mod.move_kanban_card({"node_id": "id-add-mcp-commands"}, "doing")
|
|
assert moved["to"] == "/kanban/doing/add-mcp-commands"
|
|
assert "node" not in moved
|
|
assert writes[1][0] == "PATCH"
|
|
assert writes[1][2]["folder_path"] == "/kanban/doing"
|
|
assert writes[1][2]["metadata"]["column"] == "doing"
|
|
assert writes[1][2]["metadata"]["lifecycle_state"] == "active"
|
|
|
|
blocked = mod.block_kanban_card({"node_id": "id-add-mcp-commands", "blocked_reason": "waiting"}, True)
|
|
assert blocked["updated"] is True
|
|
assert "node" not in blocked
|
|
assert writes[2][2]["metadata"]["blocked"] is True
|
|
assert writes[2][2]["metadata"]["blocked_by"] == "waiting"
|
|
|
|
with pytest.raises(RuntimeError, match="path is an index/route only"):
|
|
mod.move_kanban_card({"path": "/kanban/todo/add-mcp-commands"}, "doing")
|
|
|
|
|
|
def test_kanban_create_deduplicates_overlapping_titles(mod):
|
|
writes = []
|
|
existing_cards = [
|
|
node("/kanban/todo/add-mcp-commands", "todo", metadata={"column": "todo"}, tags=["kanban"], node_type="kanban_card"),
|
|
node("/kanban/todo/add-mcp-commands-20260510123456", "todo", metadata={"column": "todo"}, tags=["kanban"], node_type="kanban_card"),
|
|
node("/kanban/doing/add-mcp-commands", "doing", metadata={"column": "doing"}, tags=["kanban"], node_type="kanban_card"),
|
|
]
|
|
|
|
mod.list_kanban_cards = lambda args: {"items": existing_cards if args.get("column") == "todo" else []}
|
|
mod.request_project_write = lambda method, path, payload, *args, **kwargs: writes.append((method, path, payload)) or {
|
|
**existing_cards[0],
|
|
"id": "id-deduped",
|
|
"full_path": f"{payload.get('folder_path')}/{payload.get('name')}",
|
|
"folder_path": payload.get("folder_path"),
|
|
"name": payload.get("name"),
|
|
"metadata": payload.get("metadata", {}),
|
|
"tags": payload.get("tags", []),
|
|
}
|
|
mod.index_node = lambda node: None
|
|
|
|
created = mod.create_kanban_card({"title": "Add MCP commands", "column": "todo", "now": "2026-05-10T12:34:56Z"})
|
|
assert created["path"] == "/kanban/todo/add-mcp-commands-20260510123456-2"
|
|
assert "node" not in created
|
|
assert writes[0][2]["name"] == "add-mcp-commands-20260510123456-2"
|
|
|
|
|
|
def test_lineage_status_reports_generic_plan_and_card_coverage(mod):
|
|
linked_plan = node("/plans/linked", "planned", links=[{"path": "/decisions/source", "relation": "implements"}])
|
|
unlinked_plan = node("/plans/unlinked", "planned")
|
|
linked_card = node(
|
|
"/kanban/todo/linked",
|
|
"todo",
|
|
metadata={"column": "todo"},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
links=[{"path": "/plans/linked", "relation": "derived-from"}],
|
|
)
|
|
unlinked_card = node(
|
|
"/kanban/todo/unlinked",
|
|
"todo",
|
|
metadata={"column": "todo"},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
|
|
def fake_list(args):
|
|
if args.get("folder_path") == "/plans":
|
|
return {"items": [linked_plan, unlinked_plan]}
|
|
if args.get("q") == "/kanban":
|
|
return {"items": [linked_card, unlinked_card, node("/notes/not-kanban", "active")]}
|
|
return {"items": []}
|
|
|
|
mod.list_project_nodes = fake_list
|
|
result = mod.lineage_status({})
|
|
assert result["status"] == "warnings"
|
|
assert result["counts"]["plans"] == 2
|
|
assert result["counts"]["plans_with_source_record"] == 1
|
|
assert result["counts"]["kanban_cards"] == 2
|
|
assert result["counts"]["kanban_cards_with_origin_plan"] == 1
|
|
assert [warning["type"] for warning in result["warnings"]] == [
|
|
"plan_missing_source_record",
|
|
"kanban_card_missing_origin_plan",
|
|
]
|
|
|
|
|
|
def test_plan_to_kanban_creates_card_with_origin_plan_link(mod):
|
|
writes = []
|
|
plan = node(
|
|
"/plans/generic-work",
|
|
"planned",
|
|
metadata={"title": "Generic Work", "priority": "high", "owner": "codex"},
|
|
tags=["plan"],
|
|
content="Implement a generic record to plan to kanban flow.",
|
|
)
|
|
|
|
mod.get_node_by_id = lambda args: plan
|
|
mod.list_kanban_cards = lambda args: {"items": []}
|
|
mod.request_project_write = lambda method, path, payload, *args, **kwargs: writes.append((method, path, payload)) or {
|
|
"id": "id-generic-work",
|
|
"project_id": "project-1",
|
|
"full_path": f"{payload.get('folder_path')}/{payload.get('name')}",
|
|
"folder_path": payload.get("folder_path"),
|
|
"name": payload.get("name"),
|
|
"type": payload.get("type"),
|
|
"metadata": payload.get("metadata", {}),
|
|
"tags": payload.get("tags", []),
|
|
"links": payload.get("links", []),
|
|
"content_text": payload.get("content_text", ""),
|
|
"updated_at": "2026-05-06T12:00:00Z",
|
|
}
|
|
mod.index_node = lambda node: None
|
|
|
|
created = mod.plan_to_kanban_card({"plan_node_id": plan["id"], "column": "todo"})
|
|
assert created["path"] == "/kanban/todo/generic-work"
|
|
assert created["source_plan_path"] == "/plans/generic-work"
|
|
assert writes[0][0] == "POST"
|
|
assert writes[0][2]["links"] == [{"path": "/plans/generic-work", "relation": "derived-from"}]
|
|
assert writes[0][2]["metadata"]["source_plan_path"] == "/plans/generic-work"
|
|
assert writes[0][2]["metadata"]["priority"] == "high"
|
|
assert writes[0][2]["metadata"]["owner"] == "codex"
|
|
|
|
|
|
def test_plan_lifecycle_dry_run_and_write(mod):
|
|
plan = node("/plans/example", "in_progress", tags=["plan"])
|
|
plan["id"] = "plan-123"
|
|
outcome = node("/implementation-notes/example", "completed", node_type="implementation-note")
|
|
outcome["id"] = "outcome-123"
|
|
writes = []
|
|
|
|
def fake_get_by_id(args):
|
|
if args["node_id"] == plan["id"]:
|
|
return plan
|
|
if args["node_id"] == outcome["id"]:
|
|
return outcome
|
|
raise AssertionError(f"unexpected node id {args['node_id']}")
|
|
|
|
mod.get_node_by_id = fake_get_by_id
|
|
mod.update_node_by_path = lambda args: writes.append(("update", args)) or {"node": {**plan, **args}}
|
|
mod.archive_node_by_path = lambda args: writes.append(("archive", args)) or {"archived": True}
|
|
|
|
dry = mod.complete_plan({"plan_node_id": plan["id"], "outcome_node_id": outcome["id"], "dry_run": True})
|
|
assert dry["changed"] is True
|
|
assert dry["dry_run"] is True
|
|
assert dry["status"] == "completed"
|
|
assert "archive node" in dry["planned_changes"]
|
|
assert writes == []
|
|
|
|
written = mod.complete_plan({"plan_node_id": plan["id"], "outcome_node_id": outcome["id"], "reason": "implemented"})
|
|
assert written["changed"] is True
|
|
assert written["archived"] is True
|
|
assert writes[0][0] == "update"
|
|
assert writes[0][1]["metadata"]["status"] == "completed"
|
|
assert writes[0][1]["metadata"]["outcome_path"] == "/implementation-notes/example"
|
|
assert "completed" in writes[0][1]["tags"]
|
|
assert "archived" in writes[0][1]["tags"]
|
|
assert writes[0][1]["links"] == [{"path": "/implementation-notes/example", "relation": "implemented-by"}]
|
|
assert writes[1][0] == "archive"
|
|
assert writes[1][1]["node_id"] == plan["id"]
|
|
|
|
with pytest.raises(RuntimeError, match="path is an index/route only"):
|
|
mod.complete_plan({"path": "/plans/example", "outcome_path": "/implementation-notes/example"})
|
|
|
|
|
|
def test_plan_lifecycle_archived_idempotent(mod):
|
|
outcome = node("/implementation-notes/example", "completed", node_type="implementation-note")
|
|
outcome["id"] = "outcome-123"
|
|
archived = node(
|
|
"/plans/example",
|
|
"completed",
|
|
metadata={"outcome_path": "/implementation-notes/example"},
|
|
tags=["plan", "completed", "archived"],
|
|
archived_at="2026-05-06T12:01:00Z",
|
|
)
|
|
archived["id"] = "plan-123"
|
|
archived["links"] = [{"path": "/implementation-notes/example", "relation": "implemented-by"}]
|
|
|
|
def fake_get_by_id(args):
|
|
if args["node_id"] == outcome["id"]:
|
|
return outcome
|
|
raise RuntimeError("HTTP 404")
|
|
|
|
mod.get_node_by_id = fake_get_by_id
|
|
mod.list_project_nodes = lambda args: {"items": [archived]}
|
|
result = mod.complete_plan({"plan_node_id": archived["id"], "outcome_node_id": outcome["id"]})
|
|
assert result["ok"] is True
|
|
assert result["changed"] is False
|
|
assert result["archived"] is True
|
|
|
|
|
|
def test_tool_registry(mod):
|
|
names = {tool["name"] for tool in mod.raw_tools()}
|
|
for name in [
|
|
"nap_plan_list_active",
|
|
"nap_lineage_status",
|
|
"nap_kanban_list",
|
|
"nap_kanban_get_pending",
|
|
"nap_kanban_pick_next",
|
|
"nap_kanban_start",
|
|
"nap_kanban_complete",
|
|
"nap_plan_complete",
|
|
"nap_plan_supersede",
|
|
"nap_plan_cancel",
|
|
"nap_plan_to_kanban",
|
|
]:
|
|
assert name in names
|
|
assert mod.tool_contract_metadata("nap_plan_complete")["dry_run_supported"] is True
|
|
assert mod.tool_contract_metadata("nap_plan_list_active")["side_effect"] == "none"
|
|
assert mod.tool_contract_metadata("nap_lineage_status")["side_effect"] == "none"
|
|
assert mod.tool_contract_metadata("nap_kanban_get_pending")["side_effect"] == "none"
|
|
assert mod.tool_contract_metadata("nap_plan_to_kanban")["side_effect"] == "remote-write"
|
|
assert mod.tool_contract_metadata("nap_kanban_start")["side_effect"] == "remote-write"
|
|
|
|
|
|
def run():
|
|
mod = load_module()
|
|
test_active_plan_listing(mod)
|
|
mod = load_module()
|
|
test_node_discovery_allows_large_requested_limit(mod)
|
|
mod = load_module()
|
|
test_node_by_path_returns_canonical_identity_uri(mod)
|
|
mod = load_module()
|
|
test_node_by_id_returns_canonical_identity_uri(mod)
|
|
mod = load_module()
|
|
test_node_by_uri_resolves_canonical_uri(mod)
|
|
mod = load_module()
|
|
test_node_get_accepts_node_id_without_path(mod)
|
|
mod = load_module()
|
|
test_node_get_rejects_path_reads(mod)
|
|
mod = load_module()
|
|
test_tools_include_node_by_path_descriptor(mod)
|
|
mod = load_module()
|
|
test_recent_memory_defaults_to_paths(mod)
|
|
mod = load_module()
|
|
test_find_defaults_to_paths_and_requests_compact_rest_view(mod)
|
|
mod = load_module()
|
|
test_title_view_requests_summary_rest_view_without_content(mod)
|
|
mod = load_module()
|
|
test_kanban_list_grouping_and_filters(mod)
|
|
mod = load_module()
|
|
test_kanban_dependency_summary_filters_ready_and_blocking(mod)
|
|
mod = load_module()
|
|
test_kanban_list_reads_beyond_first_source_page(mod)
|
|
mod = load_module()
|
|
test_kanban_workflow_reads_pending_and_pick_next(mod)
|
|
mod = load_module()
|
|
test_kanban_workflow_create_move_and_block(mod)
|
|
mod = load_module()
|
|
test_kanban_create_deduplicates_overlapping_titles(mod)
|
|
mod = load_module()
|
|
test_lineage_status_reports_generic_plan_and_card_coverage(mod)
|
|
mod = load_module()
|
|
test_plan_to_kanban_creates_card_with_origin_plan_link(mod)
|
|
mod = load_module()
|
|
test_plan_lifecycle_dry_run_and_write(mod)
|
|
mod = load_module()
|
|
test_plan_lifecycle_archived_idempotent(mod)
|
|
mod = load_module()
|
|
test_tool_registry(mod)
|
|
print("ok: MCP memory ergonomics helpers smoke passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run()
|