agents: keep Air provider fields on sync and model picks (#1239)

This commit is contained in:
Kenoia authored and GitHub committed 2026-10-08 11:20:35 +08:00
1 parent 70ec514872
commit ad3b7fe5c2
3 files changed
+109 -1

No files matched your search

+2
View File
@@ -34,6 +34,7 @@ something else shows up as drift.
## Constraints and failure behavior
- magpie only removes what it wrote. Anything else in an agent's config stays as it was.
- JetBrains Air's `magpie-opencode.json` keeps user-added fields in `provider.magpie`, including nested options, on sync and model picks (`air` in [`air.go`](../../internal/agent/air.go), using `theirsKept` in [`sync.go`](../../internal/agent/sync.go)). The gateway fields and complete model list are still replaced, so removed models leave the list.
- Codex signed in to ChatGPT stays beside its sign-in (`openai_base_url`) unless the user picks magpie API. magpie becomes its provider only while the Codex app holds the active account, which it sends nothing for: `/wham/usage` says it isn't allowed, and it has no credits and isn't a workspace still within its overage (`codexHeld`, read by `provider.CodexUsedUp`). Where the app goes by what usage can't show (an experiment's gate, a reserve, a Team plan's overage under the reserve experiment), it counts as held: a wrong move costs ChatGPT extras, a missed one every turn. A saved reading kept through a failed read stops saying held once its used-up window has started again; a hold with no window used up stays till a fresh read. The app then loses its ChatGPT state (durable threads, remote control), so a window at 100% alone, which credits get past, doesn't move it (`codex.out` marks the move, and Sync undoes it once the account has room).
- Moving a field from one of magpie's models to another (the agent's own picker) is not drift. Moving it off magpie is.
- A field that follows another while empty (`Follows`: Claude Code's tiers and subagents on its main model) is read by `Drift` as on that field's model. The main model moved, in Claude Code's own `/model`, onto the model magpie had set a tier to is not drift (#1050).
@@ -52,5 +53,6 @@ something else shows up as drift.
```sh
go test -tags nogui ./internal/agent -run 'TestConnect|TestDisconnect|Drift|TestKeptAddress|TestWSLAgentAddress|TestReachProbe|TestDryRunKnowsTheAskersProviders|TestReseat|TestPort|TestWSL|TestClaudeKeepsItsClaudeAISignIn|TestClaudeSignIn|TestClaudeOwnEndpointWithAnEmptyKey|TestClaudeAtAnOlderGatewayAddress'
go test -tags nogui ./internal/agent
go test -tags nogui ./internal/agent -run '^TestAir'
node --test internal/gui/tests/agent-connect.test.cjs internal/gui/tests/agent-disconnect-preview.test.cjs internal/gui/tests/claude-signin.test.cjs
```
+1 -1
View File
@@ -88,7 +88,7 @@ func air(home, cfg string) *Agent {
// the OpenCode config magpie's agent runs on, magpie's own file
ocPath := filepath.Join(dir, "magpie-opencode.json")
entry := "agent_servers." + airAgent
provider := func() any { return magpieProviderJSONFor("opencode", "air") }
provider := theirsKept(ocPath, "provider."+magpieID, func() any { return magpieProviderJSONFor("opencode", "air") }, "models")
// ours: acp.json's Magpie is the agent magpie added, on its file
ours := func() bool {
v, _ := edit.GetJSON(path, entry+".env.OPENCODE_CONFIG")
+106
View File
@@ -1,10 +1,12 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/provider"
@@ -171,3 +173,107 @@ func TestAirCheckAndSync(t *testing.T) {
t.Fatalf("drift after setting it again: %+v", d)
}
}
// Sync and a model pick keep the user's provider fields, while magpie's
// endpoint, key and complete model list still follow the gateway.
func TestAirProviderWritesKeepCustomFields(t *testing.T) {
for _, action := range []string{"sync", "pick"} {
t.Run(action, func(t *testing.T) {
home, dir := airHome(t, "/opt/bin/opencode")
a := air(home, filepath.Join(home, ".config"))
cfg := filepath.Join(dir, "magpie-opencode.json")
if err := a.Apply("model", "magpie/fake/m1"); err != nil {
t.Fatal(err)
}
writeFile(t, cfg, `{
// the user's configuration
"theme": "dark",
"model": "magpie/fake/m1",
"provider": {
"mine": {"name": "mine"},
"magpie": {
"name": "old",
"npm": "old",
"options": {
"baseURL": "http://127.0.0.1:1/v1",
"apiKey": "old-key",
"timeout": 12345,
"headers": {"z": "last", "a": "first"}
},
"compat": {"sendSessionAffinityHeaders": true, "supportsLongCacheRetention": false},
"marker": {"z": 9007199254740993, "a": null},
"models": {"fake/m1": {"obsolete": true}, "removed/m0": {}}
}
}
}
`)
kept := map[string]string{}
for _, field := range []string{"compat", "marker", "options.timeout", "options.headers"} {
kept[field], _ = edit.GetJSON(cfg, "provider.magpie."+field)
}
check := func() {
t.Helper()
for field, was := range kept {
if v, ok := edit.GetJSON(cfg, "provider.magpie."+field); !ok || !sameOrder(v, json.RawMessage(was)) {
t.Errorf("custom %s lost or changed: got %q, want %q", field, v, was)
}
}
if !strings.Contains(readFile(cfg), "// the user's configuration") {
t.Error("the user's comment was removed")
}
for k, want := range map[string]string{"theme": "dark", "provider.mine.name": "mine"} {
if got, _ := edit.GetJSON(cfg, k); got != want {
t.Errorf("%s = %q, want %q", k, got, want)
}
}
want := magpieProviderJSONFor("opencode", "air").(map[string]any)
for k, v := range want["options"].(map[string]any) {
if got, _ := edit.GetJSON(cfg, "provider.magpie.options."+k); got != v {
t.Errorf("options.%s = %q, want %v", k, got, v)
}
}
for _, k := range []string{"name", "npm", "models"} {
got, _ := edit.GetJSON(cfg, "provider.magpie."+k)
if k != "models" {
b, _ := json.Marshal(got)
got = string(b)
}
if !sameOrder(got, want[k]) {
t.Errorf("managed %s is stale: %s", k, got)
}
}
}
if action == "sync" {
if err := a.Sync(); err != nil {
t.Fatal(err)
}
} else if err := a.Apply("model", "magpie/fake/m1"); err != nil {
t.Fatal(err)
}
check()
// The old catalog entry must go when the provider changes.
if err := provider.Save(provider.Provider{ID: "fake", Name: "Fake", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m2"}}); err != nil {
t.Fatal(err)
}
if err := a.Sync(); err != nil {
t.Fatal(err)
}
check()
before := readFile(cfg)
stamp := time.Unix(1000000000, 0)
if err := os.Chtimes(cfg, stamp, stamp); err != nil {
t.Fatal(err)
}
if err := a.Sync(); err != nil {
t.Fatal(err)
}
stat, err := os.Stat(cfg)
if err != nil {
t.Fatal(err)
}
if readFile(cfg) != before || !stat.ModTime().Equal(stamp) {
t.Error("unchanged provider was rewritten")
}
})
}
}