agent: an agent's own provider named as one of magpie's isn't magpie's, in every agent that spells a model provider/model (the rest of #835) — OpenHanako's own deepseek/deepseek-v4-pro read as magpie's deepseek, and so did a model of any agent's own provider whose name magpie has a provider by: Pi's, Crush's, Hermes's, OpenCode's relay/glm-4.6 with magpie's relay had the row connected on the agent's own model, and Disconnect from magpie put that model back and left the row connected. An agent says how magpie's values are spelled in it (Spelled, replacing #835's Prefixed): magpie/<ref> for Antigravity CLI, Pi, OmO, Aside, Goose, Zed, VS Code, Crush, DeepSeek Harness, Command Code, fx, omp, Hermes, Mister Morph, Kimi Code, Empryo, MiniMax Code, Droid, Qoder, Qoder CN, Grok Build, AtomCode, Cline and OpenHanako, whose every Get spells magpie's so (a sandbox Set of magpie/relay/glm-4.6 read back as that in each); for OpenCode, MiMo Code and OpenChamber also a provider of the config's own that sends to magpie's gateway, as openCodeRef names a model there. A stopped WSL distro's agent keeps its live one's. Left as they are: Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, Copilot, Devin (magpie's are bare refs there), Muse Code (a model with / is magpie's by design: Meta's have none), Alma (its own are providerId:model), Air (magpie's only). TestOwnProviderNamedAsMagpies (26 agents: own relay/glm-4.6 not connected, Connect, Disconnect puts it back and the row is off) fails on the old code in each; TestOpenCodeGatewayProviderIsMagpies. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 06:49:45 +08:00
1 parent b590edb5ef
commit 165fee0df5
25 files changed
+131 -32

No files matched your search

+7 -6
View File
@@ -176,12 +176,13 @@ type Agent struct {
// names, never taken for one of magpie's models nor moved by what
// matches the picker (RenameRefs moves the names in it).
SplitSuffix func(v string) (model, suffix string, one bool)
// Prefixed, when set, is what each of magpie's values in the agent's
// fields starts with ("magpie/", its provider there): a value without
// it is a model of one of the agent's own providers, never magpie's,
// even when magpie has a provider of the same name (OpenHanako's own
// deepseek/deepseek-v4-pro, #835).
Prefixed string
// Spelled, when set, says whether a value of the agent's fields is
// spelled as one of magpie's there (prefixed: it starts with
// "magpie/", its provider in the agent): one that isn't is a model of
// one of the agent's own providers, never magpie's, even when magpie
// has a provider of the same name (OpenHanako's own
// deepseek/deepseek-v4-pro read as magpie's deepseek, #835).
Spelled func(v string) bool
// detect, when set, says whether the agent is here in place of looking
// for its files and binary: a distro's, probed once.
detect func() bool
+28 -4
View File
@@ -135,6 +135,30 @@ func usesMagpie(vals ...string) bool {
return false
}
// prefixed is an agent's Spelled whose values of magpie's are all
// magpie/<ref>, as its provider/model names them.
func prefixed(v string) bool { return strings.HasPrefix(v, magpieID+"/") }
// openCodeSpelled is the Spelled of an OpenCode config at path: magpie's
// provider, or one of the file's own that sends to magpie's gateway (v1),
// as openCodeRef names a model there.
func openCodeSpelled(path string, v1 func() string) func(string) bool {
return func(v string) bool {
if prefixed(v) {
return true
}
p, ref, ok := strings.Cut(v, "/")
if !ok || p == "" {
return false
}
if !strings.Contains(p, ".") {
base, _ := edit.GetJSON(path, "provider."+p+".options.baseURL")
return sameGateway(base, v1())
}
return ownGatewayProvider(path, ref, v1()) == p
}
}
// pair joins a provider field and a model field into one "provider/model"
// value, which is how OpenCode already spells it and how people think of it.
func pairGet(get func(string) (string, bool), pKey, mKey string) func() string {
@@ -581,7 +605,7 @@ func openCodeLike(at place, id, name, icon, bin, dir, auth string, ua []string,
}
}
return &Agent{
ID: id, Name: name, Icon: icon, Aliases: aliases,
ID: id, Name: name, Icon: icon, Aliases: aliases, Spelled: openCodeSpelled(path, at.v1),
UA: ua,
Bin: bin, Dir: dir, Path: path,
Check: func() string {
@@ -772,7 +796,7 @@ func piLike(at place, id, name, dir string) *Agent {
return edit.SetJSON(modelsPath, edit.KV{Path: "providers." + magpieID, Value: magpieProviderJSONAt("pi", id, at.gw())})
}
return &Agent{
ID: id, Name: name, Icon: id, Bin: id, Dir: dir, Path: path,
ID: id, Name: name, Icon: id, Bin: id, Dir: dir, Path: path, Spelled: prefixed,
Check: func() string {
if p, _ := get("defaultProvider"); p != magpieID {
return ""
@@ -866,7 +890,7 @@ func goose(home, cfg string) *Agent {
set := func(kvs ...edit.KV) error { return edit.SetYAMLTop(path, kvs...) }
provider := gooseProviderPath(path)
return &Agent{
ID: "goose", Name: "Goose", Icon: "goose", Bin: "goose", Dir: filepath.Dir(path), Path: path,
ID: "goose", Name: "Goose", Icon: "goose", Bin: "goose", Dir: filepath.Dir(path), Path: path, Spelled: prefixed,
UA: []string{"goose"},
Check: func() string {
if p, _ := get("GOOSE_PROVIDER"); p != gooseProviderID {
@@ -1125,7 +1149,7 @@ func crushAt(at place, path, data string) *Agent {
}
}
return &Agent{
ID: "crush", Name: "Crush", Icon: "crush", Bin: "crush", Dir: filepath.Dir(path), Path: path,
ID: "crush", Name: "Crush", Icon: "crush", Bin: "crush", Dir: filepath.Dir(path), Path: path, Spelled: prefixed,
UA: []string{"crush"},
Check: func() string {
large, _ := pick("models.large.provider")
+1 -1
View File
@@ -98,7 +98,7 @@ func agy(home string) *Agent {
return edit.SetJSON(path, edit.KV{Path: key, Value: v})
}
return &Agent{
ID: "agy", Name: "Antigravity CLI", Icon: "antigravity-color", Aliases: []string{"antigravity-cli"},
ID: "agy", Name: "Antigravity CLI", Icon: "antigravity-color", Aliases: []string{"antigravity-cli"}, Spelled: prefixed,
Bin: "agy", Dir: dir, Path: path,
Launch: func() string {
if v := get(); usesMagpie(v) {
+1 -1
View File
@@ -218,7 +218,7 @@ func magpieValue(a *Agent, f Field, v string, vals map[string]string) bool {
return false
}
v, _, one := a.split(v)
if !one || a.Prefixed != "" && !strings.HasPrefix(v, a.Prefixed) {
if !one || a.Spelled != nil && !a.Spelled(v) {
return false
}
if isMagpie(v) {
+1 -1
View File
@@ -61,7 +61,7 @@ func asideIn(at place) *Agent {
return nil
}
a := &Agent{
ID: "aside", Name: "Aside", Icon: "aside", Bin: "aside", Path: path,
ID: "aside", Name: "Aside", Icon: "aside", Bin: "aside", Path: path, Spelled: prefixed,
// ~/.aside, the folder the installer makes, and not the account's:
// ~/.aside/u/0 only appears once someone has signed in, so an Aside
// that is installed and not yet used is not detected by it, and the
+1 -1
View File
@@ -108,7 +108,7 @@ func atomcode(home string) *Agent {
return edit.SetTOMLTablesMatching(path, atomcodeAccountNames, atomcodeTablePrefixes, nil)
}
return atomic(&Agent{
ID: "atomcode", Name: "AtomCode", Icon: "atomcode",
ID: "atomcode", Name: "AtomCode", Icon: "atomcode", Spelled: prefixed,
UA: []string{"atomcode"},
Bin: "atomcode", Dir: dir, Path: path,
Sync: func() error {
+1 -1
View File
@@ -157,7 +157,7 @@ func cline(home string) *Agent {
return "cline"
}
return &Agent{
ID: "cline", Name: "Cline", Icon: "cline", Aliases: []string{"cline-cli"},
ID: "cline", Name: "Cline", Icon: "cline", Aliases: []string{"cline-cli"}, Spelled: prefixed,
UA: []string{"cline"},
Bin: "cline", Dir: dir, Path: path,
Sync: func() error {
+1 -1
View File
@@ -39,7 +39,7 @@ func commandCodeIn(at place) *Agent {
return edit.DelJSON(providers, "provider."+magpieID)
}
return &Agent{
ID: "commandcode", Name: "Command Code", Icon: "commandcode", Aliases: []string{"command-code", "cmd"},
ID: "commandcode", Name: "Command Code", Icon: "commandcode", Aliases: []string{"command-code", "cmd"}, Spelled: prefixed,
UA: []string{"command-code", "commandcode"},
Bin: "command-code", Dir: dir, Path: path,
Sync: func() error {
+1 -1
View File
@@ -205,7 +205,7 @@ func droidIn(at place) *Agent {
return droidCustom{}, false
}
return &Agent{
ID: "droid", Name: "Droid", Icon: "factory", Aliases: []string{"factory", "factory-droid"},
ID: "droid", Name: "Droid", Icon: "factory", Aliases: []string{"factory", "factory-droid"}, Spelled: prefixed,
// droid's requests to a custom model carry factory-cli/<version>
UA: []string{"factory-cli"},
Bin: "droid", Dir: dir, Path: path,
+1 -1
View File
@@ -93,7 +93,7 @@ func dshAt(at place) *Agent {
path = files[0]
}
return &Agent{
ID: "dsh", Name: "DeepSeek Harness", Icon: "deepseek-color", Aliases: []string{"deepseek-harness"},
ID: "dsh", Name: "DeepSeek Harness", Icon: "deepseek-color", Aliases: []string{"deepseek-harness"}, Spelled: prefixed,
UA: []string{"deepseek-harness"},
Bin: "dsh", Dir: dir, Path: path,
Sync: func() error { return dshSync(dir, gw()) },
+1 -1
View File
@@ -102,7 +102,7 @@ func empryoAt(at place) *Agent {
return edit.SetJSON(path, edit.KV{Path: empryoModel, Value: v})
}
return atomic(&Agent{
ID: "empryo", Name: "Empryo", Icon: "empryo-color", Aliases: []string{"soulforge"},
ID: "empryo", Name: "Empryo", Icon: "empryo-color", Aliases: []string{"soulforge"}, Spelled: prefixed,
Bin: "empryo", Dir: dir, Path: path,
Notice: func() string {
if Running(`(^|/)empryo( |$)`) {
+1 -1
View File
@@ -57,7 +57,7 @@ func fxIn(at place) *Agent {
return edit.DelJSON(path, keys...)
}
return &Agent{
ID: "fx", Name: "fx", Icon: "fx",
ID: "fx", Name: "fx", Icon: "fx", Spelled: prefixed,
Bin: "fx", Dir: dir, Path: path,
Sync: func() error {
return syncJSON(path, "providers."+magpieID, func() any { return fxProvider(fxModels(path)[magpieID]) })
+1 -1
View File
@@ -105,7 +105,7 @@ func grokIn(at place) *Agent {
return catalog.Efforts(magpieModels("grok"), ref)
}
return atomic(&Agent{
ID: "grok", Name: "Grok Build", Icon: "xai", Aliases: []string{"grok-build", "grok-cli"},
ID: "grok", Name: "Grok Build", Icon: "xai", Aliases: []string{"grok-build", "grok-cli"}, Spelled: prefixed,
UA: []string{"grok-shell", "grok-pager", "xai-grok-build"}, // grok-pager: its terminal front end
Dir: dir, Path: path,
Sync: func() error {
+1 -1
View File
@@ -93,7 +93,7 @@ func hanako(home string) *Agent {
Dir: dir, Path: path,
// its own providers' models are provider/id as magpie's are: one
// is magpie's only on magpie's provider
Prefixed: magpieID + "/",
Spelled: prefixed,
Sync: func() error {
cur, ok := hanakoCurrent(dir)
if !ok || hanakoSame(cur, hanakoProvider()) {
+1 -1
View File
@@ -52,7 +52,7 @@ func hermesIn(at place) *Agent {
return edit.SetYAML(path, kvs...)
}
return &Agent{
ID: "hermes", Name: "Hermes Agent", Icon: "hermes", Aliases: []string{"hermes-agent"},
ID: "hermes", Name: "Hermes Agent", Icon: "hermes", Aliases: []string{"hermes-agent"}, Spelled: prefixed,
UA: []string{"hermes-agent"},
Bin: "hermes", Dir: dir, Path: path,
Sync: func() error {
+1 -1
View File
@@ -155,7 +155,7 @@ func kimiIn(at place) *Agent {
return err == nil && t != nil
}
return atomic(&Agent{
ID: "kimi", Name: "Kimi Code", Icon: "kimi", Aliases: []string{"kimi-code", "kimi-cli"},
ID: "kimi", Name: "Kimi Code", Icon: "kimi", Aliases: []string{"kimi-code", "kimi-cli"}, Spelled: prefixed,
UA: []string{"kimicli"},
Bin: "kimi", Dir: dir, Path: path,
Sync: func() error {
+1 -1
View File
@@ -76,7 +76,7 @@ func miniMaxAt(at place, dir string) *Agent {
return edit.SetYAML(path, kvs...)
}
return atomic(&Agent{
ID: "minimax-code", Name: "MiniMax Code", Icon: "minimax-color", Aliases: []string{"mcode"},
ID: "minimax-code", Name: "MiniMax Code", Icon: "minimax-color", Aliases: []string{"mcode"}, Spelled: prefixed,
UA: []string{mcodeUA},
Bin: "mcode", Dir: dir, Path: path,
Sync: func() error {
+1 -1
View File
@@ -90,7 +90,7 @@ func morphIn(at place) *Agent {
return 0
}
return &Agent{
ID: "morph", Name: "Mister Morph", Icon: "mistermorph-color", Aliases: []string{"mistermorph", "mister-morph"},
ID: "morph", Name: "Mister Morph", Icon: "mistermorph-color", Aliases: []string{"mistermorph", "mister-morph"}, Spelled: prefixed,
UA: []string{morphUA},
// no Bin: morph is other tools' name too; ~/.morph is made by its
// first run, `morph install` or the desktop app's setup
+1 -1
View File
@@ -270,7 +270,7 @@ func ompAt(at place, dir string, entry func() ompProviderEntry) *Agent {
}
}
return &Agent{
ID: "omp", Name: "omp", Icon: "omp", Aliases: []string{"oh-my-pi"},
ID: "omp", Name: "omp", Icon: "omp", Aliases: []string{"oh-my-pi"}, Spelled: prefixed,
UA: []string{"oh-my-pi"},
Bin: "omp", Dir: dir, Path: path,
// a role's thinking level is omp's, after whichever model it is on;
+1 -1
View File
@@ -182,7 +182,7 @@ func openChamber(home, cfg string) *Agent {
return edit.DelJSON(ocPath, "provider."+magpieID)
}
return &Agent{
ID: "openchamber", Name: "OpenChamber", Icon: "openchamber", Aliases: []string{"chamber"},
ID: "openchamber", Name: "OpenChamber", Icon: "openchamber", Aliases: []string{"chamber"}, Spelled: openCodeSpelled(ocPath, gatewayV1),
// its model requests are its OpenCode's, and counted as OpenCode's
Bin: "openchamber", Dir: s.dir, Path: s.prefs(),
Notice: func() string {
+1 -1
View File
@@ -113,7 +113,7 @@ func qoderAt(at place, b qoderBuild) *Agent {
return ok && qoderKeyed(get(slot+".apiKey"), b.id)
}
return &Agent{
ID: b.id, Name: b.name, Icon: "qoder", Aliases: b.aliases,
ID: b.id, Name: b.name, Icon: "qoder", Aliases: b.aliases, Spelled: prefixed,
Bin: b.bin, Dir: dir, Path: path,
Sync: func() error {
ref, ok := cutMagpie(get("model.name"))
+74
View File
@@ -0,0 +1,74 @@
package agent
import (
"path/filepath"
"testing"
)
// An agent that spells a model provider/model, magpie's as magpie/<ref>:
// one of its own providers named as one of magpie's (its own relay, and
// magpie's relay) is its own model, not magpie's. The row isn't connected
// on it, and Disconnect from magpie puts it back and is done, rather than
// leaving the row connected on the model it went back to (#835, OpenHanako's
// own deepseek/deepseek-v4-pro).
func TestOwnProviderNamedAsMagpies(t *testing.T) {
ids := []string{"agy", "opencode", "openchamber", "mimocode", "pi", "aside", "omo", "goose", "zed", "vscode",
"crush", "dsh", "commandcode", "fx", "omp", "hermes", "morph", "kimi", "empryo", "minimax-code", "droid",
"qoder", "qoder-cn", "grok", "atomcode", "cline"}
for _, id := range ids {
t.Run(id, func(t *testing.T) {
syncHome(t)
a, err := Find(id)
if err != nil {
t.Fatal(err)
}
f := &a.Fields[0]
if id == "cline" {
// a model of OpenRouter's, the provider Cline is on
hanakoWrite(t, a.Path, `{"version":1,"modes":{},"providers":{},"lastUsedProvider":"openrouter"}`, 0o600)
}
if err := f.Set("relay/glm-4.6"); err != nil {
t.Fatal(err)
}
if v := f.Get(); v != "relay/glm-4.6" {
t.Fatalf("own model reads %q", v)
}
if a.Wired() {
t.Fatal("its own relay/glm-4.6 reads as magpie's")
}
if err := a.Connect(); err != nil {
t.Fatal(err)
}
if !a.Wired() {
t.Fatalf("not connected after Connect: %v", a.Values())
}
if err := a.Disconnect(); err != nil {
t.Fatal(err)
}
if a.Wired() {
t.Fatalf("still connected after Disconnect: %v", a.Values())
}
if v := f.Get(); v != "relay/glm-4.6" {
t.Errorf("Disconnect left %q, not its own relay/glm-4.6", v)
}
})
}
}
// OpenCode's own provider that sends to magpie's gateway is magpie's all
// the same (openCodeRef names a model there by it).
func TestOpenCodeGatewayProviderIsMagpies(t *testing.T) {
syncHome(t)
a, err := Find("opencode")
if err != nil {
t.Fatal(err)
}
hanakoWrite(t, a.Path, `{"provider":{"relay":{"options":{"baseURL":"`+gatewayV1()+`"},"models":{"relay/glm-4.6":{}}}},"model":"relay/relay/glm-4.6"}`, 0o644)
if !a.Wired() {
t.Fatalf("a model through its own provider at the gateway isn't magpie's: %v (%s)", a.Values(), filepath.Base(a.Path))
}
hanakoWrite(t, a.Path, `{"provider":{"relay":{"options":{"baseURL":"https://x/v1"},"models":{"glm-4.6":{}}}},"model":"relay/glm-4.6"}`, 0o644)
if a.Wired() {
t.Fatalf("its own relay elsewhere reads as magpie's: %v", a.Values())
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ func vscodeAt(dir string) *Agent {
return edit.DelJSON(path, vscodeDefault)
}
return atomic(&Agent{
ID: "vscode", Name: "VS Code", Icon: "vscode", Aliases: []string{"vs-code", "copilot-chat", "vscode-chat"},
ID: "vscode", Name: "VS Code", Icon: "vscode", Aliases: []string{"vs-code", "copilot-chat", "vscode-chat"}, Spelled: prefixed,
Bin: "code", Dir: dir, Path: path,
UA: []string{vscodeUA},
Notice: func() string {
+1 -1
View File
@@ -477,7 +477,7 @@ func wslAgent(k wslKind, d distro) *Agent {
// files, which starts the distro.
func asleep(live *Agent, k wslKind, d distro) *Agent {
started := false
a := &Agent{ID: live.ID, Name: live.Name, Icon: live.Icon, WSL: d.Name, detect: live.detect,
a := &Agent{ID: live.ID, Name: live.Name, Icon: live.Icon, WSL: d.Name, detect: live.detect, Spelled: live.Spelled,
Notice: func() string {
if started {
return live.Notice()
+1 -1
View File
@@ -38,7 +38,7 @@ func zedAt(dir string) *Agent {
model := pairGet(func(k string) (string, bool) { return edit.GetJSON(path, k) }, zedModel+".provider", zedModel+".model")
key := "zed:" + path + ":"
return atomic(&Agent{
ID: "zed", Name: "Zed", Icon: "zed", Bin: "zed", Dir: dir, Path: path,
ID: "zed", Name: "Zed", Icon: "zed", Bin: "zed", Dir: dir, Path: path, Spelled: prefixed,
UA: []string{"zed"},
Notice: func() string {
if usesMagpie(model()) && Running(`(^|/)(zed|zeditor|zed-editor)( |$)`) {