wherever a ref is read back from an agent's settings An agent on a routing group is shown, on the Providers page, on each of that group's providers — the row's middle holds one button per agent, and `uses` is what the Routing page's "n agents routed through it" counts. A group whose model has a 1M window is written into Claude Code's settings with the mark Claude Code itself puts on it (`group/auto-deepseek-v4-1-flash[1m]`), and GroupFor takes the mark off for the gateway; the rest did not. So the group was not found, and an agent Claude Code had on it was listed on none of the group's providers, while every request it made went through them. Four places read such a ref back: - provider.GroupFinder, which the Providers page asks (agentUses) - agent.sameModel, which moves an agent whose group is gone to the same model from a provider still serving it — the mark left on made the comparison against AutoGroupOf find nothing, and the agent stayed on a group nobody serves - agent.groupNamed, which says whether two values of a field reach the same group, one of them read back from the agent's own settings - the CLI's groupUses, which lists "← agent" beside a group: the marked id was filed under one no group has, and Claude Code was on no row Each takes the mark off, as GroupFor does. A group's own members are not touched: those are the group's structure, never a ref from an agent's settings, and a [1m] there is a model's, not the group's. group_uses_test covers the page (a group found with and without the mark, and the row naming the agent on the group's provider); applied_test covers groupNamed; reseat_test covers the move (the marked group ref finds the same model elsewhere, and a group that is nobody else's still gives nothing); groups_cli_test covers the CLI, with Claude Code's settings.json as the reporter's reads. Each fails without its fix. go build, go vet pass; go test ./internal/agent/ passes, and the failures that remain are the ones a clean origin/main has too (Aside's symlinks, ~/.codex dependence, VSCode's launchers, a flaky Zed case). Co-authored-by: wizzy-yang <wizzy-yang@users.noreply.github.com>
290 lines
10 KiB
Go
290 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/yetone/magpie/internal/provider"
|
|
)
|
|
|
|
// groupsHome is a machine of its own with two providers: a serves m and
|
|
// only-a, b serves vendor/m (so magpie finds group/auto-m).
|
|
func groupsHome(t *testing.T) {
|
|
t.Helper()
|
|
t.Setenv("HOME", t.TempDir()) // no agent signed in
|
|
t.Setenv("USERPROFILE", os.Getenv("HOME"))
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
t.Setenv("DSH_HOME", "") // a dsh profile is found through it, not through HOME
|
|
for _, p := range []provider.Provider{
|
|
{ID: "a", Name: "A", Key: "ka", Models: []string{"m", "only-a", "claude-opus-5-5"}, Chat: "http://127.0.0.1:1/v1"},
|
|
{ID: "b", Name: "B", Key: "kb", Models: []string{"vendor/m", "gpt-5.5"}, Chat: "http://127.0.0.1:1/v1"},
|
|
} {
|
|
if err := provider.Save(p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// storedGroups: the groups as the file keeps them.
|
|
// A group an agent is on carries Claude Code's [1m] mark in the agent's
|
|
// own settings, as a model's does: `magpie group` lists "← agent" beside a
|
|
// group by its id, so the mark has to come off the id before it is looked
|
|
// up. Claude Code on group/auto-gpt-5[1m] was on no row at all.
|
|
func TestGroupUsesTakesTheOneMMarkOff(t *testing.T) {
|
|
groupsHome(t)
|
|
if err := provider.SaveGroup(provider.Group{ID: "auto-gpt-5", Name: "GPT 5", Members: []string{"a/gpt-5"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Claude Code's settings.json, as the reporter's reads
|
|
if err := os.MkdirAll(filepath.Join(os.Getenv("HOME"), ".claude"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(os.Getenv("HOME"), ".claude/settings.json"),
|
|
[]byte(`{"model":"group/auto-gpt-5[1m]"}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
uses := groupUses()
|
|
if got := uses["auto-gpt-5"]; len(got) != 1 || !strings.Contains(got[0], "Claude") {
|
|
t.Errorf("the group's agents: %q, all uses %q", got, uses)
|
|
}
|
|
if _, marked := uses["auto-gpt-5[1m]"]; marked {
|
|
t.Errorf("the marked id got its own row: %q", uses)
|
|
}
|
|
}
|
|
|
|
// sameGroup compares two values of a field, one of them read back from the
|
|
// agent's own settings where the [1m] mark rides on the group's id: the
|
|
// marked one is the group magpie set, not another group. (internal/agent's
|
|
// own, tested there.)
|
|
func storedGroups(t *testing.T) []map[string]any {
|
|
t.Helper()
|
|
b, err := os.ReadFile(provider.Path())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var f struct {
|
|
Groups []map[string]any `json:"groups"`
|
|
}
|
|
if err := json.Unmarshal(b, &f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return f.Groups
|
|
}
|
|
|
|
func TestParseRoutingStays(t *testing.T) {
|
|
for in, want := range map[string]string{"smart": "", "": "", "order": "order", "In-Order": "order", "rotate": "rotate", "usage": "usage", "least-used": "usage"} {
|
|
if got, err := parseRouting(in); err != nil || got != want {
|
|
t.Errorf("routing %q: %q %v, want %q", in, got, err, want)
|
|
}
|
|
}
|
|
if _, err := parseRouting("fastest"); err == nil || !strings.Contains(err.Error(), "smart, order, rotate, usage") {
|
|
t.Errorf("unknown routing: %v", err)
|
|
}
|
|
for in, want := range map[string]string{"auto": "", "session": "session", "turn": "turn", "OFF": "off"} {
|
|
if got, err := parseStays(in); err != nil || got != want {
|
|
t.Errorf("stays %q: %q %v, want %q", in, got, err, want)
|
|
}
|
|
}
|
|
if _, err := parseStays("forever"); err == nil || !strings.Contains(err.Error(), "auto, session, turn, off") {
|
|
t.Errorf("unknown stays: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApplyGroupPairs(t *testing.T) {
|
|
ids := map[string]string{"a/m": "a/m", "m": "a/m", "b/x": "b/x", "b/y": "b/y"}
|
|
resolve := func(s string) (string, error) {
|
|
if id, ok := ids[s]; ok {
|
|
return id, nil
|
|
}
|
|
return "", os.ErrNotExist
|
|
}
|
|
g := provider.Group{}
|
|
if err := applyGroupPairs(&g, []string{"models=m,b/x a/m", "routing=order", "stays=session", "name=G one"}, resolve, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(g.Members, " ") != "a/m b/x" || g.Routing != "order" || g.Affinity != "session" || g.Name != "G one" {
|
|
t.Fatalf("%+v", g)
|
|
}
|
|
if err := applyGroupPairs(&g, []string{"models+=b/y,b/x", "models-=a/m"}, resolve, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(g.Members, " ") != "b/x b/y" {
|
|
t.Fatalf("members: %v", g.Members)
|
|
}
|
|
if err := applyGroupPairs(&g, []string{"models-=x"}, resolve, false); err != nil || strings.Join(g.Members, " ") != "b/y" {
|
|
t.Fatalf("drop by bare id: %v %v", err, g.Members)
|
|
}
|
|
for _, c := range []struct {
|
|
v string
|
|
want int
|
|
}{{"272k", 272000}, {"smallest", provider.ContextSmallest}, {"largest", 0}, {"1m", 1000000}, {"", 0}} {
|
|
if err := applyGroupPairs(&g, []string{"context=" + c.v}, resolve, false); err != nil || g.Context != c.want {
|
|
t.Errorf("context=%s: %d %v", c.v, g.Context, err)
|
|
}
|
|
}
|
|
for _, bad := range [][]string{{"models"}, {"colour=red"}, {"id=x"}, {"models=nope"}, {"routing=fast"}, {"models-=a/m"}, {"context=lots"}} {
|
|
h := g
|
|
if err := applyGroupPairs(&h, bad, resolve, false); err == nil {
|
|
t.Errorf("%v: no error", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Adding, changing and removing groups writes what the Routing view does.
|
|
func TestGroupAddSetRemove(t *testing.T) {
|
|
groupsHome(t)
|
|
|
|
g, err := addGroup("Opus anywhere", []string{"models=a/claude-opus-5-5,gpt-5.5", "routing=order", "stays=turn"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g.ID != "opus-anywhere" || strings.Join(g.Members, " ") != "a/claude-opus-5-5 b/gpt-5.5" {
|
|
t.Fatalf("%+v", g)
|
|
}
|
|
gs := storedGroups(t)
|
|
if len(gs) != 1 || gs[0]["id"] != "opus-anywhere" || gs[0]["name"] != "Opus anywhere" || gs[0]["routing"] != "order" || gs[0]["affinity"] != "turn" {
|
|
t.Fatalf("stored: %v", gs)
|
|
}
|
|
if _, _, ok := provider.Resolve("group/opus-anywhere"); !ok {
|
|
t.Fatal("group not in the catalog")
|
|
}
|
|
|
|
// the same name again replaces that group, keeping its id
|
|
if g2, err := addGroup("opus Anywhere", []string{"models=a/only-a"}); err != nil || g2.ID != "opus-anywhere" || strings.Join(g2.Members, " ") != "a/only-a" {
|
|
t.Fatalf("second: %+v %v", g2, err)
|
|
}
|
|
if gs := storedGroups(t); len(gs) != 1 {
|
|
t.Fatalf("replaced into two: %v", gs)
|
|
}
|
|
if _, err := addGroup("Opus anywhere", []string{"models=a/claude-opus-5-5,gpt-5.5", "routing=order", "stays=turn"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := addGroup("x", []string{"models=a/m", "id=opus-anywhere"}); err == nil {
|
|
t.Fatal("an id taken was taken again")
|
|
}
|
|
if _, err := addGroup("empty", nil); err == nil {
|
|
t.Fatal("a group without models added")
|
|
}
|
|
_, err = addGroup("typo", []string{"models=a/claude-opus-5.5"})
|
|
if err == nil || !strings.Contains(err.Error(), "a/claude-opus-5-5") {
|
|
t.Fatalf("typo: %v", err)
|
|
}
|
|
if _, err := addGroup("amb", []string{"models=m"}); err != nil {
|
|
// a/m and b/vendor/m are different ids; "m" is only a's model
|
|
t.Fatalf("bare: %v", err)
|
|
}
|
|
// a group in a group, but never one it is in itself
|
|
if _, err := addGroup("nested", []string{"models=group/opus-anywhere,a/only-a"}); err != nil {
|
|
t.Fatalf("a group in a group: %v", err)
|
|
}
|
|
if _, err := setGroup("opus-anywhere", []string{"models+=group/nested"}); err == nil || !strings.Contains(err.Error(), "in itself") {
|
|
t.Fatalf("a loop: %v", err)
|
|
}
|
|
if _, err := removeGroup("opus-anywhere"); err == nil || !strings.Contains(err.Error(), "take it out first") {
|
|
t.Fatalf("rm while in a group: %v", err)
|
|
}
|
|
if _, err := removeGroup("nested"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := addGroup("nowhere", []string{"models=group/nothing"}); err == nil || !strings.Contains(err.Error(), "no group") {
|
|
t.Fatalf("no such group: %v", err)
|
|
}
|
|
|
|
// set: by name or id, a field at a time
|
|
g, err = setGroup("group/opus-anywhere", []string{"routing=smart", "models+=a/only-a", "models-=b/gpt-5.5", "name=Opus"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g.Routing != "" || g.Affinity != "turn" || strings.Join(g.Members, " ") != "a/claude-opus-5-5 a/only-a" || g.Name != "Opus" {
|
|
t.Fatalf("set: %+v", g)
|
|
}
|
|
if _, err := setGroup("opus", []string{"stays=off"}); err != nil {
|
|
t.Fatalf("by name: %v", err)
|
|
}
|
|
if _, err := setGroup("opus-anywhere", []string{"models-=a/claude-opus-5-5,a/only-a"}); err == nil {
|
|
t.Fatal("emptied a group")
|
|
}
|
|
if _, err := setGroup("nope", []string{"stays=off"}); err == nil || !strings.Contains(err.Error(), "opus-anywhere") {
|
|
t.Fatalf("unknown group: %v", err)
|
|
}
|
|
|
|
// one magpie found: changing it makes it the user's; removing it hides it
|
|
if _, err := setGroup("auto-m", []string{"routing=rotate"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g, _ := findGroup("auto-m"); g.Auto || g.Routing != "rotate" {
|
|
t.Fatalf("auto after set: %+v", g)
|
|
}
|
|
if _, err := removeGroup("auto-m"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g, _ := findGroup("auto-m"); !g.Hidden {
|
|
t.Fatalf("auto after rm: %+v", g)
|
|
}
|
|
if _, err := setGroup("auto-m", []string{"routing=order"}); err == nil {
|
|
t.Fatal("changed a removed group")
|
|
}
|
|
if g, err := restoreGroup("auto-m"); err != nil || g.Hidden {
|
|
t.Fatalf("restore: %+v %v", g, err)
|
|
}
|
|
|
|
if _, err := addGroup("Second", []string{"models=a/only-a"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := removeGroup("second"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := findGroup("second"); err == nil {
|
|
t.Fatal("removed group still there")
|
|
}
|
|
if _, err := removeGroup("second"); err == nil {
|
|
t.Fatal("removed twice")
|
|
}
|
|
}
|
|
|
|
func TestCloseMatches(t *testing.T) {
|
|
ids := []string{"claude/claude-opus-5-5", "copilot/claude-opus-5.5", "deepseek/deepseek-chat", "b/gpt-5.5"}
|
|
got := closeMatches("claude-opus", ids, 5)
|
|
if len(got) != 2 || got[0] != "claude/claude-opus-5-5" {
|
|
t.Errorf("%v", got)
|
|
}
|
|
if got := closeMatches("x/deepseek-chta", ids, 5); len(got) != 1 || got[0] != "deepseek/deepseek-chat" {
|
|
t.Errorf("%v", got)
|
|
}
|
|
if got := closeMatches("zzz", ids, 5); len(got) != 0 {
|
|
t.Errorf("%v", got)
|
|
}
|
|
}
|
|
|
|
// magpie group set <id> id=<new>: a found group loses its auto- prefix and
|
|
// stays removed under the old id.
|
|
func TestGroupSetID(t *testing.T) {
|
|
groupsHome(t)
|
|
if _, err := setGroup("auto-m", []string{"id=Not Slug!"}); err == nil {
|
|
t.Fatal("a bad id taken")
|
|
}
|
|
g, err := setGroup("group/auto-m", []string{"id=m", "routing=order"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g.ID != "m" || g.Auto || g.Routing != "order" || len(g.Members) != 2 {
|
|
t.Fatalf("%+v", g)
|
|
}
|
|
if _, _, ok := provider.Resolve("group/m"); !ok {
|
|
t.Fatal("group/m not in the catalog")
|
|
}
|
|
if _, _, ok := provider.FindGroup("group/auto-m"); ok {
|
|
t.Fatal("auto-m is back beside m")
|
|
}
|
|
if _, err := addGroup("Other", []string{"models=a/only-a"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := setGroup("m", []string{"id=other"}); err == nil {
|
|
t.Fatal("renamed onto another group")
|
|
}
|
|
}
|