diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 3b59bed6..4cc47d7a 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -6907,3 +6907,64 @@ func TestHandleSearchWithoutAllProjectsStillScopesToCurrentProject(t *testing.T) t.Fatalf("beta result should not leak into a scoped search; got: %s", text) } } + +// TestHandleSearchLegacyMixedCaseProject reproduces issue #146: +// mem_search returns empty when the DB contains observations stored under a +// mixed-case project name (e.g. "Ebook2Audio") but the query uses the +// normalized lowercase name (e.g. "ebook2audio") — or vice versa. +// +// The MCP path calls resolveReadProject which normalizes the override to +// lowercase, then checks ProjectExists with the lowercase name. Previously +// ProjectExists used a case-sensitive "project = ?" match and returned false +// for mixed-case legacy data, causing handleSearch to return unknown_project. +func TestHandleSearchLegacyMixedCaseProject(t *testing.T) { + s := newMCPTestStore(t) + + // Insert session and observation directly with a mixed-case project name + // to simulate data created by a pre-normalization version of engram. + legacyProject := "Ebook2Audio" + if _, err := s.DB().Exec( + `INSERT INTO sessions (id, project, directory) VALUES (?, ?, ?)`, + "legacy-mcp-sess", legacyProject, "/tmp/ebook", + ); err != nil { + t.Fatalf("insert legacy session: %v", err) + } + if _, err := s.DB().Exec(` + INSERT INTO observations (session_id, type, title, content, project, scope) + VALUES (?, ?, ?, ?, ?, ?)`, + "legacy-mcp-sess", "bugfix", + "Fixed progress reuse in DisplayManager", + "Corrected progress bar reuse so prior run state is not carried over", + legacyProject, "project", + ); err != nil { + t.Fatalf("insert legacy observation: %v", err) + } + if _, err := s.DB().Exec( + `INSERT INTO observations_fts(observations_fts) VALUES('rebuild')`, + ); err != nil { + t.Fatalf("rebuild FTS: %v", err) + } + + search := handleSearch(s, MCPConfig{}, NewSessionActivity(10*time.Minute)) + + // The agent passes the project name as typed (mixed-case). handleSearch + // normalizes it to lowercase and must still resolve and return results. + req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{ + "query": "progress bar", + "project": "Ebook2Audio", // as a user would type it + "limit": 5.0, + }}} + + res, err := search(context.Background(), req) + if err != nil { + t.Fatalf("search handler error: %v", err) + } + if res.IsError { + t.Fatalf("handleSearch returned error for legacy mixed-case project: %s", callResultText(t, res)) + } + + text := callResultText(t, res) + if !strings.Contains(text, "Found") || strings.Contains(text, "No memories found") { + t.Fatalf("expected search results for legacy project, got: %s", text) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 362c6061..f11f7e03 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -538,6 +538,11 @@ func defaultStoreHooks() storeHooks { } } +// DB returns the underlying *sql.DB. Intended for test helpers and integration +// tests that need to inject raw rows (e.g. legacy data with non-normalized +// project names) without going through the Store's public API. +func (s *Store) DB() *sql.DB { return s.db } + func (s *Store) execHook(db execer, query string, args ...any) (sql.Result, error) { if s.hooks.exec != nil { return s.hooks.exec(db, query, args...) @@ -2027,7 +2032,7 @@ func (s *Store) RecentSessions(project string, limit int) ([]SessionSummary, err args := []any{} if project != "" { - query += " AND s.project = ?" + query += " AND LOWER(s.project) = ?" args = append(args, project) } @@ -2298,7 +2303,7 @@ func (s *Store) RecentObservations(project, scope string, limit int) ([]Observat args := []any{} if project != "" { - query += " AND o.project = ?" + query += " AND LOWER(o.project) = ?" args = append(args, project) } if scope != "" { @@ -2899,7 +2904,7 @@ func (s *Store) Search(query string, opts SearchOptions) ([]SearchResult, error) tkArgs = append(tkArgs, opts.Type) } if opts.Project != "" { - tkSQL += " AND project = ?" + tkSQL += " AND LOWER(project) = ?" tkArgs = append(tkArgs, opts.Project) } if opts.Scope != "" { @@ -2947,7 +2952,7 @@ func (s *Store) Search(query string, opts SearchOptions) ([]SearchResult, error) } if opts.Project != "" { - sqlQ += " AND o.project = ?" + sqlQ += " AND LOWER(o.project) = ?" args = append(args, opts.Project) } @@ -3029,15 +3034,19 @@ func (s *Store) Stats() (*Stats, error) { // The sync_enrolled_projects branch ensures a project enrolled via EnrollProject() // without any other data is still recognized (JC1). func (s *Store) ProjectExists(name string) (bool, error) { + // Use LOWER(project) = ? so legacy data stored with mixed-case names + // (created before project normalization was enforced on writes) is found + // when queried with the current normalized (lowercase) name. The caller + // is expected to pass an already-normalized name (NormalizeProject result). const query = ` SELECT 1 FROM ( - SELECT project FROM observations WHERE project = ? AND deleted_at IS NULL + SELECT project FROM observations WHERE LOWER(project) = ? AND deleted_at IS NULL UNION ALL - SELECT project FROM sessions WHERE project = ? + SELECT project FROM sessions WHERE LOWER(project) = ? UNION ALL - SELECT project FROM user_prompts WHERE project = ? + SELECT project FROM user_prompts WHERE LOWER(project) = ? UNION ALL - SELECT project FROM sync_enrolled_projects WHERE project = ? + SELECT project FROM sync_enrolled_projects WHERE LOWER(project) = ? ) LIMIT 1` var dummy int err := s.db.QueryRow(query, name, name, name, name).Scan(&dummy) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index ad84de32..541cf693 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -7715,3 +7715,82 @@ func TestGetDeferred_NotFound(t *testing.T) { t.Errorf("expected error to contain 'not found'; got %q", err.Error()) } } + +// TestSearchLegacyMixedCaseProject reproduces issue #146: +// observations stored with a mixed-case project name (legacy data pre-normalization) +// must be found when searched with a normalized (lowercase) project name. +// +// Previously, Search and ProjectExists used case-sensitive "project = ?" which +// caused all MCP tool calls to return empty results for such projects. +func TestSearchLegacyMixedCaseProject(t *testing.T) { + s := newTestStore(t) + + // Insert a session and observation directly with mixed-case project name, + // bypassing AddObservation normalization to simulate legacy data. + legacyProject := "Ebook2Audio" + _, err := s.db.Exec( + `INSERT INTO sessions (id, project, directory) VALUES (?, ?, ?)`, + "legacy-mixed-sess", legacyProject, "/tmp/ebook", + ) + if err != nil { + t.Fatalf("insert legacy session: %v", err) + } + + _, err = s.db.Exec(` + INSERT INTO observations (session_id, type, title, content, project, scope) + VALUES (?, ?, ?, ?, ?, ?)`, + "legacy-mixed-sess", "bugfix", + "Fixed log routing in DisplayManager", + "Corrected log routing so debug output goes to stderr not stdout", + legacyProject, "project", + ) + if err != nil { + t.Fatalf("insert legacy observation: %v", err) + } + + // Re-build FTS index so the new row is searchable. + if _, err := s.db.Exec(`INSERT INTO observations_fts(observations_fts) VALUES('rebuild')`); err != nil { + t.Fatalf("rebuild FTS: %v", err) + } + + normalizedProject := "ebook2audio" + + // ProjectExists must find the legacy project via case-insensitive match. + exists, err := s.ProjectExists(normalizedProject) + if err != nil { + t.Fatalf("ProjectExists error: %v", err) + } + if !exists { + t.Error("ProjectExists returned false for mixed-case legacy project; want true") + } + + // Search must return the observation when filtering by normalized project name. + results, err := s.Search("log routing", SearchOptions{ + Project: normalizedProject, + Limit: 10, + }) + if err != nil { + t.Fatalf("Search error: %v", err) + } + if len(results) == 0 { + t.Error("Search returned 0 results for legacy mixed-case project; want >=1") + } + + // RecentObservations (used by mem_context) must also find the data. + obs, err := s.RecentObservations(normalizedProject, "", 10) + if err != nil { + t.Fatalf("RecentObservations error: %v", err) + } + if len(obs) == 0 { + t.Error("RecentObservations returned 0 results for legacy mixed-case project; want >=1") + } + + // RecentSessions (used by mem_context) must also find the session. + sessions, err := s.RecentSessions(normalizedProject, 5) + if err != nil { + t.Fatalf("RecentSessions error: %v", err) + } + if len(sessions) == 0 { + t.Error("RecentSessions returned 0 results for legacy mixed-case project; want >=1") + } +}