diff --git a/internal/agent/claude.go b/internal/agent/claude.go index 671435c1..979f90b5 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -2,6 +2,7 @@ package agent import ( "cmp" + "encoding/json" "fmt" "os" "path/filepath" @@ -427,8 +428,14 @@ func claudeIn(at place) *Agent { // model (settings.Compact), none under Full window main := mainModel() n := provider.CompactSet(main) - if n == 0 && !claudeModel(main) { - n = settings.Load().Compact() + if s := settings.Load(); n == 0 && !claudeModel(main) { + n = s.Compact() + // magpie's default gives way to an autoCompactWindow the user + // set in Claude Code (/autocompact, its settings), which the env + // would take precedence over; one typed in magpie doesn't (#876) + if s.CompactAt == 0 && claudeOwnCompact(path, main) { + n = 0 + } } if n == 0 { return dropCompact() @@ -1290,6 +1297,33 @@ func claudeModel(ref string) bool { return strings.HasPrefix(strings.ToLower(m), "claude") } +// claudeOwnCompact says the settings.json at path has an auto-compact +// window of the user's for model: autoCompactWindow, or one under +// modelSettings for it (as /autocompact saves it), a number or "auto". +// Claude Code takes CLAUDE_CODE_AUTO_COMPACT_WINDOW before either. +func claudeOwnCompact(path, model string) bool { + if _, ok := edit.GetJSON(path, "autoCompactWindow"); ok { + return true + } + raw, ok := edit.GetJSON(path, "modelSettings") + if !ok { + return false + } + var per map[string]struct { + Window any `json:"autoCompactWindow"` + } + if json.Unmarshal([]byte(raw), &per) != nil { + return false + } + model = strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(model, "magpie/"), "[1m]")) + for k, v := range per { + if v.Window != nil && strings.ToLower(strings.TrimSuffix(k, "[1m]")) == model { + return true + } + } + return false +} + // claudeWindow is the context window to tell Claude Code for the models it // runs on, 0 when magpie doesn't know it. One value serves every model not // marked [1m]: the main model's, or when that one is marked, the smallest of diff --git a/internal/agent/claude_compact_test.go b/internal/agent/claude_compact_test.go index 1c2a7ee7..35b539d7 100644 --- a/internal/agent/claude_compact_test.go +++ b/internal/agent/claude_compact_test.go @@ -138,3 +138,62 @@ func TestClaudeCompactAt(t *testing.T) { must(a.Field("model").Set("v/big")) expect("full window", "") } + +// An auto-compact window the user set in Claude Code (autoCompactWindow, +// or modelSettings..autoCompactWindow as /autocompact saves it) is +// theirs: magpie's default doesn't write the env that would take +// precedence over it; a size typed in magpie, or on the model or its +// provider, still does (#876, hisiling). +func TestClaudeCompactYieldsToOwnSetting(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + if err := provider.Save(provider.Provider{ID: "v", Name: "V", Chat: "https://example.test/v1", Key: "k", + Models: []string{"big", "flash"}}); err != nil { + t.Fatal(err) + } + if err := catalog.SaveLive("v", "https://example.test/v1", []catalog.Model{{ID: "big", Context: 1000000}, {ID: "flash", Context: 1000000}}); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, ".claude", "settings.json") + writeFile(t, path, `{"autoCompactWindow": 900000}`) + a := claude(home) + compact := func() string { v, _ := edit.GetJSON(path, "env."+claudeCompactEnv); return v } + must := func(err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + } + expect := func(what, want string) { + t.Helper() + if got := compact(); got != want { + t.Fatalf("%s: %q; want %q", what, got, want) + } + } + + must(a.Field("model").Set("v/big[1m]")) + expect("autoCompactWindow", "") + if v, _ := edit.GetJSON(path, "autoCompactWindow"); v != "900000" { + t.Fatalf("autoCompactWindow changed: %q", v) + } + must(edit.DelJSON(path, "autoCompactWindow")) + must(a.Sync()) + expect("none set", "272000") + // one for this model in modelSettings, not another's + must(edit.SetJSON(path, edit.KV{Path: "modelSettings", Value: map[string]any{"v/flash": map[string]any{"autoCompactWindow": 800000}}})) + must(a.Sync()) + expect("another model's", "272000") + must(a.Field("model").Set("v/flash[1m]")) + expect("this model's", "") + // a size typed in magpie, or the model's own there, comes first + must(provider.SetModelCompacts("v", map[string]int{"flash": 200000})) + must(a.Sync()) + expect("the model's own in magpie", "200000") + must(provider.SetModelCompacts("v", nil)) + must(provider.SetCompactAt(400000)) + must(a.Sync()) + expect("typed in magpie", "400000") +}