From cb3d98ccb287bd4f3a82d4f9a7edc86e0980a7a2 Mon Sep 17 00:00:00 2001 From: Parth Sareen Date: Thu, 9 Jul 2026 16:55:10 -0700 Subject: [PATCH] launch: warn before launching old agent models (#17063) --- cmd/launch/command_test.go | 52 +-- cmd/launch/deprecated_models.go | 133 +++++++ cmd/launch/deprecated_models_test.go | 68 ++++ cmd/launch/launch.go | 107 +++++- cmd/launch/launch_test.go | 541 ++++++++++++++++++++++----- cmd/launch/selector_hooks.go | 24 +- cmd/tui/confirm.go | 2 +- 7 files changed, 790 insertions(+), 137 deletions(-) create mode 100644 cmd/launch/deprecated_models.go create mode 100644 cmd/launch/deprecated_models_test.go diff --git a/cmd/launch/command_test.go b/cmd/launch/command_test.go index d1f6ff30f..8a23e98a2 100644 --- a/cmd/launch/command_test.go +++ b/cmd/launch/command_test.go @@ -281,7 +281,7 @@ func TestLaunchCmdModelFlagFiltersDisabledCloudFromSavedConfig(t *testing.T) { case "/api/status": fmt.Fprintf(w, `{"cloud":{"disabled":true,"source":"config"}}`) case "/api/show": - fmt.Fprintf(w, `{"model":"llama3.2"}`) + fmt.Fprintf(w, `{"model":"sample-model"}`) default: w.WriteHeader(http.StatusNotFound) } @@ -294,7 +294,7 @@ func TestLaunchCmdModelFlagFiltersDisabledCloudFromSavedConfig(t *testing.T) { defer restore() cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {}) - cmd.SetArgs([]string{"stubeditor", "--model", "llama3.2"}) + cmd.SetArgs([]string{"stubeditor", "--model", "sample-model"}) if err := cmd.Execute(); err != nil { t.Fatalf("launch command failed: %v", err) } @@ -303,14 +303,14 @@ func TestLaunchCmdModelFlagFiltersDisabledCloudFromSavedConfig(t *testing.T) { if err != nil { t.Fatalf("failed to reload integration config: %v", err) } - if diff := cmp.Diff([]string{"llama3.2"}, saved.Models); diff != "" { + 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{{"llama3.2"}}, stub.edited); diff != "" { + if diff := cmp.Diff([][]string{{"sample-model"}}, stub.edited); diff != "" { t.Fatalf("editor models mismatch (-want +got):\n%s", diff) } - if stub.ranModel != "llama3.2" { - t.Fatalf("expected launch to run with llama3.2, got %q", stub.ranModel) + if stub.ranModel != "sample-model" { + t.Fatalf("expected launch to run with sample-model, got %q", stub.ranModel) } } @@ -325,9 +325,9 @@ func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) { case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/show": - fmt.Fprint(w, `{"model":"llama3.2"}`) + fmt.Fprint(w, `{"model":"sample-model"}`) default: w.WriteHeader(http.StatusNotFound) } @@ -347,7 +347,7 @@ func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) { DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) { selectorCalls++ gotCurrent = current - return "llama3.2", nil + return "sample-model", nil } cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {}) @@ -364,7 +364,7 @@ func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) { if gotCurrent != "" { t.Fatalf("expected disabled override to be cleared before selection, got current %q", gotCurrent) } - if stub.ranModel != "llama3.2" { + 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") { @@ -375,7 +375,7 @@ func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) { if err != nil { t.Fatalf("failed to reload integration config: %v", err) } - if diff := cmp.Diff([]string{"llama3.2"}, saved.Models); diff != "" { + if diff := cmp.Diff([]string{"sample-model"}, saved.Models); diff != "" { t.Fatalf("saved models mismatch (-want +got):\n%s", diff) } } @@ -424,7 +424,7 @@ func TestLaunchCmdYes_AutoConfirmsLaunchPromptPath(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/show": - fmt.Fprint(w, `{"model":"llama3.2"}`) + fmt.Fprint(w, `{"model":"sample-model"}`) case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"not found"}`) @@ -445,16 +445,16 @@ func TestLaunchCmdYes_AutoConfirmsLaunchPromptPath(t *testing.T) { } cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {}) - cmd.SetArgs([]string{"stubeditor", "--model", "llama3.2", "--yes"}) + 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{{"llama3.2"}}, stub.edited); diff != "" { + if diff := cmp.Diff([][]string{{"sample-model"}}, stub.edited); diff != "" { t.Fatalf("editor models mismatch (-want +got):\n%s", diff) } - if stub.ranModel != "llama3.2" { - t.Fatalf("expected launch to run with llama3.2, got %q", stub.ranModel) + if stub.ranModel != "sample-model" { + t.Fatalf("expected launch to run with sample-model, got %q", stub.ranModel) } } @@ -513,7 +513,7 @@ func TestLaunchCmdHeadlessWithoutYes_AllowsConfiguredLaunch(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/show": - fmt.Fprint(w, `{"model":"llama3.2"}`) + fmt.Fprint(w, `{"model":"sample-model"}`) case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"not found"}`) @@ -534,15 +534,15 @@ func TestLaunchCmdHeadlessWithoutYes_AllowsConfiguredLaunch(t *testing.T) { } cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {}) - cmd.SetArgs([]string{"stubeditor", "--model", "llama3.2"}) + 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{{"llama3.2"}}); diff != "" { + if diff := compareStringSlices(stub.edited, [][]string{{"sample-model"}}); diff != "" { t.Fatalf("unexpected editor writes (-want +got):\n%s", diff) } - if stub.ranModel != "llama3.2" { + if stub.ranModel != "sample-model" { t.Fatalf("expected launch to run configured model, got %q", stub.ranModel) } } @@ -551,7 +551,7 @@ func TestLaunchCmdIntegrationArgPromptsForModelWithSavedSelection(t *testing.T) tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) - if err := config.SaveIntegration("stubapp", []string{"llama3.2"}); err != nil { + if err := config.SaveIntegration("stubapp", []string{"sample-model"}); err != nil { t.Fatalf("failed to seed saved config: %v", err) } @@ -560,7 +560,7 @@ func TestLaunchCmdIntegrationArgPromptsForModelWithSavedSelection(t *testing.T) case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"},{"name":"qwen3:8b"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"},{"name":"qwen3:8b"}]}`) case "/api/show": fmt.Fprint(w, `{"model":"qwen3:8b"}`) default: @@ -589,8 +589,8 @@ func TestLaunchCmdIntegrationArgPromptsForModelWithSavedSelection(t *testing.T) t.Fatalf("launch command failed: %v", err) } - if gotCurrent != "llama3.2" { - t.Fatalf("expected selector current model to be saved model llama3.2, got %q", gotCurrent) + 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) @@ -611,14 +611,14 @@ func TestLaunchCmdHeadlessYes_IntegrationRequiresModelEvenWhenSaved(t *testing.T withLauncherHooks(t) withInteractiveSession(t, false) - if err := config.SaveIntegration("stubapp", []string{"llama3.2"}); err != nil { + 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":"llama3.2"}`) + fmt.Fprint(w, `{"model":"sample-model"}`) default: w.WriteHeader(http.StatusNotFound) } diff --git a/cmd/launch/deprecated_models.go b/cmd/launch/deprecated_models.go new file mode 100644 index 000000000..3002ad6c9 --- /dev/null +++ b/cmd/launch/deprecated_models.go @@ -0,0 +1,133 @@ +package launch + +import ( + "fmt" + "strings" + + "github.com/ollama/ollama/internal/modelref" +) + +var deprecatedLaunchModels = map[string]struct{}{ + "codellama": {}, + "qwen2.5": {}, + "qwen2.5-coder": {}, + "llama3": {}, + "llama3.1": {}, + "llama3.2": {}, + "llama3.3": {}, + "mistral": {}, + "starcoder": {}, +} + +var deprecatedLaunchModelTags = map[string]map[string]struct{}{ + "deepseek-r1": { + "": {}, + "latest": {}, + "1.5b": {}, + "7b": {}, + "8b": {}, + "14b": {}, + "32b": {}, + }, +} + +var errDeprecatedLaunchModelDeclined = fmt.Errorf("%w: deprecated launch model declined", ErrCancelled) + +func isDeprecatedLaunchModel(name string) bool { + family, tag := normalizedLaunchModelRef(name) + if _, ok := deprecatedLaunchModels[family]; ok { + return true + } + tags, ok := deprecatedLaunchModelTags[family] + if !ok { + return false + } + _, ok = tags[tag] + return ok +} + +func deprecatedLaunchModelPrompt(name, label, commandName, cloudRec, localRec string) string { + if !isDeprecatedLaunchModel(name) { + return "" + } + if label = strings.TrimSpace(label); label == "" { + label = "ollama launch" + } + + var b strings.Builder + fmt.Fprintf(&b, "%s does not work well with %s. ", name, label) + switch { + case cloudRec != "" && localRec != "": + fmt.Fprintf(&b, "Try an agent-capable model like %s or %s instead", cloudRec, localRec) + case cloudRec != "": + fmt.Fprintf(&b, "Try an agent-capable model like %s instead", cloudRec) + case localRec != "": + fmt.Fprintf(&b, "Try an agent-capable model like %s instead", localRec) + default: + b.WriteString("Try a newer recommended agent-capable model instead") + } + if command := launchReplacementCommand(commandName, firstNonEmpty(cloudRec, localRec)); command != "" { + fmt.Fprintf(&b, ":\n %s", command) + } else { + b.WriteString(".") + } + fmt.Fprintf(&b, "\n\nLaunch with %s anyway?", name) + return b.String() +} + +func launchReplacementCommand(commandName, model string) string { + commandName = strings.TrimSpace(commandName) + model = strings.TrimSpace(model) + if commandName == "" || model == "" { + return "" + } + return fmt.Sprintf("ollama launch %s --model %s", commandName, model) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func normalizedLaunchModelRef(name string) (string, string) { + name = strings.TrimSpace(strings.ToLower(name)) + if name == "" { + return "", "" + } + if base, stripped := modelref.StripCloudSourceTag(name); stripped { + name = base + } + if idx := strings.LastIndex(name, "/"); idx >= 0 { + name = name[idx+1:] + } + tag := "" + if idx := strings.Index(name, ":"); idx >= 0 { + tag = strings.TrimSpace(name[idx+1:]) + name = name[:idx] + } + return strings.TrimSpace(name), tag +} + +func filterDeprecatedLaunchModelItems(items []ModelItem) []ModelItem { + filtered := items[:0] + for _, item := range items { + if !isDeprecatedLaunchModel(item.Name) { + filtered = append(filtered, item) + } + } + return filtered +} + +func filterDeprecatedLaunchModelNames(models []string) []string { + filtered := models[:0] + for _, model := range models { + if !isDeprecatedLaunchModel(model) { + filtered = append(filtered, model) + } + } + return filtered +} diff --git a/cmd/launch/deprecated_models_test.go b/cmd/launch/deprecated_models_test.go new file mode 100644 index 000000000..0c3287076 --- /dev/null +++ b/cmd/launch/deprecated_models_test.go @@ -0,0 +1,68 @@ +package launch + +import ( + "strings" + "testing" +) + +func TestLaunchModelDeprecation(t *testing.T) { + tests := []struct { + name string + deprecated bool + }{ + {name: "qwen2.5", deprecated: true}, + {name: "qwen2.5:14b", deprecated: true}, + {name: "qwen2.5-coder:32b", deprecated: true}, + {name: "library/qwen2.5-coder:7b", deprecated: true}, + {name: "llama3", deprecated: true}, + {name: "llama3.1:8b", deprecated: true}, + {name: "llama3.2:latest", deprecated: true}, + {name: "llama3.3:70b", deprecated: true}, + {name: "llama3.2:cloud", deprecated: true}, + {name: "codellama", deprecated: true}, + {name: "codellama:13b-code", deprecated: true}, + {name: "library/codellama:7b", deprecated: true}, + {name: "starcoder", deprecated: true}, + {name: "starcoder:15b", deprecated: true}, + {name: "mistral", deprecated: true}, + {name: "mistral:7b", deprecated: true}, + {name: "deepseek-r1", deprecated: true}, + {name: "deepseek-r1:latest", deprecated: true}, + {name: "deepseek-r1:1.5b", deprecated: true}, + {name: "deepseek-r1:7b", deprecated: true}, + {name: "deepseek-r1:8b", deprecated: true}, + {name: "deepseek-r1:14b", deprecated: true}, + {name: "deepseek-r1:32b", deprecated: true}, + {name: "deepseek-r1:32b-cloud", deprecated: true}, + {name: "qwen3.5", deprecated: false}, + {name: "qwen3-coder:30b", deprecated: false}, + {name: "gemma4", deprecated: false}, + {name: "my-qwen2.5-coder", deprecated: false}, + {name: "llama3.2-inspired", deprecated: false}, + {name: "codellama-inspired", deprecated: false}, + {name: "starcoder2:15b", deprecated: false}, + {name: "mixtral:8x7b", deprecated: false}, + {name: "deepseek-r1:70b", deprecated: false}, + {name: "deepseek-r1:671b", deprecated: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isDeprecatedLaunchModel(tt.name); got != tt.deprecated { + t.Fatalf("isDeprecatedLaunchModel(%q) = %v, want %v", tt.name, got, tt.deprecated) + } + }) + } +} + +func TestDeprecatedLaunchModelErrorMentionsRecommendedModels(t *testing.T) { + prompt := deprecatedLaunchModelPrompt("qwen2.5-coder:32b", "Codex", "codex", "recommended-cloud:cloud", "recommended-local") + if prompt == "" { + t.Fatal("expected deprecated model prompt") + } + for _, want := range []string{"qwen2.5-coder:32b does not work well with Codex", "recommended-cloud:cloud", "recommended-local", "ollama launch codex --model recommended-cloud:cloud", "Launch with qwen2.5-coder:32b anyway?"} { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt %q does not contain %q", prompt, want) + } + } +} diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index 42e928268..fdab3aba6 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -707,9 +707,12 @@ func (c *launcherClient) resolveRunModel(ctx context.Context, req RunModelReques } if usable { if err := c.ensureModelsReady(ctx, []string{current}); err != nil { - return "", err + if !errors.Is(err, errDeprecatedLaunchModelDeclined) { + return "", err + } + } else { + return current, nil } - return current, nil } } @@ -726,7 +729,7 @@ func (c *launcherClient) resolveRunModel(ctx context.Context, req RunModelReques } func (c *launcherClient) launchSingleIntegration(ctx context.Context, name string, runner Runner, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error { - target, _, err := c.resolveSingleIntegrationTarget(ctx, runner, primaryModelFromConfig(saved), req) + target, _, err := c.resolveSingleIntegrationTarget(ctx, name, runner, primaryModelFromConfig(saved), req) if err != nil { return err } @@ -748,14 +751,22 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin models, needsConfigure := c.resolveEditorLaunchModels(ctx, saved, req) if needsConfigure { - selected, err := c.selectMultiModelsForIntegration(ctx, runner, models) + selected, err := c.selectMultiModelsForIntegration(ctx, name, runner, models) if err != nil { return err } models = selected } else if len(models) > 0 { - if err := c.ensureModelsReady(ctx, models[:1]); err != nil { - return err + if err := c.ensureModelsReadyFor(ctx, models[:1], runner.String(), name); err != nil { + if !errors.Is(err, errDeprecatedLaunchModelDeclined) || req.ModelOverride != "" { + return err + } + selected, err := c.selectMultiModelsForIntegration(ctx, name, runner, models) + if err != nil { + return err + } + models = selected + needsConfigure = true } } @@ -784,7 +795,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam selectionCurrent = primaryModelFromConfig(saved) } - target, needsConfigure, err := c.resolveSingleIntegrationTarget(ctx, runner, selectionCurrent, req) + target, needsConfigure, err := c.resolveSingleIntegrationTarget(ctx, name, runner, selectionCurrent, req) if err != nil { return err } @@ -954,7 +965,7 @@ func (c *launcherClient) managedSingleConfigureModels(ctx context.Context, manag return dedupeModelList(models), nil } -func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, runner Runner, current string, req IntegrationLaunchRequest) (string, bool, error) { +func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, name string, runner Runner, current string, req IntegrationLaunchRequest) (string, bool, error) { target := req.ModelOverride needsConfigure := req.ForceConfigure skipReadiness := false @@ -978,14 +989,22 @@ func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, run } if needsConfigure && req.ModelOverride == "" { - selected, err := c.selectSingleModelWithSelectorReady(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector, !skipReadiness) + selected, err := c.selectSingleModelWithSelectorReady(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector, !skipReadiness, runner.String(), name) if err != nil { return "", false, err } target = selected } else if !skipReadiness { - if err := c.ensureModelsReady(ctx, []string{target}); err != nil { - return "", false, err + if err := c.ensureModelsReadyFor(ctx, []string{target}, runner.String(), name); err != nil { + if !errors.Is(err, errDeprecatedLaunchModelDeclined) || req.ModelOverride != "" { + return "", false, err + } + selected, err := c.selectSingleModelWithSelectorReady(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector, true, runner.String(), name) + if err != nil { + return "", false, err + } + target = selected + needsConfigure = true } } @@ -1023,7 +1042,7 @@ func managedRequiresInteractiveOnboarding(managed any) bool { } func (c *launcherClient) selectSingleModelWithSelector(ctx context.Context, title, current string, selector SingleSelector) (string, error) { - return c.selectSingleModelWithSelectorReady(ctx, title, current, selector, true) + return c.selectSingleModelWithSelectorReady(ctx, title, current, selector, true, "ollama launch", "") } func (c *launcherClient) latestAccountState() *AccountState { @@ -1033,7 +1052,7 @@ func (c *launcherClient) latestAccountState() *AccountState { return c.accountState } -func (c *launcherClient) selectSingleModelWithSelectorReady(ctx context.Context, title, current string, selector SingleSelector, ensureReady bool) (string, error) { +func (c *launcherClient) selectSingleModelWithSelectorReady(ctx context.Context, title, current string, selector SingleSelector, ensureReady bool, label, commandName string) (string, error) { if selector == nil && DefaultSingleSelectorWithUpdates == nil { return "", fmt.Errorf("no selector configured") } @@ -1058,11 +1077,15 @@ func (c *launcherClient) selectSingleModelWithSelectorReady(ctx context.Context, return "", ErrCancelled } if ensureReady { - if err := c.ensureModelsReady(ctx, []string{selected}); err != nil { + if err := c.ensureModelsReadyFor(ctx, []string{selected}, label, commandName); err != nil { if errors.Is(err, errUpgradeCancelled) { current = selected continue } + if errors.Is(err, errDeprecatedLaunchModelDeclined) { + current = selected + continue + } return "", err } } @@ -1070,7 +1093,7 @@ func (c *launcherClient) selectSingleModelWithSelectorReady(ctx context.Context, } } -func (c *launcherClient) selectMultiModelsForIntegration(ctx context.Context, runner Runner, preChecked []string) ([]string, error) { +func (c *launcherClient) selectMultiModelsForIntegration(ctx context.Context, name string, runner Runner, preChecked []string) ([]string, error) { if DefaultMultiSelector == nil && DefaultMultiSelectorWithUpdates == nil { return nil, fmt.Errorf("no selector configured") } @@ -1092,12 +1115,16 @@ func (c *launcherClient) selectMultiModelsForIntegration(ctx context.Context, ru if err != nil { return nil, err } - accepted, skipped, err := c.selectReadyModelsForSave(ctx, selected) + accepted, skipped, err := c.selectReadyModelsForSave(ctx, selected, runner.String(), name) if err != nil { if errors.Is(err, errUpgradeCancelled) { orderedChecked = append([]string(nil), selected...) continue } + if errors.Is(err, errDeprecatedLaunchModelDeclined) { + orderedChecked = append([]string(nil), selected...) + continue + } return nil, err } for _, skip := range skipped { @@ -1136,6 +1163,8 @@ func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked [] cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient) items, orderedChecked, _, _ := buildModelListWithRecommendations(inventory, recommendations, preChecked, current) + items = filterDeprecatedLaunchModelItems(items) + orderedChecked = filterDeprecatedLaunchModelNames(orderedChecked) if cloudDisabled { items = filterCloudItems(items) orderedChecked = c.filterDisabledCloudModels(ctx, orderedChecked) @@ -1212,13 +1241,31 @@ func (c *launcherClient) requestRecommendations(ctx context.Context) ([]ModelIte } func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string) error { + return c.ensureModelsReadyFor(ctx, models, "ollama launch", "") +} + +func (c *launcherClient) ensureModelsReadyFor(ctx context.Context, models []string, label, commandName string) error { models = dedupeModelList(models) if len(models) == 0 { return nil } + cloudRec, localRec := c.agentCapableRecommendations(ctx) cloudModels := make(map[string]bool, len(models)) for _, model := range models { + if prompt := deprecatedLaunchModelPrompt(model, label, commandName, cloudRec, localRec); prompt != "" { + ok, err := ConfirmPromptWithOptions(prompt, ConfirmOptions{ + YesLabel: "Launch anyway", + NoLabel: "Pick another model", + Default: ConfirmDefaultNo, + }) + if err != nil { + return err + } + if !ok { + return errDeprecatedLaunchModelDeclined + } + } isCloudModel := isCloudModelName(model) if isCloudModel { cloudModels[model] = true @@ -1233,6 +1280,27 @@ func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string) return ensureAuth(ctx, c.apiClient, cloudModels, models) } +func (c *launcherClient) agentCapableRecommendations(ctx context.Context) (cloud, local string) { + recs := c.recommendations(ctx) + cloudDisabled, known := cloudStatusDisabled(ctx, c.apiClient) + for _, rec := range recs { + if rec.Name == "" || isDeprecatedLaunchModel(rec.Name) { + continue + } + if isCloudModelName(rec.Name) { + if cloud == "" && !(known && cloudDisabled) { + cloud = rec.Name + } + } else if local == "" { + local = rec.Name + } + if cloud != "" && local != "" { + break + } + } + return cloud, local +} + func dedupeModelList(models []string) []string { deduped := make([]string, 0, len(models)) seen := make(map[string]bool, len(models)) @@ -1251,16 +1319,19 @@ type skippedModel struct { reason string } -func (c *launcherClient) selectReadyModelsForSave(ctx context.Context, selected []string) ([]string, []skippedModel, error) { +func (c *launcherClient) selectReadyModelsForSave(ctx context.Context, selected []string, label, commandName string) ([]string, []skippedModel, error) { selected = dedupeModelList(selected) accepted := make([]string, 0, len(selected)) skipped := make([]skippedModel, 0, len(selected)) for _, model := range selected { - if err := c.ensureModelsReady(ctx, []string{model}); err != nil { + if err := c.ensureModelsReadyFor(ctx, []string{model}, label, commandName); err != nil { if errors.Is(err, errUpgradeCancelled) { return nil, nil, err } + if errors.Is(err, errDeprecatedLaunchModelDeclined) { + return nil, nil, err + } if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return nil, nil, err } diff --git a/cmd/launch/launch_test.go b/cmd/launch/launch_test.go index ca1cd5085..a23328a93 100644 --- a/cmd/launch/launch_test.go +++ b/cmd/launch/launch_test.go @@ -3,6 +3,7 @@ package launch import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -57,6 +58,14 @@ func (r *launcherSingleRunner) Run(model string, _ []LaunchModel, args []string) func (r *launcherSingleRunner) String() string { return "StubSingle" } +func selectionItemNames(items []SelectionItem) []string { + names := make([]string, 0, len(items)) + for _, item := range items { + names = append(names, item.Name) + } + return names +} + type launcherRestorableRunner struct { launcherSingleRunner restored bool @@ -305,6 +314,104 @@ func TestBuildLauncherState_ManagedSingleIntegrationUsesCurrentModel(t *testing. } } +func TestBuildLauncherState_DeprecatedSavedModelIsUsable(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + + if err := config.SetLastModel("qwen2.5:14b"); err != nil { + t.Fatalf("failed to seed last model: %v", err) + } + if err := config.SaveIntegration("codex", []string{"llama3.2:latest"}); err != nil { + t.Fatalf("failed to seed codex config: %v", err) + } + + var showCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"qwen2.5:14b"},{"name":"llama3.2:latest"}]}`) + case "/api/show": + showCalls.Add(1) + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + 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.RunModelUsable { + t.Fatal("expected deprecated saved run model to stay usable") + } + if state.Integrations["codex"].CurrentModel != "llama3.2:latest" { + t.Fatalf("expected saved integration model to remain visible, got %q", state.Integrations["codex"].CurrentModel) + } + if !state.Integrations["codex"].ModelUsable { + t.Fatal("expected deprecated saved integration model to stay usable") + } + if showCalls.Load() != 0 { + t.Fatalf("saved models present in tags should not require /api/show, got %d calls", showCalls.Load()) + } +} + +func TestLoadSelectableModelsFiltersDeprecatedModels(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/experimental/model-recommendations": + fmt.Fprint(w, `{"recommendations":[`+ + `{"model":"qwen2.5-coder:32b","description":"old coding model"},`+ + `{"model":"qwen3.5","description":"new local model"}`+ + `]}`) + case "/api/tags": + fmt.Fprint(w, `{"models":[`+ + `{"name":"qwen2.5:14b"},`+ + `{"name":"llama3.2:latest"},`+ + `{"name":"custom-local:latest"},`+ + `{"name":"qwen3.5"}`+ + `]}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + client, err := newLauncherClient(defaultLaunchPolicy(true, false)) + if err != nil { + t.Fatalf("newLauncherClient returned error: %v", err) + } + items, orderedChecked, err := client.loadSelectableModels(context.Background(), []string{"qwen2.5:14b", "custom-local"}, "qwen2.5:14b", "no models available") + if err != nil { + t.Fatalf("loadSelectableModels returned error: %v", err) + } + + got := names(items) + for _, deprecated := range []string{"qwen2.5:14b", "llama3.2", "qwen2.5-coder:32b"} { + if slices.Contains(got, deprecated) { + t.Fatalf("deprecated model %q should be filtered from selectable models: %v", deprecated, got) + } + } + if !slices.Contains(got, "qwen3.5") { + t.Fatalf("expected newer recommendation to remain selectable, got %v", got) + } + if !slices.Contains(got, "custom-local") { + t.Fatalf("expected custom local model to remain selectable, got %v", got) + } + if slices.Contains(orderedChecked, "qwen2.5:14b") { + t.Fatalf("deprecated prechecked model should be filtered, got %v", orderedChecked) + } + if !slices.Contains(orderedChecked, "custom-local") { + t.Fatalf("non-deprecated prechecked model should remain, got %v", orderedChecked) + } +} + func TestBuildLauncherState_ManagedSingleIntegrationShowsSavedModelWhenLiveConfigMissing(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) @@ -1405,7 +1512,7 @@ func TestBuildLauncherState_InstalledAndCloudDisabled(t *testing.T) { 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 { + if err := config.SaveIntegration("opencode", []string{"glm-5:cloud", "sample-model"}); err != nil { t.Fatalf("failed to save opencode config: %v", err) } @@ -1414,7 +1521,7 @@ func TestBuildLauncherState_InstalledAndCloudDisabled(t *testing.T) { case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/status": fmt.Fprint(w, `{"cloud":{"disabled":true,"source":"config"}}`) default: @@ -1444,7 +1551,7 @@ func TestBuildLauncherState_InstalledAndCloudDisabled(t *testing.T) { 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" { + if state.Integrations["opencode"].CurrentModel != "sample-model" { t.Fatalf("expected editor current model to fall back to remaining local model, got %q", state.Integrations["opencode"].CurrentModel) } } @@ -1453,10 +1560,10 @@ func TestBuildLauncherState_MigratesLegacyOpenclawAliasConfig(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) - if err := config.SaveIntegration("clawdbot", []string{"llama3.2"}); err != nil { + if err := config.SaveIntegration("clawdbot", []string{"sample-model"}); 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 { + if err := config.SaveAliases("clawdbot", map[string]string{"primary": "sample-model"}); err != nil { t.Fatalf("failed to seed legacy alias map: %v", err) } if err := config.MarkIntegrationOnboarded("clawdbot"); err != nil { @@ -1468,7 +1575,7 @@ func TestBuildLauncherState_MigratesLegacyOpenclawAliasConfig(t *testing.T) { case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) default: http.NotFound(w, r) } @@ -1480,7 +1587,7 @@ func TestBuildLauncherState_MigratesLegacyOpenclawAliasConfig(t *testing.T) { if err != nil { t.Fatalf("BuildLauncherState returned error: %v", err) } - if state.Integrations["openclaw"].CurrentModel != "llama3.2" { + if state.Integrations["openclaw"].CurrentModel != "sample-model" { t.Fatalf("expected openclaw state to reuse legacy alias config, got %q", state.Integrations["openclaw"].CurrentModel) } @@ -1488,10 +1595,10 @@ func TestBuildLauncherState_MigratesLegacyOpenclawAliasConfig(t *testing.T) { if err != nil { t.Fatalf("expected canonical config to be migrated, got %v", err) } - if !slices.Equal(migrated.Models, []string{"llama3.2"}) { + if !slices.Equal(migrated.Models, []string{"sample-model"}) { t.Fatalf("unexpected migrated models: %v", migrated.Models) } - if migrated.Aliases["primary"] != "llama3.2" { + if migrated.Aliases["primary"] != "sample-model" { t.Fatalf("expected aliases to migrate, got %v", migrated.Aliases) } if !migrated.Onboarded { @@ -1503,7 +1610,7 @@ func TestBuildLauncherState_ToleratesInventoryFailure(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) - if err := config.SetLastModel("llama3.2"); err != nil { + if err := config.SetLastModel("sample-model"); err != nil { t.Fatalf("failed to seed last model: %v", err) } if err := config.SaveIntegration("claude", []string{"qwen3:8b"}); err != nil { @@ -1547,7 +1654,7 @@ func TestBuildLauncherState_UsesTagsInventoryWithoutShow(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) - if err := config.SetLastModel("llama3.2"); err != nil { + if err := config.SetLastModel("sample-model"); err != nil { t.Fatalf("failed to seed last model: %v", err) } if err := config.SaveIntegration("codex", []string{"qwen3:8b"}); err != nil { @@ -1559,7 +1666,7 @@ func TestBuildLauncherState_UsesTagsInventoryWithoutShow(t *testing.T) { switch r.URL.Path { case "/api/tags": fmt.Fprint(w, `{"models":[`+ - `{"name":"llama3.2","capabilities":["completion","tools"],"context_length":131072,"size":3200000000},`+ + `{"name":"sample-model","capabilities":["completion","tools"],"context_length":131072,"size":3200000000},`+ `{"name":"qwen3:8b","capabilities":["completion","tools"],"context_length":65536,"size":4500000000}`+ `]}`) case "/api/show": @@ -1595,7 +1702,7 @@ func TestResolveRunModel_UsesSavedModelWithoutSelector(t *testing.T) { setLaunchTestHome(t, tmpDir) withLauncherHooks(t) - if err := config.SetLastModel("llama3.2"); err != nil { + if err := config.SetLastModel("sample-model"); err != nil { t.Fatalf("failed to save last model: %v", err) } @@ -1610,9 +1717,9 @@ func TestResolveRunModel_UsesSavedModelWithoutSelector(t *testing.T) { case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/show": - fmt.Fprint(w, `{"model":"llama3.2"}`) + fmt.Fprint(w, `{"model":"sample-model"}`) default: http.NotFound(w, r) } @@ -1624,7 +1731,7 @@ func TestResolveRunModel_UsesSavedModelWithoutSelector(t *testing.T) { if err != nil { t.Fatalf("ResolveRunModel returned error: %v", err) } - if model != "llama3.2" { + if model != "sample-model" { t.Fatalf("expected saved model, got %q", model) } if selectorCalled { @@ -1657,7 +1764,7 @@ func TestResolveRunModel_HeadlessYesAutoPicksLastModel(t *testing.T) { case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/show": var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) @@ -1721,7 +1828,7 @@ func TestResolveRunModel_UsesRequestPolicy(t *testing.T) { case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/show": var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) @@ -1764,14 +1871,14 @@ func TestResolveRunModel_ForcePickerAlwaysUsesSelector(t *testing.T) { setLaunchTestHome(t, tmpDir) withLauncherHooks(t) - if err := config.SetLastModel("llama3.2"); err != nil { + if err := config.SetLastModel("sample-model"); err != nil { t.Fatalf("failed to save last model: %v", err) } var selectorCalls int DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) { selectorCalls++ - if current != "llama3.2" { + if current != "sample-model" { t.Fatalf("expected current selection to be last model, got %q", current) } return "qwen3:8b", nil @@ -1782,7 +1889,7 @@ func TestResolveRunModel_ForcePickerAlwaysUsesSelector(t *testing.T) { case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"},{"name":"qwen3:8b"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"},{"name":"qwen3:8b"}]}`) case "/api/show": fmt.Fprint(w, `{"model":"qwen3:8b"}`) default: @@ -2064,7 +2171,7 @@ func TestResolveRunModel_UpgradeCancelledReturnsToModelSelector(t *testing.T) { case 1: return "kimi-k2.6:cloud", nil case 2: - return "llama3.2", nil + return "sample-model", nil default: t.Fatalf("selector called too many times: %d", selectorCalls) return "", nil @@ -2079,7 +2186,7 @@ func TestResolveRunModel_UpgradeCancelledReturnsToModelSelector(t *testing.T) { case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[{"model":"kimi-k2.6:cloud","description":"Coding","context_length":262144,"max_output_tokens":262144,"required_plan":"pro"}]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"not found"}`) @@ -2100,8 +2207,8 @@ func TestResolveRunModel_UpgradeCancelledReturnsToModelSelector(t *testing.T) { if err != nil { t.Fatalf("ResolveRunModel returned error: %v", err) } - if model != "llama3.2" { - t.Fatalf("model = %q, want llama3.2", model) + if model != "sample-model" { + t.Fatalf("model = %q, want sample-model", model) } if selectorCalls != 2 { t.Fatalf("selector calls = %d, want 2", selectorCalls) @@ -2168,7 +2275,7 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) { var multiCalled bool DefaultMultiSelector = func(title string, items []SelectionItem, preChecked []string) ([]string, error) { multiCalled = true - return []string{"llama3.2", "qwen3:8b"}, nil + return []string{"sample-model", "qwen3:8b"}, nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -2176,7 +2283,7 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) { case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"},{"name":"qwen3:8b"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"},{"name":"qwen3:8b"}]}`) case "/api/show": var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) @@ -2198,17 +2305,17 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) { if !multiCalled { t.Fatal("expected multi selector to be used for forced editor configure") } - if diff := compareStringSlices(editor.edited, [][]string{{"llama3.2", "qwen3:8b"}}); diff != "" { + if diff := compareStringSlices(editor.edited, [][]string{{"sample-model", "qwen3:8b"}}); diff != "" { t.Fatalf("unexpected edited models (-want +got):\n%s", diff) } - if editor.ranModel != "llama3.2" { + if editor.ranModel != "sample-model" { 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 != "" { + if diff := compareStrings(saved.Models, []string{"sample-model", "qwen3:8b"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } } @@ -2222,7 +2329,7 @@ func TestLaunchIntegration_ClineRewritesWhenLiveProviderDrifted(t *testing.T) { writeFakeBinary(t, binDir, "cline") t.Setenv("PATH", binDir) - if err := config.SaveIntegration("cline", []string{"llama3.2"}); err != nil { + if err := config.SaveIntegration("cline", []string{"sample-model"}); err != nil { t.Fatalf("failed to seed saved config: %v", err) } @@ -2285,8 +2392,8 @@ func TestLaunchIntegration_ClineRewritesWhenLiveProviderDrifted(t *testing.T) { if settings["provider"] != clineLaunchProvider { t.Fatalf("ollama settings.provider = %v, want %s", settings["provider"], clineLaunchProvider) } - if settings["model"] != "llama3.2" { - t.Fatalf("ollama settings.model = %v, want llama3.2", settings["model"]) + if settings["model"] != "sample-model" { + t.Fatalf("ollama settings.model = %v, want sample-model", settings["model"]) } if settings["baseUrl"] != srv.URL+"/v1" { t.Fatalf("ollama settings.baseUrl = %v, want %s/v1", settings["baseUrl"], srv.URL) @@ -2302,7 +2409,7 @@ func TestLaunchIntegration_EditorForceConfigure_FloatsCheckedModelsInPicker(t *t writeFakeBinary(t, binDir, "droid") t.Setenv("PATH", binDir) - editor := &launcherEditorRunner{models: []string{"llama3.2", "missing-local"}} + editor := &launcherEditorRunner{models: []string{"sample-model", "missing-local"}} withIntegrationOverride(t, "droid", editor) if err := config.SaveIntegration("droid", []string{"qwen3.5:cloud", "qwen3.5"}); err != nil { @@ -2376,7 +2483,7 @@ func TestLaunchIntegration_EditorModelOverridePreservesExtras(t *testing.T) { editor := &launcherEditorRunner{} withIntegrationOverride(t, "droid", editor) - if err := config.SaveIntegration("droid", []string{"llama3.2", "mistral"}); err != nil { + if err := config.SaveIntegration("droid", []string{"sample-model", "mistral"}); err != nil { t.Fatalf("failed to seed config: %v", err) } @@ -2399,7 +2506,7 @@ func TestLaunchIntegration_EditorModelOverridePreservesExtras(t *testing.T) { t.Fatalf("LaunchIntegration returned error: %v", err) } - want := []string{"qwen3:8b", "llama3.2", "mistral"} + want := []string{"qwen3:8b", "sample-model", "mistral"} saved, err := config.LoadIntegration("droid") if err != nil { t.Fatalf("failed to reload saved config: %v", err) @@ -2434,7 +2541,7 @@ func TestLaunchIntegration_EditorCloudDisabledFallsBackToSelector(t *testing.T) var multiCalled bool DefaultMultiSelector = func(title string, items []SelectionItem, preChecked []string) ([]string, error) { multiCalled = true - return []string{"llama3.2"}, nil + return []string{"sample-model"}, nil } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -2444,9 +2551,9 @@ func TestLaunchIntegration_EditorCloudDisabledFallsBackToSelector(t *testing.T) case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/show": - fmt.Fprint(w, `{"model":"llama3.2"}`) + fmt.Fprint(w, `{"model":"sample-model"}`) default: http.NotFound(w, r) } @@ -2556,7 +2663,7 @@ func TestLaunchIntegration_EditorConfigureMultiSkipsUnauthedCloudAndPersistsAcce withIntegrationOverride(t, "droid", editor) DefaultMultiSelector = func(title string, items []SelectionItem, preChecked []string) ([]string, error) { - return []string{"llama3.2", "glm-5:cloud"}, nil + return []string{"sample-model", "glm-5:cloud"}, nil } DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { t.Fatalf("unexpected prompt: %q", prompt) @@ -2571,7 +2678,7 @@ func TestLaunchIntegration_EditorConfigureMultiSkipsUnauthedCloudAndPersistsAcce case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"},{"name":"glm-5:cloud","remote_model":"glm-5"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"},{"name":"glm-5:cloud","remote_model":"glm-5"}]}`) case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"not found"}`) @@ -2579,8 +2686,8 @@ func TestLaunchIntegration_EditorConfigureMultiSkipsUnauthedCloudAndPersistsAcce var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) switch req.Model { - case "llama3.2": - fmt.Fprint(w, `{"model":"llama3.2"}`) + case "sample-model": + fmt.Fprint(w, `{"model":"sample-model"}`) case "glm-5:cloud": fmt.Fprint(w, `{"remote_model":"glm-5"}`) default: @@ -2606,17 +2713,17 @@ func TestLaunchIntegration_EditorConfigureMultiSkipsUnauthedCloudAndPersistsAcce if launchErr != nil { t.Fatalf("LaunchIntegration returned error: %v", launchErr) } - if editor.ranModel != "llama3.2" { + if editor.ranModel != "sample-model" { t.Fatalf("expected launch to use local primary, 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"}); diff != "" { + if diff := compareStrings(saved.Models, []string{"sample-model"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } - if diff := compareStringSlices(editor.edited, [][]string{{"llama3.2"}}); diff != "" { + if diff := compareStringSlices(editor.edited, [][]string{{"sample-model"}}); diff != "" { t.Fatalf("unexpected edited models (-want +got):\n%s", diff) } if !strings.Contains(stderr, "Skipped glm-5:cloud: sign in was cancelled") { @@ -2646,7 +2753,7 @@ func TestLaunchIntegration_EditorConfigureUpgradeCancelledReturnsToModelSelector if diff := compareStrings(preChecked, []string{"kimi-k2.6:cloud"}); diff != "" { t.Fatalf("second selector preChecked (-want +got):\n%s", diff) } - return []string{"llama3.2"}, nil + return []string{"sample-model"}, nil default: t.Fatalf("selector called too many times: %d", selectorCalls) return nil, nil @@ -2668,7 +2775,7 @@ func TestLaunchIntegration_EditorConfigureUpgradeCancelledReturnsToModelSelector case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[{"model":"kimi-k2.6:cloud","description":"Coding","context_length":262144,"max_output_tokens":262144,"required_plan":"pro"}]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"not found"}`) @@ -2694,10 +2801,10 @@ func TestLaunchIntegration_EditorConfigureUpgradeCancelledReturnsToModelSelector if selectorCalls != 2 { t.Fatalf("selector calls = %d, want 2", selectorCalls) } - if editor.ranModel != "llama3.2" { + if editor.ranModel != "sample-model" { t.Fatalf("expected launch to use local model, got %q", editor.ranModel) } - if diff := compareStringSlices(editor.edited, [][]string{{"llama3.2"}}); diff != "" { + if diff := compareStringSlices(editor.edited, [][]string{{"sample-model"}}); diff != "" { t.Fatalf("unexpected edited models (-want +got):\n%s", diff) } } @@ -2714,7 +2821,7 @@ func TestLaunchIntegration_EditorConfigureMultiRemovesReselectedFailingModel(t * editor := &launcherEditorRunner{} withIntegrationOverride(t, "droid", editor) - if err := config.SaveIntegration("droid", []string{"glm-5:cloud", "llama3.2"}); err != nil { + if err := config.SaveIntegration("droid", []string{"glm-5:cloud", "sample-model"}); err != nil { t.Fatalf("failed to seed config: %v", err) } DefaultMultiSelector = func(title string, items []SelectionItem, preChecked []string) ([]string, error) { @@ -2733,7 +2840,7 @@ func TestLaunchIntegration_EditorConfigureMultiRemovesReselectedFailingModel(t * case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"glm-5:cloud","remote_model":"glm-5"},{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"glm-5:cloud","remote_model":"glm-5"},{"name":"sample-model"}]}`) case "/api/status": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"not found"}`) @@ -2744,8 +2851,8 @@ func TestLaunchIntegration_EditorConfigureMultiRemovesReselectedFailingModel(t * fmt.Fprint(w, `{"remote_model":"glm-5"}`) return } - if req.Model == "llama3.2" { - fmt.Fprint(w, `{"model":"llama3.2"}`) + if req.Model == "sample-model" { + fmt.Fprint(w, `{"model":"sample-model"}`) return } http.NotFound(w, r) @@ -2769,17 +2876,17 @@ func TestLaunchIntegration_EditorConfigureMultiRemovesReselectedFailingModel(t * if launchErr != nil { t.Fatalf("LaunchIntegration returned error: %v", launchErr) } - if editor.ranModel != "llama3.2" { + if editor.ranModel != "sample-model" { t.Fatalf("expected launch to use surviving model, got %q", editor.ranModel) } - if diff := compareStringSlices(editor.edited, [][]string{{"llama3.2"}}); diff != "" { + if diff := compareStringSlices(editor.edited, [][]string{{"sample-model"}}); diff != "" { t.Fatalf("unexpected edited models (-want +got):\n%s", diff) } saved, loadErr := config.LoadIntegration("droid") if loadErr != nil { t.Fatalf("failed to reload saved config: %v", loadErr) } - if diff := compareStrings(saved.Models, []string{"llama3.2"}); diff != "" { + if diff := compareStrings(saved.Models, []string{"sample-model"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } if !strings.Contains(stderr, "Skipped glm-5:cloud: sign in was cancelled") { @@ -2799,7 +2906,7 @@ func TestLaunchIntegration_EditorConfigureMultiAllFailuresKeepsExistingAndSkipsL editor := &launcherEditorRunner{} withIntegrationOverride(t, "droid", editor) - if err := config.SaveIntegration("droid", []string{"llama3.2"}); err != nil { + if err := config.SaveIntegration("droid", []string{"sample-model"}); err != nil { t.Fatalf("failed to seed config: %v", err) } @@ -2857,7 +2964,7 @@ func TestLaunchIntegration_EditorConfigureMultiAllFailuresKeepsExistingAndSkipsL if err != nil { t.Fatalf("failed to reload saved config: %v", err) } - if diff := compareStrings(saved.Models, []string{"llama3.2"}); diff != "" { + if diff := compareStrings(saved.Models, []string{"sample-model"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } if !strings.Contains(stderr, "Skipped missing-local-a:") { @@ -2877,10 +2984,10 @@ func TestLaunchIntegration_ConfiguredEditorLaunchValidatesPrimaryOnly(t *testing writeFakeBinary(t, binDir, "droid") t.Setenv("PATH", binDir) - editor := &launcherEditorRunner{models: []string{"llama3.2", "missing-local"}} + editor := &launcherEditorRunner{models: []string{"sample-model", "missing-local"}} withIntegrationOverride(t, "droid", editor) - if err := config.SaveIntegration("droid", []string{"llama3.2", "missing-local"}); err != nil { + if err := config.SaveIntegration("droid", []string{"sample-model", "missing-local"}); err != nil { t.Fatalf("failed to seed config: %v", err) } @@ -2898,8 +3005,8 @@ func TestLaunchIntegration_ConfiguredEditorLaunchValidatesPrimaryOnly(t *testing var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) switch req.Model { - case "llama3.2": - fmt.Fprint(w, `{"model":"llama3.2"}`) + case "sample-model": + fmt.Fprint(w, `{"model":"sample-model"}`) case "missing-local": missingShowCalled = true w.WriteHeader(http.StatusNotFound) @@ -2917,7 +3024,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchValidatesPrimaryOnly(t *testing if missingShowCalled { t.Fatal("expected configured launch to validate only the primary model") } - if editor.ranModel != "llama3.2" { + if editor.ranModel != "sample-model" { t.Fatalf("expected launch to use saved primary model, got %q", editor.ranModel) } if len(editor.edited) != 0 { @@ -2928,7 +3035,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchValidatesPrimaryOnly(t *testing if err != nil { t.Fatalf("failed to reload saved config: %v", err) } - if diff := compareStrings(saved.Models, []string{"llama3.2", "missing-local"}); diff != "" { + if diff := compareStrings(saved.Models, []string{"sample-model", "missing-local"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } } @@ -2946,10 +3053,10 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T) if err := os.WriteFile(settingsPath, []byte("{}"), 0o644); err != nil { t.Fatalf("failed to seed editor settings: %v", err) } - editor := &launcherEditorRunner{paths: []string{settingsPath}, models: []string{"llama3.2", "qwen3:8b"}} + editor := &launcherEditorRunner{paths: []string{settingsPath}, models: []string{"sample-model", "qwen3:8b"}} withIntegrationOverride(t, "droid", editor) - if err := config.SaveIntegration("droid", []string{"llama3.2", "qwen3:8b"}); err != nil { + if err := config.SaveIntegration("droid", []string{"sample-model", "qwen3:8b"}); err != nil { t.Fatalf("failed to seed config: %v", err) } @@ -2976,7 +3083,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T) if len(editor.edited) != 0 { t.Fatalf("expected normal launch to skip editor rewrites, got %v", editor.edited) } - if editor.ranModel != "llama3.2" { + if editor.ranModel != "sample-model" { t.Fatalf("expected launch to use saved primary model, got %q", editor.ranModel) } @@ -2984,7 +3091,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T) if err != nil { t.Fatalf("failed to reload saved config: %v", err) } - if diff := compareStrings(saved.Models, []string{"llama3.2", "qwen3:8b"}); diff != "" { + if diff := compareStrings(saved.Models, []string{"sample-model", "qwen3:8b"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } } @@ -3001,7 +3108,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchRewritesDriftedLiveConfig(t *te editor := &launcherEditorRunner{models: []string{"qwen3:8b"}} withIntegrationOverride(t, "droid", editor) - if err := config.SaveIntegration("droid", []string{"llama3.2", "mistral"}); err != nil { + if err := config.SaveIntegration("droid", []string{"sample-model", "mistral"}); err != nil { t.Fatalf("failed to seed config: %v", err) } @@ -3025,10 +3132,10 @@ func TestLaunchIntegration_ConfiguredEditorLaunchRewritesDriftedLiveConfig(t *te if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "droid"}); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } - if diff := cmp.Diff([][]string{{"llama3.2", "mistral"}}, editor.edited); diff != "" { + if diff := cmp.Diff([][]string{{"sample-model", "mistral"}}, editor.edited); diff != "" { t.Fatalf("expected editor config rewrite when live config drifts (-want +got):\n%s", diff) } - if editor.ranModel != "llama3.2" { + if editor.ranModel != "sample-model" { t.Fatalf("expected launch to use saved primary model, got %q", editor.ranModel) } @@ -3036,7 +3143,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchRewritesDriftedLiveConfig(t *te if err != nil { t.Fatalf("failed to reload saved config: %v", err) } - if diff := compareStrings(saved.Models, []string{"llama3.2", "mistral"}); diff != "" { + if diff := compareStrings(saved.Models, []string{"sample-model", "mistral"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } } @@ -3050,10 +3157,10 @@ func TestLaunchIntegration_OpenclawPreservesExistingModelList(t *testing.T) { writeFakeBinary(t, binDir, "openclaw") t.Setenv("PATH", binDir) - editor := &launcherEditorRunner{models: []string{"llama3.2", "mistral"}} + editor := &launcherEditorRunner{models: []string{"sample-model", "mistral"}} withIntegrationOverride(t, "openclaw", editor) - if err := config.SaveIntegration("openclaw", []string{"llama3.2", "mistral"}); err != nil { + if err := config.SaveIntegration("openclaw", []string{"sample-model", "mistral"}); err != nil { t.Fatalf("failed to seed config: %v", err) } @@ -3075,7 +3182,7 @@ func TestLaunchIntegration_OpenclawPreservesExistingModelList(t *testing.T) { 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" { + if editor.ranModel != "sample-model" { t.Fatalf("expected launch to use first saved model, got %q", editor.ranModel) } @@ -3083,7 +3190,7 @@ func TestLaunchIntegration_OpenclawPreservesExistingModelList(t *testing.T) { if err != nil { t.Fatalf("failed to reload saved config: %v", err) } - if diff := compareStrings(saved.Models, []string{"llama3.2", "mistral"}); diff != "" { + if diff := compareStrings(saved.Models, []string{"sample-model", "mistral"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } } @@ -3101,7 +3208,7 @@ func TestLaunchIntegration_OpenclawInstallsBeforeConfigSideEffects(t *testing.T) selectorCalled := false DefaultMultiSelector = func(title string, items []SelectionItem, preChecked []string) ([]string, error) { selectorCalled = true - return []string{"llama3.2"}, nil + return []string{"sample-model"}, nil } err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "openclaw"}) @@ -3135,7 +3242,7 @@ func TestLaunchIntegration_PiInstallsBeforeConfigSideEffects(t *testing.T) { selectorCalled := false DefaultMultiSelector = func(title string, items []SelectionItem, preChecked []string) ([]string, error) { selectorCalled = true - return []string{"llama3.2"}, nil + return []string{"sample-model"}, nil } err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "pi"}) @@ -3166,7 +3273,7 @@ func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing withIntegrationOverride(t, "droid", editor) DefaultMultiSelector = func(title string, items []SelectionItem, preChecked []string) ([]string, error) { - return []string{"llama3.2"}, nil + return []string{"sample-model"}, nil } var prompts []string @@ -3183,9 +3290,9 @@ func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/show": - fmt.Fprint(w, `{"model":"llama3.2"}`) + fmt.Fprint(w, `{"model":"sample-model"}`) default: http.NotFound(w, r) } @@ -3200,7 +3307,7 @@ func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing }); err != nil { t.Fatalf("LaunchIntegration returned error: %v", err) } - if diff := compareStringSlices(editor.edited, [][]string{{"llama3.2"}}); diff != "" { + if diff := compareStringSlices(editor.edited, [][]string{{"sample-model"}}); diff != "" { t.Fatalf("unexpected edited models (-want +got):\n%s", diff) } if editor.ranModel != "" { @@ -3325,7 +3432,7 @@ func TestLaunchIntegration_ClaudeForceConfigureMissingSelectionDoesNotSave(t *te writeFakeBinary(t, binDir, "claude") t.Setenv("PATH", binDir) - if err := config.SaveIntegration("claude", []string{"llama3.2"}); err != nil { + if err := config.SaveIntegration("claude", []string{"sample-model"}); err != nil { t.Fatalf("failed to seed config: %v", err) } @@ -3345,7 +3452,7 @@ func TestLaunchIntegration_ClaudeForceConfigureMissingSelectionDoesNotSave(t *te case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/show": var req apiShowRequest _ = json.NewDecoder(r.Body).Decode(&req) @@ -3374,7 +3481,7 @@ func TestLaunchIntegration_ClaudeForceConfigureMissingSelectionDoesNotSave(t *te if loadErr != nil { t.Fatalf("failed to reload saved config: %v", loadErr) } - if diff := compareStrings(saved.Models, []string{"llama3.2"}); diff != "" { + if diff := compareStrings(saved.Models, []string{"sample-model"}); diff != "" { t.Fatalf("unexpected saved models (-want +got):\n%s", diff) } } @@ -3447,6 +3554,260 @@ func TestLaunchIntegration_ClaudeModelOverrideSkipsSelector(t *testing.T) { } } +func TestLaunchIntegration_ModelOverrideDeprecatedPromptsAndDeclineCancels(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) + + var showCalls atomic.Int32 + var pullCalls atomic.Int32 + var prompt string + var promptOptions ConfirmOptions + DefaultConfirmPrompt = func(p string, options ConfirmOptions) (bool, error) { + prompt = p + promptOptions = options + return false, nil + } + 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":[`+ + `{"model":"best-cloud:cloud","description":"Cloud rec","context_length":262144,"max_output_tokens":32768},`+ + `{"model":"best-local","description":"Local rec"}`+ + `]}`) + case "/api/show": + showCalls.Add(1) + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + case "/api/pull": + pullCalls.Add(1) + 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: "qwen2.5-coder:32b", + }) + if !errors.Is(err, ErrCancelled) { + t.Fatalf("expected deprecated model override decline to cancel, got %v", err) + } + for _, want := range []string{"qwen2.5-coder:32b does not work well with StubSingle", "best-cloud:cloud", "best-local", "ollama launch droid --model best-cloud:cloud"} { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt %q does not contain %q", prompt, want) + } + } + if promptOptions.YesLabel != "Launch anyway" || promptOptions.NoLabel != "Pick another model" || promptOptions.Default != ConfirmDefaultNo { + t.Fatalf("unexpected deprecation prompt options: %+v", promptOptions) + } + if showCalls.Load() != 0 { + t.Fatalf("deprecated override decline should stop before /api/show, got %d calls", showCalls.Load()) + } + if pullCalls.Load() != 0 { + t.Fatalf("deprecated override decline should stop before /api/pull, got %d calls", pullCalls.Load()) + } + if runner.ranModel != "" { + t.Fatalf("expected integration not to run, got %q", runner.ranModel) + } +} + +func TestLaunchIntegration_SavedDeprecatedDeclineOpensPicker(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withLauncherHooks(t) + withInteractiveSession(t, true) + + if err := config.SaveIntegration("droid", []string{"llama3.2"}); err != nil { + t.Fatalf("failed to seed saved config: %v", err) + } + + binDir := t.TempDir() + writeFakeBinary(t, binDir, "droid") + t.Setenv("PATH", binDir) + + runner := &launcherSingleRunner{} + withIntegrationOverride(t, "droid", runner) + + var prompt string + DefaultConfirmPrompt = func(p string, options ConfirmOptions) (bool, error) { + prompt = p + return false, nil + } + + var selectorCalls int + var selectorCurrent string + DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) { + selectorCalls++ + selectorCurrent = current + itemNames := selectionItemNames(items) + if slices.Contains(itemNames, "llama3.2") { + t.Fatalf("expected saved deprecated model to be hidden from picker, got %v", itemNames) + } + if !slices.Contains(itemNames, "best-local") { + t.Fatalf("expected replacement model to remain selectable, got %v", itemNames) + } + return "best-local", nil + } + + var showCalls atomic.Int32 + 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":[`+ + `{"model":"best-cloud:cloud","description":"Cloud rec","context_length":262144,"max_output_tokens":32768},`+ + `{"model":"best-local","description":"Local rec"}`+ + `]}`) + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"llama3.2"},{"name":"best-local"}]}`) + case "/api/show": + showCalls.Add(1) + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + 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 !strings.Contains(prompt, "llama3.2 does not work well with StubSingle") { + t.Fatalf("expected saved deprecated model prompt before picker, got %q", prompt) + } + if selectorCalls != 1 { + t.Fatalf("expected picker to open after declining saved deprecated model, got %d calls", selectorCalls) + } + if selectorCurrent != "llama3.2" { + t.Fatalf("expected saved deprecated model as picker current value, got %q", selectorCurrent) + } + if showCalls.Load() != 1 { + t.Fatalf("expected only replacement model readiness to call /api/show, got %d calls", showCalls.Load()) + } + if runner.ranModel != "best-local" { + t.Fatalf("expected integration to run with replacement model, got %q", runner.ranModel) + } + saved, err := config.LoadIntegration("droid") + if err != nil { + t.Fatalf("failed to reload saved config: %v", err) + } + if got := primaryModelFromConfig(saved); got != "best-local" { + t.Fatalf("expected replacement model to be saved, got %q", got) + } +} + +func TestLaunchIntegration_ModelOverrideDeprecatedConfirmRuns(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) + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + if !strings.Contains(prompt, "qwen2.5-coder:32b does not work well with StubSingle") { + t.Fatalf("unexpected deprecated model prompt: %q", prompt) + } + return true, nil + } + var showCalls atomic.Int32 + 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":[`+ + `{"model":"best-cloud:cloud","description":"Cloud rec","context_length":262144,"max_output_tokens":32768},`+ + `{"model":"best-local","description":"Local rec"}`+ + `]}`) + case "/api/show": + showCalls.Add(1) + fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "droid", + ModelOverride: "qwen2.5-coder:32b", + }) + if err != nil { + t.Fatalf("expected deprecated model override confirmation to continue, got %v", err) + } + if showCalls.Load() == 0 { + t.Fatal("expected confirmed deprecated override to continue to /api/show") + } + if runner.ranModel != "qwen2.5-coder:32b" { + t.Fatalf("expected integration to run with confirmed deprecated model, got %q", runner.ranModel) + } +} + +func TestLaunchIntegration_ModelOverrideDeprecatedSuggestsLocalWhenCloudDisabled(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) + + var prompt string + DefaultConfirmPrompt = func(p string, options ConfirmOptions) (bool, error) { + prompt = p + return false, nil + } + 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":[`+ + `{"model":"best-cloud:cloud","description":"Cloud rec","context_length":262144,"max_output_tokens":32768},`+ + `{"model":"best-local","description":"Local rec"}`+ + `]}`) + 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) + + err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{ + Name: "droid", + ModelOverride: "llama3.2", + }) + if err == nil { + t.Fatal("expected deprecated model override to fail") + } + if !strings.Contains(prompt, "ollama launch droid --model best-local") { + t.Fatalf("expected local replacement command when cloud is disabled, got %q", prompt) + } + if strings.Contains(prompt, "ollama launch droid --model best-cloud:cloud") { + t.Fatalf("did not expect cloud replacement command when cloud is disabled, got %q", prompt) + } +} + func TestLaunchIntegration_ConfigureOnlyPrompt(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) @@ -3456,7 +3817,7 @@ func TestLaunchIntegration_ConfigureOnlyPrompt(t *testing.T) { withIntegrationOverride(t, "stubsingle", runner) DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) { - return "llama3.2", nil + return "sample-model", nil } var prompts []string @@ -3473,9 +3834,9 @@ func TestLaunchIntegration_ConfigureOnlyPrompt(t *testing.T) { case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/show": - fmt.Fprint(w, `{"model":"llama3.2"}`) + fmt.Fprint(w, `{"model":"sample-model"}`) default: http.NotFound(w, r) } @@ -3699,7 +4060,7 @@ func TestLaunchIntegration_HeadlessSelectorFlowFailsWithoutPrompt(t *testing.T) case "/api/experimental/model-recommendations": fmt.Fprint(w, `{"recommendations":[]}`) case "/api/tags": - fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`) + fmt.Fprint(w, `{"models":[{"name":"sample-model"}]}`) case "/api/show": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"error":"model not found"}`) diff --git a/cmd/launch/selector_hooks.go b/cmd/launch/selector_hooks.go index d8d83ea91..49b13f43b 100644 --- a/cmd/launch/selector_hooks.go +++ b/cmd/launch/selector_hooks.go @@ -27,10 +27,18 @@ var errCancelled = ErrCancelled // When set, ConfirmPrompt delegates to it instead of using raw terminal I/O. var DefaultConfirmPrompt func(prompt string, options ConfirmOptions) (bool, error) +type ConfirmDefault int + +const ( + ConfirmDefaultYes ConfirmDefault = iota + ConfirmDefaultNo +) + // ConfirmOptions customizes labels for confirmation prompts. type ConfirmOptions struct { YesLabel string NoLabel string + Default ConfirmDefault } // SingleSelector is a function type for single item selection. @@ -111,7 +119,12 @@ func ConfirmPromptWithOptions(prompt string, options ConfirmOptions) (bool, erro } defer term.Restore(fd, oldState) - fmt.Fprintf(os.Stderr, "%s (\033[1my\033[0m/n) ", prompt) + defaultNo := options.Default == ConfirmDefaultNo + if defaultNo { + fmt.Fprintf(os.Stderr, "%s (y/\033[1mN\033[0m) ", prompt) + } else { + fmt.Fprintf(os.Stderr, "%s (\033[1my\033[0m/n) ", prompt) + } buf := make([]byte, 1) for { @@ -120,7 +133,14 @@ func ConfirmPromptWithOptions(prompt string, options ConfirmOptions) (bool, erro } switch buf[0] { - case 'Y', 'y', 13: + case 'Y', 'y': + fmt.Fprintf(os.Stderr, "yes\r\n") + return true, nil + case 13: + if defaultNo { + fmt.Fprintf(os.Stderr, "no\r\n") + return false, nil + } fmt.Fprintf(os.Stderr, "yes\r\n") return true, nil case 'N', 'n', 27, 3: diff --git a/cmd/tui/confirm.go b/cmd/tui/confirm.go index d8122a797..a0060539a 100644 --- a/cmd/tui/confirm.go +++ b/cmd/tui/confirm.go @@ -115,7 +115,7 @@ func RunConfirmWithOptions(prompt string, options ConfirmOptions) (bool, error) prompt: prompt, yesLabel: yesLabel, noLabel: noLabel, - yes: true, // default to yes + yes: options.Default != launch.ConfirmDefaultNo, } p := tea.NewProgram(m)