package launch import ( "bytes" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "slices" "strings" "sync/atomic" "testing" "github.com/google/go-cmp/cmp" "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/config" "github.com/ollama/ollama/internal/proxy" "github.com/spf13/cobra" ) func captureStderr(t *testing.T, fn func()) string { t.Helper() oldStderr := os.Stderr r, w, err := os.Pipe() if err != nil { t.Fatalf("failed to create stderr pipe: %v", err) } os.Stderr = w defer func() { os.Stderr = oldStderr }() done := make(chan string, 1) go func() { var buf bytes.Buffer _, _ = io.Copy(&buf, r) done <- buf.String() }() fn() _ = w.Close() return <-done } func TestLaunchCmd(t *testing.T) { mockCheck := func(cmd *cobra.Command, args []string) error { return nil } mockTUI := func(cmd *cobra.Command) {} cmd := LaunchCmd(mockCheck, mockTUI) t.Run("command structure", func(t *testing.T) { if cmd.Use != "launch [INTEGRATION] [-- [EXTRA_ARGS...]]" { t.Errorf("Use = %q, want %q", cmd.Use, "launch [INTEGRATION] [-- [EXTRA_ARGS...]]") } if cmd.Short == "" { t.Error("Short description should not be empty") } if cmd.Long == "" { t.Error("Long description should not be empty") } if !strings.Contains(cmd.Long, "hermes") { t.Error("Long description should mention hermes") } if !strings.Contains(cmd.Long, "kimi") { t.Error("Long description should mention kimi") } }) t.Run("flags exist", func(t *testing.T) { if cmd.Flags().Lookup("model") == nil { t.Error("--model flag should exist") } if cmd.Flags().Lookup("config") == nil { t.Error("--config flag should exist") } if cmd.Flags().Lookup("restore") == nil { t.Error("--restore flag should exist") } if cmd.Flags().Lookup("yes") == nil { t.Error("--yes flag should exist") } }) t.Run("PreRunE is set", func(t *testing.T) { if cmd.PreRunE == nil { t.Error("PreRunE should be set to checkServerHeartbeat") } }) } func TestLaunchCmdTUICallback(t *testing.T) { mockCheck := func(cmd *cobra.Command, args []string) error { return nil } t.Run("no args calls TUI", func(t *testing.T) { tuiCalled := false mockTUI := func(cmd *cobra.Command) { tuiCalled = true } cmd := LaunchCmd(mockCheck, mockTUI) cmd.SetArgs([]string{}) _ = cmd.Execute() if !tuiCalled { t.Error("TUI callback should be called when no args provided") } }) t.Run("integration arg bypasses TUI", func(t *testing.T) { srv := httptest.NewServer(http.NotFoundHandler()) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) tuiCalled := false mockTUI := func(cmd *cobra.Command) { tuiCalled = true } cmd := LaunchCmd(mockCheck, mockTUI) cmd.SetArgs([]string{"claude"}) _ = cmd.Execute() if tuiCalled { t.Error("TUI callback should NOT be called when integration arg provided") } }) t.Run("--model flag without integration returns error", func(t *testing.T) { tuiCalled := false mockTUI := func(cmd *cobra.Command) { tuiCalled = true } cmd := LaunchCmd(mockCheck, mockTUI) cmd.SetArgs([]string{"--model", "test-model"}) err := cmd.Execute() if err == nil { t.Fatal("expected --model without an integration to fail") } if !strings.Contains(err.Error(), "require an integration name") { t.Fatalf("expected integration-name guidance, got %v", err) } if tuiCalled { t.Error("TUI callback should NOT be called when --model is provided without an integration") } }) t.Run("--config flag without integration returns error", func(t *testing.T) { tuiCalled := false mockTUI := func(cmd *cobra.Command) { tuiCalled = true } cmd := LaunchCmd(mockCheck, mockTUI) cmd.SetArgs([]string{"--config"}) err := cmd.Execute() if err == nil { t.Fatal("expected --config without an integration to fail") } if !strings.Contains(err.Error(), "require an integration name") { t.Fatalf("expected integration-name guidance, got %v", err) } if tuiCalled { t.Error("TUI callback should NOT be called when --config is provided without an integration") } }) t.Run("--yes flag without integration returns error", func(t *testing.T) { tuiCalled := false mockTUI := func(cmd *cobra.Command) { tuiCalled = true } cmd := LaunchCmd(mockCheck, mockTUI) cmd.SetArgs([]string{"--yes"}) err := cmd.Execute() if err == nil { t.Fatal("expected --yes without an integration to fail") } if !strings.Contains(err.Error(), "require an integration name") { t.Fatalf("expected integration-name guidance, got %v", err) } if tuiCalled { t.Error("TUI callback should NOT be called when --yes is provided without an integration") } }) t.Run("extra args without integration return error", func(t *testing.T) { tuiCalled := false mockTUI := func(cmd *cobra.Command) { tuiCalled = true } cmd := LaunchCmd(mockCheck, mockTUI) cmd.SetArgs([]string{"--model", "test-model", "--", "--sandbox", "workspace-write"}) err := cmd.Execute() if err == nil { t.Fatal("expected flags and extra args without an integration to fail") } if !strings.Contains(err.Error(), "require an integration name") { t.Fatalf("expected integration-name guidance, got %v", err) } if tuiCalled { t.Error("TUI callback should NOT be called when flags or extra args are provided without an integration") } }) t.Run("--restore flag without integration returns error", func(t *testing.T) { tuiCalled := false mockTUI := func(cmd *cobra.Command) { tuiCalled = true } cmd := LaunchCmd(mockCheck, mockTUI) cmd.SetArgs([]string{"--restore"}) err := cmd.Execute() if err == nil { t.Fatal("expected --restore without an integration to fail") } if !strings.Contains(err.Error(), "require an integration name") { t.Fatalf("expected integration-name guidance, got %v", err) } if tuiCalled { t.Error("TUI callback should NOT be called when --restore is provided without an integration") } }) } func TestLaunchCmdClaudeDesktopRequiresRestore(t *testing.T) { for _, name := range []string{"claude-desktop", "claude-app"} { heartbeatCalled := false cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { heartbeatCalled = true return nil }, func(cmd *cobra.Command) {}) cmd.SetArgs([]string{name}) err := cmd.Execute() if err == nil || !strings.Contains(err.Error(), "can only be restored from the command line") { t.Fatalf("expected %s launch to require --restore, got %v", name, err) } if heartbeatCalled { t.Fatalf("expected %s to fail before checking the Ollama server heartbeat", name) } } } func TestLaunchCmdClaudeDesktopRestoreRemainsAvailable(t *testing.T) { runner := &commandRestorableRunner{} restore := OverrideIntegration("claude-desktop", runner) defer restore() heartbeatCalled := false cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { heartbeatCalled = true return nil }, func(cmd *cobra.Command) {}) cmd.SetArgs([]string{"claude-desktop", "--restore"}) if err := cmd.Execute(); err != nil { t.Fatalf("expected Claude Desktop restore command to succeed, got %v", err) } if !runner.restored { t.Fatal("expected Claude Desktop restore command to run restore") } if heartbeatCalled { t.Fatal("expected Claude Desktop restore to skip the Ollama server heartbeat") } } type commandRestorableRunner struct { launcherRestorableRunner } func (r *commandRestorableRunner) SkipRestoreInstallCheck() bool { return true } func TestLaunchCmdChatGPT(t *testing.T) { for _, tt := range []struct { name string args []string routerReady bool wantOpen int }{ {name: "unavailable launch", args: []string{"chatgpt", "--model", "qwen3:8b"}}, {name: "unavailable configure only", args: []string{"codex-app", "--model", "qwen3:8b", "--config"}}, {name: "healthy launch", args: []string{"chatgpt", "--model", "qwen3:8b"}, routerReady: true, wantOpen: 1}, {name: "healthy configure only", args: []string{"chatgpt", "--model", "qwen3:8b", "--config"}, routerReady: true}, {name: "restore without server", args: []string{"chatgpt", "--restore"}, wantOpen: 1}, } { t.Run(tt.name, func(t *testing.T) { home := t.TempDir() setTestHome(t, home) withInteractiveSession(t, false) withCodexAppPlatform(t, "darwin") t.Setenv(codexAppAutoReviewModelEnv, "") var routerChecks atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/": w.WriteHeader(http.StatusOK) case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/status": fmt.Fprint(w, `{"cloud":{"disabled":true}}`) case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"qwen3:8b"}]}`) case "/api/show": fmt.Fprint(w, `{"capabilities":["completion","tools"],"model_info":{"general.context_length":131072}}`) case proxy.CodexDesktopPathPrefix + "/_health": routerChecks.Add(1) if tt.routerReady { fmt.Fprint(w, `{"ok":true}`) } else { http.NotFound(w, r) } default: http.NotFound(w, r) } })) defer server.Close() t.Setenv("OLLAMA_HOST", server.URL) configDir := filepath.Join(home, ".codex") if err := os.MkdirAll(configDir, 0o700); err != nil { t.Fatal(err) } original := map[string]string{ "config.toml": "model = \"gpt-5.5\"\napproval_policy = \"on-request\"\nmodel_provider = \"openai\"\n", "auth.json": `{"OPENAI_API_KEY":"test-existing-key"}`, } for name, data := range original { if err := os.WriteFile(filepath.Join(configDir, name), []byte(data), 0o600); err != nil { t.Fatal(err) } } restoring := slices.Contains(tt.args, "--restore") if restoring { server.Close() withCodexAppRouterHealth(t, func() error { t.Error("restore must not check ChatGPT routing") return fmt.Errorf("server is stopped") }) if err := (&CodexApp{}).Configure("qwen3:8b"); err != nil { t.Fatal(err) } } openCalls, quitCalls := 0, 0 withCodexAppProcessHooks(t, func() bool { return false }, func() error { quitCalls++; return nil }, func() error { openCalls++; return nil }, ) codexAppCanOpenID = func() bool { return true } client, err := api.ClientFromEnvironment() if err != nil { t.Fatal(err) } heartbeatCalled := false cmd := LaunchCmd(func(cmd *cobra.Command, _ []string) error { heartbeatCalled = true return client.Heartbeat(cmd.Context()) }, nil) cmd.SetArgs(tt.args) stderr := captureStderr(t, func() { err = cmd.Execute() }) wantHeartbeat := !restoring if heartbeatCalled != wantHeartbeat { t.Fatalf("heartbeat called = %v, want %v", heartbeatCalled, wantHeartbeat) } if tt.routerReady || restoring { if err != nil { t.Fatal(err) } } else if err == nil || !strings.Contains(err.Error(), "does not include ChatGPT routing") || !strings.Contains(err.Error(), "restart Ollama") { t.Fatalf("expected missing-router recovery guidance, got %v", err) } if got := strings.Contains(stderr, codexAppConfigurationSuccess); got != tt.routerReady { t.Fatalf("configuration success = %v, want %v; stderr=%q", got, tt.routerReady, stderr) } if tt.routerReady { if got := (&CodexApp{}).CurrentModel(); got != "qwen3:8b" { t.Fatalf("configured model = %q, want qwen3:8b", got) } } else { for name, want := range original { data, err := os.ReadFile(filepath.Join(configDir, name)) if err != nil || string(data) != want { t.Fatalf("%s changed: data=%q err=%v", name, data, err) } } entries, err := os.ReadDir(configDir) if err != nil || len(entries) != len(original) { t.Fatalf("client files changed: entries=%v err=%v", entries, err) } if !restoring { if _, err := os.Stat(filepath.Join(home, ".ollama")); !os.IsNotExist(err) { t.Fatalf("created launcher or restore state before router check: %v", err) } } } if quitCalls != 0 || openCalls != tt.wantOpen { t.Fatalf("process calls = quit %d, open %d; want quit 0, open %d", quitCalls, openCalls, tt.wantOpen) } if got := routerChecks.Load(); !restoring && got != 1 { t.Fatalf("router health checks = %d, want 1", got) } }) } } func TestLaunchCmdNilHeartbeat(t *testing.T) { cmd := LaunchCmd(nil, nil) if cmd == nil { t.Fatal("LaunchCmd returned nil") } if cmd.PreRunE != nil { t.Log("Note: PreRunE is set even when nil is passed (acceptable)") } } func TestLaunchCmdModelFlagFiltersDisabledCloudFromSavedConfig(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) if err := config.SaveIntegration("stubeditor", []string{"glm-5:cloud"}); err != nil { t.Fatalf("failed to seed saved config: %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/status": fmt.Fprintf(w, `{"cloud":{"disabled":true,"source":"config"}}`) case "/api/show": fmt.Fprintf(w, `{"model":"sample-model"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) stub := &launcherEditorRunner{} restore := OverrideIntegration("stubeditor", stub) defer restore() cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {}) cmd.SetArgs([]string{"stubeditor", "--model", "sample-model"}) if err := cmd.Execute(); err != nil { t.Fatalf("launch command failed: %v", err) } saved, err := config.LoadIntegration("stubeditor") if err != nil { t.Fatalf("failed to reload integration config: %v", err) } if diff := cmp.Diff([]string{"sample-model"}, saved.Models); diff != "" { t.Fatalf("saved models mismatch (-want +got):\n%s", diff) } if diff := cmp.Diff([][]string{{"sample-model"}}, stub.edited); diff != "" { t.Fatalf("editor models mismatch (-want +got):\n%s", diff) } if stub.ranModel != "sample-model" { t.Fatalf("expected launch to run with sample-model, got %q", stub.ranModel) } } func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/status": fmt.Fprintf(w, `{"cloud":{"disabled":true,"source":"config"}}`) case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/show": fmt.Fprint(w, `{"model":"sample-model"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) stub := &launcherSingleRunner{} restore := OverrideIntegration("stubapp", stub) defer restore() oldSelector := DefaultSingleSelector defer func() { DefaultSingleSelector = oldSelector }() var selectorCalls int var gotCurrent string DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) { selectorCalls++ gotCurrent = current return "sample-model", nil } cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {}) cmd.SetArgs([]string{"stubapp", "--model", "glm-5:cloud"}) stderr := captureStderr(t, func() { if err := cmd.Execute(); err != nil { t.Fatalf("launch command failed: %v", err) } }) if selectorCalls != 1 { t.Fatalf("expected disabled cloud override to fall back to selector, got %d calls", selectorCalls) } if gotCurrent != "" { t.Fatalf("expected disabled override to be cleared before selection, got current %q", gotCurrent) } if stub.ranModel != "sample-model" { t.Fatalf("expected launch to run with replacement local model, got %q", stub.ranModel) } if !strings.Contains(stderr, "Warning: ignoring --model glm-5:cloud because cloud is disabled") { t.Fatalf("expected disabled-cloud warning, got stderr: %q", stderr) } saved, err := config.LoadIntegration("stubapp") if err != nil { t.Fatalf("failed to reload integration config: %v", err) } if diff := cmp.Diff([]string{"sample-model"}, saved.Models); diff != "" { t.Fatalf("saved models mismatch (-want +got):\n%s", diff) } } func TestLaunchCmdAutodiscoveryDefaultLaunchDoesNotForceConfigure(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withInteractiveSession(t, true) withLauncherHooks(t) runner := &launcherManagedAutodiscoveryRunner{ autodiscoveryConfigured: true, } restore := OverrideIntegration("stubauto", runner) defer restore() if err := config.SaveIntegration("stubauto", []string{"Ollama Cloud"}); err != nil { t.Fatalf("failed to save managed integration config: %v", err) } if err := config.MarkIntegrationOnboarded("stubauto"); err != nil { t.Fatalf("failed to mark integration onboarded: %v", err) } cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) { t.Fatal("TUI callback should not run for direct integration launch") }) cmd.SetArgs([]string{"stubauto"}) if err := cmd.Execute(); err != nil { t.Fatalf("launch command failed: %v", err) } if runner.autodiscoveryConfigures != 0 { t.Fatalf("expected default autodiscovery launch to reuse existing config, got %d configures", runner.autodiscoveryConfigures) } if runner.ranModel != "Ollama Cloud" { t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel) } } func TestLaunchCmdYes_AutoConfirmsLaunchPromptPath(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) withInteractiveSession(t, false) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/show": fmt.Fprint(w, `{"model":"sample-model"}`) case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"not found"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) stub := &launcherEditorRunner{paths: []string{"/tmp/stubeditor.json"}} restore := OverrideIntegration("stubeditor", stub) defer restore() DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatalf("unexpected prompt with --yes: %q", prompt) return false, nil } cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {}) cmd.SetArgs([]string{"stubeditor", "--model", "sample-model", "--yes"}) if err := cmd.Execute(); err != nil { t.Fatalf("launch command with --yes failed: %v", err) } if diff := cmp.Diff([][]string{{"sample-model"}}, stub.edited); diff != "" { t.Fatalf("editor models mismatch (-want +got):\n%s", diff) } if stub.ranModel != "sample-model" { t.Fatalf("expected launch to run with sample-model, got %q", stub.ranModel) } } func TestLaunchCmdHeadlessWithYes_AutoPullsMissingLocalModel(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) withInteractiveSession(t, false) var pullCalled bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/show": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"model not found"}`) case "/api/pull": pullCalled = true w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"status":"success"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) stub := &launcherSingleRunner{} restore := OverrideIntegration("stubapp", stub) defer restore() DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatalf("unexpected prompt with --yes in headless autopull path: %q", prompt) return false, nil } cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {}) cmd.SetArgs([]string{"stubapp", "--model", "missing-model", "--yes"}) if err := cmd.Execute(); err != nil { t.Fatalf("launch command with --yes failed: %v", err) } if !pullCalled { t.Fatal("expected missing local model to be auto-pulled with --yes in headless mode") } if stub.ranModel != "missing-model" { t.Fatalf("expected launch to run with pulled model, got %q", stub.ranModel) } } func TestLaunchCmdHeadlessWithoutYes_AllowsConfiguredLaunch(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) withInteractiveSession(t, false) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/show": fmt.Fprint(w, `{"model":"sample-model"}`) case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"not found"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) stub := &launcherEditorRunner{paths: []string{"/tmp/stubeditor.json"}} restore := OverrideIntegration("stubeditor", stub) defer restore() DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatalf("unexpected prompt in headless non-yes mode: %q", prompt) return false, nil } cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {}) cmd.SetArgs([]string{"stubeditor", "--model", "sample-model"}) err := cmd.Execute() if err != nil { t.Fatalf("expected launch command to succeed without --yes when an explicit model is provided, got %v", err) } if diff := compareStringSlices(stub.edited, [][]string{{"sample-model"}}); diff != "" { t.Fatalf("unexpected editor writes (-want +got):\n%s", diff) } if stub.ranModel != "sample-model" { t.Fatalf("expected launch to run configured model, got %q", stub.ranModel) } } func TestLaunchCmdIntegrationArgPromptsForModelWithSavedSelection(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) if err := config.SaveIntegration("stubapp", []string{"sample-model"}); err != nil { t.Fatalf("failed to seed saved config: %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"sample-model"},{"name":"qwen3:8b"}]}`) case "/api/show": fmt.Fprint(w, `{"model":"qwen3:8b"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) stub := &launcherSingleRunner{} restore := OverrideIntegration("stubapp", stub) defer restore() oldSelector := DefaultSingleSelector defer func() { DefaultSingleSelector = oldSelector }() var gotCurrent string DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) { gotCurrent = current return "qwen3:8b", nil } cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {}) cmd.SetArgs([]string{"stubapp"}) if err := cmd.Execute(); err != nil { t.Fatalf("launch command failed: %v", err) } if gotCurrent != "sample-model" { t.Fatalf("expected selector current model to be saved model sample-model, got %q", gotCurrent) } if stub.ranModel != "qwen3:8b" { t.Fatalf("expected launch to run selected model qwen3:8b, got %q", stub.ranModel) } saved, err := config.LoadIntegration("stubapp") if err != nil { t.Fatalf("failed to reload integration config: %v", err) } if diff := cmp.Diff([]string{"qwen3:8b"}, saved.Models); diff != "" { t.Fatalf("saved models mismatch (-want +got):\n%s", diff) } } func TestLaunchCmdHeadlessYes_IntegrationRequiresModelEvenWhenSaved(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) withInteractiveSession(t, false) if err := config.SaveIntegration("stubapp", []string{"sample-model"}); err != nil { t.Fatalf("failed to seed saved config: %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/show": fmt.Fprint(w, `{"model":"sample-model"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) stub := &launcherSingleRunner{} restore := OverrideIntegration("stubapp", stub) defer restore() oldSelector := DefaultSingleSelector defer func() { DefaultSingleSelector = oldSelector }() DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) { t.Fatal("selector should not be called for headless --yes saved-model launch") return "", nil } cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {}) cmd.SetArgs([]string{"stubapp", "--yes"}) err := cmd.Execute() if err == nil { t.Fatal("expected launch command to fail when --yes is used headlessly without --model") } if !strings.Contains(err.Error(), "requires --model ") { t.Fatalf("expected actionable --model guidance, got %v", err) } if stub.ranModel != "" { t.Fatalf("expected launch to abort before run, got %q", stub.ranModel) } } func TestLaunchCmdHeadlessYes_IntegrationWithoutSavedModelReturnsError(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) withInteractiveSession(t, false) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) stub := &launcherSingleRunner{} restore := OverrideIntegration("stubapp", stub) defer restore() oldSelector := DefaultSingleSelector defer func() { DefaultSingleSelector = oldSelector }() DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) { t.Fatal("selector should not be called for headless --yes without saved model") return "", nil } cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {}) cmd.SetArgs([]string{"stubapp", "--yes"}) err := cmd.Execute() if err == nil { t.Fatal("expected launch command to fail when --yes is used headlessly without --model") } if !strings.Contains(err.Error(), "requires --model ") { t.Fatalf("expected actionable --model guidance, got %v", err) } if stub.ranModel != "" { t.Fatalf("expected launch to abort before run, got %q", stub.ranModel) } }