1package agent
2
3import (
4 "net/http"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "github.com/patriceckhart/zot/packages/provider"
11)
12
13func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
14 root := t.TempDir()
15 zotHome := filepath.Join(root, "zot-home")
16 project := filepath.Join(root, "repo")
17 nested := filepath.Join(project, "packages", "app")
18 if err := os.MkdirAll(zotHome, 0o755); err != nil {
19 t.Fatal(err)
20 }
21 if err := os.MkdirAll(nested, 0o755); err != nil {
22 t.Fatal(err)
23 }
24 if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
25 t.Fatal(err)
26 }
27 if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil {
28 t.Fatal(err)
29 }
30 if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil {
31 t.Fatal(err)
32 }
33
34 got := readAgentsContext(nested, zotHome)
35 for _, want := range []string{"global rule", "repo rule", "app rule"} {
36 if !strings.Contains(got, want) {
37 t.Fatalf("readAgentsContext missing %q in:\n%s", want, got)
38 }
39 }
40 if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") {
41 t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got)
42 }
43}
44
45func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
46 got := readAgentsContext(t.TempDir(), t.TempDir())
47 if got != "" {
48 t.Fatalf("expected no context, got %q", got)
49 }
50}
51
52// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
53// startup failure caught by the user's screenshot: the persisted
54// config.json points at a model id that's no longer in the active
55// catalogue (because they edited models.json or zot's bundled
56// catalogue changed). Resolve must NOT error — strands the user
57// with no way to fix it from the TUI — and should repair the config
58// so the next launch is silent.
59func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
60 t.Setenv("ZOT_HOME", t.TempDir())
61 t.Setenv("OPENAI_API_KEY", "test-key")
62 // Persist a stale model id.
63 stale := "gpt-5.5-pro-not-real"
64 if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
65 t.Fatal(err)
66 }
67
68 r, err := Resolve(Args{}, false)
69 if err != nil {
70 t.Fatalf("Resolve refused to launch with stale model: %v", err)
71 }
72 if r.Model == stale {
73 t.Fatalf("Resolve kept stale model %q", r.Model)
74 }
75 if r.Provider != "openai" {
76 t.Errorf("provider drifted: got %q; want openai", r.Provider)
77 }
78
79 // Config on disk should now hold the fallback so subsequent
80 // launches don't repeat the warning.
81 cfg, _ := LoadConfig()
82 if cfg.Model == stale {
83 t.Errorf("config.json still pins the stale model %q", cfg.Model)
84 }
85 if cfg.Model == "" {
86 t.Errorf("config.json was emptied; expected the fallback model id")
87 }
88}
89
90func TestResolveAppliesJailByDefault(t *testing.T) {
91 for _, tc := range []struct {
92 name string
93 config Config
94 want bool
95 }{
96 {name: "missing defaults to unlocked", config: Config{Provider: "openai", Model: "gpt-5"}},
97 {name: "enabled starts locked", config: Config{Provider: "openai", Model: "gpt-5", JailByDefault: boolPtr(true)}, want: true},
98 } {
99 t.Run(tc.name, func(t *testing.T) {
100 t.Setenv("ZOT_HOME", t.TempDir())
101 t.Setenv("OPENAI_API_KEY", "test-key")
102 if err := SaveConfig(tc.config); err != nil {
103 t.Fatal(err)
104 }
105
106 r, err := Resolve(Args{}, false)
107 if err != nil {
108 t.Fatalf("Resolve failed: %v", err)
109 }
110 if got := r.Sandbox.Locked(); got != tc.want {
111 t.Fatalf("sandbox locked = %v, want %v", got, tc.want)
112 }
113 })
114 }
115}
116
117func boolPtr(v bool) *bool { return &v }
118
119// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
120// repair-on-disk happens ONLY when the stale id came from the
121// persisted config. If the user passed --model X explicitly and X is
122// unknown, we still fall back, but we don't touch their config.
123func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
124 t.Setenv("ZOT_HOME", t.TempDir())
125 t.Setenv("OPENAI_API_KEY", "test-key")
126 good := "gpt-5"
127 if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
128 t.Fatal(err)
129 }
130
131 r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
132 if err != nil {
133 t.Fatalf("Resolve errored on unknown --model: %v", err)
134 }
135 if r.Model == "gpt-totally-fake" {
136 t.Errorf("Resolve kept the bogus --model value")
137 }
138 cfg, _ := LoadConfig()
139 if cfg.Model != good {
140 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
141 }
142}
143
144func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
145 t.Setenv("ZOT_HOME", t.TempDir())
146 t.Setenv("OPENROUTER_API_KEY", "test-key")
147 want := "deepseek/deepseek-v4-flash"
148 if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
149 t.Fatal(err)
150 }
151
152 r, err := Resolve(Args{}, true)
153 if err != nil {
154 t.Fatalf("Resolve failed: %v", err)
155 }
156 if r.Provider != "openrouter" {
157 t.Fatalf("provider = %q, want openrouter", r.Provider)
158 }
159 if r.Model != want {
160 t.Fatalf("model = %q, want %q", r.Model, want)
161 }
162 if r.MaxOutput != 64000 {
163 t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
164 }
165}
166
167func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
168 t.Setenv("ZOT_HOME", t.TempDir())
169 t.Setenv("OPENROUTER_API_KEY", "test-key")
170 stale := "not-a-routed-model"
171 if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
172 t.Fatal(err)
173 }
174
175 r, err := Resolve(Args{}, true)
176 if err != nil {
177 t.Fatalf("Resolve failed: %v", err)
178 }
179 if r.Provider != "openrouter" {
180 t.Fatalf("provider = %q, want openrouter", r.Provider)
181 }
182 if r.Model == stale || r.Model == "" {
183 t.Fatalf("model = %q, want repaired gateway default", r.Model)
184 }
185}
186
187// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
188// #15: pointing ZOT_HOME at a fresh dir drops the persisted
189// config.json (which pinned provider=amazon-bedrock). Resolve must
190// still discover bedrock from the AWS env vars instead of falling back
191// to anthropic and reporting "not logged in".
192func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
193 t.Setenv("ZOT_HOME", t.TempDir()) // fresh home: no config.json
194 // Disable the Kimi CLI token fallback so a developer machine with a
195 // real Kimi CLI login doesn't pre-empt bedrock in the scan.
196 if err := SetKimiCLIFallbackDisabled(true); err != nil {
197 t.Fatal(err)
198 }
199 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
200 t.Setenv("AWS_REGION", "us-east-1")
201 // Make sure no other provider's env credential pre-empts bedrock.
202 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"} {
203 t.Setenv(k, "")
204 }
205
206 r, err := Resolve(Args{}, true)
207 if err != nil {
208 t.Fatalf("Resolve errored with env-only bedrock: %v", err)
209 }
210 if r.Provider != "amazon-bedrock" {
211 t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
212 }
213 if !r.HasCredential() {
214 t.Fatalf("bedrock credential not resolved from env")
215 }
216}
217
218func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
219 t.Setenv("ZOT_HOME", t.TempDir())
220 provider.SetLiveModels(nil)
221 defer provider.SetLiveModels(nil)
222 provider.SetUserModels([]provider.Model{{
223 Provider: "ollama",
224 ID: "qwen-local",
225 DisplayName: "Qwen Local",
226 ContextWindow: 32768,
227 MaxOutput: 8192,
228 BaseURL: "http://localhost:8000/v1",
229 }})
230
231 r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
232 if err != nil {
233 t.Fatalf("Resolve failed: %v", err)
234 }
235 if r.BaseURL != "http://localhost:8000/v1" {
236 t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
237 }
238}
239
240func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
241 t.Setenv("ZOT_HOME", t.TempDir())
242 t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
243 t.Setenv("LLAMA_API_KEY", "")
244 provider.SetManagedModels(nil)
245 t.Cleanup(func() { provider.SetManagedModels(nil) })
246
247 r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
248 if err != nil {
249 t.Fatalf("Resolve failed: %v", err)
250 }
251 if r.BaseURL != "http://127.0.0.1:8080/v1" {
252 t.Fatalf("BaseURL = %q", r.BaseURL)
253 }
254 if r.Credential != "local" || !r.HasCredential() {
255 t.Fatalf("credential = %q", r.Credential)
256 }
257 if got := r.NewClient().Name(); got != "openai" {
258 t.Fatalf("client name = %q", got)
259 }
260}
261
262func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
263 t.Setenv("ZOT_HOME", t.TempDir())
264 t.Setenv("LLAMA_BASE_URL", "")
265 t.Setenv("LLAMA_API_KEY", "")
266 if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
267 t.Fatal(err)
268 }
269
270 r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
271 if err != nil {
272 t.Fatalf("Resolve failed: %v", err)
273 }
274 if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
275 t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
276 }
277}
278
279func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
280 t.Setenv("ZOT_HOME", t.TempDir())
281 t.Setenv("MY_COMPANY_API_KEY", "test-key")
282 path := filepath.Join(t.TempDir(), "models.json")
283 if err := os.WriteFile(path, []byte(`{
284 "providers": {
285 "my-company": {
286 "baseUrl": "https://provider.example.com/v1",
287 "api": "openai",
288 "models": [
289 {"id": "fast", "baseUrl": "https://model.example.com/v1"}
290 ]
291 }
292 }
293 }`), 0o644); err != nil {
294 t.Fatal(err)
295 }
296 models, warnings := provider.LoadUserModelsWithWarnings(path)
297 if len(warnings) != 0 {
298 t.Fatalf("warnings = %v", warnings)
299 }
300 provider.SetLiveModels(nil)
301 provider.SetUserModels(models)
302 t.Cleanup(func() { provider.SetLiveModels(nil) })
303
304 r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
305 if err != nil {
306 t.Fatalf("Resolve failed: %v", err)
307 }
308 if r.BaseURL != "https://model.example.com/v1" {
309 t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
310 }
311}
312
313func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
314 t.Setenv("ZOT_HOME", t.TempDir())
315 t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
316 path := filepath.Join(t.TempDir(), "models.json")
317 if err := os.WriteFile(path, []byte(`{
318 "providers": {
319 "local-proxy": {
320 "baseUrl": "https://proxy.example.com/v1",
321 "api": "openai",
322 "models": [{"id": "default"}]
323 }
324 }
325 }`), 0o644); err != nil {
326 t.Fatal(err)
327 }
328 models, warnings := provider.LoadUserModelsWithWarnings(path)
329 if len(warnings) != 0 {
330 t.Fatalf("warnings = %v", warnings)
331 }
332 provider.SetLiveModels(nil)
333 provider.SetUserModels(models)
334 t.Cleanup(func() { provider.SetLiveModels(nil) })
335
336 r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
337 if err != nil {
338 t.Fatalf("Resolve failed: %v", err)
339 }
340 if !r.InsecureTLS {
341 t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
342 }
343}
344
345func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
346 t.Setenv("ZOT_HOME", t.TempDir())
347 provider.SetLiveModels(nil)
348 defer provider.SetLiveModels(nil)
349
350 r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
351 if err != nil {
352 t.Fatalf("Resolve failed: %v", err)
353 }
354 if r.BaseURL != "http://localhost:11434" {
355 t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
356 }
357}
358
359func TestCanonicalProviderResolvesAliases(t *testing.T) {
360 cases := map[string]string{
361 "bedrock": "amazon-bedrock",
362 "AWS-Bedrock": "amazon-bedrock",
363 " bedrock ": "amazon-bedrock",
364 "vertex": "google-vertex",
365 "gemini": "google",
366 "azure": "azure-openai-responses",
367 "copilot": "github-copilot",
368 "codex": "openai-codex",
369 "moonshot": "moonshotai",
370 "vercel": "vercel-ai-gateway",
371 "hf": "huggingface",
372 "anthropic": "anthropic", // canonical passes through
373 "amazon-bedrock": "amazon-bedrock", // already canonical
374 "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
375 "Totally-UNKNOWN": "totally-unknown",
376 "": "",
377 }
378 for in, want := range cases {
379 if got := canonicalProvider(in); got != want {
380 t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
381 }
382 }
383}
384
385func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
386 for alias, canon := range providerAliases {
387 if !isKnownProvider(canon) {
388 t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
389 }
390 }
391}
392
393func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
394 orig := http.DefaultTransport
395 t.Cleanup(func() { http.DefaultTransport = orig })
396
397 t.Setenv("ZOT_HOME", t.TempDir())
398 t.Setenv("OPENAI_API_KEY", "test-key")
399
400 resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
401 if err != nil {
402 t.Fatalf("Resolve: %v", err)
403 }
404 if resolved.InsecureTLS {
405 t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
406 }
407 assertDefaultTransportStillSecure(t)
408
409 resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
410 if err != nil {
411 t.Fatalf("Resolve: %v", err)
412 }
413 if !resolved.InsecureTLS {
414 t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
415 }
416 assertDefaultTransportStillSecure(t)
417}
418
419func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
420 orig := http.DefaultTransport
421 t.Cleanup(func() { http.DefaultTransport = orig })
422
423 t.Setenv("ZOT_HOME", t.TempDir())
424 t.Setenv("OPENAI_API_KEY", "test-key")
425 if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
426 t.Fatal(err)
427 }
428
429 resolved, err := Resolve(Args{Provider: "openai"}, false)
430 if err != nil {
431 t.Fatalf("Resolve: %v", err)
432 }
433 if resolved.InsecureTLS {
434 t.Fatal("InsecureTLS must not be set without a custom base URL")
435 }
436 assertDefaultTransportStillSecure(t)
437
438 resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
439 if err != nil {
440 t.Fatalf("Resolve: %v", err)
441 }
442 if !resolved.InsecureTLS {
443 t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
444 }
445 assertDefaultTransportStillSecure(t)
446}
447
448func TestDefaultXAIModelIsGrok45(t *testing.T) {
449 if got := defaultModelForProvider("xai"); got != "grok-4.5" {
450 t.Fatalf("default xAI model = %q, want grok-4.5", got)
451 }
452}
453
454func assertDefaultTransportStillSecure(t *testing.T) {
455 t.Helper()
456 tr, ok := http.DefaultTransport.(*http.Transport)
457 if !ok {
458 return
459 }
460 if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
461 t.Fatal("http.DefaultTransport must not be made insecure")
462 }
463}