agents: dsh's patch list written in flow style is edited like a block one, and a file magpie can't edit is named as itself (#445) — a profile's cordis.patch.yml whose top-level array was written as [ { id: some-plugin, disabled: false } ] (valid YAML, and what dsh's own writers keep the template's [] as once they add a row) failed every model switch with "config.yaml is not a list of entries magpie can edit": the patch-list reader took only '- ' lines, and the error named config.yaml whatever the file; since web's list goes first, one such profile stopped all of them. New edit.BlockList reads a file whose one document is a flow sequence as the same list in block style (nested mappings and lists in block style, scalars quoted and tagged as they were, the lines before it and the comments after it kept); dsh's model/effort wiring and its MCP servers (internal/library) read and write such a file through it, and its errors name the real path. dshProfiles puts web first by partition instead of a sort whose less wasn't a strict weak order. TestDshFlowPatchList (agent, library) fails without the change; TestBlockList covers one-line, folded/nested/!!js, empty, CRLF and what isn't a flow list. Sandbox HOME: magpie dsh deepseek-v4-pro on main fails with the config.yaml error, with the change both profiles get the model and web keeps some-plugin. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.

This commit is contained in:
yetone committed 2026-10-01 23:25:05 +08:00
1 parent bd529a555e
commit e2e5f4bb64
6 files changed
+240 -11

No files matched your search

+20 -11
View File
@@ -30,7 +30,6 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"gopkg.in/yaml.v3"
@@ -129,10 +128,15 @@ func dsh(home string) *Agent {
// dshProfiles are the profiles' patch lists (dsh 0.1.5 on), web's first.
func dshProfiles(dir string) []string {
files, _ := filepath.Glob(filepath.Join(dir, "profiles", "*", "cordis.patch.yml"))
sort.SliceStable(files, func(i, j int) bool {
return filepath.Base(filepath.Dir(files[i])) == "web" && filepath.Base(filepath.Dir(files[j])) != "web"
})
return files
var web, rest []string
for _, f := range files {
if filepath.Base(filepath.Dir(f)) == "web" {
web = append(web, f)
} else {
rest = append(rest, f)
}
}
return append(web, rest...)
}
// dshItem is one entry of the patch list, as its lines.
@@ -144,9 +148,14 @@ type dshItem struct {
var dshIDLine = regexp.MustCompile(`^(?:- | )id:\s*['"]?([^'"#\s]+)['"]?\s*(#.*)?$`)
// dshParse splits the patch list into what comes before its first entry and
// the entries. A file that is not a plain block list is left alone.
func dshParse(raw string) (head []string, items []dshItem, err error) {
// dshParse splits the patch list at path into what comes before its first
// entry and the entries. A list written in flow style ([ {id: a} ], which
// dsh's own writers keep a profile's [] in) is read as the same list in block
// style, and written back so; a file that is no list is left alone.
func dshParse(path, raw string) (head []string, items []dshItem, err error) {
if b, ok := edit.BlockList(raw); ok {
raw = b
}
for _, l := range splitLinesKeep(raw) {
t := strings.TrimSpace(l)
switch {
@@ -154,14 +163,14 @@ func dshParse(raw string) (head []string, items []dshItem, err error) {
items = append(items, dshItem{lines: []string{l}})
case len(items) == 0:
if t != "" && !strings.HasPrefix(t, "#") && t != "[]" {
return nil, nil, fmt.Errorf("%s is not a list of entries magpie can edit", "config.yaml")
return nil, nil, fmt.Errorf("%s is not a list of entries magpie can edit", path)
}
if t != "[]" {
head = append(head, l)
}
continue
case t != "" && !strings.HasPrefix(t, "#") && !strings.HasPrefix(l, " "):
return nil, nil, fmt.Errorf("%s is not a list of entries magpie can edit", "config.yaml")
return nil, nil, fmt.Errorf("%s is not a list of entries magpie can edit", path)
default:
items[len(items)-1].lines = append(items[len(items)-1].lines, l)
}
@@ -188,7 +197,7 @@ func dshRead(path string) ([]string, []dshItem, error) {
if err != nil {
return nil, nil, err
}
return dshParse(string(b))
return dshParse(path, string(b))
}
// dshWrite puts a patch list back together.
+92
View File
@@ -0,0 +1,92 @@
package agent
import (
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
"github.com/yetone/magpie/internal/provider"
)
// A profile's patch list written in flow style, as dsh's own writers leave
// the template's [] once they add a row (#445), is edited like a block one:
// every profile gets the model, the user's row stays, and a file magpie
// can't edit is named in the error, not as config.yaml.
func TestDshFlowPatchList(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", 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")
web, def := filepath.Join(dir, "profiles", "web", "cordis.patch.yml"), filepath.Join(dir, "profiles", "default", "cordis.patch.yml")
flow := "# Your patch layer for this dsh profile, applied after every bundle layer:\n# a top-level YAML array of loader patch entries ...\n[ { id: some-plugin, disabled: false } ]\n"
template := "# Your patch layer for this dsh profile.\n[]\n"
for p, s := range map[string]string{web: flow, def: template} {
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(s), 0o644)
}
read := func(p string) string { b, _ := os.ReadFile(p); return string(b) }
f := dsh(home).Field("model")
if err := f.Set("magpie/deepseek/pro"); err != nil {
t.Fatal(err)
}
for _, p := range []string{web, def} {
s := read(p)
for _, want := range []string{"# Your patch layer", "- id: llm-pi-ai # magpie", "- id: agent-default-model # magpie", `model: "deepseek/pro"`} {
if !strings.Contains(s, want) {
t.Fatalf("missing %q in %s:\n%s", want, p, s)
}
}
}
s := read(web)
if !strings.Contains(s, "- id: some-plugin\n disabled: false\n") || !strings.HasPrefix(s, "# Your patch layer for this dsh profile, applied after every bundle layer:\n# a top-level YAML array") {
t.Fatalf("the user's row or comments lost:\n%s", s)
}
var rows []map[string]any
if err := yaml.Unmarshal([]byte(s), &rows); err != nil || len(rows) != 3 || rows[0]["id"] != "some-plugin" || rows[0]["disabled"] != false {
t.Fatalf("%v %v\n%s", err, rows, s)
}
if f.Get() != "magpie/deepseek/pro" {
t.Fatalf("get: %q", f.Get())
}
// back to dsh as it ships: the user's row, now a block list
if err := f.Set(""); err != nil {
t.Fatal(err)
}
if got, want := read(web), "# Your patch layer for this dsh profile, applied after every bundle layer:\n# a top-level YAML array of loader patch entries ...\n- id: some-plugin\n disabled: false\n"; got != want {
t.Fatalf("reset:\n%q\nwant\n%q", got, want)
}
// a file that is no list is named as itself
os.WriteFile(web, []byte("some-plugin:\n disabled: false\n"), 0o644)
err := f.Set("magpie/deepseek/pro")
if err == nil || !strings.Contains(err.Error(), web) || strings.Contains(err.Error(), "config.yaml") {
t.Fatalf("error: %v", err)
}
}
// web's patch list is the first whatever the profiles are called.
func TestDshProfilesWebFirst(t *testing.T) {
dir := t.TempDir()
for _, n := range []string{"a", "web", "default", "z", "desktop"} {
p := filepath.Join(dir, "profiles", n, "cordis.patch.yml")
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte("[]\n"), 0o644)
}
var got []string
for _, f := range dshProfiles(dir) {
got = append(got, filepath.Base(filepath.Dir(f)))
}
if strings.Join(got, " ") != "web a default desktop z" {
t.Fatalf("%v", got)
}
}
+57
View File
@@ -320,3 +320,60 @@ func blockStyle(n *yaml.Node) {
blockStyle(c)
}
}
// BlockList is raw, a file whose one document is a top-level sequence
// written in flow style ([ {id: a}, {id: b} ], what dsh's own writers keep
// a file that starts as []), with that sequence written as a block list
// instead: one "- " item after another, each entry's mappings and lists in
// block style too, its scalars quoted as they were. The lines before the
// sequence and the comment lines after it stay as written. ok is false, and
// raw is left to the caller, for anything else: a block list, a mapping,
// several documents, or text that isn't YAML.
func BlockList(raw string) (string, bool) {
raw = strings.ReplaceAll(raw, "\r\n", "\n")
dec := yaml.NewDecoder(strings.NewReader(raw))
var doc, more yaml.Node
if dec.Decode(&doc) != nil || dec.Decode(&more) == nil {
return "", false
}
if len(doc.Content) != 1 {
return "", false
}
seq := doc.Content[0]
if seq.Kind != yaml.SequenceNode || seq.Style&yaml.FlowStyle == 0 {
return "", false
}
lines := strings.Split(strings.TrimRight(raw, "\n"), "\n")
start := min(max(seq.Line-1, 0), len(lines))
end := len(lines)
for end > start {
if t := strings.TrimSpace(lines[end-1]); t != "" && !strings.HasPrefix(t, "#") {
break
}
end--
}
out := append([]string{}, lines[:start]...)
if len(seq.Content) == 0 {
out = append(out, "[]")
} else {
seq.HeadComment, seq.LineComment, seq.FootComment = "", "", ""
unflow(seq)
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if enc.Encode(seq) != nil || enc.Close() != nil {
return "", false
}
out = append(out, strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")...)
}
out = append(out, lines[end:]...)
return strings.Join(out, "\n") + "\n", true
}
// unflow writes n's mappings and sequences in block style.
func unflow(n *yaml.Node) {
n.Style &^= yaml.FlowStyle
for _, c := range n.Content {
unflow(c)
}
}
+46
View File
@@ -0,0 +1,46 @@
package edit
import (
"reflect"
"testing"
"gopkg.in/yaml.v3"
)
// A top-level list written in flow style, as dsh's own writers keep a
// profile's [] once they add to it (#445), comes back as the same list in
// block style, the lines around it as they were.
func TestBlockList(t *testing.T) {
cases := []struct{ name, in, want string }{
{"one line", "# Your patch layer for this dsh profile\n# a top-level YAML array\n[ { id: some-plugin, disabled: false } ]\n",
"# Your patch layer for this dsh profile\n# a top-level YAML array\n- id: some-plugin\n disabled: false\n"},
{"folded, nested, tagged", "[\n { id: tools, config: { disabled: [a, b] } },\n { id: \"@x/y\", config: { cwd: !!js process.cwd(), n: \"1\" } }\n]\n# after\n",
"- id: tools\n config:\n disabled:\n - a\n - b\n- id: \"@x/y\"\n config:\n cwd: !!js process.cwd()\n n: \"1\"\n# after\n"},
{"empty", "# head\n[ ]\n", "# head\n[]\n"},
{"bare", "[]\n", "[]\n"},
{"crlf", "# h\r\n[ {id: a} ]\r\n", "# h\n- id: a\n"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := BlockList(c.in)
if !ok || got != c.want {
t.Fatalf("got %v %q, want %q", ok, got, c.want)
}
var before, after any
if err := yaml.Unmarshal([]byte(c.in), &before); err != nil {
t.Fatal(err)
}
if err := yaml.Unmarshal([]byte(got), &after); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(before, after) {
t.Fatalf("the list changed: %v -> %v", before, after)
}
})
}
for _, in := range []string{"", "# only a comment\n", "- id: a\n", "a: [1]\n", "[a]\n---\n[b]\n", "[ {id: a\n", "- [a, b]\n"} {
if got, ok := BlockList(in); ok {
t.Fatalf("%q taken for a flow list: %q", in, got)
}
}
}
+3
View File
@@ -82,6 +82,9 @@ func dshRead(path string) (*dshPatches, error) {
return nil, err
}
p := &dshPatches{}
if b, ok := edit.BlockList(string(raw)); ok { // [ {...} ]: read, and written back, as a block list
raw = []byte(b)
}
s := strings.TrimRight(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
if s == "" {
return p, nil
+22
View File
@@ -0,0 +1,22 @@
package library
import (
"path/filepath"
"strings"
"testing"
)
// A patch list in flow style (#445) is read and written like a block one:
// the user's rows stay, magpie's server goes in as an insert of its own.
func TestDshFlowPatchList(t *testing.T) {
h := sandbox(t)
p := filepath.Join(h, ".dsh/profiles/web/cordis.patch.yml")
write(t, p, "# Your patch layer for this dsh profile\n[ { id: some-plugin, disabled: false } ]\n")
ok(t)(SaveServer("", Server{Name: "fs", Transport: "stdio", Command: "npx", Args: []string{"-y", "@mcp/fs"}, Agents: []string{"dsh"}}))
s := read(t, p)
for _, want := range []string{"# Your patch layer for this dsh profile\n- id: some-plugin\n disabled: false\n", "- insert: # magpie\n - id: magpie-mcp-fs\n", `serverName: "fs"`} {
if !strings.Contains(s, want) {
t.Fatalf("missing %q in\n%s", want, s)
}
}
}