package memory import ( "context" "errors" "strings" "testing" "time" ) func TestFormatMemoryManifest_Empty(t *testing.T) { got := formatMemoryManifest(nil) if got != "" { t.Errorf("formatMemoryManifest(nil) = %q, want %q", got, "") } got = formatMemoryManifest([]MemoryHeader{}) if got != "" { t.Errorf("formatMemoryManifest([]) = %q, want %q", got, "") } } func TestFormatMemoryManifest_WithDescription(t *testing.T) { headers := []MemoryHeader{ { Frontmatter: Frontmatter{Name: "test", Description: "a test entry", Type: TypeProject}, Path: "/home/user/.flyto/projects/abc/memory/test_memory.md", ModTime: time.Date(2026, 4, 8, 10, 0, 0, 0, time.UTC), }, } got := formatMemoryManifest(headers) if !strings.Contains(got, "- [project] test_memory.md (2026-04-08T10:00:00Z): a test entry") { t.Errorf("manifest = %q, want containing %q", got, "description") } if !strings.Contains(got, "\n") { t.Errorf("manifest should have newline") } } func TestFormatMemoryManifest_WithoutDescription(t *testing.T) { headers := []MemoryHeader{ { Frontmatter: Frontmatter{Name: "test", Description: "", Type: TypeUser}, Path: "/home/user/.flyto/projects/abc/memory/unnamed.md", ModTime: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), }, } got := formatMemoryManifest(headers) if !strings.Contains(got, "- [user] unnamed.md (2026-01-01T00:00:00Z)") { t.Errorf("manifest = %q, want containing %q", got, "no description format") } if strings.Contains(got, ":") && strings.Contains(got, ")") { // Should NOT have ":" after time if no description if strings.Contains(got, "Z):") { t.Errorf("manifest should not have ':' after time when description is empty") } } } func TestFormatMemoryManifest_NoType(t *testing.T) { headers := []MemoryHeader{ { Frontmatter: Frontmatter{Name: "test", Description: "desc", Type: ""}, Path: "/home/user/.flyto/projects/abc/memory/test.md", ModTime: time.Date(2026, 4, 8, 10, 0, 0, 0, time.UTC), }, } got := formatMemoryManifest(headers) // 空 type 不应输出 "[" 括号(与早期实现 `type ? '[type] ' : ''` 一致) if strings.Contains(got, "- [") { t.Errorf("manifest = %q, should NOT contain '- [' when type is empty", got) } if !strings.Contains(got, "- test.md") { t.Errorf("manifest = %q, should contain filename without type bracket", got) } } func TestAIMemorySelector_Select_Success(t *testing.T) { calls := 0 queryFn := func(ctx context.Context, systemPrompt, userPrompt string) (string, error) { calls++ if !strings.Contains(userPrompt, "Query: test query") { t.Errorf("userPrompt = %q, want containing 'Query: test query'", userPrompt) } return `{"selected_memories": ["memory1.md"]}`, nil } sel := NewAIMemorySelector(queryFn) headers := []MemoryHeader{ { Frontmatter: Frontmatter{Name: "memory1", Type: TypeProject}, Path: "/home/user/.flyto/projects/abc/memory/memory1.md", ModTime: time.Now(), }, { Frontmatter: Frontmatter{Name: "memory2", Type: TypeProject}, Path: "/home/user/.flyto/projects/abc/memory/memory2.md", ModTime: time.Now(), }, } result, err := sel.Select(context.Background(), "test query", headers, SelectOpts{Limit: 5}) if err != nil { t.Fatalf("Select error = %v, want nil", err) } if calls != 1 { t.Errorf("calls = %d, want 1", calls) } if len(result) != 1 { t.Fatalf("len(result) = %d, want 1", len(result)) } if !strings.Contains(result[0].Path, "memory1.md") { t.Errorf("result[0].Path = %q, want containing 'memory1.md'", result[0].Path) } } func TestAIMemorySelector_Select_WithRecentTools(t *testing.T) { var capturedPrompt string queryFn := func(ctx context.Context, systemPrompt, userPrompt string) (string, error) { capturedPrompt = userPrompt return `{"selected_memories": []}`, nil } sel := NewAIMemorySelector(queryFn) headers := []MemoryHeader{ { Frontmatter: Frontmatter{Name: "m1", Type: TypeReference}, Path: "/home/user/.flyto/projects/abc/memory/m1.md", ModTime: time.Now(), }, } _, err := sel.Select(context.Background(), "query", headers, SelectOpts{ RecentTools: []string{"Bash", "Read"}, }) if err != nil { t.Fatalf("Select error = %v", err) } if !strings.Contains(capturedPrompt, "Recently used tools: Bash, Read") { t.Errorf("prompt = %q, want containing 'Recently used tools: Bash, Read'", capturedPrompt) } } func TestAIMemorySelector_Select_AlreadySurfaced(t *testing.T) { var calls int queryFn := func(ctx context.Context, systemPrompt, userPrompt string) (string, error) { calls++ return `{"selected_memories": ["memory1.md", "memory2.md", "memory3.md"]}`, nil } sel := NewAIMemorySelector(queryFn) headers := []MemoryHeader{ { Frontmatter: Frontmatter{Name: "memory1", Type: TypeProject}, Path: "/home/user/.flyto/projects/abc/memory/memory1.md", ModTime: time.Now(), }, { Frontmatter: Frontmatter{Name: "memory2", Type: TypeProject}, Path: "/home/user/.flyto/projects/abc/memory/memory2.md", ModTime: time.Now(), }, { Frontmatter: Frontmatter{Name: "memory3", Type: TypeProject}, Path: "/home/user/.flyto/projects/abc/memory/memory3.md", ModTime: time.Now(), }, } result, err := sel.Select(context.Background(), "query", headers, SelectOpts{ AlreadySurfaced: map[string]bool{ "/home/user/.flyto/projects/abc/memory/memory1.md": true, }, }) if err != nil { t.Fatalf("Select error = %v", err) } if calls != 1 { t.Errorf("calls = %d, want 1", calls) } if len(result) != 2 { t.Errorf("len(result) = %d, want 2", len(result)) } } // TestAIMemorySelector_Select_AIFailure_ReturnsError 验证新契约: // 模型调用失败时 selector 返回 (nil, err), 不再内部 fallback. // 之前的 FallsBackToText 行为已作废 - fallback 由 Store 层做, 见 TestFileStore_FindRelevant_AIFailure_UsesStoreScorer. func TestAIMemorySelector_Select_AIFailure_ReturnsError(t *testing.T) { queryFn := func(ctx context.Context, systemPrompt, userPrompt string) (string, error) { return "", errors.New("model error") } sel := NewAIMemorySelector(queryFn) headers := []MemoryHeader{ { Frontmatter: Frontmatter{Name: "go_testing", Description: "go testing framework documentation", Type: TypeReference}, Path: "/home/user/.flyto/projects/abc/memory/go_testing.md", ModTime: time.Now(), }, } result, err := sel.Select(context.Background(), "go testing", headers, SelectOpts{Limit: 5}) if err == nil { t.Fatalf("expected non-ctx query error to propagate, got nil") } if result != nil { t.Errorf("expected nil result on error, got %d entries", len(result)) } } // TestAIMemorySelector_Select_InvalidJSON_ReturnsError 验证新契约: // JSON 解析失败时 selector 返回 (nil, err). func TestAIMemorySelector_Select_InvalidJSON_ReturnsError(t *testing.T) { queryFn := func(ctx context.Context, systemPrompt, userPrompt string) (string, error) { return "not valid json at all", nil } sel := NewAIMemorySelector(queryFn) headers := []MemoryHeader{ { Frontmatter: Frontmatter{Name: "python_guide", Description: "python programming guide", Type: TypeProject}, Path: "/home/user/.flyto/projects/abc/memory/python_guide.md", ModTime: time.Now(), }, } result, err := sel.Select(context.Background(), "python", headers, SelectOpts{Limit: 5}) if err == nil { t.Fatalf("expected json parse error to propagate, got nil") } if result != nil { t.Errorf("expected nil result on error, got %d entries", len(result)) } } func TestAIMemorySelector_Select_FilenameNotInHeaders(t *testing.T) { queryFn := func(ctx context.Context, systemPrompt, userPrompt string) (string, error) { return `{"selected_memories": ["memory1.md", "ghost.md", "memory2.md"]}`, nil } sel := NewAIMemorySelector(queryFn) headers := []MemoryHeader{ { Frontmatter: Frontmatter{Name: "memory1", Type: TypeProject}, Path: "/home/user/.flyto/projects/abc/memory/memory1.md", ModTime: time.Now(), }, { Frontmatter: Frontmatter{Name: "memory2", Type: TypeProject}, Path: "/home/user/.flyto/projects/abc/memory/memory2.md", ModTime: time.Now(), }, } result, err := sel.Select(context.Background(), "query", headers, SelectOpts{Limit: 5}) if err != nil { t.Fatalf("Select error = %v", err) } if len(result) != 2 { t.Errorf("len(result) = %d, want 2 (ghost.md filtered)", len(result)) } for _, h := range result { if strings.Contains(h.Path, "ghost") { t.Errorf("result should not contain ghost.md, got %q", h.Path) } } } func TestAIMemorySelector_Select_EmptyHeaders(t *testing.T) { calls := 0 queryFn := func(ctx context.Context, systemPrompt, userPrompt string) (string, error) { calls++ return `{"selected_memories": []}`, nil } sel := NewAIMemorySelector(queryFn) result, err := sel.Select(context.Background(), "query", []MemoryHeader{}, SelectOpts{Limit: 5}) if err != nil { t.Fatalf("Select error = %v", err) } if calls != 0 { t.Errorf("calls = %d, want 0 (should not call AI for empty headers)", calls) } if result != nil { t.Errorf("result = %v, want nil", result) } } func TestAIMemorySelector_Select_CtxCancelled(t *testing.T) { calls := 0 queryFn := func(ctx context.Context, systemPrompt, userPrompt string) (string, error) { calls++ return "", context.Canceled } sel := NewAIMemorySelector(queryFn) headers := []MemoryHeader{ { Frontmatter: Frontmatter{Name: "test", Type: TypeProject}, Path: "/home/user/.flyto/projects/abc/memory/test.md", ModTime: time.Now(), }, } ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel immediately result, err := sel.Select(ctx, "query", headers, SelectOpts{Limit: 5}) if err != nil { t.Fatalf("ctx cancelled error = %v, want nil", err) } if calls != 0 { t.Errorf("calls = %d, want 0", calls) } if result != nil { t.Errorf("result = %v, want nil for cancelled ctx", result) } }