Files
yetone 0361189f9e test: every package's tests pass on Windows, and none writes outside its sandbox (the user: 把 Windows 上那几个失败的测试修掉) — on a Windows box the suite failed in most packages, and the root package hung for 25 minutes. Tests set HOME, which os.UserHomeDir ignores on Windows (it reads USERPROFILE), so they read and wrote the runner's own home; each now sets USERPROFILE beside HOME, and Devin's, Goose's, Crush's and Claude Desktop's tests set APPDATA/LOCALAPPDATA, where those keep their files there. said() read the command's output only once it returned, and a pipe holds a few KB on Windows, so magpie model --help blocked forever; it reads while the command writes. Unix permission bits (the gateway key store's 0600 too), exec bits and symlinks Windows lacks are checked only off Windows; TOML strings hold Windows paths as literal strings, JSON as marshalled ones; a URL's path is compared as one. The provider and usage packages run in a home of their own (TestMain, as the gateway's already does), since a CLI's identity kept by a refresh still going when its test ended landed in the runner's ~/.config/magpie — and on a Mac in the user's own. Two product fixes came out of it: imagemcp took a file:///C:/… root or image for the path /C:/…, which doesn't exist on Windows, and now makes a Windows path of it (a host for a UNC share); and session discovery could keep a new Cowork session hidden on Linux, where a folder's mtime is a kernel tick and its size doesn't change, so an entry added within the tick of the last listing left both as they were — a listing made within 2s of its folder's last change is read again, as git does for racy files. TestFilePath and TestRacyDirectoryListedAgain fail without the change. On Windows 11 every package passes (32 of 32) and a run of each package in a fresh home leaves nothing in home, APPDATA or LOCALAPPDATA; on Linux (WSL) and macOS go test -tags nogui ./... passes. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build pass.
2026-10-01 23:15:07 +08:00

709 lines
27 KiB
Go

package agent
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/usage"
"gopkg.in/yaml.v3"
)
func TestOmp(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
for _, k := range []string{"PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"} {
t.Setenv(k, "")
}
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, ".omp", "agent")
configPath, modelsPath := filepath.Join(dir, "config.yml"), filepath.Join(dir, "models.yml")
read := func(p string) (map[string]any, string) {
var m map[string]any
b, _ := os.ReadFile(p)
yaml.Unmarshal(b, &m)
return m, string(b)
}
roles := func(c map[string]any) map[string]any { r, _ := c["modelRoles"].(map[string]any); return r }
providers := func(m map[string]any) map[string]any { p, _ := m["providers"].(map[string]any); return p }
os.MkdirAll(dir, 0o755)
os.WriteFile(configPath, []byte("# mine\ntheme: dark\nmodelRoles:\n default: anthropic/claude-opus-5 # main\n smol: openai/gpt-6-mini\n"), 0o644)
// an older omp's models.json, not yet moved to models.yml
os.WriteFile(filepath.Join(dir, "models.json"), []byte(`{"providers":{"mine":{"baseUrl":"https://x","apiKey":"X","api":"openai-completions","models":[{"id":"a"}]}}}`), 0o644)
f := omp(home).Field("model")
if f.Get() != "anthropic/claude-opus-5" {
t.Fatalf("get: %q", f.Get())
}
if err := f.Set("magpie/deepseek/pro"); err != nil {
t.Fatal(err)
}
c, raw := read(configPath)
if roles(c)["default"] != "magpie/deepseek/pro" || roles(c)["smol"] != "openai/gpt-6-mini" || c["theme"] != "dark" ||
!strings.Contains(raw, "# mine") || !strings.Contains(raw, "# main") {
t.Fatalf("config:\n%s", raw)
}
m, raw := read(modelsPath)
ps := providers(m)
mp, ok := ps["magpie"].(map[string]any)
if !ok || ps["mine"] == nil || mp["auth"] != "none" || mp["api"] != "openai-completions" || !strings.HasSuffix(mp["baseUrl"].(string), "/v1") {
t.Fatalf("models:\n%s", raw)
}
ids := map[string]bool{}
for _, x := range mp["models"].([]any) {
ids[x.(map[string]any)["id"].(string)] = true
}
if !ids["deepseek/pro"] || !ids["deepseek/flash"] {
t.Fatalf("models: %v", ids)
}
// its own model: magpie steps out
if err := f.Set("moonshotai/kimi-k3"); err != nil {
t.Fatal(err)
}
c, _ = read(configPath)
m, _ = read(modelsPath)
if roles(c)["default"] != "moonshotai/kimi-k3" || providers(m)["magpie"] != nil || providers(m)["mine"] == nil {
t.Fatalf("own: %v %v", c, m)
}
// another role through magpie keeps it in models.yml; the reset puts
// back the model default had before magpie took it over
f.Set("magpie/deepseek/flash")
os.WriteFile(configPath, []byte("modelRoles:\n default: magpie/deepseek/flash\n smol: magpie/deepseek/pro\n"), 0o644)
if err := f.Set(""); err != nil {
t.Fatal(err)
}
c, _ = read(configPath)
m, _ = read(modelsPath)
if roles(c)["default"] != "moonshotai/kimi-k3" || roles(c)["smol"] != "magpie/deepseek/pro" || providers(m)["magpie"] == nil {
t.Fatalf("reset with smol: %v %v", c, m)
}
// reset with nothing stashed: only default goes, and magpie with it
os.WriteFile(configPath, []byte("theme: dark\nmodelRoles:\n default: magpie/deepseek/flash\n smol: openai/gpt-6-mini\n"), 0o644)
if err := f.Set(""); err != nil {
t.Fatal(err)
}
c, _ = read(configPath)
m, _ = read(modelsPath)
if roles(c)["default"] != nil || roles(c)["smol"] != "openai/gpt-6-mini" || c["theme"] != "dark" || providers(m)["magpie"] != nil || providers(m)["mine"] == nil {
t.Fatalf("reset: %v %v", c, m)
}
}
// omp names models beyond modelRoles.default: the other roles, the retry
// fallback chains, enabledModels and the task agents' overrides. magpie
// stays in models.yml while any of them is on it, and a provider renamed
// takes them along, their thinking levels and the comments around kept.
func TestOmpRefsBeyondDefault(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
for _, k := range []string{"PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"} {
t.Setenv(k, "")
}
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, ".omp", "agent")
configPath, modelsPath := filepath.Join(dir, "config.yml"), filepath.Join(dir, "models.yml")
os.MkdirAll(dir, 0o755)
hasMagpie := func() bool {
var m struct{ Providers map[string]any }
b, _ := os.ReadFile(modelsPath)
yaml.Unmarshal(b, &m)
return m.Providers["magpie"] != nil
}
f := omp(home).Field("model")
// the last role leaves magpie; a fallback chain still goes through it
os.WriteFile(configPath, []byte("modelRoles:\n default: magpie/deepseek/flash\nretry:\n fallbackChains:\n default:\n - magpie/deepseek/pro:max\n"), 0o644)
if err := f.Set("magpie/deepseek/flash"); err != nil {
t.Fatal(err)
}
if err := f.Set(""); err != nil {
t.Fatal(err)
}
if !hasMagpie() {
t.Fatal("a fallback chain on magpie/deepseek/pro:max lost magpie's provider")
}
// none left: it goes
os.WriteFile(configPath, []byte("modelRoles:\n default: magpie/deepseek/flash\nretry:\n fallbackChains:\n default:\n - openai/gpt-6\n"), 0o644)
if err := f.Set(""); err != nil {
t.Fatal(err)
}
if hasMagpie() {
t.Fatal("magpie's provider stayed with nothing on it")
}
const before = `# mine
modelRoles:
default: magpie/deepseek/pro
slow: magpie/deepseek/pro:max, openai/gpt-6 # slow ones
plan:
- magpie/deepseek/flash:high
- openai/gpt-6
retry:
fallbackChains:
# tried in order
default:
- magpie/deepseek/flash:max
- magpie/deepseekx/flash
magpie/deepseek/pro:
- magpie/deepseek/*
enabledModels:
- magpie/deepseek/pro
- path: /work
models:
- magpie/deepseek/flash
task:
agentModelOverrides:
explore: magpie/deepseek/flash,openai/gpt-6
review: [magpie/deepseek/pro:low]
`
os.WriteFile(configPath, []byte(before), 0o644)
if err := f.Set("magpie/deepseek/pro"); err != nil {
t.Fatal(err)
}
moved, err := RenameProvider("deepseek", "ds")
if err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(configPath)
got := string(b)
want := strings.NewReplacer("magpie/deepseek/", "magpie/ds/").Replace(before)
var gotDoc, wantDoc any
yaml.Unmarshal(b, &gotDoc)
yaml.Unmarshal([]byte(want), &wantDoc)
gotY, _ := yaml.Marshal(gotDoc)
wantY, _ := yaml.Marshal(wantDoc)
if string(gotY) != string(wantY) || strings.Contains(got, "magpie/deepseek/") || !strings.Contains(got, "magpie/deepseekx/flash") ||
!strings.Contains(got, "# mine") || !strings.Contains(got, "# slow ones") || !strings.Contains(got, "# tried in order") {
t.Fatalf("renamed:\n%s", got)
}
if !slices.Contains(moved, "omp") {
t.Fatalf("moved: %v", moved)
}
}
// omp 16.x asks with Bun's User-Agent (Bun/1.3.14), which the gateway can't
// tell for omp's; magpie's provider names it, and the gateway knows that
// name for omp.
func TestOmpNamedToTheGateway(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
for _, k := range []string{"PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"} {
t.Setenv(k, "")
}
if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k", Models: []string{"pro"}}); err != nil {
t.Fatal(err)
}
if err := omp(home).Field("model").Set("magpie/deepseek/pro"); err != nil {
t.Fatal(err)
}
var m struct {
Providers map[string]struct {
Headers map[string]string `yaml:"headers"`
} `yaml:"providers"`
}
b, _ := os.ReadFile(filepath.Join(home, ".omp", "agent", "models.yml"))
if err := yaml.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
ua := m.Providers[magpieID].Headers["User-Agent"]
if ua == "" || usage.AgentOf(ua) != "omp" {
t.Fatalf("magpie's provider names omp as %q, which the gateway takes for %q:\n%s", ua, usage.AgentOf(ua), b)
}
if usage.AgentOf("Bun/1.3.14") == "omp" {
t.Fatal("Bun's own User-Agent is taken for omp's, so the header proves nothing")
}
}
// omp's own agents pick their models apart from the main one: through
// magpie on its own, a role brings magpie's provider in, has its wiring
// checked, and takes the provider out again when it is the last.
func TestOmpRoles(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
for _, k := range []string{"PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"} {
t.Setenv(k, "")
}
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, ".omp", "agent")
configPath, modelsPath := filepath.Join(dir, "config.yml"), filepath.Join(dir, "models.yml")
os.MkdirAll(dir, 0o755)
os.WriteFile(configPath, []byte("modelRoles:\n default: anthropic/claude-opus-5\n"), 0o644)
roles := func() map[string]any {
var c map[string]any
b, _ := os.ReadFile(configPath)
yaml.Unmarshal(b, &c)
r, _ := c["modelRoles"].(map[string]any)
return r
}
magpieIn := func() bool { v, ok := edit.GetYAML(modelsPath, "providers.magpie.baseUrl"); return ok && v != "" }
a := omp(home)
sub, small := a.Field("subagent"), a.Field("small")
if small.Label != "smol" || a.Field("designer") != nil {
t.Fatalf("small labelled %q; designer: %+v", small.Label, a.Field("designer"))
}
for _, k := range []string{"subagent", "small", "slow"} {
if f := a.Field(k); f == nil || !f.Quiet {
t.Fatalf("field %s: %+v", k, f)
}
}
if err := sub.Set("magpie/deepseek/flash"); err != nil {
t.Fatal(err)
}
if r := roles(); r["task"] != "magpie/deepseek/flash" || r["default"] != "anthropic/claude-opus-5" || !magpieIn() {
t.Fatalf("subagent on magpie: %v, magpie in models.yml: %v", r, magpieIn())
}
if sub.Get() != "magpie/deepseek/flash" {
t.Fatalf("get: %q", sub.Get())
}
// the main model isn't magpie's, the subagents' is: its wiring is checked
if a.Check() != "" {
t.Fatalf("check, wired: %q", a.Check())
}
edit.SetYAML(modelsPath, edit.KV{Path: "providers.magpie.baseUrl", Value: "http://127.0.0.1:1/v1"})
if a.Check() == "" {
t.Fatal("check missed the subagents' broken wiring")
}
a.Sync()
if err := small.Set("openai/gpt-6-mini"); err != nil {
t.Fatal(err)
}
if r := roles(); r["smol"] != "openai/gpt-6-mini" || !magpieIn() {
t.Fatalf("small, own: %v", r)
}
// the last role on magpie goes: so does magpie's provider
if err := sub.Set(""); err != nil {
t.Fatal(err)
}
if r := roles(); r["task"] != nil || r["smol"] != "openai/gpt-6-mini" || r["default"] != "anthropic/claude-opus-5" || magpieIn() {
t.Fatalf("subagent reset: %v, magpie in models.yml: %v", r, magpieIn())
}
}
// A role's thinking level ("…:max") stays with it when another model is
// picked, and the model the user had before magpie comes back, level and
// all, when the role is reset.
func TestOmpLevels(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("PATH", t.TempDir())
for _, k := range []string{"PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"} {
t.Setenv(k, "")
}
for _, p := range []provider.Provider{
{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k", Models: []string{"pro", "flash"}},
{ID: "other", Name: "Other", Chat: "https://other.example/v1", Key: "k", Models: []string{"pro"}},
} {
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
}
dir := filepath.Join(home, ".omp", "agent")
configPath := filepath.Join(dir, "config.yml")
os.MkdirAll(dir, 0o755)
os.WriteFile(configPath, []byte("modelRoles:\n default: anthropic/claude-opus-5:max\n slow: openai/gpt-6:auto\n"), 0o644)
a := omp(home)
get := func(k string) string { return a.Field(k).Get() }
apply := func(k, v, want string) {
t.Helper()
if err := a.Apply(k, v); err != nil {
t.Fatal(err)
}
if got := get(k); got != want {
t.Fatalf("%s set to %q: %q, want %q", k, v, got, want)
}
}
// deepseek/pro has no max of its own: omp clamps it, so it stays
apply("model", "magpie/deepseek/pro", "magpie/deepseek/pro:max")
if d := a.Drift(); d != nil {
t.Fatalf("drift on what magpie set: %+v", d)
}
apply("model", "magpie/deepseek/flash", "magpie/deepseek/flash:max")
apply("model", "magpie/deepseek/pro:low", "magpie/deepseek/pro:low") // its own level wins
// offered at its level beside the model: that keeps the level, the
// model alone clears it
var at, plain bool
for _, o := range a.Field("model").Options(a.Values()) {
at = at || o.Value == "magpie/deepseek/pro:low" && strings.HasSuffix(o.Label, " · low")
plain = plain || o.Value == "magpie/deepseek/pro"
}
if !at || !plain {
t.Fatalf("options: at its level %v, the model %v", at, plain)
}
apply("model", "magpie/deepseek/pro:low", "magpie/deepseek/pro:low")
apply("model", "magpie/deepseek/pro", "magpie/deepseek/pro")
apply("model", "magpie/deepseek/flash", "magpie/deepseek/flash")
apply("model", "magpie/deepseek/pro:low", "magpie/deepseek/pro:low")
apply("slow", "magpie/deepseek/pro", "magpie/deepseek/pro:auto")
// the first model stashed, not the magpie ones after it
apply("model", "", "anthropic/claude-opus-5:max")
apply("slow", "", "openai/gpt-6:auto")
// a colon that is no level is the model's
apply("model", "ollama/qwen3:8b", "ollama/qwen3:8b:max")
// a model of the user's own in between is the one put back
apply("model", "magpie/deepseek/pro", "magpie/deepseek/pro:max")
apply("model", "openai/gpt-6", "openai/gpt-6:max")
apply("model", "magpie/deepseek/pro", "magpie/deepseek/pro:max")
apply("model", "", "openai/gpt-6:max")
// a provider removed moves the role to the same model elsewhere, level
// and all
apply("model", "magpie/deepseek/pro", "magpie/deepseek/pro:max")
if _, err := Reseat(func() error { return provider.Delete("deepseek") }); err != nil {
t.Fatal(err)
}
if got := get("model"); got != "magpie/other/pro:max" {
t.Fatalf("reseated: %q", got)
}
}
// A role on one of magpie's models with a thinking level is that model
// wherever magpie matches a value against the picker: offered once, typed
// at a level on the command line, missed when omp puts it back to its own,
// and moved, level and all, when its provider is renamed.
func TestOmpLevelSuffix(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("PATH", t.TempDir())
for _, k := range []string{"PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"} {
t.Setenv(k, "")
}
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, ".omp", "agent")
configPath := filepath.Join(dir, "config.yml")
os.MkdirAll(dir, 0o755)
os.WriteFile(configPath, []byte("modelRoles:\n default: anthropic/claude-opus-5:max\n"), 0o644)
a := omp(home)
f := a.Field("model")
v, err := a.Spell("model", "magpie/deepseek/pro:high")
if err != nil || v != "magpie/deepseek/pro:high" {
t.Fatalf("spell at a level: %q %v", v, err)
}
if err := a.Apply("model", v); err != nil {
t.Fatal(err)
}
// the subagents on the same model at another level: moved with it too
if err := a.Apply("subagent", "magpie/deepseek/pro:max"); err != nil {
t.Fatal(err)
}
if d := a.Drift(); d != nil {
t.Fatalf("drift on what magpie set: %+v", d)
}
// omp's own /model puts the role back on the user's model
edit.SetYAML(configPath, edit.KV{Path: "modelRoles.default", Value: "anthropic/claude-opus-5:high"})
if d := a.Drift(); d == nil || d.Kind != "replaced" || d.Want != "magpie/deepseek/pro:high" {
t.Fatalf("drift, replaced: %+v", d)
}
if err := a.Reapply(); err != nil || f.Get() != "magpie/deepseek/pro:high" {
t.Fatalf("reapplied: %q %v", f.Get(), err)
}
if _, err := RenameProvider("deepseek", "ds"); err != nil {
t.Fatal(err)
}
if f.Get() != "magpie/ds/pro:high" || a.Field("subagent").Get() != "magpie/ds/pro:max" {
t.Fatalf("renamed: %q, subagent %q", f.Get(), a.Field("subagent").Get())
}
}
// A role omp falls back through, a list of models (a YAML list or the
// comma-separated string omp also takes), is the user's to have back when
// magpie lets go of the role; a level on its last model is no role's level.
func TestOmpRoleList(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
for _, k := range []string{"PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"} {
t.Setenv(k, "")
}
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, ".omp", "agent")
configPath := filepath.Join(dir, "config.yml")
os.MkdirAll(dir, 0o755)
os.WriteFile(configPath, []byte("modelRoles:\n slow:\n - openai/gpt-6\n - anthropic/claude-opus-5:high\n task: openai/gpt-6,anthropic/claude-opus-5:high\n"), 0o644)
a := omp(home)
for _, k := range []string{"slow", "subagent"} {
if got := a.Field(k).Get(); got != "openai/gpt-6,anthropic/claude-opus-5:high" {
t.Fatalf("%s reads %q", k, got)
}
if err := a.Apply(k, "magpie/deepseek/pro"); err != nil {
t.Fatal(err)
}
if got := a.Field(k).Get(); got != "magpie/deepseek/pro" {
t.Fatalf("%s on magpie: %q", k, got)
}
if err := a.Apply(k, ""); err != nil {
t.Fatal(err)
}
if got := a.Field(k).Get(); got != "openai/gpt-6,anthropic/claude-opus-5:high" {
t.Fatalf("%s reset: %q", k, got)
}
}
}
// A role on magpie at a thinking level that a fallback chain names too: a
// provider renamed moves each once, the level kept. The role back on omp's
// own, the chain alone keeps magpie's provider, has its wiring checked, and
// applying again mends it.
func TestOmpRoleAndChain(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
for _, k := range []string{"PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"} {
t.Setenv(k, "")
}
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, ".omp", "agent")
configPath, modelsPath := filepath.Join(dir, "config.yml"), filepath.Join(dir, "models.yml")
os.MkdirAll(dir, 0o755)
os.WriteFile(configPath, []byte("modelRoles:\n default: anthropic/claude-opus-5\nretry:\n fallbackChains:\n slow:\n - magpie/deepseek/pro:max\n - openai/gpt-6\n"), 0o644)
a := omp(home)
if err := a.Apply("slow", "magpie/deepseek/pro:max"); err != nil {
t.Fatal(err)
}
if _, err := RenameProvider("deepseek", "ds"); err != nil {
t.Fatal(err)
}
chain := edit.GetYAMLList(configPath, "retry.fallbackChains.slow")
if s := a.Field("slow").Get(); s != "magpie/ds/pro:max" || !slices.Equal(chain, []string{"magpie/ds/pro:max", "openai/gpt-6"}) {
t.Fatalf("renamed: slow %q, chain %v", s, chain)
}
if err := a.Apply("slow", ""); err != nil {
t.Fatal(err)
}
if v, _ := edit.GetYAML(modelsPath, "providers.magpie.baseUrl"); v == "" || a.Field("slow").Get() != "" {
t.Fatalf("the chain lost magpie's provider, or slow stayed: %q", a.Field("slow").Get())
}
edit.SetYAML(modelsPath, edit.KV{Path: "providers.magpie.baseUrl", Value: "http://127.0.0.1:1/v1"})
if d := a.Drift(); d == nil || d.Kind != "unwired" {
t.Fatalf("a chain's broken wiring: %+v", d)
}
if err := a.Reapply(); err != nil {
t.Fatal(err)
}
if c := a.Check(); c != "" {
t.Fatalf("applying again left: %s", c)
}
}
// A role that is a list of models omp falls back through is the user's own,
// even when it starts on one of magpie's: magpie taking the role over
// stashes it, and a reset puts it back written as it was — a YAML list or a
// comma-separated string.
func TestOmpListRole(t *testing.T) {
for _, slow := range []string{
" [magpie/deepseek/flash, openai/gpt-6]",
" magpie/deepseek/flash, openai/gpt-6",
"\n - magpie/deepseek/flash\n - openai/gpt-6",
} {
t.Run(slow, func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
for _, k := range []string{"PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"} {
t.Setenv(k, "")
}
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, ".omp", "agent")
configPath := filepath.Join(dir, "config.yml")
os.MkdirAll(dir, 0o755)
before := "modelRoles:\n default: anthropic/claude-opus-5\n slow:" + slow + "\n"
os.WriteFile(configPath, []byte(before), 0o644)
a := omp(home)
if err := a.Apply("slow", "magpie/deepseek/pro"); err != nil {
t.Fatal(err)
}
if err := a.Apply("slow", ""); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(configPath)
if string(b) != before {
t.Fatalf("reset:\n%s\nwant:\n%s", b, before)
}
})
}
}
// A provider renamed moves the magpie models in a list role once, the list
// written as it was, and in the list the stash keeps for a role magpie took
// over, which a reset then puts back.
func TestOmpListRoleRenamed(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
for _, k := range []string{"PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"} {
t.Setenv(k, "")
}
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, ".omp", "agent")
configPath := filepath.Join(dir, "config.yml")
os.MkdirAll(dir, 0o755)
os.WriteFile(configPath, []byte("modelRoles:\n default: anthropic/claude-opus-5\n slow: [magpie/deepseek/flash, openai/gpt-6]\n task: [magpie/deepseek/pro:high, openai/gpt-6]\n"), 0o644)
a := omp(home)
if err := a.Apply("slow", "magpie/deepseek/pro:max"); err != nil {
t.Fatal(err)
}
if _, err := RenameProvider("deepseek", "ds"); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(configPath)
task := edit.GetYAMLList(configPath, "modelRoles.task")
if s := a.Field("slow").Get(); s != "magpie/ds/pro:max" || !slices.Equal(task, []string{"magpie/ds/pro:high", "openai/gpt-6"}) || !strings.Contains(string(b), "task: [") {
t.Fatalf("renamed: slow %q\n%s", s, b)
}
if err := a.Apply("slow", ""); err != nil {
t.Fatal(err)
}
b, _ = os.ReadFile(configPath)
if !strings.Contains(string(b), "slow: [magpie/ds/flash, openai/gpt-6]") {
t.Fatalf("reset:\n%s", b)
}
}
// The model picker offers the models of the providers the user added to
// models.yml, beside models.dev's for the current provider, spelled as
// ownOptions spells them (the name as the note): not magpie's own entry, nor
// a provider whose models omp only discovers at run time. A model named in
// both keeps what models.dev knows of it, and a provider in both stays one
// group. Every role offers them, the subagents' as the model's.
func TestOmpOwnModels(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
for _, k := range []string{"PI_CODING_AGENT_DIR", "PI_CONFIG_DIR", "OMP_PROFILE", "PI_PROFILE"} {
t.Setenv(k, "")
}
os.MkdirAll(filepath.Dir(catalog.CachePath()), 0o755)
os.WriteFile(catalog.CachePath(), []byte(`{"zai":{"name":"Z.AI","models":{"glm-5":{"id":"glm-5","name":"GLM-5"},"glm-6":{"id":"glm-6","name":"GLM-6"}}}}`), 0o644)
catalog.Reset()
t.Cleanup(catalog.Reset)
dir := filepath.Join(home, ".omp", "agent")
os.MkdirAll(dir, 0o755)
os.WriteFile(filepath.Join(dir, "config.yml"), []byte("modelRoles:\n default: zai/glm-5\n"), 0o644)
os.WriteFile(filepath.Join(dir, "models.yml"), []byte(`providers:
codemaker:
baseUrl: https://codemaker.example/v1
api: openai-completions
models:
- id: claude-opus-5-5
name: Claude Opus 5.5
- id: glm-5
zai:
models:
- id: glm-5
lan:
baseUrl: http://127.0.0.1:8000/v1
api: openai-completions
discovery:
type: openai-models-list
zzrelay:
models:
- id: m1
magpie:
baseUrl: http://127.0.0.1:1/v1
auth: none
models:
- id: deepseek/pro
`), 0o644)
opts := omp(home).Field("model").Options(map[string]string{"model": "zai/glm-5"})
byValue := map[string]Option{}
for _, o := range opts {
if _, dup := byValue[o.Value]; dup {
t.Fatalf("%s offered twice: %+v", o.Value, opts)
}
byValue[o.Value] = o
}
if o := byValue["codemaker/claude-opus-5-5"]; o.Note != "Claude Opus 5.5" || o.Label != "" || o.Group != "codemaker" {
t.Fatalf("codemaker model: %+v in %+v", o, opts)
}
if o, ok := byValue["codemaker/glm-5"]; !ok || o.Note != "" {
t.Fatalf("model without a name: %+v in %+v", o, opts)
}
// models.yml's zai glm-5 has no name; models.dev's has one
if o := byValue["zai/glm-5"]; o.Note != "GLM-5" || o.Icon == "" {
t.Fatalf("zai/glm-5 lost models.dev's name or icon: %+v in %+v", o, opts)
}
// magpie has no provider here, so its entry's model comes from nowhere else
for v := range byValue {
if strings.HasPrefix(v, "lan/") || v == "magpie/deepseek/pro" {
t.Fatalf("%s offered: %+v", v, opts)
}
}
// zai is in models.yml and on models.dev, zzrelay after it: one group each
seen := map[string]bool{}
for i, o := range opts {
if i > 0 && o.Group != opts[i-1].Group {
if seen[o.Group] {
t.Fatalf("%s's models split in two: %+v", o.Group, opts)
}
}
seen[o.Group] = true
}
if _, ok := byValue["zai/glm-6"]; !ok {
t.Fatalf("models.dev's zai/glm-6 not offered: %+v", opts)
}
sub := omp(home).Field("subagent").Options(map[string]string{"model": "zai/glm-5"})
if !slices.ContainsFunc(sub, func(o Option) bool { return o.Value == "codemaker/claude-opus-5-5" }) {
t.Fatalf("the subagents' picker lacks models.yml's models: %+v", sub)
}
}