v0.3.67 80dd4c6

build_test.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"net/http"
6	"net/http/httptest"
7	"os"
8	"path/filepath"
9	"strings"
10	"testing"
11
12	"github.com/patriceckhart/zot/packages/agent/tools"
13	"github.com/patriceckhart/zot/packages/provider"
14)
15
16func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
17	root := t.TempDir()
18	zotHome := filepath.Join(root, "zot-home")
19	project := filepath.Join(root, "repo")
20	nested := filepath.Join(project, "packages", "app")
21	if err := os.MkdirAll(zotHome, 0o755); err != nil {
22		t.Fatal(err)
23	}
24	if err := os.MkdirAll(nested, 0o755); err != nil {
25		t.Fatal(err)
26	}
27	if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
28		t.Fatal(err)
29	}
30	if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil {
31		t.Fatal(err)
32	}
33	if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil {
34		t.Fatal(err)
35	}
36
37	files := loadAgentsContext(nested, zotHome)
38	if len(files) != 3 {
39		t.Fatalf("loaded %d context files, want 3: %#v", len(files), files)
40	}
41	wantPaths := []string{
42		filepath.Join(zotHome, "AGENTS.md"),
43		filepath.Join(project, "AGENTS.md"),
44		filepath.Join(nested, "AGENTS.md"),
45	}
46	for idx, want := range wantPaths {
47		if files[idx].Path != want {
48			t.Fatalf("context file %d path = %q, want %q", idx, files[idx].Path, want)
49		}
50	}
51
52	got := formatAgentsContext(files)
53	for _, want := range []string{"global rule", "repo rule", "app rule"} {
54		if !strings.Contains(got, want) {
55			t.Fatalf("formatAgentsContext missing %q in:\n%s", want, got)
56		}
57	}
58	if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") {
59		t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got)
60	}
61}
62
63func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
64	files := loadAgentsContext(t.TempDir(), t.TempDir())
65	if len(files) != 0 {
66		t.Fatalf("expected no context files, got %#v", files)
67	}
68	if got := formatAgentsContext(files); got != "" {
69		t.Fatalf("expected no context, got %q", got)
70	}
71}
72
73func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) {
74	zotHome := t.TempDir()
75	cwd := t.TempDir()
76	t.Setenv("ZOT_HOME", zotHome)
77	t.Setenv("OPENAI_API_KEY", "test-key")
78	if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
79		t.Fatal(err)
80	}
81	if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("project rule"), 0o644); err != nil {
82		t.Fatal(err)
83	}
84
85	r, err := Resolve(Args{
86		Provider:       "openai",
87		Model:          "gpt-5",
88		CWD:            cwd,
89		NoContextFiles: true,
90	}, false)
91	if err != nil {
92		t.Fatal(err)
93	}
94	if len(r.ContextFiles) != 0 {
95		t.Fatalf("ContextFiles = %#v, want empty", r.ContextFiles)
96	}
97	if strings.Contains(r.SystemPrompt, "global rule") || strings.Contains(r.SystemPrompt, "project rule") {
98		t.Fatalf("system prompt contains disabled context files:\n%s", r.SystemPrompt)
99	}
100}
101
102func TestResolveExplicitEmptySystemPromptOverridesPersistentPrompt(t *testing.T) {
103	zotHome := t.TempDir()
104	cwd := t.TempDir()
105	t.Setenv("ZOT_HOME", zotHome)
106	t.Setenv("OPENAI_API_KEY", "test-key")
107	if err := os.WriteFile(filepath.Join(zotHome, "SYSTEM.md"), []byte("persistent instructions"), 0o644); err != nil {
108		t.Fatal(err)
109	}
110
111	r, err := Resolve(Args{
112		Provider:        "openai",
113		Model:           "gpt-5",
114		CWD:             cwd,
115		SystemPromptSet: true,
116		SystemPrompt:    "",
117		NoContextFiles:  true,
118		NoSkill:         true,
119	}, false)
120	if err != nil {
121		t.Fatal(err)
122	}
123	for _, unwanted := range []string{"persistent instructions", defaultIdentity, "Zot's own docs are installed under"} {
124		if strings.Contains(r.SystemPrompt, unwanted) {
125			t.Fatalf("explicit empty system prompt unexpectedly contains %q:\n%s", unwanted, r.SystemPrompt)
126		}
127	}
128}
129
130// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
131// startup failure caught by the user's screenshot: the persisted
132// config.json points at a model id that's no longer in the active
133// catalogue (because they edited models.json or zot's bundled
134// catalogue changed). Resolve must NOT error — strands the user
135// with no way to fix it from the TUI — and should repair the config
136// so the next launch is silent.
137func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
138	t.Setenv("ZOT_HOME", t.TempDir())
139	t.Setenv("OPENAI_API_KEY", "test-key")
140	// Persist a stale model id.
141	stale := "gpt-5.5-pro-not-real"
142	if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
143		t.Fatal(err)
144	}
145
146	r, err := Resolve(Args{}, false)
147	if err != nil {
148		t.Fatalf("Resolve refused to launch with stale model: %v", err)
149	}
150	if r.Model == stale {
151		t.Fatalf("Resolve kept stale model %q", r.Model)
152	}
153	if r.Provider != "openai" {
154		t.Errorf("provider drifted: got %q; want openai", r.Provider)
155	}
156
157	// Config on disk should now hold the fallback so subsequent
158	// launches don't repeat the warning.
159	cfg, _ := LoadConfig()
160	if cfg.Model == stale {
161		t.Errorf("config.json still pins the stale model %q", cfg.Model)
162	}
163	if cfg.Model == "" {
164		t.Errorf("config.json was emptied; expected the fallback model id")
165	}
166}
167
168func TestResolveAppliesJailByDefault(t *testing.T) {
169	for _, tc := range []struct {
170		name   string
171		config Config
172		want   bool
173	}{
174		{name: "missing defaults to unlocked", config: Config{Provider: "openai", Model: "gpt-5"}},
175		{name: "enabled starts locked", config: Config{Provider: "openai", Model: "gpt-5", JailByDefault: boolPtr(true)}, want: true},
176	} {
177		t.Run(tc.name, func(t *testing.T) {
178			t.Setenv("ZOT_HOME", t.TempDir())
179			t.Setenv("OPENAI_API_KEY", "test-key")
180			if err := SaveConfig(tc.config); err != nil {
181				t.Fatal(err)
182			}
183
184			r, err := Resolve(Args{}, false)
185			if err != nil {
186				t.Fatalf("Resolve failed: %v", err)
187			}
188			if got := r.Sandbox.Locked(); got != tc.want {
189				t.Fatalf("sandbox locked = %v, want %v", got, tc.want)
190			}
191		})
192	}
193}
194
195func boolPtr(v bool) *bool { return &v }
196
197// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
198// repair-on-disk happens ONLY when the stale id came from the
199// persisted config. If the user passed --model X explicitly and X is
200// unknown, we still fall back, but we don't touch their config.
201func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
202	t.Setenv("ZOT_HOME", t.TempDir())
203	t.Setenv("OPENAI_API_KEY", "test-key")
204	good := "gpt-5"
205	if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
206		t.Fatal(err)
207	}
208
209	r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
210	if err != nil {
211		t.Fatalf("Resolve errored on unknown --model: %v", err)
212	}
213	if r.Model == "gpt-totally-fake" {
214		t.Errorf("Resolve kept the bogus --model value")
215	}
216	cfg, _ := LoadConfig()
217	if cfg.Model != good {
218		t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
219	}
220}
221
222func TestResolveReportsExtensionSkillDiagnostics(t *testing.T) {
223	home := t.TempDir()
224	project := t.TempDir()
225	t.Setenv("ZOT_HOME", home)
226	extDir := filepath.Join(project, ".zot", "extensions", "bad-skills")
227	if err := os.MkdirAll(extDir, 0o755); err != nil {
228		t.Fatal(err)
229	}
230	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"bad-skills","skills":["../outside"]}`), 0o644); err != nil {
231		t.Fatal(err)
232	}
233
234	r, err := Resolve(Args{CWD: project, WithSkills: true}, false)
235	if err != nil {
236		t.Fatal(err)
237	}
238	if len(r.SkillDiagnostics) != 1 || !strings.Contains(r.SkillDiagnostics[0], "escapes extension directory") {
239		t.Fatalf("SkillDiagnostics = %v", r.SkillDiagnostics)
240	}
241}
242
243func TestNewAgentInjectsOpenRouterServerToolsWhenSettingOn(t *testing.T) {
244	t.Setenv("ZOT_HOME", t.TempDir())
245	t.Setenv("OPENROUTER_API_KEY", "test-key")
246	on := true
247	if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &on}); err != nil {
248		t.Fatal(err)
249	}
250	r, err := Resolve(Args{NoSkill: true}, true)
251	if err != nil {
252		t.Fatal(err)
253	}
254	ag := r.NewAgent()
255	for _, name := range []string{
256		"openrouter:web_search",
257		"openrouter:web_fetch",
258		"openrouter:advisor",
259		"openrouter:subagent",
260		"openrouter:fusion",
261		"openrouter:image_generation",
262		"openrouter:datetime",
263		"openrouter:experimental__search_models",
264	} {
265		if _, err := ag.Tools.Get(name); err != nil {
266			t.Errorf("missing advertised server tool %q: %v", name, err)
267		}
268	}
269	for _, name := range []string{"openrouter:shell", "openrouter:bash", "openrouter:apply_patch", "openrouter:tool_search"} {
270		if _, err := ag.Tools.Get(name); err == nil {
271			t.Errorf("chat-completions-incompatible tool %q should not be advertised", name)
272		}
273	}
274	if ag.MaxToolCalls != tools.OpenRouterServerToolCallLimit {
275		t.Fatalf("MaxToolCalls = %d; want %d", ag.MaxToolCalls, tools.OpenRouterServerToolCallLimit)
276	}
277
278	refreshAgentToolsAndPrompt(Args{NoSkill: true}, nil, nil, ag, nil)
279	if _, err := ag.Tools.Get("openrouter:web_search"); err != nil {
280		t.Fatalf("tool refresh removed enabled OpenRouter server tools: %v", err)
281	}
282}
283
284func TestResolveOmitsOpenRouterServerToolsWithNoTools(t *testing.T) {
285	t.Setenv("ZOT_HOME", t.TempDir())
286	t.Setenv("OPENROUTER_API_KEY", "test-key")
287	on := true
288	if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &on}); err != nil {
289		t.Fatal(err)
290	}
291	r, err := Resolve(Args{NoTools: true, NoSkill: true}, true)
292	if err != nil {
293		t.Fatal(err)
294	}
295	if len(r.ToolRegistry) != 0 {
296		t.Fatalf("--no-tools registry has %d tools; want none", len(r.ToolRegistry))
297	}
298	if r.NewAgent().MaxToolCalls != 0 {
299		t.Fatal("--no-tools agent retained a server-tool call limit")
300	}
301}
302
303func TestResolveDoesNotOverrideOpenRouterPresetTools(t *testing.T) {
304	t.Setenv("ZOT_HOME", t.TempDir())
305	t.Setenv("OPENROUTER_API_KEY", "test-key")
306	on := true
307	if err := SaveConfig(Config{Provider: "openrouter", Model: "@preset/flash", OpenRouterServerToolsEnabled: &on}); err != nil {
308		t.Fatal(err)
309	}
310	r, err := Resolve(Args{NoSkill: true}, true)
311	if err != nil {
312		t.Fatal(err)
313	}
314	if hasOpenRouterServerTools(r.ToolRegistry) {
315		t.Fatal("request tools would override tools configured by the preset")
316	}
317}
318
319func TestNewAgentOmitsOpenRouterServerToolsWhenSettingOff(t *testing.T) {
320	t.Setenv("ZOT_HOME", t.TempDir())
321	t.Setenv("OPENROUTER_API_KEY", "test-key")
322	off := false
323	if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &off}); err != nil {
324		t.Fatal(err)
325	}
326	r, err := Resolve(Args{NoSkill: true}, true)
327	if err != nil {
328		t.Fatal(err)
329	}
330	ag := r.NewAgent()
331	if _, err := ag.Tools.Get("openrouter:web_search"); err == nil {
332		t.Fatal("web_search advertised while setting is off")
333	}
334}
335
336func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
337	t.Setenv("ZOT_HOME", t.TempDir())
338	t.Setenv("OPENROUTER_API_KEY", "test-key")
339	want := "deepseek/deepseek-v4-flash"
340	if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
341		t.Fatal(err)
342	}
343
344	r, err := Resolve(Args{}, true)
345	if err != nil {
346		t.Fatalf("Resolve failed: %v", err)
347	}
348	if r.Provider != "openrouter" {
349		t.Fatalf("provider = %q, want openrouter", r.Provider)
350	}
351	if r.Model != want {
352		t.Fatalf("model = %q, want %q", r.Model, want)
353	}
354	if r.MaxOutput != 64000 {
355		t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
356	}
357}
358
359func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
360	t.Setenv("ZOT_HOME", t.TempDir())
361	t.Setenv("OPENROUTER_API_KEY", "test-key")
362	stale := "not-a-routed-model"
363	if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
364		t.Fatal(err)
365	}
366
367	r, err := Resolve(Args{}, true)
368	if err != nil {
369		t.Fatalf("Resolve failed: %v", err)
370	}
371	if r.Provider != "openrouter" {
372		t.Fatalf("provider = %q, want openrouter", r.Provider)
373	}
374	if r.Model == stale || r.Model == "" {
375		t.Fatalf("model = %q, want repaired gateway default", r.Model)
376	}
377}
378
379// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
380// #15: pointing ZOT_HOME at a fresh dir drops the persisted
381// config.json (which pinned provider=amazon-bedrock). Resolve must
382// still discover bedrock from the AWS env vars instead of falling back
383// to anthropic and reporting "not logged in".
384func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
385	t.Setenv("ZOT_HOME", t.TempDir()) // fresh home: no config.json
386	// Disable the Kimi CLI token fallback so a developer machine with a
387	// real Kimi CLI login doesn't pre-empt bedrock in the scan.
388	if err := SetKimiCLIFallbackDisabled(true); err != nil {
389		t.Fatal(err)
390	}
391	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
392	t.Setenv("AWS_REGION", "us-east-1")
393	// Make sure no other provider's env credential pre-empts bedrock.
394	for _, k := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY", "KIMI_API_KEY", "MOONSHOT_API_KEY"} {
395		t.Setenv(k, "")
396	}
397
398	r, err := Resolve(Args{}, true)
399	if err != nil {
400		t.Fatalf("Resolve errored with env-only bedrock: %v", err)
401	}
402	if r.Provider != "amazon-bedrock" {
403		t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
404	}
405	if !r.HasCredential() {
406		t.Fatalf("bedrock credential not resolved from env")
407	}
408}
409
410func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
411	t.Setenv("ZOT_HOME", t.TempDir())
412	provider.SetLiveModels(nil)
413	defer provider.SetLiveModels(nil)
414	provider.SetUserModels([]provider.Model{{
415		Provider:      "ollama",
416		ID:            "qwen-local",
417		DisplayName:   "Qwen Local",
418		ContextWindow: 32768,
419		MaxOutput:     8192,
420		BaseURL:       "http://localhost:8000/v1",
421	}})
422
423	r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
424	if err != nil {
425		t.Fatalf("Resolve failed: %v", err)
426	}
427	if r.BaseURL != "http://localhost:8000/v1" {
428		t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
429	}
430}
431
432func TestResolveUsesInheritedSwarmCredential(t *testing.T) {
433	t.Setenv("ZOT_HOME", t.TempDir())
434	t.Setenv("OPENAI_API_KEY", "")
435
436	r, err := Resolve(Args{
437		Provider:            "openai",
438		Model:               "gpt-5",
439		inheritedCredential: "inherited-key",
440		inheritedAuthMethod: "apikey",
441		inheritedAccountID:  "account-id",
442	}, true)
443	if err != nil {
444		t.Fatal(err)
445	}
446	if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
447		t.Fatalf("inherited credential was not preserved: %+v", r)
448	}
449}
450
451func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
452	t.Setenv("ZOT_HOME", t.TempDir())
453	t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
454	t.Setenv("LLAMA_API_KEY", "")
455	provider.SetManagedModels(nil)
456	t.Cleanup(func() { provider.SetManagedModels(nil) })
457
458	r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
459	if err != nil {
460		t.Fatalf("Resolve failed: %v", err)
461	}
462	if r.BaseURL != "http://127.0.0.1:8080/v1" {
463		t.Fatalf("BaseURL = %q", r.BaseURL)
464	}
465	if r.Credential != "local" || !r.HasCredential() {
466		t.Fatalf("credential = %q", r.Credential)
467	}
468	if got := r.NewClient().Name(); got != "openai" {
469		t.Fatalf("client name = %q", got)
470	}
471}
472
473func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
474	t.Setenv("ZOT_HOME", t.TempDir())
475	t.Setenv("LLAMA_BASE_URL", "")
476	t.Setenv("LLAMA_API_KEY", "")
477	if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
478		t.Fatal(err)
479	}
480
481	r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
482	if err != nil {
483		t.Fatalf("Resolve failed: %v", err)
484	}
485	if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
486		t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
487	}
488}
489
490func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
491	t.Setenv("ZOT_HOME", t.TempDir())
492	t.Setenv("MY_COMPANY_API_KEY", "test-key")
493	path := filepath.Join(t.TempDir(), "models.json")
494	if err := os.WriteFile(path, []byte(`{
495		"providers": {
496			"my-company": {
497				"baseUrl": "https://provider.example.com/v1",
498				"api": "openai",
499				"models": [
500					{"id": "fast", "baseUrl": "https://model.example.com/v1"}
501				]
502			}
503		}
504	}`), 0o644); err != nil {
505		t.Fatal(err)
506	}
507	models, warnings := provider.LoadUserModelsWithWarnings(path)
508	if len(warnings) != 0 {
509		t.Fatalf("warnings = %v", warnings)
510	}
511	provider.SetLiveModels(nil)
512	provider.SetUserModels(models)
513	t.Cleanup(func() { provider.SetLiveModels(nil) })
514
515	r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
516	if err != nil {
517		t.Fatalf("Resolve failed: %v", err)
518	}
519	if r.BaseURL != "https://model.example.com/v1" {
520		t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
521	}
522}
523
524func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
525	requestPath := make(chan string, 1)
526	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
527		requestPath <- r.URL.Path
528		w.Header().Set("Content-Type", "text/event-stream")
529		w.WriteHeader(http.StatusOK)
530	}))
531	t.Cleanup(srv.Close)
532
533	path := filepath.Join(t.TempDir(), "models.json")
534	if err := os.WriteFile(path, []byte(`{
535		"providers": {
536			"company-responses": {
537				"baseUrl": "`+srv.URL+`/v1",
538				"api": "openai-responses",
539				"models": [{"id": "reasoning-model", "reasoning": true}]
540			}
541		}
542	}`), 0o644); err != nil {
543		t.Fatal(err)
544	}
545	models, warnings := provider.LoadUserModelsWithWarnings(path)
546	if len(warnings) != 0 {
547		t.Fatalf("warnings = %v", warnings)
548	}
549	provider.SetLiveModels(nil)
550	provider.SetUserModels(models)
551	t.Cleanup(func() { provider.SetLiveModels(nil) })
552
553	r := Resolved{
554		Provider:   "company-responses",
555		Credential: "test-key",
556		BaseURL:    srv.URL + "/v1",
557	}
558	events, err := r.NewClient().Stream(context.Background(), provider.Request{
559		Model:    "reasoning-model",
560		Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
561	})
562	if err != nil {
563		t.Fatal(err)
564	}
565	for range events {
566	}
567	if got := <-requestPath; got != "/v1/responses" {
568		t.Fatalf("request path = %q, want /v1/responses", got)
569	}
570}
571
572func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
573	t.Setenv("ZOT_HOME", t.TempDir())
574	t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
575	path := filepath.Join(t.TempDir(), "models.json")
576	if err := os.WriteFile(path, []byte(`{
577		"providers": {
578			"local-proxy": {
579				"baseUrl": "https://proxy.example.com/v1",
580				"api": "openai",
581				"models": [{"id": "default"}]
582			}
583		}
584	}`), 0o644); err != nil {
585		t.Fatal(err)
586	}
587	models, warnings := provider.LoadUserModelsWithWarnings(path)
588	if len(warnings) != 0 {
589		t.Fatalf("warnings = %v", warnings)
590	}
591	provider.SetLiveModels(nil)
592	provider.SetUserModels(models)
593	t.Cleanup(func() { provider.SetLiveModels(nil) })
594
595	r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
596	if err != nil {
597		t.Fatalf("Resolve failed: %v", err)
598	}
599	if !r.InsecureTLS {
600		t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
601	}
602}
603
604func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
605	t.Setenv("ZOT_HOME", t.TempDir())
606	provider.SetLiveModels(nil)
607	defer provider.SetLiveModels(nil)
608
609	r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
610	if err != nil {
611		t.Fatalf("Resolve failed: %v", err)
612	}
613	if r.BaseURL != "http://localhost:11434" {
614		t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
615	}
616}
617
618func TestCanonicalProviderResolvesAliases(t *testing.T) {
619	cases := map[string]string{
620		"bedrock":         "amazon-bedrock",
621		"AWS-Bedrock":     "amazon-bedrock",
622		"  bedrock  ":     "amazon-bedrock",
623		"vertex":          "google-vertex",
624		"gemini":          "google",
625		"azure":           "azure-openai-responses",
626		"copilot":         "github-copilot",
627		"codex":           "openai-codex",
628		"moonshot":        "moonshotai",
629		"vercel":          "vercel-ai-gateway",
630		"hf":              "huggingface",
631		"anthropic":       "anthropic",       // canonical passes through
632		"amazon-bedrock":  "amazon-bedrock",  // already canonical
633		"totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
634		"Totally-UNKNOWN": "totally-unknown",
635		"":                "",
636	}
637	for in, want := range cases {
638		if got := canonicalProvider(in); got != want {
639			t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
640		}
641	}
642}
643
644func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
645	for alias, canon := range providerAliases {
646		if !isKnownProvider(canon) {
647			t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
648		}
649	}
650}
651
652func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
653	orig := http.DefaultTransport
654	t.Cleanup(func() { http.DefaultTransport = orig })
655
656	t.Setenv("ZOT_HOME", t.TempDir())
657	t.Setenv("OPENAI_API_KEY", "test-key")
658
659	resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
660	if err != nil {
661		t.Fatalf("Resolve: %v", err)
662	}
663	if resolved.InsecureTLS {
664		t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
665	}
666	assertDefaultTransportStillSecure(t)
667
668	resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
669	if err != nil {
670		t.Fatalf("Resolve: %v", err)
671	}
672	if !resolved.InsecureTLS {
673		t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
674	}
675	assertDefaultTransportStillSecure(t)
676}
677
678func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
679	orig := http.DefaultTransport
680	t.Cleanup(func() { http.DefaultTransport = orig })
681
682	t.Setenv("ZOT_HOME", t.TempDir())
683	t.Setenv("OPENAI_API_KEY", "test-key")
684	if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
685		t.Fatal(err)
686	}
687
688	resolved, err := Resolve(Args{Provider: "openai"}, false)
689	if err != nil {
690		t.Fatalf("Resolve: %v", err)
691	}
692	if resolved.InsecureTLS {
693		t.Fatal("InsecureTLS must not be set without a custom base URL")
694	}
695	assertDefaultTransportStillSecure(t)
696
697	resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
698	if err != nil {
699		t.Fatalf("Resolve: %v", err)
700	}
701	if !resolved.InsecureTLS {
702		t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
703	}
704	assertDefaultTransportStillSecure(t)
705}
706
707func TestDefaultXAIModelIsGrok45(t *testing.T) {
708	if got := defaultModelForProvider("xai"); got != "grok-4.5" {
709		t.Fatalf("default xAI model = %q, want grok-4.5", got)
710	}
711}
712
713func assertDefaultTransportStillSecure(t *testing.T) {
714	t.Helper()
715	tr, ok := http.DefaultTransport.(*http.Transport)
716	if !ok {
717		return
718	}
719	if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
720		t.Fatal("http.DefaultTransport must not be made insecure")
721	}
722}
723
724func TestBuildToolRegistryIncludesGlob(t *testing.T) {
725	cwd := t.TempDir()
726	reg := buildToolRegistry(Args{}, cwd, nil)
727	expected := []string{"read", "write", "edit", "bash", "glob"}
728	for _, name := range expected {
729		if _, ok := reg[name]; !ok {
730			t.Errorf("expected tool %q in registry, missing", name)
731		}
732	}
733
734	summaries := toolSummaries(reg, Args{})
735	var summaryNames []string
736	for _, s := range summaries {
737		summaryNames = append(summaryNames, s.Name)
738	}
739	for _, name := range expected {
740		found := false
741		for _, sn := range summaryNames {
742			if sn == name {
743				found = true
744				break
745			}
746		}
747		if !found {
748			t.Errorf("expected tool %q in toolSummaries, missing", name)
749		}
750	}
751
752	// Filtered tools
753	filtered := buildToolRegistry(Args{Tools: []string{"glob", "read"}}, cwd, nil)
754	if len(filtered) != 2 || filtered["glob"] == nil || filtered["read"] == nil {
755		t.Errorf("expected filtered registry with glob and read, got: %#v", filtered)
756	}
757}

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