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, []); + }); + } + } +}