package seahorse import ( "context" "fmt" "testing" "time" ) // --- Retrieval Tests --- func newTestRetrieval(t *testing.T) (*RetrievalEngine, *Store, int64) { t.Helper() s := openTestStore(t) ctx := context.Background() conv, _ := s.GetOrCreateConversation(ctx, "test:retrieval") return &RetrievalEngine{store: s}, s, conv.ConversationID } func TestRetrievalGrepSummaries(t *testing.T) { r, s, convID := newTestRetrieval(t) ctx := context.Background() s.CreateSummary(ctx, CreateSummaryInput{ ConversationID: convID, Kind: SummaryKindLeaf, Depth: 0, Content: "数据库连接配置说明", TokenCount: 50, }) s.CreateSummary(ctx, CreateSummaryInput{ ConversationID: convID, Kind: SummaryKindLeaf, Depth: 0, Content: "API endpoint documentation", TokenCount: 50, }) // FTS5 search (trigram, needs >= 3 chars) results, err := r.Grep(ctx, GrepInput{ Pattern: "数据库连", }) if err != nil { t.Fatalf("Grep: %v", err) } if len(results.Summaries) == 0 { t.Error("expected at least 1 FTS result") } // LIKE search with wildcard results, err = r.Grep(ctx, GrepInput{ Pattern: "%endpoint%", }) if err != nil { t.Fatalf("Grep LIKE: %v", err) } if len(results.Summaries) == 0 { t.Error("expected at least 1 LIKE result") } } func TestRetrievalGrepMessages(t *testing.T) { r, s, convID := newTestRetrieval(t) ctx := context.Background() s.AddMessage(ctx, convID, "user", "find this message about testing", 5) s.AddMessage(ctx, convID, "user", "unrelated content here", 5) results, err := r.Grep(ctx, GrepInput{ Pattern: "testing", }) if err != nil { t.Fatalf("Grep: %v", err) } if len(results.Messages) == 0 { t.Error("expected at least 1 result for 'testing'") } } func TestRetrievalExpandMessages(t *testing.T) { r, s, convID := newTestRetrieval(t) ctx := context.Background() msg, _ := s.AddMessage(ctx, convID, "user", "expand this message", 10) result, err := r.ExpandMessages(ctx, []int64{msg.ID}) if err != nil { t.Fatalf("ExpandMessages: %v", err) } if len(result.Messages) != 1 { t.Errorf("Messages = %d, want 1", len(result.Messages)) } if result.Messages[0].Content != "expand this message" { t.Errorf("Content = %q, want 'expand this message'", result.Messages[0].Content) } } func TestRetrievalExpandMultipleMessages(t *testing.T) { r, s, convID := newTestRetrieval(t) ctx := context.Background() msg1, _ := s.AddMessage(ctx, convID, "user", "first message", 10) msg2, _ := s.AddMessage(ctx, convID, "assistant", "second message", 10) msg3, _ := s.AddMessage(ctx, convID, "user", "third message", 10) result, err := r.ExpandMessages(ctx, []int64{msg1.ID, msg2.ID, msg3.ID}) if err != nil { t.Fatalf("ExpandMessages: %v", err) } if len(result.Messages) != 3 { t.Errorf("Messages = %d, want 3", len(result.Messages)) } if result.TokenCount != 30 { t.Errorf("TokenCount = %d, want 30", result.TokenCount) } } func TestRetrievalGrepWithTimeFilter(t *testing.T) { r, s, convID := newTestRetrieval(t) ctx := context.Background() now := time.Now().UTC() before := now.Add(-2 * time.Hour) // Create messages at different times s.AddMessage(ctx, convID, "user", "old message about auth", 5) s.AddMessage(ctx, convID, "user", "recent message about auth", 5) // Search with time filter results, err := r.Grep(ctx, GrepInput{ Pattern: "auth", Since: &before, }) if err != nil { t.Fatalf("Grep: %v", err) } _ = results // Just verify no error } func TestRetrievalGrepAllConversations(t *testing.T) { r, s, _ := newTestRetrieval(t) ctx := context.Background() // Create another conversation conv2, _ := s.GetOrCreateConversation(ctx, "test:retrieval2") // Add messages to both s.AddMessage(ctx, conv2.ConversationID, "user", "unique keyword xyz", 5) // Search all conversations results, err := r.Grep(ctx, GrepInput{ Pattern: "xyz", AllConversations: true, }) if err != nil { t.Fatalf("Grep: %v", err) } if len(results.Messages) == 0 { t.Error("expected to find message in other conversation") } } // --- Last Duration Parsing Tests --- func TestParseLastDuration(t *testing.T) { tests := []struct { input string wantDur time.Duration wantErr bool }{ {"6h", 6 * time.Hour, false}, {"1d", 24 * time.Hour, false}, {"7d", 7 * 24 * time.Hour, false}, {"2w", 14 * 24 * time.Hour, false}, {"1m", 30 * 24 * time.Hour, false}, // month = 30 days {"3m", 90 * 24 * time.Hour, false}, {"", 0, true}, {"invalid", 0, true}, {"5x", 0, true}, // unknown unit } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got, err := ParseLastDuration(tt.input) if tt.wantErr { if err == nil { t.Error("expected error, got nil") } } else { if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tt.wantDur { t.Errorf("ParseLastDuration(%q) = %v, want %v", tt.input, got, tt.wantDur) } } }) } } // --- Role Filter Tests --- func TestRetrievalGrepRoleFilter(t *testing.T) { r, s, convID := newTestRetrieval(t) ctx := context.Background() s.AddMessage(ctx, convID, "user", "user message about alpha", 5) s.AddMessage(ctx, convID, "assistant", "assistant reply about alpha", 5) s.AddMessage(ctx, convID, "user", "another user message", 5) // Search all roles allResults, err := r.Grep(ctx, GrepInput{ Pattern: "alpha", }) if err != nil { t.Fatalf("Grep: %v", err) } if len(allResults.Messages) != 2 { t.Errorf("expected 2 messages, got %d", len(allResults.Messages)) } // Search user only userResults, err := r.Grep(ctx, GrepInput{ Pattern: "alpha", Role: "user", }) if err != nil { t.Fatalf("Grep: %v", err) } if len(userResults.Messages) != 1 { t.Errorf("expected 1 user message, got %d", len(userResults.Messages)) } if userResults.Messages[0].Role != "user" { t.Errorf("expected role=user, got %s", userResults.Messages[0].Role) } // Search assistant only assistantResults, err := r.Grep(ctx, GrepInput{ Pattern: "alpha", Role: "assistant", }) if err != nil { t.Fatalf("Grep: %v", err) } if len(assistantResults.Messages) != 1 { t.Errorf("expected 1 assistant message, got %d", len(assistantResults.Messages)) } } // --- Last Parameter Tests --- func TestRetrievalGrepWithLast(t *testing.T) { r, s, convID := newTestRetrieval(t) ctx := context.Background() // Add messages (we can't control timestamps in SQLite easily, // but we can verify the parameter is parsed correctly) s.AddMessage(ctx, convID, "user", "recent message about testing", 5) // Test that Last parameter is converted to Since results, err := r.Grep(ctx, GrepInput{ Pattern: "testing", Last: "1d", // last 1 day }) if err != nil { t.Fatalf("Grep: %v", err) } // Should still find the message since it's recent if len(results.Messages) == 0 { t.Error("expected to find recent message") } } // TestRetrievalGrepRoleFilterWithSummaries tests that role filter works when // searching both summaries and messages (summaries don't have role column). func TestRetrievalGrepRoleFilterWithSummaries(t *testing.T) { r, s, convID := newTestRetrieval(t) ctx := context.Background() // Create a summary (no role column) s.CreateSummary(ctx, CreateSummaryInput{ ConversationID: convID, Kind: SummaryKindLeaf, Depth: 0, Content: "summary about testing", TokenCount: 50, }) // Add messages with different roles s.AddMessage(ctx, convID, "user", "user message about testing", 5) s.AddMessage(ctx, convID, "assistant", "assistant reply about testing", 5) // Search with role filter and scope=both (default), using LIKE mode (%) // This should NOT error even though summaries don't have role column bothResults, err := r.Grep(ctx, GrepInput{ Pattern: "%testing%", // LIKE mode to trigger the bug Role: "user", Scope: "both", }) if err != nil { t.Fatalf("Grep with role and scope=both: %v", err) } // Should only return user messages, not summaries or assistant messages if len(bothResults.Messages) != 1 { t.Errorf("expected 1 user message, got %d", len(bothResults.Messages)) } if len(bothResults.Messages) > 0 && bothResults.Messages[0].Role != "user" { t.Errorf("expected role=user, got %s", bothResults.Messages[0].Role) } // Summaries should be empty since they don't have roles to filter // (or we could return all summaries - either is acceptable) } // TestRetrievalGrepTotalCounts tests that grep returns total counts. func TestRetrievalGrepTotalCounts(t *testing.T) { r, s, convID := newTestRetrieval(t) ctx := context.Background() // Create 3 summaries for i := 0; i < 3; i++ { s.CreateSummary(ctx, CreateSummaryInput{ ConversationID: convID, Kind: SummaryKindLeaf, Depth: 0, Content: fmt.Sprintf("summary about testing %d", i), TokenCount: 50, }) } // Add 5 messages for i := 0; i < 5; i++ { s.AddMessage(ctx, convID, "user", fmt.Sprintf("message about testing %d", i), 5) } // Search with limit smaller than total results, err := r.Grep(ctx, GrepInput{ Pattern: "%testing%", // LIKE mode Scope: "both", Limit: 2, }) if err != nil { t.Fatalf("Grep: %v", err) } // Should return limited results if len(results.Summaries) > 2 { t.Errorf("expected at most 2 summaries, got %d", len(results.Summaries)) } if len(results.Messages) > 2 { t.Errorf("expected at most 2 messages, got %d", len(results.Messages)) } // But total counts should reflect all matches if results.TotalSummaries != 3 { t.Errorf("expected TotalSummaries=3, got %d", results.TotalSummaries) } if results.TotalMessages != 5 { t.Errorf("expected TotalMessages=5, got %d", results.TotalMessages) } }