open-code-review/internal/llmloop/loop_execute_test.go
xujiejie 71d29810be
feat(llmloop): backfill comment thinking from turn output (#773)
* feat(llmloop): backfill comment thinking from the turn's reasoning or message

Expose ChatResponse.ReasoningContent and backfill per-comment thinking
with the current turn's reasoning content, falling back to the assistant
message for models that do not expose reasoning, so --format json output
carries thinking even when the model omits it.

* fix(llmloop): drop content fallback for comment thinking backfill

The turn's assistant message is usually a short user-facing preamble
rather than real reasoning, so backfill per-comment thinking only from
the model's native reasoning_content and leave it empty otherwise.

Add a full-wiring RunPerFile test for the reasoning backfill and a
regression test that fails if the content fallback returns. Sync the
thinking docs across en/zh/ja/ru.

* docs(llmloop): note that turn-level thinking is shared by design

Document in the main loop and at the code_comment backfill site that
the model emits reasoning once per turn, so every tool call and
comment in the same turn intentionally shares the same thinking.
2026-08-10 14:52:51 +08:00

146 lines
4.8 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
package llmloop
import (
"context"
"errors"
"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/tool"
)
// erroringProvider is a dynamic tool provider whose Execute always fails, used
// to drive executeToolCall's dynamic-tool error branch.
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 covers the dynamic-tool branch where
// the provider's Execute returns an error.
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()})
cp := r.executeToolCall(context.Background(), "file.go", llm.ToolCall{
Function: llm.FunctionCall{Name: "dyn_fail", Arguments: `{}`},
}, nil, "")
if !strings.Contains(cp.Data, "Error executing tool dyn_fail") {
t.Errorf("cp.Data = %q, want execute-error message", cp.Data)
}
}
// 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])
}
}
// 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 covers the dynamic-tool branch where
// the arguments string fails to parse.
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()})
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)
}
}