v0.4.0 a622146

build_test.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"fmt"
6	"net/http"
7	"net/http/httptest"
8	"os"
9	"path/filepath"
10	"strings"
11	"testing"
12
13	"github.com/bnema/zut/packages/agent/skills"
14	"github.com/bnema/zut/packages/agent/tools"
15	"github.com/bnema/zut/packages/core"
16	"github.com/bnema/zut/packages/provider"
17)
18
19type bundledSkillSource struct {
20	bundled []*skills.Skill
21}
22
23func (s bundledSkillSource) Tools() []ExtensionToolInfo { return nil }
24func (s bundledSkillSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
25	return nil
26}
27func (s bundledSkillSource) Skills() []*skills.Skill { return s.bundled }
28
29func TestMergeExtensionToolsCreatesSkillToolWhenDiscoveryFoundNoSkills(t *testing.T) {
30	r := Resolved{
31		CWD:           t.TempDir(),
32		ToolRegistry:  make(core.Registry),
33		skillsEnabled: true,
34	}
35	bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
36
37	r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
38
39	if r.SkillTool == nil {
40		t.Fatal("MergeExtensionTools did not create a skill tool for bundled skills")
41	}
42	if _, ok := r.ToolRegistry[r.SkillTool.Name()]; !ok {
43		t.Fatalf("skill tool %q was not registered", r.SkillTool.Name())
44	}
45	if got := r.SkillTool.Skills(); len(got) != 1 || got[0].Name != bundled.Name {
46		t.Fatalf("merged bundled skills = %#v", got)
47	}
48}
49
50func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) {
51	r := Resolved{
52		CWD:           t.TempDir(),
53		ToolRegistry:  make(core.Registry),
54		skillsEnabled: false,
55	}
56	bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
57
58	r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
59
60	if r.SkillTool != nil {
61		t.Fatal("disabled skill discovery created a skill tool")
62	}
63}
64
65func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) {
66	r := Resolved{
67		CWD:           t.TempDir(),
68		ToolRegistry:  make(core.Registry),
69		skillsEnabled: true,
70		systemAppend:  []string{PonytailSystemAddendum()},
71	}
72	bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
73
74	r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
75
76	if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
77		t.Fatalf("extension prompt rebuild lost Ponytail addendum:\n%s", r.SystemPrompt)
78	}
79}
80
81func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
82	root := t.TempDir()
83	registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false)
84	lspTool, ok := registry["lsp"].(*tools.LSPTool)
85	if !ok || lspTool.Manager == nil {
86		t.Fatalf("lsp tool = %#v", registry["lsp"])
87	}
88	t.Cleanup(func() { _ = lspTool.Manager.Close() })
89	if _, ok := registry["lsp"]; !ok {
90		t.Fatal("default registry does not include lsp")
91	}
92	write, ok := registry["write"].(*tools.WriteTool)
93	if !ok || write.LSP == nil || !write.LSPDiagnostics {
94		t.Fatalf("write tool LSP wiring = %#v", write)
95	}
96	edit, ok := registry["edit"].(*tools.EditTool)
97	if !ok || edit.LSP == nil || edit.LSPDiagnostics {
98		t.Fatalf("edit tool LSP wiring = %#v", edit)
99	}
100	if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 4 {
101		t.Fatalf("LSP-disabled registry has %d tools, want 4", len(disabled))
102	}
103	readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)
104	if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {
105		t.Fatalf("read-only registry = %#v", readOnly)
106	}
107}
108
109func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
110	for _, tc := range []struct {
111		name       string
112		tools      []string
113		wantSpawn  bool
114		wantStatus bool
115		wantAny    bool
116	}{
117		{name: "default", wantSpawn: true, wantStatus: true, wantAny: true},
118		{name: "spawn", tools: []string{"subagent_spawn"}, wantSpawn: true, wantAny: true},
119		{name: "status", tools: []string{"subagent_status"}, wantStatus: true, wantAny: true},
120		{name: "other", tools: []string{"read"}},
121		{name: "no tools", wantAny: false, tools: nil},
122	} {
123		t.Run(tc.name, func(t *testing.T) {
124			args := Args{Tools: tc.tools}
125			if tc.name == "no tools" {
126				args.NoTools = true
127			}
128			if got := autoSubagentsToolAllowed(args); got != tc.wantSpawn {
129				t.Fatalf("spawn allowed = %v, want %v", got, tc.wantSpawn)
130			}
131			if got := autoSubagentsStatusToolAllowed(args); got != tc.wantStatus {
132				t.Fatalf("status allowed = %v, want %v", got, tc.wantStatus)
133			}
134			if got := autoSubagentsAnyToolAllowed(args); got != tc.wantAny {
135				t.Fatalf("any allowed = %v, want %v", got, tc.wantAny)
136			}
137		})
138	}
139}
140
141func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
142	root := t.TempDir()
143	zutHome := filepath.Join(root, "zut-home")
144	project := filepath.Join(root, "repo")
145	nested := filepath.Join(project, "packages", "app")
146	if err := os.MkdirAll(zutHome, 0o755); err != nil {
147		t.Fatal(err)
148	}
149	if err := os.MkdirAll(nested, 0o755); err != nil {
150		t.Fatal(err)
151	}
152	if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
153		t.Fatal(err)
154	}
155	if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil {
156		t.Fatal(err)
157	}
158	if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil {
159		t.Fatal(err)
160	}
161
162	files := loadAgentsContext(nested, zutHome)
163	if len(files) != 3 {
164		t.Fatalf("loaded %d context files, want 3: %#v", len(files), files)
165	}
166	wantPaths := []string{
167		filepath.Join(zutHome, "AGENTS.md"),
168		filepath.Join(project, "AGENTS.md"),
169		filepath.Join(nested, "AGENTS.md"),
170	}
171	for idx, want := range wantPaths {
172		if files[idx].Path != want {
173			t.Fatalf("context file %d path = %q, want %q", idx, files[idx].Path, want)
174		}
175	}
176
177	got := formatAgentsContext(files)
178	for _, want := range []string{"global rule", "repo rule", "app rule"} {
179		if !strings.Contains(got, want) {
180			t.Fatalf("formatAgentsContext missing %q in:\n%s", want, got)
181		}
182	}
183	if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") {
184		t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got)
185	}
186}
187
188func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
189	profileSource := filepath.Join(t.TempDir(), "profiles.md")
190	if err := os.WriteFile(profileSource, []byte("not a directory"), 0o600); err != nil {
191		t.Fatal(err)
192	}
193	t.Setenv("ZUT_AGENT_PROFILES", profileSource)
194
195	profile, err := findSubagentProfile("", "reviewer")
196	if profile != nil {
197		t.Fatalf("profile = %#v, want nil", profile)
198	}
199	if err == nil || !strings.Contains(err.Error(), "discover subagent profiles") {
200		t.Fatalf("error = %v, want discovery failure", err)
201	}
202	if strings.Contains(err.Error(), profileSource) {
203		t.Fatalf("error leaked profile path: %v", err)
204	}
205}
206
207func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) {
208	root := t.TempDir()
209	home := filepath.Join(root, "home")
210	zutHome := filepath.Join(root, "zut-home")
211	project := filepath.Join(root, "repo")
212	if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
213		t.Fatal(err)
214	}
215	if err := os.MkdirAll(project, 0o755); err != nil {
216		t.Fatal(err)
217	}
218	t.Setenv("HOME", home)
219	t.Setenv("ZUT_HOME", zutHome)
220	t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
221	enabled := true
222	if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &enabled}); err != nil {
223		t.Fatal(err)
224	}
225	profile := `---
226name: reviewer
227description: Read-only code reviewer
228thinking: high
229---
230Review without editing.
231`
232	if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
233		t.Fatal(err)
234	}
235
236	r, err := Resolve(Args{CWD: project}, false)
237	if err != nil {
238		t.Fatal(err)
239	}
240	for _, want := range []string{"[subagents_list]", "reviewer", "Read-only code reviewer", "[/subagents_list]"} {
241		if !strings.Contains(r.SystemPrompt, want) {
242			t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)
243		}
244	}
245}
246
247func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
248	root := t.TempDir()
249	home := filepath.Join(root, "home")
250	zutHome := filepath.Join(root, "zut-home")
251	project := filepath.Join(root, "repo")
252	if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
253		t.Fatal(err)
254	}
255	if err := os.MkdirAll(project, 0o755); err != nil {
256		t.Fatal(err)
257	}
258	t.Setenv("HOME", home)
259	t.Setenv("ZUT_HOME", zutHome)
260	t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
261	if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil {
262		t.Fatal(err)
263	}
264	if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global context"), 0o600); err != nil {
265		t.Fatal(err)
266	}
267	profile := `---
268name: reviewer
269description: Read-only code reviewer
270tools: read
271thinking: off
272systemPromptMode: replace
273inheritProjectContext: false
274inheritSkills: false
275---
276You are a read-only reviewer.
277`
278	if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
279		t.Fatal(err)
280	}
281
282	r, err := Resolve(Args{CWD: project, Subagent: "reviewer"}, false)
283	if err != nil {
284		t.Fatal(err)
285	}
286	if r.Reasoning != "" {
287		t.Fatalf("profile thinking=off did not override config reasoning: %q", r.Reasoning)
288	}
289	if _, ok := r.ToolRegistry["read"]; !ok || len(r.ToolRegistry) != 1 {
290		t.Fatalf("profile tools = %#v, want only read", r.ToolRegistry)
291	}
292	if len(r.ContextFiles) != 0 {
293		t.Fatalf("profile inheritProjectContext=false loaded %#v", r.ContextFiles)
294	}
295	if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") {
296		t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt)
297	}
298	if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
299		t.Fatalf("replace-mode profile lost global Ponytail addendum:\n%s", r.SystemPrompt)
300	}
301}
302
303func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) {
304	cases := []struct {
305		name         string
306		hostFastMode bool
307		profileFast  *bool
308		argFastMode  bool
309		argFastSet   bool
310		wantFastMode bool
311	}{
312		{name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true},
313		{name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false},
314		{name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false},
315		{name: "true cannot enable disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: false},
316		{name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false},
317		{name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false},
318	}
319	for _, tc := range cases {
320		t.Run(tc.name, func(t *testing.T) {
321			root := t.TempDir()
322			home := filepath.Join(root, "home")
323			zutHome := filepath.Join(root, "zut-home")
324			project := filepath.Join(root, "repo")
325			profilesDir := filepath.Join(home, ".agents", "agents")
326			if err := os.MkdirAll(profilesDir, 0o755); err != nil {
327				t.Fatal(err)
328			}
329			if err := os.MkdirAll(project, 0o755); err != nil {
330				t.Fatal(err)
331			}
332			t.Setenv("HOME", home)
333			t.Setenv("ZUT_HOME", zutHome)
334			t.Setenv("ZUT_AGENT_PROFILES", profilesDir)
335
336			cfg := Config{Provider: "openai", Model: "gpt-5"}
337			cfg.FastMode = &tc.hostFastMode
338			if err := SaveConfig(cfg); err != nil {
339				t.Fatal(err)
340			}
341			profile := "---\nname: reviewer\n"
342			if tc.profileFast != nil {
343				profile += fmt.Sprintf("fastMode: %t\n", *tc.profileFast)
344			}
345			profile += "---\nReview the requested scope.\n"
346			if err := os.WriteFile(filepath.Join(profilesDir, "reviewer.md"), []byte(profile), 0o600); err != nil {
347				t.Fatal(err)
348			}
349
350			r, err := Resolve(Args{
351				CWD:         project,
352				Subagent:    "reviewer",
353				FastMode:    tc.argFastMode,
354				FastModeSet: tc.argFastSet,
355			}, false)
356			if err != nil {
357				t.Fatal(err)
358			}
359			if r.FastMode != tc.wantFastMode {
360				t.Fatalf("FastMode = %v, want %v", r.FastMode, tc.wantFastMode)
361			}
362		})
363	}
364}
365
366func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
367	files := loadAgentsContext(t.TempDir(), t.TempDir())
368	if len(files) != 0 {
369		t.Fatalf("expected no context files, got %#v", files)
370	}
371	if got := formatAgentsContext(files); got != "" {
372		t.Fatalf("expected no context, got %q", got)
373	}
374}
375
376// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
377// startup failure caught by the user's screenshot: the persisted
378// config.json points at a model id that's no longer in the active
379// catalogue (because they edited models.json or zut's bundled
380// catalogue changed). Resolve must NOT error — strands the user
381// with no way to fix it from the TUI — and should repair the config
382// so the next launch is silent.
383func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
384	t.Setenv("ZUT_HOME", t.TempDir())
385	t.Setenv("OPENAI_API_KEY", "test-key")
386	// Persist a stale model id.
387	stale := "gpt-5.5-pro-not-real"
388	if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
389		t.Fatal(err)
390	}
391
392	r, err := Resolve(Args{}, false)
393	if err != nil {
394		t.Fatalf("Resolve refused to launch with stale model: %v", err)
395	}
396	if r.Model == stale {
397		t.Fatalf("Resolve kept stale model %q", r.Model)
398	}
399	if r.Provider != "openai" {
400		t.Errorf("provider drifted: got %q; want openai", r.Provider)
401	}
402
403	// Config on disk should now hold the fallback so subsequent
404	// launches don't repeat the warning.
405	cfg, _ := LoadConfig()
406	if cfg.Model == stale {
407		t.Errorf("config.json still pins the stale model %q", cfg.Model)
408	}
409	if cfg.Model == "" {
410		t.Errorf("config.json was emptied; expected the fallback model id")
411	}
412}
413
414func TestResolveAppliesJailByDefault(t *testing.T) {
415	for _, tc := range []struct {
416		name   string
417		config Config
418		want   bool
419	}{
420		{name: "missing defaults to unlocked", config: Config{Provider: "openai", Model: "gpt-5"}},
421		{name: "enabled starts locked", config: Config{Provider: "openai", Model: "gpt-5", JailByDefault: boolPtr(true)}, want: true},
422	} {
423		t.Run(tc.name, func(t *testing.T) {
424			t.Setenv("ZUT_HOME", t.TempDir())
425			t.Setenv("OPENAI_API_KEY", "test-key")
426			if err := SaveConfig(tc.config); err != nil {
427				t.Fatal(err)
428			}
429
430			r, err := Resolve(Args{}, false)
431			if err != nil {
432				t.Fatalf("Resolve failed: %v", err)
433			}
434			if got := r.Sandbox.Locked(); got != tc.want {
435				t.Fatalf("sandbox locked = %v, want %v", got, tc.want)
436			}
437		})
438	}
439}
440
441func boolPtr(v bool) *bool { return &v }
442
443// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
444// repair-on-disk happens ONLY when the stale id came from the
445// persisted config. If the user passed --model X explicitly and X is
446// unknown, we still fall back, but we don't touch their config.
447func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
448	t.Setenv("ZUT_HOME", t.TempDir())
449	t.Setenv("OPENAI_API_KEY", "test-key")
450	good := "gpt-5"
451	if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
452		t.Fatal(err)
453	}
454
455	r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
456	if err != nil {
457		t.Fatalf("Resolve errored on unknown --model: %v", err)
458	}
459	if r.Model == "gpt-totally-fake" {
460		t.Errorf("Resolve kept the bogus --model value")
461	}
462	cfg, _ := LoadConfig()
463	if cfg.Model != good {
464		t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
465	}
466}
467
468func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
469	t.Setenv("ZUT_HOME", t.TempDir())
470	t.Setenv("OPENROUTER_API_KEY", "test-key")
471	want := "deepseek/deepseek-v4-flash"
472	if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
473		t.Fatal(err)
474	}
475
476	r, err := Resolve(Args{}, true)
477	if err != nil {
478		t.Fatalf("Resolve failed: %v", err)
479	}
480	if r.Provider != "openrouter" {
481		t.Fatalf("provider = %q, want openrouter", r.Provider)
482	}
483	if r.Model != want {
484		t.Fatalf("model = %q, want %q", r.Model, want)
485	}
486	if r.MaxOutput != 64000 {
487		t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
488	}
489}
490
491func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
492	t.Setenv("ZUT_HOME", t.TempDir())
493	t.Setenv("OPENROUTER_API_KEY", "test-key")
494	stale := "not-a-routed-model"
495	if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
496		t.Fatal(err)
497	}
498
499	r, err := Resolve(Args{}, true)
500	if err != nil {
501		t.Fatalf("Resolve failed: %v", err)
502	}
503	if r.Provider != "openrouter" {
504		t.Fatalf("provider = %q, want openrouter", r.Provider)
505	}
506	if r.Model == stale || r.Model == "" {
507		t.Fatalf("model = %q, want repaired gateway default", r.Model)
508	}
509}
510
511// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
512// #15: pointing ZUT_HOME at a fresh dir drops the persisted
513// config.json (which pinned provider=amazon-bedrock). Resolve must
514// still discover bedrock from the AWS env vars instead of falling back
515// to anthropic and reporting "not logged in".
516func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
517	t.Setenv("ZUT_HOME", t.TempDir()) // fresh home: no config.json
518	// Disable the Kimi CLI token fallback so a developer machine with a
519	// real Kimi CLI login doesn't pre-empt bedrock in the scan.
520	if err := SetKimiCLIFallbackDisabled(true); err != nil {
521		t.Fatal(err)
522	}
523	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
524	t.Setenv("AWS_REGION", "us-east-1")
525	// Make sure no other provider's env credential pre-empts bedrock.
526	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"} {
527		t.Setenv(k, "")
528	}
529
530	r, err := Resolve(Args{}, true)
531	if err != nil {
532		t.Fatalf("Resolve errored with env-only bedrock: %v", err)
533	}
534	if r.Provider != "amazon-bedrock" {
535		t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
536	}
537	if !r.HasCredential() {
538		t.Fatalf("bedrock credential not resolved from env")
539	}
540}
541
542func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
543	t.Setenv("ZUT_HOME", t.TempDir())
544	provider.SetLiveModels(nil)
545	defer provider.SetLiveModels(nil)
546	provider.SetUserModels([]provider.Model{{
547		Provider:      "ollama",
548		ID:            "qwen-local",
549		DisplayName:   "Qwen Local",
550		ContextWindow: 32768,
551		MaxOutput:     8192,
552		BaseURL:       "http://localhost:8000/v1",
553	}})
554
555	r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
556	if err != nil {
557		t.Fatalf("Resolve failed: %v", err)
558	}
559	if r.BaseURL != "http://localhost:8000/v1" {
560		t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
561	}
562}
563
564func TestResolveUsesInheritedSupervisorCredential(t *testing.T) {
565	t.Setenv("ZUT_HOME", t.TempDir())
566	t.Setenv("OPENAI_API_KEY", "")
567
568	r, err := Resolve(Args{
569		Provider:            "openai",
570		Model:               "gpt-5",
571		inheritedCredential: "inherited-key",
572		inheritedAuthMethod: "apikey",
573		inheritedAccountID:  "account-id",
574	}, true)
575	if err != nil {
576		t.Fatal(err)
577	}
578	if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
579		t.Fatalf("inherited credential was not preserved: %+v", r)
580	}
581}
582
583func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
584	t.Setenv("ZUT_HOME", t.TempDir())
585	t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
586	t.Setenv("LLAMA_API_KEY", "")
587	provider.SetManagedModels(nil)
588	t.Cleanup(func() { provider.SetManagedModels(nil) })
589
590	r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
591	if err != nil {
592		t.Fatalf("Resolve failed: %v", err)
593	}
594	if r.BaseURL != "http://127.0.0.1:8080/v1" {
595		t.Fatalf("BaseURL = %q", r.BaseURL)
596	}
597	if r.Credential != "local" || !r.HasCredential() {
598		t.Fatalf("credential = %q", r.Credential)
599	}
600	if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID {
601		t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID)
602	}
603}
604
605func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
606	t.Setenv("ZUT_HOME", t.TempDir())
607	t.Setenv("LLAMA_BASE_URL", "")
608	t.Setenv("LLAMA_API_KEY", "")
609	if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
610		t.Fatal(err)
611	}
612
613	r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
614	if err != nil {
615		t.Fatalf("Resolve failed: %v", err)
616	}
617	if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
618		t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
619	}
620}
621
622func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
623	t.Setenv("ZUT_HOME", t.TempDir())
624	t.Setenv("MY_COMPANY_API_KEY", "test-key")
625	path := filepath.Join(t.TempDir(), "models.json")
626	if err := os.WriteFile(path, []byte(`{
627		"providers": {
628			"my-company": {
629				"baseUrl": "https://provider.example.com/v1",
630				"api": "openai",
631				"models": [
632					{"id": "fast", "baseUrl": "https://model.example.com/v1"}
633				]
634			}
635		}
636	}`), 0o644); err != nil {
637		t.Fatal(err)
638	}
639	models, warnings := provider.LoadUserModelsWithWarnings(path)
640	if len(warnings) != 0 {
641		t.Fatalf("warnings = %v", warnings)
642	}
643	provider.SetLiveModels(nil)
644	provider.SetUserModels(models)
645	t.Cleanup(func() { provider.SetLiveModels(nil) })
646
647	r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
648	if err != nil {
649		t.Fatalf("Resolve failed: %v", err)
650	}
651	if r.BaseURL != "https://model.example.com/v1" {
652		t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
653	}
654}
655
656func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
657	requestPath := make(chan string, 1)
658	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
659		requestPath <- r.URL.Path
660		w.Header().Set("Content-Type", "text/event-stream")
661		w.WriteHeader(http.StatusOK)
662	}))
663	t.Cleanup(srv.Close)
664
665	path := filepath.Join(t.TempDir(), "models.json")
666	if err := os.WriteFile(path, []byte(`{
667		"providers": {
668			"company-responses": {
669				"baseUrl": "`+srv.URL+`/v1",
670				"api": "openai-responses",
671				"models": [{"id": "reasoning-model", "reasoning": true}]
672			}
673		}
674	}`), 0o644); err != nil {
675		t.Fatal(err)
676	}
677	models, warnings := provider.LoadUserModelsWithWarnings(path)
678	if len(warnings) != 0 {
679		t.Fatalf("warnings = %v", warnings)
680	}
681	provider.SetLiveModels(nil)
682	provider.SetUserModels(models)
683	t.Cleanup(func() { provider.SetLiveModels(nil) })
684
685	r := Resolved{
686		Provider:   "company-responses",
687		Credential: "test-key",
688		BaseURL:    srv.URL + "/v1",
689	}
690	events, err := r.NewClient().Stream(context.Background(), provider.Request{
691		Model:    "reasoning-model",
692		Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
693	})
694	if err != nil {
695		t.Fatal(err)
696	}
697	for range events {
698	}
699	if got := <-requestPath; got != "/v1/responses" {
700		t.Fatalf("request path = %q, want /v1/responses", got)
701	}
702}
703
704func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
705	t.Setenv("ZUT_HOME", t.TempDir())
706	t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
707	path := filepath.Join(t.TempDir(), "models.json")
708	if err := os.WriteFile(path, []byte(`{
709		"providers": {
710			"local-proxy": {
711				"baseUrl": "https://proxy.example.com/v1",
712				"api": "openai",
713				"models": [{"id": "default"}]
714			}
715		}
716	}`), 0o644); err != nil {
717		t.Fatal(err)
718	}
719	models, warnings := provider.LoadUserModelsWithWarnings(path)
720	if len(warnings) != 0 {
721		t.Fatalf("warnings = %v", warnings)
722	}
723	provider.SetLiveModels(nil)
724	provider.SetUserModels(models)
725	t.Cleanup(func() { provider.SetLiveModels(nil) })
726
727	r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
728	if err != nil {
729		t.Fatalf("Resolve failed: %v", err)
730	}
731	if !r.InsecureTLS {
732		t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
733	}
734}
735
736func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
737	t.Setenv("ZUT_HOME", t.TempDir())
738	provider.SetLiveModels(nil)
739	defer provider.SetLiveModels(nil)
740
741	r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
742	if err != nil {
743		t.Fatalf("Resolve failed: %v", err)
744	}
745	if r.BaseURL != "http://localhost:11434" {
746		t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
747	}
748}
749
750func TestCanonicalProviderResolvesAliases(t *testing.T) {
751	cases := map[string]string{
752		"bedrock":         "amazon-bedrock",
753		"AWS-Bedrock":     "amazon-bedrock",
754		"  bedrock  ":     "amazon-bedrock",
755		"vertex":          "google-vertex",
756		"gemini":          "google",
757		"azure":           "azure-openai-responses",
758		"copilot":         "github-copilot",
759		"codex":           "openai-codex",
760		"moonshot":        "moonshotai",
761		"vercel":          "vercel-ai-gateway",
762		"hf":              "huggingface",
763		"anthropic":       "anthropic",       // canonical passes through
764		"amazon-bedrock":  "amazon-bedrock",  // already canonical
765		"totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
766		"Totally-UNKNOWN": "totally-unknown",
767		"":                "",
768	}
769	for in, want := range cases {
770		if got := canonicalProvider(in); got != want {
771			t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
772		}
773	}
774}
775
776func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
777	for alias, canon := range providerAliases {
778		if !isKnownProvider(canon) {
779			t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
780		}
781	}
782}
783
784func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
785	orig := http.DefaultTransport
786	t.Cleanup(func() { http.DefaultTransport = orig })
787
788	t.Setenv("ZUT_HOME", t.TempDir())
789	t.Setenv("OPENAI_API_KEY", "test-key")
790
791	resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
792	if err != nil {
793		t.Fatalf("Resolve: %v", err)
794	}
795	if resolved.InsecureTLS {
796		t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
797	}
798	assertDefaultTransportStillSecure(t)
799
800	resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
801	if err != nil {
802		t.Fatalf("Resolve: %v", err)
803	}
804	if !resolved.InsecureTLS {
805		t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
806	}
807	assertDefaultTransportStillSecure(t)
808}
809
810func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
811	orig := http.DefaultTransport
812	t.Cleanup(func() { http.DefaultTransport = orig })
813
814	t.Setenv("ZUT_HOME", t.TempDir())
815	t.Setenv("OPENAI_API_KEY", "test-key")
816	if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
817		t.Fatal(err)
818	}
819
820	resolved, err := Resolve(Args{Provider: "openai"}, false)
821	if err != nil {
822		t.Fatalf("Resolve: %v", err)
823	}
824	if resolved.InsecureTLS {
825		t.Fatal("InsecureTLS must not be set without a custom base URL")
826	}
827	assertDefaultTransportStillSecure(t)
828
829	resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
830	if err != nil {
831		t.Fatalf("Resolve: %v", err)
832	}
833	if !resolved.InsecureTLS {
834		t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
835	}
836	assertDefaultTransportStillSecure(t)
837}
838
839func TestDefaultXAIModelIsGrok45(t *testing.T) {
840	if got := defaultModelForProvider("xai"); got != "grok-4.5" {
841		t.Fatalf("default xAI model = %q, want grok-4.5", got)
842	}
843}
844
845func assertDefaultTransportStillSecure(t *testing.T) {
846	t.Helper()
847	tr, ok := http.DefaultTransport.(*http.Transport)
848	if !ok {
849		return
850	}
851	if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
852		t.Fatal("http.DefaultTransport must not be made insecure")
853	}
854}

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