From 8ffc6be1ff43638d42f9fb9e364b4e87a6397591 Mon Sep 17 00:00:00 2001 From: wangzhengzhuo05 <175673456+wangzhengzhuo05@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:43:03 +0800 Subject: [PATCH 1/3] fix(mcp): report never-indexed projects instead of a silent empty search On a project whose last_indexed_at is NULL, search_notes and recent_activity returned an ordinary empty result, indistinguishable from an honest miss on an indexed project. Readiness already exists (ProjectIndexPhase/describe, used by bm status) but nothing under mcp/ read it. On the zero-result path only, read readiness via ProjectClient.get_status and, when the phase is NEVER_INDEXED, reuse ProjectIndexReadiness.describe so the wording cannot drift from bm status. search_notes JSON output carries index_phase; an unreadable readiness falls back to today's copy. Fixes #1534 Signed-off-by: wangzhengzhuo05 <175673456+wangzhengzhuo05@users.noreply.github.com> --- tests/mcp/test_tool_recent_activity.py | 50 ++++++++++++ tests/mcp/test_tool_search.py | 109 +++++++++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/tests/mcp/test_tool_recent_activity.py b/tests/mcp/test_tool_recent_activity.py index 9f69dcd82..6ce7acadb 100644 --- a/tests/mcp/test_tool_recent_activity.py +++ b/tests/mcp/test_tool_recent_activity.py @@ -695,3 +695,53 @@ async def test_recent_activity_entity_rows_include_external_id(client, test_grap assert any(re.search(uuid_pattern, line) for line in entity_lines), ( f"entity rows missing external_id: {entity_lines!r}" ) + + +@pytest.mark.asyncio +async def test_recent_activity_never_indexed_says_so( + client, test_project, session_maker, config_home +): + """A never-indexed project must not report an ordinary empty activity feed (#1534).""" + from sqlalchemy import text as sa_text + + from basic_memory import db + + # Trigger: files exist on disk but no index pass has ever completed. + notes_dir = config_home / "notes" + notes_dir.mkdir(exist_ok=True) + (notes_dir / "unindexed-note.md").write_text("# Unindexed Note\n\nNot yet indexed.\n") + async with db.scoped_session(session_maker) as session: + await session.execute( + sa_text("UPDATE project SET last_indexed_at = NULL WHERE id = :id"), + {"id": test_project.id}, + ) + + result = await recent_activity(project=test_project.name, timeframe="7d") + + assert isinstance(result, str) + assert "never been indexed" in result + assert "bm project index" in result + + +@pytest.mark.asyncio +async def test_recent_activity_indexed_empty_keeps_onboarding( + client, test_project, session_maker, config_home +): + """An indexed-but-quiet project keeps the original onboarding copy (#1534).""" + from datetime import datetime + + from sqlalchemy import text as sa_text + + from basic_memory import db + + async with db.scoped_session(session_maker) as session: + await session.execute( + sa_text("UPDATE project SET last_indexed_at = :now WHERE id = :id"), + {"now": datetime.now(), "id": test_project.id}, + ) + + result = await recent_activity(project=test_project.name, timeframe="7d") + + assert isinstance(result, str) + assert "No recent activity" in result + assert "never been indexed" not in result diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py index 21bee60a9..1acf62ef1 100644 --- a/tests/mcp/test_tool_search.py +++ b/tests/mcp/test_tool_search.py @@ -2281,3 +2281,112 @@ def test_search_notes_parse_str_list_rejects_non_string_list_elements_in_place() # All-string lists still work correctly. assert parse_str_list(["note", "task"]) == ["note", "task"] assert parse_str_list(["note,task"]) == ["note", "task"] + + +@pytest.mark.asyncio +async def test_search_never_indexed_text_says_so(client, test_project, session_maker, config_home): + """A never-indexed project must not report an ordinary empty result (#1534).""" + from sqlalchemy import text as sa_text + + from basic_memory import db + + # Trigger: files exist on disk but no index pass has ever completed. + notes_dir = config_home / "notes" + notes_dir.mkdir(exist_ok=True) + (notes_dir / "unindexed-note.md").write_text( + "# Unindexed Note\n\nNothing about this file can be found by search.\n" + ) + async with db.scoped_session(session_maker) as session: + await session.execute( + sa_text("UPDATE project SET last_indexed_at = NULL WHERE id = :id"), + {"id": test_project.id}, + ) + + response = await search_notes( + project=test_project.name, + query="unindexed-note-xyzzy", + search_type="text", + output_format="text", + ) + + assert isinstance(response, str) + assert "never been indexed" in response + assert "bm project index" in response + assert "Try broader or different terms" not in response + + +@pytest.mark.asyncio +async def test_search_never_indexed_json_carries_phase( + client, test_project, session_maker, config_home +): + """The structured search path carries index_phase on a never-indexed miss (#1534).""" + from sqlalchemy import text as sa_text + + from basic_memory import db + + notes_dir = config_home / "notes" + notes_dir.mkdir(exist_ok=True) + (notes_dir / "unindexed-note.md").write_text("# Unindexed Note\n\nNo index covers this.\n") + async with db.scoped_session(session_maker) as session: + await session.execute( + sa_text("UPDATE project SET last_indexed_at = NULL WHERE id = :id"), + {"id": test_project.id}, + ) + + response = await search_notes( + project=test_project.name, + query="unindexed-note-xyzzy", + search_type="text", + output_format="json", + ) + + assert isinstance(response, dict) + assert response["results"] == [] + assert response["index_phase"] == "never_indexed" + + +@pytest.mark.asyncio +async def test_search_indexed_miss_keeps_original_copy( + client, test_project, session_maker, config_home +): + """An indexed project with genuinely no match keeps the plain-miss copy (#1534).""" + from datetime import datetime + + from sqlalchemy import text as sa_text + + from basic_memory import db + from basic_memory.mcp.tools import write_note as write_note_tool + + async with db.scoped_session(session_maker) as session: + await session.execute( + sa_text("UPDATE project SET last_indexed_at = :now WHERE id = :id"), + {"now": datetime.now(), "id": test_project.id}, + ) + + created = await write_note_tool( + project=test_project.name, + title="Indexed Note", + directory="notes", + content="# Indexed Note\n\nSearchable content about telescopes.\n", + ) + assert created + + miss = await search_notes( + project=test_project.name, + query="nothing-matches-this-xyzzy", + search_type="text", + output_format="text", + ) + assert isinstance(miss, str) + assert "Try broader or different terms" in miss + assert "never been indexed" not in miss + + hit = await search_notes( + project=test_project.name, + query="telescopes", + search_type="text", + output_format="json", + ) + assert isinstance(hit, dict) + assert len(hit["results"]) > 0 + assert "index_phase" not in hit From ba2579427b92e652894bb495d59d9df33490035e Mon Sep 17 00:00:00 2001 From: phernandez Date: Sun, 13 Sep 2026 22:35:03 -0500 Subject: [PATCH 2/3] fix(mcp): surface index-required guidance for empty reads Signed-off-by: phernandez --- src/basic_memory/mcp/index_readiness.py | 37 +++++++ src/basic_memory/mcp/tools/recent_activity.py | 14 ++- src/basic_memory/mcp/tools/search.py | 11 +-- tests/mcp/test_index_readiness.py | 99 +++++++++++++++++++ tests/mcp/test_tool_recent_activity.py | 7 +- tests/mcp/test_tool_search.py | 34 +++++-- .../tools/test_search_notes_multi_project.py | 3 + tests/test_remedy_emission_sites.py | 4 + 8 files changed, 193 insertions(+), 16 deletions(-) create mode 100644 src/basic_memory/mcp/index_readiness.py create mode 100644 tests/mcp/test_index_readiness.py diff --git a/src/basic_memory/mcp/index_readiness.py b/src/basic_memory/mcp/index_readiness.py new file mode 100644 index 000000000..7eebc4c01 --- /dev/null +++ b/src/basic_memory/mcp/index_readiness.py @@ -0,0 +1,37 @@ +"""Index-required guidance shared by MCP discovery tools.""" + +from httpx import AsyncClient + +from basic_memory.schemas.project_info import ProjectItem +from basic_memory.schemas.project_readiness import ProjectIndexPhase +from basic_memory.utils import shell_command + + +async def project_index_required(client: AsyncClient, project: ProjectItem) -> str | None: + """Distinguish an unsearched project from an honest empty result. + + Call only after an empty read: the status endpoint observes project files, + so successful reads should not pay for an extra directory scan. Let status + failures propagate rather than interpreting unknown readiness as an empty index. + """ + # Match the tools' deferred client imports to keep CLI startup lightweight. + from basic_memory.mcp.clients.project import ProjectClient + + status = await ProjectClient(client).get_status(project.external_id) + readiness = status.readiness + if readiness.phase is not ProjectIndexPhase.NEVER_INDEXED: + return None + + # ProjectItem carries no routing mode. Label both remedies explicitly instead + # of guessing from local config, which cannot identify a hosted factory route. + local = readiness.describe( + project.name, index_command=shell_command("bm", "project", "index", project.name) + ) + cloud = readiness.describe(project.name, index_command=None) + return ( + "# Project Index Required\n\n" + f"Project '{project.name}' has never been indexed. You need to index it before " + "an empty result can establish that no notes match.\n\n" + f"- For a local project: {local}.\n" + f"- For a cloud project: {cloud}; wait for server-side indexing before retrying." + ) diff --git a/src/basic_memory/mcp/tools/recent_activity.py b/src/basic_memory/mcp/tools/recent_activity.py index 8a42acdeb..aeb337d9e 100644 --- a/src/basic_memory/mcp/tools/recent_activity.py +++ b/src/basic_memory/mcp/tools/recent_activity.py @@ -9,6 +9,7 @@ from pydantic import AliasChoices, Field from basic_memory.mcp.async_client import get_client +from basic_memory.mcp.index_readiness import project_index_required from basic_memory.mcp.project_context import ( get_project_client, resolve_project_parameter, @@ -117,7 +118,7 @@ async def recent_activity( it routes to the exact project regardless of name collisions across cloud workspaces. Takes precedence over `project`. Get from list_memory_projects(). output_format: "text" returns human-readable summary text. "json" returns - a flat list of recent items. + a flat list of recent items, or index-required guidance if never indexed. context: Optional FastMCP context for performance caching. Returns: @@ -224,6 +225,11 @@ async def recent_activity( # Query each project's activity for project_info in project_list.projects: project_activity = await _get_project_activity(client, project_info, params, depth) + # Discovery must not hide an unindexed project in an empty summary. + if not project_activity.item_count: + guidance = await project_index_required(client, project_info) + if guidance is not None: + return guidance projects_activity[project_info.name] = project_activity # Aggregate stats @@ -330,6 +336,12 @@ async def recent_activity( ) activity_data = GraphContext.model_validate(response.json()) + # Do not offer first-note onboarding before the project was indexed. + if not activity_data.results: + guidance = await project_index_required(client, active_project) + if guidance is not None: + return guidance + if output_format == "json": return _extract_recent_rows(activity_data) diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 9f1f6dabb..c79225ae8 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -25,6 +25,7 @@ is_factory_mode, ) from basic_memory.mcp.container import get_container +from basic_memory.mcp.index_readiness import project_index_required from basic_memory.mcp.project_context import ( detect_project_from_identifier_prefix, get_project_client, @@ -1477,13 +1478,11 @@ async def search_notes( f"page={result.current_page} page_size={result.page_size}" ) - # Check if we got no results and provide helpful guidance + # An empty page is a trustworthy miss only after an index pass. if not result.results: - logger.debug( - f"Search returned no results for query: {query} in project {active_project.name}" - ) - # Don't treat this as an error, but the user might want guidance - # We return the empty result as normal - the user can decide if they need help + guidance = await project_index_required(client, active_project) + if guidance is not None: + return guidance if compact: result = _compact_search_response(result) diff --git a/tests/mcp/test_index_readiness.py b/tests/mcp/test_index_readiness.py new file mode 100644 index 000000000..ed8c79f4c --- /dev/null +++ b/tests/mcp/test_index_readiness.py @@ -0,0 +1,99 @@ +"""Readiness must survive MCP serialization and account-wide search.""" + +from unittest.mock import AsyncMock + +import pytest +from fastmcp import Client +from fastmcp.exceptions import ToolError +from sqlalchemy import update + +from basic_memory import db +from basic_memory.mcp.clients.project import ProjectClient +from basic_memory.mcp.tools import recent_activity, search_notes, write_note +from basic_memory.models import Project + + +@pytest.mark.parametrize("tool", ["search_notes", "recent_activity"]) +@pytest.mark.parametrize("output_format", ["text", "json"]) +@pytest.mark.parametrize("page", [1, 2]) +async def test_index_required_through_mcp(mcp, client, test_project, tool, output_format, page): + """Neither JSON nor a later page may disguise a never-indexed project as empty.""" + params = {"project": test_project.name, "output_format": output_format, "page": page} + if tool == "search_notes": + params.update(query="missing", search_type="text") + else: + params.update(type="observation") + async with Client(mcp) as mcp_client: + result = await mcp_client.call_tool(tool, params) + assert "# Project Index Required" in result.content[0].text + assert "never been indexed" in result.content[0].text + assert "For a local project:" in result.content[0].text + assert "For a cloud project:" in result.content[0].text + + +@pytest.mark.parametrize("output_format", ["text", "json"]) +async def test_all_project_search_preserves_index_required(client, test_project, output_format): + result = await search_notes( + query="missing", search_type="text", search_all_projects=True, output_format=output_format + ) + assert isinstance(result, str) + assert result.startswith("# Project Index Required") + assert test_project.name in result + + +async def test_readiness_failure_is_not_an_empty_success(client, test_project, monkeypatch): + monkeypatch.setattr( + ProjectClient, "get_status", AsyncMock(side_effect=ToolError("readiness unavailable")) + ) + result = await search_notes(project=test_project.name, query="missing", output_format="json") + assert isinstance(result, str) + assert "Search Failed" in result + assert "readiness unavailable" in result + with pytest.raises(ToolError, match="readiness unavailable"): + await recent_activity(project=test_project.name, output_format="json") + + +async def test_nonempty_reads_skip_status(client, test_project, monkeypatch): + await write_note( + project=test_project.name, title="Visible", content="Visible content", directory="notes" + ) + status = AsyncMock(side_effect=AssertionError("nonempty reads must not scan status")) + monkeypatch.setattr(ProjectClient, "get_status", status) + result = await search_notes( + project=test_project.name, query="Visible", search_type="text", output_format="json" + ) + assert isinstance(result, dict) and result["results"] + assert isinstance(await recent_activity(project=test_project.name, output_format="json"), list) + status.assert_not_called() + + +async def test_indexed_pending_misses_stay_empty(client, test_project, session_maker, config_home): + """Pending new files do not revoke an already-indexed project's ordinary misses.""" + from datetime import datetime, timezone + + (config_home / "unindexed.md").write_text("# A file awaiting the next pass\n") + async with db.scoped_session(session_maker) as session: + await session.execute( + update(Project) + .where(Project.id == test_project.id) + .values(last_indexed_at=datetime.now(timezone.utc)) + ) + result = await search_notes( + project=test_project.name, query="missing", search_type="text", output_format="json" + ) + assert isinstance(result, dict) and result["results"] == [] + assert await recent_activity(project=test_project.name, output_format="json") == [] + + +@pytest.mark.parametrize("output_format", ["text", "json"]) +async def test_activity_discovery_preserves_index_required( + client, test_project, monkeypatch, output_format +): + import importlib + + activity_module = importlib.import_module("basic_memory.mcp.tools.recent_activity") + monkeypatch.setattr(activity_module, "resolve_project_parameter", AsyncMock(return_value=None)) + result = await recent_activity(output_format=output_format) + assert isinstance(result, str) + assert result.startswith("# Project Index Required") + assert test_project.name in result diff --git a/tests/mcp/test_tool_recent_activity.py b/tests/mcp/test_tool_recent_activity.py index 6ce7acadb..e2322b809 100644 --- a/tests/mcp/test_tool_recent_activity.py +++ b/tests/mcp/test_tool_recent_activity.py @@ -698,8 +698,9 @@ async def test_recent_activity_entity_rows_include_external_id(client, test_grap @pytest.mark.asyncio +@pytest.mark.parametrize("output_format", ["text", "json"]) async def test_recent_activity_never_indexed_says_so( - client, test_project, session_maker, config_home + client, test_project, session_maker, config_home, output_format ): """A never-indexed project must not report an ordinary empty activity feed (#1534).""" from sqlalchemy import text as sa_text @@ -716,7 +717,9 @@ async def test_recent_activity_never_indexed_says_so( {"id": test_project.id}, ) - result = await recent_activity(project=test_project.name, timeframe="7d") + result = await recent_activity( + project=test_project.name, timeframe="7d", output_format=output_format + ) assert isinstance(result, str) assert "never been indexed" in result diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py index 1acf62ef1..a4de44c43 100644 --- a/tests/mcp/test_tool_search.py +++ b/tests/mcp/test_tool_search.py @@ -1833,7 +1833,7 @@ async def test_search_notes_tags_invalid_type_rejected_via_mcp(mcp, client, test @pytest.mark.asyncio -async def test_search_notes_direct_call_splits_comma_tags(client, test_project): +async def test_search_notes_direct_call_splits_comma_tags(client, test_project, indexed_project): """Direct callers bypass the BeforeValidator, so the body must normalize tags. Regression for the CLI path: `bm tool search-notes --tag alpha,beta` calls this @@ -2231,7 +2231,9 @@ def test_search_notes_categories_annotation_rejects_non_string_list_elements(): @pytest.mark.asyncio -async def test_search_notes_direct_call_splits_comma_note_types(client, test_project): +async def test_search_notes_direct_call_splits_comma_note_types( + client, test_project, indexed_project +): """Direct callers bypass the BeforeValidator, so the body must normalize note_types. Regression for the CLI path: `bm tool search-notes --type note,task` calls this @@ -2316,10 +2318,10 @@ async def test_search_never_indexed_text_says_so(client, test_project, session_m @pytest.mark.asyncio -async def test_search_never_indexed_json_carries_phase( +async def test_search_never_indexed_json_returns_index_required( client, test_project, session_maker, config_home ): - """The structured search path carries index_phase on a never-indexed miss (#1534).""" + """JSON mode uses error guidance, never a success-shaped empty result (#1534).""" from sqlalchemy import text as sa_text from basic_memory import db @@ -2340,9 +2342,10 @@ async def test_search_never_indexed_json_carries_phase( output_format="json", ) - assert isinstance(response, dict) - assert response["results"] == [] - assert response["index_phase"] == "never_indexed" + assert isinstance(response, str) + assert response.startswith("# Project Index Required") + assert "never been indexed" in response + assert "bm project index" in response @pytest.mark.asyncio @@ -2390,3 +2393,20 @@ async def test_search_indexed_miss_keeps_original_copy( assert isinstance(hit, dict) assert len(hit["results"]) > 0 assert "index_phase" not in hit + + +@pytest.fixture +async def indexed_project(test_project, session_maker): + """Filter miss tests require a project with a completed index pass.""" + from sqlalchemy import update + + from basic_memory import db + from basic_memory.models import Project + + async with db.scoped_session(session_maker) as session: + await session.execute( + update(Project) + .where(Project.id == test_project.id) + .values(last_indexed_at=datetime.now()) + ) + return test_project diff --git a/tests/mcp/tools/test_search_notes_multi_project.py b/tests/mcp/tools/test_search_notes_multi_project.py index 8cfe20335..69a616cf0 100644 --- a/tests/mcp/tools/test_search_notes_multi_project.py +++ b/tests/mcp/tools/test_search_notes_multi_project.py @@ -2,6 +2,7 @@ from contextlib import asynccontextmanager import importlib +from unittest.mock import AsyncMock from httpx import HTTPStatusError, Request, Response from fastmcp.exceptions import ToolError @@ -168,6 +169,8 @@ async def search(self, payload, page, page_size): monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path) monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient) + monkeypatch.setattr(search_mod, "project_index_required", AsyncMock(return_value=None)) + result = await search_mod.search_notes(query="MCP Test Note", output_format="json") assert isinstance(result, dict) diff --git a/tests/test_remedy_emission_sites.py b/tests/test_remedy_emission_sites.py index 38e13c000..2b7bdcc80 100644 --- a/tests/test_remedy_emission_sites.py +++ b/tests/test_remedy_emission_sites.py @@ -36,6 +36,10 @@ # Keyed by (module relative to basic_memory/, enclosing function) so the registry # survives line moves. JUSTIFIED_SITES: dict[tuple[str, str], str] = { + ("mcp/index_readiness.py", "project_index_required"): ( + "labels the command as local-project-only and separately presents the server-side " + "cloud remedy; ProjectItem does not carry the resolved routing mode" + ), ("cli/commands/project.py", "add_project"): ( "the `bm project index` hints sit under `if not effective_cloud_mode`, and the " "`bm cloud bisync` one is the Personal-only aside printed after the Team-safe " From 4dba3855feb46f588ead56ed52cd809abda660a6 Mon Sep 17 00:00:00 2001 From: phernandez Date: Sun, 13 Sep 2026 22:57:58 -0500 Subject: [PATCH 3/3] test(mcp): index projects before asserting honest misses Signed-off-by: phernandez --- src/basic_memory/mcp/tools/recent_activity.py | 2 +- .../test_cli_tool_delete_note_integration.py | 8 ++-- .../test_cli_tool_json_failure_integration.py | 10 +++-- test-int/conftest.py | 9 ++++ .../mcp/test_chatgpt_tools_integration.py | 2 +- test-int/mcp/test_delete_note_integration.py | 4 +- .../mcp/test_index_readiness_integration.py | 42 +++++++++++++++++++ test-int/mcp/test_search_integration.py | 2 +- tests/mcp/conftest.py | 9 ++++ tests/mcp/test_first_connect_onboarding.py | 12 ++++-- tests/mcp/test_prompts.py | 2 +- tests/mcp/test_tool_json_output_modes.py | 2 +- tests/mcp/test_tool_search.py | 17 -------- 13 files changed, 86 insertions(+), 35 deletions(-) create mode 100644 test-int/mcp/test_index_readiness_integration.py diff --git a/src/basic_memory/mcp/tools/recent_activity.py b/src/basic_memory/mcp/tools/recent_activity.py index aeb337d9e..97ad6e4ff 100644 --- a/src/basic_memory/mcp/tools/recent_activity.py +++ b/src/basic_memory/mcp/tools/recent_activity.py @@ -118,7 +118,7 @@ async def recent_activity( it routes to the exact project regardless of name collisions across cloud workspaces. Takes precedence over `project`. Get from list_memory_projects(). output_format: "text" returns human-readable summary text. "json" returns - a flat list of recent items, or index-required guidance if never indexed. + a flat list of recent items. context: Optional FastMCP context for performance caching. Returns: diff --git a/test-int/cli/test_cli_tool_delete_note_integration.py b/test-int/cli/test_cli_tool_delete_note_integration.py index b00acdee9..1820d0148 100644 --- a/test-int/cli/test_cli_tool_delete_note_integration.py +++ b/test-int/cli/test_cli_tool_delete_note_integration.py @@ -97,7 +97,7 @@ def _project_file(test_project, file_path: str) -> Path: def test_delete_note_removes_file_database_record_and_search_result( - app, app_config, test_project, config_manager + app, app_config, test_project, config_manager, indexed_project ) -> None: """Single-note deletion removes the note from every user-visible surface.""" note = _write_note( @@ -164,7 +164,7 @@ def test_delete_note_case_mismatch_does_not_delete_exact_note( def test_delete_note_project_id_takes_precedence_over_wrong_project_name( - app, app_config, test_project, config_manager + app, app_config, test_project, config_manager, indexed_project ) -> None: """CLI `--project-id` routes destructive operations to the exact project.""" note = _write_note( @@ -186,7 +186,7 @@ def test_delete_note_project_id_takes_precedence_over_wrong_project_name( def test_delete_note_memory_url_detects_project_from_identifier( - app, app_config, test_project, config_manager + app, app_config, test_project, config_manager, indexed_project ) -> None: """A memory:// URL can select the project without a separate --project flag.""" note = _write_note( @@ -206,7 +206,7 @@ def test_delete_note_memory_url_detects_project_from_identifier( def test_delete_directory_removes_nested_files_database_records_and_search_results( - app, app_config, test_project, config_manager + app, app_config, test_project, config_manager, indexed_project ) -> None: """Directory deletion removes nested notes and reports a complete JSON summary.""" notes = [ diff --git a/test-int/cli/test_cli_tool_json_failure_integration.py b/test-int/cli/test_cli_tool_json_failure_integration.py index 7faa1f30f..de4fc7c3c 100644 --- a/test-int/cli/test_cli_tool_json_failure_integration.py +++ b/test-int/cli/test_cli_tool_json_failure_integration.py @@ -17,7 +17,9 @@ @pytest.mark.parametrize( "identifier", ["nonexistent-note-that-does-not-exist", "22222222-2222-4222-8222-222222222222"] ) -def test_read_note_not_found(app, app_config, test_project, config_manager, identifier): +def test_read_note_not_found( + app, app_config, test_project, config_manager, identifier, indexed_project +): """A missing note remains machine-readable but must not report success.""" result = runner.invoke( cli_app, @@ -37,7 +39,7 @@ def test_read_note_not_found(app, app_config, test_project, config_manager, iden @pytest.mark.parametrize("mode", ["piped", "json", "plain", "rich"]) @pytest.mark.parametrize("related", [False, True]) def test_read_after_delete_fails_in_every_mode( - app, app_config, test_project, config_manager, monkeypatch, mode, related + app, app_config, test_project, config_manager, monkeypatch, mode, related, indexed_project ): """The actual write/delete/read flow must fail even when search offers alternatives.""" monkeypatch.setattr("basic_memory.cli.commands.tool._use_rich", lambda: mode == "rich") @@ -131,7 +133,9 @@ def test_write_note_then_read_note_roundtrip(app, app_config, test_project, conf assert read_data["permalink"] == write_data["permalink"] -def test_recent_activity_empty_project(app, app_config, test_project, config_manager, monkeypatch): +def test_recent_activity_empty_project( + app, app_config, test_project, config_manager, monkeypatch, indexed_project +): """recent-activity on empty project returns valid empty JSON list.""" monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", test_project.name) diff --git a/test-int/conftest.py b/test-int/conftest.py index 32fb81975..80e58ebb8 100644 --- a/test-int/conftest.py +++ b/test-int/conftest.py @@ -543,3 +543,12 @@ async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]: """Create test client that both MCP and tests will use.""" async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: yield client + + +@pytest_asyncio.fixture +async def indexed_project(client: AsyncClient, test_project: Project) -> Project: + """Complete an initial pass for tests asserting honest misses after reads/deletes.""" + from basic_memory.mcp.clients.project import ProjectClient + + await ProjectClient(client).index(test_project.external_id, run_in_background=False) + return test_project diff --git a/test-int/mcp/test_chatgpt_tools_integration.py b/test-int/mcp/test_chatgpt_tools_integration.py index 007b7adc5..c1bd312a3 100644 --- a/test-int/mcp/test_chatgpt_tools_integration.py +++ b/test-int/mcp/test_chatgpt_tools_integration.py @@ -104,7 +104,7 @@ async def test_chatgpt_search_basic(mcp_server, app, test_project): @pytest.mark.asyncio -async def test_chatgpt_search_empty_results(mcp_server, app, test_project): +async def test_chatgpt_search_empty_results(mcp_server, app, test_project, indexed_project): """Test ChatGPT search with no matching results.""" async with openai_mcp_client(mcp_server) as client: diff --git a/test-int/mcp/test_delete_note_integration.py b/test-int/mcp/test_delete_note_integration.py index 251fa9cb1..07c164cae 100644 --- a/test-int/mcp/test_delete_note_integration.py +++ b/test-int/mcp/test_delete_note_integration.py @@ -67,7 +67,7 @@ async def test_delete_note_by_title(mcp_server, app, test_project): @pytest.mark.asyncio -async def test_delete_note_by_permalink(mcp_server, app, test_project): +async def test_delete_note_by_permalink(mcp_server, app, test_project, indexed_project): """Test deleting a note by its permalink.""" async with Client(mcp_server) as client: @@ -362,7 +362,7 @@ async def test_delete_note_rejects_case_mismatch(mcp_server, app, test_project): @pytest.mark.asyncio -async def test_delete_multiple_notes_sequentially(mcp_server, app, test_project): +async def test_delete_multiple_notes_sequentially(mcp_server, app, test_project, indexed_project): """Test deleting multiple notes in sequence.""" async with Client(mcp_server) as client: diff --git a/test-int/mcp/test_index_readiness_integration.py b/test-int/mcp/test_index_readiness_integration.py new file mode 100644 index 000000000..bbe6662d5 --- /dev/null +++ b/test-int/mcp/test_index_readiness_integration.py @@ -0,0 +1,42 @@ +"""A real first index pass changes MCP guidance into trustworthy retrieval.""" + +from pathlib import Path + +import pytest + +from basic_memory.mcp.clients.project import ProjectClient +from basic_memory.mcp.tools import recent_activity, search_notes +from basic_memory.schemas.project_readiness import ProjectIndexPhase + + +@pytest.mark.parametrize("output_format", ["text", "json"]) +async def test_first_index_makes_empty_reads_trustworthy(client, test_project, output_format): + note = Path(test_project.path) / "unindexed.md" + note.write_text("# Previously Invisible\n\nUniqueReadinessToken\n", encoding="utf-8") + projects = ProjectClient(client) + before = await projects.get_status(test_project.external_id) + assert before.readiness.phase is ProjectIndexPhase.NEVER_INDEXED + + search = await search_notes( + project=test_project.name, query="UniqueReadinessToken", output_format=output_format + ) + activity = await recent_activity(project=test_project.name, output_format=output_format) + assert isinstance(search, str) and search.startswith("# Project Index Required") + assert isinstance(activity, str) and activity.startswith("# Project Index Required") + + await projects.index(test_project.external_id, run_in_background=False) + after = await projects.get_status(test_project.external_id) + assert after.readiness.phase is not ProjectIndexPhase.NEVER_INDEXED + found = await search_notes( + project=test_project.name, query="UniqueReadinessToken", output_format="json" + ) + assert isinstance(found, dict) and found["results"] + miss = await search_notes( + project=test_project.name, query="MissingReadinessToken", output_format="json" + ) + assert isinstance(miss, dict) and miss["results"] == [] + # The note has no observations: filtering them is now an honest empty activity page. + assert ( + await recent_activity(project=test_project.name, type="observation", output_format="json") + == [] + ) diff --git a/test-int/mcp/test_search_integration.py b/test-int/mcp/test_search_integration.py index f7bb9e78d..1609fafa8 100644 --- a/test-int/mcp/test_search_integration.py +++ b/test-int/mcp/test_search_integration.py @@ -382,7 +382,7 @@ async def test_search_pagination(mcp_server, app, test_project): @pytest.mark.asyncio -async def test_search_no_results(mcp_server, app, test_project): +async def test_search_no_results(mcp_server, app, test_project, indexed_project): """Test search with no matching results.""" async with Client(mcp_server) as client: diff --git a/tests/mcp/conftest.py b/tests/mcp/conftest.py index 40577b239..662da63f8 100644 --- a/tests/mcp/conftest.py +++ b/tests/mcp/conftest.py @@ -124,3 +124,12 @@ async def second_project(config_manager, engine_factory, tmp_path_factory) -> Pr config.projects["second-project"] = ProjectEntry(path=str(project_path)) config_manager.save_config(config) return project + + +@pytest_asyncio.fixture +async def indexed_project(client: AsyncClient, test_project: Project) -> Project: + """Complete an initial pass for tests asserting ordinary empty retrieval.""" + from basic_memory.mcp.clients.project import ProjectClient + + await ProjectClient(client).index(test_project.external_id, run_in_background=False) + return test_project diff --git a/tests/mcp/test_first_connect_onboarding.py b/tests/mcp/test_first_connect_onboarding.py index 5b09bc65a..08126556e 100644 --- a/tests/mcp/test_first_connect_onboarding.py +++ b/tests/mcp/test_first_connect_onboarding.py @@ -38,7 +38,9 @@ def test_server_sets_first_connect_instructions(): @pytest.mark.asyncio -async def test_recent_activity_empty_project_offers_first_note(client, test_project): +async def test_recent_activity_empty_project_offers_first_note( + client, test_project, indexed_project +): """An empty project (no test_graph) should surface the offer-not-act first-note guidance.""" result = await recent_activity(project=test_project.name, timeframe="7d") # pyright: ignore[reportGeneralTypeIssues] @@ -64,7 +66,9 @@ async def test_recent_activity_populated_project_has_no_first_note_offer( @pytest.mark.asyncio -async def test_recent_activity_filtered_empty_result_has_no_first_note_offer(client, test_project): +async def test_recent_activity_filtered_empty_result_has_no_first_note_offer( + client, test_project, indexed_project +): """A type-filter miss does not mean an established project has no notes.""" await write_note( project=test_project.name, @@ -87,7 +91,7 @@ async def test_recent_activity_filtered_empty_result_has_no_first_note_offer(cli @pytest.mark.asyncio async def test_recent_activity_out_of_range_page_has_no_first_note_offer( - client, test_project, test_graph + client, test_project, test_graph, indexed_project ): """An empty later page should direct the agent back through pagination.""" result = await recent_activity( @@ -107,7 +111,7 @@ async def test_recent_activity_out_of_range_page_has_no_first_note_offer( @pytest.mark.asyncio -async def test_search_no_results_points_to_recent_activity(client, test_project): +async def test_search_no_results_points_to_recent_activity(client, test_project, indexed_project): """Empty search must point at recent_activity rather than repeat the first-note offer.""" result = await search_notes(query="XYZ123NoSuchNote", project=test_project.name) # pyright: ignore[reportGeneralTypeIssues] diff --git a/tests/mcp/test_prompts.py b/tests/mcp/test_prompts.py index 2e6276f00..701ed10ce 100644 --- a/tests/mcp/test_prompts.py +++ b/tests/mcp/test_prompts.py @@ -70,7 +70,7 @@ async def test_search_prompt_with_timeframe(client, test_graph): @pytest.mark.asyncio -async def test_search_prompt_no_results(client): +async def test_search_prompt_no_results(client, indexed_project): """Test search_prompt when no results are found.""" result = await search_prompt("XYZ123NonExistentQuery") # pyright: ignore [reportGeneralTypeIssues] diff --git a/tests/mcp/test_tool_json_output_modes.py b/tests/mcp/test_tool_json_output_modes.py index 06960aac3..a459aad25 100644 --- a/tests/mcp/test_tool_json_output_modes.py +++ b/tests/mcp/test_tool_json_output_modes.py @@ -50,7 +50,7 @@ async def test_write_note_text_and_json_modes(app, test_project): @pytest.mark.asyncio -async def test_read_note_text_and_json_modes(app, test_project): +async def test_read_note_text_and_json_modes(app, test_project, indexed_project): await write_note( project=test_project.name, title="Mode Read Note", diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py index a4de44c43..0d6a65551 100644 --- a/tests/mcp/test_tool_search.py +++ b/tests/mcp/test_tool_search.py @@ -2393,20 +2393,3 @@ async def test_search_indexed_miss_keeps_original_copy( assert isinstance(hit, dict) assert len(hit["results"]) > 0 assert "index_phase" not in hit - - -@pytest.fixture -async def indexed_project(test_project, session_maker): - """Filter miss tests require a project with a completed index pass.""" - from sqlalchemy import update - - from basic_memory import db - from basic_memory.models import Project - - async with db.scoped_session(session_maker) as session: - await session.execute( - update(Project) - .where(Project.id == test_project.id) - .values(last_indexed_at=datetime.now()) - ) - return test_project