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