947 lines
31 KiB
Go
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")
|
|
}
|
|
}
|