Files
magpie/groups_cli_test.go
T
wizzy-yangandwizzy-yang 6f391bc8f2 provider, agent, cli: a routing group's id carries Claude Code's [1m] mark (#1000)
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>
2026-10-06 16:02:03 +08:00

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")
}
}