agents: keep Air provider fields on sync and model picks (#1239)
This commit is contained in:
1 parent
70ec514872
commit
ad3b7fe5c2
3 files changed
+109
-1
No files matched your search
@@ -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
|
||||
```
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user