Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cmd/opencodereview/flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,23 @@ func TestParseReviewFlags_NegativeMaxTools(t *testing.T) {
}
}

func TestParseReviewFlags_PlanTimeout(t *testing.T) {
opts, err := parseReviewFlags([]string{"--plan-timeout", "120"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if opts.planTimeoutSecs != 120 {
t.Errorf("planTimeoutSecs = %d, want 120", opts.planTimeoutSecs)
}
}

func TestParseReviewFlags_NegativePlanTimeout(t *testing.T) {
_, err := parseReviewFlags([]string{"--plan-timeout", "-1"})
if err == nil {
t.Fatal("expected error for negative plan-timeout")
}
}

func TestParseReviewFlags_MaxToolsBelowMin(t *testing.T) {
opts, err := parseReviewFlags([]string{"--max-tools", "5"})
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions cmd/opencodereview/review_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type reviewOptions struct {
model string
concurrency int
perFileTimeout int
planTimeoutSecs int
maxTools int
maxGitProcs int
maxTokens int
Expand Down Expand Up @@ -201,6 +202,7 @@ func executeReview(opts reviewOptions) error {
CommentWorkerPool: agent.NewCommentWorkerPool(opts.concurrency),
MaxConcurrency: opts.concurrency,
ConcurrentTaskTimeout: opts.perFileTimeout,
PlanTaskTimeout: time.Duration(opts.planTimeoutSecs) * time.Second,
Model: rt.Model,
Provider: rt.Provider,
Background: opts.background,
Expand Down
4 changes: 2 additions & 2 deletions cmd/opencodereview/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func resolveMaxTokens(templateDefault int, cfg *Config, cliOverride int) (int, e
}

// loadCommonContext validates the working directory, loads the embedded
// template, raises MaxToolRequestTimes when maxTools exceeds the default,
// template, overrides MaxToolRequestTimes when maxTools is explicitly set,
// resolves the absolute repo path, loads system review rules, and creates
// the global git subprocess limiter. Both review and scan callers go
// through this so the startup sequence stays consistent.
Expand All @@ -74,7 +74,7 @@ func loadCommonContext(repoDirInput, rulePath string, maxTools, maxGitProcs int,
if err != nil {
return nil, fmt.Errorf("load default template: %w", err)
}
if maxTools > tpl.MaxToolRequestTimes {
if maxTools > 0 {
tpl.MaxToolRequestTimes = maxTools
}
if err := tpl.Validate(); err != nil {
Expand Down
4 changes: 4 additions & 0 deletions cmd/opencodereview/shared_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ func validateReviewOptions(opts *reviewOptions) error {
if opts.preview && opts.resume != "" {
return fmt.Errorf("--preview and --resume cannot be used together")
}
if opts.planTimeoutSecs < 0 {
return fmt.Errorf("--plan-timeout must be a non-negative integer (0 means no separate timeout)")
}
if err := validateAudience(opts.audience); err != nil {
return err
}
Expand Down Expand Up @@ -175,6 +178,7 @@ func registerReviewFlags(cmd *cobra.Command, opts *reviewOptions) {
addExcludeFlag(cmd, &opts.excludes)
addOutputFlags(cmd, &opts.outputFormat, &opts.audience)
addConcurrencyFlags(cmd, &opts.concurrency, &opts.perFileTimeout, &opts.maxTools, &opts.maxGitProcs, &opts.maxTokens, &opts.maxTokensBudget)
cmd.Flags().IntVar(&opts.planTimeoutSecs, "plan-timeout", 0, "per-file plan task timeout in seconds (0 = use the file timeout only)")
addBackgroundFlags(cmd, &opts.background, &opts.backgroundFile)
addProviderFlag(cmd, &opts.provider)
addModelFlag(cmd, &opts.model)
Expand Down
28 changes: 28 additions & 0 deletions cmd/opencodereview/shared_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,34 @@ func TestApplyCLIExcludes_Empty(t *testing.T) {
}
}

func TestLoadCommonContext_MaxToolsOverride(t *testing.T) {
t.Setenv("HOME", t.TempDir())
repoDir := t.TempDir()

tests := []struct {
name string
maxTools int
want int
}{
{name: "template default", maxTools: 0, want: 30},
{name: "lower bound", maxTools: 10, want: 10},
{name: "lower than default", maxTools: 15, want: 15},
{name: "higher than default", maxTools: 40, want: 40},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cc, err := loadCommonContext(repoDir, "", tt.maxTools, 0, false)
if err != nil {
t.Fatalf("loadCommonContext() error: %v", err)
}
if got := cc.Template.MaxToolRequestTimes; got != tt.want {
t.Errorf("MaxToolRequestTimes = %d, want %d", got, tt.want)
}
})
}
}

func TestApplyCLIExcludes_AppendsPatterns(t *testing.T) {
cc := &commonContext{FileFilter: &rules.FileFilter{Exclude: []string{"a"}}}
applyCLIExcludes(cc, []string{"b", "c"})
Expand Down
11 changes: 11 additions & 0 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ type Args struct {
// Concurrent task timeout in minutes. 0 means no timeout.
ConcurrentTaskTimeout int

// PlanTaskTimeout bounds the optional plan LLM call. When it expires,
// executeSubtask continues with the main task without plan guidance.
// A non-positive value means the per-file context is the only deadline.
PlanTaskTimeout time.Duration

// CommentCollector collects review comments generated by the code_comment tool.
CommentCollector *tool.CommentCollector

Expand Down Expand Up @@ -1460,6 +1465,12 @@ func (a *Agent) extFromPath(path string) string {
// executePlanPhase runs the plan task for a single file, sending template messages
// with resolved placeholders and collecting the LLM response as plan guidance.
func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFiles, rule string) (string, error) {
if a.args.PlanTaskTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, a.args.PlanTaskTimeout)
defer cancel()
}

ctx, span := telemetry.StartSpan(ctx, "plan.execute")
defer span.End()
telemetry.SetAttr(span, "file.path", newPath)
Expand Down
33 changes: 33 additions & 0 deletions internal/agent/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"strings"
"testing"
"time"

"github.com/alibaba/open-code-review/internal/config/rules"
"github.com/alibaba/open-code-review/internal/config/template"
Expand All @@ -18,6 +19,13 @@ import (
"github.com/alibaba/open-code-review/internal/tool"
)

type contextBlockingClient struct{}

func (contextBlockingClient) CompletionsWithCtx(ctx context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
<-ctx.Done()
return nil, ctx.Err()
}

func TestAgent_Getters(t *testing.T) {
tmpDir := t.TempDir()
sess := session.New(tmpDir, "main", "test-model", session.SessionOptions{ReviewMode: "diff"})
Expand Down Expand Up @@ -417,6 +425,31 @@ func TestExecutePlanPhase_LLMError(t *testing.T) {
}
}

func TestExecutePlanPhase_Timeout(t *testing.T) {
tmpDir := t.TempDir()
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})

a := New(Args{
LLMClient: contextBlockingClient{},
Model: "test",
Session: sess,
PlanTaskTimeout: 20 * time.Millisecond,
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 !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("executePlanPhase error = %v, want context deadline exceeded", err)
}
}

func TestExecuteSubtask_EmptyMainTask(t *testing.T) {
tmpDir := t.TempDir()
sess := session.New(tmpDir, "main", "test", session.SessionOptions{ReviewMode: "diff"})
Expand Down
2 changes: 1 addition & 1 deletion internal/config/template/task_template.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@
},
"MAX_TOOL_REQUEST_TIMES": 30,
"PLAN_MODE_LINE_THRESHOLD": 50,
"MAX_TOKENS": 58888
"MAX_TOKENS": 30000
}
4 changes: 2 additions & 2 deletions internal/config/template/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ func TestLoadDefault_FieldsPopulated(t *testing.T) {
if tpl.ReviewFilterTask == nil {
t.Fatal("ReviewFilterTask is nil, expected non-nil")
}
if tpl.MaxTokens != 58888 {
t.Errorf("MaxTokens = %d, want 58888", tpl.MaxTokens)
if tpl.MaxTokens != 30000 {
t.Errorf("MaxTokens = %d, want 30000", tpl.MaxTokens)
}
if tpl.MaxToolRequestTimes != 30 {
t.Errorf("MaxToolRequestTimes = %d, want 30", tpl.MaxToolRequestTimes)
Expand Down