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