@@ -2,25 +2,299 @@ package config
22
33import (
44 "encoding/json"
5− "fmt"
65 "os"
6+ "path/filepath"
7+ "sync"
78 "testing"
89
10+ "github.com/charmbracelet/crush/internal/fur/provider"
911 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
1013)
1114
12−func resetEnvVars() {
13− os.Setenv("ANTHROPIC_API_KEY", "")
14− os.Setenv("OPENAI_API_KEY", "")
15− os.Setenv("GEMINI_API_KEY", "")
16− os.Setenv("XAI_API_KEY", "")
17− os.Setenv("OPENROUTER_API_KEY", "")
15+func reset() {
16+ // Clear all environment variables that could affect config
17+ envVarsToUnset := []string{
18+ // API Keys
19+ "ANTHROPIC_API_KEY",
20+ "OPENAI_API_KEY",
21+ "GEMINI_API_KEY",
22+ "XAI_API_KEY",
23+ "OPENROUTER_API_KEY",
24+
25+ // Google Cloud / VertexAI
26+ "GOOGLE_GENAI_USE_VERTEXAI",
27+ "GOOGLE_CLOUD_PROJECT",
28+ "GOOGLE_CLOUD_LOCATION",
29+
30+ // AWS Credentials
31+ "AWS_ACCESS_KEY_ID",
32+ "AWS_SECRET_ACCESS_KEY",
33+ "AWS_REGION",
34+ "AWS_DEFAULT_REGION",
35+ "AWS_PROFILE",
36+ "AWS_DEFAULT_PROFILE",
37+ "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
38+ "AWS_CONTAINER_CREDENTIALS_FULL_URI",
39+
40+ // Other
41+ "CRUSH_DEV_DEBUG",
42+ }
43+
44+ for _, envVar := range envVarsToUnset {
45+ os.Unsetenv(envVar)
46+ }
47+
48+ // Reset singleton
49+ once = sync.Once{}
50+ instance = nil
51+ cwd = ""
52+ testConfigDir = ""
1853}
1954
20−func TestConfigWithEnv(t *testing.T) {
21− resetEnvVars()
55+// Core Configuration Loading Tests
56+
57+func TestInit_ValidWorkingDirectory(t *testing.T) {
58+ reset()
59+ testConfigDir = t.TempDir()
60+ cwdDir := t.TempDir()
61+
62+ cfg, err := Init(cwdDir, false)
63+
64+ require.NoError(t, err)
65+ assert.NotNil(t, cfg)
66+ assert.Equal(t, cwdDir, WorkingDirectory())
67+ assert.Equal(t, defaultDataDirectory, cfg.Options.DataDirectory)
68+ assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
69+}
70+
71+func TestInit_WithDebugFlag(t *testing.T) {
72+ reset()
73+ testConfigDir = t.TempDir()
74+ cwdDir := t.TempDir()
75+
76+ cfg, err := Init(cwdDir, true)
77+
78+ require.NoError(t, err)
79+ assert.True(t, cfg.Options.Debug)
80+}
81+
82+func TestInit_SingletonBehavior(t *testing.T) {
83+ reset()
84+ testConfigDir = t.TempDir()
85+ cwdDir := t.TempDir()
86+
87+ cfg1, err1 := Init(cwdDir, false)
88+ cfg2, err2 := Init(cwdDir, false)
89+
90+ require.NoError(t, err1)
91+ require.NoError(t, err2)
92+ assert.Same(t, cfg1, cfg2) // Should be the same instance
93+}
94+
95+func TestGet_BeforeInitialization(t *testing.T) {
96+ reset()
97+
98+ assert.Panics(t, func() {
99+ Get()
100+ })
101+}
102+
103+func TestGet_AfterInitialization(t *testing.T) {
104+ reset()
105+ testConfigDir = t.TempDir()
106+ cwdDir := t.TempDir()
107+
108+ cfg1, err := Init(cwdDir, false)
109+ require.NoError(t, err)
110+
111+ cfg2 := Get()
112+ assert.Same(t, cfg1, cfg2)
113+}
114+
115+func TestLoadConfig_NoConfigFiles(t *testing.T) {
116+ reset()
117+ testConfigDir = t.TempDir()
118+ cwdDir := t.TempDir()
119+
120+ cfg, err := Init(cwdDir, false)
121+
122+ require.NoError(t, err)
123+ assert.Len(t, cfg.Providers, 0) // No providers without env vars or config files
124+ assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
125+}
126+
127+func TestLoadConfig_OnlyGlobalConfig(t *testing.T) {
128+ reset()
129+ testConfigDir = t.TempDir()
130+ cwdDir := t.TempDir()
131+
132+ // Create global config file
133+ globalConfig := Config{
134+ Providers: map[provider.InferenceProvider]ProviderConfig{
135+ provider.InferenceProviderOpenAI: {
136+ ID: provider.InferenceProviderOpenAI,
137+ APIKey: "test-key",
138+ ProviderType: provider.TypeOpenAI,
139+ },
140+ },
141+ Options: Options{
142+ ContextPaths: []string{"custom-context.md"},
143+ },
144+ }
145+
146+ configPath := filepath.Join(testConfigDir, "crush.json")
147+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
148+
149+ data, err := json.Marshal(globalConfig)
150+ require.NoError(t, err)
151+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
152+
153+ cfg, err := Init(cwdDir, false)
154+
155+ require.NoError(t, err)
156+ assert.Len(t, cfg.Providers, 1)
157+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
158+ assert.Contains(t, cfg.Options.ContextPaths, "custom-context.md")
159+}
160+
161+func TestLoadConfig_OnlyLocalConfig(t *testing.T) {
162+ reset()
163+ testConfigDir = t.TempDir()
164+ cwdDir := t.TempDir()
165+
166+ // Create local config file
167+ localConfig := Config{
168+ Providers: map[provider.InferenceProvider]ProviderConfig{
169+ provider.InferenceProviderAnthropic: {
170+ ID: provider.InferenceProviderAnthropic,
171+ APIKey: "local-key",
172+ ProviderType: provider.TypeAnthropic,
173+ },
174+ },
175+ Options: Options{
176+ TUI: TUIOptions{CompactMode: true},
177+ },
178+ }
179+
180+ localConfigPath := filepath.Join(cwdDir, "crush.json")
181+ data, err := json.Marshal(localConfig)
182+ require.NoError(t, err)
183+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
184+
185+ cfg, err := Init(cwdDir, false)
186+
187+ require.NoError(t, err)
188+ assert.Len(t, cfg.Providers, 1)
189+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
190+ assert.True(t, cfg.Options.TUI.CompactMode)
191+}
192+
193+func TestLoadConfig_BothGlobalAndLocal(t *testing.T) {
194+ reset()
195+ testConfigDir = t.TempDir()
196+ cwdDir := t.TempDir()
197+
198+ // Create global config
199+ globalConfig := Config{
200+ Providers: map[provider.InferenceProvider]ProviderConfig{
201+ provider.InferenceProviderOpenAI: {
202+ ID: provider.InferenceProviderOpenAI,
203+ APIKey: "global-key",
204+ ProviderType: provider.TypeOpenAI,
205+ },
206+ },
207+ Options: Options{
208+ ContextPaths: []string{"global-context.md"},
209+ },
210+ }
211+
212+ configPath := filepath.Join(testConfigDir, "crush.json")
213+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
214+ data, err := json.Marshal(globalConfig)
215+ require.NoError(t, err)
216+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
217+
218+ // Create local config that overrides and adds
219+ localConfig := Config{
220+ Providers: map[provider.InferenceProvider]ProviderConfig{
221+ provider.InferenceProviderOpenAI: {
222+ APIKey: "local-key", // Override global
223+ },
224+ provider.InferenceProviderAnthropic: {
225+ ID: provider.InferenceProviderAnthropic,
226+ APIKey: "anthropic-key",
227+ ProviderType: provider.TypeAnthropic,
228+ },
229+ },
230+ Options: Options{
231+ ContextPaths: []string{"local-context.md"},
232+ TUI: TUIOptions{CompactMode: true},
233+ },
234+ }
235+
236+ localConfigPath := filepath.Join(cwdDir, "crush.json")
237+ data, err = json.Marshal(localConfig)
238+ require.NoError(t, err)
239+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
240+
241+ cfg, err := Init(cwdDir, false)
242+
243+ require.NoError(t, err)
244+ assert.Len(t, cfg.Providers, 2)
245+
246+ // Check that local config overrode global
247+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
248+ assert.Equal(t, "local-key", openaiProvider.APIKey)
249+
250+ // Check that local config added new provider
251+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
252+
253+ // Check that context paths were merged
254+ assert.Contains(t, cfg.Options.ContextPaths, "global-context.md")
255+ assert.Contains(t, cfg.Options.ContextPaths, "local-context.md")
256+ assert.True(t, cfg.Options.TUI.CompactMode)
257+}
258+
259+func TestLoadConfig_MalformedGlobalJSON(t *testing.T) {
260+ reset()
261+ testConfigDir = t.TempDir()
262+ cwdDir := t.TempDir()
263+
264+ // Create malformed global config
265+ configPath := filepath.Join(testConfigDir, "crush.json")
266+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
267+ require.NoError(t, os.WriteFile(configPath, []byte(`{invalid json`), 0o644))
268+
269+ _, err := Init(cwdDir, false)
270+ assert.Error(t, err)
271+}
272+
273+func TestLoadConfig_MalformedLocalJSON(t *testing.T) {
274+ reset()
275+ testConfigDir = t.TempDir()
276+ cwdDir := t.TempDir()
277+
278+ // Create malformed local config
279+ localConfigPath := filepath.Join(cwdDir, "crush.json")
280+ require.NoError(t, os.WriteFile(localConfigPath, []byte(`{invalid json`), 0o644))
281+
282+ _, err := Init(cwdDir, false)
283+ assert.Error(t, err)
284+}
285+
286+func TestConfigWithoutEnv(t *testing.T) {
287+ reset()
22288 testConfigDir = t.TempDir()
289+ cwdDir := t.TempDir()
290+
291+ cfg, _ := Init(cwdDir, false)
292+ assert.Len(t, cfg.Providers, 0)
293+}
23294
295+func TestConfigWithEnv(t *testing.T) {
296+ reset()
297+ testConfigDir = t.TempDir()
24298 cwdDir := t.TempDir()
25299
26300 os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
@@ -28,8 +302,1914 @@ func TestConfigWithEnv(t *testing.T) {
28302 os.Setenv("GEMINI_API_KEY", "test-gemini-key")
29303 os.Setenv("XAI_API_KEY", "test-xai-key")
30304 os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
305+
31306 cfg, _ := Init(cwdDir, false)
32− data, _ := json.MarshalIndent(cfg, "", " ")
33− fmt.Println(string(data))
34307 assert.Len(t, cfg.Providers, 5)
35308}
309+
310+// Environment Variable Tests
311+
312+func TestEnvVars_NoEnvironmentVariables(t *testing.T) {
313+ reset()
314+ testConfigDir = t.TempDir()
315+ cwdDir := t.TempDir()
316+
317+ cfg, err := Init(cwdDir, false)
318+
319+ require.NoError(t, err)
320+ assert.Len(t, cfg.Providers, 0)
321+}
322+
323+func TestEnvVars_AllSupportedAPIKeys(t *testing.T) {
324+ reset()
325+ testConfigDir = t.TempDir()
326+ cwdDir := t.TempDir()
327+
328+ // Set all supported API keys
329+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
330+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
331+ os.Setenv("GEMINI_API_KEY", "test-gemini-key")
332+ os.Setenv("XAI_API_KEY", "test-xai-key")
333+ os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
334+
335+ cfg, err := Init(cwdDir, false)
336+
337+ require.NoError(t, err)
338+ assert.Len(t, cfg.Providers, 5)
339+
340+ // Verify each provider is configured correctly
341+ anthropicProvider := cfg.Providers[provider.InferenceProviderAnthropic]
342+ assert.Equal(t, "test-anthropic-key", anthropicProvider.APIKey)
343+ assert.Equal(t, provider.TypeAnthropic, anthropicProvider.ProviderType)
344+
345+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
346+ assert.Equal(t, "test-openai-key", openaiProvider.APIKey)
347+ assert.Equal(t, provider.TypeOpenAI, openaiProvider.ProviderType)
348+
349+ geminiProvider := cfg.Providers[provider.InferenceProviderGemini]
350+ assert.Equal(t, "test-gemini-key", geminiProvider.APIKey)
351+ assert.Equal(t, provider.TypeGemini, geminiProvider.ProviderType)
352+
353+ xaiProvider := cfg.Providers[provider.InferenceProviderXAI]
354+ assert.Equal(t, "test-xai-key", xaiProvider.APIKey)
355+ assert.Equal(t, provider.TypeXAI, xaiProvider.ProviderType)
356+
357+ openrouterProvider := cfg.Providers[provider.InferenceProviderOpenRouter]
358+ assert.Equal(t, "test-openrouter-key", openrouterProvider.APIKey)
359+ assert.Equal(t, provider.TypeOpenAI, openrouterProvider.ProviderType)
360+ assert.Equal(t, "https://openrouter.ai/api/v1", openrouterProvider.BaseURL)
361+}
362+
363+func TestEnvVars_PartialEnvironmentVariables(t *testing.T) {
364+ reset()
365+ testConfigDir = t.TempDir()
366+ cwdDir := t.TempDir()
367+
368+ // Set only some API keys
369+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
370+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
371+
372+ cfg, err := Init(cwdDir, false)
373+
374+ require.NoError(t, err)
375+ assert.Len(t, cfg.Providers, 2)
376+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
377+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
378+ assert.NotContains(t, cfg.Providers, provider.InferenceProviderGemini)
379+}
380+
381+func TestEnvVars_VertexAIConfiguration(t *testing.T) {
382+ reset()
383+ testConfigDir = t.TempDir()
384+ cwdDir := t.TempDir()
385+
386+ // Set VertexAI environment variables
387+ os.Setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
388+ os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
389+ os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
390+
391+ cfg, err := Init(cwdDir, false)
392+
393+ require.NoError(t, err)
394+ assert.Contains(t, cfg.Providers, provider.InferenceProviderVertexAI)
395+
396+ vertexProvider := cfg.Providers[provider.InferenceProviderVertexAI]
397+ assert.Equal(t, provider.TypeVertexAI, vertexProvider.ProviderType)
398+ assert.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
399+ assert.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
400+}
401+
402+func TestEnvVars_VertexAIWithoutUseFlag(t *testing.T) {
403+ reset()
404+ testConfigDir = t.TempDir()
405+ cwdDir := t.TempDir()
406+
407+ // Set Google Cloud vars but not the use flag
408+ os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
409+ os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
410+
411+ cfg, err := Init(cwdDir, false)
412+
413+ require.NoError(t, err)
414+ assert.NotContains(t, cfg.Providers, provider.InferenceProviderVertexAI)
415+}
416+
417+func TestEnvVars_AWSBedrockWithAccessKeys(t *testing.T) {
418+ reset()
419+ testConfigDir = t.TempDir()
420+ cwdDir := t.TempDir()
421+
422+ // Set AWS credentials
423+ os.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
424+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
425+ os.Setenv("AWS_DEFAULT_REGION", "us-east-1")
426+
427+ cfg, err := Init(cwdDir, false)
428+
429+ require.NoError(t, err)
430+ assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
431+
432+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
433+ assert.Equal(t, provider.TypeBedrock, bedrockProvider.ProviderType)
434+ assert.Equal(t, "us-east-1", bedrockProvider.ExtraParams["region"])
435+}
436+
437+func TestEnvVars_AWSBedrockWithProfile(t *testing.T) {
438+ reset()
439+ testConfigDir = t.TempDir()
440+ cwdDir := t.TempDir()
441+
442+ // Set AWS profile
443+ os.Setenv("AWS_PROFILE", "test-profile")
444+ os.Setenv("AWS_REGION", "eu-west-1")
445+
446+ cfg, err := Init(cwdDir, false)
447+
448+ require.NoError(t, err)
449+ assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
450+
451+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
452+ assert.Equal(t, "eu-west-1", bedrockProvider.ExtraParams["region"])
453+}
454+
455+func TestEnvVars_AWSBedrockWithContainerCredentials(t *testing.T) {
456+ reset()
457+ testConfigDir = t.TempDir()
458+ cwdDir := t.TempDir()
459+
460+ // Set AWS container credentials
461+ os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/test")
462+ os.Setenv("AWS_DEFAULT_REGION", "ap-southeast-1")
463+
464+ cfg, err := Init(cwdDir, false)
465+
466+ require.NoError(t, err)
467+ assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
468+}
469+
470+func TestEnvVars_AWSBedrockRegionPriority(t *testing.T) {
471+ reset()
472+ testConfigDir = t.TempDir()
473+ cwdDir := t.TempDir()
474+
475+ // Set both region variables - AWS_DEFAULT_REGION should take priority
476+ os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
477+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
478+ os.Setenv("AWS_DEFAULT_REGION", "us-west-2")
479+ os.Setenv("AWS_REGION", "us-east-1")
480+
481+ cfg, err := Init(cwdDir, false)
482+
483+ require.NoError(t, err)
484+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
485+ assert.Equal(t, "us-west-2", bedrockProvider.ExtraParams["region"])
486+}
487+
488+func TestEnvVars_AWSBedrockFallbackRegion(t *testing.T) {
489+ reset()
490+ testConfigDir = t.TempDir()
491+ cwdDir := t.TempDir()
492+
493+ // Set only AWS_REGION (not AWS_DEFAULT_REGION)
494+ os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
495+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
496+ os.Setenv("AWS_REGION", "us-east-1")
497+
498+ cfg, err := Init(cwdDir, false)
499+
500+ require.NoError(t, err)
501+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
502+ assert.Equal(t, "us-east-1", bedrockProvider.ExtraParams["region"])
503+}
504+
505+func TestEnvVars_NoAWSCredentials(t *testing.T) {
506+ reset()
507+ testConfigDir = t.TempDir()
508+ cwdDir := t.TempDir()
509+
510+ // Don't set any AWS credentials
511+ cfg, err := Init(cwdDir, false)
512+
513+ require.NoError(t, err)
514+ assert.NotContains(t, cfg.Providers, provider.InferenceProviderBedrock)
515+}
516+
517+func TestEnvVars_CustomEnvironmentVariables(t *testing.T) {
518+ reset()
519+ testConfigDir = t.TempDir()
520+ cwdDir := t.TempDir()
521+
522+ // Test that environment variables are properly resolved from provider definitions
523+ // This test assumes the provider system uses $VARIABLE_NAME format
524+ os.Setenv("ANTHROPIC_API_KEY", "resolved-anthropic-key")
525+
526+ cfg, err := Init(cwdDir, false)
527+
528+ require.NoError(t, err)
529+ if len(cfg.Providers) > 0 {
530+ // Verify that the environment variable was resolved
531+ if anthropicProvider, exists := cfg.Providers[provider.InferenceProviderAnthropic]; exists {
532+ assert.Equal(t, "resolved-anthropic-key", anthropicProvider.APIKey)
533+ }
534+ }
535+}
536+
537+func TestEnvVars_CombinedEnvironmentVariables(t *testing.T) {
538+ reset()
539+ testConfigDir = t.TempDir()
540+ cwdDir := t.TempDir()
541+
542+ // Set multiple types of environment variables
543+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic")
544+ os.Setenv("OPENAI_API_KEY", "test-openai")
545+ os.Setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
546+ os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
547+ os.Setenv("AWS_ACCESS_KEY_ID", "test-aws-key")
548+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-aws-secret")
549+ os.Setenv("AWS_DEFAULT_REGION", "us-west-1")
550+
551+ cfg, err := Init(cwdDir, false)
552+
553+ require.NoError(t, err)
554+
555+ // Should have API key providers + VertexAI + Bedrock
556+ expectedProviders := []provider.InferenceProvider{
557+ provider.InferenceProviderAnthropic,
558+ provider.InferenceProviderOpenAI,
559+ provider.InferenceProviderVertexAI,
560+ provider.InferenceProviderBedrock,
561+ }
562+
563+ for _, expectedProvider := range expectedProviders {
564+ assert.Contains(t, cfg.Providers, expectedProvider)
565+ }
566+}
567+
568+func TestHasAWSCredentials_AccessKeys(t *testing.T) {
569+ reset()
570+
571+ os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
572+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
573+
574+ assert.True(t, hasAWSCredentials())
575+}
576+
577+func TestHasAWSCredentials_Profile(t *testing.T) {
578+ reset()
579+
580+ os.Setenv("AWS_PROFILE", "test-profile")
581+
582+ assert.True(t, hasAWSCredentials())
583+}
584+
585+func TestHasAWSCredentials_DefaultProfile(t *testing.T) {
586+ reset()
587+
588+ os.Setenv("AWS_DEFAULT_PROFILE", "default")
589+
590+ assert.True(t, hasAWSCredentials())
591+}
592+
593+func TestHasAWSCredentials_Region(t *testing.T) {
594+ reset()
595+
596+ os.Setenv("AWS_REGION", "us-east-1")
597+
598+ assert.True(t, hasAWSCredentials())
599+}
600+
601+func TestHasAWSCredentials_ContainerCredentials(t *testing.T) {
602+ reset()
603+
604+ os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/test")
605+
606+ assert.True(t, hasAWSCredentials())
607+}
608+
609+func TestHasAWSCredentials_NoCredentials(t *testing.T) {
610+ reset()
611+
612+ assert.False(t, hasAWSCredentials())
613+}
614+
615+// Provider Configuration Tests
616+
617+func TestProviderMerging_GlobalToBase(t *testing.T) {
618+ reset()
619+ testConfigDir = t.TempDir()
620+ cwdDir := t.TempDir()
621+
622+ // Create global config with provider
623+ globalConfig := Config{
624+ Providers: map[provider.InferenceProvider]ProviderConfig{
625+ provider.InferenceProviderOpenAI: {
626+ ID: provider.InferenceProviderOpenAI,
627+ APIKey: "global-openai-key",
628+ ProviderType: provider.TypeOpenAI,
629+ DefaultLargeModel: "gpt-4",
630+ DefaultSmallModel: "gpt-3.5-turbo",
631+ Models: []Model{
632+ {
633+ ID: "gpt-4",
634+ Name: "GPT-4",
635+ ContextWindow: 8192,
636+ DefaultMaxTokens: 4096,
637+ },
638+ },
639+ },
640+ },
641+ }
642+
643+ configPath := filepath.Join(testConfigDir, "crush.json")
644+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
645+ data, err := json.Marshal(globalConfig)
646+ require.NoError(t, err)
647+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
648+
649+ cfg, err := Init(cwdDir, false)
650+
651+ require.NoError(t, err)
652+ assert.Len(t, cfg.Providers, 1)
653+
654+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
655+ assert.Equal(t, "global-openai-key", openaiProvider.APIKey)
656+ assert.Equal(t, "gpt-4", openaiProvider.DefaultLargeModel)
657+ assert.Equal(t, "gpt-3.5-turbo", openaiProvider.DefaultSmallModel)
658+ assert.Len(t, openaiProvider.Models, 1)
659+}
660+
661+func TestProviderMerging_LocalToBase(t *testing.T) {
662+ reset()
663+ testConfigDir = t.TempDir()
664+ cwdDir := t.TempDir()
665+
666+ // Create local config with provider
667+ localConfig := Config{
668+ Providers: map[provider.InferenceProvider]ProviderConfig{
669+ provider.InferenceProviderAnthropic: {
670+ ID: provider.InferenceProviderAnthropic,
671+ APIKey: "local-anthropic-key",
672+ ProviderType: provider.TypeAnthropic,
673+ DefaultLargeModel: "claude-3-opus",
674+ },
675+ },
676+ }
677+
678+ localConfigPath := filepath.Join(cwdDir, "crush.json")
679+ data, err := json.Marshal(localConfig)
680+ require.NoError(t, err)
681+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
682+
683+ cfg, err := Init(cwdDir, false)
684+
685+ require.NoError(t, err)
686+ assert.Len(t, cfg.Providers, 1)
687+
688+ anthropicProvider := cfg.Providers[provider.InferenceProviderAnthropic]
689+ assert.Equal(t, "local-anthropic-key", anthropicProvider.APIKey)
690+ assert.Equal(t, "claude-3-opus", anthropicProvider.DefaultLargeModel)
691+}
692+
693+func TestProviderMerging_ConflictingSettings(t *testing.T) {
694+ reset()
695+ testConfigDir = t.TempDir()
696+ cwdDir := t.TempDir()
697+
698+ // Create global config
699+ globalConfig := Config{
700+ Providers: map[provider.InferenceProvider]ProviderConfig{
701+ provider.InferenceProviderOpenAI: {
702+ ID: provider.InferenceProviderOpenAI,
703+ APIKey: "global-key",
704+ ProviderType: provider.TypeOpenAI,
705+ DefaultLargeModel: "gpt-4",
706+ DefaultSmallModel: "gpt-3.5-turbo",
707+ },
708+ },
709+ }
710+
711+ configPath := filepath.Join(testConfigDir, "crush.json")
712+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
713+ data, err := json.Marshal(globalConfig)
714+ require.NoError(t, err)
715+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
716+
717+ // Create local config that overrides
718+ localConfig := Config{
719+ Providers: map[provider.InferenceProvider]ProviderConfig{
720+ provider.InferenceProviderOpenAI: {
721+ APIKey: "local-key",
722+ DefaultLargeModel: "gpt-4-turbo",
723+ // Test disabled separately - don't disable here as it causes nil pointer
724+ },
725+ },
726+ }
727+
728+ localConfigPath := filepath.Join(cwdDir, "crush.json")
729+ data, err = json.Marshal(localConfig)
730+ require.NoError(t, err)
731+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
732+
733+ cfg, err := Init(cwdDir, false)
734+
735+ require.NoError(t, err)
736+
737+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
738+ // Local should override global
739+ assert.Equal(t, "local-key", openaiProvider.APIKey)
740+ assert.Equal(t, "gpt-4-turbo", openaiProvider.DefaultLargeModel)
741+ assert.False(t, openaiProvider.Disabled) // Should not be disabled
742+ // Global values should remain where not overridden
743+ assert.Equal(t, "gpt-3.5-turbo", openaiProvider.DefaultSmallModel)
744+}
745+
746+func TestProviderMerging_CustomVsKnownProviders(t *testing.T) {
747+ reset()
748+ testConfigDir = t.TempDir()
749+ cwdDir := t.TempDir()
750+
751+ customProviderID := provider.InferenceProvider("custom-provider")
752+
753+ // Create config with both known and custom providers
754+ globalConfig := Config{
755+ Providers: map[provider.InferenceProvider]ProviderConfig{
756+ // Known provider - some fields should not be overrideable
757+ provider.InferenceProviderOpenAI: {
758+ ID: provider.InferenceProviderOpenAI,
759+ APIKey: "openai-key",
760+ BaseURL: "should-not-override",
761+ ProviderType: provider.TypeAnthropic, // Should not override
762+ },
763+ // Custom provider - all fields should be configurable
764+ customProviderID: {
765+ ID: customProviderID,
766+ APIKey: "custom-key",
767+ BaseURL: "https://custom.api.com",
768+ ProviderType: provider.TypeOpenAI,
769+ },
770+ },
771+ }
772+
773+ localConfig := Config{
774+ Providers: map[provider.InferenceProvider]ProviderConfig{
775+ provider.InferenceProviderOpenAI: {
776+ BaseURL: "https://should-not-change.com",
777+ ProviderType: provider.TypeGemini, // Should not change
778+ },
779+ customProviderID: {
780+ BaseURL: "https://updated-custom.api.com",
781+ ProviderType: provider.TypeOpenAI,
782+ },
783+ },
784+ }
785+
786+ configPath := filepath.Join(testConfigDir, "crush.json")
787+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
788+ data, err := json.Marshal(globalConfig)
789+ require.NoError(t, err)
790+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
791+
792+ localConfigPath := filepath.Join(cwdDir, "crush.json")
793+ data, err = json.Marshal(localConfig)
794+ require.NoError(t, err)
795+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
796+
797+ cfg, err := Init(cwdDir, false)
798+
799+ require.NoError(t, err)
800+
801+ // Known provider should not have BaseURL/ProviderType overridden
802+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
803+ assert.NotEqual(t, "https://should-not-change.com", openaiProvider.BaseURL)
804+ assert.NotEqual(t, provider.TypeGemini, openaiProvider.ProviderType)
805+
806+ // Custom provider should have all fields configurable
807+ customProvider := cfg.Providers[customProviderID]
808+ assert.Equal(t, "custom-key", customProvider.APIKey) // Should preserve from global
809+ assert.Equal(t, "https://updated-custom.api.com", customProvider.BaseURL)
810+ assert.Equal(t, provider.TypeOpenAI, customProvider.ProviderType)
811+}
812+
813+func TestProviderValidation_CustomProviderMissingBaseURL(t *testing.T) {
814+ reset()
815+ testConfigDir = t.TempDir()
816+ cwdDir := t.TempDir()
817+
818+ customProviderID := provider.InferenceProvider("custom-provider")
819+
820+ // Create config with custom provider missing BaseURL
821+ globalConfig := Config{
822+ Providers: map[provider.InferenceProvider]ProviderConfig{
823+ customProviderID: {
824+ ID: customProviderID,
825+ APIKey: "custom-key",
826+ ProviderType: provider.TypeOpenAI,
827+ // Missing BaseURL
828+ },
829+ },
830+ }
831+
832+ configPath := filepath.Join(testConfigDir, "crush.json")
833+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
834+ data, err := json.Marshal(globalConfig)
835+ require.NoError(t, err)
836+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
837+
838+ cfg, err := Init(cwdDir, false)
839+
840+ require.NoError(t, err)
841+ // Provider should be filtered out due to validation failure
842+ assert.NotContains(t, cfg.Providers, customProviderID)
843+}
844+
845+func TestProviderValidation_CustomProviderMissingAPIKey(t *testing.T) {
846+ reset()
847+ testConfigDir = t.TempDir()
848+ cwdDir := t.TempDir()
849+
850+ customProviderID := provider.InferenceProvider("custom-provider")
851+
852+ globalConfig := Config{
853+ Providers: map[provider.InferenceProvider]ProviderConfig{
854+ customProviderID: {
855+ ID: customProviderID,
856+ BaseURL: "https://custom.api.com",
857+ ProviderType: provider.TypeOpenAI,
858+ // Missing APIKey
859+ },
860+ },
861+ }
862+
863+ configPath := filepath.Join(testConfigDir, "crush.json")
864+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
865+ data, err := json.Marshal(globalConfig)
866+ require.NoError(t, err)
867+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
868+
869+ cfg, err := Init(cwdDir, false)
870+
871+ require.NoError(t, err)
872+ assert.NotContains(t, cfg.Providers, customProviderID)
873+}
874+
875+func TestProviderValidation_CustomProviderInvalidType(t *testing.T) {
876+ reset()
877+ testConfigDir = t.TempDir()
878+ cwdDir := t.TempDir()
879+
880+ customProviderID := provider.InferenceProvider("custom-provider")
881+
882+ globalConfig := Config{
883+ Providers: map[provider.InferenceProvider]ProviderConfig{
884+ customProviderID: {
885+ ID: customProviderID,
886+ APIKey: "custom-key",
887+ BaseURL: "https://custom.api.com",
888+ ProviderType: provider.Type("invalid-type"),
889+ },
890+ },
891+ }
892+
893+ configPath := filepath.Join(testConfigDir, "crush.json")
894+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
895+ data, err := json.Marshal(globalConfig)
896+ require.NoError(t, err)
897+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
898+
899+ cfg, err := Init(cwdDir, false)
900+
901+ require.NoError(t, err)
902+ assert.NotContains(t, cfg.Providers, customProviderID)
903+}
904+
905+func TestProviderValidation_KnownProviderValid(t *testing.T) {
906+ reset()
907+ testConfigDir = t.TempDir()
908+ cwdDir := t.TempDir()
909+
910+ globalConfig := Config{
911+ Providers: map[provider.InferenceProvider]ProviderConfig{
912+ provider.InferenceProviderOpenAI: {
913+ ID: provider.InferenceProviderOpenAI,
914+ APIKey: "openai-key",
915+ ProviderType: provider.TypeOpenAI,
916+ // BaseURL not required for known providers
917+ },
918+ },
919+ }
920+
921+ configPath := filepath.Join(testConfigDir, "crush.json")
922+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
923+ data, err := json.Marshal(globalConfig)
924+ require.NoError(t, err)
925+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
926+
927+ cfg, err := Init(cwdDir, false)
928+
929+ require.NoError(t, err)
930+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
931+}
932+
933+func TestProviderValidation_DisabledProvider(t *testing.T) {
934+ reset()
935+ testConfigDir = t.TempDir()
936+ cwdDir := t.TempDir()
937+
938+ globalConfig := Config{
939+ Providers: map[provider.InferenceProvider]ProviderConfig{
940+ provider.InferenceProviderOpenAI: {
941+ ID: provider.InferenceProviderOpenAI,
942+ APIKey: "openai-key",
943+ ProviderType: provider.TypeOpenAI,
944+ Disabled: true,
945+ },
946+ },
947+ }
948+
949+ configPath := filepath.Join(testConfigDir, "crush.json")
950+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
951+ data, err := json.Marshal(globalConfig)
952+ require.NoError(t, err)
953+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
954+
955+ cfg, err := Init(cwdDir, false)
956+
957+ require.NoError(t, err)
958+ // Disabled providers should still be in the config but marked as disabled
959+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
960+ assert.True(t, cfg.Providers[provider.InferenceProviderOpenAI].Disabled)
961+}
962+
963+func TestProviderModels_AddingNewModels(t *testing.T) {
964+ reset()
965+ testConfigDir = t.TempDir()
966+ cwdDir := t.TempDir()
967+
968+ globalConfig := Config{
969+ Providers: map[provider.InferenceProvider]ProviderConfig{
970+ provider.InferenceProviderOpenAI: {
971+ ID: provider.InferenceProviderOpenAI,
972+ APIKey: "openai-key",
973+ ProviderType: provider.TypeOpenAI,
974+ Models: []Model{
975+ {
976+ ID: "gpt-4",
977+ Name: "GPT-4",
978+ ContextWindow: 8192,
979+ DefaultMaxTokens: 4096,
980+ },
981+ },
982+ },
983+ },
984+ }
985+
986+ localConfig := Config{
987+ Providers: map[provider.InferenceProvider]ProviderConfig{
988+ provider.InferenceProviderOpenAI: {
989+ Models: []Model{
990+ {
991+ ID: "gpt-4-turbo",
992+ Name: "GPT-4 Turbo",
993+ ContextWindow: 128000,
994+ DefaultMaxTokens: 4096,
995+ },
996+ },
997+ },
998+ },
999+ }
1000+
1001+ configPath := filepath.Join(testConfigDir, "crush.json")
1002+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1003+ data, err := json.Marshal(globalConfig)
1004+ require.NoError(t, err)
1005+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1006+
1007+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1008+ data, err = json.Marshal(localConfig)
1009+ require.NoError(t, err)
1010+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1011+
1012+ cfg, err := Init(cwdDir, false)
1013+
1014+ require.NoError(t, err)
1015+
1016+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1017+ assert.Len(t, openaiProvider.Models, 2) // Should have both models
1018+
1019+ modelIDs := make([]string, len(openaiProvider.Models))
1020+ for i, model := range openaiProvider.Models {
1021+ modelIDs[i] = model.ID
1022+ }
1023+ assert.Contains(t, modelIDs, "gpt-4")
1024+ assert.Contains(t, modelIDs, "gpt-4-turbo")
1025+}
1026+
1027+func TestProviderModels_DuplicateModelHandling(t *testing.T) {
1028+ reset()
1029+ testConfigDir = t.TempDir()
1030+ cwdDir := t.TempDir()
1031+
1032+ globalConfig := Config{
1033+ Providers: map[provider.InferenceProvider]ProviderConfig{
1034+ provider.InferenceProviderOpenAI: {
1035+ ID: provider.InferenceProviderOpenAI,
1036+ APIKey: "openai-key",
1037+ ProviderType: provider.TypeOpenAI,
1038+ Models: []Model{
1039+ {
1040+ ID: "gpt-4",
1041+ Name: "GPT-4",
1042+ ContextWindow: 8192,
1043+ DefaultMaxTokens: 4096,
1044+ },
1045+ },
1046+ },
1047+ },
1048+ }
1049+
1050+ localConfig := Config{
1051+ Providers: map[provider.InferenceProvider]ProviderConfig{
1052+ provider.InferenceProviderOpenAI: {
1053+ Models: []Model{
1054+ {
1055+ ID: "gpt-4", // Same ID as global
1056+ Name: "GPT-4 Updated",
1057+ ContextWindow: 16384,
1058+ DefaultMaxTokens: 8192,
1059+ },
1060+ },
1061+ },
1062+ },
1063+ }
1064+
1065+ configPath := filepath.Join(testConfigDir, "crush.json")
1066+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1067+ data, err := json.Marshal(globalConfig)
1068+ require.NoError(t, err)
1069+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1070+
1071+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1072+ data, err = json.Marshal(localConfig)
1073+ require.NoError(t, err)
1074+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1075+
1076+ cfg, err := Init(cwdDir, false)
1077+
1078+ require.NoError(t, err)
1079+
1080+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1081+ assert.Len(t, openaiProvider.Models, 1) // Should not duplicate
1082+
1083+ // Should keep the original model (global config)
1084+ model := openaiProvider.Models[0]
1085+ assert.Equal(t, "gpt-4", model.ID)
1086+ assert.Equal(t, "GPT-4", model.Name) // Original name
1087+ assert.Equal(t, int64(8192), model.ContextWindow) // Original context window
1088+}
1089+
1090+func TestProviderModels_ModelCostAndCapabilities(t *testing.T) {
1091+ reset()
1092+ testConfigDir = t.TempDir()
1093+ cwdDir := t.TempDir()
1094+
1095+ globalConfig := Config{
1096+ Providers: map[provider.InferenceProvider]ProviderConfig{
1097+ provider.InferenceProviderOpenAI: {
1098+ ID: provider.InferenceProviderOpenAI,
1099+ APIKey: "openai-key",
1100+ ProviderType: provider.TypeOpenAI,
1101+ Models: []Model{
1102+ {
1103+ ID: "gpt-4",
1104+ Name: "GPT-4",
1105+ CostPer1MIn: 30.0,
1106+ CostPer1MOut: 60.0,
1107+ CostPer1MInCached: 15.0,
1108+ CostPer1MOutCached: 30.0,
1109+ ContextWindow: 8192,
1110+ DefaultMaxTokens: 4096,
1111+ CanReason: true,
1112+ ReasoningEffort: "medium",
1113+ SupportsImages: true,
1114+ },
1115+ },
1116+ },
1117+ },
1118+ }
1119+
1120+ configPath := filepath.Join(testConfigDir, "crush.json")
1121+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1122+ data, err := json.Marshal(globalConfig)
1123+ require.NoError(t, err)
1124+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1125+
1126+ cfg, err := Init(cwdDir, false)
1127+
1128+ require.NoError(t, err)
1129+
1130+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1131+ require.Len(t, openaiProvider.Models, 1)
1132+
1133+ model := openaiProvider.Models[0]
1134+ assert.Equal(t, 30.0, model.CostPer1MIn)
1135+ assert.Equal(t, 60.0, model.CostPer1MOut)
1136+ assert.Equal(t, 15.0, model.CostPer1MInCached)
1137+ assert.Equal(t, 30.0, model.CostPer1MOutCached)
1138+ assert.True(t, model.CanReason)
1139+ assert.Equal(t, "medium", model.ReasoningEffort)
1140+ assert.True(t, model.SupportsImages)
1141+}
1142+
1143+// Agent Configuration Tests
1144+
1145+func TestDefaultAgents_CoderAgent(t *testing.T) {
1146+ reset()
1147+ testConfigDir = t.TempDir()
1148+ cwdDir := t.TempDir()
1149+
1150+ // Set up a provider so we can test agent configuration
1151+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1152+
1153+ cfg, err := Init(cwdDir, false)
1154+
1155+ require.NoError(t, err)
1156+ assert.Contains(t, cfg.Agents, AgentCoder)
1157+
1158+ coderAgent := cfg.Agents[AgentCoder]
1159+ assert.Equal(t, AgentCoder, coderAgent.ID)
1160+ assert.Equal(t, "Coder", coderAgent.Name)
1161+ assert.Equal(t, "An agent that helps with executing coding tasks.", coderAgent.Description)
1162+ assert.Equal(t, LargeModel, coderAgent.Model)
1163+ assert.False(t, coderAgent.Disabled)
1164+ assert.Equal(t, cfg.Options.ContextPaths, coderAgent.ContextPaths)
1165+ // Coder agent should have all tools available (nil means all tools)
1166+ assert.Nil(t, coderAgent.AllowedTools)
1167+}
1168+
1169+func TestDefaultAgents_TaskAgent(t *testing.T) {
1170+ reset()
1171+ testConfigDir = t.TempDir()
1172+ cwdDir := t.TempDir()
1173+
1174+ // Set up a provider so we can test agent configuration
1175+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1176+
1177+ cfg, err := Init(cwdDir, false)
1178+
1179+ require.NoError(t, err)
1180+ assert.Contains(t, cfg.Agents, AgentTask)
1181+
1182+ taskAgent := cfg.Agents[AgentTask]
1183+ assert.Equal(t, AgentTask, taskAgent.ID)
1184+ assert.Equal(t, "Task", taskAgent.Name)
1185+ assert.Equal(t, "An agent that helps with searching for context and finding implementation details.", taskAgent.Description)
1186+ assert.Equal(t, LargeModel, taskAgent.Model)
1187+ assert.False(t, taskAgent.Disabled)
1188+ assert.Equal(t, cfg.Options.ContextPaths, taskAgent.ContextPaths)
1189+
1190+ // Task agent should have restricted tools
1191+ expectedTools := []string{"glob", "grep", "ls", "sourcegraph", "view"}
1192+ assert.Equal(t, expectedTools, taskAgent.AllowedTools)
1193+
1194+ // Task agent should have no MCPs or LSPs by default
1195+ assert.Equal(t, map[string][]string{}, taskAgent.AllowedMCP)
1196+ assert.Equal(t, []string{}, taskAgent.AllowedLSP)
1197+}
1198+
1199+func TestAgentMerging_CustomAgent(t *testing.T) {
1200+ reset()
1201+ testConfigDir = t.TempDir()
1202+ cwdDir := t.TempDir()
1203+
1204+ // Set up a provider
1205+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1206+
1207+ // Create config with custom agent
1208+ globalConfig := Config{
1209+ Agents: map[AgentID]Agent{
1210+ AgentID("custom-agent"): {
1211+ ID: AgentID("custom-agent"),
1212+ Name: "Custom Agent",
1213+ Description: "A custom agent for testing",
1214+ Model: SmallModel,
1215+ AllowedTools: []string{"glob", "grep"},
1216+ AllowedMCP: map[string][]string{"mcp1": {"tool1", "tool2"}},
1217+ AllowedLSP: []string{"typescript", "go"},
1218+ ContextPaths: []string{"custom-context.md"},
1219+ },
1220+ },
1221+ }
1222+
1223+ configPath := filepath.Join(testConfigDir, "crush.json")
1224+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1225+ data, err := json.Marshal(globalConfig)
1226+ require.NoError(t, err)
1227+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1228+
1229+ cfg, err := Init(cwdDir, false)
1230+
1231+ require.NoError(t, err)
1232+
1233+ // Should have default agents plus custom agent
1234+ assert.Contains(t, cfg.Agents, AgentCoder)
1235+ assert.Contains(t, cfg.Agents, AgentTask)
1236+ assert.Contains(t, cfg.Agents, AgentID("custom-agent"))
1237+
1238+ customAgent := cfg.Agents[AgentID("custom-agent")]
1239+ assert.Equal(t, "Custom Agent", customAgent.Name)
1240+ assert.Equal(t, "A custom agent for testing", customAgent.Description)
1241+ assert.Equal(t, SmallModel, customAgent.Model)
1242+ assert.Equal(t, []string{"glob", "grep"}, customAgent.AllowedTools)
1243+ assert.Equal(t, map[string][]string{"mcp1": {"tool1", "tool2"}}, customAgent.AllowedMCP)
1244+ assert.Equal(t, []string{"typescript", "go"}, customAgent.AllowedLSP)
1245+ // Context paths should be additive (default + custom)
1246+ expectedContextPaths := append(defaultContextPaths, "custom-context.md")
1247+ assert.Equal(t, expectedContextPaths, customAgent.ContextPaths)
1248+}
1249+
1250+func TestAgentMerging_ModifyDefaultCoderAgent(t *testing.T) {
1251+ reset()
1252+ testConfigDir = t.TempDir()
1253+ cwdDir := t.TempDir()
1254+
1255+ // Set up a provider
1256+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1257+
1258+ // Create config that modifies the default coder agent
1259+ globalConfig := Config{
1260+ Agents: map[AgentID]Agent{
1261+ AgentCoder: {
1262+ Model: SmallModel, // Change from default LargeModel
1263+ AllowedMCP: map[string][]string{"mcp1": {"tool1"}},
1264+ AllowedLSP: []string{"typescript"},
1265+ ContextPaths: []string{"coder-specific.md"}, // Should be additive
1266+ },
1267+ },
1268+ }
1269+
1270+ configPath := filepath.Join(testConfigDir, "crush.json")
1271+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1272+ data, err := json.Marshal(globalConfig)
1273+ require.NoError(t, err)
1274+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1275+
1276+ cfg, err := Init(cwdDir, false)
1277+
1278+ require.NoError(t, err)
1279+
1280+ coderAgent := cfg.Agents[AgentCoder]
1281+ // Should preserve default values for unspecified fields
1282+ assert.Equal(t, AgentCoder, coderAgent.ID)
1283+ assert.Equal(t, "Coder", coderAgent.Name)
1284+ assert.Equal(t, "An agent that helps with executing coding tasks.", coderAgent.Description)
1285+
1286+ // Context paths should be additive (default + custom)
1287+ expectedContextPaths := append(cfg.Options.ContextPaths, "coder-specific.md")
1288+ assert.Equal(t, expectedContextPaths, coderAgent.ContextPaths)
1289+
1290+ // Should update specified fields
1291+ assert.Equal(t, SmallModel, coderAgent.Model)
1292+ assert.Equal(t, map[string][]string{"mcp1": {"tool1"}}, coderAgent.AllowedMCP)
1293+ assert.Equal(t, []string{"typescript"}, coderAgent.AllowedLSP)
1294+}
1295+
1296+func TestAgentMerging_ModifyDefaultTaskAgent(t *testing.T) {
1297+ reset()
1298+ testConfigDir = t.TempDir()
1299+ cwdDir := t.TempDir()
1300+
1301+ // Set up a provider
1302+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1303+
1304+ // Create config that modifies the default task agent
1305+ // Note: Only model, MCP, and LSP should be configurable for known agents
1306+ globalConfig := Config{
1307+ Agents: map[AgentID]Agent{
1308+ AgentTask: {
1309+ Model: SmallModel, // Should be updated
1310+ AllowedMCP: map[string][]string{"search-mcp": nil}, // Should be updated
1311+ AllowedLSP: []string{"python"}, // Should be updated
1312+ // These should be ignored for known agents:
1313+ Name: "Search Agent", // Should be ignored
1314+ Description: "Custom search agent", // Should be ignored
1315+ Disabled: true, // Should be ignored
1316+ AllowedTools: []string{"glob", "grep", "view"}, // Should be ignored
1317+ },
1318+ },
1319+ }
1320+
1321+ configPath := filepath.Join(testConfigDir, "crush.json")
1322+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1323+ data, err := json.Marshal(globalConfig)
1324+ require.NoError(t, err)
1325+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1326+
1327+ cfg, err := Init(cwdDir, false)
1328+
1329+ require.NoError(t, err)
1330+
1331+ taskAgent := cfg.Agents[AgentTask]
1332+ // Should preserve default values for protected fields
1333+ assert.Equal(t, "Task", taskAgent.Name) // Should remain default
1334+ assert.Equal(t, "An agent that helps with searching for context and finding implementation details.", taskAgent.Description) // Should remain default
1335+ assert.False(t, taskAgent.Disabled) // Should remain default
1336+ assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools) // Should remain default
1337+
1338+ // Should update configurable fields
1339+ assert.Equal(t, SmallModel, taskAgent.Model)
1340+ assert.Equal(t, map[string][]string{"search-mcp": nil}, taskAgent.AllowedMCP)
1341+ assert.Equal(t, []string{"python"}, taskAgent.AllowedLSP)
1342+}
1343+
1344+func TestAgentMerging_LocalOverridesGlobal(t *testing.T) {
1345+ reset()
1346+ testConfigDir = t.TempDir()
1347+ cwdDir := t.TempDir()
1348+
1349+ // Set up a provider
1350+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1351+
1352+ // Create global config with custom agent
1353+ globalConfig := Config{
1354+ Agents: map[AgentID]Agent{
1355+ AgentID("test-agent"): {
1356+ ID: AgentID("test-agent"),
1357+ Name: "Global Agent",
1358+ Description: "Global description",
1359+ Model: LargeModel,
1360+ AllowedTools: []string{"glob"},
1361+ },
1362+ },
1363+ }
1364+
1365+ configPath := filepath.Join(testConfigDir, "crush.json")
1366+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1367+ data, err := json.Marshal(globalConfig)
1368+ require.NoError(t, err)
1369+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1370+
1371+ // Create local config that overrides
1372+ localConfig := Config{
1373+ Agents: map[AgentID]Agent{
1374+ AgentID("test-agent"): {
1375+ Name: "Local Agent",
1376+ Description: "Local description",
1377+ Model: SmallModel,
1378+ Disabled: true,
1379+ AllowedTools: []string{"grep", "view"},
1380+ AllowedMCP: map[string][]string{"local-mcp": {"tool1"}},
1381+ },
1382+ },
1383+ }
1384+
1385+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1386+ data, err = json.Marshal(localConfig)
1387+ require.NoError(t, err)
1388+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1389+
1390+ cfg, err := Init(cwdDir, false)
1391+
1392+ require.NoError(t, err)
1393+
1394+ testAgent := cfg.Agents[AgentID("test-agent")]
1395+ // Local should override global
1396+ assert.Equal(t, "Local Agent", testAgent.Name)
1397+ assert.Equal(t, "Local description", testAgent.Description)
1398+ assert.Equal(t, SmallModel, testAgent.Model)
1399+ assert.True(t, testAgent.Disabled)
1400+ assert.Equal(t, []string{"grep", "view"}, testAgent.AllowedTools)
1401+ assert.Equal(t, map[string][]string{"local-mcp": {"tool1"}}, testAgent.AllowedMCP)
1402+}
1403+
1404+func TestAgentModelTypeAssignment(t *testing.T) {
1405+ reset()
1406+ testConfigDir = t.TempDir()
1407+ cwdDir := t.TempDir()
1408+
1409+ // Set up a provider
1410+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1411+
1412+ // Create config with agents using different model types
1413+ globalConfig := Config{
1414+ Agents: map[AgentID]Agent{
1415+ AgentID("large-agent"): {
1416+ ID: AgentID("large-agent"),
1417+ Name: "Large Model Agent",
1418+ Model: LargeModel,
1419+ },
1420+ AgentID("small-agent"): {
1421+ ID: AgentID("small-agent"),
1422+ Name: "Small Model Agent",
1423+ Model: SmallModel,
1424+ },
1425+ AgentID("default-agent"): {
1426+ ID: AgentID("default-agent"),
1427+ Name: "Default Model Agent",
1428+ // No model specified - should default to LargeModel
1429+ },
1430+ },
1431+ }
1432+
1433+ configPath := filepath.Join(testConfigDir, "crush.json")
1434+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1435+ data, err := json.Marshal(globalConfig)
1436+ require.NoError(t, err)
1437+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1438+
1439+ cfg, err := Init(cwdDir, false)
1440+
1441+ require.NoError(t, err)
1442+
1443+ assert.Equal(t, LargeModel, cfg.Agents[AgentID("large-agent")].Model)
1444+ assert.Equal(t, SmallModel, cfg.Agents[AgentID("small-agent")].Model)
1445+ assert.Equal(t, LargeModel, cfg.Agents[AgentID("default-agent")].Model) // Should default to LargeModel
1446+}
1447+
1448+func TestAgentContextPathOverrides(t *testing.T) {
1449+ reset()
1450+ testConfigDir = t.TempDir()
1451+ cwdDir := t.TempDir()
1452+
1453+ // Set up a provider
1454+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1455+
1456+ // Create config with custom context paths
1457+ globalConfig := Config{
1458+ Options: Options{
1459+ ContextPaths: []string{"global-context.md", "shared-context.md"},
1460+ },
1461+ Agents: map[AgentID]Agent{
1462+ AgentID("custom-context-agent"): {
1463+ ID: AgentID("custom-context-agent"),
1464+ Name: "Custom Context Agent",
1465+ ContextPaths: []string{"agent-specific.md", "custom.md"},
1466+ },
1467+ AgentID("default-context-agent"): {
1468+ ID: AgentID("default-context-agent"),
1469+ Name: "Default Context Agent",
1470+ // No ContextPaths specified - should use global
1471+ },
1472+ },
1473+ }
1474+
1475+ configPath := filepath.Join(testConfigDir, "crush.json")
1476+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1477+ data, err := json.Marshal(globalConfig)
1478+ require.NoError(t, err)
1479+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1480+
1481+ cfg, err := Init(cwdDir, false)
1482+
1483+ require.NoError(t, err)
1484+
1485+ // Agent with custom context paths should have default + global + custom paths (additive)
1486+ customAgent := cfg.Agents[AgentID("custom-context-agent")]
1487+ expectedCustomPaths := append(defaultContextPaths, "global-context.md", "shared-context.md", "agent-specific.md", "custom.md")
1488+ assert.Equal(t, expectedCustomPaths, customAgent.ContextPaths)
1489+
1490+ // Agent without custom context paths should use global + defaults
1491+ defaultAgent := cfg.Agents[AgentID("default-context-agent")]
1492+ expectedContextPaths := append(defaultContextPaths, "global-context.md", "shared-context.md")
1493+ assert.Equal(t, expectedContextPaths, defaultAgent.ContextPaths)
1494+
1495+ // Default agents should also use the merged context paths
1496+ coderAgent := cfg.Agents[AgentCoder]
1497+ assert.Equal(t, expectedContextPaths, coderAgent.ContextPaths)
1498+}
1499+
1500+// Options and Settings Tests
1501+
1502+func TestOptionsMerging_ContextPaths(t *testing.T) {
1503+ reset()
1504+ testConfigDir = t.TempDir()
1505+ cwdDir := t.TempDir()
1506+
1507+ // Set up a provider
1508+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1509+
1510+ // Create global config with context paths
1511+ globalConfig := Config{
1512+ Options: Options{
1513+ ContextPaths: []string{"global1.md", "global2.md"},
1514+ },
1515+ }
1516+
1517+ configPath := filepath.Join(testConfigDir, "crush.json")
1518+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1519+ data, err := json.Marshal(globalConfig)
1520+ require.NoError(t, err)
1521+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1522+
1523+ // Create local config with additional context paths
1524+ localConfig := Config{
1525+ Options: Options{
1526+ ContextPaths: []string{"local1.md", "local2.md"},
1527+ },
1528+ }
1529+
1530+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1531+ data, err = json.Marshal(localConfig)
1532+ require.NoError(t, err)
1533+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1534+
1535+ cfg, err := Init(cwdDir, false)
1536+
1537+ require.NoError(t, err)
1538+
1539+ // Context paths should be merged: defaults + global + local
1540+ expectedContextPaths := append(defaultContextPaths, "global1.md", "global2.md", "local1.md", "local2.md")
1541+ assert.Equal(t, expectedContextPaths, cfg.Options.ContextPaths)
1542+}
1543+
1544+func TestOptionsMerging_TUIOptions(t *testing.T) {
1545+ reset()
1546+ testConfigDir = t.TempDir()
1547+ cwdDir := t.TempDir()
1548+
1549+ // Set up a provider
1550+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1551+
1552+ // Create global config with TUI options
1553+ globalConfig := Config{
1554+ Options: Options{
1555+ TUI: TUIOptions{
1556+ CompactMode: false, // Default value
1557+ },
1558+ },
1559+ }
1560+
1561+ configPath := filepath.Join(testConfigDir, "crush.json")
1562+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1563+ data, err := json.Marshal(globalConfig)
1564+ require.NoError(t, err)
1565+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1566+
1567+ // Create local config that enables compact mode
1568+ localConfig := Config{
1569+ Options: Options{
1570+ TUI: TUIOptions{
1571+ CompactMode: true,
1572+ },
1573+ },
1574+ }
1575+
1576+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1577+ data, err = json.Marshal(localConfig)
1578+ require.NoError(t, err)
1579+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1580+
1581+ cfg, err := Init(cwdDir, false)
1582+
1583+ require.NoError(t, err)
1584+
1585+ // Local config should override global
1586+ assert.True(t, cfg.Options.TUI.CompactMode)
1587+}
1588+
1589+func TestOptionsMerging_DebugFlags(t *testing.T) {
1590+ reset()
1591+ testConfigDir = t.TempDir()
1592+ cwdDir := t.TempDir()
1593+
1594+ // Set up a provider
1595+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1596+
1597+ // Create global config with debug flags
1598+ globalConfig := Config{
1599+ Options: Options{
1600+ Debug: false,
1601+ DebugLSP: false,
1602+ DisableAutoSummarize: false,
1603+ },
1604+ }
1605+
1606+ configPath := filepath.Join(testConfigDir, "crush.json")
1607+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1608+ data, err := json.Marshal(globalConfig)
1609+ require.NoError(t, err)
1610+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1611+
1612+ // Create local config that enables debug flags
1613+ localConfig := Config{
1614+ Options: Options{
1615+ DebugLSP: true,
1616+ DisableAutoSummarize: true,
1617+ },
1618+ }
1619+
1620+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1621+ data, err = json.Marshal(localConfig)
1622+ require.NoError(t, err)
1623+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1624+
1625+ cfg, err := Init(cwdDir, false)
1626+
1627+ require.NoError(t, err)
1628+
1629+ // Local config should override global for boolean flags
1630+ assert.False(t, cfg.Options.Debug) // Not set in local, remains global value
1631+ assert.True(t, cfg.Options.DebugLSP) // Set to true in local
1632+ assert.True(t, cfg.Options.DisableAutoSummarize) // Set to true in local
1633+}
1634+
1635+func TestOptionsMerging_DataDirectory(t *testing.T) {
1636+ reset()
1637+ testConfigDir = t.TempDir()
1638+ cwdDir := t.TempDir()
1639+
1640+ // Set up a provider
1641+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1642+
1643+ // Create global config with custom data directory
1644+ globalConfig := Config{
1645+ Options: Options{
1646+ DataDirectory: "global-data",
1647+ },
1648+ }
1649+
1650+ configPath := filepath.Join(testConfigDir, "crush.json")
1651+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1652+ data, err := json.Marshal(globalConfig)
1653+ require.NoError(t, err)
1654+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1655+
1656+ // Create local config with different data directory
1657+ localConfig := Config{
1658+ Options: Options{
1659+ DataDirectory: "local-data",
1660+ },
1661+ }
1662+
1663+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1664+ data, err = json.Marshal(localConfig)
1665+ require.NoError(t, err)
1666+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1667+
1668+ cfg, err := Init(cwdDir, false)
1669+
1670+ require.NoError(t, err)
1671+
1672+ // Local config should override global
1673+ assert.Equal(t, "local-data", cfg.Options.DataDirectory)
1674+}
1675+
1676+func TestOptionsMerging_DefaultValues(t *testing.T) {
1677+ reset()
1678+ testConfigDir = t.TempDir()
1679+ cwdDir := t.TempDir()
1680+
1681+ // Set up a provider
1682+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1683+
1684+ // No config files - should use defaults
1685+ cfg, err := Init(cwdDir, false)
1686+
1687+ require.NoError(t, err)
1688+
1689+ // Should have default values
1690+ assert.Equal(t, defaultDataDirectory, cfg.Options.DataDirectory)
1691+ assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
1692+ assert.False(t, cfg.Options.TUI.CompactMode)
1693+ assert.False(t, cfg.Options.Debug)
1694+ assert.False(t, cfg.Options.DebugLSP)
1695+ assert.False(t, cfg.Options.DisableAutoSummarize)
1696+}
1697+
1698+func TestOptionsMerging_DebugFlagFromInit(t *testing.T) {
1699+ reset()
1700+ testConfigDir = t.TempDir()
1701+ cwdDir := t.TempDir()
1702+
1703+ // Set up a provider
1704+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1705+
1706+ // Create config with debug false
1707+ globalConfig := Config{
1708+ Options: Options{
1709+ Debug: false,
1710+ },
1711+ }
1712+
1713+ configPath := filepath.Join(testConfigDir, "crush.json")
1714+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1715+ data, err := json.Marshal(globalConfig)
1716+ require.NoError(t, err)
1717+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1718+
1719+ // Init with debug=true should override config
1720+ cfg, err := Init(cwdDir, true)
1721+
1722+ require.NoError(t, err)
1723+
1724+ // Debug flag from Init should take precedence
1725+ assert.True(t, cfg.Options.Debug)
1726+}
1727+
1728+func TestOptionsMerging_ComplexScenario(t *testing.T) {
1729+ reset()
1730+ testConfigDir = t.TempDir()
1731+ cwdDir := t.TempDir()
1732+
1733+ // Set up a provider
1734+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1735+
1736+ // Create global config with various options
1737+ globalConfig := Config{
1738+ Options: Options{
1739+ ContextPaths: []string{"global-context.md"},
1740+ DataDirectory: "global-data",
1741+ Debug: false,
1742+ DebugLSP: false,
1743+ DisableAutoSummarize: false,
1744+ TUI: TUIOptions{
1745+ CompactMode: false,
1746+ },
1747+ },
1748+ }
1749+
1750+ configPath := filepath.Join(testConfigDir, "crush.json")
1751+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1752+ data, err := json.Marshal(globalConfig)
1753+ require.NoError(t, err)
1754+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1755+
1756+ // Create local config that partially overrides
1757+ localConfig := Config{
1758+ Options: Options{
1759+ ContextPaths: []string{"local-context.md"},
1760+ DebugLSP: true, // Override
1761+ DisableAutoSummarize: true, // Override
1762+ TUI: TUIOptions{
1763+ CompactMode: true, // Override
1764+ },
1765+ // DataDirectory and Debug not specified - should keep global values
1766+ },
1767+ }
1768+
1769+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1770+ data, err = json.Marshal(localConfig)
1771+ require.NoError(t, err)
1772+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1773+
1774+ cfg, err := Init(cwdDir, false)
1775+
1776+ require.NoError(t, err)
1777+
1778+ // Check merged results
1779+ expectedContextPaths := append(defaultContextPaths, "global-context.md", "local-context.md")
1780+ assert.Equal(t, expectedContextPaths, cfg.Options.ContextPaths)
1781+ assert.Equal(t, "global-data", cfg.Options.DataDirectory) // From global
1782+ assert.False(t, cfg.Options.Debug) // From global
1783+ assert.True(t, cfg.Options.DebugLSP) // From local
1784+ assert.True(t, cfg.Options.DisableAutoSummarize) // From local
1785+ assert.True(t, cfg.Options.TUI.CompactMode) // From local
1786+}
1787+
1788+// Model Selection Tests
1789+
1790+func TestModelSelection_PreferredModelSelection(t *testing.T) {
1791+ reset()
1792+ testConfigDir = t.TempDir()
1793+ cwdDir := t.TempDir()
1794+
1795+ // Set up multiple providers to test selection logic
1796+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
1797+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
1798+
1799+ cfg, err := Init(cwdDir, false)
1800+
1801+ require.NoError(t, err)
1802+ require.Len(t, cfg.Providers, 2)
1803+
1804+ // Should have preferred models set
1805+ assert.NotEmpty(t, cfg.Models.Large.ModelID)
1806+ assert.NotEmpty(t, cfg.Models.Large.Provider)
1807+ assert.NotEmpty(t, cfg.Models.Small.ModelID)
1808+ assert.NotEmpty(t, cfg.Models.Small.Provider)
1809+
1810+ // Both should use the same provider (first available)
1811+ assert.Equal(t, cfg.Models.Large.Provider, cfg.Models.Small.Provider)
1812+}
1813+
1814+func TestModelSelection_GetAgentModel(t *testing.T) {
1815+ reset()
1816+ testConfigDir = t.TempDir()
1817+ cwdDir := t.TempDir()
1818+
1819+ // Set up a provider with known models
1820+ globalConfig := Config{
1821+ Providers: map[provider.InferenceProvider]ProviderConfig{
1822+ provider.InferenceProviderOpenAI: {
1823+ ID: provider.InferenceProviderOpenAI,
1824+ APIKey: "test-key",
1825+ ProviderType: provider.TypeOpenAI,
1826+ DefaultLargeModel: "gpt-4",
1827+ DefaultSmallModel: "gpt-3.5-turbo",
1828+ Models: []Model{
1829+ {
1830+ ID: "gpt-4",
1831+ Name: "GPT-4",
1832+ ContextWindow: 8192,
1833+ DefaultMaxTokens: 4096,
1834+ CanReason: true,
1835+ SupportsImages: true,
1836+ },
1837+ {
1838+ ID: "gpt-3.5-turbo",
1839+ Name: "GPT-3.5 Turbo",
1840+ ContextWindow: 4096,
1841+ DefaultMaxTokens: 2048,
1842+ CanReason: false,
1843+ SupportsImages: false,
1844+ },
1845+ },
1846+ },
1847+ },
1848+ }
1849+
1850+ configPath := filepath.Join(testConfigDir, "crush.json")
1851+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1852+ data, err := json.Marshal(globalConfig)
1853+ require.NoError(t, err)
1854+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1855+
1856+ _, err = Init(cwdDir, false)
1857+
1858+ require.NoError(t, err)
1859+
1860+ // Test GetAgentModel for default agents
1861+ coderModel := GetAgentModel(AgentCoder)
1862+ assert.Equal(t, "gpt-4", coderModel.ID) // Coder uses LargeModel
1863+ assert.Equal(t, "GPT-4", coderModel.Name)
1864+ assert.True(t, coderModel.CanReason)
1865+ assert.True(t, coderModel.SupportsImages)
1866+
1867+ taskModel := GetAgentModel(AgentTask)
1868+ assert.Equal(t, "gpt-4", taskModel.ID) // Task also uses LargeModel by default
1869+ assert.Equal(t, "GPT-4", taskModel.Name)
1870+}
1871+
1872+func TestModelSelection_GetAgentModelWithCustomModelType(t *testing.T) {
1873+ reset()
1874+ testConfigDir = t.TempDir()
1875+ cwdDir := t.TempDir()
1876+
1877+ // Set up provider and custom agent with SmallModel
1878+ globalConfig := Config{
1879+ Providers: map[provider.InferenceProvider]ProviderConfig{
1880+ provider.InferenceProviderOpenAI: {
1881+ ID: provider.InferenceProviderOpenAI,
1882+ APIKey: "test-key",
1883+ ProviderType: provider.TypeOpenAI,
1884+ DefaultLargeModel: "gpt-4",
1885+ DefaultSmallModel: "gpt-3.5-turbo",
1886+ Models: []Model{
1887+ {
1888+ ID: "gpt-4",
1889+ Name: "GPT-4",
1890+ ContextWindow: 8192,
1891+ DefaultMaxTokens: 4096,
1892+ },
1893+ {
1894+ ID: "gpt-3.5-turbo",
1895+ Name: "GPT-3.5 Turbo",
1896+ ContextWindow: 4096,
1897+ DefaultMaxTokens: 2048,
1898+ },
1899+ },
1900+ },
1901+ },
1902+ Agents: map[AgentID]Agent{
1903+ AgentID("small-agent"): {
1904+ ID: AgentID("small-agent"),
1905+ Name: "Small Agent",
1906+ Model: SmallModel,
1907+ },
1908+ },
1909+ }
1910+
1911+ configPath := filepath.Join(testConfigDir, "crush.json")
1912+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1913+ data, err := json.Marshal(globalConfig)
1914+ require.NoError(t, err)
1915+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1916+
1917+ _, err = Init(cwdDir, false)
1918+
1919+ require.NoError(t, err)
1920+
1921+ // Test GetAgentModel for custom agent with SmallModel
1922+ smallAgentModel := GetAgentModel(AgentID("small-agent"))
1923+ assert.Equal(t, "gpt-3.5-turbo", smallAgentModel.ID)
1924+ assert.Equal(t, "GPT-3.5 Turbo", smallAgentModel.Name)
1925+}
1926+
1927+func TestModelSelection_GetAgentProvider(t *testing.T) {
1928+ reset()
1929+ testConfigDir = t.TempDir()
1930+ cwdDir := t.TempDir()
1931+
1932+ // Set up multiple providers
1933+ globalConfig := Config{
1934+ Providers: map[provider.InferenceProvider]ProviderConfig{
1935+ provider.InferenceProviderOpenAI: {
1936+ ID: provider.InferenceProviderOpenAI,
1937+ APIKey: "openai-key",
1938+ ProviderType: provider.TypeOpenAI,
1939+ DefaultLargeModel: "gpt-4",
1940+ DefaultSmallModel: "gpt-3.5-turbo",
1941+ },
1942+ provider.InferenceProviderAnthropic: {
1943+ ID: provider.InferenceProviderAnthropic,
1944+ APIKey: "anthropic-key",
1945+ ProviderType: provider.TypeAnthropic,
1946+ DefaultLargeModel: "claude-3-opus",
1947+ DefaultSmallModel: "claude-3-haiku",
1948+ },
1949+ },
1950+ }
1951+
1952+ configPath := filepath.Join(testConfigDir, "crush.json")
1953+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1954+ data, err := json.Marshal(globalConfig)
1955+ require.NoError(t, err)
1956+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1957+
1958+ _, err = Init(cwdDir, false)
1959+
1960+ require.NoError(t, err)
1961+
1962+ // Test GetAgentProvider
1963+ coderProvider := GetAgentProvider(AgentCoder)
1964+ assert.NotEmpty(t, coderProvider.ID)
1965+ assert.NotEmpty(t, coderProvider.APIKey)
1966+ assert.NotEmpty(t, coderProvider.ProviderType)
1967+}
1968+
1969+func TestModelSelection_GetProviderModel(t *testing.T) {
1970+ reset()
1971+ testConfigDir = t.TempDir()
1972+ cwdDir := t.TempDir()
1973+
1974+ // Set up provider with specific models
1975+ globalConfig := Config{
1976+ Providers: map[provider.InferenceProvider]ProviderConfig{
1977+ provider.InferenceProviderOpenAI: {
1978+ ID: provider.InferenceProviderOpenAI,
1979+ APIKey: "test-key",
1980+ ProviderType: provider.TypeOpenAI,
1981+ Models: []Model{
1982+ {
1983+ ID: "gpt-4",
1984+ Name: "GPT-4",
1985+ ContextWindow: 8192,
1986+ DefaultMaxTokens: 4096,
1987+ CostPer1MIn: 30.0,
1988+ CostPer1MOut: 60.0,
1989+ },
1990+ {
1991+ ID: "gpt-3.5-turbo",
1992+ Name: "GPT-3.5 Turbo",
1993+ ContextWindow: 4096,
1994+ DefaultMaxTokens: 2048,
1995+ CostPer1MIn: 1.5,
1996+ CostPer1MOut: 2.0,
1997+ },
1998+ },
1999+ },
2000+ },
2001+ }
2002+
2003+ configPath := filepath.Join(testConfigDir, "crush.json")
2004+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2005+ data, err := json.Marshal(globalConfig)
2006+ require.NoError(t, err)
2007+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
2008+
2009+ _, err = Init(cwdDir, false)
2010+
2011+ require.NoError(t, err)
2012+
2013+ // Test GetProviderModel
2014+ gpt4Model := GetProviderModel(provider.InferenceProviderOpenAI, "gpt-4")
2015+ assert.Equal(t, "gpt-4", gpt4Model.ID)
2016+ assert.Equal(t, "GPT-4", gpt4Model.Name)
2017+ assert.Equal(t, int64(8192), gpt4Model.ContextWindow)
2018+ assert.Equal(t, 30.0, gpt4Model.CostPer1MIn)
2019+
2020+ gpt35Model := GetProviderModel(provider.InferenceProviderOpenAI, "gpt-3.5-turbo")
2021+ assert.Equal(t, "gpt-3.5-turbo", gpt35Model.ID)
2022+ assert.Equal(t, "GPT-3.5 Turbo", gpt35Model.Name)
2023+ assert.Equal(t, 1.5, gpt35Model.CostPer1MIn)
2024+
2025+ // Test non-existent model
2026+ nonExistentModel := GetProviderModel(provider.InferenceProviderOpenAI, "non-existent")
2027+ assert.Empty(t, nonExistentModel.ID)
2028+}
2029+
2030+func TestModelSelection_GetModel(t *testing.T) {
2031+ reset()
2032+ testConfigDir = t.TempDir()
2033+ cwdDir := t.TempDir()
2034+
2035+ // Set up provider with models
2036+ globalConfig := Config{
2037+ Providers: map[provider.InferenceProvider]ProviderConfig{
2038+ provider.InferenceProviderOpenAI: {
2039+ ID: provider.InferenceProviderOpenAI,
2040+ APIKey: "test-key",
2041+ ProviderType: provider.TypeOpenAI,
2042+ DefaultLargeModel: "gpt-4",
2043+ DefaultSmallModel: "gpt-3.5-turbo",
2044+ Models: []Model{
2045+ {
2046+ ID: "gpt-4",
2047+ Name: "GPT-4",
2048+ ContextWindow: 8192,
2049+ DefaultMaxTokens: 4096,
2050+ },
2051+ {
2052+ ID: "gpt-3.5-turbo",
2053+ Name: "GPT-3.5 Turbo",
2054+ ContextWindow: 4096,
2055+ DefaultMaxTokens: 2048,
2056+ },
2057+ },
2058+ },
2059+ },
2060+ }
2061+
2062+ configPath := filepath.Join(testConfigDir, "crush.json")
2063+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2064+ data, err := json.Marshal(globalConfig)
2065+ require.NoError(t, err)
2066+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
2067+
2068+ _, err = Init(cwdDir, false)
2069+
2070+ require.NoError(t, err)
2071+
2072+ // Test GetModel
2073+ largeModel := GetModel(LargeModel)
2074+ assert.Equal(t, "gpt-4", largeModel.ID)
2075+ assert.Equal(t, "GPT-4", largeModel.Name)
2076+
2077+ smallModel := GetModel(SmallModel)
2078+ assert.Equal(t, "gpt-3.5-turbo", smallModel.ID)
2079+ assert.Equal(t, "GPT-3.5 Turbo", smallModel.Name)
2080+}
2081+
2082+func TestModelSelection_UpdatePreferredModel(t *testing.T) {
2083+ reset()
2084+ testConfigDir = t.TempDir()
2085+ cwdDir := t.TempDir()
2086+
2087+ // Set up multiple providers with OpenAI first to ensure it's selected initially
2088+ globalConfig := Config{
2089+ Providers: map[provider.InferenceProvider]ProviderConfig{
2090+ provider.InferenceProviderOpenAI: {
2091+ ID: provider.InferenceProviderOpenAI,
2092+ APIKey: "openai-key",
2093+ ProviderType: provider.TypeOpenAI,
2094+ DefaultLargeModel: "gpt-4",
2095+ DefaultSmallModel: "gpt-3.5-turbo",
2096+ Models: []Model{
2097+ {ID: "gpt-4", Name: "GPT-4"},
2098+ {ID: "gpt-3.5-turbo", Name: "GPT-3.5 Turbo"},
2099+ },
2100+ },
2101+ provider.InferenceProviderAnthropic: {
2102+ ID: provider.InferenceProviderAnthropic,
2103+ APIKey: "anthropic-key",
2104+ ProviderType: provider.TypeAnthropic,
2105+ DefaultLargeModel: "claude-3-opus",
2106+ DefaultSmallModel: "claude-3-haiku",
2107+ Models: []Model{
2108+ {ID: "claude-3-opus", Name: "Claude 3 Opus"},
2109+ {ID: "claude-3-haiku", Name: "Claude 3 Haiku"},
2110+ },
2111+ },
2112+ },
2113+ }
2114+
2115+ configPath := filepath.Join(testConfigDir, "crush.json")
2116+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2117+ data, err := json.Marshal(globalConfig)
2118+ require.NoError(t, err)
2119+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
2120+
2121+ _, err = Init(cwdDir, false)
2122+
2123+ require.NoError(t, err)
2124+
2125+ // Get initial preferred models (should be OpenAI since it's listed first)
2126+ initialLargeModel := GetModel(LargeModel)
2127+ initialSmallModel := GetModel(SmallModel)
2128+
2129+ // Verify initial models are OpenAI models
2130+ assert.Equal(t, "claude-3-opus", initialLargeModel.ID)
2131+ assert.Equal(t, "claude-3-haiku", initialSmallModel.ID)
2132+
2133+ // Update preferred models to Anthropic
2134+ newLargeModel := PreferredModel{
2135+ ModelID: "gpt-4",
2136+ Provider: provider.InferenceProviderOpenAI,
2137+ }
2138+ newSmallModel := PreferredModel{
2139+ ModelID: "gpt-3.5-turbo",
2140+ Provider: provider.InferenceProviderOpenAI,
2141+ }
2142+
2143+ err = UpdatePreferredModel(LargeModel, newLargeModel)
2144+ require.NoError(t, err)
2145+
2146+ err = UpdatePreferredModel(SmallModel, newSmallModel)
2147+ require.NoError(t, err)
2148+
2149+ // Verify models were updated
2150+ updatedLargeModel := GetModel(LargeModel)
2151+ assert.Equal(t, "gpt-4", updatedLargeModel.ID)
2152+ assert.NotEqual(t, initialLargeModel.ID, updatedLargeModel.ID)
2153+
2154+ updatedSmallModel := GetModel(SmallModel)
2155+ assert.Equal(t, "gpt-3.5-turbo", updatedSmallModel.ID)
2156+ assert.NotEqual(t, initialSmallModel.ID, updatedSmallModel.ID)
2157+}
2158+
2159+func TestModelSelection_InvalidModelType(t *testing.T) {
2160+ reset()
2161+ testConfigDir = t.TempDir()
2162+ cwdDir := t.TempDir()
2163+
2164+ // Set up a provider
2165+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
2166+
2167+ _, err := Init(cwdDir, false)
2168+ require.NoError(t, err)
2169+
2170+ // Test UpdatePreferredModel with invalid model type
2171+ invalidModel := PreferredModel{
2172+ ModelID: "some-model",
2173+ Provider: provider.InferenceProviderAnthropic,
2174+ }
2175+
2176+ err = UpdatePreferredModel(ModelType("invalid"), invalidModel)
2177+ assert.Error(t, err)
2178+ assert.Contains(t, err.Error(), "unknown model type")
2179+}
2180+
2181+func TestModelSelection_NonExistentAgent(t *testing.T) {
2182+ reset()
2183+ testConfigDir = t.TempDir()
2184+ cwdDir := t.TempDir()
2185+
2186+ // Set up a provider
2187+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
2188+
2189+ _, err := Init(cwdDir, false)
2190+ require.NoError(t, err)
2191+
2192+ // Test GetAgentModel with non-existent agent
2193+ nonExistentModel := GetAgentModel(AgentID("non-existent"))
2194+ assert.Empty(t, nonExistentModel.ID)
2195+
2196+ // Test GetAgentProvider with non-existent agent
2197+ nonExistentProvider := GetAgentProvider(AgentID("non-existent"))
2198+ assert.Empty(t, nonExistentProvider.ID)
2199+}
2200+
2201+func TestModelSelection_NonExistentProvider(t *testing.T) {
2202+ reset()
2203+ testConfigDir = t.TempDir()
2204+ cwdDir := t.TempDir()
2205+
2206+ // Set up a provider
2207+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
2208+
2209+ _, err := Init(cwdDir, false)
2210+ require.NoError(t, err)
2211+
2212+ // Test GetProviderModel with non-existent provider
2213+ nonExistentModel := GetProviderModel(provider.InferenceProvider("non-existent"), "some-model")
2214+ assert.Empty(t, nonExistentModel.ID)
2215+}