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
40 changes: 21 additions & 19 deletions backend/internal/adapters/agent/vibe/vibe.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
// Package vibe implements the Mistral Vibe agent adapter: launching new
// non-interactive Vibe sessions and resuming sessions when a native Vibe
// session id is known.
// Package vibe implements the Mistral Vibe agent adapter: launching interactive
// Vibe sessions and resuming sessions when a native Vibe session id is known.
//
// Mistral Vibe (binary "vibe", https://github.com/mistralai/mistral-vibe) is a
// Python CLI installed via `uv tool install mistral-vibe`, pip, or its install
// script. AO drives it in programmatic/headless mode with `-p <prompt>`, which
// auto-approves tools, prints the final response, and exits. `--trust` skips
// the working-directory trust prompt for non-interactive automation, and
// `--output text` pins the human-readable output format.
// script. AO drives Vibe in interactive mode by passing the task as the
// positional initial prompt. `--trust` skips the working-directory trust prompt
// for AO-managed worktrees while preserving Vibe's normal TUI.
//
// Permission modes map onto Vibe's builtin agent profiles via `--agent`:
// accept-edits ("auto-approves file edits only") and auto-approve
Expand Down Expand Up @@ -66,16 +64,19 @@ func (p *Plugin) Manifest() adapters.Manifest {
}
}

// GetLaunchCommand builds the argv to start a new non-interactive Vibe session:
// GetLaunchCommand builds the argv to start a new interactive Vibe session:
//
// vibe --trust --output text [--workdir <path>] [--agent <profile>] -p <prompt>
// vibe --trust [--workdir <path>] [--agent <profile>] [-- <prompt>]
//
// The prompt is delivered through `-p` (programmatic mode), so AO uses
// in-command delivery. `--trust` skips the trust prompt for automation and
// `--output text` pins the output format. `--workdir` is passed explicitly
// because Vibe validates its own working directory in addition to the process
// cwd AO sets through the runtime. Vibe exposes no CLI system-prompt flag
// (system prompts are config-driven), so SystemPrompt is not forwarded.
// When present, the prompt is delivered as Vibe's positional initial prompt, so
// AO uses in-command delivery. Empty prompts intentionally launch an interactive
// Vibe TUI with no positional prompt: the session manager uses promptless
// launches for orchestrators and restore fallback. `--trust` skips the trust
// prompt for automation and avoiding `-p` keeps Vibe in its Textual TUI instead
// of programmatic output mode. `--workdir` is passed explicitly because Vibe
// validates its own working directory in addition to the process cwd AO sets
// through the runtime. Vibe exposes no CLI system-prompt flag (system prompts
// are config-driven), so SystemPrompt is not forwarded.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
if err := ctx.Err(); err != nil {
return nil, err
Expand All @@ -85,11 +86,12 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
return nil, err
}

cmd = []string{binary, "--trust", "--output", "text"}
cmd = make([]string, 0, 6)
cmd = append(cmd, binary, "--trust")
appendWorkdirFlag(&cmd, cfg.WorkspacePath)
appendAgentFlags(&cmd, cfg.Permissions)
if cfg.Prompt != "" {
cmd = append(cmd, "-p", cfg.Prompt)
if strings.TrimSpace(cfg.Prompt) != "" {
cmd = append(cmd, "--", cfg.Prompt)
}
return cmd, nil
}
Expand All @@ -111,7 +113,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
return nil, false, err
}
cmd = make([]string, 0, 8)
cmd = append(cmd, binary, "--trust", "--output", "text")
cmd = append(cmd, binary, "--trust")
appendWorkdirFlag(&cmd, cfg.Session.WorkspacePath)
appendAgentFlags(&cmd, cfg.Permissions)
cmd = append(cmd, "--resume", agentSessionID)
Expand Down
61 changes: 45 additions & 16 deletions backend/internal/adapters/agent/vibe/vibe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,30 +147,49 @@ func TestGetLaunchCommandWithPrompt(t *testing.T) {
t.Fatal(err)
}

want := []string{"vibe", "--trust", "--output", "text", "--workdir", "/work/repo", "--agent", "auto-approve", "-p", "add a health check"}
want := []string{"vibe", "--trust", "--workdir", "/work/repo", "--agent", "auto-approve", "--", "add a health check"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
}

func TestGetLaunchCommandUsesInteractiveTUI(t *testing.T) {
p := &Plugin{resolvedBinary: "vibe"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Prompt: "task"})
if err != nil {
t.Fatal(err)
}

for i, arg := range cmd {
switch arg {
case "--output", "-p", "--prompt":
t.Fatalf("cmd = %#v must not use programmatic output mode", cmd)
case "--":
if i+1 >= len(cmd) || cmd[i+1] != "task" {
t.Fatalf("cmd = %#v must pass prompt as positional initial prompt", cmd)
}
}
}
}

func TestGetLaunchCommandMapsPermissionModes(t *testing.T) {
tests := []struct {
name string
mode ports.PermissionMode
want []string
wantAbsent string
}{
{"default omits flag", ports.PermissionModeDefault, []string{"vibe", "--trust", "--output", "text"}, "--agent"},
{"empty omits flag", "", []string{"vibe", "--trust", "--output", "text"}, "--agent"},
{"accept edits", ports.PermissionModeAcceptEdits, []string{"vibe", "--trust", "--output", "text", "--agent", "accept-edits"}, ""},
{"auto", ports.PermissionModeAuto, []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve"}, ""},
{"bypass", ports.PermissionModeBypassPermissions, []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve"}, ""},
{"default omits flag", ports.PermissionModeDefault, []string{"vibe", "--trust", "--", "task"}, "--agent"},
{"empty omits flag", "", []string{"vibe", "--trust", "--", "task"}, "--agent"},
{"accept edits", ports.PermissionModeAcceptEdits, []string{"vibe", "--trust", "--agent", "accept-edits", "--", "task"}, ""},
{"auto", ports.PermissionModeAuto, []string{"vibe", "--trust", "--agent", "auto-approve", "--", "task"}, ""},
{"bypass", ports.PermissionModeBypassPermissions, []string{"vibe", "--trust", "--agent", "auto-approve", "--", "task"}, ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &Plugin{resolvedBinary: "vibe"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: tt.mode})
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: tt.mode, Prompt: "task"})
if err != nil {
t.Fatal(err)
}
Expand All @@ -188,23 +207,33 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) {
}
}

func TestGetLaunchCommandOmitsPromptWhenEmpty(t *testing.T) {
func TestGetLaunchCommandPromptlessLaunchStaysInteractive(t *testing.T) {
p := &Plugin{resolvedBinary: "vibe"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Permissions: ports.PermissionModeAuto,
Permissions: ports.PermissionModeAuto,
WorkspacePath: "/work/repo",
})
if err != nil {
t.Fatal(err)
}

want := []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve"}
want := []string{"vibe", "--trust", "--workdir", "/work/repo", "--agent", "auto-approve"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("cmd = %#v, want %#v", cmd, want)
}
for _, arg := range cmd {
if arg == "-p" {
t.Fatalf("cmd = %#v unexpectedly contains %q", cmd, "-p")
}
}

func TestGetLaunchCommandWhitespacePromptStaysInteractive(t *testing.T) {
p := &Plugin{resolvedBinary: "vibe"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Prompt: " \t\n",
Permissions: ports.PermissionModeAcceptEdits,
})
if err != nil {
t.Fatal(err)
}
want := []string{"vibe", "--trust", "--agent", "accept-edits"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("cmd = %#v, want %#v", cmd, want)
}
}

Expand All @@ -224,7 +253,7 @@ func TestGetRestoreCommand(t *testing.T) {
t.Fatal("ok=false, want true")
}

want := []string{"vibe", "--trust", "--output", "text", "--workdir", "/work/repo", "--agent", "auto-approve", "--resume", "abcd1234-5678-90ab-cdef-1234567890ab"}
want := []string{"vibe", "--trust", "--workdir", "/work/repo", "--agent", "auto-approve", "--resume", "abcd1234-5678-90ab-cdef-1234567890ab"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("cmd = %#v, want %#v", cmd, want)
}
Expand Down
Loading