1package agent
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "github.com/patriceckhart/zot/packages/provider"
10)
11
12func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
13 root := t.TempDir()
14 zotHome := filepath.Join(root, "zot-home")
15 project := filepath.Join(root, "repo")
16 nested := filepath.Join(project, "packages", "app")
17 if err := os.MkdirAll(zotHome, 0o755); err != nil {
18 t.Fatal(err)
19 }
20 if err := os.MkdirAll(nested, 0o755); err != nil {
21 t.Fatal(err)
22 }
23 if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
24 t.Fatal(err)
25 }
26 if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil {
27 t.Fatal(err)
28 }
29 if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil {
30 t.Fatal(err)
31 }
32
33 got := readAgentsContext(nested, zotHome)
34 for _, want := range []string{"global rule", "repo rule", "app rule"} {
35 if !strings.Contains(got, want) {
36 t.Fatalf("readAgentsContext missing %q in:\n%s", want, got)
37 }
38 }
39 if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") {
40 t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got)
41 }
42}
43
44func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
45 got := readAgentsContext(t.TempDir(), t.TempDir())
46 if got != "" {
47 t.Fatalf("expected no context, got %q", got)
48 }
49}
50
51// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
52// startup failure caught by the user's screenshot: the persisted
53// config.json points at a model id that's no longer in the active
54// catalogue (because they edited models.json or zot's bundled
55// catalogue changed). Resolve must NOT error — strands the user
56// with no way to fix it from the TUI — and should repair the config
57// so the next launch is silent.
58func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
59 t.Setenv("ZOT_HOME", t.TempDir())
60 t.Setenv("OPENAI_API_KEY", "test-key")
61 // Persist a stale model id.
62 stale := "gpt-5.5-pro-not-real"
63 if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
64 t.Fatal(err)
65 }
66
67 r, err := Resolve(Args{}, false)
68 if err != nil {
69 t.Fatalf("Resolve refused to launch with stale model: %v", err)
70 }
71 if r.Model == stale {
72 t.Fatalf("Resolve kept stale model %q", r.Model)
73 }
74 if r.Provider != "openai" {
75 t.Errorf("provider drifted: got %q; want openai", r.Provider)
76 }
77
78 // Config on disk should now hold the fallback so subsequent
79 // launches don't repeat the warning.
80 cfg, _ := LoadConfig()
81 if cfg.Model == stale {
82 t.Errorf("config.json still pins the stale model %q", cfg.Model)
83 }
84 if cfg.Model == "" {
85 t.Errorf("config.json was emptied; expected the fallback model id")
86 }
87}
88
89// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
90// repair-on-disk happens ONLY when the stale id came from the
91// persisted config. If the user passed --model X explicitly and X is
92// unknown, we still fall back, but we don't touch their config.
93func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
94 t.Setenv("ZOT_HOME", t.TempDir())
95 t.Setenv("OPENAI_API_KEY", "test-key")
96 good := "gpt-5"
97 if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
98 t.Fatal(err)
99 }
100
101 r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
102 if err != nil {
103 t.Fatalf("Resolve errored on unknown --model: %v", err)
104 }
105 if r.Model == "gpt-totally-fake" {
106 t.Errorf("Resolve kept the bogus --model value")
107 }
108 cfg, _ := LoadConfig()
109 if cfg.Model != good {
110 t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
111 }
112}
113
114// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
115// #15: pointing ZOT_HOME at a fresh dir drops the persisted
116// config.json (which pinned provider=amazon-bedrock). Resolve must
117// still discover bedrock from the AWS env vars instead of falling back
118// to anthropic and reporting "not logged in".
119func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
120 t.Setenv("ZOT_HOME", t.TempDir()) // fresh home: no config.json
121 // Disable the Kimi CLI token fallback so a developer machine with a
122 // real Kimi CLI login doesn't pre-empt bedrock in the scan.
123 if err := SetKimiCLIFallbackDisabled(true); err != nil {
124 t.Fatal(err)
125 }
126 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
127 t.Setenv("AWS_REGION", "us-east-1")
128 // Make sure no other provider's env credential pre-empts bedrock.
129 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"} {
130 t.Setenv(k, "")
131 }
132
133 r, err := Resolve(Args{}, true)
134 if err != nil {
135 t.Fatalf("Resolve errored with env-only bedrock: %v", err)
136 }
137 if r.Provider != "amazon-bedrock" {
138 t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
139 }
140 if !r.HasCredential() {
141 t.Fatalf("bedrock credential not resolved from env")
142 }
143}
144
145func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
146 t.Setenv("ZOT_HOME", t.TempDir())
147 provider.SetLiveModels(nil)
148 defer provider.SetLiveModels(nil)
149 provider.SetUserModels([]provider.Model{{
150 Provider: "ollama",
151 ID: "qwen-local",
152 DisplayName: "Qwen Local",
153 ContextWindow: 32768,
154 MaxOutput: 8192,
155 BaseURL: "http://localhost:8000/v1",
156 }})
157
158 r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
159 if err != nil {
160 t.Fatalf("Resolve failed: %v", err)
161 }
162 if r.BaseURL != "http://localhost:8000/v1" {
163 t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
164 }
165}
166
167func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
168 t.Setenv("ZOT_HOME", t.TempDir())
169 provider.SetLiveModels(nil)
170 defer provider.SetLiveModels(nil)
171
172 r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
173 if err != nil {
174 t.Fatalf("Resolve failed: %v", err)
175 }
176 if r.BaseURL != "http://localhost:11434" {
177 t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
178 }
179}
180
181func TestCanonicalProviderResolvesAliases(t *testing.T) {
182 cases := map[string]string{
183 "bedrock": "amazon-bedrock",
184 "AWS-Bedrock": "amazon-bedrock",
185 " bedrock ": "amazon-bedrock",
186 "vertex": "google-vertex",
187 "gemini": "google",
188 "azure": "azure-openai-responses",
189 "copilot": "github-copilot",
190 "codex": "openai-codex",
191 "moonshot": "moonshotai",
192 "vercel": "vercel-ai-gateway",
193 "hf": "huggingface",
194 "anthropic": "anthropic", // canonical passes through
195 "amazon-bedrock": "amazon-bedrock", // already canonical
196 "totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
197 "Totally-UNKNOWN": "totally-unknown",
198 "": "",
199 }
200 for in, want := range cases {
201 if got := canonicalProvider(in); got != want {
202 t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
203 }
204 }
205}
206
207func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
208 for alias, canon := range providerAliases {
209 if !isKnownProvider(canon) {
210 t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
211 }
212 }
213}