package launch import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "net/url" "slices" "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/ollama/ollama/api" ) type stubEditorRunner struct { edited [][]string ranModel string editErr error } func (s *stubEditorRunner) Run(model string, args []string) error { s.ranModel = model return nil } func (s *stubEditorRunner) String() string { return "StubEditor" } func (s *stubEditorRunner) Paths() []string { return nil } func (s *stubEditorRunner) Edit(models []string) error { if s.editErr != nil { return s.editErr } cloned := append([]string(nil), models...) s.edited = append(s.edited, cloned) return nil } func (s *stubEditorRunner) Models() []string { return nil } func TestIntegrationLookup(t *testing.T) { tests := []struct { name string input string wantFound bool wantName string }{ {"claude lowercase", "claude", true, "Claude Code"}, {"claude uppercase", "CLAUDE", true, "Claude Code"}, {"claude mixed case", "Claude", true, "Claude Code"}, {"codex", "codex", true, "Codex"}, {"droid", "droid", true, "Droid"}, {"opencode", "opencode", true, "OpenCode"}, {"unknown integration", "unknown", false, ""}, {"empty string", "", false, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, found := integrations[strings.ToLower(tt.input)] if found != tt.wantFound { t.Errorf("integrations[%q] found = %v, want %v", tt.input, found, tt.wantFound) } if found && r.String() != tt.wantName { t.Errorf("integrations[%q].String() = %q, want %q", tt.input, r.String(), tt.wantName) } }) } } func TestIntegrationRegistry(t *testing.T) { expectedIntegrations := []string{"claude", "codex", "droid", "opencode"} for _, name := range expectedIntegrations { t.Run(name, func(t *testing.T) { r, ok := integrations[name] if !ok { t.Fatalf("integration %q not found in registry", name) } if r.String() == "" { t.Error("integration.String() should not be empty") } }) } } func TestHasLocalModel(t *testing.T) { tests := []struct { name string models []string want bool }{ {"empty list", []string{}, false}, {"single local model", []string{"llama3.2"}, true}, {"single cloud model", []string{"cloud-model"}, false}, {"mixed models", []string{"cloud-model", "llama3.2"}, true}, {"multiple local models", []string{"llama3.2", "qwen2.5"}, true}, {"multiple cloud models", []string{"cloud-a", "cloud-b"}, false}, {"local model first", []string{"llama3.2", "cloud-model"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := slices.ContainsFunc(tt.models, func(m string) bool { return !strings.Contains(m, "cloud") }) if got != tt.want { t.Errorf("hasLocalModel(%v) = %v, want %v", tt.models, got, tt.want) } }) } } func TestLookupIntegration_UnknownIntegration(t *testing.T) { _, _, err := LookupIntegration("unknown-integration") if err == nil { t.Error("expected error for unknown integration, got nil") } if !strings.Contains(err.Error(), "unknown integration") { t.Errorf("error should mention 'unknown integration', got: %v", err) } } func TestIsIntegrationInstalled_UnknownIntegrationReturnsFalse(t *testing.T) { stderr := captureStderr(t, func() { if IsIntegrationInstalled("unknown-integration") { t.Fatal("expected unknown integration to report not installed") } }) if !strings.Contains(stderr, `Ollama couldn't find integration "unknown-integration", so it'll show up as not installed.`) { t.Fatalf("expected unknown-integration warning, got stderr: %q", stderr) } } func TestHasLocalModel_DocumentsHeuristic(t *testing.T) { tests := []struct { name string models []string want bool reason string }{ {"empty list", []string{}, false, "empty list has no local models"}, {"contains-cloud-substring", []string{"deepseek-r1:cloud"}, false, "model with 'cloud' substring is considered cloud"}, {"cloud-in-name", []string{"my-cloud-model"}, false, "'cloud' anywhere in name = cloud model"}, {"cloudless", []string{"cloudless-model"}, false, "'cloudless' still contains 'cloud'"}, {"local-model", []string{"llama3.2"}, true, "no 'cloud' = local"}, {"mixed", []string{"cloud-model", "llama3.2"}, true, "one local model = hasLocalModel true"}, {"all-cloud", []string{"cloud-a", "cloud-b"}, false, "all contain 'cloud'"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := slices.ContainsFunc(tt.models, func(m string) bool { return !strings.Contains(m, "cloud") }) if got != tt.want { t.Errorf("hasLocalModel(%v) = %v, want %v (%s)", tt.models, got, tt.want, tt.reason) } }) } } func TestAllIntegrations_HaveRequiredMethods(t *testing.T) { for name, r := range integrations { t.Run(name, func(t *testing.T) { displayName := r.String() if displayName == "" { t.Error("String() should not return empty") } var _ func(string, []string) error = r.Run }) } } func TestParseArgs(t *testing.T) { // Tests reflect cobra's ArgsLenAtDash() semantics: // - cobra strips "--" from args // - ArgsLenAtDash() returns the index where "--" was, or -1 tests := []struct { name string args []string // args as cobra delivers them (no "--") dashIdx int // what ArgsLenAtDash() returns wantName string wantArgs []string wantErr bool }{ { name: "no extra args, no dash", args: []string{"claude"}, dashIdx: -1, wantName: "claude", }, { name: "with extra args after --", args: []string{"codex", "-p", "myprofile"}, dashIdx: 1, wantName: "codex", wantArgs: []string{"-p", "myprofile"}, }, { name: "extra args only after --", args: []string{"codex", "--sandbox", "workspace-write"}, dashIdx: 1, wantName: "codex", wantArgs: []string{"--sandbox", "workspace-write"}, }, { name: "-- at end with no args after", args: []string{"claude"}, dashIdx: 1, wantName: "claude", }, { name: "-- with no integration name", args: []string{"--verbose"}, dashIdx: 0, wantName: "", wantArgs: []string{"--verbose"}, }, { name: "multiple args before -- is error", args: []string{"claude", "codex", "--verbose"}, dashIdx: 2, wantErr: true, }, { name: "multiple args without -- is error", args: []string{"claude", "codex"}, dashIdx: -1, wantErr: true, }, { name: "no args, no dash", args: []string{}, dashIdx: -1, wantName: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Simulate the parsing logic from LaunchCmd using dashIdx var name string var parsedArgs []string var err error dashIdx := tt.dashIdx args := tt.args if dashIdx == -1 { if len(args) > 1 { err = fmt.Errorf("unexpected arguments: %v", args[1:]) } else if len(args) == 1 { name = args[0] } } else { if dashIdx > 1 { err = fmt.Errorf("expected at most 1 integration name before '--', got %d", dashIdx) } else { if dashIdx == 1 { name = args[0] } parsedArgs = args[dashIdx:] } } if tt.wantErr { if err == nil { t.Fatal("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if name != tt.wantName { t.Errorf("name = %q, want %q", name, tt.wantName) } if !slices.Equal(parsedArgs, tt.wantArgs) { t.Errorf("args = %v, want %v", parsedArgs, tt.wantArgs) } }) } } func TestIsCloudModel(t *testing.T) { // isCloudModel now only uses Show API, so nil client always returns false t.Run("nil client returns false", func(t *testing.T) { models := []string{"glm-5:cloud", "kimi-k2.5:cloud", "local-model"} for _, model := range models { if isCloudModel(context.Background(), nil, model) { t.Errorf("isCloudModel(%q) with nil client should return false", model) } } }) } func names(items []ModelItem) []string { var out []string for _, item := range items { out = append(out, item.Name) } return out } func TestBuildModelList_NoExistingModels(t *testing.T) { items, _, _, _ := buildModelList(nil, nil, "") want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "glm-4.7-flash", "qwen3.5"} if diff := cmp.Diff(want, names(items)); diff != "" { t.Errorf("with no existing models, items should be recommended in order (-want +got):\n%s", diff) } for _, item := range items { if strings.HasSuffix(item.Name, ":cloud") { if strings.HasSuffix(item.Description, "(not downloaded)") { t.Errorf("cloud model %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description) } } else { if !strings.HasSuffix(item.Description, "(not downloaded)") { t.Errorf("item %q should have description ending with '(not downloaded)', got %q", item.Name, item.Description) } } } } func TestBuildModelList_OnlyLocalModels_CloudRecsAtBottom(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, {Name: "qwen2.5:latest", Remote: false}, } items, _, _, _ := buildModelList(existing, nil, "") got := names(items) // Recommended pinned at top (local recs first, then cloud recs when only-local), then installed non-recs want := []string{"glm-4.7-flash", "qwen3.5", "kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "llama3.2", "qwen2.5"} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("recs pinned at top, local recs before cloud recs (-want +got):\n%s", diff) } } func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, {Name: "glm-5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, nil, "") got := names(items) // All recs pinned at top (cloud before local in mixed case), then non-recs want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "glm-4.7-flash", "qwen3.5", "llama3.2"} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("recs pinned at top, cloud recs first in mixed case (-want +got):\n%s", diff) } } func TestBuildModelList_PreCheckedFirst(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, {Name: "glm-5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "") got := names(items) if got[0] != "llama3.2" { t.Errorf("pre-checked model should be first, got %v", got) } } func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) { existing := []modelInfo{ {Name: "glm-4.7-flash", Remote: false}, {Name: "glm-5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, nil, "") for _, item := range items { switch item.Name { case "glm-4.7-flash", "glm-5:cloud": if strings.HasSuffix(item.Description, "(not downloaded)") { t.Errorf("installed recommended %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description) } case "qwen3.5": if !strings.HasSuffix(item.Description, "(not downloaded)") { t.Errorf("non-installed recommended %q should have '(not downloaded)' suffix, got %q", item.Name, item.Description) } case "minimax-m2.7:cloud", "kimi-k2.5:cloud", "qwen3.5:cloud": if strings.HasSuffix(item.Description, "(not downloaded)") { t.Errorf("cloud model %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description) } } } } func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) { existing := []modelInfo{ {Name: "glm-4.7-flash", Remote: false}, {Name: "glm-5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, nil, "") got := names(items) // glm-4.7-flash and glm-5:cloud are installed so they sort normally; // kimi-k2.5:cloud, qwen3.5:cloud, and qwen3.5 are not installed so they go to the bottom // All recs: cloud first in mixed case, then local, in rec order within each want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "glm-4.7-flash", "qwen3.5"} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("all recs, cloud first in mixed case (-want +got):\n%s", diff) } } func TestBuildModelList_HasRecommendedCloudModel_OnlyNonInstalledAtBottom(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, {Name: "kimi-k2.5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, nil, "") got := names(items) // kimi-k2.5:cloud is installed so it sorts normally; // the rest of the recommendations are not installed so they go to the bottom // All recs pinned at top (cloud first in mixed case), then non-recs want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "glm-4.7-flash", "qwen3.5", "llama3.2"} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("recs pinned at top, cloud first in mixed case (-want +got):\n%s", diff) } for _, item := range items { isCloud := strings.HasSuffix(item.Name, ":cloud") isInstalled := slices.Contains([]string{"kimi-k2.5:cloud", "llama3.2"}, item.Name) if isInstalled || isCloud { if strings.HasSuffix(item.Description, "(not downloaded)") { t.Errorf("installed or cloud model %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description) } } else { if !strings.HasSuffix(item.Description, "(not downloaded)") { t.Errorf("non-installed %q should have '(not downloaded)' suffix, got %q", item.Name, item.Description) } } } } func TestBuildModelList_LatestTagStripped(t *testing.T) { existing := []modelInfo{ {Name: "glm-4.7-flash:latest", Remote: false}, {Name: "llama3.2:latest", Remote: false}, } items, _, existingModels, _ := buildModelList(existing, nil, "") got := names(items) // :latest should be stripped from display names for _, name := range got { if strings.HasSuffix(name, ":latest") { t.Errorf("name %q should not have :latest suffix", name) } } // glm-4.7-flash should not be duplicated (existing :latest matches the recommendation) count := 0 for _, name := range got { if name == "glm-4.7-flash" { count++ } } if count != 1 { t.Errorf("glm-4.7-flash should appear exactly once, got %d in %v", count, got) } // Stripped name should be in existingModels so it won't be pulled if !existingModels["glm-4.7-flash"] { t.Error("glm-4.7-flash should be in existingModels") } } func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, {Name: "glm-5:cloud", Remote: true}, } _, _, existingModels, cloudModels := buildModelList(existing, nil, "") if !existingModels["llama3.2"] { t.Error("llama3.2 should be in existingModels") } if !existingModels["glm-5:cloud"] { t.Error("glm-5:cloud should be in existingModels") } if existingModels["glm-4.7-flash"] { t.Error("glm-4.7-flash should not be in existingModels (it's a recommendation)") } if !cloudModels["glm-5:cloud"] { t.Error("glm-5:cloud should be in cloudModels") } if !cloudModels["kimi-k2.5:cloud"] { t.Error("kimi-k2.5:cloud should be in cloudModels (recommended cloud)") } if !cloudModels["qwen3.5:cloud"] { t.Error("qwen3.5:cloud should be in cloudModels (recommended cloud)") } if cloudModels["llama3.2"] { t.Error("llama3.2 should not be in cloudModels") } } func TestBuildModelList_RecommendedFieldSet(t *testing.T) { existing := []modelInfo{ {Name: "glm-4.7-flash", Remote: false}, {Name: "llama3.2:latest", Remote: false}, } items, _, _, _ := buildModelList(existing, nil, "") for _, item := range items { switch item.Name { case "glm-4.7-flash", "qwen3.5", "glm-5:cloud", "kimi-k2.5:cloud", "qwen3.5:cloud": if !item.Recommended { t.Errorf("%q should have Recommended=true", item.Name) } case "llama3.2": if item.Recommended { t.Errorf("%q should have Recommended=false", item.Name) } } } } func TestBuildModelList_MixedCase_CloudRecsFirst(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, {Name: "glm-5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, nil, "") got := names(items) // Cloud recs should sort before local recs in mixed case cloudIdx := slices.Index(got, "glm-5:cloud") localIdx := slices.Index(got, "glm-4.7-flash") if cloudIdx > localIdx { t.Errorf("cloud recs should be before local recs in mixed case, got %v", got) } } func TestBuildModelList_OnlyLocal_LocalRecsFirst(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, } items, _, _, _ := buildModelList(existing, nil, "") got := names(items) // Local recs should sort before cloud recs in only-local case localIdx := slices.Index(got, "glm-4.7-flash") cloudIdx := slices.Index(got, "glm-5:cloud") if localIdx > cloudIdx { t.Errorf("local recs should be before cloud recs in only-local case, got %v", got) } } func TestBuildModelList_RecsAboveNonRecs(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, {Name: "custom-model", Remote: false}, } items, _, _, _ := buildModelList(existing, nil, "") got := names(items) // All recommended models should appear before non-recommended installed models lastRecIdx := -1 firstNonRecIdx := len(got) for i, name := range got { isRec := name == "glm-4.7-flash" || name == "qwen3.5" || name == "minimax-m2.7:cloud" || name == "glm-5:cloud" || name == "kimi-k2.5:cloud" || name == "qwen3.5:cloud" if isRec && i > lastRecIdx { lastRecIdx = i } if !isRec && i < firstNonRecIdx { firstNonRecIdx = i } } if lastRecIdx > firstNonRecIdx { t.Errorf("all recs should be above non-recs, got %v", got) } } func TestBuildModelList_CheckedBeforeRecs(t *testing.T) { existing := []modelInfo{ {Name: "llama3.2:latest", Remote: false}, {Name: "glm-5:cloud", Remote: true}, } items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "") got := names(items) if got[0] != "llama3.2" { t.Errorf("checked model should be first even before recs, got %v", got) } } func TestBuildModelList_CurrentPrefersExactLocalOverCloudPrefix(t *testing.T) { existing := []modelInfo{ {Name: "qwen3.5:cloud", Remote: true}, {Name: "qwen3.5", Remote: false}, } _, orderedChecked, _, _ := buildModelList(existing, []string{"qwen3.5", "qwen3.5:cloud"}, "qwen3.5") if len(orderedChecked) < 2 { t.Fatalf("expected orderedChecked to preserve both selections, got %v", orderedChecked) } if orderedChecked[0] != "qwen3.5" { t.Fatalf("expected exact local current to stay first, got %v", orderedChecked) } } func TestBuildModelList_CurrentPrefersExactCloudOverLocalPrefix(t *testing.T) { existing := []modelInfo{ {Name: "qwen3.5", Remote: false}, {Name: "qwen3.5:cloud", Remote: true}, } _, orderedChecked, _, _ := buildModelList(existing, []string{"qwen3.5:cloud", "qwen3.5"}, "qwen3.5:cloud") if len(orderedChecked) < 2 { t.Fatalf("expected orderedChecked to preserve both selections, got %v", orderedChecked) } if orderedChecked[0] != "qwen3.5:cloud" { t.Fatalf("expected exact cloud current to stay first, got %v", orderedChecked) } } func TestEditorIntegration_SavedConfigSkipsSelection(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) // Save a config for opencode so it looks like a previous launch if err := SaveIntegration("opencode", []string{"llama3.2"}); err != nil { t.Fatal(err) } // Verify loadIntegration returns the saved models saved, err := LoadIntegration("opencode") if err != nil { t.Fatal(err) } if len(saved.Models) == 0 { t.Fatal("expected saved models") } if saved.Models[0] != "llama3.2" { t.Errorf("expected llama3.2, got %s", saved.Models[0]) } } func TestLauncherClientFilterDisabledCloudModels_ChecksStatusOncePerInvocation(t *testing.T) { var statusCalls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/status": statusCalls++ fmt.Fprintf(w, `{"cloud":{"disabled":true,"source":"config"}}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u, _ := url.Parse(srv.URL) client := &launcherClient{ apiClient: api.NewClient(u, srv.Client()), } filtered := client.filterDisabledCloudModels(context.Background(), []string{"llama3.2", "glm-5:cloud", "qwen3.5:cloud"}) if diff := cmp.Diff([]string{"llama3.2"}, filtered); diff != "" { t.Fatalf("filtered models mismatch (-want +got):\n%s", diff) } if statusCalls != 1 { t.Fatalf("expected one cloud status lookup, got %d", statusCalls) } } func TestPrepareEditorIntegration_SavesOnlyAfterSuccessfulEdit(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) if err := SaveIntegration("droid", []string{"existing-model"}); err != nil { t.Fatalf("failed to seed config: %v", err) } editor := &stubEditorRunner{editErr: errors.New("boom")} err := prepareEditorIntegration("droid", editor, editor, []string{"new-model"}) if err == nil || !strings.Contains(err.Error(), "setup failed") { t.Fatalf("expected setup failure, got %v", err) } saved, err := LoadIntegration("droid") if err != nil { t.Fatalf("failed to reload saved config: %v", err) } if diff := cmp.Diff([]string{"existing-model"}, saved.Models); diff != "" { t.Fatalf("saved models mismatch (-want +got):\n%s", diff) } } func TestShowOrPull_ModelExists(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/show" { w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"model":"test-model"}`) return } w.WriteHeader(http.StatusNotFound) })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := showOrPullWithPolicy(context.Background(), client, "test-model", missingModelPromptPull, false) if err != nil { t.Errorf("showOrPull should return nil when model exists, got: %v", err) } } func TestShowOrPullWithPolicy_ModelExists(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/show" { w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"model":"test-model"}`) return } w.WriteHeader(http.StatusNotFound) })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := showOrPullWithPolicy(context.Background(), client, "test-model", missingModelFail, false) if err != nil { t.Errorf("showOrPullWithPolicy should return nil when model exists, got: %v", err) } } func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T) { oldHook := DefaultConfirmPrompt DefaultConfirmPrompt = func(prompt string) (bool, error) { t.Fatal("confirm prompt should not be called with fail policy") return false, nil } defer func() { DefaultConfirmPrompt = oldHook }() 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.Fprintf(w, `{"error":"model not found"}`) case "/api/pull": pullCalled = true w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"status":"success"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := showOrPullWithPolicy(context.Background(), client, "missing-model", missingModelFail, false) if err == nil { t.Fatal("expected fail policy to return an error for missing model") } if !strings.Contains(err.Error(), "ollama pull missing-model") { t.Fatalf("expected actionable pull guidance, got: %v", err) } if pullCalled { t.Fatal("expected pull not to be called with fail policy") } } func TestShowOrPullWithPolicy_ModelNotFound_PromptPolicyPulls(t *testing.T) { oldHook := DefaultConfirmPrompt DefaultConfirmPrompt = func(prompt string) (bool, error) { if !strings.Contains(prompt, "missing-model") { t.Fatalf("expected prompt to mention missing model, got %q", prompt) } return true, nil } defer func() { DefaultConfirmPrompt = oldHook }() 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.Fprintf(w, `{"error":"model not found"}`) case "/api/pull": pullCalled = true w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"status":"success"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := showOrPullWithPolicy(context.Background(), client, "missing-model", missingModelPromptPull, false) if err != nil { t.Fatalf("expected prompt policy to pull and succeed, got %v", err) } if !pullCalled { t.Fatal("expected pull to be called with prompt policy") } } func TestShowOrPullWithPolicy_ModelNotFound_AutoPullPolicyPullsWithoutPrompt(t *testing.T) { oldHook := DefaultConfirmPrompt DefaultConfirmPrompt = func(prompt string) (bool, error) { t.Fatalf("confirm prompt should not be called with auto-pull policy: %q", prompt) return false, nil } defer func() { DefaultConfirmPrompt = oldHook }() 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.Fprintf(w, `{"error":"model not found"}`) case "/api/pull": pullCalled = true w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"status":"success"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := showOrPullWithPolicy(context.Background(), client, "missing-model", missingModelAutoPull, false) if err != nil { t.Fatalf("expected auto-pull policy to pull and succeed, got %v", err) } if !pullCalled { t.Fatal("expected pull to be called with auto-pull policy") } } func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *testing.T) { oldHook := DefaultConfirmPrompt DefaultConfirmPrompt = func(prompt string) (bool, error) { t.Fatal("confirm prompt should not be called for explicit cloud models") return false, nil } defer func() { DefaultConfirmPrompt = oldHook }() for _, policy := range []missingModelPolicy{missingModelPromptPull, missingModelAutoPull, missingModelFail} { t.Run(fmt.Sprintf("policy=%d", policy), func(t *testing.T) { 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.Fprintf(w, `{"error":"model not found"}`) case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, `{"error":"not found"}`) case "/api/pull": pullCalled = true w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"status":"success"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := showOrPullWithPolicy(context.Background(), client, "glm-5:cloud", policy, true) if err == nil { t.Fatalf("expected cloud model not-found error for policy %d", policy) } if !strings.Contains(err.Error(), `model "glm-5:cloud" not found`) { t.Fatalf("expected not-found error for policy %d, got %v", policy, err) } if pullCalled { t.Fatalf("expected pull not to be called for cloud model with policy %d", policy) } }) } } func TestShowOrPullWithPolicy_CloudModelDisabled_FailsWithCloudDisabledError(t *testing.T) { oldHook := DefaultConfirmPrompt DefaultConfirmPrompt = func(prompt string) (bool, error) { t.Fatal("confirm prompt should not be called for explicit cloud models") return false, nil } defer func() { DefaultConfirmPrompt = oldHook }() for _, policy := range []missingModelPolicy{missingModelPromptPull, missingModelAutoPull, missingModelFail} { t.Run(fmt.Sprintf("policy=%d", policy), func(t *testing.T) { 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.Fprintf(w, `{"error":"model not found"}`) case "/api/status": w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"cloud":{"disabled":true,"source":"config"}}`) case "/api/pull": pullCalled = true w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"status":"success"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := showOrPullWithPolicy(context.Background(), client, "glm-5:cloud", policy, true) if err == nil { t.Fatalf("expected cloud disabled error for policy %d", policy) } if !strings.Contains(err.Error(), "remote inference is unavailable") { t.Fatalf("expected cloud disabled error for policy %d, got %v", policy, err) } if pullCalled { t.Fatalf("expected pull not to be called for cloud model with policy %d", policy) } }) } } func TestShowOrPull_ModelNotFound_NoTerminal(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, `{"error":"model not found"}`) })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) // confirmPrompt will fail in test (no terminal), so showOrPull should return an error err := showOrPullWithPolicy(context.Background(), client, "missing-model", missingModelPromptPull, false) if err == nil { t.Error("showOrPull should return error when model not found and no terminal available") } } func TestShowOrPull_ShowCalledWithCorrectModel(t *testing.T) { var receivedModel string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/show" { var req api.ShowRequest if err := json.NewDecoder(r.Body).Decode(&req); err == nil { receivedModel = req.Model } w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"model":"%s"}`, receivedModel) return } w.WriteHeader(http.StatusNotFound) })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) _ = showOrPullWithPolicy(context.Background(), client, "qwen3.5", missingModelPromptPull, false) if receivedModel != "qwen3.5" { t.Errorf("expected Show to be called with %q, got %q", "qwen3.5", receivedModel) } } func TestShowOrPull_ModelNotFound_ConfirmYes_Pulls(t *testing.T) { // Set up hook so confirmPrompt doesn't need a terminal oldHook := DefaultConfirmPrompt DefaultConfirmPrompt = func(prompt string) (bool, error) { if !strings.Contains(prompt, "missing-model") { t.Errorf("expected prompt to contain model name, got %q", prompt) } return true, nil } defer func() { DefaultConfirmPrompt = oldHook }() 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.Fprintf(w, `{"error":"model not found"}`) case "/api/pull": pullCalled = true w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"status":"success"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := showOrPullWithPolicy(context.Background(), client, "missing-model", missingModelPromptPull, false) if err != nil { t.Errorf("ShowOrPull should succeed after pull, got: %v", err) } if !pullCalled { t.Error("expected pull to be called when user confirms download") } } func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) { oldHook := DefaultConfirmPrompt DefaultConfirmPrompt = func(prompt string) (bool, error) { return false, ErrCancelled } defer func() { DefaultConfirmPrompt = oldHook }() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/show": w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, `{"error":"model not found"}`) case "/api/pull": t.Error("pull should not be called when user declines") default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := showOrPullWithPolicy(context.Background(), client, "missing-model", missingModelPromptPull, false) if err == nil { t.Error("ShowOrPull should return error when user declines") } } func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) { // Confirm prompt should NOT be called for explicit cloud models oldHook := DefaultConfirmPrompt DefaultConfirmPrompt = func(prompt string) (bool, error) { t.Error("confirm prompt should not be called for cloud models") return false, nil } defer func() { DefaultConfirmPrompt = oldHook }() 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.Fprintf(w, `{"error":"model not found"}`) case "/api/pull": pullCalled = true w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"status":"success"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := showOrPullWithPolicy(context.Background(), client, "glm-5:cloud", missingModelPromptPull, true) if err == nil { t.Error("ShowOrPull should return not-found error for cloud model") } if !strings.Contains(err.Error(), `model "glm-5:cloud" not found`) { t.Errorf("expected cloud model not-found error, got: %v", err) } if pullCalled { t.Error("expected pull not to be called for cloud model") } } func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) { // Confirm prompt should NOT be called for explicit cloud models oldHook := DefaultConfirmPrompt DefaultConfirmPrompt = func(prompt string) (bool, error) { t.Error("confirm prompt should not be called for cloud models") return false, nil } defer func() { DefaultConfirmPrompt = oldHook }() 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.Fprintf(w, `{"error":"model not found"}`) case "/api/pull": pullCalled = true w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"status":"success"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := showOrPullWithPolicy(context.Background(), client, "gpt-oss:20b-cloud", missingModelPromptPull, true) if err == nil { t.Error("ShowOrPull should return not-found error for cloud model") } if !strings.Contains(err.Error(), `model "gpt-oss:20b-cloud" not found`) { t.Errorf("expected cloud model not-found error, got: %v", err) } if pullCalled { t.Error("expected pull not to be called for cloud model") } } func TestConfirmPrompt_DelegatesToHook(t *testing.T) { oldHook := DefaultConfirmPrompt var hookCalled bool DefaultConfirmPrompt = func(prompt string) (bool, error) { hookCalled = true if prompt != "test prompt?" { t.Errorf("expected prompt %q, got %q", "test prompt?", prompt) } return true, nil } defer func() { DefaultConfirmPrompt = oldHook }() ok, err := ConfirmPrompt("test prompt?") if err != nil { t.Errorf("unexpected error: %v", err) } if !ok { t.Error("expected true from hook") } if !hookCalled { t.Error("expected DefaultConfirmPrompt hook to be called") } } func TestEnsureAuth_NoCloudModels(t *testing.T) { // ensureAuth should be a no-op when no cloud models are selected srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("no API calls expected when no cloud models selected") })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := ensureAuth(context.Background(), client, map[string]bool{}, []string{"local-model"}) if err != nil { t.Errorf("ensureAuth should return nil for non-cloud models, got: %v", err) } } func TestEnsureAuth_CloudModelFilteredCorrectly(t *testing.T) { // ensureAuth should only care about models in cloudModels map var whoamiCalled bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/me" { whoamiCalled = true w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"name":"testuser"}`) return } w.WriteHeader(http.StatusNotFound) })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) cloudModels := map[string]bool{"cloud-model:cloud": true} selected := []string{"cloud-model:cloud", "local-model"} err := ensureAuth(context.Background(), client, cloudModels, selected) if err != nil { t.Errorf("ensureAuth should succeed when user is authenticated, got: %v", err) } if !whoamiCalled { t.Error("expected whoami to be called for cloud model") } } func TestEnsureAuth_SkipsWhenNoCloudSelected(t *testing.T) { var whoamiCalled bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/me" { whoamiCalled = true } w.WriteHeader(http.StatusOK) })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) // cloudModels has entries but none are in selected cloudModels := map[string]bool{"cloud-model:cloud": true} selected := []string{"local-model"} err := ensureAuth(context.Background(), client, cloudModels, selected) if err != nil { t.Errorf("expected nil error, got: %v", err) } if whoamiCalled { t.Error("whoami should not be called when no cloud models are selected") } } func TestEnsureAuth_PreservesCancelledSignInHook(t *testing.T) { oldSignIn := DefaultSignIn DefaultSignIn = func(modelName, signInURL string) (string, error) { return "", ErrCancelled } defer func() { DefaultSignIn = oldSignIn }() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, `{"error":"not found"}`) case "/api/me": w.WriteHeader(http.StatusUnauthorized) fmt.Fprintf(w, `{"error":"unauthorized","signin_url":"https://example.com/signin"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := ensureAuth(context.Background(), client, map[string]bool{"cloud-model:cloud": true}, []string{"cloud-model:cloud"}) if !errors.Is(err, ErrCancelled) { t.Fatalf("expected ErrCancelled, got %v", err) } } func TestEnsureAuth_DeclinedFallbackReturnsCancelled(t *testing.T) { oldConfirm := DefaultConfirmPrompt DefaultConfirmPrompt = func(prompt string) (bool, error) { return false, nil } defer func() { DefaultConfirmPrompt = oldConfirm }() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, `{"error":"not found"}`) case "/api/me": w.WriteHeader(http.StatusUnauthorized) fmt.Fprintf(w, `{"error":"unauthorized","signin_url":"https://example.com/signin"}`) default: w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u, _ := url.Parse(srv.URL) client := api.NewClient(u, srv.Client()) err := ensureAuth(context.Background(), client, map[string]bool{"cloud-model:cloud": true}, []string{"cloud-model:cloud"}) if !errors.Is(err, ErrCancelled) { t.Fatalf("expected ErrCancelled, got %v", err) } } func TestHyperlink(t *testing.T) { tests := []struct { name string url string text string wantURL string wantText string }{ { name: "basic link", url: "https://example.com", text: "click here", wantURL: "https://example.com", wantText: "click here", }, { name: "url with path", url: "https://example.com/docs/install", text: "install docs", wantURL: "https://example.com/docs/install", wantText: "install docs", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := hyperlink(tt.url, tt.text) // Should contain OSC 8 escape sequences if !strings.Contains(got, "\033]8;;") { t.Error("should contain OSC 8 open sequence") } if !strings.Contains(got, tt.wantURL) { t.Errorf("should contain URL %q", tt.wantURL) } if !strings.Contains(got, tt.wantText) { t.Errorf("should contain text %q", tt.wantText) } // Should have closing OSC 8 sequence wantSuffix := "\033]8;;\033\\" if !strings.HasSuffix(got, wantSuffix) { t.Error("should end with OSC 8 close sequence") } }) } } func TestIntegration_InstallHint(t *testing.T) { tests := []struct { name string input string wantEmpty bool wantURL string }{ { name: "claude has hint", input: "claude", wantURL: "https://code.claude.com/docs/en/quickstart", }, { name: "codex has hint", input: "codex", wantURL: "https://developers.openai.com/codex/cli/", }, { name: "openclaw has hint", input: "openclaw", wantURL: "https://docs.openclaw.ai", }, { name: "unknown has no hint", input: "unknown", wantEmpty: true, }, { name: "empty name has no hint", input: "", wantEmpty: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := "" integration, err := integrationFor(tt.input) if err == nil { got = integration.installHint } if tt.wantEmpty { if got != "" { t.Errorf("expected empty hint, got %q", got) } return } if !strings.Contains(got, "Install from") { t.Errorf("hint should start with 'Install from', got %q", got) } if !strings.Contains(got, tt.wantURL) { t.Errorf("hint should contain URL %q, got %q", tt.wantURL, got) } // Should be a clickable hyperlink if !strings.Contains(got, "\033]8;;") { t.Error("hint URL should be wrapped in OSC 8 hyperlink") } }) } } func TestListIntegrationInfos(t *testing.T) { infos := ListIntegrationInfos() t.Run("excludes aliases", func(t *testing.T) { for _, info := range infos { if integrationAliases[info.Name] { t.Errorf("alias %q should not appear in ListIntegrationInfos", info.Name) } } }) t.Run("sorted with custom order at end", func(t *testing.T) { // integrationOrder entries (cline, opencode) should appear last, in that order. // All other entries should be sorted alphabetically before them. orderRank := make(map[string]int) for i, name := range integrationOrder { orderRank[name] = i + 1 } for i := 1; i < len(infos); i++ { aRank, bRank := orderRank[infos[i-1].Name], orderRank[infos[i].Name] switch { case aRank == 0 && bRank == 0: if infos[i-1].Name >= infos[i].Name { t.Errorf("non-ordered items not sorted: %q >= %q", infos[i-1].Name, infos[i].Name) } case aRank > 0 && bRank == 0: t.Errorf("ordered item %q should come after non-ordered %q", infos[i-1].Name, infos[i].Name) case aRank > 0 && bRank > 0: if aRank >= bRank { t.Errorf("ordered items wrong: %q (rank %d) before %q (rank %d)", infos[i-1].Name, aRank, infos[i].Name, bRank) } } } }) t.Run("all fields populated", func(t *testing.T) { for _, info := range infos { if info.Name == "" { t.Error("Name should not be empty") } if info.DisplayName == "" { t.Errorf("DisplayName for %q should not be empty", info.Name) } } }) t.Run("includes known integrations", func(t *testing.T) { known := map[string]bool{"claude": false, "codex": false, "opencode": false} for _, info := range infos { if _, ok := known[info.Name]; ok { known[info.Name] = true } } for name, found := range known { if !found { t.Errorf("expected %q in ListIntegrationInfos", name) } } }) } func TestBuildModelList_Descriptions(t *testing.T) { t.Run("installed recommended has base description", func(t *testing.T) { existing := []modelInfo{ {Name: "qwen3.5", Remote: false}, } items, _, _, _ := buildModelList(existing, nil, "") for _, item := range items { if item.Name == "qwen3.5" { if strings.HasSuffix(item.Description, "install?") { t.Errorf("installed model should not have 'install?' suffix, got %q", item.Description) } if item.Description == "" { t.Error("installed recommended model should have a description") } return } } t.Error("qwen3.5 not found in items") }) t.Run("not-installed local rec has VRAM in description", func(t *testing.T) { items, _, _, _ := buildModelList(nil, nil, "") for _, item := range items { if item.Name == "qwen3.5" { if !strings.Contains(item.Description, "~11GB") { t.Errorf("not-installed qwen3.5 should show VRAM hint, got %q", item.Description) } return } } t.Error("qwen3.5 not found in items") }) t.Run("installed local rec omits VRAM", func(t *testing.T) { existing := []modelInfo{ {Name: "qwen3.5", Remote: false}, } items, _, _, _ := buildModelList(existing, nil, "") for _, item := range items { if item.Name == "qwen3.5" { if strings.Contains(item.Description, "~11GB") { t.Errorf("installed qwen3.5 should not show VRAM hint, got %q", item.Description) } return } } t.Error("qwen3.5 not found in items") }) } func TestIntegration_Editor(t *testing.T) { tests := []struct { name string want bool }{ {"droid", true}, {"opencode", true}, {"openclaw", true}, {"claude", false}, {"codex", false}, {"nonexistent", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := false integration, err := integrationFor(tt.name) if err == nil { got = integration.editor } if got != tt.want { t.Errorf("integrationFor(%q).editor = %v, want %v", tt.name, got, tt.want) } }) } } func TestIntegrationModels(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Run("returns nil when not configured", func(t *testing.T) { if got := IntegrationModels("droid"); got != nil { t.Errorf("expected nil, got %v", got) } }) t.Run("returns all saved models", func(t *testing.T) { if err := SaveIntegration("droid", []string{"llama3.2", "qwen3.5"}); err != nil { t.Fatal(err) } got := IntegrationModels("droid") want := []string{"llama3.2", "qwen3.5"} if diff := cmp.Diff(want, got); diff != "" { t.Errorf("IntegrationModels mismatch (-want +got):\n%s", diff) } }) }