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