From dbecfb243ff166a647d6495c6842a18d0d025262 Mon Sep 17 00:00:00 2001 From: SIN CI Date: Thu, 18 Jun 2026 02:13:49 +0200 Subject: [PATCH 1/2] fix(fusion): pass prompt to tournament runner, wire status cmd, complete worktree config (issues #290, #326, #319) - agentloop/loop.go: pass prompt to TournamentRunner.Run so forked sessions run with the original task instead of an empty prompt. - loopbuilder/builder.go: set SourceSessionID, respect FusionDifficultyGate in ShouldRun, pass prompt into tournament. - Update fusion/loopbuilder/orchestrator tests for the new Run(ctx, prompt) signature. - main.go: wire NewStatusCmd() (issue #326). - config.go: add remaining worktree.conflict_check / worktree.target_branch get/set/list handlers (issue #319). - Remove unused snapshot.go/snapshot_test.go (duplicate of status package). - Add coverage tests for agentloop and fusion packages. All changed packages pass go test -race -count=1. --- .../internal/agentloop/coverage_test.go | 738 ++++++++++++++ cmd/sin-code/internal/agentloop/loop.go | 14 +- cmd/sin-code/internal/config.go | 35 +- cmd/sin-code/internal/fusion/coverage_test.go | 965 ++++++++++++++++++ cmd/sin-code/internal/loopbuilder/builder.go | 14 +- .../loopbuilder/fusion_integration_test.go | 114 ++- .../internal/loopbuilder/wiring_test.go | 8 +- .../internal/orchestrator/e2e_test.go | 10 +- cmd/sin-code/internal/snapshot.go | 474 --------- cmd/sin-code/internal/snapshot_test.go | 375 ------- cmd/sin-code/main.go | 1 + 11 files changed, 1874 insertions(+), 874 deletions(-) create mode 100644 cmd/sin-code/internal/agentloop/coverage_test.go create mode 100644 cmd/sin-code/internal/fusion/coverage_test.go delete mode 100644 cmd/sin-code/internal/snapshot.go delete mode 100644 cmd/sin-code/internal/snapshot_test.go diff --git a/cmd/sin-code/internal/agentloop/coverage_test.go b/cmd/sin-code/internal/agentloop/coverage_test.go new file mode 100644 index 00000000..44122203 --- /dev/null +++ b/cmd/sin-code/internal/agentloop/coverage_test.go @@ -0,0 +1,738 @@ +// SPDX-License-Identifier: MIT +// Purpose: Coverage gap tests for the agentloop package. +// Targets uncovered error paths, edge cases, and helper functions. +// All tests pass under `go test -race -count=1` (mandate M7). +package agentloop + +import ( + "context" + "errors" + "io" + "net/http" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/ledger" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/llm" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session" +) + +// --------------------------------------------------------------------------- +// budget.go coverage +// --------------------------------------------------------------------------- + +func TestBudgetLevel_String_AllLevels(t *testing.T) { + cases := []struct { + level BudgetLevel + want string + }{ + {BudgetGreen, "green"}, + {BudgetYellow, "yellow"}, + {BudgetRed, "red"}, + {BudgetLevel(99), "unknown"}, + } + for _, c := range cases { + if got := c.level.String(); got != c.want { + t.Errorf("BudgetLevel(%d).String() = %q, want %q", c.level, got, c.want) + } + } +} + +func TestBudget_MaxTokens(t *testing.T) { + b := NewBudget(5000, 10.0) + if got := b.MaxTokens(); got != 5000 { + t.Errorf("MaxTokens: got %d, want 5000", got) + } + var nilB *Budget + if got := nilB.MaxTokens(); got != 0 { + t.Errorf("nil MaxTokens: got %d, want 0", got) + } +} + +func TestBudget_MaxCostUSD(t *testing.T) { + b := NewBudget(5000, 10.0) + if got := b.MaxCostUSD(); got != 10.0 { + t.Errorf("MaxCostUSD: got %.2f, want 10.0", got) + } + var nilB *Budget + if got := nilB.MaxCostUSD(); got != 0 { + t.Errorf("nil MaxCostUSD: got %.2f, want 0", got) + } +} + +func TestBudget_UsedTokens_Nil(t *testing.T) { + var b *Budget + if got := b.UsedTokens(); got != 0 { + t.Errorf("nil UsedTokens: got %d, want 0", got) + } +} + +func TestBudget_UsedCost_Nil(t *testing.T) { + var b *Budget + if got := b.UsedCost(); got != 0 { + t.Errorf("nil UsedCost: got %.2f, want 0", got) + } +} + +func TestBudget_Consume_NegativeValues(t *testing.T) { + b := NewBudget(1000, 5.0) + if err := b.Consume(-100, -1.0); err != nil { + t.Fatalf("negative consume should be clamped to 0, got: %v", err) + } + if got := b.UsedTokens(); got != 0 { + t.Errorf("after negative consume, used tokens: got %d, want 0", got) + } + if got := b.UsedCost(); got != 0 { + t.Errorf("after negative consume, used cost: got %.2f, want 0", got) + } +} + +func TestBudget_Consume_BothLimitsExceeded(t *testing.T) { + b := NewBudget(100, 0.10) + err := b.Consume(200, 0.50) + if err == nil { + t.Fatal("expected error when both limits exceeded") + } + if !errors.Is(err, ErrBudgetExhausted) { + t.Fatalf("expected ErrBudgetExhausted, got: %v", err) + } + if !strings.Contains(err.Error(), "tokens") || !strings.Contains(err.Error(), "cost") { + t.Errorf("error should mention both tokens and cost, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// toolcoverage.go coverage +// --------------------------------------------------------------------------- + +func TestJoinBackticks(t *testing.T) { + got := joinBackticks([]string{"sin_poc", "sin_oracle"}) + if got != "`sin_poc`, `sin_oracle`" { + t.Errorf("joinBackticks: got %q, want `sin_poc`, `sin_oracle`", got) + } +} + +func TestJoinBackticks_Single(t *testing.T) { + got := joinBackticks([]string{"sin_poc"}) + if got != "`sin_poc`" { + t.Errorf("joinBackticks single: got %q, want `sin_poc`", got) + } +} + +func TestJoinBackticks_Empty(t *testing.T) { + got := joinBackticks(nil) + if got != "" { + t.Errorf("joinBackticks empty: got %q, want ''", got) + } +} + +func TestToolCoverageEnforcer_Record_EmptyName(t *testing.T) { + e := NewToolCoverageEnforcer(nil, nil) + e.Record("") // should be a no-op + used := e.Used() + if len(used) != 0 { + t.Errorf("Record('') should not add anything, got %v", used) + } +} + +func TestToolCoverageEnforcer_Record_NilEnforcer(t *testing.T) { + var e *ToolCoverageEnforcer + e.Record("sin_poc") // should not panic +} + +func TestToolCoverageEnforcer_Check_NilEnforcer(t *testing.T) { + var e *ToolCoverageEnforcer + ok, missing, forbidden := e.Check() + if !ok || len(missing) != 0 || len(forbidden) != 0 { + t.Errorf("nil Check should pass, got ok=%v missing=%v forbidden=%v", ok, missing, forbidden) + } +} + +func TestToolCoverageEnforcer_Used_NilEnforcer(t *testing.T) { + var e *ToolCoverageEnforcer + if got := e.Used(); got != nil { + t.Errorf("nil Used should return nil, got %v", got) + } +} + +func TestToolCoverageEnforcer_HasConstraints_NilEnforcer(t *testing.T) { + var e *ToolCoverageEnforcer + if e.HasConstraints() { + t.Error("nil HasConstraints should be false") + } +} + +func TestToolCoverageEnforcer_HasConstraints_WithRequired(t *testing.T) { + e := NewToolCoverageEnforcer([]string{"sin_poc"}, nil) + if !e.HasConstraints() { + t.Error("expected HasConstraints=true with required tools") + } +} + +func TestToolCoverageEnforcer_HasConstraints_WithForbidden(t *testing.T) { + e := NewToolCoverageEnforcer(nil, []string{"sin_bash"}) + if !e.HasConstraints() { + t.Error("expected HasConstraints=true with forbidden tools") + } +} + +func TestToolCoverageEnforcer_HasConstraints_None(t *testing.T) { + e := NewToolCoverageEnforcer(nil, nil) + if e.HasConstraints() { + t.Error("expected HasConstraints=false with no constraints") + } +} + +func TestToolCoverageEnforcer_Feedback_MultipleMissing(t *testing.T) { + e := NewToolCoverageEnforcer([]string{"sin_poc", "sin_oracle"}, nil) + fb := e.Feedback([]string{"sin_poc", "sin_oracle"}, nil) + if fb == "" { + t.Fatal("expected feedback for multiple missing") + } + if !strings.Contains(fb, "call:") { + t.Errorf("expected 'call:' in feedback, got %q", fb) + } +} + +func TestToolCoverageEnforcer_Feedback_MultipleForbidden(t *testing.T) { + e := NewToolCoverageEnforcer(nil, []string{"sin_bash", "sin_git"}) + fb := e.Feedback(nil, []string{"sin_bash", "sin_git"}) + if fb == "" { + t.Fatal("expected feedback for multiple forbidden") + } + if !strings.Contains(fb, "forbidden tools:") { + t.Errorf("expected 'forbidden tools:' in feedback, got %q", fb) + } +} + +func TestToolCoverageEnforcer_Feedback_Empty(t *testing.T) { + e := NewToolCoverageEnforcer(nil, nil) + fb := e.Feedback(nil, nil) + if fb != "" { + t.Errorf("expected empty feedback for no violations, got %q", fb) + } +} + +func TestToolCoverageEnforcer_Feedback_NilEnforcer(t *testing.T) { + var e *ToolCoverageEnforcer + fb := e.Feedback([]string{"x"}, nil) + if fb != "" { + t.Errorf("nil enforcer feedback should be empty, got %q", fb) + } +} + +func TestToolCoverageEnforcer_Feedback_SingleForbidden(t *testing.T) { + e := NewToolCoverageEnforcer(nil, []string{"sin_bash"}) + fb := e.Feedback(nil, []string{"sin_bash"}) + if fb == "" { + t.Fatal("expected feedback for single forbidden") + } + if !strings.Contains(fb, "forbidden tool") { + t.Errorf("expected 'forbidden tool' in feedback, got %q", fb) + } +} + +// --------------------------------------------------------------------------- +// epic_coordinator.go coverage +// --------------------------------------------------------------------------- + +func TestEpicCoordinator_NilLoader_LoadEpic(t *testing.T) { + c := NewEpicCoordinator() + // Default loader is nilLoader which returns ErrEpicNotFound + _, err := c.LoadEpic(42) + if err != ErrEpicNotFound { + t.Fatalf("expected ErrEpicNotFound from nilLoader, got: %v", err) + } +} + +func TestEpicCoordinator_NilLoader_LoadDependencies(t *testing.T) { + c := NewEpicCoordinator() + deps, err := c.Dependencies(42) + if err != nil { + t.Fatalf("unexpected error from nilLoader LoadDependencies: %v", err) + } + if deps != nil { + t.Errorf("expected nil deps from nilLoader, got %v", deps) + } +} + +func TestEpicCoordinator_SetLoader_NilCoordinator(t *testing.T) { + var c *EpicCoordinator + c.SetLoader(mockLoader{}) // should not panic +} + +func TestEpicCoordinator_LoadEpic_NilEpicFromLoader(t *testing.T) { + c := NewEpicCoordinator() + c.SetLoader(mockLoader{ + epics: map[int]*Epic{ + 10: nil, // loader returns nil epic + }, + }) + _, err := c.LoadEpic(10) + if err != ErrEpicNotFound { + t.Fatalf("expected ErrEpicNotFound for nil epic, got: %v", err) + } +} + +func TestEpicCoordinator_MarkComplete_AlreadyComplete(t *testing.T) { + c := NewEpicCoordinator() + c.SetLoader(mockLoader{ + epics: map[int]*Epic{ + 100: {IssueNumber: 100, Title: "Epic", SubIssues: []int{101, 102}, Completed: []int{101}}, + }, + }) + epic, _ := c.LoadEpic(100) + // Mark 101 complete again — should be idempotent + if err := c.MarkComplete(101); err != nil { + t.Fatalf("unexpected error: %v", err) + } + count := 0 + for _, num := range epic.Completed { + if num == 101 { + count++ + } + } + if count != 1 { + t.Errorf("expected 101 to appear once, got %d", count) + } +} + +func TestEpicCoordinator_MarkComplete_NotSubIssue(t *testing.T) { + c := NewEpicCoordinator() + c.SetLoader(mockLoader{ + epics: map[int]*Epic{ + 100: {IssueNumber: 100, Title: "Epic", SubIssues: []int{101, 102}, Completed: nil}, + }, + }) + epic, _ := c.LoadEpic(100) + // 999 is not a sub-issue of epic 100 — should not be added to Completed + _ = c.MarkComplete(999) + for _, num := range epic.Completed { + if num == 999 { + t.Error("non-sub-issue should not be added to epic.Completed") + } + } +} + +func TestEpicCoordinator_MarkComplete_NilCoordinator(t *testing.T) { + var c *EpicCoordinator + err := c.MarkComplete(1) + if err != ErrEpicNotFound { + t.Errorf("nil MarkComplete should return ErrEpicNotFound, got %v", err) + } +} + +func TestEpicCoordinator_Dependencies_NilCoordinator(t *testing.T) { + var c *EpicCoordinator + _, err := c.Dependencies(1) + if err != ErrEpicNotFound { + t.Errorf("nil Dependencies should return ErrEpicNotFound, got %v", err) + } +} + +func TestEpicCoordinator_Dependencies_ErrorFromLoader(t *testing.T) { + c := NewEpicCoordinator() + c.SetLoader(errorLoader{}) + _, err := c.Dependencies(42) + if err == nil { + t.Fatal("expected error from errorLoader") + } +} + +func TestEpicCoordinator_LoadEpic_ErrorFromLoader(t *testing.T) { + c := NewEpicCoordinator() + c.SetLoader(errorLoader{}) + _, err := c.LoadEpic(42) + if err == nil { + t.Fatal("expected error from errorLoader") + } +} + +func TestEpicCoordinator_Progress_NoSubIssues(t *testing.T) { + c := NewEpicCoordinator() + epic := &Epic{IssueNumber: 100, Title: "Empty", SubIssues: nil} + if got := c.Progress(epic); got != 1.0 { + t.Errorf("expected 1.0 for no sub-issues, got %.2f", got) + } +} + +func TestEpicCoordinator_Progress_NilEpic(t *testing.T) { + c := NewEpicCoordinator() + if got := c.Progress(nil); got != 0 { + t.Errorf("nil Progress should return 0, got %.2f", got) + } +} + +func TestEpicCoordinator_Summary_NilCoordinator(t *testing.T) { + var c *EpicCoordinator + if got := c.Summary(nil); got != "epic: " { + t.Errorf("nil Summary should return 'epic: ', got %q", got) + } +} + +func TestEpicCoordinator_Summary_AllComplete(t *testing.T) { + c := NewEpicCoordinator() + c.SetLoader(mockLoader{ + epics: map[int]*Epic{ + 100: {IssueNumber: 100, Title: "Done", SubIssues: []int{101, 102}, Completed: nil}, + }, + }) + epic, _ := c.LoadEpic(100) + c.MarkComplete(101) + c.MarkComplete(102) + summary := c.Summary(epic) + if !strings.Contains(summary, "all complete") { + t.Errorf("summary should say 'all complete', got: %s", summary) + } +} + +func TestEpicCoordinator_Summary_OverFull(t *testing.T) { + c := NewEpicCoordinator() + c.SetLoader(mockLoader{ + epics: map[int]*Epic{ + 100: {IssueNumber: 100, Title: "Epic", SubIssues: []int{101}, Completed: nil}, + }, + }) + epic, _ := c.LoadEpic(100) + // Mark complete multiple times to push progress over 100% + c.MarkComplete(101) + summary := c.Summary(epic) + if summary == "" { + t.Error("expected non-empty summary") + } +} + +func TestEpicCoordinator_NextIssue_NilCoordinator(t *testing.T) { + var c *EpicCoordinator + _, err := c.NextIssue(&Epic{SubIssues: []int{1}}) + if err != ErrNoRemainingIssues { + t.Errorf("nil NextIssue should return ErrNoRemainingIssues, got %v", err) + } +} + +// errorLoader returns errors for all methods. +type errorLoader struct{} + +func (errorLoader) LoadEpic(issueNumber int) (*Epic, error) { + return nil, errors.New("loader error") +} + +func (errorLoader) LoadDependencies(issueNumber int) ([]int, error) { + return nil, errors.New("loader error") +} + +// --------------------------------------------------------------------------- +// watch.go coverage +// --------------------------------------------------------------------------- + +func TestWatchMode_MatchesPattern_NoPatterns(t *testing.T) { + w := NewWatchMode(nil) + if !w.matchesPattern("anything.go") { + t.Error("no patterns should match everything") + } + if !w.matchesPattern("whatever.txt") { + t.Error("no patterns should match everything") + } +} + +func TestIsWatchIgnored(t *testing.T) { + // Known ignored dirs + for _, name := range []string{".git", "vendor", "node_modules", "__pycache__", ".sin-code", ".sin", "dist", "build", "target", ".cache", "tmp"} { + if !isWatchIgnored(name) { + t.Errorf("isWatchIgnored(%q) should be true", name) + } + } + // Non-ignored dir + if isWatchIgnored("src") { + t.Error("isWatchIgnored('src') should be false") + } + if isWatchIgnored("myproject") { + t.Error("isWatchIgnored('myproject') should be false") + } +} + +func TestWatchMode_ScanDir_NonexistentDir(t *testing.T) { + w := NewWatchMode([]string{"*.go"}) + w.SetRoot("/nonexistent/path/that/does/not/exist") + // scanDir on a nonexistent dir should return false, not panic + if w.scanDir("/nonexistent/path/that/does/not/exist", true) { + t.Error("expected false for nonexistent dir") + } +} + +func TestWatchMode_ScanForChanges_EmptyRoot(t *testing.T) { + w := NewWatchMode([]string{"*.go"}) + // root is empty — should fall back to os.Getwd() + // Just verify it doesn't panic + w.scanForChanges() +} + +func TestWatchMode_InitialScan_EmptyRoot(t *testing.T) { + w := NewWatchMode([]string{"*.go"}) + // root is empty — should fall back to os.Getwd() + w.initialScan() +} + +func TestWatchMode_MatchesPattern_ExtensionMatch(t *testing.T) { + w := NewWatchMode([]string{"*.go"}) + // Test the extension-suffix path + if !w.matchesPattern("test.go") { + t.Error("expected '*.go' to match 'test.go'") + } + if w.matchesPattern("test.py") { + t.Error("expected '*.go' to not match 'test.py'") + } +} + +func TestWatchMode_ScanDir_WithNonMatchingFiles(t *testing.T) { + root := t.TempDir() + writeWatchFile(t, root, "readme.md", "# readme") + writeWatchFile(t, root, "main.go", "package main") + w := NewWatchMode([]string{"*.go"}) + w.SetRoot(root) + changed := w.scanDir(root, true) + if changed { + t.Error("initial scan should not report changes") + } + w.mu.Lock() + count := len(w.snapshots) + w.mu.Unlock() + if count != 1 { + t.Errorf("expected 1 file in snapshots (only .go), got %d", count) + } +} + +func TestWatchMode_ScanDir_EntryInfoError(t *testing.T) { + root := t.TempDir() + // Create a symlink to a nonexistent file — entry.Info() may still work, + // but let's test the general path of scanning a dir with a broken entry + w := NewWatchMode([]string{"*"}) + w.SetRoot(root) + // Just ensure scanning an empty dir doesn't panic + if w.scanDir(root, true) { + t.Error("expected false for empty dir initial scan") + } +} + +// --------------------------------------------------------------------------- +// background.go coverage +// --------------------------------------------------------------------------- + +func TestItoa3_NegativeNumber(t *testing.T) { + got := itoa3(-5) + if got != "005" { + t.Errorf("itoa3(-5) = %q, want '005'", got) + } +} + +func TestItoa3_LargeNumber(t *testing.T) { + got := itoa3(1234) + if got != "234" { + t.Errorf("itoa3(1234) = %q, want '234'", got) + } +} + +func TestTaskRegistry_Finish_NotFound(t *testing.T) { + r := NewTaskRegistry() + res := &Result{Summary: "ok"} + r.Finish("bg-999", "verified", res, nil) + // Should not panic, should be a no-op +} + +func TestTaskRegistry_Finish_WithError(t *testing.T) { + r := NewTaskRegistry() + t1 := r.Add("task") + r.Finish(t1.ID, "cancelled", nil, errors.New("timeout")) + got, _ := r.Get(t1.ID) + if got.Status != "cancelled" { + t.Errorf("expected status=cancelled, got %q", got.Status) + } + if got.Err != "timeout" { + t.Errorf("expected err=timeout, got %q", got.Err) + } +} + +// --------------------------------------------------------------------------- +// frustration.go coverage +// --------------------------------------------------------------------------- + +func TestFrustration_Score_NilDetector(t *testing.T) { + var d *FrustrationDetector + if got := d.Score(); got != 0 { + t.Errorf("nil Score should return 0, got %d", got) + } +} + +func TestNormalizeMessage_EdgeCases(t *testing.T) { + tests := []struct { + input string + check func(string) bool + desc string + }{ + {"", func(s string) bool { return s == "" }, "empty string"}, + {" ", func(s string) bool { return s == "" }, "whitespace only"}, + {"\n\t", func(s string) bool { return s == "" }, "newlines and tabs"}, + {"Hello World", func(s string) bool { return !strings.Contains(s, " ") }, "multiple spaces collapsed"}, + {" Hello World ", func(s string) bool { return s == "hello world" }, "trimmed and collapsed"}, + } + for _, tt := range tests { + got := normalizeMessage(tt.input) + if !tt.check(got) { + t.Errorf("normalizeMessage(%q) = %q, %s failed", tt.input, got, tt.desc) + } + } +} + +// --------------------------------------------------------------------------- +// provider_adapter.go coverage — cache paths +// --------------------------------------------------------------------------- + +func TestNewProviderCompletionWithCache_CacheHit(t *testing.T) { + cache := llm.NewPromptCache(5 * time.Minute) + // Pre-populate cache with a known key + systemPrompt := "You are a helpful assistant" + firstUser := "Do the task" + key := llm.CacheKey(systemPrompt, firstUser) + cache.Set(key, "prefix-123") + + c := newFakeClient(func(req *http.Request) (*http.Response, error) { + // Verify cache headers are set + if req.Header.Get("X-SIN-Cache-Prefix-ID") != "prefix-123" { + // Not a hard error — the header may not be set if model doesn't support caching + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`)), + }, nil + }) + + fn := NewProviderCompletionWithCache(c, "claude-3-opus", 100, 0.0, cache) + comp, err := fn(context.Background(), []session.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: firstUser}, + }, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if comp.Text != "done" { + t.Errorf("expected 'done', got %q", comp.Text) + } +} + +func TestNewProviderCompletionWithCache_CacheMiss(t *testing.T) { + cache := llm.NewPromptCache(5 * time.Minute) + + c := newFakeClient(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`)), + }, nil + }) + + fn := NewProviderCompletionWithCache(c, "claude-3-opus", 100, 0.0, cache) + comp, err := fn(context.Background(), []session.Message{ + {Role: "system", Content: "You are a helpful assistant"}, + {Role: "user", Content: "Do the task"}, + }, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if comp.Text != "done" { + t.Errorf("expected 'done', got %q", comp.Text) + } +} + +func TestNewProviderCompletionWithCache_NonCachingModel(t *testing.T) { + cache := llm.NewPromptCache(5 * time.Minute) + + c := newFakeClient(func(req *http.Request) (*http.Response, error) { + // Verify no cache headers are set for non-caching model + if req.Header.Get("X-SIN-Cache-Key") != "" { + t.Errorf("expected no cache key header for non-caching model, got %q", req.Header.Get("X-SIN-Cache-Key")) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`)), + }, nil + }) + + fn := NewProviderCompletionWithCache(c, "gpt-4", 100, 0.0, cache) + comp, err := fn(context.Background(), []session.Message{ + {Role: "system", Content: "system"}, + {Role: "user", Content: "user"}, + }, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if comp.Text != "done" { + t.Errorf("expected 'done', got %q", comp.Text) + } +} + +func TestNewProviderCompletionWithCache_NilCache(t *testing.T) { + c := newFakeClient(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`)), + }, nil + }) + + fn := NewProviderCompletionWithCache(c, "claude-3-opus", 100, 0.0, nil) + comp, err := fn(context.Background(), []session.Message{ + {Role: "system", Content: "system"}, + {Role: "user", Content: "user"}, + }, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if comp.Text != "done" { + t.Errorf("expected 'done', got %q", comp.Text) + } +} + +// --------------------------------------------------------------------------- +// loop.go coverage — recordUsage +// --------------------------------------------------------------------------- + +func TestLoop_RecordUsage_NilLedger(t *testing.T) { + l := &Loop{ + SessionID: "test-session", + // Ledger is nil — should be a no-op + } + l.recordUsage(context.Background(), "sin_read", ledger.OutcomeOK) + // Should not panic +} + +func TestLoop_RecordUsage_EmptySessionID(t *testing.T) { + ledgerStore, err := ledger.Open(filepath.Join(t.TempDir(), "ledger.db")) + if err != nil { + t.Fatalf("open ledger: %v", err) + } + defer ledgerStore.Close() + + l := &Loop{ + Ledger: ledgerStore, + SessionID: "", // empty — should be a no-op + } + l.recordUsage(context.Background(), "sin_read", ledger.OutcomeOK) + // Should not panic +} + +func TestLoop_RecordUsage_Success(t *testing.T) { + ledgerStore, err := ledger.Open(filepath.Join(t.TempDir(), "ledger.db")) + if err != nil { + t.Fatalf("open ledger: %v", err) + } + defer ledgerStore.Close() + + l := &Loop{ + Ledger: ledgerStore, + SessionID: "test-session", + } + l.recordUsage(context.Background(), "sin_read", ledger.OutcomeOK) + // Should not panic — coverage of the actual RecordUsage call +} + + diff --git a/cmd/sin-code/internal/agentloop/loop.go b/cmd/sin-code/internal/agentloop/loop.go index d688dea3..954dfd41 100644 --- a/cmd/sin-code/internal/agentloop/loop.go +++ b/cmd/sin-code/internal/agentloop/loop.go @@ -231,13 +231,15 @@ type Loop struct { // TournamentRunner is the interface for fusion verify-tournaments (issue // #290). The loop calls ShouldRun to check if a verify-fail warrants a -// tournament fan-out, and Run to execute it. On success, Run returns the -// winner's output and token count. On failure, the loop falls back to -// the legacy same-model retry. Defined here (not in internal/fusion) to -// avoid a circular import (fusion imports agentloop for Result). +// tournament fan-out, and Run to execute it. The prompt is passed so the +// tournament can fan it out to each provider — without it, forked sessions +// would run with an empty task. On success, Run returns the winner's output +// and token count. On failure, the loop falls back to the legacy same-model +// retry. Defined here (not in internal/fusion) to avoid a circular import +// (fusion imports agentloop for Result). type TournamentRunner interface { ShouldRun(vr verify.Result) bool - Run(ctx context.Context) (output string, tokens int, err error) + Run(ctx context.Context, prompt string) (output string, tokens int, err error) } // saveHistoryHook is a test seam for injecting a mock around session @@ -565,7 +567,7 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (* if l.TournamentRunner != nil && (l.Gate.Mode() == verify.ModePoC || l.Gate.Mode() == verify.ModeOracle) && l.TournamentRunner.ShouldRun(res) { - output, tokens, terr := l.TournamentRunner.Run(ctx) + output, tokens, terr := l.TournamentRunner.Run(ctx, prompt) if terr == nil && output != "" { l.fire(ctx, hooks.VerifyPass, "", map[string]any{ "mode": "poc", "report": "fusion tournament: winner passed verify-gate", diff --git a/cmd/sin-code/internal/config.go b/cmd/sin-code/internal/config.go index aee08aa2..2e008ac9 100644 --- a/cmd/sin-code/internal/config.go +++ b/cmd/sin-code/internal/config.go @@ -506,6 +506,12 @@ test.auto_generate = %v test.timeout_seconds = %d test.use_llm = %v test.repair_rounds = %d + +# Worktree conflict prediction (issue #319). +# conflict_check: off|warn|abort — action when git merge-tree predicts conflicts +# target_branch: integration branch to compare against when creating a worktree +worktree.conflict_check = %q +worktree.target_branch = %q `, cfg.Theme, cfg.DefaultTimeout, cfg.DefaultFormat, cfg.MCPServerEnabled, cfg.LLMBaseURL, cfg.LLMAPIKey, cfg.LLMModel, cfg.LLMMaxTokens, cfg.LLMTemperature, cfg.LLMStyle, @@ -513,7 +519,8 @@ test.repair_rounds = %d strings.Join(cfg.AgentLoopRequiredTools, ","), strings.Join(cfg.AgentLoopForbiddenTools, ","), strings.Join(cfg.ToolsAllow, ","), strings.Join(cfg.ToolsDeny, ","), cfg.PathsMCPConfig, cfg.PathsSkillsDir, - cfg.TestCoverageThreshold, cfg.TestMutationThreshold, cfg.TestAutoGenerate, cfg.TestTimeoutSeconds, cfg.TestUseLLM, cfg.TestRepairRounds) + cfg.TestCoverageThreshold, cfg.TestMutationThreshold, cfg.TestAutoGenerate, cfg.TestTimeoutSeconds, cfg.TestUseLLM, cfg.TestRepairRounds, + cfg.WorktreeConflictCheck, cfg.WorktreeTargetBranch) } func initConfig() error { @@ -637,6 +644,10 @@ func getConfigValueFrom(key string, cfg SinCodeConfig) (string, error) { return fmt.Sprintf("%v", cfg.AgentLoopFrustrationDetection), nil case "permission.yolo_risk_threshold": return cfg.PermissionYoloRiskThreshold, nil + case "worktree.conflict_check": + return cfg.WorktreeConflictCheck, nil + case "worktree.target_branch": + return cfg.WorktreeTargetBranch, nil default: return "", fmt.Errorf("unknown config key: %q", key) } @@ -810,6 +821,13 @@ func setConfigValueIn(key, value string, cfg *SinCodeConfig) error { cfg.AgentLoopFrustrationDetection = value == "true" || value == "1" case "permission.yolo_risk_threshold": cfg.PermissionYoloRiskThreshold = value + case "worktree.conflict_check": + if value != "off" && value != "warn" && value != "abort" { + return fmt.Errorf("worktree.conflict_check must be 'off', 'warn', or 'abort', got %q", value) + } + cfg.WorktreeConflictCheck = value + case "worktree.target_branch": + cfg.WorktreeTargetBranch = value default: return fmt.Errorf("unknown config key: %q", key) } @@ -869,6 +887,8 @@ func configPairs(cfg SinCodeConfig, mask bool) []configPair { {"agentloop.compaction_threshold", fmt.Sprintf("%v", cfg.AgentLoopCompactionThreshold)}, {"agentloop.frustration_detection", fmt.Sprintf("%v", cfg.AgentLoopFrustrationDetection)}, {"permission.yolo_risk_threshold", cfg.PermissionYoloRiskThreshold}, + {"worktree.conflict_check", cfg.WorktreeConflictCheck}, + {"worktree.target_branch", cfg.WorktreeTargetBranch}, } sort.Slice(pairs, func(i, j int) bool { return pairs[i].Key < pairs[j].Key }) return pairs @@ -937,6 +957,10 @@ func showJSON(cfg SinCodeConfig, mask bool) error { "mcp_config": cfg.PathsMCPConfig, "skills_dir": cfg.PathsSkillsDir, }, + "worktree": map[string]any{ + "conflict_check": cfg.WorktreeConflictCheck, + "target_branch": cfg.WorktreeTargetBranch, + }, } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") @@ -961,6 +985,8 @@ func showTOML(cfg SinCodeConfig, mask bool) error { TestCoverageThreshold: cfg.TestCoverageThreshold, TestMutationThreshold: cfg.TestMutationThreshold, TestAutoGenerate: cfg.TestAutoGenerate, TestTimeoutSeconds: cfg.TestTimeoutSeconds, TestUseLLM: cfg.TestUseLLM, TestRepairRounds: cfg.TestRepairRounds, + WorktreeConflictCheck: cfg.WorktreeConflictCheck, + WorktreeTargetBranch: cfg.WorktreeTargetBranch, })) return nil } @@ -1015,6 +1041,9 @@ func validateConfig(cfg SinCodeConfig) []string { if cfg.AgentLoopCompactionThreshold <= 0 || cfg.AgentLoopCompactionThreshold > 1 { issues = append(issues, fmt.Sprintf("agentloop.compaction_threshold must be in (0,1], got %v", cfg.AgentLoopCompactionThreshold)) } + if cfg.WorktreeConflictCheck != "" && cfg.WorktreeConflictCheck != "off" && cfg.WorktreeConflictCheck != "warn" && cfg.WorktreeConflictCheck != "abort" { + issues = append(issues, fmt.Sprintf("worktree.conflict_check must be 'off', 'warn', or 'abort', got %q", cfg.WorktreeConflictCheck)) + } return issues } @@ -1122,6 +1151,10 @@ func applyMap(cfg *SinCodeConfig, m map[string]string) { cfg.AgentLoopFrustrationDetection = val == "true" || val == "1" case "permission.yolo_risk_threshold": cfg.PermissionYoloRiskThreshold = val + case "worktree.conflict_check": + cfg.WorktreeConflictCheck = val + case "worktree.target_branch": + cfg.WorktreeTargetBranch = val } } } diff --git a/cmd/sin-code/internal/fusion/coverage_test.go b/cmd/sin-code/internal/fusion/coverage_test.go new file mode 100644 index 00000000..24089e13 --- /dev/null +++ b/cmd/sin-code/internal/fusion/coverage_test.go @@ -0,0 +1,965 @@ +// SPDX-License-Identifier: MIT +// Purpose: Coverage gap tests for the fusion package (issue #290, #321, #344). +// Targets uncovered error paths, edge cases, and helper functions. +// All tests pass under `go test -race -count=1` (mandate M7). +package fusion + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/agentloop" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/hooks" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/ledger" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/lessons" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/llm" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/verify" +) + +// --------------------------------------------------------------------------- +// oracle.go coverage +// --------------------------------------------------------------------------- + +func TestBuildOracleJudgePrompt(t *testing.T) { + candidates := []Candidate{ + {Provider: "a", Output: "solution A"}, + {Provider: "b", Output: "solution B"}, + } + prompt := buildOracleJudgePrompt("do the task", candidates, 10) + if !strings.Contains(prompt, "do the task") { + t.Error("prompt should contain the task prompt") + } + if !strings.Contains(prompt, "solution A") { + t.Error("prompt should contain candidate A output") + } + if !strings.Contains(prompt, "solution B") { + t.Error("prompt should contain candidate B output") + } + if !strings.Contains(prompt, "0-10") { + t.Error("prompt should mention score range") + } +} + +func TestPickHighestScore_Empty(t *testing.T) { + got := pickHighestScore(nil, nil) + if got != "" { + t.Errorf("expected empty string for no candidates, got %q", got) + } +} + +func TestPickHighestScore_ByScore(t *testing.T) { + scores := map[string]OracleScore{ + "low": {Total: 5}, + "high": {Total: 20}, + "mid": {Total: 10}, + } + candidates := []Candidate{ + {Provider: "low"}, + {Provider: "high"}, + {Provider: "mid"}, + } + got := pickHighestScore(scores, candidates) + if got != "high" { + t.Errorf("expected 'high', got %q", got) + } +} + +func TestPickHighestScore_TieBreakByCost(t *testing.T) { + scores := map[string]OracleScore{ + "cheap": {Total: 10}, + "pricey": {Total: 10}, + } + candidates := []Candidate{ + {Provider: "cheap", CostUSD: 0.01}, + {Provider: "pricey", CostUSD: 0.10}, + } + got := pickHighestScore(scores, candidates) + if got != "cheap" { + t.Errorf("expected 'cheap' (lower cost), got %q", got) + } +} + +func TestPickHighestScore_TieBreakByLatency(t *testing.T) { + scores := map[string]OracleScore{ + "fast": {Total: 10}, + "slow": {Total: 10}, + } + candidates := []Candidate{ + {Provider: "fast", CostUSD: 0.01, LatencyMs: 100}, + {Provider: "slow", CostUSD: 0.01, LatencyMs: 500}, + } + got := pickHighestScore(scores, candidates) + if got != "fast" { + t.Errorf("expected 'fast' (lower latency), got %q", got) + } +} + +func TestPickHighestScore_TieBreakByName(t *testing.T) { + scores := map[string]OracleScore{ + "zeta": {Total: 10}, + "alpha": {Total: 10}, + } + candidates := []Candidate{ + {Provider: "zeta", CostUSD: 0.01, LatencyMs: 100}, + {Provider: "alpha", CostUSD: 0.01, LatencyMs: 100}, + } + got := pickHighestScore(scores, candidates) + if got != "alpha" { + t.Errorf("expected 'alpha' (alphabetical), got %q", got) + } +} + +func TestLLMOracleJudge_ResolveModel(t *testing.T) { + j := &LLMOracleJudge{ + Client: &llm.Client{BaseURL: "http://x", APIKey: "k"}, + ModelName: "test-model", + } + if got := j.resolveModel(context.Background()); got != "test-model" { + t.Errorf("expected 'test-model', got %q", got) + } +} + +func TestLLMOracleJudge_EmptyCandidates(t *testing.T) { + j := NewLLMOracleJudge(&llm.Client{BaseURL: "http://x", APIKey: "k"}, "model") + _, err := j.Judge(context.Background(), "prompt", nil) + if err == nil { + t.Fatal("expected error for empty candidates") + } + if !strings.Contains(err.Error(), "no candidates") { + t.Errorf("expected 'no candidates' error, got: %v", err) + } +} + +func TestLLMOracleJudge_MaxScoreDefault(t *testing.T) { + srv := stubOracleServer(t, `{"scores":{"candidate-0":{"correctness_score":5,"completeness_score":5,"risk_score":2,"reasoning":"r"}},"winner_candidate_id":"candidate-0","reasoning":"ok"}`) + defer srv.Close() + + j := &LLMOracleJudge{ + Client: llm.NewClient(srv.URL, "test-key"), + ModelName: "test-model", + MaxScore: 0, // should default to DefaultOracleMaxScore + } + candidates := []Candidate{{Provider: "a", Output: "x"}} + verdict, err := j.Judge(context.Background(), "prompt", candidates) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if verdict.WinnerProvider != "a" { + t.Errorf("expected winner 'a', got %q", verdict.WinnerProvider) + } +} + +func TestLLMOracleJudge_SuccessViaHTTP(t *testing.T) { + srv := stubOracleServer(t, `{"scores":{"candidate-0":{"correctness_score":9,"completeness_score":8,"risk_score":1,"reasoning":"good"},"candidate-1":{"correctness_score":3,"completeness_score":3,"risk_score":7,"reasoning":"bad"}},"winner_candidate_id":"candidate-0","reasoning":"a wins"}`) + defer srv.Close() + + j := NewLLMOracleJudge(llm.NewClient(srv.URL, "test-key"), "test-model") + candidates := []Candidate{ + {Provider: "a", Output: "good solution"}, + {Provider: "b", Output: "bad solution"}, + } + verdict, err := j.Judge(context.Background(), "do the task", candidates) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // The Judge method randomizes candidate order to mitigate position bias, + // so candidate-0 may map to either "a" or "b". The mock always scores + // candidate-0 high (total 26) and candidate-1 low (total 9). Verify the + // winner has the high score and the loser has the low score, regardless + // of which provider the shuffle assigned to which index. + if verdict.WinnerProvider == "" { + t.Fatal("expected non-empty winner") + } + winnerScore := verdict.Scores[verdict.WinnerProvider] + if winnerScore.Total != 9+8+(10-1) { + t.Errorf("expected winner total 26, got %d (winner=%s)", winnerScore.Total, verdict.WinnerProvider) + } + // Find the loser and verify it has the low score. + for _, c := range candidates { + if c.Provider != verdict.WinnerProvider { + loserScore := verdict.Scores[c.Provider] + if loserScore.Total != 3+3+(10-7) { + t.Errorf("expected loser total 9, got %d (loser=%s)", loserScore.Total, c.Provider) + } + } + } +} + +func TestLLMOracleJudge_EmptyResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"content": ""}, "finish_reason": "stop"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + j := NewLLMOracleJudge(llm.NewClient(srv.URL, "test-key"), "test-model") + _, err := j.Judge(context.Background(), "prompt", []Candidate{{Provider: "a", Output: "x"}}) + if err == nil { + t.Fatal("expected error for empty response") + } + if !strings.Contains(err.Error(), "empty response") { + t.Errorf("expected 'empty response' error, got: %v", err) + } +} + +func TestLLMOracleJudge_NoChoices(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{"choices": []map[string]any{}} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + j := NewLLMOracleJudge(llm.NewClient(srv.URL, "test-key"), "test-model") + _, err := j.Judge(context.Background(), "prompt", []Candidate{{Provider: "a", Output: "x"}}) + if err == nil { + t.Fatal("expected error for no choices") + } +} + +func TestLLMOracleJudge_LLMCallFails(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + j := NewLLMOracleJudge(llm.NewClient(srv.URL, "test-key"), "test-model") + _, err := j.Judge(context.Background(), "prompt", []Candidate{{Provider: "a", Output: "x"}}) + if err == nil { + t.Fatal("expected error for server failure") + } +} + +func TestParseOracleVerdict_InvalidJSON(t *testing.T) { + _, err := parseOracleVerdict("not json at all", []Candidate{{Provider: "a"}}, 10) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestParseOracleVerdict_UnknownWinnerFallback(t *testing.T) { + candidates := []Candidate{ + {Provider: "a", Output: "x", CostUSD: 0.01}, + {Provider: "b", Output: "y", CostUSD: 0.02}, + } + raw := `{"scores":{"candidate-0":{"correctness_score":9,"completeness_score":9,"risk_score":1,"reasoning":"r0"},"candidate-1":{"correctness_score":3,"completeness_score":3,"risk_score":7,"reasoning":"r1"}},"winner_candidate_id":"candidate-99","reasoning":"unknown"}` + verdict, err := parseOracleVerdict(raw, candidates, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Winner is unknown, so pickHighestScore should be used: a has total 27, b has 9 + if verdict.WinnerProvider != "a" { + t.Errorf("expected fallback winner 'a', got %q", verdict.WinnerProvider) + } +} + +func TestParseOracleVerdict_MissingScoreForCandidate(t *testing.T) { + candidates := []Candidate{ + {Provider: "a", Output: "x"}, + {Provider: "b", Output: "y"}, + } + raw := `{"scores":{"candidate-0":{"correctness_score":9,"completeness_score":9,"risk_score":1,"reasoning":"r0"}},"winner_candidate_id":"candidate-0","reasoning":"ok"}` + verdict, err := parseOracleVerdict(raw, candidates, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // candidate-1 has no score entry — should default to zero + if verdict.Scores["b"].Total != 0 { + t.Errorf("expected total 0 for missing score, got %d", verdict.Scores["b"].Total) + } +} + +// stubOracleServer creates an httptest server that returns the given JSON +// as the LLM response content. +func stubOracleServer(t *testing.T, responseJSON string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"content": responseJSON}, "finish_reason": "stop"}, + }, + "usage": map[string]any{ + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) +} + +// --------------------------------------------------------------------------- +// tournament.go coverage — recordOutcome, fireHook, tieBreak, drainChannel +// --------------------------------------------------------------------------- + +func TestRecordOutcome_WithLedgerAndLessons(t *testing.T) { + ledgerStore, err := ledger.Open(filepath.Join(t.TempDir(), "ledger.db")) + if err != nil { + t.Fatalf("open ledger: %v", err) + } + defer ledgerStore.Close() + + lessonsStore, err := lessons.Open(filepath.Join(t.TempDir(), "lessons.db")) + if err != nil { + t.Fatalf("open lessons: %v", err) + } + defer lessonsStore.Close() + + tournament := &Tournament{ + Ledger: ledgerStore, + Lessons: lessonsStore, + HookSessionID: "test-session", + Workspace: "/test/ws", + } + + result := &Result{ + Winner: &Candidate{ + Provider: "winner", + SessionID: "sess-1", + TokensUsed: 100, + LatencyMs: 50, + CostUSD: 0.01, + VerifyResult: verify.Result{Passed: true, Mode: verify.ModePoC, Report: "passed"}, + }, + Losers: []Candidate{ + {Provider: "loser1", TokensUsed: 50, VerifyResult: verify.Result{Passed: false, Report: "failed"}}, + }, + TotalCostUSD: 0.02, + DurationMs: 100, + } + + tournament.recordOutcome(context.Background(), result) + // If we get here without panic, the ledger+lessons paths are covered. +} + +func TestRecordOutcome_AllFailed(t *testing.T) { + ledgerStore, err := ledger.Open(filepath.Join(t.TempDir(), "ledger.db")) + if err != nil { + t.Fatalf("open ledger: %v", err) + } + defer ledgerStore.Close() + + lessonsStore, err := lessons.Open(filepath.Join(t.TempDir(), "lessons.db")) + if err != nil { + t.Fatalf("open lessons: %v", err) + } + defer lessonsStore.Close() + + tournament := &Tournament{ + Ledger: ledgerStore, + Lessons: lessonsStore, + HookSessionID: "fail-session", + Workspace: "/test/ws", + } + + result := &Result{ + AllFailed: true, + Losers: []Candidate{{Provider: "a", TokensUsed: 10, VerifyResult: verify.Result{Passed: false, Report: "failed"}}}, + TotalCostUSD: 0.001, + DurationMs: 50, + } + + tournament.recordOutcome(context.Background(), result) +} + +func TestRecordOutcome_NilStores(t *testing.T) { + tournament := &Tournament{} + result := &Result{ + Winner: &Candidate{Provider: "w", TokensUsed: 10, LatencyMs: 5, CostUSD: 0.01}, + } + // Should not panic with nil Ledger/Lessons + tournament.recordOutcome(context.Background(), result) +} + +func TestRecordOutcome_LedgerWithoutSessionID(t *testing.T) { + ledgerStore, err := ledger.Open(filepath.Join(t.TempDir(), "ledger.db")) + if err != nil { + t.Fatalf("open ledger: %v", err) + } + defer ledgerStore.Close() + + tournament := &Tournament{ + Ledger: ledgerStore, + HookSessionID: "", // empty session ID — should skip ledger recording + } + result := &Result{AllFailed: true} + tournament.recordOutcome(context.Background(), result) +} + +func TestFireHook_WithEngine(t *testing.T) { + engine := hooks.New([]hooks.Hook{ + {Event: "fusion.dispatch", Type: "prompt", Text: "injected context"}, + }) + tournament := &Tournament{ + Hooks: engine, + HookSessionID: "hook-test", + Workspace: "/ws", + } + // Should not panic + tournament.fireHook(context.Background(), "fusion.dispatch", map[string]any{"providers": 2}) +} + +func TestFireHook_NilEngine(t *testing.T) { + tournament := &Tournament{} + tournament.fireHook(context.Background(), "fusion.dispatch", nil) +} + +func TestTieBreak_Empty(t *testing.T) { + tournament := &Tournament{} + got := tournament.tieBreak(nil) + if got != nil { + t.Errorf("expected nil for empty candidates, got %v", got) + } +} + +func TestTieBreak_SingleCandidate(t *testing.T) { + tournament := &Tournament{} + c := []Candidate{{Provider: "only"}} + got := tournament.tieBreak(c) + if got == nil || got.Provider != "only" { + t.Errorf("expected 'only', got %v", got) + } +} + +func TestDrainChannel_Timeout(t *testing.T) { + ch := make(chan Candidate, 2) + // Don't send anything — drainChannel should timeout and return empty + got := drainChannel(ch) + if len(got) != 0 { + t.Errorf("expected 0 candidates on timeout, got %d", len(got)) + } +} + +func TestDrainChannel_ClosedChannel(t *testing.T) { + ch := make(chan Candidate, 2) + ch <- Candidate{Provider: "a"} + close(ch) + got := drainChannel(ch) + if len(got) != 1 { + t.Errorf("expected 1 candidate, got %d", len(got)) + } + if got[0].Provider != "a" { + t.Errorf("expected 'a', got %q", got[0].Provider) + } +} + +// --------------------------------------------------------------------------- +// provider_pool.go coverage +// --------------------------------------------------------------------------- + +func TestResolveAPIKey_EmptyProvider(t *testing.T) { + got := resolveAPIKey("") + if got != "" { + t.Errorf("expected empty string for empty provider, got %q", got) + } +} + +func TestResolveAPIKey_EnvSet(t *testing.T) { + t.Setenv("TESTPROVIDER_API_KEY", "secret123") + got := resolveAPIKey("testprovider") + if got != "secret123" { + t.Errorf("expected 'secret123', got %q", got) + } +} + +func TestResolveAPIKey_EnvNotSet(t *testing.T) { + // Make sure the env var is not set + os.Unsetenv("NONEXISTENTPROV_API_KEY") + got := resolveAPIKey("nonexistentprov") + if got != "" { + t.Errorf("expected empty string for unset env var, got %q", got) + } +} + +func TestEstimateInputPrice(t *testing.T) { + tests := []struct { + provider string + want float64 + }{ + {"fireworks", 1.0}, + {"qwen-relay", 0.0}, + {"unknown", 2.0}, + {"", 2.0}, + {"FIREWORKS", 1.0}, // case-insensitive + } + for _, tt := range tests { + got := estimateInputPrice(tt.provider, "any-model") + if got != tt.want { + t.Errorf("estimateInputPrice(%q) = %v, want %v", tt.provider, got, tt.want) + } + } +} + +func TestEstimateOutputPrice(t *testing.T) { + tests := []struct { + provider string + want float64 + }{ + {"fireworks", 3.0}, + {"qwen-relay", 0.0}, + {"unknown", 5.0}, + {"", 5.0}, + {"QWEN-RELAY", 0.0}, // case-insensitive + } + for _, tt := range tests { + got := estimateOutputPrice(tt.provider, "any-model") + if got != tt.want { + t.Errorf("estimateOutputPrice(%q) = %v, want %v", tt.provider, got, tt.want) + } + } +} + +func TestLoadProviderPool_DecodeError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.toml") + if err := os.WriteFile(path, []byte("this is not valid toml = = =\n[broken"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + _, err := LoadProviderPool(dir, nil) + if err == nil { + t.Fatal("expected error for invalid TOML") + } +} + +func TestLoadProviderPool_ReadFileError(t *testing.T) { + dir := t.TempDir() + // Create a symlink to a nonexistent file — os.ReadDir lists it, but + // os.ReadFile fails when following the broken symlink. + linkPath := filepath.Join(dir, "broken.toml") + target := filepath.Join(dir, "nonexistent_target") + if err := os.Symlink(target, linkPath); err != nil { + t.Fatalf("symlink: %v", err) + } + _, err := LoadProviderPool(dir, nil) + if err == nil { + t.Fatal("expected error when reading broken symlink") + } +} + +func TestLoadProviderPool_EmptyNameSkipped(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "noname.toml") + if err := os.WriteFile(path, []byte(`name = "" +model = "test-model" +base_url = "http://test" +provider = "test" +`), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + pool, err := LoadProviderPool(dir, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pool) != 0 { + t.Errorf("expected 0 profiles (empty name skipped), got %d", len(pool)) + } +} + +func TestLoadProviderPool_NonTomlSkipped(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "readme.md"), []byte("# not a profile"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + if err := os.Mkdir(filepath.Join(dir, "subdir"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + pool, err := LoadProviderPool(dir, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pool) != 0 { + t.Errorf("expected 0 profiles, got %d", len(pool)) + } +} + +func TestLoadProviderPool_ValidProfile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "testprof.toml") + if err := os.WriteFile(path, []byte(`name = "testprof" +model = "accounts/fireworks/models/test" +base_url = "https://api.test.com/v1" +provider = "fireworks" +max_tokens = 4096 +`), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + pool, err := LoadProviderPool(dir, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pool) != 1 { + t.Fatalf("expected 1 profile, got %d", len(pool)) + } + if pool[0].Name != "testprof" { + t.Errorf("expected 'testprof', got %q", pool[0].Name) + } + if pool[0].InputPer1M != 1.0 { + t.Errorf("expected input price 1.0 for fireworks, got %v", pool[0].InputPer1M) + } + if pool[0].OutputPer1M != 3.0 { + t.Errorf("expected output price 3.0 for fireworks, got %v", pool[0].OutputPer1M) + } +} + +// --------------------------------------------------------------------------- +// plan_execute.go coverage +// --------------------------------------------------------------------------- + +func TestSimpleArbiter_PickResult_Empty(t *testing.T) { + a := &SimpleArbiter{} + _, err := a.PickResult(nil) + if err == nil { + t.Fatal("expected error for no result candidates") + } +} + +func TestSimpleArbiter_PickPlan_TieBreakByName(t *testing.T) { + a := &SimpleArbiter{} + plans := []PlanCandidate{ + {Model: "zeta", Plan: "same"}, + {Model: "alpha", Plan: "same"}, + } + best, err := a.PickPlan(plans) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if best.Model != "alpha" { + t.Errorf("expected 'alpha' (alphabetical tie-break), got %q", best.Model) + } +} + +func TestProviderPool_Get_NilPool(t *testing.T) { + var pool *ProviderPool + got := pool.Get(nil) + if got != nil { + t.Errorf("expected nil for nil pool, got %v", got) + } +} + +func TestProviderPool_Get_NoMatch(t *testing.T) { + pool := NewProviderPool([]ProviderConfig{{Name: "a"}, {Name: "b"}}) + got := pool.Get([]string{"nonexistent"}) + if len(got) != 0 { + t.Errorf("expected 0 for no match, got %d", len(got)) + } +} + +func TestPlanExecuteTournament_Plan_NilTournament(t *testing.T) { + var tournament *PlanExecuteTournament + _, err := tournament.Plan(context.Background(), "task", nil) + if err == nil { + t.Fatal("expected error for nil tournament") + } +} + +func TestPlanExecuteTournament_Plan_NilPool(t *testing.T) { + tournament := &PlanExecuteTournament{} + _, err := tournament.Plan(context.Background(), "task", nil) + if err == nil { + t.Fatal("expected error for nil pool") + } +} + +func TestPlanExecuteTournament_Plan_NilPlanFunc(t *testing.T) { + pool := NewProviderPool([]ProviderConfig{{Name: "a"}}) + tournament := NewPlanExecuteTournament(pool) + _, err := tournament.Plan(context.Background(), "task", nil) + if err == nil { + t.Fatal("expected error for nil PlanFunc") + } +} + +func TestPlanExecuteTournament_Execute_NilTournament(t *testing.T) { + var tournament *PlanExecuteTournament + _, err := tournament.Execute(context.Background(), &BestPlan{Plan: "x"}, nil) + if err == nil { + t.Fatal("expected error for nil tournament") + } +} + +func TestPlanExecuteTournament_Execute_NilPool(t *testing.T) { + tournament := &PlanExecuteTournament{} + _, err := tournament.Execute(context.Background(), &BestPlan{Plan: "x"}, nil) + if err == nil { + t.Fatal("expected error for nil pool") + } +} + +func TestPlanExecuteTournament_Execute_NilExecuteFunc(t *testing.T) { + pool := NewProviderPool([]ProviderConfig{{Name: "a"}}) + tournament := NewPlanExecuteTournament(pool) + _, err := tournament.Execute(context.Background(), &BestPlan{Plan: "x"}, nil) + if err == nil { + t.Fatal("expected error for nil ExecuteFunc") + } +} + +func TestPlanExecuteTournament_Execute_NoProvidersMatch(t *testing.T) { + pool := NewProviderPool([]ProviderConfig{{Name: "a"}}) + tournament := NewPlanExecuteTournament(pool) + tournament.ExecuteFunc = func(ctx context.Context, prov ProviderConfig, p *BestPlan) (string, error) { + return "x", nil + } + _, err := tournament.Execute(context.Background(), &BestPlan{Plan: "x"}, []string{"nonexistent"}) + if err == nil { + t.Fatal("expected error for no matching providers") + } +} + +func TestPlanExecuteTournament_Plan_NilArbiter(t *testing.T) { + pool := NewProviderPool([]ProviderConfig{{Name: "a"}}) + tournament := NewPlanExecuteTournament(pool) + tournament.Arbiter = nil + tournament.PlanFunc = func(ctx context.Context, prov ProviderConfig, prompt string) (string, error) { + return "plan", nil + } + best, err := tournament.Plan(context.Background(), "task", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if best == nil || best.Plan != "plan" { + t.Errorf("expected plan from default arbiter, got %v", best) + } +} + +func TestPlanExecuteTournament_Execute_NilArbiter(t *testing.T) { + pool := NewProviderPool([]ProviderConfig{{Name: "a"}}) + tournament := NewPlanExecuteTournament(pool) + tournament.Arbiter = nil + tournament.ExecuteFunc = func(ctx context.Context, prov ProviderConfig, p *BestPlan) (string, error) { + return "output", nil + } + best, err := tournament.Execute(context.Background(), &BestPlan{Plan: "x"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if best == nil || best.Output != "output" { + t.Errorf("expected output from default arbiter, got %v", best) + } +} + +// --------------------------------------------------------------------------- +// runOracle additional coverage — ForkFunc/RunFunc failure paths +// --------------------------------------------------------------------------- + +func TestOracleTournament_ForkFuncFailure(t *testing.T) { + forkFunc := func(srcSessionID string, turn int) (*session.Session, error) { + return nil, errors.New("fork failed") + } + tournament := &Tournament{ + Providers: []ProviderConfig{{Name: "a"}, {Name: "b"}}, + RunFunc: makeOracleRunFunc(map[string]*fakeProvider{"a": {output: "x", tokens: 1}, "b": {output: "y", tokens: 1}}), + ForkFunc: forkFunc, + Mode: ModeOracle, + OracleJudge: fakeOracleJudge("CORRECT"), + MinQuorum: 2, + Workspace: "/test/ws", + Prompt: "do the thing", + SourceSessionID: "src-1", + } + result, err := tournament.Run(context.Background()) + if !errors.Is(err, ErrAllProvidersFailed) { + t.Fatalf("expected ErrAllProvidersFailed, got: %v", err) + } + if result == nil || !result.AllFailed { + t.Error("expected AllFailed=true") + } +} + +func TestOracleTournament_RunFuncFailure(t *testing.T) { + runFunc := func(ctx context.Context, prov ProviderConfig, sess *session.Session, prompt string) (*agentloop.Result, error) { + return nil, errors.New("run failed") + } + tournament := &Tournament{ + Providers: []ProviderConfig{{Name: "a"}, {Name: "b"}}, + RunFunc: runFunc, + ForkFunc: makeForkFunc(), + Mode: ModeOracle, + OracleJudge: fakeOracleJudge("CORRECT"), + MinQuorum: 2, + Workspace: "/test/ws", + Prompt: "do the thing", + SourceSessionID: "src-1", + } + result, err := tournament.Run(context.Background()) + if !errors.Is(err, ErrAllProvidersFailed) { + t.Fatalf("expected ErrAllProvidersFailed, got: %v", err) + } + if result == nil || !result.AllFailed { + t.Error("expected AllFailed=true") + } +} + +func TestOracleTournament_NilRunFunc(t *testing.T) { + tournament := &Tournament{ + Providers: []ProviderConfig{{Name: "a"}}, + ForkFunc: makeForkFunc(), + Mode: ModeOracle, + OracleJudge: fakeOracleJudge("CORRECT"), + MinQuorum: 1, + } + _, err := tournament.Run(context.Background()) + if err == nil { + t.Fatal("expected error for nil RunFunc in oracle mode") + } +} + +func TestOracleTournament_NilForkFunc(t *testing.T) { + tournament := &Tournament{ + Providers: []ProviderConfig{{Name: "a"}}, + RunFunc: makeOracleRunFunc(map[string]*fakeProvider{"a": {output: "x", tokens: 1}}), + ForkFunc: nil, + Mode: ModeOracle, + OracleJudge: fakeOracleJudge("CORRECT"), + MinQuorum: 1, + } + _, err := tournament.Run(context.Background()) + if err == nil { + t.Fatal("expected error for nil ForkFunc in oracle mode") + } +} + +func TestOracleTournament_JudgeReturnsUnknownWinner(t *testing.T) { + providers := map[string]*fakeProvider{ + "a": {name: "a", delay: 10 * time.Millisecond, output: "CORRECT", tokens: 100}, + "b": {name: "b", delay: 10 * time.Millisecond, output: "CORRECT", tokens: 100}, + } + tournament := &Tournament{ + Providers: []ProviderConfig{{Name: "a"}, {Name: "b"}}, + RunFunc: makeOracleRunFunc(providers), + ForkFunc: makeForkFunc(), + Mode: ModeOracle, + OracleJudge: func(ctx context.Context, prompt string, candidates []Candidate) (OracleVerdict, error) { + scores := make(map[string]OracleScore) + for _, c := range candidates { + scores[c.Provider] = OracleScore{Correctness: 8, Completeness: 8, Risk: 2, Total: 16} + } + // Return a winner that doesn't match any candidate + return OracleVerdict{WinnerProvider: "nonexistent", Scores: scores, Reasoning: "fallback"}, nil + }, + MinQuorum: 2, + Workspace: "/test/ws", + Prompt: "do the thing", + SourceSessionID: "src-1", + } + result, err := tournament.Run(context.Background()) + if err != nil { + t.Fatalf("expected success (fallback winner), got: %v", err) + } + if result.Winner == nil { + t.Fatal("expected a winner via fallback") + } +} + +func TestOracleTournament_JudgeReturnsEmptyWinner(t *testing.T) { + providers := map[string]*fakeProvider{ + "a": {name: "a", delay: 10 * time.Millisecond, output: "CORRECT", tokens: 100, }, + } + tournament := &Tournament{ + Providers: []ProviderConfig{{Name: "a"}}, + RunFunc: makeOracleRunFunc(providers), + ForkFunc: makeForkFunc(), + Mode: ModeOracle, + OracleJudge: func(ctx context.Context, prompt string, candidates []Candidate) (OracleVerdict, error) { + scores := make(map[string]OracleScore) + for _, c := range candidates { + scores[c.Provider] = OracleScore{Correctness: 5, Completeness: 5, Risk: 5, Total: 10} + } + // Empty winner — should trigger pickHighestScore fallback + return OracleVerdict{WinnerProvider: "", Scores: scores, Reasoning: "empty"}, nil + }, + MinQuorum: 1, + Workspace: "/test/ws", + Prompt: "do the thing", + SourceSessionID: "src-1", + } + result, err := tournament.Run(context.Background()) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + if result.Winner == nil { + t.Fatal("expected winner via fallback") + } + if result.Winner.Provider != "a" { + t.Errorf("expected 'a', got %q", result.Winner.Provider) + } +} + +func TestOracleTournament_WithHooks(t *testing.T) { + engine := hooks.New(nil) + providers := map[string]*fakeProvider{ + "a": {name: "a", delay: 10 * time.Millisecond, output: "CORRECT", tokens: 100}, + } + tournament := &Tournament{ + Providers: []ProviderConfig{{Name: "a"}}, + RunFunc: makeOracleRunFunc(providers), + ForkFunc: makeForkFunc(), + Mode: ModeOracle, + OracleJudge: fakeOracleJudge("CORRECT"), + MinQuorum: 1, + Hooks: engine, + HookSessionID: "hook-test", + Workspace: "/test/ws", + Prompt: "do the thing", + SourceSessionID: "src-1", + } + _, err := tournament.Run(context.Background()) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } +} + +func TestOracleTournament_WithLedgerAndLessons(t *testing.T) { + ledgerStore, err := ledger.Open(filepath.Join(t.TempDir(), "ledger.db")) + if err != nil { + t.Fatalf("open ledger: %v", err) + } + defer ledgerStore.Close() + + lessonsStore, err := lessons.Open(filepath.Join(t.TempDir(), "lessons.db")) + if err != nil { + t.Fatalf("open lessons: %v", err) + } + defer lessonsStore.Close() + + providers := map[string]*fakeProvider{ + "a": {name: "a", delay: 10 * time.Millisecond, output: "CORRECT", tokens: 100}, + "b": {name: "b", delay: 10 * time.Millisecond, output: "wrong", tokens: 80}, + } + tournament := &Tournament{ + Providers: []ProviderConfig{{Name: "a"}, {Name: "b"}}, + RunFunc: makeOracleRunFunc(providers), + ForkFunc: makeForkFunc(), + Mode: ModeOracle, + OracleJudge: fakeOracleJudge("CORRECT"), + MinQuorum: 2, + Ledger: ledgerStore, + Lessons: lessonsStore, + HookSessionID: "oracle-ledger-test", + Workspace: "/test/ws", + Prompt: "do the thing", + SourceSessionID: "src-1", + } + result, err := tournament.Run(context.Background()) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + if result.Winner == nil { + t.Fatal("expected winner") + } +} diff --git a/cmd/sin-code/internal/loopbuilder/builder.go b/cmd/sin-code/internal/loopbuilder/builder.go index 77c47937..1a87bdfb 100644 --- a/cmd/sin-code/internal/loopbuilder/builder.go +++ b/cmd/sin-code/internal/loopbuilder/builder.go @@ -523,6 +523,7 @@ func WireFusion(loop *agentloop.Loop, cfg Config, gate *verify.Gate, client *llm MinQuorum: cfg.FusionMinQuorum, PerProviderTimeout: time.Duration(cfg.FusionPerProviderTimeoutS) * time.Second, Workspace: cfg.Workspace, + SourceSessionID: cfg.SessionID, Lessons: memStore, Ledger: ledgerStore, Hooks: hookEngine, @@ -602,17 +603,20 @@ type fusionAdapter struct { } func (a *fusionAdapter) ShouldRun(vr verify.Result) bool { + if !a.cfg.FusionDifficultyGate { + return !vr.Passed + } return fusion.ShouldTournament(vr) } -func (a *fusionAdapter) Run(ctx context.Context) (string, int, error) { - // Phase 1: the fork and run funcs are not yet wired to real session - // stores and loop builders. The tournament runs with whatever was - // configured at construction time. If ForkFunc/RunFunc are nil, the - // tournament returns an error and the loop falls back to legacy retry. +func (a *fusionAdapter) Run(ctx context.Context, prompt string) (string, int, error) { if a.t.ForkFunc == nil || a.t.RunFunc == nil { return "", 0, fmt.Errorf("fusion: tournament not fully wired (phase 1 — fork/run funcs nil)") } + a.t.Prompt = prompt + if a.t.SourceSessionID == "" { + a.t.SourceSessionID = a.cfg.SessionID + } result, err := a.t.Run(ctx) if err != nil { return "", 0, err diff --git a/cmd/sin-code/internal/loopbuilder/fusion_integration_test.go b/cmd/sin-code/internal/loopbuilder/fusion_integration_test.go index 9272a95f..e62705d2 100644 --- a/cmd/sin-code/internal/loopbuilder/fusion_integration_test.go +++ b/cmd/sin-code/internal/loopbuilder/fusion_integration_test.go @@ -57,7 +57,7 @@ func TestFusionIntegration_RunReturnsErrorWithoutSessionStore(t *testing.T) { t.Fatal("expected TournamentRunner to be non-nil") } - _, _, err = loop.TournamentRunner.Run(context.Background()) + _, _, err = loop.TournamentRunner.Run(context.Background(), "test prompt") if err == nil { t.Fatal("expected error when ForkFunc/RunFunc are nil (no SessionStore)") } @@ -210,7 +210,7 @@ func TestFusionIntegration_TournamentEndToEndAllFail(t *testing.T) { } adapter := &fusionAdapter{t: tournament} - _, _, err := adapter.Run(context.Background()) + _, _, err := adapter.Run(context.Background(), "test prompt") if !errors.Is(err, fusion.ErrAllProvidersFailed) { t.Fatalf("expected ErrAllProvidersFailed, got: %v", err) } @@ -246,7 +246,7 @@ func TestFusionIntegration_TournamentEndToEndWinner(t *testing.T) { } adapter := &fusionAdapter{t: tournament} - output, tokens, err := adapter.Run(context.Background()) + output, tokens, err := adapter.Run(context.Background(), "test prompt") if err != nil { t.Fatalf("expected no error, got: %v", err) } @@ -259,7 +259,8 @@ func TestFusionIntegration_TournamentEndToEndWinner(t *testing.T) { } func TestFusionIntegration_ShouldRun(t *testing.T) { - adapter := &fusionAdapter{t: &fusion.Tournament{}} + // Default: FusionDifficultyGate=true → text heuristic filters stylistic failures. + adapter := &fusionAdapter{t: &fusion.Tournament{}, cfg: Config{FusionDifficultyGate: true}} structuralFail := verify.Result{Passed: false, Mode: verify.ModePoC, Report: "compile error: undefined variable"} if !adapter.ShouldRun(structuralFail) { @@ -270,4 +271,109 @@ func TestFusionIntegration_ShouldRun(t *testing.T) { if adapter.ShouldRun(passed) { t.Error("expected ShouldRun=false for passed result") } + + stylisticFail := verify.Result{Passed: false, Mode: verify.ModePoC, Report: "naming convention violation"} + if adapter.ShouldRun(stylisticFail) { + t.Error("expected ShouldRun=false for stylistic failure when difficulty gate is on") + } +} + +func TestFusionIntegration_ShouldRunDifficultyGateOff(t *testing.T) { + // FusionDifficultyGate=false → ALL failures trigger tournament (no filtering). + adapter := &fusionAdapter{t: &fusion.Tournament{}, cfg: Config{FusionDifficultyGate: false}} + + structuralFail := verify.Result{Passed: false, Mode: verify.ModePoC, Report: "compile error: undefined variable"} + if !adapter.ShouldRun(structuralFail) { + t.Error("expected ShouldRun=true for structural failure (gate off)") + } + + stylisticFail := verify.Result{Passed: false, Mode: verify.ModePoC, Report: "naming convention violation"} + if !adapter.ShouldRun(stylisticFail) { + t.Error("expected ShouldRun=true for stylistic failure when difficulty gate is off") + } + + passed := verify.Result{Passed: true, Mode: verify.ModePoC, Report: "passed"} + if adapter.ShouldRun(passed) { + t.Error("expected ShouldRun=false for passed result even with gate off") + } +} + +func TestFusionIntegration_RunSetsPromptAndSourceSessionID(t *testing.T) { + var capturedPrompt string + var capturedSourceSessionID string + + mockRunFunc := func(ctx context.Context, prov fusion.ProviderConfig, sess *session.Session, prompt string) (*agentloop.Result, error) { + capturedPrompt = prompt + return &agentloop.Result{ + SessionID: sess.ID, + Summary: "CORRECT", + Turns: 1, + Tokens: 100, + }, nil + } + mockForkFunc := func(srcSessionID string, turn int) (*session.Session, error) { + capturedSourceSessionID = srcSessionID + return &session.Session{ID: "fork-session"}, nil + } + mockVerifyFn := func(ctx context.Context, workspace string) verify.Result { + return verify.Result{Passed: true, Mode: verify.ModePoC, Report: "passed"} + } + + tournament := &fusion.Tournament{ + Providers: []fusion.ProviderConfig{{Name: "mock-a"}, {Name: "mock-b"}}, + RunFunc: mockRunFunc, + ForkFunc: mockForkFunc, + VerifyFn: mockVerifyFn, + MinQuorum: 2, + MaxCostUSD: 10.0, + PerProviderTimeout: 5 * time.Second, + Workspace: t.TempDir(), + } + + adapter := &fusionAdapter{ + t: tournament, + cfg: Config{SessionID: "source-session-42"}, + } + + output, _, err := adapter.Run(context.Background(), "fix the bug in auth.go") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if output != "CORRECT" { + t.Errorf("expected output 'CORRECT', got %q", output) + } + if capturedPrompt != "fix the bug in auth.go" { + t.Errorf("expected prompt propagated to RunFunc, got %q", capturedPrompt) + } + if capturedSourceSessionID != "source-session-42" { + t.Errorf("expected SourceSessionID propagated to ForkFunc, got %q", capturedSourceSessionID) + } + if tournament.Prompt != "fix the bug in auth.go" { + t.Errorf("expected tournament.Prompt set, got %q", tournament.Prompt) + } + if tournament.SourceSessionID != "source-session-42" { + t.Errorf("expected tournament.SourceSessionID set, got %q", tournament.SourceSessionID) + } +} + +func TestFusionIntegration_WireFusionSetsSourceSessionID(t *testing.T) { + loop := &agentloop.Loop{} + gate := verify.NewGate("poc", nil, nil) + cfg := Config{ + FusionEnabled: true, + FusionProviders: []string{"minimax-m3", "glm-5p2"}, + FusionMaxCostUSD: 10.0, + FusionMinQuorum: 2, + SessionID: "test-session-99", + } + + WireFusion(loop, cfg, gate, nil, nil, nil, nil) + + adapter, ok := loop.TournamentRunner.(*fusionAdapter) + if !ok { + t.Fatalf("expected *fusionAdapter, got %T", loop.TournamentRunner) + } + if adapter.t.SourceSessionID != "test-session-99" { + t.Errorf("expected SourceSessionID 'test-session-99', got %q", adapter.t.SourceSessionID) + } } diff --git a/cmd/sin-code/internal/loopbuilder/wiring_test.go b/cmd/sin-code/internal/loopbuilder/wiring_test.go index ca13207d..f2c506a0 100644 --- a/cmd/sin-code/internal/loopbuilder/wiring_test.go +++ b/cmd/sin-code/internal/loopbuilder/wiring_test.go @@ -212,7 +212,7 @@ func TestWiring_FusionTournamentTriggeredOnVerifyFail(t *testing.T) { }, TournamentRunner: &mockTournamentRunner{ shouldRun: true, - runFn: func(ctx context.Context) (string, int, error) { + runFn: func(ctx context.Context, prompt string) (string, int, error) { called = true return "winner output", 100, nil }, @@ -420,16 +420,16 @@ func TestWiring_RecordPlanCompletionNilSafe(t *testing.T) { type mockTournamentRunner struct { shouldRun bool - runFn func(ctx context.Context) (string, int, error) + runFn func(ctx context.Context, prompt string) (string, int, error) } func (m *mockTournamentRunner) ShouldRun(vr verify.Result) bool { return m.shouldRun } -func (m *mockTournamentRunner) Run(ctx context.Context) (string, int, error) { +func (m *mockTournamentRunner) Run(ctx context.Context, prompt string) (string, int, error) { if m.runFn != nil { - return m.runFn(ctx) + return m.runFn(ctx, prompt) } return "winner", 50, nil } diff --git a/cmd/sin-code/internal/orchestrator/e2e_test.go b/cmd/sin-code/internal/orchestrator/e2e_test.go index 4ed922f0..e9dce298 100644 --- a/cmd/sin-code/internal/orchestrator/e2e_test.go +++ b/cmd/sin-code/internal/orchestrator/e2e_test.go @@ -512,7 +512,7 @@ type stubTournamentRunner struct { providersTried int shouldRunVal bool providers []string - runFn func(ctx context.Context) (string, int, error) + runFn func(ctx context.Context, prompt string) (string, int, error) } func (s *stubTournamentRunner) ShouldRun(vr verify.Result) bool { @@ -521,14 +521,14 @@ func (s *stubTournamentRunner) ShouldRun(vr verify.Result) bool { return s.shouldRunVal } -func (s *stubTournamentRunner) Run(ctx context.Context) (string, int, error) { +func (s *stubTournamentRunner) Run(ctx context.Context, prompt string) (string, int, error) { s.mu.Lock() s.runCalled = true s.providersTried++ fn := s.runFn s.mu.Unlock() if fn != nil { - return fn(ctx) + return fn(ctx, prompt) } return "tournament winner", 50, nil } @@ -554,7 +554,7 @@ type multiProviderStubRunner struct { workspace string } -func (m *multiProviderStubRunner) Run(ctx context.Context) (string, int, error) { +func (m *multiProviderStubRunner) Run(ctx context.Context, prompt string) (string, int, error) { m.providerMu.Lock() idx := m.nextProvider m.nextProvider++ @@ -599,7 +599,7 @@ func TestE2E_FusionTournamentTrigger(t *testing.T) { stub := &stubTournamentRunner{ shouldRunVal: true, - runFn: func(ctx context.Context) (string, int, error) { + runFn: func(ctx context.Context, prompt string) (string, int, error) { return "fusion winner output", 200, nil }, } diff --git a/cmd/sin-code/internal/snapshot.go b/cmd/sin-code/internal/snapshot.go deleted file mode 100644 index 6086f9c0..00000000 --- a/cmd/sin-code/internal/snapshot.go +++ /dev/null @@ -1,474 +0,0 @@ -// SPDX-License-Identifier: MIT -// Purpose: Status snapshot / readiness report (issue #326). Collects system -// status — Go version, git state, build/vet/test results, config, MCP servers, -// skills, sessions, todos — and renders a markdown readiness report. -package internal - -import ( - "bytes" - "fmt" - "os/exec" - "runtime" - "sort" - "strings" - "time" - - "github.com/spf13/cobra" -) - -// ── Data types ────────────────────────────────────────────────────────────── - -// SnapshotData holds the collected system status fields rendered by -// RenderMarkdown. Populated by Collect; zero-value fields mean "not collected" -// or "collection failed" (the corresponding error field carries the reason). -type SnapshotData struct { - GeneratedAt time.Time `json:"generated_at"` - - GoVersion string `json:"go_version"` - BuildPass bool `json:"build_pass"` - VetPass bool `json:"vet_pass"` - TestsPass int `json:"tests_pass"` - TestsFail int `json:"tests_fail"` - - GitBranch string `json:"git_branch"` - GitClean bool `json:"git_clean"` - GitAhead int `json:"git_ahead"` - GitBehind int `json:"git_behind"` - - ConfigModel string `json:"config_model"` - ConfigProvider string `json:"config_provider"` - ConfigVerifyMode string `json:"config_verify_mode"` - - MCPServers map[string]bool `json:"mcp_servers"` - MCPOrder []string `json:"-"` - Skills int `json:"skills_installed"` - Sessions int `json:"sessions_active"` - TodosOpen int `json:"todos_open"` - TodosBlocked int `json:"todos_blocked"` - TodosReady int `json:"todos_ready"` -} - -// Snapshot is the collector + renderer for the readiness report (issue #326). -// All external operations (git, go build/vet/test, store queries) go through -// injectable function hooks so tests can run hermetically without spawning -// subprocesses or opening real databases. -type Snapshot struct { - Workdir string - - // git runs a git command in Workdir and returns trimmed stdout. - git func(args ...string) (string, error) - // exec runs an arbitrary command in Workdir and returns trimmed stdout. - exec func(name string, args ...string) (string, error) - // sessionCount returns the number of active sessions. - sessionCount func() (int, error) - // todoCounts returns open, blocked, ready counts. - todoCounts func() (open, blocked, ready int, err error) - // mcpStatus returns a map of server-name → available (true/false), - // plus the display order. - mcpStatus func() (map[string]bool, []string, error) - // skillsCount returns the number of installed skills. - skillsCount func() (int, error) - // configValues returns model, provider, verify_mode from the effective config. - configValues func() (model, provider, verifyMode string) - - data SnapshotData - collected bool -} - -// NewSnapshot returns a Snapshot wired with real default implementations. -// All hooks are overridable for testing; see NewSnapshotWithHooks. -func NewSnapshot() *Snapshot { - s := &Snapshot{ - Workdir: ".", - } - s.git = func(args ...string) (string, error) { - return s.runCmd("git", args...) - } - s.exec = func(name string, args ...string) (string, error) { - return s.runCmd(name, args...) - } - s.sessionCount = func() (int, error) { return 0, nil } - s.todoCounts = func() (int, int, int, error) { return 0, 0, 0, nil } - s.mcpStatus = func() (map[string]bool, []string, error) { return nil, nil, nil } - s.skillsCount = func() (int, error) { return 0, nil } - s.configValues = func() (string, string, string) { return "", "", "" } - return s -} - -// NewSnapshotWithHooks returns a Snapshot with all hooks overridden by the -// caller. Any nil hook falls back to the NewSnapshot default (no-op / zero). -func NewSnapshotWithHooks(hooks SnapshotHooks) *Snapshot { - s := NewSnapshot() - if hooks.Git != nil { - s.git = hooks.Git - } - if hooks.Exec != nil { - s.exec = hooks.Exec - } - if hooks.SessionCount != nil { - s.sessionCount = hooks.SessionCount - } - if hooks.TodoCounts != nil { - s.todoCounts = hooks.TodoCounts - } - if hooks.MCPStatus != nil { - s.mcpStatus = hooks.MCPStatus - } - if hooks.SkillsCount != nil { - s.skillsCount = hooks.SkillsCount - } - if hooks.ConfigValues != nil { - s.configValues = hooks.ConfigValues - } - if hooks.Workdir != "" { - s.Workdir = hooks.Workdir - } - return s -} - -// SnapshotHooks is the injection point for test overrides. -type SnapshotHooks struct { - Workdir string - Git func(args ...string) (string, error) - Exec func(name string, args ...string) (string, error) - SessionCount func() (int, error) - TodoCounts func() (open, blocked, ready int, err error) - MCPStatus func() (map[string]bool, []string, error) - SkillsCount func() (int, error) - ConfigValues func() (model, provider, verifyMode string) -} - -// runCmd executes a command in Workdir and returns trimmed stdout. -func (s *Snapshot) runCmd(name string, args ...string) (string, error) { - cmd := exec.Command(name, args...) // #nosec G204 - cmd.Dir = s.Workdir - var out, errb bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &errb - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, errb.String()) - } - return strings.TrimSpace(out.String()), nil -} - -// ── Collect ───────────────────────────────────────────────────────────────── - -// Collect gathers all system status fields. It is resilient: each section is -// collected independently and a failure in one section does not abort the -// others. Errors are swallowed (the field stays zero-value) so the report is -// always rendered, even on a broken system. -func (s *Snapshot) Collect() error { - s.data.GeneratedAt = time.Now() - - // Go version (always available — runtime.Version). - s.data.GoVersion = runtime.Version() - - // Build: go build ./... - if out, err := s.exec("go", "build", "./..."); err != nil { - s.data.BuildPass = false - _ = out - } else { - s.data.BuildPass = true - } - - // Vet: go vet ./... - if out, err := s.exec("go", "vet", "./..."); err != nil { - s.data.VetPass = false - _ = out - } else { - s.data.VetPass = true - } - - // Tests: go test -count=1 ./... — parse output for pass/fail counts. - s.collectTests() - - // Git state. - s.collectGit() - - // Config. - model, provider, verifyMode := s.configValues() - s.data.ConfigModel = model - s.data.ConfigProvider = provider - s.data.ConfigVerifyMode = verifyMode - - // MCP servers. - if servers, order, err := s.mcpStatus(); err == nil && servers != nil { - s.data.MCPServers = servers - s.data.MCPOrder = order - } - - // Skills. - if n, err := s.skillsCount(); err == nil { - s.data.Skills = n - } - - // Sessions. - if n, err := s.sessionCount(); err == nil { - s.data.Sessions = n - } - - // Todos. - if open, blocked, ready, err := s.todoCounts(); err == nil { - s.data.TodosOpen = open - s.data.TodosBlocked = blocked - s.data.TodosReady = ready - } - - s.collected = true - return nil -} - -// collectTests runs `go test -count=1 ./...` and parses the output for -// pass/fail counts. The output format is one line per package: -// -// ok pkg/path 0.123s -// FAIL pkg/path 0.456s -// -// A timeout of 5 minutes is enforced so a hanging test suite does not block -// the snapshot indefinitely. -func (s *Snapshot) collectTests() { - out, err := s.exec("go", "test", "-count=1", "./...") - if err != nil { - // Even on failure, the output contains pass/fail lines we can parse. - _ = err - } - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "ok") { - s.data.TestsPass++ - } else if strings.HasPrefix(line, "FAIL") { - s.data.TestsFail++ - } - } -} - -// collectGit gathers branch, dirty state, and ahead/behind counts. -func (s *Snapshot) collectGit() { - branch, err := s.git("rev-parse", "--abbrev-ref", "HEAD") - if err != nil { - return - } - s.data.GitBranch = branch - - status, err := s.git("status", "--porcelain") - if err == nil { - s.data.GitClean = status == "" - } - - ahead, err := s.git("rev-list", "--count", "@{u}..HEAD") - if err == nil { - s.data.GitAhead = atoiSafe(ahead) - } - - behind, err := s.git("rev-list", "--count", "HEAD..@{u}") - if err == nil { - s.data.GitBehind = atoiSafe(behind) - } -} - -// atoiSafe converts s to int, returning 0 on any error. -func atoiSafe(s string) int { - s = strings.TrimSpace(s) - n := 0 - for _, c := range s { - if c < '0' || c > '9' { - return 0 - } - n = n*10 + int(c-'0') - } - return n -} - -// ── RenderMarkdown ────────────────────────────────────────────────────────── - -// RenderMarkdown renders the collected data as a markdown readiness report. -// Collect must be called first; if it hasn't, RenderMarkdown calls it. -func (s *Snapshot) RenderMarkdown() string { - if !s.collected { - _ = s.Collect() - } - d := s.data - var b strings.Builder - - fmt.Fprintf(&b, "# SIN-Code Readiness Report\n") - fmt.Fprintf(&b, "Generated: %s\n\n", d.GeneratedAt.Format("2006-01-02 15:04:05")) - - // Build section - fmt.Fprintf(&b, "## Build\n") - fmt.Fprintf(&b, "- Go: %s\n", d.GoVersion) - fmt.Fprintf(&b, "- Build: %s\n", checkmark(d.BuildPass)) - fmt.Fprintf(&b, "- Vet: %s\n", checkmark(d.VetPass)) - fmt.Fprintf(&b, "- Tests: %d pass, %d fail\n\n", d.TestsPass, d.TestsFail) - - // Git section - fmt.Fprintf(&b, "## Git\n") - fmt.Fprintf(&b, "- Branch: %s\n", d.GitBranch) - fmt.Fprintf(&b, "- Clean: %s\n", checkmark(d.GitClean)) - fmt.Fprintf(&b, "- Ahead: %d, Behind: %d\n\n", d.GitAhead, d.GitBehind) - - // Configuration section - fmt.Fprintf(&b, "## Configuration\n") - fmt.Fprintf(&b, "- Model: %s\n", snapshotOrDash(d.ConfigModel)) - fmt.Fprintf(&b, "- Provider: %s\n", snapshotOrDash(d.ConfigProvider)) - fmt.Fprintf(&b, "- Verify mode: %s\n\n", snapshotOrDash(d.ConfigVerifyMode)) - - // MCP Servers section - fmt.Fprintf(&b, "## MCP Servers\n") - if len(d.MCPServers) == 0 { - fmt.Fprintf(&b, "- (none configured)\n") - } else { - order := d.MCPOrder - if len(order) == 0 { - order = make([]string, 0, len(d.MCPServers)) - for k := range d.MCPServers { - order = append(order, k) - } - sort.Strings(order) - } - for _, name := range order { - avail, ok := d.MCPServers[name] - if !ok { - continue - } - fmt.Fprintf(&b, "- %s: %s\n", name, checkmark(avail)) - } - } - fmt.Fprintf(&b, "\n") - - // Skills section - fmt.Fprintf(&b, "## Skills\n") - fmt.Fprintf(&b, "- Installed: %d\n\n", d.Skills) - - // Sessions section - fmt.Fprintf(&b, "## Sessions\n") - fmt.Fprintf(&b, "- Active: %d\n\n", d.Sessions) - - // Todos section - fmt.Fprintf(&b, "## Todos\n") - fmt.Fprintf(&b, "- Open: %d, Blocked: %d, Ready: %d\n\n", d.TodosOpen, d.TodosBlocked, d.TodosReady) - - // Verdict - fmt.Fprintf(&b, "## Verdict\n") - fmt.Fprintf(&b, "%s\n", s.verdict()) - - return b.String() -} - -// verdict computes the overall readiness verdict based on collected data. -func (s *Snapshot) verdict() string { - d := s.data - issues := 0 - if !d.BuildPass { - issues++ - } - if !d.VetPass { - issues++ - } - if d.TestsFail > 0 { - issues++ - } - if !d.GitClean { - issues++ - } - switch { - case issues == 0: - return "READY FOR PRODUCTION ✅" - case issues <= 2: - return "ATTENTION NEEDED ⚠️" - default: - return "NOT READY ❌" - } -} - -// NeedsAttention returns true when the verdict is not "READY FOR PRODUCTION". -// Used by the --exit-code flag (exit 2 when attention is needed). -func (s *Snapshot) NeedsAttention() bool { - return s.verdict() != "READY FOR PRODUCTION ✅" -} - -// checkmark returns ✅ for true and ❌ for false. -func checkmark(ok bool) string { - if ok { - return "✅ pass" - } - return "❌ fail" -} - -// snapshotOrDash returns s or "—" if s is empty. -func snapshotOrDash(s string) string { - if s == "" { - return "—" - } - return s -} - -// ── CLI command ───────────────────────────────────────────────────────────── - -var snapshotMarkdown bool -var snapshotJSON bool -var snapshotExitCode bool - -// SnapshotCmd is the cobra command for `sin-code snapshot`. -// Register via rootCmd.AddCommand(internal.SnapshotCmd) in main.go. -var SnapshotCmd = &cobra.Command{ - Use: "snapshot", - Short: "Generate a readiness report (issue #326)", - Long: `Collect system status — Go version, git state, build/vet/test results, -config, MCP servers, skills, sessions, todos — and render a readiness report. - -Examples: - sin-code snapshot --markdown - sin-code snapshot --json - sin-code snapshot --exit-code # exits 2 when attention needed`, - Args: cobra.NoArgs, - Version: Version, - RunE: func(cmd *cobra.Command, args []string) error { - s := NewSnapshot() - if err := s.Collect(); err != nil { - return err - } - if snapshotJSON { - cmd.Println(renderSnapshotJSON(s)) - return nil - } - cmd.Print(s.RenderMarkdown()) - if snapshotExitCode && s.NeedsAttention() { - cmd.SilenceUsage = true - return fmt.Errorf("readiness: %s", s.verdict()) - } - return nil - }, -} - -func init() { - SnapshotCmd.Flags().BoolVar(&snapshotMarkdown, "markdown", true, "render as markdown (default)") - SnapshotCmd.Flags().BoolVar(&snapshotJSON, "json", false, "render as JSON") - SnapshotCmd.Flags().BoolVar(&snapshotExitCode, "exit-code", false, "exit 2 when readiness needs attention (CI gate)") - RegisterVersionCmd(SnapshotCmd) -} - -// renderSnapshotJSON produces a compact JSON representation of the snapshot. -func renderSnapshotJSON(s *Snapshot) string { - d := s.data - var b strings.Builder - fmt.Fprintf(&b, `{"generated_at":"%s",`, d.GeneratedAt.Format(time.RFC3339)) - fmt.Fprintf(&b, `"go_version":"%s",`, d.GoVersion) - fmt.Fprintf(&b, `"build_pass":%t,`, d.BuildPass) - fmt.Fprintf(&b, `"vet_pass":%t,`, d.VetPass) - fmt.Fprintf(&b, `"tests_pass":%d,`, d.TestsPass) - fmt.Fprintf(&b, `"tests_fail":%d,`, d.TestsFail) - fmt.Fprintf(&b, `"git_branch":"%s",`, d.GitBranch) - fmt.Fprintf(&b, `"git_clean":%t,`, d.GitClean) - fmt.Fprintf(&b, `"git_ahead":%d,`, d.GitAhead) - fmt.Fprintf(&b, `"git_behind":%d,`, d.GitBehind) - fmt.Fprintf(&b, `"config_model":"%s",`, d.ConfigModel) - fmt.Fprintf(&b, `"config_provider":"%s",`, d.ConfigProvider) - fmt.Fprintf(&b, `"config_verify_mode":"%s",`, d.ConfigVerifyMode) - fmt.Fprintf(&b, `"skills_installed":%d,`, d.Skills) - fmt.Fprintf(&b, `"sessions_active":%d,`, d.Sessions) - fmt.Fprintf(&b, `"todos_open":%d,`, d.TodosOpen) - fmt.Fprintf(&b, `"todos_blocked":%d,`, d.TodosBlocked) - fmt.Fprintf(&b, `"todos_ready":%d,`, d.TodosReady) - fmt.Fprintf(&b, `"verdict":"%s"}`, s.verdict()) - return b.String() -} diff --git a/cmd/sin-code/internal/snapshot_test.go b/cmd/sin-code/internal/snapshot_test.go deleted file mode 100644 index e0275208..00000000 --- a/cmd/sin-code/internal/snapshot_test.go +++ /dev/null @@ -1,375 +0,0 @@ -// SPDX-License-Identifier: MIT -// Purpose: tests for issue #326 — Status snapshot / readiness report. -// All external operations are injected via SnapshotHooks so tests run -// hermetically without git, go build, or real databases. -package internal - -import ( - "fmt" - "strings" - "testing" -) - -// helperSnapshot returns a Snapshot with all hooks stubbed to deterministic -// values, so every test starts from a known state. -func helperSnapshot() *Snapshot { - return NewSnapshotWithHooks(SnapshotHooks{ - Workdir: ".", - Git: func(args ...string) (string, error) { - switch { - case len(args) >= 2 && args[0] == "rev-parse" && args[1] == "--abbrev-ref": - return "main", nil - case len(args) >= 1 && args[0] == "status": - return "", nil // clean tree - case len(args) >= 2 && args[0] == "rev-list" && args[1] == "--count": - if len(args) >= 3 && args[2] == "@{u}..HEAD" { - return "0", nil - } - if len(args) >= 3 && args[2] == "HEAD..@{u}" { - return "0", nil - } - } - return "", nil - }, - Exec: func(name string, args ...string) (string, error) { - // Simulate clean build + vet + all tests pass. - if name == "go" && len(args) > 0 { - switch args[0] { - case "build": - return "", nil - case "vet": - return "", nil - case "test": - return "ok\tpkg/a\t0.1s\nok\tpkg/b\t0.2s\n", nil - } - } - return "", nil - }, - SessionCount: func() (int, error) { return 3, nil }, - TodoCounts: func() (int, int, int, error) { return 5, 1, 2, nil }, - MCPStatus: func() (map[string]bool, []string, error) { - return map[string]bool{ - "websearch": true, - "scheduler": true, - "browser": false, - }, - []string{"websearch", "scheduler", "browser"}, - nil - }, - SkillsCount: func() (int, error) { return 37, nil }, - ConfigValues: func() (string, string, string) { - return "claude-mythos-5", "anthropic", "poc" - }, - }) -} - -// ── Test 1: Collect populates all fields ──────────────────────────────────── - -func TestSnapshot_Collect_PopulatesAllFields(t *testing.T) { - s := helperSnapshot() - if err := s.Collect(); err != nil { - t.Fatalf("Collect() error: %v", err) - } - d := s.data - - if d.GoVersion == "" { - t.Error("GoVersion should be non-empty") - } - if !d.BuildPass { - t.Error("BuildPass should be true") - } - if !d.VetPass { - t.Error("VetPass should be true") - } - if d.TestsPass != 2 { - t.Errorf("TestsPass = %d, want 2", d.TestsPass) - } - if d.TestsFail != 0 { - t.Errorf("TestsFail = %d, want 0", d.TestsFail) - } - if d.GitBranch != "main" { - t.Errorf("GitBranch = %q, want %q", d.GitBranch, "main") - } - if !d.GitClean { - t.Error("GitClean should be true") - } - if d.GitAhead != 0 || d.GitBehind != 0 { - t.Errorf("GitAhead=%d GitBehind=%d, want 0/0", d.GitAhead, d.GitBehind) - } - if d.ConfigModel != "claude-mythos-5" { - t.Errorf("ConfigModel = %q, want %q", d.ConfigModel, "claude-mythos-5") - } - if d.ConfigProvider != "anthropic" { - t.Errorf("ConfigProvider = %q, want %q", d.ConfigProvider, "anthropic") - } - if d.ConfigVerifyMode != "poc" { - t.Errorf("ConfigVerifyMode = %q, want %q", d.ConfigVerifyMode, "poc") - } - if len(d.MCPServers) != 3 { - t.Errorf("MCPServers len = %d, want 3", len(d.MCPServers)) - } - if d.Skills != 37 { - t.Errorf("Skills = %d, want 37", d.Skills) - } - if d.Sessions != 3 { - t.Errorf("Sessions = %d, want 3", d.Sessions) - } - if d.TodosOpen != 5 || d.TodosBlocked != 1 || d.TodosReady != 2 { - t.Errorf("Todos open/blocked/ready = %d/%d/%d, want 5/1/2", d.TodosOpen, d.TodosBlocked, d.TodosReady) - } -} - -// ── Test 2: RenderMarkdown produces expected sections ─────────────────────── - -func TestSnapshot_RenderMarkdown_ContainsAllSections(t *testing.T) { - s := helperSnapshot() - _ = s.Collect() - md := s.RenderMarkdown() - - required := []string{ - "# SIN-Code Readiness Report", - "## Build", - "## Git", - "## Configuration", - "## MCP Servers", - "## Skills", - "## Sessions", - "## Todos", - "## Verdict", - } - for _, section := range required { - if !strings.Contains(md, section) { - t.Errorf("RenderMarkdown() missing section %q", section) - } - } -} - -// ── Test 3: RenderMarkdown shows correct verdict for clean system ────────── - -func TestSnapshot_RenderMarkdown_VerdictReady(t *testing.T) { - s := helperSnapshot() - _ = s.Collect() - md := s.RenderMarkdown() - if !strings.Contains(md, "READY FOR PRODUCTION") { - t.Errorf("clean system should be READY FOR PRODUCTION, got:\n%s", md) - } -} - -// ── Test 4: RenderMarkdown shows attention needed when build fails ───────── - -func TestSnapshot_RenderMarkdown_VerdictAttentionOnBuildFail(t *testing.T) { - s := helperSnapshot() - s.exec = func(name string, args ...string) (string, error) { - if name == "go" && len(args) > 0 && args[0] == "build" { - return "", fmt.Errorf("build failed") - } - return "", nil - } - _ = s.Collect() - if !s.NeedsAttention() { - t.Error("NeedsAttention should be true when build fails") - } - md := s.RenderMarkdown() - if !strings.Contains(md, "ATTENTION NEEDED") && !strings.Contains(md, "NOT READY") { - t.Errorf("failing build should not be READY, got verdict: %s", s.verdict()) - } -} - -// ── Test 5: RenderMarkdown shows NOT READY when multiple failures ────────── - -func TestSnapshot_RenderMarkdown_VerdictNotReady(t *testing.T) { - s := helperSnapshot() - s.exec = func(name string, args ...string) (string, error) { - if name == "go" && len(args) > 0 { - switch args[0] { - case "build": - return "", fmt.Errorf("build failed") - case "vet": - return "", fmt.Errorf("vet failed") - case "test": - return "FAIL\tpkg/a\t0.1s\nFAIL\tpkg/b\t0.2s\n", nil - } - } - return "", nil - } - s.git = func(args ...string) (string, error) { - if len(args) > 0 && args[0] == "status" { - return "M file.go", nil // dirty - } - if len(args) > 0 && args[0] == "rev-parse" { - return "main", nil - } - return "0", nil - } - _ = s.Collect() - md := s.RenderMarkdown() - if !strings.Contains(md, "NOT READY") { - t.Errorf("3+ issues should be NOT READY, got:\n%s", md) - } -} - -// ── Test 6: MCP servers render in order with correct status ──────────────── - -func TestSnapshot_RenderMarkdown_MCPOrderAndStatus(t *testing.T) { - s := helperSnapshot() - _ = s.Collect() - md := s.RenderMarkdown() - - // websearch and scheduler should show ✅, browser should show ❌ - if !strings.Contains(md, "websearch: ✅") { - t.Error("websearch should show ✅") - } - if !strings.Contains(md, "scheduler: ✅") { - t.Error("scheduler should show ✅") - } - if !strings.Contains(md, "browser: ❌") { - t.Error("browser should show ❌") - } - - // Verify order: websearch before scheduler before browser - wsIdx := strings.Index(md, "websearch") - scIdx := strings.Index(md, "scheduler") - brIdx := strings.Index(md, "browser") - if !(wsIdx < scIdx && scIdx < brIdx) { - t.Errorf("MCP servers not in expected order: ws=%d sc=%d br=%d", wsIdx, scIdx, brIdx) - } -} - -// ── Test 7: Collect is resilient — one hook failing doesn't abort ────────── - -func TestSnapshot_Collect_ResilientToHookFailures(t *testing.T) { - s := helperSnapshot() - // Make git fail entirely — Collect should still populate other fields. - s.git = func(args ...string) (string, error) { - return "", fmt.Errorf("git not found") - } - // Make todoCounts fail. - s.todoCounts = func() (int, int, int, error) { - return 0, 0, 0, fmt.Errorf("todo db locked") - } - // Make MCP fail. - s.mcpStatus = func() (map[string]bool, []string, error) { - return nil, nil, fmt.Errorf("mcp unavailable") - } - - if err := s.Collect(); err != nil { - t.Fatalf("Collect() should not return error even when hooks fail: %v", err) - } - d := s.data - - // Build/vet/tests should still be populated. - if !d.BuildPass { - t.Error("BuildPass should still be true even when git fails") - } - // Git fields should be zero-values (collection failed silently). - if d.GitBranch != "" { - t.Errorf("GitBranch should be empty when git fails, got %q", d.GitBranch) - } - // Todo fields should be zero. - if d.TodosOpen != 0 { - t.Errorf("TodosOpen should be 0 when todo hook fails, got %d", d.TodosOpen) - } - // MCP should be nil/empty. - if len(d.MCPServers) != 0 { - t.Errorf("MCPServers should be empty when mcp hook fails, got %d", len(d.MCPServers)) - } -} - -// ── Test 8: NeedsAttention + verdict logic ────────────────────────────────── - -func TestSnapshot_NeedsAttention_Logic(t *testing.T) { - cases := []struct { - name string - mutate func(s *Snapshot) - want bool - verdict string - }{ - { - name: "all clean", - mutate: func(s *Snapshot) {}, - want: false, - verdict: "READY FOR PRODUCTION ✅", - }, - { - name: "build fail only", - mutate: func(s *Snapshot) { - s.data.BuildPass = false - }, - want: true, - verdict: "ATTENTION NEEDED ⚠️", - }, - { - name: "dirty git only", - mutate: func(s *Snapshot) { - s.data.GitClean = false - }, - want: true, - verdict: "ATTENTION NEEDED ⚠️", - }, - { - name: "tests fail only", - mutate: func(s *Snapshot) { - s.data.TestsFail = 3 - }, - want: true, - verdict: "ATTENTION NEEDED ⚠️", - }, - { - name: "all fail", - mutate: func(s *Snapshot) { - s.data.BuildPass = false - s.data.VetPass = false - s.data.TestsFail = 5 - s.data.GitClean = false - }, - want: true, - verdict: "NOT READY ❌", - }, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - s := helperSnapshot() - _ = s.Collect() - c.mutate(s) - got := s.NeedsAttention() - if got != c.want { - t.Errorf("NeedsAttention() = %v, want %v", got, c.want) - } - v := s.verdict() - if v != c.verdict { - t.Errorf("verdict() = %q, want %q", v, c.verdict) - } - }) - } -} - -// ── Test 9 (bonus): RenderMarkdown calls Collect if not yet called ───────── - -func TestSnapshot_RenderMarkdown_AutoCollects(t *testing.T) { - s := helperSnapshot() - // Don't call Collect — RenderMarkdown should call it. - md := s.RenderMarkdown() - if !strings.Contains(md, "# SIN-Code Readiness Report") { - t.Error("RenderMarkdown should auto-collect and produce the report") - } - if !s.collected { - t.Error("RenderMarkdown should set collected=true after auto-collect") - } -} - -// ── Test 10 (bonus): NewSnapshot defaults produce no panics ──────────────── - -func TestSnapshot_NewSnapshot_DefaultsNoPanic(t *testing.T) { - s := NewSnapshot() - // Override the heavy hooks so we don't actually run go build/test. - s.exec = func(name string, args ...string) (string, error) { return "", nil } - s.git = func(args ...string) (string, error) { return "", nil } - if err := s.Collect(); err != nil { - t.Fatalf("Collect with defaults should not error: %v", err) - } - md := s.RenderMarkdown() - if !strings.Contains(md, "## Verdict") { - t.Error("default snapshot should still render a verdict") - } -} diff --git a/cmd/sin-code/main.go b/cmd/sin-code/main.go index 13346221..c60ff79d 100644 --- a/cmd/sin-code/main.go +++ b/cmd/sin-code/main.go @@ -105,6 +105,7 @@ func init() { NewCoverCmd(), // Coverage-Drohne: scan, check, gaps, generate, hook internal.InstinctCmd, internal.HooksCmd, internal.AssetsCmd, internal.EvalCmd, internal.PRPCmd, // continuous learning + lifecycle hooks + asset harvest + evalset + prp workflow NewImageGraphCmd(), // image-graph: deterministic chart generation (bar/line/pie/area) + NewStatusCmd(), // v3.22.0 — readiness/status snapshot (issue #326) ) // Pass build-time version to self-update module. From 921edf04310d4ce3be0252e0dd2372e3fd5bef6e Mon Sep 17 00:00:00 2001 From: SIN CI Date: Thu, 18 Jun 2026 03:10:33 +0200 Subject: [PATCH 2/2] =?UTF-8?q?test(memory):=20coverage=2090.7%=20?= =?UTF-8?q?=E2=86=92=2094.5%=20=E2=80=94=20auto=5Fobserve,=20embedding=5Fc?= =?UTF-8?q?ache,=20autodream,=20context=5Fguard,=20vector=5Findex,=20insti?= =?UTF-8?q?nct,=20evidence=5Fgraph,=20honcho=5Fnative,=20import=5Fexport,?= =?UTF-8?q?=20unified=5Fquery,=20versioning=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/sin-code/internal/memory/coverage_test.go | 866 ++++++++++++++++++ 1 file changed, 866 insertions(+) create mode 100644 cmd/sin-code/internal/memory/coverage_test.go diff --git a/cmd/sin-code/internal/memory/coverage_test.go b/cmd/sin-code/internal/memory/coverage_test.go new file mode 100644 index 00000000..7155273c --- /dev/null +++ b/cmd/sin-code/internal/memory/coverage_test.go @@ -0,0 +1,866 @@ +// SPDX-License-Identifier: MIT +// Purpose: Coverage gap tests for the memory package. +// Targets uncovered error paths, edge cases, and helper functions. +// All tests pass under `go test -race -count=1` (mandate M7). +package memory + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/llm" +) + +// --------------------------------------------------------------------------- +// auto_observe.go coverage — ObservableTools, ReadOnlyTools (0%) +// --------------------------------------------------------------------------- + +func TestObservableTools(t *testing.T) { + tools := ObservableTools() + if len(tools) == 0 { + t.Fatal("expected non-empty observable tools list") + } + expected := map[string]bool{ + "edit": true, "write": true, "execute": true, "test": true, + "sin_edit": true, "sin_write": true, "sin_execute": true, "sin_test": true, + } + for _, tool := range tools { + if !expected[tool] { + t.Errorf("unexpected observable tool: %q", tool) + } + } +} + +func TestReadOnlyTools(t *testing.T) { + tools := ReadOnlyTools() + if len(tools) == 0 { + t.Fatal("expected non-empty read-only tools list") + } + expected := map[string]bool{ + "discover": true, "scout": true, "map": true, "read": true, + "sin_discover": true, "sin_scout": true, "sin_map": true, "sin_read": true, + } + for _, tool := range tools { + if !expected[tool] { + t.Errorf("unexpected read-only tool: %q", tool) + } + } +} + +// --------------------------------------------------------------------------- +// embedding_cache.go coverage — MaxEntries, TTL, nil safety (0%) +// --------------------------------------------------------------------------- + +func TestEmbeddingCache_MaxEntries(t *testing.T) { + c := NewEmbeddingCache(42, time.Hour) + if got := c.MaxEntries(); got != 42 { + t.Errorf("MaxEntries: got %d, want 42", got) + } +} + +func TestEmbeddingCache_TTL(t *testing.T) { + c := NewEmbeddingCache(10, 30*time.Minute) + if got := c.TTL(); got != 30*time.Minute { + t.Errorf("TTL: got %v, want 30m", got) + } +} + +func TestEmbeddingCache_MaxEntries_Nil(t *testing.T) { + var c *EmbeddingCache + if got := c.MaxEntries(); got != 0 { + t.Errorf("nil MaxEntries: got %d, want 0", got) + } +} + +func TestEmbeddingCache_TTL_Nil(t *testing.T) { + var c *EmbeddingCache + if got := c.TTL(); got != 0 { + t.Errorf("nil TTL: got %v, want 0", got) + } +} + +func TestEmbeddingCache_NewEmbeddingCache_Defaults(t *testing.T) { + c := NewEmbeddingCache(0, time.Hour) + if got := c.MaxEntries(); got != 10000 { + t.Errorf("default maxEntries: got %d, want 10000", got) + } + c2 := NewEmbeddingCache(10, 0) + if got := c2.TTL(); got != time.Hour { + t.Errorf("default TTL: got %v, want 1h", got) + } +} + +func TestEmbeddingCache_Stats_AfterEviction(t *testing.T) { + c := NewEmbeddingCache(2, time.Hour) + c.Set("a", []float32{1}) + c.Set("b", []float32{2}) + c.Set("c", []float32{3}) + s := c.Stats() + if s.Size != 2 { + t.Errorf("size after eviction: got %d, want 2", s.Size) + } + if s.Evictions != 1 { + t.Errorf("evictions: got %d, want 1", s.Evictions) + } +} + +func TestEmbeddingCache_Clear(t *testing.T) { + c := NewEmbeddingCache(10, time.Hour) + c.Set("a", []float32{1}) + c.Set("b", []float32{2}) + c.Clear() + if _, ok := c.Get("a"); ok { + t.Error("expected miss after Clear") + } + s := c.Stats() + if s.Size != 0 { + t.Errorf("size after Clear: got %d, want 0", s.Size) + } +} + +func TestEmbeddingCache_PurgeExpired(t *testing.T) { + c := NewEmbeddingCache(10, 10*time.Millisecond) + c.Set("a", []float32{1}) + time.Sleep(20 * time.Millisecond) + removed := c.PurgeExpired() + if removed != 1 { + t.Errorf("expected 1 expired entry purged, got %d", removed) + } +} + +// --------------------------------------------------------------------------- +// autodream.go coverage — WithLLMClient (0%), tryLLMSummary (0%), buildSummary (50%) +// --------------------------------------------------------------------------- + +func TestWithLLMClient(t *testing.T) { + store := tempStore(t) + ad := NewAutoDream(store, WithLLMClient(nil)) + if ad == nil { + t.Fatal("expected non-nil AutoDream") + } +} + +func TestAutoDream_TryLLMSummary_Success(t *testing.T) { + t.Setenv("SIN_LLM_MODEL", "test-model") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"content": "consolidated insight"}, "finish_reason": "stop"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + store := tempStore(t) + client := llm.NewClient(srv.URL, "test-key") + ad := NewAutoDream(store, WithLLMClient(client)) + + mems := []*Memory{ + {ID: "m1", Insight: "memory one"}, + {ID: "m2", Insight: "memory two"}, + } + summary := ad.tryLLMSummary(context.Background(), "test-tag", mems) + if summary == "" { + t.Fatal("expected non-empty summary from LLM") + } + if !strings.Contains(summary, "test-tag") { + t.Errorf("summary should contain tag, got %q", summary) + } +} + +func TestAutoDream_TryLLMSummary_Error(t *testing.T) { + t.Setenv("SIN_LLM_MODEL", "test-model") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + store := tempStore(t) + client := llm.NewClient(srv.URL, "test-key") + ad := NewAutoDream(store, WithLLMClient(client)) + + mems := []*Memory{{ID: "m1", Insight: "memory one"}} + summary := ad.tryLLMSummary(context.Background(), "test-tag", mems) + if summary != "" { + t.Errorf("expected empty summary on LLM error, got %q", summary) + } +} + +func TestAutoDream_TryLLMSummary_EmptyResponse(t *testing.T) { + t.Setenv("SIN_LLM_MODEL", "test-model") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"content": ""}, "finish_reason": "stop"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + store := tempStore(t) + client := llm.NewClient(srv.URL, "test-key") + ad := NewAutoDream(store, WithLLMClient(client)) + + mems := []*Memory{{ID: "m1", Insight: "memory one"}} + summary := ad.tryLLMSummary(context.Background(), "test-tag", mems) + if summary != "" { + t.Errorf("expected empty summary for empty LLM response, got %q", summary) + } +} + +func TestAutoDream_BuildSummary_WithLLM(t *testing.T) { + t.Setenv("SIN_LLM_MODEL", "test-model") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"content": "LLM summary"}, "finish_reason": "stop"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + store := tempStore(t) + client := llm.NewClient(srv.URL, "test-key") + ad := NewAutoDream(store, WithLLMClient(client)) + + mems := []*Memory{{ID: "m1", Insight: "memory one"}} + summary := ad.buildSummary(context.Background(), "tag", mems) + if summary == "" { + t.Fatal("expected non-empty summary") + } + if !strings.Contains(summary, "LLM summary") { + t.Errorf("expected LLM summary content, got %q", summary) + } +} + +func TestAutoDream_BuildSummary_FallbackToDeterministic(t *testing.T) { + t.Setenv("SIN_LLM_MODEL", "test-model") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + store := tempStore(t) + client := llm.NewClient(srv.URL, "test-key") + ad := NewAutoDream(store, WithLLMClient(client)) + + mems := []*Memory{{ID: "m1", Insight: "memory one"}} + summary := ad.buildSummary(context.Background(), "tag", mems) + if summary == "" { + t.Fatal("expected non-empty fallback summary") + } +} + +// --------------------------------------------------------------------------- +// context_guard.go coverage — String, ratio (83.3%, 80%) +// --------------------------------------------------------------------------- + +func TestGuardLevel_String_AllLevels(t *testing.T) { + cases := []struct { + level GuardLevel + want string + }{ + {GuardGreen, "green"}, + {GuardYellow, "yellow"}, + {GuardOrange, "orange"}, + {GuardRed, "red"}, + {GuardLevel(99), "unknown"}, + } + for _, c := range cases { + if got := c.level.String(); got != c.want { + t.Errorf("GuardLevel(%d).String() = %q, want %q", c.level, got, c.want) + } + } +} + +func TestContextGuard_Ratio_ZeroMax(t *testing.T) { + g := &ContextGuard{maxTokens: 0} + if got := g.ratio(); got != 1.0 { + t.Errorf("ratio with maxTokens=0: got %.2f, want 1.0", got) + } +} + +// --------------------------------------------------------------------------- +// governance.go coverage — itoa (85.7%) +// --------------------------------------------------------------------------- + +func TestGovernanceItoa(t *testing.T) { + cases := map[int]string{ + 0: "0", + 1: "1", + 9: "9", + 10: "10", + 99: "99", + 100: "100", + 42: "42", + } + for n, want := range cases { + if got := itoa(n); got != want { + t.Errorf("itoa(%d) = %q, want %q", n, got, want) + } + } +} + +// --------------------------------------------------------------------------- +// unified_query.go coverage — pure functions (40-75%) +// --------------------------------------------------------------------------- + +func TestImportanceScore(t *testing.T) { + cases := []struct { + imp float64 + want float64 + }{ + {0, 0}, + {-1, 0}, + {0.5, 0.5}, + {1.0, 1.0}, + {1.5, 1.0}, + {0.3, 0.3}, + } + for _, c := range cases { + if got := importanceScore(c.imp); got != c.want { + t.Errorf("importanceScore(%.2f) = %.2f, want %.2f", c.imp, got, c.want) + } + } +} + +func TestClamp01(t *testing.T) { + cases := []struct { + x float64 + want float64 + }{ + {-1, 0}, + {0, 0}, + {0.5, 0.5}, + {1, 1}, + {1.5, 1}, + {-0.1, 0}, + } + for _, c := range cases { + if got := clamp01(c.x); got != c.want { + t.Errorf("clamp01(%.2f) = %.2f, want %.2f", c.x, got, c.want) + } + } +} + +func TestParseTime(t *testing.T) { + valid := "2026-01-15T10:30:00Z" + t1 := parseTime(valid) + if t1.IsZero() { + t.Error("expected non-zero time for valid RFC3339") + } + t2 := parseTime("not-a-date") + if !t2.IsZero() { + t.Error("expected zero time for invalid input") + } + t3 := parseTime("") + if !t3.IsZero() { + t.Error("expected zero time for empty string") + } +} + +func TestRecencyScore(t *testing.T) { + if got := recencyScore(time.Time{}); got != 0 { + t.Errorf("recencyScore(zero) = %.2f, want 0", got) + } + now := recencyScore(time.Now()) + if now < 0.99 || now > 1.0 { + t.Errorf("recencyScore(now) = %.4f, want ~1.0", now) + } + old := time.Now().Add(-30 * 24 * time.Hour) + if got := recencyScore(old); got <= 0 || got >= 1 { + t.Errorf("recencyScore(30d ago) = %.4f, want 0 < x < 1", got) + } +} + +func TestSubstringScore(t *testing.T) { + if got := substringScore("", "haystack"); got != 0.5 { + t.Errorf("substringScore('', ...) = %.2f, want 0.5", got) + } + if got := substringScore("hello", "hello"); got != 1.0 { + t.Errorf("substringScore(exact) = %.2f, want 1.0", got) + } + if got := substringScore("xyz", "hello"); got != 0 { + t.Errorf("substringScore(no match) = %.2f, want 0", got) + } + score := substringScore("hello world", "hello there") + if score <= 0 || score >= 1 { + t.Errorf("substringScore(partial words) = %.2f, want 0 < x < 1", score) + } +} + +func TestDedupeByContent_SameContentHigherScore(t *testing.T) { + in := []UnifiedResult{ + {Content: "same text", Score: 0.5}, + {Content: "same text", Score: 0.9}, + } + out := dedupeByContent(in) + if len(out) != 1 { + t.Fatalf("expected 1 after dedup, got %d", len(out)) + } + if out[0].Score != 0.9 { + t.Errorf("expected higher score 0.9, got %.2f", out[0].Score) + } +} + +func TestDedupeByContent_DifferentContent(t *testing.T) { + in := []UnifiedResult{ + {Content: "text A", Score: 0.5}, + {Content: "text B", Score: 0.9}, + } + out := dedupeByContent(in) + if len(out) != 2 { + t.Fatalf("expected 2, got %d", len(out)) + } +} + +func TestUnifiedStore_HasStore_Nil(t *testing.T) { + var u *UnifiedStore + if u.HasStore(StoreMemory) { + t.Error("nil HasStore should be false") + } +} + +func TestNewUnifiedStore_AllNil(t *testing.T) { + u := NewUnifiedStore(nil, nil, nil, nil, nil) + if u == nil { + t.Fatal("expected non-nil UnifiedStore") + } + for _, label := range []StoreLabel{StoreMemory, StoreLessons, StoreSessions, StoreLedger, StoreEpisodes} { + if u.HasStore(label) { + t.Errorf("HasStore(%v) should be false with all nil stores", label) + } + } +} + +// --------------------------------------------------------------------------- +// vector_index.go coverage — assign, removeIDFromClustersLocked, nil safety +// --------------------------------------------------------------------------- + +func TestVectorIndex_Assign(t *testing.T) { + vi := NewVectorIndex(3, 2) + vi.Add("a", []float32{1, 0, 0}) + vi.Add("b", []float32{0, 1, 0}) + vi.Build() + c := vi.assign([]float32{1, 0, 0}) + if c < 0 { + t.Errorf("assign returned negative cluster: %d", c) + } +} + +func TestVectorIndex_Size_Nil(t *testing.T) { + var vi *VectorIndex + if got := vi.Size(); got != 0 { + t.Errorf("nil Size: got %d, want 0", got) + } +} + +func TestVectorIndex_Build_Nil(t *testing.T) { + var vi *VectorIndex + vi.Build() +} + +func TestVectorIndex_Add_Nil(t *testing.T) { + var vi *VectorIndex + vi.Add("x", []float32{1, 2, 3}) +} + +func TestVectorIndex_Remove_Nil(t *testing.T) { + var vi *VectorIndex + if vi.Remove("x") { + t.Error("nil Remove should return false") + } +} + +func TestVectorIndex_RemoveIDFromClustersLocked_MultipleClusters(t *testing.T) { + vi := NewVectorIndex(2, 4) + vi.Add("a", []float32{1, 0}) + vi.Add("b", []float32{0, 1}) + vi.Add("c", []float32{1, 0}) + vi.Build() + vi.mu.Lock() + vi.removeIDFromClustersLocked("a") + vi.mu.Unlock() + totalEntries := 0 + vi.mu.RLock() + for _, list := range vi.entries { + for _, e := range list { + if e.id == "a" { + t.Error("a should have been removed from clusters") + } + totalEntries++ + } + } + vi.mu.RUnlock() + if totalEntries < 2 { + t.Errorf("expected at least 2 entries remaining, got %d", totalEntries) + } +} + +func TestVectorIndex_Add_AfterBuild(t *testing.T) { + vi := NewVectorIndex(2, 2) + vi.Add("a", []float32{1, 0}) + vi.Build() + vi.Add("b", []float32{0, 1}) + if vi.Size() != 2 { + t.Errorf("size after post-build add: got %d, want 2", vi.Size()) + } +} + +func TestVectorIndex_Build_Empty(t *testing.T) { + vi := NewVectorIndex(2, 4) + vi.Build() + if vi.Size() != 0 { + t.Errorf("size after empty build: got %d, want 0", vi.Size()) + } +} + +func TestMinLen(t *testing.T) { + if got := minLen([]float32{1, 2, 3}, []float32{4, 5}); got != 2 { + t.Errorf("minLen(3, 2) = %d, want 2", got) + } + if got := minLen([]float32{1}, []float32{2, 3, 4}); got != 1 { + t.Errorf("minLen(1, 3) = %d, want 1", got) + } + if got := minLen(nil, []float32{1, 2}); got != 0 { + t.Errorf("minLen(nil, 2) = %d, want 0", got) + } +} + +func TestDotProduct(t *testing.T) { + got := DotProduct([]float32{1, 2, 3}, []float32{4, 5, 6}) + want := float32(1*4 + 2*5 + 3*6) + if got != want { + t.Errorf("DotProduct = %.2f, want %.2f", got, want) + } +} + +func TestDotProduct_LengthMismatch(t *testing.T) { + got := DotProduct([]float32{1, 2, 3}, []float32{4, 5}) + want := float32(1*4 + 2*5) + if got != want { + t.Errorf("DotProduct mismatched = %.2f, want %.2f", got, want) + } +} + +func TestVectorIndex_Search_NotBuilt(t *testing.T) { + vi := NewVectorIndex(2, 2) + vi.Add("a", []float32{1, 0}) + results := vi.Search([]float32{1, 0}, 1) + if len(results) == 0 { + t.Fatal("expected at least 1 result even without Build") + } + if results[0].ID != "a" { + t.Errorf("expected 'a', got %q", results[0].ID) + } +} + +// --------------------------------------------------------------------------- +// versioning.go coverage — lineDiff, Diff error paths +// --------------------------------------------------------------------------- + +func TestLineDiff(t *testing.T) { + diff := lineDiff("line1\nline2\nline3", "line1\nmodified\nline3") + if diff == "" { + t.Fatal("expected non-empty diff") + } +} + +func TestLineDiff_Identical(t *testing.T) { + diff := lineDiff("same\ncontent", "same\ncontent") + if strings.Contains(diff, "- ") || strings.Contains(diff, "+ ") { + t.Errorf("expected no diff lines for identical content, got %q", diff) + } +} + +func TestLineDiff_EmptyOld(t *testing.T) { + diff := lineDiff("", "new\ncontent") + if diff == "" { + t.Fatal("expected non-empty diff when old is empty") + } +} + +func TestLineDiff_EmptyNew(t *testing.T) { + diff := lineDiff("old\ncontent", "") + if diff == "" { + t.Fatal("expected non-empty diff when new is empty") + } +} + +func TestVersioningStore_Diff_VersionNotFound(t *testing.T) { + vs := newVersioningStore(t) + ctx := context.Background() + _ = vs.SaveVersion(ctx, "mem1", "content v1", "content v1 edited", "first edit") + _, err := vs.Diff(ctx, "mem1", 1, 99) + if err == nil { + t.Fatal("expected error for non-existent version") + } +} + +func TestVersioningStore_Diff_MemoryNotFound(t *testing.T) { + vs := newVersioningStore(t) + ctx := context.Background() + _, err := vs.Diff(ctx, "nonexistent", 1, 2) + if err == nil { + t.Fatal("expected error for non-existent memory") + } +} + +// --------------------------------------------------------------------------- +// instinct.go coverage — error paths +// --------------------------------------------------------------------------- + +func TestInstinctStore_Demote_NotFound(t *testing.T) { + is := instinctStore(t) + err := is.Demote(context.Background(), "nonexistent-id") + if err == nil { + t.Fatal("expected error for demoting non-existent instinct") + } +} + +func TestInstinctStore_Promote_NotFound_Coverage(t *testing.T) { + is := instinctStore(t) + err := is.Promote(context.Background(), "nonexistent-id") + if err == nil { + t.Fatal("expected error for promoting non-existent instinct") + } +} + +func TestInstinctStore_Get_NotFound_Coverage(t *testing.T) { + is := instinctStore(t) + _, err := is.Get(context.Background(), "nonexistent-id") + if err == nil { + t.Fatal("expected error for getting non-existent instinct") + } +} + +func TestInstinctStore_List_Empty_Coverage(t *testing.T) { + is := instinctStore(t) + list, err := is.List(context.Background(), "global") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(list) != 0 { + t.Errorf("expected empty list, got %d", len(list)) + } +} + +// --------------------------------------------------------------------------- +// evidence_graph.go coverage — edge cases +// --------------------------------------------------------------------------- + +func TestEvidenceGraph_GetNode_NotFound(t *testing.T) { + g := NewEvidenceGraph() + _, ok := g.GetNode("nonexistent") + if ok { + t.Error("expected false for non-existent node") + } +} + +func TestEvidenceGraph_NodeCount_Empty(t *testing.T) { + g := NewEvidenceGraph() + if g.NodeCount() != 0 { + t.Errorf("expected 0 nodes, got %d", g.NodeCount()) + } +} + +func TestEvidenceGraph_LinkCount_Empty(t *testing.T) { + g := NewEvidenceGraph() + if g.LinkCount() != 0 { + t.Errorf("expected 0 links, got %d", g.LinkCount()) + } +} + +func TestEvidenceGraph_RenderDOT_Empty(t *testing.T) { + g := NewEvidenceGraph() + dot := g.RenderDOT() + if dot == "" { + t.Fatal("expected non-empty DOT for empty graph") + } +} + +// --------------------------------------------------------------------------- +// honcho_native.go coverage — nil and error paths +// --------------------------------------------------------------------------- + +func TestHonchoIntegration_NilSafe(t *testing.T) { + var h *HonchoIntegration + prefs, err := h.GetUserPreferences(context.Background()) + if err != nil || prefs != nil { + t.Errorf("nil GetUserPreferences: prefs=%v err=%v", prefs, err) + } + model, err := h.GetPeerModel(context.Background(), "user") + if err != nil || model != nil { + t.Errorf("nil GetPeerModel: model=%v err=%v", model, err) + } + if err := h.SavePreference(context.Background(), "key", "value"); err != nil { + t.Errorf("nil SavePreference: err=%v", err) + } + if err := h.SavePeerModel(context.Background(), "user", nil); err != nil { + t.Errorf("nil SavePeerModel: err=%v", err) + } +} + +func TestHonchoIntegration_NilStore(t *testing.T) { + h := NewHonchoIntegration(nil) + prefs, err := h.GetUserPreferences(context.Background()) + if err != nil || prefs != nil { + t.Errorf("nil store GetUserPreferences: prefs=%v err=%v", prefs, err) + } + model, err := h.GetPeerModel(context.Background(), "user") + if err != nil || model != nil { + t.Errorf("nil store GetPeerModel: model=%v err=%v", model, err) + } + if err := h.SavePreference(context.Background(), "key", "value"); err != nil { + t.Errorf("nil store SavePreference: err=%v", err) + } +} + +func TestHonchoIntegration_SaveAndGetPreference(t *testing.T) { + store := tempStore(t) + h := NewHonchoIntegration(store) + ctx := context.Background() + if err := h.SavePreference(ctx, "prefers-terse", "true"); err != nil { + t.Fatalf("SavePreference: %v", err) + } + prefs, err := h.GetUserPreferences(ctx) + if err != nil { + t.Fatalf("GetUserPreferences: %v", err) + } + if len(prefs) != 1 { + t.Fatalf("expected 1 preference, got %d", len(prefs)) + } +} + +func TestHonchoIntegration_SaveAndGetPeerModel(t *testing.T) { + store := tempStore(t) + h := NewHonchoIntegration(store) + ctx := context.Background() + model := map[string]any{"name": "test-model", "score": 0.85} + if err := h.SavePeerModel(ctx, "user-1", model); err != nil { + t.Fatalf("SavePeerModel: %v", err) + } + got, err := h.GetPeerModel(ctx, "user-1") + if err != nil { + t.Fatalf("GetPeerModel: %v", err) + } + if got == nil { + t.Fatal("expected non-nil peer model") + } + if got["name"] != "test-model" { + t.Errorf("expected name 'test-model', got %v", got["name"]) + } +} + +func TestHonchoIntegration_GetPeerModel_NotFound(t *testing.T) { + store := tempStore(t) + h := NewHonchoIntegration(store) + got, err := h.GetPeerModel(context.Background(), "nonexistent-user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Errorf("expected nil for non-existent peer model, got %v", got) + } +} + +func TestHonchoIntegration_SavePeerModel_InvalidJSON(t *testing.T) { + store := tempStore(t) + h := NewHonchoIntegration(store) + ctx := context.Background() + m := &Memory{ + Insight: "not valid json", + Tags: []string{tagPeerModel}, + Actor: "bad-user", + } + if err := store.Add(m); err != nil { + t.Fatalf("Add: %v", err) + } + _, err := h.GetPeerModel(ctx, "bad-user") + if err == nil { + t.Fatal("expected error for invalid JSON peer model") + } +} + +// --------------------------------------------------------------------------- +// import_export.go coverage — edge cases +// --------------------------------------------------------------------------- + +func TestExportToInstinct_Empty(t *testing.T) { + path := filepath.Join(t.TempDir(), "instincts.json") + if err := ExportToInstinct(nil, path); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestExportToInstinct_WithInstincts(t *testing.T) { + path := filepath.Join(t.TempDir(), "instincts.json") + instincts := []Instinct{ + {Content: "test instinct", Confidence: 0.9, Scope: "global", Source: "test"}, + } + if err := ExportToInstinct(instincts, path); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("expected file to exist: %v", err) + } +} + +func TestImportFromInstinct_NonexistentFile(t *testing.T) { + _, err := ImportFromInstinct("/nonexistent/file.json") + if err == nil { + t.Fatal("expected error for non-existent file") + } +} + +func TestImportFromInstinct_EmptyFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty.json") + if err := os.WriteFile(path, []byte("[]"), 0644); err != nil { + t.Fatal(err) + } + instincts, err := ImportFromInstinct(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(instincts) != 0 { + t.Errorf("expected 0 instincts from empty JSON array, got %d", len(instincts)) + } +} + +func TestImportFromInstinct_InvalidJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "invalid.json") + if err := os.WriteFile(path, []byte("not valid json"), 0644); err != nil { + t.Fatal(err) + } + _, err := ImportFromInstinct(path) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestParseMEMORYMD_Empty(t *testing.T) { + memories := parseMEMORYMD("") + if len(memories) != 0 { + t.Errorf("expected 0 memories from empty string, got %d", len(memories)) + } +} + +func TestParseMEMORYMD_NoSections(t *testing.T) { + memories := parseMEMORYMD("just some text\nwithout any sections") + if len(memories) != 0 { + t.Errorf("expected 0 memories without sections, got %d", len(memories)) + } +}