package agent import ( "net/http" "os" "path/filepath" "strings" "testing" "github.com/patriceckhart/zot/packages/provider" ) func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { root := t.TempDir() zotHome := filepath.Join(root, "zot-home") project := filepath.Join(root, "repo") nested := filepath.Join(project, "packages", "app") if err := os.MkdirAll(zotHome, 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(nested, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil { t.Fatal(err) } got := readAgentsContext(nested, zotHome) for _, want := range []string{"global rule", "repo rule", "app rule"} { if !strings.Contains(got, want) { t.Fatalf("readAgentsContext missing %q in:\n%s", want, got) } } if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") { t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got) } } func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { got := readAgentsContext(t.TempDir(), t.TempDir()) if got != "" { t.Fatalf("expected no context, got %q", got) } } // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the // startup failure caught by the user's screenshot: the persisted // config.json points at a model id that's no longer in the active // catalogue (because they edited models.json or zot's bundled // catalogue changed). Resolve must NOT error — strands the user // with no way to fix it from the TUI — and should repair the config // so the next launch is silent. func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) { t.Setenv("ZOT_HOME", t.TempDir()) t.Setenv("OPENAI_API_KEY", "test-key") // Persist a stale model id. stale := "gpt-5.5-pro-not-real" if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil { t.Fatal(err) } r, err := Resolve(Args{}, false) if err != nil { t.Fatalf("Resolve refused to launch with stale model: %v", err) } if r.Model == stale { t.Fatalf("Resolve kept stale model %q", r.Model) } if r.Provider != "openai" { t.Errorf("provider drifted: got %q; want openai", r.Provider) } // Config on disk should now hold the fallback so subsequent // launches don't repeat the warning. cfg, _ := LoadConfig() if cfg.Model == stale { t.Errorf("config.json still pins the stale model %q", cfg.Model) } if cfg.Model == "" { t.Errorf("config.json was emptied; expected the fallback model id") } } func TestResolveAppliesJailByDefault(t *testing.T) { for _, tc := range []struct { name string config Config want bool }{ {name: "missing defaults to unlocked", config: Config{Provider: "openai", Model: "gpt-5"}}, {name: "enabled starts locked", config: Config{Provider: "openai", Model: "gpt-5", JailByDefault: boolPtr(true)}, want: true}, } { t.Run(tc.name, func(t *testing.T) { t.Setenv("ZOT_HOME", t.TempDir()) t.Setenv("OPENAI_API_KEY", "test-key") if err := SaveConfig(tc.config); err != nil { t.Fatal(err) } r, err := Resolve(Args{}, false) if err != nil { t.Fatalf("Resolve failed: %v", err) } if got := r.Sandbox.Locked(); got != tc.want { t.Fatalf("sandbox locked = %v, want %v", got, tc.want) } }) } } func boolPtr(v bool) *bool { return &v } // TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the // repair-on-disk happens ONLY when the stale id came from the // persisted config. If the user passed --model X explicitly and X is // unknown, we still fall back, but we don't touch their config. func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) { t.Setenv("ZOT_HOME", t.TempDir()) t.Setenv("OPENAI_API_KEY", "test-key") good := "gpt-5" if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil { t.Fatal(err) } r, err := Resolve(Args{Model: "gpt-totally-fake"}, false) if err != nil { t.Fatalf("Resolve errored on unknown --model: %v", err) } if r.Model == "gpt-totally-fake" { t.Errorf("Resolve kept the bogus --model value") } cfg, _ := LoadConfig() if cfg.Model != good { t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model) } } func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) { t.Setenv("ZOT_HOME", t.TempDir()) t.Setenv("OPENROUTER_API_KEY", "test-key") want := "deepseek/deepseek-v4-flash" if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil { t.Fatal(err) } r, err := Resolve(Args{}, true) if err != nil { t.Fatalf("Resolve failed: %v", err) } if r.Provider != "openrouter" { t.Fatalf("provider = %q, want openrouter", r.Provider) } if r.Model != want { t.Fatalf("model = %q, want %q", r.Model, want) } if r.MaxOutput != 64000 { t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput) } } func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) { t.Setenv("ZOT_HOME", t.TempDir()) t.Setenv("OPENROUTER_API_KEY", "test-key") stale := "not-a-routed-model" if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil { t.Fatal(err) } r, err := Resolve(Args{}, true) if err != nil { t.Fatalf("Resolve failed: %v", err) } if r.Provider != "openrouter" { t.Fatalf("provider = %q, want openrouter", r.Provider) } if r.Model == stale || r.Model == "" { t.Fatalf("model = %q, want repaired gateway default", r.Model) } } // TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue // #15: pointing ZOT_HOME at a fresh dir drops the persisted // config.json (which pinned provider=amazon-bedrock). Resolve must // still discover bedrock from the AWS env vars instead of falling back // to anthropic and reporting "not logged in". func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) { t.Setenv("ZOT_HOME", t.TempDir()) // fresh home: no config.json // Disable the Kimi CLI token fallback so a developer machine with a // real Kimi CLI login doesn't pre-empt bedrock in the scan. if err := SetKimiCLIFallbackDisabled(true); err != nil { t.Fatal(err) } t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token") t.Setenv("AWS_REGION", "us-east-1") // Make sure no other provider's env credential pre-empts bedrock. 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"} { t.Setenv(k, "") } r, err := Resolve(Args{}, true) if err != nil { t.Fatalf("Resolve errored with env-only bedrock: %v", err) } if r.Provider != "amazon-bedrock" { t.Fatalf("provider = %q, want amazon-bedrock", r.Provider) } if !r.HasCredential() { t.Fatalf("bedrock credential not resolved from env") } } func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) { t.Setenv("ZOT_HOME", t.TempDir()) provider.SetLiveModels(nil) defer provider.SetLiveModels(nil) provider.SetUserModels([]provider.Model{{ Provider: "ollama", ID: "qwen-local", DisplayName: "Qwen Local", ContextWindow: 32768, MaxOutput: 8192, BaseURL: "http://localhost:8000/v1", }}) r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false) if err != nil { t.Fatalf("Resolve failed: %v", err) } if r.BaseURL != "http://localhost:8000/v1" { t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL) } } func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) { t.Setenv("ZOT_HOME", t.TempDir()) t.Setenv("MY_COMPANY_API_KEY", "test-key") path := filepath.Join(t.TempDir(), "models.json") if err := os.WriteFile(path, []byte(`{ "providers": { "my-company": { "baseUrl": "https://provider.example.com/v1", "api": "openai", "models": [ {"id": "fast", "baseUrl": "https://model.example.com/v1"} ] } } }`), 0o644); err != nil { t.Fatal(err) } models, warnings := provider.LoadUserModelsWithWarnings(path) if len(warnings) != 0 { t.Fatalf("warnings = %v", warnings) } provider.SetLiveModels(nil) provider.SetUserModels(models) t.Cleanup(func() { provider.SetLiveModels(nil) }) r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true) if err != nil { t.Fatalf("Resolve failed: %v", err) } if r.BaseURL != "https://model.example.com/v1" { t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL) } } func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) { t.Setenv("ZOT_HOME", t.TempDir()) t.Setenv("LOCAL_PROXY_API_KEY", "test-key") path := filepath.Join(t.TempDir(), "models.json") if err := os.WriteFile(path, []byte(`{ "providers": { "local-proxy": { "baseUrl": "https://proxy.example.com/v1", "api": "openai", "models": [{"id": "default"}] } } }`), 0o644); err != nil { t.Fatal(err) } models, warnings := provider.LoadUserModelsWithWarnings(path) if len(warnings) != 0 { t.Fatalf("warnings = %v", warnings) } provider.SetLiveModels(nil) provider.SetUserModels(models) t.Cleanup(func() { provider.SetLiveModels(nil) }) r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true) if err != nil { t.Fatalf("Resolve failed: %v", err) } if !r.InsecureTLS { t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl") } } func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) { t.Setenv("ZOT_HOME", t.TempDir()) provider.SetLiveModels(nil) defer provider.SetLiveModels(nil) r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false) if err != nil { t.Fatalf("Resolve failed: %v", err) } if r.BaseURL != "http://localhost:11434" { t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL) } } func TestCanonicalProviderResolvesAliases(t *testing.T) { cases := map[string]string{ "bedrock": "amazon-bedrock", "AWS-Bedrock": "amazon-bedrock", " bedrock ": "amazon-bedrock", "vertex": "google-vertex", "gemini": "google", "azure": "azure-openai-responses", "copilot": "github-copilot", "codex": "openai-codex", "moonshot": "moonshotai", "vercel": "vercel-ai-gateway", "hf": "huggingface", "anthropic": "anthropic", // canonical passes through "amazon-bedrock": "amazon-bedrock", // already canonical "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered) "Totally-UNKNOWN": "totally-unknown", "": "", } for in, want := range cases { if got := canonicalProvider(in); got != want { t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want) } } } func TestCanonicalProviderAliasesAreKnown(t *testing.T) { for alias, canon := range providerAliases { if !isKnownProvider(canon) { t.Errorf("alias %q maps to %q which is not a known provider", alias, canon) } } } func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) { orig := http.DefaultTransport t.Cleanup(func() { http.DefaultTransport = orig }) t.Setenv("ZOT_HOME", t.TempDir()) t.Setenv("OPENAI_API_KEY", "test-key") resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false) if err != nil { t.Fatalf("Resolve: %v", err) } if resolved.InsecureTLS { t.Fatal("InsecureTLS must not be set for built-in provider base URLs") } assertDefaultTransportStillSecure(t) resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false) if err != nil { t.Fatalf("Resolve: %v", err) } if !resolved.InsecureTLS { t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url") } assertDefaultTransportStillSecure(t) } func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) { orig := http.DefaultTransport t.Cleanup(func() { http.DefaultTransport = orig }) t.Setenv("ZOT_HOME", t.TempDir()) t.Setenv("OPENAI_API_KEY", "test-key") if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil { t.Fatal(err) } resolved, err := Resolve(Args{Provider: "openai"}, false) if err != nil { t.Fatalf("Resolve: %v", err) } if resolved.InsecureTLS { t.Fatal("InsecureTLS must not be set without a custom base URL") } assertDefaultTransportStillSecure(t) resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false) if err != nil { t.Fatalf("Resolve: %v", err) } if !resolved.InsecureTLS { t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided") } assertDefaultTransportStillSecure(t) } func TestDefaultXAIModelIsGrok45(t *testing.T) { if got := defaultModelForProvider("xai"); got != "grok-4.5" { t.Fatalf("default xAI model = %q, want grok-4.5", got) } } func assertDefaultTransportStillSecure(t *testing.T) { t.Helper() tr, ok := http.DefaultTransport.(*http.Transport) if !ok { return } if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify { t.Fatal("http.DefaultTransport must not be made insecure") } }