agent: add Reasonix Studio with separate Executor and Plan models (#427)

* feat(agent): add Reasonix Studio 2.x integration

* agent: select Reasonix planner independently from executor

* agent: restore Reasonix when its credential file was initially absent

* agent: preserve Reasonix planner choices and model controls

Keep configured and saved native planner references selectable, and retain full model picker behavior and role labels. Exercise native planner restoration and both browser layouts. Separate temporary CLI first-launch work from version and intentional timeout assertions.

* test(gui): follow the current Reasonix connection flow

* test(gui): open connected Reasonix details before checking roles

* agentenv: isolate Reasonix configuration in test sandboxes

* gui: restore Reasonix roles without disconnecting the other

* gui: Reasonix's role labels read in every language, and its confirm names them

The review found four things on the Reasonix roles' strings.

"executor" and "planner" are field labels like any other, so t() translates
them; they had no i18n entry in any language, so the zh, ja and de windows
showed the English key. They now read 执行模型/规划模型, 実行モデル/計画モデル
and Executor-Modell/Planner-Modell, beside model/effort/thinking in each block.

The confirm said "neither Executor nor Plan" — names that appear nowhere in
the UI — and its zh text said "Executor 和 Plan", while the fields are labelled
executor and planner. It now names the labels, and each language names its own
translation of them (zh 执行模型和规划模型, ja 実行モデルも計画モデル, de vom
Executor- bzw. Planner-Modell).

Both new strings had a zh entry and no ja or de entry, which is what turned
gui-ja and gui-de red: every zh key needs its ja and de with the same
placeholders. "restore the previous {field} selection" also started at column
0 in i18n.js; it is indented with the lines around it now.

reasonix-roles.test.cjs asserts the rendered label, so its zh expectations name
执行模型/规划模型; the en ones stay as they were, the en label being the key.
Its en assertions on the confirm's first sentence are unchanged, and the
reworded second sentence is not asserted.

Verified on this head: gui-ja passes 3/3 and gui-de 3/3 (every zh string has
its ja and de, same placeholders and tags); reasonix-roles.test.cjs passes 8/8
in Chromium and WebKit, en and zh, window (1000px) and panel (440px); go vet
./... and the four builds pass. The rest of internal/gui/tests was run and
compared against a clean main worktree at e0bf3e61: the same assertions fail on
both, and none of them is about i18n or Reasonix (see the PR comment).

* edit: a TOML value is written the way TOML reads it, preserving or not

setTOMLTop spelled every value with strconv.Quote. Go's escapes are not TOML's:
a control byte came out as \x01, and TOML defines no \x escape, so a preserving
edit wrote a config.toml that no parser reads — an agent's own value could not
be written at all. Worse, it only failed at writeTOML, after the edit was
already decided.

The preserving path and the path that adds a key now both use tomlLiteral, as
SetTOMLKey already did, so both write the same encoding. tomlString spells a
basic string from the TOML spec (\b \t \n \f \r \" \\, \uXXXX for the other
control bytes and DEL, everything else as it is, non-ASCII included). A byte
that is not valid UTF-8 has no TOML escape, so it is left as it is and writeTOML
refuses the file, rather than the value being quietly replaced by U+FFFD.

TestTOMLPreservingKeepsAValueTOMLCanParse parses the file back with the TOML
parser and compares the value, and TestTOMLPreservingAndPlainAgreeOnEncoding
puts the same value through both paths and compares the bytes. TestSetTOMLKeepsCRLF's
"top = \"9\"" is "top = 9" now: an int is a TOML integer, the same value
SetTOMLKey writes for it, and no caller but that test passes anything but a
string here.

Verified on this head: go test ./internal/edit/ passes whole; the two new tests
and TestSetTOMLKeepsCRLF pass under -race -count=10; go vet ./... and the four
builds pass. TestTOMLBigFileParsesInLinearTime fails under -race -count=10 on
this head and on a clean main at e0bf3e61 alike (a 1s budget against ~1.2s under
the detector); it passes without -race.

---------

Co-authored-by: h4rK8s <63350517+h4rk8s@users.noreply.github.com>
This commit is contained in:
TryWorldandh4rK8s authored and GitHub committed 2026-10-06 16:14:35 +08:00
1 parent f109b270c0
commit efd501ec74
24 files changed
+2637 -12

No files matched your search

+27 -1
View File
@@ -222,7 +222,7 @@ Write your instructions, MCP servers and skills once. magpie writes them into ea
<table>
<tr><td>
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
</td></tr>
</table>
@@ -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
+1
View File
@@ -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 |
+1
View File
@@ -87,6 +87,7 @@ func All() []*Agent {
copilot(home),
crush(home, cfg),
dsh(home),
reasonix(home),
commandCode(home),
fx(home),
omp(home),
+658
View File
@@ -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, ", ") + "]")
}
+87
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !windows
package agent
func reasonixStudioDirs() []string { return nil }
+54
View File
@@ -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.")
}
+135
View File
@@ -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)
}
}
}
@@ -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)
}
})
}
+140
View File
@@ -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")
}
}
+79
View File
@@ -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.")
}
+80
View File
@@ -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)))
})
}
+585
View File
@@ -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)
}
}
+72
View File
@@ -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")
}
})
}
}
+2
View File
@@ -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/)
+3 -1
View File
@@ -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)
}
+65 -2
View File
@@ -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.
+159
View File
@@ -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
}
+111
View File
@@ -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)
}
}
+98
View File
@@ -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)
}
})
}
}
+17 -8
View File
@@ -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();
+12
View File
@@ -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",
@@ -0,0 +1,48 @@
<!--
Reasonix Studio app icon, from esengine/DeepSeek-Reasonix/desktop/electron/assets/icon.svg.
MIT License
Copyright (c) 2026 Reasonix Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#23211d"/>
<stop offset="1" stop-color="#0b0a09"/>
</linearGradient>
<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#f6dc9a"/>
<stop offset=".48" stop-color="#d4a652"/>
<stop offset="1" stop-color="#9a6b25"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e2bd6e" stop-opacity=".55"/>
<stop offset="1" stop-color="#e2bd6e" stop-opacity=".08"/>
</linearGradient>
</defs>
<rect x="103" y="103" width="818" height="818" rx="190" fill="url(#tile)"/>
<rect x="106" y="106" width="812" height="812" rx="187" fill="none" stroke="url(#rim)" stroke-width="6"/>
<g fill="url(#gold)" transform="translate(512 512) scale(1.9) translate(-359.74 -192.62)">
<path d="M369.75,185.3c2.09,1.76,4.98,2.41,7.95,1.06,1.18-.54,1.96-1.91,1.47-3.25-3.92-10.71-14.97-18.54-19.38-12.48-.54.74-.52,1.7-.11,2.29.32.47.97.57,1.82.4,2.68-.56,6.02,1.31,5.7,4.3-.33,3.12.3,5.79,2.56,7.69Z"/>
<path d="M434.28,204.78c10.12-11.32,16.46-25.01,18.67-40.07,5.04-32.93-11.31-63.01-42.11-75.75-10.83-4.35-21.95-6.2-33.76-6.09l-114.5.03v219.42s56.29-.04,56.29-.04v-77.17c-1.86-.69-3.15-1.27-3.15-1.27-12.24-5.59-21.6-15.29-26.74-27.44-6.56-15.51-4.54-32.9,5.78-46.12,10.28-12.84,24.31-16.92,40.41-14.67,5.95.83,12.3-6.15,24.4-4.81.81.09,1.63.7,1.71,1.18.28,1.65-4.4,2.54-4.4,7.05,0,1.9.85,4.1,2.65,5.36,6.17,4.33,11.51,9.19,17.1,14.22,2.72,2.44,11.93,9.31,14.27,3.72,1.37-3.26,2.27-6.68,3.18-10.15.45-1.72-.5-2.74-1.89-3.56-9.5-5.63-13.08-17.35-9.37-27.74.36-1,1.29-1.49,2.08-1.52,3.19-.14,1.49,5.78,9.38,8.21,7.49,2.31,7.71,8.53,11.35,6.05,8.48-5.78,11.54-1.05,19.41-8.46.81-.76,2.2-.82,3.04-.23.52.36.97,1.29.93,2.38-.23,6.26-2.67,12.24-6.95,16.84-8.09,8.69-14.89,4.53-15.33,10.62-1.29,17.86-7.13,35.69-19.81,48.66-.41.42-.65,1.01-.58,1.41.07.42.57.83,1.12,1.01l10.68,3.63c1.51.51,2.6,1.87,2.41,3.31-.17,1.26-1.17,2.37-2.61,2.85-7.84,2.59-16.26,2.14-24.24-.27-10.56,8.89-23.54,13.48-37.68,12.93l20.53,28.34,33.49,45.73,66.85-.09-56.68-76.53c13.19-3.89,24.78-10.87,34.07-20.94Z"/>
<circle cx="362.14" cy="177.19" r="1.58"/>
<path d="M300.31,167.85c-3.53.43-5.65,2.96-5.31,6.74,2.39,26.35,26.7,47.92,53.94,42.72,5.8-1.11,11.08-3.41,15.36-7.74-9.27-7.98-13.79-16.12-21.41-24.13-10.91-11.47-26.43-19.54-42.58-17.59Z"/>
</g>
</svg>
+155
View File
@@ -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, []);
});
}
}
}