launch: warn before launching old agent models (#17063)

This commit is contained in:
Parth Sareen
2026-07-09 16:55:10 -07:00
committed by GitHub
parent d47859ce49
commit cb3d98ccb2
7 changed files with 790 additions and 137 deletions
+26 -26
View File
@@ -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)
}
+133
View File
@@ -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
}
+68
View File
@@ -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)
}
}
}
+89 -18
View File
@@ -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
}
+451 -90
View File
@@ -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"}`)
+22 -2
View File
@@ -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:
+1 -1
View File
@@ -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)