package config import ( "os" "path/filepath" "reflect" "runtime" "slices" "strings" "testing" "github.com/BurntSushi/toml" ) func TestSetDefaultModel(t *testing.T) { c := Default() if err := c.SetDefaultModel("deepseek-pro"); err != nil { t.Fatalf("set valid default: %v", err) } if c.DefaultModel != "deepseek-pro" { t.Errorf("default = %q, want deepseek-pro", c.DefaultModel) } if err := c.SetDefaultModel("nope"); err == nil { t.Error("expected error for unknown provider") } // "provider/model" form is also accepted: the /model picker stores the // full ref so a user can land on a non-default model under the same // provider across restarts. if err := c.SetDefaultModel("deepseek-pro/deepseek-v4-pro"); err != nil { t.Fatalf("set provider/model default: %v", err) } if c.DefaultModel != "deepseek-pro/deepseek-v4-pro" { t.Errorf("default = %q, want deepseek-pro/deepseek-v4-pro", c.DefaultModel) } if err := c.SetDefaultModel("deepseek-pro/missing"); err == nil { t.Error("expected error for unknown model under known provider") } if err := c.SetDefaultModel(""); err == nil { t.Error("expected error for empty name") } } func TestUIThemeNormalizes(t *testing.T) { c := Default() for _, tt := range []struct { in string want string }{ {"", "auto"}, {"AUTO", "auto"}, {"dark", "dark"}, {" light ", "light"}, {"unknown", "auto"}, } { c.UI.Theme = tt.in if got := c.UITheme(); got != tt.want { t.Errorf("UITheme(%q) = %q, want %q", tt.in, got, tt.want) } } } func TestUIThemeStyleNormalizes(t *testing.T) { c := Default() for _, tt := range []struct { in string want string }{ {"", ""}, {"AURORA", "aurora"}, {" nocturne ", "nocturne"}, {" glacier ", "glacier"}, {"unknown", ""}, } { c.UI.ThemeStyle = tt.in if got := c.UIThemeStyle(); got != tt.want { t.Errorf("UIThemeStyle(%q) = %q, want %q", tt.in, got, tt.want) } } } func TestUICursorShapeNormalizes(t *testing.T) { c := Default() for _, tt := range []struct { in string want string }{ {"", "underline"}, {"UNDERLINE", "underline"}, {" block ", "block"}, {"bar", "bar"}, {"unknown", "underline"}, } { c.UI.CursorShape = tt.in if got := c.UICursorShape(); got != tt.want { t.Errorf("UICursorShape(%q) = %q, want %q", tt.in, got, tt.want) } } } func TestUICloseBehaviorNormalizes(t *testing.T) { c := Default() for _, tt := range []struct { in string want string }{ {"", "background"}, {"QUIT", "quit"}, {"exit", "quit"}, {" background ", "background"}, {"hide", "background"}, {"unknown", "background"}, } { c.UI.CloseBehavior = tt.in if got := c.UICloseBehavior(); got != tt.want { t.Errorf("UICloseBehavior(%q) = %q, want %q", tt.in, got, tt.want) } } } func TestDesktopPreferencesAreSeparateFromCLI(t *testing.T) { c := Default() c.Language = "zh" c.UI.Theme = "light" c.UI.ThemeStyle = "glacier" if err := c.SetDesktopLanguage("en"); err != nil { t.Fatalf("SetDesktopLanguage: %v", err) } if err := c.SetDesktopAppearance("dark", "graphite"); err != nil { t.Fatalf("SetDesktopAppearance: %v", err) } if err := c.SetDesktopLayoutStyle("workbench"); err != nil { t.Fatalf("SetDesktopLayoutStyle: %v", err) } if err := c.SetDesktopStatusBarStyle("text"); err != nil { t.Fatalf("SetDesktopStatusBarStyle: %v", err) } if err := c.SetDesktopStatusBarItems([]string{"model", "balance", "cache"}); err != nil { t.Fatalf("SetDesktopStatusBarItems: %v", err) } if c.Language != "zh" { t.Fatalf("CLI language changed to %q", c.Language) } if got := c.UITheme(); got != "light" { t.Fatalf("CLI theme = %q, want light", got) } if got := c.UIThemeStyle(); got != "glacier" { t.Fatalf("CLI theme style = %q, want glacier", got) } if got := c.DesktopLanguage(); got != "en" { t.Fatalf("desktop language = %q, want en", got) } if got := c.DesktopTheme(); got != "dark" { t.Fatalf("desktop theme = %q, want dark", got) } if got := c.DesktopThemeStyle(); got != "graphite" { t.Fatalf("desktop theme style = %q, want graphite", got) } if got := c.DesktopLayoutStyle(); got != "workbench" { t.Fatalf("desktop layout style = %q, want workbench", got) } if got := c.DesktopStatusBarStyle(); got != "text" { t.Fatalf("desktop status bar style = %q, want text", got) } if got, want := c.DesktopStatusBarItems(), []string{"model", "balance", "cache"}; !reflect.DeepEqual(got, want) { t.Fatalf("desktop status bar items = %v, want %v", got, want) } } func TestDesktopLayoutStyleNormalizes(t *testing.T) { if got := Default().DesktopLayoutStyle(); got != "workbench" { t.Fatalf("default desktop layout style = %q, want workbench", got) } for _, tt := range []struct { in string want string wantErr bool }{ {"", "classic", false}, {"classic", "classic", false}, {" workbench ", "workbench", false}, {"workspace", "workbench", false}, {"creation", "creation", false}, {" Creation ", "creation", false}, {"later", "workbench", true}, } { c := Default() if err := c.SetDesktopLayoutStyle(tt.in); (err != nil) != tt.wantErr { t.Fatalf("SetDesktopLayoutStyle(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) } if got := c.DesktopLayoutStyle(); got != tt.want { t.Fatalf("DesktopLayoutStyle(%q) = %q, want %q", tt.in, got, tt.want) } } c := Default() c.Desktop.ThemeStyle = "workbench" if got := c.DesktopLayoutStyle(); got != "workbench" { t.Fatalf("legacy desktop theme_style=workbench layout = %q, want workbench", got) } if got := c.DesktopThemeStyle(); got != "" { t.Fatalf("legacy desktop theme_style=workbench theme style = %q, want empty", got) } } func TestDesktopStatusBarStyleNormalizes(t *testing.T) { if got := Default().DesktopStatusBarStyle(); got != "text" { t.Fatalf("default desktop status bar style = %q, want text", got) } for _, tt := range []struct { in string want string wantErr bool }{ {"", "text", false}, {"icon", "icon", false}, {"icons", "icon", false}, {"text", "text", false}, {"labels", "text", false}, {"later", "text", true}, } { c := Default() if err := c.SetDesktopStatusBarStyle(tt.in); (err != nil) != tt.wantErr { t.Fatalf("SetDesktopStatusBarStyle(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) } if got := c.DesktopStatusBarStyle(); got != tt.want { t.Fatalf("DesktopStatusBarStyle(%q) = %q, want %q", tt.in, got, tt.want) } } } func TestDesktopStatusBarItemsNormalizeAndValidate(t *testing.T) { if got, want := Default().DesktopStatusBarItems(), DefaultDesktopStatusBarItems(); !reflect.DeepEqual(got, want) { t.Fatalf("default desktop status bar items = %v, want %v", got, want) } for _, id := range []string{"workspace", "git_branch"} { if !slices.Contains(DefaultDesktopStatusBarItems(), id) { t.Fatalf("default desktop status bar items must include configurable item %q", id) } } c := Default() c.Desktop.StatusBarItems = []string{" balance ", "cache", "cache", "unknown", "model"} if got, want := c.DesktopStatusBarItems(), []string{"balance", "cache", "model"}; !reflect.DeepEqual(got, want) { t.Fatalf("normalized desktop status bar items = %v, want %v", got, want) } c = Default() if err := c.SetDesktopStatusBarItems([]string{"balance", "cache", "balance", "model"}); err != nil { t.Fatalf("SetDesktopStatusBarItems subset: %v", err) } if got, want := c.DesktopStatusBarItems(), []string{"balance", "cache", "model"}; !reflect.DeepEqual(got, want) { t.Fatalf("saved desktop status bar items = %v, want %v", got, want) } c = Default() if err := c.SetDesktopStatusBarItems([]string{"workspace", "git_branch", "model"}); err != nil { t.Fatalf("SetDesktopStatusBarItems workspace metadata: %v", err) } if got, want := c.DesktopStatusBarItems(), []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got, want) { t.Fatalf("saved workspace metadata status bar items = %v, want %v", got, want) } if err := c.SetDesktopStatusBarItems(nil); err != nil { t.Fatalf("SetDesktopStatusBarItems nil: %v", err) } if got, want := c.DesktopStatusBarItems(), DefaultDesktopStatusBarItems(); !reflect.DeepEqual(got, want) { t.Fatalf("nil desktop status bar items = %v, want default %v", got, want) } if err := c.SetDesktopStatusBarItems([]string{"ghost"}); err == nil { t.Fatal("expected error for unknown status bar item") } } func TestDesktopCloseBehaviorFallsBackToLegacyUI(t *testing.T) { c := Default() c.UI.CloseBehavior = "quit" if got := c.DesktopCloseBehavior(); got != "quit" { t.Fatalf("legacy close behavior = %q, want quit", got) } c.Desktop.CloseBehavior = "background" if got := c.DesktopCloseBehavior(); got != "background" { t.Fatalf("desktop close behavior = %q, want background", got) } } func TestSetUICloseBehavior(t *testing.T) { c := Default() if err := c.SetUICloseBehavior("background"); err != nil { t.Fatalf("SetUICloseBehavior background: %v", err) } if got := c.UICloseBehavior(); got != "background" { t.Fatalf("close behavior = %q, want background", got) } if err := c.SetUICloseBehavior("quit"); err != nil { t.Fatalf("SetUICloseBehavior quit: %v", err) } if got := c.UICloseBehavior(); got != "quit" { t.Fatalf("close behavior = %q, want quit", got) } if err := c.SetUICloseBehavior("later"); err == nil { t.Fatal("expected error for invalid close behavior") } } func TestSetPlannerModel(t *testing.T) { c := Default() if err := c.SetPlannerModel("deepseek-pro"); err != nil { t.Fatalf("set planner: %v", err) } if c.Agent.PlannerModel != "deepseek-pro" { t.Errorf("planner = %q", c.Agent.PlannerModel) } if err := c.SetPlannerModel(""); err != nil || c.Agent.PlannerModel != "" { t.Errorf("clearing planner failed: err=%v planner=%q", err, c.Agent.PlannerModel) } if err := c.SetPlannerModel("ghost"); err == nil { t.Error("expected error for unknown planner") } } func TestSetAutoPlan(t *testing.T) { c := Default() for _, mode := range []string{"on", "off"} { if err := c.SetAutoPlan(mode); err != nil { t.Fatalf("SetAutoPlan(%q): %v", mode, err) } if c.Agent.AutoPlan != mode { t.Fatalf("auto_plan = %q, want %q", c.Agent.AutoPlan, mode) } } if err := c.SetAutoPlan("ask"); err != nil { t.Fatalf("legacy ask should be accepted: %v", err) } if c.Agent.AutoPlan != "on" { t.Fatalf("legacy ask should save as on, got %q", c.Agent.AutoPlan) } if err := c.SetAutoPlan("auto"); err == nil { t.Fatal("expected error for invalid auto_plan mode") } } func TestSetDesktopDefaultToolApprovalMode(t *testing.T) { c := Default() if got := c.DesktopDefaultToolApprovalMode(); got != "auto" { t.Fatalf("desktop default tool approval mode = %q, want built-in auto", got) } for _, mode := range []string{"ask", "auto", "yolo"} { if err := c.SetDesktopDefaultToolApprovalMode(mode); err != nil { t.Fatalf("SetDesktopDefaultToolApprovalMode(%q): %v", mode, err) } if c.DesktopDefaultToolApprovalMode() != mode { t.Fatalf("desktop default tool approval mode = %q, want %q", c.DesktopDefaultToolApprovalMode(), mode) } } if err := c.SetDesktopDefaultToolApprovalMode("full-access"); err != nil { t.Fatalf("legacy full-access should be accepted: %v", err) } if c.DesktopDefaultToolApprovalMode() != "yolo" { t.Fatalf("legacy full-access should save as yolo, got %q", c.DesktopDefaultToolApprovalMode()) } if err := c.SetDesktopDefaultToolApprovalMode("maybe"); err == nil { t.Fatal("expected error for invalid desktop default tool approval mode") } } func TestLoadForEditMissingDesktopApprovalDefaultsAuto(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte("config_version = 4\n"), 0o600); err != nil { t.Fatalf("write config: %v", err) } if got := LoadForEdit(path).DesktopDefaultToolApprovalMode(); got != "auto" { t.Fatalf("missing desktop default tool approval mode = %q, want auto", got) } } func TestSetMemoryCompilerEnabled(t *testing.T) { c := Default() if err := c.SetMemoryCompilerEnabled(false); err != nil { t.Fatalf("SetMemoryCompilerEnabled(false): %v", err) } if c.MemoryCompilerEnabled() { t.Fatal("memory compiler explicit false = true, want false") } if err := c.SetMemoryCompilerEnabled(true); err != nil { t.Fatalf("SetMemoryCompilerEnabled(true): %v", err) } if !c.MemoryCompilerEnabled() { t.Fatal("memory compiler explicit true = false, want true") } } func TestSetMemoryCompilerVerbosity(t *testing.T) { c := Default() if err := c.SetMemoryCompilerVerbosity("compact"); err != nil { t.Fatalf("SetMemoryCompilerVerbosity(compact): %v", err) } if got := c.MemoryCompilerVerbosity(); got != MemoryCompilerVerbosityCompact { t.Fatalf("memory compiler verbosity = %q, want compact", got) } if err := c.SetMemoryCompilerVerbosity("on"); err != nil { t.Fatalf("SetMemoryCompilerVerbosity(on): %v", err) } if got := c.MemoryCompilerVerbosity(); got != MemoryCompilerVerbosityCompact { t.Fatalf("memory compiler verbosity after on = %q, want compact", got) } if err := c.SetMemoryCompilerVerbosity("observe"); err != nil { t.Fatalf("SetMemoryCompilerVerbosity(observe): %v", err) } if got := c.MemoryCompilerVerbosity(); got != MemoryCompilerVerbosityObserve { t.Fatalf("memory compiler verbosity = %q, want observe", got) } if err := c.SetMemoryCompilerVerbosity("verbose"); err == nil { t.Fatal("expected error for invalid memory compiler verbosity") } } func TestSetUIShortcutLayout(t *testing.T) { c := Default() if got := c.UIShortcutLayout(); got != "classic" { t.Fatalf("default shortcut layout = %q, want classic", got) } if err := c.SetUIShortcutLayout("desktop"); err != nil { t.Fatalf("SetUIShortcutLayout desktop: %v", err) } if got := c.UIShortcutLayout(); got != "desktop" { t.Fatalf("shortcut layout = %q, want desktop", got) } if err := c.SetUIShortcutLayout("dual-axis"); err != nil { t.Fatalf("SetUIShortcutLayout alias: %v", err) } if got := c.UIShortcutLayout(); got != "desktop" { t.Fatalf("shortcut layout alias = %q, want desktop", got) } if err := c.SetUIShortcutLayout("classic"); err != nil { t.Fatalf("SetUIShortcutLayout classic: %v", err) } if got := c.UIShortcutLayout(); got != "classic" { t.Fatalf("shortcut layout = %q, want classic", got) } if err := c.SetUIShortcutLayout("surprise"); err == nil { t.Fatal("expected error for invalid shortcut layout") } } func TestUpsertProvider(t *testing.T) { c := Default() n := len(c.Providers) // Add a new one. if err := c.UpsertProvider(ProviderEntry{Name: "local", Kind: "openai", BaseURL: "http://localhost:1234/v1", Model: "x"}); err != nil { t.Fatalf("add: %v", err) } if len(c.Providers) != n+1 { t.Fatalf("provider count = %d, want %d", len(c.Providers), n+1) } // Replace it in place (no growth, position preserved). if err := c.UpsertProvider(ProviderEntry{Name: "local", Kind: "openai", BaseURL: "http://localhost:9999/v1", Model: "y"}); err != nil { t.Fatalf("replace: %v", err) } if len(c.Providers) != n+1 { t.Errorf("replace grew the list to %d", len(c.Providers)) } got, _ := c.Provider("local") if got.BaseURL != "http://localhost:9999/v1" || got.Model != "y" { t.Errorf("replace didn't apply: %+v", got) } // Multi-model providers may omit the back-compat single model field. if err := c.UpsertProvider(ProviderEntry{ Name: "multi", Kind: "openai", BaseURL: "http://localhost:8888/v1", Models: []string{"m1", "m2"}, Default: "m1", }); err != nil { t.Fatalf("multi-model add: %v", err) } // Missing required fields error. for _, bad := range []ProviderEntry{ {Kind: "openai", BaseURL: "u", Model: "m"}, // no name {Name: "a", BaseURL: "u", Model: "m"}, // no kind {Name: "a", Kind: "openai", Model: "m"}, // no base_url {Name: "a", Kind: "openai", BaseURL: "u"}, // no model {Name: "a", Kind: "openai", BaseURL: "u", Model: "m", APIKeyEnv: "grok-4.5"}, // invalid credential variable name } { if err := c.UpsertProvider(bad); err == nil { t.Errorf("expected validation error for %+v", bad) } } } func TestSetProviderEffort(t *testing.T) { c := Default() if err := c.SetProviderEffort("deepseek-flash", "MAX"); err != nil { t.Fatalf("SetProviderEffort: %v", err) } p, _ := c.Provider("deepseek-flash") if p.Effort != "max" { t.Fatalf("effort = %q, want max", p.Effort) } if err := c.SetProviderEffort("missing", "high"); err == nil { t.Fatal("SetProviderEffort should reject unknown provider") } } func TestSetLanguage(t *testing.T) { c := Default() if err := c.SetLanguage("zh"); err != nil { t.Fatalf("SetLanguage zh: %v", err) } if c.Language != "zh" { t.Fatalf("language = %q, want zh", c.Language) } if err := c.SetLanguage("auto"); err != nil { t.Fatalf("SetLanguage auto: %v", err) } if c.Language != "" { t.Fatalf("language = %q, want cleared", c.Language) } } func TestSetReasoningLanguage(t *testing.T) { c := Default() if err := c.SetReasoningLanguage("中文"); err != nil { t.Fatalf("SetReasoningLanguage zh: %v", err) } if c.Agent.ReasoningLanguage != "zh" || c.ReasoningLanguage() != "zh" { t.Fatalf("reasoning language = %q/%q, want zh", c.Agent.ReasoningLanguage, c.ReasoningLanguage()) } if err := c.SetReasoningLanguage("model-default"); err != nil { t.Fatalf("SetReasoningLanguage legacy default: %v", err) } if c.Agent.ReasoningLanguage != "" || c.ReasoningLanguage() != "auto" { t.Fatalf("legacy default should normalize to empty/auto, got %q/%q", c.Agent.ReasoningLanguage, c.ReasoningLanguage()) } if err := c.SetReasoningLanguage("auto"); err != nil { t.Fatalf("SetReasoningLanguage auto: %v", err) } if c.Agent.ReasoningLanguage != "" || c.ReasoningLanguage() != "auto" { t.Fatalf("reasoning language = %q/%q, want empty/auto", c.Agent.ReasoningLanguage, c.ReasoningLanguage()) } if err := c.SetReasoningLanguage("klingon"); err == nil { t.Fatal("SetReasoningLanguage should reject unknown values") } } func TestNormalizeEffortDeepSeek(t *testing.T) { e := &ProviderEntry{Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4"} cap := EffortCapabilityForEntry(e) if !cap.Supported || len(cap.Levels) != 4 || cap.Levels[0] != "auto" || cap.Levels[1] != "disabled" || cap.Levels[2] != "high" || cap.Levels[3] != "max" { t.Fatalf("DeepSeek levels = %+v, want auto/disabled/high/max", cap) } for in, want := range map[string]string{"auto": "", "disabled": "disabled", "high": "high", "max": "max", "low": "high", "medium": "high", "xhigh": "max"} { got, err := NormalizeEffort(e, in) if err != nil || got != want { t.Fatalf("NormalizeEffort(%q) = %q/%v, want %q/nil", in, got, err, want) } } // "off" is the retired DeepSeek "no thinking" spelling — now maps to disabled. if got, err := NormalizeEffort(e, "off"); err != nil || got != "disabled" { t.Fatalf("NormalizeEffort(\"off\") = %q/%v, want \"disabled\"/nil", got, err) } } func TestNormalizeLegacyEffortMigratesProviderDefaults(t *testing.T) { c := &Config{Providers: []ProviderEntry{ {Name: "deepseek", Effort: "off"}, {Name: "deepseek-upper", Effort: "OFF"}, {Name: "deepseek-auto", Effort: "auto"}, {Name: "deepseek-auto-upper", Effort: "AUTO"}, {Name: "keep", Effort: "high"}, }} normalizeLegacyEffort(c) normalizeEffortConfig(c) if c.Providers[0].Effort != "" || c.Providers[1].Effort != "" || c.Providers[2].Effort != "" || c.Providers[3].Effort != "" { t.Fatalf("provider default efforts should migrate to empty, got %q/%q/%q/%q", c.Providers[0].Effort, c.Providers[1].Effort, c.Providers[2].Effort, c.Providers[3].Effort) } if c.Providers[4].Effort != "high" { t.Fatalf("non-legacy effort changed: %q", c.Providers[4].Effort) } } func TestNormalizeEffortAnthropic(t *testing.T) { e := &ProviderEntry{Name: "claude", Kind: "anthropic", Model: "claude-opus-4-8"} cap := EffortCapabilityForEntry(e) if !cap.Supported || len(cap.Levels) != 6 { t.Fatalf("Anthropic levels = %+v, want auto plus five levels", cap) } for _, level := range []string{"low", "medium", "high", "xhigh", "max"} { got, err := NormalizeEffort(e, level) if err != nil || got != level { t.Fatalf("NormalizeEffort(%q) = %q/%v, want %q/nil", level, got, err, level) } } got, err := NormalizeEffort(e, "auto") if err != nil || got != "" { t.Fatalf("NormalizeEffort(auto) = %q/%v, want empty/nil", got, err) } } func TestResolveModelPreservesProviderEffort(t *testing.T) { c := Default() c.Providers = append(c.Providers, ProviderEntry{ Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, Default: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", Effort: "max", }) e, ok := c.ResolveModel("deepseek/deepseek-v4-pro") if !ok { t.Fatal("ResolveModel did not find deepseek/deepseek-v4-pro") } if e.Name != "deepseek" || e.Model != "deepseek-v4-pro" || e.Effort != "max" { t.Fatalf("resolved entry = %+v, want provider deepseek model deepseek-v4-pro effort max", e) } } func TestEffectiveVisionForMimoEndpointModels(t *testing.T) { c := Default() c.Providers = append(c.Providers, legacyMimoCustomProvider("mimo-api")) c.Desktop.ProviderAccess = []string{"mimo-api"} normalizeDesktopOfficialProviderAccess(c) pro, ok := c.ResolveModel("mimo-api/mimo-v2.5-pro") if !ok { t.Fatal("ResolveModel did not find mimo-api/mimo-v2.5-pro") } if EffectiveVision(pro) { t.Fatalf("mimo-v2.5-pro should remain text-only by default") } vision, ok := c.ResolveModel("mimo-api/mimo-v2.5") if !ok { t.Fatal("ResolveModel did not find mimo-api/mimo-v2.5") } if !EffectiveVision(vision) { t.Fatalf("mimo-v2.5 on the official MiMo API should enable vision") } omni, ok := c.ResolveModel("mimo-api/mimo-v2-omni") if !ok { t.Fatal("ResolveModel did not find mimo-api/mimo-v2-omni") } if !EffectiveVision(omni) { t.Fatalf("mimo-v2-omni on the official MiMo API should enable vision") } } func TestEffectiveVisionDoesNotInferCustomMimoProxy(t *testing.T) { custom := &ProviderEntry{ Name: "mimo-proxy", Kind: "openai", BaseURL: "https://proxy.example.com/v1", Model: "mimo-v2.5", } if EffectiveVision(custom) { t.Fatalf("custom MiMo proxy should require explicit vision=true") } custom.Vision = true if !EffectiveVision(custom) { t.Fatalf("explicit vision=true should still enable custom providers") } } func TestEffectiveVisionUsesPerModelVisionList(t *testing.T) { c := &Config{Providers: []ProviderEntry{{ Name: "custom", Kind: "openai", BaseURL: "https://proxy.example.com/v1", Models: []string{"text-only", "qwen-vl-plus"}, Default: "text-only", VisionModels: []string{"qwen-vl-plus"}, }}} textOnly, ok := c.ResolveModel("custom/text-only") if !ok { t.Fatal("ResolveModel did not find custom/text-only") } if EffectiveVision(textOnly) { t.Fatalf("text-only should remain text-only when not listed in vision_models") } vision, ok := c.ResolveModel("custom/qwen-vl-plus") if !ok { t.Fatal("ResolveModel did not find custom/qwen-vl-plus") } if !EffectiveVision(vision) { t.Fatalf("model listed in vision_models should enable image input") } textOnly.Vision = true if !EffectiveVision(textOnly) { t.Fatalf("provider-level vision=true should still enable every selected model") } } func TestResolveModelAppliesModelOverrides(t *testing.T) { visionOff := false c := &Config{Providers: []ProviderEntry{{ Name: "gateway", Kind: "openai", BaseURL: "https://proxy.example.com/v1", Models: []string{"deepseek-v4-flash", "plain-chat"}, Default: "plain-chat", ReasoningProtocol: ReasoningProtocolOpenAI, SupportedEfforts: []string{"low", "medium", "high"}, ModelOverrides: map[string]ProviderModelOverride{ "deepseek-v4-flash": { ReasoningProtocol: ReasoningProtocolDeepSeek, SupportedEfforts: []string{"high", "max"}, DefaultEffort: "max", Vision: &visionOff, }, }, }}} deepseek, ok := c.ResolveModel("gateway/deepseek-v4-flash") if !ok { t.Fatal("ResolveModel did not find gateway/deepseek-v4-flash") } if protocol := ReasoningProtocolForEntry(deepseek); protocol != ReasoningProtocolDeepSeek { t.Fatalf("deepseek protocol = %q, want deepseek", protocol) } cap := EffortCapabilityForEntry(deepseek) if cap.Default != "max" || !containsString(cap.Levels, "max") || containsString(cap.Levels, "low") { t.Fatalf("deepseek effort capability = %+v, want high|max default max", cap) } if EffectiveVision(deepseek) { t.Fatalf("vision override false should disable image input") } plain, ok := c.ResolveModel("gateway/plain-chat") if !ok { t.Fatal("ResolveModel did not find gateway/plain-chat") } if protocol := ReasoningProtocolForEntry(plain); protocol != ReasoningProtocolOpenAI { t.Fatalf("plain protocol = %q, want provider-level openai", protocol) } } func TestRemoveProvider(t *testing.T) { c := Default() c.Agent.PlannerModel = "deepseek-pro" // Cannot remove the default model when no configured fallback is available. for i := range c.Providers { c.Providers[i].APIKeyEnv = "" } if err := c.RemoveProvider(c.DefaultModel); err == nil { t.Error("expected error removing the default model") } // Removing the planner provider clears planner_model. if err := c.RemoveProvider("deepseek-pro"); err != nil { t.Fatalf("remove planner provider: %v", err) } if c.Agent.PlannerModel != "" { t.Errorf("planner should be cleared, got %q", c.Agent.PlannerModel) } if _, ok := c.Provider("deepseek-pro"); ok { t.Error("provider not actually removed") } // Unknown name errors. if err := c.RemoveProvider("ghost"); err == nil { t.Error("expected error for unknown provider") } } func TestPermissionMutators(t *testing.T) { c := Default() if err := c.SetPermissionMode("DENY"); err != nil || c.Permissions.Mode != "deny" { t.Errorf("set mode: err=%v mode=%q", err, c.Permissions.Mode) } if err := c.SetPermissionMode("nonsense"); err == nil { t.Error("expected error for bad mode") } if err := c.AddPermissionRule("deny", "Bash(rm -rf*)"); err != nil { t.Fatalf("add deny: %v", err) } // Duplicate is a no-op, not an error or a second entry. if err := c.AddPermissionRule("deny", "Bash(rm -rf*)"); err != nil { t.Fatalf("dup add: %v", err) } if len(c.Permissions.Deny) != 1 { t.Errorf("deny list = %v, want one entry", c.Permissions.Deny) } // Invalid rule and unknown list both error. if err := c.AddPermissionRule("deny", " "); err == nil { t.Error("expected error for empty rule") } if err := c.AddPermissionRule("nope", "read_file"); err == nil { t.Error("expected error for unknown list") } removed, err := c.RemovePermissionRule("deny", "Bash(rm -rf*)") if err != nil || !removed { t.Errorf("remove: removed=%v err=%v", removed, err) } if removed, _ := c.RemovePermissionRule("deny", "absent"); removed { t.Error("removing absent rule should report false") } } func TestSkillPathMutators(t *testing.T) { c := Default() root := t.TempDir() if err := c.ExcludeSkillPath(root); err != nil { t.Fatalf("exclude skill path: %v", err) } if err := c.AddSkillPath(root); err != nil { t.Fatalf("add skill path: %v", err) } if len(c.Skills.ExcludedPaths) != 0 { t.Fatalf("add skill path should restore excluded path, got %v", c.Skills.ExcludedPaths) } if err := c.AddSkillPath(filepath.Join(root, ".")); err != nil { t.Fatalf("duplicate skill path: %v", err) } if len(c.Skills.Paths) != 1 { t.Fatalf("paths = %v, want one deduped entry", c.Skills.Paths) } if err := c.AddSkillPath(" "); err == nil { t.Fatal("empty skill path should error") } removed, err := c.RemoveSkillPath(filepath.Join(root, ".")) if err != nil || !removed { t.Fatalf("remove skill path: removed=%v err=%v", removed, err) } if len(c.Skills.Paths) != 0 { t.Fatalf("paths after remove = %v", c.Skills.Paths) } if removed, err := c.RemoveSkillPath(root); err != nil || removed { t.Fatalf("remove absent: removed=%v err=%v", removed, err) } if err := c.ExcludeSkillPath(filepath.Join(root, ".")); err != nil { t.Fatalf("exclude skill path: %v", err) } if err := c.ExcludeSkillPath(root); err != nil { t.Fatalf("duplicate exclude skill path: %v", err) } if len(c.Skills.ExcludedPaths) != 1 { t.Fatalf("excluded paths = %v, want one deduped entry", c.Skills.ExcludedPaths) } if err := c.ExcludeSkillPath(" "); err == nil { t.Fatal("empty excluded skill path should error") } if err := c.RestoreSkillPath(root); err != nil { t.Fatalf("restore skill path: %v", err) } if len(c.Skills.ExcludedPaths) != 0 { t.Fatalf("excluded paths after restore = %v, want empty", c.Skills.ExcludedPaths) } if err := c.RestoreSkillPath(" "); err == nil { t.Fatal("empty restored skill path should error") } } func TestSkillEnabledMutator(t *testing.T) { c := Default() if err := c.SetSkillEnabled("review", false); err != nil { t.Fatalf("disable skill: %v", err) } if err := c.SetSkillEnabled("review", false); err != nil { t.Fatalf("disable duplicate skill: %v", err) } if len(c.Skills.DisabledSkills) != 1 || c.Skills.DisabledSkills[0] != "review" { t.Fatalf("disabled skills = %v, want [review]", c.Skills.DisabledSkills) } if !c.IsSkillDisabled("review") { t.Fatal("review should be disabled") } if err := c.SetSkillEnabled("review", true); err != nil { t.Fatalf("enable skill: %v", err) } if len(c.Skills.DisabledSkills) != 0 { t.Fatalf("disabled skills after enable = %v, want empty", c.Skills.DisabledSkills) } if err := c.SetSkillEnabled("bad name", false); err == nil { t.Fatal("invalid skill name should error") } } func TestPluginMutators(t *testing.T) { c := Default() if err := c.UpsertPlugin(PluginEntry{Name: "ex", Command: "reasonix-plugin-example"}); err != nil { t.Fatalf("add stdio: %v", err) } if err := c.UpsertPlugin(PluginEntry{Name: "stripe", Type: "http", URL: "https://mcp.stripe.com"}); err != nil { t.Fatalf("add http: %v", err) } if len(c.Plugins) != 2 { t.Fatalf("plugin count = %d, want 2", len(c.Plugins)) } // Transport validation: stdio needs command, http needs url. if err := c.UpsertPlugin(PluginEntry{Name: "bad"}); err == nil { t.Error("stdio without command should error") } if err := c.UpsertPlugin(PluginEntry{Name: "bad", Type: "http"}); err == nil { t.Error("http without url should error") } if err := c.UpsertPlugin(PluginEntry{Name: "bad", Type: "carrier-pigeon", Command: "x"}); err == nil { t.Error("unknown transport should error") } if err := c.UpsertPlugin(PluginEntry{Name: "bad", Command: "x", CallTimeoutSeconds: -1}); err == nil { t.Error("negative call_timeout_seconds should error") } if err := c.UpsertPlugin(PluginEntry{Name: "bad", Command: "x", ToolTimeoutSeconds: map[string]int{"generate": -1}}); err == nil { t.Error("negative tool_timeout_seconds should error") } if err := c.UpsertPlugin(PluginEntry{Name: "bad", Command: "x", ToolTimeoutSeconds: map[string]int{" ": 1}}); err == nil { t.Error("empty tool_timeout_seconds key should error") } // Replace in place. if err := c.UpsertPlugin(PluginEntry{Name: "ex", Command: "other-cmd"}); err != nil { t.Fatalf("replace: %v", err) } if len(c.Plugins) != 2 { t.Errorf("replace grew plugins to %d", len(c.Plugins)) } if !c.RemovePlugin("ex") { t.Error("remove should report true") } if c.RemovePlugin("ex") { t.Error("second remove should report false") } } func TestAutoStartPlugins(t *testing.T) { c := Default() off := false on := true c.Plugins = []PluginEntry{ {Name: "implicit", Command: "implicit-bin"}, {Name: "disabled", Command: "disabled-bin", AutoStart: &off}, {Name: "enabled", Command: "enabled-bin", AutoStart: &on}, } got := c.AutoStartPlugins() if len(got) != 2 || got[0].Name != "implicit" || got[1].Name != "enabled" { t.Fatalf("AutoStartPlugins = %+v, want implicit + enabled", got) } } func TestPluginResolvedTierDefaultsToBackground(t *testing.T) { for _, tc := range []struct { name string tier string want string }{ {name: "empty", tier: "", want: "background"}, {name: "legacy lazy", tier: "lazy", want: "background"}, {name: "background", tier: "background", want: "background"}, {name: "eager", tier: "eager", want: "eager"}, {name: "unknown", tier: "startup", want: "background"}, } { t.Run(tc.name, func(t *testing.T) { got := (PluginEntry{Name: "mcp", Command: "mcp-server", Tier: tc.tier}).ResolvedTier() if got != tc.want { t.Fatalf("ResolvedTier(%q) = %q, want %q", tc.tier, got, tc.want) } }) } } func TestClearPluginAuthentication(t *testing.T) { c := Default() c.Plugins = []PluginEntry{{ Name: "dida", Type: "http", URL: "https://mcp.dida365.com/mcp?access_token=abc&workspace=main", Headers: map[string]string{ "Authorization": "Bearer ${DIDA_TOKEN}", "X-Org": "team", }, Env: map[string]string{ "DIDA_TOKEN": "${DIDA_TOKEN}", "DEBUG": "1", }, Tier: "lazy", }} updated, changed, err := c.ClearPluginAuthentication("dida") if err != nil { t.Fatalf("ClearPluginAuthentication: %v", err) } if !changed { t.Fatal("ClearPluginAuthentication should report changed") } if updated.URL != "https://mcp.dida365.com/mcp?workspace=main" { t.Fatalf("url = %q", updated.URL) } if _, ok := updated.Headers["Authorization"]; ok { t.Fatalf("auth header should be removed: %v", updated.Headers) } if updated.Headers["X-Org"] != "team" { t.Fatalf("ordinary header should be preserved: %v", updated.Headers) } if _, ok := updated.Env["DIDA_TOKEN"]; ok { t.Fatalf("auth env should be removed: %v", updated.Env) } if updated.Env["DEBUG"] != "1" { t.Fatalf("ordinary env should be preserved: %v", updated.Env) } } // TestSaveToRoundTrips stages several mutations, persists atomically, and // re-decodes the file to confirm the changes survived a write/read cycle. func TestSaveToRoundTrips(t *testing.T) { c := Default() if err := c.SetDefaultModel("deepseek-pro"); err != nil { t.Fatal(err) } if err := c.SetPlannerModel("deepseek-pro"); err != nil { t.Fatal(err) } if err := c.UpsertProvider(ProviderEntry{Name: "local", Kind: "openai", BaseURL: "http://localhost:1234/v1", Model: "llama"}); err != nil { t.Fatal(err) } if err := c.SetPermissionMode("deny"); err != nil { t.Fatal(err) } if err := c.AddPermissionRule("allow", "Bash(go test:*)"); err != nil { t.Fatal(err) } if err := c.SetNetwork(NetworkConfig{ ProxyMode: "custom", Proxy: NetworkProxyConfig{ Type: "socks5", Server: "127.0.0.1", Port: 7890, }, }); err != nil { t.Fatal(err) } autoStart := false if err := c.UpsertPlugin(PluginEntry{Name: "stripe", Type: "http", URL: "https://mcp.stripe.com", AutoStart: &autoStart}); err != nil { t.Fatal(err) } path := filepath.Join(t.TempDir(), "nested", "reasonix.toml") if err := c.SaveTo(path); err != nil { t.Fatalf("SaveTo: %v", err) } var got Config if _, err := toml.DecodeFile(path, &got); err != nil { t.Fatalf("saved file does not parse: %v", err) } if got.DefaultModel != "deepseek-pro" { t.Errorf("default_model = %q", got.DefaultModel) } if got.Agent.PlannerModel != "deepseek-pro" { t.Errorf("planner_model = %q", got.Agent.PlannerModel) } if _, ok := got.Provider("local"); !ok { t.Error("added provider 'local' missing after round-trip") } if got.Permissions.Mode != "deny" { t.Errorf("mode = %q", got.Permissions.Mode) } if len(got.Permissions.Allow) != 1 || got.Permissions.Allow[0] != "Bash(go test:*)" { t.Errorf("allow list = %v", got.Permissions.Allow) } if got.Network.ProxyMode != "custom" || got.Network.Proxy.Server != "127.0.0.1" || got.Network.Proxy.Port != 7890 { t.Errorf("network = %+v", got.Network) } if len(got.Plugins) != 1 || got.Plugins[0].Name != "stripe" { t.Errorf("plugins = %+v", got.Plugins) } if got.Plugins[0].AutoStart == nil || *got.Plugins[0].AutoStart { t.Errorf("auto_start should round-trip false, got %+v", got.Plugins[0].AutoStart) } } func TestSaveToScopesUserAndProjectFiles(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) c := Default() c.Desktop.Theme = "dark" c.Desktop.ThemeStyle = "graphite" c.Desktop.CloseBehavior = "background" userPath := UserConfigPath() if err := c.SaveTo(userPath); err != nil { t.Fatalf("SaveTo user config: %v", err) } userBody, err := os.ReadFile(userPath) if err != nil { t.Fatalf("read user config: %v", err) } if !strings.Contains(string(userBody), "[desktop]") { t.Fatalf("user config should include desktop preferences:\n%s", userBody) } if info, err := os.Stat(userPath); err != nil { t.Fatalf("stat user config: %v", err) } else if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { t.Fatalf("user config mode = %o, want 600", info.Mode().Perm()) } projectPath := filepath.Join(t.TempDir(), "reasonix.toml") if err := c.SaveTo(projectPath); err != nil { t.Fatalf("SaveTo project config: %v", err) } projectBody, err := os.ReadFile(projectPath) if err != nil { t.Fatalf("read project config: %v", err) } if strings.Contains(string(projectBody), "[desktop]") || strings.Contains(string(projectBody), "close_behavior") || strings.Contains(string(projectBody), "default_tool_approval_mode") { t.Fatalf("project config should not include desktop preferences:\n%s", projectBody) } if info, err := os.Stat(projectPath); err != nil { t.Fatalf("stat project config: %v", err) } else if runtime.GOOS != "windows" && info.Mode().Perm() != 0o644 { t.Fatalf("project config mode = %o, want 644", info.Mode().Perm()) } } func TestLoadForRootKeepsOfficialProviderAliasesDistinct(t *testing.T) { isolateUserConfigHome(t) root := t.TempDir() userPath := UserConfigPath() if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(userPath, []byte(` config_version = 2 default_model = "deepseek/deepseek-v4-flash" [desktop] provider_access = ["deepseek"] [[providers]] name = "deepseek" kind = "openai" base_url = "https://api.deepseek.com" models = ["deepseek-v4-flash", "deepseek-v4-pro"] default = "deepseek-v4-flash" api_key_env = "USER_DEEPSEEK_KEY" `), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(` [[providers]] name = "deepseek-flash" kind = "openai" base_url = "https://api.deepseek.com" model = "deepseek-v4-flash" api_key_env = "PROJECT_DEEPSEEK_KEY" effort = "max" `), 0o644); err != nil { t.Fatal(err) } cfg, err := LoadForRoot(root) if err != nil { t.Fatalf("LoadForRoot: %v", err) } userProvider, ok := cfg.Provider("deepseek") if !ok { t.Fatalf("user deepseek provider missing: %+v", cfg.Providers) } if userProvider.APIKeyEnv != "USER_DEEPSEEK_KEY" { t.Fatalf("deepseek provider = %+v, want user provider preserved", userProvider) } projectProvider, ok := cfg.Provider("deepseek-flash") if !ok { t.Fatalf("project deepseek-flash provider missing: %+v", cfg.Providers) } if projectProvider.APIKeyEnv != "PROJECT_DEEPSEEK_KEY" || projectProvider.Effort != "max" { t.Fatalf("deepseek-flash provider = %+v, want project provider preserved", projectProvider) } } func TestLoadForRootKeepsUserProviderOverSameNamedProjectProvider(t *testing.T) { isolateUserConfigHome(t) root := t.TempDir() userPath := UserConfigPath() if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(userPath, []byte(` [[providers]] name = "shared" kind = "openai" base_url = "https://global.example/v1" model = "global-model" api_key_env = "GLOBAL_SHARED_KEY" `), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(` [[providers]] name = "shared" kind = "openai" base_url = "https://project.example/v1" model = "project-model" api_key_env = "PROJECT_SHARED_KEY" [[providers]] name = "project-only" kind = "openai" base_url = "https://project.example/v1" model = "project-only-model" api_key_env = "PROJECT_ONLY_KEY" `), 0o644); err != nil { t.Fatal(err) } cfg, err := LoadForRoot(root) if err != nil { t.Fatalf("LoadForRoot: %v", err) } shared, ok := cfg.Provider("shared") if !ok { t.Fatalf("shared provider missing: %+v", cfg.Providers) } if shared.BaseURL != "https://global.example/v1" || shared.APIKeyEnv != "GLOBAL_SHARED_KEY" || shared.Model != "global-model" { t.Fatalf("shared provider = %+v, want global provider to win over project provider", shared) } if _, ok := cfg.Provider("project-only"); !ok { t.Fatalf("project-only provider missing: %+v", cfg.Providers) } } func TestLoadForRootKeepsGlobalAgentStepLimitsOverProject(t *testing.T) { isolateUserConfigHome(t) root := t.TempDir() userPath := UserConfigPath() if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(userPath, []byte(` [agent] max_steps = 17 planner_max_steps = 9 temperature = 0.4 `), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(` default_model = "deepseek-pro" [agent] max_steps = 3 planner_max_steps = 4 temperature = 0.8 `), 0o644); err != nil { t.Fatal(err) } cfg, err := LoadForRoot(root) if err != nil { t.Fatalf("LoadForRoot: %v", err) } if cfg.Agent.MaxSteps != 17 || cfg.Agent.PlannerMaxSteps != 9 { t.Fatalf("agent steps = max:%d planner:%d, want global 17/9", cfg.Agent.MaxSteps, cfg.Agent.PlannerMaxSteps) } if cfg.Agent.Temperature != 0.8 { t.Fatalf("agent temperature = %v, want project override to keep working for other agent settings", cfg.Agent.Temperature) } if cfg.DefaultModel != "deepseek-pro" { t.Fatalf("default_model = %q, want project config to keep overriding unrelated fields", cfg.DefaultModel) } } func TestLoadForRootIgnoresProjectAgentStepLimitsWithoutUserConfig(t *testing.T) { isolateUserConfigHome(t) root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(` [agent] max_steps = 3 planner_max_steps = 4 `), 0o644); err != nil { t.Fatal(err) } cfg, err := LoadForRoot(root) if err != nil { t.Fatalf("LoadForRoot: %v", err) } if cfg.Agent.MaxSteps != 0 || cfg.Agent.PlannerMaxSteps != 0 { t.Fatalf("agent steps = max:%d planner:%d, want built-in global defaults 0/0", cfg.Agent.MaxSteps, cfg.Agent.PlannerMaxSteps) } } func TestSaveForRootPreservesShadowedProjectProvider(t *testing.T) { isolateUserConfigHome(t) root := t.TempDir() userPath := UserConfigPath() if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(userPath, []byte(` [[providers]] name = "shared" kind = "openai" base_url = "https://global.example/v1" model = "global-model" api_key_env = "GLOBAL_SHARED_KEY" `), 0o644); err != nil { t.Fatal(err) } projectPath := filepath.Join(root, "reasonix.toml") if err := os.WriteFile(projectPath, []byte(` [[providers]] name = "shared" kind = "openai" base_url = "https://project.example/v1" model = "project-model" api_key_env = "PROJECT_SHARED_KEY" `), 0o644); err != nil { t.Fatal(err) } cfg, err := LoadForRoot(root) if err != nil { t.Fatalf("LoadForRoot: %v", err) } if err := cfg.SaveForRoot(root); err != nil { t.Fatalf("SaveForRoot: %v", err) } var saved Config if _, err := toml.DecodeFile(projectPath, &saved); err != nil { t.Fatalf("saved project config does not parse: %v", err) } shared, ok := saved.Provider("shared") if !ok { t.Fatalf("saved project provider missing: %+v", saved.Providers) } if shared.BaseURL != "https://project.example/v1" || shared.APIKeyEnv != "PROJECT_SHARED_KEY" { t.Fatalf("saved provider = %+v, want original project provider", shared) } } func TestSaveForRootDoesNotWriteUserProvidersIntoProjectConfig(t *testing.T) { isolateUserConfigHome(t) root := t.TempDir() userPath := UserConfigPath() if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(userPath, []byte(` config_version = 2 [[providers]] name = "global" kind = "openai" base_url = "https://global.example/v1" model = "global-model" api_key_env = "GLOBAL_KEY" `), 0o644); err != nil { t.Fatal(err) } projectPath := filepath.Join(root, "reasonix.toml") if err := os.WriteFile(projectPath, []byte(` config_version = 2 default_model = "project-local/project-model" [[providers]] name = "project-local" kind = "openai" base_url = "https://project.example/v1" model = "project-model" api_key_env = "PROJECT_KEY" `), 0o644); err != nil { t.Fatal(err) } cfg, err := LoadForRoot(root) if err != nil { t.Fatalf("LoadForRoot: %v", err) } if _, ok := cfg.Provider("global"); !ok { t.Fatal("runtime config should include user provider before saving") } if _, ok := cfg.Provider("project-local"); !ok { t.Fatal("runtime config should include project provider before saving") } if err := cfg.SaveForRoot(root); err != nil { t.Fatalf("SaveForRoot: %v", err) } var got Config if _, err := toml.DecodeFile(projectPath, &got); err != nil { t.Fatalf("saved project config does not parse: %v", err) } if _, ok := got.Provider("global"); ok { t.Fatalf("user provider leaked into project config: %+v", got.Providers) } if _, ok := got.Provider("project-local"); !ok { t.Fatalf("project provider missing after save: %+v", got.Providers) } } func TestSaveToExistingProjectPersistsTopLevelDelta(t *testing.T) { projectPath := filepath.Join(t.TempDir(), "reasonix.toml") if err := os.WriteFile(projectPath, []byte("[permissions]\nallow = [\"Bash(go test:*)\"]\n"), 0o644); err != nil { t.Fatal(err) } cfg := Default() cfg.ConfigVersion = 2 if err := cfg.SetDefaultModel("deepseek-pro"); err != nil { t.Fatal(err) } if err := cfg.SaveTo(projectPath); err != nil { t.Fatalf("SaveTo: %v", err) } body, err := os.ReadFile(projectPath) if err != nil { t.Fatalf("read project config: %v", err) } if !strings.Contains(string(body), `default_model = "deepseek-pro"`) { t.Fatalf("project config dropped top-level default_model delta:\n%s", body) } if !strings.Contains(string(body), "config_version = 2") { t.Fatalf("project config dropped top-level config_version delta:\n%s", body) } var got Config if _, err := toml.DecodeFile(projectPath, &got); err != nil { t.Fatalf("saved project config does not parse: %v", err) } if got.DefaultModel != "deepseek-pro" { t.Fatalf("default_model = %q, want deepseek-pro", got.DefaultModel) } if got.ConfigVersion != 2 { t.Fatalf("config_version = %d, want 2", got.ConfigVersion) } } func TestSaveToExistingProjectPersistsProviderAccessWithoutReplacingDesktopSection(t *testing.T) { projectPath := filepath.Join(t.TempDir(), "reasonix.toml") if err := os.WriteFile(projectPath, []byte("[desktop]\nlegacy_preference = \"keep\"\n\n[permissions]\nallow = [\"Bash(go test:*)\"]\n"), 0o644); err != nil { t.Fatal(err) } cfg := LoadForEditWithoutCredentials(projectPath) cfg.Desktop.ProviderAccess = []string{"project-relay"} if err := cfg.SaveTo(projectPath); err != nil { t.Fatalf("SaveTo: %v", err) } body, err := os.ReadFile(projectPath) if err != nil { t.Fatal(err) } text := string(body) for _, want := range []string{`provider_access = ["project-relay"]`, `legacy_preference = "keep"`, `[permissions]`} { if !strings.Contains(text, want) { t.Fatalf("existing project config missing %q after provider access update:\n%s", want, text) } } cfg.Desktop.ProviderAccess = []string{} if err := cfg.SaveTo(projectPath); err != nil { t.Fatalf("SaveTo explicit empty access: %v", err) } body, err = os.ReadFile(projectPath) if err != nil { t.Fatal(err) } if !strings.Contains(string(body), "provider_access = []") { t.Fatalf("explicit empty project provider access was not persisted:\n%s", body) } } func TestProviderEntriesConfigEqualIgnoresResolvedCredentialState(t *testing.T) { a := ProviderEntry{Name: "relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "m", APIKeyEnv: "RELAY_API_KEY"} b := a a.resolvedAPIKey = "old-secret" a.resolvedSource = CredentialSource{Kind: CredentialSourceCredentials, Label: "old"} b.resolvedAPIKey = "new-secret" b.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: "new"} if !ProviderEntriesConfigEqual(a, b) { t.Fatal("runtime-only credential state caused a persisted provider conflict") } b.Headers = map[string]string{"X-External": "changed"} if ProviderEntriesConfigEqual(a, b) { t.Fatal("persisted provider field change was ignored") } snapshot := ProviderEntryConfigSnapshot(a) if snapshot.resolvedAPIKey != "" || snapshot.resolvedSource != (CredentialSource{}) { t.Fatal("provider config snapshot retained runtime credential state") } cfg := &Config{Providers: []ProviderEntry{a}} updated := a updated.resolvedAPIKey = "" updated.resolvedSource = CredentialSource{} updated.Headers = map[string]string{"X-Replayed": "yes"} if err := cfg.UpsertProviderPreservingRuntime(updated); err != nil { t.Fatal(err) } got, _ := cfg.Provider("relay") if got.APIKey() != "old-secret" || got.Headers["X-Replayed"] != "yes" { t.Fatalf("runtime-preserving upsert = %+v", got) } updated.APIKeyEnv = "NEW_RELAY_API_KEY" if err := cfg.UpsertProviderPreservingRuntime(updated); err != nil { t.Fatal(err) } got, _ = cfg.Provider("relay") if got.resolvedAPIKey != "" || got.resolvedSource != (CredentialSource{}) { t.Fatal("runtime credential survived an api_key_env change") } } func TestSaveToExistingProjectRemovesPluginDelta(t *testing.T) { projectPath := filepath.Join(t.TempDir(), "reasonix.toml") cfg := Default() if err := cfg.UpsertPlugin(PluginEntry{Name: "ed", Type: "http", URL: "https://mcp.example.com/mcp", Headers: map[string]string{"Authorization": "Bearer token"}}); err != nil { t.Fatal(err) } if err := cfg.SaveTo(projectPath); err != nil { t.Fatalf("initial SaveTo: %v", err) } if !cfg.RemovePlugin("ed") { t.Fatal("RemovePlugin should report changed") } if err := cfg.SaveTo(projectPath); err != nil { t.Fatalf("SaveTo after remove: %v", err) } body, err := os.ReadFile(projectPath) if err != nil { t.Fatalf("read project config: %v", err) } if strings.Contains(string(body), "[[plugins]]") || strings.Contains(string(body), "[plugins.headers]") || strings.Contains(string(body), "Authorization") { t.Fatalf("removed plugin should not remain in project config:\n%s", body) } var got Config if _, err := toml.DecodeFile(projectPath, &got); err != nil { t.Fatalf("saved project config does not parse: %v", err) } if len(got.Plugins) != 0 { t.Fatalf("plugins = %+v, want none", got.Plugins) } } func TestSaveToExistingProjectRemovesIneffectiveWindowsBashEnforce(t *testing.T) { setRuntimeGOOS(t, "windows") projectPath := filepath.Join(t.TempDir(), "reasonix.toml") if err := os.WriteFile(projectPath, []byte("[sandbox]\nbash = \"enforce\"\n"), 0o644); err != nil { t.Fatal(err) } cfg := Default() cfg.Sandbox.Bash = "enforce" if err := cfg.SaveTo(projectPath); err != nil { t.Fatalf("SaveTo: %v", err) } body, err := os.ReadFile(projectPath) if err != nil { t.Fatalf("read project config: %v", err) } if strings.Contains(string(body), `[sandbox]`) || strings.Contains(string(body), `bash = "enforce"`) { t.Fatalf("ineffective Windows project bash enforce should be removed:\n%s", body) } if _, err := toml.Decode(string(body), &Config{}); err != nil { t.Fatalf("saved project config does not parse: %v", err) } } func TestSaveToExistingProjectRemovesIneffectiveWindowsBashEnforceWhenTargetIsOff(t *testing.T) { setRuntimeGOOS(t, "windows") projectPath := filepath.Join(t.TempDir(), "reasonix.toml") if err := os.WriteFile(projectPath, []byte("[sandbox]\nbash = \"enforce\"\n"), 0o644); err != nil { t.Fatal(err) } cfg := Default() cfg.Sandbox.Bash = "off" if err := cfg.SaveTo(projectPath); err != nil { t.Fatalf("SaveTo: %v", err) } body, err := os.ReadFile(projectPath) if err != nil { t.Fatalf("read project config: %v", err) } if strings.Contains(string(body), `[sandbox]`) || strings.Contains(string(body), `bash = "enforce"`) { t.Fatalf("ineffective Windows project bash enforce should be removed even when the target mode is raw off:\n%s", body) } if _, err := toml.Decode(string(body), &Config{}); err != nil { t.Fatalf("saved project config does not parse: %v", err) } } func TestSaveToExistingProjectRemovesOnlyIneffectiveWindowsBashEnforce(t *testing.T) { setRuntimeGOOS(t, "windows") projectPath := filepath.Join(t.TempDir(), "reasonix.toml") if err := os.WriteFile(projectPath, []byte("[sandbox]\nbash = \"enforce\"\nnetwork = true\n"), 0o644); err != nil { t.Fatal(err) } cfg := Default() cfg.Sandbox.Bash = "enforce" if err := cfg.SaveTo(projectPath); err != nil { t.Fatalf("SaveTo: %v", err) } body, err := os.ReadFile(projectPath) if err != nil { t.Fatalf("read project config: %v", err) } if strings.Contains(string(body), `bash = "enforce"`) { t.Fatalf("ineffective Windows project bash enforce should be removed:\n%s", body) } if !strings.Contains(string(body), `[sandbox]`) || !strings.Contains(string(body), `network = true`) { t.Fatalf("other sandbox fields should be preserved:\n%s", body) } var got Config if _, err := toml.Decode(string(body), &got); err != nil { t.Fatalf("saved project config does not parse: %v", err) } if !got.Sandbox.Network { t.Fatalf("network = false, want preserved true") } } func TestSaveForRootDoesNotWriteUserAgentSettingsIntoProjectConfig(t *testing.T) { isolateUserConfigHome(t) root := t.TempDir() userPath := UserConfigPath() if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(userPath, []byte("[agent]\ntemperature = 0.42\n"), 0o644); err != nil { t.Fatal(err) } projectPath := filepath.Join(root, "reasonix.toml") if err := os.WriteFile(projectPath, []byte("[permissions]\nallow = [\"Bash(go test:*)\"]\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := LoadForRoot(root) if err != nil { t.Fatalf("LoadForRoot: %v", err) } if cfg.Agent.Temperature != 0.42 { t.Fatalf("runtime temperature = %v, want merged user config", cfg.Agent.Temperature) } if err := cfg.SaveForRoot(root); err != nil { t.Fatalf("SaveForRoot: %v", err) } body, err := os.ReadFile(projectPath) if err != nil { t.Fatalf("read project config: %v", err) } if strings.Contains(string(body), "temperature") { t.Fatalf("user agent setting leaked into project config:\n%s", body) } } func TestSetNetworkRejectsIncompleteCustomProxy(t *testing.T) { c := Default() if err := c.SetNetwork(NetworkConfig{ProxyMode: "custom"}); err == nil { t.Fatal("custom proxy without server/port should be rejected") } } func TestEffortCapabilityCustomSupportedEfforts(t *testing.T) { e := &ProviderEntry{ Name: "custom", Kind: "openai", BaseURL: "https://example.com", SupportedEfforts: []string{"low", "medium", "high"}, DefaultEffort: "high", } cap := EffortCapabilityForEntry(e) if !cap.Supported { t.Fatalf("expected supported, got %+v", cap) } wantLevels := []string{"auto", "low", "medium", "high"} if len(cap.Levels) != len(wantLevels) { t.Fatalf("levels = %v, want %v", cap.Levels, wantLevels) } for i, l := range wantLevels { if cap.Levels[i] != l { t.Errorf("levels[%d] = %q, want %q", i, cap.Levels[i], l) } } if cap.Default != "high" { t.Errorf("default = %q, want high", cap.Default) } } func TestEffortCapabilityUsesKnownModelRegistry(t *testing.T) { e := &ProviderEntry{ Name: "deepseek-proxy", Kind: "openai", BaseURL: "https://proxy.example.com/v1", Model: "deepseek-v4-flash", } cap := EffortCapabilityForEntry(e) if !cap.Supported { t.Fatalf("deepseek model behind proxy should expose effort, got %+v", cap) } wantLevels := []string{"auto", "disabled", "high", "max"} if len(cap.Levels) != len(wantLevels) { t.Fatalf("levels = %v, want %v", cap.Levels, wantLevels) } for i, want := range wantLevels { if cap.Levels[i] != want { t.Fatalf("levels[%d] = %q, want %q", i, cap.Levels[i], want) } } if cap.Default != "high" { t.Fatalf("default = %q, want high", cap.Default) } if protocol := ReasoningProtocolForEntry(e); protocol != ReasoningProtocolDeepSeek { t.Fatalf("protocol = %q, want deepseek", protocol) } if got, err := NormalizeEffort(e, "max"); err != nil || got != "max" { t.Fatalf("NormalizeEffort(max) = %q/%v, want max/nil", got, err) } } func TestReasoningProtocolOverrideControlsEffortCapability(t *testing.T) { e := &ProviderEntry{ Name: "deepseek-proxy", Kind: "openai", BaseURL: "https://proxy.example.com/v1", Model: "deepseek-v4-flash", ReasoningProtocol: "none", } if cap := EffortCapabilityForEntry(e); cap.Supported { t.Fatalf("reasoning_protocol=none should disable effort, got %+v", cap) } if protocol := ReasoningProtocolForEntry(e); protocol != ReasoningProtocolNone { t.Fatalf("protocol = %q, want none", protocol) } if _, err := NormalizeEffort(e, "max"); err == nil { t.Fatal("NormalizeEffort should reject effort when reasoning_protocol=none") } e.ReasoningProtocol = "openai" cap := EffortCapabilityForEntry(e) if !cap.Supported { t.Fatalf("reasoning_protocol=openai should expose OpenAI effort levels, got %+v", cap) } wantLevels := []string{"auto", "low", "medium", "high"} if len(cap.Levels) != len(wantLevels) { t.Fatalf("levels = %v, want %v", cap.Levels, wantLevels) } for i, want := range wantLevels { if cap.Levels[i] != want { t.Fatalf("levels[%d] = %q, want %q", i, cap.Levels[i], want) } } if _, err := NormalizeEffort(e, "max"); err == nil { t.Fatal("OpenAI reasoning_protocol should reject max") } if got, err := NormalizeEffort(e, "medium"); err != nil || got != "medium" { t.Fatalf("NormalizeEffort(medium) = %q/%v, want medium/nil", got, err) } } func TestNormalizeEffortCustomSupportedEfforts(t *testing.T) { e := &ProviderEntry{ Name: "custom", Kind: "openai", BaseURL: "https://example.com", SupportedEfforts: []string{"low", "medium", "high"}, } for in, want := range map[string]string{"auto": "", "low": "low", "MEDIUM": "medium", "high": "high"} { got, err := NormalizeEffort(e, in) if err != nil || got != want { t.Fatalf("NormalizeEffort(%q) = %q/%v, want %q/nil", in, got, err, want) } } for _, bad := range []string{"max", "xhigh", "", " "} { if _, err := NormalizeEffort(e, bad); err == nil { t.Errorf("NormalizeEffort(%q) should be rejected", bad) } } } func TestNormalizeEffortCustomDefaultEffort(t *testing.T) { e := &ProviderEntry{ Name: "custom", Kind: "openai", BaseURL: "https://example.com", SupportedEfforts: []string{"low", "medium", "high"}, DefaultEffort: "xhigh", // not in the list — must fall back to the first level } cap := EffortCapabilityForEntry(e) if cap.Default != "low" { t.Fatalf("default = %q, want low (first of supported_efforts)", cap.Default) } // Omitting DefaultEffort also falls back to the first level. e2 := *e e2.DefaultEffort = "" if cap := EffortCapabilityForEntry(&e2); cap.Default != "low" { t.Errorf("empty default = %q, want low", cap.Default) } // /effort auto still maps to "" regardless of DefaultEffort. if got, err := NormalizeEffort(e, "auto"); err != nil || got != "" { t.Fatalf("NormalizeEffort(auto) = %q/%v, want empty/nil", got, err) } e.Effort = "auto" if got := EffectiveEffort(e); got != "low" { t.Fatalf("stored auto should fall through to default_effort, got %q", got) } e.Effort = "high" if got := EffectiveEffort(e); got != "high" { t.Fatalf("explicit effort should win over default_effort, got %q", got) } } func TestNormalizeEffortCustomLevelsCaseInsensitive(t *testing.T) { e := &ProviderEntry{ Name: "custom", Kind: "openai", BaseURL: "https://example.com", SupportedEfforts: []string{"Low", "MEDIUM", "medium", "auto", " "}, DefaultEffort: "MEDIUM", } cap := EffortCapabilityForEntry(e) wantLevels := []string{"auto", "low", "medium"} if len(cap.Levels) != len(wantLevels) { t.Fatalf("levels = %v, want %v", cap.Levels, wantLevels) } for i, want := range wantLevels { if cap.Levels[i] != want { t.Fatalf("levels[%d] = %q, want %q", i, cap.Levels[i], want) } } if cap.Default != "medium" { t.Fatalf("default = %q, want medium", cap.Default) } got, err := NormalizeEffort(e, "MEDIUM") if err != nil || got != "medium" { t.Fatalf("NormalizeEffort(MEDIUM) = %q/%v, want medium/nil", got, err) } if got := EffectiveEffort(e); got != "medium" { t.Fatalf("EffectiveEffort = %q, want medium", got) } } func TestUpsertProviderNormalizesCustomEffortFields(t *testing.T) { c := &Config{} if err := c.UpsertProvider(ProviderEntry{ Name: "custom", Kind: "openai", BaseURL: "https://example.com", Model: "m", Effort: " HIGH ", ReasoningProtocol: " OPENAI ", SupportedEfforts: []string{"Low", "MEDIUM", "medium", "auto"}, DefaultEffort: " LOW ", }); err != nil { t.Fatalf("UpsertProvider: %v", err) } got, _ := c.Provider("custom") if got.Effort != "high" || got.DefaultEffort != "low" { t.Fatalf("effort/default = %q/%q, want high/low", got.Effort, got.DefaultEffort) } if got.ReasoningProtocol != "openai" { t.Fatalf("reasoning_protocol = %q, want openai", got.ReasoningProtocol) } wantSupported := []string{"low", "medium"} if len(got.SupportedEfforts) != len(wantSupported) { t.Fatalf("supported_efforts = %v, want %v", got.SupportedEfforts, wantSupported) } for i, want := range wantSupported { if got.SupportedEfforts[i] != want { t.Fatalf("supported_efforts[%d] = %q, want %q", i, got.SupportedEfforts[i], want) } } } func TestEffortCapabilityEmptySupportedEffortsNotConfigurable(t *testing.T) { // mimo-pro without SupportedEfforts: no built-in heuristic, /effort must reject. e := &ProviderEntry{ Name: "mimo-pro", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", } if cap := EffortCapabilityForEntry(e); cap.Supported { t.Fatalf("mimo-pro without SupportedEfforts should not be configurable, got %+v", cap) } if _, err := NormalizeEffort(e, "high"); err == nil { t.Fatal("NormalizeEffort should reject level for unsupported provider") } // `supported_efforts = []` (empty slice) is treated like nil — the v2 design // has no way to opt out of the built-in heuristic; users either configure // levels or leave the field unset. e2 := *e e2.SupportedEfforts = []string{} if cap := EffortCapabilityForEntry(&e2); cap.Supported { t.Fatalf("empty supported_efforts should also fall through to the heuristic, got %+v", cap) } }