diff --git a/README.md b/README.md
index 25999d2d..73b522bc 100644
--- a/README.md
+++ b/README.md
@@ -222,7 +222,7 @@ Write your instructions, MCP servers and skills once. magpie writes them into ea
-Claude Code · Claude Desktop · Codex · Gemini CLI · OpenCode · OpenChamber · MiMo Code · Pi · Aside · OmO · Goose · Cursor CLI · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot CLI · Crush · DeepSeek Harness · Command Code · fx · oh-my-pi · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · T3 Code · OpenHanako · AtomCode · Alma
+Claude Code · Claude Desktop · Codex · Gemini CLI · OpenCode · OpenChamber · MiMo Code · Pi · Aside · OmO · Goose · Cursor CLI · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · oh-my-pi · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · T3 Code · OpenHanako · AtomCode · Alma
@@ -235,6 +235,32 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:3425 ANTHROPIC_API_KEY=magpie
export GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:3425 GEMINI_API_KEY=magpie
```
+### Reasonix Studio
+
+Reasonix Studio is detected from its desktop installation or a native Go
+`reasonix` 2.x or 1.39.x CLI; historical npm wrappers and a shared config alone
+do not count. Tested with
+[Studio 2.24.0](https://github.com/esengine/DeepSeek-Reasonix/releases/tag/studio-v2.24.0).
+
+Pick the Executor model in the app, or use
+`magpie reasonix magpie/deepseek/deepseek-chat` (`magpie reasonix
+magpie/group/code` for a routing group). Select Plan independently with
+`magpie reasonix planner magpie/deepseek/deepseek-chat`, or use `magpie reasonix
+planner off` to disable the separate planner. `magpie reasonix effort high` sets
+an advertised Executor reasoning level. `magpie reasonix default` restores the
+previous Executor selection; `magpie reasonix planner default` restores Plan.
+Magpie's provider and private `.env` key are removed when neither role needs
+them. Restart Studio for new sessions; project/session overrides still take
+precedence. Other providers and credentials are preserved. A non-managed
+provider named `magpie` is a conflict, reported without overwriting it. Studio
+and the native CLI share these files, so updating only Studio does not isolate
+their settings.
+
+The released-client stream/tool contract test is run with
+`MAGPIE_TEST_REASONIX_CLI=/path/to/reasonix go test ./internal/agent -run
+'^TestReasonixStudioCLIIntegration$' -count=1`. It uses isolated settings and
+a local test upstream, with no vendor credentials.
+
### VSCodium Chat
VSCodium's Chat features are disabled by default. To use the Chat model
diff --git a/docs/reference.md b/docs/reference.md
index 5462b2df..36b51122 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -99,6 +99,7 @@ line; agents connected to magpie lose it when it quits.
| Copilot CLI | `~/.copilot/settings.json` | model |
| Crush | `~/.config/crush/crush.json` | large, small |
| DeepSeek Harness (dsh) | `~/.dsh/profiles/*/cordis.patch.yml` (`$DSH_HOME`; a custom provider, Magpie, its key `MAGPIE_GATEWAY_KEY` in `~/.dsh/.env` and dsh's own key store `~/.dsh/.credentials.yaml`, which the desktop app reads; on one of magpie's models its `web-search-deepseek` row also goes to the gateway, which searches with the model or Settings › Web search, unless dsh has a `DEEPSEEK_API_KEY` or a row of your own), or `~/.dsh/config.yaml` before dsh 0.1.5 | model, effort |
+| Reasonix Studio (2.x, or a native Go `reasonix` 2.x / 1.39.x CLI) | `~/.reasonix/config.toml` + `.env` (`%APPDATA%/reasonix` on Windows; `$REASONIX_HOME`) | model (Executor), planner (Plan), effort (a dedicated `magpie` provider, shared by Studio and the native CLI) |
| Command Code | `~/.commandcode/settings.json` (+ `providers.json`) | model |
| fx | `~/.fx/settings.json` | model (a keyless `magpie` provider) |
| omp (oh-my-pi) | `~/.omp/agent/config.yml` (+ `models.yml`) | model |
diff --git a/internal/agent/agents.go b/internal/agent/agents.go
index 9db95f24..a84a2e2d 100644
--- a/internal/agent/agents.go
+++ b/internal/agent/agents.go
@@ -87,6 +87,7 @@ func All() []*Agent {
copilot(home),
crush(home, cfg),
dsh(home),
+ reasonix(home),
commandCode(home),
fx(home),
omp(home),
diff --git a/internal/agent/reasonix.go b/internal/agent/reasonix.go
new file mode 100644
index 00000000..0ceb865b
--- /dev/null
+++ b/internal/agent/reasonix.go
@@ -0,0 +1,658 @@
+package agent
+
+// Reasonix Studio 2.x and its CLI share config.toml and .env under REASONIX_HOME,
+// ~/.reasonix, or %APPDATA%/reasonix on Windows. Magpie owns one [[providers]]
+// entry, never the user's other providers. Project/session overrides stay theirs.
+
+import (
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "regexp"
+ "runtime"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/pelletier/go-toml/v2"
+ "github.com/yetone/magpie/internal/catalog"
+ "github.com/yetone/magpie/internal/edit"
+ "github.com/yetone/magpie/internal/gateway"
+ "github.com/yetone/magpie/internal/provider"
+)
+
+const reasonixKey = "MAGPIE_REASONIX_API_KEY"
+
+var reasonixKeyLine = regexp.MustCompile(`(?m)^\s*(?:export\s+)?` + reasonixKey + `\s*=`)
+
+// Every API request and catalog sync constructs its own Agent. The transaction
+// must cover the whole edit and rollback across those instances, including reads.
+var reasonixEdits sync.RWMutex
+
+type reasonixConfig struct {
+ DefaultModel *string `toml:"default_model"`
+ Agent struct {
+ Planner *string `toml:"planner_model"`
+ } `toml:"agent"`
+ Providers []reasonixProvider `toml:"providers"`
+ Desktop struct {
+ Access []string `toml:"provider_access"`
+ } `toml:"desktop"`
+}
+
+type reasonixProvider struct {
+ Name string `toml:"name"`
+ Kind string `toml:"kind"`
+ BaseURL string `toml:"base_url"`
+ APIKeyEnv string `toml:"api_key_env"`
+ Model string `toml:"model,omitempty"`
+ Models []string `toml:"models,omitempty"`
+ Default string `toml:"default,omitempty"`
+ Effort string `toml:"effort,omitempty"`
+ Protocol string `toml:"reasoning_protocol,omitempty"`
+ NoProxy bool `toml:"no_proxy,omitempty"`
+ Overrides map[string]reasonixModel `toml:"model_overrides,omitempty"`
+}
+
+type reasonixModel struct {
+ Protocol string `toml:"reasoning_protocol,omitempty"`
+ Efforts []string `toml:"supported_efforts,omitempty"`
+ Vision *bool `toml:"vision,omitempty"`
+ Context int `toml:"context_window,omitempty"`
+ Output int `toml:"max_output_tokens,omitempty"`
+}
+
+// The journal contains settings, never an upstream credential. A separate file
+// per home makes portable Studio instances independent and participates in rollback.
+type reasonixState struct {
+ Version int `json:"version"`
+ Planner *string `json:"planner_model,omitempty"`
+ PlannerManaged bool `json:"planner_managed,omitempty"`
+ Default *string `json:"default_model"`
+ NewConfig bool `json:"new_config"`
+ NewEnv bool `json:"new_env"`
+ AddedAccess bool `json:"added_access"`
+}
+
+func reasonix(home string) *Agent {
+ dir := reasonixHome(runtime.GOOS, home, os.Getenv)
+ if absolute, err := filepath.Abs(dir); err == nil {
+ dir = absolute
+ }
+ path, env := filepath.Join(dir, "config.toml"), filepath.Join(dir, ".env")
+ sum := sha256.Sum256([]byte(filepath.Clean(dir)))
+ statePath := filepath.Join(filepath.Dir(provider.Path()), fmt.Sprintf("reasonix-%x.json", sum[:8]))
+ load := func() (reasonixConfig, *reasonixState, error) {
+ cfg, err := readReasonix(path)
+ if err != nil {
+ return cfg, nil, err
+ }
+ b, err := edit.Read(statePath)
+ if err != nil {
+ return cfg, nil, err
+ }
+ var state *reasonixState
+ if b != nil {
+ state = &reasonixState{}
+ if err := json.Unmarshal(b, state); err != nil {
+ return cfg, nil, fmt.Errorf("Reasonix restore record: %w", err)
+ }
+ if state.Version != 1 {
+ return cfg, nil, fmt.Errorf("Reasonix restore record has an unsupported or missing version")
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(b, &fields); err != nil {
+ return cfg, nil, err
+ }
+ for _, key := range []string{"default_model", "new_config", "new_env", "added_access"} {
+ value, present := fields[key]
+ if !present || key != "default_model" && strings.TrimSpace(string(value)) == "null" {
+ return cfg, nil, fmt.Errorf("Reasonix restore record is incomplete")
+ }
+ }
+ }
+ return cfg, state, nil
+ }
+ model := func() string {
+ cfg, err := readReasonix(path)
+ if err == nil && cfg.DefaultModel != nil {
+ return *cfg.DefaultModel
+ }
+ return ""
+ }
+ checkOwner := func(cfg reasonixConfig, state *reasonixState) error {
+ for _, p := range cfg.Providers {
+ if p.Name == magpieID && (state == nil || p.APIKeyEnv != reasonixKey) {
+ return fmt.Errorf("Reasonix already has a provider named magpie that magpie does not own")
+ }
+ if p.Name != magpieID && p.APIKeyEnv == reasonixKey {
+ return fmt.Errorf("Reasonix's %s is used by another provider", reasonixKey)
+ }
+ }
+ b, err := edit.Read(env)
+ if err != nil {
+ return err
+ }
+ // Studio's dotenv reader resolves duplicate assignments differently from
+ // the editor. Ownership is ambiguous: do not replace or remove either.
+ if len(reasonixKeyLine.FindAllIndex(b, -1)) > 1 {
+ return fmt.Errorf("Reasonix credentials repeat %s", reasonixKey)
+ }
+ if _, exists := edit.GetEnvFile(env, reasonixKey); exists && state == nil {
+ return fmt.Errorf("Reasonix's %s already exists; it was not created by magpie", reasonixKey)
+ }
+ return nil
+ }
+ writeProvider := func(p reasonixProvider) error {
+ b, err := toml.Marshal(struct {
+ Providers []reasonixProvider `toml:"providers"`
+ }{[]reasonixProvider{p}})
+ if err != nil {
+ return err
+ }
+ return edit.SetTOMLArrayTable(path, "providers", "name", magpieID, string(b))
+ }
+ // Restore only what this adapter added. A later native selection and edits to
+ // other providers, credentials, and the desktop access list survive.
+ restore := func(cfg reasonixConfig, state *reasonixState, native string) error {
+ if state == nil {
+ if native == "" {
+ return nil
+ }
+ return edit.SetTOMLTopPreserving(path, edit.KV{Path: "default_model", Value: native})
+ }
+ if err := checkOwner(cfg, state); err != nil {
+ return err
+ }
+ if cfg.Agent.Planner != nil && usesMagpie(*cfg.Agent.Planner) {
+ // Keep the connection while the independently selected Plan uses it.
+ if native != "" {
+ return edit.SetTOMLTopPreserving(path, edit.KV{Path: "default_model", Value: native})
+ }
+ if cfg.DefaultModel != nil && usesMagpie(*cfg.DefaultModel) {
+ if state.Default != nil {
+ return edit.SetTOMLTopPreserving(path, edit.KV{Path: "default_model", Value: *state.Default})
+ }
+ return edit.DelTOMLTop(path, "default_model")
+ }
+ return nil
+ }
+ if err := edit.DelTOMLArrayTable(path, "providers", "name", magpieID); err != nil {
+ return err
+ }
+ if native != "" {
+ if err := edit.SetTOMLTopPreserving(path, edit.KV{Path: "default_model", Value: native}); err != nil {
+ return err
+ }
+ } else if cfg.DefaultModel != nil && usesMagpie(*cfg.DefaultModel) {
+ if state.Default != nil {
+ if err := edit.SetTOMLTopPreserving(path, edit.KV{Path: "default_model", Value: *state.Default}); err != nil {
+ return err
+ }
+ } else if err := edit.DelTOMLTop(path, "default_model"); err != nil {
+ return err
+ }
+ }
+ if state.AddedAccess && slices.Contains(cfg.Desktop.Access, magpieID) {
+ access := slices.DeleteFunc(cfg.Desktop.Access, func(v string) bool { return v == magpieID })
+ if err := edit.SetTOMLKey(path, "desktop", "provider_access", reasonixArray(access)); err != nil {
+ return err
+ }
+ }
+ if v, exists := edit.GetEnvFile(env, reasonixKey); exists && v == gateway.TokenFor("reasonix") {
+ if err := edit.DelEnvFile(env, reasonixKey); err != nil {
+ return err
+ }
+ }
+ for _, f := range []struct {
+ path string
+ made bool
+ }{{path, state.NewConfig}, {env, state.NewEnv}} {
+ if _, err := os.Stat(f.path); os.IsNotExist(err) {
+ continue
+ }
+ if b, err := edit.Read(f.path); err != nil {
+ return err
+ } else if f.made && len(strings.TrimSpace(string(b))) == 0 {
+ if err := edit.Remove(f.path); err != nil {
+ return err
+ }
+ }
+ }
+ return edit.Remove(statePath)
+ }
+ ensureGateway := func(cfg reasonixConfig, state **reasonixState, ref, selected string) error {
+ currentState := *state
+ models := magpieModels("reasonix")
+ if !slices.ContainsFunc(models, func(m catalog.Model) bool { return m.ID == ref }) {
+ return fmt.Errorf("magpie has no visible model %q", ref)
+ }
+ if err := checkOwner(cfg, currentState); err != nil {
+ return err
+ }
+ if currentState == nil {
+ _, cErr := os.Stat(path)
+ _, eErr := os.Stat(env)
+ currentState = &reasonixState{Version: 1, Default: cfg.DefaultModel, NewConfig: os.IsNotExist(cErr), NewEnv: os.IsNotExist(eErr)}
+ currentState.AddedAccess = cfg.Desktop.Access != nil && !slices.Contains(cfg.Desktop.Access, magpieID)
+ }
+ p := reasonixCatalog(models, selected, cfg.magpie().Effort)
+ if err := writeProvider(p); err != nil {
+ return err
+ }
+ if cfg.Desktop.Access != nil && !slices.Contains(cfg.Desktop.Access, magpieID) {
+ access := append(cfg.Desktop.Access, magpieID)
+ if err := edit.SetTOMLKey(path, "desktop", "provider_access", reasonixArray(access)); err != nil {
+ return err
+ }
+ currentState.AddedAccess = true
+ }
+ if token, _ := edit.GetEnvFile(env, reasonixKey); token != gateway.TokenFor("reasonix") {
+ if err := edit.SetEnvFile(env, edit.KV{Path: reasonixKey, Value: gateway.TokenFor("reasonix")}); err != nil {
+ return err
+ }
+ }
+ b, err := json.Marshal(currentState)
+ if err != nil {
+ return err
+ }
+ if err := edit.WriteAtomic(statePath, b); err != nil {
+ return err
+ }
+ *state = currentState
+ return nil
+ }
+ set := func(v string) error {
+ cfg, state, err := load()
+ if err != nil {
+ return err
+ }
+ ref, magpie := strings.CutPrefix(v, magpieID+"/")
+ if !magpie {
+ known := cfg.hasModel(v) || cfg.DefaultModel != nil && *cfg.DefaultModel == v ||
+ state != nil && state.Default != nil && *state.Default == v
+ if v != "" && !known {
+ return fmt.Errorf("Reasonix has no model %q", v)
+ }
+ return restore(cfg, state, v)
+ }
+ if err := ensureGateway(cfg, &state, ref, ref); err != nil {
+ return err
+ }
+ return edit.SetTOMLTopPreserving(path, edit.KV{Path: "default_model", Value: v})
+ }
+
+ a := &Agent{
+ ID: "reasonix", Name: "Reasonix Studio", Icon: "reasonix-color", Aliases: []string{"reasonix-studio"},
+ Bin: "reasonix", Dir: dir, Path: path, UA: []string{"reasonix"},
+ detect: func() bool { return reasonixDetected(home) },
+ Notice: func() string {
+ return "Reasonix Studio 2.x and the native 1.39.x/2.x CLI read global settings at startup. Start a new process to load changes. Project/session models can override Executor and Plan."
+ },
+ Check: func() string {
+ ref, on := strings.CutPrefix(model(), magpieID+"/")
+ if !on {
+ cfg, _, err := load()
+ if err != nil {
+ return "Reasonix configuration cannot be read"
+ }
+ if cfg.Agent.Planner != nil {
+ ref, on = strings.CutPrefix(*cfg.Agent.Planner, magpieID+"/")
+ }
+ if !on {
+ return ""
+ }
+ }
+ cfg, state, err := load()
+ if err != nil {
+ return "Reasonix configuration cannot be read"
+ }
+ p := cfg.magpie()
+ if state == nil || p.APIKeyEnv != reasonixKey {
+ return "Reasonix's magpie provider or restore record is missing"
+ }
+ if err := checkOwner(cfg, state); err != nil {
+ return err.Error()
+ }
+ if p.Kind != "openai" || p.BaseURL != gatewayV1() {
+ return "Reasonix's magpie base_url or kind changed"
+ }
+ if token, _ := edit.GetEnvFile(env, reasonixKey); token != gateway.TokenFor("reasonix") {
+ return "Reasonix's gateway credential changed"
+ }
+ if !slices.Contains(p.Models, ref) || cfg.Agent.Planner != nil && usesMagpie(*cfg.Agent.Planner) && !slices.Contains(p.Models, strings.TrimPrefix(*cfg.Agent.Planner, magpieID+"/")) {
+ return "Reasonix's selected model is missing from the magpie catalog"
+ }
+ if cfg.Desktop.Access != nil && !slices.Contains(cfg.Desktop.Access, magpieID) {
+ return "Reasonix Studio's provider_access excludes magpie"
+ }
+ return ""
+ },
+ Sync: func() error {
+ cfg, state, err := load()
+ if err != nil {
+ return err
+ }
+ if state == nil {
+ return nil
+ }
+ if err := checkOwner(cfg, state); err != nil {
+ return err
+ }
+ current := cfg.magpie()
+ if current.Name == "" {
+ return nil
+ }
+ models := magpieModels("reasonix")
+ if len(models) == 0 {
+ return fmt.Errorf("magpie has no visible Reasonix models")
+ }
+ next := reasonixCatalog(models, current.Default, current.Effort)
+ if reflect.DeepEqual(current, next) {
+ return nil
+ }
+ return writeProvider(next)
+ },
+ Fields: []Field{
+ {Key: "model", Label: "model", Get: model, Set: set,
+ Options: func(cur map[string]string) []Option {
+ cfg, state, _ := load()
+ var own []Option
+ for _, p := range cfg.Providers {
+ if p.Name == magpieID {
+ continue
+ }
+ models := p.Models
+ if len(models) == 0 && p.Model != "" {
+ models = []string{p.Model}
+ }
+ for _, m := range models {
+ own = append(own, Option{Value: p.Name + "/" + m, Icon: ModelIcon(m)})
+ }
+ }
+ addNative := func(v string) {
+ if v != "" && !usesMagpie(v) && !slices.ContainsFunc(own, func(o Option) bool { return o.Value == v }) {
+ own = append(own, Option{Value: v, Icon: ModelIcon(v)})
+ }
+ }
+ if state != nil && state.Default != nil {
+ addNative(*state.Default)
+ }
+ addNative(cur["model"])
+ return append(group("Reasonix Studio", own), viaMagpie("reasonix", magpieID+"/")...)
+ },
+ },
+ {Key: "effort", Label: "effort", Quiet: true,
+ Get: func() string {
+ cfg, _ := readReasonix(path)
+ if usesMagpie(model()) {
+ return cfg.magpie().Effort
+ }
+ return ""
+ },
+ Set: func(v string) error {
+ cfg, state, err := load()
+ if err != nil {
+ return err
+ }
+ if state == nil || cfg.DefaultModel == nil || !usesMagpie(*cfg.DefaultModel) {
+ if v == "" {
+ return nil
+ }
+ return fmt.Errorf("select a magpie model in Reasonix before setting effort")
+ }
+ if err := checkOwner(cfg, state); err != nil {
+ return err
+ }
+ p := cfg.magpie()
+ ref := strings.TrimPrefix(*cfg.DefaultModel, magpieID+"/")
+ if v == "auto" {
+ v = ""
+ }
+ if v != "" && !slices.Contains(p.Overrides[ref].Efforts, v) {
+ return fmt.Errorf("Reasonix model %q does not support effort %q", ref, v)
+ }
+ p.Effort = v
+ return writeProvider(p)
+ },
+ Options: func(cur map[string]string) []Option {
+ ref, ok := strings.CutPrefix(cur["model"], magpieID+"/")
+ if !ok {
+ return nil
+ }
+ cfg, _ := readReasonix(path)
+ levels := cfg.magpie().Overrides[ref].Efforts
+ if len(levels) == 0 {
+ return nil
+ }
+ return static(append([]string{"auto"}, levels...)...)
+ },
+ },
+ },
+ }
+ planner := Field{Key: "planner", Label: "planner", Get: func() string {
+ cfg, _ := readReasonix(path)
+ if cfg.Agent.Planner != nil {
+ return *cfg.Agent.Planner
+ }
+ return ""
+ }, Options: a.Fields[0].Options, Set: func(v string) error {
+ cfg, state, err := load()
+ if err != nil {
+ return err
+ }
+ off := v == "off"
+ if off {
+ v = ""
+ }
+ ref, via := strings.CutPrefix(v, magpieID+"/")
+ if via {
+ selected := ref
+ if cfg.DefaultModel != nil && usesMagpie(*cfg.DefaultModel) {
+ selected = strings.TrimPrefix(*cfg.DefaultModel, magpieID+"/")
+ }
+ if err := ensureGateway(cfg, &state, ref, selected); err != nil {
+ return err
+ }
+ if !state.PlannerManaged {
+ state.Planner = cfg.Agent.Planner
+ state.PlannerManaged = true
+ }
+ } else if v != "" {
+ known := cfg.hasModel(v) || cfg.Agent.Planner != nil && *cfg.Agent.Planner == v ||
+ cfg.DefaultModel != nil && *cfg.DefaultModel == v ||
+ state != nil && (state.Planner != nil && *state.Planner == v || state.Default != nil && *state.Default == v)
+ if !known {
+ return fmt.Errorf("Reasonix has no planner model %q", v)
+ }
+ }
+ if v == "" && !off && state != nil && state.PlannerManaged && state.Planner != nil {
+ v = *state.Planner
+ }
+ if v == "" {
+ err = edit.DelTOMLKey(path, "agent", "planner_model")
+ } else {
+ err = edit.SetTOMLKey(path, "agent", "planner_model", v)
+ }
+ if err != nil {
+ return err
+ }
+ if state != nil {
+ raw, err := json.Marshal(state)
+ if err != nil {
+ return err
+ }
+ if err = edit.WriteAtomic(statePath, raw); err != nil {
+ return err
+ }
+ }
+ if !via && !usesMagpie(model()) {
+ next, _, err := load()
+ if err != nil {
+ return err
+ }
+ return restore(next, state, "")
+ }
+ return nil
+ }}
+ options := planner.Options
+ planner.Options = func(cur map[string]string) []Option {
+ choices := options(cur)
+ cfg, state, _ := load()
+ addNative := func(ref *string) {
+ if ref != nil && *ref != "" && !usesMagpie(*ref) && !slices.ContainsFunc(choices, func(o Option) bool { return o.Value == *ref }) {
+ choices = append(choices, Option{Value: *ref, Icon: ModelIcon(*ref), Group: "Reasonix Studio"})
+ }
+ }
+ addNative(cfg.Agent.Planner)
+ if state != nil {
+ addNative(state.Planner)
+ }
+ return append([]Option{{Value: "off", Label: "off", Note: "Disable the separate planner"}}, choices...)
+ }
+ a.Fields[0].Label = "executor"
+ a.Fields = append(a.Fields, planner)
+ return serializedReasonix(atomic(a, path, env, statePath))
+}
+
+func serializedReasonix(a *Agent) *Agent {
+ for i := range a.Fields {
+ f := &a.Fields[i]
+ if get := f.Get; get != nil {
+ f.Get = func() string {
+ reasonixEdits.RLock()
+ defer reasonixEdits.RUnlock()
+ return get()
+ }
+ }
+ if set := f.Set; set != nil {
+ f.Set = func(v string) error {
+ reasonixEdits.Lock()
+ defer reasonixEdits.Unlock()
+ return set(v)
+ }
+ }
+ if options := f.Options; options != nil {
+ f.Options = func(cur map[string]string) []Option {
+ reasonixEdits.RLock()
+ defer reasonixEdits.RUnlock()
+ return options(cur)
+ }
+ }
+ }
+ if check := a.Check; check != nil {
+ a.Check = func() string {
+ reasonixEdits.RLock()
+ defer reasonixEdits.RUnlock()
+ return check()
+ }
+ }
+ if sync := a.Sync; sync != nil {
+ a.Sync = func() error {
+ reasonixEdits.Lock()
+ defer reasonixEdits.Unlock()
+ return sync()
+ }
+ }
+ return a
+}
+
+func reasonixHome(goos, home string, getenv func(string) string) string {
+ if dir := getenv("REASONIX_HOME"); dir != "" {
+ return filepath.Clean(dir)
+ }
+ if goos == "windows" && getenv("APPDATA") != "" {
+ return filepath.Join(getenv("APPDATA"), "reasonix")
+ }
+ return filepath.Join(home, ".reasonix")
+}
+
+func readReasonix(path string) (reasonixConfig, error) {
+ var cfg reasonixConfig
+ b, err := edit.Read(path)
+ if err == nil && b != nil {
+ err = toml.Unmarshal(b, &cfg)
+ }
+ if err != nil {
+ return cfg, fmt.Errorf("Reasonix config.toml: %w", err)
+ }
+ names := map[string]bool{}
+ for _, p := range cfg.Providers {
+ if strings.TrimSpace(p.Name) == magpieID && p.Name != magpieID {
+ return cfg, fmt.Errorf("Reasonix provider has a noncanonical magpie name")
+ }
+ if names[p.Name] {
+ return cfg, fmt.Errorf("Reasonix has duplicate provider %q", p.Name)
+ }
+ names[p.Name] = true
+ }
+ return cfg, nil
+}
+
+func (cfg reasonixConfig) magpie() reasonixProvider {
+ for _, p := range cfg.Providers {
+ if p.Name == magpieID {
+ return p
+ }
+ }
+ return reasonixProvider{}
+}
+
+func (cfg reasonixConfig) hasModel(ref string) bool {
+ for _, p := range cfg.Providers {
+ if p.Name == magpieID {
+ continue
+ }
+ if ref == p.Name || ref == p.Model || slices.Contains(p.Models, ref) {
+ return true
+ }
+ if name, model, ok := strings.Cut(ref, "/"); ok && name == p.Name && (model == p.Model || slices.Contains(p.Models, model)) {
+ return true
+ }
+ }
+ return false
+}
+
+func reasonixCatalog(models []catalog.Model, selected, effort string) reasonixProvider {
+ p := reasonixProvider{Name: magpieID, Kind: "openai", BaseURL: gatewayV1(), APIKeyEnv: reasonixKey,
+ Default: selected, Protocol: "openai", NoProxy: true, Overrides: map[string]reasonixModel{}}
+ for _, m := range models {
+ p.Models = append(p.Models, m.ID)
+ ov := reasonixModel{Vision: m.ImageInput, Context: m.Context, Output: maxTokens(m)}
+ if ov.Vision == nil && m.Images {
+ vision := true
+ ov.Vision = &vision
+ }
+ for _, e := range m.Efforts {
+ if e == "off" {
+ e = "none"
+ }
+ if slices.Contains([]string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, e) && !slices.Contains(ov.Efforts, e) {
+ ov.Efforts = append(ov.Efforts, e)
+ }
+ }
+ if len(ov.Efforts) == 0 {
+ ov.Protocol = "none"
+ }
+ p.Overrides[m.ID] = ov
+ }
+ if !slices.Contains(p.Models, selected) {
+ p.Default = firstOf(p.Models)
+ }
+ if slices.Contains(p.Overrides[p.Default].Efforts, effort) {
+ p.Effort = effort
+ }
+ return p
+}
+
+func reasonixArray(values []string) edit.Raw {
+ quoted := make([]string, len(values))
+ for i, v := range values {
+ quoted[i] = strconv.Quote(v)
+ }
+ return edit.Raw("[" + strings.Join(quoted, ", ") + "]")
+}
diff --git a/internal/agent/reasonix_detect.go b/internal/agent/reasonix_detect.go
new file mode 100644
index 00000000..347cd3cf
--- /dev/null
+++ b/internal/agent/reasonix_detect.go
@@ -0,0 +1,87 @@
+package agent
+
+import (
+ "context"
+ "debug/buildinfo"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/yetone/magpie/internal/proc"
+)
+
+func reasonixDetected(home string) bool {
+ switch runtime.GOOS {
+ case "windows":
+ for _, dir := range reasonixStudioDirs() {
+ if (isFile(filepath.Join(dir, "Reasonix Studio.exe")) || isFile(filepath.Join(dir, "reasonix-studio.exe"))) &&
+ reasonixStudioPackage(filepath.Join(dir, "resources", "app.asar")) {
+ return true
+ }
+ }
+ case "darwin":
+ for _, dir := range []string{"/Applications", filepath.Join(home, "Applications")} {
+ if reasonixStudioPackage(filepath.Join(dir, "Reasonix Studio.app", "Contents", "Resources", "app.asar")) {
+ return true
+ }
+ }
+ case "linux":
+ for _, bin := range []string{"reasonix-studio", "reasonix-studio-electron"} {
+ if path, err := exec.LookPath(bin); err == nil {
+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
+ path = resolved
+ }
+ if reasonixStudioPackage(filepath.Join(filepath.Dir(path), "resources", "app.asar")) {
+ return true
+ }
+ }
+ }
+ }
+ path, err := exec.LookPath("reasonix")
+ return err == nil && reasonixCLI(path)
+}
+
+var reasonixVersions = struct {
+ sync.Mutex
+ m map[goProgramKey]bool
+}{m: map[goProgramKey]bool{}}
+
+// Config alone cannot distinguish the native CLI from the historical npm 1.x
+// client. Probe only a native Reasonix CLI, once per binary revision, without
+// starting either its agent loop or the desktop host.
+func reasonixCLI(path string) bool {
+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
+ path = resolved
+ }
+ st, err := os.Stat(path)
+ if err != nil || !st.Mode().IsRegular() {
+ return false
+ }
+ key := goProgramKey{path, st.Size(), st.ModTime()}
+ reasonixVersions.Lock()
+ defer reasonixVersions.Unlock()
+ if v, ok := reasonixVersions.m[key]; ok {
+ return v
+ }
+ info, err := buildinfo.ReadFile(path)
+ if err != nil {
+ return false
+ }
+ detected := false
+ if info.Path == "reasonix/cmd/reasonix" {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ b, err := proc.CommandContext(ctx, path, "--version").Output()
+ if err != nil {
+ return false // retry a failed probe; only a confirmed version is cached
+ }
+ version, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "reasonix v")
+ detected = ok && (strings.HasPrefix(version, "2.") || strings.HasPrefix(version, "1.39."))
+ }
+ reasonixVersions.m[key] = detected
+ return detected
+}
diff --git a/internal/agent/reasonix_detect_other.go b/internal/agent/reasonix_detect_other.go
new file mode 100644
index 00000000..13fc6201
--- /dev/null
+++ b/internal/agent/reasonix_detect_other.go
@@ -0,0 +1,5 @@
+//go:build !windows
+
+package agent
+
+func reasonixStudioDirs() []string { return nil }
diff --git a/internal/agent/reasonix_detect_windows.go b/internal/agent/reasonix_detect_windows.go
new file mode 100644
index 00000000..fc7d023e
--- /dev/null
+++ b/internal/agent/reasonix_detect_windows.go
@@ -0,0 +1,54 @@
+//go:build windows
+
+package agent
+
+import (
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/sys/windows/registry"
+)
+
+func reasonixStudioDirs() []string {
+ var dirs []string
+ for _, hive := range []registry.Key{registry.CURRENT_USER, registry.LOCAL_MACHINE} {
+ for _, root := range []string{`Software\Microsoft\Windows\CurrentVersion\Uninstall`, `Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`} {
+ k, err := registry.OpenKey(hive, root, registry.READ)
+ if err != nil {
+ continue
+ }
+ names, _ := k.ReadSubKeyNames(-1)
+ for _, name := range names {
+ entry, err := registry.OpenKey(k, name, registry.QUERY_VALUE)
+ if err != nil {
+ continue
+ }
+ display, _, _ := entry.GetStringValue("DisplayName")
+ version, _, _ := entry.GetStringValue("DisplayVersion")
+ if reasonixStudioRelease(display, version) {
+ dir, _, _ := entry.GetStringValue("InstallLocation")
+ if dir == "" {
+ icon, _, _ := entry.GetStringValue("DisplayIcon")
+ if at := strings.LastIndex(icon, ","); at >= 0 {
+ icon = icon[:at]
+ }
+ if icon != "" {
+ dir = filepath.Dir(strings.Trim(icon, `"`))
+ }
+ }
+ if filepath.IsAbs(dir) {
+ dirs = append(dirs, dir)
+ }
+ }
+ entry.Close()
+ }
+ k.Close()
+ }
+ }
+ return dirs
+}
+
+func reasonixStudioRelease(display, version string) bool {
+ name := display == "Reasonix Studio" || strings.HasPrefix(display, "Reasonix Studio ")
+ return name && strings.HasPrefix(strings.TrimPrefix(strings.TrimSpace(version), "v"), "2.")
+}
diff --git a/internal/agent/reasonix_integration_test.go b/internal/agent/reasonix_integration_test.go
new file mode 100644
index 00000000..c39517e2
--- /dev/null
+++ b/internal/agent/reasonix_integration_test.go
@@ -0,0 +1,135 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/yetone/magpie/internal/gateway"
+ "github.com/yetone/magpie/internal/proc"
+ "github.com/yetone/magpie/internal/provider"
+)
+
+// An opt-in contract check against the released client. Everything it reads and
+// writes is isolated; the upstream is a fixture, with no vendor credentials.
+func TestReasonixStudioCLIIntegration(t *testing.T) {
+ bin := os.Getenv("MAGPIE_TEST_REASONIX_CLI")
+ if bin == "" {
+ t.Skip("set MAGPIE_TEST_REASONIX_CLI to a Studio 2.x native CLI")
+ }
+ if !reasonixCLI(bin) {
+ t.Fatal("integration client must be a native Reasonix 2.x CLI")
+ }
+ a, _ := reasonixFixture(t, strings.Split(reasonixNativeConfig, "[plugins]")[0])
+ t.Setenv(reasonixKey, "unused-shell-credential")
+ t.Setenv("REASONIX_STATE_HOME", filepath.Join(t.TempDir(), "state"))
+ work := t.TempDir()
+ const sentinel = "MAGPIE_REASONIX_TOOL_RESULT"
+ // low is also the SDK's fallback for this declared list, so it cannot
+ // prove that the model-specific setting survived SDK override resolution.
+ const expectedEffort = "xhigh"
+ if err := os.WriteFile(filepath.Join(work, "sentinel.txt"), []byte(sentinel), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var mu sync.Mutex
+ var requests int
+ var toolResult bool
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+ Effort string `json:"reasoning_effort"`
+ Messages []struct {
+ Role string `json:"role"`
+ Content json.RawMessage `json:"content"`
+ } `json:"messages"`
+ Tools []struct {
+ Function struct{ Name string } `json:"function"`
+ } `json:"tools"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ t.Errorf("decode client request: %v", err)
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ requests++
+ if req.Model != "pro" || !req.Stream || req.Effort != expectedEffort || r.Header.Get("Authorization") != "Bearer upstream-secret" {
+ t.Errorf("wire contract: model=%q stream=%v effort=%q", req.Model, req.Stream, req.Effort)
+ }
+ for _, msg := range req.Messages {
+ if msg.Role == "tool" && strings.Contains(string(msg.Content), sentinel) {
+ toolResult = true
+ }
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ send := func(delta any, finish any) {
+ chunk := map[string]any{"id": "reasonix-contract", "object": "chat.completion.chunk", "model": "pro", "choices": []any{map[string]any{"index": 0, "delta": delta, "finish_reason": finish}}}
+ b, _ := json.Marshal(chunk)
+ fmt.Fprintf(w, "data: %s\n\n", b)
+ w.(http.Flusher).Flush()
+ }
+ if toolResult {
+ send(map[string]any{"content": "REASONIX_"}, nil)
+ send(map[string]any{"content": "INTEGRATION_OK"}, nil)
+ send(map[string]any{}, "stop")
+ } else {
+ found := false
+ for _, tool := range req.Tools {
+ found = found || tool.Function.Name == "read_file"
+ }
+ if !found {
+ t.Error("client did not expose read_file")
+ }
+ send(map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": "read_sentinel", "type": "function", "function": map[string]any{"name": "read_file", "arguments": `{"path":`}}}}, nil)
+ send(map[string]any{"tool_calls": []any{map[string]any{"index": 0, "function": map[string]any{"arguments": `"sentinel.txt"}`}}}}, nil)
+ send(map[string]any{}, "tool_calls")
+ }
+ fmt.Fprint(w, "data: [DONE]\n\n")
+ }))
+ defer upstream.Close()
+ if err := provider.Save(provider.Provider{ID: "a", Chat: upstream.URL + "/v1", Key: "upstream-secret", Models: []string{"pro", "flash"}}); err != nil {
+ t.Fatal(err)
+ }
+ s := gateway.New()
+ gw := httptest.NewServer(s.Handler())
+ defer gw.Close()
+ t.Setenv("MAGPIE_ADDR", strings.TrimPrefix(gw.URL, "http://"))
+ if err := a.Field("model").Set("magpie/group/code"); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Field("effort").Set(expectedEffort); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
+ cmd := proc.CommandContext(ctx, bin, "run", "--print", "--preset", "light", "--max-steps", "4", "--permission-mode", "read-only", "--dir", work, "Read sentinel.txt using read_file, then report the verification result.")
+ cmd.Dir = work
+ cmd.Env = os.Environ()
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("released CLI: %v\n%s", err, out)
+ }
+ if !strings.Contains(string(out), "REASONIX_INTEGRATION_OK") {
+ t.Fatalf("streamed reply was not assembled: %s", out)
+ }
+ mu.Lock()
+ if requests < 2 || !toolResult {
+ t.Errorf("stream/tool round trip: requests=%d result=%v", requests, toolResult)
+ }
+ mu.Unlock()
+ for _, call := range s.Recent() {
+ if call.Agent != "reasonix" || call.Provider != "a" || call.Status != http.StatusOK {
+ t.Errorf("gateway attribution: agent=%q provider=%q status=%d error=%q", call.Agent, call.Provider, call.Status, call.Error)
+ }
+ }
+}
diff --git a/internal/agent/reasonix_planner_native_test.go b/internal/agent/reasonix_planner_native_test.go
new file mode 100644
index 00000000..8b982657
--- /dev/null
+++ b/internal/agent/reasonix_planner_native_test.go
@@ -0,0 +1,43 @@
+package agent
+
+import (
+ "slices"
+ "testing"
+)
+
+func TestReasonixPlannerNativeSelections(t *testing.T) {
+ const original = "default_model = \"deepseek-flash\"\n[agent]\nplanner_model = \"deepseek-pro\"\n"
+ t.Run("current", func(t *testing.T) {
+ a, _ := reasonixFixture(t, original)
+ if err := a.Field("planner").Set("deepseek-pro"); err != nil {
+ t.Fatalf("the current native planner cannot be reselected: %v", err)
+ }
+ })
+ t.Run("executor option", func(t *testing.T) {
+ a, _ := reasonixFixture(t, original)
+ f := a.Field("planner")
+ if !slices.ContainsFunc(f.Options(a.Values()), func(o Option) bool { return o.Value == "deepseek-flash" }) {
+ t.Fatal("the native executor is missing from the shared model options")
+ }
+ if err := f.Set("deepseek-flash"); err != nil {
+ t.Fatalf("the planner refuses an offered native model: %v", err)
+ }
+ })
+ t.Run("return", func(t *testing.T) {
+ a, _ := reasonixFixture(t, original)
+ f := a.Field("planner")
+ if err := f.Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ if !slices.ContainsFunc(f.Options(a.Values()), func(o Option) bool { return o.Value == "deepseek-pro" }) {
+ t.Fatal("the original native planner disappears from its model options")
+ }
+ if err := f.Set("deepseek-pro"); err != nil {
+ t.Fatalf("the original native planner cannot be reselected: %v", err)
+ }
+ cfg, err := readReasonix(a.Path)
+ if err != nil || cfg.Agent.Planner == nil || *cfg.Agent.Planner != "deepseek-pro" || cfg.magpie().Name != "" {
+ t.Fatalf("native planner restoration left a managed provider: %+v %v", cfg, err)
+ }
+ })
+}
diff --git a/internal/agent/reasonix_roles_test.go b/internal/agent/reasonix_roles_test.go
new file mode 100644
index 00000000..0e97d8e7
--- /dev/null
+++ b/internal/agent/reasonix_roles_test.go
@@ -0,0 +1,140 @@
+package agent
+
+import (
+ "github.com/yetone/magpie/internal/edit"
+ "os"
+ "testing"
+)
+
+func TestReasonixIndependentRolesAndRestore(t *testing.T) {
+ a, env := reasonixFixture(t, reasonixNativeConfig+"\n[agent]\nplanner_model = \"native/other\"\n")
+ must := func(err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ must(a.Field("planner").Set("magpie/a/pro"))
+ if got := a.Field("model").Get(); got != "native/old" {
+ t.Fatalf("Plan changed Executor: %s", got)
+ }
+ must(a.Field("model").Set("magpie/a/flash"))
+ if got := a.Field("planner").Get(); got != "magpie/a/pro" {
+ t.Fatalf("Executor changed Plan: %s", got)
+ }
+ must(a.Field("model").Set("native/other"))
+ cfg, err := readReasonix(a.Path)
+ must(err)
+ if cfg.magpie().Name == "" || a.Check() != "" {
+ t.Fatal("Executor switch removed active Plan connection")
+ }
+ must(a.Field("planner").Set(""))
+ must(a.Field("model").Set(""))
+ cfg, err = readReasonix(a.Path)
+ must(err)
+ if cfg.magpie().Name != "" || *cfg.DefaultModel != "native/other" || cfg.Agent.Planner == nil || *cfg.Agent.Planner != "native/other" {
+ t.Fatalf("incorrect restore: %+v", cfg)
+ }
+ if _, exists := editEnvForReasonixTest(env); exists {
+ t.Fatal("unused gateway credential survived")
+ }
+}
+
+func editEnvForReasonixTest(path string) (string, bool) { return edit.GetEnvFile(path, reasonixKey) }
+
+func TestReasonixPlanOffKeepsExecutorAndSyncDoesNotWrite(t *testing.T) {
+ a, _ := reasonixFixture(t, reasonixNativeConfig)
+ for _, pair := range [][2]string{{"model", "magpie/a/pro"}, {"planner", "magpie/a/flash"}, {"planner", "off"}} {
+ if err := a.Field(pair[0]).Set(pair[1]); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if a.Field("planner").Get() != "" || a.Field("model").Get() != "magpie/a/pro" || a.Check() != "" {
+ t.Fatal("Plan off changed Executor connection")
+ }
+ before, _ := os.ReadFile(a.Path)
+ st, _ := os.Stat(a.Path)
+ for i := 0; i < 10; i++ {
+ if err := a.Sync(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ after, _ := os.ReadFile(a.Path)
+ next, _ := os.Stat(a.Path)
+ if string(before) != string(after) || !st.ModTime().Equal(next.ModTime()) {
+ t.Fatal("unchanged Sync rewrote config")
+ }
+ if err := a.Field("model").Set(""); err != nil {
+ t.Fatal(err)
+ }
+ if a.Field("model").Get() != "native/old" {
+ t.Fatal("Executor restore failed")
+ }
+}
+
+func TestReasonixPlannerConflictRollsBack(t *testing.T) {
+ a, env := reasonixFixture(t, reasonixNativeConfig)
+ if err := a.Field("model").Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := os.ReadFile(a.Path)
+ if err := os.WriteFile(env, []byte(reasonixKey+"=user-change\n"+reasonixKey+"=duplicate\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Field("planner").Set("magpie/a/flash"); err == nil {
+ t.Fatal("credential conflict accepted")
+ }
+ after, _ := os.ReadFile(a.Path)
+ if string(before) != string(after) {
+ t.Fatal("failed Plan selection changed config")
+ }
+}
+
+func BenchmarkReasonixRolesSyncUnchanged(b *testing.B) {
+ a, _ := reasonixFixture(b, reasonixNativeConfig)
+ if err := a.Field("model").Set("magpie/a/pro"); err != nil {
+ b.Fatal(err)
+ }
+ if err := a.Field("planner").Set("magpie/a/flash"); err != nil {
+ b.Fatal(err)
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ if err := a.Sync(); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func TestReasonixPlanDoesNotResetExecutorEffort(t *testing.T) {
+ a, _ := reasonixFixture(t, reasonixNativeConfig)
+ if err := a.Field("model").Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Field("effort").Set("high"); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Field("planner").Set("magpie/a/flash"); err != nil {
+ t.Fatal(err)
+ }
+ if got := a.Field("effort").Get(); got != "high" {
+ t.Fatalf("Plan changed Executor effort: %s", got)
+ }
+}
+
+func TestReasonixRestoreWithInitiallyAbsentEnv(t *testing.T) {
+ a, env := reasonixFixture(t, reasonixNativeConfig)
+ if err := os.Remove(env); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Field("planner").Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Field("planner").Set(""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(env); !os.IsNotExist(err) {
+ t.Fatal("adapter-created empty env was not removed")
+ }
+}
diff --git a/internal/agent/reasonix_studio.go b/internal/agent/reasonix_studio.go
new file mode 100644
index 00000000..5a0f1cf6
--- /dev/null
+++ b/internal/agent/reasonix_studio.go
@@ -0,0 +1,79 @@
+package agent
+
+import (
+ "encoding/binary"
+ "encoding/json"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+var reasonixStudioVersions = struct {
+ sync.Mutex
+ m map[goProgramKey]bool
+}{m: map[goProgramKey]bool{}}
+
+// The app's package, rather than its filename, distinguishes Electron Studio
+// 2.x from the earlier desktop clients. Read only package.json from the archive;
+// never launch the app to ask its version or read the whole bundle into memory.
+func reasonixStudioPackage(path string) bool {
+ st, err := os.Stat(path)
+ if err != nil || !st.Mode().IsRegular() {
+ return false
+ }
+ key := goProgramKey{path, st.Size(), st.ModTime()}
+ reasonixStudioVersions.Lock()
+ defer reasonixStudioVersions.Unlock()
+ if detected, ok := reasonixStudioVersions.m[key]; ok {
+ return detected
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return false // a temporary file lock must not hide an installed client forever
+ }
+ defer f.Close()
+ detected := reasonixStudioArchiveVersion(f, st.Size())
+ reasonixStudioVersions.m[key] = detected
+ return detected
+}
+
+func reasonixStudioArchiveVersion(r io.ReaderAt, size int64) bool {
+ var prefix [16]byte
+ if _, err := r.ReadAt(prefix[:], 0); err != nil || binary.LittleEndian.Uint32(prefix[:4]) != 4 {
+ return false
+ }
+ headerSize := int64(binary.LittleEndian.Uint32(prefix[4:8]))
+ jsonSize := int64(binary.LittleEndian.Uint32(prefix[12:16]))
+ if headerSize < 8 || headerSize > 4<<20 || headerSize > size-8 || jsonSize < 2 || jsonSize > headerSize-8 {
+ return false
+ }
+ header := make([]byte, jsonSize)
+ if _, err := r.ReadAt(header, 16); err != nil {
+ return false
+ }
+ var archive struct {
+ Files map[string]struct {
+ Size int64 `json:"size"`
+ Offset string `json:"offset"`
+ Unpacked bool `json:"unpacked"`
+ } `json:"files"`
+ }
+ if json.Unmarshal(header, &archive) != nil {
+ return false
+ }
+ entry, ok := archive.Files["package.json"]
+ offset, err := strconv.ParseInt(entry.Offset, 10, 64)
+ dataSize := size - 8 - headerSize
+ if !ok || err != nil || entry.Unpacked || entry.Size <= 0 || entry.Size > 64<<10 || offset < 0 || offset > dataSize || entry.Size > dataSize-offset {
+ return false
+ }
+ metadata := make([]byte, entry.Size)
+ if _, err := r.ReadAt(metadata, 8+headerSize+offset); err != nil {
+ return false
+ }
+ var pkg struct{ Name, Version string }
+ return json.Unmarshal(metadata, &pkg) == nil && pkg.Name == "reasonix-studio-electron" &&
+ strings.HasPrefix(strings.TrimPrefix(strings.TrimSpace(pkg.Version), "v"), "2.")
+}
diff --git a/internal/agent/reasonix_studio_test.go b/internal/agent/reasonix_studio_test.go
new file mode 100644
index 00000000..be80cf3f
--- /dev/null
+++ b/internal/agent/reasonix_studio_test.go
@@ -0,0 +1,80 @@
+package agent
+
+import (
+ "bytes"
+ "encoding/binary"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// The official Electron archive stores package metadata after a Pickle header.
+func reasonixStudioArchive(t testing.TB, name, version string) []byte {
+ t.Helper()
+ metadata, err := json.Marshal(map[string]string{"name": name, "version": version})
+ if err != nil {
+ t.Fatal(err)
+ }
+ header, err := json.Marshal(map[string]any{"files": map[string]any{
+ "package.json": map[string]any{"size": len(metadata), "offset": "0"},
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ headerSize := (8 + len(header) + 3) &^ 3
+ archive := make([]byte, 8+headerSize)
+ binary.LittleEndian.PutUint32(archive[0:4], 4)
+ binary.LittleEndian.PutUint32(archive[4:8], uint32(headerSize))
+ binary.LittleEndian.PutUint32(archive[8:12], uint32(headerSize-4))
+ binary.LittleEndian.PutUint32(archive[12:16], uint32(len(header)))
+ copy(archive[16:], header)
+ return append(archive, metadata...)
+}
+
+func TestReasonixStudioPackageRequiresVersionTwo(t *testing.T) {
+ for _, tc := range []struct {
+ name, version string
+ want bool
+ }{
+ {"reasonix-studio-electron", "2.24.0", true},
+ {"reasonix-studio-electron", "2.25.0-beta.1", true},
+ {"reasonix-studio-electron", "1.21.3", false},
+ {"reasonix-studio-electron", "0.0.0", false},
+ {"some-other-app", "2.24.0", false},
+ } {
+ t.Run(tc.name+"/"+tc.version, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "app.asar")
+ if err := os.WriteFile(path, reasonixStudioArchive(t, tc.name, tc.version), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if got := reasonixStudioPackage(path); got != tc.want {
+ t.Fatalf("Studio package detection = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestReasonixStudioPackageRejectsMalformedArchive(t *testing.T) {
+ for _, data := range [][]byte{nil, []byte("not an archive"), make([]byte, 16),
+ {4, 0, 0, 0, 255, 255, 255, 255},
+ reasonixStudioArchive(t, "reasonix-studio-electron", "2.24.0")[:20],
+ } {
+ path := filepath.Join(t.TempDir(), "app.asar")
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if reasonixStudioPackage(path) {
+ t.Fatal("malformed Electron archive counted as Studio")
+ }
+ }
+}
+
+func FuzzReasonixStudioArchive(f *testing.F) {
+ f.Add(reasonixStudioArchive(f, "reasonix-studio-electron", "2.24.0"))
+ f.Add([]byte{4, 0, 0, 0, 255, 255, 255, 255})
+ f.Add([]byte{})
+ f.Fuzz(func(t *testing.T, data []byte) {
+ reasonixStudioArchiveVersion(bytes.NewReader(data), int64(len(data)))
+ })
+}
diff --git a/internal/agent/reasonix_test.go b/internal/agent/reasonix_test.go
new file mode 100644
index 00000000..0fd6b3c3
--- /dev/null
+++ b/internal/agent/reasonix_test.go
@@ -0,0 +1,585 @@
+package agent
+
+import (
+ "context"
+ "debug/buildinfo"
+ "os"
+ "path/filepath"
+ "reflect"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/pelletier/go-toml/v2"
+ "github.com/yetone/magpie/internal/catalog"
+ "github.com/yetone/magpie/internal/edit"
+ "github.com/yetone/magpie/internal/gateway"
+ "github.com/yetone/magpie/internal/proc"
+ "github.com/yetone/magpie/internal/provider"
+ "github.com/yetone/magpie/internal/usage"
+)
+
+func TestReasonixRegistered(t *testing.T) {
+ t.Setenv("REASONIX_HOME", t.TempDir())
+ a, err := Find("reasonix")
+ if err != nil {
+ t.Fatalf("Reasonix Studio is missing from the agent registry: %v", err)
+ }
+ if a.ID != "reasonix" || a.Name != "Reasonix Studio" || a.Field("model") == nil {
+ t.Fatalf("incomplete Reasonix Studio agent: %+v", a)
+ }
+}
+
+func TestReasonixCredentialFileTakesPrecedence(t *testing.T) {
+ a, _ := reasonixFixture(t, reasonixNativeConfig)
+ t.Setenv(reasonixKey, "unused-shell-credential")
+ if err := a.Field("model").Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ if got := a.Check(); got != "" {
+ t.Fatalf("Studio 2.x resolves provider credentials from its global .env: %s", got)
+ }
+}
+
+func TestReasonixDuplicatePrivateCredentialIsAConflict(t *testing.T) {
+ a, env := reasonixFixture(t, reasonixNativeConfig)
+ if err := a.Field("model").Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := os.ReadFile(a.Path)
+ b, _ := os.ReadFile(env)
+ b = append(b, []byte("\nexport "+reasonixKey+" = later-user-credential\n")...)
+ if err := os.WriteFile(env, b, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if a.Check() == "" {
+ t.Fatal("multiple private credentials were reported as owned")
+ }
+ if err := a.Field("model").Set(""); err == nil {
+ t.Fatal("restore removed a later credential sharing the private key")
+ }
+ after, _ := os.ReadFile(a.Path)
+ afterEnv, _ := os.ReadFile(env)
+ if string(before) != string(after) || string(b) != string(afterEnv) {
+ t.Fatal("ambiguous restore changed configuration")
+ }
+}
+
+func TestReasonixCapabilities(t *testing.T) {
+ yes, no := true, false
+ p := reasonixCatalog([]catalog.Model{
+ {ID: "a/vision", Context: 128000, Output: 384000, ImageInput: &yes, Efforts: []string{"off", "none", "minimal", "high", "xhigh", "unknown"}},
+ {ID: "a/text", Context: 32768, Output: 8192, ImageInput: &no},
+ }, "a/vision", "xhigh")
+ vision, text := p.Overrides["a/vision"], p.Overrides["a/text"]
+ if vision.Context != 128000 || vision.Output != 128000 || vision.Vision == nil || !*vision.Vision {
+ t.Fatalf("vision model limits: %+v", vision)
+ }
+ if !reflect.DeepEqual(vision.Efforts, []string{"none", "minimal", "high", "xhigh"}) || p.Effort != "xhigh" {
+ t.Fatalf("explicit reasoning capabilities: %+v", vision)
+ }
+ if text.Protocol != "none" || len(text.Efforts) != 0 || text.Vision == nil || *text.Vision {
+ t.Fatalf("text model gained unadvertised capabilities: %+v", text)
+ }
+}
+
+func TestReasonixRestoresAbsentAndEmptyDefault(t *testing.T) {
+ for _, root := range []string{"", "default_model = \"\"\n"} {
+ t.Run(root, func(t *testing.T) {
+ a, _ := reasonixFixture(t, root+"[desktop]\nprovider_access = []\n")
+ if err := a.Field("model").Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Field("model").Set(""); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := readReasonix(a.Path)
+ if err != nil || (cfg.DefaultModel == nil) != (root == "") || cfg.DefaultModel != nil && *cfg.DefaultModel != "" {
+ t.Fatalf("default presence changed: %+v %v", cfg, err)
+ }
+ if cfg.Desktop.Access == nil || len(cfg.Desktop.Access) != 0 {
+ t.Fatalf("intentional empty access list changed: %v", cfg.Desktop.Access)
+ }
+ })
+ }
+}
+
+func TestReasonixRejectsCorruptRestoreRecord(t *testing.T) {
+ for _, record := range []string{"", "null", "{}", "{\"version\":999}", "{\"version\":1}", "{\"version\":1,\"default_model\":null}"} {
+ t.Run(record, func(t *testing.T) {
+ a, env := reasonixFixture(t, reasonixNativeConfig)
+ if err := a.Field("model").Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ files, _ := filepath.Glob(filepath.Join(filepath.Dir(provider.Path()), "reasonix-*.json"))
+ if len(files) != 1 {
+ t.Fatalf("restore records: %v", files)
+ }
+ os.WriteFile(files[0], []byte(record), 0o600)
+ before, _ := os.ReadFile(a.Path)
+ beforeEnv, _ := os.ReadFile(env)
+ if err := a.Field("model").Set(""); err == nil {
+ t.Fatal("corrupt journal discarded the original selection")
+ }
+ after, _ := os.ReadFile(a.Path)
+ afterEnv, _ := os.ReadFile(env)
+ if string(before) != string(after) || string(beforeEnv) != string(afterEnv) {
+ t.Fatal("failed restore changed configuration")
+ }
+ })
+ }
+}
+
+func TestReasonixRestoreKeepsLaterUserEdits(t *testing.T) {
+ a, env := reasonixFixture(t, reasonixNativeConfig)
+ if err := a.Field("model").Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ edit.SetTOMLTop(a.Path, edit.KV{Path: "default_model", Value: "native/other"})
+ edit.SetTOMLKey(a.Path, "ui", "theme", "light")
+ edit.SetTOMLKey(a.Path, "desktop", "provider_access", edit.Raw("[\"native\", \"magpie\", \"later\"]"))
+ edit.SetEnvFile(env, edit.KV{Path: "LATER_API_KEY", Value: "later-secret"})
+ if err := a.Field("model").Set(""); err != nil {
+ t.Fatal(err)
+ }
+ cfg := reasonixDecode(t, a.Path)
+ if cfg["default_model"] != "native/other" || cfg["ui"].(map[string]any)["theme"] != "light" {
+ t.Fatal("restore overwrote later preferences")
+ }
+ if got := cfg["desktop"].(map[string]any)["provider_access"]; !reflect.DeepEqual(got, []any{"native", "later"}) {
+ t.Fatal(got)
+ }
+ if value, _ := edit.GetEnvFile(env, "LATER_API_KEY"); value != "later-secret" {
+ t.Fatal("removed another credential")
+ }
+}
+
+func TestReasonixCLIRequiresSupportedNativeVersion(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "cmd", "reasonix"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module reasonix\n\ngo 1.26\n"), 0o600)
+ bin := filepath.Join(dir, "reasonix.exe")
+ for _, tc := range []struct {
+ version string
+ want bool
+ }{{"1.18.0", false}, {"1.39.4", true}, {"2.24.0", true}} {
+ source := "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"reasonix v" + tc.version + "\") }\n"
+ os.WriteFile(filepath.Join(dir, "cmd", "reasonix", "main.go"), []byte(source), 0o600)
+ cmd := proc.Command("go", "build", "-o", bin, "./cmd/reasonix")
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GOWORK=off")
+ if b, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("build fixture: %v %s", err, b)
+ }
+ warmReasonixCLIFixture(t, bin)
+ if got := reasonixCLI(bin); got != tc.want {
+ info, infoErr := buildinfo.ReadFile(bin)
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ out, probeErr := proc.CommandContext(ctx, bin, "--version").CombinedOutput()
+ t.Fatalf("CLI %s detected = %v; metadata=%v (%v), probe=%q (%v)", tc.version, got, info, infoErr, out, probeErr)
+ }
+ }
+ script := filepath.Join(dir, "reasonix.js")
+ os.WriteFile(script, []byte("throw new Error('do not execute npm wrapper')"), 0o600)
+ if reasonixCLI(script) {
+ t.Fatal("npm wrapper counted as Studio 2.x")
+ }
+}
+
+func TestReasonixCLIRecoversAfterProbeTimeout(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "cmd", "reasonix"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("REASONIX_TEST_PROBE_MARKER", filepath.Join(dir, "probed"))
+ for path, source := range map[string]string{
+ "go.mod": "module reasonix\n\ngo 1.26\n",
+ "cmd/reasonix/main.go": `package main
+import ("fmt"; "os"; "time")
+func main() {
+ if len(os.Args) > 1 && os.Args[1] == "--warmup" { return }
+ marker := os.Getenv("REASONIX_TEST_PROBE_MARKER")
+ if _, err := os.Stat(marker); os.IsNotExist(err) {
+ os.WriteFile(marker, nil, 0600)
+ time.Sleep(3 * time.Second)
+ }
+ fmt.Println("reasonix v2.24.0")
+}
+`,
+ } {
+ if err := os.WriteFile(filepath.Join(dir, path), []byte(source), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ bin := filepath.Join(dir, "reasonix.exe")
+ cmd := proc.Command("go", "build", "-o", bin, "./cmd/reasonix")
+ cmd.Dir, cmd.Env = dir, append(os.Environ(), "GOWORK=off")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("build native probe: %v %s", err, out)
+ }
+ warmReasonixCLIFixture(t, bin)
+ if reasonixCLI(bin) {
+ t.Fatal("the initial slow probe should time out")
+ }
+ if _, err := os.Stat(os.Getenv("REASONIX_TEST_PROBE_MARKER")); err != nil {
+ t.Fatalf("the timed-out probe never reached the intentional delay: %v", err)
+ }
+ if !reasonixCLI(bin) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ out, err := proc.CommandContext(ctx, bin, "--version").CombinedOutput()
+ t.Fatalf("a transient timeout permanently hid the installed 2.x CLI; direct probe=%q (%v)", out, err)
+ }
+}
+
+// Separate a temporary executable's first launch from the version and timeout
+// assertions. Windows may delay that first launch beyond the probe budget.
+func warmReasonixCLIFixture(t *testing.T, bin string) {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if out, err := proc.CommandContext(ctx, bin, "--warmup").CombinedOutput(); err != nil {
+ t.Fatalf("start native CLI fixture: %v %s", err, out)
+ }
+}
+
+func reasonixFixture(t testing.TB, config string) (*Agent, string) {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("USERPROFILE", home)
+ t.Setenv("APPDATA", filepath.Join(home, "appdata"))
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
+ t.Setenv("REASONIX_HOME", filepath.Join(home, "reasonix"))
+ if err := provider.Save(provider.Provider{ID: "a", Name: "A", Chat: "http://127.0.0.1:1/v1", Key: "upstream-secret", Models: []string{"pro", "flash"}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := provider.SetModelEfforts("a/pro", []string{"none", "low", "high", "xhigh", "max"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := provider.SaveGroup(provider.Group{Name: "code", Members: []string{"a/pro"}, Levels: []string{"low", "high", "xhigh"}}); err != nil {
+ t.Fatal(err)
+ }
+ a, err := Find("reasonix")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(a.Dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if config != "" {
+ if err := os.WriteFile(a.Path, []byte(config), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ env := filepath.Join(a.Dir, ".env")
+ if err := os.WriteFile(env, []byte("# original key\nNATIVE_API_KEY=native-secret\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return a, env
+}
+
+func reasonixDecode(t *testing.T, path string) map[string]any {
+ t.Helper()
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out map[string]any
+ if err := toml.Unmarshal(b, &out); err != nil {
+ t.Fatalf("%v\n%s", err, b)
+ }
+ return out
+}
+
+const reasonixNativeConfig = `# configuration I wrote
+default_model = "native/old" # keep this comment
+[desktop]
+provider_access = ["native"]
+[ui]
+theme = 'dark'
+[[providers]]
+name = "native"
+kind = "openai"
+base_url = "https://native.example/v1"
+api_key_env = "NATIVE_API_KEY"
+models = ["old", "other"]
+[providers.headers]
+custom = "keep"
+[plugins]
+enabled = ["docs"]
+`
+
+func TestReasonixGatewayAndRestore(t *testing.T) {
+ a, env := reasonixFixture(t, reasonixNativeConfig)
+ original := reasonixDecode(t, a.Path)
+ f := a.Field("model")
+ if f.Get() != "native/old" {
+ t.Fatalf("native selection: %q", f.Get())
+ }
+ var opts []string
+ for _, o := range f.Options(a.Values()) {
+ opts = append(opts, o.Value)
+ }
+ for _, want := range []string{"native/other", "magpie/a/pro", "magpie/group/code"} {
+ if !slices.Contains(opts, want) {
+ t.Fatalf("missing option %s: %v", want, opts)
+ }
+ }
+ if err := f.Set("magpie/group/code"); err != nil {
+ t.Fatal(err)
+ }
+ cfg := reasonixDecode(t, a.Path)
+ if cfg["default_model"] != "magpie/group/code" {
+ t.Fatalf("default: %v", cfg)
+ }
+ providers := cfg["providers"].([]any)
+ if len(providers) != 2 || !reflect.DeepEqual(providers[0], original["providers"].([]any)[0]) {
+ t.Fatalf("native provider changed: %v", providers)
+ }
+ p := providers[1].(map[string]any)
+ if p["name"] != "magpie" || p["kind"] != "openai" || p["base_url"] != gatewayV1() || p["default"] != "group/code" {
+ t.Fatalf("wiring: %v", p)
+ }
+ key, _ := p["api_key_env"].(string)
+ if value, ok := edit.GetEnvFile(env, key); !ok || value != gateway.TokenFor("reasonix") {
+ t.Fatalf("gateway credential: %s %q", key, value)
+ }
+ if len(cfg["desktop"].(map[string]any)["provider_access"].([]any)) != 2 {
+ t.Fatal("Studio can't select the new provider")
+ }
+ if usage.AgentOf("reasonix/2.24.0") != "reasonix" {
+ t.Fatal("Reasonix user agent not recognized")
+ }
+ if a.Check() != "" {
+ t.Fatal(a.Check())
+ }
+ if err := f.Set("magpie/a/flash"); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.Set(""); err != nil {
+ t.Fatal(err)
+ }
+ if got := reasonixDecode(t, a.Path); !reflect.DeepEqual(got, original) {
+ t.Fatalf("restore: %v", got)
+ }
+ raw, _ := os.ReadFile(a.Path)
+ if !strings.Contains(string(raw), "# keep this comment") {
+ t.Fatal("lost the default model comment")
+ }
+ if got, _ := os.ReadFile(env); string(got) != "# original key\nNATIVE_API_KEY=native-secret\n" {
+ t.Fatalf("restore credentials: %s", got)
+ }
+ if err := f.Set(""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestReasonixEffortAndSync(t *testing.T) {
+ a, env := reasonixFixture(t, reasonixNativeConfig)
+ if err := a.Field("model").Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ effort := a.Field("effort")
+ if effort == nil {
+ t.Fatal("missing effort field")
+ }
+ if err := effort.Set("xhigh"); err != nil {
+ t.Fatal(err)
+ }
+ if effort.Get() != "xhigh" {
+ t.Fatal(effort.Get())
+ }
+ cfgBefore, _ := os.ReadFile(a.Path)
+ envBefore, _ := os.ReadFile(env)
+ if err := a.Sync(); err != nil {
+ t.Fatal(err)
+ }
+ cfgAfter, _ := os.ReadFile(a.Path)
+ envAfter, _ := os.ReadFile(env)
+ if string(cfgAfter) != string(cfgBefore) || string(envAfter) != string(envBefore) {
+ t.Fatal("sync changed selection or credentials")
+ }
+ if err := effort.Set("impossible"); err == nil {
+ t.Fatal("accepted unsupported effort")
+ }
+ if err := provider.Save(provider.Provider{ID: "b", Name: "B", Chat: "http://127.0.0.1:1/v1", Key: "k", Models: []string{"new"}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Sync(); err != nil {
+ t.Fatal(err)
+ }
+ cfg := reasonixDecode(t, a.Path)
+ p := cfg["providers"].([]any)[1].(map[string]any)
+ if !slices.Contains(p["models"].([]any), any("b/new")) || cfg["default_model"] != "magpie/a/pro" {
+ t.Fatalf("new catalog: %v", p)
+ }
+ overrides := p["model_overrides"].(map[string]any)
+ if !slices.Contains(overrides["a/pro"].(map[string]any)["supported_efforts"].([]any), any("xhigh")) {
+ t.Fatal("Studio lost extended effort levels")
+ }
+ if err := a.Field("model").Set("magpie/a/flash"); err != nil {
+ t.Fatal(err)
+ }
+ if effort.Get() != "" {
+ t.Fatalf("effort leaked to another model: %q", effort.Get())
+ }
+}
+
+func TestReasonixConflictsAndReadErrorsLeaveFiles(t *testing.T) {
+ for _, extra := range []string{
+ "\n[[providers]]\nname = \"magpie\"\nmodels = [\"mine\"]\n",
+ "\n[[providers]]\nname = \"broken\"\nmodels = [\n",
+ } {
+ t.Run(extra, func(t *testing.T) {
+ a, env := reasonixFixture(t, reasonixNativeConfig+extra)
+ before, _ := os.ReadFile(a.Path)
+ beforeEnv, _ := os.ReadFile(env)
+ if err := a.Field("model").Set("magpie/a/pro"); err == nil {
+ t.Fatal("accepted conflicting or malformed config")
+ }
+ after, _ := os.ReadFile(a.Path)
+ afterEnv, _ := os.ReadFile(env)
+ if string(before) != string(after) || string(beforeEnv) != string(afterEnv) {
+ t.Fatal("failed operation changed config")
+ }
+ })
+ }
+ a, env := reasonixFixture(t, reasonixNativeConfig)
+ os.Remove(env)
+ os.Mkdir(env, 0o700)
+ before, _ := os.ReadFile(a.Path)
+ if err := a.Field("model").Set("magpie/a/pro"); err == nil {
+ t.Fatal("accepted unreadable credential file")
+ }
+ after, _ := os.ReadFile(a.Path)
+ if string(before) != string(after) {
+ t.Fatal("failure changed config")
+ }
+}
+
+func TestReasonixSyncDoesNotCreateConfiguration(t *testing.T) {
+ a, _ := reasonixFixture(t, "")
+ if a.Sync == nil {
+ t.Fatal("missing catalog synchronization")
+ }
+ if err := a.Sync(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(a.Path); !os.IsNotExist(err) {
+ t.Fatal("sync created an unused agent config")
+ }
+ if err := a.Field("model").Set(""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(a.Path); !os.IsNotExist(err) {
+ t.Fatal("reset created a config")
+ }
+ if err := a.Field("model").Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Field("model").Set(""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(a.Path); !os.IsNotExist(err) {
+ t.Fatal("reset kept a config made by magpie")
+ }
+}
+
+func TestReasonixBuiltinDefaultCanBeReselected(t *testing.T) {
+ const original = "default_model = \"deepseek-flash\"\n"
+ t.Run("current", func(t *testing.T) {
+ a, _ := reasonixFixture(t, original)
+ if err := a.Field("model").Set("deepseek-flash"); err != nil {
+ t.Fatalf("the picker offers the current native model but refuses it: %v", err)
+ }
+ })
+ t.Run("return", func(t *testing.T) {
+ a, env := reasonixFixture(t, original)
+ f := a.Field("model")
+ if err := f.Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ if !slices.ContainsFunc(f.Options(a.Values()), func(o Option) bool { return o.Value == "deepseek-flash" }) {
+ t.Fatal("the original built-in default disappears from the picker")
+ }
+ if err := f.Set("deepseek-flash"); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := readReasonix(a.Path)
+ if err != nil || cfg.DefaultModel == nil || *cfg.DefaultModel != "deepseek-flash" || cfg.magpie().Name != "" {
+ t.Fatalf("native default was not restored: %+v %v", cfg, err)
+ }
+ if _, exists := edit.GetEnvFile(env, reasonixKey); exists {
+ t.Fatal("the managed credential survived native restoration")
+ }
+ })
+}
+
+func TestReasonixRestoreRemovesAccessAddedLater(t *testing.T) {
+ a, _ := reasonixFixture(t, strings.Replace(reasonixNativeConfig, "provider_access = [\"native\"]\n", "", 1))
+ f := a.Field("model")
+ if err := f.Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ if err := edit.SetTOMLKey(a.Path, "desktop", "provider_access", edit.Raw("[\"native\", \"later\"]")); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.Set("magpie/a/flash"); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.Set(""); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := readReasonix(a.Path)
+ if err != nil || !reflect.DeepEqual(cfg.Desktop.Access, []string{"native", "later"}) {
+ t.Fatalf("restore kept access that magpie added after initial setup: %v %v", cfg.Desktop.Access, err)
+ }
+}
+
+func TestReasonixConcurrentEditsAndSync(t *testing.T) {
+ a, _ := reasonixFixture(t, reasonixNativeConfig)
+ if err := a.Field("model").Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ b, err := Find("reasonix") // API calls and catalog sync construct separate agents.
+ if err != nil {
+ t.Fatal(err)
+ }
+ var workers sync.WaitGroup
+ errors := make(chan error, 128)
+ for i := range 16 {
+ workers.Add(1)
+ go func() {
+ defer workers.Done()
+ target := []*Agent{a, b}[i%2]
+ for j := range 4 {
+ if err := target.Field("model").Set([]string{"magpie/a/pro", "magpie/a/flash"}[(i+j)%2]); err != nil {
+ errors <- err
+ }
+ if err := target.Sync(); err != nil {
+ errors <- err
+ }
+ }
+ }()
+ }
+ workers.Wait()
+ close(errors)
+ for err := range errors {
+ t.Errorf("concurrent configuration operation failed: %v", err)
+ break
+ }
+ cfg, err := readReasonix(a.Path)
+ if err != nil || cfg.DefaultModel == nil || *cfg.DefaultModel != "magpie/"+cfg.magpie().Default {
+ t.Fatalf("concurrent edits split the selected model and provider: %+v %v", cfg, err)
+ }
+ if drift := a.Check(); drift != "" {
+ t.Fatal(drift)
+ }
+}
diff --git a/internal/agent/reasonix_windows_test.go b/internal/agent/reasonix_windows_test.go
new file mode 100644
index 00000000..ce8418a7
--- /dev/null
+++ b/internal/agent/reasonix_windows_test.go
@@ -0,0 +1,72 @@
+//go:build windows
+
+package agent
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/yetone/magpie/internal/provider"
+ "golang.org/x/sys/windows"
+)
+
+func TestReasonixDesktopRequiresMajorVersionTwo(t *testing.T) {
+ for _, tc := range []struct {
+ name, version string
+ want bool
+ }{
+ {"Reasonix Studio", "1.0.3", false},
+ {"Reasonix Studio", "", false},
+ {"Reasonix Studio 2.24.0", "2.24.0", true},
+ {"Reasonix Studio", "v2.24.0", true},
+ {"Reasonix Studio Host", "1.18.0", false},
+ {"Other app", "2.24.0", false},
+ } {
+ if got := reasonixStudioRelease(tc.name, tc.version); got != tc.want {
+ t.Errorf("%q version %q detected=%v", tc.name, tc.version, got)
+ }
+ }
+}
+
+func TestReasonixLockedCredentialRollsBackAllFiles(t *testing.T) {
+ for _, restore := range []bool{false, true} {
+ t.Run(map[bool]string{false: "select", true: "restore"}[restore], func(t *testing.T) {
+ a, env := reasonixFixture(t, reasonixNativeConfig)
+ if restore {
+ if err := a.Field("model").Set("magpie/a/pro"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ files, _ := filepath.Glob(filepath.Join(filepath.Dir(provider.Path()), "reasonix-*.json"))
+ before := map[string]string{}
+ for _, path := range append(files, a.Path, env) {
+ b, _ := os.ReadFile(path)
+ before[path] = string(b)
+ }
+ name, _ := windows.UTF16PtrFromString(env)
+ h, err := windows.CreateFile(name, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer windows.CloseHandle(h)
+ value := "magpie/a/pro"
+ if restore {
+ value = ""
+ }
+ if err := a.Field("model").Set(value); err == nil {
+ t.Fatal("writing a locked credential file unexpectedly succeeded")
+ }
+ for path, original := range before {
+ got, _ := os.ReadFile(path)
+ if string(got) != original {
+ t.Fatalf("failed transaction changed %s", path)
+ }
+ }
+ after, _ := filepath.Glob(filepath.Join(filepath.Dir(provider.Path()), "reasonix-*.json"))
+ if len(after) != len(files) {
+ t.Fatal("failed transaction changed restore records")
+ }
+ })
+ }
+}
diff --git a/internal/agentenv/agentenv.go b/internal/agentenv/agentenv.go
index 04a21eeb..fa1cd703 100644
--- a/internal/agentenv/agentenv.go
+++ b/internal/agentenv/agentenv.go
@@ -54,6 +54,8 @@ var Vars = []string{
// MiMo Code, MiniMax Code, OpenHanako, Hermes, dsh, WorkBuddy, CodeBuddy Code
"MIMOCODE_HOME", "MINIMAX_DATA_DIR", "HANA_HOME", "HERMES_HOME", "DSH_HOME",
"WORKBUDDY_CONFIG_DIR", "CODEBUDDY_CONFIG_DIR",
+ // Reasonix Studio and its native CLI share this config home
+ "REASONIX_HOME",
// Mister Morph's config file
"MISTER_MORPH_CONFIG",
// T3 Code's base folder (its settings in userdata/)
diff --git a/internal/edit/crlf_test.go b/internal/edit/crlf_test.go
index b70961bb..5f6e6406 100644
--- a/internal/edit/crlf_test.go
+++ b/internal/edit/crlf_test.go
@@ -47,7 +47,9 @@ func TestSetTOMLKeepsCRLF(t *testing.T) {
if err := DelTOMLKey(p, "b", "z"); err != nil {
t.Fatal(err)
}
- want := "top = \"9\"\r\nt2 = \"x\"\r\n[a]\r\nk = \"v\"\r\nn = \"added\"\r\n\r\n[c]\r\nq = \"1\"\r\n"
+ // top is written through tomlLiteral, as every other TOML edit is: an int
+ // is a TOML integer (9), not the string "9" strconv.Quote used to make.
+ want := "top = 9\r\nt2 = \"x\"\r\n[a]\r\nk = \"v\"\r\nn = \"added\"\r\n\r\n[c]\r\nq = \"1\"\r\n"
if got := read(t, p); got != want {
t.Fatalf("got %q, want %q", got, want)
}
diff --git a/internal/edit/toml.go b/internal/edit/toml.go
index 01b1cee2..1b48833c 100644
--- a/internal/edit/toml.go
+++ b/internal/edit/toml.go
@@ -8,6 +8,7 @@ import (
"sort"
"strconv"
"strings"
+ "unicode/utf8"
"github.com/pelletier/go-toml/v2"
"github.com/pelletier/go-toml/v2/unstable"
@@ -240,6 +241,16 @@ func getTOMLTopLines(path, key string) (string, bool) {
// there is none. Every key is located in the file as it was read, so the
// edit is one write that either happens entirely or not at all.
func SetTOMLTop(path string, kvs ...KV) error {
+ return setTOMLTop(path, false, kvs...)
+}
+
+// SetTOMLTopPreserving is SetTOMLTop with an existing key's spacing and trailing
+// comment kept. New keys use the same spelling as SetTOMLTop.
+func SetTOMLTopPreserving(path string, kvs ...KV) error {
+ return setTOMLTop(path, true, kvs...)
+}
+
+func setTOMLTop(path string, preserve bool, kvs ...KV) error {
raw, err := Read(path)
if err != nil {
return err
@@ -261,10 +272,13 @@ func SetTOMLTop(path string, kvs ...KV) error {
at = doc.root.keys[n-1].to
}
for _, kv := range kvs {
- line := kv.Path + " = " + strconv.Quote(toString(kv.Value))
+ line := kv.Path + " = " + tomlLiteral(kv.Value)
found := false
for _, k := range doc.root.keys {
if k.name == kv.Path {
+ if preserve {
+ line = k.prefix + tomlLiteral(kv.Value) + k.suffix
+ }
replace[k.from] = span{k.to, line}
found = true
break
@@ -605,7 +619,56 @@ func tomlLiteral(v any) string {
case int:
return strconv.Itoa(x)
}
- return strconv.Quote(toString(v))
+ return tomlString(toString(v))
+}
+
+// tomlString spells s as a TOML basic string. Go's strconv.Quote is not a
+// substitute: it escapes a control byte as \x01, and TOML defines no \x
+// escape, so such a value leaves a file no TOML parser reads. writeTOML
+// refuses that file before any change reaches the disk, so the config is
+// never corrupted, but the edit then cannot be made at all — an agent's own
+// value has to be writable. Escapes taken from the TOML spec; anything else
+// that is printable, including non-ASCII, is written as it is.
+func tomlString(s string) string {
+ var b strings.Builder
+ b.Grow(len(s) + 2)
+ b.WriteByte('"')
+ for i := 0; i < len(s); {
+ r, size := utf8.DecodeRuneInString(s[i:])
+ i += size
+ if r == utf8.RuneError && size == 1 {
+ // One byte that is not part of a rune: TOML has no escape for a
+ // single byte, only for code points, so a value that is not valid
+ // UTF-8 cannot be written. It is left as it is, and writeTOML
+ // refuses the file, rather than being quietly replaced by U+FFFD.
+ b.WriteByte(s[i-size])
+ continue
+ }
+ switch r {
+ case '\b':
+ b.WriteString(`\b`)
+ case '\t':
+ b.WriteString(`\t`)
+ case '\n':
+ b.WriteString(`\n`)
+ case '\f':
+ b.WriteString(`\f`)
+ case '\r':
+ b.WriteString(`\r`)
+ case '"':
+ b.WriteString(`\"`)
+ case '\\':
+ b.WriteString(`\\`)
+ default:
+ if r < 0x20 || r == 0x7f {
+ fmt.Fprintf(&b, `\u%04X`, r)
+ continue
+ }
+ b.WriteRune(r)
+ }
+ }
+ b.WriteByte('"')
+ return b.String()
}
// Table is one TOML table: its header name and its keys, in order.
diff --git a/internal/edit/toml_array.go b/internal/edit/toml_array.go
new file mode 100644
index 00000000..316ab62e
--- /dev/null
+++ b/internal/edit/toml_array.go
@@ -0,0 +1,159 @@
+package edit
+
+import (
+ "fmt"
+ "slices"
+
+ "github.com/pelletier/go-toml/v2/unstable"
+)
+
+// SetTOMLArrayTable replaces one named array entry and its child tables, or
+// appends it. block is a complete array-table declaration, including its header.
+// Duplicate matches, invalid input and replacements for another entry are errors.
+func SetTOMLArrayTable(path, name, key, value, block string) error {
+ return editTOMLArrayTable(path, name, key, value, block)
+}
+
+// DelTOMLArrayTable removes just the matching entry and its child tables.
+func DelTOMLArrayTable(path, name, key, value string) error {
+ return editTOMLArrayTable(path, name, key, value, "")
+}
+
+func editTOMLArrayTable(path, name, key, value, block string) error {
+ raw, err := Read(path)
+ if err != nil {
+ return err
+ }
+ if err := validateTOML(raw); err != nil {
+ return fmt.Errorf("%s: %w", path, err)
+ }
+ lines := splitLines(string(raw))
+ doc, err := parseTOMLFile(lines)
+ if err != nil {
+ return err
+ }
+ spans, err := namedTOMLArray(doc, name, key, value)
+ if err != nil {
+ return fmt.Errorf("%s: %w", path, err)
+ }
+ var replacement []string
+ if block != "" {
+ if err := validateTOML([]byte(block)); err != nil {
+ return fmt.Errorf("replacement: %w", err)
+ }
+ replacement = splitLines(block)
+ newDoc, err := parseTOMLFile(replacement)
+ if err != nil {
+ return err
+ }
+ matches, err := namedTOMLArray(newDoc, name, key, value)
+ if err != nil || len(matches) == 0 || len(newDoc.root.keys) > 0 {
+ return fmt.Errorf("replacement must declare one %s entry with %s = %q", name, key, value)
+ }
+ // All headers must belong to this entry, including child array tables.
+ want := tomlHeaderParts(name)
+ for _, table := range newDoc.tables {
+ parts := tomlHeaderParts(table.name)
+ if !tomlDescendant(parts, want) && table.from != matches[0].from {
+ return fmt.Errorf("replacement contains another table")
+ }
+ }
+ replacement = trimBlank(replacement)
+ }
+ if len(spans) == 0 {
+ if block == "" {
+ return nil
+ }
+ out := trimBlank(lines)
+ if len(out) > 0 {
+ out = append(out, "")
+ }
+ out = append(out, replacement...)
+ return writeTOML(path, out)
+ }
+ var out []string
+ at := 0
+ for i, span := range spans {
+ out = append(out, lines[at:span.from]...)
+ if i == 0 {
+ out = append(out, replacement...)
+ }
+ at = span.to
+ }
+ out = append(out, lines[at:]...)
+ return writeTOML(path, out)
+}
+
+type tomlArraySpan struct{ from, to int }
+
+func namedTOMLArray(doc tomlFile, name, key, value string) ([]tomlArraySpan, error) {
+ want := tomlHeaderParts(name)
+ if len(want) == 0 {
+ return nil, fmt.Errorf("invalid table name %q", name)
+ }
+ // An inline array cannot safely be treated as independent table blocks.
+ for _, kv := range doc.root.keys {
+ if kv.name == name {
+ return nil, fmt.Errorf("%s is an inline value, not an array table", name)
+ }
+ }
+ var spans []tomlArraySpan
+ for i, table := range doc.tables {
+ if !slices.Equal(tomlHeaderParts(table.name), want) {
+ continue
+ }
+ if !table.array {
+ return nil, fmt.Errorf("%s is not an array table", name)
+ }
+ matched := false
+ for _, kv := range table.keys {
+ if kv.name == key && kv.scalar && kv.value == value {
+ matched = true
+ }
+ }
+ if !matched {
+ continue
+ }
+ if len(spans) > 0 {
+ return nil, fmt.Errorf("multiple %s entries have %s = %q", name, key, value)
+ }
+ spans = append(spans, tomlArraySpan{table.from, tomlContentEnd(table)})
+ for j := i + 1; j < len(doc.tables); j++ {
+ parts := tomlHeaderParts(doc.tables[j].name)
+ if slices.Equal(parts, want) {
+ break
+ }
+ if tomlDescendant(parts, want) {
+ spans = append(spans, tomlArraySpan{doc.tables[j].from, tomlContentEnd(doc.tables[j])})
+ }
+ }
+ }
+ return spans, nil
+}
+
+func tomlContentEnd(table tomlTableSpan) int {
+ if len(table.keys) > 0 {
+ return table.keys[len(table.keys)-1].to
+ }
+ return table.from + 1
+}
+
+func tomlDescendant(parts, parent []string) bool {
+ return len(parts) > len(parent) && slices.Equal(parts[:len(parent)], parent)
+}
+
+func tomlHeaderParts(name string) []string {
+ var p unstable.Parser
+ p.Reset([]byte("[" + name + "]"))
+ if !p.NextExpression() || p.Expression().Kind != unstable.Table {
+ return nil
+ }
+ var parts []string
+ for keys := p.Expression().Key(); keys.Next(); {
+ parts = append(parts, string(keys.Node().Data))
+ }
+ if p.NextExpression() || p.Error() != nil {
+ return nil
+ }
+ return parts
+}
diff --git a/internal/edit/toml_array_test.go b/internal/edit/toml_array_test.go
new file mode 100644
index 00000000..40349018
--- /dev/null
+++ b/internal/edit/toml_array_test.go
@@ -0,0 +1,111 @@
+package edit
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestTOMLArrayTableEditsOnlyNamedEntry(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.toml")
+ before := `# global
+default_model = "native/a"
+[[providers]]
+name = "native"
+models = [
+ "a", # keep
+ "b",
+]
+[providers.headers]
+x = "keep"
+[[ 'providers' ]]
+name = 'magpie'
+models = ["old"]
+[providers.model_overrides."old"]
+context_window = 10
+note = """
+[[providers]]
+name = "fake"
+"""
+# keep the next section's comment
+[[providers]]
+name = "last"
+models = ["z"]
+[ui]
+theme = "dark"
+`
+ if err := os.WriteFile(path, []byte(before), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ block := "[[providers]]\nname = \"magpie\"\nmodels = [\"new\"]\n[providers.model_overrides.\"new\"]\ncontext_window = 100\n"
+ if err := SetTOMLArrayTable(path, "providers", "name", "magpie", block); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := os.ReadFile(path)
+ keep := before[:strings.Index(before, "[[ 'providers' ]]")]
+ tail := before[strings.Index(before, "# keep the next"):]
+ if !strings.HasPrefix(string(got), keep) || !strings.HasSuffix(string(got), tail) || strings.Contains(string(got), "context_window = 10\n") {
+ t.Fatalf("neighboring data or the owned subtree was edited incorrectly:\n%s", got)
+ }
+ if err := DelTOMLArrayTable(path, "providers", "name", "magpie"); err != nil {
+ t.Fatal(err)
+ }
+ got, _ = os.ReadFile(path)
+ if !strings.HasPrefix(string(got), keep) || !strings.HasSuffix(string(got), tail) || strings.Contains(string(got), `name = "magpie"`) {
+ t.Fatalf("delete changed another entry:\n%s", got)
+ }
+}
+
+func TestTOMLArrayTableRejectsAmbiguityAndInvalidInput(t *testing.T) {
+ for _, input := range []string{
+ "[[providers]]\nname = \"magpie\"\n[[providers]]\nname = \"magpie\"\n",
+ "[[providers]]\nname = \"other\"\nname = \"duplicate\"\n",
+ "[[providers]]\nname = [\n",
+ "providers = [{ name = \"native\" }]\n",
+ } {
+ t.Run(input, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.toml")
+ os.WriteFile(path, []byte(input), 0o600)
+ if err := SetTOMLArrayTable(path, "providers", "name", "magpie", "[[providers]]\nname = \"magpie\"\n"); err == nil {
+ t.Fatal("accepted unsafe input")
+ }
+ got, _ := os.ReadFile(path)
+ if string(got) != input {
+ t.Fatalf("failed edit changed the file: %s", got)
+ }
+ })
+ }
+ path := filepath.Join(t.TempDir(), "config.toml")
+ if err := SetTOMLArrayTable(path, "providers", "name", "magpie", "[[providers]]\nname = \"foreign\"\n"); err == nil {
+ t.Fatal("accepted a mismatching replacement")
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatal("invalid replacement created a file")
+ }
+}
+
+func TestTOMLTopKeepsCommentAndSpacing(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.toml")
+ os.WriteFile(path, []byte("default_model = 'native/a' # chosen by me\n[ui]\ntheme = 'dark'\n"), 0o600)
+ if err := SetTOMLTopPreserving(path, KV{Path: "default_model", Value: "magpie/group/code"}); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := os.ReadFile(path)
+ if string(got) != "default_model = \"magpie/group/code\" # chosen by me\n[ui]\ntheme = 'dark'\n" {
+ t.Fatalf("lost formatting: %s", got)
+ }
+}
+
+func TestTOMLArrayTableInterleavedChildren(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.toml")
+ input := "[[providers]]\nname = \"magpie\"\n[ui]\ntheme = \"dark\"\n[providers.model_overrides.\"old\"]\ncontext_window = 10\n[[providers]]\nname = \"native\"\n"
+ os.WriteFile(path, []byte(input), 0o600)
+ if err := SetTOMLArrayTable(path, "providers", "name", "magpie", "[[providers]]\nname = \"magpie\"\nmodels = [\"new\"]\n"); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := os.ReadFile(path)
+ if strings.Contains(string(got), "context_window = 10") || !strings.Contains(string(got), "[ui]\ntheme = \"dark\"") || !strings.Contains(string(got), "name = \"native\"") {
+ t.Fatalf("interleaved subtree: %s", got)
+ }
+}
diff --git a/internal/edit/toml_literal_test.go b/internal/edit/toml_literal_test.go
new file mode 100644
index 00000000..1a5f2324
--- /dev/null
+++ b/internal/edit/toml_literal_test.go
@@ -0,0 +1,98 @@
+package edit
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/pelletier/go-toml/v2"
+)
+
+// A value holding a byte TOML has no escape for must still leave a file a
+// TOML parser can read. Go's strconv.Quote spells a control byte as \x01,
+// and TOML defines no \x escape, so a preserving edit wrote a config.toml
+// that no longer parsed. Both paths spell a string the same way now, and
+// the file is parsed here rather than compared to a literal, so an escape
+// TOML rejects is caught rather than hard-coded.
+func TestTOMLPreservingKeepsAValueTOMLCanParse(t *testing.T) {
+ // Control bytes Go escapes with \x and TOML defines no escape for.
+ values := []string{
+ "a" + string(rune(1)) + "b", // SOH
+ "a" + string(rune(0x7f)) + "b", // DEL
+ "a" + string(rune(0x0b)) + "b", // VT
+ "tab\there",
+ "nl\nhere",
+ "quote\"here",
+ `back\slash`,
+ "cr\rhere",
+ "bell" + string(rune(7)),
+ }
+ for _, value := range values {
+ t.Run(value, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.toml")
+ before := "default_model = \"native/a\" # keep\n"
+ if err := os.WriteFile(path, []byte(before), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetTOMLTopPreserving(path, KV{Path: "default_model", Value: value}); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(raw), "# keep") {
+ t.Errorf("the trailing comment was not kept: %s", raw)
+ }
+ var got struct {
+ DefaultModel string `toml:"default_model"`
+ }
+ if err := toml.Unmarshal(raw, &got); err != nil {
+ t.Fatalf("the file no longer parses as TOML: %v\n%s", err, raw)
+ }
+ if got.DefaultModel != value {
+ t.Errorf("read back %q, want %q", got.DefaultModel, value)
+ }
+ })
+ }
+}
+
+// The same value through both paths, so the preserving one cannot drift
+// from SetTOMLTop again: a caller switching between them writes the same
+// bytes.
+func TestTOMLPreservingAndPlainAgreeOnEncoding(t *testing.T) {
+ for _, value := range []string{
+ "a" + string(rune(1)) + "b",
+ "a" + string(rune(0x7f)) + "b",
+ "plain",
+ "",
+ } {
+ t.Run(value, func(t *testing.T) {
+ dir := t.TempDir()
+ plain, keeping := filepath.Join(dir, "plain.toml"), filepath.Join(dir, "keeping.toml")
+ for _, p := range []string{plain, keeping} {
+ if err := os.WriteFile(p, []byte("default_model = \"native/a\"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := SetTOMLTop(plain, KV{Path: "default_model", Value: value}); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetTOMLTopPreserving(keeping, KV{Path: "default_model", Value: value}); err != nil {
+ t.Fatal(err)
+ }
+ a, err := os.ReadFile(plain)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := os.ReadFile(keeping)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(a) != string(b) {
+ t.Errorf("the two paths wrote different bytes:\nSetTOMLTop: %q\nSetTOMLTopPreserving: %q", a, b)
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index 22506659..94a8ce3b 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -266,7 +266,7 @@ function renderAgents() {
// the model picker takes the wide column, everything else the narrow one,
// so the controls line up down the list
const fields = el("div", "fields");
- const wide = (f) => f.label === "model" || f.label === "large";
+ const wide = (f) => f.label === "model" || f.label === "large" || f.label === "executor" || f.label === "planner";
// an effort or ultracode the model has none of (Claude Code on Haiku
// 4.5, ultracode short of xhigh) isn't drawn at all, nor are subagents
// with no model to go on (Claude Code's, until it runs through magpie)
@@ -291,6 +291,7 @@ function renderAgents() {
else if (effort) b.append(effortIcon(f));
else if (!f.value && !f.menu && a.icon) b.append(icon(a.icon));
else if (!wide(f) || !f.value) b.append(el("span", "k", t(f.label)));
+ if ((f.label === "executor" || f.label === "planner") && !b.querySelector(".k")) b.append(el("span", "k", t(f.label)));
const shown = f.menu ? f.summary : effort ? effortName(opt || { value: f.value }) : (opt?.label || f.value || t(FOLLOWS_MODEL.includes(f.label) ? "same as model" : "default"));
if (f.menu) b.title = f.options.map((o) => `${o.label}: ${o.note}`).join("\n");
b.append(el("span", "v" + (f.value || f.custom ? "" : " empty"), shown));
@@ -3439,9 +3440,10 @@ function openPicker(agent, field, anchor, ev, only) {
// provider signed in, in its own order, so Default is no fixed model
// and a model picked in Pi's /model lasts the session only unless
// saved there with Ctrl+S (#709)
- const note = PICKS_ITSELF.includes(agent.id) && field.key === "model" ? "clears the default model; {agent} picks one on its own"
+ const note = agent.id === "reasonix" && ["model", "planner"].includes(field.key) ? "restore the previous {field} selection"
+ : PICKS_ITSELF.includes(agent.id) && field.key === "model" ? "clears the default model; {agent} picks one on its own"
: field.label === MEMORIES ? MEMORIES_DEFAULT : "what {agent} ships with";
- options.unshift({ value: "", label: t("Default"), note: t(note, { agent: agent.name }), icon: agent.icon, reset: true });
+ options.unshift({ value: "", label: t("Default"), note: t(note, { agent: agent.name, field: t(field.label) }), icon: agent.icon, reset: true });
}
// Default is the agent as installed; this is the agent as it was before
// magpie, beside it so the two aren't taken for each other
@@ -3449,7 +3451,7 @@ function openPicker(agent, field, anchor, ev, only) {
const at = options.findIndex((o) => !o.reset);
options.splice(at < 0 ? options.length : at, 0, { value: "\0disconnect", label: t("Disconnect from magpie"), note: t("put back what {agent} had before magpie", { agent: agent.name }), svg: UNPLUG, reset: true, run: () => askDisconnect(agent) });
}
- const modelPicker = ["model", "small", "large", MEMORIES, ...FOLLOWS_MODEL].includes(field.label) && !only;
+ const modelPicker = ["model", "small", "large", MEMORIES, "executor", "planner", ...FOLLOWS_MODEL].includes(field.label) && !only;
pick = { agent, field, options, anchor, cursor: 0, free: !only && !field.menu, modelPicker, effortPicker, groupFilter: "all" };
anchor.classList.add("open");
const pop = $("#pop");
@@ -3701,7 +3703,7 @@ function filter(keep) {
if (q) scored.sort((a, b) => b.s - a.s || a.i - b.i);
pick.items = scored.map((x) => x.o);
const typed = $("#q").value.trim();
- if (typed && pick.free && ["model", "small", "large", ...FOLLOWS_MODEL].includes(pick.field.label) && !pick.items.some((o) => o.value === typed)) {
+ if (typed && pick.free && ["model", "small", "large", "executor", "planner", ...FOLLOWS_MODEL].includes(pick.field.label) && !pick.items.some((o) => o.value === typed)) {
pick.items.push({ value: typed, note: t("use as typed"), custom: true });
}
pick.items = foldSame(pick.items);
@@ -4098,7 +4100,12 @@ async function commit(value) {
// directly (Claude Code's own, which unroutes it). From one of its own
// models already, the pick doesn't move it off magpie.
function leavesMagpie(a, field, value, opt) {
- if (a?.native || !a?.wired || !connectable(a) || field !== (startField(a) || connectField(a))) return false;
+ if (a?.native || !a?.wired || !connectable(a)) return false;
+ // Reasonix restores each role separately; either role can keep its provider.
+ if (a.id === "reasonix" && value === "") {
+ return !!optionFor(field, field.value)?.ref && !a.fields.some((f) => f !== field && optionFor(f, f.value)?.ref);
+ }
+ if (field !== (startField(a) || connectField(a))) return false;
if (optionFor(field, field.value)?.direct) return false;
return value === "" || !!opt?.direct;
}
@@ -4112,7 +4119,9 @@ function askLeave(a, field, value, opt) {
const head = el("div", "ehead");
head.append(icon(a.icon), el("b", "", t("Take {agent} off magpie?", { agent: a.name })));
ed.append(head);
- ed.append(el("p", "lib-confirm", value === ""
+ ed.append(el("p", "lib-confirm", value === "" && a.id === "reasonix"
+ ? t("Restores {agent}'s previous {field} selection. Magpie's provider and private credential are removed when neither executor nor planner uses them.", { agent: a.name, field: t(field.label) })
+ : value === ""
? t("Default is {agent} as installed: magpie's endpoint and models come out, and {agent} starts on its own default model. What it had before magpie isn't put back; Disconnect and restore does that.", { agent: a.name })
: t("{model} is {agent}'s own model: {agent} asks {vendor} for it itself, with its own sign-in, not through magpie. Picking it takes {agent} off magpie, and it starts on {model}.", { agent: a.name, model, vendor: opt.direct })));
const others = state.agents.some((x) => x.id !== a.id && onMagpie(x) && !isHidden(x));
@@ -4187,7 +4196,7 @@ async function setPick(agent, field, value, opt) {
flash();
const shown = opt?.label || value;
if (state.notice) status(`${agent.name} → ${shown}. ${t(state.notice)}`, "warn", 9000);
- else if (leaving && value === "") status(t("{agent} no longer goes through magpie · on its own default", { agent: agent.name }), "ok", 6000);
+ else if (leaving && value === "") status(t(agent.id === "reasonix" ? "{agent} no longer goes through magpie; its own settings are back" : "{agent} no longer goes through magpie · on its own default", { agent: agent.name }), "ok", 6000);
else if (opt?.direct) status(`${agent.name} ${t(field.label)} → ${shown} · ${t("straight to {vendor}, not through magpie", { vendor: opt.direct })}`, "ok", 6000);
else status(`${agent.name} ${t(field.label)} → ${shown}`, "ok");
if (providers) loadProviders();
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index 4f6ff46d..f898c4d2 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -313,6 +313,8 @@ const I18N = {
"off magpie": "不经 magpie",
"{agent} as installed: magpie's endpoint and models come out": "{agent} 初始设置:移除 magpie 的地址和模型",
"Take {agent} off magpie?": "让 {agent} 不再经过 magpie?",
+ "restore the previous {field} selection": "还原原来的 {field} 选择",
+ "Restores {agent}'s previous {field} selection. Magpie's provider and private credential are removed when neither executor nor planner uses them.": "还原 {agent} 原来的 {field} 选择。执行模型和规划模型都不再使用 magpie 时,移除它的服务商配置和专用密钥。",
"Default is {agent} as installed: magpie's endpoint and models come out, and {agent} starts on its own default model. What it had before magpie isn't put back; Disconnect and restore does that.": "「默认」即 {agent} 的初始设置:移除 magpie 的地址和模型,{agent} 以自身默认模型启动。不还原接入前的设置,还原请用「断开并还原」。",
"{model} is {agent}'s own model: {agent} asks {vendor} for it itself, with its own sign-in, not through magpie. Picking it takes {agent} off magpie, and it starts on {model}.": "{model} 是 {agent} 自带的模型:{agent} 用自己的登录直接向 {vendor} 请求,不经过 magpie。选择后 {agent} 断开 magpie,以 {model} 启动。",
"「接入」 goes off, and {agent} is listed under Not set up with the agents not connected. Switch it on again to go back through magpie.": "「接入」将关闭,{agent} 列入「未设置」。再次打开「接入」即可重新经过 magpie。",
@@ -355,6 +357,8 @@ const I18N = {
"thinking": "思考",
"small": "小模型",
"large": "大模型",
+ "executor": "执行模型",
+ "planner": "规划模型",
"tiers": "分档",
"subagents": "子 agent",
"subagent effort": "子 agent 推理强度",
@@ -3787,6 +3791,8 @@ const I18N = {
"off magpie": "magpie を経由しない",
"{agent} as installed: magpie's endpoint and models come out": "インストール時の {agent}:magpie のエンドポイントとモデルを削除",
"Take {agent} off magpie?": "{agent} を magpie から外しますか?",
+ "restore the previous {field} selection": "元の {field} の選択に戻します",
+ "Restores {agent}'s previous {field} selection. Magpie's provider and private credential are removed when neither executor nor planner uses them.": "{agent} の前の {field} の選択を復元します。実行モデルも計画モデルも magpie を使わなくなると、magpie のプロバイダーと専用クレデンシャルを削除します。",
"Default is {agent} as installed: magpie's endpoint and models come out, and {agent} starts on its own default model. What it had before magpie isn't put back; Disconnect and restore does that.": "デフォルトはインストール時の {agent} です:magpie のエンドポイントとモデルが削除され、{agent} は独自のデフォルトモデルで起動します。magpie 接続前の設定には戻りません。戻すには「切断して復元」を使ってください。",
"{model} is {agent}'s own model: {agent} asks {vendor} for it itself, with its own sign-in, not through magpie. Picking it takes {agent} off magpie, and it starts on {model}.": "{model} は {agent} 独自のモデルです:{agent} は magpie を経由せず、自身のサインインで {vendor} に直接リクエストします。これを選ぶと {agent} は magpie から外れ、{model} で起動します。",
"「接入」 goes off, and {agent} is listed under Not set up with the agents not connected. Switch it on again to go back through magpie.": "「接入」がオフになり、{agent} は接続されていない他のエージェントと一緒に「未設定」に表示されます。もう一度オンにすると magpie 経由に戻ります。",
@@ -3829,6 +3835,8 @@ const I18N = {
"thinking": "思考",
"small": "小",
"large": "大",
+ "executor": "実行モデル",
+ "planner": "計画モデル",
"tiers": "階層",
"subagents": "サブエージェント",
"subagent effort": "サブエージェントの推論レベル",
@@ -7260,6 +7268,8 @@ const I18N = {
"off magpie": "ohne magpie",
"{agent} as installed: magpie's endpoint and models come out": "{agent} wie installiert: Endpunkt und Modelle von magpie werden entfernt",
"Take {agent} off magpie?": "{agent} von magpie lösen?",
+ "restore the previous {field} selection": "vorherige {field}-Auswahl wiederherstellen",
+ "Restores {agent}'s previous {field} selection. Magpie's provider and private credential are removed when neither executor nor planner uses them.": "Stellt die vorherige {field}-Auswahl von {agent} wieder her. Magpies Anbieter und privater Zugangsschlüssel werden entfernt, wenn sie weder vom Executor- noch vom Planner-Modell genutzt werden.",
"Default is {agent} as installed: magpie's endpoint and models come out, and {agent} starts on its own default model. What it had before magpie isn't put back; Disconnect and restore does that.": "„Standard“ ist {agent} wie installiert: Endpunkt und Modelle von magpie werden entfernt, und {agent} startet mit seinem eigenen Standardmodell. Der Stand vor magpie wird nicht wiederhergestellt; das erledigt „Trennen und wiederherstellen“.",
"{model} is {agent}'s own model: {agent} asks {vendor} for it itself, with its own sign-in, not through magpie. Picking it takes {agent} off magpie, and it starts on {model}.": "{model} ist ein eigenes Modell von {agent}: {agent} fragt es mit eigener Anmeldung direkt bei {vendor} an, nicht über magpie. Bei dieser Auswahl wird {agent} von magpie gelöst und startet mit {model}.",
"「接入」 goes off, and {agent} is listed under Not set up with the agents not connected. Switch it on again to go back through magpie.": "„Verbinden“ wird ausgeschaltet, und {agent} erscheint mit den nicht verbundenen Agenten unter „Nicht eingerichtet“. Wieder einschalten, um erneut über magpie zu laufen.",
@@ -7302,6 +7312,8 @@ const I18N = {
"thinking": "Thinking",
"small": "klein",
"large": "groß",
+ "executor": "Executor-Modell",
+ "planner": "Planner-Modell",
"tiers": "Stufen",
"subagents": "Subagenten",
"subagent effort": "Reasoning-Effort der Subagenten",
diff --git a/internal/gui/assets/icons/reasonix-color.svg b/internal/gui/assets/icons/reasonix-color.svg
new file mode 100644
index 00000000..1eae9413
--- /dev/null
+++ b/internal/gui/assets/icons/reasonix-color.svg
@@ -0,0 +1,48 @@
+
+
diff --git a/internal/gui/tests/reasonix-roles.test.cjs b/internal/gui/tests/reasonix-roles.test.cjs
new file mode 100644
index 00000000..f21443d8
--- /dev/null
+++ b/internal/gui/tests/reasonix-roles.test.cjs
@@ -0,0 +1,155 @@
+// Real assets, isolated API fixtures; no Reasonix process or user configuration.
+const assert = require("node:assert/strict");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { test } = require("node:test");
+const { chromium, webkit } = require("playwright");
+
+const assets = path.resolve(__dirname, "../assets");
+const options = [
+ { value: "magpie/b/executor", label: "B Executor", ref: "b/executor", group: "B", icon: "deepseek-color" },
+ { value: "magpie/b/planner", label: "B Planner", ref: "b/planner", group: "B", icon: "deepseek-color" },
+ { value: "magpie/a/pro", label: "A Pro", ref: "a/pro", group: "A", icon: "deepseek-color" },
+ { value: "magpie/a/flash", label: "A Flash", ref: "a/flash", group: "A", icon: "deepseek-color" },
+ { value: "native/old", label: "Native Old", group: "Reasonix Studio" },
+];
+
+for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
+ for (const lang of ["en", "zh"]) {
+ for (const mode of ["window", "panel"]) {
+ test(`${engine} ${lang} ${mode}: Reasonix roles keep labels and model controls`, async (t) => {
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ const page = await browser.newPage({ viewport: { width: mode === "panel" ? 440 : 1000, height: mode === "panel" ? 560 : 700 } });
+ page.setDefaultTimeout(5000);
+ const errors = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ const fields = [
+ { key: "model", label: "executor", value: "", options },
+ { key: "planner", label: "planner", value: "", options: [{ value: "off", label: "off" }, ...options] },
+ ];
+ // The role labels are field labels like any other: t(label) translates
+ // them, and both the picker note and the confirm fill {field} from it.
+ const roleText = { executor: "执行模型", planner: "规划模型" };
+ const labelText = (l) => (lang === "zh" ? roleText[l] : l);
+ const posts = [];
+ const state = () => ({ agents: [{ id: "reasonix", name: "Reasonix Studio", icon: "reasonix-color", path: "/fixture/config.toml", wired: fields.some((f) => f.value.startsWith("magpie/")), fields }], profiles: [], settings: { lang, theme: "light" } });
+ await page.addInitScript(() => localStorage.setItem("magpie.modelFavorites", '["a/pro"]'));
+ await page.route("**/*", async (route) => {
+ const url = new URL(route.request().url());
+ const json = (data) => route.fulfill({ json: data });
+ if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
+ if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
+ if (url.pathname === "/api/state") return json(state());
+ if (url.pathname === "/api/set") {
+ const body = route.request().postDataJSON();
+ posts.push(body);
+ fields.find((f) => f.key === body.field).value = body.value || "native/old";
+ return json(state());
+ }
+ if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true, window: true } });
+ if (url.pathname === "/api/groups") return json({ groups: [] });
+ if (url.pathname === "/api/usage/quotas") return json([]);
+ if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false });
+ if (url.pathname.startsWith("/api/")) return json({});
+ const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
+ const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
+ return route.fulfill({ body: await fs.readFile(file), contentType });
+ });
+ t.after(async () => {
+ if (process.env.ARTIFACT_DIR) {
+ await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
+ await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-${mode}-reasonix-roles.png`) });
+ }
+ await browser.close();
+ });
+ const row = page.locator('.row.agent[data-id="reasonix"]');
+ const open = async () => {
+ await row.waitFor();
+ if (mode === "panel") await row.locator(".ag-sum").click();
+ else if (await row.locator(':scope > .ag-link[aria-expanded="false"]').count()) {
+ await row.locator(":scope > .ag-link").click();
+ await row.locator(".ag-exp").waitFor();
+ }
+ };
+ await page.goto("http://magpie.test/" + (mode === "panel" ? "?mode=panel" : ""));
+ await open();
+ if (mode === "window") {
+ // Not connected, the row says what the agent is on, its Default or
+ // its own model, and lists its own models with magpie's as when
+ // connected; it does not read "Pick a model" (EZN7L2C3, #834).
+ // The Executor is the model field here and is named "executor".
+ const def = lang === "zh" ? "默认" : "default";
+ assert.equal((await row.locator(".ag-start").textContent()).trim(), `${labelText(fields[0].label)}${def}`);
+ } else for (const f of fields) {
+ assert.equal(await row.locator(`.field[data-key="${f.key}"] > .k`).count(), 1, "a default role must be labelled once");
+ }
+ fields[0].value = "magpie/b/executor";
+ fields[1].value = "magpie/b/planner";
+ // Either role keeps the connection while the other is on its default.
+ for (const f of fields) {
+ const selected = f.value;
+ f.value = "";
+ await page.reload();
+ await open();
+ assert.equal(await row.locator(`.field[data-key="${f.key}"] > .k`).count(), 1, "a connected default role must be labelled once");
+ f.value = selected;
+ }
+ await page.reload();
+ await open();
+ for (const f of fields) {
+ const field = row.locator(`.field[data-key="${f.key}"]`);
+ assert.equal(await field.locator(":scope > .k").count(), 1, "a selected role must be labelled once");
+ await field.click();
+ const pop = page.locator("#pop:not([hidden])");
+ assert(await pop.evaluate((e) => e.classList.contains("model-picker")), "role lost its model picker");
+ assert(await page.locator("#pickerRail").isVisible());
+ await page.locator('#pickerRail [data-group="favorites"]').click();
+ await page.waitForFunction(() => document.querySelectorAll("#list li[data-i]").length === 1);
+ assert.equal(await page.locator("#list li[data-i]").count(), 1);
+ assert.match(await page.locator("#list").innerText(), /A Pro/);
+ await page.locator('#pickerRail [data-group="A"]').click();
+ await page.waitForFunction(() => {
+ const text = document.querySelector("#list").innerText;
+ return text.includes("A Pro") && text.includes("A Flash") && !text.includes("Native Old");
+ });
+ const listed = await page.locator("#list").innerText();
+ assert.match(listed, /A Pro/);
+ assert.match(listed, /A Flash/);
+ assert(!listed.includes("Native Old"));
+ await page.locator('#pickerRail [data-group="all"]').click();
+ await page.locator("#q").fill("magpie/a/typed");
+ assert.equal(await page.locator("#list li.custom").count(), 1, "typed model choice is missing");
+ await page.keyboard.press("Escape");
+ }
+ // Restoring one role does not disconnect the other. The last role
+ // asks before leaving, and describes restoration rather than a
+ // factory reset. Exercise the actual picker and resulting API calls.
+ const defaults = async (key) => {
+ await row.locator(`.field[data-key="${key}"]`).click();
+ const label = fields.find((f) => f.key === key).label;
+ assert((await page.locator("#pop").innerText()).includes(lang === "zh" ? `还原原来的 ${labelText(label)} 选择` : `restore the previous ${label} selection`));
+ await page.locator("#pop").getByText(lang === "zh" ? "默认" : "Default", { exact: true }).first().click();
+ };
+ const first = mode === "panel" ? fields[1] : fields[0];
+ const last = fields.find((f) => f !== first);
+ const remaining = last.value;
+ await defaults(first.key);
+ await page.waitForFunction((key) => document.querySelector(`.agent[data-id="reasonix"] .field[data-key="${key}"]`)?.textContent.includes("Native Old"), first.key);
+ assert.deepEqual(posts, [{ agent: "reasonix", field: first.key, value: "" }]);
+ assert.equal(await page.locator(".leave-ask").count(), 0, "the other role still uses magpie");
+ assert.equal(last.value, remaining);
+ await defaults(last.key);
+ const ask = page.locator(".leave-ask");
+ await ask.waitFor();
+ assert((await ask.innerText()).includes(lang === "zh" ? `还原 Reasonix Studio 原来的 ${labelText(last.label)} 选择` : `Restores Reasonix Studio's previous ${last.label} selection`));
+ assert.equal(posts.length, 1, "the last role waits for confirmation");
+ await Promise.all([
+ page.waitForResponse((r) => new URL(r.url()).pathname === "/api/set"),
+ ask.locator(".bar button.primary").click(),
+ ]);
+ assert.deepEqual(posts[1], { agent: "reasonix", field: last.key, value: "" });
+ assert.deepEqual(errors, []);
+ });
+ }
+ }
+}