mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-09-14 19:59:52 +08:00
235 lines
7.7 KiB
Go
235 lines
7.7 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 alibaba/open-code-review Contributors
|
|
|
|
package llmloop
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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/stdout"
|
|
"github.com/alibaba/open-code-review/internal/tool"
|
|
)
|
|
|
|
// erroringProvider is a dynamic tool provider whose Execute always fails.
|
|
type erroringProvider struct {
|
|
tool tool.Tool
|
|
}
|
|
|
|
func (p *erroringProvider) Tool() tool.Tool { return p.tool }
|
|
func (p *erroringProvider) Execute(_ context.Context, _ map[string]any) (string, error) {
|
|
return "", errors.New("boom")
|
|
}
|
|
|
|
// TestExecuteToolCall_DynamicNotRegistered covers the path where the LLM calls
|
|
// a name that is neither a built-in tool nor present in the registry.
|
|
func TestExecuteToolCall_DynamicNotRegistered(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Freeze()
|
|
r := NewRunner(Deps{Tools: reg, CommentCollector: tool.NewCommentCollector()})
|
|
|
|
cp := r.executeToolCall(context.Background(), "file.go", llm.ToolCall{
|
|
Function: llm.FunctionCall{Name: "totally_unknown", Arguments: `{}`},
|
|
}, nil, "")
|
|
|
|
if cp.Data != tool.NotAvailableMsg {
|
|
t.Errorf("cp.Data = %q, want NotAvailableMsg", cp.Data)
|
|
}
|
|
}
|
|
|
|
// TestExecuteToolCall_DynamicExecuteError verifies newly registered tools use
|
|
// the common failure-observation path without tool-specific instrumentation.
|
|
func TestExecuteToolCall_DynamicExecuteError(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Register(&erroringProvider{tool: tool.Dynamic("dyn_fail")})
|
|
reg.Freeze()
|
|
r := NewRunner(Deps{Tools: reg, CommentCollector: tool.NewCommentCollector()})
|
|
|
|
rec := &session.TaskRecord{}
|
|
cp := r.executeToolCall(context.Background(), "file.go", llm.ToolCall{
|
|
Function: llm.FunctionCall{Name: "dyn_fail", Arguments: `{"query":"needle"}`},
|
|
}, rec, "")
|
|
|
|
if !strings.Contains(cp.Data, "Error executing tool dyn_fail") {
|
|
t.Errorf("cp.Data = %q, want execute-error message", cp.Data)
|
|
}
|
|
failures := r.ToolFailures()
|
|
if len(failures) != 1 {
|
|
t.Fatalf("ToolFailures() = %+v, want one failure", failures)
|
|
}
|
|
failure := failures[0]
|
|
if failure.ToolCallNumber != 1 || failure.ToolName != "dyn_fail" || failure.FilePath != "file.go" {
|
|
t.Errorf("failure identity = %+v", failure)
|
|
}
|
|
if failure.Arguments != `{"query":"needle"}` {
|
|
t.Errorf("failure arguments = %q, want raw tool arguments", failure.Arguments)
|
|
}
|
|
if failure.Error != "boom" {
|
|
t.Errorf("failure details = %+v", failure)
|
|
}
|
|
if len(rec.ToolResults) != 1 || rec.ToolResults[0].OK || rec.ToolResults[0].Result != "boom" {
|
|
t.Errorf("session tool failure = %+v", rec.ToolResults)
|
|
}
|
|
}
|
|
|
|
// TestExecuteToolCall_DynamicSuccessRecordsResult covers the dynamic-tool
|
|
// success path with a non-nil TaskRecord so AddToolResult runs.
|
|
func TestExecuteToolCall_DynamicSuccessRecordsResult(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
dyn := &argsCapturingProvider{tool: tool.Dynamic("dyn_ok")}
|
|
reg.Register(dyn)
|
|
reg.Freeze()
|
|
r := NewRunner(Deps{Tools: reg, CommentCollector: tool.NewCommentCollector()})
|
|
|
|
rec := &session.TaskRecord{}
|
|
cp := r.executeToolCall(context.Background(), "file.go", llm.ToolCall{
|
|
Function: llm.FunctionCall{Name: "dyn_ok", Arguments: `{"k":"v"}`},
|
|
}, rec, "")
|
|
|
|
if cp.Data != "ok" {
|
|
t.Errorf("cp.Data = %q, want ok", cp.Data)
|
|
}
|
|
if len(rec.ToolResults) != 1 {
|
|
t.Fatalf("expected 1 recorded tool result, got %d", len(rec.ToolResults))
|
|
}
|
|
if rec.ToolResults[0].ToolName != "dyn_ok" || rec.ToolResults[0].Result != "ok" {
|
|
t.Errorf("recorded result = %+v, want dyn_ok/ok", rec.ToolResults[0])
|
|
}
|
|
if !rec.ToolResults[0].OK {
|
|
t.Errorf("successful result marked failed: %+v", rec.ToolResults[0])
|
|
}
|
|
}
|
|
|
|
// TestExecuteToolCall_KnownToolNotRegistered covers the lookupTool-nil branch:
|
|
// a built-in tool the model may call but which is absent from the registry.
|
|
func TestExecuteToolCall_KnownToolNotRegistered(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Freeze()
|
|
r := NewRunner(Deps{Tools: reg, CommentCollector: tool.NewCommentCollector()})
|
|
|
|
cp := r.executeToolCall(context.Background(), "file.go", llm.ToolCall{
|
|
Function: llm.FunctionCall{Name: tool.FileRead.Name(), Arguments: `{"path":"x"}`},
|
|
}, nil, "")
|
|
|
|
if cp.Data != tool.NotAvailableMsg {
|
|
t.Errorf("cp.Data = %q, want NotAvailableMsg", cp.Data)
|
|
}
|
|
}
|
|
|
|
// TestCollectPendingComments_AwaitsPool covers the worker-pool drain branch of
|
|
// CollectPendingComments.
|
|
func TestCollectPendingComments_AwaitsPool(t *testing.T) {
|
|
collector := tool.NewCommentCollector()
|
|
pool := NewCommentWorkerPool(2)
|
|
r := NewRunner(Deps{
|
|
Tools: tool.NewRegistry(),
|
|
CommentCollector: collector,
|
|
CommentWorkerPool: pool,
|
|
})
|
|
|
|
done := make(chan struct{})
|
|
pool.Submit(func() ([]model.LlmComment, error) {
|
|
close(done)
|
|
return nil, nil
|
|
})
|
|
|
|
got := r.CollectPendingComments()
|
|
select {
|
|
case <-done:
|
|
default:
|
|
t.Fatal("CollectPendingComments returned before pool work drained")
|
|
}
|
|
if len(got) != 0 {
|
|
t.Errorf("comments = %d, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
// TestExecuteToolCall_DynamicParseError verifies malformed calls to registered
|
|
// tools are captured by the same failure-observation path.
|
|
func TestExecuteToolCall_DynamicParseError(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Register(&argsCapturingProvider{tool: tool.Dynamic("dyn_ok")})
|
|
reg.Freeze()
|
|
r := NewRunner(Deps{Tools: reg, CommentCollector: tool.NewCommentCollector()})
|
|
|
|
var cp tool.TaskCheckpoint
|
|
progress, errOut := captureToolTerminal(t, func() {
|
|
cp = r.executeToolCall(context.Background(), "file.go", llm.ToolCall{
|
|
Function: llm.FunctionCall{Name: "dyn_ok", Arguments: `{bad`},
|
|
}, nil, "")
|
|
})
|
|
|
|
if !strings.Contains(cp.Data, "Error parsing tool arguments for dyn_ok") {
|
|
t.Errorf("cp.Data = %q, want parse-error message", cp.Data)
|
|
}
|
|
if !strings.Contains(progress, "[ocr] ▶ dyn_ok") {
|
|
t.Errorf("progress = %q, want tool start", progress)
|
|
}
|
|
if !strings.Contains(errOut, "[ocr] ✘ dyn_ok failed: Error parsing tool arguments for dyn_ok") {
|
|
t.Errorf("stderr = %q, want terminal parse failure", errOut)
|
|
}
|
|
failures := r.ToolFailures()
|
|
if len(failures) != 1 || failures[0].ToolName != "dyn_ok" || failures[0].ToolCallNumber != 1 {
|
|
t.Errorf("ToolFailures() = %+v, want dyn_ok tool call 1", failures)
|
|
}
|
|
if !strings.Contains(failures[0].Error, "Error parsing tool arguments") {
|
|
t.Errorf("parse failure details = %+v", failures[0])
|
|
}
|
|
if failures[0].Arguments != `{bad` {
|
|
t.Errorf("parse failure arguments = %q, want malformed raw arguments", failures[0].Arguments)
|
|
}
|
|
}
|
|
|
|
func TestExecuteToolCall_CodeCommentValidationErrorPrintsTerminalFailure(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Register(&tool.CodeCommentProvider{Collector: tool.NewCommentCollector()})
|
|
reg.Freeze()
|
|
r := NewRunner(Deps{Tools: reg, CommentCollector: tool.NewCommentCollector()})
|
|
|
|
progress, errOut := captureToolTerminal(t, func() {
|
|
r.executeToolCall(context.Background(), "file.go", llm.ToolCall{
|
|
Function: llm.FunctionCall{Name: tool.CodeComment.Name(), Arguments: `{}`},
|
|
}, nil, "")
|
|
})
|
|
|
|
if !strings.Contains(progress, "[ocr] ▶ code_comment") {
|
|
t.Errorf("progress = %q, want tool start", progress)
|
|
}
|
|
if !strings.Contains(errOut, "[ocr] ✘ code_comment failed: Error: 'comments' array is required") {
|
|
t.Errorf("stderr = %q, want terminal validation failure", errOut)
|
|
}
|
|
}
|
|
|
|
func captureToolTerminal(t *testing.T, fn func()) (string, string) {
|
|
t.Helper()
|
|
|
|
var progress bytes.Buffer
|
|
restoreProgress := stdout.Swap(&progress)
|
|
defer restoreProgress()
|
|
|
|
oldStderr := os.Stderr
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.Stderr = w
|
|
fn()
|
|
_ = w.Close()
|
|
os.Stderr = oldStderr
|
|
|
|
errBytes, err := io.ReadAll(r)
|
|
_ = r.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return progress.String(), string(errBytes)
|
|
}
|