mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-10-02 01:15:23 +08:00
test: add coverage tests for agent, llm, llmloop, and scan packages
Raise statement coverage to 80%+ across four core packages to meet FLOSS best practice badge criteria. Key additions: - internal/scan (67% → 90%): getters, lookupDiff, filterScanItems, whyExcluded all branches, extFromPath, maybeRunPlan/ProjectSummary/ Dedup success paths, executeSubtask, Run pipeline, dispatchSubtasks. - internal/agent (60% → 83%): getters, filterDiffs, findDiff, resolveSystemRule, injectDiffMap, executeReviewFilter, executePlanPhase, executeSubtask, dispatchSubtasks. - internal/llmloop (59% → 80%): warnings, tool calls, usage recording, compression lifecycle (cancel/tryApply/run/trigger), partitionMessages. - internal/llm (67% → 80%): parseBpeData, embedded BPE loader, message constructors, ExtractText, ChatResponse helpers, parseShellRC.
This commit is contained in:
@@ -0,0 +1,633 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/config/rules"
|
||||
"github.com/open-code-review/open-code-review/internal/config/template"
|
||||
"github.com/open-code-review/open-code-review/internal/llm"
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
"github.com/open-code-review/open-code-review/internal/session"
|
||||
"github.com/open-code-review/open-code-review/internal/tool"
|
||||
)
|
||||
|
||||
func TestAgent_Getters(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test-model", session.SessionOptions{ReviewMode: "diff"})
|
||||
collector := tool.NewCommentCollector()
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test-model",
|
||||
CommentCollector: collector,
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 10,
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "test"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "a.go", Diff: "+code"},
|
||||
{NewPath: "b.go", Diff: "+more"},
|
||||
}
|
||||
|
||||
if a.Session() != sess {
|
||||
t.Error("Session() does not return expected session")
|
||||
}
|
||||
if a.FilesReviewed() != 2 {
|
||||
t.Errorf("FilesReviewed() = %d, want 2", a.FilesReviewed())
|
||||
}
|
||||
if len(a.Diffs()) != 2 {
|
||||
t.Errorf("Diffs() len = %d, want 2", len(a.Diffs()))
|
||||
}
|
||||
if a.ProjectSummary() != "" {
|
||||
t.Errorf("ProjectSummary() = %q, want empty", a.ProjectSummary())
|
||||
}
|
||||
if a.TotalTokensUsed() != 0 {
|
||||
t.Errorf("TotalTokensUsed() = %d, want 0", a.TotalTokensUsed())
|
||||
}
|
||||
if a.TotalCacheReadTokens() != 0 {
|
||||
t.Errorf("TotalCacheReadTokens() = %d, want 0", a.TotalCacheReadTokens())
|
||||
}
|
||||
if a.TotalCacheWriteTokens() != 0 {
|
||||
t.Errorf("TotalCacheWriteTokens() = %d, want 0", a.TotalCacheWriteTokens())
|
||||
}
|
||||
if len(a.Warnings()) != 0 {
|
||||
t.Errorf("Warnings() should be empty initially, got %d", len(a.Warnings()))
|
||||
}
|
||||
if len(a.ToolCalls()) != 0 {
|
||||
t.Errorf("ToolCalls() should be empty initially, got %d", len(a.ToolCalls()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_RecordWarning(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test-model", session.SessionOptions{ReviewMode: "diff"})
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test-model",
|
||||
Session: sess,
|
||||
Template: template.Template{MaxTokens: 10000, MaxToolRequestTimes: 5, MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}}},
|
||||
})
|
||||
|
||||
a.recordWarning("error", "main.go", "something")
|
||||
warnings := a.Warnings()
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("expected 1 warning, got %d", len(warnings))
|
||||
}
|
||||
if warnings[0].Type != "error" || warnings[0].File != "main.go" {
|
||||
t.Errorf("unexpected warning: %+v", warnings[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommentWorkerPool(t *testing.T) {
|
||||
pool := NewCommentWorkerPool(2)
|
||||
if pool == nil {
|
||||
t.Fatal("NewCommentWorkerPool returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectDiffMap(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
emptyDM := tool.NewDiffMap(nil)
|
||||
frd := tool.NewFileReadDiff(emptyDM)
|
||||
reg.Register(frd)
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Tools: reg,
|
||||
Template: template.Template{MaxTokens: 10000, MaxToolRequestTimes: 5, MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}}},
|
||||
})
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "main.go", OldPath: "main.go", Diff: "+new code"},
|
||||
{NewPath: "/dev/null", OldPath: "deleted.go", Diff: "-deleted"},
|
||||
}
|
||||
|
||||
a.injectDiffMap()
|
||||
|
||||
result, err := frd.Execute(context.Background(), map[string]any{
|
||||
"path_array": []any{"main.go"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result, "+new code") {
|
||||
t.Errorf("DiffMap did not contain main.go diff, got: %q", result)
|
||||
}
|
||||
|
||||
result2, _ := frd.Execute(context.Background(), map[string]any{
|
||||
"path_array": []any{"deleted.go"},
|
||||
})
|
||||
if !strings.Contains(result2, "not found") {
|
||||
t.Errorf("/dev/null path should not be in DiffMap, got: %q", result2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterDiffs(t *testing.T) {
|
||||
a := New(Args{
|
||||
FileFilter: &rules.FileFilter{
|
||||
Exclude: []string{"vendor/**"},
|
||||
},
|
||||
})
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "main.go"},
|
||||
{NewPath: "vendor/dep.go"},
|
||||
{NewPath: "image.png", IsBinary: true},
|
||||
{NewPath: "handler.go"},
|
||||
}
|
||||
|
||||
kept := a.filterDiffs(a.diffs)
|
||||
|
||||
names := make(map[string]bool)
|
||||
for _, d := range kept {
|
||||
names[d.NewPath] = true
|
||||
}
|
||||
if names["vendor/dep.go"] {
|
||||
t.Error("vendor file should be filtered")
|
||||
}
|
||||
if names["image.png"] {
|
||||
t.Error("binary file should be filtered")
|
||||
}
|
||||
if !names["main.go"] || !names["handler.go"] {
|
||||
t.Error("valid files should be kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemRule(t *testing.T) {
|
||||
t.Run("nil SystemRule returns empty", func(t *testing.T) {
|
||||
a := New(Args{SystemRule: nil})
|
||||
if got := a.resolveSystemRule("main.go"); got != "" {
|
||||
t.Errorf("expected empty, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with resolver", func(t *testing.T) {
|
||||
rule, err := rules.LoadDefault()
|
||||
if err != nil {
|
||||
t.Skipf("cannot load default rules: %v", err)
|
||||
}
|
||||
a := New(Args{SystemRule: rule})
|
||||
got := a.resolveSystemRule("main.go")
|
||||
if got == "" {
|
||||
t.Error("expected non-empty rule for .go file")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindDiff(t *testing.T) {
|
||||
a := New(Args{})
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "a.go", OldPath: "a.go", Diff: "+a"},
|
||||
{NewPath: "b.go", OldPath: "old_b.go", Diff: "+b"},
|
||||
}
|
||||
|
||||
if d := a.findDiff("a.go"); d == nil || d.NewPath != "a.go" {
|
||||
t.Error("findDiff should find by NewPath")
|
||||
}
|
||||
if d := a.findDiff("old_b.go"); d == nil || d.NewPath != "b.go" {
|
||||
t.Error("findDiff should find by OldPath")
|
||||
}
|
||||
if d := a.findDiff("nonexist.go"); d != nil {
|
||||
t.Error("findDiff should return nil for missing path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReviewFilter_NoFilterTask(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
client := &fakeAgentClient{}
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
ReviewFilterTask: nil,
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
|
||||
a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go"}, "a.go")
|
||||
if client.calls != 0 {
|
||||
t.Errorf("no LLM calls expected when ReviewFilterTask is nil, got %d", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReviewFilter_NoComments(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
client := &fakeAgentClient{}
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
ReviewFilterTask: &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "Filter {{comments}} for {{path}} in {{diff}}"}},
|
||||
},
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
|
||||
a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x"}, "a.go")
|
||||
if client.calls != 0 {
|
||||
t.Errorf("no LLM calls expected when no comments exist, got %d", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReviewFilter_RemovesComments(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
filterResp := `["c-1"]`
|
||||
client := &fakeAgentClient{
|
||||
responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{Content: &filterResp},
|
||||
}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
|
||||
}},
|
||||
}
|
||||
|
||||
collector := tool.NewCommentCollector()
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "keep this"})
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "remove this"})
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "also keep"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
CommentCollector: collector,
|
||||
Template: template.Template{
|
||||
ReviewFilterTask: &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "Filter: {{comments}} path={{path}} diff={{diff}}"}},
|
||||
},
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
|
||||
a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go", Diff: "+code"}, "a.go")
|
||||
|
||||
comments := collector.CommentsForPath("a.go")
|
||||
if len(comments) != 2 {
|
||||
t.Fatalf("expected 2 comments after filter, got %d", len(comments))
|
||||
}
|
||||
for _, c := range comments {
|
||||
if c.Content == "remove this" {
|
||||
t.Error("filtered comment should have been removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReviewFilter_LLMError(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
client := &fakeAgentClient{
|
||||
responses: nil,
|
||||
}
|
||||
|
||||
collector := tool.NewCommentCollector()
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "comment"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
CommentCollector: collector,
|
||||
Template: template.Template{
|
||||
ReviewFilterTask: &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "{{comments}} {{path}} {{diff}}"}},
|
||||
},
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
|
||||
a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x"}, "a.go")
|
||||
|
||||
comments := collector.CommentsForPath("a.go")
|
||||
if len(comments) != 1 {
|
||||
t.Errorf("comments should be unchanged on LLM error, got %d", len(comments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePlanPhase(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
planText := "review plan output"
|
||||
client := &fakeAgentClient{
|
||||
responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{Content: &planText},
|
||||
}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 20, CompletionTokens: 10},
|
||||
}},
|
||||
}
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Background: "test background",
|
||||
Template: template.Template{
|
||||
PlanTask: &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "system", Content: "You are a planner. Date: {{current_system_date_time}}"},
|
||||
{Role: "user", Content: "Plan review for {{current_file_path}}. Rule: {{system_rule}}. Changes: {{change_files}}. Diff: {{diff}}. Background: {{requirement_background}}. Tools: {{plan_tools}}"},
|
||||
},
|
||||
},
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
|
||||
result, err := a.executePlanPhase(context.Background(), "main.go", "+new code", "helper.go", "check for bugs")
|
||||
if err != nil {
|
||||
t.Fatalf("executePlanPhase: %v", err)
|
||||
}
|
||||
if result != "review plan output" {
|
||||
t.Errorf("result = %q", result)
|
||||
}
|
||||
if a.TotalInputTokens() != 20 {
|
||||
t.Errorf("TotalInputTokens = %d, want 20", a.TotalInputTokens())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePlanPhase_LLMError(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
client := &fakeAgentClient{responses: nil}
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
PlanTask: &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "{{diff}}"}},
|
||||
},
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := a.executePlanPhase(context.Background(), "a.go", "+x", "", "")
|
||||
if err != nil {
|
||||
t.Logf("expected no-error from empty response, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSubtask_EmptyMainTask(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: nil},
|
||||
},
|
||||
})
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
|
||||
err := a.executeSubtask(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty main_task messages")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "main_task.messages is empty") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSubtask_TokenThresholdExceeded(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 10,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Review: {{diff}}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
a.diffs = []model.Diff{{NewPath: "a.go", Diff: strings.Repeat("code ", 200), Insertions: 100}}
|
||||
|
||||
err := a.executeSubtask(context.Background(), a.diffs[0])
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
warnings := a.Warnings()
|
||||
found := false
|
||||
for _, w := range warnings {
|
||||
if w.Type == "token_threshold_exceeded" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected token_threshold_exceeded warning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSubtask_WithPlanPhase(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
planText := "my plan"
|
||||
doneContent := ""
|
||||
client := &fakeAgentClient{
|
||||
responses: []*llm.ChatResponse{
|
||||
{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &planText}}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 3},
|
||||
},
|
||||
{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{
|
||||
Content: &doneContent,
|
||||
ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Type: "function",
|
||||
Function: llm.FunctionCall{Name: "task_done", Arguments: "{}"},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Tools: reg,
|
||||
Template: template.Template{
|
||||
MaxTokens: 100000,
|
||||
MaxToolRequestTimes: 10,
|
||||
PlanModeLineThreshold: 0,
|
||||
PlanTask: &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Plan for {{current_file_path}}: {{diff}}"},
|
||||
},
|
||||
},
|
||||
MainTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Review {{current_file_path}} with plan {{plan_guidance}}: {{diff}}"},
|
||||
},
|
||||
},
|
||||
},
|
||||
MainToolDefs: []llm.ToolDef{
|
||||
{Type: "function", Function: llm.FunctionDef{Name: "task_done", Description: "done"}},
|
||||
},
|
||||
})
|
||||
a.currentDate = "2025-06-26 10:00"
|
||||
a.diffs = []model.Diff{{NewPath: "main.go", OldPath: "main.go", Diff: "+new code", Insertions: 5}}
|
||||
|
||||
err := a.executeSubtask(context.Background(), a.diffs[0])
|
||||
if err != nil {
|
||||
t.Fatalf("executeSubtask: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSubtask_ContextCancelled(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "{{diff}}"}}},
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := a.executeSubtask(ctx, model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReviewFilter_WithTimeout(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
filterResp := `[]`
|
||||
client := &fakeAgentClient{
|
||||
responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &filterResp}}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 2},
|
||||
}},
|
||||
}
|
||||
|
||||
collector := tool.NewCommentCollector()
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "comment"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
CommentCollector: collector,
|
||||
Template: template.Template{
|
||||
ReviewFilterTask: &template.LlmConversation{
|
||||
Timeout: 30,
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "{{comments}} {{path}} {{diff}}"}},
|
||||
},
|
||||
MaxTokens: 10000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "t"}}},
|
||||
},
|
||||
})
|
||||
|
||||
a.executeReviewFilter(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x"}, "a.go")
|
||||
|
||||
comments := collector.CommentsForPath("a.go")
|
||||
if len(comments) != 1 {
|
||||
t.Errorf("expected 1 comment unchanged, got %d", len(comments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubtasks_AllFilteredBySize(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 10,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "{{diff}}"}}},
|
||||
},
|
||||
})
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "big.go", Diff: strings.Repeat("word ", 500), Insertions: 100},
|
||||
}
|
||||
|
||||
_, err := a.dispatchSubtasks(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "all diffs filtered out") {
|
||||
t.Errorf("expected 'all diffs filtered out' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubtasks_AllFailed(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
|
||||
|
||||
a := New(Args{
|
||||
LLMClient: &fakeAgentClient{},
|
||||
Model: "test",
|
||||
Session: sess,
|
||||
Template: template.Template{
|
||||
MaxTokens: 100000,
|
||||
MaxToolRequestTimes: 5,
|
||||
MainTask: template.LlmConversation{Messages: nil},
|
||||
},
|
||||
})
|
||||
a.diffs = []model.Diff{
|
||||
{NewPath: "a.go", Diff: "+x", Insertions: 1},
|
||||
}
|
||||
a.currentDate = "2025-06-26"
|
||||
|
||||
_, err := a.dispatchSubtasks(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "failed") {
|
||||
t.Errorf("expected failure error, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBpeData_Valid(t *testing.T) {
|
||||
// "hello" base64 = "aGVsbG8="
|
||||
input := []byte("aGVsbG8= 42\nd29ybGQ= 7\n")
|
||||
ranks, err := parseBpeData(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parseBpeData: %v", err)
|
||||
}
|
||||
if ranks["hello"] != 42 {
|
||||
t.Errorf("ranks[hello] = %d, want 42", ranks["hello"])
|
||||
}
|
||||
if ranks["world"] != 7 {
|
||||
t.Errorf("ranks[world] = %d, want 7", ranks["world"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBpeData_EmptyLines(t *testing.T) {
|
||||
input := []byte("\n \naGVsbG8= 1\n\n")
|
||||
ranks, err := parseBpeData(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parseBpeData: %v", err)
|
||||
}
|
||||
if len(ranks) != 1 {
|
||||
t.Errorf("expected 1 entry, got %d", len(ranks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBpeData_InvalidLine(t *testing.T) {
|
||||
input := []byte("nospacehere\n")
|
||||
_, err := parseBpeData(input)
|
||||
if err == nil {
|
||||
t.Error("expected error for line without space")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBpeData_InvalidBase64(t *testing.T) {
|
||||
input := []byte("!!!invalid 1\n")
|
||||
_, err := parseBpeData(input)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBpeData_InvalidRank(t *testing.T) {
|
||||
input := []byte("aGVsbG8= notanumber\n")
|
||||
_, err := parseBpeData(input)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-integer rank")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTiktokenBpe_KnownURL(t *testing.T) {
|
||||
loader := &embeddedBpeLoader{}
|
||||
ranks, err := loader.LoadTiktokenBpe("https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTiktokenBpe: %v", err)
|
||||
}
|
||||
if len(ranks) == 0 {
|
||||
t.Error("expected non-empty ranks for cl100k_base")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTiktokenBpe_UnknownURL(t *testing.T) {
|
||||
loader := &embeddedBpeLoader{}
|
||||
_, err := loader.LoadTiktokenBpe("https://example.com/unknown.tiktoken")
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitEmbeddedLoader(t *testing.T) {
|
||||
InitEmbeddedLoader()
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewTextMessage(t *testing.T) {
|
||||
m := NewTextMessage("user", "hello")
|
||||
if m.Role != "user" {
|
||||
t.Errorf("Role = %q, want user", m.Role)
|
||||
}
|
||||
if m.Content != "hello" {
|
||||
t.Errorf("Content = %v, want hello", m.Content)
|
||||
}
|
||||
if m.ToolCallID != "" {
|
||||
t.Error("ToolCallID should be empty")
|
||||
}
|
||||
if len(m.ToolCalls) != 0 {
|
||||
t.Errorf("ToolCalls should be nil, got %v", m.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewToolCallMessage(t *testing.T) {
|
||||
calls := []ToolCall{
|
||||
{ID: "c1", Type: "function", Function: FunctionCall{Name: "tool_a", Arguments: `{}`}},
|
||||
{ID: "c2", Type: "function", Function: FunctionCall{Name: "tool_b", Arguments: `{"x":1}`}},
|
||||
}
|
||||
m := NewToolCallMessage("thinking", calls)
|
||||
if m.Role != "assistant" {
|
||||
t.Errorf("Role = %q, want assistant", m.Role)
|
||||
}
|
||||
if m.Content != "thinking" {
|
||||
t.Errorf("Content = %v, want thinking", m.Content)
|
||||
}
|
||||
if len(m.ToolCalls) != 2 {
|
||||
t.Fatalf("ToolCalls len = %d, want 2", len(m.ToolCalls))
|
||||
}
|
||||
if m.ToolCalls[0].ID != "c1" || m.ToolCalls[1].Function.Name != "tool_b" {
|
||||
t.Errorf("ToolCalls not copied correctly")
|
||||
}
|
||||
|
||||
// Mutation of original must not affect the message.
|
||||
calls[0].ID = "mutated"
|
||||
if m.ToolCalls[0].ID == "mutated" {
|
||||
t.Error("NewToolCallMessage must copy ToolCalls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewToolCallMessage_NilCalls(t *testing.T) {
|
||||
m := NewToolCallMessage("text", nil)
|
||||
if m.ToolCalls != nil {
|
||||
t.Errorf("expected nil ToolCalls for nil input, got %v", m.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewToolResultMessage(t *testing.T) {
|
||||
m := NewToolResultMessage("call-123", "result text")
|
||||
if m.Role != "tool" {
|
||||
t.Errorf("Role = %q, want tool", m.Role)
|
||||
}
|
||||
if m.Content != "result text" {
|
||||
t.Errorf("Content = %v, want result text", m.Content)
|
||||
}
|
||||
if m.ToolCallID != "call-123" {
|
||||
t.Errorf("ToolCallID = %q, want call-123", m.ToolCallID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_String(t *testing.T) {
|
||||
m := Message{Role: "user", Content: "plain text"}
|
||||
if got := m.ExtractText(); got != "plain text" {
|
||||
t.Errorf("ExtractText() = %q, want plain text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_ContentBlocks(t *testing.T) {
|
||||
m := Message{Role: "assistant", Content: []ContentBlock{
|
||||
{Type: "text", Text: "part1"},
|
||||
{Type: "text", Text: " part2"},
|
||||
}}
|
||||
if got := m.ExtractText(); got != "part1 part2" {
|
||||
t.Errorf("ExtractText() = %q, want 'part1 part2'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_NestedContentBlocks(t *testing.T) {
|
||||
m := Message{Role: "tool", Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_result",
|
||||
Content: []ContentBlock{
|
||||
{Type: "text", Text: "inner1"},
|
||||
{Type: "text", Text: "inner2"},
|
||||
},
|
||||
},
|
||||
{Type: "text", Text: "outer"},
|
||||
}}
|
||||
got := m.ExtractText()
|
||||
if got != "inner1inner2outer" {
|
||||
t.Errorf("ExtractText() = %q, want inner1inner2outer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_Default(t *testing.T) {
|
||||
m := Message{Role: "user", Content: 42}
|
||||
if got := m.ExtractText(); got != "" {
|
||||
t.Errorf("ExtractText() for non-string/non-block = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractText_NilContent(t *testing.T) {
|
||||
m := Message{Role: "user", Content: nil}
|
||||
if got := m.ExtractText(); got != "" {
|
||||
t.Errorf("ExtractText() for nil = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_Content(t *testing.T) {
|
||||
text := "hello world"
|
||||
resp := &ChatResponse{
|
||||
Choices: []Choice{{
|
||||
Message: ResponseMessage{Content: &text},
|
||||
}},
|
||||
}
|
||||
if got := resp.Content(); got != "hello world" {
|
||||
t.Errorf("Content() = %q, want hello world", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_Content_Empty(t *testing.T) {
|
||||
resp := &ChatResponse{}
|
||||
if got := resp.Content(); got != "" {
|
||||
t.Errorf("Content() with no choices = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_Content_FallbackToReasoning(t *testing.T) {
|
||||
empty := ""
|
||||
resp := &ChatResponse{
|
||||
Choices: []Choice{{
|
||||
Message: ResponseMessage{Content: &empty, ReasoningContent: "reasoning here"},
|
||||
}},
|
||||
}
|
||||
if got := resp.Content(); got != "reasoning here" {
|
||||
t.Errorf("Content() = %q, want reasoning here", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_Content_NilContent(t *testing.T) {
|
||||
resp := &ChatResponse{
|
||||
Choices: []Choice{{
|
||||
Message: ResponseMessage{Content: nil, ReasoningContent: "fallback"},
|
||||
}},
|
||||
}
|
||||
if got := resp.Content(); got != "fallback" {
|
||||
t.Errorf("Content() = %q, want fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_Content_StripsThinkTags(t *testing.T) {
|
||||
text := "<think>internal</think>answer"
|
||||
resp := &ChatResponse{
|
||||
Choices: []Choice{{
|
||||
Message: ResponseMessage{Content: &text},
|
||||
}},
|
||||
}
|
||||
if got := resp.Content(); got != "internalanswer" {
|
||||
t.Errorf("Content() = %q, want internalanswer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_ToolCalls(t *testing.T) {
|
||||
resp := &ChatResponse{
|
||||
Choices: []Choice{{
|
||||
Message: ResponseMessage{
|
||||
ToolCalls: []ToolCall{
|
||||
{ID: "c1", Type: "function", Function: FunctionCall{Name: "tool_a"}},
|
||||
{ID: "c2", Type: "function", Function: FunctionCall{Name: "tool_b"}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
calls := resp.ToolCalls()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("ToolCalls() len = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[0].Function.Name != "tool_a" || calls[1].Function.Name != "tool_b" {
|
||||
t.Error("ToolCalls returned unexpected values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponse_ToolCalls_Empty(t *testing.T) {
|
||||
resp := &ChatResponse{}
|
||||
if got := resp.ToolCalls(); got != nil {
|
||||
t.Errorf("ToolCalls() with no choices = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShellRC(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
rcPath := filepath.Join(tmp, ".zshrc")
|
||||
|
||||
content := `# some comment
|
||||
export PATH="/usr/bin:$PATH"
|
||||
export ANTHROPIC_BASE_URL="https://api.example.com"
|
||||
export ANTHROPIC_AUTH_TOKEN='sk-test-token'
|
||||
export ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||
`
|
||||
if err := os.WriteFile(rcPath, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ep, ok, err := parseShellRC(rcPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseShellRC: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
if ep.Token != "sk-test-token" {
|
||||
t.Errorf("Token = %q, want sk-test-token", ep.Token)
|
||||
}
|
||||
if ep.Model != "claude-sonnet-4-20250514" {
|
||||
t.Errorf("Model = %q", ep.Model)
|
||||
}
|
||||
if ep.Protocol != "anthropic" {
|
||||
t.Errorf("Protocol = %q, want anthropic", ep.Protocol)
|
||||
}
|
||||
if ep.AuthHeader != "authorization" {
|
||||
t.Errorf("AuthHeader = %q, want authorization", ep.AuthHeader)
|
||||
}
|
||||
if ep.Source != "Shell rc file" {
|
||||
t.Errorf("Source = %q", ep.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShellRC_ModelOverride(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
rcPath := filepath.Join(tmp, ".bashrc")
|
||||
|
||||
content := `export ANTHROPIC_BASE_URL="https://api.example.com"
|
||||
export ANTHROPIC_AUTH_TOKEN="token"
|
||||
export ANTHROPIC_MODEL=claude-3-opus
|
||||
`
|
||||
os.WriteFile(rcPath, []byte(content), 0644)
|
||||
|
||||
ep, ok, err := parseShellRC(rcPath, "override-model")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
if ep.Model != "override-model" {
|
||||
t.Errorf("Model = %q, want override-model", ep.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShellRC_Incomplete(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
rcPath := filepath.Join(tmp, ".zshrc")
|
||||
|
||||
content := `export ANTHROPIC_BASE_URL="https://api.example.com"
|
||||
export ANTHROPIC_AUTH_TOKEN="token"
|
||||
# missing ANTHROPIC_MODEL
|
||||
`
|
||||
os.WriteFile(rcPath, []byte(content), 0644)
|
||||
|
||||
_, ok, err := parseShellRC(rcPath, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected ok=false when model is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShellRC_NonexistentFile(t *testing.T) {
|
||||
_, ok, err := parseShellRC("/nonexistent/path/.zshrc", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected ok=false for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelListContains(t *testing.T) {
|
||||
models := []string{"gpt-4", " claude-3-opus ", "gemini-pro"}
|
||||
if !modelListContains(models, "claude-3-opus") {
|
||||
t.Error("expected true for claude-3-opus")
|
||||
}
|
||||
if !modelListContains(models, "gpt-4") {
|
||||
t.Error("expected true for gpt-4")
|
||||
}
|
||||
if modelListContains(models, "gpt-3.5") {
|
||||
t.Error("expected false for gpt-3.5")
|
||||
}
|
||||
if modelListContains(nil, "anything") {
|
||||
t.Error("expected false for nil list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAuthHeader(t *testing.T) {
|
||||
if got := defaultAuthHeader("anthropic"); got != "authorization" {
|
||||
t.Errorf("anthropic: got %q, want authorization", got)
|
||||
}
|
||||
if got := defaultAuthHeader("openai"); got != "" {
|
||||
t.Errorf("openai: got %q, want empty", got)
|
||||
}
|
||||
if got := defaultAuthHeader(""); got != "" {
|
||||
t.Errorf("empty: got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAgent(t *testing.T) {
|
||||
got := userAgent("anthropic")
|
||||
if got != "open-code-review/dev | anthropic" {
|
||||
t.Errorf("userAgent(anthropic) = %q", got)
|
||||
}
|
||||
got2 := userAgent("")
|
||||
if got2 != "open-code-review/dev" {
|
||||
t.Errorf("userAgent('') = %q", got2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package llmloop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/config/template"
|
||||
"github.com/open-code-review/open-code-review/internal/llm"
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
"github.com/open-code-review/open-code-review/internal/session"
|
||||
"github.com/open-code-review/open-code-review/internal/tool"
|
||||
)
|
||||
|
||||
type fakeLLMClient struct {
|
||||
response *llm.ChatResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeLLMClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
|
||||
return f.response, f.err
|
||||
}
|
||||
|
||||
func newTestRunner(client llm.LLMClient, tpl template.Template) *Runner {
|
||||
sess := session.New(t_tempDir, "main", "test-model", session.SessionOptions{ReviewMode: "diff"})
|
||||
collector := tool.NewCommentCollector()
|
||||
return NewRunner(Deps{
|
||||
LLMClient: client,
|
||||
Model: "test-model",
|
||||
Template: tpl,
|
||||
CommentCollector: collector,
|
||||
Session: sess,
|
||||
})
|
||||
}
|
||||
|
||||
var t_tempDir string
|
||||
|
||||
func TestRecordWarning(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
r := newTestRunner(&fakeLLMClient{}, template.Template{})
|
||||
r.RecordWarning("error", "main.go", "something went wrong")
|
||||
r.RecordWarning("warn", "lib.go", "not great")
|
||||
|
||||
warnings := r.Warnings()
|
||||
if len(warnings) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(warnings))
|
||||
}
|
||||
if warnings[0].Type != "error" || warnings[0].File != "main.go" {
|
||||
t.Errorf("warning[0] = %+v", warnings[0])
|
||||
}
|
||||
if warnings[1].Message != "not great" {
|
||||
t.Errorf("warning[1].Message = %q", warnings[1].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordToolCall(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
r := newTestRunner(&fakeLLMClient{}, template.Template{})
|
||||
r.recordToolCall("file_read")
|
||||
r.recordToolCall("file_read")
|
||||
r.recordToolCall("code_comment")
|
||||
|
||||
calls := r.ToolCalls()
|
||||
if calls["file_read"] != 2 {
|
||||
t.Errorf("file_read = %d, want 2", calls["file_read"])
|
||||
}
|
||||
if calls["code_comment"] != 1 {
|
||||
t.Errorf("code_comment = %d, want 1", calls["code_comment"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUsage(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
r := newTestRunner(&fakeLLMClient{}, template.Template{})
|
||||
|
||||
r.RecordUsage(nil)
|
||||
if r.TotalInputTokens() != 0 {
|
||||
t.Error("nil usage should not change counters")
|
||||
}
|
||||
|
||||
r.RecordUsage(&llm.UsageInfo{
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 50,
|
||||
CacheReadTokens: 10,
|
||||
CacheWriteTokens: 5,
|
||||
})
|
||||
if r.TotalInputTokens() != 100 {
|
||||
t.Errorf("TotalInputTokens = %d, want 100", r.TotalInputTokens())
|
||||
}
|
||||
if r.TotalOutputTokens() != 50 {
|
||||
t.Errorf("TotalOutputTokens = %d, want 50", r.TotalOutputTokens())
|
||||
}
|
||||
if r.TotalCacheReadTokens() != 10 {
|
||||
t.Errorf("TotalCacheReadTokens = %d, want 10", r.TotalCacheReadTokens())
|
||||
}
|
||||
if r.TotalCacheWriteTokens() != 5 {
|
||||
t.Errorf("TotalCacheWriteTokens = %d, want 5", r.TotalCacheWriteTokens())
|
||||
}
|
||||
if r.TotalTokensUsed() != 150 {
|
||||
t.Errorf("TotalTokensUsed = %d, want 150", r.TotalTokensUsed())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectPendingComments_NilPool(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
collector := tool.NewCommentCollector()
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "fix"})
|
||||
|
||||
r := NewRunner(Deps{
|
||||
CommentCollector: collector,
|
||||
CommentWorkerPool: nil,
|
||||
})
|
||||
comments := r.CollectPendingComments()
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("expected 1 comment, got %d", len(comments))
|
||||
}
|
||||
if comments[0].Path != "a.go" {
|
||||
t.Errorf("comment.Path = %q", comments[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelPendingCompression_NilJob(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
r := newTestRunner(&fakeLLMClient{}, template.Template{})
|
||||
r.cancelPendingCompression()
|
||||
}
|
||||
|
||||
func TestCancelPendingCompression_WithJob(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
r := newTestRunner(&fakeLLMClient{}, template.Template{})
|
||||
|
||||
cancelled := false
|
||||
job := &compressionJob{
|
||||
done: make(chan struct{}),
|
||||
cancel: func() { cancelled = true },
|
||||
}
|
||||
r.pendingJob = job
|
||||
r.cancelPendingCompression()
|
||||
|
||||
if !cancelled {
|
||||
t.Error("cancel was not called")
|
||||
}
|
||||
if r.pendingJob != nil {
|
||||
t.Error("pendingJob should be nil after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryApplyPendingCompression_NilJob(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
r := newTestRunner(&fakeLLMClient{}, template.Template{})
|
||||
msgs := []llm.Message{msg("user", "hi")}
|
||||
if r.tryApplyPendingCompression(&msgs) {
|
||||
t.Error("expected false for nil job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryApplyPendingCompression_NotDone(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
r := newTestRunner(&fakeLLMClient{}, template.Template{})
|
||||
|
||||
job := &compressionJob{
|
||||
done: make(chan struct{}),
|
||||
cancel: func() {},
|
||||
}
|
||||
r.pendingJob = job
|
||||
|
||||
msgs := []llm.Message{msg("user", "hi")}
|
||||
if r.tryApplyPendingCompression(&msgs) {
|
||||
t.Error("expected false for non-completed job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryApplyPendingCompression_Applied(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
r := newTestRunner(&fakeLLMClient{}, template.Template{})
|
||||
|
||||
rebuilt := []llm.Message{msg("system", "sys"), msg("user", "compressed")}
|
||||
job := &compressionJob{
|
||||
done: make(chan struct{}),
|
||||
cancel: func() {},
|
||||
rebuilt: rebuilt,
|
||||
snapshotLen: 3,
|
||||
}
|
||||
close(job.done)
|
||||
r.pendingJob = job
|
||||
|
||||
msgs := []llm.Message{
|
||||
msg("system", "sys"),
|
||||
msg("user", "orig"),
|
||||
msg("assistant", "resp"),
|
||||
msg("tool", "appended after snapshot"),
|
||||
}
|
||||
applied := r.tryApplyPendingCompression(&msgs)
|
||||
if !applied {
|
||||
t.Fatal("expected applied=true")
|
||||
}
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("len(msgs) = %d, want 3", len(msgs))
|
||||
}
|
||||
if msgs[1].ExtractText() != "compressed" {
|
||||
t.Errorf("msgs[1] = %q, want compressed", msgs[1].ExtractText())
|
||||
}
|
||||
if msgs[2].ExtractText() != "appended after snapshot" {
|
||||
t.Errorf("msgs[2] = %q, want appended after snapshot", msgs[2].ExtractText())
|
||||
}
|
||||
if r.pendingJob != nil {
|
||||
t.Error("pendingJob should be nil after apply")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryApplyPendingCompression_NilRebuilt(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
r := newTestRunner(&fakeLLMClient{}, template.Template{})
|
||||
|
||||
job := &compressionJob{
|
||||
done: make(chan struct{}),
|
||||
cancel: func() {},
|
||||
rebuilt: nil,
|
||||
snapshotLen: 3,
|
||||
}
|
||||
close(job.done)
|
||||
r.pendingJob = job
|
||||
|
||||
msgs := []llm.Message{msg("user", "hi")}
|
||||
applied := r.tryApplyPendingCompression(&msgs)
|
||||
if applied {
|
||||
t.Error("expected false when rebuilt is nil (compression failed)")
|
||||
}
|
||||
if r.pendingJob != nil {
|
||||
t.Error("pendingJob should be nil even on non-apply")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionMessages_CompressionNeeded(t *testing.T) {
|
||||
messages := []llm.Message{
|
||||
msg("system", "sys"),
|
||||
msg("user", "prompt"),
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
messages = append(messages, msg("assistant", strings.Repeat("word ", 200)))
|
||||
messages = append(messages, msg("tool", strings.Repeat("data ", 100)))
|
||||
}
|
||||
|
||||
result := partitionMessages(messages, 500, 0)
|
||||
|
||||
if result.frozenEnd != 2 {
|
||||
t.Errorf("frozenEnd = %d, want 2", result.frozenEnd)
|
||||
}
|
||||
if result.activeCount == 0 {
|
||||
t.Error("activeCount should be > 0 for compression-needed case")
|
||||
}
|
||||
if result.compressEnd >= len(messages) {
|
||||
t.Errorf("compressEnd = %d, should be < %d", result.compressEnd, len(messages))
|
||||
}
|
||||
if result.compressEnd <= result.frozenEnd {
|
||||
t.Errorf("compressEnd (%d) should be > frozenEnd (%d)", result.compressEnd, result.frozenEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCompression_EmptyTemplate(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
r := newTestRunner(&fakeLLMClient{}, template.Template{
|
||||
MaxTokens: 1000,
|
||||
})
|
||||
|
||||
msgs := []llm.Message{
|
||||
msg("system", "sys"),
|
||||
msg("user", "prompt"),
|
||||
msg("assistant", "resp"),
|
||||
}
|
||||
got, err := r.runCompression(context.Background(), msgs, "test.go")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("expected 2 (frozen only), got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCompression_ShortMessages(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
tpl := template.Template{
|
||||
MemoryCompressionTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}},
|
||||
},
|
||||
MaxTokens: 1000,
|
||||
}
|
||||
r := newTestRunner(&fakeLLMClient{}, tpl)
|
||||
|
||||
msgs := []llm.Message{msg("system", "sys"), msg("user", "prompt")}
|
||||
got, err := r.runCompression(context.Background(), msgs, "test.go")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("expected 2, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCompression_Success(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
summaryText := "compressed summary"
|
||||
client := &fakeLLMClient{
|
||||
response: &llm.ChatResponse{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{Content: &summaryText},
|
||||
}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 100, CompletionTokens: 20},
|
||||
},
|
||||
}
|
||||
tpl := template.Template{
|
||||
MemoryCompressionTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "Summarize: {{context}}"}},
|
||||
},
|
||||
MaxTokens: 50,
|
||||
}
|
||||
r := newTestRunner(client, tpl)
|
||||
|
||||
msgs := []llm.Message{
|
||||
msg("system", "sys"),
|
||||
msg("user", "prompt"),
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, msg("assistant", strings.Repeat("word ", 100)))
|
||||
msgs = append(msgs, msg("tool", strings.Repeat("data ", 50)))
|
||||
}
|
||||
|
||||
got, err := r.runCompression(context.Background(), msgs, "test.go")
|
||||
if err != nil {
|
||||
t.Fatalf("runCompression: %v", err)
|
||||
}
|
||||
if len(got) < 2 {
|
||||
t.Fatalf("expected at least 2 messages, got %d", len(got))
|
||||
}
|
||||
if !strings.Contains(got[1].ExtractText(), "previous_review_summary") {
|
||||
t.Errorf("expected summary in rebuilt messages, got: %s", got[1].ExtractText())
|
||||
}
|
||||
if r.TotalInputTokens() != 100 {
|
||||
t.Errorf("TotalInputTokens = %d, want 100", r.TotalInputTokens())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCompression_LLMError(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
client := &fakeLLMClient{
|
||||
err: context.DeadlineExceeded,
|
||||
}
|
||||
tpl := template.Template{
|
||||
MemoryCompressionTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}},
|
||||
},
|
||||
MaxTokens: 50,
|
||||
}
|
||||
r := newTestRunner(client, tpl)
|
||||
|
||||
msgs := []llm.Message{
|
||||
msg("system", "sys"),
|
||||
msg("user", "prompt"),
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, msg("assistant", strings.Repeat("word ", 100)))
|
||||
msgs = append(msgs, msg("tool", strings.Repeat("data ", 50)))
|
||||
}
|
||||
|
||||
got, err := r.runCompression(context.Background(), msgs, "test.go")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if len(got) != len(msgs) {
|
||||
t.Errorf("expected messages unchanged on error, got %d vs %d", len(got), len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCompression_EmptySummary(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
emptyStr := ""
|
||||
client := &fakeLLMClient{
|
||||
response: &llm.ChatResponse{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{Content: &emptyStr},
|
||||
}},
|
||||
},
|
||||
}
|
||||
tpl := template.Template{
|
||||
MemoryCompressionTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}},
|
||||
},
|
||||
MaxTokens: 50,
|
||||
}
|
||||
r := newTestRunner(client, tpl)
|
||||
|
||||
msgs := []llm.Message{
|
||||
msg("system", "sys"),
|
||||
msg("user", "prompt"),
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, msg("assistant", strings.Repeat("word ", 100)))
|
||||
msgs = append(msgs, msg("tool", strings.Repeat("data ", 50)))
|
||||
}
|
||||
|
||||
got, err := r.runCompression(context.Background(), msgs, "test.go")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != len(msgs) {
|
||||
t.Errorf("expected messages unchanged on empty summary, got %d vs %d", len(got), len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerAsyncCompression(t *testing.T) {
|
||||
t_tempDir = t.TempDir()
|
||||
summaryText := "async summary"
|
||||
client := &fakeLLMClient{
|
||||
response: &llm.ChatResponse{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{Content: &summaryText},
|
||||
}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 50, CompletionTokens: 10},
|
||||
},
|
||||
}
|
||||
tpl := template.Template{
|
||||
MemoryCompressionTask: template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}},
|
||||
},
|
||||
MaxTokens: 50,
|
||||
}
|
||||
r := newTestRunner(client, tpl)
|
||||
|
||||
msgs := []llm.Message{
|
||||
msg("system", "sys"),
|
||||
msg("user", "prompt"),
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, msg("assistant", strings.Repeat("word ", 100)))
|
||||
msgs = append(msgs, msg("tool", strings.Repeat("data ", 50)))
|
||||
}
|
||||
|
||||
r.triggerAsyncCompression(context.Background(), msgs, "test.go")
|
||||
|
||||
r.compressionMu.Lock()
|
||||
job := r.pendingJob
|
||||
r.compressionMu.Unlock()
|
||||
|
||||
if job == nil {
|
||||
t.Fatal("expected pendingJob to be set")
|
||||
}
|
||||
<-job.done
|
||||
|
||||
if job.rebuilt == nil {
|
||||
t.Fatal("expected rebuilt to be set after completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripMarkdownFences_AdditionalCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"markdown fence", "```markdown\ncontent\n```", "content"},
|
||||
{"xml fence", "```xml\n<tag/>\n```", "<tag/>"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := StripMarkdownFences(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
package scan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-code-review/open-code-review/internal/config/rules"
|
||||
"github.com/open-code-review/open-code-review/internal/config/template"
|
||||
"github.com/open-code-review/open-code-review/internal/llm"
|
||||
"github.com/open-code-review/open-code-review/internal/model"
|
||||
"github.com/open-code-review/open-code-review/internal/session"
|
||||
"github.com/open-code-review/open-code-review/internal/tool"
|
||||
)
|
||||
|
||||
// fakeScanClient is a minimal LLM client for scan tests that returns
|
||||
// pre-configured responses in sequence.
|
||||
type fakeScanClient struct {
|
||||
responses []*llm.ChatResponse
|
||||
idx int
|
||||
}
|
||||
|
||||
func (f *fakeScanClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
|
||||
if f.idx >= len(f.responses) {
|
||||
empty := ""
|
||||
return &llm.ChatResponse{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &empty}}},
|
||||
Usage: &llm.UsageInfo{},
|
||||
}, nil
|
||||
}
|
||||
resp := f.responses[f.idx]
|
||||
f.idx++
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// errorScanClient always returns an error.
|
||||
type errorScanClient struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *errorScanClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
|
||||
return nil, e.err
|
||||
}
|
||||
|
||||
func TestAgent_Getters(t *testing.T) {
|
||||
tpl := makeTemplateWithFullScan()
|
||||
a := newAgentForTest(t, tpl)
|
||||
a.items = []model.ScanItem{
|
||||
{Path: "a.go", Content: "package a", LineCount: 1},
|
||||
{Path: "b.go", Content: "package b", LineCount: 1},
|
||||
}
|
||||
|
||||
if a.ProjectSummary() != "" {
|
||||
t.Errorf("ProjectSummary() should be empty, got %q", a.ProjectSummary())
|
||||
}
|
||||
if a.Session() == nil {
|
||||
t.Error("Session() should not be nil")
|
||||
}
|
||||
if a.FilesReviewed() != 2 {
|
||||
t.Errorf("FilesReviewed() = %d, want 2", a.FilesReviewed())
|
||||
}
|
||||
diffs := a.Diffs()
|
||||
if len(diffs) != 2 {
|
||||
t.Fatalf("Diffs() len = %d, want 2", len(diffs))
|
||||
}
|
||||
if diffs[0].NewPath != "a.go" || diffs[1].NewPath != "b.go" {
|
||||
t.Errorf("Diffs paths wrong: %q, %q", diffs[0].NewPath, diffs[1].NewPath)
|
||||
}
|
||||
if a.TotalTokensUsed() != 0 {
|
||||
t.Errorf("TotalTokensUsed() = %d, want 0", a.TotalTokensUsed())
|
||||
}
|
||||
if len(a.ToolCalls()) != 0 {
|
||||
t.Errorf("ToolCalls() should be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupDiff(t *testing.T) {
|
||||
a := newAgentForTest(t, makeTemplateWithFullScan())
|
||||
a.items = []model.ScanItem{
|
||||
{Path: "main.go", Content: "package main\n", LineCount: 1},
|
||||
{Path: "lib.go", Content: "package lib\n", LineCount: 1},
|
||||
}
|
||||
|
||||
d := a.lookupDiff("main.go")
|
||||
if d == nil {
|
||||
t.Fatal("expected non-nil for existing path")
|
||||
}
|
||||
if d.NewPath != "main.go" {
|
||||
t.Errorf("NewPath = %q, want main.go", d.NewPath)
|
||||
}
|
||||
if d.NewFileContent != "package main\n" {
|
||||
t.Errorf("NewFileContent = %q", d.NewFileContent)
|
||||
}
|
||||
|
||||
if d2 := a.lookupDiff("nonexist.go"); d2 != nil {
|
||||
t.Errorf("expected nil for missing path, got %+v", d2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterScanItems(t *testing.T) {
|
||||
a := NewAgent(Args{
|
||||
Template: makeTemplateWithFullScan(),
|
||||
FileFilter: &rules.FileFilter{
|
||||
Exclude: []string{"vendor/**"},
|
||||
},
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
|
||||
items := []model.ScanItem{
|
||||
{Path: "main.go", Content: "package main\n", LineCount: 1},
|
||||
{Path: "image.png", Content: "", IsBinary: true},
|
||||
{Path: "vendor/dep.go", Content: "package dep\n", LineCount: 1},
|
||||
{Path: "handler.go", Content: "package h\n", LineCount: 1},
|
||||
}
|
||||
|
||||
kept := a.filterScanItems(items)
|
||||
if len(kept) != 2 {
|
||||
t.Fatalf("expected 2 kept, got %d", len(kept))
|
||||
}
|
||||
for _, it := range kept {
|
||||
if it.Path == "image.png" || it.Path == "vendor/dep.go" {
|
||||
t.Errorf("should not keep %s", it.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhyExcluded_AllBranches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item model.ScanItem
|
||||
filter *rules.FileFilter
|
||||
want model.ExcludeReason
|
||||
}{
|
||||
{
|
||||
name: "binary",
|
||||
item: model.ScanItem{Path: "img.png", IsBinary: true},
|
||||
want: model.ExcludeBinary,
|
||||
},
|
||||
{
|
||||
name: "user exclude",
|
||||
item: model.ScanItem{Path: "vendor/dep.go", Content: "x"},
|
||||
filter: &rules.FileFilter{Exclude: []string{"vendor/**"}},
|
||||
want: model.ExcludeUserRule,
|
||||
},
|
||||
{
|
||||
name: "unsupported extension",
|
||||
item: model.ScanItem{Path: "data.xyz123"},
|
||||
want: model.ExcludeExtension,
|
||||
},
|
||||
{
|
||||
name: "user include match passes",
|
||||
item: model.ScanItem{Path: "src/main.go", Content: "x"},
|
||||
filter: &rules.FileFilter{Include: []string{"src/**"}},
|
||||
want: model.ExcludeNone,
|
||||
},
|
||||
{
|
||||
name: "default excluded path",
|
||||
item: model.ScanItem{Path: "pkg/handler_test.go", Content: "x"},
|
||||
want: model.ExcludeDefaultPath,
|
||||
},
|
||||
{
|
||||
name: "allowed file passes",
|
||||
item: model.ScanItem{Path: "main.go", Content: "x"},
|
||||
want: model.ExcludeNone,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := NewAgent(Args{
|
||||
Template: makeTemplateWithFullScan(),
|
||||
FileFilter: tt.filter,
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
got := a.whyExcluded(tt.item)
|
||||
if got != tt.want {
|
||||
t.Errorf("whyExcluded(%q) = %q, want %q", tt.item.Path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtFromPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"main.go", ".go"},
|
||||
{"src/lib/utils.ts", ".ts"},
|
||||
{"Makefile", ""},
|
||||
{".gitignore", ""},
|
||||
{"path/to/FILE.Go", ".go"},
|
||||
{"a/b/c.Test.JS", ".js"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := extFromPath(tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("extFromPath(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeRunPlan_Success(t *testing.T) {
|
||||
planJSON := `{"summary":"check error handling","checkpoints":[{"focus":"nil check","lines":"10-20","why":"potential NPE"}]}`
|
||||
client := &fakeScanClient{
|
||||
responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &planJSON}}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 100, CompletionTokens: 50},
|
||||
}},
|
||||
}
|
||||
|
||||
tpl := makeTemplateWithFullScan()
|
||||
tpl.PlanTask = &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Plan for {{current_file_path}}: {{file_content}}"},
|
||||
},
|
||||
}
|
||||
|
||||
a := NewAgent(Args{
|
||||
Template: tpl,
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
CommentCollector: tool.NewCommentCollector(),
|
||||
Tools: tool.NewRegistry(),
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
a.currentDate = "2026-06-26 10:00"
|
||||
|
||||
it := model.ScanItem{Path: "handler.go", Content: "package h\nfunc Handle() {}\n"}
|
||||
guidance := a.maybeRunPlan(context.Background(), it, "rule-text")
|
||||
|
||||
if !strings.Contains(guidance, "nil check") {
|
||||
t.Errorf("guidance missing checkpoint, got: %q", guidance)
|
||||
}
|
||||
if !strings.Contains(guidance, "check error handling") {
|
||||
t.Errorf("guidance missing summary, got: %q", guidance)
|
||||
}
|
||||
if a.TotalTokensUsed() != 150 {
|
||||
t.Errorf("TotalTokensUsed() = %d, want 150", a.TotalTokensUsed())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeRunProjectSummary_Success(t *testing.T) {
|
||||
summaryText := "Overall the code has good error handling but lacks input validation."
|
||||
client := &fakeScanClient{
|
||||
responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &summaryText}}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 200, CompletionTokens: 80},
|
||||
}},
|
||||
}
|
||||
|
||||
tpl := makeTemplateWithFullScan()
|
||||
tpl.ProjectSummaryTask = &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Summarize {{comment_count}} comments across {{file_count}} files:\n{{all_comments}}"},
|
||||
},
|
||||
}
|
||||
|
||||
a := NewAgent(Args{
|
||||
Template: tpl,
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
CommentCollector: tool.NewCommentCollector(),
|
||||
Tools: tool.NewRegistry(),
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
|
||||
comments := []model.LlmComment{
|
||||
{Path: "a.go", Content: "missing error check"},
|
||||
{Path: "b.go", Content: "no input validation"},
|
||||
}
|
||||
|
||||
a.maybeRunProjectSummary(context.Background(), comments)
|
||||
|
||||
if a.ProjectSummary() != summaryText {
|
||||
t.Errorf("ProjectSummary() = %q, want %q", a.ProjectSummary(), summaryText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeRunProjectSummary_SkipWhenDisabled(t *testing.T) {
|
||||
a := newAgentForTest(t, makeTemplateWithFullScan())
|
||||
a.maybeRunProjectSummary(context.Background(), []model.LlmComment{{Path: "a.go", Content: "x"}})
|
||||
if a.ProjectSummary() != "" {
|
||||
t.Error("summary should be empty when template has no ProjectSummaryTask")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeRunProjectSummary_SkipWhenNoComments(t *testing.T) {
|
||||
tpl := makeTemplateWithFullScan()
|
||||
tpl.ProjectSummaryTask = &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "{{all_comments}}"}},
|
||||
}
|
||||
a := NewAgent(Args{
|
||||
Template: tpl,
|
||||
LLMClient: &fakeScanClient{},
|
||||
Model: "test",
|
||||
CommentCollector: tool.NewCommentCollector(),
|
||||
Tools: tool.NewRegistry(),
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
|
||||
a.maybeRunProjectSummary(context.Background(), nil)
|
||||
if a.ProjectSummary() != "" {
|
||||
t.Error("summary should be empty when no comments")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeRunDedup_Success(t *testing.T) {
|
||||
dedupResp := `{"groups":[{"members":["c-0","c-1"],"merged_content":"combined finding"},{"members":["c-2"]}]}`
|
||||
client := &fakeScanClient{
|
||||
responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &dedupResp}}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 80, CompletionTokens: 30},
|
||||
}},
|
||||
}
|
||||
|
||||
tpl := makeTemplateWithFullScan()
|
||||
tpl.DedupTask = &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Dedup: {{batch_comments}}"},
|
||||
},
|
||||
}
|
||||
|
||||
collector := tool.NewCommentCollector()
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "duplicate finding 1"})
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "duplicate finding 2"})
|
||||
collector.Add(model.LlmComment{Path: "b.go", Content: "unique finding"})
|
||||
|
||||
a := NewAgent(Args{
|
||||
Template: tpl,
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
CommentCollector: collector,
|
||||
Tools: tool.NewRegistry(),
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
|
||||
batchStart := 0
|
||||
a.maybeRunDedup(context.Background(), 0, batchStart)
|
||||
|
||||
comments := collector.Comments()
|
||||
if len(comments) != 2 {
|
||||
t.Fatalf("expected 2 deduped comments, got %d", len(comments))
|
||||
}
|
||||
if comments[0].Content != "combined finding" {
|
||||
t.Errorf("merged comment content = %q, want 'combined finding'", comments[0].Content)
|
||||
}
|
||||
if comments[1].Content != "unique finding" {
|
||||
t.Errorf("second comment = %q, want 'unique finding'", comments[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeRunDedup_SkipWhenDisabled(t *testing.T) {
|
||||
collector := tool.NewCommentCollector()
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "c1"})
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "c2"})
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "c3"})
|
||||
|
||||
a := newAgentForTest(t, makeTemplateWithFullScan())
|
||||
a.args.CommentCollector = collector
|
||||
a.maybeRunDedup(context.Background(), 0, 0)
|
||||
|
||||
if len(collector.Comments()) != 3 {
|
||||
t.Errorf("comments should be unchanged when dedup is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeRunDedup_SkipWhenTooFewComments(t *testing.T) {
|
||||
tpl := makeTemplateWithFullScan()
|
||||
tpl.DedupTask = &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "{{batch_comments}}"}},
|
||||
}
|
||||
tpl.DedupMinComments = 5
|
||||
|
||||
collector := tool.NewCommentCollector()
|
||||
collector.Add(model.LlmComment{Path: "a.go", Content: "only one"})
|
||||
|
||||
a := NewAgent(Args{
|
||||
Template: tpl,
|
||||
LLMClient: &fakeScanClient{},
|
||||
Model: "test",
|
||||
CommentCollector: collector,
|
||||
Tools: tool.NewRegistry(),
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
|
||||
a.maybeRunDedup(context.Background(), 0, 0)
|
||||
if len(collector.Comments()) != 1 {
|
||||
t.Error("comments should be unchanged when below min threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSubtask_Success(t *testing.T) {
|
||||
doneContent := ""
|
||||
client := &fakeScanClient{
|
||||
responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{
|
||||
Content: &doneContent,
|
||||
ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Type: "function",
|
||||
Function: llm.FunctionCall{Name: "task_done", Arguments: "{}"},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 50, CompletionTokens: 20},
|
||||
}},
|
||||
}
|
||||
|
||||
tpl := makeTemplateWithFullScan()
|
||||
tpl.MaxTokens = 100000
|
||||
|
||||
a := NewAgent(Args{
|
||||
Template: tpl,
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
CommentCollector: tool.NewCommentCollector(),
|
||||
Tools: tool.NewRegistry(),
|
||||
SkipPlan: true,
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
a.currentDate = "2026-06-26 10:00"
|
||||
|
||||
it := model.ScanItem{Path: "main.go", Content: "package main\n", LineCount: 1}
|
||||
err := a.executeSubtask(context.Background(), it)
|
||||
if err != nil {
|
||||
t.Fatalf("executeSubtask: %v", err)
|
||||
}
|
||||
if a.TotalTokensUsed() != 70 {
|
||||
t.Errorf("TotalTokensUsed() = %d, want 70", a.TotalTokensUsed())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSubtask_WithPlan(t *testing.T) {
|
||||
planJSON := `{"summary":"focus on error paths","checkpoints":[]}`
|
||||
doneContent := ""
|
||||
client := &fakeScanClient{
|
||||
responses: []*llm.ChatResponse{
|
||||
{
|
||||
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &planJSON}}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 30, CompletionTokens: 20},
|
||||
},
|
||||
{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{
|
||||
Content: &doneContent,
|
||||
ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Type: "function",
|
||||
Function: llm.FunctionCall{Name: "task_done", Arguments: "{}"},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 60, CompletionTokens: 30},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tpl := makeTemplateWithFullScan()
|
||||
tpl.MaxTokens = 100000
|
||||
tpl.PlanTask = &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{
|
||||
{Role: "user", Content: "Plan {{current_file_path}}: {{file_content}}"},
|
||||
},
|
||||
}
|
||||
|
||||
a := NewAgent(Args{
|
||||
Template: tpl,
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
CommentCollector: tool.NewCommentCollector(),
|
||||
Tools: tool.NewRegistry(),
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
a.currentDate = "2026-06-26 10:00"
|
||||
|
||||
it := model.ScanItem{Path: "handler.go", Content: "package h\nfunc Handle() error { return nil }\n", LineCount: 2}
|
||||
err := a.executeSubtask(context.Background(), it)
|
||||
if err != nil {
|
||||
t.Fatalf("executeSubtask: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSubtask_ContextCancelled(t *testing.T) {
|
||||
a := newAgentForTest(t, makeTemplateWithFullScan())
|
||||
a.currentDate = "2026-06-26"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := a.executeSubtask(ctx, model.ScanItem{Path: "a.go", Content: "x", LineCount: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_EmptyTemplate(t *testing.T) {
|
||||
a := NewAgent(Args{
|
||||
Template: template.ScanTemplate{},
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
_, err := a.Run(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "MAIN_TASK is missing") {
|
||||
t.Errorf("expected MAIN_TASK error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_NoReviewableFiles(t *testing.T) {
|
||||
repo := initTestRepo(t)
|
||||
writeFile(t, repo, "img.png", []byte{0x89, 0x50, 0x4e, 0x47})
|
||||
gitCommit(t, repo, "binary")
|
||||
|
||||
a := NewAgent(Args{
|
||||
RepoDir: repo,
|
||||
Template: makeTemplateWithFullScan(),
|
||||
LLMClient: &fakeScanClient{},
|
||||
CommentCollector: tool.NewCommentCollector(),
|
||||
Tools: tool.NewRegistry(),
|
||||
SkipPlan: true,
|
||||
SkipDedup: true,
|
||||
SkipSummary: true,
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
|
||||
comments, err := a.Run(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(comments) != 0 {
|
||||
t.Errorf("expected 0 comments for binary-only repo, got %d", len(comments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_FullPipeline(t *testing.T) {
|
||||
repo := initTestRepo(t)
|
||||
writeFile(t, repo, "main.go", []byte("package main\nfunc main() {}\n"))
|
||||
gitCommit(t, repo, "init")
|
||||
|
||||
doneContent := ""
|
||||
client := &fakeScanClient{
|
||||
responses: []*llm.ChatResponse{{
|
||||
Choices: []llm.Choice{{
|
||||
Message: llm.ResponseMessage{
|
||||
Content: &doneContent,
|
||||
ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Type: "function",
|
||||
Function: llm.FunctionCall{Name: "task_done", Arguments: "{}"},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
Usage: &llm.UsageInfo{PromptTokens: 100, CompletionTokens: 50},
|
||||
}},
|
||||
}
|
||||
|
||||
tpl := makeTemplateWithFullScan()
|
||||
tpl.MaxTokens = 100000
|
||||
|
||||
a := NewAgent(Args{
|
||||
RepoDir: repo,
|
||||
Template: tpl,
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
CommentCollector: tool.NewCommentCollector(),
|
||||
Tools: tool.NewRegistry(),
|
||||
MaxConcurrency: 1,
|
||||
SkipPlan: true,
|
||||
SkipDedup: true,
|
||||
SkipSummary: true,
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
|
||||
comments, err := a.Run(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
_ = comments
|
||||
|
||||
if a.FilesReviewed() != 1 {
|
||||
t.Errorf("FilesReviewed = %d, want 1", a.FilesReviewed())
|
||||
}
|
||||
if a.TotalTokensUsed() == 0 {
|
||||
t.Error("expected non-zero tokens after run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubtasks_AllFailed(t *testing.T) {
|
||||
client := &errorScanClient{err: context.DeadlineExceeded}
|
||||
|
||||
tpl := makeTemplateWithFullScan()
|
||||
tpl.MaxTokens = 100000
|
||||
|
||||
a := NewAgent(Args{
|
||||
Template: tpl,
|
||||
LLMClient: client,
|
||||
Model: "test",
|
||||
CommentCollector: tool.NewCommentCollector(),
|
||||
Tools: tool.NewRegistry(),
|
||||
MaxConcurrency: 1,
|
||||
SkipPlan: true,
|
||||
SkipDedup: true,
|
||||
SkipSummary: true,
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
a.items = []model.ScanItem{{Path: "a.go", Content: "x", LineCount: 1}}
|
||||
a.currentDate = "2026-06-26"
|
||||
a.args.Tools.Freeze()
|
||||
|
||||
_, err := a.dispatchSubtasks(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "failed") {
|
||||
t.Errorf("expected all-failed error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhaseEnabled(t *testing.T) {
|
||||
tpl := makeTemplateWithFullScan()
|
||||
a := newAgentForTest(t, tpl)
|
||||
|
||||
if a.planEnabled() {
|
||||
t.Error("planEnabled should be false without PlanTask")
|
||||
}
|
||||
if a.dedupEnabled() {
|
||||
t.Error("dedupEnabled should be false without DedupTask")
|
||||
}
|
||||
if a.summaryEnabled() {
|
||||
t.Error("summaryEnabled should be false without ProjectSummaryTask")
|
||||
}
|
||||
|
||||
tpl.PlanTask = &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "plan"}},
|
||||
}
|
||||
tpl.DedupTask = &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "dedup"}},
|
||||
}
|
||||
tpl.ProjectSummaryTask = &template.LlmConversation{
|
||||
Messages: []template.ChatMessage{{Role: "user", Content: "summary"}},
|
||||
}
|
||||
|
||||
a2 := NewAgent(Args{
|
||||
Template: tpl,
|
||||
CommentCollector: tool.NewCommentCollector(),
|
||||
Tools: tool.NewRegistry(),
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
if !a2.planEnabled() {
|
||||
t.Error("planEnabled should be true with PlanTask")
|
||||
}
|
||||
if !a2.dedupEnabled() {
|
||||
t.Error("dedupEnabled should be true with DedupTask")
|
||||
}
|
||||
if !a2.summaryEnabled() {
|
||||
t.Error("summaryEnabled should be true with ProjectSummaryTask")
|
||||
}
|
||||
|
||||
a3 := NewAgent(Args{
|
||||
Template: tpl,
|
||||
CommentCollector: tool.NewCommentCollector(),
|
||||
Tools: tool.NewRegistry(),
|
||||
SkipPlan: true,
|
||||
SkipDedup: true,
|
||||
SkipSummary: true,
|
||||
Session: session.New(t.TempDir(), "main", "test", session.SessionOptions{
|
||||
ReviewMode: session.ReviewModeFullScan,
|
||||
}),
|
||||
})
|
||||
if a3.planEnabled() {
|
||||
t.Error("planEnabled should be false with SkipPlan")
|
||||
}
|
||||
if a3.dedupEnabled() {
|
||||
t.Error("dedupEnabled should be false with SkipDedup")
|
||||
}
|
||||
if a3.summaryEnabled() {
|
||||
t.Error("summaryEnabled should be false with SkipSummary")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user