1900 lines
62 KiB
Go
1900 lines
62 KiB
Go
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)
|
|
}
|
|
}
|