diff --git a/.gitignore b/.gitignore index 2dccea30..ee7f11f2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ coverage.out # IDE .idea/ +.cursor/ .vscode/ *.swp *.swo diff --git a/internal/agent/opencode.go b/internal/agent/opencode.go index fefe00b4..55840772 100644 --- a/internal/agent/opencode.go +++ b/internal/agent/opencode.go @@ -3,9 +3,11 @@ package agent import ( "bytes" "context" + "encoding/json" "fmt" "io" "os/exec" + "strings" ) // OpenCodeAgent runs code reviews using the OpenCode CLI @@ -47,6 +49,34 @@ func (a *OpenCodeAgent) CommandName() string { return a.Command } +// filterOpencodeToolCallLines removes OpenCode tool-call JSON lines from stdout. +// opencode run --format default streams {"name":"...","arguments":{...}} for tool +// requests; roborev needs only the final text. We drop lines that are solely such objects. +func filterOpencodeToolCallLines(s string) string { + var out []string + for _, line := range strings.Split(s, "\n") { + if isOpencodeToolCallLine(line) { + continue + } + out = append(out, line) + } + return strings.TrimSpace(strings.Join(out, "\n")) +} + +func isOpencodeToolCallLine(line string) bool { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "{") { + return false + } + var m map[string]json.RawMessage + if err := json.Unmarshal([]byte(line), &m); err != nil { + return false + } + _, hasName := m["name"] + _, hasArgs := m["arguments"] + return hasName && hasArgs +} + func (a *OpenCodeAgent) Review(ctx context.Context, repoPath, commitSHA, prompt string, output io.Writer) (string, error) { // OpenCode CLI supports a headless invocation via `opencode run [message..]`. // We run it from the repo root so it can use project context, and pass the full @@ -79,11 +109,10 @@ func (a *OpenCodeAgent) Review(ctx context.Context, repoPath, commitSHA, prompt ) } - result := stdout.String() + result := filterOpencodeToolCallLines(stdout.String()) if len(result) == 0 { return "No review output generated", nil } - return result, nil } diff --git a/internal/agent/opencode_test.go b/internal/agent/opencode_test.go new file mode 100644 index 00000000..c9f01aaa --- /dev/null +++ b/internal/agent/opencode_test.go @@ -0,0 +1,84 @@ +package agent + +import ( + "context" + "strings" + "testing" +) + +func TestFilterOpencodeToolCallLines(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "only tool-call lines", + input: `{"name":"read","arguments":{"path":"/foo"}}` + "\n" + `{"name":"edit","arguments":{}}`, + expected: "", + }, + { + name: "only normal text", + input: "**Review:** No issues.\nDone.", + expected: "**Review:** No issues.\nDone.", + }, + { + name: "mixed", + input: `{"name":"read","arguments":{}}` + "\n" + "Real text\n" + `{"name":"edit","arguments":{}}`, + expected: "Real text", + }, + { + name: "empty", + input: "", + expected: "", + }, + { + name: "only newlines", + input: "\n\n", + expected: "", + }, + { + name: "JSON without arguments", + input: `{"name":"foo"}`, + expected: `{"name":"foo"}`, + }, + { + name: "JSON without name", + input: `{"arguments":{}}`, + expected: `{"arguments":{}}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterOpencodeToolCallLines(tt.input) + if got != tt.expected { + t.Errorf("filterOpencodeToolCallLines(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestOpenCodeReviewFiltersToolCallLines(t *testing.T) { + skipIfWindows(t) + script := `#!/bin/sh +printf '%s\n' '{"name":"read","arguments":{"path":"/foo"}}' +echo "**Review:** Fix the typo." +printf '%s\n' '{"name":"edit","arguments":{}}' +echo "Done." +` + cmdPath := writeTempCommand(t, script) + a := NewOpenCodeAgent(cmdPath) + result, err := a.Review(context.Background(), t.TempDir(), "head", "prompt", nil) + if err != nil { + t.Fatalf("Review: %v", err) + } + if !strings.Contains(result, "**Review:**") { + t.Errorf("result missing **Review:**: %q", result) + } + if !strings.Contains(result, "Done.") { + t.Errorf("result missing Done.: %q", result) + } + if strings.Contains(result, `"name":"read"`) { + t.Errorf("result should not contain tool-call JSON: %q", result) + } +}