v0.3.59 e54d007

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 TestNewAgentInjectsOpenRouterServerToolsWhenSettingOn(t *testing.T) {
223	t.Setenv("ZOT_HOME", t.TempDir())
224	t.Setenv("OPENROUTER_API_KEY", "test-key")
225	on := true
226	if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &on}); err != nil {
227		t.Fatal(err)
228	}
229	r, err := Resolve(Args{NoSkill: true}, true)
230	if err != nil {
231		t.Fatal(err)
232	}
233	ag := r.NewAgent()
234	for _, name := range []string{
235		"openrouter:web_search",
236		"openrouter:web_fetch",
237		"openrouter:advisor",
238		"openrouter:subagent",
239		"openrouter:fusion",
240		"openrouter:image_generation",
241		"openrouter:datetime",
242		"openrouter:experimental__search_models",
243	} {
244		if _, err := ag.Tools.Get(name); err != nil {
245			t.Errorf("missing advertised server tool %q: %v", name, err)
246		}
247	}
248	for _, name := range []string{"openrouter:shell", "openrouter:bash", "openrouter:apply_patch", "openrouter:tool_search"} {
249		if _, err := ag.Tools.Get(name); err == nil {
250			t.Errorf("chat-completions-incompatible tool %q should not be advertised", name)
251		}
252	}
253	if ag.MaxToolCalls != tools.OpenRouterServerToolCallLimit {
254		t.Fatalf("MaxToolCalls = %d; want %d", ag.MaxToolCalls, tools.OpenRouterServerToolCallLimit)
255	}
256
257	refreshAgentToolsAndPrompt(Args{NoSkill: true}, nil, nil, ag, nil)
258	if _, err := ag.Tools.Get("openrouter:web_search"); err != nil {
259		t.Fatalf("tool refresh removed enabled OpenRouter server tools: %v", err)
260	}
261}
262
263func TestResolveOmitsOpenRouterServerToolsWithNoTools(t *testing.T) {
264	t.Setenv("ZOT_HOME", t.TempDir())
265	t.Setenv("OPENROUTER_API_KEY", "test-key")
266	on := true
267	if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &on}); err != nil {
268		t.Fatal(err)
269	}
270	r, err := Resolve(Args{NoTools: true, NoSkill: true}, true)
271	if err != nil {
272		t.Fatal(err)
273	}
274	if len(r.ToolRegistry) != 0 {
275		t.Fatalf("--no-tools registry has %d tools; want none", len(r.ToolRegistry))
276	}
277	if r.NewAgent().MaxToolCalls != 0 {
278		t.Fatal("--no-tools agent retained a server-tool call limit")
279	}
280}
281
282func TestResolveDoesNotOverrideOpenRouterPresetTools(t *testing.T) {
283	t.Setenv("ZOT_HOME", t.TempDir())
284	t.Setenv("OPENROUTER_API_KEY", "test-key")
285	on := true
286	if err := SaveConfig(Config{Provider: "openrouter", Model: "@preset/flash", OpenRouterServerToolsEnabled: &on}); err != nil {
287		t.Fatal(err)
288	}
289	r, err := Resolve(Args{NoSkill: true}, true)
290	if err != nil {
291		t.Fatal(err)
292	}
293	if hasOpenRouterServerTools(r.ToolRegistry) {
294		t.Fatal("request tools would override tools configured by the preset")
295	}
296}
297
298func TestNewAgentOmitsOpenRouterServerToolsWhenSettingOff(t *testing.T) {
299	t.Setenv("ZOT_HOME", t.TempDir())
300	t.Setenv("OPENROUTER_API_KEY", "test-key")
301	off := false
302	if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &off}); err != nil {
303		t.Fatal(err)
304	}
305	r, err := Resolve(Args{NoSkill: true}, true)
306	if err != nil {
307		t.Fatal(err)
308	}
309	ag := r.NewAgent()
310	if _, err := ag.Tools.Get("openrouter:web_search"); err == nil {
311		t.Fatal("web_search advertised while setting is off")
312	}
313}
314
315func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
316	t.Setenv("ZOT_HOME", t.TempDir())
317	t.Setenv("OPENROUTER_API_KEY", "test-key")
318	want := "deepseek/deepseek-v4-flash"
319	if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
320		t.Fatal(err)
321	}
322
323	r, err := Resolve(Args{}, true)
324	if err != nil {
325		t.Fatalf("Resolve failed: %v", err)
326	}
327	if r.Provider != "openrouter" {
328		t.Fatalf("provider = %q, want openrouter", r.Provider)
329	}
330	if r.Model != want {
331		t.Fatalf("model = %q, want %q", r.Model, want)
332	}
333	if r.MaxOutput != 64000 {
334		t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
335	}
336}
337
338func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
339	t.Setenv("ZOT_HOME", t.TempDir())
340	t.Setenv("OPENROUTER_API_KEY", "test-key")
341	stale := "not-a-routed-model"
342	if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
343		t.Fatal(err)
344	}
345
346	r, err := Resolve(Args{}, true)
347	if err != nil {
348		t.Fatalf("Resolve failed: %v", err)
349	}
350	if r.Provider != "openrouter" {
351		t.Fatalf("provider = %q, want openrouter", r.Provider)
352	}
353	if r.Model == stale || r.Model == "" {
354		t.Fatalf("model = %q, want repaired gateway default", r.Model)
355	}
356}
357
358// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
359// #15: pointing ZOT_HOME at a fresh dir drops the persisted
360// config.json (which pinned provider=amazon-bedrock). Resolve must
361// still discover bedrock from the AWS env vars instead of falling back
362// to anthropic and reporting "not logged in".
363func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
364	t.Setenv("ZOT_HOME", t.TempDir()) // fresh home: no config.json
365	// Disable the Kimi CLI token fallback so a developer machine with a
366	// real Kimi CLI login doesn't pre-empt bedrock in the scan.
367	if err := SetKimiCLIFallbackDisabled(true); err != nil {
368		t.Fatal(err)
369	}
370	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
371	t.Setenv("AWS_REGION", "us-east-1")
372	// Make sure no other provider's env credential pre-empts bedrock.
373	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"} {
374		t.Setenv(k, "")
375	}
376
377	r, err := Resolve(Args{}, true)
378	if err != nil {
379		t.Fatalf("Resolve errored with env-only bedrock: %v", err)
380	}
381	if r.Provider != "amazon-bedrock" {
382		t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
383	}
384	if !r.HasCredential() {
385		t.Fatalf("bedrock credential not resolved from env")
386	}
387}
388
389func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
390	t.Setenv("ZOT_HOME", t.TempDir())
391	provider.SetLiveModels(nil)
392	defer provider.SetLiveModels(nil)
393	provider.SetUserModels([]provider.Model{{
394		Provider:      "ollama",
395		ID:            "qwen-local",
396		DisplayName:   "Qwen Local",
397		ContextWindow: 32768,
398		MaxOutput:     8192,
399		BaseURL:       "http://localhost:8000/v1",
400	}})
401
402	r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
403	if err != nil {
404		t.Fatalf("Resolve failed: %v", err)
405	}
406	if r.BaseURL != "http://localhost:8000/v1" {
407		t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
408	}
409}
410
411func TestResolveUsesInheritedSwarmCredential(t *testing.T) {
412	t.Setenv("ZOT_HOME", t.TempDir())
413	t.Setenv("OPENAI_API_KEY", "")
414
415	r, err := Resolve(Args{
416		Provider:            "openai",
417		Model:               "gpt-5",
418		inheritedCredential: "inherited-key",
419		inheritedAuthMethod: "apikey",
420		inheritedAccountID:  "account-id",
421	}, true)
422	if err != nil {
423		t.Fatal(err)
424	}
425	if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
426		t.Fatalf("inherited credential was not preserved: %+v", r)
427	}
428}
429
430func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
431	t.Setenv("ZOT_HOME", t.TempDir())
432	t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
433	t.Setenv("LLAMA_API_KEY", "")
434	provider.SetManagedModels(nil)
435	t.Cleanup(func() { provider.SetManagedModels(nil) })
436
437	r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
438	if err != nil {
439		t.Fatalf("Resolve failed: %v", err)
440	}
441	if r.BaseURL != "http://127.0.0.1:8080/v1" {
442		t.Fatalf("BaseURL = %q", r.BaseURL)
443	}
444	if r.Credential != "local" || !r.HasCredential() {
445		t.Fatalf("credential = %q", r.Credential)
446	}
447	if got := r.NewClient().Name(); got != "openai" {
448		t.Fatalf("client name = %q", got)
449	}
450}
451
452func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
453	t.Setenv("ZOT_HOME", t.TempDir())
454	t.Setenv("LLAMA_BASE_URL", "")
455	t.Setenv("LLAMA_API_KEY", "")
456	if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
457		t.Fatal(err)
458	}
459
460	r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
461	if err != nil {
462		t.Fatalf("Resolve failed: %v", err)
463	}
464	if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
465		t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
466	}
467}
468
469func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
470	t.Setenv("ZOT_HOME", t.TempDir())
471	t.Setenv("MY_COMPANY_API_KEY", "test-key")
472	path := filepath.Join(t.TempDir(), "models.json")
473	if err := os.WriteFile(path, []byte(`{
474		"providers": {
475			"my-company": {
476				"baseUrl": "https://provider.example.com/v1",
477				"api": "openai",
478				"models": [
479					{"id": "fast", "baseUrl": "https://model.example.com/v1"}
480				]
481			}
482		}
483	}`), 0o644); err != nil {
484		t.Fatal(err)
485	}
486	models, warnings := provider.LoadUserModelsWithWarnings(path)
487	if len(warnings) != 0 {
488		t.Fatalf("warnings = %v", warnings)
489	}
490	provider.SetLiveModels(nil)
491	provider.SetUserModels(models)
492	t.Cleanup(func() { provider.SetLiveModels(nil) })
493
494	r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
495	if err != nil {
496		t.Fatalf("Resolve failed: %v", err)
497	}
498	if r.BaseURL != "https://model.example.com/v1" {
499		t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
500	}
501}
502
503func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
504	requestPath := make(chan string, 1)
505	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
506		requestPath <- r.URL.Path
507		w.Header().Set("Content-Type", "text/event-stream")
508		w.WriteHeader(http.StatusOK)
509	}))
510	t.Cleanup(srv.Close)
511
512	path := filepath.Join(t.TempDir(), "models.json")
513	if err := os.WriteFile(path, []byte(`{
514		"providers": {
515			"company-responses": {
516				"baseUrl": "`+srv.URL+`/v1",
517				"api": "openai-responses",
518				"models": [{"id": "reasoning-model", "reasoning": true}]
519			}
520		}
521	}`), 0o644); err != nil {
522		t.Fatal(err)
523	}
524	models, warnings := provider.LoadUserModelsWithWarnings(path)
525	if len(warnings) != 0 {
526		t.Fatalf("warnings = %v", warnings)
527	}
528	provider.SetLiveModels(nil)
529	provider.SetUserModels(models)
530	t.Cleanup(func() { provider.SetLiveModels(nil) })
531
532	r := Resolved{
533		Provider:   "company-responses",
534		Credential: "test-key",
535		BaseURL:    srv.URL + "/v1",
536	}
537	events, err := r.NewClient().Stream(context.Background(), provider.Request{
538		Model:    "reasoning-model",
539		Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
540	})
541	if err != nil {
542		t.Fatal(err)
543	}
544	for range events {
545	}
546	if got := <-requestPath; got != "/v1/responses" {
547		t.Fatalf("request path = %q, want /v1/responses", got)
548	}
549}
550
551func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
552	t.Setenv("ZOT_HOME", t.TempDir())
553	t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
554	path := filepath.Join(t.TempDir(), "models.json")
555	if err := os.WriteFile(path, []byte(`{
556		"providers": {
557			"local-proxy": {
558				"baseUrl": "https://proxy.example.com/v1",
559				"api": "openai",
560				"models": [{"id": "default"}]
561			}
562		}
563	}`), 0o644); err != nil {
564		t.Fatal(err)
565	}
566	models, warnings := provider.LoadUserModelsWithWarnings(path)
567	if len(warnings) != 0 {
568		t.Fatalf("warnings = %v", warnings)
569	}
570	provider.SetLiveModels(nil)
571	provider.SetUserModels(models)
572	t.Cleanup(func() { provider.SetLiveModels(nil) })
573
574	r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
575	if err != nil {
576		t.Fatalf("Resolve failed: %v", err)
577	}
578	if !r.InsecureTLS {
579		t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
580	}
581}
582
583func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
584	t.Setenv("ZOT_HOME", t.TempDir())
585	provider.SetLiveModels(nil)
586	defer provider.SetLiveModels(nil)
587
588	r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
589	if err != nil {
590		t.Fatalf("Resolve failed: %v", err)
591	}
592	if r.BaseURL != "http://localhost:11434" {
593		t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
594	}
595}
596
597func TestCanonicalProviderResolvesAliases(t *testing.T) {
598	cases := map[string]string{
599		"bedrock":         "amazon-bedrock",
600		"AWS-Bedrock":     "amazon-bedrock",
601		"  bedrock  ":     "amazon-bedrock",
602		"vertex":          "google-vertex",
603		"gemini":          "google",
604		"azure":           "azure-openai-responses",
605		"copilot":         "github-copilot",
606		"codex":           "openai-codex",
607		"moonshot":        "moonshotai",
608		"vercel":          "vercel-ai-gateway",
609		"hf":              "huggingface",
610		"anthropic":       "anthropic",       // canonical passes through
611		"amazon-bedrock":  "amazon-bedrock",  // already canonical
612		"totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
613		"Totally-UNKNOWN": "totally-unknown",
614		"":                "",
615	}
616	for in, want := range cases {
617		if got := canonicalProvider(in); got != want {
618			t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
619		}
620	}
621}
622
623func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
624	for alias, canon := range providerAliases {
625		if !isKnownProvider(canon) {
626			t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
627		}
628	}
629}
630
631func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
632	orig := http.DefaultTransport
633	t.Cleanup(func() { http.DefaultTransport = orig })
634
635	t.Setenv("ZOT_HOME", t.TempDir())
636	t.Setenv("OPENAI_API_KEY", "test-key")
637
638	resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
639	if err != nil {
640		t.Fatalf("Resolve: %v", err)
641	}
642	if resolved.InsecureTLS {
643		t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
644	}
645	assertDefaultTransportStillSecure(t)
646
647	resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
648	if err != nil {
649		t.Fatalf("Resolve: %v", err)
650	}
651	if !resolved.InsecureTLS {
652		t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
653	}
654	assertDefaultTransportStillSecure(t)
655}
656
657func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
658	orig := http.DefaultTransport
659	t.Cleanup(func() { http.DefaultTransport = orig })
660
661	t.Setenv("ZOT_HOME", t.TempDir())
662	t.Setenv("OPENAI_API_KEY", "test-key")
663	if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
664		t.Fatal(err)
665	}
666
667	resolved, err := Resolve(Args{Provider: "openai"}, false)
668	if err != nil {
669		t.Fatalf("Resolve: %v", err)
670	}
671	if resolved.InsecureTLS {
672		t.Fatal("InsecureTLS must not be set without a custom base URL")
673	}
674	assertDefaultTransportStillSecure(t)
675
676	resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
677	if err != nil {
678		t.Fatalf("Resolve: %v", err)
679	}
680	if !resolved.InsecureTLS {
681		t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
682	}
683	assertDefaultTransportStillSecure(t)
684}
685
686func TestDefaultXAIModelIsGrok45(t *testing.T) {
687	if got := defaultModelForProvider("xai"); got != "grok-4.5" {
688		t.Fatalf("default xAI model = %q, want grok-4.5", got)
689	}
690}
691
692func assertDefaultTransportStillSecure(t *testing.T) {
693	t.Helper()
694	tr, ok := http.DefaultTransport.(*http.Transport)
695	if !ok {
696		return
697	}
698	if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
699		t.Fatal("http.DefaultTransport must not be made insecure")
700	}
701}
702
703func TestBuildToolRegistryIncludesGlob(t *testing.T) {
704	cwd := t.TempDir()
705	reg := buildToolRegistry(Args{}, cwd, nil)
706	expected := []string{"read", "write", "edit", "bash", "glob"}
707	for _, name := range expected {
708		if _, ok := reg[name]; !ok {
709			t.Errorf("expected tool %q in registry, missing", name)
710		}
711	}
712
713	summaries := toolSummaries(reg, Args{})
714	var summaryNames []string
715	for _, s := range summaries {
716		summaryNames = append(summaryNames, s.Name)
717	}
718	for _, name := range expected {
719		found := false
720		for _, sn := range summaryNames {
721			if sn == name {
722				found = true
723				break
724			}
725		}
726		if !found {
727			t.Errorf("expected tool %q in toolSummaries, missing", name)
728		}
729	}
730
731	// Filtered tools
732	filtered := buildToolRegistry(Args{Tools: []string{"glob", "read"}}, cwd, nil)
733	if len(filtered) != 2 || filtered["glob"] == nil || filtered["read"] == nil {
734		t.Errorf("expected filtered registry with glob and read, got: %#v", filtered)
735	}
736}

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 477

↑↓ moveenter openesc close