dsh: Disconnect keeps the model picked in dsh 0.2, which saves it into magpie's agent-default-model entry (#1392)

What dsh 0.2.0-rc.2 changed (read in @deepseek-ai/dsh-settings and
dsh-agent-default-model from npm): ~/.dsh/settings.yaml is gone. On its
first boot dsh renames it to settings.yaml.imported and merges its sections
into the booted profile's patch list (profiles/<name>/cordis.patch.yml);
nothing reads the .imported file afterwards. A /model pick is no longer
kept in settings.yaml either: agentDefaultModel.saveSelection goes through
ConfigEditor.edit, which replaces the config of the profile's last
agent-default-model entry. That entry is magpie's, so the user's pick sits
under magpie's "# magpie" mark. Profile paths, the home layer, .env and
.credentials.yaml are unchanged.

What broke: Disconnect took that entry for magpie's own, dropped it and put
back what magpie stashed from before it connected, so the model picked in
dsh (or imported from settings.yaml) was lost. Seen with real dsh
0.2.0-rc.2: after the import, Disconnect left `[]` and dsh started on its
shipped deepseek-flash instead of the imported deepseek-v4-flash.

Fix: dshKeepPick, run per profile before Disconnect takes magpie's wiring
out, hands a marked agent-default-model that is off magpie's route back to
the user (mark removed, old stash discarded). An entry on magpie's route,
or one on the pre-0.1.5 wiring, is still magpie's. dshStart still reads a
settings.yaml not yet imported, which dsh will put over the booted profile.

Run: TestDshPickSavedIntoMagpiesEntryOutlivesDisconnect (fixture from the
patch real dsh 0.2.0-rc.2 wrote) fails on the old code ("the pick made in
dsh was lost", the stashed deepseek-v4-pro came back) and passes. Real dsh
0.2.0-rc.2 under a temp HOME/DSH_HOME, fake gateway on 127.0.0.1:3561:
`dsh headless "say hi"` sent POST /v1/chat/completions, Bearer magpie,
model deepseek/pro to it, before and after a settings.yaml import; after
Disconnect `dsh headless --dump-config` keeps deepseek-v4-flash;
reconnecting sends deepseek/flash to the fake gateway. go test -tags nogui
./... (temp HOME, pinned caches) passes, -race -count=5 -run TestDsh
passes, gofmt -l clean, go vet and GOOS=linux/windows go vet -tags nogui
./internal/agent pass.
This commit is contained in:
yetone committed 2026-10-09 22:26:00 +08:00
1 parent dec951229c
commit 603dc0c44f
2 files changed
+107 -1

No files matched your search

+36 -1
View File
@@ -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.
+71
View File
@@ -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)
}
}