package main import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "slices" "strings" "sync" "testing" "time" "github.com/charmbracelet/x/ansi" ) func TestAIDefaultsAreDisabledAndBounded(t *testing.T) { config := defaultConfig() if config.AI.Enabled { t.Fatal("AI must be disabled by default") } config.AI.Enabled = true if err := validateAIConfig(config.AI); err != nil { t.Fatalf("default AI limits should validate when enabled: %v", err) } if config.AI.MaxRunBytes < config.AI.MaxRequestBytes || config.AI.MaxCalls < 1 { t.Fatalf("unbounded defaults: %#v", config.AI) } if config.AI.MaxContextRounds != 2 || config.AI.MaxContextFiles != 8 || len(config.AI.SensitivePaths) == 0 { t.Fatalf("focused-context defaults: %#v", config.AI) } } func TestPrepareAIDiffFiltersAndRedacts(t *testing.T) { config := defaultAIConfig() raw := `diff --git a/main.go b/main.go --- a/main.go +++ b/main.go @@ -1 +1,2 @@ package main +token = "ghp_abcdefghijklmnopqrstuvwxyz123456" diff --git a/go.sum b/go.sum --- a/go.sum +++ b/go.sum @@ -1 +1 @@ -old +new diff --git a/.env b/.env --- a/.env +++ b/.env @@ -0,0 +1 @@ +PASSWORD=do-not-send ` files, excluded, redactions := prepareAIDiff(raw, config) if len(files) != 1 || files[0].Path != "main.go" { t.Fatalf("files = %#v, want only main.go", files) } if len(excluded) != 2 { t.Fatalf("excluded = %#v, want go.sum and .env", excluded) } if redactions != 1 || strings.Contains(files[0].Text, "ghp_") || !strings.Contains(files[0].Text, "[REDACTED]") { t.Fatalf("redaction failed: count=%d text=%q", redactions, files[0].Text) } } func TestParseAIDiffTracksDeletedSide(t *testing.T) { file, ok := parseAIDiffFile(`diff --git a/old.go b/old.go deleted file mode 100644 --- a/old.go +++ /dev/null @@ -7,2 +0,0 @@ -dangerous() -cleanup() `) if !ok { t.Fatal("deletion-only diff was excluded") } if file.Path != "old.go" || !file.DeletedLines[7] || !file.DeletedLines[8] || len(file.ChangedLines) != 0 { t.Fatalf("parsed deletion = %#v", file) } if hunk := boundedDiffHunk(file.Text, 8, "LEFT"); !strings.Contains(hunk, "cleanup") { t.Fatalf("left-side hunk = %q", hunk) } } func TestBoundedDiffHunkKeepsTargetFromLargeHunk(t *testing.T) { var diff strings.Builder diff.WriteString("@@ -1,100 +1,101 @@\n") for line := 1; line <= 100; line++ { if line == 90 { diff.WriteString("+TARGET\n") } diff.WriteString(" " + strings.Repeat("x", 400) + "\n") } hunk := boundedDiffHunk(diff.String(), 90, "RIGHT") if !strings.Contains(hunk, "TARGET") || len(hunk) > 24_000 { t.Fatalf("bounded hunk length=%d contains target=%t", len(hunk), strings.Contains(hunk, "TARGET")) } } func TestParseCodexEventsRejectsToolUse(t *testing.T) { data := []byte("{\"type\":\"item.completed\",\"item\":{\"type\":\"command_execution\",\"command\":\"pwd\"}}\n") if _, _, err := parseCodexEvents(data); err == nil { t.Fatal("tool event was accepted") } valid := []byte("{\"type\":\"item.completed\",\"model\":\"gpt-test\",\"item\":{\"type\":\"agent_message\",\"text\":\"{\\\"findings\\\":[],\\\"thread_comments\\\":[]}\"}}\n") content, model, err := parseCodexEvents(valid) if err != nil { t.Fatal(err) } if model != "gpt-test" || !strings.Contains(string(content), `"findings"`) { t.Fatalf("content=%q model=%q", content, model) } } func TestCodexFailureDetailReadsJSONErrorEvents(t *testing.T) { stdout := []byte( "{\"type\":\"thread.started\",\"thread_id\":\"x\"}\n" + "{\"type\":\"turn.failed\",\"error\":{\"message\":\"model unavailable\\u001b]8;;bad\"}}\n", ) got := codexFailureDetail(stdout, nil) if !strings.Contains(got, "model unavailable") || strings.ContainsRune(got, '\x1b') { t.Fatalf("failure detail = %q", got) } if got := codexFailureDetail(nil, []byte("plain stderr")); got != "plain stderr" { t.Fatalf("stderr detail = %q", got) } } func TestPathWithinUsesPathBoundaries(t *testing.T) { parent := filepath.Join(t.TempDir(), "repo") if !pathWithin(parent, filepath.Join(parent, "nested", "file")) { t.Fatal("nested path was not recognized") } if pathWithin(parent, parent+"-other/file") { t.Fatal("sibling prefix was treated as inside the workspace") } } func TestPullRequestDiffUsesAuthenticatedGHESRESTEndpoint(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.URL.Path != "/api/v3/repos/owner/repo/pulls/12" { t.Fatalf("path = %q", request.URL.Path) } if request.Header.Get("Authorization") != "Bearer token" { t.Fatalf("authorization = %q", request.Header.Get("Authorization")) } if request.Header.Get("Accept") != "application/vnd.github.v3.diff" { t.Fatalf("accept = %q", request.Header.Get("Accept")) } _, _ = writer.Write([]byte("diff --git a/a.go b/a.go\n")) })) defer server.Close() client := NewGitHubClient(server.URL+"/api/graphql", "token") diff, err := client.PullRequestDiff(context.Background(), "owner", "repo", 12) if err != nil { t.Fatal(err) } if !strings.HasPrefix(diff, "diff --git") { t.Fatalf("diff = %q", diff) } } func TestRepositoryTreeAndBlobUseAuthenticatedExactCommitEndpoints(t *testing.T) { var requests []string server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.Header.Get("Authorization") != "Bearer token" { t.Fatalf("authorization = %q", request.Header.Get("Authorization")) } requests = append(requests, request.URL.RequestURI()) switch request.URL.Path { case "/api/v3/repos/owner/repo/git/commits/head-oid": _, _ = writer.Write([]byte(`{"tree":{"sha":"tree-oid"}}`)) case "/api/v3/repos/owner/repo/git/trees/tree-oid": _, _ = writer.Write([]byte(`{"truncated":false,"tree":[ {"path":"main.go","type":"blob","mode":"100644","sha":"blob-oid","size":13}, {"path":"nested","type":"tree","sha":"nested-tree"} ]}`)) case "/api/v3/repos/owner/repo/git/blobs/blob-oid": _, _ = writer.Write([]byte(`{ "sha":"blob-oid","size":13,"encoding":"base64", "content":"cGFja2FnZSBtYWluCg==" }`)) default: t.Fatalf("unexpected request: %s", request.URL.RequestURI()) } })) defer server.Close() client := NewGitHubClient(server.URL+"/api/graphql", "token") tree, err := client.RepositoryTree(context.Background(), "owner", "repo", "head-oid") if err != nil { t.Fatal(err) } if tree.CommitOID != "head-oid" || tree.Truncated || len(tree.Entries) != 2 || tree.Entries[0].Path != "main.go" || tree.Entries[0].OID != "blob-oid" || tree.Entries[0].Mode != "100644" { t.Fatalf("tree = %#v", tree) } content, err := client.RepositoryBlob(context.Background(), "owner", "repo", "blob-oid", 100) if err != nil { t.Fatal(err) } if string(content) != "package main\n" { t.Fatalf("blob = %q", content) } if len(requests) != 3 || requests[1] != "/api/v3/repos/owner/repo/git/trees/tree-oid?recursive=1" { t.Fatalf("requests = %#v", requests) } } func TestAIStoreIsPrivateAndMarksOldHeadOutdated(t *testing.T) { store := NewAIStore(t.TempDir()) pr := PRDetails{PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 7}, HeadOID: "head-1"} state, err := store.Load(pr) if err != nil { t.Fatal(err) } state.Threads = []ReviewThread{{ ID: "local-ai-x", Path: "main.go", Line: 3, HeadOID: "head-1", Origin: reviewOriginLocalAI, }} if err := store.Save(pr, state); err != nil { t.Fatal(err) } info, err := os.Stat(store.path(pr)) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0o600 { t.Fatalf("store mode = %o, want 600", info.Mode().Perm()) } pr.HeadOID = "head-2" merged := state.Merge(pr) if len(merged.Threads) != 1 || !merged.Threads[0].IsOutdated { t.Fatalf("merged threads = %#v", merged.Threads) } } type fakeAIProvider struct { response AIInferenceResponse requests []AIInferenceRequest } func (p *fakeAIProvider) Name() string { return "fake" } func (p *fakeAIProvider) Status(context.Context) AIProviderStatus { return AIProviderStatus{Ready: true, Summary: "ready", Model: "gpt-test"} } func (p *fakeAIProvider) Generate(_ context.Context, request AIInferenceRequest) (AIInferenceResponse, error) { p.requests = append(p.requests, request) return p.response, nil } type fakeStreamingAIProvider struct { fakeAIProvider progress []AIProviderProgress } func (p *fakeStreamingAIProvider) GenerateWithProgress( _ context.Context, request AIInferenceRequest, report func(AIProviderProgress), ) (AIInferenceResponse, error) { p.requests = append(p.requests, request) for _, progress := range p.progress { report(progress) } return p.response, nil } type fakeAIDiffService struct{ diff string } func (s fakeAIDiffService) PullRequestDiff(context.Context, string, string, int) (string, error) { return s.diff, nil } type fakeAIRepositoryService struct { mu sync.Mutex diffCalls int treeCalls []string blobCalls []string trees map[string]AIRepositoryTree blobs map[string][]byte } func (s *fakeAIRepositoryService) PullRequestDiff( context.Context, string, string, int, ) (string, error) { s.mu.Lock() defer s.mu.Unlock() s.diffCalls++ return "", errors.New("focused discussion must not request the PR diff") } func (s *fakeAIRepositoryService) RepositoryTree( _ context.Context, _, _, commitOID string, ) (AIRepositoryTree, error) { s.mu.Lock() defer s.mu.Unlock() s.treeCalls = append(s.treeCalls, commitOID) tree, ok := s.trees[commitOID] if !ok { return AIRepositoryTree{}, errors.New("tree not found") } return tree, nil } func (s *fakeAIRepositoryService) RepositoryBlob( _ context.Context, _, _, oid string, maxBytes int, ) ([]byte, error) { s.mu.Lock() defer s.mu.Unlock() s.blobCalls = append(s.blobCalls, oid) content, ok := s.blobs[oid] if !ok { return nil, errors.New("blob not found") } if len(content) > maxBytes { return nil, errors.New("blob too large") } return append([]byte(nil), content...), nil } type scriptedAIProvider struct { responses []AIInferenceResponse requests []AIInferenceRequest } func (p *scriptedAIProvider) Name() string { return "scripted" } func (p *scriptedAIProvider) Status(context.Context) AIProviderStatus { return AIProviderStatus{Ready: true, Model: "gpt-test"} } func (p *scriptedAIProvider) Generate( _ context.Context, request AIInferenceRequest, ) (AIInferenceResponse, error) { p.requests = append(p.requests, request) if len(p.responses) == 0 { return AIInferenceResponse{}, errors.New("unexpected provider call") } response := p.responses[0] p.responses = p.responses[1:] return response, nil } func focusedAIRepository() *fakeAIRepositoryService { return &fakeAIRepositoryService{ trees: map[string]AIRepositoryTree{ "head": { CommitOID: "head", Entries: []AIRepositoryEntry{ {Path: ".env", OID: "env", Type: "blob", Size: 10}, {Path: "go.sum", OID: "sum", Type: "blob", Size: 10}, {Path: "helper.go", OID: "helper", Type: "blob", Size: 24}, {Path: "main.go", OID: "main", Type: "blob", Size: 40}, }, }, }, blobs: map[string][]byte{ "main": []byte("package main\n\nfunc selected() {}\n"), "helper": []byte("package main\n\nfunc helper() {}\n"), }, } } func focusedAIDetails() PRDetails { return PRDetails{ PullRequest: PullRequest{ Owner: "owner", Repository: "repo", Number: 7, Title: "Focused change", }, Body: "PR-BODY-MUST-NOT-BE-SENT", BaseRef: "main", BaseOID: "base", HeadRef: "feature", HeadOID: "head", Threads: []ReviewThread{ { ID: "selected", Path: "main.go", Line: 3, Comments: []ReviewComment{{ Author: "reviewer", Body: "SELECTED-THREAD-CONTEXT", DiffHunk: "@@ -1 +1 @@\n-old\n+new", OriginalCommitOID: "old", }}, }, { ID: "unrelated", Path: "other.go", Comments: []ReviewComment{{Author: "other", Body: "UNRELATED-THREAD-CONTEXT"}}, }, }, } } func TestFocusedAIPrepareUsesOnlySelectedThreadAndExactHeadContext(t *testing.T) { repository := focusedAIRepository() config := defaultAIConfig() config.Enabled = true controller := &AIController{ config: config, provider: &scriptedAIProvider{}, diffs: repository, repository: repository, store: NewAIStore(t.TempDir()), } preview, err := controller.Prepare( context.Background(), focusedAIDetails(), "selected", "Please explain this.", ) if err != nil { t.Fatal(err) } prompt := preview.thread.basePrompt for _, wanted := range []string{ "SELECTED-THREAD-CONTEXT", "Please explain this.", "package main", "helper.go", "go.sum [unavailable: excluded]", "TARGET FILE main.go", } { if !strings.Contains(prompt, wanted) { t.Fatalf("focused prompt is missing %q:\n%s", wanted, prompt) } } for _, forbidden := range []string{ "UNRELATED-THREAD-CONTEXT", "PR-BODY-MUST-NOT-BE-SENT", ".env", } { if strings.Contains(prompt, forbidden) { t.Fatalf("focused prompt leaked %q:\n%s", forbidden, prompt) } } if repository.diffCalls != 0 || preview.Calls != 3 || preview.ContextRounds != 2 || preview.ContextFiles != 8 || preview.TreeHidden != 1 || preview.TreeUnavailable != 1 { t.Fatalf("preview=%#v diffCalls=%d", preview, repository.diffCalls) } } func TestFocusedAIDiscussionFetchesRequestedFilesAndStoresAnswer(t *testing.T) { repository := focusedAIRepository() provider := &scriptedAIProvider{responses: []AIInferenceResponse{ { Model: "gpt-test", Content: []byte(`{"action":"request_files","answer":"","requested_files":[ {"path":"helper.go","reason":"Need the helper"}, {"path":"../secret","reason":"Invalid"} ]}`), }, { Model: "gpt-test", Content: []byte(`{ "action":"answer","answer":"The helper confirms the behavior.", "requested_files":[] }`), }, }} config := defaultAIConfig() config.Enabled = true store := NewAIStore(t.TempDir()) controller := &AIController{ config: config, provider: provider, diffs: repository, repository: repository, store: store, } details := focusedAIDetails() preview, err := controller.Prepare( context.Background(), details, "selected", "Please explain this.", ) if err != nil { t.Fatal(err) } result, err := controller.Run(context.Background(), preview) if err != nil { t.Fatal(err) } if len(provider.requests) != 2 || !strings.Contains(provider.requests[1].Prompt, "func helper()") || !strings.Contains(provider.requests[1].Prompt, "../secret: unavailable (invalid path)") { t.Fatalf("provider requests = %#v", provider.requests) } if repository.diffCalls != 0 || result.Comments != 1 || result.Timing.Calls != 2 || result.Timing.Files != 1 { t.Fatalf("result=%#v diffCalls=%d", result, repository.diffCalls) } thread := result.Details.Threads[0] if len(thread.Comments) != 3 || thread.Comments[1].Origin != reviewOriginLocalAIUser || thread.Comments[2].Body != "The helper confirms the behavior." { t.Fatalf("stored discussion = %#v", thread.Comments) } } func TestFocusedAIPrepareFallsBackToReviewCommitForDeletedTarget(t *testing.T) { repository := focusedAIRepository() repository.trees["head"] = AIRepositoryTree{ CommitOID: "head", Entries: []AIRepositoryEntry{{Path: "helper.go", OID: "helper", Type: "blob", Size: 24}}, } repository.trees["old"] = AIRepositoryTree{ CommitOID: "old", Entries: []AIRepositoryEntry{{Path: "main.go", OID: "old-main", Type: "blob", Size: 20}}, } repository.blobs["old-main"] = []byte("package old\n") config := defaultAIConfig() config.Enabled = true controller := &AIController{ config: config, provider: &scriptedAIProvider{}, diffs: repository, repository: repository, store: NewAIStore(t.TempDir()), } preview, err := controller.Prepare( context.Background(), focusedAIDetails(), "selected", "What happened?", ) if err != nil { t.Fatal(err) } if preview.InitialRevision != "old" || !strings.Contains(preview.thread.basePrompt, "package old") || !slices.Equal(repository.treeCalls, []string{"head", "old"}) { t.Fatalf("preview=%#v treeCalls=%#v", preview, repository.treeCalls) } } func TestFocusedAIPrepareRejectsSensitiveTarget(t *testing.T) { repository := focusedAIRepository() details := focusedAIDetails() details.Threads[0].Path = ".env" config := defaultAIConfig() config.Enabled = true controller := &AIController{ config: config, provider: &scriptedAIProvider{}, diffs: repository, repository: repository, store: NewAIStore(t.TempDir()), } _, err := controller.Prepare(context.Background(), details, "selected", "Explain.") if err == nil || !strings.Contains(err.Error(), "sensitive path") || len(repository.treeCalls) != 0 { t.Fatalf("err=%v treeCalls=%#v", err, repository.treeCalls) } } func TestFocusedAIDiscussionEnforcesConfiguredFileAndRoundLimits(t *testing.T) { repository := focusedAIRepository() headTree := repository.trees["head"] requests := make([]string, 0, 9) for index := range 9 { path := fmt.Sprintf("extra-%d.go", index) oid := fmt.Sprintf("extra-%d", index) requests = append(requests, fmt.Sprintf( `{"path":%q,"reason":"Need context"}`, path, )) headTree.Entries = append( headTree.Entries, AIRepositoryEntry{Path: path, OID: oid, Type: "blob", Size: 10}, ) repository.blobs[oid] = []byte("package x\n") } repository.trees["head"] = headTree provider := &scriptedAIProvider{responses: []AIInferenceResponse{ { Model: "gpt-test", Content: []byte(fmt.Sprintf( `{"action":"request_files","answer":"","requested_files":[%s]}`, strings.Join(requests, ","), )), }, {Model: "gpt-test", Content: []byte(`{"answer":"Final bounded answer."}`)}, }} config := defaultAIConfig() config.Enabled = true config.MaxContextRounds = 1 config.MaxContextFiles = 8 store := NewAIStore(t.TempDir()) controller := &AIController{ config: config, provider: provider, diffs: repository, repository: repository, store: store, } preview, err := controller.Prepare( context.Background(), focusedAIDetails(), "selected", "Investigate.", ) if err != nil { t.Fatal(err) } result, err := controller.Run(context.Background(), preview) if err != nil { t.Fatal(err) } if len(provider.requests) != 2 || result.Timing.Calls != 2 || result.Timing.Files != 8 { t.Fatalf("result=%#v requests=%d", result, len(provider.requests)) } finalPrompt := provider.requests[1].Prompt if !strings.Contains(finalPrompt, "extra-7.go") || !strings.Contains(finalPrompt, "extra-8.go: unavailable (file limit)") { t.Fatalf("final prompt did not enforce file limit:\n%s", finalPrompt) } if !strings.Contains(string(provider.requests[1].Schema), `"required":["answer"]`) { t.Fatalf("final call did not use answer-only schema: %s", provider.requests[1].Schema) } } func TestAIConfirmationShowsAutomaticContextConsent(t *testing.T) { config := defaultAIConfig() config.Enabled = true controller := &AIController{ config: config, provider: &scriptedAIProvider{}, } app := NewApp(nil, "", "", false, 10, time.Minute) app.width, app.height = 100, 35 app.ai, app.aiMode = controller, aiConfirm app.aiPreview = AIPreview{ Files: 1, Bytes: 1200, Calls: 3, HeadOID: "head-oid", Model: "gpt-test", Included: []string{"main.go"}, TreeEntries: 20, TreeUnavailable: 3, TreeHidden: 2, TreeTruncated: true, ContextRounds: 2, ContextFiles: 8, InitialRevision: "head-oid", thread: &aiThreadPrepared{}, } plain := strings.Join(strings.Fields(ansi.Strip(app.viewAI())), " ") for _, wanted := range []string{ "20 visible tree entries", "3 unavailable", "2 sensitive hidden", "truncated", "2 automatic request round(s)", "and 8 additional", } { if !strings.Contains(plain, wanted) { t.Fatalf("confirmation is missing %q:\n%s", wanted, plain) } } } func TestAIControllerAcceptsOnlyChangedLinesAndDeduplicates(t *testing.T) { output := aiOutput{} output.Findings = append(output.Findings, aiFinding{ Path: "main.go", Side: "RIGHT", StartLine: 2, EndLine: 2, Severity: "high", Title: "Bug", Body: "This is broken.", Suggestion: "var broken = false", }) output.Findings = append(output.Findings, aiFinding{ Path: "main.go", Side: "RIGHT", StartLine: 99, EndLine: 99, Severity: "high", Title: "Invented", Body: "Not changed.", }) content, _ := json.Marshal(output) provider := &fakeAIProvider{response: AIInferenceResponse{Model: "gpt-test", Content: content}} config := defaultAIConfig() config.Enabled = true config.MaxRequestBytes = 16_000 config.MaxRunBytes = 32_000 store := NewAIStore(filepath.Join(t.TempDir(), "ai")) controller := &AIController{ config: config, provider: provider, diffs: fakeAIDiffService{diff: `diff --git a/main.go b/main.go --- a/main.go +++ b/main.go @@ -1 +1,2 @@ package main +var broken = true `}, store: store, } pr := PRDetails{ PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 1, Title: "PR"}, HeadOID: "abc", } preview, err := controller.Prepare(context.Background(), pr, "", "") if err != nil { t.Fatal(err) } result, err := controller.Run(context.Background(), preview) if err != nil { t.Fatal(err) } if result.Findings != 1 { t.Fatalf("findings = %d, want 1", result.Findings) } if len(result.Details.Threads) != 1 || !strings.Contains(result.Details.Threads[0].Comments[0].DiffHunk, "var broken") { t.Fatalf("local finding did not retain its review-time hunk: %#v", result.Details.Threads) } parsed := parseCommentBody(result.Details.Threads[0].Comments[0].Body) if len(parsed.Suggestions) != 1 || parsed.Suggestions[0] != "var broken = false" { t.Fatalf("AI suggestion = %#v", parsed.Suggestions) } second, err := controller.Run(context.Background(), preview) if err != nil { t.Fatal(err) } if second.Findings != 0 { t.Fatalf("duplicate findings = %d, want 0", second.Findings) } if len(provider.requests) != 2 || provider.requests[0].Model != "gpt-test" { t.Fatalf("requests = %#v", provider.requests) } } func TestAIProviderTestUsesOneMinimalRequestAndForwardsProgress(t *testing.T) { provider := &fakeStreamingAIProvider{ fakeAIProvider: fakeAIProvider{response: AIInferenceResponse{ Model: "gpt-test", Content: []byte(`{"ok":true}`), }}, progress: []AIProviderProgress{{Kind: "reasoning", Text: "Checking response shape"}}, } config := defaultAIConfig() config.Enabled = true controller := &AIController{config: config, provider: provider} var progress []AIRunProgress model, err := controller.TestProvider(context.Background(), func(update AIRunProgress) { progress = append(progress, update) }) if err != nil { t.Fatal(err) } if model != "gpt-test" || len(provider.requests) != 1 { t.Fatalf("model=%q requests=%d", model, len(provider.requests)) } request := provider.requests[0] if len(request.Prompt) > 100 || strings.Contains(request.Prompt, "PR ") || strings.Contains(request.Prompt, "DIFF") { t.Fatalf("provider test sent non-minimal or PR-related input: %q", request.Prompt) } if len(progress) < 3 || progress[1].SummaryKind != "reasoning" || progress[len(progress)-1].CompletedCalls != 1 { t.Fatalf("progress = %#v", progress) } } func TestCodexProgressWriterHandlesFragmentedReasoningEvents(t *testing.T) { var updates []AIProviderProgress output := &limitedBuffer{limit: 4096} writer := &codexProgressWriter{ output: output, report: func(progress AIProviderProgress) { updates = append(updates, progress) }, } first := `{"type":"item.completed","item":{"type":"reasoning","text":"Check` second := "ing\\u001b[31m result\"}}\n" if _, err := writer.Write([]byte(first)); err != nil { t.Fatal(err) } if len(updates) != 0 { t.Fatalf("reported incomplete event: %#v", updates) } if _, err := writer.Write([]byte(second)); err != nil { t.Fatal(err) } if len(updates) != 1 || updates[0].Kind != "reasoning" || updates[0].Text != "Checking[31m result" || strings.ContainsRune(updates[0].Text, '\x1b') { t.Fatalf("updates = %#v", updates) } } func TestAIProgressBarHasStableWidth(t *testing.T) { first := renderAIProgressBar(20, AIRunProgress{TotalCalls: 3}, 0) second := renderAIProgressBar(20, AIRunProgress{TotalCalls: 3, CompletedCalls: 2}, 7) if ansi.StringWidth(first) != ansi.StringWidth(second) { t.Fatalf("progress bar width changed: %q (%d), %q (%d)", first, ansi.StringWidth(first), second, ansi.StringWidth(second)) } } func TestValidatedAISuggestionRejectsUnsafeOrUncontainedReplacement(t *testing.T) { base := aiFinding{ Path: "main.go", Side: "RIGHT", StartLine: 2, EndLine: 2, Suggestion: " replacement()", } if got := validatedAISuggestion(base, map[int]bool{2: true}); got != " replacement()" { t.Fatalf("valid suggestion = %q", got) } left := base left.Side = "LEFT" if got := validatedAISuggestion(left, map[int]bool{2: true}); got != "" { t.Fatalf("LEFT-side suggestion accepted: %q", got) } fenced := base fenced.Suggestion = "```go\nreplacement()\n```" if got := validatedAISuggestion(fenced, map[int]bool{2: true}); got != "" { t.Fatalf("fenced suggestion accepted: %q", got) } uncontained := base uncontained.EndLine = 3 if got := validatedAISuggestion(uncontained, map[int]bool{2: true}); got != "" { t.Fatalf("suggestion over unchanged line accepted: %q", got) } } func TestExistingLocalAIFindingCanGainSuggestion(t *testing.T) { finding := aiFinding{ Path: "main.go", Side: "RIGHT", StartLine: 2, EndLine: 2, Severity: "high", Title: "Bug", Body: "This is broken.", Suggestion: "fixed()", } fingerprint := aiFingerprint( finding.Path, finding.StartLine, finding.EndLine, finding.Title, finding.Body, ) state := &aiStoredState{ Version: aiStoreVersion, Annotations: make(map[string][]ReviewComment), Threads: []ReviewThread{{ ID: "local-ai-" + fingerprint, Fingerprint: fingerprint, Origin: reviewOriginLocalAI, Comments: []ReviewComment{{Body: "**high: Bug**\n\nThis is broken."}}, }}, } added, _ := state.Apply( PRDetails{}, aiOutput{Findings: []aiFinding{finding}}, "fake", "model", "", "", map[string]map[int]bool{"main.go": {2: true}}, nil, nil, ) if added != 0 { t.Fatalf("duplicate finding count = %d", added) } suggestions := parseCommentBody(state.Threads[0].Comments[0].Body).Suggestions if len(suggestions) != 1 || suggestions[0] != "fixed()" { t.Fatalf("enriched suggestions = %#v", suggestions) } } func TestAIStoreSkipsUnchangedWrites(t *testing.T) { store := NewAIStore(t.TempDir()) pr := PRDetails{PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 2}} state, _ := store.Load(pr) if err := store.Save(pr, state); err != nil { t.Fatal(err) } first, _ := os.Stat(store.path(pr)) time.Sleep(20 * time.Millisecond) if err := store.Save(pr, state); err != nil { t.Fatal(err) } second, _ := os.Stat(store.path(pr)) if !first.ModTime().Equal(second.ModTime()) { t.Fatal("unchanged AI state rewrote the file") } } func TestWithoutLocalAIDoesNotMutateVisibleDetails(t *testing.T) { remote := ReviewThread{ ID: "remote", Comments: []ReviewComment{ {ID: "github"}, {ID: "local", Origin: reviewOriginLocalAI}, {ID: "local-user", Origin: reviewOriginLocalAIUser}, }, } local := ReviewThread{ID: "local-thread", Origin: reviewOriginLocalAI} pr := PRDetails{Threads: []ReviewThread{remote, local}} clean := withoutLocalAI(pr) if len(clean.Threads) != 1 || len(clean.Threads[0].Comments) != 1 { t.Fatalf("clean details = %#v", clean.Threads) } if len(pr.Threads) != 2 || len(pr.Threads[0].Comments) != 3 { t.Fatal("filter mutated the visible PR details") } } func TestAIDiscussionStoresUserMessageBeforeProviderResponse(t *testing.T) { state := &aiStoredState{ Version: 1, Annotations: make(map[string][]ReviewComment), } pr := PRDetails{Threads: []ReviewThread{{ID: "thread-1"}}} output := aiOutput{ThreadComments: []struct { ThreadID string `json:"thread_id"` Body string `json:"body"` }{{ThreadID: "thread-1", Body: "Provider response"}}} _, added := state.Apply( pr, output, "codex", "model", "thread-1", "User follow-up", nil, nil, nil, ) comments := state.Annotations["thread-1"] if added != 1 || len(comments) != 2 { t.Fatalf("added=%d comments=%#v", added, comments) } if comments[0].Origin != reviewOriginLocalAIUser || comments[0].Author != "you" || comments[0].Body != "User follow-up" { t.Fatalf("user message = %#v", comments[0]) } if comments[1].Origin != reviewOriginLocalAI || comments[1].Body != "Provider response" { t.Fatalf("provider response = %#v", comments[1]) } } func TestAIAnnotationsMergeIntoThreadTimelineByCreationTime(t *testing.T) { rootTime := time.Date(2026, time.July, 29, 15, 0, 0, 0, time.Local) aiTime := rootTime.Add(40 * time.Minute) replyTime := rootTime.Add(58 * time.Minute) pr := PRDetails{Threads: []ReviewThread{{ ID: "thread-1", Comments: []ReviewComment{ {ID: "root", Body: "Root", CreatedAt: rootTime}, {ID: "remote-reply", Body: "Remote reply", CreatedAt: replyTime}, }, }}} state := &aiStoredState{ Version: 1, Annotations: map[string][]ReviewComment{ "thread-1": {{ ID: "local-ai-comment", Body: "Earlier AI discussion", CreatedAt: aiTime, Origin: reviewOriginLocalAI, }}, }, } merged := state.Merge(pr) comments := merged.Threads[0].Comments if len(comments) != 3 || comments[0].ID != "root" || comments[1].ID != "local-ai-comment" || comments[2].ID != "remote-reply" { t.Fatalf("merged timeline = %#v", comments) } if pr.Threads[0].Comments[1].ID != "remote-reply" { t.Fatal("merge mutated the GitHub thread") } } func TestAIDiscussionUsesViewerGitHubLogin(t *testing.T) { state := &aiStoredState{ Version: 1, Annotations: make(map[string][]ReviewComment), } pr := PRDetails{ ViewerLogin: "pablu", Threads: []ReviewThread{{ID: "thread-1"}}, } state.Apply(pr, aiOutput{}, "codex", "model", "thread-1", "Follow-up", nil, nil, nil) comments := state.Annotations["thread-1"] if len(comments) != 1 || comments[0].Author != "pablu" || comments[0].Origin != reviewOriginLocalAIUser { t.Fatalf("local user comment = %#v", comments) } } func TestReplyOnLocalAIThreadStartsInlineDiscussion(t *testing.T) { config := defaultAIConfig() config.Enabled = true store := NewAIStore(t.TempDir()) controller := &AIController{config: config, store: store} app := NewAppWithSettings(nil, "", "", false, 10, time.Minute, AppSettings{ FoldResolved: true, ThreadListWidthPercent: 33, ThreadStatusOrder: []string{"unresolved", "outdated", "resolved"}, ThreadWithinStatus: "file", DashboardMode: "hotkey", EditorMode: "vim", KeyBindings: defaultKeyBindings(), AI: controller, AIStore: store, }) app.screen = threadScreen app.details.Threads = []ReviewThread{{ID: "local", Origin: reviewOriginLocalAI}} app.startReply() if app.aiMode != aiDiscussion || app.writeThreadID != "local" { t.Fatalf("AI mode=%v thread=%q", app.aiMode, app.writeThreadID) } } func TestAITextSimilarityDetectsCloseRestatement(t *testing.T) { left := "This nil pointer can panic when the response body is absent" right := "The absent response body causes a nil pointer panic" if got := aiTextSimilarity(left, right); got < 0.68 { t.Fatalf("similarity = %v, want close restatement", got) } if rangesNear(10, 12, 30, 31) { t.Fatal("distant line ranges were considered overlapping") } }