v0.14.0 75d4151

modelsync_test.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"net/http"
8	"net/http/httptest"
9	"os"
10	"path/filepath"
11	"testing"
12
13	"github.com/bnema/zut/packages/provider"
14)
15
16func TestFilterCacheByProviderScopesRemovesMismatchedProvider(t *testing.T) {
17	cache := provider.ModelCache{
18		Models: []provider.Model{
19			{Provider: "openai-codex", ID: "account-a-only"},
20			{Provider: "openai", ID: "shared"},
21		},
22		AuthoritativeProviders: []string{"openai-codex"},
23		ProviderScopes:         map[string]string{"openai-codex": "account-a"},
24	}
25
26	filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-b"})
27	if len(filtered.Models) != 1 || filtered.Models[0].Provider != "openai" {
28		t.Fatalf("models = %+v, want only non-scoped model", filtered.Models)
29	}
30	if len(filtered.AuthoritativeProviders) != 0 {
31		t.Fatalf("authoritative providers = %v, want none", filtered.AuthoritativeProviders)
32	}
33	if len(filtered.ProviderScopes) != 0 {
34		t.Fatalf("provider scopes = %v, want none", filtered.ProviderScopes)
35	}
36}
37
38func TestFilterCacheByProviderScopesRetainsMatchingScopedProvider(t *testing.T) {
39	cache := provider.ModelCache{
40		Models:                 []provider.Model{{Provider: "openai-codex", ID: "account-a-only"}},
41		AuthoritativeProviders: []string{"openai-codex"},
42		ProviderScopes:         map[string]string{"openai-codex": "account-a"},
43	}
44
45	filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-a"})
46	if len(filtered.Models) != 1 || len(filtered.AuthoritativeProviders) != 1 || filtered.ProviderScopes["openai-codex"] != "account-a" {
47		t.Fatalf("matching scoped cache was not preserved: %+v", filtered)
48	}
49}
50
51func TestFilterCacheByProviderScopesDropsLegacyAuthoritativeCodex(t *testing.T) {
52	cache := provider.ModelCache{
53		Models:                 []provider.Model{{Provider: "openai-codex", ID: "legacy"}},
54		AuthoritativeProviders: []string{"openai-codex"},
55	}
56
57	filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-a"})
58	if len(filtered.Models) != 0 || len(filtered.AuthoritativeProviders) != 0 {
59		t.Fatalf("legacy Codex cache was retained: %+v", filtered)
60	}
61}
62
63func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
64	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65		if r.URL.Path != "/models" {
66			http.NotFound(w, r)
67			return
68		}
69		fmt.Fprint(w, `{"data":[{"id":"loaded","status":{"value":"loaded"},"meta":{"n_ctx":32768}},{"id":"offline","status":{"value":"unloaded"}}]}`)
70	}))
71	defer server.Close()
72
73	t.Setenv("ZUT_HOME", t.TempDir())
74	t.Setenv("LLAMA_BASE_URL", "")
75	if err := AuthStoreFor().SetEndpointCredential(provider.LlamaCPPProviderID, server.URL, ""); err != nil {
76		t.Fatal(err)
77	}
78	provider.SetManagedModels(nil)
79	t.Cleanup(func() { provider.SetManagedModels(nil) })
80
81	if err := RefreshLlamaCPPModels(context.Background()); err != nil {
82		t.Fatal(err)
83	}
84	loaded, err := provider.FindModel(provider.LlamaCPPProviderID, "loaded")
85	if err != nil {
86		t.Fatal(err)
87	}
88	if loaded.ContextWindow != 32768 || loaded.BaseURL != server.URL+"/v1" {
89		t.Fatalf("loaded model = %+v", loaded)
90	}
91	if _, err := provider.FindModel(provider.LlamaCPPProviderID, "offline"); err == nil {
92		t.Fatal("unloaded model must not be selectable")
93	}
94}
95
96// TestValidateAndRepairConfig_MismatchedPair simulates the bug from a
97// stale /model switch: provider=anthropic but model=kimi-for-coding
98// (which belongs to provider=kimi). The validator should rewrite the
99// model to anthropic's default and persist.
100func TestValidateAndRepairConfig_MismatchedPair(t *testing.T) {
101	home := t.TempDir()
102	t.Setenv("ZUT_HOME", home)
103
104	must := func(c Config) {
105		t.Helper()
106		b, _ := json.Marshal(c)
107		if err := os.WriteFile(filepath.Join(home, "config.json"), b, 0o644); err != nil {
108			t.Fatal(err)
109		}
110	}
111	must(Config{Provider: "anthropic", Model: "kimi-for-coding"})
112
113	ValidateAndRepairConfig()
114
115	out, err := LoadConfig()
116	if err != nil {
117		t.Fatal(err)
118	}
119	if out.Provider != "anthropic" {
120		t.Errorf("provider not preserved: %q", out.Provider)
121	}
122	if out.Model == "kimi-for-coding" {
123		t.Errorf("model not repaired; still %q", out.Model)
124	}
125	if out.Model == "" {
126		t.Errorf("model not set; expected anthropic default")
127	}
128}
129
130// TestValidateAndRepairConfig_UnknownProvider resets to anthropic and
131// clears the model when the saved provider id isn't recognised
132// (e.g. user removed it from a previous build).
133func TestValidateAndRepairConfig_UnknownProvider(t *testing.T) {
134	home := t.TempDir()
135	t.Setenv("ZUT_HOME", home)
136
137	b, _ := json.Marshal(Config{Provider: "made-up-provider", Model: "some-model"})
138	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
139
140	ValidateAndRepairConfig()
141
142	out, _ := LoadConfig()
143	if out.Provider != "anthropic" {
144		t.Errorf("provider not reset: %q", out.Provider)
145	}
146	if out.Model != "" {
147		t.Errorf("model not cleared: %q", out.Model)
148	}
149}
150
151// TestValidateAndRepairConfig_UnknownModel keeps the provider but
152// snaps the model to that provider's default when the saved id is no
153// longer in the catalog.
154func TestValidateAndRepairConfig_UnknownModel(t *testing.T) {
155	home := t.TempDir()
156	t.Setenv("ZUT_HOME", home)
157
158	b, _ := json.Marshal(Config{Provider: "anthropic", Model: "claude-deleted-model"})
159	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
160
161	ValidateAndRepairConfig()
162
163	out, _ := LoadConfig()
164	if out.Provider != "anthropic" {
165		t.Errorf("provider changed: %q", out.Provider)
166	}
167	if out.Model == "" || out.Model == "claude-deleted-model" {
168		t.Errorf("model not repaired: %q", out.Model)
169	}
170}
171
172func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *testing.T) {
173	home := t.TempDir()
174	t.Setenv("ZUT_HOME", home)
175
176	b, _ := json.Marshal(Config{Provider: "openai-codex", Model: "gpt-5.5"})
177	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
178
179	ValidateAndRepairConfig()
180
181	out, _ := LoadConfig()
182	if out.Provider != "openai-codex" {
183		t.Errorf("provider mutated: %q", out.Provider)
184	}
185	if out.Model != "gpt-5.5" {
186		t.Errorf("model mutated: %q", out.Model)
187	}
188}
189
190func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) {
191	home := t.TempDir()
192	t.Setenv("ZUT_HOME", home)
193
194	want := "deepseek/deepseek-v4-flash"
195	b, _ := json.Marshal(Config{Provider: "openrouter", Model: want})
196	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
197
198	ValidateAndRepairConfig()
199
200	out, _ := LoadConfig()
201	if out.Provider != "openrouter" {
202		t.Errorf("provider mutated: %q", out.Provider)
203	}
204	if out.Model != want {
205		t.Errorf("routed model id mutated: got %q, want %q", out.Model, want)
206	}
207}
208
209func TestValidateAndRepairConfig_GatewayPlainUnknownModelStillRepairs(t *testing.T) {
210	home := t.TempDir()
211	t.Setenv("ZUT_HOME", home)
212
213	b, _ := json.Marshal(Config{Provider: "openrouter", Model: "not-a-routed-model"})
214	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
215
216	ValidateAndRepairConfig()
217
218	out, _ := LoadConfig()
219	if out.Provider != "openrouter" {
220		t.Errorf("provider mutated: %q", out.Provider)
221	}
222	if out.Model == "not-a-routed-model" || out.Model == "" {
223		t.Errorf("plain unknown gateway model was not repaired: %q", out.Model)
224	}
225}
226
227// TestValidateAndRepairConfig_HappyPath leaves a valid config alone.
228func TestValidateAndRepairConfig_HappyPath(t *testing.T) {
229	home := t.TempDir()
230	t.Setenv("ZUT_HOME", home)
231
232	b, _ := json.Marshal(Config{Provider: "anthropic", Model: "claude-sonnet-4-5"})
233	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
234
235	ValidateAndRepairConfig()
236
237	out, _ := LoadConfig()
238	if out.Provider != "anthropic" {
239		t.Errorf("provider mutated: %q", out.Provider)
240	}
241	if out.Model != "claude-sonnet-4-5" {
242		t.Errorf("model mutated: %q", out.Model)
243	}
244}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close