From 6ff39076a09bcbb2c9c8ed8deb4fe4bdcd77d3bd Mon Sep 17 00:00:00 2001 From: phernandez Date: Sun, 22 Feb 2026 00:33:25 -0600 Subject: [PATCH] fix: double-default display in project list + stale test updates Use config.default_project as single source of truth for the Default column in `bm project list`, removing checks against local DB and cloud API is_default fields that could independently mark multiple projects. Also updates tests that were out of date after get_project_mode changed to default unknown projects to CLOUD: - test_get_client_local_project_uses_asgi_transport: register "main" as LOCAL - test_run_filters_cloud_projects_each_cycle: register local project in config - test_new_project_addition_scenario: register projects as LOCAL in config - test_get_project_mode_defaults_to_cloud: assert new CLOUD default Co-Authored-By: Claude Opus 4.6 Signed-off-by: phernandez --- src/basic_memory/cli/commands/project.py | 30 +++-- src/basic_memory/cli/commands/tool.py | 56 ++------ src/basic_memory/config.py | 6 +- src/basic_memory/mcp/project_context.py | 2 +- tests/cli/test_cli_tool_json_output.py | 161 ++++------------------- tests/mcp/test_async_client_modes.py | 1 + tests/mcp/test_project_context.py | 48 ++++++- tests/sync/test_watch_service_reload.py | 8 +- tests/test_config.py | 10 +- 9 files changed, 121 insertions(+), 201 deletions(-) diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 2f4ea215..e9e6f678 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -161,13 +161,7 @@ def list_projects( else: cli_route = ProjectMode.LOCAL.value - is_default = "" - if config.default_project == project_name: - is_default = "[X]" - if local_project is not None and local_project.is_default: - is_default = "[X]" - if cloud_project is not None and cloud_project.is_default: - is_default = "[X]" + is_default = "[X]" if config.default_project == project_name else "" has_sync = "[X]" if entry and entry.local_sync_path else "" mcp_stdio_target = "local" if local_project is not None else "n/a" @@ -326,7 +320,16 @@ def remove_project( raise typer.Exit(1) async def _remove_project(): - async with get_client() as client: + # Resolve workspace so cloud-only projects auto-route without --cloud + config = ConfigManager().config + entry = config.projects.get(name) + ws = None + if entry and entry.workspace_id: + ws = entry.workspace_id + elif config.default_workspace: + ws = config.default_workspace + + async with get_client(project_name=name, workspace=ws) as client: project_client = ProjectClient(client) # Convert name to permalink for efficient resolution project_permalink = generate_permalink(name) @@ -405,7 +408,16 @@ def set_default_project( """ async def _set_default(): - async with get_client() as client: + # Resolve workspace so cloud-only projects auto-route without flags + config = ConfigManager().config + entry = config.projects.get(name) + ws = None + if entry and entry.workspace_id: + ws = entry.workspace_id + elif config.default_workspace: + ws = config.default_workspace + + async with get_client(project_name=name, workspace=ws) as client: project_client = ProjectClient(client) # Convert name to permalink for efficient resolution project_permalink = generate_permalink(name) diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index bd6eeb9c..9eae841b 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -14,7 +14,6 @@ from loguru import logger from basic_memory.cli.app import app from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.cli.commands.routing import force_routing, validate_routing_flags -from basic_memory.config import ConfigManager from basic_memory.mcp.tools import build_context as mcp_build_context from basic_memory.mcp.tools import edit_note as mcp_edit_note from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects @@ -36,16 +35,6 @@ VALID_EDIT_OPERATIONS = ["append", "prepend", "find_replace", "replace_section"] # --- Shared helpers --- -def _resolve_project(config_manager: ConfigManager, project: Optional[str]) -> Optional[str]: - """Resolve project name from CLI arg or config default.""" - if project is not None: - project_name, _ = config_manager.get_project(project) - if not project_name: - raise ValueError(f"No project found named: {project}") - return project_name - return config_manager.default_project - - def _print_json(result: Any) -> None: """Print a result as formatted JSON.""" print(json.dumps(result, indent=2, ensure_ascii=True, default=str)) @@ -108,9 +97,6 @@ def write_note( typer.echo("Empty content provided. Please provide non-empty content.", err=True) raise typer.Exit(1) - config_manager = ConfigManager() - project_name = _resolve_project(config_manager, project) - assert content is not None with force_routing(local=local, cloud=cloud): @@ -119,7 +105,7 @@ def write_note( title=title, content=content, directory=folder, - project=project_name, + project=project, workspace=workspace, tags=tags, output_format="json", @@ -168,14 +154,11 @@ def read_note( try: validate_routing_flags(local, cloud) - config_manager = ConfigManager() - project_name = _resolve_project(config_manager, project) - with force_routing(local=local, cloud=cloud): result = run_with_cleanup( mcp_read_note( identifier=identifier, - project=project_name, + project=project, workspace=workspace, page=page, page_size=page_size, @@ -237,16 +220,13 @@ def edit_note( try: validate_routing_flags(local, cloud) - config_manager = ConfigManager() - project_name = _resolve_project(config_manager, project) - with force_routing(local=local, cloud=cloud): result = run_with_cleanup( mcp_edit_note( identifier=identifier, operation=operation, content=content, - project=project_name, + project=project, workspace=workspace, section=section, find_text=find_text, @@ -304,14 +284,11 @@ def build_context( try: validate_routing_flags(local, cloud) - config_manager = ConfigManager() - project_name = _resolve_project(config_manager, project) - with force_routing(local=local, cloud=cloud): result = run_with_cleanup( mcp_build_context( url=url, - project=project_name, + project=project, workspace=workspace, depth=depth, timeframe=timeframe, @@ -365,9 +342,6 @@ def recent_activity( try: validate_routing_flags(local, cloud) - config_manager = ConfigManager() - project_name = _resolve_project(config_manager, project) - with force_routing(local=local, cloud=cloud): result = run_with_cleanup( mcp_recent_activity( @@ -376,7 +350,7 @@ def recent_activity( timeframe=timeframe if timeframe is not None else "7d", page=page, page_size=page_size, - project=project_name, + project=project, workspace=workspace, output_format="json", ) @@ -460,9 +434,6 @@ def search_notes( try: validate_routing_flags(local, cloud) - config_manager = ConfigManager() - project_name = _resolve_project(config_manager, project) - mode_flags = [permalink, title, vector, hybrid] if sum(1 for enabled in mode_flags if enabled) > 1: # pragma: no cover typer.echo( @@ -515,7 +486,7 @@ def search_notes( result = run_with_cleanup( mcp_search( query=query or "", - project=project_name, + project=project, workspace=workspace, search_type=search_type, output_format="json", @@ -649,9 +620,6 @@ def schema_validate( try: validate_routing_flags(local, cloud) - config_manager = ConfigManager() - project_name = _resolve_project(config_manager, project) - # Heuristic: if target contains / or ., treat as identifier; otherwise as note type note_type, identifier = None, None if target: @@ -665,7 +633,7 @@ def schema_validate( mcp_schema_validate( note_type=note_type, identifier=identifier, - project=project_name, + project=project, workspace=workspace, output_format="json", ) @@ -717,15 +685,12 @@ def schema_infer( try: validate_routing_flags(local, cloud) - config_manager = ConfigManager() - project_name = _resolve_project(config_manager, project) - with force_routing(local=local, cloud=cloud): result = run_with_cleanup( mcp_schema_infer( note_type=entity_type, threshold=threshold, - project=project_name, + project=project, workspace=workspace, output_format="json", ) @@ -773,14 +738,11 @@ def schema_diff( try: validate_routing_flags(local, cloud) - config_manager = ConfigManager() - project_name = _resolve_project(config_manager, project) - with force_routing(local=local, cloud=cloud): result = run_with_cleanup( mcp_schema_diff( note_type=entity_type, - project=project_name, + project=project, workspace=workspace, output_format="json", ) diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index fdd976b5..ebef14ff 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -425,10 +425,12 @@ class BasicMemoryConfig(BaseSettings): def get_project_mode(self, project_name: str) -> ProjectMode: """Get the routing mode for a project. - Returns the per-project mode if set, otherwise LOCAL. + Returns the per-project mode if set. + Unknown projects (not in local config) default to CLOUD — + local projects are always registered in config. """ entry = self.projects.get(project_name) - return entry.mode if entry else ProjectMode.LOCAL + return entry.mode if entry else ProjectMode.CLOUD def set_project_mode(self, project_name: str, mode: ProjectMode) -> None: """Set the routing mode for a project. diff --git a/src/basic_memory/mcp/project_context.py b/src/basic_memory/mcp/project_context.py index 9107c077..cc058df9 100644 --- a/src/basic_memory/mcp/project_context.py +++ b/src/basic_memory/mcp/project_context.py @@ -445,6 +445,7 @@ async def get_project_client( # Step 3: Determine if cloud routing is needed config = ConfigManager().config + project_entry = config.projects.get(resolved_project) project_mode = config.get_project_mode(resolved_project) # Trigger: workspace provided for a local project (without explicit --cloud) @@ -459,7 +460,6 @@ async def get_project_client( if project_mode == ProjectMode.CLOUD or (_explicit_routing() and not _force_local_mode()): # --- Cloud routing: resolve workspace with priority chain --- effective_workspace = workspace - project_entry = config.projects.get(resolved_project) # Priority 2: per-project workspace_id from config if effective_workspace is None and project_entry and project_entry.workspace_id: diff --git a/tests/cli/test_cli_tool_json_output.py b/tests/cli/test_cli_tool_json_output.py index 7c09e09f..816b4fdf 100644 --- a/tests/cli/test_cli_tool_json_output.py +++ b/tests/cli/test_cli_tool_json_output.py @@ -5,7 +5,7 @@ Tests mock the MCP tool functions directly. """ import json -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch from typer.testing import CliRunner @@ -79,28 +79,16 @@ SEARCH_RESULT = { } -def _mock_config_manager(): - """Create a mock ConfigManager that avoids reading real config.""" - mock_cm = MagicMock() - mock_cm.config = MagicMock() - mock_cm.default_project = "test-project" - mock_cm.get_project.return_value = ("test-project", "/tmp/test") - return mock_cm - - # --- write-note --- -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_write_note", new_callable=AsyncMock, return_value=WRITE_NOTE_RESULT, ) -def test_write_note_json_output(mock_mcp_write, mock_config_cls): +def test_write_note_json_output(mock_mcp_write): """write-note outputs valid JSON from MCP tool.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, [ @@ -125,16 +113,13 @@ def test_write_note_json_output(mock_mcp_write, mock_config_cls): assert mock_mcp_write.call_args.kwargs["output_format"] == "json" -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_write_note", new_callable=AsyncMock, return_value=WRITE_NOTE_RESULT, ) -def test_write_note_with_tags(mock_mcp_write, mock_config_cls): +def test_write_note_with_tags(mock_mcp_write): """write-note passes tags through to MCP tool.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, [ @@ -160,16 +145,13 @@ def test_write_note_with_tags(mock_mcp_write, mock_config_cls): # --- read-note --- -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_read_note", new_callable=AsyncMock, return_value=READ_NOTE_RESULT, ) -def test_read_note_json_output(mock_mcp_read, mock_config_cls): +def test_read_note_json_output(mock_mcp_read): """read-note outputs valid JSON from MCP tool.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "read-note", "test-note"], @@ -185,16 +167,13 @@ def test_read_note_json_output(mock_mcp_read, mock_config_cls): assert mock_mcp_read.call_args.kwargs["output_format"] == "json" -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_read_note", new_callable=AsyncMock, return_value=READ_NOTE_RESULT, ) -def test_read_note_workspace_passthrough(mock_mcp_read, mock_config_cls): +def test_read_note_workspace_passthrough(mock_mcp_read): """read-note --workspace passes workspace through to the MCP tool call.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "read-note", "test-note", "--workspace", "tenant-123"], @@ -204,16 +183,13 @@ def test_read_note_workspace_passthrough(mock_mcp_read, mock_config_cls): assert mock_mcp_read.call_args.kwargs["workspace"] == "tenant-123" -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_read_note", new_callable=AsyncMock, return_value=READ_NOTE_RESULT, ) -def test_read_note_include_frontmatter(mock_mcp_read, mock_config_cls): +def test_read_note_include_frontmatter(mock_mcp_read): """read-note --include-frontmatter passes flag through to MCP tool.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "read-note", "test-note", "--include-frontmatter"], @@ -223,16 +199,13 @@ def test_read_note_include_frontmatter(mock_mcp_read, mock_config_cls): assert mock_mcp_read.call_args.kwargs["include_frontmatter"] is True -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_read_note", new_callable=AsyncMock, return_value=READ_NOTE_RESULT, ) -def test_read_note_pagination(mock_mcp_read, mock_config_cls): +def test_read_note_pagination(mock_mcp_read): """read-note --page and --page-size are passed through.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "read-note", "test-note", "--page", "2", "--page-size", "5"], @@ -246,16 +219,13 @@ def test_read_note_pagination(mock_mcp_read, mock_config_cls): # --- edit-note --- -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_edit_note", new_callable=AsyncMock, return_value=EDIT_NOTE_RESULT, ) -def test_edit_note_json_output(mock_mcp_edit, mock_config_cls): +def test_edit_note_json_output(mock_mcp_edit): """edit-note outputs valid JSON from MCP tool.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, [ @@ -277,16 +247,13 @@ def test_edit_note_json_output(mock_mcp_edit, mock_config_cls): assert mock_mcp_edit.call_args.kwargs["output_format"] == "json" -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_edit_note", new_callable=AsyncMock, return_value={"title": "Test", "permalink": "test", "error": "Edit failed: not found"}, ) -def test_edit_note_error_response(mock_mcp_edit, mock_config_cls): +def test_edit_note_error_response(mock_mcp_edit): """edit-note exits with code 1 when MCP tool returns error field.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, [ @@ -306,16 +273,13 @@ def test_edit_note_error_response(mock_mcp_edit, mock_config_cls): # --- build-context --- -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_build_context", new_callable=AsyncMock, return_value=BUILD_CONTEXT_RESULT, ) -def test_build_context_json_output(mock_build_ctx, mock_config_cls): +def test_build_context_json_output(mock_build_ctx): """build-context outputs valid JSON from MCP tool.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "build-context", "memory://test/topic"], @@ -328,16 +292,13 @@ def test_build_context_json_output(mock_build_ctx, mock_config_cls): assert mock_build_ctx.call_args.kwargs["output_format"] == "json" -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_build_context", new_callable=AsyncMock, return_value=BUILD_CONTEXT_RESULT, ) -def test_build_context_with_options(mock_build_ctx, mock_config_cls): +def test_build_context_with_options(mock_build_ctx): """build-context passes depth, timeframe, pagination through.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, [ @@ -366,16 +327,13 @@ def test_build_context_with_options(mock_build_ctx, mock_config_cls): # --- recent-activity --- -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_recent_activity", new_callable=AsyncMock, return_value=RECENT_ACTIVITY_RESULT, ) -def test_recent_activity_json_output(mock_mcp_recent, mock_config_cls): +def test_recent_activity_json_output(mock_mcp_recent): """recent-activity outputs valid JSON list from MCP tool.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "recent-activity"], @@ -391,16 +349,13 @@ def test_recent_activity_json_output(mock_mcp_recent, mock_config_cls): assert mock_mcp_recent.call_args.kwargs["output_format"] == "json" -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_recent_activity", new_callable=AsyncMock, return_value=RECENT_ACTIVITY_RESULT, ) -def test_recent_activity_pagination(mock_mcp_recent, mock_config_cls): +def test_recent_activity_pagination(mock_mcp_recent): """recent-activity passes --page and --page-size through.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "recent-activity", "--page", "2", "--page-size", "10"], @@ -412,16 +367,13 @@ def test_recent_activity_pagination(mock_mcp_recent, mock_config_cls): assert kwargs["page_size"] == 10 -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_recent_activity", new_callable=AsyncMock, return_value=[], ) -def test_recent_activity_empty(mock_mcp_recent, mock_config_cls): +def test_recent_activity_empty(mock_mcp_recent): """recent-activity handles empty results.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "recent-activity"], @@ -435,16 +387,13 @@ def test_recent_activity_empty(mock_mcp_recent, mock_config_cls): # --- search-notes --- -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_search", new_callable=AsyncMock, return_value=SEARCH_RESULT, ) -def test_search_notes_json_output(mock_mcp_search, mock_config_cls): +def test_search_notes_json_output(mock_mcp_search): """search-notes outputs valid JSON from MCP tool.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "search-notes", "test query"], @@ -458,16 +407,13 @@ def test_search_notes_json_output(mock_mcp_search, mock_config_cls): assert mock_mcp_search.call_args.kwargs["output_format"] == "json" -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_search", new_callable=AsyncMock, return_value=SEARCH_RESULT, ) -def test_search_notes_with_meta_filter(mock_mcp_search, mock_config_cls): +def test_search_notes_with_meta_filter(mock_mcp_search): """search-notes --meta key=value builds metadata filters.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "search-notes", "query", "--meta", "status=draft"], @@ -477,16 +423,13 @@ def test_search_notes_with_meta_filter(mock_mcp_search, mock_config_cls): assert mock_mcp_search.call_args.kwargs["metadata_filters"] == {"status": "draft"} -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_search", new_callable=AsyncMock, return_value=SEARCH_RESULT, ) -def test_search_notes_permalink_mode(mock_mcp_search, mock_config_cls): +def test_search_notes_permalink_mode(mock_mcp_search): """search-notes --permalink sets search_type.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "search-notes", "specs/*", "--permalink"], @@ -496,16 +439,13 @@ def test_search_notes_permalink_mode(mock_mcp_search, mock_config_cls): assert mock_mcp_search.call_args.kwargs["search_type"] == "permalink" -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_search", new_callable=AsyncMock, return_value="Error: search failed", ) -def test_search_notes_string_error(mock_mcp_search, mock_config_cls): +def test_search_notes_string_error(mock_mcp_search): """search-notes exits with code 1 when MCP returns string error.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "search-notes", "query"], @@ -514,41 +454,6 @@ def test_search_notes_string_error(mock_mcp_search, mock_config_cls): assert result.exit_code == 1 -# --- Project resolution --- - - -@patch("basic_memory.cli.commands.tool.ConfigManager") -@patch( - "basic_memory.cli.commands.tool.mcp_write_note", - new_callable=AsyncMock, - return_value=WRITE_NOTE_RESULT, -) -def test_project_not_found_error(mock_mcp_write, mock_config_cls): - """Commands exit with error when specified project doesn't exist.""" - mock_cm = _mock_config_manager() - mock_cm.get_project.return_value = (None, None) - mock_config_cls.return_value = mock_cm - - result = runner.invoke( - cli_app, - [ - "tool", - "write-note", - "--title", - "Test", - "--folder", - "notes", - "--content", - "hello", - "--project", - "nonexistent", - ], - ) - - assert result.exit_code == 1 - assert "No project found" in result.output - - # --- Routing flags --- @@ -595,16 +500,13 @@ SCHEMA_VALIDATE_RESULT = { } -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_schema_validate", new_callable=AsyncMock, return_value=SCHEMA_VALIDATE_RESULT, ) -def test_schema_validate_json_output(mock_mcp, mock_config_cls): +def test_schema_validate_json_output(mock_mcp): """schema-validate outputs valid JSON from MCP tool.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "schema-validate", "person"], @@ -618,16 +520,13 @@ def test_schema_validate_json_output(mock_mcp, mock_config_cls): assert mock_mcp.call_args.kwargs["output_format"] == "json" -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_schema_validate", new_callable=AsyncMock, return_value=SCHEMA_VALIDATE_RESULT, ) -def test_schema_validate_identifier_heuristic(mock_mcp, mock_config_cls): +def test_schema_validate_identifier_heuristic(mock_mcp): """schema-validate treats target with / as identifier, not note_type.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "schema-validate", "people/alice.md"], @@ -638,16 +537,13 @@ def test_schema_validate_identifier_heuristic(mock_mcp, mock_config_cls): assert mock_mcp.call_args.kwargs["note_type"] is None -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_schema_validate", new_callable=AsyncMock, return_value={"error": "No notes found of type 'person'"}, ) -def test_schema_validate_error_response(mock_mcp, mock_config_cls): +def test_schema_validate_error_response(mock_mcp): """schema-validate outputs error JSON when MCP returns error dict.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "schema-validate", "person"], @@ -674,16 +570,13 @@ SCHEMA_INFER_RESULT = { } -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_schema_infer", new_callable=AsyncMock, return_value=SCHEMA_INFER_RESULT, ) -def test_schema_infer_json_output(mock_mcp, mock_config_cls): +def test_schema_infer_json_output(mock_mcp): """schema-infer outputs valid JSON from MCP tool.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "schema-infer", "person"], @@ -697,16 +590,13 @@ def test_schema_infer_json_output(mock_mcp, mock_config_cls): assert mock_mcp.call_args.kwargs["output_format"] == "json" -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_schema_infer", new_callable=AsyncMock, return_value=SCHEMA_INFER_RESULT, ) -def test_schema_infer_threshold_passthrough(mock_mcp, mock_config_cls): +def test_schema_infer_threshold_passthrough(mock_mcp): """schema-infer passes --threshold through to MCP tool.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "schema-infer", "person", "--threshold", "0.5"], @@ -729,16 +619,13 @@ SCHEMA_DIFF_RESULT = { } -@patch("basic_memory.cli.commands.tool.ConfigManager") @patch( "basic_memory.cli.commands.tool.mcp_schema_diff", new_callable=AsyncMock, return_value=SCHEMA_DIFF_RESULT, ) -def test_schema_diff_json_output(mock_mcp, mock_config_cls): +def test_schema_diff_json_output(mock_mcp): """schema-diff outputs valid JSON from MCP tool.""" - mock_config_cls.return_value = _mock_config_manager() - result = runner.invoke( cli_app, ["tool", "schema-diff", "person"], diff --git a/tests/mcp/test_async_client_modes.py b/tests/mcp/test_async_client_modes.py index f276f4ed..f65c4f2e 100644 --- a/tests/mcp/test_async_client_modes.py +++ b/tests/mcp/test_async_client_modes.py @@ -128,6 +128,7 @@ async def test_get_client_local_project_uses_asgi_transport(config_manager): """Local-mode project uses ASGI transport even if API key exists.""" cfg = config_manager.load_config() cfg.cloud_api_key = "bmc_test_key_123" + cfg.set_project_mode("main", ProjectMode.LOCAL) config_manager.save_config(cfg) async with get_client(project_name="main") as client: diff --git a/tests/mcp/test_project_context.py b/tests/mcp/test_project_context.py index 89bb7fa7..3eea0e45 100644 --- a/tests/mcp/test_project_context.py +++ b/tests/mcp/test_project_context.py @@ -266,8 +266,15 @@ async def test_workspace_uses_cached_workspace_without_fetch(monkeypatch): @pytest.mark.asyncio -async def test_get_project_client_rejects_workspace_for_local_project(): +async def test_get_project_client_rejects_workspace_for_local_project(config_manager): from basic_memory.mcp.project_context import get_project_client + from basic_memory.config import ProjectEntry + + # Register "main" as a LOCAL project so get_project_mode returns LOCAL + config = config_manager.load_config() + (config_manager.config_dir.parent / "main").mkdir(parents=True, exist_ok=True) + config.projects["main"] = ProjectEntry(path=str(config_manager.config_dir.parent / "main")) + config_manager.save_config(config) with pytest.raises( ValueError, match="Workspace 'tenant-123' cannot be used with local project" @@ -429,3 +436,42 @@ class TestGetProjectClientRoutingOrder: error_msg = str(exc_info.value).lower() assert "resolve_workspace_parameter should not be called" not in error_msg + + @pytest.mark.asyncio + async def test_cloud_only_project_routes_to_cloud(self, config_manager, monkeypatch): + """Project NOT in local config should route to cloud (not default to LOCAL). + + Cloud-only projects aren't registered in local config. The routing logic + should detect this and use CLOUD mode, falling back to default_workspace. + """ + from basic_memory.mcp.project_context import get_project_client + + config = config_manager.load_config() + # Do NOT add "cloud-only-proj" to config.projects — it's cloud-only + config.default_workspace = "global-default-tenant-id" + config.cloud_api_key = "bmc_test123" + config_manager.save_config(config) + + # Patch resolve_workspace_parameter to fail if called — it should be skipped + # because default_workspace is set (priority 3) + async def fail_if_called(**kwargs): # pragma: no cover + raise AssertionError( + "resolve_workspace_parameter should not be called when default_workspace is set" + ) + + monkeypatch.setattr( + "basic_memory.mcp.project_context.resolve_workspace_parameter", + fail_if_called, + ) + + # Will fail at cloud client creation (no real cloud), but proves cloud routing + # was selected instead of local routing + with pytest.raises(Exception) as exc_info: + async with get_project_client(project="cloud-only-proj"): + pass + + # The error should NOT be about workspace resolution or local routing + error_msg = str(exc_info.value).lower() + assert "resolve_workspace_parameter should not be called" not in error_msg + # Should not get a local ASGI routing error + assert "no project found" not in error_msg diff --git a/tests/sync/test_watch_service_reload.py b/tests/sync/test_watch_service_reload.py index 30198472..3291a58f 100644 --- a/tests/sync/test_watch_service_reload.py +++ b/tests/sync/test_watch_service_reload.py @@ -148,6 +148,7 @@ async def test_run_filters_cloud_projects_each_cycle(monkeypatch, tmp_path): config = BasicMemoryConfig( watch_project_reload_interval=1, projects={ + "local-project": {"path": str(tmp_path / "local"), "mode": "local"}, "cloud-project": {"path": str(tmp_path / "cloud"), "mode": "cloud"}, }, ) @@ -258,7 +259,12 @@ async def test_timer_task_cancelled_properly(monkeypatch, tmp_path): @pytest.mark.asyncio async def test_new_project_addition_scenario(monkeypatch, tmp_path): - config = BasicMemoryConfig() + config = BasicMemoryConfig( + projects={ + "existing": {"path": str(tmp_path / "existing"), "mode": "local"}, + "new": {"path": str(tmp_path / "new"), "mode": "local"}, + }, + ) initial_projects = [ Project(id=1, name="existing", path=str(tmp_path / "existing"), permalink="existing") diff --git a/tests/test_config.py b/tests/test_config.py index 15ad5908..27864bef 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -830,10 +830,14 @@ class TestProjectMode: assert ProjectMode.LOCAL.value == "local" assert ProjectMode.CLOUD.value == "cloud" - def test_get_project_mode_defaults_to_local(self): - """Test that unknown projects default to LOCAL mode.""" + def test_get_project_mode_defaults_to_cloud(self): + """Test that unknown projects default to CLOUD mode. + + Unknown projects are not registered in local config, so they + are assumed to be cloud-only projects discovered from the API. + """ config = BasicMemoryConfig() - assert config.get_project_mode("nonexistent") == ProjectMode.LOCAL + assert config.get_project_mode("nonexistent") == ProjectMode.CLOUD def test_set_project_mode_cloud(self): """Test setting a project to cloud mode."""