Files
diple/ai_test.go
2026-07-29 17:08:01 +02:00

947 lines
31 KiB
Go

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")
}
}