v0.38.0 58133ea

modelsync_test.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"net/http"
9	"net/http/httptest"
10	"os"
11	"path/filepath"
12	"sync"
13	"testing"
14	"time"
15
16	"github.com/bnema/zut/packages/provider"
17	"github.com/bnema/zut/packages/provider/auth"
18)
19
20func TestFilterCacheByProviderScopesRemovesMismatchedProvider(t *testing.T) {
21	cache := provider.ModelCache{
22		Models: []provider.Model{
23			{Provider: "openai-codex", ID: "account-a-only"},
24			{Provider: "openai", ID: "shared"},
25		},
26		AuthoritativeProviders: []string{"openai-codex"},
27		ProviderScopes:         map[string]string{"openai-codex": "account-a"},
28	}
29
30	filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-b"})
31	if len(filtered.Models) != 1 || filtered.Models[0].Provider != "openai" {
32		t.Fatalf("models = %+v, want only non-scoped model", filtered.Models)
33	}
34	if len(filtered.AuthoritativeProviders) != 0 {
35		t.Fatalf("authoritative providers = %v, want none", filtered.AuthoritativeProviders)
36	}
37	if len(filtered.ProviderScopes) != 0 {
38		t.Fatalf("provider scopes = %v, want none", filtered.ProviderScopes)
39	}
40}
41
42func TestFilterCacheByProviderScopesRetainsMatchingScopedProvider(t *testing.T) {
43	cache := provider.ModelCache{
44		Models:                 []provider.Model{{Provider: "openai-codex", ID: "account-a-only"}},
45		AuthoritativeProviders: []string{"openai-codex"},
46		ProviderScopes:         map[string]string{"openai-codex": "account-a"},
47	}
48
49	filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-a"})
50	if len(filtered.Models) != 1 || len(filtered.AuthoritativeProviders) != 1 || filtered.ProviderScopes["openai-codex"] != "account-a" {
51		t.Fatalf("matching scoped cache was not preserved: %+v", filtered)
52	}
53}
54
55func TestFilterCacheByProviderScopesRemovesRotatedOpenCodeGoCredential(t *testing.T) {
56	cache := provider.ModelCache{
57		Models:                 []provider.Model{{Provider: "opencode-go", ID: "old-key-model"}},
58		AuthoritativeProviders: []string{"opencode-go"},
59		ProviderScopes:         map[string]string{"opencode-go": credentialScopeForEndpoint("old-key", "")},
60	}
61
62	filtered := filterCacheByProviderScopes(cache, map[string]string{"opencode-go": credentialScopeForEndpoint("new-key", "")})
63	if len(filtered.Models) != 0 || len(filtered.AuthoritativeProviders) != 0 || len(filtered.ProviderScopes) != 0 {
64		t.Fatalf("rotated OpenCode Go cache was retained: %+v", filtered)
65	}
66}
67
68func TestLoadCachedModelsFiltersExplicitOpenCodeGoCredentialScope(t *testing.T) {
69	home := t.TempDir()
70	t.Setenv("ZUT_HOME", home)
71	t.Setenv("OPENCODE_API_KEY", "")
72
73	if err := provider.SaveCache(filepath.Join(home, "models-cache.json"), provider.ModelCache{
74		Version:                provider.ModelCacheVersion,
75		FetchedAt:              time.Now(),
76		Models:                 []provider.Model{{Provider: "opencode-go", ID: "old-key-model"}},
77		AuthoritativeProviders: []string{"opencode-go"},
78		ProviderScopes:         map[string]string{"opencode-go": credentialScopeForEndpoint("old-key", "")},
79	}); err != nil {
80		t.Fatal(err)
81	}
82	provider.SetLiveModels(nil)
83	t.Cleanup(func() { provider.SetLiveModels(nil) })
84
85	loadCachedModels(map[string]string{"opencode-go": credentialScopeForEndpoint("new-key", "")})
86	if _, err := provider.FindModel("opencode-go", "old-key-model"); err == nil {
87		t.Fatal("cached model from another OpenCode Go credential remained active")
88	}
89}
90
91func TestLoadCachedModelsClearsMismatchedActiveOpenCodeGoOverlay(t *testing.T) {
92	home := t.TempDir()
93	t.Setenv("ZUT_HOME", home)
94	provider.SetLiveModels([]provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "stale-model", BaseURL: "https://old.example/v1"}})
95	t.Cleanup(func() { provider.SetLiveModels(nil) })
96
97	loadCachedModels(map[string]string{provider.ProviderOpenCodeGo: credentialScopeForEndpoint("new-key", "https://new.example/v1")})
98	if _, err := provider.FindModel(provider.ProviderOpenCodeGo, "stale-model"); err == nil {
99		t.Fatal("mismatched OpenCode Go metadata remained active after cache filtering")
100	}
101}
102
103func TestLoadCachedModelsRejectsOldRoutingMetadata(t *testing.T) {
104	home := t.TempDir()
105	t.Setenv("ZUT_HOME", home)
106	if err := provider.SaveCache(filepath.Join(home, "models-cache.json"), provider.ModelCache{
107		Version:   provider.ModelCacheVersion - 1,
108		FetchedAt: time.Now(),
109		Models: []provider.Model{{
110			Provider: provider.ProviderOpenCodeGo,
111			ID:       "old-routing-model",
112			API:      provider.APIResponses,
113		}},
114		AuthoritativeProviders: []string{provider.ProviderOpenCodeGo},
115		ProviderScopes: map[string]string{
116			provider.ProviderOpenCodeGo: credentialScopeForEndpoint("key", ""),
117		},
118	}); err != nil {
119		t.Fatal(err)
120	}
121	provider.SetLiveModels(nil)
122	t.Cleanup(func() { provider.SetLiveModels(nil) })
123
124	loadCachedModels(map[string]string{provider.ProviderOpenCodeGo: credentialScopeForEndpoint("key", "")})
125	if _, err := provider.FindModel(provider.ProviderOpenCodeGo, "old-routing-model"); err == nil {
126		t.Fatal("old routing metadata remained active after cache load")
127	}
128}
129
130func TestFilterCacheByProviderScopesDropsLegacyAuthoritativeCodex(t *testing.T) {
131	cache := provider.ModelCache{
132		Models:                 []provider.Model{{Provider: "openai-codex", ID: "legacy"}},
133		AuthoritativeProviders: []string{"openai-codex"},
134	}
135
136	filtered := filterCacheByProviderScopes(cache, map[string]string{"openai-codex": "account-a"})
137	if len(filtered.Models) != 0 || len(filtered.AuthoritativeProviders) != 0 {
138		t.Fatalf("legacy Codex cache was retained: %+v", filtered)
139	}
140}
141
142func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
143	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144		if r.URL.Path != "/models" {
145			http.NotFound(w, r)
146			return
147		}
148		fmt.Fprint(w, `{"data":[{"id":"loaded","status":{"value":"loaded"},"meta":{"n_ctx":32768}},{"id":"offline","status":{"value":"unloaded"}}]}`)
149	}))
150	defer server.Close()
151
152	t.Setenv("ZUT_HOME", t.TempDir())
153	t.Setenv("LLAMA_BASE_URL", "")
154	if err := AuthStoreFor().SetEndpointCredential(provider.LlamaCPPProviderID, server.URL, ""); err != nil {
155		t.Fatal(err)
156	}
157	provider.SetManagedModels(nil)
158	t.Cleanup(func() { provider.SetManagedModels(nil) })
159
160	if err := RefreshLlamaCPPModels(context.Background()); err != nil {
161		t.Fatal(err)
162	}
163	loaded, err := provider.FindModel(provider.LlamaCPPProviderID, "loaded")
164	if err != nil {
165		t.Fatal(err)
166	}
167	if loaded.ContextWindow != 32768 || loaded.BaseURL != server.URL+"/v1" {
168		t.Fatalf("loaded model = %+v", loaded)
169	}
170	if _, err := provider.FindModel(provider.LlamaCPPProviderID, "offline"); err == nil {
171		t.Fatal("unloaded model must not be selectable")
172	}
173}
174
175// TestValidateAndRepairConfig_MismatchedPair simulates the bug from a
176// stale /model switch: provider=anthropic but model=kimi-for-coding
177// (which belongs to provider=kimi). The validator should rewrite the
178// model to anthropic's default and persist.
179func TestValidateAndRepairConfig_MismatchedPair(t *testing.T) {
180	home := t.TempDir()
181	t.Setenv("ZUT_HOME", home)
182
183	must := func(c Config) {
184		t.Helper()
185		b, _ := json.Marshal(c)
186		if err := os.WriteFile(filepath.Join(home, "config.json"), b, 0o644); err != nil {
187			t.Fatal(err)
188		}
189	}
190	must(Config{Provider: "anthropic", Model: "kimi-for-coding"})
191
192	ValidateAndRepairConfig()
193
194	out, err := LoadConfig()
195	if err != nil {
196		t.Fatal(err)
197	}
198	if out.Provider != "anthropic" {
199		t.Errorf("provider not preserved: %q", out.Provider)
200	}
201	if out.Model == "kimi-for-coding" {
202		t.Errorf("model not repaired; still %q", out.Model)
203	}
204	if out.Model == "" {
205		t.Errorf("model not set; expected anthropic default")
206	}
207}
208
209// TestValidateAndRepairConfig_UnknownProvider resets to anthropic and
210// clears the model when the saved provider id isn't recognised
211// (e.g. user removed it from a previous build).
212func TestValidateAndRepairConfig_UnknownProvider(t *testing.T) {
213	home := t.TempDir()
214	t.Setenv("ZUT_HOME", home)
215
216	b, _ := json.Marshal(Config{Provider: "made-up-provider", Model: "some-model"})
217	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
218
219	ValidateAndRepairConfig()
220
221	out, _ := LoadConfig()
222	if out.Provider != "anthropic" {
223		t.Errorf("provider not reset: %q", out.Provider)
224	}
225	if out.Model != "" {
226		t.Errorf("model not cleared: %q", out.Model)
227	}
228}
229
230// TestValidateAndRepairConfig_UnknownModel keeps the provider but
231// snaps the model to that provider's default when the saved id is no
232// longer in the catalog.
233func TestValidateAndRepairConfig_UnknownModel(t *testing.T) {
234	home := t.TempDir()
235	t.Setenv("ZUT_HOME", home)
236
237	b, _ := json.Marshal(Config{Provider: "anthropic", Model: "claude-deleted-model"})
238	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
239
240	ValidateAndRepairConfig()
241
242	out, _ := LoadConfig()
243	if out.Provider != "anthropic" {
244		t.Errorf("provider changed: %q", out.Provider)
245	}
246	if out.Model == "" || out.Model == "claude-deleted-model" {
247		t.Errorf("model not repaired: %q", out.Model)
248	}
249}
250
251func TestValidateAndRepairConfig_OpenCodeGoPreservesDynamicModel(t *testing.T) {
252	home := t.TempDir()
253	t.Setenv("ZUT_HOME", home)
254
255	const model = "muse-spark-1.2-contributor"
256	b, _ := json.Marshal(Config{Provider: "opencode-go", Model: model})
257	if err := os.WriteFile(filepath.Join(home, "config.json"), b, 0o644); err != nil {
258		t.Fatal(err)
259	}
260
261	ValidateAndRepairConfig()
262
263	out, err := LoadConfig()
264	if err != nil {
265		t.Fatal(err)
266	}
267	if out.Provider != "opencode-go" || out.Model != model {
268		t.Fatalf("dynamic OpenCode Go config changed: provider=%q model=%q", out.Provider, out.Model)
269	}
270}
271
272func TestResolveAllowsUnknownOpenCodeGoModelBeforeDiscovery(t *testing.T) {
273	t.Setenv("ZUT_HOME", t.TempDir())
274	t.Setenv("OPENCODE_API_KEY", "")
275
276	const model = "muse-spark-1.2-contributor"
277	resolved, err := Resolve(Args{Provider: "opencode-go", Model: model}, false)
278	if err != nil {
279		t.Fatal(err)
280	}
281	if resolved.Provider != "opencode-go" || resolved.Model != model {
282		t.Fatalf("resolved dynamic model = provider=%q model=%q", resolved.Provider, resolved.Model)
283	}
284	if resolved.ContextWindow != 128000 || resolved.MaxOutput != 16384 {
285		t.Fatalf("bootstrap model limits = context %d output %d", resolved.ContextWindow, resolved.MaxOutput)
286	}
287	if resolved.ModelCatalogSnapshot() != nil {
288		t.Fatal("normal Resolve captured a private OpenCode Go catalog snapshot")
289	}
290}
291
292func TestResolveRejectsUnknownOpenCodeGoModelAfterDiscovery(t *testing.T) {
293	preserveProviderCatalog(t)
294	t.Setenv("ZUT_HOME", t.TempDir())
295	t.Setenv("OPENCODE_API_KEY", "")
296	provider.SetLiveModelsForProviders([]provider.Model{{
297		Provider:      provider.ProviderOpenCodeGo,
298		ID:            "served-model",
299		ContextWindow: 128000,
300		MaxOutput:     16384,
301	}}, []string{provider.ProviderOpenCodeGo})
302	provider.SetUserModels(nil)
303
304	resolved, err := Resolve(Args{Provider: provider.ProviderOpenCodeGo, Model: "removed-model"}, false)
305	if err != nil {
306		t.Fatal(err)
307	}
308	if resolved.Model != "served-model" {
309		t.Fatalf("unknown model resolved to %q, want served-model", resolved.Model)
310	}
311}
312
313func TestResolveFallbackUsesCapturedOpenCodeGoCatalog(t *testing.T) {
314	preserveProviderCatalog(t)
315	t.Setenv("ZUT_HOME", t.TempDir())
316	t.Setenv("OPENCODE_API_KEY", "")
317
318	const modelID = "shared-scoped-model"
319	provider.SetLiveModelsForProviders([]provider.Model{{
320		Provider:      provider.ProviderOpenCodeGo,
321		ID:            modelID,
322		ContextWindow: 111000,
323		MaxOutput:     11000,
324		BaseURL:       "https://account-a.example/v1",
325	}}, []string{provider.ProviderOpenCodeGo})
326	args := Args{
327		Provider:                  provider.ProviderOpenCodeGo,
328		Model:                     "removed-from-account-a",
329		APIKey:                    "account-a-key",
330		modelCatalog:              provider.ModelsForProvider(provider.ProviderOpenCodeGo),
331		modelCatalogAuthoritative: true,
332		NoTools:                   true,
333	}
334
335	// A later runtime replaces the process-global catalog with another
336	// credential's endpoint. The first runtime must resolve its fallback from
337	// the snapshot it captured before that replacement.
338	provider.SetLiveModelsForProviders([]provider.Model{{
339		Provider:      provider.ProviderOpenCodeGo,
340		ID:            modelID,
341		ContextWindow: 222000,
342		MaxOutput:     22000,
343		BaseURL:       "https://account-b.example/v1",
344	}}, []string{provider.ProviderOpenCodeGo})
345
346	resolved, err := Resolve(args, false)
347	if err != nil {
348		t.Fatal(err)
349	}
350	if resolved.Model != modelID || resolved.ContextWindow != 111000 || resolved.MaxOutput != 11000 || resolved.BaseURL != "https://account-a.example/v1" {
351		t.Fatalf("fallback resolved from the wrong catalog: provider=%q model=%q context=%d output=%d base=%q", resolved.Provider, resolved.Model, resolved.ContextWindow, resolved.MaxOutput, resolved.BaseURL)
352	}
353}
354
355func TestValidateAndRepairConfigPreservesPaddedDynamicModel(t *testing.T) {
356	home := t.TempDir()
357	t.Setenv("ZUT_HOME", home)
358	const model = "muse-spark-1.2-contributor"
359	if err := os.WriteFile(filepath.Join(home, "config.json"), []byte(`{"provider":"opencode-go","model":" `+model+` "}`), 0o644); err != nil {
360		t.Fatal(err)
361	}
362
363	ValidateAndRepairConfig()
364
365	cfg, err := LoadConfig()
366	if err != nil {
367		t.Fatal(err)
368	}
369	if cfg.Provider != "opencode-go" || cfg.Model != " "+model+" " {
370		t.Fatalf("padded dynamic model was repaired: provider=%q model=%q", cfg.Provider, cfg.Model)
371	}
372	resolved, err := Resolve(Args{}, false)
373	if err != nil {
374		t.Fatal(err)
375	}
376	if resolved.Model != model {
377		t.Fatalf("resolved model = %q, want trimmed dynamic id", resolved.Model)
378	}
379}
380
381func TestResolveTrimsModelID(t *testing.T) {
382	t.Setenv("ZUT_HOME", t.TempDir())
383
384	resolved, err := Resolve(Args{Provider: "openai", Model: " gpt-5.6-sol "}, false)
385	if err != nil {
386		t.Fatal(err)
387	}
388	if resolved.Model != "gpt-5.6-sol" {
389		t.Fatalf("resolved model = %q, want trimmed id", resolved.Model)
390	}
391}
392
393func TestResolveTreatsWhitespaceConfigModelAsMissing(t *testing.T) {
394	home := t.TempDir()
395	t.Setenv("ZUT_HOME", home)
396	if err := os.WriteFile(filepath.Join(home, "config.json"), []byte(`{"provider":"openai","model":" \t "}`), 0o644); err != nil {
397		t.Fatal(err)
398	}
399
400	resolved, err := Resolve(Args{}, false)
401	if err != nil {
402		t.Fatal(err)
403	}
404	if resolved.Provider != "openai" || resolved.Model != defaultModelForProvider("openai") {
405		t.Fatalf("whitespace config model resolved to provider=%q model=%q", resolved.Provider, resolved.Model)
406	}
407}
408
409func TestCurrentModelProviderScopesHashesOpenCodeGoCredential(t *testing.T) {
410	t.Setenv("ZUT_HOME", t.TempDir())
411	const key = "synthetic-opencode-key"
412	t.Setenv("OPENCODE_API_KEY", key)
413
414	scopes := currentModelProviderScopes()
415	if got, want := scopes["opencode-go"], credentialScopeForEndpoint(key, ""); got != want {
416		t.Fatalf("OpenCode Go scope = %q, want %q", got, want)
417	}
418	if got, other := scopes["opencode-go"], credentialScopeForEndpoint(key, "https://proxy.example/v1"); got == other {
419		t.Fatal("OpenCode Go cache scope did not change with the endpoint")
420	}
421	if scopes["opencode-go"] == key {
422		t.Fatal("OpenCode Go credential was stored directly in its cache scope")
423	}
424}
425
426func TestSynchronousOpenCodeGoAPIKeyHonorsCancellation(t *testing.T) {
427	preserveProviderCatalog(t)
428	home := t.TempDir()
429	t.Setenv("ZUT_HOME", home)
430	t.Setenv("OPENCODE_API_KEY", "")
431	t.Setenv("ZUT_AGENT_API_KEY_COMMAND_HELPER", "1")
432	marker := filepath.Join(home, "canceled-command-ran")
433	credentials := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
434		provider.ProviderOpenCodeGo: {
435			APIKeyCommand: &auth.APIKeyCommand{
436				Program: os.Args[0],
437				Args:    []string{"-test.run=^TestAgentAPIKeyCommandHelperProcess$", "--", marker},
438			},
439		},
440	}}
441	encoded, err := json.Marshal(credentials)
442	if err != nil {
443		t.Fatal(err)
444	}
445	if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
446		t.Fatal(err)
447	}
448
449	previous := discoverOpenCodeGoFn
450	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
451	discoverOpenCodeGoFn = func(context.Context, string, string) ([]provider.Model, error) {
452		t.Fatal("canceled catalog preparation reached discovery")
453		return nil, nil
454	}
455	ctx, cancel := context.WithCancel(context.Background())
456	cancel()
457	if _, err := ResolveSDK(ctx, Args{Provider: provider.ProviderOpenCodeGo}); !errors.Is(err, context.Canceled) {
458		t.Fatalf("ResolveSDK error = %v, want context.Canceled", err)
459	}
460
461	ctx, cancel = context.WithCancel(context.Background())
462	cancel()
463	PrepareRuntimeCatalog(ctx, true, provider.ProviderOpenCodeGo, "", "", nil)
464	if _, err := os.Stat(marker); !os.IsNotExist(err) {
465		t.Fatalf("canceled credential command marker = %v", err)
466	}
467}
468
469func TestPrepareRuntimeCatalogRefreshHonorsCancellation(t *testing.T) {
470	preserveProviderCatalog(t)
471	t.Setenv("ZUT_HOME", t.TempDir())
472	previous := discoverOpenCodeGoFn
473	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
474	called := make(chan struct{})
475	release := make(chan struct{})
476	discoverOpenCodeGoFn = func(ctx context.Context, _, _ string) ([]provider.Model, error) {
477		close(called)
478		select {
479		case <-ctx.Done():
480			return nil, ctx.Err()
481		case <-release:
482			return nil, fmt.Errorf("released discovery")
483		}
484	}
485
486	ctx, cancel := context.WithCancel(context.Background())
487	done := make(chan struct{})
488	go func() {
489		PrepareRuntimeCatalog(ctx, true, provider.ProviderOpenCodeGo, "synthetic-key", "", nil)
490		close(done)
491	}()
492
493	select {
494	case <-called:
495		cancel()
496	case <-time.After(2 * time.Second):
497		cancel()
498		close(release)
499		<-done
500		t.Fatal("catalog discovery did not start")
501	}
502	select {
503	case <-done:
504	case <-time.After(1 * time.Second):
505		close(release)
506		<-done
507		t.Fatal("canceled catalog refresh did not exit promptly")
508	}
509}
510
511func TestSynchronousRefreshExecutesOpenCodeGoAPIKeyCommand(t *testing.T) {
512	preserveProviderCatalog(t)
513	home := t.TempDir()
514	t.Setenv("ZUT_HOME", home)
515	t.Setenv("OPENCODE_API_KEY", "")
516	t.Setenv("ZUT_AGENT_API_KEY_COMMAND_HELPER", "1")
517	marker := filepath.Join(home, "command-ran")
518	credentials := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
519		provider.ProviderOpenCodeGo: {
520			APIKeyCommand: &auth.APIKeyCommand{
521				Program: os.Args[0],
522				Args:    []string{"-test.run=^TestAgentAPIKeyCommandHelperProcess$", "--", marker},
523			},
524		},
525	}}
526	encoded, err := json.Marshal(credentials)
527	if err != nil {
528		t.Fatal(err)
529	}
530	if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
531		t.Fatal(err)
532	}
533	previous := discoverOpenCodeGoFn
534	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
535	var gotKey string
536	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
537		gotKey = apiKey
538		if endpoint != "" {
539			t.Fatalf("discovery endpoint = %q, want default", endpoint)
540		}
541		return []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "command-model"}}, nil
542	}
543
544	refreshModelsWithMode(provider.ProviderOpenCodeGo, "", "", provider.ProviderOpenCodeGo, apiKeyCommandExecute)
545	if gotKey != "resolved-secret" {
546		t.Fatalf("discovery key = %q, want command result", gotKey)
547	}
548	if _, err := os.Stat(marker); err != nil {
549		t.Fatalf("credential command marker: %v", err)
550	}
551	cache, err := provider.LoadCache(ModelCachePath())
552	if err != nil {
553		t.Fatal(err)
554	}
555	if cache.ProviderScopes[provider.ProviderOpenCodeGo] != credentialScopeForEndpoint(gotKey, "") {
556		t.Fatalf("cache scope = %q, want command credential scope", cache.ProviderScopes[provider.ProviderOpenCodeGo])
557	}
558}
559
560func TestResolveSDKRefreshReusesResolvedCommandCredential(t *testing.T) {
561	preserveProviderCatalog(t)
562	home := t.TempDir()
563	t.Setenv("ZUT_HOME", home)
564	t.Setenv("OPENCODE_API_KEY", "")
565	t.Setenv("ZUT_AGENT_API_KEY_COMMAND_HELPER", "1")
566	marker := filepath.Join(home, "sdk-command-ran")
567	credentials := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
568		provider.ProviderOpenCodeGo: {
569			APIKeyCommand: &auth.APIKeyCommand{
570				Program: os.Args[0],
571				Args:    []string{"-test.run=^TestAgentAPIKeyCommandHelperProcess$", "--", marker},
572			},
573		},
574	}}
575	encoded, err := json.Marshal(credentials)
576	if err != nil {
577		t.Fatal(err)
578	}
579	if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
580		t.Fatal(err)
581	}
582	previous := discoverOpenCodeGoFn
583	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
584	called := make(chan string, 1)
585	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
586		called <- apiKey + "\x00" + endpoint
587		return nil, fmt.Errorf("synthetic discovery stop")
588	}
589
590	if _, err := ResolveSDK(context.Background(), Args{Provider: provider.ProviderOpenCodeGo, BaseURL: "https://proxy.example/v1"}); err != nil {
591		t.Fatal(err)
592	}
593	select {
594	case got := <-called:
595		if got != "resolved-secret\x00https://proxy.example/v1" {
596			t.Fatalf("discovery inputs = %q, want resolved credential and endpoint", got)
597		}
598	case <-time.After(2 * time.Second):
599		t.Fatal("SDK OpenCode Go discovery was not invoked")
600	}
601}
602
603func TestRefreshModelsUsesExplicitOpenCodeGoEndpoint(t *testing.T) {
604	home := t.TempDir()
605	t.Setenv("ZUT_HOME", home)
606	provider.SetLiveModels(nil)
607	t.Cleanup(func() { provider.SetLiveModels(nil) })
608
609	const (
610		key     = "synthetic-proxy-key"
611		baseURL = "https://proxy.example/v1"
612		modelID = "proxy-model"
613	)
614	previous := discoverOpenCodeGoFn
615	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
616	var gotKey, gotBaseURL string
617	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
618		gotKey, gotBaseURL = apiKey, endpoint
619		return []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: modelID, BaseURL: endpoint, Source: "live"}}, nil
620	}
621
622	refreshModels(provider.ProviderOpenCodeGo, key, baseURL, provider.ProviderOpenCodeGo)
623	if gotKey != key || gotBaseURL != baseURL {
624		t.Fatalf("discovery received key=%q endpoint=%q, want key=%q endpoint=%q", gotKey, gotBaseURL, key, baseURL)
625	}
626	cache, err := provider.LoadCache(ModelCachePath())
627	if err != nil {
628		t.Fatal(err)
629	}
630	if cache.ProviderScopes[provider.ProviderOpenCodeGo] != credentialScopeForEndpoint(key, baseURL) {
631		t.Fatalf("cache scope = %q, want endpoint-scoped credential", cache.ProviderScopes[provider.ProviderOpenCodeGo])
632	}
633}
634
635func TestRefreshModelsReplacesUnchangedOpenCodeGoScope(t *testing.T) {
636	home := t.TempDir()
637	t.Setenv("ZUT_HOME", home)
638	provider.SetLiveModels(nil)
639	t.Cleanup(func() { provider.SetLiveModels(nil) })
640
641	const (
642		oldKey  = "synthetic-old-key"
643		oldBase = "https://old.example/v1"
644		newKey  = "synthetic-new-key"
645		newBase = "https://new.example/v1"
646	)
647	if err := provider.SaveCache(ModelCachePath(), provider.ModelCache{
648		Version:                2,
649		FetchedAt:              time.Now().Add(-48 * time.Hour),
650		Models:                 []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "old-model", BaseURL: oldBase}},
651		AuthoritativeProviders: []string{provider.ProviderOpenCodeGo},
652		ProviderScopes:         map[string]string{provider.ProviderOpenCodeGo: credentialScope(oldKey)},
653	}); err != nil {
654		t.Fatal(err)
655	}
656	previous := discoverOpenCodeGoFn
657	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
658	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
659		if apiKey != newKey || endpoint != newBase {
660			t.Fatalf("discovery inputs = key %q endpoint %q", apiKey, endpoint)
661		}
662		return []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "new-model", BaseURL: endpoint, Source: "live"}}, nil
663	}
664
665	refreshModels(provider.ProviderOpenCodeGo, newKey, newBase, provider.ProviderOpenCodeGo)
666	cache, err := provider.LoadCache(ModelCachePath())
667	if err != nil {
668		t.Fatal(err)
669	}
670	if cache.ProviderScopes[provider.ProviderOpenCodeGo] != credentialScopeForEndpoint(newKey, newBase) {
671		t.Fatalf("scope = %q, want new endpoint-scoped scope", cache.ProviderScopes[provider.ProviderOpenCodeGo])
672	}
673	if _, ok := findCachedModel(cache.Models, provider.ProviderOpenCodeGo, "new-model"); !ok {
674		t.Fatalf("new OpenCode Go model missing: %+v", cache.Models)
675	}
676	if cache.IsFresh() {
677		t.Fatal("partial upgrade of a legacy cache must still require a full refresh")
678	}
679}
680
681func TestEffectiveCatalogBaseURLUsesProviderLevelOpenCodeGoEndpoint(t *testing.T) {
682	home := t.TempDir()
683	t.Setenv("ZUT_HOME", home)
684	path := filepath.Join(home, "models.json")
685	const baseURL = "https://provider-proxy.example/v1"
686	if err := os.WriteFile(path, []byte(`{"providers":{"opencode-go":{"baseUrl":"`+baseURL+`","models":[]}}}`), 0o600); err != nil {
687		t.Fatal(err)
688	}
689	models := LoadUserModels()
690	t.Cleanup(func() {
691		_ = os.WriteFile(path, []byte(`{"providers":{}}`), 0o600)
692		LoadUserModels()
693		provider.SetUserModels(nil)
694	})
695
696	if got := effectiveCatalogBaseURL(provider.ProviderOpenCodeGo, "new-live-model", "", models); got != baseURL {
697		t.Fatalf("provider-level endpoint = %q, want %q", got, baseURL)
698	}
699	manifest := ZutfileManifest{}
700	manifest.Model.Preferred = []string{"new-live-model"}
701	gotProvider, gotModel := zutfileCatalogSelection(Args{}, manifest, models)
702	if gotProvider != provider.ProviderOpenCodeGo || gotModel != "new-live-model" {
703		t.Fatalf("catalog selection = provider=%q model=%q, want %q/new-live-model", gotProvider, gotModel, provider.ProviderOpenCodeGo)
704	}
705}
706
707func TestZutfilePreferredDynamicModelRefreshesBeforeRequirements(t *testing.T) {
708	t.Setenv("ZUT_HOME", t.TempDir())
709	provider.SetLiveModels(nil)
710	provider.SetUserModels(nil)
711	t.Cleanup(func() {
712		provider.SetLiveModels(nil)
713		provider.SetUserModels(nil)
714	})
715	previous := discoverOpenCodeGoFn
716	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
717	const (
718		key     = "synthetic-zutfile-key"
719		modelID = "new-preferred-model"
720	)
721	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
722		if apiKey != key || endpoint != "" {
723			t.Fatalf("discovery inputs = key %q endpoint %q", apiKey, endpoint)
724		}
725		return []provider.Model{{
726			Provider:      provider.ProviderOpenCodeGo,
727			ID:            modelID,
728			ContextWindow: 256000,
729			MaxOutput:     32000,
730		}}, nil
731	}
732	manifest := ZutfileManifest{}
733	manifest.Model.MinContext = 200000
734	manifest.Model.Preferred = []string{modelID}
735	catalogProvider, catalogModel := zutfileCatalogSelection(Args{Provider: provider.ProviderOpenCodeGo}, manifest, nil)
736	PrepareRuntimeCatalog(context.Background(), true, catalogProvider, key, "", nil, catalogModel)
737
738	args := Args{}
739	if err := applyZutfileModelRequirements(&args, manifest); err != nil {
740		t.Fatal(err)
741	}
742	if args.Provider != provider.ProviderOpenCodeGo || args.Model != modelID {
743		t.Fatalf("selected model = provider=%q model=%q, want %q/%q", args.Provider, args.Model, provider.ProviderOpenCodeGo, modelID)
744	}
745}
746
747func TestResolveSDKUsesUserModelEndpointForDiscovery(t *testing.T) {
748	home := t.TempDir()
749	t.Setenv("ZUT_HOME", home)
750	t.Setenv("OPENCODE_API_KEY", "")
751	provider.SetLiveModels(nil)
752	provider.SetUserModels(nil)
753	t.Cleanup(func() {
754		provider.SetLiveModels(nil)
755		provider.SetUserModels(nil)
756	})
757
758	const (
759		key     = "synthetic-proxy-key"
760		baseURL = "https://model-proxy.example/v1"
761		modelID = "proxy-model"
762	)
763	if err := os.WriteFile(UserModelsPath(), []byte(`{"providers":{"opencode-go":{"models":[{"id":"`+modelID+`","baseUrl":"`+baseURL+`"}]}}}`), 0o600); err != nil {
764		t.Fatal(err)
765	}
766	previous := discoverOpenCodeGoFn
767	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
768	called := make(chan struct{}, 1)
769	var gotKey, gotBaseURL string
770	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
771		gotKey, gotBaseURL = apiKey, endpoint
772		called <- struct{}{}
773		return nil, fmt.Errorf("synthetic discovery stop")
774	}
775
776	resolved, err := ResolveSDK(context.Background(), Args{Provider: provider.ProviderOpenCodeGo, Model: modelID, APIKey: key})
777	if err != nil {
778		t.Fatal(err)
779	}
780	if resolved.BaseURL != baseURL {
781		t.Fatalf("resolved base URL = %q, want user model endpoint %q", resolved.BaseURL, baseURL)
782	}
783	select {
784	case <-called:
785		if gotKey != key || gotBaseURL != baseURL {
786			t.Fatalf("discovery received key=%q endpoint=%q, want key=%q endpoint=%q", gotKey, gotBaseURL, key, baseURL)
787		}
788	case <-time.After(2 * time.Second):
789		t.Fatal("OpenCode Go discovery was not invoked")
790	}
791}
792
793func TestResolveSDKDoesNotInheritLiveEndpointForMetadataOnlyUserModel(t *testing.T) {
794	home := t.TempDir()
795	t.Setenv("ZUT_HOME", home)
796	t.Setenv("OPENCODE_API_KEY", "")
797	provider.SetLiveModels([]provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "metadata-only", BaseURL: "https://old.example/v1", Source: "live"}})
798	provider.SetUserModels(nil)
799	t.Cleanup(func() {
800		provider.SetLiveModels(nil)
801		provider.SetUserModels(nil)
802	})
803	if err := os.WriteFile(UserModelsPath(), []byte(`{"providers":{"opencode-go":{"models":[{"id":"metadata-only","contextWindow":90000}]}}}`), 0o600); err != nil {
804		t.Fatal(err)
805	}
806
807	const key = "synthetic-metadata-key"
808	previous := discoverOpenCodeGoFn
809	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
810	called := make(chan string, 1)
811	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
812		if apiKey != key {
813			t.Errorf("discovery key = %q, want %q", apiKey, key)
814		}
815		called <- endpoint
816		return nil, fmt.Errorf("synthetic discovery stop")
817	}
818
819	resolved, err := ResolveSDK(context.Background(), Args{Provider: provider.ProviderOpenCodeGo, Model: "metadata-only", APIKey: key})
820	if err != nil {
821		t.Fatal(err)
822	}
823	if resolved.BaseURL != "" {
824		t.Fatalf("metadata-only user model inherited base URL %q", resolved.BaseURL)
825	}
826	select {
827	case endpoint := <-called:
828		if endpoint != "" {
829			t.Fatalf("discovery endpoint = %q, want default endpoint", endpoint)
830		}
831	case <-time.After(2 * time.Second):
832		t.Fatal("OpenCode Go discovery was not invoked")
833	}
834}
835
836func TestResolveSDKFollowsActiveModelProfile(t *testing.T) {
837	const (
838		key     = "synthetic-profile-key"
839		baseURL = "https://profile-proxy.example/v1"
840	)
841	for _, tc := range []struct {
842		name            string
843		topProvider     string
844		topModel        string
845		profileProvider string
846		profileModel    string
847		wantContext     int
848		wantCachedModel bool
849	}{
850		{
851			name:            "profile switches to OpenCode Go",
852			topProvider:     provider.ProviderAnthropic,
853			topModel:        "claude-sonnet-4-5",
854			profileProvider: provider.ProviderOpenCodeGo,
855			profileModel:    "profile-opencode-model",
856			wantContext:     654321,
857			wantCachedModel: true,
858		},
859		{
860			name:            "profile switches away from OpenCode Go",
861			topProvider:     provider.ProviderOpenCodeGo,
862			topModel:        "profile-opencode-model",
863			profileProvider: provider.ProviderAnthropic,
864			profileModel:    "claude-sonnet-4-5",
865			wantCachedModel: false,
866		},
867	} {
868		t.Run(tc.name, func(t *testing.T) {
869			home := t.TempDir()
870			t.Setenv("ZUT_HOME", home)
871			provider.SetLiveModels(nil)
872			provider.SetUserModels(nil)
873			t.Cleanup(func() {
874				provider.SetLiveModels(nil)
875				provider.SetUserModels(nil)
876			})
877
878			if err := SaveConfig(Config{
879				Provider:           tc.topProvider,
880				Model:              tc.topModel,
881				ActiveModelProfile: 1,
882				QuickModelShortcuts: []QuickModelShortcut{{
883					Provider: tc.profileProvider,
884					Model:    tc.profileModel,
885				}},
886			}); err != nil {
887				t.Fatal(err)
888			}
889			if tc.wantCachedModel {
890				if err := provider.SaveCache(filepath.Join(home, "models-cache.json"), provider.ModelCache{
891					Version:                provider.ModelCacheVersion,
892					FetchedAt:              time.Now().UTC(),
893					Models:                 []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: tc.profileModel, ContextWindow: tc.wantContext, MaxOutput: 8192, BaseURL: baseURL}},
894					AuthoritativeProviders: []string{provider.ProviderOpenCodeGo},
895					ProviderScopes:         map[string]string{provider.ProviderOpenCodeGo: credentialScopeForEndpoint(key, baseURL)},
896				}); err != nil {
897					t.Fatal(err)
898				}
899			}
900
901			modelCatalogMu.Lock()
902			_, _ = prepareRuntimeCatalog("", key, baseURL, nil, "")
903			resolved, err := Resolve(Args{APIKey: key, BaseURL: baseURL}, false)
904			modelCatalogMu.Unlock()
905			if err != nil {
906				t.Fatal(err)
907			}
908			if resolved.Provider != tc.profileProvider || resolved.Model != tc.profileModel {
909				t.Fatalf("resolved profile = provider=%q model=%q, want provider=%q model=%q", resolved.Provider, resolved.Model, tc.profileProvider, tc.profileModel)
910			}
911			if tc.wantCachedModel && resolved.ContextWindow != tc.wantContext {
912				t.Fatalf("cached OpenCode Go context = %d, want %d", resolved.ContextWindow, tc.wantContext)
913			}
914		})
915	}
916}
917
918func TestEligibleProviderDiscoveryIncompleteIncludesUnscopedProviders(t *testing.T) {
919	eligible := map[string]struct{}{
920		provider.ProviderOpenAI:     {},
921		provider.ProviderOpenCodeGo: {},
922	}
923	discovered := map[string][]provider.Model{
924		provider.ProviderOpenCodeGo: {{Provider: provider.ProviderOpenCodeGo, ID: "served"}},
925	}
926	if !eligibleProviderDiscoveryIncomplete(eligible, discovered) {
927		t.Fatal("missing unscoped provider discovery was not detected")
928	}
929	discovered[provider.ProviderOpenAI] = nil
930	if eligibleProviderDiscoveryIncomplete(eligible, discovered) {
931		t.Fatal("providers present in discovery were treated as missing")
932	}
933}
934
935func TestPartialOpenCodeGoRefreshPreservesFullCatalogFreshness(t *testing.T) {
936	home := t.TempDir()
937	t.Setenv("ZUT_HOME", home)
938	provider.SetLiveModels(nil)
939	t.Cleanup(func() { provider.SetLiveModels(nil) })
940
941	const (
942		key     = "synthetic-partial-key"
943		baseURL = "https://partial-proxy.example/v1"
944	)
945	staleAt := time.Now().Add(-48 * time.Hour)
946	if err := provider.SaveCache(ModelCachePath(), provider.ModelCache{
947		Version:   provider.ModelCacheVersion,
948		FetchedAt: staleAt,
949		Models: []provider.Model{
950			{Provider: provider.ProviderOpenAI, ID: "cached-openai"},
951			{Provider: provider.ProviderOpenCodeGo, ID: "old-opencode"},
952		},
953		AuthoritativeProviders: []string{provider.ProviderOpenCodeGo},
954		ProviderScopes:         map[string]string{provider.ProviderOpenCodeGo: credentialScopeForEndpoint(key, baseURL)},
955	}); err != nil {
956		t.Fatal(err)
957	}
958	previous := discoverOpenCodeGoFn
959	t.Cleanup(func() { discoverOpenCodeGoFn = previous })
960	calls := 0
961	discoverOpenCodeGoFn = func(_ context.Context, apiKey, endpoint string) ([]provider.Model, error) {
962		calls++
963		if apiKey != key || endpoint != baseURL {
964			t.Fatalf("discovery inputs = key %q endpoint %q", apiKey, endpoint)
965		}
966		return []provider.Model{{Provider: provider.ProviderOpenCodeGo, ID: "new-opencode", Source: "live"}}, nil
967	}
968
969	refreshModels(provider.ProviderOpenCodeGo, key, baseURL, provider.ProviderOpenCodeGo)
970	partial, err := provider.LoadCache(ModelCachePath())
971	if err != nil {
972		t.Fatal(err)
973	}
974	if partial.IsFresh() {
975		t.Fatal("partial OpenCode Go refresh made a stale full catalog fresh")
976	}
977	if _, ok := findCachedModel(partial.Models, provider.ProviderOpenAI, "cached-openai"); !ok {
978		t.Fatalf("partial refresh dropped unrelated cached model: %+v", partial.Models)
979	}
980
981	refreshModels(provider.ProviderOpenCodeGo, key, baseURL, "")
982	if calls != 2 {
983		t.Fatalf("full refresh calls = %d, want a refresh after partial stale cache", calls)
984	}
985}
986
987func findCachedModel(models []provider.Model, providerName, id string) (provider.Model, bool) {
988	for _, model := range models {
989		if model.Provider == providerName && model.ID == id {
990			return model, true
991		}
992	}
993	return provider.Model{}, false
994}
995
996func TestNeedsOpenCodeGoRefreshWhenCredentialAppearsAfterCache(t *testing.T) {
997	t.Setenv("ZUT_HOME", t.TempDir())
998	t.Setenv("OPENCODE_API_KEY", "synthetic-key")
999
1000	freshWithoutOpenCode := provider.ModelCache{
1001		Version:   provider.ModelCacheVersion,
1002		FetchedAt: time.Now(),
1003		Models:    []provider.Model{{Provider: "openai", ID: "gpt-5"}},
1004	}
1005	if !needsOpenCodeGoRefresh(freshWithoutOpenCode) {
1006		t.Fatal("fresh cache without OpenCode Go should refresh after credential appears")
1007	}
1008
1009	freshWithOpenCode := freshWithoutOpenCode
1010	freshWithOpenCode.Models = append(freshWithOpenCode.Models, provider.Model{Provider: "opencode-go", ID: "served"})
1011	if needsOpenCodeGoRefresh(freshWithOpenCode) {
1012		t.Fatal("cache with OpenCode Go models should remain fresh")
1013	}
1014
1015	t.Setenv("OPENCODE_API_KEY", "")
1016	if needsOpenCodeGoRefresh(freshWithoutOpenCode) {
1017		t.Fatal("cache without an OpenCode Go credential should not refresh")
1018	}
1019}
1020
1021func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *testing.T) {
1022	home := t.TempDir()
1023	t.Setenv("ZUT_HOME", home)
1024
1025	b, _ := json.Marshal(Config{Provider: "openai-codex", Model: "gpt-5.5"})
1026	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
1027
1028	ValidateAndRepairConfig()
1029
1030	out, _ := LoadConfig()
1031	if out.Provider != "openai-codex" {
1032		t.Errorf("provider mutated: %q", out.Provider)
1033	}
1034	if out.Model != "gpt-5.5" {
1035		t.Errorf("model mutated: %q", out.Model)
1036	}
1037}
1038
1039func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) {
1040	home := t.TempDir()
1041	t.Setenv("ZUT_HOME", home)
1042
1043	want := "deepseek/deepseek-v4-flash"
1044	b, _ := json.Marshal(Config{Provider: "openrouter", Model: want})
1045	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
1046
1047	ValidateAndRepairConfig()
1048
1049	out, _ := LoadConfig()
1050	if out.Provider != "openrouter" {
1051		t.Errorf("provider mutated: %q", out.Provider)
1052	}
1053	if out.Model != want {
1054		t.Errorf("routed model id mutated: got %q, want %q", out.Model, want)
1055	}
1056}
1057
1058func TestValidateAndRepairConfig_GatewayPlainUnknownModelStillRepairs(t *testing.T) {
1059	home := t.TempDir()
1060	t.Setenv("ZUT_HOME", home)
1061
1062	b, _ := json.Marshal(Config{Provider: "openrouter", Model: "not-a-routed-model"})
1063	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
1064
1065	ValidateAndRepairConfig()
1066
1067	out, _ := LoadConfig()
1068	if out.Provider != "openrouter" {
1069		t.Errorf("provider mutated: %q", out.Provider)
1070	}
1071	if out.Model == "not-a-routed-model" || out.Model == "" {
1072		t.Errorf("plain unknown gateway model was not repaired: %q", out.Model)
1073	}
1074}
1075
1076// TestValidateAndRepairConfig_HappyPath leaves a valid config alone.
1077func TestValidateAndRepairConfig_HappyPath(t *testing.T) {
1078	home := t.TempDir()
1079	t.Setenv("ZUT_HOME", home)
1080
1081	b, _ := json.Marshal(Config{Provider: "anthropic", Model: "claude-sonnet-4-5"})
1082	_ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
1083
1084	ValidateAndRepairConfig()
1085
1086	out, _ := LoadConfig()
1087	if out.Provider != "anthropic" {
1088		t.Errorf("provider mutated: %q", out.Provider)
1089	}
1090	if out.Model != "claude-sonnet-4-5" {
1091		t.Errorf("model mutated: %q", out.Model)
1092	}
1093}
1094
1095func TestScopedRefreshPreservesActiveOpenCodeGoScopeOnUnrelatedWrite(t *testing.T) {
1096	home := t.TempDir()
1097	t.Setenv("ZUT_HOME", home)
1098	t.Setenv("OPENCODE_API_KEY", "synthetic-scope-key")
1099	provider.SetLiveModels(nil)
1100	t.Cleanup(func() { provider.SetLiveModels(nil) })
1101
1102	origDiscover := discoverOpenCodeGoFn
1103	started := make(chan struct{})
1104	release := make(chan struct{})
1105	var once sync.Once
1106	discoverOpenCodeGoFn = func(ctx context.Context, apiKey, baseURL string) ([]provider.Model, error) {
1107		once.Do(func() { close(started) })
1108		select {
1109		case <-release:
1110		case <-ctx.Done():
1111		}
1112		return []provider.Model{{
1113			Provider: provider.ProviderOpenCodeGo,
1114			ID:       "go-scoped-model",
1115			Source:   "live",
1116		}}, nil
1117	}
1118	t.Cleanup(func() { discoverOpenCodeGoFn = origDiscover })
1119
1120	done := make(chan struct{})
1121	go func() {
1122		defer close(done)
1123		refreshModelsWithContext(context.Background(), "", "", "", provider.ProviderOpenCodeGo, apiKeyCommandSkip)
1124	}()
1125	<-started
1126	// A concurrent runtime with an unrelated credential scope replaces the
1127	// cache while this refresh's OpenCode Go discovery is in flight. It
1128	// carries no OpenCode Go scope of its own, so OpenCode Go is this
1129	// refresh's only discovered provider.
1130	if err := provider.SaveCache(ModelCachePath(), provider.ModelCache{
1131		Version:   provider.ModelCacheVersion,
1132		FetchedAt: time.Now().UTC(),
1133		Models: []provider.Model{{
1134			Provider: "kimi",
1135			ID:       "kimi-unrelated",
1136			Source:   "live",
1137		}},
1138	}); err != nil {
1139		t.Fatal(err)
1140	}
1141	close(release)
1142	select {
1143	case <-done:
1144	case <-time.After(30 * time.Second):
1145		t.Fatal("scoped refresh did not finish")
1146	}
1147
1148	final, err := provider.LoadCache(ModelCachePath())
1149	if err != nil {
1150		t.Fatal(err)
1151	}
1152	var sawGo, sawKimi bool
1153	for _, m := range final.Models {
1154		if m.Provider == provider.ProviderOpenCodeGo && m.ID == "go-scoped-model" {
1155			sawGo = true
1156		}
1157		if m.Provider == "kimi" && m.ID == "kimi-unrelated" {
1158			sawKimi = true
1159		}
1160	}
1161	if !sawGo {
1162		t.Fatalf("active OpenCode Go scope was discarded by an unrelated cache write: %+v", final.Models)
1163	}
1164	if !sawKimi {
1165		t.Fatalf("unrelated scope was discarded by the OpenCode Go refresh: %+v", final.Models)
1166	}
1167	if got := final.ProviderScopes[provider.ProviderOpenCodeGo]; got != credentialScopeForEndpoint("synthetic-scope-key", "") {
1168		t.Fatalf("OpenCode Go scope = %q, want active credential scope", got)
1169	}
1170}

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