package launch import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "slices" "strings" "testing" "github.com/ollama/ollama/cmd/config" ) type launcherEditorRunner struct { paths []string edited [][]string ranModel string } func (r *launcherEditorRunner) Run(model string, args []string) error { r.ranModel = model return nil } func (r *launcherEditorRunner) String() string { return "LauncherEditor" } func (r *launcherEditorRunner) Paths() []string { return r.paths } func (r *launcherEditorRunner) Edit(models []string) error { r.edited = append(r.edited, append([]string(nil), models...)) return nil } func (r *launcherEditorRunner) Models() []string { return nil } type launcherSingleRunner struct { ranModel string } func (r *launcherSingleRunner) Run(model string, args []string) error { r.ranModel = model return nil } func (r *launcherSingleRunner) String() string { return "StubSingle" } func setLaunchTestHome(t *testing.T, dir string) { t.Helper() t.Setenv("HOME", dir) t.Setenv("TMPDIR", dir) t.Setenv("USERPROFILE", dir) } func writeFakeBinary(t *testing.T, dir, name string) { t.Helper() path := filepath.Join(dir, name) data := []byte("#!/bin/sh\nexit 0\n") if runtime.GOOS == "windows" { path += ".cmd" data = []byte("@echo off\r\nexit /b 0\r\n") } if err := os.WriteFile(path, data, 0o755); err != nil { t.Fatalf("failed to write fake binary: %v", err) } } func withIntegrationOverride(t *testing.T, name string, runner Runner) { t.Helper() restore := OverrideIntegration(name, runner) t.Cleanup(restore) } func withInteractiveSession(t *testing.T, interactive bool) { t.Helper() old := isInteractiveSession isInteractiveSession = func() bool { return interactive } t.Cleanup(func() { isInteractiveSession = old }) } func withLauncherHooks(t *testing.T) { t.Helper() oldSingle := DefaultSingleSelector oldMulti := DefaultMultiSelector oldConfirm := DefaultConfirmPrompt oldSignIn := DefaultSignIn t.Cleanup(func() { DefaultSingleSelector = oldSingle DefaultMultiSelector = oldMulti DefaultConfirmPrompt = oldConfirm DefaultSignIn = oldSignIn }) } func TestDefaultLaunchPolicy(t *testing.T) { tests := []struct { name string interactive bool yes bool want LaunchPolicy }{ { name: "interactive default prompts and prompt-pull", interactive: true, yes: false, want: LaunchPolicy{Confirm: LaunchConfirmPrompt, MissingModel: LaunchMissingModelPromptToPull}, }, { name: "headless without yes requires yes and fail-missing", interactive: false, yes: false, want: LaunchPolicy{Confirm: LaunchConfirmRequireYes, MissingModel: LaunchMissingModelFail}, }, { name: "interactive yes auto-approves and auto-pulls", interactive: true, yes: true, want: LaunchPolicy{Confirm: LaunchConfirmAutoApprove, MissingModel: LaunchMissingModelAutoPull}, }, { name: "headless yes auto-approves and auto-pulls", interactive: false, yes: true, want: LaunchPolicy{Confirm: LaunchConfirmAutoApprove, MissingModel: LaunchMissingModelAutoPull}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := defaultLaunchPolicy(tt.interactive, tt.yes) if got != tt.want { t.Fatalf("defaultLaunchPolicy(%v, %v) = %+v, want %+v", tt.interactive, tt.yes, got, tt.want) } }) } } func TestBuildLauncherState_InstalledAndCloudDisabled(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) binDir := t.TempDir() writeFakeBinary(t, binDir, "opencode") t.Setenv("PATH", binDir) if err := config.SetLastModel("glm-5:cloud"); err != nil { t.Fatalf("failed to save last model: %v", err) } if err := config.SaveIntegration("claude", []string{"glm-5:cloud"}); err != nil { t.Fatalf("failed to save claude config: %v", err) } if err := config.SaveIntegration("opencode", []string{"glm-5:cloud", "llama3.2"}); err != nil { t.Fatalf("failed to save opencode config: %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) case "/api/status": fmt.Fprint(w, `{"cloud":{"disabled":true,"source":"config"}}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) state, err := BuildLauncherState(context.Background()) if err != nil { t.Fatalf("BuildLauncherState returned error: %v", err) } if !state.Integrations["opencode"].Installed { t.Fatal("expected opencode to be marked installed") } if state.Integrations["claude"].Installed { t.Fatal("expected claude to be marked not installed") } if state.RunModelUsable { t.Fatal("expected saved cloud run model to be unusable when cloud is disabled") } if state.Integrations["claude"].ModelUsable { t.Fatal("expected claude cloud config to be unusable when cloud is disabled") } if !state.Integrations["opencode"].ModelUsable { t.Fatal("expected editor config with a remaining local model to stay usable") } if state.Integrations["opencode"].CurrentModel != "llama3.2" { t.Fatalf("expected editor current model to fall back to remaining local model, got %q", state.Integrations["opencode"].CurrentModel) } } func TestBuildLauncherState_MigratesLegacyOpenclawAliasConfig(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) if err := config.SaveIntegration("clawdbot", []string{"llama3.2"}); err != nil { t.Fatalf("failed to seed legacy alias config: %v", err) } if err := config.SaveAliases("clawdbot", map[string]string{"primary": "llama3.2"}); err != nil { t.Fatalf("failed to seed legacy alias map: %v", err) } if err := config.MarkIntegrationOnboarded("clawdbot"); err != nil { t.Fatalf("failed to seed legacy onboarding state: %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) state, err := BuildLauncherState(context.Background()) if err != nil { t.Fatalf("BuildLauncherState returned error: %v", err) } if state.Integrations["openclaw"].CurrentModel != "llama3.2" { t.Fatalf("expected openclaw state to reuse legacy alias config, got %q", state.Integrations["openclaw"].CurrentModel) } migrated, err := config.LoadIntegration("openclaw") if err != nil { t.Fatalf("expected canonical config to be migrated, got %v", err) } if !slices.Equal(migrated.Models, []string{"llama3.2"}) { t.Fatalf("unexpected migrated models: %v", migrated.Models) } if migrated.Aliases["primary"] != "llama3.2" { t.Fatalf("expected aliases to migrate, got %v", migrated.Aliases) } if !migrated.Onboarded { t.Fatal("expected onboarding state to migrate to canonical openclaw key") } } func TestBuildLauncherState_ToleratesInventoryFailure(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) if err := config.SetLastModel("llama3.2"); err != nil { t.Fatalf("failed to seed last model: %v", err) } if err := config.SaveIntegration("claude", []string{"qwen3:8b"}); err != nil { t.Fatalf("failed to seed claude config: %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, `{"error":"temporary failure"}`) case "/api/show": var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) fmt.Fprintf(w, `{"model":%q}`, req.Model) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) state, err := BuildLauncherState(context.Background()) if err != nil { t.Fatalf("BuildLauncherState should tolerate inventory failure, got %v", err) } if !state.RunModelUsable { t.Fatal("expected saved run model to remain usable via show fallback") } if state.Integrations["claude"].CurrentModel != "qwen3:8b" { t.Fatalf("expected saved integration model to remain visible, got %q", state.Integrations["claude"].CurrentModel) } if !state.Integrations["claude"].ModelUsable { t.Fatal("expected saved integration model to remain usable via show fallback") } } func TestResolveRunModel_UsesSavedModelWithoutSelector(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) if err := config.SetLastModel("llama3.2"); err != nil { t.Fatalf("failed to save last model: %v", err) } selectorCalled := false DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { selectorCalled = true return "", nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) case "/api/show": fmt.Fprint(w, `{"model":"llama3.2"}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) model, err := ResolveRunModel(context.Background(), RunModelRequest{}) if err != nil { t.Fatalf("ResolveRunModel returned error: %v", err) } if model != "llama3.2" { t.Fatalf("expected saved model, got %q", model) } if selectorCalled { t.Fatal("selector should not be called when saved model is usable") } } func TestResolveRunModel_HeadlessYesAutoPicksLastModel(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) withInteractiveSession(t, false) if err := config.SetLastModel("missing-model"); err != nil { t.Fatalf("failed to save last model: %v", err) } DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { t.Fatal("selector should not be called in headless --yes mode") return "", nil } restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) defer restoreConfirm() pullCalled := false modelPulled := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) case "/api/show": var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) if req.Model == "missing-model" && !modelPulled { w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"model not found"}`) return } fmt.Fprintf(w, `{"model":%q}`, req.Model) case "/api/pull": pullCalled = true modelPulled = true w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"status":"success"}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) var model string stderr := captureStderr(t, func() { var err error model, err = ResolveRunModel(context.Background(), RunModelRequest{}) if err != nil { t.Fatalf("ResolveRunModel returned error: %v", err) } }) if model != "missing-model" { t.Fatalf("expected saved last model to be selected, got %q", model) } if !pullCalled { t.Fatal("expected missing saved model to be auto-pulled in headless --yes mode") } if !strings.Contains(stderr, `Headless mode: auto-selected last used model "missing-model"`) { t.Fatalf("expected headless auto-pick message in stderr, got %q", stderr) } } func TestResolveRunModel_UsesRequestPolicy(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) withInteractiveSession(t, false) if err := config.SetLastModel("missing-model"); err != nil { t.Fatalf("failed to save last model: %v", err) } DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { t.Fatal("selector should not be called when request policy enables headless auto-pick") return "", nil } pullCalled := false modelPulled := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) case "/api/show": var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) if req.Model == "missing-model" && !modelPulled { w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"model not found"}`) return } fmt.Fprintf(w, `{"model":%q}`, req.Model) case "/api/pull": pullCalled = true modelPulled = true w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"status":"success"}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) reqPolicy := LaunchPolicy{ Confirm: LaunchConfirmAutoApprove, MissingModel: LaunchMissingModelAutoPull, } model, err := ResolveRunModel(context.Background(), RunModelRequest{Policy: &reqPolicy}) if err != nil { t.Fatalf("ResolveRunModel returned error: %v", err) } if model != "missing-model" { t.Fatalf("expected saved last model to be selected, got %q", model) } if !pullCalled { t.Fatal("expected missing saved model to be auto-pulled when request policy enables auto-pull") } } func TestResolveRunModel_ForcePickerAlwaysUsesSelector(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) if err := config.SetLastModel("llama3.2"); err != nil { t.Fatalf("failed to save last model: %v", err) } var selectorCalls int DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { selectorCalls++ if current != "llama3.2" { t.Fatalf("expected current selection to be last model, got %q", current) } return "qwen3:8b", nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"llama3.2"},{"name":"qwen3:8b"}]}`) case "/api/show": fmt.Fprint(w, `{"model":"qwen3:8b"}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) model, err := ResolveRunModel(context.Background(), RunModelRequest{ForcePicker: true}) if err != nil { t.Fatalf("ResolveRunModel returned error: %v", err) } if selectorCalls != 1 { t.Fatalf("expected selector to be called once, got %d", selectorCalls) } if model != "qwen3:8b" { t.Fatalf("expected forced selection to win, got %q", model) } if got := config.LastModel(); got != "qwen3:8b" { t.Fatalf("expected last model to be updated, got %q", got) } } func TestResolveRunModel_ForcePicker_DoesNotReorderByLastModel(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) if err := config.SetLastModel("qwen3.5"); err != nil { t.Fatalf("failed to save last model: %v", err) } var gotNames []string DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { if current != "qwen3.5" { t.Fatalf("expected current selection to be last model, got %q", current) } gotNames = make([]string, 0, len(items)) for _, item := range items { gotNames = append(gotNames, item.Name) } return "qwen3.5", nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"qwen3.5"},{"name":"glm-4.7-flash"}]}`) case "/api/show": fmt.Fprint(w, `{"model":"qwen3.5"}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) _, err := ResolveRunModel(context.Background(), RunModelRequest{ForcePicker: true}) if err != nil { t.Fatalf("ResolveRunModel returned error: %v", err) } if len(gotNames) == 0 { t.Fatal("expected selector to receive model items") } glmIdx := slices.Index(gotNames, "glm-4.7-flash") qwenIdx := slices.Index(gotNames, "qwen3.5") if glmIdx == -1 || qwenIdx == -1 { t.Fatalf("expected recommended local models in selector items, got %v", gotNames) } if qwenIdx < glmIdx { t.Fatalf("expected list order to stay stable and not float last model to top, got %v", gotNames) } } func TestResolveRunModel_UsesSignInHookForCloudModel(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { return "glm-5:cloud", nil } signInCalled := false DefaultSignIn = func(modelName, signInURL string) (string, error) { signInCalled = true if modelName != "glm-5:cloud" { t.Fatalf("unexpected model passed to sign-in: %q", modelName) } return "test-user", nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[]}`) case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"not found"}`) case "/api/show": fmt.Fprint(w, `{"remote_model":"glm-5"}`) case "/api/me": w.WriteHeader(http.StatusUnauthorized) fmt.Fprint(w, `{"error":"unauthorized","signin_url":"https://example.com/signin"}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) model, err := ResolveRunModel(context.Background(), RunModelRequest{ForcePicker: true}) if err != nil { t.Fatalf("ResolveRunModel returned error: %v", err) } if model != "glm-5:cloud" { t.Fatalf("expected selected cloud model, got %q", model) } if !signInCalled { t.Fatal("expected sign-in hook to be used for cloud model") } } func TestLaunchIntegration_EditorForceConfigure(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) binDir := t.TempDir() writeFakeBinary(t, binDir, "droid") t.Setenv("PATH", binDir) editor := &launcherEditorRunner{paths: []string{"/tmp/settings.json"}} withIntegrationOverride(t, "droid", editor) var multiCalled bool DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) { multiCalled = true return []string{"llama3.2", "qwen3:8b"}, nil } var proceedPrompt bool DefaultConfirmPrompt = func(prompt string) (bool, error) { if prompt == "Proceed?" { proceedPrompt = true } return true, nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"llama3.2"},{"name":"qwen3:8b"}]}`) case "/api/show": var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) fmt.Fprintf(w, `{"model":%q}`, req.Model) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ Name: "droid", ForceConfigure: true, }); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } if !multiCalled { t.Fatal("expected multi selector to be used for forced editor configure") } if !proceedPrompt { t.Fatal("expected backup warning confirmation before edit") } if diff := compareStringSlices(editor.edited, [][]string{{"llama3.2", "qwen3:8b"}}); diff != "" { t.Fatalf("unexpected edited models (-want +got):\n%s", diff) } if editor.ranModel != "llama3.2" { t.Fatalf("expected launch to use first selected model, got %q", editor.ranModel) } saved, err := config.LoadIntegration("droid") if err != nil { t.Fatalf("failed to reload saved config: %v", err) } if diff := compareStrings(saved.Models, []string{"llama3.2", "qwen3:8b"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } } func TestLaunchIntegration_EditorForceConfigure_DoesNotFloatCheckedModelsInPicker(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) binDir := t.TempDir() writeFakeBinary(t, binDir, "droid") t.Setenv("PATH", binDir) editor := &launcherEditorRunner{} withIntegrationOverride(t, "droid", editor) if err := config.SaveIntegration("droid", []string{"qwen3.5:cloud", "qwen3.5"}); err != nil { t.Fatalf("failed to seed config: %v", err) } var gotItems []string var gotPreChecked []string DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) { for _, item := range items { gotItems = append(gotItems, item.Name) } gotPreChecked = append([]string(nil), preChecked...) return []string{"qwen3.5:cloud", "qwen3.5"}, nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"qwen3.5:cloud","remote_model":"qwen3.5"},{"name":"qwen3.5"}]}`) case "/api/show": var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) if req.Model == "qwen3.5:cloud" { fmt.Fprint(w, `{"remote_model":"qwen3.5"}`) return } fmt.Fprintf(w, `{"model":%q}`, req.Model) case "/api/me": fmt.Fprint(w, `{"name":"test-user"}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ Name: "droid", ForceConfigure: true, }); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } if len(gotItems) == 0 { t.Fatal("expected multi selector to receive items") } if gotItems[0] != "kimi-k2.5:cloud" { t.Fatalf("expected stable recommendation order with kimi-k2.5:cloud first, got %v", gotItems) } if len(gotPreChecked) < 2 { t.Fatalf("expected prechecked models to be preserved, got %v", gotPreChecked) } if gotPreChecked[0] != "qwen3.5:cloud" { t.Fatalf("expected saved default to remain first in prechecked, got %v", gotPreChecked) } } func TestLaunchIntegration_EditorModelOverridePreservesExtras(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) binDir := t.TempDir() writeFakeBinary(t, binDir, "droid") t.Setenv("PATH", binDir) editor := &launcherEditorRunner{} withIntegrationOverride(t, "droid", editor) if err := config.SaveIntegration("droid", []string{"llama3.2", "mistral"}); err != nil { t.Fatalf("failed to seed config: %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/show" { var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) fmt.Fprintf(w, `{"model":%q}`, req.Model) return } http.NotFound(w, r) })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ Name: "droid", ModelOverride: "qwen3:8b", }); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } want := []string{"qwen3:8b", "llama3.2", "mistral"} saved, err := config.LoadIntegration("droid") if err != nil { t.Fatalf("failed to reload saved config: %v", err) } if diff := compareStrings(saved.Models, want); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } if diff := compareStringSlices(editor.edited, [][]string{want}); diff != "" { t.Fatalf("unexpected edited models (-want +got):\n%s", diff) } if editor.ranModel != "qwen3:8b" { t.Fatalf("expected override model to launch first, got %q", editor.ranModel) } } func TestLaunchIntegration_EditorCloudDisabledFallsBackToSelector(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) binDir := t.TempDir() writeFakeBinary(t, binDir, "droid") t.Setenv("PATH", binDir) editor := &launcherEditorRunner{} withIntegrationOverride(t, "droid", editor) if err := config.SaveIntegration("droid", []string{"glm-5:cloud"}); err != nil { t.Fatalf("failed to seed config: %v", err) } var multiCalled bool DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) { multiCalled = true return []string{"llama3.2"}, nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/status": fmt.Fprint(w, `{"cloud":{"disabled":true,"source":"config"}}`) case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) case "/api/show": fmt.Fprint(w, `{"model":"llama3.2"}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "droid"}); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } if !multiCalled { t.Fatal("expected editor flow to reopen selector when cloud-only config is unusable") } } func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) binDir := t.TempDir() writeFakeBinary(t, binDir, "droid") t.Setenv("PATH", binDir) editor := &launcherEditorRunner{paths: []string{"/tmp/settings.json"}} withIntegrationOverride(t, "droid", editor) if err := config.SaveIntegration("droid", []string{"llama3.2", "qwen3:8b"}); err != nil { t.Fatalf("failed to seed config: %v", err) } DefaultConfirmPrompt = func(prompt string) (bool, error) { t.Fatalf("did not expect prompt during a normal editor launch: %s", prompt) return false, nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/show" { var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) fmt.Fprintf(w, `{"model":%q}`, req.Model) return } http.NotFound(w, r) })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "droid"}); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } if len(editor.edited) != 0 { t.Fatalf("expected normal launch to skip editor rewrites, got %v", editor.edited) } if editor.ranModel != "llama3.2" { t.Fatalf("expected launch to use saved primary model, got %q", editor.ranModel) } saved, err := config.LoadIntegration("droid") if err != nil { t.Fatalf("failed to reload saved config: %v", err) } if diff := compareStrings(saved.Models, []string{"llama3.2", "qwen3:8b"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } } func TestLaunchIntegration_OpenclawPreservesExistingModelList(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) binDir := t.TempDir() writeFakeBinary(t, binDir, "openclaw") t.Setenv("PATH", binDir) editor := &launcherEditorRunner{} withIntegrationOverride(t, "openclaw", editor) if err := config.SaveIntegration("openclaw", []string{"llama3.2", "mistral"}); err != nil { t.Fatalf("failed to seed config: %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/show" { var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) fmt.Fprintf(w, `{"model":%q}`, req.Model) return } http.NotFound(w, r) })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "openclaw"}); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } if len(editor.edited) != 0 { t.Fatalf("expected launch to preserve the existing OpenClaw config, got rewrites %v", editor.edited) } if editor.ranModel != "llama3.2" { t.Fatalf("expected launch to use first saved model, got %q", editor.ranModel) } saved, err := config.LoadIntegration("openclaw") if err != nil { t.Fatalf("failed to reload saved config: %v", err) } if diff := compareStrings(saved.Models, []string{"llama3.2", "mistral"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } } func TestLaunchIntegration_OpenclawInstallsBeforeConfigSideEffects(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) t.Setenv("PATH", t.TempDir()) editor := &launcherEditorRunner{} withIntegrationOverride(t, "openclaw", editor) selectorCalled := false DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) { selectorCalled = true return []string{"llama3.2"}, nil } err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "openclaw"}) if err == nil { t.Fatal("expected launch to fail before configuration when OpenClaw is missing") } if !strings.Contains(err.Error(), "required dependencies are missing") { t.Fatalf("expected install prerequisite error, got %v", err) } if selectorCalled { t.Fatal("expected install check to happen before model selection") } if len(editor.edited) != 0 { t.Fatalf("expected no editor writes before install succeeds, got %v", editor.edited) } if _, statErr := os.Stat(filepath.Join(tmpDir, ".openclaw", "openclaw.json")); !os.IsNotExist(statErr) { t.Fatalf("expected no OpenClaw config file to be created, stat err = %v", statErr) } } func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) t.Setenv("PATH", t.TempDir()) editor := &launcherEditorRunner{paths: []string{"/tmp/settings.json"}} withIntegrationOverride(t, "droid", editor) DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) { return []string{"llama3.2"}, nil } var prompts []string DefaultConfirmPrompt = func(prompt string) (bool, error) { prompts = append(prompts, prompt) if strings.Contains(prompt, "Launch LauncherEditor now?") { return false, nil } return true, nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) case "/api/show": fmt.Fprint(w, `{"model":"llama3.2"}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ Name: "droid", ForceConfigure: true, ConfigureOnly: true, }); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } if diff := compareStringSlices(editor.edited, [][]string{{"llama3.2"}}); diff != "" { t.Fatalf("unexpected edited models (-want +got):\n%s", diff) } if editor.ranModel != "" { t.Fatalf("expected configure-only flow to skip launch, got %q", editor.ranModel) } if !slices.Contains(prompts, "Proceed?") { t.Fatalf("expected editor warning prompt, got %v", prompts) } if !slices.Contains(prompts, "Launch LauncherEditor now?") { t.Fatalf("expected configure-only launch prompt, got %v", prompts) } } func TestLaunchIntegration_ClaudeSavesPrimaryModel(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) binDir := t.TempDir() writeFakeBinary(t, binDir, "claude") t.Setenv("PATH", binDir) var aliasSyncCalled bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[]}`) case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"not found"}`) case "/api/show": fmt.Fprint(w, `{"remote_model":"glm-5"}`) case "/api/me": fmt.Fprint(w, `{"name":"test-user"}`) case "/api/experimental/aliases": aliasSyncCalled = true t.Fatalf("did not expect alias sync call after removing Claude alias flow") default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ Name: "claude", ModelOverride: "glm-5:cloud", }); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } saved, err := config.LoadIntegration("claude") if err != nil { t.Fatalf("failed to reload saved config: %v", err) } if diff := compareStrings(saved.Models, []string{"glm-5:cloud"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } if aliasSyncCalled { t.Fatal("expected Claude launch flow not to sync aliases") } } func TestLaunchIntegration_ClaudeForceConfigureReprompts(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) binDir := t.TempDir() writeFakeBinary(t, binDir, "claude") t.Setenv("PATH", binDir) if err := config.SaveIntegration("claude", []string{"qwen3:8b"}); err != nil { t.Fatalf("failed to seed config: %v", err) } var selectorCalls int DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { selectorCalls++ return "glm-5:cloud", nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"qwen3:8b"}]}`) case "/api/show": fmt.Fprint(w, `{"model":"qwen3:8b"}`) case "/api/me": fmt.Fprint(w, `{"name":"test-user"}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ Name: "claude", ForceConfigure: true, }); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } if selectorCalls != 1 { t.Fatalf("expected forced configure to reprompt for model selection, got %d calls", selectorCalls) } saved, err := config.LoadIntegration("claude") if err != nil { t.Fatalf("failed to reload saved config: %v", err) } if saved.Models[0] != "glm-5:cloud" { t.Fatalf("expected saved primary to be replaced, got %q", saved.Models[0]) } } func TestLaunchIntegration_ClaudeModelOverrideSkipsSelector(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) withInteractiveSession(t, true) binDir := t.TempDir() writeFakeBinary(t, binDir, "claude") t.Setenv("PATH", binDir) var selectorCalls int DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { selectorCalls++ return "", fmt.Errorf("selector should not run when --model override is set") } var confirmCalls int DefaultConfirmPrompt = func(prompt string) (bool, error) { confirmCalls++ if !strings.Contains(prompt, "glm-4") { t.Fatalf("expected download prompt for override model, got %q", prompt) } return true, nil } 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: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ Name: "claude", ModelOverride: "glm-4", }); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } if selectorCalls != 0 { t.Fatalf("expected model override to skip selector, got %d calls", selectorCalls) } if confirmCalls == 0 { t.Fatal("expected missing override model to prompt for download in interactive mode") } if !pullCalled { t.Fatal("expected missing override model to be pulled after confirmation") } saved, err := config.LoadIntegration("claude") if err != nil { t.Fatalf("failed to reload saved config: %v", err) } if saved.Models[0] != "glm-4" { t.Fatalf("expected saved primary to match override, got %q", saved.Models[0]) } } func TestLaunchIntegration_ConfigureOnlyPrompt(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) runner := &launcherSingleRunner{} withIntegrationOverride(t, "stubsingle", runner) DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { return "llama3.2", nil } var prompts []string DefaultConfirmPrompt = func(prompt string) (bool, error) { prompts = append(prompts, prompt) if strings.Contains(prompt, "Launch StubSingle now?") { return false, nil } return true, nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) case "/api/show": fmt.Fprint(w, `{"model":"llama3.2"}`) default: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ Name: "stubsingle", ForceConfigure: true, ConfigureOnly: true, }); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } if runner.ranModel != "" { t.Fatalf("expected configure-only flow to skip launch when prompt is declined, got %q", runner.ranModel) } if !slices.Contains(prompts, "Launch StubSingle now?") { t.Fatalf("expected launch confirmation prompt, got %v", prompts) } } func TestLaunchIntegration_ModelOverrideHeadlessMissingFailsWithoutPrompt(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) withInteractiveSession(t, false) binDir := t.TempDir() writeFakeBinary(t, binDir, "droid") t.Setenv("PATH", binDir) runner := &launcherSingleRunner{} withIntegrationOverride(t, "droid", runner) confirmCalled := false DefaultConfirmPrompt = func(prompt string) (bool, error) { confirmCalled = true return true, nil } pullCalled := false 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: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ Name: "droid", ModelOverride: "missing-model", }) if err == nil { t.Fatal("expected missing model to fail in headless mode") } if !strings.Contains(err.Error(), "ollama pull missing-model") { t.Fatalf("expected actionable missing model error, got %v", err) } if confirmCalled { t.Fatal("expected no confirmation prompt in headless mode") } if pullCalled { t.Fatal("expected pull request not to run in headless mode") } if runner.ranModel != "" { t.Fatalf("expected launch to abort before running integration, got %q", runner.ranModel) } } func TestLaunchIntegration_ModelOverrideHeadlessCanOverrideMissingModelPolicy(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) withInteractiveSession(t, false) binDir := t.TempDir() writeFakeBinary(t, binDir, "droid") t.Setenv("PATH", binDir) runner := &launcherSingleRunner{} withIntegrationOverride(t, "droid", runner) confirmCalled := false DefaultConfirmPrompt = func(prompt string) (bool, error) { confirmCalled = true if !strings.Contains(prompt, "missing-model") { t.Fatalf("expected prompt to mention missing model, got %q", prompt) } return true, nil } pullCalled := false 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: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) customPolicy := LaunchPolicy{MissingModel: LaunchMissingModelPromptToPull} if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ Name: "droid", ModelOverride: "missing-model", Policy: &customPolicy, }); err != nil { t.Fatalf("expected policy override to allow prompt/pull in headless mode, got %v", err) } if !confirmCalled { t.Fatal("expected confirmation prompt when missing-model policy is overridden to prompt/pull") } if !pullCalled { t.Fatal("expected pull request to run when missing-model policy is overridden to prompt/pull") } if runner.ranModel != "missing-model" { t.Fatalf("expected integration to launch after pull, got %q", runner.ranModel) } } func TestLaunchIntegration_ModelOverrideInteractiveMissingPromptsAndPulls(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) withInteractiveSession(t, true) binDir := t.TempDir() writeFakeBinary(t, binDir, "droid") t.Setenv("PATH", binDir) runner := &launcherSingleRunner{} withIntegrationOverride(t, "droid", runner) confirmCalled := false DefaultConfirmPrompt = func(prompt string) (bool, error) { confirmCalled = true if !strings.Contains(prompt, "missing-model") { t.Fatalf("expected prompt to mention missing model, got %q", prompt) } return true, nil } pullCalled := false 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: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ Name: "droid", ModelOverride: "missing-model", }) if err != nil { t.Fatalf("expected interactive override to prompt/pull and succeed, got %v", err) } if !confirmCalled { t.Fatal("expected interactive flow to prompt before pulling missing model") } if !pullCalled { t.Fatal("expected pull request to run after interactive confirmation") } if runner.ranModel != "missing-model" { t.Fatalf("expected integration to run with pulled model, got %q", runner.ranModel) } } func TestLaunchIntegration_HeadlessSelectorFlowFailsWithoutPrompt(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) withLauncherHooks(t) withInteractiveSession(t, false) binDir := t.TempDir() writeFakeBinary(t, binDir, "droid") t.Setenv("PATH", binDir) runner := &launcherSingleRunner{} withIntegrationOverride(t, "droid", runner) DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) { return "missing-model", nil } confirmCalled := false DefaultConfirmPrompt = func(prompt string) (bool, error) { confirmCalled = true return true, nil } pullCalled := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) 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: http.NotFound(w, r) } })) defer srv.Close() t.Setenv("OLLAMA_HOST", srv.URL) err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ Name: "droid", ForceConfigure: true, }) if err == nil { t.Fatal("expected headless selector flow to fail on missing model") } if !strings.Contains(err.Error(), "ollama pull missing-model") { t.Fatalf("expected actionable missing model error, got %v", err) } if confirmCalled { t.Fatal("expected no confirmation prompt in headless selector flow") } if pullCalled { t.Fatal("expected no pull request in headless selector flow") } if runner.ranModel != "" { t.Fatalf("expected flow to abort before launch, got %q", runner.ranModel) } } type apiShowRequest struct { Model string `json:"model"` } func compareStrings(got, want []string) string { if slices.Equal(got, want) { return "" } return fmt.Sprintf("want %v got %v", want, got) } func compareStringSlices(got, want [][]string) string { if len(got) != len(want) { return fmt.Sprintf("want %v got %v", want, got) } for i := range got { if !slices.Equal(got[i], want[i]) { return fmt.Sprintf("want %v got %v", want, got) } } return "" }