fix: resolve case-insensitive project switching database lookup issue

Fix project switching bug where case-insensitive matching worked but
caused database lookup failures for subsequent operations.

**Problem:**
- switch_project('personal') succeeded (case-insensitive matching)
- get_current_project() failed with 'Project personal not found'
- Session stored user input case instead of canonical database name

**Solution:**
- Find project by permalink (case-insensitive) in switch_project
- Store canonical project name from database in session
- Use canonical name for all API calls and responses

**Test Coverage:**
- Added comprehensive case-insensitive project switching tests
- Added tests for case preservation in project listings
- Added tests for session state consistency after case switching
- Added error handling tests for non-existent projects

**Files Changed:**
- src/basic_memory/mcp/tools/project_management.py: Fixed switch_project logic
- test-int/mcp/test_project_management_integration.py: Added test coverage

**Test Cases Now Passing:**
-  switch_project('personal') → finds 'Personal' project
-  get_current_project() → works with canonical name
-  Project summary shows stats correctly
-  Case-insensitive matching for all case variations
-  Error handling for non-existent projects

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
phernandez
2025-06-11 18:19:17 -05:00
parent 85a178a6b8
commit 5b69fd65cd
5 changed files with 287 additions and 19 deletions
+8 -5
View File
@@ -256,11 +256,14 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
# the config contains a dict[str,str] of project names and absolute paths
assert actual_project_name is not None, "actual_project_name cannot be None"
project_path = app_config.projects.get(actual_project_name)
if not project_path: # pragma: no cover
raise ValueError(f"Project '{actual_project_name}' not found")
project_permalink = generate_permalink(actual_project_name)
return ProjectConfig(name=actual_project_name, home=Path(project_path))
for name, path in app_config.projects.items():
if project_permalink == generate_permalink(name):
return ProjectConfig(name=name, home=Path(path))
# otherwise raise error
raise ValueError(f"Project '{actual_project_name}' not found")
# Create config manager
@@ -335,4 +338,4 @@ def setup_basic_memory_logging(): # pragma: no cover
# Set up logging
setup_basic_memory_logging()
setup_basic_memory_logging()
@@ -85,14 +85,20 @@ async def switch_project(project_name: str, ctx: Context | None = None) -> str:
response = await call_get(client, "/projects/projects")
project_list = ProjectList.model_validate(response.json())
# Check if project exists
project_exists = any(p.permalink == project_permalink for p in project_list.projects)
if not project_exists:
# Find the project by permalink (case-insensitive)
target_project = None
for p in project_list.projects:
if p.permalink == project_permalink:
target_project = p
break
if not target_project:
available_projects = [p.name for p in project_list.projects]
return f"Error: Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
# Switch to the project
session.set_current_project(project_permalink)
# Switch to the project using the canonical name from database
canonical_name = target_project.name
session.set_current_project(canonical_name)
current_project = session.get_current_project()
project_config = get_project_config(current_project)
@@ -101,11 +107,11 @@ async def switch_project(project_name: str, ctx: Context | None = None) -> str:
response = await call_get(
client,
f"{project_config.project_url}/project/info",
params={"project_name": project_permalink},
params={"project_name": canonical_name},
)
project_info = ProjectInfoResponse.model_validate(response.json())
result = f"✓ Switched to {project_permalink} project\n\n"
result = f"✓ Switched to {canonical_name} project\n\n"
result += "Project Summary:\n"
result += f"{project_info.statistics.total_entities} entities\n"
result += f"{project_info.statistics.total_observations} observations\n"
@@ -113,11 +119,11 @@ async def switch_project(project_name: str, ctx: Context | None = None) -> str:
except Exception as e:
# If we can't get project info, still confirm the switch
logger.warning(f"Could not get project info for {project_name}: {e}")
result = f"✓ Switched to {project_name} project\n\n"
logger.warning(f"Could not get project info for {canonical_name}: {e}")
result = f"✓ Switched to {canonical_name} project\n\n"
result += "Project summary unavailable.\n"
return add_project_metadata(result, project_name)
return add_project_metadata(result, canonical_name)
except Exception as e:
logger.error(f"Error switching to project {project_name}: {e}")
@@ -331,4 +337,4 @@ async def delete_project(project_name: str, ctx: Context | None = None) -> str:
result += "Files remain on disk but project is no longer tracked by Basic Memory.\n"
result += "Re-add the project to access its content again.\n"
return add_project_metadata(result, session.get_current_project())
return add_project_metadata(result, session.get_current_project())
+2 -2
View File
@@ -185,9 +185,9 @@ class ProjectItem(BaseModel):
name: str
path: str
is_default: bool = False
@property
def permalink(self) -> str: # pragma: no cover
def permalink(self) -> str: # pragma: no cover
return generate_permalink(self.name)
+1 -1
View File
@@ -668,4 +668,4 @@ class ProjectService:
database_size=db_size_readable,
watch_status=watch_status,
timestamp=datetime.now(),
)
)