diff --git a/backend/internal/adapters/agent/vibe/vibe.go b/backend/internal/adapters/agent/vibe/vibe.go index a0b71943a1..b90383064c 100644 --- a/backend/internal/adapters/agent/vibe/vibe.go +++ b/backend/internal/adapters/agent/vibe/vibe.go @@ -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 `, 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 @@ -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 ] [--agent ] -p +// vibe --trust [--workdir ] [--agent ] [-- ] // -// 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 @@ -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 } @@ -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) diff --git a/backend/internal/adapters/agent/vibe/vibe_test.go b/backend/internal/adapters/agent/vibe/vibe_test.go index baae1024da..ce8e3efabc 100644 --- a/backend/internal/adapters/agent/vibe/vibe_test.go +++ b/backend/internal/adapters/agent/vibe/vibe_test.go @@ -147,12 +147,31 @@ 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 @@ -160,17 +179,17 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { 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) } @@ -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) } } @@ -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) }