Skip to content

Commit 0a3a6bb

Browse files
authored
test(mcp): integration coverage for colliding observation permalinks (#926)
Refs #909. Integration coverage over the #931 fix. Signed-off-by: phernandez <paul@basicmemory.com>
1 parent 7bb7664 commit 0a3a6bb

1 file changed

Lines changed: 141 additions & 0 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Regression tests for https://github.com/basicmachines-co/basic-memory/issues/909.
2+
3+
Observation content is truncated to 200 chars when building the synthetic
4+
permalink (Postgres btree index limit), so distinct observations sharing a
5+
category and a 200-char content prefix used to collide and the second one was
6+
silently dropped from the search index, making it unfindable. #931 fixed this
7+
by appending a content digest to the truncated permalink.
8+
9+
These tests pin the end-to-end behavior independent of the disambiguation
10+
mechanism: every observation stays searchable, and deleting the note removes
11+
every index row — including rows whose permalinks needed disambiguation.
12+
"""
13+
14+
import json
15+
from typing import Any
16+
17+
import pytest
18+
from fastmcp import Client
19+
20+
21+
def _json_content(tool_result) -> Any:
22+
"""Parse a FastMCP tool result content block into JSON."""
23+
assert len(tool_result.content) == 1
24+
assert tool_result.content[0].type == "text"
25+
return json.loads(tool_result.content[0].text) # pyright: ignore [reportAttributeAccessIssue]
26+
27+
28+
@pytest.mark.asyncio
29+
async def test_duplicate_category_content_observations_both_searchable(
30+
mcp_server, app, test_project
31+
):
32+
"""Both observations must be indexed even when their synthetic permalinks collide."""
33+
async with Client(mcp_server) as client:
34+
prefix = "x" * 210 # > 200 so the truncated permalink prefixes are identical
35+
content = (
36+
"# Dup Obs Note\n\n"
37+
"## Observations\n"
38+
f"- [note] {prefix} ALPHA_UNIQUE_MARKER\n"
39+
f"- [note] {prefix} BETA_UNIQUE_MARKER\n"
40+
)
41+
await client.call_tool(
42+
"write_note",
43+
{
44+
"project": test_project.name,
45+
"title": "Dup Obs Note",
46+
"directory": "dup",
47+
"content": content,
48+
},
49+
)
50+
51+
# Both observations should be independently findable by their unique suffix
52+
for marker in ("ALPHA_UNIQUE_MARKER", "BETA_UNIQUE_MARKER"):
53+
result = await client.call_tool(
54+
"search_notes",
55+
{
56+
"project": test_project.name,
57+
"query": marker,
58+
"search_type": "text",
59+
"entity_types": ["observation"],
60+
"output_format": "json",
61+
},
62+
)
63+
data = _json_content(result)
64+
snippets = [r.get("content") or "" for r in data["results"]]
65+
assert any(marker in s for s in snippets), (
66+
f"observation containing {marker} was dropped from the search index "
67+
"due to a synthetic-permalink collision (content truncated to 200 chars). "
68+
"results=" + json.dumps(data, default=str)[:800]
69+
)
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_delete_note_with_colliding_observations_leaves_no_ghost_rows(
74+
mcp_server, app, test_project, search_service
75+
):
76+
"""Deleting a note must clean up disambiguated observation index rows.
77+
78+
search_index has no FK cascade from entity, so any index-time permalink
79+
disambiguation must be matched by delete-time cleanup or the extra
80+
observation survives in the search index as a ghost row pointing at the
81+
deleted file.
82+
83+
The post-delete assertions inspect the index through ``search_service``
84+
rather than the ``search_notes`` tool: the MCP search pipeline happens to
85+
hide rows whose entity is gone, which would mask the orphan.
86+
"""
87+
from basic_memory.schemas.search import SearchQuery
88+
89+
async with Client(mcp_server) as client:
90+
prefix = "y" * 210 # > 200 so the truncated permalink prefixes are identical
91+
content = (
92+
"# Ghost Obs Note\n\n"
93+
"## Observations\n"
94+
f"- [note] {prefix} GHOST_ALPHA_MARKER\n"
95+
f"- [note] {prefix} GHOST_BETA_MARKER\n"
96+
)
97+
await client.call_tool(
98+
"write_note",
99+
{
100+
"project": test_project.name,
101+
"title": "Ghost Obs Note",
102+
"directory": "ghost",
103+
"content": content,
104+
},
105+
)
106+
107+
# Both observations are searchable before deletion
108+
for marker in ("GHOST_ALPHA_MARKER", "GHOST_BETA_MARKER"):
109+
result = await client.call_tool(
110+
"search_notes",
111+
{
112+
"project": test_project.name,
113+
"query": marker,
114+
"search_type": "text",
115+
"entity_types": ["observation"],
116+
"output_format": "json",
117+
},
118+
)
119+
data = _json_content(result)
120+
assert any(marker in (r.get("content") or "") for r in data["results"])
121+
122+
# Both rows exist in the search index itself under distinct permalinks
123+
index_rows = await search_service.search(SearchQuery(text="GHOST_BETA_MARKER"))
124+
assert any(r.type == "observation" for r in index_rows)
125+
126+
delete_result = await client.call_tool(
127+
"delete_note",
128+
{
129+
"project": test_project.name,
130+
"identifier": "Ghost Obs Note",
131+
},
132+
)
133+
assert "true" in delete_result.content[0].text.lower() # pyright: ignore [reportAttributeAccessIssue]
134+
135+
# No ghost rows remain in the index - including any disambiguated row
136+
for marker in ("GHOST_ALPHA_MARKER", "GHOST_BETA_MARKER"):
137+
index_rows = await search_service.search(SearchQuery(text=marker))
138+
assert index_rows == [], (
139+
f"search index row containing {marker} survived note deletion as a ghost: "
140+
f"{[(r.type, r.permalink) for r in index_rows]}"
141+
)

0 commit comments

Comments
 (0)