Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions internal/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
25 changes: 17 additions & 8 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down
79 changes: 79 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading