mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-09-14 19:59:52 +08:00
9cd9297305
* refactor(llmloop): rename RunPerFile to RunMainTask The loop stopped being per-file when review switched from one subtask per file to one subtask per group: internal/agent passes a groupKey that may name several files, while internal/scan still passes a single path. The name asserted a granularity only one of the two callers has. RunMainTask names what the function actually drives — one MAIN_TASK conversation — matching the vocabulary already in place around it (MainLoopStop, the main.loop span, session.MainTask). RunSubtask would have been a second misfit, since review calls this once per review round and one subtask spans several rounds. The newPath parameter is renamed taskKey along the whole private chain (runGraceRound, executeToolCall, addNextMessage, runCompression, triggerAsyncCompression, requestCtx). Deps.NewRequestMeta keeps filePath: the session layer still spells that slot "file path" in its persisted records, and requestCtx now documents the seam instead of hiding it. ToolFailureDetail.FilePath is where a group key becomes externally visible — it reaches --format json as file_path — so the field now says so rather than letting a consumer assume it parses as one path. Two comments claimed executeToolCall overrides a comment's path with the loop's path argument. It has always been a fallback that applies only when the model named no path, and the difference decides whether a grouped review files its comments under the right files, so both now describe the fallback. Pure rename plus comment edits; no behavior change. * refactor(cli): retire per-file naming left over from grouped review perFileTimeout was the last holdout of the old granularity in the CLI options: its flag is --timeout, its help already said "concurrent task timeout", and it feeds Args.ConcurrentTaskTimeout. Only the field name still claimed a per-file deadline it never set. Two review help strings also outlived the change to grouped subtasks, and disagreed with cli-reference.md, which already documents both as per-group: --concurrency dispatches file groups, and --max-tokens caps a group's prompt. The scan command keeps its per-file wording, where one subtask is still one file. Same for six agent comments that describe per-group work as per-file — the budget look-ahead, the dispatch step and its function doc, the only deadline classifyItemError can see, and the goroutines allDiffs is read from — plus Run's pipeline summary, which omitted the grouping step altogether. Granularity that really is per-file stays as it is, and the two are easy to confuse: filterLargeDiffs measures each file's diff against the token ceiling on its own, review items and their status badges are per file, and a group's prompt embeds one XML element per file. The comment in review_cmd.go now names filterLargeDiffs so the distinction is checkable rather than a matter of trusting the adjective. The estimator keeps both its name avgMainRoundsPerFile and its arithmetic: it still bills every file for its own MAIN_TASK rounds, so a group of files is over-estimated. That is a costing bug rather than a naming one and needs its own change; only estimateDiffFileTokens' doc comment is corrected here, since the look-ahead consuming it sums per group.
764 lines
22 KiB
Go
764 lines
22 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 alibaba/open-code-review Contributors
|
|
|
|
package llmloop
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/alibaba/open-code-review/internal/config/template"
|
|
"github.com/alibaba/open-code-review/internal/llm"
|
|
"github.com/alibaba/open-code-review/internal/model"
|
|
"github.com/alibaba/open-code-review/internal/session"
|
|
"github.com/alibaba/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
|
|
}
|
|
|
|
// gatedLLMClient signals when a request starts, then blocks until release
|
|
// is closed. Requests whose context is canceled while blocked are counted,
|
|
// letting tests assert that no in-flight compression was aborted.
|
|
type gatedLLMClient struct {
|
|
started chan struct{}
|
|
release chan struct{}
|
|
canceled atomic.Int64
|
|
response *llm.ChatResponse
|
|
}
|
|
|
|
func (g *gatedLLMClient) CompletionsWithCtx(ctx context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
|
|
g.started <- struct{}{}
|
|
select {
|
|
case <-g.release:
|
|
return g.response, nil
|
|
case <-ctx.Done():
|
|
g.canceled.Add(1)
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
// concurrentFakeClient is goroutine-safe and distinguishes compression
|
|
// requests (no tools attached) from main-loop requests (tools attached),
|
|
// mirroring how runCompression and RunMainTask build their ChatRequests.
|
|
type concurrentFakeClient struct {
|
|
compressionCalls atomic.Int64
|
|
}
|
|
|
|
func (c *concurrentFakeClient) CompletionsWithCtx(_ context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
|
|
if len(req.Tools) == 0 {
|
|
c.compressionCalls.Add(1)
|
|
summary := "compressed summary"
|
|
return &llm.ChatResponse{
|
|
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &summary}}},
|
|
}, nil
|
|
}
|
|
content := strings.Repeat("word ", 650)
|
|
return &llm.ChatResponse{
|
|
Choices: []llm.Choice{{
|
|
Message: llm.ResponseMessage{
|
|
Content: &content,
|
|
ToolCalls: []llm.ToolCall{{
|
|
ID: "call_1",
|
|
Type: "function",
|
|
Function: llm.FunctionCall{
|
|
Name: "file_read",
|
|
Arguments: `{"path":"f.go"}`,
|
|
},
|
|
}},
|
|
},
|
|
}},
|
|
}, nil
|
|
}
|
|
|
|
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{})
|
|
if got := r.recordToolCall("file_read"); got != 1 {
|
|
t.Errorf("first tool call number = %d, want 1", got)
|
|
}
|
|
if got := r.recordToolCall("file_read"); got != 2 {
|
|
t.Errorf("second tool call number = %d, want 2", got)
|
|
}
|
|
if got := r.recordToolCall("code_comment"); got != 3 {
|
|
t.Errorf("third tool call number = %d, want 3", got)
|
|
}
|
|
|
|
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 TestToolFailures_AreOrderedAndSnapshotIsolated(t *testing.T) {
|
|
r := NewRunner(Deps{})
|
|
r.recordToolFailure(2, "tool_b", "b.go", "second", nil, `{}`, 0)
|
|
r.recordToolFailure(1, "tool_a", "a.go", "first", nil, `{}`, 0)
|
|
|
|
failures := r.ToolFailures()
|
|
if len(failures) != 2 || failures[0].ToolCallNumber != 1 || failures[1].ToolCallNumber != 2 {
|
|
t.Fatalf("ToolFailures() = %+v, want call numbers 1, 2", failures)
|
|
}
|
|
failures[0].Error = "mutated"
|
|
failures[0].Arguments = "mutated"
|
|
|
|
again := r.ToolFailures()
|
|
if again[0].Error != "first" || again[0].Arguments != `{}` {
|
|
t.Errorf("ToolFailures snapshot mutated internal state: %+v", again[0])
|
|
}
|
|
}
|
|
|
|
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(&compressionState{})
|
|
}
|
|
|
|
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 },
|
|
}
|
|
st := &compressionState{pendingJob: job}
|
|
r.cancelPendingCompression(st)
|
|
|
|
if !cancelled {
|
|
t.Error("cancel was not called")
|
|
}
|
|
if st.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(&compressionState{}, &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() {},
|
|
}
|
|
st := &compressionState{pendingJob: job}
|
|
|
|
msgs := []llm.Message{msg("user", "hi")}
|
|
if r.tryApplyPendingCompression(st, &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)
|
|
st := &compressionState{pendingJob: job}
|
|
|
|
msgs := []llm.Message{
|
|
msg("system", "sys"),
|
|
msg("user", "orig"),
|
|
msg("assistant", "resp"),
|
|
msg("tool", "appended after snapshot"),
|
|
}
|
|
applied := r.tryApplyPendingCompression(st, &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 st.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)
|
|
st := &compressionState{pendingJob: job}
|
|
|
|
msgs := []llm.Message{msg("user", "hi")}
|
|
applied := r.tryApplyPendingCompression(st, &msgs)
|
|
if applied {
|
|
t.Error("expected false when rebuilt is nil (compression failed)")
|
|
}
|
|
if st.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)))
|
|
}
|
|
|
|
st := &compressionState{}
|
|
r.triggerAsyncCompression(context.Background(), st, msgs, "test.go")
|
|
|
|
st.mu.Lock()
|
|
job := st.pendingJob
|
|
st.mu.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 TestCompression_CrossFileIsolation(t *testing.T) {
|
|
t_tempDir = t.TempDir()
|
|
summary := "summary A"
|
|
gated := &gatedLLMClient{
|
|
started: make(chan struct{}, 1),
|
|
release: make(chan struct{}),
|
|
response: &llm.ChatResponse{
|
|
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &summary}}},
|
|
},
|
|
}
|
|
tpl := template.Template{
|
|
MemoryCompressionTask: template.LlmConversation{
|
|
Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}},
|
|
},
|
|
MaxTokens: 50,
|
|
}
|
|
r := newTestRunner(gated, tpl)
|
|
|
|
msgsA := []llm.Message{msg("system", "sys"), msg("user", "file A prompt")}
|
|
for i := 0; i < 10; i++ {
|
|
msgsA = append(msgsA, msg("assistant", strings.Repeat("word ", 100)))
|
|
msgsA = append(msgsA, msg("tool", strings.Repeat("data ", 50)))
|
|
}
|
|
|
|
// File A starts an async compression; its LLM request is now in flight.
|
|
stA := &compressionState{}
|
|
r.triggerAsyncCompression(context.Background(), stA, msgsA, "a.go")
|
|
<-gated.started
|
|
|
|
// File B, sharing the Runner, cancels and tries to consume a pending
|
|
// job. Neither operation may touch file A's in-flight compression.
|
|
stB := &compressionState{}
|
|
msgsB := []llm.Message{msg("system", "sys"), msg("user", "file B prompt")}
|
|
wantB := msgsB[1].ExtractText()
|
|
|
|
r.cancelPendingCompression(stB)
|
|
if r.tryApplyPendingCompression(stB, &msgsB) {
|
|
t.Error("file B must not consume a compression job it never started")
|
|
}
|
|
if len(msgsB) != 2 || msgsB[1].ExtractText() != wantB {
|
|
t.Error("file B's messages must be unchanged")
|
|
}
|
|
|
|
stA.mu.Lock()
|
|
pending := stA.pendingJob
|
|
stA.mu.Unlock()
|
|
if pending == nil {
|
|
t.Fatal("file A's job must still be pending after file B's cancel")
|
|
}
|
|
if got := gated.canceled.Load(); got != 0 {
|
|
t.Errorf("in-flight compression requests canceled = %d, want 0", got)
|
|
}
|
|
|
|
// A second trigger while a job is pending must be a no-op, not a
|
|
// replacement — the in-flight job stays the owner of the slot.
|
|
r.triggerAsyncCompression(context.Background(), stA, msgsA, "a.go")
|
|
stA.mu.Lock()
|
|
replaced := stA.pendingJob != pending
|
|
stA.mu.Unlock()
|
|
if replaced {
|
|
t.Error("second trigger must not replace the in-flight job")
|
|
}
|
|
|
|
// Let A's compression finish.
|
|
close(gated.release)
|
|
<-pending.done
|
|
|
|
t.Run("SummaryAppliesOnlyToOwningConversation", func(t *testing.T) {
|
|
if r.tryApplyPendingCompression(stB, &msgsB) {
|
|
t.Error("file B must not receive file A's summary")
|
|
}
|
|
if len(msgsB) != 2 || msgsB[1].ExtractText() != wantB {
|
|
t.Error("file B's messages must be byte-identical")
|
|
}
|
|
|
|
// A message appended after the snapshot must survive the apply.
|
|
msgsA = append(msgsA, msg("tool", "post-snapshot"))
|
|
if !r.tryApplyPendingCompression(stA, &msgsA) {
|
|
t.Fatal("file A should apply its own completed compression")
|
|
}
|
|
if !strings.Contains(msgsA[1].ExtractText(), "<previous_review_summary>") {
|
|
t.Errorf("file A's rebuilt prompt should embed the summary, got: %s", msgsA[1].ExtractText())
|
|
}
|
|
if last := msgsA[len(msgsA)-1].ExtractText(); last != "post-snapshot" {
|
|
t.Errorf("post-snapshot suffix lost, last message = %q", last)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAddNextMessage_NoStartThenCancelSameCall(t *testing.T) {
|
|
t_tempDir = t.TempDir()
|
|
summary := "compressed summary"
|
|
// release is pre-closed: requests complete instantly, and canceled
|
|
// only increments if a compression context was aborted mid-flight.
|
|
gated := &gatedLLMClient{
|
|
started: make(chan struct{}, 8),
|
|
release: make(chan struct{}),
|
|
response: &llm.ChatResponse{
|
|
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &summary}}},
|
|
},
|
|
}
|
|
close(gated.release)
|
|
tpl := template.Template{
|
|
MemoryCompressionTask: template.LlmConversation{
|
|
Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}},
|
|
},
|
|
MaxTokens: 1000, // soft 600, warn 800
|
|
}
|
|
r := newTestRunner(gated, tpl)
|
|
|
|
// Pre-append the conversation sits between the soft and warning
|
|
// thresholds; the appended round pushes it over the warning threshold —
|
|
// the same-file case from #384 where a job was started and then
|
|
// cancelled within one update.
|
|
msgs := []llm.Message{
|
|
msg("system", "sys"),
|
|
msg("user", "prompt"),
|
|
msg("assistant", strings.Repeat("word ", 400)),
|
|
msg("tool", strings.Repeat("data ", 290)),
|
|
}
|
|
calls := []llm.ToolCall{{
|
|
ID: "c1",
|
|
Type: "function",
|
|
Function: llm.FunctionCall{Name: "file_read", Arguments: `{"path":"f.go"}`},
|
|
}}
|
|
results := []tool.ToolCallResult{{
|
|
ToolCallID: "c1",
|
|
Name: "file_read",
|
|
Result: strings.Repeat("data ", 100),
|
|
}}
|
|
|
|
st := &compressionState{}
|
|
ok := r.addNextMessage(context.Background(), strings.Repeat("word ", 200), calls, llm.NativeTurn{}, "", results, &msgs, "f.go", st)
|
|
|
|
if !ok {
|
|
t.Error("expected true: sync compression should bring the count under the warning threshold")
|
|
}
|
|
if got := gated.canceled.Load(); got != 0 {
|
|
t.Errorf("compression requests canceled mid-flight = %d, want 0", got)
|
|
}
|
|
st.mu.Lock()
|
|
pending := st.pendingJob
|
|
st.mu.Unlock()
|
|
if pending != nil {
|
|
t.Error("no async job should be pending after a sync compression in the same call")
|
|
}
|
|
if len(gated.started) != 1 {
|
|
t.Errorf("compression LLM calls = %d, want exactly 1 (the sync one)", len(gated.started))
|
|
}
|
|
if !strings.Contains(msgs[1].ExtractText(), "<previous_review_summary>") {
|
|
t.Errorf("sync compression should have rebuilt the prompt, got: %s", msgs[1].ExtractText())
|
|
}
|
|
}
|
|
|
|
func TestRunMainTask_ConcurrentFilesCompression_Race(t *testing.T) {
|
|
t_tempDir = t.TempDir()
|
|
tpl := template.Template{
|
|
MemoryCompressionTask: template.LlmConversation{
|
|
Messages: []template.ChatMessage{{Role: "user", Content: "{{context}}"}},
|
|
},
|
|
MaxTokens: 1000,
|
|
MaxToolRequestTimes: 5,
|
|
}
|
|
reg := tool.NewRegistry()
|
|
reg.Register(&fakeFileReadProvider{result: "package main\n"})
|
|
client := &concurrentFakeClient{}
|
|
r := NewRunner(Deps{
|
|
LLMClient: client,
|
|
Model: "test-model",
|
|
Template: tpl,
|
|
Tools: reg,
|
|
CommentCollector: tool.NewCommentCollector(),
|
|
// MainToolDefs must be non-empty: the fake client classifies a
|
|
// request with no tools as a compression request, mirroring how
|
|
// RunMainTask and runCompression build their ChatRequests.
|
|
MainToolDefs: []llm.ToolDef{{Type: "function", Function: llm.FunctionDef{Name: "file_read"}}},
|
|
Session: session.New(t_tempDir, "main", "test-model", session.SessionOptions{ReviewMode: "diff"}),
|
|
})
|
|
|
|
// Four files reviewed concurrently over one shared Runner, like the
|
|
// agent/scan fan-out. Each conversation crosses the soft and warning
|
|
// thresholds repeatedly; the real assertion is -race cleanliness.
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, 4)
|
|
for i := 0; i < 4; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
msgs := []llm.Message{msg("system", "sys"), msg("user", "review this file")}
|
|
_, _, err := r.RunMainTask(context.Background(), msgs, fmt.Sprintf("f%d.go", i))
|
|
errs[i] = err
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Errorf("file %d: RunMainTask: %v", i, err)
|
|
}
|
|
}
|
|
// Guard against the test going vacuous: if no compression request was
|
|
// ever issued, the conversations never crossed the thresholds and the
|
|
// -race assertion proved nothing.
|
|
if client.compressionCalls.Load() == 0 {
|
|
t.Error("no compression requests were made; the compression path was not exercised")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|