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