233 lines
7.2 KiB
Go
233 lines
7.2 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"`
|
|
} `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)
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
}
|