返回 DeepSeek-Reasonix
edit_test.go
根目录 / internal / config / edit_test.go
1 package config
2
3 import (
4 "bytes"
5 "fmt"
6 "math"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "reflect"
11 "runtime"
12 "slices"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/BurntSushi/toml"
18 )
19
20 func TestUIThemeNormalizes(t *testing.T) {
21 c := Default()
22 for _, tt := range []struct {
23 in string
24 want string
25 }{
26 {"", "auto"},
27 {"AUTO", "auto"},
28 {"dark", "dark"},
29 {" light ", "light"},
30 {"unknown", "auto"},
31 } {
32 c.UI.Theme = tt.in
33 if got := c.UITheme(); got != tt.want {
34 t.Errorf("UITheme(%q) = %q, want %q", tt.in, got, tt.want)
35 }
36 }
37 }
38
39 func TestUIThemeStyleNormalizes(t *testing.T) {
40 c := Default()
41 for _, tt := range []struct {
42 in string
43 want string
44 }{
45 {"", ""},
46 {"AURORA", "aurora"},
47 {" nocturne ", "nocturne"},
48 {" glacier ", "glacier"},
49 {"unknown", ""},
50 } {
51 c.UI.ThemeStyle = tt.in
52 if got := c.UIThemeStyle(); got != tt.want {
53 t.Errorf("UIThemeStyle(%q) = %q, want %q", tt.in, got, tt.want)
54 }
55 }
56 }
57
58 func TestUICursorShapeNormalizes(t *testing.T) {
59 c := Default()
60 for _, tt := range []struct {
61 in string
62 want string
63 }{
64 {"", "bar"},
65 {"UNDERLINE", "underline"},
66 {" block ", "block"},
67 {"bar", "bar"},
68 {"unknown", "bar"},
69 } {
70 c.UI.CursorShape = tt.in
71 if got := c.UICursorShape(); got != tt.want {
72 t.Errorf("UICursorShape(%q) = %q, want %q", tt.in, got, tt.want)
73 }
74 }
75 }
76
77 func TestUICloseBehaviorNormalizes(t *testing.T) {
78 c := Default()
79 for _, tt := range []struct {
80 in string
81 want string
82 }{
83 {"", "background"},
84 {"QUIT", "quit"},
85 {"exit", "quit"},
86 {" background ", "background"},
87 {"hide", "background"},
88 {"unknown", "background"},
89 } {
90 c.UI.CloseBehavior = tt.in
91 if got := c.UICloseBehavior(); got != tt.want {
92 t.Errorf("UICloseBehavior(%q) = %q, want %q", tt.in, got, tt.want)
93 }
94 }
95 }
96
97 func TestDesktopPreferencesAreSeparateFromCLI(t *testing.T) {
98 c := Default()
99 c.Language = "zh"
100 c.UI.Theme = "light"
101 c.UI.ThemeStyle = "glacier"
102
103 if err := c.SetDesktopLanguage("en"); err != nil {
104 t.Fatalf("SetDesktopLanguage: %v", err)
105 }
106 if err := c.SetDesktopAppearance("dark", "graphite"); err != nil {
107 t.Fatalf("SetDesktopAppearance: %v", err)
108 }
109 if err := c.SetDesktopTerminalTheme("light"); err != nil {
110 t.Fatalf("SetDesktopTerminalTheme: %v", err)
111 }
112 if err := c.SetDesktopLayoutStyle("workbench"); err != nil {
113 t.Fatalf("SetDesktopLayoutStyle: %v", err)
114 }
115 if err := c.SetDesktopStatusBarStyle("text"); err != nil {
116 t.Fatalf("SetDesktopStatusBarStyle: %v", err)
117 }
118 if err := c.SetDesktopStatusBarItems([]string{"model", "balance", "cache"}); err != nil {
119 t.Fatalf("SetDesktopStatusBarItems: %v", err)
120 }
121
122 if c.Language != "zh" {
123 t.Fatalf("CLI language changed to %q", c.Language)
124 }
125 if got := c.UITheme(); got != "light" {
126 t.Fatalf("CLI theme = %q, want light", got)
127 }
128 if got := c.UIThemeStyle(); got != "glacier" {
129 t.Fatalf("CLI theme style = %q, want glacier", got)
130 }
131 if got := c.DesktopLanguage(); got != "en" {
132 t.Fatalf("desktop language = %q, want en", got)
133 }
134 if got := c.DesktopTheme(); got != "dark" {
135 t.Fatalf("desktop theme = %q, want dark", got)
136 }
137 if got := c.DesktopThemeStyle(); got != "graphite" {
138 t.Fatalf("desktop theme style = %q, want graphite", got)
139 }
140 if got := c.DesktopTerminalTheme(); got != "light" {
141 t.Fatalf("desktop terminal theme = %q, want light", got)
142 }
143 if got := c.DesktopLayoutStyle(); got != "workbench" {
144 t.Fatalf("desktop layout style = %q, want workbench", got)
145 }
146 if got := c.DesktopStatusBarStyle(); got != "text" {
147 t.Fatalf("desktop status bar style = %q, want text", got)
148 }
149 if got, want := c.DesktopStatusBarItems(), []string{"model", "balance", "cache"}; !reflect.DeepEqual(got, want) {
150 t.Fatalf("desktop status bar items = %v, want %v", got, want)
151 }
152 }
153
154 func TestSetDesktopTerminalThemeValidatesPreference(t *testing.T) {
155 c := Default()
156 for _, theme := range []string{"auto", "dark", "light"} {
157 if err := c.SetDesktopTerminalTheme(theme); err != nil {
158 t.Fatalf("SetDesktopTerminalTheme(%q): %v", theme, err)
159 }
160 if got := c.DesktopTerminalTheme(); got != theme {
161 t.Fatalf("DesktopTerminalTheme() = %q, want %q", got, theme)
162 }
163 }
164 if err := c.SetDesktopTerminalTheme("sepia"); err == nil {
165 t.Fatal("SetDesktopTerminalTheme(sepia) succeeded, want validation error")
166 }
167 }
168
169 func TestDesktopCurrencyNormalizesAndRefreshesOfficialPricing(t *testing.T) {
170 c := Default()
171 c.Desktop.Language = "zh"
172 flash, _ := c.Provider("deepseek-flash")
173 // Capture frozen list price before display switches.
174 wantOutput := flash.Price.Output
175 wantCurrency := flash.Price.Currency
176 if err := c.SetDesktopCurrency("usd"); err != nil {
177 t.Fatalf("SetDesktopCurrency USD: %v", err)
178 }
179 if got := c.DesktopCurrency(); got != "USD" {
180 t.Fatalf("desktop currency = %q, want USD", got)
181 }
182 if got := c.DisplayCurrencyPref(); got != "USD" {
183 t.Fatalf("display currency pref = %q, want USD", got)
184 }
185 // Display currency must not rewrite frozen provider list prices.
186 if flash.Price == nil || flash.Price.Output != wantOutput || flash.Price.Currency != wantCurrency {
187 t.Fatalf("list price mutated by display switch: %+v", flash.Price)
188 }
189 if err := c.SetDesktopCurrency("auto"); err != nil {
190 t.Fatalf("SetDesktopCurrency auto: %v", err)
191 }
192 if got := c.DesktopCurrency(); got != "" {
193 t.Fatalf("auto desktop currency = %q, want empty", got)
194 }
195 if flash.Price == nil || flash.Price.Output != wantOutput || flash.Price.Currency != wantCurrency {
196 t.Fatalf("list price mutated after auto: %+v", flash.Price)
197 }
198 if err := c.SetDesktopCurrency("EUR"); err == nil {
199 t.Fatal("SetDesktopCurrency accepted unsupported EUR")
200 }
201 }
202
203 func TestDesktopLayoutStyleNormalizes(t *testing.T) {
204 if got := Default().DesktopLayoutStyle(); got != "workbench" {
205 t.Fatalf("default desktop layout style = %q, want workbench", got)
206 }
207 for _, tt := range []struct {
208 in string
209 want string
210 wantErr bool
211 }{
212 {"", "workbench", false},
213 {"classic", "workbench", false},
214 {" workbench ", "workbench", false},
215 {"workspace", "workbench", false},
216 {"creation", "creation", false},
217 {" Creation ", "creation", false},
218 {"later", "workbench", true},
219 } {
220 c := Default()
221 if err := c.SetDesktopLayoutStyle(tt.in); (err != nil) != tt.wantErr {
222 t.Fatalf("SetDesktopLayoutStyle(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr)
223 }
224 if got := c.DesktopLayoutStyle(); got != tt.want {
225 t.Fatalf("DesktopLayoutStyle(%q) = %q, want %q", tt.in, got, tt.want)
226 }
227 }
228
229 c := Default()
230 c.Desktop.ThemeStyle = "workbench"
231 if got := c.DesktopLayoutStyle(); got != "workbench" {
232 t.Fatalf("legacy desktop theme_style=workbench layout = %q, want workbench", got)
233 }
234 if got := c.DesktopThemeStyle(); got != "" {
235 t.Fatalf("legacy desktop theme_style=workbench theme style = %q, want empty", got)
236 }
237 }
238
239 func TestDesktopConversationWidthNormalizes(t *testing.T) {
240 if got := Default().DesktopConversationWidth(); got != "standard" {
241 t.Fatalf("default desktop conversation width = %q, want standard", got)
242 }
243
244 for _, tt := range []struct {
245 in string
246 want string
247 wantErr bool
248 }{
249 {"", "standard", false},
250 {"standard", "standard", false},
251 {" FULL ", "full", false},
252 {"wide", "standard", true},
253 } {
254 c := Default()
255 if err := c.SetDesktopConversationWidth(tt.in); (err != nil) != tt.wantErr {
256 t.Fatalf("SetDesktopConversationWidth(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr)
257 }
258 if got := c.DesktopConversationWidth(); got != tt.want {
259 t.Fatalf("DesktopConversationWidth(%q) = %q, want %q", tt.in, got, tt.want)
260 }
261 }
262
263 c := Default()
264 c.Desktop.ConversationWidth = " FULL "
265 if got := c.DesktopConversationWidth(); got != "full" {
266 t.Fatalf("manually edited conversation width = %q, want full", got)
267 }
268 }
269
270 func TestDesktopExternalOpenerValidation(t *testing.T) {
271 c := Default()
272 if got := c.DesktopExternalOpener(); got != "" {
273 t.Fatalf("default external opener = %q, want empty platform fallback", got)
274 }
275 if err := c.SetDesktopExternalOpener(" Cursor "); err != nil {
276 t.Fatalf("SetDesktopExternalOpener: %v", err)
277 }
278 if got := c.DesktopExternalOpener(); got != "cursor" {
279 t.Fatalf("DesktopExternalOpener = %q, want cursor", got)
280 }
281 for _, invalid := range []string{"../../bin/sh", "vscode;open", "app id"} {
282 if err := c.SetDesktopExternalOpener(invalid); err == nil {
283 t.Fatalf("SetDesktopExternalOpener(%q) unexpectedly succeeded", invalid)
284 }
285 }
286 if err := c.SetDesktopExternalOpener(""); err != nil || c.DesktopExternalOpener() != "" {
287 t.Fatalf("clearing external opener = (%q, %v), want empty", c.DesktopExternalOpener(), err)
288 }
289 }
290
291 func TestDesktopStatusBarStyleNormalizes(t *testing.T) {
292 if got := Default().DesktopStatusBarStyle(); got != "icon" {
293 t.Fatalf("default desktop status bar style = %q, want icon", got)
294 }
295 for _, tt := range []struct {
296 in string
297 want string
298 wantErr bool
299 }{
300 {"", "text", false},
301 {"icon", "icon", false},
302 {"icons", "icon", false},
303 {"text", "text", false},
304 {"labels", "text", false},
305 {"later", "icon", true},
306 } {
307 c := Default()
308 if err := c.SetDesktopStatusBarStyle(tt.in); (err != nil) != tt.wantErr {
309 t.Fatalf("SetDesktopStatusBarStyle(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr)
310 }
311 if got := c.DesktopStatusBarStyle(); got != tt.want {
312 t.Fatalf("DesktopStatusBarStyle(%q) = %q, want %q", tt.in, got, tt.want)
313 }
314 }
315 }
316
317 func TestDesktopStatusBarItemsNormalizeAndValidate(t *testing.T) {
318 if got, want := Default().DesktopStatusBarItems(), DefaultDesktopStatusBarItems(); !reflect.DeepEqual(got, want) {
319 t.Fatalf("default desktop status bar items = %v, want %v", got, want)
320 }
321 for _, id := range []string{"workspace", "git_branch"} {
322 if !slices.Contains(DefaultDesktopStatusBarItems(), id) {
323 t.Fatalf("default desktop status bar items must include configurable item %q", id)
324 }
325 }
326
327 c := Default()
328 c.Desktop.StatusBarItems = []string{" balance ", "cache", "cache", "unknown", "model"}
329 if got, want := c.DesktopStatusBarItems(), []string{"balance", "cache", "model"}; !reflect.DeepEqual(got, want) {
330 t.Fatalf("normalized desktop status bar items = %v, want %v", got, want)
331 }
332
333 c = Default()
334 if err := c.SetDesktopStatusBarItems([]string{"balance", "cache", "balance", "model"}); err != nil {
335 t.Fatalf("SetDesktopStatusBarItems subset: %v", err)
336 }
337 if got, want := c.DesktopStatusBarItems(), []string{"balance", "cache", "model"}; !reflect.DeepEqual(got, want) {
338 t.Fatalf("saved desktop status bar items = %v, want %v", got, want)
339 }
340
341 c = Default()
342 if err := c.SetDesktopStatusBarItems([]string{"workspace", "git_branch", "model"}); err != nil {
343 t.Fatalf("SetDesktopStatusBarItems workspace metadata: %v", err)
344 }
345 if got, want := c.DesktopStatusBarItems(), []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got, want) {
346 t.Fatalf("saved workspace metadata status bar items = %v, want %v", got, want)
347 }
348
349 if err := c.SetDesktopStatusBarItems(nil); err != nil {
350 t.Fatalf("SetDesktopStatusBarItems nil: %v", err)
351 }
352 if got, want := c.DesktopStatusBarItems(), DefaultDesktopStatusBarItems(); !reflect.DeepEqual(got, want) {
353 t.Fatalf("nil desktop status bar items = %v, want default %v", got, want)
354 }
355
356 if err := c.SetDesktopStatusBarItems([]string{"ghost"}); err == nil {
357 t.Fatal("expected error for unknown status bar item")
358 }
359 }
360
361 func TestDesktopCloseBehaviorFallsBackToLegacyUI(t *testing.T) {
362 c := Default()
363 c.UI.CloseBehavior = "quit"
364 if got := c.DesktopCloseBehavior(); got != "quit" {
365 t.Fatalf("legacy close behavior = %q, want quit", got)
366 }
367 c.Desktop.CloseBehavior = "background"
368 if got := c.DesktopCloseBehavior(); got != "background" {
369 t.Fatalf("desktop close behavior = %q, want background", got)
370 }
371 }
372
373 func TestSetUICloseBehavior(t *testing.T) {
374 c := Default()
375 if err := c.SetUICloseBehavior("background"); err != nil {
376 t.Fatalf("SetUICloseBehavior background: %v", err)
377 }
378 if got := c.UICloseBehavior(); got != "background" {
379 t.Fatalf("close behavior = %q, want background", got)
380 }
381 if err := c.SetUICloseBehavior("quit"); err != nil {
382 t.Fatalf("SetUICloseBehavior quit: %v", err)
383 }
384 if got := c.UICloseBehavior(); got != "quit" {
385 t.Fatalf("close behavior = %q, want quit", got)
386 }
387 if err := c.SetUICloseBehavior("later"); err == nil {
388 t.Fatal("expected error for invalid close behavior")
389 }
390 }
391
392 func TestSetPlannerModel(t *testing.T) {
393 c := Default()
394 if err := c.SetPlannerModel("deepseek-pro"); err != nil {
395 t.Fatalf("set planner: %v", err)
396 }
397 if c.Agent.PlannerModel != "deepseek-pro" {
398 t.Errorf("planner = %q", c.Agent.PlannerModel)
399 }
400 if err := c.SetPlannerModel(""); err != nil || c.Agent.PlannerModel != "" {
401 t.Errorf("clearing planner failed: err=%v planner=%q", err, c.Agent.PlannerModel)
402 }
403 if err := c.SetPlannerModel("ghost"); err == nil {
404 t.Error("expected error for unknown planner")
405 }
406 }
407
408 func TestSetAutoPlanRejectsRetiredModes(t *testing.T) {
409 c := Default()
410 if err := c.SetAutoPlan("off"); err != nil {
411 t.Fatalf("SetAutoPlan(off): %v", err)
412 }
413 if c.Agent.AutoPlan != "off" || c.Agent.AutoPlanClassifier != "" {
414 t.Fatalf("retired auto-plan state = (%q, %q), want off/empty", c.Agent.AutoPlan, c.Agent.AutoPlanClassifier)
415 }
416 for _, mode := range []string{"on", "ask", "auto"} {
417 if err := c.SetAutoPlan(mode); err == nil || !strings.Contains(err.Error(), "retired") {
418 t.Fatalf("SetAutoPlan(%q) err = %v, want retired error", mode, err)
419 }
420 }
421 }
422
423 func TestSetDesktopDefaultToolApprovalMode(t *testing.T) {
424 c := Default()
425 if got := c.DesktopDefaultToolApprovalMode(); got != "workspace-write" {
426 t.Fatalf("desktop default tool approval mode = %q, want workspace-write", got)
427 }
428 for _, tc := range []struct{ in, want string }{
429 {"read-only", "read-only"},
430 {"workspace-write", "workspace-write"},
431 {"danger-full-access", "danger-full-access"},
432 {"ask", "read-only"},
433 {"auto", "workspace-write"},
434 {"yolo", "workspace-write"},
435 } {
436 mode := tc.in
437 if err := c.SetDesktopDefaultToolApprovalMode(mode); err != nil {
438 t.Fatalf("SetDesktopDefaultToolApprovalMode(%q): %v", mode, err)
439 }
440 if c.DesktopDefaultToolApprovalMode() != tc.want {
441 t.Fatalf("desktop default tool approval mode = %q, want %q", c.DesktopDefaultToolApprovalMode(), tc.want)
442 }
443 }
444 if err := c.SetDesktopDefaultToolApprovalMode("full-access"); err != nil {
445 t.Fatalf("legacy full-access should be accepted: %v", err)
446 }
447 if c.DesktopDefaultToolApprovalMode() != "danger-full-access" {
448 t.Fatalf("legacy full-access should save as danger-full-access, got %q", c.DesktopDefaultToolApprovalMode())
449 }
450 if err := c.SetDesktopDefaultToolApprovalMode("maybe"); err == nil {
451 t.Fatal("expected error for invalid desktop default tool approval mode")
452 }
453 }
454
455 func TestLoadForEditMissingDesktopApprovalDefaultsWorkspaceWrite(t *testing.T) {
456 path := filepath.Join(t.TempDir(), "config.toml")
457 if err := os.WriteFile(path, []byte("config_version = 4\n"), 0o600); err != nil {
458 t.Fatalf("write config: %v", err)
459 }
460 if got := LoadForEdit(path).DesktopDefaultToolApprovalMode(); got != "workspace-write" {
461 t.Fatalf("missing desktop default tool approval mode = %q, want workspace-write", got)
462 }
463 }
464
465 func TestSetUIShortcutLayout(t *testing.T) {
466 c := Default()
467 if got := c.UIShortcutLayout(); got != "classic" {
468 t.Fatalf("default shortcut layout = %q, want classic", got)
469 }
470 if err := c.SetUIShortcutLayout("desktop"); err != nil {
471 t.Fatalf("SetUIShortcutLayout desktop: %v", err)
472 }
473 if got := c.UIShortcutLayout(); got != "desktop" {
474 t.Fatalf("shortcut layout = %q, want desktop", got)
475 }
476 if err := c.SetUIShortcutLayout("dual-axis"); err != nil {
477 t.Fatalf("SetUIShortcutLayout alias: %v", err)
478 }
479 if got := c.UIShortcutLayout(); got != "desktop" {
480 t.Fatalf("shortcut layout alias = %q, want desktop", got)
481 }
482 if err := c.SetUIShortcutLayout("classic"); err != nil {
483 t.Fatalf("SetUIShortcutLayout classic: %v", err)
484 }
485 if got := c.UIShortcutLayout(); got != "classic" {
486 t.Fatalf("shortcut layout = %q, want classic", got)
487 }
488 if err := c.SetUIShortcutLayout("surprise"); err == nil {
489 t.Fatal("expected error for invalid shortcut layout")
490 }
491 }
492
493 func TestUpsertProvider(t *testing.T) {
494 c := Default()
495 n := len(c.Providers)
496
497 // Add a new one.
498 if err := c.UpsertProvider(ProviderEntry{Name: "local", Kind: "openai", BaseURL: "http://localhost:1234/v1", Model: "x"}); err != nil {
499 t.Fatalf("add: %v", err)
500 }
501 if len(c.Providers) != n+1 {
502 t.Fatalf("provider count = %d, want %d", len(c.Providers), n+1)
503 }
504
505 // Replace it in place (no growth, position preserved).
506 if err := c.UpsertProvider(ProviderEntry{Name: "local", Kind: "openai", BaseURL: "http://localhost:9999/v1", Model: "y"}); err != nil {
507 t.Fatalf("replace: %v", err)
508 }
509 if len(c.Providers) != n+1 {
510 t.Errorf("replace grew the list to %d", len(c.Providers))
511 }
512 got, _ := c.Provider("local")
513 if got.BaseURL != "http://localhost:9999/v1" || got.Model != "y" {
514 t.Errorf("replace didn't apply: %+v", got)
515 }
516
517 // Multi-model providers may omit the back-compat single model field.
518 if err := c.UpsertProvider(ProviderEntry{
519 Name: "multi",
520 Kind: "openai",
521 BaseURL: "http://localhost:8888/v1",
522 Models: []string{"m1", "m2"},
523 Default: "m1",
524 }); err != nil {
525 t.Fatalf("multi-model add: %v", err)
526 }
527
528 // Missing required fields error.
529 for _, bad := range []ProviderEntry{
530 {Kind: "openai", BaseURL: "u", Model: "m"}, // no name
531 {Name: "a", BaseURL: "u", Model: "m"}, // no kind
532 {Name: "a", Kind: "openai", Model: "m"}, // no base_url
533 {Name: "a", Kind: "openai", BaseURL: "u"}, // no model
534 {Name: "a", Kind: "openai", BaseURL: "u", Model: "m", APIKeyEnv: "grok-4.5"}, // invalid credential variable name
535 } {
536 if err := c.UpsertProvider(bad); err == nil {
537 t.Errorf("expected validation error for %+v", bad)
538 }
539 }
540 }
541
542 func TestSetProviderEffort(t *testing.T) {
543 c := Default()
544 if err := c.SetProviderEffort("deepseek-flash", "MAX"); err != nil {
545 t.Fatalf("SetProviderEffort: %v", err)
546 }
547 p, _ := c.Provider("deepseek-flash")
548 if p.Effort != "max" {
549 t.Fatalf("effort = %q, want max", p.Effort)
550 }
551 if err := c.SetProviderEffort("missing", "high"); err == nil {
552 t.Fatal("SetProviderEffort should reject unknown provider")
553 }
554 }
555
556 func TestSetLanguage(t *testing.T) {
557 c := Default()
558 if err := c.SetLanguage("zh"); err != nil {
559 t.Fatalf("SetLanguage zh: %v", err)
560 }
561 if c.Language != "zh" {
562 t.Fatalf("language = %q, want zh", c.Language)
563 }
564 if err := c.SetLanguage("auto"); err != nil {
565 t.Fatalf("SetLanguage auto: %v", err)
566 }
567 if c.Language != "" {
568 t.Fatalf("language = %q, want cleared", c.Language)
569 }
570 }
571
572 func TestSetReasoningLanguage(t *testing.T) {
573 c := Default()
574 if err := c.SetReasoningLanguage("中文"); err != nil {
575 t.Fatalf("SetReasoningLanguage zh: %v", err)
576 }
577 if c.Agent.ReasoningLanguage != "zh" || c.ReasoningLanguage() != "zh" {
578 t.Fatalf("reasoning language = %q/%q, want zh", c.Agent.ReasoningLanguage, c.ReasoningLanguage())
579 }
580 if err := c.SetReasoningLanguage("model-default"); err != nil {
581 t.Fatalf("SetReasoningLanguage legacy default: %v", err)
582 }
583 if c.Agent.ReasoningLanguage != "" || c.ReasoningLanguage() != "auto" {
584 t.Fatalf("legacy default should normalize to empty/auto, got %q/%q", c.Agent.ReasoningLanguage, c.ReasoningLanguage())
585 }
586 if err := c.SetReasoningLanguage("auto"); err != nil {
587 t.Fatalf("SetReasoningLanguage auto: %v", err)
588 }
589 if c.Agent.ReasoningLanguage != "" || c.ReasoningLanguage() != "auto" {
590 t.Fatalf("reasoning language = %q/%q, want empty/auto", c.Agent.ReasoningLanguage, c.ReasoningLanguage())
591 }
592 if err := c.SetReasoningLanguage("klingon"); err == nil {
593 t.Fatal("SetReasoningLanguage should reject unknown values")
594 }
595 }
596
597 func TestSetCompactRatio(t *testing.T) {
598 c := Default()
599 for _, ratio := range []float64{0.30, 0.64, 0.65, 0.7, 0.8, 0.85} {
600 if err := c.SetCompactRatio(ratio); err != nil {
601 t.Fatalf("SetCompactRatio(%v): %v", ratio, err)
602 }
603 if c.Agent.CompactRatio != ratio {
604 t.Fatalf("compact ratio = %v, want %v", c.Agent.CompactRatio, ratio)
605 }
606 }
607
608 previous := c.Agent.CompactRatio
609 for _, ratio := range []float64{0.29, 0.86, math.NaN(), math.Inf(1), math.Inf(-1)} {
610 if err := c.SetCompactRatio(ratio); err == nil {
611 t.Fatalf("SetCompactRatio(%v) should fail", ratio)
612 }
613 if c.Agent.CompactRatio != previous {
614 t.Fatalf("rejected ratio %v changed compact ratio to %v", ratio, c.Agent.CompactRatio)
615 }
616 }
617
618 // Deprecated snip/force ratios no longer constrain SetCompactRatio.
619 c.Agent.ToolResultSnipRatio = 0.75
620 c.Agent.CompactForceRatio = 0.8
621 if err := c.SetCompactRatio(0.7); err != nil {
622 t.Fatalf("SetCompactRatio(0.7) with legacy snip/force fields: %v", err)
623 }
624 if err := c.SetCompactRatio(0.8); err != nil {
625 t.Fatalf("SetCompactRatio(0.8) with legacy force field: %v", err)
626 }
627 }
628
629 func TestNormalizeEffortDeepSeek(t *testing.T) {
630 e := &ProviderEntry{Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4"}
631 cap := EffortCapabilityForEntry(e)
632 if !cap.Supported || len(cap.Levels) != 4 || cap.Levels[0] != "auto" || cap.Levels[1] != "disabled" || cap.Levels[2] != "high" || cap.Levels[3] != "max" {
633 t.Fatalf("DeepSeek levels = %+v, want auto/disabled/high/max", cap)
634 }
635 for in, want := range map[string]string{"auto": "", "disabled": "disabled", "high": "high", "max": "max", "low": "high", "medium": "high", "xhigh": "max"} {
636 got, err := NormalizeEffort(e, in)
637 if in != want && in != "auto" {
638 if err == nil {
639 t.Fatalf("undeclared %q accepted as %q", in, got)
640 }
641 continue
642 }
643 if err != nil || got != want {
644 t.Fatalf("NormalizeEffort(%q) = %q/%v, want %q/nil", in, got, err, want)
645 }
646 }
647 // "off" is the retired DeepSeek "no thinking" spelling — now maps to disabled.
648 if got, err := NormalizeEffort(e, "off"); err == nil {
649 t.Fatalf("NormalizeEffort(\"off\") = %q/%v, want \"disabled\"/nil", got, err)
650 }
651 }
652
653 func TestNormalizeLegacyEffortMigratesProviderDefaults(t *testing.T) {
654 c := &Config{Providers: []ProviderEntry{
655 {Name: "deepseek", Effort: "off"},
656 {Name: "deepseek-upper", Effort: "OFF"},
657 {Name: "deepseek-auto", Effort: "auto"},
658 {Name: "deepseek-auto-upper", Effort: "AUTO"},
659 {Name: "keep", Effort: "high"},
660 }}
661 normalizeLegacyEffort(c)
662 normalizeEffortConfig(c)
663 if c.Providers[0].Effort != "" || c.Providers[1].Effort != "" || c.Providers[2].Effort != "" || c.Providers[3].Effort != "" {
664 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)
665 }
666 if c.Providers[4].Effort != "high" {
667 t.Fatalf("non-legacy effort changed: %q", c.Providers[4].Effort)
668 }
669 }
670
671 func TestNormalizeEffortAnthropic(t *testing.T) {
672 e := &ProviderEntry{Name: "claude", Kind: "anthropic", Model: "claude-opus-4-8"}
673 cap := EffortCapabilityForEntry(e)
674 if !cap.Supported || len(cap.Levels) != 6 {
675 t.Fatalf("Anthropic levels = %+v, want auto plus five levels", cap)
676 }
677 for _, level := range []string{"low", "medium", "high", "xhigh", "max"} {
678 got, err := NormalizeEffort(e, level)
679 if err != nil || got != level {
680 t.Fatalf("NormalizeEffort(%q) = %q/%v, want %q/nil", level, got, err, level)
681 }
682 }
683 got, err := NormalizeEffort(e, "auto")
684 if err != nil || got != "" {
685 t.Fatalf("NormalizeEffort(auto) = %q/%v, want empty/nil", got, err)
686 }
687 }
688
689 func TestResolveModelPreservesProviderEffort(t *testing.T) {
690 c := Default()
691 c.Providers = append(c.Providers, ProviderEntry{
692 Name: "deepseek",
693 Kind: "openai",
694 BaseURL: "https://api.deepseek.com",
695 Model: "deepseek-v4-flash",
696 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
697 Default: "deepseek-v4-flash",
698 APIKeyEnv: "DEEPSEEK_API_KEY",
699 Effort: "max",
700 })
701 e, ok := c.ResolveModel("deepseek/deepseek-v4-pro")
702 if !ok {
703 t.Fatal("ResolveModel did not find deepseek/deepseek-v4-pro")
704 }
705 if e.Name != "deepseek" || e.Model != "deepseek-v4-pro" || e.Effort != "max" {
706 t.Fatalf("resolved entry = %+v, want provider deepseek model deepseek-v4-pro effort max", e)
707 }
708 }
709
710 func TestEffectiveVisionForMimoEndpointModels(t *testing.T) {
711 c := Default()
712 c.Providers = append(c.Providers, legacyMimoCustomProvider("mimo-api"))
713 c.Desktop.ProviderAccess = []string{"mimo-api"}
714 normalizeDesktopOfficialProviderAccess(c)
715
716 pro, ok := c.ResolveModel("mimo-api/mimo-v2.5-pro")
717 if !ok {
718 t.Fatal("ResolveModel did not find mimo-api/mimo-v2.5-pro")
719 }
720 if EffectiveVision(pro) {
721 t.Fatalf("mimo-v2.5-pro should remain text-only by default")
722 }
723
724 vision, ok := c.ResolveModel("mimo-api/mimo-v2.5")
725 if !ok {
726 t.Fatal("ResolveModel did not find mimo-api/mimo-v2.5")
727 }
728 if !EffectiveVision(vision) {
729 t.Fatalf("mimo-v2.5 on the official MiMo API should enable vision")
730 }
731
732 omni, ok := c.ResolveModel("mimo-api/mimo-v2-omni")
733 if !ok {
734 t.Fatal("ResolveModel did not find mimo-api/mimo-v2-omni")
735 }
736 if !EffectiveVision(omni) {
737 t.Fatalf("mimo-v2-omni on the official MiMo API should enable vision")
738 }
739 }
740
741 func TestEffectiveVisionDoesNotInferCustomMimoProxy(t *testing.T) {
742 custom := &ProviderEntry{
743 Name: "mimo-proxy",
744 Kind: "openai",
745 BaseURL: "https://proxy.example.com/v1",
746 Model: "mimo-v2.5",
747 }
748 if EffectiveVision(custom) {
749 t.Fatalf("custom MiMo proxy should require explicit vision=true")
750 }
751 custom.Vision = true
752 if !EffectiveVision(custom) {
753 t.Fatalf("explicit vision=true should still enable custom providers")
754 }
755 }
756
757 func TestEffectiveVisionRejectsOfficialDeepSeekOverridesButPreservesCustomGateways(t *testing.T) {
758 for _, endpoint := range []struct {
759 kind string
760 baseURL string
761 }{
762 {kind: "openai", baseURL: "https://api.deepseek.com"},
763 {kind: "openai", baseURL: "https://api.deepseek.com/v1"},
764 {kind: "openai", baseURL: "https://eu.deepseek.com/v1"},
765 {kind: "anthropic", baseURL: "https://api.deepseek.com/anthropic"},
766 } {
767 visionOn := true
768 official := &ProviderEntry{
769 Name: "deepseek",
770 Kind: endpoint.kind,
771 BaseURL: endpoint.baseURL,
772 Model: "deepseek-v4-pro",
773 Vision: true,
774 VisionModels: []string{"deepseek-v4-pro"},
775 visionOverride: &visionOn,
776 ReasoningProtocol: ReasoningProtocolDeepSeek,
777 }
778 if !CanConfigureVision(official) {
779 t.Fatalf("official DeepSeek endpoint %q must allow Settings vision checkboxes", endpoint.baseURL)
780 }
781 if EffectiveVision(official) {
782 t.Fatalf("official DeepSeek endpoint %q must remain text-only", endpoint.baseURL)
783 }
784 if ExplicitModelVision(official) {
785 t.Fatalf("official DeepSeek endpoint %q must not expose ignored vision metadata as usable", endpoint.baseURL)
786 }
787 if !official.HasVisionModel("deepseek-v4-pro") {
788 t.Fatalf("official DeepSeek endpoint %q lost persisted vision metadata instead of ignoring it", endpoint.baseURL)
789 }
790 }
791
792 future := &ProviderEntry{
793 Name: "deepseek",
794 Kind: "openai",
795 BaseURL: "https://api.deepseek.com",
796 Model: "deepseek-v5-vision",
797 VisionModels: []string{"deepseek-v5-vision"},
798 }
799 if !EffectiveVision(future) {
800 t.Fatal("explicit vision model list must support unknown DeepSeek models")
801 }
802
803 visionOn := true
804 cfg := &Config{Providers: []ProviderEntry{{
805 Name: "deepseek",
806 Kind: "openai",
807 BaseURL: "https://api.deepseek.com",
808 Models: []string{"deepseek-v5-override"},
809 ModelOverrides: map[string]ProviderModelOverride{
810 "deepseek-v5-override": {Vision: &visionOn},
811 },
812 }}}
813 overridden, ok := cfg.ResolveModel("deepseek/deepseek-v5-override")
814 if !ok {
815 t.Fatal("ResolveModel did not find explicit future DeepSeek model")
816 }
817 if !EffectiveVision(overridden) {
818 t.Fatal("model_overrides vision=true must enable unknown DeepSeek models")
819 }
820
821 custom := &ProviderEntry{
822 Name: "deepseek-gateway",
823 Kind: "openai",
824 BaseURL: "https://gateway.example/v1",
825 Model: "deepseek-v4-pro",
826 Vision: true,
827 ReasoningProtocol: ReasoningProtocolDeepSeek,
828 }
829 if !CanConfigureVision(custom) || !EffectiveVision(custom) {
830 t.Fatal("explicit vision=true must remain available for custom DeepSeek gateways")
831 }
832 custom.Vision = false
833 custom.VisionModels = []string{"deepseek-v4-pro"}
834 if !ExplicitModelVision(custom) {
835 t.Fatal("custom DeepSeek gateway must expose positive model-scoped vision metadata")
836 }
837 }
838
839 func TestEffectiveVisionUsesPerModelVisionList(t *testing.T) {
840 c := &Config{Providers: []ProviderEntry{{
841 Name: "custom",
842 Kind: "openai",
843 BaseURL: "https://proxy.example.com/v1",
844 Models: []string{"text-only", "qwen-vl-plus"},
845 Default: "text-only",
846 VisionModels: []string{"qwen-vl-plus"},
847 }}}
848
849 textOnly, ok := c.ResolveModel("custom/text-only")
850 if !ok {
851 t.Fatal("ResolveModel did not find custom/text-only")
852 }
853 if EffectiveVision(textOnly) {
854 t.Fatalf("text-only should remain text-only when not listed in vision_models")
855 }
856
857 vision, ok := c.ResolveModel("custom/qwen-vl-plus")
858 if !ok {
859 t.Fatal("ResolveModel did not find custom/qwen-vl-plus")
860 }
861 if !EffectiveVision(vision) {
862 t.Fatalf("model listed in vision_models should enable image input")
863 }
864
865 textOnly.Vision = true
866 if !EffectiveVision(textOnly) {
867 t.Fatalf("provider-level vision=true should still enable every selected model")
868 }
869 }
870
871 func TestResolveModelAppliesModelOverrides(t *testing.T) {
872 visionOff := false
873 c := &Config{Providers: []ProviderEntry{{
874 Name: "gateway",
875 Kind: "openai",
876 BaseURL: "https://proxy.example.com/v1",
877 Models: []string{"deepseek-v4-flash", "plain-chat"},
878 Default: "plain-chat",
879 ContextWindow: 131_072,
880 MaxOutputTokens: 8_192,
881 ReasoningProtocol: ReasoningProtocolOpenAI,
882 SupportedEfforts: []string{"low", "medium", "high"},
883 ModelOverrides: map[string]ProviderModelOverride{
884 "deepseek-v4-flash": {
885 ReasoningProtocol: ReasoningProtocolDeepSeek,
886 SupportedEfforts: []string{"high", "max"},
887 DefaultEffort: "max",
888 Vision: &visionOff,
889 ContextWindow: 1_000_000,
890 MaxOutputTokens: 32_768,
891 },
892 },
893 }}}
894
895 deepseek, ok := c.ResolveModel("gateway/deepseek-v4-flash")
896 if !ok {
897 t.Fatal("ResolveModel did not find gateway/deepseek-v4-flash")
898 }
899 if protocol := ReasoningProtocolForEntry(deepseek); protocol != ReasoningProtocolDeepSeek {
900 t.Fatalf("deepseek protocol = %q, want deepseek", protocol)
901 }
902 cap := EffortCapabilityForEntry(deepseek)
903 if cap.Default != "max" || !containsString(cap.Levels, "max") || containsString(cap.Levels, "low") {
904 t.Fatalf("deepseek effort capability = %+v, want high|max default max", cap)
905 }
906 if EffectiveVision(deepseek) {
907 t.Fatalf("vision override false should disable image input")
908 }
909 if deepseek.ContextWindow != 1_000_000 {
910 t.Fatalf("deepseek context window = %d, want per-model override", deepseek.ContextWindow)
911 }
912 if deepseek.MaxOutputTokens != 32_768 {
913 t.Fatalf("deepseek max output tokens = %d, want per-model override", deepseek.MaxOutputTokens)
914 }
915
916 plain, ok := c.ResolveModel("gateway/plain-chat")
917 if !ok {
918 t.Fatal("ResolveModel did not find gateway/plain-chat")
919 }
920 if protocol := ReasoningProtocolForEntry(plain); protocol != ReasoningProtocolOpenAI {
921 t.Fatalf("plain protocol = %q, want provider-level openai", protocol)
922 }
923 if plain.ContextWindow != 131_072 {
924 t.Fatalf("plain context window = %d, want inherited provider value", plain.ContextWindow)
925 }
926 if plain.MaxOutputTokens != 8_192 {
927 t.Fatalf("plain max output tokens = %d, want inherited provider value", plain.MaxOutputTokens)
928 }
929 }
930
931 func TestRemoveProvider(t *testing.T) {
932 c := Default()
933 c.Agent.PlannerModel = "deepseek-pro"
934
935 // Cannot remove the default model when no configured fallback is available.
936 for i := range c.Providers {
937 c.Providers[i].APIKeyEnv = ""
938 }
939 if err := c.RemoveProvider(c.DefaultModel); err == nil {
940 t.Error("expected error removing the default model")
941 }
942 // Removing the planner provider clears planner_model.
943 if err := c.RemoveProvider("deepseek-pro"); err != nil {
944 t.Fatalf("remove planner provider: %v", err)
945 }
946 if c.Agent.PlannerModel != "" {
947 t.Errorf("planner should be cleared, got %q", c.Agent.PlannerModel)
948 }
949 if _, ok := c.Provider("deepseek-pro"); ok {
950 t.Error("provider not actually removed")
951 }
952 // Unknown name errors.
953 if err := c.RemoveProvider("ghost"); err == nil {
954 t.Error("expected error for unknown provider")
955 }
956 }
957
958 func TestPermissionMutators(t *testing.T) {
959 c := Default()
960
961 if err := c.SetPermissionMode("DENY"); err != nil || c.Permissions.Mode != "deny" {
962 t.Errorf("set mode: err=%v mode=%q", err, c.Permissions.Mode)
963 }
964 if err := c.SetPermissionMode("nonsense"); err == nil {
965 t.Error("expected error for bad mode")
966 }
967
968 if err := c.AddPermissionRule("deny", "Bash(rm -rf*)"); err != nil {
969 t.Fatalf("add deny: %v", err)
970 }
971 // Duplicate is a no-op, not an error or a second entry.
972 if err := c.AddPermissionRule("deny", "Bash(rm -rf*)"); err != nil {
973 t.Fatalf("dup add: %v", err)
974 }
975 if len(c.Permissions.Deny) != 1 {
976 t.Errorf("deny list = %v, want one entry", c.Permissions.Deny)
977 }
978 // Invalid rule and unknown list both error.
979 if err := c.AddPermissionRule("deny", " "); err == nil {
980 t.Error("expected error for empty rule")
981 }
982 if err := c.AddPermissionRule("nope", "read_file"); err == nil {
983 t.Error("expected error for unknown list")
984 }
985
986 removed, err := c.RemovePermissionRule("deny", "Bash(rm -rf*)")
987 if err != nil || !removed {
988 t.Errorf("remove: removed=%v err=%v", removed, err)
989 }
990 if removed, _ := c.RemovePermissionRule("deny", "absent"); removed {
991 t.Error("removing absent rule should report false")
992 }
993 }
994
995 func TestSkillPathMutators(t *testing.T) {
996 c := Default()
997 root := t.TempDir()
998 if err := c.ExcludeSkillPath(root); err != nil {
999 t.Fatalf("exclude skill path: %v", err)
1000 }
1001 if err := c.AddSkillPath(root); err != nil {
1002 t.Fatalf("add skill path: %v", err)
1003 }
1004 if len(c.Skills.ExcludedPaths) != 0 {
1005 t.Fatalf("add skill path should restore excluded path, got %v", c.Skills.ExcludedPaths)
1006 }
1007 if err := c.AddSkillPath(filepath.Join(root, ".")); err != nil {
1008 t.Fatalf("duplicate skill path: %v", err)
1009 }
1010 if len(c.Skills.Paths) != 1 {
1011 t.Fatalf("paths = %v, want one deduped entry", c.Skills.Paths)
1012 }
1013 if err := c.AddSkillPath(" "); err == nil {
1014 t.Fatal("empty skill path should error")
1015 }
1016 removed, err := c.RemoveSkillPath(filepath.Join(root, "."))
1017 if err != nil || !removed {
1018 t.Fatalf("remove skill path: removed=%v err=%v", removed, err)
1019 }
1020 if len(c.Skills.Paths) != 0 {
1021 t.Fatalf("paths after remove = %v", c.Skills.Paths)
1022 }
1023 if removed, err := c.RemoveSkillPath(root); err != nil || removed {
1024 t.Fatalf("remove absent: removed=%v err=%v", removed, err)
1025 }
1026 if err := c.ExcludeSkillPath(filepath.Join(root, ".")); err != nil {
1027 t.Fatalf("exclude skill path: %v", err)
1028 }
1029 if err := c.ExcludeSkillPath(root); err != nil {
1030 t.Fatalf("duplicate exclude skill path: %v", err)
1031 }
1032 if len(c.Skills.ExcludedPaths) != 1 {
1033 t.Fatalf("excluded paths = %v, want one deduped entry", c.Skills.ExcludedPaths)
1034 }
1035 if err := c.ExcludeSkillPath(" "); err == nil {
1036 t.Fatal("empty excluded skill path should error")
1037 }
1038 if err := c.RestoreSkillPath(root); err != nil {
1039 t.Fatalf("restore skill path: %v", err)
1040 }
1041 if len(c.Skills.ExcludedPaths) != 0 {
1042 t.Fatalf("excluded paths after restore = %v, want empty", c.Skills.ExcludedPaths)
1043 }
1044 if err := c.RestoreSkillPath(" "); err == nil {
1045 t.Fatal("empty restored skill path should error")
1046 }
1047 }
1048
1049 func TestSkillPathEnabledMutatorPreservesConfiguredPath(t *testing.T) {
1050 c := Default()
1051 root := t.TempDir()
1052 if err := c.AddSkillPath(root); err != nil {
1053 t.Fatalf("add skill path: %v", err)
1054 }
1055 if err := c.SetSkillPathEnabled(root, false); err != nil {
1056 t.Fatalf("disable skill path: %v", err)
1057 }
1058 if len(c.Skills.Paths) != 1 || filepath.Clean(c.Skills.Paths[0]) != filepath.Clean(root) {
1059 t.Fatalf("paths after disable = %v, want %q preserved", c.Skills.Paths, root)
1060 }
1061 if len(c.Skills.ExcludedPaths) != 1 || CanonicalSkillPath(c.Skills.ExcludedPaths[0]) != CanonicalSkillPath(root) {
1062 t.Fatalf("excluded paths after disable = %v, want %q", c.Skills.ExcludedPaths, root)
1063 }
1064 if err := c.SetSkillPathEnabled(root, true); err != nil {
1065 t.Fatalf("enable skill path: %v", err)
1066 }
1067 if len(c.Skills.Paths) != 1 || len(c.Skills.ExcludedPaths) != 0 {
1068 t.Fatalf("state after enable = paths %v excluded %v", c.Skills.Paths, c.Skills.ExcludedPaths)
1069 }
1070 }
1071
1072 func TestSkillEnabledMutator(t *testing.T) {
1073 c := Default()
1074 if err := c.SetSkillEnabled("review", false); err != nil {
1075 t.Fatalf("disable skill: %v", err)
1076 }
1077 if err := c.SetSkillEnabled("review", false); err != nil {
1078 t.Fatalf("disable duplicate skill: %v", err)
1079 }
1080 if len(c.Skills.DisabledSkills) != 1 || c.Skills.DisabledSkills[0] != "review" {
1081 t.Fatalf("disabled skills = %v, want [review]", c.Skills.DisabledSkills)
1082 }
1083 if !c.IsSkillDisabled("review") {
1084 t.Fatal("review should be disabled")
1085 }
1086 if err := c.SetSkillEnabled("review", true); err != nil {
1087 t.Fatalf("enable skill: %v", err)
1088 }
1089 if len(c.Skills.DisabledSkills) != 0 {
1090 t.Fatalf("disabled skills after enable = %v, want empty", c.Skills.DisabledSkills)
1091 }
1092 if err := c.SetSkillEnabled("bad name", false); err == nil {
1093 t.Fatal("invalid skill name should error")
1094 }
1095 }
1096
1097 func TestSkillImplicitInvocationMutator(t *testing.T) {
1098 c := Default()
1099 if !c.ImplicitSkillInvocationEnabled() {
1100 t.Fatal("implicit skill invocation should be enabled by default")
1101 }
1102 c.SetSkillImplicitInvocation(false)
1103 if c.ImplicitSkillInvocationEnabled() || !c.Skills.DisableImplicitInvocation {
1104 t.Fatal("implicit skill invocation should be disabled")
1105 }
1106 c.SetSkillImplicitInvocation(true)
1107 if !c.ImplicitSkillInvocationEnabled() || c.Skills.DisableImplicitInvocation {
1108 t.Fatal("implicit skill invocation should be enabled")
1109 }
1110 }
1111
1112 func TestPluginMutators(t *testing.T) {
1113 c := Default()
1114
1115 if err := c.UpsertPlugin(PluginEntry{Name: "ex", Command: "reasonix-plugin-example"}); err != nil {
1116 t.Fatalf("add stdio: %v", err)
1117 }
1118 if err := c.UpsertPlugin(PluginEntry{Name: "stripe", Type: "http", URL: "https://mcp.stripe.com"}); err != nil {
1119 t.Fatalf("add http: %v", err)
1120 }
1121 if len(c.Plugins) != 2 {
1122 t.Fatalf("plugin count = %d, want 2", len(c.Plugins))
1123 }
1124
1125 // Transport validation: stdio needs command, http needs url.
1126 if err := c.UpsertPlugin(PluginEntry{Name: "bad"}); err == nil {
1127 t.Error("stdio without command should error")
1128 }
1129 if err := c.UpsertPlugin(PluginEntry{Name: "bad", Type: "http"}); err == nil {
1130 t.Error("http without url should error")
1131 }
1132 if err := c.UpsertPlugin(PluginEntry{Name: "bad", Type: "carrier-pigeon", Command: "x"}); err == nil {
1133 t.Error("unknown transport should error")
1134 }
1135 if err := c.UpsertPlugin(PluginEntry{Name: "bad", Command: "x", CallTimeoutSeconds: -1}); err == nil {
1136 t.Error("negative call_timeout_seconds should error")
1137 }
1138 if err := c.UpsertPlugin(PluginEntry{Name: "bad", Command: "x", StartupTimeoutSeconds: -1}); err == nil {
1139 t.Error("negative startup_timeout_seconds should error")
1140 }
1141 if err := c.UpsertPlugin(PluginEntry{Name: "bad", Command: "x", ToolTimeoutSeconds: map[string]int{"generate": -1}}); err == nil {
1142 t.Error("negative tool_timeout_seconds should error")
1143 }
1144 if err := c.UpsertPlugin(PluginEntry{Name: "bad", Command: "x", ToolTimeoutSeconds: map[string]int{" ": 1}}); err == nil {
1145 t.Error("empty tool_timeout_seconds key should error")
1146 }
1147 // Replace in place.
1148 if err := c.UpsertPlugin(PluginEntry{Name: "ex", Command: "other-cmd"}); err != nil {
1149 t.Fatalf("replace: %v", err)
1150 }
1151 if len(c.Plugins) != 2 {
1152 t.Errorf("replace grew plugins to %d", len(c.Plugins))
1153 }
1154
1155 if !c.RemovePlugin("ex") {
1156 t.Error("remove should report true")
1157 }
1158 if c.RemovePlugin("ex") {
1159 t.Error("second remove should report false")
1160 }
1161 }
1162
1163 func TestAutoStartPlugins(t *testing.T) {
1164 c := Default()
1165 off := false
1166 on := true
1167 c.Plugins = []PluginEntry{
1168 {Name: "implicit", Command: "implicit-bin"},
1169 {Name: "disabled", Command: "disabled-bin", AutoStart: &off},
1170 {Name: "enabled", Command: "enabled-bin", AutoStart: &on},
1171 }
1172 got := c.AutoStartPlugins()
1173 if len(got) != 2 || got[0].Name != "implicit" || got[1].Name != "enabled" {
1174 t.Fatalf("AutoStartPlugins = %+v, want implicit + enabled", got)
1175 }
1176 }
1177
1178 func TestPluginResolvedTierDefaultsToBackground(t *testing.T) {
1179 for _, tc := range []struct {
1180 name string
1181 tier string
1182 want string
1183 }{
1184 {name: "empty", tier: "", want: "background"},
1185 {name: "legacy lazy", tier: "lazy", want: "background"},
1186 {name: "background", tier: "background", want: "background"},
1187 {name: "eager", tier: "eager", want: "eager"},
1188 {name: "unknown", tier: "startup", want: "background"},
1189 } {
1190 t.Run(tc.name, func(t *testing.T) {
1191 got := (PluginEntry{Name: "mcp", Command: "mcp-server", Tier: tc.tier}).ResolvedTier()
1192 if got != tc.want {
1193 t.Fatalf("ResolvedTier(%q) = %q, want %q", tc.tier, got, tc.want)
1194 }
1195 })
1196 }
1197 }
1198
1199 func TestClearPluginAuthentication(t *testing.T) {
1200 c := Default()
1201 c.Plugins = []PluginEntry{{
1202 Name: "dida",
1203 Type: "http",
1204 URL: "https://mcp.dida365.com/mcp?access_token=abc&workspace=main",
1205 Headers: map[string]string{
1206 "Authorization": "Bearer ${DIDA_TOKEN}",
1207 "X-Org": "team",
1208 },
1209 Env: map[string]string{
1210 "DIDA_TOKEN": "${DIDA_TOKEN}",
1211 "DEBUG": "1",
1212 },
1213 Tier: "lazy",
1214 }}
1215 updated, changed, err := c.ClearPluginAuthentication("dida")
1216 if err != nil {
1217 t.Fatalf("ClearPluginAuthentication: %v", err)
1218 }
1219 if !changed {
1220 t.Fatal("ClearPluginAuthentication should report changed")
1221 }
1222 if updated.URL != "https://mcp.dida365.com/mcp?workspace=main" {
1223 t.Fatalf("url = %q", updated.URL)
1224 }
1225 if _, ok := updated.Headers["Authorization"]; ok {
1226 t.Fatalf("auth header should be removed: %v", updated.Headers)
1227 }
1228 if updated.Headers["X-Org"] != "team" {
1229 t.Fatalf("ordinary header should be preserved: %v", updated.Headers)
1230 }
1231 if _, ok := updated.Env["DIDA_TOKEN"]; ok {
1232 t.Fatalf("auth env should be removed: %v", updated.Env)
1233 }
1234 if updated.Env["DEBUG"] != "1" {
1235 t.Fatalf("ordinary env should be preserved: %v", updated.Env)
1236 }
1237 }
1238
1239 // TestSaveToRoundTrips stages several mutations, persists atomically, and
1240 // re-decodes the file to confirm the changes survived a write/read cycle.
1241 func TestSaveToRoundTrips(t *testing.T) {
1242 c := Default()
1243 if err := c.SetDefaultModel("deepseek-pro"); err != nil {
1244 t.Fatal(err)
1245 }
1246 if err := c.SetPlannerModel("deepseek-pro"); err != nil {
1247 t.Fatal(err)
1248 }
1249 if err := c.UpsertProvider(ProviderEntry{Name: "local", Kind: "openai", BaseURL: "http://localhost:1234/v1", Model: "llama"}); err != nil {
1250 t.Fatal(err)
1251 }
1252 if err := c.SetPermissionMode("deny"); err != nil {
1253 t.Fatal(err)
1254 }
1255 if err := c.AddPermissionRule("allow", "Bash(go test:*)"); err != nil {
1256 t.Fatal(err)
1257 }
1258 if err := c.SetNetwork(NetworkConfig{
1259 ProxyMode: "custom",
1260 Proxy: NetworkProxyConfig{
1261 Type: "socks5",
1262 Server: "127.0.0.1",
1263 Port: 7890,
1264 },
1265 }); err != nil {
1266 t.Fatal(err)
1267 }
1268 autoStart := false
1269 if err := c.UpsertPlugin(PluginEntry{Name: "stripe", Type: "http", URL: "https://mcp.stripe.com", AutoStart: &autoStart}); err != nil {
1270 t.Fatal(err)
1271 }
1272
1273 path := filepath.Join(t.TempDir(), "nested", "reasonix.toml")
1274 if err := c.SaveTo(path); err != nil {
1275 t.Fatalf("SaveTo: %v", err)
1276 }
1277
1278 var got Config
1279 if _, err := toml.DecodeFile(path, &got); err != nil {
1280 t.Fatalf("saved file does not parse: %v", err)
1281 }
1282 if got.DefaultModel != "deepseek-pro" {
1283 t.Errorf("default_model = %q", got.DefaultModel)
1284 }
1285 if got.Agent.PlannerModel != "deepseek-pro" {
1286 t.Errorf("planner_model = %q", got.Agent.PlannerModel)
1287 }
1288 if _, ok := got.Provider("local"); !ok {
1289 t.Error("added provider 'local' missing after round-trip")
1290 }
1291 if got.Permissions.Mode != "deny" {
1292 t.Errorf("mode = %q", got.Permissions.Mode)
1293 }
1294 if len(got.Permissions.Allow) != 1 || got.Permissions.Allow[0] != "Bash(go test:*)" {
1295 t.Errorf("allow list = %v", got.Permissions.Allow)
1296 }
1297 if got.Network.ProxyMode != "custom" || got.Network.Proxy.Server != "127.0.0.1" || got.Network.Proxy.Port != 7890 {
1298 t.Errorf("network = %+v", got.Network)
1299 }
1300 if len(got.Plugins) != 1 || got.Plugins[0].Name != "stripe" {
1301 t.Errorf("plugins = %+v", got.Plugins)
1302 }
1303 if got.Plugins[0].AutoStart == nil || *got.Plugins[0].AutoStart {
1304 t.Errorf("auto_start should round-trip false, got %+v", got.Plugins[0].AutoStart)
1305 }
1306 }
1307
1308 func TestRetiredRecoveryReviewerSettingsAreNotWrittenOnSave(t *testing.T) {
1309 isolateUserConfigHome(t)
1310 c := Default()
1311 c.Agent.RecoveryModel = "deepseek-pro"
1312 c.Agent.RecoveryTemperature = 0.25
1313
1314 path := UserConfigPath()
1315 if err := c.SaveTo(path); err != nil {
1316 t.Fatalf("SaveTo: %v", err)
1317 }
1318 got := LoadForEdit(path)
1319 if got.Agent.RecoveryModel != "" || got.Agent.RecoveryTemperature != 0 {
1320 t.Fatalf("retired recovery settings survived save: %+v", got.Agent)
1321 }
1322 }
1323
1324 func TestRetiredAutoGuardKeysAreIgnoredAndRemovedOnSave(t *testing.T) {
1325 path := filepath.Join(t.TempDir(), "reasonix.toml")
1326 if err := os.WriteFile(path, []byte("[desktop]\ndefault_auto_recovery_checkpoint = false\n\n[agent]\nauto_recovery_checkpoint = \"off\"\nrecovery_model = \"deepseek-pro\"\n"), 0o600); err != nil {
1327 t.Fatal(err)
1328 }
1329 c := LoadForEdit(path)
1330 if c.Agent.RecoveryModel != "deepseek-pro" {
1331 t.Fatalf("unrelated recovery model was not loaded: %+v", c.Agent)
1332 }
1333 if err := c.SaveTo(path); err != nil {
1334 t.Fatal(err)
1335 }
1336 raw, err := os.ReadFile(path)
1337 if err != nil {
1338 t.Fatal(err)
1339 }
1340 text := string(raw)
1341 if strings.Contains(text, "default_auto_recovery_checkpoint") || strings.Contains(text, "auto_recovery_checkpoint") {
1342 t.Fatalf("retired Auto Guard keys survived save:\n%s", text)
1343 }
1344 if strings.Contains(text, "recovery_model") {
1345 t.Fatalf("retired recovery model survived save:\n%s", text)
1346 }
1347 }
1348
1349 func TestSaveToScopesUserAndProjectFiles(t *testing.T) {
1350 home := isolateUserConfigHome(t)
1351 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
1352 c := Default()
1353 c.Desktop.Theme = "dark"
1354 c.Desktop.ThemeStyle = "graphite"
1355 c.Desktop.CloseBehavior = "background"
1356
1357 userPath := UserConfigPath()
1358 requireTestPathWithin(t, home, userPath)
1359 if err := c.SaveTo(userPath); err != nil {
1360 t.Fatalf("SaveTo user config: %v", err)
1361 }
1362 userBody, err := os.ReadFile(userPath)
1363 if err != nil {
1364 t.Fatalf("read user config: %v", err)
1365 }
1366 if !strings.Contains(string(userBody), "[desktop]") {
1367 t.Fatalf("user config should include desktop preferences:\n%s", userBody)
1368 }
1369 if info, err := os.Stat(userPath); err != nil {
1370 t.Fatalf("stat user config: %v", err)
1371 } else if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
1372 t.Fatalf("user config mode = %o, want 600", info.Mode().Perm())
1373 }
1374
1375 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
1376 if err := c.SaveTo(projectPath); err != nil {
1377 t.Fatalf("SaveTo project config: %v", err)
1378 }
1379 projectBody, err := os.ReadFile(projectPath)
1380 if err != nil {
1381 t.Fatalf("read project config: %v", err)
1382 }
1383 if strings.Contains(string(projectBody), "[desktop]") ||
1384 strings.Contains(string(projectBody), "close_behavior") ||
1385 strings.Contains(string(projectBody), "default_tool_approval_mode") {
1386 t.Fatalf("project config should not include desktop preferences:\n%s", projectBody)
1387 }
1388 if info, err := os.Stat(projectPath); err != nil {
1389 t.Fatalf("stat project config: %v", err)
1390 } else if runtime.GOOS != "windows" && info.Mode().Perm() != 0o644 {
1391 t.Fatalf("project config mode = %o, want 644", info.Mode().Perm())
1392 }
1393 }
1394
1395 func TestLoadForRootKeepsOfficialProviderAliasesDistinct(t *testing.T) {
1396 isolateUserConfigHome(t)
1397 root := t.TempDir()
1398 userPath := UserConfigPath()
1399 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
1400 t.Fatal(err)
1401 }
1402 if err := os.WriteFile(userPath, []byte(`
1403 config_version = 2
1404 default_model = "deepseek/deepseek-v4-flash"
1405
1406 [desktop]
1407 provider_access = ["deepseek"]
1408
1409 [[providers]]
1410 name = "deepseek"
1411 kind = "openai"
1412 base_url = "https://api.deepseek.com"
1413 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
1414 default = "deepseek-v4-flash"
1415 api_key_env = "USER_DEEPSEEK_KEY"
1416 `), 0o644); err != nil {
1417 t.Fatal(err)
1418 }
1419 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(`
1420 [[providers]]
1421 name = "deepseek-flash"
1422 kind = "openai"
1423 base_url = "https://api.deepseek.com"
1424 model = "deepseek-v4-flash"
1425 api_key_env = "PROJECT_DEEPSEEK_KEY"
1426 effort = "max"
1427 `), 0o644); err != nil {
1428 t.Fatal(err)
1429 }
1430
1431 cfg, err := LoadForRoot(root)
1432 if err != nil {
1433 t.Fatalf("LoadForRoot: %v", err)
1434 }
1435 userProvider, ok := cfg.Provider("deepseek")
1436 if !ok {
1437 t.Fatalf("user deepseek provider missing: %+v", cfg.Providers)
1438 }
1439 if userProvider.APIKeyEnv != "USER_DEEPSEEK_KEY" {
1440 t.Fatalf("deepseek provider = %+v, want user provider preserved", userProvider)
1441 }
1442 projectProvider, ok := cfg.Provider("deepseek-flash")
1443 if !ok {
1444 t.Fatalf("project deepseek-flash provider missing: %+v", cfg.Providers)
1445 }
1446 if projectProvider.APIKeyEnv != "PROJECT_DEEPSEEK_KEY" || projectProvider.Effort != "max" {
1447 t.Fatalf("deepseek-flash provider = %+v, want project provider preserved", projectProvider)
1448 }
1449 }
1450
1451 func TestLoadForRootKeepsUserProviderOverSameNamedProjectProvider(t *testing.T) {
1452 isolateUserConfigHome(t)
1453 root := t.TempDir()
1454 userPath := UserConfigPath()
1455 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
1456 t.Fatal(err)
1457 }
1458 if err := os.WriteFile(userPath, []byte(`
1459 [[providers]]
1460 name = "shared"
1461 kind = "openai"
1462 base_url = "https://global.example/v1"
1463 model = "global-model"
1464 api_key_env = "GLOBAL_SHARED_KEY"
1465 `), 0o644); err != nil {
1466 t.Fatal(err)
1467 }
1468 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(`
1469 [[providers]]
1470 name = "shared"
1471 kind = "openai"
1472 base_url = "https://project.example/v1"
1473 model = "project-model"
1474 api_key_env = "PROJECT_SHARED_KEY"
1475
1476 [[providers]]
1477 name = "project-only"
1478 kind = "openai"
1479 base_url = "https://project.example/v1"
1480 model = "project-only-model"
1481 api_key_env = "PROJECT_ONLY_KEY"
1482 `), 0o644); err != nil {
1483 t.Fatal(err)
1484 }
1485
1486 cfg, err := LoadForRoot(root)
1487 if err != nil {
1488 t.Fatalf("LoadForRoot: %v", err)
1489 }
1490 shared, ok := cfg.Provider("shared")
1491 if !ok {
1492 t.Fatalf("shared provider missing: %+v", cfg.Providers)
1493 }
1494 if shared.BaseURL != "https://global.example/v1" || shared.APIKeyEnv != "GLOBAL_SHARED_KEY" || shared.Model != "global-model" {
1495 t.Fatalf("shared provider = %+v, want global provider to win over project provider", shared)
1496 }
1497 if _, ok := cfg.Provider("project-only"); !ok {
1498 t.Fatalf("project-only provider missing: %+v", cfg.Providers)
1499 }
1500 }
1501
1502 func TestMigrateDeprecatedAgentStepLimitsForRootRunsOnce(t *testing.T) {
1503 isolateUserConfigHome(t)
1504 root := t.TempDir()
1505 userPath := UserConfigPath()
1506 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
1507 t.Fatal(err)
1508 }
1509 if err := os.WriteFile(userPath, []byte(`
1510 [agent]
1511 max_steps = 17
1512 planner_max_steps = 9
1513 temperature = 0.4
1514
1515 [bot]
1516 max_steps = 21
1517 `), 0o644); err != nil {
1518 t.Fatal(err)
1519 }
1520 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(`
1521 default_model = "deepseek-pro"
1522
1523 [agent]
1524 max_steps = 3
1525 planner_max_steps = 4
1526 temperature = 0.8
1527 `), 0o644); err != nil {
1528 t.Fatal(err)
1529 }
1530
1531 changed, err := MigrateLegacyAgentStepLimitsForRoot(root)
1532 if err != nil {
1533 t.Fatalf("MigrateLegacyAgentStepLimitsForRoot: %v", err)
1534 }
1535 if !changed {
1536 t.Fatal("first migration should remove deprecated step-limit keys")
1537 }
1538
1539 cfg, err := LoadForRoot(root)
1540 if err != nil {
1541 t.Fatalf("LoadForRoot: %v", err)
1542 }
1543 if cfg.Agent.MaxSteps != 0 || cfg.Agent.PlannerMaxSteps != 0 {
1544 t.Fatalf("deprecated agent steps = max:%d planner:%d, want automatic 0/0", cfg.Agent.MaxSteps, cfg.Agent.PlannerMaxSteps)
1545 }
1546 if cfg.IgnoredLegacyAgentStepLimits() {
1547 t.Fatal("migrated config should no longer report legacy step limits")
1548 }
1549 if cfg.Agent.Temperature != 0.8 {
1550 t.Fatalf("agent temperature = %v, want project override to keep working for other agent settings", cfg.Agent.Temperature)
1551 }
1552 if cfg.DefaultModel != "deepseek-pro" {
1553 t.Fatalf("default_model = %q, want project config to keep overriding unrelated fields", cfg.DefaultModel)
1554 }
1555 if cfg.Bot.MaxSteps != 21 {
1556 t.Fatalf("bot.max_steps = %d, want independent bot limit preserved", cfg.Bot.MaxSteps)
1557 }
1558 for _, path := range []string{userPath, filepath.Join(root, "reasonix.toml")} {
1559 raw, err := os.ReadFile(path)
1560 if err != nil {
1561 t.Fatal(err)
1562 }
1563 if _, changed := stripLegacyAgentStepLimitLines(string(raw)); changed {
1564 t.Fatalf("runtime migration left deprecated [agent] step limits in %s:\n%s", path, raw)
1565 }
1566 }
1567 userRaw, err := os.ReadFile(userPath)
1568 if err != nil {
1569 t.Fatal(err)
1570 }
1571 if !strings.Contains(string(userRaw), "[bot]\nmax_steps = 21") {
1572 t.Fatalf("migration removed independent bot.max_steps:\n%s", userRaw)
1573 }
1574
1575 again, err := MigrateLegacyAgentStepLimitsForRoot(root)
1576 if err != nil {
1577 t.Fatalf("second migration: %v", err)
1578 }
1579 if again {
1580 t.Fatal("migration notice should be one-shot after deprecated keys are removed")
1581 }
1582 }
1583
1584 func TestMigrateLegacyRedactToolOutputForRoot(t *testing.T) {
1585 isolateUserConfigHome(t)
1586 root := t.TempDir()
1587 userPath := UserConfigPath()
1588 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
1589 t.Fatal(err)
1590 }
1591 if err := os.WriteFile(userPath, []byte(`[secrets]
1592 redact_tool_output = true
1593 filter_subprocess_env = true
1594 `), 0o644); err != nil {
1595 t.Fatal(err)
1596 }
1597 projectPath := filepath.Join(root, "reasonix.toml")
1598 if err := os.WriteFile(projectPath, []byte(`[secrets]
1599 redact_tool_output = false
1600 protect_sensitive_files = true
1601 `), 0o644); err != nil {
1602 t.Fatal(err)
1603 }
1604
1605 changed, err := MigrateLegacyRedactToolOutputForRoot(root)
1606 if err != nil {
1607 t.Fatalf("MigrateLegacyRedactToolOutputForRoot: %v", err)
1608 }
1609 if !changed {
1610 t.Fatal("first migration should remove deprecated redact_tool_output keys")
1611 }
1612 for _, path := range []string{userPath, projectPath} {
1613 raw, err := os.ReadFile(path)
1614 if err != nil {
1615 t.Fatal(err)
1616 }
1617 if strings.Contains(string(raw), "redact_tool_output") {
1618 t.Fatalf("deprecated redact_tool_output remains in %s:\n%s", path, raw)
1619 }
1620 }
1621 userRaw, err := os.ReadFile(userPath)
1622 if err != nil {
1623 t.Fatal(err)
1624 }
1625 if !strings.Contains(string(userRaw), "filter_subprocess_env = true") {
1626 t.Fatalf("migration removed an active secrets setting:\n%s", userRaw)
1627 }
1628 projectRaw, err := os.ReadFile(projectPath)
1629 if err != nil {
1630 t.Fatal(err)
1631 }
1632 if !strings.Contains(string(projectRaw), "protect_sensitive_files = true") {
1633 t.Fatalf("migration removed an unrelated project setting:\n%s", projectRaw)
1634 }
1635
1636 again, err := MigrateLegacyRedactToolOutputForRoot(root)
1637 if err != nil {
1638 t.Fatalf("second migration: %v", err)
1639 }
1640 if again {
1641 t.Fatal("migration should be a no-op after deprecated keys are removed")
1642 }
1643 }
1644
1645 func TestMigrateLegacyMemoryCompilerForRoot(t *testing.T) {
1646 isolateUserConfigHome(t)
1647 root := t.TempDir()
1648 userPath := UserConfigPath()
1649 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
1650 t.Fatal(err)
1651 }
1652 if err := os.WriteFile(userPath, []byte(`[agent]
1653 memory_compiler = { enabled = true, verbosity = "compact" }
1654 temperature = 0.4
1655 `), 0o644); err != nil {
1656 t.Fatal(err)
1657 }
1658 projectPath := filepath.Join(root, "reasonix.toml")
1659 if err := os.WriteFile(projectPath, []byte(`[agent]
1660 memory_compiler = { enabled = false }
1661 reasoning_language = "zh"
1662 `), 0o644); err != nil {
1663 t.Fatal(err)
1664 }
1665
1666 changed, err := MigrateLegacyMemoryCompilerForRoot(root)
1667 if err != nil {
1668 t.Fatalf("MigrateLegacyMemoryCompilerForRoot: %v", err)
1669 }
1670 if !changed {
1671 t.Fatal("first migration should remove deprecated memory_compiler keys")
1672 }
1673 for _, path := range []string{userPath, projectPath} {
1674 raw, err := os.ReadFile(path)
1675 if err != nil {
1676 t.Fatal(err)
1677 }
1678 if strings.Contains(string(raw), "memory_compiler") {
1679 t.Fatalf("deprecated memory_compiler remains in %s:\n%s", path, raw)
1680 }
1681 }
1682 userRaw, err := os.ReadFile(userPath)
1683 if err != nil {
1684 t.Fatal(err)
1685 }
1686 if !strings.Contains(string(userRaw), "temperature = 0.4") {
1687 t.Fatalf("migration removed an active agent setting:\n%s", userRaw)
1688 }
1689 projectRaw, err := os.ReadFile(projectPath)
1690 if err != nil {
1691 t.Fatal(err)
1692 }
1693 if !strings.Contains(string(projectRaw), `reasoning_language = "zh"`) {
1694 t.Fatalf("migration removed an unrelated project setting:\n%s", projectRaw)
1695 }
1696
1697 again, err := MigrateLegacyMemoryCompilerForRoot(root)
1698 if err != nil {
1699 t.Fatalf("second migration: %v", err)
1700 }
1701 if again {
1702 t.Fatal("migration should be a no-op after deprecated keys are removed")
1703 }
1704 }
1705
1706 func TestRetiredConfigMigrationRequiresConfigFileLock(t *testing.T) {
1707 path := filepath.Join(t.TempDir(), "config.toml")
1708 const original = "[agent]\nmemory_compiler = \"compact\"\n"
1709 if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
1710 t.Fatal(err)
1711 }
1712 release, err := acquireConfigFileEditLockWithTimeout(path, time.Second)
1713 if err != nil {
1714 t.Fatalf("hold config file lock: %v", err)
1715 }
1716 defer release()
1717
1718 previousTimeout := configEditLockTimeout
1719 configEditLockTimeout = 30 * time.Millisecond
1720 t.Cleanup(func() { configEditLockTimeout = previousTimeout })
1721
1722 changed, err := migrateLegacyMemoryCompilerFile(path)
1723 if err == nil || changed {
1724 t.Fatalf("migration while file lock held = (%v, %v), want unchanged lock error", changed, err)
1725 }
1726 got, readErr := os.ReadFile(path)
1727 if readErr != nil {
1728 t.Fatal(readErr)
1729 }
1730 if string(got) != original {
1731 t.Fatalf("blocked migration changed config:\n%s", got)
1732 }
1733 }
1734
1735 func TestLegacyMCPTierMigrationRequiresConfigFileLock(t *testing.T) {
1736 path := filepath.Join(t.TempDir(), "config.toml")
1737 const original = "[[plugins]]\nname = \"playwright\"\ntier = \"lazy\"\n"
1738 if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
1739 t.Fatal(err)
1740 }
1741 release, err := acquireConfigFileEditLockWithTimeout(path, time.Second)
1742 if err != nil {
1743 t.Fatalf("hold config file lock: %v", err)
1744 }
1745 defer release()
1746
1747 previousTimeout := configEditLockTimeout
1748 configEditLockTimeout = 30 * time.Millisecond
1749 t.Cleanup(func() { configEditLockTimeout = previousTimeout })
1750
1751 err = migrateLegacyMCPTiersFile(path)
1752 if err == nil {
1753 t.Fatal("migration succeeded while another process-equivalent config transaction held the file lock")
1754 }
1755 got, readErr := os.ReadFile(path)
1756 if readErr != nil {
1757 t.Fatal(readErr)
1758 }
1759 if string(got) != original {
1760 t.Fatalf("blocked migration changed config:\n%s", got)
1761 }
1762 }
1763
1764 // TestMigrateLegacyMemoryCompilerKeepsMultilineSystemPrompt reproduces the
1765 // review finding: a multiline system_prompt quoting a `memory_compiler = ...`
1766 // example line must survive the retired-key migration byte-for-byte.
1767 func TestMigrateLegacyMemoryCompilerKeepsMultilineSystemPrompt(t *testing.T) {
1768 isolateUserConfigHome(t)
1769 root := t.TempDir()
1770 userPath := UserConfigPath()
1771 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
1772 t.Fatal(err)
1773 }
1774 original := `[agent]
1775 system_prompt = """
1776 You are Reasonix. Historical config example:
1777 memory_compiler = { enabled = true, verbosity = "compact" }
1778 Keep answers short.
1779 """
1780 temperature = 0.2
1781 `
1782 if err := os.WriteFile(userPath, []byte(original), 0o644); err != nil {
1783 t.Fatal(err)
1784 }
1785
1786 changed, err := MigrateLegacyMemoryCompilerForRoot(root)
1787 if err != nil {
1788 t.Fatalf("MigrateLegacyMemoryCompilerForRoot: %v", err)
1789 }
1790 if changed {
1791 t.Fatal("migration must not rewrite a config whose only memory_compiler text lives inside a multiline string")
1792 }
1793 raw, err := os.ReadFile(userPath)
1794 if err != nil {
1795 t.Fatal(err)
1796 }
1797 if string(raw) != original {
1798 t.Fatalf("multiline system_prompt was modified:\n--- got ---\n%s\n--- want ---\n%s", raw, original)
1799 }
1800 }
1801
1802 // TestStripTOMLKeyLinesPreservesMultilineStrings pins the shared stripper used
1803 // by every retired-config-key migration: lines inside TOML multiline strings
1804 // are never treated as section headers or key assignments, while real retired
1805 // keys outside strings are still removed.
1806 func TestStripTOMLKeyLinesPreservesMultilineStrings(t *testing.T) {
1807 cases := []struct {
1808 name string
1809 raw string
1810 section string
1811 keys []string
1812 wantChanged bool
1813 wantSame bool // raw must round-trip unchanged
1814 wantKept string // substring that must survive
1815 wantGone string // substring that must be removed
1816 }{
1817 {
1818 name: "multiline basic string keeps quoted example",
1819 raw: "[agent]\nsystem_prompt = \"\"\"\nmemory_compiler = { enabled = true }\n\"\"\"\n",
1820 section: "agent", keys: []string{"memory_compiler"},
1821 wantChanged: false, wantSame: true,
1822 },
1823 {
1824 name: "multiline literal string keeps quoted example",
1825 raw: "[agent]\nsystem_prompt = '''\nmemory_compiler = { enabled = true }\n'''\n",
1826 section: "agent", keys: []string{"memory_compiler"},
1827 wantChanged: false, wantSame: true,
1828 },
1829 {
1830 name: "section header inside multiline string does not switch sections",
1831 raw: "[agent]\nsystem_prompt = \"\"\"\n[secrets]\nredact_tool_output = true\n\"\"\"\n",
1832 section: "secrets", keys: []string{"redact_tool_output"},
1833 wantChanged: false, wantSame: true,
1834 },
1835 {
1836 name: "real key next to a multiline string is still removed",
1837 raw: "[agent]\nsystem_prompt = \"\"\"\nmemory_compiler = { enabled = true }\n\"\"\"\nmemory_compiler = { enabled = true, verbosity = \"compact\" }\n",
1838 section: "agent", keys: []string{"memory_compiler"},
1839 wantChanged: true,
1840 wantKept: "system_prompt = \"\"\"\nmemory_compiler = { enabled = true }\n\"\"\"",
1841 wantGone: "verbosity",
1842 },
1843 {
1844 name: "single-line triple-quoted value does not open a multiline state",
1845 raw: "[agent]\nsystem_prompt = \"\"\"one line\"\"\"\nmax_steps = 40\n",
1846 section: "agent", keys: []string{"max_steps", "planner_max_steps"},
1847 wantChanged: true,
1848 wantKept: "system_prompt = \"\"\"one line\"\"\"",
1849 wantGone: "max_steps",
1850 },
1851 {
1852 name: "comment containing triple quotes does not open a multiline state",
1853 raw: "[plugins]\n# docs say \"\"\" starts a multiline string\ntier = 2\n",
1854 section: "plugins", keys: []string{"tier"},
1855 wantChanged: true,
1856 wantKept: "# docs say \"\"\" starts a multiline string",
1857 wantGone: "tier = 2",
1858 },
1859 }
1860 for _, tc := range cases {
1861 t.Run(tc.name, func(t *testing.T) {
1862 got, changed := stripTOMLKeyLines(tc.raw, tc.section, tc.keys...)
1863 if changed != tc.wantChanged {
1864 t.Fatalf("changed = %v, want %v\n--- got ---\n%s", changed, tc.wantChanged, got)
1865 }
1866 if tc.wantSame && got != tc.raw {
1867 t.Fatalf("content was modified:\n--- got ---\n%s\n--- want ---\n%s", got, tc.raw)
1868 }
1869 if tc.wantKept != "" && !strings.Contains(got, tc.wantKept) {
1870 t.Fatalf("expected content was removed:\n--- got ---\n%s\n--- want kept ---\n%s", got, tc.wantKept)
1871 }
1872 if tc.wantGone != "" && strings.Contains(got, tc.wantGone) {
1873 t.Fatalf("retired key survived:\n--- got ---\n%s\n--- want gone ---\n%s", got, tc.wantGone)
1874 }
1875 })
1876 }
1877 }
1878
1879 func TestLoadForRootReadOnlyIgnoresDeprecatedAgentStepLimitsWithoutRewriting(t *testing.T) {
1880 isolateUserConfigHome(t)
1881 root := t.TempDir()
1882 path := filepath.Join(root, "reasonix.toml")
1883 original := []byte(`
1884 [agent]
1885 max_steps = 3
1886 planner_max_steps = 4
1887 `)
1888 if err := os.WriteFile(path, original, 0o644); err != nil {
1889 t.Fatal(err)
1890 }
1891
1892 cfg, err := LoadForRootReadOnly(root)
1893 if err != nil {
1894 t.Fatalf("LoadForRootReadOnly: %v", err)
1895 }
1896 if cfg.Agent.MaxSteps != 0 || cfg.Agent.PlannerMaxSteps != 0 {
1897 t.Fatalf("deprecated steps = max:%d planner:%d, want automatic 0/0", cfg.Agent.MaxSteps, cfg.Agent.PlannerMaxSteps)
1898 }
1899 if !cfg.IgnoredLegacyAgentStepLimits() {
1900 t.Fatal("read-only load should report ignored deprecated step limits")
1901 }
1902 raw, err := os.ReadFile(path)
1903 if err != nil {
1904 t.Fatal(err)
1905 }
1906 if !bytes.Equal(raw, original) {
1907 t.Fatalf("read-only load rewrote config:\n%s", raw)
1908 }
1909 }
1910
1911 func TestSaveForRootPreservesShadowedProjectProvider(t *testing.T) {
1912 isolateUserConfigHome(t)
1913 root := t.TempDir()
1914 userPath := UserConfigPath()
1915 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
1916 t.Fatal(err)
1917 }
1918 if err := os.WriteFile(userPath, []byte(`
1919 [[providers]]
1920 name = "shared"
1921 kind = "openai"
1922 base_url = "https://global.example/v1"
1923 model = "global-model"
1924 api_key_env = "GLOBAL_SHARED_KEY"
1925 `), 0o644); err != nil {
1926 t.Fatal(err)
1927 }
1928 projectPath := filepath.Join(root, "reasonix.toml")
1929 if err := os.WriteFile(projectPath, []byte(`
1930 [[providers]]
1931 name = "shared"
1932 kind = "openai"
1933 base_url = "https://project.example/v1"
1934 model = "project-model"
1935 api_key_env = "PROJECT_SHARED_KEY"
1936 `), 0o644); err != nil {
1937 t.Fatal(err)
1938 }
1939
1940 cfg, err := LoadForRoot(root)
1941 if err != nil {
1942 t.Fatalf("LoadForRoot: %v", err)
1943 }
1944 if err := cfg.SaveForRoot(root); err != nil {
1945 t.Fatalf("SaveForRoot: %v", err)
1946 }
1947 var saved Config
1948 if _, err := toml.DecodeFile(projectPath, &saved); err != nil {
1949 t.Fatalf("saved project config does not parse: %v", err)
1950 }
1951 shared, ok := saved.Provider("shared")
1952 if !ok {
1953 t.Fatalf("saved project provider missing: %+v", saved.Providers)
1954 }
1955 if shared.BaseURL != "https://project.example/v1" || shared.APIKeyEnv != "PROJECT_SHARED_KEY" {
1956 t.Fatalf("saved provider = %+v, want original project provider", shared)
1957 }
1958 }
1959
1960 func TestSaveForRootDoesNotWriteUserProvidersIntoProjectConfig(t *testing.T) {
1961 isolateUserConfigHome(t)
1962 root := t.TempDir()
1963 userPath := UserConfigPath()
1964 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
1965 t.Fatal(err)
1966 }
1967 if err := os.WriteFile(userPath, []byte(`
1968 config_version = 2
1969
1970 [[providers]]
1971 name = "global"
1972 kind = "openai"
1973 base_url = "https://global.example/v1"
1974 model = "global-model"
1975 api_key_env = "GLOBAL_KEY"
1976 `), 0o644); err != nil {
1977 t.Fatal(err)
1978 }
1979 projectPath := filepath.Join(root, "reasonix.toml")
1980 if err := os.WriteFile(projectPath, []byte(`
1981 config_version = 2
1982 default_model = "project-local/project-model"
1983
1984 [[providers]]
1985 name = "project-local"
1986 kind = "openai"
1987 base_url = "https://project.example/v1"
1988 model = "project-model"
1989 api_key_env = "PROJECT_KEY"
1990 `), 0o644); err != nil {
1991 t.Fatal(err)
1992 }
1993
1994 cfg, err := LoadForRoot(root)
1995 if err != nil {
1996 t.Fatalf("LoadForRoot: %v", err)
1997 }
1998 if _, ok := cfg.Provider("global"); !ok {
1999 t.Fatal("runtime config should include user provider before saving")
2000 }
2001 if _, ok := cfg.Provider("project-local"); !ok {
2002 t.Fatal("runtime config should include project provider before saving")
2003 }
2004 if err := cfg.SaveForRoot(root); err != nil {
2005 t.Fatalf("SaveForRoot: %v", err)
2006 }
2007
2008 var got Config
2009 if _, err := toml.DecodeFile(projectPath, &got); err != nil {
2010 t.Fatalf("saved project config does not parse: %v", err)
2011 }
2012 if _, ok := got.Provider("global"); ok {
2013 t.Fatalf("user provider leaked into project config: %+v", got.Providers)
2014 }
2015 if _, ok := got.Provider("project-local"); !ok {
2016 t.Fatalf("project provider missing after save: %+v", got.Providers)
2017 }
2018 }
2019
2020 func TestSaveToExistingProjectPersistsTopLevelDelta(t *testing.T) {
2021 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2022 if err := os.WriteFile(projectPath, []byte("[permissions]\nallow = [\"Bash(go test:*)\"]\n"), 0o644); err != nil {
2023 t.Fatal(err)
2024 }
2025 cfg := Default()
2026 cfg.ConfigVersion = 2
2027 if err := cfg.SetDefaultModel("deepseek-pro"); err != nil {
2028 t.Fatal(err)
2029 }
2030 if err := cfg.SaveTo(projectPath); err != nil {
2031 t.Fatalf("SaveTo: %v", err)
2032 }
2033 body, err := os.ReadFile(projectPath)
2034 if err != nil {
2035 t.Fatalf("read project config: %v", err)
2036 }
2037 if !strings.Contains(string(body), `default_model = "deepseek-pro"`) {
2038 t.Fatalf("project config dropped top-level default_model delta:\n%s", body)
2039 }
2040 if !strings.Contains(string(body), "config_version = 2") {
2041 t.Fatalf("project config dropped top-level config_version delta:\n%s", body)
2042 }
2043 var got Config
2044 if _, err := toml.DecodeFile(projectPath, &got); err != nil {
2045 t.Fatalf("saved project config does not parse: %v", err)
2046 }
2047 if got.DefaultModel != "deepseek-pro" {
2048 t.Fatalf("default_model = %q, want deepseek-pro", got.DefaultModel)
2049 }
2050 if got.ConfigVersion != 2 {
2051 t.Fatalf("config_version = %d, want 2", got.ConfigVersion)
2052 }
2053 }
2054
2055 func TestSaveToExistingProjectRemovesResetSkillOverrides(t *testing.T) {
2056 tests := []struct {
2057 name string
2058 key string
2059 set func(*Config)
2060 reset func(*Config)
2061 }{
2062 {name: "paths", key: "paths", set: func(c *Config) { c.Skills.Paths = []string{"project-skills"} }, reset: func(c *Config) { c.Skills.Paths = nil }},
2063 {name: "excluded paths", key: "excluded_paths", set: func(c *Config) { c.Skills.ExcludedPaths = []string{"project-skills"} }, reset: func(c *Config) { c.Skills.ExcludedPaths = nil }},
2064 {name: "disabled skills", key: "disabled_skills", set: func(c *Config) { c.Skills.DisabledSkills = []string{"review"} }, reset: func(c *Config) { c.Skills.DisabledSkills = nil }},
2065 {name: "implicit invocation", key: "disable_implicit_invocation", set: func(c *Config) { c.Skills.DisableImplicitInvocation = true }, reset: func(c *Config) { c.Skills.DisableImplicitInvocation = false }},
2066 {name: "max depth", key: "max_depth", set: func(c *Config) { c.Skills.MaxDepth = 2 }, reset: func(c *Config) { c.Skills.MaxDepth = 0 }},
2067 }
2068 for _, tt := range tests {
2069 t.Run(tt.name, func(t *testing.T) {
2070 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2071 cfg := Default()
2072 tt.set(cfg)
2073 if err := cfg.SaveTo(projectPath); err != nil {
2074 t.Fatalf("initial SaveTo: %v", err)
2075 }
2076 loaded, err := LoadForEditReadOnlyStrict(projectPath)
2077 if err != nil {
2078 t.Fatalf("load project config: %v", err)
2079 }
2080 tt.reset(loaded)
2081 if err := loaded.SaveTo(projectPath); err != nil {
2082 t.Fatalf("reset SaveTo: %v", err)
2083 }
2084 body, err := os.ReadFile(projectPath)
2085 if err != nil {
2086 t.Fatal(err)
2087 }
2088 if strings.Contains(string(body), tt.key+" =") {
2089 t.Fatalf("reset left stale %s override:\n%s", tt.key, body)
2090 }
2091 fresh, err := LoadForEditReadOnlyStrict(projectPath)
2092 if err != nil {
2093 t.Fatalf("reload reset project config: %v", err)
2094 }
2095 if fresh.Skills.Paths != nil || fresh.Skills.ExcludedPaths != nil || fresh.Skills.DisabledSkills != nil || fresh.Skills.DisableImplicitInvocation || fresh.Skills.MaxDepth != 0 {
2096 t.Fatalf("reloaded skills retained reset override: %+v", fresh.Skills)
2097 }
2098 })
2099 }
2100 }
2101
2102 func TestSaveToExistingProjectPreservesExplicitSkillDefaults(t *testing.T) {
2103 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2104 if err := os.WriteFile(projectPath, []byte("[skills]\npaths = [\"project-skills\"]\n"), 0o644); err != nil {
2105 t.Fatal(err)
2106 }
2107 cfg, err := LoadForEditReadOnlyStrict(projectPath)
2108 if err != nil {
2109 t.Fatalf("load project config: %v", err)
2110 }
2111 cfg.Skills.Paths = nil
2112 cfg.Skills.ExcludedPaths = nil
2113 cfg.Skills.DisabledSkills = nil
2114 cfg.Skills.DisableImplicitInvocation = false
2115 cfg.Skills.MaxDepth = 0
2116 for _, key := range projectSkillKeys {
2117 if err := cfg.KeepProjectSkillKey(key); err != nil {
2118 t.Fatalf("keep %s: %v", key, err)
2119 }
2120 }
2121 if err := cfg.SaveTo(projectPath); err != nil {
2122 t.Fatalf("save explicit project defaults: %v", err)
2123 }
2124 body, err := os.ReadFile(projectPath)
2125 if err != nil {
2126 t.Fatal(err)
2127 }
2128 text := string(body)
2129 for _, want := range []string{
2130 "paths = []",
2131 "excluded_paths = []",
2132 "disabled_skills = []",
2133 "disable_implicit_invocation = false",
2134 "max_depth = 0",
2135 } {
2136 if !strings.Contains(text, want) {
2137 t.Fatalf("explicit project default %q missing from:\n%s", want, text)
2138 }
2139 }
2140 }
2141
2142 func TestUnrelatedProjectSavePreservesExplicitDefaultSkillOverride(t *testing.T) {
2143 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2144 if err := os.WriteFile(projectPath, []byte("[skills]\ndisable_implicit_invocation = false\n\n[permissions]\nmode = \"ask\"\n"), 0o644); err != nil {
2145 t.Fatal(err)
2146 }
2147 cfg, err := LoadForEditReadOnlyStrict(projectPath)
2148 if err != nil {
2149 t.Fatal(err)
2150 }
2151 if err := cfg.SetDefaultModel("deepseek-pro"); err != nil {
2152 t.Fatal(err)
2153 }
2154 if err := cfg.SaveTo(projectPath); err != nil {
2155 t.Fatal(err)
2156 }
2157 body, err := os.ReadFile(projectPath)
2158 if err != nil {
2159 t.Fatal(err)
2160 }
2161 if !strings.Contains(string(body), "disable_implicit_invocation = false") {
2162 t.Fatalf("explicit default override was removed:\n%s", body)
2163 }
2164 }
2165
2166 func TestExplicitProjectSkillDefaultOverridesUserConfig(t *testing.T) {
2167 home := t.TempDir()
2168 t.Setenv("REASONIX_HOME", home)
2169 t.Setenv("HOME", home)
2170 t.Setenv("USERPROFILE", home)
2171 project := t.TempDir()
2172 user := Default()
2173 user.Skills.DisableImplicitInvocation = true
2174 if err := user.SaveTo(UserConfigPath()); err != nil {
2175 t.Fatalf("save user config: %v", err)
2176 }
2177 projectPath := filepath.Join(project, "reasonix.toml")
2178 if err := os.WriteFile(projectPath, []byte("[skills]\ndisable_implicit_invocation = true\n"), 0o644); err != nil {
2179 t.Fatal(err)
2180 }
2181 cfg, err := LoadForEditReadOnlyStrict(projectPath)
2182 if err != nil {
2183 t.Fatalf("load project config: %v", err)
2184 }
2185 cfg.SetSkillImplicitInvocation(true)
2186 if err := cfg.KeepProjectSkillKey("disable_implicit_invocation"); err != nil {
2187 t.Fatal(err)
2188 }
2189 if err := cfg.SaveTo(projectPath); err != nil {
2190 t.Fatalf("save project override: %v", err)
2191 }
2192 effective, err := LoadForRootReadOnly(project)
2193 if err != nil {
2194 t.Fatalf("load effective config: %v", err)
2195 }
2196 if !effective.ImplicitSkillInvocationEnabled() {
2197 t.Fatalf("project explicit false did not override user config: %+v", effective.Skills)
2198 }
2199 }
2200
2201 func TestSaveToExistingProjectRemovesMultilineSkillArray(t *testing.T) {
2202 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2203 original := "[skills]\npaths = [\n \"project-skills\",\n \"shared-skills\",\n]\n\n[permissions]\nmode = \"ask\"\n"
2204 if err := os.WriteFile(projectPath, []byte(original), 0o644); err != nil {
2205 t.Fatal(err)
2206 }
2207 cfg, err := LoadForEditReadOnlyStrict(projectPath)
2208 if err != nil {
2209 t.Fatalf("load project config: %v", err)
2210 }
2211 cfg.Skills.Paths = nil
2212 if err := cfg.SaveTo(projectPath); err != nil {
2213 t.Fatalf("reset multiline paths: %v", err)
2214 }
2215 body, err := os.ReadFile(projectPath)
2216 if err != nil {
2217 t.Fatal(err)
2218 }
2219 if strings.Contains(string(body), "project-skills") || strings.Contains(string(body), "shared-skills") {
2220 t.Fatalf("multiline skill array was only partially removed:\n%s", body)
2221 }
2222 if err := ValidateFile(projectPath); err != nil {
2223 t.Fatalf("reset project config is invalid TOML: %v\n%s", err, body)
2224 }
2225 }
2226
2227 func TestSaveToExistingProjectPersistsProviderAccessWithoutReplacingDesktopSection(t *testing.T) {
2228 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2229 if err := os.WriteFile(projectPath, []byte("[desktop]\nlegacy_preference = \"keep\"\n\n[permissions]\nallow = [\"Bash(go test:*)\"]\n"), 0o644); err != nil {
2230 t.Fatal(err)
2231 }
2232 cfg := LoadForEditWithoutCredentials(projectPath)
2233 cfg.Desktop.ProviderAccess = []string{"project-relay"}
2234 if err := cfg.SaveTo(projectPath); err != nil {
2235 t.Fatalf("SaveTo: %v", err)
2236 }
2237 body, err := os.ReadFile(projectPath)
2238 if err != nil {
2239 t.Fatal(err)
2240 }
2241 text := string(body)
2242 for _, want := range []string{`provider_access = ["project-relay"]`, `legacy_preference = "keep"`, `[permissions]`} {
2243 if !strings.Contains(text, want) {
2244 t.Fatalf("existing project config missing %q after provider access update:\n%s", want, text)
2245 }
2246 }
2247 cfg.Desktop.ProviderAccess = []string{}
2248 if err := cfg.SaveTo(projectPath); err != nil {
2249 t.Fatalf("SaveTo explicit empty access: %v", err)
2250 }
2251 body, err = os.ReadFile(projectPath)
2252 if err != nil {
2253 t.Fatal(err)
2254 }
2255 if !strings.Contains(string(body), "provider_access = []") {
2256 t.Fatalf("explicit empty project provider access was not persisted:\n%s", body)
2257 }
2258 }
2259
2260 func TestWritePermissionsAllowUpdatesOnlyAllow(t *testing.T) {
2261 path := filepath.Join(t.TempDir(), "reasonix.toml")
2262 original := `[permissions]
2263 # Keep the policy rationale.
2264 mode = "deny"
2265 allow = [
2266 # Keep the list rationale.
2267 "Bash(existing)", # Keep the existing rule rationale.
2268 ] # Keep the allow rationale.
2269 ask = ["Edit(*.env)"]
2270 deny = ["Bash(rm:*)"]
2271 future_policy = "keep"
2272
2273 [desktop]
2274 legacy_preference = "keep"
2275 `
2276 if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
2277 t.Fatal(err)
2278 }
2279 if err := WritePermissionsAllow(path, []string{"Bash(existing)", "Edit(src/app.go)"}); err != nil {
2280 t.Fatal(err)
2281 }
2282
2283 got, err := LoadForEditReadOnlyStrict(path)
2284 if err != nil {
2285 t.Fatalf("updated config does not parse: %v", err)
2286 }
2287 if !reflect.DeepEqual(got.Permissions.Allow, []string{"Bash(existing)", "Edit(src/app.go)"}) {
2288 t.Fatalf("permissions.allow = %v", got.Permissions.Allow)
2289 }
2290 if got.Permissions.Mode != "deny" || !reflect.DeepEqual(got.Permissions.Ask, []string{"Edit(*.env)"}) || !reflect.DeepEqual(got.Permissions.Deny, []string{"Bash(rm:*)"}) {
2291 t.Fatalf("permission policy changed: %+v", got.Permissions)
2292 }
2293 raw, err := os.ReadFile(path)
2294 if err != nil {
2295 t.Fatal(err)
2296 }
2297 body := string(raw)
2298 for _, want := range []string{
2299 "# Keep the policy rationale.",
2300 "# Keep the list rationale.",
2301 "# Keep the existing rule rationale.",
2302 "# Keep the allow rationale.",
2303 `future_policy = "keep"`,
2304 "[desktop]\nlegacy_preference = \"keep\"",
2305 } {
2306 if !strings.Contains(body, want) {
2307 t.Errorf("updated config missing %q:\n%s", want, body)
2308 }
2309 }
2310 }
2311
2312 func TestWritePermissionsAllowIgnoresSectionExamplesInMultilineStrings(t *testing.T) {
2313 tests := []struct {
2314 name string
2315 body string
2316 }{
2317 {
2318 name: "multiline basic string with five-quote close before existing section",
2319 body: `[agent]
2320 system_prompt = """
2321 Example only:
2322 A "quoted" explanation and an escaped \" marker.
2323 [permissions]
2324 allow = ["Bash(example)"]
2325 Ends with two quotes."""""
2326
2327 [permissions]
2328 mode = "ask"
2329 allow = ["Bash(existing)"]
2330 deny = ["Bash(rm:*)"]
2331 `,
2332 },
2333 {
2334 name: "multiline literal string with four-quote close without existing section",
2335 body: `[agent]
2336 system_prompt = '''
2337 Example only:
2338 A 'quoted' explanation.
2339 [permissions]
2340 allow = ["Bash(example)"]
2341 Ends with one quote.''''
2342 `,
2343 },
2344 }
2345
2346 for _, tt := range tests {
2347 t.Run(tt.name, func(t *testing.T) {
2348 path := filepath.Join(t.TempDir(), "reasonix.toml")
2349 if err := os.WriteFile(path, []byte(tt.body), 0o644); err != nil {
2350 t.Fatal(err)
2351 }
2352
2353 wantAllow := []string{"Bash(existing)", "Edit(src/app.go)"}
2354 if !strings.Contains(tt.body, `Bash(existing)`) {
2355 wantAllow = []string{"Edit(src/app.go)"}
2356 }
2357 if err := WritePermissionsAllow(path, wantAllow); err != nil {
2358 t.Fatal(err)
2359 }
2360
2361 got, err := LoadForEditReadOnlyStrict(path)
2362 if err != nil {
2363 t.Fatalf("updated config does not parse: %v", err)
2364 }
2365 if !reflect.DeepEqual(got.Permissions.Allow, wantAllow) {
2366 t.Fatalf("permissions.allow = %v, want %v", got.Permissions.Allow, wantAllow)
2367 }
2368 if !strings.Contains(got.Agent.SystemPrompt, "[permissions]\nallow = [\"Bash(example)\"]") {
2369 t.Fatalf("system prompt example changed: %q", got.Agent.SystemPrompt)
2370 }
2371 raw, err := os.ReadFile(path)
2372 if err != nil {
2373 t.Fatal(err)
2374 }
2375 if !strings.Contains(string(raw), "[permissions]\nallow = [\"Bash(example)\"]") {
2376 t.Fatalf("multiline string content changed:\n%s", raw)
2377 }
2378 })
2379 }
2380 }
2381
2382 func TestWritePermissionsAllowReplacesArrayContainingMultilineString(t *testing.T) {
2383 path := filepath.Join(t.TempDir(), "reasonix.toml")
2384 original := `[permissions]
2385 allow = [
2386 """Bash(example]
2387 [desktop]
2388 )""",
2389 "Bash(existing)",
2390 ]
2391 deny = ["Bash(rm:*)"]
2392
2393 [desktop]
2394 legacy_preference = "keep"
2395 `
2396 if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
2397 t.Fatal(err)
2398 }
2399
2400 wantAllow := []string{"Bash(existing)", "Edit(src/app.go)"}
2401 if err := WritePermissionsAllow(path, wantAllow); err != nil {
2402 t.Fatal(err)
2403 }
2404 got, err := LoadForEditReadOnlyStrict(path)
2405 if err != nil {
2406 t.Fatalf("updated config does not parse: %v", err)
2407 }
2408 if !reflect.DeepEqual(got.Permissions.Allow, wantAllow) {
2409 t.Fatalf("permissions.allow = %v, want %v", got.Permissions.Allow, wantAllow)
2410 }
2411 if !reflect.DeepEqual(got.Permissions.Deny, []string{"Bash(rm:*)"}) {
2412 t.Fatalf("permissions.deny = %v", got.Permissions.Deny)
2413 }
2414 raw, err := os.ReadFile(path)
2415 if err != nil {
2416 t.Fatal(err)
2417 }
2418 if !strings.Contains(string(raw), "[desktop]\nlegacy_preference = \"keep\"") {
2419 t.Fatalf("unrelated section changed:\n%s", raw)
2420 }
2421 }
2422
2423 func TestProviderEntriesConfigEqualIgnoresRuntimeState(t *testing.T) {
2424 a := ProviderEntry{Name: "relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "m", APIKeyEnv: "RELAY_API_KEY"}
2425 b := a
2426 a.resolvedAPIKey = "old-secret"
2427 a.resolvedSource = CredentialSource{Kind: CredentialSourceCredentials, Label: "old"}
2428 a.persistedOfficialCurrency = "USD"
2429 b.resolvedAPIKey = "new-secret"
2430 b.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: "new"}
2431 if !ProviderEntriesConfigEqual(a, b) {
2432 t.Fatal("runtime-only provider state caused a persisted provider conflict")
2433 }
2434 b.Headers = map[string]string{"X-External": "changed"}
2435 if ProviderEntriesConfigEqual(a, b) {
2436 t.Fatal("persisted provider field change was ignored")
2437 }
2438 snapshot := ProviderEntryConfigSnapshot(a)
2439 if snapshot.resolvedAPIKey != "" || snapshot.resolvedSource != (CredentialSource{}) || snapshot.persistedOfficialCurrency != "" {
2440 t.Fatal("provider config snapshot retained runtime state")
2441 }
2442 cfg := &Config{Providers: []ProviderEntry{a}}
2443 updated := a
2444 updated.resolvedAPIKey = ""
2445 updated.resolvedSource = CredentialSource{}
2446 updated.Headers = map[string]string{"X-Replayed": "yes"}
2447 if err := cfg.UpsertProviderPreservingRuntime(updated); err != nil {
2448 t.Fatal(err)
2449 }
2450 got, _ := cfg.Provider("relay")
2451 if got.APIKey() != "old-secret" || got.Headers["X-Replayed"] != "yes" || got.persistedOfficialCurrency != "USD" {
2452 t.Fatalf("runtime-preserving upsert = %+v", got)
2453 }
2454 updated.APIKeyEnv = "NEW_RELAY_API_KEY"
2455 if err := cfg.UpsertProviderPreservingRuntime(updated); err != nil {
2456 t.Fatal(err)
2457 }
2458 got, _ = cfg.Provider("relay")
2459 if got.resolvedAPIKey != "" || got.resolvedSource != (CredentialSource{}) {
2460 t.Fatal("runtime credential survived an api_key_env change")
2461 }
2462 if got.persistedOfficialCurrency != "USD" {
2463 t.Fatal("pricing provenance was lost after an api_key_env change")
2464 }
2465 }
2466
2467 func TestSaveToExistingProjectRemovesPluginDelta(t *testing.T) {
2468 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2469 cfg := Default()
2470 if err := cfg.UpsertPlugin(PluginEntry{Name: "ed", Type: "http", URL: "https://mcp.example.com/mcp", Headers: map[string]string{"Authorization": "Bearer token"}}); err != nil {
2471 t.Fatal(err)
2472 }
2473 if err := cfg.SaveTo(projectPath); err != nil {
2474 t.Fatalf("initial SaveTo: %v", err)
2475 }
2476 if !cfg.RemovePlugin("ed") {
2477 t.Fatal("RemovePlugin should report changed")
2478 }
2479 if err := cfg.SaveTo(projectPath); err != nil {
2480 t.Fatalf("SaveTo after remove: %v", err)
2481 }
2482 body, err := os.ReadFile(projectPath)
2483 if err != nil {
2484 t.Fatalf("read project config: %v", err)
2485 }
2486 if strings.Contains(string(body), "[[plugins]]") || strings.Contains(string(body), "[plugins.headers]") || strings.Contains(string(body), "Authorization") {
2487 t.Fatalf("removed plugin should not remain in project config:\n%s", body)
2488 }
2489 var got Config
2490 if _, err := toml.DecodeFile(projectPath, &got); err != nil {
2491 t.Fatalf("saved project config does not parse: %v", err)
2492 }
2493 if len(got.Plugins) != 0 {
2494 t.Fatalf("plugins = %+v, want none", got.Plugins)
2495 }
2496 }
2497
2498 func TestSaveToNewProjectKeepsPluginSourcesSeparate(t *testing.T) {
2499 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2500 cfg := Default()
2501 cfg.Plugins = []PluginEntry{
2502 {Name: "unknown", Command: "unknown-mcp"},
2503 {Name: "user", Command: "user-mcp", Source: MCPSourceUserConfig},
2504 {Name: "project", Command: "project-mcp", Source: MCPSourceProjectConfig},
2505 {Name: "mcp-json", Command: "json-mcp", Source: MCPSourceProjectMCPJSON},
2506 {Name: "legacy", Command: "legacy-mcp", Source: MCPSourceLegacyUser},
2507 {Name: "package", Command: "package-mcp", Source: MCPSourcePluginPackage},
2508 }
2509 if err := cfg.SaveTo(projectPath); err != nil {
2510 t.Fatalf("SaveTo: %v", err)
2511 }
2512 body, err := os.ReadFile(projectPath)
2513 if err != nil {
2514 t.Fatal(err)
2515 }
2516 text := string(body)
2517 for _, name := range []string{"unknown", "project"} {
2518 if !strings.Contains(text, `name = "`+name+`"`) {
2519 t.Fatalf("new project config missing plugin %q:\n%s", name, text)
2520 }
2521 }
2522 for _, name := range []string{"user", "mcp-json", "legacy", "package"} {
2523 if strings.Contains(text, `name = "`+name+`"`) {
2524 t.Fatalf("new project config leaked plugin %q:\n%s", name, text)
2525 }
2526 }
2527 }
2528
2529 func TestSaveToExistingProjectKeepsPluginSourcesSeparate(t *testing.T) {
2530 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2531 if err := os.WriteFile(projectPath, []byte("# keep\n"), 0o644); err != nil {
2532 t.Fatal(err)
2533 }
2534 cfg := Default()
2535 cfg.Plugins = []PluginEntry{
2536 {Name: "user", Command: "user-mcp", Source: MCPSourceUserConfig},
2537 {Name: "project", Command: "project-mcp", Source: MCPSourceProjectConfig},
2538 {Name: "mcp-json", Command: "json-mcp", Source: MCPSourceProjectMCPJSON},
2539 }
2540 if err := cfg.SaveTo(projectPath); err != nil {
2541 t.Fatalf("SaveTo: %v", err)
2542 }
2543 body, err := os.ReadFile(projectPath)
2544 if err != nil {
2545 t.Fatal(err)
2546 }
2547 text := string(body)
2548 if !strings.Contains(text, `name = "project"`) || strings.Contains(text, `name = "user"`) || strings.Contains(text, `name = "mcp-json"`) {
2549 t.Fatalf("existing project config crossed plugin source boundaries:\n%s", text)
2550 }
2551 }
2552
2553 func TestSaveToExistingProjectRemovesPluginDeltaWithOnlyForeignSources(t *testing.T) {
2554 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2555 if err := os.WriteFile(projectPath, []byte("[[plugins]]\nname = \"old\"\ncommand = \"old-mcp\"\n"), 0o644); err != nil {
2556 t.Fatal(err)
2557 }
2558 cfg := Default()
2559 cfg.Plugins = []PluginEntry{
2560 {Name: "user", Command: "user-mcp", Source: MCPSourceUserConfig},
2561 {Name: "mcp-json", Command: "json-mcp", Source: MCPSourceProjectMCPJSON},
2562 {Name: "legacy", Command: "legacy-mcp", Source: MCPSourceLegacyUser},
2563 {Name: "package", Command: "package-mcp", Source: MCPSourcePluginPackage},
2564 }
2565 if err := cfg.SaveTo(projectPath); err != nil {
2566 t.Fatalf("SaveTo: %v", err)
2567 }
2568 body, err := os.ReadFile(projectPath)
2569 if err != nil {
2570 t.Fatal(err)
2571 }
2572 if strings.Contains(string(body), "[[plugins]]") {
2573 t.Fatalf("project plugin block remained after its last owned entry was removed:\n%s", body)
2574 }
2575 }
2576
2577 func TestSaveToExistingProjectRemovesIneffectiveWindowsBashEnforce(t *testing.T) {
2578 setRuntimeGOOS(t, "windows")
2579 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2580 if err := os.WriteFile(projectPath, []byte("[sandbox]\nbash = \"enforce\"\n"), 0o644); err != nil {
2581 t.Fatal(err)
2582 }
2583
2584 cfg := Default()
2585 cfg.Sandbox.Bash = "enforce"
2586 if err := cfg.SaveTo(projectPath); err != nil {
2587 t.Fatalf("SaveTo: %v", err)
2588 }
2589 body, err := os.ReadFile(projectPath)
2590 if err != nil {
2591 t.Fatalf("read project config: %v", err)
2592 }
2593 if strings.Contains(string(body), `[sandbox]`) || strings.Contains(string(body), `bash = "enforce"`) {
2594 t.Fatalf("ineffective Windows project bash enforce should be removed:\n%s", body)
2595 }
2596 if _, err := toml.Decode(string(body), &Config{}); err != nil {
2597 t.Fatalf("saved project config does not parse: %v", err)
2598 }
2599 }
2600
2601 func TestSaveToExistingProjectCanDisableWindowsBashEnforce(t *testing.T) {
2602 setRuntimeGOOS(t, "windows")
2603 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2604 if err := os.WriteFile(projectPath, []byte("[sandbox]\nbash = \"enforce\"\n"), 0o644); err != nil {
2605 t.Fatal(err)
2606 }
2607
2608 cfg := Default()
2609 cfg.Sandbox.Bash = "off"
2610 if err := cfg.SaveTo(projectPath); err != nil {
2611 t.Fatalf("SaveTo: %v", err)
2612 }
2613 body, err := os.ReadFile(projectPath)
2614 if err != nil {
2615 t.Fatalf("read project config: %v", err)
2616 }
2617 if strings.Contains(string(body), `bash = "enforce"`) {
2618 t.Fatalf("Windows sandbox mode should no longer remain enforce after disabling it:\n%s", body)
2619 }
2620 if _, err := toml.Decode(string(body), &Config{}); err != nil {
2621 t.Fatalf("saved project config does not parse: %v", err)
2622 }
2623 }
2624
2625 func TestSaveToExistingProjectRemovesOnlyIneffectiveWindowsBashEnforce(t *testing.T) {
2626 setRuntimeGOOS(t, "windows")
2627 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
2628 if err := os.WriteFile(projectPath, []byte("[sandbox]\nbash = \"enforce\"\nnetwork = true\n"), 0o644); err != nil {
2629 t.Fatal(err)
2630 }
2631
2632 cfg := Default()
2633 cfg.Sandbox.Bash = "enforce"
2634 if err := cfg.SaveTo(projectPath); err != nil {
2635 t.Fatalf("SaveTo: %v", err)
2636 }
2637 body, err := os.ReadFile(projectPath)
2638 if err != nil {
2639 t.Fatalf("read project config: %v", err)
2640 }
2641 if strings.Contains(string(body), `bash = "enforce"`) {
2642 t.Fatalf("ineffective Windows project bash enforce should be removed:\n%s", body)
2643 }
2644 if !strings.Contains(string(body), `[sandbox]`) || !strings.Contains(string(body), `network = true`) {
2645 t.Fatalf("other sandbox fields should be preserved:\n%s", body)
2646 }
2647 var got Config
2648 if _, err := toml.Decode(string(body), &got); err != nil {
2649 t.Fatalf("saved project config does not parse: %v", err)
2650 }
2651 if !got.Sandbox.Network {
2652 t.Fatalf("network = false, want preserved true")
2653 }
2654 }
2655
2656 func TestSaveForRootDoesNotWriteUserAgentSettingsIntoProjectConfig(t *testing.T) {
2657 isolateUserConfigHome(t)
2658 root := t.TempDir()
2659 userPath := UserConfigPath()
2660 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
2661 t.Fatal(err)
2662 }
2663 if err := os.WriteFile(userPath, []byte("[agent]\ntemperature = 0.42\n"), 0o644); err != nil {
2664 t.Fatal(err)
2665 }
2666 projectPath := filepath.Join(root, "reasonix.toml")
2667 if err := os.WriteFile(projectPath, []byte("[permissions]\nallow = [\"Bash(go test:*)\"]\n"), 0o644); err != nil {
2668 t.Fatal(err)
2669 }
2670 cfg, err := LoadForRoot(root)
2671 if err != nil {
2672 t.Fatalf("LoadForRoot: %v", err)
2673 }
2674 if cfg.Agent.Temperature != 0.42 {
2675 t.Fatalf("runtime temperature = %v, want merged user config", cfg.Agent.Temperature)
2676 }
2677 if err := cfg.SaveForRoot(root); err != nil {
2678 t.Fatalf("SaveForRoot: %v", err)
2679 }
2680 body, err := os.ReadFile(projectPath)
2681 if err != nil {
2682 t.Fatalf("read project config: %v", err)
2683 }
2684 if strings.Contains(string(body), "temperature") {
2685 t.Fatalf("user agent setting leaked into project config:\n%s", body)
2686 }
2687 }
2688
2689 func TestSetNetworkRejectsIncompleteCustomProxy(t *testing.T) {
2690 c := Default()
2691 if err := c.SetNetwork(NetworkConfig{ProxyMode: "custom"}); err == nil {
2692 t.Fatal("custom proxy without server/port should be rejected")
2693 }
2694 }
2695
2696 func TestEffortCapabilityCustomSupportedEfforts(t *testing.T) {
2697 e := &ProviderEntry{
2698 Name: "custom",
2699 Kind: "openai",
2700 BaseURL: "https://example.com",
2701 SupportedEfforts: []string{"low", "medium", "high"},
2702 DefaultEffort: "high",
2703 }
2704 cap := EffortCapabilityForEntry(e)
2705 if !cap.Supported {
2706 t.Fatalf("expected supported, got %+v", cap)
2707 }
2708 wantLevels := []string{"auto", "low", "medium", "high"}
2709 if len(cap.Levels) != len(wantLevels) {
2710 t.Fatalf("levels = %v, want %v", cap.Levels, wantLevels)
2711 }
2712 for i, l := range wantLevels {
2713 if cap.Levels[i] != l {
2714 t.Errorf("levels[%d] = %q, want %q", i, cap.Levels[i], l)
2715 }
2716 }
2717 if cap.Default != "high" {
2718 t.Errorf("default = %q, want high", cap.Default)
2719 }
2720 }
2721
2722 func TestEffortCapabilityUsesKnownModelRegistry(t *testing.T) {
2723 e := &ProviderEntry{
2724 Name: "deepseek-proxy",
2725 Kind: "openai",
2726 BaseURL: "https://proxy.example.com/v1",
2727 Model: "deepseek-v4-flash",
2728 }
2729 cap := EffortCapabilityForEntry(e)
2730 if !cap.Supported {
2731 t.Fatalf("deepseek model behind proxy should expose effort, got %+v", cap)
2732 }
2733 wantLevels := []string{"auto", "disabled", "low", "high", "max"}
2734 if len(cap.Levels) != len(wantLevels) {
2735 t.Fatalf("levels = %v, want %v", cap.Levels, wantLevels)
2736 }
2737 for i, want := range wantLevels {
2738 if cap.Levels[i] != want {
2739 t.Fatalf("levels[%d] = %q, want %q", i, cap.Levels[i], want)
2740 }
2741 }
2742 if cap.Default != "high" {
2743 t.Fatalf("default = %q, want high", cap.Default)
2744 }
2745 if protocol := ReasoningProtocolForEntry(e); protocol != ReasoningProtocolDeepSeek {
2746 t.Fatalf("protocol = %q, want deepseek", protocol)
2747 }
2748 if got, err := NormalizeEffort(e, "max"); err != nil || got != "max" {
2749 t.Fatalf("NormalizeEffort(max) = %q/%v, want max/nil", got, err)
2750 }
2751 if got, err := NormalizeEffort(e, "low"); err != nil || got != "low" {
2752 t.Fatalf("NormalizeEffort(low) = %q/%v, want low/nil", got, err)
2753 }
2754 }
2755
2756 func TestReasoningProtocolOverrideControlsEffortCapability(t *testing.T) {
2757 e := &ProviderEntry{
2758 Name: "deepseek-proxy",
2759 Kind: "openai",
2760 BaseURL: "https://proxy.example.com/v1",
2761 Model: "deepseek-v4-flash",
2762 ReasoningProtocol: "none",
2763 }
2764 if cap := EffortCapabilityForEntry(e); cap.Supported {
2765 t.Fatalf("reasoning_protocol=none should disable effort, got %+v", cap)
2766 }
2767 if protocol := ReasoningProtocolForEntry(e); protocol != ReasoningProtocolNone {
2768 t.Fatalf("protocol = %q, want none", protocol)
2769 }
2770 if _, err := NormalizeEffort(e, "max"); err == nil {
2771 t.Fatal("NormalizeEffort should reject effort when reasoning_protocol=none")
2772 }
2773
2774 e.ReasoningProtocol = "openai"
2775 cap := EffortCapabilityForEntry(e)
2776 if !cap.Supported {
2777 t.Fatalf("reasoning_protocol=openai should expose OpenAI effort levels, got %+v", cap)
2778 }
2779 wantLevels := []string{"auto", "low", "medium", "high"}
2780 if len(cap.Levels) != len(wantLevels) {
2781 t.Fatalf("levels = %v, want %v", cap.Levels, wantLevels)
2782 }
2783 for i, want := range wantLevels {
2784 if cap.Levels[i] != want {
2785 t.Fatalf("levels[%d] = %q, want %q", i, cap.Levels[i], want)
2786 }
2787 }
2788 if _, err := NormalizeEffort(e, "max"); err == nil {
2789 t.Fatal("OpenAI reasoning_protocol should reject max")
2790 }
2791 if got, err := NormalizeEffort(e, "medium"); err != nil || got != "medium" {
2792 t.Fatalf("NormalizeEffort(medium) = %q/%v, want medium/nil", got, err)
2793 }
2794 }
2795
2796 func TestNormalizeEffortCustomSupportedEfforts(t *testing.T) {
2797 e := &ProviderEntry{
2798 Name: "custom",
2799 Kind: "openai",
2800 BaseURL: "https://example.com",
2801 SupportedEfforts: []string{"low", "medium", "high"},
2802 }
2803 for in, want := range map[string]string{"auto": "", "low": "low", "MEDIUM": "medium", "high": "high"} {
2804 got, err := NormalizeEffort(e, in)
2805 if in != want && in != "auto" {
2806 if err == nil {
2807 t.Fatalf("undeclared %q accepted as %q", in, got)
2808 }
2809 continue
2810 }
2811 if err != nil || got != want {
2812 t.Fatalf("NormalizeEffort(%q) = %q/%v, want %q/nil", in, got, err, want)
2813 }
2814 }
2815 for _, bad := range []string{"max", "xhigh", "", " "} {
2816 if _, err := NormalizeEffort(e, bad); err == nil {
2817 t.Errorf("NormalizeEffort(%q) should be rejected", bad)
2818 }
2819 }
2820 }
2821
2822 func TestNormalizeEffortCustomDefaultEffort(t *testing.T) {
2823 e := &ProviderEntry{
2824 Name: "custom",
2825 Kind: "openai",
2826 BaseURL: "https://example.com",
2827 SupportedEfforts: []string{"low", "medium", "high"},
2828 DefaultEffort: "xhigh", // not in the list — must fall back to the first level
2829 }
2830 cap := EffortCapabilityForEntry(e)
2831 if cap.Default != "xhigh" {
2832 t.Fatalf("invalid default must remain visible for validation, got %q", cap.Default)
2833 }
2834 // Omitting DefaultEffort also falls back to the first level.
2835 e2 := *e
2836 e2.DefaultEffort = ""
2837 if cap := EffortCapabilityForEntry(&e2); cap.Default != "low" {
2838 t.Errorf("empty default = %q, want low", cap.Default)
2839 }
2840 // /effort auto still maps to "" regardless of DefaultEffort.
2841 if got, err := NormalizeEffort(e, "auto"); err != nil || got != "" {
2842 t.Fatalf("NormalizeEffort(auto) = %q/%v, want empty/nil", got, err)
2843 }
2844 e.Effort = "auto"
2845 if got := EffectiveEffort(e); got != "xhigh" {
2846 t.Fatalf("invalid configured default must not silently fall back, got %q", got)
2847 }
2848 e.Effort = "high"
2849 if got := EffectiveEffort(e); got != "high" {
2850 t.Fatalf("explicit effort should win over default_effort, got %q", got)
2851 }
2852 }
2853
2854 func TestNormalizeEffortCustomLevelsCaseInsensitive(t *testing.T) {
2855 e := &ProviderEntry{
2856 Name: "custom",
2857 Kind: "openai",
2858 BaseURL: "https://example.com",
2859 SupportedEfforts: []string{"Low", "MEDIUM", "medium", "auto", " "},
2860 DefaultEffort: "MEDIUM",
2861 }
2862 cap := EffortCapabilityForEntry(e)
2863 wantLevels := []string{"auto", "low", "medium"}
2864 if len(cap.Levels) != len(wantLevels) {
2865 t.Fatalf("levels = %v, want %v", cap.Levels, wantLevels)
2866 }
2867 for i, want := range wantLevels {
2868 if cap.Levels[i] != want {
2869 t.Fatalf("levels[%d] = %q, want %q", i, cap.Levels[i], want)
2870 }
2871 }
2872 if cap.Default != "medium" {
2873 t.Fatalf("default = %q, want medium", cap.Default)
2874 }
2875 got, err := NormalizeEffort(e, "MEDIUM")
2876 if err == nil {
2877 t.Fatalf("NormalizeEffort(MEDIUM) accepted nonexact ID %q", got)
2878 }
2879 if got := EffectiveEffort(e); got != "medium" {
2880 t.Fatalf("EffectiveEffort = %q, want medium", got)
2881 }
2882 }
2883
2884 func TestUpsertProviderNormalizesCustomEffortFields(t *testing.T) {
2885 c := &Config{}
2886 if err := c.UpsertProvider(ProviderEntry{
2887 Name: "custom",
2888 Kind: "openai",
2889 BaseURL: "https://example.com",
2890 Model: "m",
2891 Effort: " HIGH ",
2892 ReasoningProtocol: " OPENAI ",
2893 SupportedEfforts: []string{"Low", "MEDIUM", "medium", "auto"},
2894 DefaultEffort: " LOW ",
2895 }); err != nil {
2896 t.Fatalf("UpsertProvider: %v", err)
2897 }
2898 got, _ := c.Provider("custom")
2899 if got.Effort != "high" || got.DefaultEffort != "low" {
2900 t.Fatalf("effort/default = %q/%q, want high/low", got.Effort, got.DefaultEffort)
2901 }
2902 if got.ReasoningProtocol != "openai" {
2903 t.Fatalf("reasoning_protocol = %q, want openai", got.ReasoningProtocol)
2904 }
2905 wantSupported := []string{"low", "medium"}
2906 if len(got.SupportedEfforts) != len(wantSupported) {
2907 t.Fatalf("supported_efforts = %v, want %v", got.SupportedEfforts, wantSupported)
2908 }
2909 for i, want := range wantSupported {
2910 if got.SupportedEfforts[i] != want {
2911 t.Fatalf("supported_efforts[%d] = %q, want %q", i, got.SupportedEfforts[i], want)
2912 }
2913 }
2914 }
2915
2916 func TestEffortCapabilityEmptySupportedEffortsNotConfigurable(t *testing.T) {
2917 // mimo-pro without SupportedEfforts: no built-in heuristic, /effort must reject.
2918 e := &ProviderEntry{
2919 Name: "mimo-pro",
2920 Kind: "openai",
2921 BaseURL: "https://unknown-gateway.example.com/v1",
2922 Model: "mimo-v2.5-pro",
2923 }
2924 if cap := EffortCapabilityForEntry(e); cap.Supported {
2925 t.Fatalf("mimo-pro without SupportedEfforts should not be configurable, got %+v", cap)
2926 }
2927 if _, err := NormalizeEffort(e, "high"); err == nil {
2928 t.Fatal("NormalizeEffort should reject level for unsupported provider")
2929 }
2930 // `supported_efforts = []` (empty slice) is treated like nil — the v2 design
2931 // has no way to opt out of the built-in heuristic; users either configure
2932 // levels or leave the field unset.
2933 e2 := *e
2934 e2.SupportedEfforts = []string{}
2935 if cap := EffortCapabilityForEntry(&e2); cap.Supported {
2936 t.Fatalf("empty supported_efforts should also fall through to the heuristic, got %+v", cap)
2937 }
2938 }
2939
2940 func TestWriteFilePreservesSymlinkToWritableTarget(t *testing.T) {
2941 home := t.TempDir()
2942 targetDir := t.TempDir()
2943 t.Setenv("REASONIX_HOME", home)
2944 target := filepath.Join(targetDir, "target.toml")
2945 link := UserConfigPath()
2946 if err := os.WriteFile(target, []byte("default_model = \"old\"\n"), 0o600); err != nil {
2947 t.Fatal(err)
2948 }
2949 if err := os.Symlink(target, link); err != nil {
2950 t.Skipf("symlinks are unavailable: %v", err)
2951 }
2952
2953 cfg := Default()
2954 cfg.DefaultModel = "deepseek-pro"
2955 if err := cfg.WriteFile(link); err != nil {
2956 t.Fatalf("WriteFile through symlink: %v", err)
2957 }
2958 info, err := os.Lstat(link)
2959 if err != nil {
2960 t.Fatal(err)
2961 }
2962 if info.Mode()&os.ModeSymlink == 0 {
2963 t.Fatal("WriteFile replaced the config symlink")
2964 }
2965 var persisted Config
2966 if _, err := toml.DecodeFile(target, &persisted); err != nil {
2967 t.Fatalf("decode target: %v", err)
2968 }
2969 if persisted.DefaultModel != "deepseek-pro" {
2970 t.Fatalf("target default_model = %q, want deepseek-pro", persisted.DefaultModel)
2971 }
2972 }
2973
2974 func TestSaveToPreservesMultiLevelSymlinkChain(t *testing.T) {
2975 home := t.TempDir()
2976 targetDir := t.TempDir()
2977 t.Setenv("REASONIX_HOME", home)
2978 target := filepath.Join(targetDir, "target.toml")
2979 first := filepath.Join(targetDir, "first.toml")
2980 second := UserConfigPath()
2981 if err := os.WriteFile(target, []byte("default_model = \"old\"\n"), 0o600); err != nil {
2982 t.Fatal(err)
2983 }
2984 if err := os.Symlink(target, first); err != nil {
2985 t.Skipf("symlinks are unavailable: %v", err)
2986 }
2987 if err := os.Symlink(first, second); err != nil {
2988 t.Skipf("symlink chains are unavailable: %v", err)
2989 }
2990
2991 resolvedTarget, err := filepath.EvalSymlinks(target)
2992 if err != nil {
2993 t.Fatal(err)
2994 }
2995 got, err := resolveConfigAccessPath(second, true)
2996 if err != nil {
2997 t.Fatalf("resolveConfigAccessPath(second): %v", err)
2998 }
2999 if got != resolvedTarget {
3000 t.Fatalf("resolveConfigAccessPath(second) = %q, want %q", got, resolvedTarget)
3001 }
3002
3003 cfg := Default()
3004 cfg.DefaultModel = "deepseek-pro"
3005 if err := cfg.SaveTo(second); err != nil {
3006 t.Fatalf("SaveTo through symlink chain: %v", err)
3007 }
3008 for name, path := range map[string]string{"first": first, "second": second} {
3009 info, err := os.Lstat(path)
3010 if err != nil {
3011 t.Fatalf("Lstat(%s): %v", name, err)
3012 }
3013 if info.Mode()&os.ModeSymlink == 0 {
3014 t.Fatalf("SaveTo replaced the %s symlink", name)
3015 }
3016 }
3017 var persisted Config
3018 if _, err := toml.DecodeFile(target, &persisted); err != nil {
3019 t.Fatalf("decode target: %v", err)
3020 }
3021 if persisted.DefaultModel != "deepseek-pro" {
3022 t.Fatalf("target default_model = %q, want deepseek-pro", persisted.DefaultModel)
3023 }
3024 }
3025
3026 // makeDirReadOnly makes a directory non-writable using the platform's real
3027 // permission mechanism. Windows directory read-only attributes do not block
3028 // writes, so the test must use an ACL there.
3029 func makeDirReadOnly(dir string) (func(), error) {
3030 if runtime.GOOS == "windows" {
3031 const everyoneSID = "*S-1-1-0"
3032 if err := exec.Command("icacls", dir, "/deny", everyoneSID+":(W)").Run(); err != nil {
3033 return nil, fmt.Errorf("icacls /deny: %w", err)
3034 }
3035 return func() {
3036 _ = exec.Command("icacls", dir, "/remove:d", everyoneSID).Run()
3037 }, nil
3038 }
3039
3040 info, err := os.Stat(dir)
3041 if err != nil {
3042 return nil, err
3043 }
3044 if err := os.Chmod(dir, 0o555); err != nil {
3045 return nil, err
3046 }
3047 return func() { _ = os.Chmod(dir, info.Mode().Perm()) }, nil
3048 }
3049
3050 func TestSaveToUnwritableUserSymlinkTargetPreservesLink(t *testing.T) {
3051 home := t.TempDir()
3052 targetDir := filepath.Join(t.TempDir(), "readonly")
3053 t.Setenv("REASONIX_HOME", home)
3054 target := filepath.Join(targetDir, "target.toml")
3055 link := UserConfigPath()
3056 if err := os.MkdirAll(targetDir, 0o755); err != nil {
3057 t.Fatal(err)
3058 }
3059 if err := os.WriteFile(target, []byte("default_model = \"old\"\n"), 0o600); err != nil {
3060 t.Fatal(err)
3061 }
3062 if err := os.Symlink(target, link); err != nil {
3063 t.Skipf("symlinks are unavailable: %v", err)
3064 }
3065
3066 cleanup, err := makeDirReadOnly(targetDir)
3067 if err != nil {
3068 t.Fatalf("make target directory read-only: %v", err)
3069 }
3070 t.Cleanup(cleanup)
3071
3072 cfg := Default()
3073 cfg.DefaultModel = "deepseek-pro"
3074 if err := cfg.SaveTo(link); err == nil {
3075 t.Fatal("SaveTo through symlink with unwritable target unexpectedly succeeded")
3076 }
3077 info, err := os.Lstat(link)
3078 if err != nil {
3079 t.Fatal(err)
3080 }
3081 if info.Mode()&os.ModeSymlink == 0 {
3082 t.Fatal("failed target write replaced the user config symlink")
3083 }
3084 var persisted Config
3085 if _, err := toml.DecodeFile(target, &persisted); err != nil {
3086 t.Fatalf("decode unchanged target config: %v", err)
3087 }
3088 if persisted.DefaultModel != "old" {
3089 t.Fatalf("failed write changed target default_model to %q", persisted.DefaultModel)
3090 }
3091 }
3092
3093 func TestSaveToBrokenUserSymlinkFailsAndPreservesLink(t *testing.T) {
3094 home := t.TempDir()
3095 t.Setenv("REASONIX_HOME", home)
3096 link := UserConfigPath()
3097 missingTarget := filepath.Join(t.TempDir(), "missing", "target.toml")
3098 if err := os.Symlink(missingTarget, link); err != nil {
3099 t.Skipf("symlinks are unavailable: %v", err)
3100 }
3101
3102 cfg := Default()
3103 cfg.DefaultModel = "deepseek-pro"
3104 if err := cfg.SaveTo(link); err == nil {
3105 t.Fatal("SaveTo through broken user symlink unexpectedly succeeded")
3106 }
3107 info, err := os.Lstat(link)
3108 if err != nil {
3109 t.Fatal(err)
3110 }
3111 if info.Mode()&os.ModeSymlink == 0 {
3112 t.Fatal("failed write replaced the broken user config symlink")
3113 }
3114 }
3115
3116 func TestSaveToProjectSymlinkOutsideRootFailsWithoutReadingOrReplacing(t *testing.T) {
3117 project := t.TempDir()
3118 outside := t.TempDir()
3119 target := filepath.Join(outside, "target.toml")
3120 link := filepath.Join(project, "reasonix.toml")
3121 const sentinel = "private_token = \"must-not-be-copied\"\n"
3122 if err := os.WriteFile(target, []byte(sentinel), 0o600); err != nil {
3123 t.Fatal(err)
3124 }
3125 if err := os.Symlink(target, link); err != nil {
3126 t.Skipf("symlinks are unavailable: %v", err)
3127 }
3128 if _, err := LoadForRootReadOnly(project); err == nil {
3129 t.Fatal("LoadForRootReadOnly accepted a project config symlink outside root")
3130 }
3131
3132 cfg := Default()
3133 cfg.DefaultModel = "deepseek-pro"
3134 if err := cfg.SaveTo(link); err == nil {
3135 t.Fatal("SaveTo through project symlink outside root unexpectedly succeeded")
3136 }
3137 info, err := os.Lstat(link)
3138 if err != nil {
3139 t.Fatal(err)
3140 }
3141 if info.Mode()&os.ModeSymlink == 0 {
3142 t.Fatal("failed project config write replaced the external symlink")
3143 }
3144 got, err := os.ReadFile(target)
3145 if err != nil {
3146 t.Fatal(err)
3147 }
3148 if string(got) != sentinel {
3149 t.Fatalf("project config write changed outside target:\n%s", got)
3150 }
3151 }
3152
3153 func TestProjectConfigSymlinkWithinRootLoadsAndSavesTarget(t *testing.T) {
3154 project := t.TempDir()
3155 targetDir := filepath.Join(project, "config")
3156 target := filepath.Join(targetDir, "reasonix.toml")
3157 link := filepath.Join(project, "reasonix.toml")
3158 if err := os.MkdirAll(targetDir, 0o755); err != nil {
3159 t.Fatal(err)
3160 }
3161 if err := os.WriteFile(target, []byte("default_model = \"deepseek-pro\"\n"), 0o644); err != nil {
3162 t.Fatal(err)
3163 }
3164 if err := os.Symlink(filepath.Join("config", "reasonix.toml"), link); err != nil {
3165 t.Skipf("symlinks are unavailable: %v", err)
3166 }
3167
3168 loaded, err := LoadForRootReadOnly(project)
3169 if err != nil {
3170 t.Fatalf("LoadForRootReadOnly through internal symlink: %v", err)
3171 }
3172 if loaded.DefaultModel != "deepseek-pro" {
3173 t.Fatalf("loaded default_model = %q, want deepseek-pro", loaded.DefaultModel)
3174 }
3175 loaded.Agent.Temperature = 0.42
3176 if err := loaded.SaveTo(link); err != nil {
3177 t.Fatalf("SaveTo through internal project symlink: %v", err)
3178 }
3179 info, err := os.Lstat(link)
3180 if err != nil {
3181 t.Fatal(err)
3182 }
3183 if info.Mode()&os.ModeSymlink == 0 {
3184 t.Fatal("SaveTo replaced an internal project config symlink")
3185 }
3186 raw, err := os.ReadFile(target)
3187 if err != nil {
3188 t.Fatal(err)
3189 }
3190 if !strings.Contains(string(raw), "temperature = 0.42") {
3191 t.Fatalf("internal symlink target was not updated:\n%s", raw)
3192 }
3193 }
3194
3195 func TestBrokenProjectConfigSymlinkFailsLoadAndSave(t *testing.T) {
3196 project := t.TempDir()
3197 link := filepath.Join(project, "reasonix.toml")
3198 if err := os.Symlink(filepath.Join("missing", "reasonix.toml"), link); err != nil {
3199 t.Skipf("symlinks are unavailable: %v", err)
3200 }
3201
3202 if _, err := LoadForRootReadOnly(project); err == nil {
3203 t.Fatal("LoadForRootReadOnly accepted a broken project config symlink")
3204 }
3205 cfg := Default()
3206 cfg.DefaultModel = "deepseek-pro"
3207 if err := cfg.SaveTo(link); err == nil {
3208 t.Fatal("SaveTo accepted a broken project config symlink")
3209 }
3210 info, err := os.Lstat(link)
3211 if err != nil {
3212 t.Fatal(err)
3213 }
3214 if info.Mode()&os.ModeSymlink == 0 {
3215 t.Fatal("failed operations replaced the broken project config symlink")
3216 }
3217 }
3218
3218 lines GO