|
| 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