Skip to content

Commit 06b2ce3

Browse files
gerigkArthur Gerigkfactory-droid[bot]wesmclaude
authored
Add Factory Droid agent support (#91)
## Summary Add support for [Factory's Droid CLI](https://factory.ai) as a code review agent, enabling roborev users who use Droid to get automatic code reviews. ## Changes - **`internal/agent/droid.go`** - DroidAgent implementation using `droid exec` for headless execution - **`internal/agent/droid_test.go`** - Comprehensive tests (9 test cases) - **`internal/agent/agent.go`** - Add "droid" to fallback order - **`README.md`** - Add Droid to supported agents table ## Implementation Details - Uses `droid exec` with `--auto low` for read-only review mode - Uses `--auto medium` for agentic mode (refine) - Supports `--reasoning-effort` for thorough/fast reasoning levels - Output captured via temp file (same pattern as codex agent) - All existing tests pass ## Test Plan - [x] `go build ./...` passes - [x] `go test ./...` passes (all 50 agent tests, including 9 new droid tests) --------- Co-authored-by: Arthur Gerigk <arthur.2@gerigk.co> Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 980b749 commit 06b2ce3

File tree

4 files changed

+341
-3
lines changed

4 files changed

+341
-3
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ See [configuration guide](https://roborev.io/configuration/) for all options.
8383
| Gemini | `npm install -g @google/gemini-cli` |
8484
| Copilot | `npm install -g @github/copilot` |
8585
| OpenCode | `npm install -g opencode-ai` |
86+
| Droid | [factory.ai](https://factory.ai/) |
8687

8788
roborev auto-detects installed agents.
8889

internal/agent/agent.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,8 @@ func GetAvailable(preferred string) (Agent, error) {
155155
return Get(preferred)
156156
}
157157

158-
// Fallback order: codex, claude-code, gemini, copilot, opencode
159-
fallbacks := []string{"codex", "claude-code", "gemini", "copilot", "opencode"}
158+
// Fallback order: codex, claude-code, gemini, copilot, opencode, droid
159+
fallbacks := []string{"codex", "claude-code", "gemini", "copilot", "opencode", "droid"}
160160
for _, name := range fallbacks {
161161
if name != preferred && IsAvailable(name) {
162162
return Get(name)
@@ -172,7 +172,7 @@ func GetAvailable(preferred string) (Agent, error) {
172172
}
173173

174174
if len(available) == 0 {
175-
return nil, fmt.Errorf("no agents available (install one of: codex, claude-code, gemini, copilot, opencode)")
175+
return nil, fmt.Errorf("no agents available (install one of: codex, claude-code, gemini, copilot, opencode, droid)")
176176
}
177177

178178
return Get(available[0])

internal/agent/droid.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package agent
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"io"
8+
"os/exec"
9+
)
10+
11+
// DroidAgent runs code reviews using Factory's Droid CLI
12+
type DroidAgent struct {
13+
Command string // The droid command to run (default: "droid")
14+
Reasoning ReasoningLevel // Reasoning level for the agent
15+
Agentic bool // Whether agentic mode is enabled (allow file edits)
16+
}
17+
18+
// NewDroidAgent creates a new Droid agent with standard reasoning
19+
func NewDroidAgent(command string) *DroidAgent {
20+
if command == "" {
21+
command = "droid"
22+
}
23+
return &DroidAgent{Command: command, Reasoning: ReasoningStandard}
24+
}
25+
26+
// WithReasoning returns a copy of the agent with the specified reasoning level
27+
func (a *DroidAgent) WithReasoning(level ReasoningLevel) Agent {
28+
return &DroidAgent{Command: a.Command, Reasoning: level, Agentic: a.Agentic}
29+
}
30+
31+
// WithAgentic returns a copy of the agent configured for agentic mode.
32+
func (a *DroidAgent) WithAgentic(agentic bool) Agent {
33+
return &DroidAgent{
34+
Command: a.Command,
35+
Reasoning: a.Reasoning,
36+
Agentic: agentic,
37+
}
38+
}
39+
40+
// droidReasoningEffort maps ReasoningLevel to droid-specific effort values
41+
func (a *DroidAgent) droidReasoningEffort() string {
42+
switch a.Reasoning {
43+
case ReasoningThorough:
44+
return "high"
45+
case ReasoningFast:
46+
return "low"
47+
default:
48+
return "" // use droid default
49+
}
50+
}
51+
52+
func (a *DroidAgent) Name() string {
53+
return "droid"
54+
}
55+
56+
func (a *DroidAgent) CommandName() string {
57+
return a.Command
58+
}
59+
60+
func (a *DroidAgent) buildArgs(prompt string, agenticMode bool) []string {
61+
args := []string{"exec"}
62+
63+
// Set autonomy level based on agentic mode
64+
if agenticMode {
65+
args = append(args, "--auto", "medium")
66+
} else {
67+
args = append(args, "--auto", "low")
68+
}
69+
70+
// Set reasoning effort if specified
71+
if effort := a.droidReasoningEffort(); effort != "" {
72+
args = append(args, "--reasoning-effort", effort)
73+
}
74+
75+
// Add -- to stop flag parsing, then the prompt as the final argument
76+
// This prevents prompts starting with "-" from being parsed as flags
77+
args = append(args, "--", prompt)
78+
79+
return args
80+
}
81+
82+
func (a *DroidAgent) Review(ctx context.Context, repoPath, commitSHA, prompt string, output io.Writer) (string, error) {
83+
// Use agentic mode if either per-job setting or global setting enables it
84+
agenticMode := a.Agentic || AllowUnsafeAgents()
85+
86+
args := a.buildArgs(prompt, agenticMode)
87+
88+
cmd := exec.CommandContext(ctx, a.Command, args...)
89+
cmd.Dir = repoPath
90+
91+
var stdout, stderr bytes.Buffer
92+
cmd.Stdout = &stdout
93+
if sw := newSyncWriter(output); sw != nil {
94+
cmd.Stderr = io.MultiWriter(&stderr, sw)
95+
} else {
96+
cmd.Stderr = &stderr
97+
}
98+
99+
if err := cmd.Run(); err != nil {
100+
return "", fmt.Errorf("droid failed: %w\nstderr: %s", err, stderr.String())
101+
}
102+
103+
result := stdout.String()
104+
if len(result) == 0 {
105+
return "No review output generated", nil
106+
}
107+
108+
return result, nil
109+
}
110+
111+
func init() {
112+
Register(NewDroidAgent(""))
113+
}

internal/agent/droid_test.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestDroidBuildArgsAgenticMode(t *testing.T) {
12+
a := NewDroidAgent("droid")
13+
14+
// Test non-agentic mode (--auto low)
15+
args := a.buildArgs("prompt", false)
16+
if !containsString(args, "low") {
17+
t.Fatalf("expected --auto low in non-agentic mode, got %v", args)
18+
}
19+
if containsString(args, "medium") {
20+
t.Fatalf("expected no --auto medium in non-agentic mode, got %v", args)
21+
}
22+
23+
// Test agentic mode (--auto medium)
24+
args = a.buildArgs("prompt", true)
25+
if !containsString(args, "medium") {
26+
t.Fatalf("expected --auto medium in agentic mode, got %v", args)
27+
}
28+
}
29+
30+
func TestDroidBuildArgsReasoningEffort(t *testing.T) {
31+
// Test thorough reasoning
32+
a := NewDroidAgent("droid").WithReasoning(ReasoningThorough).(*DroidAgent)
33+
args := a.buildArgs("prompt", false)
34+
if !containsString(args, "--reasoning-effort") || !containsString(args, "high") {
35+
t.Fatalf("expected --reasoning-effort high for thorough, got %v", args)
36+
}
37+
38+
// Test fast reasoning
39+
a = NewDroidAgent("droid").WithReasoning(ReasoningFast).(*DroidAgent)
40+
args = a.buildArgs("prompt", false)
41+
if !containsString(args, "--reasoning-effort") || !containsString(args, "low") {
42+
t.Fatalf("expected --reasoning-effort low for fast, got %v", args)
43+
}
44+
45+
// Test standard reasoning (no flag)
46+
a = NewDroidAgent("droid").WithReasoning(ReasoningStandard).(*DroidAgent)
47+
args = a.buildArgs("prompt", false)
48+
if containsString(args, "--reasoning-effort") {
49+
t.Fatalf("expected no --reasoning-effort for standard, got %v", args)
50+
}
51+
}
52+
53+
func TestDroidName(t *testing.T) {
54+
a := NewDroidAgent("")
55+
if a.Name() != "droid" {
56+
t.Fatalf("expected name 'droid', got %s", a.Name())
57+
}
58+
if a.CommandName() != "droid" {
59+
t.Fatalf("expected command name 'droid', got %s", a.CommandName())
60+
}
61+
}
62+
63+
func TestDroidWithAgentic(t *testing.T) {
64+
a := NewDroidAgent("droid")
65+
if a.Agentic {
66+
t.Fatal("expected non-agentic by default")
67+
}
68+
69+
a2 := a.WithAgentic(true).(*DroidAgent)
70+
if !a2.Agentic {
71+
t.Fatal("expected agentic after WithAgentic(true)")
72+
}
73+
if a.Agentic {
74+
t.Fatal("original should be unchanged")
75+
}
76+
}
77+
78+
func TestDroidReviewSuccess(t *testing.T) {
79+
tmpDir := t.TempDir()
80+
outputContent := "Review feedback from Droid"
81+
82+
// Create a mock droid command that outputs to stdout
83+
cmdPath := writeTempCommand(t, `#!/bin/sh
84+
echo "`+outputContent+`"
85+
`)
86+
87+
a := NewDroidAgent(cmdPath)
88+
result, err := a.Review(context.Background(), tmpDir, "deadbeef", "review this commit", nil)
89+
if err != nil {
90+
t.Fatalf("expected no error, got %v", err)
91+
}
92+
if !strings.Contains(result, outputContent) {
93+
t.Fatalf("expected result to contain %q, got %q", outputContent, result)
94+
}
95+
}
96+
97+
func TestDroidReviewFailure(t *testing.T) {
98+
tmpDir := t.TempDir()
99+
100+
cmdPath := writeTempCommand(t, `#!/bin/sh
101+
echo "error: something went wrong" >&2
102+
exit 1
103+
`)
104+
105+
a := NewDroidAgent(cmdPath)
106+
_, err := a.Review(context.Background(), tmpDir, "deadbeef", "review this commit", nil)
107+
if err == nil {
108+
t.Fatal("expected error, got nil")
109+
}
110+
if !strings.Contains(err.Error(), "droid failed") {
111+
t.Fatalf("expected 'droid failed' in error, got %v", err)
112+
}
113+
}
114+
115+
func TestDroidReviewEmptyOutput(t *testing.T) {
116+
tmpDir := t.TempDir()
117+
118+
// Create a mock that outputs nothing to stdout
119+
cmdPath := writeTempCommand(t, `#!/bin/sh
120+
exit 0
121+
`)
122+
123+
a := NewDroidAgent(cmdPath)
124+
result, err := a.Review(context.Background(), tmpDir, "deadbeef", "review this commit", nil)
125+
if err != nil {
126+
t.Fatalf("expected no error, got %v", err)
127+
}
128+
if result != "No review output generated" {
129+
t.Fatalf("expected 'No review output generated', got %q", result)
130+
}
131+
}
132+
133+
func TestDroidReviewWithProgress(t *testing.T) {
134+
tmpDir := t.TempDir()
135+
progressFile := filepath.Join(tmpDir, "progress.txt")
136+
137+
cmdPath := writeTempCommand(t, `#!/bin/sh
138+
echo "Processing..." >&2
139+
echo "Done"
140+
`)
141+
142+
// Create a writer to capture progress (stderr)
143+
f, err := os.Create(progressFile)
144+
if err != nil {
145+
t.Fatalf("create progress file: %v", err)
146+
}
147+
defer f.Close()
148+
149+
a := NewDroidAgent(cmdPath)
150+
_, err = a.Review(context.Background(), tmpDir, "deadbeef", "review this commit", f)
151+
if err != nil {
152+
t.Fatalf("expected no error, got %v", err)
153+
}
154+
155+
progress, _ := os.ReadFile(progressFile)
156+
if !strings.Contains(string(progress), "Processing") {
157+
t.Fatalf("expected progress output, got %q", string(progress))
158+
}
159+
}
160+
161+
func TestDroidBuildArgsPromptWithDash(t *testing.T) {
162+
a := NewDroidAgent("droid")
163+
164+
// Test that prompts starting with "-" are passed as data, not flags
165+
// The "--" terminator must appear before the prompt
166+
prompt := "-o /tmp/malicious --auto high"
167+
args := a.buildArgs(prompt, false)
168+
169+
// Find the position of "--" and the prompt
170+
dashDashIdx := -1
171+
promptIdx := -1
172+
for i, arg := range args {
173+
if arg == "--" {
174+
dashDashIdx = i
175+
}
176+
if arg == prompt {
177+
promptIdx = i
178+
}
179+
}
180+
181+
if dashDashIdx == -1 {
182+
t.Fatalf("expected '--' terminator in args, got %v", args)
183+
}
184+
if promptIdx == -1 {
185+
t.Fatalf("expected prompt in args, got %v", args)
186+
}
187+
if dashDashIdx >= promptIdx {
188+
t.Fatalf("expected '--' before prompt, got %v", args)
189+
}
190+
191+
// Verify the prompt is passed exactly as-is (not split or interpreted)
192+
if args[len(args)-1] != prompt {
193+
t.Fatalf("expected prompt as last arg, got %v", args)
194+
}
195+
}
196+
197+
func TestDroidReviewAgenticModeFromGlobal(t *testing.T) {
198+
prevAllowUnsafe := AllowUnsafeAgents()
199+
SetAllowUnsafeAgents(true)
200+
t.Cleanup(func() { SetAllowUnsafeAgents(prevAllowUnsafe) })
201+
202+
tmpDir := t.TempDir()
203+
argsFile := filepath.Join(tmpDir, "args.txt")
204+
t.Setenv("ARGS_FILE", argsFile)
205+
206+
cmdPath := writeTempCommand(t, `#!/bin/sh
207+
echo "$@" > "$ARGS_FILE"
208+
echo "result"
209+
`)
210+
211+
a := NewDroidAgent(cmdPath)
212+
if _, err := a.Review(context.Background(), tmpDir, "deadbeef", "prompt", nil); err != nil {
213+
t.Fatalf("expected no error, got %v", err)
214+
}
215+
216+
args, err := os.ReadFile(argsFile)
217+
if err != nil {
218+
t.Fatalf("read args: %v", err)
219+
}
220+
// Should use --auto medium when global unsafe agents is enabled
221+
if !strings.Contains(string(args), "medium") {
222+
t.Fatalf("expected '--auto medium' in args when global unsafe enabled, got %s", strings.TrimSpace(string(args)))
223+
}
224+
}

0 commit comments

Comments
 (0)