agents: the 「接入」 boards filled in — the opened row as designed, real accounts tried

- The opened row follows the boards: what to do in the agent, running copies with the old list, Claude Code's tiers (each with its own effort beside its model again, #536), the model list by whose models they are with what's hidden, the model new sessions start on, what connecting costs (Gemini's Google sign-in), and the files put back on disconnect, shown line by line.
- Disconnecting asks first and shows the lines it puts back. Secrets are masked, and a config of tens of thousands of lines diffs in a fraction of a second.
- Connecting prefers the agent's own subscription when magpie serves it (Claude, Codex).
- The dry run goes through proc.
- Every model hidden still keeps the row connected, with the way back to the list (#356).
- Routing groups are named in Chinese.
- Phone widths: the pickers and squares wrap inside the opened row, and the name stays beside its logo.

GUI tests moved to the new rows: connected fixtures carry refs and open the row from its link. tier-effort and mobile-web each found a real gap and now guard the fix.

Tried in a sandbox HOME with the owner's real accounts:
- Claude Code: connected, a request answered, and disconnect restored the settings exactly.
- OpenCode: a request answered.
- Gemini CLI: connected.
- Codex: connected.
- Model list picks were saved.

go vet, builds (darwin, GOOS=windows, GOOS=linux -tags nogui) and go test -tags nogui ./... under a temp HOME pass. The agent GUI tests pass except cli-update's tight-row case, menu-scroll and old-webkit, which fail on main too.
This commit is contained in:
yetone committed 2026-10-04 02:05:10 +08:00
1 parent 4c7ab667e5
commit d0d098c69f
39 files changed
+1860 -242

No files matched your search

+3
View File
@@ -55,6 +55,9 @@ type Option struct {
// own: served on the agent's own sign-in (viaMagpie), for Same
own bool
// sub: served on a subscription signed in in magpie, not a key; a
// Claude account only for Claude Code, which alone may use it safely
sub bool
}
// Field is one tunable setting of an agent. Set with an empty value puts
+98 -21
View File
@@ -295,32 +295,91 @@ func (a *Agent) Connect() error {
if len(a.Fields) == 0 || a.Wired() {
return nil
}
f := a.Fields[0]
if f.Options == nil {
return fmt.Errorf("%s can't be connected to magpie", a.Name)
}
// the field magpie is picked in: the one listing magpie's models (for
// Gemini CLI its model, its first field being how it signs in), else one
// whose value is magpie itself
vals := a.Values()
cur, _, _ := a.split(vals[f.Key])
if i := strings.LastIndex(cur, "/"); i >= 0 {
cur = cur[i+1:]
}
pick, same := "", ""
for _, o := range f.Options(vals) {
switch {
case o.Value == magpieID:
return a.Apply(f.Key, magpieID)
case o.Ref == "" || o.Group == RoutingGroups:
case pick == "":
pick = o.Value
fallthrough
case same == "" && cur != "" && strings.HasSuffix(o.Ref, "/"+cur):
if strings.HasSuffix(o.Ref, "/"+cur) {
same = o.Value
var f *Field
var opts []Option
for pass := 0; pass < 2 && f == nil; pass++ {
for i := range a.Fields {
if a.Fields[i].Options == nil {
continue
}
list := a.Fields[i].Options(vals)
for _, o := range list {
if pass == 0 && o.Ref != "" || pass == 1 && o.Value == magpieID {
f, opts = &a.Fields[i], list
break
}
}
if f != nil {
break
}
}
}
if same != "" {
if f == nil {
return fmt.Errorf("%s can't be connected to magpie", a.Name)
}
cur := connectWas(vals[f.Key], opts)
if cur == "" && f.Key != a.Fields[0].Key {
cur = connectWas(a.Values()[a.Fields[0].Key], nil)
}
if cur == "" {
// on its default: the first of its own models
for _, o := range opts {
if o.Ref == "" && o.Group == a.Name && o.Value != magpieID {
cur = connectWas(o.Value, opts)
break
}
}
}
// the model it is on now, through magpie (or of its family, for an
// alias): on the account it is signed in to (its vendor's) first, then
// on a subscription, before a key's; else its own vendor's; else a
// subscription's; else the first
var pick, same, alike, own, sub string
sameRank, alikeRank := -1, -1
for _, o := range opts {
if o.Value == magpieID {
return a.Apply(f.Key, magpieID)
}
if o.Ref == "" || o.Group == RoutingGroups {
continue
}
rank := 0
if o.own || o.Group == a.Name {
rank = 2
} else if o.sub {
rank = 1
}
if pick == "" {
pick = o.Value
}
id := o.Ref[strings.LastIndex(o.Ref, "/")+1:]
if cur != "" && (id == cur || strings.TrimSuffix(id, "[1m]") == cur) && rank > sameRank {
same, sameRank = o.Value, rank
}
// an alias (opus) no label read: the model of that family
if cur != "" && !strings.Contains(cur, "-") && strings.Contains(id+"-", "-"+cur+"-") && rank > alikeRank {
alike, alikeRank = o.Value, rank
}
if own == "" && (o.own || o.Group == a.Name) {
own = o.Value
}
if sub == "" && o.sub {
sub = o.Value
}
}
switch {
case same != "":
pick = same
case alike != "":
pick = alike
case own != "":
pick = own
case sub != "":
pick = sub
}
if pick == "" {
return fmt.Errorf("magpie has no models %s can use: add a subscription or a provider first", a.Name)
@@ -328,6 +387,24 @@ func (a *Agent) Connect() error {
return a.Apply(f.Key, pick)
}
// connectWas is the model an agent is on before it is connected, as a
// model id: its own option's alias read through the label that names the
// model ("opus · claude-opus-5-5"), a provider's prefix taken off.
func connectWas(v string, opts []Option) string {
for _, o := range opts {
if o.Value == v && o.Ref == "" {
if _, id, ok := strings.Cut(o.Label, " · "); ok {
v = strings.TrimSpace(id)
}
break
}
}
if i := strings.LastIndex(v, "/"); i >= 0 {
v = v[i+1:]
}
return strings.TrimSpace(v)
}
// Disconnect takes magpie out of the agent's config and puts back what the
// user had, the Agents page's "Disconnect from magpie" (Fate on Discord:
// picking the agent's default did it, but nothing said so). Unwire first,
+113
View File
@@ -0,0 +1,113 @@
package agent
import (
"os"
"path/filepath"
"testing"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/provider"
)
// The 「接入」 switch keeps an agent on the model it was on, now through
// magpie: Claude Code on its opus alias goes to the Claude Opus magpie
// serves, not to the first model of the first provider.
func TestConnectKeepsTheModel(t *testing.T) {
home, _ := codexHome(t, "", "")
if err := provider.Save(provider.Provider{ID: "aaa", Name: "AAA", Chat: "https://a.example/v1", Key: "k", Models: []string{"first", "claude-opus-5-5"}}); err != nil {
t.Fatal(err)
}
path := filepath.Join(home, ".claude", "settings.json")
writeFile(t, path, `{"model": "opus"}`)
c := claude(home)
if err := c.Connect(); err != nil {
t.Fatal(err)
}
if m, _ := edit.GetJSON(path, "model"); m != "aaa/claude-opus-5-5" {
t.Fatalf("connected on %q:\n%s", m, readFile(path))
}
}
// Gemini CLI is connected through its model (its first field is how it
// signs in), and Disconnect leaves its settings.json as they were, with
// no "model": {} behind.
func TestGeminiConnectRoundTrip(t *testing.T) {
home, _ := codexHome(t, "", "")
if err := provider.Save(provider.Provider{ID: "aaa", Name: "AAA", Chat: "https://a.example/v1", Key: "k", Models: []string{"m1"}}); err != nil {
t.Fatal(err)
}
dir := filepath.Join(home, ".gemini")
const was = `{"security": {"auth": {"selectedType": "oauth-personal"}}}`
writeFile(t, filepath.Join(dir, "settings.json"), was)
g := gemini(home)
if err := g.Connect(); err != nil {
t.Fatal(err)
}
if !g.Wired() {
t.Fatalf("not connected:\n%s", readFile(filepath.Join(dir, "settings.json")))
}
if err := g.Disconnect(); err != nil {
t.Fatal(err)
}
if v, ok := edit.GetJSON(filepath.Join(dir, "settings.json"), "model"); ok {
t.Fatalf("model left: %q", v)
}
if a, _ := edit.GetJSON(filepath.Join(dir, "settings.json"), "security.auth.selectedType"); a != "oauth-personal" {
t.Fatalf("sign-in %q", a)
}
if _, err := os.Stat(filepath.Join(dir, ".env")); err == nil {
if k, _ := edit.GetEnvFile(filepath.Join(dir, ".env"), "GEMINI_API_KEY"); k != "" {
t.Fatalf("magpie's key left: %q", k)
}
}
}
// Of the providers serving the model the agent is on, Connect takes the
// account it is signed in to, then a subscription, over a relay listed
// first; on no model of its own, a subscription's before the first.
func TestConnectPrefersTheSubscription(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
for _, c := range []struct {
cur string
opts []Option
want string
}{
{"claude-opus-5-5", []Option{
{Value: "group/auto", Ref: "group/auto", Group: RoutingGroups},
{Value: "relay/claude-opus-5-5[1m]", Ref: "relay/claude-opus-5-5[1m]", Group: "Relay"},
{Value: "other/claude-opus-5-5", Ref: "other/claude-opus-5-5", Group: "Other", sub: true},
{Value: "claude/claude-opus-5-5", Ref: "claude/claude-opus-5-5", Group: "Claude", sub: true, own: true},
}, "claude/claude-opus-5-5"},
{"opus", []Option{
{Value: "relay/claude-opus-5-5[1m]", Ref: "relay/claude-opus-5-5", Group: "Relay"},
{Value: "claude/claude-opus-5-5[1m]", Ref: "claude/claude-opus-5-5", Group: "X"},
}, "claude/claude-opus-5-5[1m]"},
{"claude-opus-5-5", []Option{
{Value: "relay/claude-opus-5-5", Ref: "relay/claude-opus-5-5", Group: "Relay"},
{Value: "other/claude-opus-5-5", Ref: "other/claude-opus-5-5", Group: "Other", sub: true},
}, "other/claude-opus-5-5"},
{"", []Option{
{Value: "stepfun/step-1", Ref: "stepfun/step-1", Group: "StepFun"},
{Value: "claude/claude-sonnet-5-5", Ref: "claude/claude-sonnet-5-5", Group: "Claude", sub: true},
}, "claude/claude-sonnet-5-5"},
{"", []Option{
{Value: "stepfun/step-1", Ref: "stepfun/step-1", Group: "StepFun"},
}, "stepfun/step-1"},
} {
v := c.cur
a := &Agent{ID: "x", Name: "X", Fields: []Field{{Key: "model",
Get: func() string { return v },
Set: func(s string) error { v = s; return nil },
Options: func(map[string]string) []Option { return c.opts },
}}}
if err := a.Connect(); err != nil {
t.Fatal(err)
}
if v != c.want {
t.Errorf("on %q: connected on %q, want %q", c.cur, v, c.want)
}
}
}
+150
View File
@@ -0,0 +1,150 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/proc"
)
// What the Agents page says beside an agent's 「接入」 switch (the owner's
// design): before it is on, what the agent runs on now — its own
// subscription, sign-in or providers — so the user can tell what turning
// it on touches; once on, whether copies of it already running still have
// the list they started with.
// Source is what an agent not connected to magpie runs on now: "sub" (a
// Claude subscription), "chatgpt" (a ChatGPT sign-in), "key" (an API key
// of its own), "providers:N" (N providers of its own); "" when unknown.
func (a *Agent) Source() string {
if a.WSL != "" || a.Path == "" {
return ""
}
dir := filepath.Dir(a.Path)
switch a.ID {
case "claude":
// signed in to claude.ai: ~/.claude.json (or the one in
// CLAUDE_CONFIG_DIR) has the account
for _, p := range []string{filepath.Join(dir, ".claude.json"), filepath.Join(filepath.Dir(dir), ".claude.json")} {
if v, _ := edit.GetJSON(p, "oauthAccount.accountUuid"); v != "" {
return "sub"
}
}
if v, _ := edit.GetJSON(a.Path, "env.ANTHROPIC_API_KEY"); v != "" {
return "key"
}
if v, _ := edit.GetJSON(a.Path, "apiKeyHelper"); v != "" {
return "key"
}
case "codex":
if codexChatGPT(dir) {
return "chatgpt"
}
if v, _ := edit.GetJSON(filepath.Join(dir, "auth.json"), "OPENAI_API_KEY"); v != "" {
return "key"
}
case "opencode", "mimocode":
// the providers in its config, and those signed in through it
n := map[string]bool{}
var cfg map[string]json.RawMessage
if v, _ := edit.GetJSON(a.Path, "provider"); json.Unmarshal([]byte(v), &cfg) == nil {
for k := range cfg {
n[k] = true
}
}
home, _ := os.UserHomeDir()
var auth map[string]json.RawMessage
if b, err := os.ReadFile(filepath.Join(home, ".local", "share", a.ID, "auth.json")); err == nil && json.Unmarshal(b, &auth) == nil {
for k := range auth {
n[k] = true
}
}
delete(n, magpieID)
if len(n) > 0 {
return "providers:" + strconv.Itoa(len(n))
}
}
return ""
}
// startsWith is, for an agent that reads magpie's models only as it
// starts, how to find its processes (patterns for pgrep -f) and the files
// whose change they missed.
func (a *Agent) startsWith() (pats []string, files []string) {
switch a.ID {
case "codex":
return []string{`(^|/)codex( |$)`}, []string{a.Path, filepath.Join(filepath.Dir(a.Path), "magpie-models.json")}
case "claude":
return []string{`(^|/)claude( |$)`}, []string{a.Path}
}
return nil, nil
}
// Stale is how many copies of the agent are running that started before
// magpie last changed what it reads at start: they still have the list
// they started with until reopened. Zero where it can't be told (Windows).
func (a *Agent) Stale() int {
pats, files := a.startsWith()
if len(pats) == 0 || a.WSL != "" || runtime.GOOS == "windows" {
return 0
}
var changed time.Time
for _, f := range files {
if st, err := os.Stat(f); err == nil && st.ModTime().After(changed) {
changed = st.ModTime()
}
}
if changed.IsZero() {
return 0
}
n := 0
for _, pat := range pats {
out, _ := proc.Command("pgrep", "-f", pat).Output()
pids := strings.Fields(string(out))
if len(pids) == 0 {
continue
}
out, _ = proc.Command("ps", "-o", "etime=", "-p", strings.Join(pids, ",")).Output()
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if up, ok := elapsed(l); ok && time.Now().Add(-up).Before(changed.Add(-time.Second)) {
n++
}
}
}
return n
}
// elapsed reads ps's etime, [[dd-]hh:]mm:ss.
func elapsed(s string) (time.Duration, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
var days int
if d, rest, ok := strings.Cut(s, "-"); ok {
n, err := strconv.Atoi(d)
if err != nil {
return 0, false
}
days, s = n, rest
}
parts := strings.Split(s, ":")
if len(parts) < 2 || len(parts) > 3 {
return 0, false
}
secs := 0
for _, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return 0, false
}
secs = secs*60 + n
}
return time.Duration(days)*24*time.Hour + time.Duration(secs)*time.Second, true
}
+18 -3
View File
@@ -68,7 +68,7 @@ func gemini(home string) *Agent {
if m := unstash("gemini.model"); m != "" {
return edit.SetJSON(path, edit.KV{Path: "model.name", Value: m})
}
return edit.DelJSON(path, "model.name")
return delModelName(path)
}
setModel := func(v string) error {
if v == "" {
@@ -77,9 +77,11 @@ func gemini(home string) *Agent {
if err := edit.DelEnvFile(envPath, "GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY"); err != nil {
return err
}
return edit.DelJSON(path, "security.auth.selectedType", "model.name")
if err := edit.DelJSON(path, "security.auth.selectedType"); err != nil {
return err
}
}
return edit.DelJSON(path, "model.name")
return delModelName(path)
}
if isMagpie(v) {
if !routed() {
@@ -245,3 +247,16 @@ func gemini(home string) *Agent {
},
}
}
// delModelName takes model.name out of Gemini CLI's settings.json, and the
// model it was in once nothing else is there: the "model": {} it would
// leave was never the user's.
func delModelName(path string) error {
if err := edit.DelJSON(path, "model.name"); err != nil {
return err
}
if v, _ := edit.GetJSON(path, "model"); strings.TrimSpace(strings.Trim(strings.TrimSpace(v), "{}")) == "" && strings.HasPrefix(strings.TrimSpace(v), "{") {
return edit.DelJSON(path, "model")
}
return nil
}
+427
View File
@@ -0,0 +1,427 @@
package agent
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/yetone/magpie/internal/agentenv"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/proc"
)
// Before an agent is disconnected the user is shown what changes in its
// files, line by line (the owner's 断开 design: 删除、改回、保留), not a
// sentence about it. What Disconnect does depends on the stash, the record
// and each agent's own unwiring, so rather than a second account of it that
// could say otherwise, magpie runs Disconnect itself on a copy: the agent's
// config folder and magpie's own files under a temporary home, in a process
// of its own (DryRunArg), and compares the copy with the files as they are.
// DryRunArg is the command line's first word for the process that
// disconnects an agent on a copy of its files (main.go).
const DryRunArg = "agent-disconnect-dry-run"
// FileChange is how disconnecting changes one of the agent's files.
type FileChange struct {
Path string `json:"path"` // as the user knows it, ~ for home
Removed bool `json:"removed,omitempty"` // the file goes
Created bool `json:"created,omitempty"` // the file is made
Lines []LineDiff `json:"lines,omitempty"`
}
// LineDiff is a line of a file disconnecting changes: Op is "-" for a line
// taken out, "+" for one put in, "~" for a line whose value goes back to
// another (Was is the line now).
type LineDiff struct {
Op string `json:"op"`
Text string `json:"text"`
Was string `json:"was,omitempty"`
}
// DisconnectPreview is what Disconnect would change in the agent's files,
// found by running it on a copy. An error is a preview not to be had (the
// agent's files outside home, a WSL agent): the dialog then says it in
// words.
func DisconnectPreview(a *Agent, exe string) ([]FileChange, error) {
if a.WSL != "" || a.Path == "" {
return nil, errors.New("no preview for this agent")
}
if appdir.Portable() != "" {
return nil, errors.New("no preview for a portable magpie")
}
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
rel := func(p string) (string, bool) {
r, err := filepath.Rel(home, p)
return r, err == nil && r != "." && !strings.HasPrefix(r, "..")
}
dir := filepath.Dir(a.Path)
dirRel, ok := rel(dir)
cfg := appdir.Config()
cfgRel, ok2 := rel(cfg)
if !ok || !ok2 {
return nil, errors.New("the agent's files are outside home")
}
tmp, err := os.MkdirTemp("", "magpie-preview-")
if err != nil {
return nil, err
}
defer os.RemoveAll(tmp)
tmp, _ = filepath.EvalSymlinks(tmp)
// the agent's folder: its files, copied; its folders aren't its config
before := map[string][]byte{}
if err := copyTop(dir, filepath.Join(tmp, dirRel), before, false); err != nil {
return nil, err
}
// magpie's: its files copied (the stash, the record, the providers),
// its folders (plugins) only read, so linked
if err := copyTop(cfg, filepath.Join(tmp, cfgRel), nil, true); err != nil {
return nil, err
}
env := previewEnv(home, tmp)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cmd := proc.CommandContext(ctx, exe, DryRunArg, a.ID)
cmd.Env = env
var out bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &out
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("dry run: %v: %s", err, strings.TrimSpace(out.String()))
}
// what the agent's folder holds now against what it held
var changes []FileChange
copied := filepath.Join(tmp, dirRel)
after := map[string][]byte{}
entries, _ := os.ReadDir(copied)
for _, e := range entries {
if e.Type().IsRegular() {
if b, err := os.ReadFile(filepath.Join(copied, e.Name())); err == nil {
after[e.Name()] = b
}
}
}
shown := func(name string) string { return "~/" + filepath.ToSlash(filepath.Join(dirRel, name)) }
// the agent's own file first, then the others by name
names := []string{filepath.Base(a.Path)}
for n := range before {
if n != names[0] {
names = append(names, n)
}
}
for n := range after {
if _, ok := before[n]; !ok {
names = append(names, n)
}
}
sortTail(names)
for _, n := range names {
was, had := before[n]
now, has := after[n]
switch {
case had && !has:
changes = append(changes, FileChange{Path: shown(n), Removed: true})
case !had && has:
changes = append(changes, FileChange{Path: shown(n), Created: true, Lines: diffLines("", string(now))})
case had && has && !bytes.Equal(was, now):
if l := diffLines(string(was), string(now)); len(l) > 0 {
changes = append(changes, FileChange{Path: shown(n), Lines: l})
}
}
}
return changes, nil
}
// sortTail sorts all but the first name.
func sortTail(names []string) {
for i := 2; i < len(names); i++ {
for j := i; j > 1 && names[j] < names[j-1]; j-- {
names[j], names[j-1] = names[j-1], names[j]
}
}
}
// copyTop copies a folder's files into to, keeping what they held in
// before when it is given; with link, its folders are linked there.
func copyTop(from, to string, before map[string][]byte, link bool) error {
if err := os.MkdirAll(to, 0o700); err != nil {
return err
}
entries, err := os.ReadDir(from)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
const most = 16 << 20
for _, e := range entries {
p := filepath.Join(from, e.Name())
st, err := os.Stat(p) // a linked file is its target
if err != nil {
continue
}
if st.IsDir() {
if link {
os.Symlink(p, filepath.Join(to, e.Name()))
}
continue
}
if !st.Mode().IsRegular() || st.Size() > most || notConfig(e.Name()) {
continue
}
b, err := os.ReadFile(p)
if err != nil {
continue
}
if err := os.WriteFile(filepath.Join(to, e.Name()), b, 0o600); err != nil {
return err
}
if before != nil {
before[e.Name()] = b
}
}
return nil
}
// notConfig is a file no agent keeps its settings in: a database, a log, a
// history, which can be large and which Disconnect never writes.
func notConfig(name string) bool {
for _, ext := range []string{".sqlite", ".sqlite-wal", ".sqlite-shm", ".db", ".db-wal", ".db-shm", ".jsonl", ".log"} {
if strings.HasSuffix(name, ext) {
return true
}
}
return false
}
// previewEnv is this process's environment with home, and the folders under
// it, moved to the copy.
func previewEnv(home, tmp string) []string {
moved := map[string]bool{"HOME": true, "USERPROFILE": true, "XDG_CONFIG_HOME": true, "XDG_DATA_HOME": true, "XDG_STATE_HOME": true, "APPDATA": true, "LOCALAPPDATA": true}
for _, k := range agentenv.Vars {
moved[k] = true
}
var out []string
for _, kv := range os.Environ() {
k, v, _ := strings.Cut(kv, "=")
if !moved[k] {
out = append(out, kv)
continue
}
if k == "HOME" || k == "USERPROFILE" {
continue
}
// a folder under home is the same one under the copy's; one
// elsewhere isn't copied, so it is left out
if r, err := filepath.Rel(home, v); err == nil && !strings.HasPrefix(r, "..") {
out = append(out, k+"="+filepath.Join(tmp, r))
}
}
return append(out, "HOME="+tmp, "USERPROFILE="+tmp)
}
// diffLines is the lines that differ between two texts, in the order of the
// new one: a line taken out, one put in, and a line whose key stays with its
// value changed (model = "x" → model = "y") as one going back. Values that
// read as secrets are masked. Between the lines both start and end with,
// texts too far apart to compare line by line (OpenCode's table of
// thousands of magpie models taken out) keep the lines of the new one
// found in order in the old.
func diffLines(a, b string) []LineDiff {
x, y := splitLines(a), splitLines(b)
pre := 0
for pre < len(x) && pre < len(y) && x[pre] == y[pre] {
pre++
}
suf := 0
for suf < len(x)-pre && suf < len(y)-pre && x[len(x)-1-suf] == y[len(y)-1-suf] {
suf++
}
keepX, keepY := make([]bool, len(x)), make([]bool, len(y))
for i := 0; i < pre; i++ {
keepX[i], keepY[i] = true, true
}
for i := 1; i <= suf; i++ {
keepX[len(x)-i], keepY[len(y)-i] = true, true
}
mx, my := x[pre:len(x)-suf], y[pre:len(y)-suf]
if kx, ky, ok := sameLines(mx, my, 4000); ok {
copy(keepX[pre:], kx)
copy(keepY[pre:], ky)
} else if len(mx)*len(my) <= 50_000_000 {
// each line of the one, in order, kept where the other has it
// next: what is left of a file once a block is taken out of it
// (past that, all of it taken out and put in)
i := 0
for j := range my {
for k := i; k < len(mx); k++ {
if mx[k] == my[j] {
keepX[pre+k], keepY[pre+j] = true, true
i = k + 1
break
}
}
}
}
var out []LineDiff
var gone, came []string
flush := func() {
// a line taken out and one put in under the same key are one
// going back
for _, c := range came {
k := lineKey(c)
paired := false
for i, g := range gone {
if k != "" && lineKey(g) == k {
out = append(out, LineDiff{Op: "~", Text: mask(c), Was: mask(g)})
gone = append(gone[:i], gone[i+1:]...)
paired = true
break
}
}
if !paired {
out = append(out, LineDiff{Op: "+", Text: mask(c)})
}
}
for _, g := range gone {
out = append(out, LineDiff{Op: "-", Text: mask(g)})
}
gone, came = nil, nil
}
i, j := 0, 0
for i < len(x) || j < len(y) {
switch {
case i < len(x) && j < len(y) && keepX[i] && keepY[j]:
flush()
i++
j++
case i < len(x) && !keepX[i]:
if strings.TrimSpace(x[i]) != "" {
gone = append(gone, x[i])
}
i++
default:
if strings.TrimSpace(y[j]) != "" {
came = append(came, y[j])
}
j++
}
}
flush()
// a bracket or brace left alone on its line says nothing
var kept []LineDiff
for _, l := range out {
if t := strings.Trim(strings.TrimSpace(l.Text), "{}[](),"); t == "" {
continue
}
kept = append(kept, l)
}
return kept
}
// sameLines is which lines of x and of y a shortest edit keeps (Myers'
// diff: time and memory grow with the edits, not the files — an agent's
// config can be 30,000 lines, and magpie changes a few). Not ok past most
// edits.
func sameLines(x, y []string, most int) (keepX, keepY []bool, ok bool) {
n, m := len(x), len(y)
if most > n+m {
most = n + m
}
off := most + 1
v := make([]int, 2*off+1)
// trace[d] is v as step d found it, for k in -d-1..d+1
var trace [][]int
get := func(d, k int) int { return trace[d][k+d+1] }
for d := 0; d <= most; d++ {
trace = append(trace, append([]int(nil), v[off-d-1:off+d+2]...))
for k := -d; k <= d; k += 2 {
var xi int
if k == -d || (k != d && v[off+k-1] < v[off+k+1]) {
xi = v[off+k+1]
} else {
xi = v[off+k-1] + 1
}
yi := xi - k
for xi < n && yi < m && x[xi] == y[yi] {
xi++
yi++
}
v[off+k] = xi
if xi < n || yi < m {
continue
}
// back from the end, marking the diagonals walked
keepX, keepY = make([]bool, n), make([]bool, m)
xi, yi = n, m
for e := d; e >= 0; e-- {
k := xi - yi
var pk int
if k == -e || (k != e && get(e, k-1) < get(e, k+1)) {
pk = k + 1
} else {
pk = k - 1
}
px := get(e, pk)
py := px - pk
for xi > px && yi > py && xi > 0 && yi > 0 {
keepX[xi-1], keepY[yi-1] = true, true
xi--
yi--
}
xi, yi = px, py
}
return keepX, keepY, true
}
}
return nil, nil, false
}
func splitLines(s string) []string {
if s == "" {
return nil
}
return strings.Split(strings.TrimRight(strings.ReplaceAll(s, "\r\n", "\n"), "\n"), "\n")
}
// lineKey is what a line sets: TOML's and .env's key before =, JSON's and
// YAML's before :, "" for a line that sets nothing.
var keyRe = regexp.MustCompile(`^\s*(?:export\s+)?"?([A-Za-z0-9_.\-]+)"?\s*[:=]`)
func lineKey(l string) string {
if m := keyRe.FindStringSubmatch(l); m != nil {
return m[1]
}
if t := strings.TrimSpace(l); strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") {
return t
}
return ""
}
var secretKey = regexp.MustCompile(`(?i)(key|token|secret|password|auth)`)
// mask hides the value of a line whose key reads as a secret's; magpie's
// own token, which is no secret, stays.
func mask(l string) string {
k := lineKey(l)
if k == "" || !secretKey.MatchString(k) {
return l
}
m := keyRe.FindStringIndex(l)
val := strings.TrimSpace(l[m[1]:])
bare := strings.Trim(val, `"', `)
if bare == "" || bare == "magpie" || strings.HasPrefix(bare, "{") || strings.HasPrefix(bare, "$") {
return l
}
return l[:m[1]] + " ••••"
}
+87
View File
@@ -0,0 +1,87 @@
package agent
import (
"fmt"
"strings"
"testing"
"time"
)
// The lines Disconnect puts back, as the disconnect dialog shows them: a
// line taken out, one put in, a value going back; secrets masked; quick on
// a config of tens of thousands of lines (a Codex config.toml with its
// [projects]).
func TestDiffLines(t *testing.T) {
var big strings.Builder
for i := 0; i < 30000; i++ {
fmt.Fprintf(&big, "[projects.\"/p/%d\"]\ntrust_level = \"trusted\"\n", i)
}
before := "model = \"magpie/deepseek/pro\"\nmodel_provider = \"magpie\"\napi_key = \"sk-live-123\"\n" + big.String() + "[model_providers.magpie]\nname = \"magpie\"\n"
after := "model = \"gpt-6\"\nopenai_key = \"sk-live-456\"\n" + big.String()
start := time.Now()
got := diffLines(before, after)
if d := time.Since(start); d > 2*time.Second {
t.Fatalf("took %s", d)
}
want := []LineDiff{
{Op: "~", Text: `model = "gpt-6"`, Was: `model = "magpie/deepseek/pro"`},
{Op: "+", Text: "openai_key = ••••"},
{Op: "-", Text: `model_provider = "magpie"`},
{Op: "-", Text: "api_key = ••••"},
{Op: "-", Text: "[model_providers.magpie]"},
{Op: "-", Text: `name = "magpie"`},
}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("got %v\nwant %v", got, want)
}
if d := diffLines("a\nb\n", "a\nb\n"); len(d) != 0 {
t.Fatalf("same texts: %v", d)
}
if d := diffLines("", "x = 1\n}\n"); fmt.Sprint(d) != fmt.Sprint([]LineDiff{{Op: "+", Text: "x = 1"}}) {
t.Fatalf("from nothing, a lone brace left out: %v", d)
}
// a block of thousands taken out, past what is compared line by
// line: its lines, quickly
var table strings.Builder
for i := 0; i < 7000; i++ {
fmt.Fprintf(&table, " \"m%d\": {\"name\": \"M %d\"},\n", i, i)
}
start = time.Now()
d := diffLines("{\n\"model\": \"magpie/x\",\n\"provider\": {\n"+table.String()+"}\n}\n", "{\n\"provider\": {\n}\n}\n")
if time.Since(start) > 2*time.Second || len(d) != 7001 || d[0] != (LineDiff{Op: "-", Text: `"model": "magpie/x",`}) {
t.Fatalf("a table taken out: %d lines in %s, first %v", len(d), time.Since(start), d[:min(len(d), 1)])
}
}
func TestMask(t *testing.T) {
for in, want := range map[string]string{
` "ANTHROPIC_AUTH_TOKEN": "magpie",`: ` "ANTHROPIC_AUTH_TOKEN": "magpie",`,
` "ANTHROPIC_API_KEY": "sk-ant-x",`: ` "ANTHROPIC_API_KEY": ••••`,
`export OPENAI_API_KEY=sk-1`: `export OPENAI_API_KEY= ••••`,
`env_key = "$KEY"`: `env_key = "$KEY"`,
`model = "x"`: `model = "x"`,
} {
if got := mask(in); got != want {
t.Errorf("mask(%q) = %q, want %q", in, got, want)
}
}
}
func TestElapsed(t *testing.T) {
for in, want := range map[string]time.Duration{
" 01:02": 62 * time.Second,
"03:00:01": 3*time.Hour + time.Second,
"2-00:00:10": 48*time.Hour + 10*time.Second,
"10-23:59:59": 10*24*time.Hour + 23*time.Hour + 59*time.Minute + 59*time.Second,
} {
if got, ok := elapsed(in); !ok || got != want {
t.Errorf("elapsed(%q) = %s %v, want %s", in, got, ok, want)
}
}
for _, in := range []string{"", "x", "1:2:3:4", "a-01:02"} {
if _, ok := elapsed(in); ok {
t.Errorf("elapsed(%q) read", in)
}
}
}
+2 -1
View File
@@ -56,7 +56,8 @@ func viaMagpie(agent, prefix string) []Option {
note = a.User + " · via magpie"
}
out = append(out, Option{Value: prefix + e.ID, Label: e.Name, Note: note,
Icon: e.Provider.Icon, Group: e.Provider.Name, Ref: e.ID, Free: e.Free, Rate: e.Rate, RateWas: e.RateWas, Context: e.Context, own: own[e.Provider.ID]})
Icon: e.Provider.Icon, Group: e.Provider.Name, Ref: e.ID, Free: e.Free, Rate: e.Rate, RateWas: e.RateWas, Context: e.Context, own: own[e.Provider.ID],
sub: e.Provider.Account != nil && (e.Provider.Account.Agent != "claude" || agent == "claude")})
}
return append(groups, out...)
}
+27 -2
View File
@@ -18,6 +18,18 @@ import (
type modelCountJSON struct {
Shown int `json:"shown"`
Listed int `json:"listed"`
// By is the models shown by whose they are, a connected agent's
// expanded row's chips (OpenAI 6 · OpenRouter 24)
By []modelGroupJSON `json:"by,omitempty"`
}
// modelGroupJSON is how many of the models shown one provider (or the
// routing groups) gives.
type modelGroupJSON struct {
Name string `json:"name"`
Icon string `json:"icon,omitempty"`
Icons []string `json:"icons,omitempty"`
N int `json:"n"`
}
// agentModelJSON is a model an agent may be shown, as the list draws it.
@@ -84,10 +96,23 @@ func modelCount(id string) *modelCountJSON {
listed, _ := provider.ListedFor(id)
off := provider.HiddenModels(id)
c := &modelCountJSON{Listed: len(listed)}
at := map[string]int{}
for _, e := range listed {
if !off[e.ID] {
c.Shown++
if off[e.ID] {
continue
}
c.Shown++
g := modelGroupJSON{Name: e.Provider.Name, Icon: e.Provider.Icon}
if e.Group != "" {
g = modelGroupJSON{Name: agent.RoutingGroups, Icons: e.Icons}
}
i, ok := at[g.Name]
if !ok {
i = len(c.By)
at[g.Name] = i
c.By = append(c.By, g)
}
c.By[i].N++
}
return c
}
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
@@ -69,7 +70,7 @@ func TestAgentModelsAPI(t *testing.T) {
got.Count == nil || got.Count.Shown != got.Count.Listed-1 {
t.Fatalf("%+v %+v", got.Models, got.Count)
}
if c := modelCount("codex"); *c != *got.Count {
if c := modelCount("codex"); !reflect.DeepEqual(c, got.Count) {
t.Fatalf("count %+v, answered %+v", c, got.Count)
}
got = call("POST", `{"hidden":[]}`)
+31
View File
@@ -166,6 +166,11 @@ type agentJSON struct {
// Models: how many of the catalog its lists show, for an agent that
// picks among it (agent_models.go)
Models *modelCountJSON `json:"models,omitempty"`
// Source: what an agent not connected runs on now (agent.Source), and
// Stale: copies of a connected one still running on the list they
// started with (agent.Stale), for the line under its name
Source string `json:"source,omitempty"`
Stale int `json:"stale,omitempty"`
}
// clientJSON is an agent, or another client the gateway knows, as a
@@ -645,6 +650,27 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
}
writeJSON(rw, c)
})
// what disconnecting an agent changes in its files, line by line, for
// the dialog asking it (agent.DisconnectPreview); its files when the
// preview can't be had
mux.HandleFunc("GET /api/agents/preview/{id}", func(rw http.ResponseWriter, r *http.Request) {
a, err := agent.Find(r.PathValue("id"))
if err != nil {
fail(rw, err)
return
}
exe, err := os.Executable()
if err != nil {
fail(rw, err)
return
}
changes, err := agent.DisconnectPreview(a, exe)
out := map[string]any{"changes": changes}
if err != nil {
out["error"] = err.Error()
}
writeJSON(rw, out)
})
mux.HandleFunc("POST /api/agents/{action}/{id}", func(rw http.ResponseWriter, r *http.Request) {
a, err := agent.Find(r.PathValue("id"))
if err != nil {
@@ -1195,6 +1221,11 @@ func state() stateJSON {
aj.Models = agentModelCount(a.ID, aj.Fields)
aj.Drift = a.Drift()
aj.Wired = a.Wired()
if aj.Wired {
aj.Stale = a.Stale()
} else {
aj.Source = a.Source()
}
if a.Import != nil {
aj.Import, aj.Added = a.Import(), a.Added != nil && a.Added()
}
+97 -5
View File
@@ -2643,11 +2643,103 @@ body.window .row.agent .who.with-models { flex-wrap: wrap; row-gap: 0; }
(claude-direct.test.cjs); the page holds the reader itself */
#view-agents { overflow-anchor: none; }
body.window .row.agent > .ag-conn { margin-left: 12px; align-self: center; cursor: pointer; }
.ag-conn-line { flex: 0 0 100%; min-width: 0; font-size: 11.5px; line-height: 18px; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ag-conn-line.on { color: var(--muted); }
.ag-conn-line.on::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: var(--green); vertical-align: 1px; }
.row.agent.unplugged > .fields { opacity: .55; transition: opacity .15s; }
.row.agent.unplugged:hover > .fields { opacity: 1; }
.ag-conn-row { display: flex; align-items: center; gap: 8px; min-height: 28px; }
.ag-conn-row .ag-conn-said { flex: 1; min-width: 0; font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ag-conn-row .lib-switch { cursor: pointer; }
/* 「接入」 in the window: a row says whether the agent goes through magpie —
a line under its name, a link that opens it, its switch */
.ag-lead { margin: 0 2px 12px; font-size: 12.5px; color: var(--fg-2); }
.ag-lead b { font-weight: 600; color: var(--fg); }
.ag-lead.welcome { margin: 0 0 12px; padding: 16px 18px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); }
.ag-lead .ag-lead-h { display: block; font-size: 14px; margin-bottom: 4px; }
.ag-lead p { margin: 0; line-height: 1.55; color: var(--muted); }
.ag-lead .text.primary { margin-top: 12px; }
body.window .row.agent.conn { flex-wrap: wrap; min-height: 56px; }
.ag-st { flex: 0 0 100%; min-width: 0; display: flex; align-items: center; gap: 6px; font-size: 12px; line-height: 18px; color: var(--muted); }
.ag-st .ag-st-t { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ag-st .ag-st-t b { font-weight: 600; color: var(--fg-2); }
.ag-dot { width: 6px; height: 6px; flex: none; border-radius: 50%; background: var(--faint); }
.ag-st.on .ag-dot { background: var(--green); }
.ag-st.wait .ag-dot, .ag-warn .ag-dot { background: var(--amber); }
.ag-st.wait .ag-st-t { color: var(--amber); }
.ag-st.bad .ag-dot { background: var(--red); }
.ag-st.bad .ag-st-t { color: var(--red); }
.ag-st .ag-fix { flex: none; }
.ag-link, .ag-quiet { flex: none; display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 8px; border: 0; border-radius: 6px; background: none; color: var(--fg-2); font: inherit; font-size: 12px; white-space: nowrap; cursor: pointer; transition: background-color .15s, color .15s; }
.ag-link:hover, .ag-quiet:hover, .ag-link.open { background: var(--pill); color: var(--fg); }
.ag-link .chev, .ag-quiet .chev { display: inline-flex; color: var(--faint); transition: transform .2s ease; }
.ag-quiet.open .chev { transform: rotate(90deg); }
.ag-quiet.red { color: var(--red); }
.ag-quiet.red:hover { background: color-mix(in srgb, var(--red) 10%, transparent); color: var(--red); }
.ag-quiet.accent { color: var(--accent); }
.ag-link:focus-visible, .ag-quiet:focus-visible, .ag-add:focus-visible { outline: 1.5px solid var(--accent); outline-offset: 1px; }
.row.agent.conn > .ag-link { margin-left: auto; }
.row.agent.conn > .fields + .ag-link { margin-left: 0; }
.row.agent.conn > .ag-conn { margin-left: 4px; align-self: center; cursor: pointer; }
.row.agent.conn > .ag-link + .ag-conn { margin-left: 4px; }
.row.agent.conn > .who + .ag-conn, .row.agent.conn > .who + .ag-add, .row.agent.conn > .who + .ag-cant { margin-left: auto; }
.row.agent.conn > .ag-conn:disabled { opacity: .4; cursor: default; }
.ag-add { flex: none; height: 24px; padding: 0 10px; border: 0; border-radius: 6px; background: var(--pill); color: var(--fg); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; }
.ag-add:hover { background: var(--pill-hover); }
.ag-cant { flex: none; font-size: 12px; color: var(--faint); }
.row.agent.conn.unplugged .ag-handle .ic { opacity: .7; }
/* opened: the settings as named lines on the row's quieter ground */
.ag-exp { flex: 0 0 calc(100% + 26px); margin: 7px -12px -7px -14px; padding: 4px 16px 14px 50px; border-top: 1px solid var(--line-2); background: var(--card-2); cursor: default; }
.ag-kv { display: flex; align-items: flex-start; gap: 16px; padding: 11px 0; border-top: 1px solid var(--line-2); }
.ag-kv:first-child { border-top: 0; }
.ag-k { width: 112px; flex: none; padding-top: 3px; font-size: 12px; color: var(--muted); }
.ag-v { flex: 1; min-width: 0; font-size: 12.5px; color: var(--fg-2); display: grid; gap: 4px; }
.ag-vl { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; min-height: 24px; }
.ag-vl > .fields { margin-left: 0; }
.ag-code, .ag-diff code, .ag-path { font-family: var(--code); font-size: 11.5px; }
.ag-code { padding: 1px 5px; border-radius: 4px; background: var(--pill); color: var(--fg); }
.ag-warn { gap: 8px; font-size: 12px; }
.ag-hint { font-size: 12px; color: var(--muted); }
.ag-tier { width: 70px; flex: none; font-size: 12px; color: var(--fg-2); }
.ag-vl .field.ag-pick { width: 220px; justify-content: flex-start; }
.ag-vl .field.ag-eff { flex: none; }
.ag-vl .field.ag-pick .v { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
.ag-exp .extras-cell { justify-content: flex-start; }
.ag-chip { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 6px; background: var(--card); box-shadow: inset 0 0 0 1px var(--line); font-size: 12px; color: var(--fg-2); }
.ag-chip .ic { width: 13px !important; height: 13px !important; }
.ag-chip .n { color: var(--muted); font-variant-numeric: tabular-nums; }
.ag-foot { display: flex; align-items: center; gap: 8px; margin-top: 2px; padding-top: 12px; border-top: 1px solid var(--line-2); font-size: 12px; color: var(--muted); }
.ag-files { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ag-path { color: var(--fg-2); }
.ag-diff-box { margin-top: 10px; max-height: 320px; overflow: auto; }
.ag-diff-more { display: block; margin: 2px 0 6px 28px; padding: 2px 0; border: 0; background: none; font: inherit; font-size: 11.5px; color: var(--accent); cursor: pointer; }
/* a file's lines as disconnecting changes them */
.ag-diff { display: grid; gap: 10px; }
.ag-diff-file { border: 1px solid var(--line); border-radius: 8px; background: var(--card); overflow: hidden; }
.ag-diff-path { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line-2); font-family: var(--code); font-size: 11.5px; color: var(--fg); }
.ag-diff-path:last-child { border-bottom: 0; }
.ag-diff-line { display: flex; align-items: baseline; gap: 8px; padding: 2px 10px; font-size: 11.5px; line-height: 1.6; }
.ag-diff-line:first-of-type { padding-top: 6px; }
.ag-diff-line:last-child { padding-bottom: 6px; }
.ag-diff-line .op { width: 10px; flex: none; text-align: center; font-family: var(--code); }
.ag-diff-line code { flex: 1; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--fg-2); }
.ag-diff-line.del .op, .ag-diff-line.del code { color: var(--red); }
.ag-diff-line .ind { text-decoration: none; }
.ag-diff-line.del code .t { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--red) 45%, transparent); }
.ag-diff-line.back .op { color: var(--green); }
.ag-tag { flex: none; padding: 0 6px; border-radius: 4px; background: var(--pill); font-family: inherit; font-size: 10.5px; line-height: 16px; color: var(--muted); }
.ag-tag.del { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); }
.disconnect-ask .ag-diff-wrap { max-height: min(46vh, 380px); overflow: auto; margin: 2px 0 10px; }
.ag-diff-loading { padding: 14px 0; font-size: 12px; color: var(--muted); }
.disconnect-ask .ag-note { margin: 0 0 10px; font-size: 12px; color: var(--muted); }
.ag-bar-note { font-size: 12px; color: var(--muted); }
@media (max-width: 600px) {
body.window .row.agent.conn { display: flex; }
/* the name beside its logo; the link and the switch wrap under it when short of room */
body.window .row.agent.conn > .who { width: auto; flex: 1 1 160px; min-width: 0; }
.ag-exp { padding-left: 16px; min-width: 0; }
.ag-kv { flex-direction: column; gap: 4px; min-width: 0; }
/* the pickers take the line, the effort and the squares wrap under them */
.ag-vl { width: 100%; min-width: 0; }
#agents .ag-vl > .fields { display: flex; flex-wrap: wrap; width: 100%; min-width: 0; }
.ag-vl > .fields > .field.main, .ag-vl > .fields > .field.solo { flex: 1 1 100%; min-width: 0; }
.ag-vl > .fields > .field.side { flex: 1 0 124px; }
.ag-vl > .fields > .extras-cell { flex-wrap: wrap; max-width: 100%; }
.ag-vl .field.ag-pick { flex: 1 1 160px; width: auto; min-width: 0; }
}
+452 -22
View File
@@ -239,6 +239,7 @@ function renderAgents() {
page.removeAttribute("aria-busy");
const list = $("#agents");
list.replaceChildren();
agentsLead(list);
if (!state.agents.length) {
const e = el("div", "empty-state");
e.append(el("b", "", t("No agents found")), el("span", "", t("Install Claude Code, Codex, Gemini CLI, OpenCode… and magpie will list them here.")));
@@ -269,13 +270,12 @@ function renderAgents() {
// the squares share one cell, side by side: two (Codex's subagents and
// sign-in) wrapped the second under the first
const extras = el("span", "extras-cell");
for (const f of sorted) {
if (extra(f)) { extras.append(extraField(a, f)); continue; }
const b = el("button", "field " + (plain === 1 ? "solo" : wide(f) ? "main" : "side"));
const fieldBtn = (f, cls) => {
const b = el("button", "field " + cls);
const opt = optionFor(f, f.value);
b.title = t("{label}: {value}", { label: t(f.label), value: f.value || t("agent default") }) + (opt?.note ? ` · ${opt.note}` : "");
if (f.value && opt?.direct) b.title += "\n" + directSaid(a, opt);
const effort = f.key === "effort" || f.label === "effort" || f.label === "thinking";
const effort = f.key === "effort" || f.label === "effort" || f.label === "thinking" || TIER_EFFORTS.includes(f.label);
if (opt?.icon || opt?.icons?.length) b.append(optionIcon(opt));
// a field with no logo of its own still leads with an icon: how much
// effort, or the agent's own for its default, as the picker shows it
@@ -290,7 +290,11 @@ function renderAgents() {
b.append(c);
b.dataset.key = f.key;
b.onclick = (ev) => openPicker(a, f, b, ev);
fields.append(b);
return b;
};
for (const f of sorted) {
if (extra(f)) { extras.append(extraField(a, f)); continue; }
fields.append(fieldBtn(f, plain === 1 ? "solo" : wide(f) ? "main" : "side"));
}
// an agent that takes the gateway only from its environment (agy): a
// square that copies the command starting it on magpie
@@ -396,15 +400,17 @@ function renderAgents() {
}
// on the name's own line, so the row keeps its height and the pickers
// their columns
// a row with 「接入」 says it on its line under the name instead
const kind = mode !== "panel" ? connectKind(a) : "";
if (a.drift) {
row.classList.add("drifted");
who.append(driftFix(a));
if (!kind) who.append(driftFix(a));
}
// the CLI's version, and an update when one is out (#202); the panel's
// name column has no room for it
if (mode !== "panel") who.append(cliTag(a));
// which of magpie's models its lists show, on a line under the name
if (mode !== "panel" && a.models) {
if (mode !== "panel" && a.models && !kind) {
const line = el("div", "ag-models-line");
line.append(modelsEntry(a));
who.append(line);
@@ -413,10 +419,15 @@ function renderAgents() {
// connected to magpie or not, said under the name, with where the agent
// picks among magpie's models itself
const sw = connectable(a) ? connectSwitch(a) : null;
if (sw && mode !== "panel") {
who.append(el("div", "ag-conn-line" + (a.wired ? " on" : ""), connectSaid(a)));
who.classList.add("with-models");
row.classList.toggle("unplugged", !a.wired);
if (kind) {
// Claude Code's tiers and subagents have rows of their own when opened
if (a.id === "claude") {
for (const f of a.fields.filter((f) => TIERS.includes(f.label) || f.label === "subagents")) extras.querySelector(`[data-key="${CSS.escape(f.key)}"]`)?.remove();
extras.querySelector('[data-key="tiers"]')?.remove();
}
row.append(agentHandle(a, row, inFold), who);
connectRow(a, row, who, { fields, extras, fieldBtn, sw, kind });
return row;
}
if (sw && openBox) {
const line = el("div", "ag-conn-row");
@@ -711,14 +722,14 @@ function profileDetail(p, footed) {
// driftFix is the one thing a drifted agent shows: an amber pill after its
// name that sets magpie's settings again. What is off is its tooltip; taking
// the config as it is now is in the row's menu.
function driftFix(a) {
function driftFix(a, label = "Apply again") {
const d = a.drift, f = a.fields.find((x) => x.key === d.field);
const want = (f && optionFor(f, d.want)?.label) || d.want;
const fix = el("button", "ag-fix");
fix.type = "button";
fix.title = `${t(DRIFT_WHY[d.kind] || DRIFT_WHY.unwired, { agent: a.name, model: want })}\n${d.detail}`;
fix.setAttribute("aria-label", t("Apply again"));
fix.append(svg(REAPPLY, 11, 1.8), el("span", "", t("Apply again")));
fix.setAttribute("aria-label", t(label));
fix.append(svg(REAPPLY, 11, 1.8), el("span", "", t(label)));
fix.onclick = (e) => { e.stopPropagation(); reapplyAgent(a, fix); };
return fix;
}
@@ -740,9 +751,13 @@ const DISCONNECT_TIP = "Take out everything magpie wrote into {agent}'s config a
// its provider and its own model list has magpie's models (agent.Connect);
// off, magpie takes out what it wrote (askDisconnect). An agent with none of
// magpie's models to offer (Cursor, Copilot) has no switch.
// the field magpie is picked in: the first, or Gemini CLI's model, its
// first being how it signs in
const connectField = (a) => a.fields.find((f) => f.options.some((o) => o.ref)) || a.fields.find((f) => f.options.some((o) => o.value === "magpie"));
// connectable: magpie has models for it — in its pickers, or every one
// hidden from its lists, which leave its pickers but not the way back (#356)
function connectable(a) {
const f = a.fields[0];
return !a.import && !!f && f.options.some((o) => o.ref || o.value === "magpie");
return !a.import && (!!connectField(a) || !!a.models?.listed);
}
// PICKS_IN is where an agent picks a model itself, for the words under it.
@@ -783,6 +798,393 @@ function connectSwitch(a) {
return s;
}
// ---------- 「接入」 in the window (the owner's design boards) ----------
// A row there answers one thing: does the agent go through magpie. Its
// switch stands where the model and effort pickers stood, a line under the
// name says what it runs on and where it picks a model, and a connected
// one opens to what else there is: when it takes effect, which models it
// lists, what a new session starts on, the files magpie changed, and the
// way back.
// agents with no way onto magpie: no switch, the reason instead
const NATIVE_ONLY = {
cursor: "Only Cursor's own models",
copilot: "Only GitHub Copilot's own models",
goose: "Only Goose's own providers",
devin: "Only Devin's own models",
};
// agents that pick no model once started: the one they start on stays in
// the row
const NO_PICKER = new Set(["gemini", "hermes", "agy", "muse"]);
// what turning one on costs, said in its opened row
const CONNECT_COST = {
gemini: "Gemini CLI's requests all go through magpie; its Google sign-in is turned off first and comes back when you disconnect",
};
// agents that read magpie's list only as they start: copies already
// running keep the list they started with (agent.Stale)
const STARTS_WITH = new Set(["codex", "claude"]);
let agentExpanded = null; // the connected agent opened in the window
const staleSeen = {}; // agent id → the running copies the reader was told of
const previews = {}; // agent id → its disconnect preview, as last read
const anyMagpieModels = () => state.agents.some((a) => connectField(a)?.options.some((o) => o.ref));
// connectKind: "ok" (a switch), "empty" (a switch for when magpie has
// models), "no" (never), "" (the row as it was: an app with a link of its own)
function connectKind(a) {
if (a.import) return "";
if (connectable(a)) return "ok";
if (NATIVE_ONLY[a.id]) return "no";
if (a.fields.length && !anyMagpieModels()) return "empty";
return "";
}
// the models it lists the reader hasn't seen yet: counted once, said in
// bold for a while, then counted as seen
const SEEN_KEY = "magpie.agentModelsSeen";
let seenTimer = null;
function newModels(a) {
if (!a.wired || !a.models) return 0;
let seen = {};
try { seen = JSON.parse(localStorage.getItem(SEEN_KEY) || "{}") || {}; } catch { /* none kept */ }
const was = seen[a.id], now = a.models.shown;
const keep = () => { try { seen[a.id] = now; localStorage.setItem(SEEN_KEY, JSON.stringify(seen)); } catch { /* not kept */ } };
if (was === undefined || now <= was) { if (was !== now) keep(); return 0; }
clearTimeout(seenTimer);
seenTimer = setTimeout(() => { keep(); renderAgents(); }, 12000);
return now - was;
}
const staleNow = (a) => a.wired && a.stale > 0 && staleSeen[a.id] !== a.stale;
// connectLine: the dot and the words under an agent's name
function connectLine(a, kind) {
const line = el("div", "ag-st");
const dot = el("span", "ag-dot");
const words = el("span", "ag-st-t");
line.append(dot, words);
const say = (s, cls) => { words.textContent = s; if (cls) line.classList.add(cls); };
if (kind === "no") { say(t(NATIVE_ONLY[a.id])); return line; }
if (kind === "empty") { say(t("Add a key or a subscription first; then there are models to connect")); return line; }
if (!a.wired) {
const src = a.source || "";
if (a.id === "agy") say(t("Not connected · once connected, start it with magpie's command"));
else if (src === "sub") say(t("Not connected · now on its Claude subscription"));
else if (src === "chatgpt") say(t("Not connected · now signed in with ChatGPT · OpenAI's models stay available once connected"));
else if (src === "key") say(t("Not connected · now on an API key of its own"));
else if (src.startsWith("providers:")) say(t("Not connected · now has {n} providers of its own", { n: src.slice(10) }));
else say(t("Not connected · {agent} uses its own settings", { agent: a.name }));
return line;
}
if (a.drift) {
say(t(DRIFT_WHY[a.drift.kind] || DRIFT_WHY.unwired, { agent: a.name, model: a.drift.want }), "bad");
words.title = a.drift.detail || "";
line.append(driftFix(a, "Reconnect"));
return line;
}
line.classList.add("on");
if (staleNow(a)) {
if (a.id === "claude") say(t("Connected · new sessions take it"));
else say(t("Connected · takes effect once {agent} is reopened", { agent: a.name }), "wait");
return line;
}
const fresh = newModels(a);
if (fresh) {
const [pre, post] = t("{n} models · {new} show in new {agent} sessions", { n: a.models.shown, agent: a.name }).split("{new}");
words.append(pre, el("b", "", t("{n} new", { n: fresh })), post || "");
return line;
}
const at = PICKS_IN[a.id];
if (a.id === "claude") say(t("Connected · switch the opus · sonnet · haiku tiers in /model"));
else if (NO_PICKER.has(a.id)) {
const f = connectField(a), o = f && optionFor(f, f.value);
say(o ? t("Connected · starts on {model}", { model: o.label || f.value }) : t("Connected · pick the model it starts on"));
} else if (at && a.models) say(t("{n} models in {agent}'s {cmd}", { n: a.models.shown, agent: a.name, cmd: at }));
else say(connectSaid(a));
return line;
}
// expandLink opens a connected agent's row, and closes it again
function expandLink(a) {
const open = agentExpanded === a.id;
const b = el("button", "ag-link" + (open ? " open" : ""));
b.type = "button";
b.setAttribute("aria-expanded", String(open));
b.append(el("span", "", open ? t("Collapse") : a.id === "claude" ? t("Tiers") : a.models ? t("Models {n}", { n: a.models.shown }) : t("Details")));
if (!open) {
const c = el("span", "chev");
c.append(svg(CHEV_R, 10, 1.6));
b.append(c);
}
b.onclick = (e) => {
e.stopPropagation();
agentExpanded = open ? null : a.id;
renderAgents();
};
return b;
}
// connectRow finishes a window row that has 「接入」
function connectRow(a, row, who, { fields, extras, fieldBtn, sw, kind }) {
// its list open, what was picked there until the state has it
if (agentModels?.a.id === a.id && agentModels.count) a.models = agentModels.count;
row.classList.add("conn");
row.classList.toggle("unplugged", kind === "ok" && !a.wired);
who.classList.add("with-models");
who.append(connectLine(a, kind));
const noPicker = NO_PICKER.has(a.id);
if (kind === "ok") {
if (noPicker && a.wired) row.append(fields);
if (a.wired) row.append(expandLink(a));
row.append(sw);
} else if (kind === "empty") {
const add = el("button", "ag-add", t("Add a provider"));
add.type = "button";
add.onclick = (e) => { e.stopPropagation(); addProviderFromAgents(); };
const off = el("button", "lib-switch ag-conn");
off.type = "button";
off.disabled = true;
off.setAttribute("role", "switch");
off.setAttribute("aria-checked", "false");
off.setAttribute("aria-label", t("Connect {agent} to magpie", { agent: a.name }));
off.title = t("Add a key or a subscription first; then there are models to connect");
off.append(el("i"));
row.append(add, off);
} else row.append(el("span", "ag-cant", t("Can't connect")));
if (kind === "ok" && a.wired && agentExpanded === a.id) {
row.classList.add("expanded");
row.append(connectPanel(a, { fields: noPicker ? null : fields, extras, fieldBtn }));
}
}
// agentsLead: above the window's list, what 「接入」 does, said once in a
// line; while no agent is connected, as a welcome, with a way to add a
// provider when magpie has no models to give
function agentsLead(list) {
let lead = $("#agentsLead");
if (!lead) {
lead = el("div", "ag-lead");
lead.id = "agentsLead";
list.before(lead);
}
lead.replaceChildren();
const kinds = mode === "panel" ? [] : state.agents.map(connectKind);
lead.hidden = !kinds.some((k) => k === "ok" || k === "empty");
if (lead.hidden) return;
const first = !state.agents.some((a) => a.wired);
lead.classList.toggle("welcome", first);
if (!first) {
const [pre, post] = t("Switch an agent on and the models you set up in magpie show up as the {magpie} provider in its own model list.").split("{magpie}");
lead.append(pre, el("b", "", "magpie"), post || "");
return;
}
lead.append(el("b", "ag-lead-h", t("Let your agents use the models you set up in magpie")));
lead.append(el("p", "", t("Switch an agent on and your models show up as the magpie provider in its own model list. Then change models in the agent; switch it off and it goes back to how it was.")));
if (!anyMagpieModels()) {
const add = el("button", "text primary", t("Add a provider"));
add.type = "button";
add.onclick = () => addProviderFromAgents();
lead.append(add);
}
}
function addProviderFromAgents() {
show("providers");
$("#addProvider")?.click();
}
// connectPanel: a connected agent's row, opened
function connectPanel(a, { fields, fieldBtn }) {
const box = el("div", "ag-exp");
box.onclick = (e) => e.stopPropagation();
const kv = (k, ...v) => {
const r = el("div", "ag-kv");
const val = el("div", "ag-v");
val.append(...v);
r.append(el("div", "ag-k", k), val);
box.append(r);
return val;
};
const line = (...parts) => {
const l = el("div", "ag-vl");
l.append(...parts);
return l;
};
const code = (s) => el("code", "ag-code", s);
// where it picks, and when the copies running take it
const at = PICKS_IN[a.id];
if (at || STARTS_WITH.has(a.id)) {
const parts = [];
// the command in the words as code, wherever the language puts it
const said = (s, cmd) => { const [pre, post] = t(s, { agent: a.name }).split("{cmd}"); return line(pre, code(cmd), post || ""); };
if (a.id === "claude") parts.push(said("New sessions take it; switch the tiers in {cmd}.", "/model"));
else if (STARTS_WITH.has(a.id)) parts.push(said("Reopen {agent}, then pick any model in {cmd}.", at));
else parts.push(said("Pick any model in {agent}'s {cmd}.", at));
if (staleNow(a)) {
const w = line();
w.classList.add("ag-warn");
w.append(el("span", "ag-dot"), el("span", "", t(a.stale === 1 ? "1 running {agent} still has the old list" : "{n} running {agent} still have the old list", { n: a.stale, agent: a.name })));
const later = el("button", "ag-quiet", t("Got it"));
later.type = "button";
later.onclick = () => { staleSeen[a.id] = a.stale; renderAgents(); };
w.append(later);
parts.push(w);
}
kv(t("In {agent}", { agent: a.name }), ...parts);
}
// Claude Code's tiers, each with the /model that picks it
if (a.id === "claude") {
const tiers = a.fields.filter((f) => TIERS.includes(f.label) && f.options.length);
const hint = { opus: "/model opus", sonnet: "/model sonnet", haiku: "/model haiku", fable: "/model fable" };
const also = { sonnet: " · " + t("the default tier"), haiku: " · " + t("also for small background tasks") };
const rows = tiers.map((f) => {
const l = line(el("span", "ag-tier", f.label[0].toUpperCase() + f.label.slice(1)), fieldBtn(f, "ag-pick"));
// the tier's own effort (#536), while there are levels to pick;
// unset, it runs at the effort Claude Code asks for
const e = a.fields.find((x) => x.label === f.label + " effort" && (x.options.length || x.value));
if (e) {
const b = fieldBtn(e, "ag-eff");
b.title = t("{label}: {value}", { label: t(e.label), value: e.value ? effortName(optionFor(e, e.value) || { value: e.value }) : t("the effort Claude Code asks for") });
b.setAttribute("aria-label", b.title);
l.append(b);
}
l.append(el("span", "ag-hint", (hint[f.label] || "") + (also[f.label] || "")));
return l;
});
const sub = a.fields.find((f) => f.label === "subagents" && (f.options.length || f.value));
if (sub) rows.push(line(el("span", "ag-tier", t("Subagents")), fieldBtn(sub, "ag-pick")));
if (rows.length) kv(t("Tiers"), ...rows);
const main = a.fields.find((f) => f.key === "model");
const hasSub = main?.options.some((o) => (o.ref || "").startsWith("claude/"));
if (hasSub) kv(t("Claude models"), line(t("Your Claude subscription is in magpie; picking a claude-* model goes through it as before")));
else {
const add = el("button", "ag-quiet accent", t("Add a Claude subscription"));
add.type = "button";
add.onclick = () => addProviderFromAgents();
kv(t("Claude models"), line(t("claude-* models need a Claude subscription in magpie"), add));
}
}
// which of magpie's models it lists, by whose they are
if (a.models) {
const chips = line();
chips.classList.add("ag-chips");
for (const g of a.models.by || []) {
const c = el("span", "ag-chip");
if (g.icons?.length) c.append(optionIcon({ icons: g.icons }));
else if (g.icon) c.append(icon(g.icon));
c.append(el("span", "", g.name === ROUTING_GROUPS ? t(g.name) : g.name), el("span", "n", String(g.n)));
chips.append(c);
}
const hidden = a.models.listed - a.models.shown;
if (hidden > 0) chips.append(el("span", "ag-hint", t("{n} hidden", { n: hidden })));
const pick = el("button", "ag-quiet");
pick.type = "button";
pick.append(el("span", "", t("Pick")));
const c = el("span", "chev");
c.append(svg(CHEV_R, 10, 1.6));
pick.append(c);
pick.onclick = (ev) => openAgentModels(a, pick, ev);
// drawn again while its list is open: the list stays, held to it
if (agentModels?.a.id === a.id) {
pick.classList.add("open");
agentModels.anchor = pick;
}
chips.append(pick);
kv(t("Model list"), chips);
}
// what a new session starts on: optional, the agent's own last pick unset
if (fields && a.id !== "claude") {
kv(t("New sessions"), line(fields), el("div", "ag-hint", t("Optional · unset, {agent} starts on its own last pick", { agent: a.name })));
} else if (fields && a.id === "claude") {
kv(t("New sessions"), line(fields));
}
if (CONNECT_COST[a.id]) kv(t("Once connected"), line(t(CONNECT_COST[a.id])));
if (a.launch) {
const cp = el("button", "ag-quiet", t("Copy"));
cp.type = "button";
cp.onclick = () => copy(a.launch, t("Launch command"), null, t("Copied — run it to start {name} on magpie", { name: a.name }));
kv(t("Start it with"), line(code(a.launch), cp));
}
// the files magpie changed, and the way back
const foot = el("div", "ag-foot");
const files = el("span", "ag-files");
const view = el("button", "ag-quiet");
view.type = "button";
view.hidden = true;
const diff = el("div", "ag-diff-box");
diff.hidden = true;
view.onclick = () => {
diff.hidden = !diff.hidden;
view.firstChild.textContent = diff.hidden ? t("View") : t("Hide changes");
view.classList.toggle("open", !diff.hidden);
};
view.append(el("span", "", t("View")));
const vc = el("span", "chev");
vc.append(svg(CHEV_R, 10, 1.6));
view.append(vc);
const off = el("button", "ag-quiet red", t("Disconnect and restore"));
off.type = "button";
off.onclick = () => askDisconnect(a);
foot.append(files, view, el("span", "grow"), off);
box.append(foot, diff);
const fill = (changes) => {
if (!changes?.length) return;
files.replaceChildren(t("Changed "));
changes.forEach((c, i) => {
if (i) files.append(" · ");
files.append(el("span", "ag-path", i ? c.path.split("/").pop() : c.path));
});
view.hidden = false;
diff.replaceChildren(changesList(changes));
};
if (previews[a.id]) fill(previews[a.id]);
loadPreview(a).then(fill, () => {});
return box;
}
async function loadPreview(a) {
const r = await api("agents/preview/" + a.id);
if (r?.error) throw new Error(r.error);
previews[a.id] = r?.changes || [];
return previews[a.id];
}
// changesList: what disconnecting does to each file, line by line
function changesList(changes) {
const box = el("div", "ag-diff");
for (const c of changes) {
const f = el("div", "ag-diff-file");
const h = el("div", "ag-diff-path");
h.append(el("span", "", c.path));
if (c.removed) h.append(el("span", "ag-tag del", t("Deleted")));
if (c.created) h.append(el("span", "ag-tag", t("Created")));
f.append(h);
const line = (l) => {
const r = el("div", "ag-diff-line " + (l.op === "-" ? "del" : "back"));
// the indent shows where it was, and isn't struck through
const code = el("code"), ind = l.text.match(/^\s*/)[0];
code.append(el("span", "ind", ind), el("span", "t", l.text.slice(ind.length)));
r.append(el("span", "op", l.op === "-" ? "−" : "↺"), code);
if (l.op !== "-") r.append(el("span", "ag-tag", t("value before connecting")));
if (l.op === "~" && l.was) r.title = t("Now: {line}", { line: l.was });
return r;
};
// a list magpie wrote whole (Claude Code's modelPicker, Codex's
// catalog) is a few lines and the rest on asking
const lines = c.lines || [], FEW = 8;
f.append(...lines.slice(0, FEW).map(line));
if (lines.length > FEW + 2) {
const rest = el("button", "ag-diff-more", t("{n} more lines", { n: lines.length - FEW }));
rest.type = "button";
rest.onclick = () => rest.replaceWith(...lines.slice(FEW).map(line));
f.append(rest);
} else f.append(...lines.slice(FEW).map(line));
box.append(f);
}
return box;
}
// askDisconnect takes magpie out of an agent's config once asked: every
// field magpie set, and its endpoint and key, give way to what the agent
// had before magpie (agent.Disconnect). Picking Default isn't that: it is
@@ -792,9 +1194,22 @@ function askDisconnect(a) {
const head = el("div", "ehead");
head.append(icon(a.icon), el("b", "", t("Disconnect {agent} from magpie?", { agent: a.name })));
ed.append(head);
ed.append(el("p", "lib-confirm", t("magpie takes out everything it wrote into {agent}'s config — its endpoint, key, models and effort — and puts back the settings {agent} had before. magpie's providers and accounts stay as they are.", { agent: a.name })));
ed.append(el("p", "lib-confirm", t("{agent} goes back to how it was before it was connected; these are the places put back.", { agent: a.name })));
// what changes, line by line, read from a dry run on a copy (preview.go);
// where none can be had, the old words
const said = el("p", "lib-confirm", t("magpie takes out everything it wrote into {agent}'s config — its endpoint, key, models and effort — and puts back the settings {agent} had before. magpie's providers and accounts stay as they are.", { agent: a.name }));
const body = el("div", "ag-diff-wrap");
const show = (changes) => {
if (changes?.length) body.replaceChildren(changesList(changes));
else body.replaceChildren(said);
};
if (previews[a.id]) show(previews[a.id]);
else body.append(el("div", "ag-diff-loading", t("Reading what changes…")));
loadPreview(a).then(show, () => show(null));
ed.append(body);
if (a.id === "codex") ed.append(el("p", "ag-note", t("magpie's provider table stays, so sessions opened on magpie's models still open")));
const bar = el("div", "bar");
const go = el("button", "text primary danger-fill", t("Disconnect"));
const go = el("button", "text primary danger-fill", t("Disconnect and restore"));
go.onclick = async (e) => {
e.stopPropagation();
go.disabled = true;
@@ -802,6 +1217,8 @@ function askDisconnect(a) {
try {
state = await api("agents/disconnect/" + a.id, {});
closeConfirmAsk();
delete previews[a.id];
if (agentExpanded === a.id) agentExpanded = null;
renderAgents();
const msg = t("{agent} no longer goes through magpie; its own settings are back", { agent: a.name });
if (state.notice) status(`${msg}. ${t(state.notice)}`, "warn", 9000);
@@ -814,7 +1231,7 @@ function askDisconnect(a) {
};
const cancel = el("button", "text", t("Cancel"));
cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
bar.append(el("span", "grow"), cancel, go);
bar.append(STARTS_WITH.has(a.id) ? el("span", "ag-bar-note", t("Running {agent} copies take it once reopened", { agent: a.name })) : el("span"), el("span", "grow"), cancel, go);
ed.append(bar);
confirmAsk = ed;
openModal(ed);
@@ -1387,9 +1804,19 @@ async function openAgentModels(a, anchor, ev) {
const save = () => {
me.changed = true;
const hidden = models.filter((m) => m.hidden).map((m) => m.id);
const count = { shown: models.length - hidden.length, listed: models.length };
// by whose they are, as the state counts them, for a connected row's chips
const by = [];
for (const m of models) {
if (m.hidden) continue;
let g = by.find((x) => x.name === m.group);
if (!g) by.push(g = m.group === ROUTING_GROUPS ? { name: m.group, icons: m.icons, n: 0 } : { name: m.group, icon: m.icon, n: 0 });
g.n++;
}
const count = { shown: models.length - hidden.length, listed: models.length, by };
me.count = a.models = count;
fillModelsEntry(me.anchor, a);
// the line under the name counts them; an opened row is drawn again
if (me.anchor.classList.contains("ag-models")) fillModelsEntry(me.anchor, a);
else renderAgents();
me.saving = me.saving.then(() => api("agent-models/" + encodeURIComponent(a.id), { hidden }))
.catch((e) => status(e.message, "err"));
};
@@ -2533,7 +2960,10 @@ function renderEffortPicker() {
}
// a tier's effort, opened from the tiers' square: that square's title
// and light follow the level
if (TIER_EFFORTS.includes(opened.field.label)) {
if (TIER_EFFORTS.includes(opened.field.label) && opened.anchor.classList.contains("ag-eff")) {
opened.anchor.title = t("{label}: {value}", { label: t(opened.field.label), value: option.value ? effortName(option) : t("the effort Claude Code asks for") });
opened.anchor.setAttribute("aria-label", opened.anchor.title);
} else if (TIER_EFFORTS.includes(opened.field.label)) {
const menu = tierMenu(opened.agent);
if (menu) {
opened.anchor.classList.toggle("set", menu.custom);
+63 -2
View File
@@ -134,10 +134,71 @@ const I18N = {
"Disconnect from magpie": "断开 magpie(还原配置)",
"Take out everything magpie wrote into {agent}'s config and put back what it had before": "移除 magpie 写入 {agent} 配置的所有内容,还原成接入 magpie 之前的设置",
"put back what {agent} had before magpie": "还原 {agent} 接入 magpie 之前的设置",
"Disconnect {agent} from magpie?": "断开 {agent} 与 magpie 的连接?",
"Disconnect {agent} from magpie?": "断开 {agent} 和 magpie?",
"magpie takes out everything it wrote into {agent}'s config — its endpoint, key, models and effort — and puts back the settings {agent} had before. magpie's providers and accounts stay as they are.": "magpie 会移除它写入 {agent} 配置的所有内容(接口地址、密钥、模型和推理强度),并还原 {agent} 之前的设置。magpie 里的供应商和账号保持不变。",
"Disconnect": "断开",
"Not connected · {agent} uses its own settings": "未接入 · {agent} 用它自己的设置",
"Not connected · {agent} uses its own settings": "未接入 · 用 {agent} 自己的设置",
"Only Cursor's own models": "只能用 Cursor 自己的模型",
"Only GitHub Copilot's own models": "只能用 GitHub Copilot 自己的模型",
"Only Goose's own providers": "只能用 Goose 自己的供应商",
"Only Devin's own models": "只能用 Devin 自己的模型",
"Gemini CLI's requests all go through magpie; its Google sign-in is turned off first and comes back when you disconnect": "Gemini CLI 的请求都走 magpie,Google 登录会先关掉,断开时恢复",
"Add a key or a subscription first; then there are models to connect": "先添加一个 key 或订阅,接入后才有模型可选",
"Not connected · once connected, start it with magpie's command": "未接入 · 接入后要用 magpie 给的命令启动",
"Not connected · now on its Claude subscription": "未接入 · 现在用 Claude 订阅",
"Not connected · now signed in with ChatGPT · OpenAI's models stay available once connected": "未接入 · 现在用 ChatGPT 登录 · 接入后 OpenAI 的模型照常可用",
"Not connected · now on an API key of its own": "未接入 · 现在用它自己的 API key",
"Not connected · now has {n} providers of its own": "未接入 · 现在有 {n} 个自己的供应商",
"Reconnect": "重新接入",
"Connected · new sessions take it": "已接入 · 新开的会话生效",
"Connected · takes effect once {agent} is reopened": "已接入 · 重开 {agent} 后生效",
"{n} models · {new} show in new {agent} sessions": "{n} 个模型 · {new}在新的 {agent} 会话里可见",
"{n} new": "新加的 {n} 个",
"Connected · switch the opus · sonnet · haiku tiers in /model": "已接入 · 在 /model 里切换 opus · sonnet · haiku 档位",
"Connected · starts on {model}": "已接入 · 用 {model} 启动",
"Connected · pick the model it starts on": "已接入 · 选一个启动模型",
"{n} models in {agent}'s {cmd}": "{n} 个模型在 {agent} 的 {cmd} 里",
"Collapse": "收起",
"Tiers": "档位",
"Models {n}": "模型 {n}",
"Details": "详情",
"Can't connect": "不能接入",
"New sessions take it; switch the tiers in {cmd}.": "新开的会话生效,在 {cmd} 里切换档位。",
"Reopen {agent}, then pick any model in {cmd}.": "重开 {agent} 后,在 {cmd} 里选任何一个模型。",
"Pick any model in {agent}'s {cmd}.": "在 {agent} 的 {cmd} 里选任何一个模型。",
"1 running {agent} still has the old list": "1 个正在运行的 {agent} 还是旧列表",
"{n} running {agent} still have the old list": "{n} 个正在运行的 {agent} 还是旧列表",
"Got it": "知道了",
"In {agent}": "在 {agent} 里",
"also for small background tasks": "也用于后台小任务",
"the default tier": "默认档位",
"Subagents": "子 agent",
"Claude models": "Claude 模型",
"Your Claude subscription is in magpie; picking a claude-* model goes through it as before": "Claude 订阅已在 magpie 里添加,选 claude-* 模型时照常走你的订阅",
"Add a Claude subscription": "添加 Claude 订阅",
"claude-* models need a Claude subscription in magpie": "claude-* 模型要先在 magpie 里添加 Claude 订阅",
"Pick": "挑选",
"Model list": "模型列表",
"New sessions": "新会话默认",
"Optional · unset, {agent} starts on its own last pick": "可选 · 不设就用 {agent} 自己上次的选择",
"Once connected": "接入后",
"Start it with": "启动命令",
"View": "查看",
"Hide changes": "收起",
"Disconnect and restore": "断开并还原",
"Changed ": "改动了 ",
"Deleted": "删除",
"Created": "新建",
"value before connecting": "接入前的值",
"Now: {line}": "现在:{line}",
"{n} more lines": "还有 {n} 行",
"{agent} goes back to how it was before it was connected; these are the places put back.": "{agent} 会回到接入前的样子,下面是要改回的地方。",
"Reading what changes…": "正在读取要改回的地方…",
"magpie's provider table stays, so sessions opened on magpie's models still open": "保留 magpie 供应商表,用 magpie 模型开过的会话还能打开",
"Running {agent} copies take it once reopened": "正在运行的 {agent} 重开后生效",
"Switch an agent on and the models you set up in magpie show up as the {magpie} provider in its own model list.": "打开「接入」,你在 magpie 配的模型会以 {magpie} 供应商出现在 agent 自己的模型列表里。",
"Let your agents use the models you set up in magpie": "让 agent 用上你在 magpie 里配的模型",
"Switch an agent on and your models show up as the magpie provider in its own model list. Then change models in the agent; switch it off and it goes back to how it was.": "打开一个 agent 的「接入」,你的模型就会以 magpie 供应商出现在它自己的模型列表里。之后在 agent 里换模型就行;关掉开关,它会回到原来的样子。",
"Connected · pick magpie's models with {cmd} in {agent}": "已接入 · 在 {agent} 里用 {cmd} 选 magpie 的模型",
"Connected · magpie's models are in {agent}'s model list": "已接入 · magpie 的模型在 {agent} 的模型列表里",
"Connect {agent} to magpie": "将 {agent} 接入 magpie",
+2 -2
View File
@@ -55,7 +55,7 @@ function server(lang, posts) {
const words = {
en: { off: "Not connected · Codex uses its own settings", on: "Connected · pick magpie's models with /model in Codex", go: "Disconnect", done: /Codex is connected to magpie/ },
zh: { off: "未接入 · Codex 用它自己的设置", on: "已接入 · 在 Codex 里用 /model 选 magpie 的模型", go: "断开", done: /Codex 已接入 magpie/ },
zh: { off: "未接入 · 用 Codex 自己的设置", on: "已接入 · 在 Codex 里用 /model 选 magpie 的模型", go: "断开", done: /Codex 已接入 magpie/ },
};
const row = (id) => `.row.agent[data-id="${id}"]`;
@@ -86,7 +86,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const tops = () => page.evaluate(() => [document.scrollingElement.scrollTop, document.querySelector("#view-agents")?.scrollTop]);
const top = await tops();
const sw = () => page.locator(`${row("codex")} .ag-conn`);
const said = () => page.locator(`${row("codex")} .ag-conn-line`).textContent();
const said = () => page.locator(`${row("codex")} .ag-st`).textContent();
assert.equal(await page.locator(`${row("cursor")} .ag-conn`).count(), 0, "nothing of magpie's to connect it to");
assert.equal(await sw().getAttribute("role"), "switch");
+8 -9
View File
@@ -15,7 +15,7 @@ const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const options = ["gpt-5.4", "magpie/relay/m1"].map((m) => ({ value: m, label: "Label " + m }));
const options = ["gpt-5.4", "magpie/relay/m1"].map((m) => ({ value: m, label: "Label " + m, ...(m.startsWith("magpie/") ? { ref: "relay/m1" } : {}) }));
const fresh = () => ({
agents: [
{ id: "codex", name: "Codex", icon: "generic", path: "/fixture/codex", wired: true, fields: [{ key: "model", label: "model", value: "magpie/relay/m1", options }] },
@@ -55,7 +55,7 @@ function server(lang, posts) {
const words = {
en: { item: "Disconnect from magpie", ask: "Disconnect Codex from magpie?", go: "Disconnect", cancel: "Cancel", done: /Codex no longer goes through magpie/ },
zh: { item: "断开 magpie(还原配置)", ask: "断开 Codex 与 magpie 的连接?", go: "断开", cancel: "取消", done: /Codex 已不再经过 magpie/ },
zh: { item: "断开 magpie(还原配置)", ask: "断开 Codex 和 magpie?", go: "断开", cancel: "取消", done: /Codex 已不再经过 magpie/ },
};
const row = (id) => `.row.agent[data-id="${id}"]`;
@@ -111,12 +111,11 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await ask.waitFor({ state: "detached" });
assert.deepEqual(posts, []);
// the model picker has it too, beside Default; Claude Code's hasn't
await page.locator(`${row("claude")} .field[data-key="model"]`).click();
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
assert.equal(await page.locator("#list li", { hasText: w.item }).count(), 0);
await page.keyboard.press("Escape");
await page.locator("#pop").waitFor({ state: "hidden" });
// the model picker, in the opened row, has it too, beside Default;
// Claude Code, not connected, has no picker to open
assert.equal(await page.locator(`${row("claude")} .field`).count(), 0);
assert.equal(await page.locator(`${row("claude")} .ag-link`).count(), 0);
await page.locator(`${row("codex")} .ag-link`).click();
await page.locator(`${row("codex")} .field[data-key="model"]`).click();
const pickItem = page.locator("#list li", { hasText: w.item });
await pickItem.waitFor();
@@ -128,7 +127,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await ask.locator(".bar button", { hasText: w.go }).click();
await ask.waitFor({ state: "detached" });
await page.waitForFunction((r) => document.querySelector(r + ' .field[data-key="model"] .v')?.textContent === "Label gpt-5.4", row("codex"));
await page.waitForFunction((r) => document.querySelector(r + " .ag-conn")?.getAttribute("aria-checked") === "false", row("codex"));
assert.deepEqual(posts, ["/api/agents/disconnect/codex"]);
assert.match(await page.locator("#status").textContent(), w.done);
await openMenu("codex");
+25 -29
View File
@@ -1,5 +1,6 @@
// An agent's name must stay readable when the main window is narrow or
// enlarged, without making the model and effort controls run past the row.
// enlarged, without making its 「接入」 switch and the link that opens it run
// past the row; the switches line up down the list.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -11,16 +12,16 @@ const assets = path.resolve(__dirname, "../assets");
const state = {
agents: [
{
id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color",
id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", wired: true,
fields: [
{ key: "model", label: "model", value: "claude-sonnet-4.5", options: [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color" }] },
{ key: "model", label: "model", value: "claude-sonnet-4.5", options: [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color", ref: "claude/claude-sonnet-4.5" }] },
{ key: "effort", label: "effort", value: "high", options: [{ value: "high", label: "High" }] },
],
},
{
id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color",
id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color", wired: true,
fields: [
{ key: "model", label: "model", value: "gpt-6.1-sol", options: [{ value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai" }] },
{ key: "model", label: "model", value: "gpt-6.1-sol", options: [{ value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai", ref: "openai/gpt-6.1-sol" }] },
{ key: "effort", label: "effort", value: "high", options: [{ value: "high", label: "Highest" }] },
],
},
@@ -64,32 +65,27 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.goto("http://magpie.test/");
await page.waitForLoadState("networkidle");
const row = page.locator('.row.agent[data-id="claude"]');
const who = row.locator(".who");
const name = row.locator(".name");
const fields = row.locator(".fields");
await fields.waitFor();
for (const width of [520, 560, 600]) {
await page.locator('.row.agent[data-id="claude"] .ag-conn').waitFor();
const boxes = () => page.locator(".row.agent").evaluateAll((els) => els.map((el) => {
const box = (e) => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width }; };
return { row: box(el), who: box(el.querySelector(".who")), controls: [...el.querySelectorAll(":scope > .ag-link, :scope > .ag-conn")].map(box) };
}));
for (const width of [520, 560, 600, 660, 601, 960]) {
await page.setViewportSize({ width, height: 700 });
const [rb, wb, nb, fb] = await Promise.all([row.boundingBox(), who.boundingBox(), name.boundingBox(), fields.boundingBox()]);
assert(wb.width >= 150, `agent identity column is too narrow at ${width}px: ${JSON.stringify({ rb, wb, nb, fb })}`);
assert(fb.y >= wb.y + wb.height - 1, `controls should wrap below the agent name at ${width}px: ${JSON.stringify({ wb, fb })}`);
assert(Math.abs(fb.x - wb.x) < 1, `wrapped controls should align with the name at ${width}px: ${JSON.stringify({ wb, fb })}`);
assert(fb.x + fb.width <= rb.x + rb.width - 8, `controls must stay inside the row at ${width}px: ${JSON.stringify({ rb, fb })}`);
const rows = await boxes();
for (const { row, who, controls } of rows) {
assert(who.width >= 150, `agent identity column is too narrow at ${width}px: ${JSON.stringify({ row, who })}`);
assert.equal(controls.length, 2, "the link and the switch");
for (const c of controls) {
assert(c.right <= row.right - 8, `controls must stay inside the row at ${width}px: ${JSON.stringify({ row, c })}`);
// beside the name, or on a line below it in a narrow window
const ok = width > 600 ? c.x >= who.right && c.y < who.bottom : c.x >= who.right || c.y >= who.bottom - 1;
assert(ok, `controls stay clear of the name at ${width}px: ${JSON.stringify({ who, c })}`);
}
}
const rights = rows.map((r) => r.controls[1].right);
assert(Math.abs(rights[0] - rights[1]) < 1, `the switches line up at ${width}px: ${JSON.stringify(rights)}`);
if (width === 520) narrowShot = await page.screenshot();
}
// The default 660px window and widths above the breakpoint keep
// the controls beside the names and their right edges aligned.
for (const width of [660, 601, 960]) {
await page.setViewportSize({ width, height: 700 });
const boxes = await page.locator(".row.agent").evaluateAll((els) => els.map((el) => {
const whoBox = el.querySelector(".who").getBoundingClientRect();
const fieldsBox = el.querySelector(".fields").getBoundingClientRect();
return { who: { x: whoBox.x, y: whoBox.y, right: whoBox.right, bottom: whoBox.bottom }, fields: { x: fieldsBox.x, y: fieldsBox.y, right: fieldsBox.right } };
}));
assert(boxes.every(({ who, fields }) => fields.y < who.bottom && fields.x > who.right), `controls should stay beside names at ${width}px: ${JSON.stringify(boxes)}`);
assert(Math.abs(boxes[0].fields.right - boxes[1].fields.right) < 1, `control columns should remain aligned at ${width}px: ${JSON.stringify(boxes)}`);
if (width === 660) defaultShot = await page.screenshot();
}
});
@@ -25,7 +25,7 @@ async function serve(route) {
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({
agents: [{
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color",
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true,
fields: [{ key: "model", label: "model", value: "magpie/openai/gpt-6", options: [{ value: "magpie/openai/gpt-6", label: "GPT-6", ref: "openai/gpt-6" }] }],
models: { shown: models.length, listed: models.length },
}],
@@ -56,7 +56,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await browser.close();
});
await page.goto("http://magpie.test/");
await page.locator('.row.agent[data-id="codex"] .ag-models').click();
await page.locator('.row.agent[data-id="codex"] .ag-link').click();
await page.locator('.row.agent[data-id="codex"] .ag-exp .ag-chips .ag-quiet').click();
const pop = page.locator(".am-pop:not(.leaving)");
await pop.waitFor();
const row = (name) => pop.locator(".am-mr", { hasText: name });
@@ -1,8 +1,9 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Every model taken out of an agent's lists (#356, DeepSeek Harness): the
// line under its name stays, "Showing 0 / 3 models", and opens the list to
// put them back. Its pickers then list only the agent's own models, and the
// line had gone with the catalog entries, leaving no way back.
// Every model taken out of an agent's lists (#356, DeepSeek Harness): its
// opened row still says how many are hidden, "3 hidden", and its Pick opens
// the list to put them back. Its pickers then list only the agent's own
// models, and the way in had gone with the catalog entries, leaving no way
// back.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -24,7 +25,7 @@ function fixture(lang) {
...list.filter((m) => !m.hidden).map((m) => ({ value: "magpie/" + m.id, label: m.name, ref: m.id }))];
return {
agents: [{
id: "dsh", name: "DeepSeek Harness", path: "/test/.dsh/config.json", icon: "deepseek-color",
id: "dsh", name: "DeepSeek Harness", path: "/test/.dsh/config.json", icon: "deepseek-color", wired: true,
fields: [{ key: "model", label: "model", value: "deepseek-v4-pro", options }],
models: count(),
}],
@@ -59,8 +60,8 @@ function fixture(lang) {
}
const W = {
en: { all: "All 3 models", none: "Showing 0 / 3 models", hideAll: "Hide all", showAll: "Show all" },
zh: { all: "全部 3 个模型", none: "显示 0 / 3 个模型", hideAll: "全部隐藏", showAll: "全部显示" },
en: { none: "3 hidden", hideAll: "Hide all", showAll: "Show all" },
zh: { none: "已隐藏 3 个", hideAll: "全部隐藏", showAll: "全部显示" },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
@@ -82,9 +83,14 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await browser.close();
});
await page.goto("http://magpie.test/");
const entry = page.locator('.row.agent[data-id="dsh"] .ag-models');
const row = page.locator('.row.agent[data-id="dsh"]');
await row.locator(".ag-link").click();
const entry = row.locator(".ag-exp .ag-chips .ag-quiet");
const hint = row.locator(".ag-exp .ag-chips .ag-hint");
const shown = () => row.locator(".ag-st-t").innerText();
await entry.waitFor();
assert.equal((await entry.innerText()).trim(), w.all);
assert.equal(await hint.count(), 0, "none hidden");
assert.match(await shown(), /^3 /);
const scroll = () => page.evaluate(() => [scrollY, document.scrollingElement.scrollTop, $("#view-agents").scrollTop]);
const was = await scroll();
@@ -94,17 +100,19 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await pop.locator(".am-hide").click();
await page.waitForTimeout(150);
assert.equal(fx.posts.at(-1).length, 3);
assert.equal((await entry.innerText()).trim(), w.none);
assert.equal((await hint.innerText()).trim(), w.none);
assert.match(await shown(), /^0 /);
assert.equal(await row.locator(".ag-chip").count(), 0, "no provider gives one");
assert.equal(await pop.count(), 1, "the list stays open as the row is drawn again");
// closed, the agents are drawn again from the state: the line stays
// closed, the agents are drawn again from the state: the way back stays
const before = fx.stateCalls();
await page.keyboard.press("Escape");
await pop.waitFor({ state: "detached" });
await page.waitForTimeout(300);
assert(fx.stateCalls() > before, "the state is asked for again");
await entry.waitFor();
assert.equal((await entry.innerText()).trim(), w.none);
assert.equal(await entry.locator("b").innerText(), "0");
assert.equal((await hint.innerText()).trim(), w.none);
// and opens the list to show them all again
await entry.click();
@@ -114,11 +122,12 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await showAll.click();
await page.waitForTimeout(150);
assert.deepEqual(fx.posts.at(-1), []);
assert.equal((await entry.innerText()).trim(), w.all);
assert.equal(await hint.count(), 0);
assert.equal(await row.locator(".ag-chip").innerText().then((s) => s.replace(/\s+/g, " ")), "DeepSeek 3");
await page.keyboard.press("Escape");
await pop.waitFor({ state: "detached" });
await page.waitForTimeout(300);
assert.equal((await entry.innerText()).trim(), w.all);
assert.equal(await hint.count(), 0);
assert.deepEqual(await scroll(), was, "no click moved the page");
assert.deepEqual(errors, []);
});
@@ -24,7 +24,7 @@ async function serve(route) {
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({
agents: [{
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color",
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true,
fields: [{ key: "model", label: "model", value: "magpie/openai/gpt-5.5", options: [{ value: "magpie/openai/gpt-5.5", label: "GPT-5.5", ref: "openai/gpt-5.5" }] }],
models: { shown: 3, listed: 3 },
}],
@@ -52,7 +52,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.route("**/*", serve);
t.after(() => browser.close());
await page.goto("http://magpie.test/");
const entry = page.locator('.row.agent[data-id="codex"] .ag-models');
// the list opens from Pick in the connected row, opened
await page.locator('.row.agent[data-id="codex"] .ag-link').click();
const entry = page.locator('.row.agent[data-id="codex"] .ag-exp .ag-chips .ag-quiet');
await entry.waitFor();
const boxes = () => page.locator(".am-pop:not(.leaving)").count();
+29 -16
View File
@@ -1,7 +1,8 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// An agent's model list (@ChongkaiX on X: pick which models show in Codex's
// /model): the line under its name counts them and opens the list, where a
// click on a row takes a model out or puts it back, at once.
// /model): its connected row, opened, counts them by provider and those
// hidden, and Pick opens the list, where a click on a row takes a model out
// or puts it back, at once.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -19,10 +20,18 @@ const models = () => [
function fixture(lang) {
const list = models();
const posts = [];
const count = () => ({ shown: list.filter((m) => !m.hidden).length, listed: list.length });
const count = () => {
const by = [];
for (const m of list.filter((m) => !m.hidden)) {
let g = by.find((x) => x.name === m.group);
if (!g) by.push(g = { name: m.group, icon: m.icon, n: 0 });
g.n++;
}
return { shown: list.filter((m) => !m.hidden).length, listed: list.length, by };
};
const state = () => ({
agents: [{
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color",
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true,
fields: [{ key: "model", label: "model", value: "magpie/openai/gpt-5.5", options: [{ value: "magpie/openai/gpt-5.5", label: "GPT-5.5", ref: "openai/gpt-5.5" }] }],
models: count(),
}],
@@ -55,8 +64,8 @@ function fixture(lang) {
}
const W = {
en: { entry: "Showing 8 / 31 models", after: "Showing 7 / 31 models", all: "All 31 models", title: "Codex's model list", current: "Current", shown: "Shown", hideAll: "Hide all", one: "Showing 1 / 31 models", showAll: "Show all" },
zh: { entry: "显示 8 / 31 个模型", after: "显示 7 / 31 个模型", all: "全部 31 个模型", title: "Codex 的模型列表", current: "在用", shown: "已显示", hideAll: "全部隐藏", one: "显示 1 / 31 个模型", showAll: "全部显示" },
en: { entry: "23 hidden", after: "24 hidden", all: "", title: "Codex's model list", current: "Current", shown: "Shown", hideAll: "Hide all", one: "30 hidden", showAll: "Show all" },
zh: { entry: "已隐藏 23 个", after: "已隐藏 24 个", all: "", title: "Codex 的模型列表", current: "在用", shown: "已显示", hideAll: "全部隐藏", one: "已隐藏 30 个", showAll: "全部显示" },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
@@ -78,13 +87,16 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await browser.close();
});
await page.goto("http://magpie.test/");
const entry = page.locator('.row.agent[data-id="codex"] .ag-models');
const row = page.locator('.row.agent[data-id="codex"]');
await row.locator(".ag-link").click();
const entry = row.locator(".ag-exp .ag-chips .ag-quiet");
const hidden = async () => (await row.locator(".ag-exp .ag-chips .ag-hint").allInnerTexts()).join("").trim();
const chips = async () => (await row.locator(".ag-chip").allInnerTexts()).map((s) => s.replace(/\s+/g, " "));
await entry.waitFor();
assert.equal((await entry.innerText()).trim(), w.entry);
// under the name, not beside it
const name = await page.locator('.row.agent[data-id="codex"] .who .name').boundingBox();
const eb = await entry.boundingBox();
assert(eb.y >= name.y + name.height - 2, "the line is under the name");
assert.equal(await hidden(), w.entry);
assert.deepEqual(await chips(), ["Routing groups 1", "OpenAI 6", "OpenRouter 1"].map((c) => lang === "zh" ? c.replace("Routing groups", "路由组") : c));
// the line under the name counts them too
assert.match(await row.locator(".who .ag-st-t").innerText(), /^8\b/);
const scroll = () => page.evaluate(() => [scrollY, document.scrollingElement.scrollTop, $("#view-agents").scrollTop]);
const was = await scroll();
@@ -117,8 +129,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
// a click takes one out at once, the count under the name with it
const mini = pop.locator(".am-mr", { hasText: "O4-MINI" });
await mini.click();
await page.waitForFunction(() => document.querySelector(".ag-models")?.innerText.includes("7"));
assert.equal((await entry.innerText()).trim(), w.after);
await page.waitForFunction(() => document.querySelector(".ag-chips .ag-hint")?.innerText.includes("24"));
assert.equal(await hidden(), w.after);
assert.equal((await chips())[1], "OpenAI 5");
await page.waitForTimeout(150);
assert.equal(fx.posts.length, 1);
assert(fx.posts[0].includes("openai/o4-mini") && fx.posts[0].includes("openrouter/m5"), "the whole list goes");
@@ -154,7 +167,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.waitForTimeout(150);
assert.equal(fx.posts.at(-1).length, 30);
assert(!fx.posts.at(-1).includes("openai/gpt-5.5"));
assert.equal((await entry.innerText()).trim(), w.one);
assert.equal(await hidden(), w.one);
assert(await hideAll.isDisabled());
assert.equal(await pop.locator(".am-g").nth(1).locator(".c").innerText(), "1 / 6");
@@ -162,7 +175,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await showAll.click();
await page.waitForTimeout(150);
assert.deepEqual(fx.posts.at(-1), []);
assert.equal((await entry.innerText()).trim(), w.all);
assert.equal(await hidden(), w.all);
assert(await showAll.isDisabled());
assert(!(await hideAll.isDisabled()));
+4 -2
View File
@@ -16,9 +16,9 @@ const opt = (v) => ({ value: v, label: v });
function agents({ ultracode = "", effort = "", tierEffort = "", subEffort = "", model = "" } = {}) {
return [
{
id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color",
id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", wired: true,
fields: [
{ key: "model", label: "model", value: model, options: [opt("claude-sonnet-4.5")] },
{ key: "model", label: "model", value: model, options: [{ ...opt("claude-sonnet-4.5"), ref: "claude/claude-sonnet-4.5" }] },
{ key: "effort", label: "effort", value: effort, options: [opt("high"), opt("xhigh")] },
{ key: "ultracode", label: "ultracode", value: ultracode, options: [opt("on")] },
{ key: "opus_effort", label: "opus effort", value: tierEffort, options: [opt("high")] },
@@ -91,6 +91,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
test(`${engine}: turning ultracode on keeps every agent in view`, async (t) => {
const { page, errors } = await open(t, engine, {});
assert.deepEqual((await arranged(page)).folded, [], "a fresh magpie shows them all");
// in its connected row, opened
await page.locator('.row.agent[data-id="claude"] .ag-link').click();
const toggle = page.locator('.row.agent[data-id="claude"] [data-key="ultracode"]');
await toggle.click();
await page.waitForFunction(() => document.querySelector('.row.agent[data-id="claude"] [data-key="ultracode"]')?.getAttribute("aria-pressed") === "true");
+4 -4
View File
@@ -1,8 +1,8 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Antigravity CLI (agy) takes magpie's gateway only from its environment, so
// its row on the Agents page has a square that copies the command starting
// it on magpie, while it is on one of magpie's models: the command in its
// tooltip, one click copying it (posted to /api/copy) and saying so, with the
// it on magpie, once it is connected (its model picker stays in the row, as
// agy picks no model once started): the command in its tooltip, one click copying it (posted to /api/copy) and saying so, with the
// page left where it was; none on an agent without one; in the tray panel's
// opened row too; the words in Chinese. No backend: the API is faked here.
const assert = require("node:assert/strict");
@@ -13,9 +13,9 @@ const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const LAUNCH = "GEMINI_API_KEY=magpie-agy GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:3425 agy --model 'magpie/deepseek/pro'";
const models = ["magpie/deepseek/pro", "model-b"].map((m) => ({ value: m, label: m }));
const models = [{ value: "magpie/deepseek/pro", label: "magpie/deepseek/pro", ref: "deepseek/pro" }, { value: "model-b", label: "model-b" }];
const agent = (id, name, launch) => ({
id, name, path: "/test/" + id, launch,
id, name, path: "/test/" + id, launch, wired: !!launch,
fields: [{ key: "model", label: "model", value: "magpie/deepseek/pro", options: models }],
});
const state = {
+7 -4
View File
@@ -7,7 +7,8 @@
// a model Claude Code asks Anthropic for itself says so: the row's tooltip
// says it isn't through magpie and why the file names no magpie endpoint,
// and picking one says "straight to Anthropic". A magpie model says neither.
// No click moves the page. In English and Chinese, Chromium and WebKit.
// The model is picked in the connected row, opened from its link. No click
// moves the page. In English and Chinese, Chromium and WebKit.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -26,7 +27,7 @@ const filler = [{ key: "model", label: "model", value: "magpie/deepseek/pro", op
const fresh = () => ({
agents: [
...Array.from({ length: 5 }, (_, i) => ({ id: "agent-" + i, name: "Agent " + i, path: "/test/" + i, fields: filler })),
{ id: "claude", name: "Claude Code", icon: "claudecode-color", path: "~/.claude/settings.json",
{ id: "claude", name: "Claude Code", icon: "claudecode-color", path: "~/.claude/settings.json", wired: true,
fields: [{ key: "model", label: "model", value: "sonnet", options }] },
...Array.from({ length: 8 }, (_, i) => ({ id: "more-" + i, name: "More " + i, path: "/test/m" + i, fields: filler })),
],
@@ -85,7 +86,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const sets = [];
await page.route("**/*", server(lang, sets));
await page.goto("http://magpie.test/");
const field = page.locator(`${row} .field[data-key="model"]`);
await page.locator(`${row} .ag-link`).click();
const field = page.locator(`${row} .ag-exp .field[data-key="model"]`);
await field.waitFor();
// the alias, as the model it stands for, with Claude's logo
@@ -97,7 +99,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
// the page scrolled, a pick moves nothing and says where it goes
const view = page.locator("#view-agents");
await page.mouse.move(400, 300);
for (let i = 0; i < 2; i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(20); }
// down to the opened row's picker, so the click has no need to scroll
for (let i = 0; i < 8; i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(20); }
await page.waitForTimeout(300);
const top = await view.evaluate((v) => v.scrollTop);
assert(top > 0, "the list must be scrolled");
+15 -7
View File
@@ -5,8 +5,9 @@
// a click posts ultracode on, lights it and says the restart; the effort
// slider's change says it too. On Opus 4.5 the slider has its three levels
// and no ultracode; on Haiku 4.5 there is neither an effort nor ultracode.
// The window scrolled down stays where it is through the clicks. English
// and Chinese, Chromium and WebKit, the window and the tray panel.
// In the window they are in a connected row, opened from its link; the
// window scrolled down stays where it is through the clicks. English and
// Chinese, Chromium and WebKit, the window and the tray panel.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -16,9 +17,9 @@ const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const five = ["low", "medium", "high", "xhigh", "max"];
const claude = (id, model, efforts, ultracode) => ({
id, name: "Claude Code " + id, icon: "claudecode-color", path: "/test/" + id,
id, name: "Claude Code " + id, icon: "claudecode-color", path: "/test/" + id, wired: true,
fields: [
{ key: "model", label: "model", value: model, options: [{ value: model, icon: "claude-color" }] },
{ key: "model", label: "model", value: model, options: [{ value: model, icon: "claude-color", ref: "claude/" + model }] },
{ key: "effort", label: "effort", value: "", options: efforts.map((value) => ({ value })) },
{ key: "ultracode", label: "ultracode", value: ultracode, options: efforts.includes("xhigh") ? [{ value: "on", note: "Claude plans a workflow for each substantive task" }] : [] },
],
@@ -82,12 +83,19 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await t.test("the window", async () => {
const sets = [];
const page = await open("", sets);
// the rows as the model has them
assert.equal(await page.locator(`${row("o55")} .field[data-key="ultracode"]`).count(), 1);
// the rows as the model has them, each opened in turn
const expand = async (id) => {
await page.locator(`${row(id)} .ag-link`).click();
await page.locator(`${row(id)} .ag-exp`).waitFor();
};
await expand("o45");
assert.equal(await page.locator(`${row("o45")} .field[data-key="ultracode"]`).count(), 0, "ultracode on a model without xhigh");
assert.equal(await page.locator(`${row("o45")} .field[data-key="effort"]`).count(), 1);
await expand("h45");
assert.equal(await page.locator(`${row("h45")} .field[data-key="effort"]`).count(), 0, "an effort for Haiku 4.5");
assert.equal(await page.locator(`${row("h45")} .field[data-key="ultracode"]`).count(), 0);
assert.equal(await page.locator(`${row("o45")} .field[data-key="effort"]`).count(), 1);
await expand("o55");
assert.equal(await page.locator(`${row("o55")} .field[data-key="ultracode"]`).count(), 1);
const sq = page.locator(`${row("o55")} .field[data-key="ultracode"]`);
assert.equal(await sq.getAttribute("aria-pressed"), "false");
@@ -12,7 +12,8 @@
// a click on it leaves the keys to the filter (focus went to the page, and
// Esc and the arrows did nothing); a query shows the rows it finds, so an id
// typed in full is picked with Enter (which only opened the fold); and a
// star on a dated id still shows (its row was gone, Favorites empty).
// star on a dated id still shows (its row was gone, Favorites empty). The
// picker is the connected row's, opened from its link.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -37,7 +38,7 @@ const options = [
via("claude/claude-opus-4-5-20251101", "Claude Opus 4.5", "claude/claude-opus-4-5"),
];
const state = (value) => ({
agents: [{ id: "claude", name: "Claude Code", icon: "claudecode-color", path: "/test/settings.json",
agents: [{ id: "claude", name: "Claude Code", icon: "claudecode-color", path: "/test/settings.json", wired: true,
fields: [{ key: "model", label: "model", value, options }] }],
profiles: [],
});
@@ -89,7 +90,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.route("**/*", server(lang, value, sets));
await page.goto("http://magpie.test/");
await page.locator(row).waitFor();
await page.locator(`${row} .field[data-key="model"]`).click();
await page.locator(`${row} .ag-link`).click();
await page.locator(`${row} .ag-exp .field[data-key="model"]`).click();
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
await page.waitForTimeout(400); // the picker grows open
return page;
@@ -147,7 +149,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.locator(`${row} .field[data-key="model"]`).click();
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
list = await rows(page);
assert.deepEqual(list[1], { v: "Claude Opus 5.5", n: "me@example.com · via magpie", cur: true, fold: false }, JSON.stringify(list));
// after Default and, connected, the way back
assert.deepEqual(list[2], { v: "Claude Opus 5.5", n: "me@example.com · via magpie", cur: true, fold: false }, JSON.stringify(list));
assert.equal(list.filter((r) => r.fold).map((r) => r.v).join(), w.one);
await page.context().close();
});
+13 -4
View File
@@ -111,10 +111,18 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const posts = [];
const page = await open("en", posts);
const view = page.locator("#view-agents");
// the reader scrolls down a little, so a scroll would show
await page.mouse.move(400, 300);
for (let i = 0; i < 6; i++) { await page.mouse.wheel(0, 40); await page.waitForTimeout(20); }
await page.waitForTimeout(300);
// the reader scrolls down until a pill is in view, clear of the
// footer, so a scroll would show
const wheelTo = async (id) => {
const pill = page.locator(`${row(id)} .ag-up`);
await page.mouse.move(400, 300);
for (let i = 0; i < 30 && await pill.evaluate((b) => b.getBoundingClientRect().bottom > 460); i++) {
await page.mouse.wheel(0, 40);
await page.waitForTimeout(60);
}
await page.waitForTimeout(300);
};
await wheelTo("codex");
const top = await view.evaluate((v) => v.scrollTop);
assert(top > 0, "the list must be scrolled");
const was = await page.locator(`${row("codex")} .ag-up`).evaluate((b) => b.getBoundingClientRect().top);
@@ -134,6 +142,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
assert(Math.abs(now - was) <= 3, `the row moved ${now - was}px`);
// one that fails says why, and can be tried again
await wheelTo("gemini");
await page.locator(`${row("gemini")} .ag-up`).click();
await page.waitForTimeout(1000);
assert.match(await page.locator("#status").textContent(), /EACCES/);
+6 -4
View File
@@ -3,7 +3,8 @@
// tiers each take a model of their own from magpie (WilianWeng). Desktop has
// no model field here for them to follow: the tiers' square says what an
// unset one runs on instead, and its picker's reset entry is "Not set", not
// "Same as model". In English and Chinese. No backend: the API is faked here.
// "Same as model". Connected to magpie, the square is in the row opened from
// its link. In English and Chinese. No backend: the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -11,11 +12,11 @@ const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const models = [{ value: "magpie/v/glm", label: "GLM" }, { value: "magpie/v/flash", label: "Flash" }];
const models = [{ value: "magpie/v/glm", label: "GLM", ref: "v/glm" }, { value: "magpie/v/flash", label: "Flash", ref: "v/flash" }];
const tiers = ["opus", "sonnet", "haiku", "fable"];
const fresh = () => ({
agents: [{
id: "claude-desktop", name: "Claude Desktop", path: "/test/claude_desktop_config.json", icon: "claude-color",
id: "claude-desktop", name: "Claude Desktop", path: "/test/claude_desktop_config.json", icon: "claude-color", wired: true,
fields: [
{ key: "provider", label: "provider", value: "magpie", options: [{ value: "magpie", label: "magpie" }] },
...tiers.map((tier) => ({ key: tier, label: tier, value: tier === "haiku" ? "magpie/v/flash" : "", options: models })),
@@ -70,8 +71,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
t.after(() => browser.close());
await page.goto("http://magpie.test/");
await page.locator(cd).waitFor();
await page.locator(`${cd} .ag-link`).click();
const square = page.locator(`${cd} .field.extra[data-key="tiers"]`);
const square = page.locator(`${cd} .ag-exp .field.extra[data-key="tiers"]`);
assert.equal(await square.count(), 1, "no tiers square");
assert.ok((await square.getAttribute("aria-label")).startsWith(w.summary), await square.getAttribute("aria-label"));
const y = await page.evaluate(() => scrollY);
+7 -3
View File
@@ -10,8 +10,8 @@ const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const models = ["claude-sonnet-5-5", "claude-opus-5-5"].map((m) => ({ value: m, label: "Label " + m }));
const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [{ key: "model", label: "model", value: "claude-sonnet-5-5", options: models }] }];
const models = ["claude-sonnet-5-5", "claude-opus-5-5"].map((m) => ({ value: m, ref: "claude/" + m, label: "Label " + m }));
const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", wired: true, fields: [{ key: "model", label: "model", value: "claude-sonnet-5-5", options: models }] }];
function server(lang, theme) {
return async (route) => {
@@ -53,7 +53,11 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.locator(`${row} .ag-sum`).click();
await page.waitForTimeout(700);
await page.locator(`${row} .ag-open .field[data-key="model"]`).click();
} else await page.locator(`${row} .field[data-key="model"]`).click();
} else {
// the window: in the connected agent's opened row
await page.locator(`${row} .ag-link`).click();
await page.locator(`${row} .ag-exp .field[data-key="model"]`).click();
}
const li = page.locator("#pop:not([hidden]) #list li").filter({ hasText: "Label claude-opus-5-5" }).first();
await li.waitFor();
const star = li.locator(".favorite");
+20 -11
View File
@@ -1,8 +1,9 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Phone navigation and content stay usable in both languages (#391). The
// desktop screenshots match BASE_REF within a small rendering tolerance
// (origin/main by default); only API
// boundaries are faked, never the page's layout or scrolling helpers.
// (origin/main by default), but for the Agents page while BASE_REF hasn't its
// 「接入」 rows yet; only API boundaries are faked, never the page's layout or
// scrolling helpers.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -22,27 +23,33 @@ const usage = { ...groups[0], path: "/test/usage.jsonl", bucket: "day", agents:
series: Array.from({ length: 30 }, (_, i) => ({ ...groups[0], label: "09-" + String(i + 1).padStart(2, "0") })) };
// Populated rows are essential: an empty Agents list cannot expose clipped
// model/effort controls or make the desktop comparison cover those rows.
const modelOptions = [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color" },
{ value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai" }];
// Connected, so each opens on its pickers.
const modelOptions = [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color", ref: "claude/claude-sonnet-4.5" },
{ value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai", ref: "openai/gpt-6.1-sol" }];
const modelField = (key, label, value = "") => ({ key, label, value, options: modelOptions });
const agentRows = [
{ id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", fields: [
{ id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", wired: true, fields: [
modelField("model", "model", "claude-sonnet-4.5"),
{ key: "effort", label: "thinking", value: "high", options: [{ value: "high", label: "High" }] },
] },
{ id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color", fields: [
{ id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color", wired: true, fields: [
modelField("model", "model", "gpt-6.1-sol"),
{ key: "effort", label: "effort", value: "high", options: [{ value: "high", label: "High" }] },
modelField("subagent", "subagents"),
{ key: "signin", label: "sign-in", value: "magpie", options: [{ value: "magpie", label: "magpie" }, { value: "chatgpt", label: "ChatGPT" }] },
] },
{ id: "omp", name: "omp", path: "/test/omp.json", icon: "omp", fields: [
{ id: "omp", name: "omp", path: "/test/omp.json", icon: "omp", wired: true, fields: [
modelField("model", "model", "gpt-6.1-sol"), modelField("subagent", "subagents"),
modelField("small", "smol"), modelField("slow", "slow"),
] },
];
const views = ["agents", "providers", "gateway", "routing", "usage", "library", "plugins", "settings"];
function baselineFile(file) {
if (!baseline.has(file)) baseline.set(file, execFileSync("git", ["show", `${base}:internal/gui/assets/${file}`], { cwd: assets, maxBuffer: 4 * 1024 * 1024 }));
return baseline.get(file);
}
function server(lang, original = false, opened = []) {
return async route => {
const url = new URL(route.request().url());
@@ -73,10 +80,8 @@ function server(lang, original = false, opened = []) {
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try {
let body;
if (original) {
if (!baseline.has(file)) baseline.set(file, execFileSync("git", ["show", `${base}:internal/gui/assets/${file}`], { cwd: assets, maxBuffer: 4 * 1024 * 1024 }));
body = baseline.get(file);
} else body = await fs.readFile(path.join(assets, file));
if (original) body = baselineFile(file);
else body = await fs.readFile(path.join(assets, file));
await route.fulfill({ body, contentType });
} catch { await route.fulfill({ status: 404 }); }
};
@@ -131,6 +136,9 @@ async function agentsFit(page) {
for (const agent of agentRows) {
const row = page.locator(`.row.agent[data-id="${agent.id}"]`);
await row.waitFor();
// opened, its pickers in the row
await row.locator(".ag-link").click();
await row.locator(".ag-exp").waitFor();
const bad = await row.evaluate(row => {
const box = row.getBoundingClientRect(), bad = [];
for (const field of row.querySelectorAll(".field")) {
@@ -252,6 +260,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const original = await open(browser, lang, width, { touch: false, original: true });
try {
for (const view of views) {
if (view === "agents" && !baselineFile("app.js").includes("function connectPanel")) continue;
await go(current, view); await go(original, view);
await current.mouse.move(0, 0); await original.mouse.move(0, 0);
// Removing hover can repaint the tab's shadow in a later frame.
+19 -8
View File
@@ -3,7 +3,8 @@
// still out (it answers with the whole state, which took seconds: Claude
// Code's model took 5–10s to show, in the window and the tray panel alike).
// The answer then draws what the config says; a refused pick puts the old
// model back and says why. The page never moves. In English and Chinese.
// model back and says why. The page never moves. In the window the picker
// is in the connected agent's opened row. In English and Chinese.
// No backend: the API is faked here, its /api/set held for SLOW ms.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
@@ -13,9 +14,9 @@ const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const SLOW = 2500;
const models = ["claude-sonnet-5-5", "claude-opus-5-5", "magpie/deepseek/pro", "magpie/kimi/k3"].map((m) => ({ value: m, label: "Label " + m }));
const models = ["claude-sonnet-5-5", "claude-opus-5-5", "magpie/deepseek/pro", "magpie/kimi/k3"].map((m) => ({ value: m, ref: m.replace(/^magpie\//, ""), label: "Label " + m }));
const agent = (id, name) => ({
id, name, path: "/test/" + id,
id, name, path: "/test/" + id, wired: id === "claude",
fields: [{ key: "model", label: "model", value: "claude-sonnet-5-5", options: models }],
});
const fresh = () => ({
@@ -72,6 +73,17 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.locator(row).waitFor();
return page;
};
// the window: the connected agent's row opened, and scrolled down to its
// picker as a reader would (the page puts back any scroll that isn't
// the reader's), by at least `least` turns of the wheel
const toPicker = async (page, field, least = 0) => {
const view = page.locator("#view-agents");
await page.locator(`${row} .ag-link`).click();
await page.mouse.move(400, 300);
const shown = async () => { const f = await field.boundingBox(), v = await view.boundingBox(); return f.y + f.height + 8 <= v.y + v.height; };
for (let i = 0; i < least || !(await shown()); i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(i < least ? 20 : 80); }
await page.waitForTimeout(300);
};
// the picker's row for a model, clicked
const pickModel = async (page, value) => {
const li = page.locator("#list li").filter({ hasText: "Label " + value });
@@ -84,10 +96,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const sets = [];
const page = await open("http://magpie.test/", lang, sets);
const view = page.locator("#view-agents");
const field = page.locator(`${row} .field[data-key="model"]`);
await page.mouse.move(400, 300);
for (let i = 0; i < 2; i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(20); }
await page.waitForTimeout(300);
const field = page.locator(`${row} .ag-exp .field[data-key="model"]`);
await toPicker(page, field, 2);
const top = await view.evaluate((v) => v.scrollTop);
assert(top > 0, "the list must be scrolled");
await field.click();
@@ -126,7 +136,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await t.test("a refused pick puts the old model back", async () => {
const sets = [];
const page = await open("http://magpie.test/", "en", sets, { refuse: true });
const field = page.locator(`${row} .field[data-key="model"]`);
const field = page.locator(`${row} .ag-exp .field[data-key="model"]`);
await toPicker(page, field);
await field.click();
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
await pickModel(page, "claude-opus-5-5");
+9 -7
View File
@@ -6,8 +6,9 @@
// through (Qwen3.8-Flash free, 0.1× struck through; 0.2× with 0.5× struck
// through). A small grey badge on the provider editor's model chips and in
// an agent's model picker, its title saying it; a model with no rate has
// none. Clicking a chip picks it and scrolls nothing; no stripe. In English
// and Chinese, Chromium and WebKit.
// none (the picker in a connected agent's opened row). Clicking a chip
// picks it and scrolls nothing; no stripe. In English and Chinese,
// Chromium and WebKit.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -29,11 +30,11 @@ const qoder = {
key: { set: true, masked: "sk-…one" }, keyList: [], balanceToken: { takes: false, set: false }, proxy: "",
};
const options = [
{ value: "magpie/qoder/qmodel_38max", label: "Qwen3.8-Max", note: "Qoder · via magpie", group: "Qoder", rate: 0.5 },
{ value: "magpie/qoder/qfmodel", label: "Qwen3.8-Flash", note: "Qoder · via magpie", group: "Qoder", free: true, rateWas: 0.1 },
{ value: "magpie/qoder/plain", label: "Plain", note: "Qoder · via magpie", group: "Qoder" },
{ value: "magpie/qoder/qmodel_38max", ref: "qoder/qmodel_38max", label: "Qwen3.8-Max", note: "Qoder · via magpie", group: "Qoder", rate: 0.5 },
{ value: "magpie/qoder/qfmodel", ref: "qoder/qfmodel", label: "Qwen3.8-Flash", note: "Qoder · via magpie", group: "Qoder", free: true, rateWas: 0.1 },
{ value: "magpie/qoder/plain", ref: "qoder/plain", label: "Plain", note: "Qoder · via magpie", group: "Qoder" },
];
const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [{ key: "model", label: "model", value: "magpie/qoder/plain", options }] }];
const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", wired: true, fields: [{ key: "model", label: "model", value: "magpie/qoder/plain", options }] }];
function serve(lang) {
const providers = { providers: [qoder], presets: [], excluded: [], gateway: { running: true, window: true } };
@@ -128,7 +129,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
// an agent's model picker
await page.goto("http://magpie.test/");
await page.locator('.row.agent[data-id="claude"] .field[data-key="model"]').click();
await page.locator('.row.agent[data-id="claude"] .ag-link').click();
await page.locator('.row.agent[data-id="claude"] .ag-exp .field[data-key="model"]').click();
const list = page.locator("#pop:not([hidden]) #list");
await list.locator("li").filter({ hasText: "Qwen3.8-Max" }).first().waitFor();
const rows = await list.locator("li[data-i]").evaluateAll((ls) => ls.map((l) => {
+7 -3
View File
@@ -3,7 +3,8 @@
// (#325), so each is a square after the pickers, not a picker of its own:
// unset it says it follows the model, its picker opens on "Same as model",
// and set it names the model it is on. OpenCode's small model, which
// doesn't follow the model, stays a picker. In English and Chinese.
// doesn't follow the model, stays a picker. Both read in the connected
// agent's opened row. In English and Chinese.
// No backend: the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
@@ -20,10 +21,10 @@ const field = (key, label, value) => ({ key, label, value, options });
const fresh = () => ({
agents: [
{
id: "omp", name: "omp", path: "/test/config.yml", icon: "omp",
id: "omp", name: "omp", path: "/test/config.yml", icon: "omp", wired: true,
fields: [field("model", "model", "magpie/deepseek/pro"), field("subagent", "subagents", ""), field("small", "smol", ""), field("slow", "slow", "magpie/deepseek/flash")],
},
{ id: "opencode", name: "OpenCode", path: "/test/opencode.json", fields: [field("model", "model", "magpie/deepseek/pro"), field("small", "small", "")] },
{ id: "opencode", name: "OpenCode", path: "/test/opencode.json", wired: true, fields: [field("model", "model", "magpie/deepseek/pro"), field("small", "small", "")] },
],
profiles: [],
});
@@ -81,6 +82,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
});
await page.goto("http://magpie.test/");
await page.locator(omp).waitFor();
await page.locator(`${omp} .ag-link`).click();
// the three roles are squares in the row's squares cell; the model alone is a picker
const square = (key) => page.locator(`${omp} .extras-cell .field.extra[data-key="${key}"]`);
@@ -105,6 +107,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
// OpenCode's small model doesn't follow the model: a picker, not a square
const oc = '.row.agent[data-id="opencode"]';
await page.locator(`${oc} .ag-link`).click();
await page.locator(`${oc} .ag-exp`).waitFor();
assert.equal(await page.locator(`${oc} .field.extra`).count(), 0);
assert.equal(await page.locator(`${oc} .field[data-key="small"]`).count(), 1);
assert.deepEqual(errors, []);
+13 -7
View File
@@ -8,7 +8,8 @@
// bars in the list and the effort icon full; one between two offered lights
// them as far as its stop. auto, none and the default light no bar, where
// minimal lights one, and none counts for no level: Hermes at low, second
// of five, lights one of three. In English and Chinese. No backend: the API
// of five, lights one of three; the window's icon is read in a connected
// agent's opened row. In English and Chinese. No backend: the API
// is faked here. ARTIFACT_DIR gets the slider and the panel's list.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
@@ -19,8 +20,8 @@ const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const levels = ["minimal", "low", "medium", "high", "xhigh", "max"].map((value) => ({ value }));
const agent = (id, name, effort, options = levels) => ({
id, name, path: "/test/" + id,
fields: [{ key: "model", label: "model", value: "anthropic/claude-opus-5-5:max", options: [] },
id, name, path: "/test/" + id, wired: true,
fields: [{ key: "model", label: "model", value: "anthropic/claude-opus-5-5:max", options: [{ value: "anthropic/claude-opus-5-5:max", ref: "anthropic/claude-opus-5-5:max" }] },
{ key: "effort", label: "thinking", value: effort, options }],
});
const some = (...vs) => vs.map((value) => ({ value }));
@@ -64,8 +65,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
});
// the page, a row opened in the panel with its slider and a click on the
// slider's first or last stop
const load = async (lang, sets, url) => {
const page = await (await browser.newContext({ viewport: { width: 440, height: 560 } })).newPage();
const load = async (lang, sets, url, viewport = { width: 440, height: 560 }) => {
const page = await (await browser.newContext({ viewport })).newPage();
page.setDefaultTimeout(5000);
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang, sets));
@@ -129,10 +130,15 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await t.test("as bars", async () => {
const lit = (page, id) => page.locator(`${row(id)} .field[data-key="effort"] .effort-ic rect[opacity="1"]`).count();
const ids = ["omp-listed", "pi-minimal", "codex", "kimi", "grok", "hermes"];
const page = await load("en", [], "http://magpie.test/");
const page = await load("en", [], "http://magpie.test/", { width: 980, height: 900 });
await page.locator(row("hermes")).waitFor();
const icons = [];
for (const id of ids) icons.push(await lit(page, id));
for (const id of ids) {
// Hermes picks no model once started: its pickers stay in the row
const more = page.locator(`${row(id)} .ag-link`);
if (await more.count()) { await more.click(); await page.waitForTimeout(100); }
icons.push(await lit(page, id));
}
const panel = await load("en", [], "http://magpie.test/?mode=panel");
await panel.locator(row("hermes")).waitFor();
const bars = [];
+7 -4
View File
@@ -3,8 +3,9 @@
// the icon below: the browser's own tooltip came up under the pointer, and
// Devin's name sat on ZCode's Z as if it were ZCode's (Elan on X). Hovering
// from one icon to the next moves the name with it; leaving, a click, Esc
// and closing the picker take it away. Keyboard focus shows it too. In
// Chromium and WebKit, English and Chinese, light and dark.
// and closing the picker take it away. Keyboard focus shows it too. The
// picker is the one in a connected agent's opened row. In Chromium and
// WebKit, English and Chinese, light and dark.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -13,9 +14,9 @@ const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const vendors = [["Grok (SuperGrok)", "xai"], ["Devin", "devin"], ["ZCode", "zcode"], ["Kimi", "kimi"]];
const options = vendors.flatMap(([g, ic]) => [1, 2, 3].map((n) => ({ value: `${ic}/m${n}`, label: `${g} ${n}`, group: g, icon: ic })));
const options = vendors.flatMap(([g, ic]) => [1, 2, 3].map((n) => ({ value: `${ic}/m${n}`, ref: `${ic}/m${n}`, label: `${g} ${n}`, group: g, icon: ic })));
const state = (lang, theme) => ({
agents: [{ id: "claude", name: "Claude Code", path: "/test/settings.json", fields: [{ key: "model", label: "model", value: "xai/m1", options }] }],
agents: [{ id: "claude", name: "Claude Code", path: "/test/settings.json", wired: true, fields: [{ key: "model", label: "model", value: "xai/m1", options }] }],
profiles: [], settings: { lang, theme },
});
@@ -60,6 +61,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
await page.route("http://magpie.test/**", serve(lang, theme));
await page.goto("http://magpie.test/");
await page.locator('#nav [data-view="agents"]').click();
// connected, its model picked in its opened row
await page.locator("#agents .ag-link").first().click();
await page.locator('#agents [data-key="model"]').first().click();
await page.locator("#pickerRail .rail-item").first().waitFor();
const item = (n) => page.locator(`#pickerRail .rail-item[aria-label="${n}"]`);
+20 -13
View File
@@ -5,7 +5,8 @@
// effort, or the subagent model's own default), it opens the effort slider
// with Default as its first stop, and a level picked is posted and lights
// the square. Claude Code's subagents (#468) have no square
// while there is no model to pick. In English and Chinese. No backend: the API is faked here.
// while there is no model to pick. Connected, the squares are in the row
// opened from its link. In English and Chinese. No backend: the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
@@ -13,11 +14,11 @@ const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const models = [{ value: "gpt-5.5", label: "GPT-5.5" }, { value: "gpt-5.4-mini", label: "GPT-5.4 mini" }];
const models = [{ value: "gpt-5.5", label: "GPT-5.5", ref: "openai/gpt-5.5" }, { value: "gpt-5.4-mini", label: "GPT-5.4 mini", ref: "openai/gpt-5.4-mini" }];
const levels = ["low", "medium", "high", "xhigh"].map((value) => ({ value }));
const fresh = () => ({
agents: [{
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color",
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true,
fields: [
{ key: "model", label: "model", value: "gpt-5.5", options: models },
{ key: "effort", label: "effort", value: "high", options: levels },
@@ -27,8 +28,8 @@ const fresh = () => ({
},
// Claude Code's subagents (#468): a model of magpie's, offered once it runs
// through magpie; before, there is nothing to pick and no square
...[["cc-own", "opus", []], ["cc-magpie", "magpie/deepseek/pro", [{ value: "magpie/deepseek/flash", label: "DeepSeek Flash" }]]].map(([id, model, options]) => ({
id, name: "Claude Code", path: "/test/settings.json", icon: "claudecode-color",
...[["cc-own", "opus", []], ["cc-magpie", "magpie/deepseek/pro", [{ value: "magpie/deepseek/flash", label: "DeepSeek Flash", ref: "deepseek/flash" }]]].map(([id, model, options]) => ({
id, name: "Claude Code", path: "/test/settings.json", icon: "claudecode-color", wired: options.length > 0,
fields: [{ key: "model", label: "model", value: model, options: [{ value: model }] }, { key: "subagent", label: "subagents", value: "", options }],
}))],
profiles: [],
@@ -93,16 +94,22 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
});
await page.goto("http://magpie.test/");
await page.locator(codex).waitFor();
// a square beside the subagents' one; model and effort alone are pickers
const square = page.locator(`${codex} .extras-cell .field.extra[data-key="subagent_effort"]`);
assert.equal(await square.count(), 1);
assert.equal(await page.locator(`${codex} .extras-cell .field.extra[data-key="subagent"]`).count(), 1);
assert.deepEqual(await page.locator(`${codex} .field:not(.extra)`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["model", "effort"]);
assert.equal(await square.getAttribute("aria-label"), w.unset);
assert.equal(await square.evaluate((e) => e.classList.contains("set")), false);
const expand = async (sel) => {
await page.locator(`${sel} .ag-link`).click();
await page.locator(`${sel} .ag-exp`).waitFor();
};
await expand('.row.agent[data-id="cc-magpie"]');
assert.equal(await page.locator('.row.agent[data-id="cc-own"] .field.extra[data-key="subagent"]').count(), 0, "a subagents square with nothing to pick");
assert.equal(await page.locator('.row.agent[data-id="cc-magpie"] .field.extra[data-key="subagent"]').getAttribute("aria-label"), w.follows);
await expand(codex);
// a square beside the subagents' one; model and effort alone are pickers
const square = page.locator(`${codex} .ag-exp .extras-cell .field.extra[data-key="subagent_effort"]`);
assert.equal(await square.count(), 1);
assert.equal(await page.locator(`${codex} .extras-cell .field.extra[data-key="subagent"]`).count(), 1);
assert.deepEqual(await page.locator(`${codex} .ag-exp .field:not(.extra)`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["model", "effort"]);
assert.equal(await square.getAttribute("aria-label"), w.unset);
assert.equal(await square.evaluate((e) => e.classList.contains("set")), false);
// the effort slider, Default its first stop and the square's value on it
const y = await page.evaluate(() => scrollY);
+23 -23
View File
@@ -1,10 +1,10 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Claude Code's tiers can each run at an effort of their own (#536), as its
// subagents can. A tier's effort is an entry under its model in the tiers'
// square, not a picker of its own: unset it says the tier runs at the effort
// subagents can. In the window, connected, a tier's effort sits beside its
// model in the opened row's Tiers: unset it says the tier runs at the effort
// Claude Code asks for, a click opens the effort slider with Default as its
// first stop, and a level picked is posted, lights the square and is named in
// its title. The subagents' effort is a square, as Codex's is, that says what
// first stop, and a level picked is posted and named in its title. (The
// tray panel keeps them as entries under the models in the tiers' square.) The subagents' effort is a square, as Codex's is, that says what
// unset means for Claude Code. Before Claude Code goes through magpie there
// are no levels: no effort entries and no square. No click scrolls the page.
// In English and Chinese. No backend: the API is faked here.
@@ -15,11 +15,11 @@ const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const models = [{ value: "magpie/v/glm", label: "GLM" }, { value: "magpie/v/flash", label: "Flash" }];
const models = [{ value: "magpie/v/glm", label: "GLM", ref: "v/glm" }, { value: "magpie/v/flash", label: "Flash", ref: "v/flash" }];
const levels = ["low", "medium", "high"].map((value) => ({ value }));
const tiers = ["opus", "sonnet", "haiku", "fable"];
const claude = (id, routed) => ({
id, name: "Claude Code", path: "/test/settings.json", icon: "claudecode-color",
id, name: "Claude Code", path: "/test/settings.json", icon: "claudecode-color", wired: routed,
fields: [
{ key: "model", label: "model", value: routed ? "magpie/v/glm" : "opus", options: routed ? models : [{ value: "opus" }] },
{ key: "effort", label: "effort", value: "high", options: levels },
@@ -61,19 +61,21 @@ function server(lang, sets) {
const W = {
en: {
asks: "the effort Claude Code asks for", effort: (tier) => tier + " effort", title: "haiku effort", def: "default", low: "low", high: "high",
sub: "subagent effort: default\nthe effort Claude Code asks for", summary: "tiers: haiku (low)",
sub: "subagent effort: default\nthe effort Claude Code asks for", sep: (l, v) => `${l}: ${v}`,
},
zh: {
asks: "跟随 Claude Code 请求的推理强度", effort: (tier) => tier + " 推理强度", title: "haiku 推理强度", def: "默认", low: "低", high: "高",
sub: "子 agent 推理强度:默认\n跟随 Claude Code 请求的推理强度", summary: "分档:haiku (低)",
sub: "子 agent 推理强度:默认\n跟随 Claude Code 请求的推理强度", sep: (l, v) => `${l}:${v}`,
},
};
const cc = '.row.agent[data-id="claude"]';
// a field's title as fieldBtn words it
const t2 = (w, label, value) => w.sep(label, value);
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
test(`${engine} ${lang}: a Claude Code tier's effort is picked from the tiers' square`, async (t) => {
test(`${engine} ${lang}: a Claude Code tier's effort is picked beside its model`, async (t) => {
const w = W[lang];
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await browser.newPage({ viewport: { width: 1100, height: 700 } });
@@ -90,27 +92,25 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
});
await page.goto("http://magpie.test/");
await page.locator(cc).waitFor();
await page.locator(`${cc} .ag-link`).click();
await page.locator(`${cc} .ag-exp`).waitFor();
// no tier's effort is a field of its own; the subagents' is a square
assert.deepEqual(await page.locator(`${cc} .field[data-key$="_effort"]`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["subagent_effort"]);
// no tier's effort is a field of the row's own; the subagents' is a square
assert.deepEqual(await page.locator(`${cc} .field:not(.ag-eff)[data-key$="_effort"]`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["subagent_effort"]);
assert.equal(await page.locator(`${cc} .field.extra[data-key="subagent_effort"]`).getAttribute("aria-label"), w.sub);
// not through magpie: no levels, no square, no effort entries
assert.equal(await page.locator('.row.agent[data-id="cc-own"] .field[data-key$="_effort"]').count(), 0);
const square = page.locator(`${cc} .field.extra[data-key="tiers"]`);
assert.equal(await square.evaluate((e) => e.classList.contains("set")), true, "haiku has a model of its own");
// each tier's row in the opened row: its model, then its effort
const effs = page.locator(`${cc} .ag-exp .field.ag-eff`);
assert.deepEqual(await effs.evaluateAll((es) => es.map((e) => e.dataset.key)), tiers.map((tier) => tier + "_effort"));
for (const tier of tiers) assert.equal(await page.locator(`${cc} .ag-exp .field.ag-eff[data-key="${tier}_effort"]`).getAttribute("aria-label"), t2(w, w.effort(tier), w.asks));
assert.equal(await effs.locator(".effort-ic").count(), 4, "each effort shows the bars");
const haiku = page.locator(`${cc} .ag-exp .field.ag-eff[data-key="haiku_effort"]`);
const y = await page.evaluate(() => scrollY);
await square.click();
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
const rows = await page.locator("#list li").evaluateAll((es) => es.map((e) => [e.querySelector(".v")?.textContent, e.querySelector(".n")?.textContent || ""]));
assert.deepEqual(rows.map((r) => r[0]), tiers.flatMap((tier) => [tier, w.effort(tier)]));
for (const [, note] of rows.filter((_, i) => i % 2)) assert.equal(note, w.asks);
assert.equal(rows[4][1], "Flash", "haiku's own model");
// each effort entry shows the bars
assert.equal(await page.locator("#list li .effort-ic").count(), 4);
// haiku's effort: the slider, Default first, then its levels
await page.locator("#list li").nth(5).click();
await haiku.click();
await page.locator("#effortControl:not([hidden])").waitFor();
assert.equal(await page.evaluate(() => scrollY), y, "a click scrolled the page");
assert.equal(await page.locator("#effortTitle").textContent(), w.title);
@@ -124,7 +124,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
assert.equal(await page.locator("#effortValue").textContent(), w.low);
await page.waitForFunction(() => document.querySelector("#status")?.textContent);
assert.deepEqual(sets, [{ agent: "claude", field: "haiku_effort", value: "low" }]);
assert.ok((await square.getAttribute("aria-label")).startsWith(w.summary), await square.getAttribute("aria-label"));
assert.equal(await haiku.getAttribute("aria-label"), t2(w, w.title, w.low));
assert.equal(await page.evaluate(() => scrollY), y, "a pick scrolled the page");
// the session's effort is untouched
assert.equal(await page.locator(`${cc} .field[data-key="effort"] .v`).textContent(), w.high);
+4 -2
View File
@@ -47,7 +47,7 @@ const words = {
};
function serve(lang, posts) {
const state = () => ({ agents: [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [{ key: "model", label: "model", value: "magpie/other/m1", options }] }],
const state = () => ({ agents: [{ id: "claude", name: "Claude Code", path: "/test/claude", wired: true, fields: [{ key: "model", label: "model", value: "magpie/other/m1", options }] }],
profiles: [], unlisted, settings: { lang, theme: "light" } });
const groups = { groups: [], pools: [], deciders: [], models: [{ id: "hunyuan/hy4", name: "hy4", provider: "hunyuan", providerName: "Hunyuan", icon: "generic" }] };
return async (r) => {
@@ -101,7 +101,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
// the window's picker: hy3 is reached through Mine, picked at a click
let posts = [];
let page = await open("http://magpie.test/", posts);
const field = page.locator(`${row} .field[data-key="model"]`);
// connected: its picker is in its opened row
await page.locator(`${row} .ag-link`).click();
const field = page.locator(`${row} .ag-exp .field[data-key="model"]`);
await field.waitFor();
const view = page.locator("#view-agents");
await filterFor(page, field, "hy3");
+10
View File
@@ -144,6 +144,16 @@ func run(args []string) error {
if len(args) > 0 && args[0] == "healthcheck" {
return healthcheck() // every few seconds in a container: nothing else
}
if len(args) == 2 && args[0] == agent.DryRunArg {
// an agent disconnected on a copy of its files under a temporary
// home, for the Agents page to show what disconnecting changes
// (agent.DisconnectPreview)
a, err := agent.Find(args[1])
if err != nil {
return err
}
return a.Disconnect()
}
makeDirs()
settings.Migrate()
agent.RenameLegacy()