331 lines
11 KiB
Go
331 lines
11 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/yetone/magpie/internal/gateway"
|
|
"github.com/yetone/magpie/internal/provider"
|
|
)
|
|
|
|
// t3User is ~/.t3/userdata/settings.json as T3 Code writes it sparse: its
|
|
// Claude given a binary path (the legacy key), a Codex account of the
|
|
// user's own as an instance with a custom model, and a key of T3's.
|
|
const t3User = `{
|
|
"providers": {
|
|
"claudeAgent": {"binaryPath": "/opt/claude/bin/claude"}
|
|
},
|
|
"providerInstances": {
|
|
"codex_work": {
|
|
"driver": "codex",
|
|
"displayName": "Codex (work)",
|
|
"config": {"homePath": "~/.codex-work", "customModels": [{"slug": "gpt-6-astra", "name": "gpt-6-astra"}]}
|
|
}
|
|
},
|
|
"textGenerationModelSelection": {"instanceId": "codex", "model": "gpt-6-luna"}
|
|
}
|
|
`
|
|
|
|
// t3Envelope is T3 Code's ProviderInstanceConfig, with the claudeAgent
|
|
// config magpie writes; nothing else taken.
|
|
type t3Envelope struct {
|
|
Driver string `json:"driver"`
|
|
DisplayName string `json:"displayName"`
|
|
Enabled *bool `json:"enabled"`
|
|
Environment []struct {
|
|
Name string `json:"name"`
|
|
Value string `json:"value"`
|
|
Sensitive *bool `json:"sensitive"`
|
|
} `json:"environment"`
|
|
Config struct {
|
|
BinaryPath string `json:"binaryPath"`
|
|
HomePath string `json:"homePath"`
|
|
CustomModels []struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Capabilities *struct {
|
|
OptionDescriptors []struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Type string `json:"type"`
|
|
CurrentValue *bool `json:"currentValue"`
|
|
Options []struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
IsDefault *bool `json:"isDefault"`
|
|
} `json:"options"`
|
|
} `json:"optionDescriptors"`
|
|
} `json:"capabilities"`
|
|
} `json:"customModels"`
|
|
} `json:"config"`
|
|
}
|
|
|
|
// t3Format decodes magpie's instance strictly as T3 Code's schema has it:
|
|
// an instance id of its slug pattern, a claudeAgent envelope, environment
|
|
// variable names of its pattern, custom models with a slug.
|
|
func t3Format(t *testing.T, raw []byte) t3Envelope {
|
|
t.Helper()
|
|
var e t3Envelope
|
|
d := json.NewDecoder(bytes.NewReader(raw))
|
|
d.DisallowUnknownFields()
|
|
if err := d.Decode(&e); err != nil {
|
|
t.Fatalf("not T3 Code's instance: %v\n%s", err, raw)
|
|
}
|
|
if !regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`).MatchString(magpieID) {
|
|
t.Errorf("instance id %q", magpieID)
|
|
}
|
|
if e.Driver != "claudeAgent" || e.DisplayName == "" || e.Enabled == nil || !*e.Enabled {
|
|
t.Errorf("envelope: %s", raw)
|
|
}
|
|
for _, v := range e.Environment {
|
|
if !regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`).MatchString(v.Name) || v.Sensitive == nil || *v.Sensitive {
|
|
t.Errorf("environment: %s", raw)
|
|
}
|
|
}
|
|
for _, m := range e.Config.CustomModels {
|
|
if m.Slug == "" || m.Name == "" {
|
|
t.Errorf("custom model: %s", raw)
|
|
}
|
|
if m.Capabilities == nil {
|
|
continue
|
|
}
|
|
for _, d := range m.Capabilities.OptionDescriptors {
|
|
defaults := 0
|
|
for _, o := range d.Options {
|
|
if o.ID == "" || o.Label == "" {
|
|
t.Errorf("option: %s", raw)
|
|
}
|
|
if o.IsDefault != nil && *o.IsDefault {
|
|
defaults++
|
|
}
|
|
}
|
|
select_ := d.Type == "select" && len(d.Options) > 0 && defaults == 1 && d.CurrentValue == nil
|
|
boolean := d.Type == "boolean" && len(d.Options) == 0 && d.CurrentValue != nil
|
|
if d.ID == "" || d.Label == "" || !select_ && !boolean {
|
|
t.Errorf("descriptor: %s", raw)
|
|
}
|
|
}
|
|
}
|
|
return e
|
|
}
|
|
|
|
// T3 Code (KevinXC on Discord) gets magpie as a provider of its own: a
|
|
// claudeAgent instance on the gateway with every magpie model as a custom
|
|
// model, the user's instances, their Claude's binary path and T3's keys
|
|
// kept; off takes only magpie's out, and the catalog sync leaves it off.
|
|
func TestT3CodeInstance(t *testing.T) {
|
|
home := syncHome(t)
|
|
t.Setenv("T3CODE_HOME", "")
|
|
for _, p := range []provider.Provider{
|
|
{ID: "resp", Name: "Resp", Key: "k", Responses: "http://127.0.0.1:1/v1", Models: []string{"gpt-5.5"}},
|
|
} {
|
|
if err := provider.Save(p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
base := filepath.Join(home, ".t3")
|
|
path := filepath.Join(base, "userdata", "settings.json")
|
|
|
|
a := t3code(home)
|
|
if a.Path != path {
|
|
t.Fatalf("path %s", a.Path)
|
|
}
|
|
if a.Detected() && !t3App(home) {
|
|
t.Fatal("found with no ~/.t3")
|
|
}
|
|
if !slices.ContainsFunc(All(), func(x *Agent) bool { return x.ID == "t3code" && x.Name == "T3 Code" }) {
|
|
t.Fatal("T3 Code is not among the agents")
|
|
}
|
|
os.MkdirAll(filepath.Dir(path), 0o755)
|
|
os.WriteFile(path, []byte(t3User), 0o600)
|
|
if !a.Detected() {
|
|
t.Fatal("not found with ~/.t3")
|
|
}
|
|
f := a.Field("provider")
|
|
if f == nil || f.Get() != "" {
|
|
t.Fatalf("provider field: %+v", f)
|
|
}
|
|
if err := f.Set(magpieID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.Get() != magpieID {
|
|
t.Fatalf("get: %q", f.Get())
|
|
}
|
|
|
|
var file, user map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(readFile(path)), &file); err != nil {
|
|
t.Fatalf("%v\n%s", err, readFile(path))
|
|
}
|
|
json.Unmarshal([]byte(t3User), &user)
|
|
for _, k := range []string{"providers", "textGenerationModelSelection"} {
|
|
if !sameJSON(string(file[k]), user[k]) {
|
|
t.Errorf("T3's %s changed: %s", k, file[k])
|
|
}
|
|
}
|
|
var insts, userInsts map[string]json.RawMessage
|
|
json.Unmarshal(file["providerInstances"], &insts)
|
|
json.Unmarshal(user["providerInstances"], &userInsts)
|
|
if !sameJSON(string(insts["codex_work"]), userInsts["codex_work"]) {
|
|
t.Errorf("the user's instance changed: %s", insts["codex_work"])
|
|
}
|
|
|
|
e := t3Format(t, insts[magpieID])
|
|
env := map[string]string{}
|
|
for _, v := range e.Environment {
|
|
env[v.Name] = v.Value
|
|
}
|
|
if env["ANTHROPIC_BASE_URL"] != gateway.URL() || env["ANTHROPIC_AUTH_TOKEN"] != gateway.Token {
|
|
t.Errorf("environment: %v", env)
|
|
}
|
|
if e.Config.BinaryPath != "/opt/claude/bin/claude" || e.Config.HomePath != "" {
|
|
t.Errorf("T3's Claude's binary path not carried over: %+v", e.Config)
|
|
}
|
|
slugs := []string{}
|
|
for _, m := range e.Config.CustomModels {
|
|
slugs = append(slugs, m.Slug)
|
|
}
|
|
for _, id := range []string{"relay/glm-4.6", "resp/gpt-5.5"} {
|
|
if !slices.Contains(slugs, id) {
|
|
t.Errorf("%s not listed: %v", id, slugs)
|
|
}
|
|
}
|
|
|
|
// the catalog sync keeps it, and the user's
|
|
if err := a.Sync(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
json.Unmarshal([]byte(readFile(path)), &file)
|
|
insts = nil
|
|
json.Unmarshal(file["providerInstances"], &insts)
|
|
t3Format(t, insts[magpieID])
|
|
if _, ok := insts["codex_work"]; !ok {
|
|
t.Fatal("the user's instance is gone after a sync")
|
|
}
|
|
|
|
if err := f.Set(""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.Get() != "" {
|
|
t.Fatalf("get after off: %q", f.Get())
|
|
}
|
|
file, insts = nil, nil
|
|
json.Unmarshal([]byte(readFile(path)), &file)
|
|
json.Unmarshal(file["providerInstances"], &insts)
|
|
if _, ok := insts[magpieID]; ok || !sameJSON(string(insts["codex_work"]), userInsts["codex_work"]) ||
|
|
!sameJSON(string(file["providers"]), user["providers"]) {
|
|
t.Errorf("after off: %s", readFile(path))
|
|
}
|
|
if err := a.Sync(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.Get() != "" {
|
|
t.Fatalf("sync put it back: %s", readFile(path))
|
|
}
|
|
}
|
|
|
|
// T3 Code's Claude already an instance (T3 moves it there once edited in
|
|
// its settings) has its Claude home carried over from there; $T3CODE_HOME
|
|
// moves the file, and a settings.json not there yet is made.
|
|
func TestT3CodeHomeAndInstance(t *testing.T) {
|
|
home := syncHome(t)
|
|
base := filepath.Join(home, "elsewhere")
|
|
t.Setenv("T3CODE_HOME", base)
|
|
a := t3code(home)
|
|
path := filepath.Join(base, "userdata", "settings.json")
|
|
if a.Path != path {
|
|
t.Fatalf("path %s, want %s", a.Path, path)
|
|
}
|
|
f := a.Field("provider")
|
|
if err := f.Set(magpieID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var file map[string]map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(readFile(path)), &file); err != nil {
|
|
t.Fatalf("%v\n%s", err, readFile(path))
|
|
}
|
|
t3Format(t, file["providerInstances"][magpieID])
|
|
|
|
os.WriteFile(path, []byte(`{"providerInstances":{"claudeAgent":{"driver":"claudeAgent","config":{"homePath":"~/.claude-t3"}}}}`), 0o600)
|
|
if err := f.Set(magpieID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
json.Unmarshal([]byte(readFile(path)), &file)
|
|
if e := t3Format(t, file["providerInstances"][magpieID]); e.Config.HomePath != "~/.claude-t3" || e.Config.BinaryPath != "" {
|
|
t.Errorf("config: %+v", e.Config)
|
|
}
|
|
if _, ok := file["providerInstances"]["claudeAgent"]; !ok {
|
|
t.Error("the user's Claude instance is gone")
|
|
}
|
|
}
|
|
|
|
// Each magpie model in T3 Code has a Reasoning pick of the levels it takes
|
|
// that Claude Code can send, medium chosen first, else the lowest (KevinXC
|
|
// on Discord: every model ran at medium with no way to change it), and a
|
|
// model that thinks at some level a Thinking switch, on at first, as T3's
|
|
// own Claude Haiku has; neither for a model with no such level.
|
|
func TestT3CodeEfforts(t *testing.T) {
|
|
type opt struct {
|
|
id string
|
|
def bool
|
|
}
|
|
pick := func(id string, efforts []string) ([]opt, bool) {
|
|
c := t3Capabilities(id, efforts)
|
|
if c == nil {
|
|
return nil, false
|
|
}
|
|
raw, _ := json.Marshal(c)
|
|
var caps struct {
|
|
OptionDescriptors []struct {
|
|
ID, Label, Type string
|
|
CurrentValue *bool
|
|
Options []struct {
|
|
ID, Label string
|
|
IsDefault bool
|
|
}
|
|
}
|
|
}
|
|
json.Unmarshal(raw, &caps)
|
|
var out []opt
|
|
thinking := false
|
|
for _, d := range caps.OptionDescriptors {
|
|
switch {
|
|
case d.ID == "effort" && d.Type == "select" && out == nil:
|
|
for _, o := range d.Options {
|
|
if o.Label == "" {
|
|
t.Errorf("%s: no label: %s", id, raw)
|
|
}
|
|
out = append(out, opt{o.ID, o.IsDefault})
|
|
}
|
|
case d.ID == "thinking" && d.Type == "boolean" && d.Label == "Thinking" && d.CurrentValue != nil && *d.CurrentValue && !thinking:
|
|
thinking = true
|
|
default:
|
|
t.Fatalf("%s: %s", id, raw)
|
|
}
|
|
}
|
|
return out, thinking
|
|
}
|
|
for _, c := range []struct {
|
|
id string
|
|
efforts []string
|
|
want []opt
|
|
thinking bool
|
|
}{
|
|
{"codex/gpt-6", []string{"none", "minimal", "low", "medium", "high", "xhigh"}, []opt{{"low", false}, {"medium", true}, {"high", false}, {"xhigh", false}}, true},
|
|
{"ds/deepseek-v4", []string{"high", "max"}, []opt{{"high", true}, {"max", false}}, true},
|
|
{"claude/claude-opus-4-6", []string{"low", "medium", "high", "xhigh", "max"}, []opt{{"low", false}, {"medium", true}, {"high", false}, {"max", false}}, true},
|
|
{"claude/claude-haiku-4-5", []string{"low", "medium", "high"}, nil, true},
|
|
{"glm/glm-4.6", []string{"none", "minimal"}, nil, true},
|
|
{"glm/glm-4.6", []string{"none"}, nil, false},
|
|
{"glm/glm-4.6", nil, nil, false},
|
|
} {
|
|
if got, thinking := pick(c.id, c.efforts); !slices.Equal(got, c.want) || thinking != c.thinking {
|
|
t.Errorf("%s %v: %v thinking %v, want %v thinking %v", c.id, c.efforts, got, thinking, c.want, c.thinking)
|
|
}
|
|
}
|
|
}
|