diff --git a/src/basic_memory/cli/commands/doctor.py b/src/basic_memory/cli/commands/doctor.py index be383bd3..57cafc48 100644 --- a/src/basic_memory/cli/commands/doctor.py +++ b/src/basic_memory/cli/commands/doctor.py @@ -139,6 +139,9 @@ def doctor( """Run local consistency checks to verify file/database sync.""" try: validate_routing_flags(local, cloud) + # Doctor runs local filesystem checks — always default to local routing + if not local and not cloud: + local = True with force_routing(local=local, cloud=cloud): run_with_cleanup(run_doctor()) except (ToolError, ValueError) as e: diff --git a/src/basic_memory/cli/commands/status.py b/src/basic_memory/cli/commands/status.py index afec5fc4..d9e7e1e9 100644 --- a/src/basic_memory/cli/commands/status.py +++ b/src/basic_memory/cli/commands/status.py @@ -179,6 +179,13 @@ def status( try: validate_routing_flags(local, cloud) + # Trigger: no explicit routing flag provided + # Why: status scans the local filesystem — cloud routing would use the + # Docker-internal path stored in the cloud database, which doesn't + # exist locally. + # Outcome: default to local routing unless --cloud was explicitly requested. + if not local and not cloud: + local = True with force_routing(local=local, cloud=cloud): run_with_cleanup(run_status(project, verbose)) # pragma: no cover except ValueError as e: diff --git a/src/basic_memory/mcp/prompts/continue_conversation.py b/src/basic_memory/mcp/prompts/continue_conversation.py index a2f5de56..fc080e65 100644 --- a/src/basic_memory/mcp/prompts/continue_conversation.py +++ b/src/basic_memory/mcp/prompts/continue_conversation.py @@ -4,17 +4,16 @@ These prompts help users continue conversations and work across sessions, providing context from previous interactions to maintain continuity. """ +from textwrap import dedent from typing import Annotated, Optional from loguru import logger from pydantic import Field -from basic_memory.config import ConfigManager -from basic_memory.mcp.async_client import get_client -from basic_memory.mcp.project_context import get_active_project from basic_memory.mcp.server import mcp -from basic_memory.mcp.tools.utils import call_post -from basic_memory.schemas.prompt import ContinueConversationRequest +from basic_memory.mcp.tools.recent_activity import recent_activity +from basic_memory.mcp.tools.search import search_notes +from basic_memory.schemas.search import SearchResponse @mcp.prompt( @@ -42,22 +41,94 @@ async def continue_conversation( """ logger.info(f"Continuing session, topic: {topic}, timeframe: {timeframe}") - async with get_client() as client: - config = ConfigManager().config - active_project = await get_active_project(client, project=config.default_project) + if topic: + # Search for the topic using the search tool directly + result = await search_notes(query=topic, after_date=timeframe) - # Create request model - request = ContinueConversationRequest( # pyright: ignore [reportCallIssue] - topic=topic, timeframe=timeframe - ) + if isinstance(result, SearchResponse): + context_text = _format_continuation_results(result, topic) + result_count = len(result.results) + elif isinstance(result, dict): + results = result.get("results", []) + context_text = str(result) + result_count = len(results) + else: + # Error string + context_text = str(result) + result_count = 0 + else: + # No topic — show recent activity + effective_timeframe = timeframe or "7d" + activity_text = await recent_activity(timeframe=effective_timeframe) + context_text = str(activity_text) + result_count = -1 # Signals we used recent_activity - # Call the prompt API endpoint - response = await call_post( - client, - f"/v2/projects/{active_project.external_id}/prompt/continue-conversation", - json=request.model_dump(exclude_none=True), - ) + target = f"'{topic}'" if topic else "recent activity" - # Extract the rendered prompt from the response - result = response.json() - return result["prompt"] + prompt = dedent(f""" + # Continuing conversation on: {target} + + This is a memory retrieval session. + + Please use the available basic-memory tools to gather relevant context before responding. + Start by executing one of the suggested commands below to retrieve content. + + {context_text} + + --- + + ## Next Steps + """) + + if topic and result_count > 0: + prompt += dedent(f""" + Found {result_count} results related to '{topic}'. + + 1. **Read full content** - Use `read_note("permalink")` to dive into specific notes + 2. **Build context** - Use `build_context("memory://path")` to see relationships + 3. **Search deeper** - Use `search_notes("{topic}")` with different filters + + > **Knowledge Capture:** As you continue this conversation, actively look for + > opportunities to record new information, decisions, or insights using `write_note()`. + """) + elif topic: + prompt += dedent(f""" + No previous context found for '{topic}'. + + This is an opportunity to start documenting this topic: + + 1. **Create a new note** - Use `write_note(title="{topic}", content="...")` to start + 2. **Search with variations** - Try `search_notes("{topic}")` with different terms + 3. **Check recent activity** - Use `recent_activity(timeframe="7d")` to see what's new + """) + else: + prompt += dedent(""" + 1. **Explore specific items** - Use `read_note("permalink")` to dive deeper + 2. **Search for topics** - Use `search_notes("topic")` to find specific content + 3. **Build context** - Use `build_context("memory://path")` to see relationships + """) + + return prompt + + +def _format_continuation_results(result: SearchResponse, topic: str) -> str: + """Format search results for conversation continuation context.""" + if not result.results: + return f"No previous context found for '{topic}'." + + lines = [f"## Previous Context for '{topic}'\n"] + + for item in result.results: + title = item.title or "Untitled" + permalink = item.permalink or "" + + lines.append(f"### {title}") + if permalink: + lines.append(f"permalink: {permalink}") + lines.append(f"Read with: `read_note(\"{permalink}\")`") + if item.content: + content = item.content[:300] + "..." if len(item.content) > 300 else item.content + lines.append(f"\n{content}") + lines.append("") + + return "\n".join(lines) diff --git a/src/basic_memory/mcp/prompts/search.py b/src/basic_memory/mcp/prompts/search.py index 1aa12b3a..6c055f57 100644 --- a/src/basic_memory/mcp/prompts/search.py +++ b/src/basic_memory/mcp/prompts/search.py @@ -3,17 +3,15 @@ These prompts help users search and explore their knowledge base. """ +from textwrap import dedent from typing import Annotated, Optional from loguru import logger from pydantic import Field -from basic_memory.config import ConfigManager -from basic_memory.mcp.async_client import get_client -from basic_memory.mcp.project_context import get_active_project from basic_memory.mcp.server import mcp -from basic_memory.mcp.tools.utils import call_post -from basic_memory.schemas.prompt import SearchPromptRequest +from basic_memory.mcp.tools.search import search_notes +from basic_memory.schemas.search import SearchResponse @mcp.prompt( @@ -41,20 +39,65 @@ async def search_prompt( """ logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}") - async with get_client() as client: - config = ConfigManager().config - active_project = await get_active_project(client, project=config.default_project) + # Call the search tool directly — it returns SearchResponse, dict, or error string + result = await search_notes(query=query, after_date=timeframe) - # Create request model - request = SearchPromptRequest(query=query, timeframe=timeframe) + # Format the tool output into a prompt with guidance + if isinstance(result, SearchResponse): + result_count = len(result.results) + result_text = _format_search_results(result, query) + elif isinstance(result, dict): + # json output format + results = result.get("results", []) + result_count = len(results) + result_text = str(result) + else: + # Error string from search tool + result_count = 0 + result_text = str(result) - # Call the prompt API endpoint - response = await call_post( - client, - f"/v2/projects/{active_project.external_id}/prompt/search", - json=request.model_dump(exclude_none=True), - ) + return dedent(f""" + # Search Results: "{query}" - # Extract the rendered prompt from the response - result = response.json() - return result["prompt"] + This is a memory retrieval session showing search results. + + {result_text} + + --- + + ## Next Steps + + Based on these {result_count} results, you can: + + 1. **Read a specific note** - Use `read_note("permalink")` to see full content + 2. **Build context** - Use `build_context("memory://path")` to see relationships + 3. **Refine search** - Use `search_notes("refined query")` to narrow results + 4. **Check recent activity** - Use `recent_activity(timeframe="7d")` for recent changes + """) + + +def _format_search_results(result: SearchResponse, query: str) -> str: + """Format SearchResponse into readable markdown.""" + if not result.results: + return f"No results found for '{query}'." + + lines = [f"Found {len(result.results)} results:\n"] + + for item in result.results: + title = item.title or "Untitled" + permalink = item.permalink or "" + score = f" (score: {item.score:.2f})" if item.score else "" + + lines.append(f"- **{title}**{score}") + if permalink: + lines.append(f" permalink: {permalink}") + if item.content: + # Truncate content snippet + content = item.content[:200] + "..." if len(item.content) > 200 else item.content + lines.append(f" {content}") + lines.append("") + + if result.has_more: + lines.append("*More results available. Use page=2 to see next page.*") + + return "\n".join(lines) diff --git a/src/basic_memory/sync/sync_service.py b/src/basic_memory/sync/sync_service.py index bf7cd734..982e2875 100644 --- a/src/basic_memory/sync/sync_service.py +++ b/src/basic_memory/sync/sync_service.py @@ -29,6 +29,7 @@ from basic_memory.repository import ( ) from basic_memory.repository.search_repository import create_search_repository from basic_memory.services import EntityService, FileService +from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError from basic_memory.services.exceptions import SyncFatalError from basic_memory.services.link_resolver import LinkResolver from basic_memory.services.search_service import SearchService @@ -600,7 +601,18 @@ class SyncService: entity, checksum = await self.sync_regular_file(path, new) if entity is not None: - await self.search_service.index_entity(entity) + try: + await self.search_service.index_entity(entity) + except SemanticDependenciesMissingError: + # Trigger: sqlite-vec or embedding provider unavailable + # Why: FTS indexing succeeded but vector embeddings cannot be generated. + # Don't fail the entire sync — the entity is usable for text search. + # Outcome: entity returned successfully, warning logged for visibility. + logger.warning( + f"Semantic search dependencies missing — vector embeddings skipped " + f"for path={path}. Run 'bm reindex --embeddings' after resolving " + f"the dependency issue." + ) # Clear failure tracking on successful sync self._clear_failure(path) diff --git a/tests/mcp/test_prompt_tool_delegation.py b/tests/mcp/test_prompt_tool_delegation.py new file mode 100644 index 00000000..8aacb37a --- /dev/null +++ b/tests/mcp/test_prompt_tool_delegation.py @@ -0,0 +1,179 @@ +"""Tests for prompt → tool delegation pattern. + +All MCP prompts should delegate to their corresponding MCP tool and wrap +the output with prompt-specific guidance. These tests verify the delegation +using monkeypatching (no real services needed). +""" + +import pytest + +from basic_memory.mcp.prompts.search import search_prompt +from basic_memory.mcp.prompts.continue_conversation import continue_conversation +from basic_memory.schemas.search import SearchResponse, SearchResult + + +# --- search_prompt --- + + +@pytest.mark.asyncio +async def test_search_prompt_delegates_to_search_notes(monkeypatch): + """Search prompt should call search_notes tool and wrap output.""" + captured_kwargs = {} + + fake_result = SearchResponse( + results=[ + SearchResult( + type="entity", + title="Test Note", + permalink="test-note", + file_path="test-note.md", + score=0.95, + ) + ], + current_page=1, + page_size=10, + ) + + async def fake_search_notes(**kwargs): + captured_kwargs.update(kwargs) + return fake_result + + monkeypatch.setattr("basic_memory.mcp.prompts.search.search_notes", fake_search_notes) + + out = await search_prompt("my query", timeframe="1w") # pyright: ignore[reportGeneralTypeIssues] + + # Verify delegation + assert captured_kwargs["query"] == "my query" + assert captured_kwargs["after_date"] == "1w" + + # Verify output wrapping + assert 'Search Results: "my query"' in out + assert "Test Note" in out + assert "read_note" in out + + +@pytest.mark.asyncio +async def test_search_prompt_handles_no_results(monkeypatch): + """Search prompt should handle empty results gracefully.""" + fake_result = SearchResponse(results=[], current_page=1, page_size=10) + + async def fake_search_notes(**kwargs): + return fake_result + + monkeypatch.setattr("basic_memory.mcp.prompts.search.search_notes", fake_search_notes) + + out = await search_prompt("nonexistent") # pyright: ignore[reportGeneralTypeIssues] + + assert "No results found" in out + assert "0 results" in out + + +@pytest.mark.asyncio +async def test_search_prompt_handles_error_string(monkeypatch): + """Search prompt should handle error string from search tool.""" + + async def fake_search_notes(**kwargs): + return "# Search Failed - Invalid Syntax\n\nThe query contains errors." + + monkeypatch.setattr("basic_memory.mcp.prompts.search.search_notes", fake_search_notes) + + out = await search_prompt("bad(query") # pyright: ignore[reportGeneralTypeIssues] + + assert "Search Failed" in out + assert "0 results" in out + + +# --- continue_conversation --- + + +@pytest.mark.asyncio +async def test_continue_conversation_delegates_to_search_notes(monkeypatch): + """Continue conversation with topic should call search_notes.""" + captured_kwargs = {} + + fake_result = SearchResponse( + results=[ + SearchResult( + type="entity", + title="Previous Discussion", + permalink="discussions/previous", + file_path="discussions/previous.md", + score=0.9, + ) + ], + current_page=1, + page_size=10, + ) + + async def fake_search_notes(**kwargs): + captured_kwargs.update(kwargs) + return fake_result + + monkeypatch.setattr( + "basic_memory.mcp.prompts.continue_conversation.search_notes", fake_search_notes + ) + + out = await continue_conversation(topic="my topic", timeframe="3d") # pyright: ignore[reportGeneralTypeIssues] + + assert captured_kwargs["query"] == "my topic" + assert captured_kwargs["after_date"] == "3d" + + assert "'my topic'" in out + assert "Previous Discussion" in out + assert "read_note" in out + + +@pytest.mark.asyncio +async def test_continue_conversation_delegates_to_recent_activity(monkeypatch): + """Continue conversation without topic should call recent_activity.""" + captured_kwargs = {} + + async def fake_recent_activity(**kwargs): + captured_kwargs.update(kwargs) + return "## Recent Activity: test-project (7d)\n\n**Items:** 3 found" + + monkeypatch.setattr( + "basic_memory.mcp.prompts.continue_conversation.recent_activity", fake_recent_activity + ) + + out = await continue_conversation(timeframe="7d") # pyright: ignore[reportGeneralTypeIssues] + + assert captured_kwargs["timeframe"] == "7d" + assert "recent activity" in out + assert "Recent Activity: test-project" in out + + +@pytest.mark.asyncio +async def test_continue_conversation_no_topic_default_timeframe(monkeypatch): + """Continue conversation without topic or timeframe defaults to 7d.""" + captured_kwargs = {} + + async def fake_recent_activity(**kwargs): + captured_kwargs.update(kwargs) + return "## Recent Activity Summary" + + monkeypatch.setattr( + "basic_memory.mcp.prompts.continue_conversation.recent_activity", fake_recent_activity + ) + + await continue_conversation() # pyright: ignore[reportGeneralTypeIssues] + + assert captured_kwargs["timeframe"] == "7d" + + +@pytest.mark.asyncio +async def test_continue_conversation_no_results_for_topic(monkeypatch): + """Continue conversation should show capture opportunity when no results found.""" + fake_result = SearchResponse(results=[], current_page=1, page_size=10) + + async def fake_search_notes(**kwargs): + return fake_result + + monkeypatch.setattr( + "basic_memory.mcp.prompts.continue_conversation.search_notes", fake_search_notes + ) + + out = await continue_conversation(topic="unknown topic") # pyright: ignore[reportGeneralTypeIssues] + + assert "No previous context found" in out + assert "write_note" in out diff --git a/tests/mcp/test_prompts.py b/tests/mcp/test_prompts.py index 7fd72454..6448a0d7 100644 --- a/tests/mcp/test_prompts.py +++ b/tests/mcp/test_prompts.py @@ -12,54 +12,40 @@ from basic_memory.mcp.prompts.recent_activity import recent_activity_prompt @pytest.mark.asyncio async def test_continue_conversation_with_topic(client, test_graph): """Test continue_conversation with a topic.""" - # We can use the test_graph fixture which already has relevant content - - # Call the function with a topic that should match existing content result = await continue_conversation(topic="Root", timeframe="1w") # pyright: ignore [reportGeneralTypeIssues] - # Check that the result contains expected content - assert "Continuing conversation on: Root" in result # pyright: ignore [reportOperatorIssue] + assert "Continuing conversation on:" in result # pyright: ignore [reportOperatorIssue] + assert "'Root'" in result # pyright: ignore [reportOperatorIssue] assert "This is a memory retrieval session" in result # pyright: ignore [reportOperatorIssue] - assert "Start by executing one of the suggested commands" in result # pyright: ignore [reportOperatorIssue] @pytest.mark.asyncio async def test_continue_conversation_with_recent_activity(client, test_graph): """Test continue_conversation with no topic, using recent activity.""" - # Call the function without a topic result = await continue_conversation(timeframe="1w") # pyright: ignore [reportGeneralTypeIssues] - # Check that the result contains expected content for recent activity - assert "Continuing conversation on: Recent Activity" in result # pyright: ignore [reportOperatorIssue] + assert "Continuing conversation on: recent activity" in result # pyright: ignore [reportOperatorIssue] assert "This is a memory retrieval session" in result # pyright: ignore [reportOperatorIssue] - assert "Please use the available basic-memory tools" in result # pyright: ignore [reportOperatorIssue] assert "Next Steps" in result # pyright: ignore [reportOperatorIssue] @pytest.mark.asyncio async def test_continue_conversation_no_results(client): """Test continue_conversation when no results are found.""" - # Call with a non-existent topic result = await continue_conversation(topic="NonExistentTopic", timeframe="1w") # pyright: ignore [reportGeneralTypeIssues] - # Check the response indicates no results found - assert "Continuing conversation on: NonExistentTopic" in result # pyright: ignore [reportOperatorIssue] - assert "The supplied query did not return any information" in result # pyright: ignore [reportOperatorIssue] + assert "NonExistentTopic" in result # pyright: ignore [reportOperatorIssue] + assert "No previous context found" in result # pyright: ignore [reportOperatorIssue] @pytest.mark.asyncio async def test_continue_conversation_creates_structured_suggestions(client, test_graph): """Test that continue_conversation generates structured tool usage suggestions.""" - # Call the function with a topic that should match existing content result = await continue_conversation(topic="Root", timeframe="1w") # pyright: ignore [reportGeneralTypeIssues] - # Verify the response includes clear tool usage instructions assert "start by executing one of the suggested commands" in result.lower() # pyright: ignore [reportAttributeAccessIssue] - - # Check that the response contains tool call examples assert "read_note" in result # pyright: ignore [reportOperatorIssue] assert "search" in result # pyright: ignore [reportOperatorIssue] - assert "recent_activity" in result # pyright: ignore [reportOperatorIssue] # Search prompt tests @@ -68,38 +54,28 @@ async def test_continue_conversation_creates_structured_suggestions(client, test @pytest.mark.asyncio async def test_search_prompt_with_results(client, test_graph): """Test search_prompt with a query that returns results.""" - # Call the function with a query that should match existing content result = await search_prompt("Root") # pyright: ignore [reportGeneralTypeIssues] - # Check the response contains expected content - assert 'Search Results for: "Root"' in result # pyright: ignore [reportOperatorIssue] - assert "I found " in result # pyright: ignore [reportOperatorIssue] - assert "You can view this content with: `read_note" in result # pyright: ignore [reportOperatorIssue] - assert "Synthesize and Capture Knowledge" in result # pyright: ignore [reportOperatorIssue] + assert 'Search Results: "Root"' in result # pyright: ignore [reportOperatorIssue] + assert "Found" in result # pyright: ignore [reportOperatorIssue] + assert "read_note" in result # pyright: ignore [reportOperatorIssue] @pytest.mark.asyncio async def test_search_prompt_with_timeframe(client, test_graph): """Test search_prompt with a timeframe.""" - # Call the function with a query and timeframe result = await search_prompt("Root", timeframe="1w") # pyright: ignore [reportGeneralTypeIssues] - # Check the response includes timeframe information - assert 'Search Results for: "Root" (after 7d)' in result # pyright: ignore [reportOperatorIssue] - assert "I found " in result # pyright: ignore [reportOperatorIssue] + assert 'Search Results: "Root"' in result # pyright: ignore [reportOperatorIssue] @pytest.mark.asyncio async def test_search_prompt_no_results(client): """Test search_prompt when no results are found.""" - # Call with a query that won't match anything result = await search_prompt("XYZ123NonExistentQuery") # pyright: ignore [reportGeneralTypeIssues] - # Check the response indicates no results found - assert 'Search Results for: "XYZ123NonExistentQuery"' in result # pyright: ignore [reportOperatorIssue] - assert "I couldn't find any results for this query" in result # pyright: ignore [reportOperatorIssue] - assert "Opportunity to Capture Knowledge" in result # pyright: ignore [reportOperatorIssue] - assert "write_note" in result # pyright: ignore [reportOperatorIssue] + assert 'Search Results: "XYZ123NonExistentQuery"' in result # pyright: ignore [reportOperatorIssue] + assert "No results found" in result # pyright: ignore [reportOperatorIssue] # Test utils @@ -150,10 +126,8 @@ def test_prompt_context_with_file_path_no_permalink(): @pytest.mark.asyncio async def test_recent_activity_prompt_discovery_mode(client, test_project, test_graph): """Test recent_activity_prompt in discovery mode (no project).""" - # Call the function in discovery mode result = await recent_activity_prompt(timeframe="1w") # pyright: ignore [reportGeneralTypeIssues] - # Check the response contains expected discovery mode content assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue] assert "all projects" in result # pyright: ignore [reportOperatorIssue] assert "Next Steps" in result # pyright: ignore [reportOperatorIssue] @@ -163,10 +137,8 @@ async def test_recent_activity_prompt_discovery_mode(client, test_project, test_ @pytest.mark.asyncio async def test_recent_activity_prompt_project_specific(client, test_project, test_graph): """Test recent_activity_prompt in project-specific mode.""" - # Call the function with a specific project result = await recent_activity_prompt(timeframe="1w", project=test_project.name) # pyright: ignore [reportGeneralTypeIssues] - # Check the response contains expected project-specific content assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue] assert test_project.name in result # pyright: ignore [reportOperatorIssue] assert "Next Steps" in result # pyright: ignore [reportOperatorIssue] @@ -176,9 +148,7 @@ async def test_recent_activity_prompt_project_specific(client, test_project, tes @pytest.mark.asyncio async def test_recent_activity_prompt_with_custom_timeframe(client, test_project, test_graph): """Test recent_activity_prompt with custom timeframe.""" - # Call the function with a custom timeframe in discovery mode result = await recent_activity_prompt(timeframe="1d") # pyright: ignore [reportGeneralTypeIssues] - # Check the response includes the custom timeframe assert "1d" in result # pyright: ignore [reportOperatorIssue] assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue] diff --git a/tests/sync/test_sync_service.py b/tests/sync/test_sync_service.py index df6c6465..0be9b848 100644 --- a/tests/sync/test_sync_service.py +++ b/tests/sync/test_sync_service.py @@ -1754,3 +1754,59 @@ async def test_sync_handles_file_not_found_gracefully( # Entity should be deleted from database entity = await sync_service.entity_repository.get_by_file_path("missing_file.md") assert entity is None, "Orphaned entity should be deleted when file is not found" + + +@pytest.mark.asyncio +async def test_sync_file_continues_on_semantic_dependency_error( + sync_service: SyncService, project_config: ProjectConfig +): + """Test that sync_file returns the entity even when vector embedding fails. + + When sqlite-vec or another semantic dependency is missing, FTS indexing + still succeeds. The entity should be returned successfully with a warning, + not treated as a file-level failure. + """ + from unittest.mock import AsyncMock + + from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError + + project_dir = project_config.home + content = """--- +type: note +--- +# Semantic Error Test + +## Observations +- [test] This entity should still be synced despite embedding failure +""" + await create_test_file(project_dir / "semantic_test.md", content) + await sync_service.sync(project_dir) + + # Patch index_entity to raise SemanticDependenciesMissingError + original_index = sync_service.search_service.index_entity + call_count = 0 + + async def index_with_semantic_error(entity, **kwargs): + nonlocal call_count + call_count += 1 + raise SemanticDependenciesMissingError("sqlite-vec package is missing") + + sync_service.search_service.index_entity = AsyncMock(side_effect=index_with_semantic_error) + + try: + # Modify the file so it gets re-synced + await create_test_file( + project_dir / "semantic_test.md", + content.replace("should still be synced", "updated content"), + ) + + entity, checksum = await sync_service.sync_file("semantic_test.md", new=False) + + # Entity should be returned successfully despite semantic error + assert entity is not None, "Entity should be returned even when embedding fails" + assert checksum is not None + + # Verify circuit breaker was NOT triggered (failure not recorded) + assert "semantic_test.md" not in sync_service._file_failures + finally: + sync_service.search_service.index_entity = original_index