Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ See [configuration guide](https://roborev.io/configuration/) for all options.
| Gemini | `npm install -g @google/gemini-cli` |
| Copilot | `npm install -g @github/copilot` |
| OpenCode | `npm install -g opencode-ai` |
| Droid | [factory.ai](https://factory.ai/) |

roborev auto-detects installed agents.

Expand Down
6 changes: 3 additions & 3 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ func GetAvailable(preferred string) (Agent, error) {
return Get(preferred)
}

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

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

return Get(available[0])
Expand Down
113 changes: 113 additions & 0 deletions internal/agent/droid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package agent

import (
"bytes"
"context"
"fmt"
"io"
"os/exec"
)

// DroidAgent runs code reviews using Factory's Droid CLI
type DroidAgent struct {
Command string // The droid command to run (default: "droid")
Reasoning ReasoningLevel // Reasoning level for the agent
Agentic bool // Whether agentic mode is enabled (allow file edits)
}

// NewDroidAgent creates a new Droid agent with standard reasoning
func NewDroidAgent(command string) *DroidAgent {
if command == "" {
command = "droid"
}
return &DroidAgent{Command: command, Reasoning: ReasoningStandard}
}

// WithReasoning returns a copy of the agent with the specified reasoning level
func (a *DroidAgent) WithReasoning(level ReasoningLevel) Agent {
return &DroidAgent{Command: a.Command, Reasoning: level, Agentic: a.Agentic}
}

// WithAgentic returns a copy of the agent configured for agentic mode.
func (a *DroidAgent) WithAgentic(agentic bool) Agent {
return &DroidAgent{
Command: a.Command,
Reasoning: a.Reasoning,
Agentic: agentic,
}
}

// droidReasoningEffort maps ReasoningLevel to droid-specific effort values
func (a *DroidAgent) droidReasoningEffort() string {
switch a.Reasoning {
case ReasoningThorough:
return "high"
case ReasoningFast:
return "low"
default:
return "" // use droid default
}
}

func (a *DroidAgent) Name() string {
return "droid"
}

func (a *DroidAgent) CommandName() string {
return a.Command
}

func (a *DroidAgent) buildArgs(prompt string, agenticMode bool) []string {
args := []string{"exec"}

// Set autonomy level based on agentic mode
if agenticMode {
args = append(args, "--auto", "medium")
} else {
args = append(args, "--auto", "low")
}

// Set reasoning effort if specified
if effort := a.droidReasoningEffort(); effort != "" {
args = append(args, "--reasoning-effort", effort)
}

// Add -- to stop flag parsing, then the prompt as the final argument
// This prevents prompts starting with "-" from being parsed as flags
args = append(args, "--", prompt)

return args
}

func (a *DroidAgent) Review(ctx context.Context, repoPath, commitSHA, prompt string, output io.Writer) (string, error) {
// Use agentic mode if either per-job setting or global setting enables it
agenticMode := a.Agentic || AllowUnsafeAgents()

args := a.buildArgs(prompt, agenticMode)

cmd := exec.CommandContext(ctx, a.Command, args...)
cmd.Dir = repoPath

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
if sw := newSyncWriter(output); sw != nil {
cmd.Stderr = io.MultiWriter(&stderr, sw)
} else {
cmd.Stderr = &stderr
}

if err := cmd.Run(); err != nil {
return "", fmt.Errorf("droid failed: %w\nstderr: %s", err, stderr.String())
}

result := stdout.String()
if len(result) == 0 {
return "No review output generated", nil
}

return result, nil
}

func init() {
Register(NewDroidAgent(""))
}
224 changes: 224 additions & 0 deletions internal/agent/droid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package agent

import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)

func TestDroidBuildArgsAgenticMode(t *testing.T) {
a := NewDroidAgent("droid")

// Test non-agentic mode (--auto low)
args := a.buildArgs("prompt", false)
if !containsString(args, "low") {
t.Fatalf("expected --auto low in non-agentic mode, got %v", args)
}
if containsString(args, "medium") {
t.Fatalf("expected no --auto medium in non-agentic mode, got %v", args)
}

// Test agentic mode (--auto medium)
args = a.buildArgs("prompt", true)
if !containsString(args, "medium") {
t.Fatalf("expected --auto medium in agentic mode, got %v", args)
}
}

func TestDroidBuildArgsReasoningEffort(t *testing.T) {
// Test thorough reasoning
a := NewDroidAgent("droid").WithReasoning(ReasoningThorough).(*DroidAgent)
args := a.buildArgs("prompt", false)
if !containsString(args, "--reasoning-effort") || !containsString(args, "high") {
t.Fatalf("expected --reasoning-effort high for thorough, got %v", args)
}

// Test fast reasoning
a = NewDroidAgent("droid").WithReasoning(ReasoningFast).(*DroidAgent)
args = a.buildArgs("prompt", false)
if !containsString(args, "--reasoning-effort") || !containsString(args, "low") {
t.Fatalf("expected --reasoning-effort low for fast, got %v", args)
}

// Test standard reasoning (no flag)
a = NewDroidAgent("droid").WithReasoning(ReasoningStandard).(*DroidAgent)
args = a.buildArgs("prompt", false)
if containsString(args, "--reasoning-effort") {
t.Fatalf("expected no --reasoning-effort for standard, got %v", args)
}
}

func TestDroidName(t *testing.T) {
a := NewDroidAgent("")
if a.Name() != "droid" {
t.Fatalf("expected name 'droid', got %s", a.Name())
}
if a.CommandName() != "droid" {
t.Fatalf("expected command name 'droid', got %s", a.CommandName())
}
}

func TestDroidWithAgentic(t *testing.T) {
a := NewDroidAgent("droid")
if a.Agentic {
t.Fatal("expected non-agentic by default")
}

a2 := a.WithAgentic(true).(*DroidAgent)
if !a2.Agentic {
t.Fatal("expected agentic after WithAgentic(true)")
}
if a.Agentic {
t.Fatal("original should be unchanged")
}
}

func TestDroidReviewSuccess(t *testing.T) {
tmpDir := t.TempDir()
outputContent := "Review feedback from Droid"

// Create a mock droid command that outputs to stdout
cmdPath := writeTempCommand(t, `#!/bin/sh
echo "`+outputContent+`"
`)

a := NewDroidAgent(cmdPath)
result, err := a.Review(context.Background(), tmpDir, "deadbeef", "review this commit", nil)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !strings.Contains(result, outputContent) {
t.Fatalf("expected result to contain %q, got %q", outputContent, result)
}
}

func TestDroidReviewFailure(t *testing.T) {
tmpDir := t.TempDir()

cmdPath := writeTempCommand(t, `#!/bin/sh
echo "error: something went wrong" >&2
exit 1
`)

a := NewDroidAgent(cmdPath)
_, err := a.Review(context.Background(), tmpDir, "deadbeef", "review this commit", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "droid failed") {
t.Fatalf("expected 'droid failed' in error, got %v", err)
}
}

func TestDroidReviewEmptyOutput(t *testing.T) {
tmpDir := t.TempDir()

// Create a mock that outputs nothing to stdout
cmdPath := writeTempCommand(t, `#!/bin/sh
exit 0
`)

a := NewDroidAgent(cmdPath)
result, err := a.Review(context.Background(), tmpDir, "deadbeef", "review this commit", nil)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result != "No review output generated" {
t.Fatalf("expected 'No review output generated', got %q", result)
}
}

func TestDroidReviewWithProgress(t *testing.T) {
tmpDir := t.TempDir()
progressFile := filepath.Join(tmpDir, "progress.txt")

cmdPath := writeTempCommand(t, `#!/bin/sh
echo "Processing..." >&2
echo "Done"
`)

// Create a writer to capture progress (stderr)
f, err := os.Create(progressFile)
if err != nil {
t.Fatalf("create progress file: %v", err)
}
defer f.Close()

a := NewDroidAgent(cmdPath)
_, err = a.Review(context.Background(), tmpDir, "deadbeef", "review this commit", f)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}

progress, _ := os.ReadFile(progressFile)
if !strings.Contains(string(progress), "Processing") {
t.Fatalf("expected progress output, got %q", string(progress))
}
}

func TestDroidBuildArgsPromptWithDash(t *testing.T) {
a := NewDroidAgent("droid")

// Test that prompts starting with "-" are passed as data, not flags
// The "--" terminator must appear before the prompt
prompt := "-o /tmp/malicious --auto high"
args := a.buildArgs(prompt, false)

// Find the position of "--" and the prompt
dashDashIdx := -1
promptIdx := -1
for i, arg := range args {
if arg == "--" {
dashDashIdx = i
}
if arg == prompt {
promptIdx = i
}
}

if dashDashIdx == -1 {
t.Fatalf("expected '--' terminator in args, got %v", args)
}
if promptIdx == -1 {
t.Fatalf("expected prompt in args, got %v", args)
}
if dashDashIdx >= promptIdx {
t.Fatalf("expected '--' before prompt, got %v", args)
}

// Verify the prompt is passed exactly as-is (not split or interpreted)
if args[len(args)-1] != prompt {
t.Fatalf("expected prompt as last arg, got %v", args)
}
}

func TestDroidReviewAgenticModeFromGlobal(t *testing.T) {
prevAllowUnsafe := AllowUnsafeAgents()
SetAllowUnsafeAgents(true)
t.Cleanup(func() { SetAllowUnsafeAgents(prevAllowUnsafe) })

tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
t.Setenv("ARGS_FILE", argsFile)

cmdPath := writeTempCommand(t, `#!/bin/sh
echo "$@" > "$ARGS_FILE"
echo "result"
`)

a := NewDroidAgent(cmdPath)
if _, err := a.Review(context.Background(), tmpDir, "deadbeef", "prompt", nil); err != nil {
t.Fatalf("expected no error, got %v", err)
}

args, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read args: %v", err)
}
// Should use --auto medium when global unsafe agents is enabled
if !strings.Contains(string(args), "medium") {
t.Fatalf("expected '--auto medium' in args when global unsafe enabled, got %s", strings.TrimSpace(string(args)))
}
}
Loading