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:
kite
2026-06-27 00:15:28 +08:00
parent 110e5284fb
commit 453c4f9c76
5 changed files with 2211 additions and 0 deletions
+633
View File
@@ -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)
}
}
+78
View File
@@ -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()
}
+326
View File
@@ -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)
}
}
+471
View File
@@ -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)
}
})
}
}
+703
View File
@@ -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")
}
}