diff --git a/internal/agent/dsh.go b/internal/agent/dsh.go index 3b25bc9c..1d8a5135 100644 --- a/internal/agent/dsh.go +++ b/internal/agent/dsh.go @@ -138,6 +138,9 @@ func dshAt(at place) *Agent { dshWrites.Lock() defer dshWrites.Unlock() for _, f := range dshProfiles(dir) { + if err := dshKeepPick(f); err != nil { + return err + } if err := dshSetFile(f, "", true, nil, gw()); err != nil { return err } @@ -576,7 +579,10 @@ func dshGet(dir string) string { // dshStart reads the model a profile's sessions start on: the one last picked // in dsh, saved in its settings, which go over every profile, else the -// agent-default-model entry of the home layer or the profile's own. +// agent-default-model entry of the home layer or the profile's own. dsh 0.2 +// keeps a pick in that entry itself: a settings.yaml left by 0.1.x is +// imported into the profile it next boots and renamed settings.yaml.imported, +// which nothing reads (#1392). func dshStart(dir string, items []dshItem) string { items = dshOver(dir, items) sel := edit.GetYAMLMap(filepath.Join(dir, "settings.yaml"), "agent-default-model") @@ -822,6 +828,35 @@ func dshSet(dir, v, gw string) error { return nil } +// dshKeepPick hands a profile's start back to the user where dsh saved their +// own pick into magpie's agent-default-model entry. Since 0.2 dsh no longer +// keeps a pick in ~/.dsh/settings.yaml: its /model (and the one-time import +// that renames settings.yaml to settings.yaml.imported) writes the config of +// the profile's last agent-default-model entry, which is magpie's, mark and +// all (#1392). A start there off magpie's route is the user's then, and +// Disconnect leaves it as it is, as it left a pick in settings.yaml before; +// what magpie stashed from before it wrote the entry is older than that pick. +// One still on the old wiring names DeepSeek's row for magpie's models and +// stays magpie's. +func dshKeepPick(f string) error { + head, items, err := dshRead(f) + if err != nil || dshOldWiring(items) { + return nil + } + i := dshFind(items, "agent-default-model") + if i < 0 || !items[i].magpie { + return nil + } + if p := dshConfig(items[i])["provider"]; p == "" || p == dshRoute || dshConfig(items[i])["model"] == "" { + return nil + } + lines := append([]string{}, items[i].lines...) + lines[0] = strings.TrimRight(strings.Replace(lines[0], dshMark, "", 1), " ") + items[i] = dshItem{id: items[i].id, lines: lines} + unstash(dshStashKey(f, "agent-default-model")) + return dshWrite(f, head, items) +} + // dshEnv puts the key for the gateway in $DSH_HOME/.env and dsh's own key // store, or with on false takes it out; the name magpie's llm-deepseek entry // used goes either way. diff --git a/internal/agent/dsh_joined_test.go b/internal/agent/dsh_joined_test.go index 47792899..eadedcfe 100644 --- a/internal/agent/dsh_joined_test.go +++ b/internal/agent/dsh_joined_test.go @@ -74,3 +74,74 @@ func TestDshOwnPickStaysConnected(t *testing.T) { t.Fatalf("model after Disconnect: %q", got) } } + +// Since 0.2 dsh keeps no pick in settings.yaml: its /model, and the one-time +// import that renames a settings.yaml to settings.yaml.imported, write the +// config of the profile's last agent-default-model entry — magpie's, mark +// and all (#1392). The entry below is as dsh 0.2.0-rc.2 left it after +// importing a settings.yaml whose pick was DeepSeek's own. Disconnect takes +// magpie's route and key out and leaves that pick, not the entry magpie +// stashed from before it was connected. +func TestDshPickSavedIntoMagpiesEntryOutlivesDisconnect(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("DSH_HOME", "") + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k", Models: []string{"pro", "flash"}}); err != nil { + t.Fatal(err) + } + dir := filepath.Join(home, ".dsh") + head := "# Your patch layer for this dsh profile, applied after every bundle layer:\n" + before := head + "- id: agent-default-model\n name: '@deepseek-ai/dsh-agent-default-model'\n config:\n provider: deepseek-official\n model: deepseek-v4-pro\n" + headless := filepath.Join(dir, "profiles", "headless", "cordis.patch.yml") + os.MkdirAll(filepath.Dir(headless), 0o755) + os.WriteFile(headless, []byte(before), 0o644) + read := func(p string) string { b, _ := os.ReadFile(p); return string(b) } + + a := dsh(home) + if err := a.Pick("model", "magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + // dsh 0.2.0-rc.2's ConfigEditor.edit: the last agent-default-model + // entry's config replaced, its id line (and magpie's mark) kept + s := read(headless) + ours := "- id: agent-default-model # magpie\n config:\n provider: magpie\n model: \"deepseek/pro\"\n" + if !strings.Contains(s, ours) { + t.Fatalf("magpie's start:\n%s", s) + } + s = strings.Replace(s, ours, "- id: agent-default-model # magpie\n config:\n provider: deepseek-official\n model: deepseek-v4-flash\n", 1) + os.WriteFile(headless, []byte(s), 0o600) + os.WriteFile(filepath.Join(dir, "settings.yaml.imported"), []byte("agent-default-model:\n provider: deepseek-official\n model: deepseek-v4-flash\n"), 0o600) + + if got := a.Field("model").Get(); got != "deepseek-v4-flash" { + t.Fatalf("model picked in dsh: %q", got) + } + if !a.Wired() { + t.Fatal("not connected with magpie's route still in the list") + } + if err := a.Disconnect(); err != nil { + t.Fatal(err) + } + s = read(headless) + if a.Wired() || strings.Contains(s, "llm-pi-ai") || strings.Contains(s, dshMark) { + t.Fatalf("magpie's wiring outlived Disconnect:\n%s", s) + } + if got := a.Field("model").Get(); got != "deepseek-v4-flash" || !strings.Contains(s, "model: deepseek-v4-flash") || strings.Contains(s, "deepseek-v4-pro") { + t.Fatalf("the pick made in dsh was lost (model %q):\n%s", got, s) + } + if strings.Contains(read(filepath.Join(dir, ".env")), dshKeyRef) { + t.Fatal("the key outlived the route") + } + // the user back on magpie and out again: nothing of the old stash returns + if err := a.Pick("model", "magpie/deepseek/flash"); err != nil { + t.Fatal(err) + } + if err := a.Pick("model", ""); err != nil { + t.Fatal(err) + } + if s := read(headless); strings.Contains(s, dshMark) || !strings.Contains(s, "model: deepseek-v4-flash") { + t.Fatalf("after magpie's model and back:\n%s", s) + } +}