返回 DeepSeek-Reasonix
settings_app_test.go
根目录 / desktop / settings_app_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "net/http"
6 "net/http/httptest"
7 "os"
8 "path/filepath"
9 "reflect"
10 "strings"
11 "testing"
12
13 "reasonix/internal/config"
14 "reasonix/internal/control"
15 fileencoding "reasonix/internal/fileutil/encoding"
16 "reasonix/internal/hook"
17 "reasonix/internal/provider"
18 "reasonix/internal/sandbox"
19 )
20
21 func TestWithFreshSystemPromptReplacesExistingSystemMessage(t *testing.T) {
22 msgs := []provider.Message{
23 {Role: provider.RoleSystem, Content: "old", ReasoningContent: "stale", ReasoningSignature: "sig", ToolCalls: []provider.ToolCall{{ID: "call", Name: "noop"}}, ToolCallID: "tool", Name: "name"},
24 {Role: provider.RoleUser, Content: "hello"},
25 }
26
27 got := withFreshSystemPrompt(msgs, "new")
28 if got[0].Content != "new" {
29 t.Fatalf("system prompt = %q, want new", got[0].Content)
30 }
31 if got[0].ReasoningContent != "" || got[0].ReasoningSignature != "" || len(got[0].ToolCalls) != 0 || got[0].ToolCallID != "" || got[0].Name != "" {
32 t.Fatalf("system metadata should be cleared, got %+v", got[0])
33 }
34 if got[1].Content != "hello" {
35 t.Fatalf("non-system message changed: %+v", got[1])
36 }
37 if msgs[0].Content != "old" {
38 t.Fatalf("input slice was mutated: %+v", msgs[0])
39 }
40 }
41
42 func TestWithFreshSystemPromptPrependsMissingSystemMessage(t *testing.T) {
43 msgs := []provider.Message{{Role: provider.RoleUser, Content: "hello"}}
44
45 got := withFreshSystemPrompt(msgs, "new")
46 if len(got) != 2 || got[0].Role != provider.RoleSystem || got[0].Content != "new" {
47 t.Fatalf("expected prepended system prompt, got %+v", got)
48 }
49 if got[1].Content != "hello" {
50 t.Fatalf("existing user message changed: %+v", got[1])
51 }
52 }
53
54 func TestProviderViewFromEntry_FiltersNonChatModels(t *testing.T) {
55 p := config.ProviderEntry{
56 Name: "mimo-api",
57 Models: []string{
58 "mimo-v2", "mimo-v2-pro",
59 "mimo-v2-asr", "mimo-v2-tts",
60 "mimo-v2-tts-voiceclone", "mimo-v2-tts-voicedesign",
61 },
62 VisionModels: []string{"mimo-v2", "mimo-v2-asr", "mimo-v2-omni"},
63 }
64 view := providerViewFromEntry(p, true, false)
65 want := []string{"mimo-v2", "mimo-v2-pro"}
66 if !reflect.DeepEqual(view.Models, want) {
67 t.Errorf("ProviderView.Models = %v, want %v", view.Models, want)
68 }
69 if got, want := view.VisionModels, []string{"mimo-v2"}; !reflect.DeepEqual(got, want) {
70 t.Errorf("ProviderView.VisionModels = %v, want %v", got, want)
71 }
72 if !view.VisionModelsSet {
73 t.Fatal("ProviderView.VisionModelsSet = false, want true for configured vision_models")
74 }
75 }
76
77 func TestProviderModelOverridesPreservePerModelContextWindow(t *testing.T) {
78 overrides := map[string]config.ProviderModelOverride{
79 "short-model": {ContextWindow: 32_768},
80 "long-model": {ContextWindow: 1_000_000},
81 "removed": {ContextWindow: 8_192},
82 }
83 models := []string{"short-model", "long-model"}
84
85 view := providerModelOverridesForView(overrides, models)
86 if len(view) != 2 || view[0].Model != "long-model" || view[0].ContextWindow != 1_000_000 || view[1].Model != "short-model" || view[1].ContextWindow != 32_768 {
87 t.Fatalf("provider model override view = %+v", view)
88 }
89
90 view[0].ContextWindow = -1
91 saved := providerModelOverridesForSave(view, models)
92 if _, ok := saved["long-model"]; ok {
93 t.Fatalf("non-positive context-only override should be removed: %+v", saved)
94 }
95 if got := saved["short-model"].ContextWindow; got != 32_768 {
96 t.Fatalf("saved short-model context window = %d, want 32768", got)
97 }
98 }
99
100 func TestProviderViewFromEntry_MigratesProviderWideVision(t *testing.T) {
101 p := config.ProviderEntry{
102 Name: "custom",
103 Models: []string{"text-only", "qwen-vl-plus"},
104 Vision: true,
105 }
106 view := providerViewFromEntry(p, false, true)
107 if got, want := view.VisionModels, []string{"text-only", "qwen-vl-plus"}; !reflect.DeepEqual(got, want) {
108 t.Errorf("ProviderView.VisionModels = %v, want %v", got, want)
109 }
110 if !view.VisionModelsSet {
111 t.Fatal("ProviderView.VisionModelsSet = false, want true for provider-wide vision")
112 }
113 }
114
115 func TestProviderViewFromEntryIncludesThinking(t *testing.T) {
116 view := providerViewFromEntry(config.ProviderEntry{
117 Name: "anthropic",
118 Thinking: "ADAPTIVE",
119 }, false, true)
120 if view.Thinking != "adaptive" {
121 t.Fatalf("ProviderView.Thinking = %q, want adaptive", view.Thinking)
122 }
123 }
124
125 func TestProviderViewFromEntryUsesEffectiveWebSearch(t *testing.T) {
126 view := providerViewFromEntry(config.ProviderEntry{
127 Name: "deepseek-responses",
128 Kind: "responses",
129 BaseURL: "https://api.deepseek.com",
130 }, false, true)
131 if !view.WebSearch {
132 t.Fatal("official DeepSeek Responses omission did not default web search on")
133 }
134
135 disabled := false
136 explicitOff := providerViewFromEntry(config.ProviderEntry{
137 Name: "deepseek-responses",
138 Kind: "responses",
139 BaseURL: "https://api.deepseek.com",
140 WebSearch: &disabled,
141 }, false, true)
142 if explicitOff.WebSearch {
143 t.Fatal("explicit web_search=false was not preserved")
144 }
145
146 custom := providerViewFromEntry(config.ProviderEntry{
147 Name: "custom-responses",
148 Kind: "responses",
149 BaseURL: "https://gateway.example/v1",
150 }, false, true)
151 if custom.WebSearch {
152 t.Fatal("custom provider unexpectedly enabled web search")
153 }
154 }
155
156 func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
157 isolateDesktopUserDirs(t)
158 t.Setenv("TEST_PROVIDER_KEY_SOURCE", "")
159 os.Unsetenv("TEST_PROVIDER_KEY_SOURCE")
160 if _, err := config.SetCredential("TEST_PROVIDER_KEY_SOURCE", "sk-test"); err != nil {
161 t.Fatalf("SetCredential: %v", err)
162 }
163
164 view := providerViewFromEntry(config.ProviderEntry{
165 Name: "custom",
166 APIKeyEnv: "TEST_PROVIDER_KEY_SOURCE",
167 }, false, true)
168 if !view.KeySet {
169 t.Fatal("KeySet = false, want true")
170 }
171 if !view.Configured {
172 t.Fatal("Configured = false, want true from resolved credentials")
173 }
174 if view.KeySource == "" || !strings.Contains(view.KeySource, "credentials") {
175 t.Fatalf("KeySource = %q, want credentials source", view.KeySource)
176 }
177 }
178
179 func TestSettingsExposesEffectiveSandboxWriteRoots(t *testing.T) {
180 home := isolateDesktopUserDirs(t)
181 project := robustTempDir(t)
182 cfg := config.LoadForEdit(config.UserConfigPath())
183 cfg.Sandbox.AllowWrite = []string{
184 "${HOME}/.m2",
185 "${HOME}/.m2/repository",
186 }
187 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
188 t.Fatalf("save config: %v", err)
189 }
190
191 app := NewApp()
192 app.tabs = map[string]*WorkspaceTab{
193 "project": {ID: "project", Scope: "project", WorkspaceRoot: project, Ready: true},
194 }
195 app.activeTabID = "project"
196
197 got := app.Settings().Sandbox
198 if got.EffectiveWorkspaceRoot != project {
199 t.Fatalf("EffectiveWorkspaceRoot = %q, want %q", got.EffectiveWorkspaceRoot, project)
200 }
201 // Settings expose expanded configured roots; the writer confiner normalizes
202 // separators later when enforcing them.
203 want := []string{
204 project,
205 home + "/.m2",
206 home + "/.m2/repository",
207 }
208 if !reflect.DeepEqual(got.EffectiveWriteRoots, want) {
209 t.Fatalf("EffectiveWriteRoots = %v, want %v", got.EffectiveWriteRoots, want)
210 }
211 if !reflect.DeepEqual(got.AllowWrite, cfg.Sandbox.AllowWrite) {
212 t.Fatalf("AllowWrite = %v, want raw configured paths %v", got.AllowWrite, cfg.Sandbox.AllowWrite)
213 }
214 if got.EffectiveShell == "" {
215 t.Fatal("EffectiveShell is empty")
216 }
217 }
218
219 func TestSandboxEffectiveShellViewLabels(t *testing.T) {
220 cases := []struct {
221 name string
222 shell sandbox.Shell
223 want string
224 }{
225 {"bash", sandbox.Shell{Kind: sandbox.ShellBash, Path: "bash"}, "bash"},
226 {"git bash", sandbox.Shell{Kind: sandbox.ShellBash, Path: `C:\Program Files\Git\bin\bash.exe`}, "git-bash"},
227 {"windows powershell", sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "powershell"}, "powershell"},
228 {"pwsh", sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"}, "pwsh"},
229 }
230 for _, tc := range cases {
231 if got := sandboxEffectiveShellView(tc.shell); got != tc.want {
232 t.Errorf("%s: sandboxEffectiveShellView() = %q, want %q", tc.name, got, tc.want)
233 }
234 }
235 }
236
237 func TestProviderViewFromEntryExposesNoAuthAvailability(t *testing.T) {
238 isolateDesktopUserDirs(t)
239 t.Setenv("LOCAL_API_KEY", "")
240 os.Unsetenv("LOCAL_API_KEY")
241
242 noAuth := providerViewFromEntry(config.ProviderEntry{
243 Name: "local",
244 Kind: "openai",
245 BaseURL: "http://127.0.0.1:23333/v1",
246 Models: []string{"model-a"},
247 }, false, true)
248 if noAuth.RequiresKey {
249 t.Fatal("no-auth provider RequiresKey = true, want false")
250 }
251 if !noAuth.Configured {
252 t.Fatal("no-auth provider Configured = false, want true")
253 }
254 if noAuth.KeySet {
255 t.Fatal("no-auth provider KeySet = true, want false")
256 }
257
258 legacyLoopback := providerViewFromEntry(config.ProviderEntry{
259 Name: "local",
260 Kind: "openai",
261 BaseURL: "http://127.0.0.1:23333/v1",
262 Models: []string{"model-a"},
263 APIKeyEnv: "LOCAL_API_KEY",
264 }, false, true)
265 if legacyLoopback.RequiresKey {
266 t.Fatal("loopback provider with missing legacy key env RequiresKey = true, want false")
267 }
268 if !legacyLoopback.Configured {
269 t.Fatal("loopback provider with missing legacy key env Configured = false, want true")
270 }
271
272 official := providerViewFromEntry(config.ProviderEntry{
273 Name: "deepseek",
274 Kind: "openai",
275 BaseURL: "https://api.deepseek.com",
276 Models: []string{"deepseek-v4-flash"},
277 }, true, true)
278 if !official.RequiresKey {
279 t.Fatal("official provider RequiresKey = false, want true")
280 }
281 if official.Configured {
282 t.Fatal("official provider without key Configured = true, want false")
283 }
284 }
285
286 func TestSetProviderKeyDoesNotWarnWhenProjectEnvAlsoDefinesSavedKey(t *testing.T) {
287 isolateDesktopUserDirs(t)
288 project := t.TempDir()
289 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("TEST_PROVIDER_SHADOW=old-key\n"), 0o600); err != nil {
290 t.Fatal(err)
291 }
292 t.Setenv("TEST_PROVIDER_SHADOW", "")
293 os.Unsetenv("TEST_PROVIDER_SHADOW")
294
295 app := &App{
296 tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}},
297 activeTabID: "project",
298 }
299 warning, err := app.SetProviderKey("TEST_PROVIDER_SHADOW", "new-key")
300 if err != nil {
301 t.Fatalf("SetProviderKey: %v", err)
302 }
303 if warning != "" {
304 t.Fatalf("SetProviderKey warning = %q, want no warning because provider keys use global credentials only", warning)
305 }
306 data, readErr := os.ReadFile(config.UserCredentialsPath())
307 if readErr != nil {
308 t.Fatalf("read credentials: %v", readErr)
309 }
310 if !strings.Contains(string(data), "TEST_PROVIDER_SHADOW=new-key") {
311 t.Fatalf("saved credentials missing new key:\n%s", data)
312 }
313 }
314
315 func TestSetProviderKeyDoesNotWarnWhenEnvironmentAlsoDefinesSavedKey(t *testing.T) {
316 isolateDesktopUserDirs(t)
317 t.Setenv("TEST_PROVIDER_EMPTY_ENV", "")
318
319 app := &App{}
320 warning, err := app.SetProviderKey("TEST_PROVIDER_EMPTY_ENV", "new-key")
321 if err != nil {
322 t.Fatalf("SetProviderKey: %v", err)
323 }
324 if warning != "" {
325 t.Fatalf("SetProviderKey warning = %q, want no warning because provider keys use global credentials only", warning)
326 }
327 data, readErr := os.ReadFile(config.UserCredentialsPath())
328 if readErr != nil {
329 t.Fatalf("read credentials: %v", readErr)
330 }
331 if !strings.Contains(string(data), "TEST_PROVIDER_EMPTY_ENV=new-key") {
332 t.Fatalf("saved credentials missing new key:\n%s", data)
333 }
334 }
335
336 func TestSetProviderKeyDoesNotWarnWhenEmptyProjectEnvAlsoDefinesSavedKey(t *testing.T) {
337 isolateDesktopUserDirs(t)
338 project := t.TempDir()
339 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("TEST_PROVIDER_EMPTY_PROJECT=\n"), 0o600); err != nil {
340 t.Fatal(err)
341 }
342 t.Setenv("TEST_PROVIDER_EMPTY_PROJECT", "")
343 os.Unsetenv("TEST_PROVIDER_EMPTY_PROJECT")
344
345 app := &App{
346 tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}},
347 activeTabID: "project",
348 }
349 warning, err := app.SetProviderKey("TEST_PROVIDER_EMPTY_PROJECT", "new-key")
350 if err != nil {
351 t.Fatalf("SetProviderKey: %v", err)
352 }
353 if warning != "" {
354 t.Fatalf("SetProviderKey warning = %q, want no warning because provider keys use global credentials only", warning)
355 }
356 }
357
358 func TestFetchProviderModelsFiltersNonChatModels(t *testing.T) {
359 isolateDesktopUserDirs(t)
360 if _, err := config.SetCredential("TEST_PROVIDER_KEY", "test-key"); err != nil {
361 t.Fatalf("SetCredential: %v", err)
362 }
363 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
364 if r.URL.Path != "/models" {
365 http.NotFound(w, r)
366 return
367 }
368 if r.Header.Get("Authorization") != "Bearer test-key" {
369 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
370 return
371 }
372 _ = json.NewEncoder(w).Encode(map[string]any{
373 "object": "list",
374 "data": []map[string]string{
375 {"id": "mimo-v2.5-pro", "object": "model"},
376 {"id": "mimo-v2.5-asr", "object": "model"},
377 {"id": "mimo-v2.5-tts", "object": "model"},
378 },
379 })
380 }))
381 defer srv.Close()
382
383 got, err := NewApp().FetchProviderModels(ProviderView{
384 Name: "mimo-api",
385 BaseURL: srv.URL,
386 APIKeyEnv: "TEST_PROVIDER_KEY",
387 })
388 if err != nil {
389 t.Fatalf("FetchProviderModels: %v", err)
390 }
391 want := []string{"mimo-v2.5-pro"}
392 if !reflect.DeepEqual(got, want) {
393 t.Errorf("FetchProviderModels = %v, want %v", got, want)
394 }
395 }
396
397 func TestFetchProviderModelsUsesSavedCredentialBeforeEnvironment(t *testing.T) {
398 isolateDesktopUserDirs(t)
399 const keyEnv = "TEST_PROVIDER_FETCH_KEY"
400 if _, err := config.SetCredential(keyEnv, "saved-key"); err != nil {
401 t.Fatalf("SetCredential: %v", err)
402 }
403 t.Setenv(keyEnv, "stale-env-key")
404
405 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
406 if r.URL.Path != "/models" {
407 http.NotFound(w, r)
408 return
409 }
410 if r.Header.Get("Authorization") != "Bearer saved-key" {
411 http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
412 return
413 }
414 _ = json.NewEncoder(w).Encode(map[string]any{
415 "object": "list",
416 "data": []map[string]string{
417 {"id": "model-a", "object": "model"},
418 },
419 })
420 }))
421 defer srv.Close()
422
423 got, err := NewApp().FetchProviderModels(ProviderView{
424 Name: "custom",
425 BaseURL: srv.URL,
426 APIKeyEnv: keyEnv,
427 })
428 if err != nil {
429 t.Fatalf("FetchProviderModels: %v", err)
430 }
431 if want := []string{"model-a"}; !reflect.DeepEqual(got, want) {
432 t.Errorf("FetchProviderModels = %v, want %v", got, want)
433 }
434 if got := os.Getenv(keyEnv); got != "stale-env-key" {
435 t.Fatalf("process env = %q, want stale env left untouched", got)
436 }
437 }
438
439 func TestFetchAllProviderModelsOmitsFailuresWithoutJSONNulls(t *testing.T) {
440 good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
441 _ = json.NewEncoder(w).Encode(map[string]any{
442 "object": "list",
443 "data": []map[string]string{{"id": "model-a", "object": "model"}},
444 })
445 }))
446 defer good.Close()
447 bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
448 http.Error(w, `{"error":"temporary"}`, http.StatusServiceUnavailable)
449 }))
450 defer bad.Close()
451
452 got := NewApp().FetchAllProviderModels([]ProviderView{
453 {Name: "good", Kind: "openai", BaseURL: good.URL},
454 {Name: "bad", Kind: "openai", BaseURL: bad.URL},
455 })
456 if want := []string{"model-a"}; !reflect.DeepEqual(got["good"], want) {
457 t.Fatalf("good provider models = %v, want %v", got["good"], want)
458 }
459 if _, ok := got["bad"]; ok {
460 t.Fatalf("failed provider unexpectedly present: %#v", got)
461 }
462 raw, err := json.Marshal(got)
463 if err != nil {
464 t.Fatalf("marshal batch result: %v", err)
465 }
466 if strings.Contains(string(raw), "null") {
467 t.Fatalf("batch result contains JSON null: %s", raw)
468 }
469 }
470
471 func TestSaveProviderFiltersNonChatModels(t *testing.T) {
472 isolateDesktopUserDirs(t)
473
474 app := NewApp()
475 if err := app.SaveProvider(ProviderView{
476 Name: "mimo-api",
477 Kind: "openai",
478 BaseURL: "https://api.xiaomimimo.com/v1",
479 Models: []string{"mimo-v2.5-asr", "mimo-v2.5-pro", "mimo-v2.5-tts"},
480 VisionModels: []string{
481 "mimo-v2.5-asr",
482 "mimo-v2.5-pro",
483 "mimo-v2.5-tts",
484 },
485 VisionModelsSet: true,
486 Default: "mimo-v2.5-asr",
487 APIKeyEnv: "MIMO_API_KEY",
488 }); err != nil {
489 t.Fatalf("SaveProvider: %v", err)
490 }
491
492 cfg := config.LoadForEdit(config.UserConfigPath())
493 got, ok := cfg.Provider("mimo-api")
494 if !ok {
495 t.Fatal("saved provider not found")
496 }
497 want := []string{"mimo-v2.5-pro"}
498 if !reflect.DeepEqual(got.ModelList(), want) {
499 t.Errorf("saved provider models = %v, want %v", got.ModelList(), want)
500 }
501 if got.DefaultModel() != "mimo-v2.5-pro" {
502 t.Errorf("saved provider default = %q, want mimo-v2.5-pro", got.DefaultModel())
503 }
504 if got, want := got.VisionModels, []string{"mimo-v2.5-pro"}; !reflect.DeepEqual(got, want) {
505 t.Errorf("saved provider vision_models = %v, want %v", got, want)
506 }
507 raw, err := os.ReadFile(config.UserConfigPath())
508 if err != nil {
509 t.Fatalf("read saved config: %v", err)
510 }
511 saved := string(raw)
512 blockStart := strings.Index(saved, "\n[[providers]]\nname = \"mimo-api\"")
513 if blockStart < 0 {
514 t.Fatalf("saved config missing mimo-api provider block:\n%s", raw)
515 }
516 block := saved[blockStart:]
517 if next := strings.Index(block[len("\n[[providers]]"):], "\n[[providers]]"); next >= 0 {
518 block = block[:len("\n[[providers]]")+next]
519 }
520 if !strings.Contains(block, `models = ["mimo-v2.5-pro"]`) {
521 t.Fatalf("saved provider block did not persist single selection as models array:\n%s", block)
522 }
523 if strings.Contains(block, `model = "mimo-v2.5-pro"`) {
524 t.Fatalf("saved provider block should not persist explicit single selection as legacy model:\n%s", block)
525 }
526 if !strings.Contains(block, `vision_models = ["mimo-v2.5-pro"]`) {
527 t.Fatalf("saved provider block did not persist filtered vision_models:\n%s", block)
528 }
529 }
530
531 func TestSaveProviderModelCatalogsPersistsFreshBatchAtomically(t *testing.T) {
532 isolateDesktopUserDirs(t)
533
534 app := NewApp()
535 providers := []ProviderView{
536 {Name: "batch-a", Kind: "openai", BaseURL: "https://a.example.com/v1", Models: []string{"model-a"}, APIKeyEnv: "BATCH_A_API_KEY", Headers: map[string]string{"X-Tenant": "a"}},
537 {Name: "batch-b", Kind: "openai", BaseURL: "https://b.example.com/v1", Models: []string{"model-b"}, APIKeyEnv: "BATCH_B_API_KEY"},
538 }
539 for _, provider := range providers {
540 if err := app.SaveProvider(provider); err != nil {
541 t.Fatalf("SaveProvider(%s): %v", provider.Name, err)
542 }
543 }
544
545 cfg := config.LoadForEdit(config.UserConfigPath())
546 a, _ := cfg.Provider("batch-a")
547 b, _ := cfg.Provider("batch-b")
548 updates := []ProviderModelCatalogUpdate{
549 {Name: "batch-a", ExpectedFingerprint: providerModelCatalogFingerprint(*a), Models: []string{"model-a", "model-a-new"}, Default: "model-a-new"},
550 {Name: "batch-b", ExpectedFingerprint: providerModelCatalogFingerprint(*b), Models: []string{"model-b", "model-b-new"}, Default: "model-b-new"},
551 }
552 applied, err := app.SaveProviderModelCatalogs(updates)
553 if err != nil {
554 t.Fatalf("SaveProviderModelCatalogs: %v", err)
555 }
556 if !reflect.DeepEqual(applied, []string{"batch-a", "batch-b"}) {
557 t.Fatalf("applied = %v, want both providers", applied)
558 }
559
560 cfg = config.LoadForEdit(config.UserConfigPath())
561 a, _ = cfg.Provider("batch-a")
562 b, _ = cfg.Provider("batch-b")
563 if a.DefaultModel() != "model-a-new" || b.DefaultModel() != "model-b-new" {
564 t.Fatalf("catalog defaults = %q/%q, want model-a-new/model-b-new", a.DefaultModel(), b.DefaultModel())
565 }
566 if a.BaseURL != providers[0].BaseURL || a.APIKeyEnv != providers[0].APIKeyEnv || a.Headers["X-Tenant"] != "a" {
567 t.Fatalf("narrow catalog update changed provider identity: %+v", *a)
568 }
569
570 aFingerprint := providerModelCatalogFingerprint(*a)
571 bFingerprint := providerModelCatalogFingerprint(*b)
572 if _, err := app.SaveProviderModelCatalogs([]ProviderModelCatalogUpdate{
573 {Name: "batch-a", ExpectedFingerprint: aFingerprint, Models: []string{"must-not-persist"}},
574 {Name: "batch-b", ExpectedFingerprint: bFingerprint, Models: []string{"text-embedding-3-small"}},
575 }); err == nil {
576 t.Fatal("SaveProviderModelCatalogs invalid batch returned nil error")
577 }
578 cfg = config.LoadForEdit(config.UserConfigPath())
579 a, _ = cfg.Provider("batch-a")
580 if a.DefaultModel() == "must-not-persist" {
581 t.Fatal("SaveProviderModelCatalogs persisted a partial invalid batch")
582 }
583 }
584
585 func TestSaveProviderModelCatalogsRejectsStaleCompletion(t *testing.T) {
586 isolateDesktopUserDirs(t)
587
588 app := NewApp()
589 if err := app.SaveProvider(ProviderView{
590 Name: "race-provider", Kind: "openai", BaseURL: "https://old.example.com/v1",
591 Models: []string{"old-model"}, Default: "old-model", APIKeyEnv: "OLD_API_KEY",
592 Headers: map[string]string{"X-Version": "old"},
593 }); err != nil {
594 t.Fatalf("SaveProvider(old): %v", err)
595 }
596 cfg := config.LoadForEdit(config.UserConfigPath())
597 old, _ := cfg.Provider("race-provider")
598 oldFingerprint := providerModelCatalogFingerprint(*old)
599
600 started := make(chan struct{})
601 release := make(chan struct{})
602 type result struct {
603 applied []string
604 err error
605 }
606 done := make(chan result, 1)
607 go func() {
608 close(started)
609 <-release
610 applied, err := app.SaveProviderModelCatalogs([]ProviderModelCatalogUpdate{{
611 Name: "race-provider", ExpectedFingerprint: oldFingerprint,
612 Models: []string{"old-model", "stale-fetched-model"}, Default: "stale-fetched-model",
613 }})
614 done <- result{applied: applied, err: err}
615 }()
616 <-started
617
618 if err := app.SaveProvider(ProviderView{
619 Name: "race-provider", Kind: "openai", BaseURL: "https://new.example.com/v1",
620 Models: []string{"new-model"}, Default: "new-model", APIKeyEnv: "NEW_API_KEY",
621 Headers: map[string]string{"X-Version": "new"},
622 }); err != nil {
623 t.Fatalf("SaveProvider(new): %v", err)
624 }
625 close(release)
626 gotResult := <-done
627 if gotResult.err != nil {
628 t.Fatalf("stale SaveProviderModelCatalogs: %v", gotResult.err)
629 }
630 if len(gotResult.applied) != 0 {
631 t.Fatalf("stale update applied providers %v, want none", gotResult.applied)
632 }
633
634 cfg = config.LoadForEdit(config.UserConfigPath())
635 got, _ := cfg.Provider("race-provider")
636 if got.BaseURL != "https://new.example.com/v1" || got.APIKeyEnv != "NEW_API_KEY" || got.Headers["X-Version"] != "new" {
637 t.Fatalf("stale completion overwrote provider identity: %+v", *got)
638 }
639 if !reflect.DeepEqual(got.ChatModelList(), []string{"new-model"}) || got.DefaultModel() != "new-model" {
640 t.Fatalf("stale completion overwrote model selection: models=%v default=%q", got.ChatModelList(), got.DefaultModel())
641 }
642 }
643
644 func TestSaveProviderModelCatalogsRejectsStaleCredentialSnapshot(t *testing.T) {
645 isolateDesktopUserDirs(t)
646
647 app := NewApp()
648 if err := app.SaveProvider(ProviderView{
649 Name: "credential-race", Kind: "openai", BaseURL: "https://credential.example.com/v1",
650 Models: []string{"current-model"}, APIKeyEnv: "CREDENTIAL_RACE_API_KEY",
651 }); err != nil {
652 t.Fatalf("SaveProvider: %v", err)
653 }
654 if _, err := app.SaveProviderKey("CREDENTIAL_RACE_API_KEY", "old-key"); err != nil {
655 t.Fatalf("SaveProviderKey(old): %v", err)
656 }
657 cfg := config.LoadForEdit(config.UserConfigPath())
658 provider, _ := cfg.Provider("credential-race")
659 oldFingerprint := providerModelCatalogFingerprint(*provider)
660
661 if _, err := app.SaveProviderKey("CREDENTIAL_RACE_API_KEY", "new-key-with-different-length"); err != nil {
662 t.Fatalf("SaveProviderKey(new): %v", err)
663 }
664 applied, err := app.SaveProviderModelCatalogs([]ProviderModelCatalogUpdate{{
665 Name: "credential-race", ExpectedFingerprint: oldFingerprint,
666 Models: []string{"current-model", "stale-key-model"}, Default: "stale-key-model",
667 }})
668 if err != nil {
669 t.Fatalf("SaveProviderModelCatalogs: %v", err)
670 }
671 if len(applied) != 0 {
672 t.Fatalf("credential-stale update applied providers %v, want none", applied)
673 }
674 cfg = config.LoadForEdit(config.UserConfigPath())
675 provider, _ = cfg.Provider("credential-race")
676 if !reflect.DeepEqual(provider.ChatModelList(), []string{"current-model"}) {
677 t.Fatalf("credential-stale update overwrote models: %v", provider.ChatModelList())
678 }
679 }
680
681 func TestSaveProviderModelCatalogsRejectsOverlappingCredentialRotation(t *testing.T) {
682 isolateDesktopUserDirs(t)
683
684 app := NewApp()
685 const keyEnv = "CREDENTIAL_OVERLAP_API_KEY"
686 if err := app.SaveProvider(ProviderView{
687 Name: "credential-overlap", Kind: "openai", BaseURL: "https://credential.example.com/v1",
688 Models: []string{"current-model"}, APIKeyEnv: keyEnv,
689 }); err != nil {
690 t.Fatalf("SaveProvider: %v", err)
691 }
692 if _, err := app.SaveProviderKey(keyEnv, "old-key"); err != nil {
693 t.Fatalf("SaveProviderKey(old): %v", err)
694 }
695 cfg := config.LoadForEdit(config.UserConfigPath())
696 provider, _ := cfg.Provider("credential-overlap")
697 oldFingerprint := providerModelCatalogFingerprint(*provider)
698
699 snapshotRead := make(chan struct{})
700 releaseApply := make(chan struct{})
701 app.providerCatalogBeforeCredentialLockHook = func(string) {
702 close(snapshotRead)
703 <-releaseApply
704 }
705 type result struct {
706 applied []string
707 err error
708 }
709 catalogDone := make(chan result, 1)
710 go func() {
711 applied, err := app.SaveProviderModelCatalogs([]ProviderModelCatalogUpdate{{
712 Name: "credential-overlap", ExpectedFingerprint: oldFingerprint,
713 Models: []string{"current-model", "stale-key-model"}, Default: "stale-key-model",
714 }})
715 catalogDone <- result{applied: applied, err: err}
716 }()
717 <-snapshotRead
718
719 // Keep the replacement the same length as the old value: revision safety
720 // must come from credential contents and locking, not size or mtime luck.
721 if _, err := app.SaveProviderKey(keyEnv, "new-key"); err != nil {
722 t.Fatalf("SaveProviderKey(new): %v", err)
723 }
724 close(releaseApply)
725 gotResult := <-catalogDone
726 if gotResult.err != nil {
727 t.Fatalf("SaveProviderModelCatalogs: %v", gotResult.err)
728 }
729 if len(gotResult.applied) != 0 {
730 t.Fatalf("credential-stale update applied providers %v, want none", gotResult.applied)
731 }
732
733 cfg = config.LoadForEdit(config.UserConfigPath())
734 provider, _ = cfg.Provider("credential-overlap")
735 if !reflect.DeepEqual(provider.ChatModelList(), []string{"current-model"}) {
736 t.Fatalf("overlapping credential rotation persisted stale models: %v", provider.ChatModelList())
737 }
738 }
739
740 func TestSaveProviderPersistsThinkingOverride(t *testing.T) {
741 isolateDesktopUserDirs(t)
742
743 app := NewApp()
744 if err := app.SaveProvider(ProviderView{
745 Name: "glm-proxy",
746 Kind: "openai",
747 BaseURL: "https://proxy.example.com/v1",
748 Models: []string{"glm-4.5-air"},
749 APIKeyEnv: "GLM_PROXY_API_KEY",
750 Thinking: "DISABLED",
751 }); err != nil {
752 t.Fatalf("SaveProvider: %v", err)
753 }
754
755 cfg := config.LoadForEdit(config.UserConfigPath())
756 got, ok := cfg.Provider("glm-proxy")
757 if !ok {
758 t.Fatal("saved provider not found")
759 }
760 if got.Thinking != "disabled" {
761 t.Fatalf("saved provider thinking = %q, want disabled", got.Thinking)
762 }
763 }
764
765 func TestSaveProviderPersistsAuthHeader(t *testing.T) {
766 isolateDesktopUserDirs(t)
767
768 app := NewApp()
769 if err := app.SaveProvider(ProviderView{
770 Name: "minimax-global-anthropic",
771 Kind: "anthropic",
772 BaseURL: "https://api.minimax.io/anthropic",
773 Models: []string{"MiniMax-M3"},
774 APIKeyEnv: "MINIMAX_API_KEY",
775 AuthHeader: true,
776 }); err != nil {
777 t.Fatalf("SaveProvider: %v", err)
778 }
779
780 cfg := config.LoadForEdit(config.UserConfigPath())
781 got, ok := cfg.Provider("minimax-global-anthropic")
782 if !ok {
783 t.Fatal("saved provider not found")
784 }
785 if !got.AuthHeader {
786 t.Fatal("saved provider auth_header = false, want true")
787 }
788 view := providerViewFromEntry(*got, false, true)
789 if !view.AuthHeader {
790 t.Fatal("provider view authHeader = false, want true")
791 }
792 }
793
794 func TestSaveProviderPersistsCustomEndpointURLs(t *testing.T) {
795 isolateDesktopUserDirs(t)
796
797 app := NewApp()
798 if err := app.SaveProvider(ProviderView{
799 Name: "sub2api",
800 Kind: "openai",
801 BaseURL: "https://proxy.example.com/v1",
802 ChatURL: " https://proxy.example.com/custom/chat/completions ",
803 ModelsURL: " https://proxy.example.com/v1/models ",
804 Models: []string{"model-a"},
805 Default: "model-a",
806 APIKeyEnv: "SUB2API_KEY",
807 }); err != nil {
808 t.Fatalf("SaveProvider: %v", err)
809 }
810
811 cfg := config.LoadForEdit(config.UserConfigPath())
812 got, ok := cfg.Provider("sub2api")
813 if !ok {
814 t.Fatal("saved provider not found")
815 }
816 if got.ChatURL != "https://proxy.example.com/custom/chat/completions" {
817 t.Fatalf("saved chat_url = %q", got.ChatURL)
818 }
819 if got.ModelsURL != "https://proxy.example.com/v1/models" {
820 t.Fatalf("saved models_url = %q", got.ModelsURL)
821 }
822
823 view := app.Settings()
824 for _, provider := range view.Providers {
825 if provider.Name != "sub2api" {
826 continue
827 }
828 if provider.ChatURL != "https://proxy.example.com/custom/chat/completions" {
829 t.Fatalf("Settings chatUrl = %q", provider.ChatURL)
830 }
831 if provider.ModelsURL != "https://proxy.example.com/v1/models" {
832 t.Fatalf("Settings modelsUrl = %q", provider.ModelsURL)
833 }
834 return
835 }
836 t.Fatalf("Settings providers missing sub2api: %+v", view.Providers)
837 }
838
839 func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
840 isolateDesktopUserDirs(t)
841
842 cfg := config.LoadForEdit(config.UserConfigPath())
843 cfg.Providers = []config.ProviderEntry{{
844 Name: "custom",
845 Kind: "openai",
846 BaseURL: "https://proxy.example.com/v1",
847 Models: []string{"model-a", "model-b"},
848 Default: "model-a",
849 APIKeyEnv: "CUSTOM_API_KEY",
850 Price: &provider.Pricing{Input: 1, Output: 2, Currency: "$"},
851 Prices: map[string]*provider.Pricing{"model-b": {Input: 3, Output: 4, Currency: "$"}},
852 Thinking: "adaptive",
853 Effort: "high",
854 VisionDetail: "low",
855 ExtraBody: map[string]any{"enable_thinking": true},
856 NoProxy: true,
857 }}
858 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
859 t.Fatalf("SaveTo: %v", err)
860 }
861
862 app := NewApp()
863 settings := app.Settings()
864 var view ProviderView
865 found := false
866 for _, p := range settings.Providers {
867 if p.Name == "custom" {
868 view = p
869 found = true
870 break
871 }
872 }
873 if !found {
874 t.Fatalf("Settings providers missing custom: %+v", settings.Providers)
875 }
876 if view.ExtraBody["enable_thinking"] != true {
877 t.Fatalf("settings extra_body = %+v, want enable_thinking=true", view.ExtraBody)
878 }
879
880 if err := app.SaveProvider(view); err != nil {
881 t.Fatalf("SaveProvider: %v", err)
882 }
883
884 gotCfg := config.LoadForEdit(config.UserConfigPath())
885 got, ok := gotCfg.Provider("custom")
886 if !ok {
887 t.Fatal("saved provider not found")
888 }
889 if got.Price == nil || got.Price.Input != 1 || got.Price.Output != 2 || got.Price.Currency != "$" {
890 t.Fatalf("provider-wide price = %+v, want preserved", got.Price)
891 }
892 if got.Prices["model-b"] == nil || got.Prices["model-b"].Input != 3 || got.Prices["model-b"].Output != 4 || got.Prices["model-b"].Currency != "$" {
893 t.Fatalf("per-model prices = %+v, want model-b price preserved", got.Prices)
894 }
895 if got.Thinking != "adaptive" || got.Effort != "high" {
896 t.Fatalf("thinking/effort = %q/%q, want adaptive/high", got.Thinking, got.Effort)
897 }
898 if got.VisionDetail != "low" {
899 t.Fatalf("vision_detail = %q, want low", got.VisionDetail)
900 }
901 if got.ExtraBody["enable_thinking"] != true {
902 t.Fatalf("extra_body = %+v, want enable_thinking=true", got.ExtraBody)
903 }
904 if !got.NoProxy {
905 t.Fatal("no_proxy = false, want preserved true")
906 }
907 }
908
909 func TestSaveProviderClearsProviderWideVisionForPerModelSelection(t *testing.T) {
910 isolateDesktopUserDirs(t)
911
912 cfg := config.LoadForEdit(config.UserConfigPath())
913 cfg.Providers = []config.ProviderEntry{{
914 Name: "custom",
915 Kind: "openai",
916 BaseURL: "https://proxy.example.com/v1",
917 Models: []string{"text-only", "qwen-vl-plus"},
918 Default: "text-only",
919 Vision: true,
920 }}
921 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
922 t.Fatalf("SaveTo: %v", err)
923 }
924
925 if err := NewApp().SaveProvider(ProviderView{
926 Name: "custom",
927 Kind: "openai",
928 BaseURL: "https://proxy.example.com/v1",
929 Models: []string{"text-only", "qwen-vl-plus"},
930 VisionModels: []string{"qwen-vl-plus"},
931 VisionModelsSet: true,
932 Default: "text-only",
933 }); err != nil {
934 t.Fatalf("SaveProvider: %v", err)
935 }
936
937 gotCfg := config.LoadForEdit(config.UserConfigPath())
938 got, ok := gotCfg.Provider("custom")
939 if !ok {
940 t.Fatal("saved provider not found")
941 }
942 if got.Vision {
943 t.Fatal("saved provider kept provider-wide vision=true")
944 }
945 if got, want := got.VisionModels, []string{"qwen-vl-plus"}; !reflect.DeepEqual(got, want) {
946 t.Fatalf("saved provider vision_models = %v, want %v", got, want)
947 }
948 textOnly := *got
949 textOnly.Model = "text-only"
950 if config.EffectiveVision(&textOnly) {
951 t.Fatal("unchecked text-only model should not inherit image input")
952 }
953 vision := *got
954 vision.Model = "qwen-vl-plus"
955 if !config.EffectiveVision(&vision) {
956 t.Fatal("checked vision model should keep image input")
957 }
958 }
959
960 func TestSaveProviderPreservesExplicitEmptyVisionModels(t *testing.T) {
961 isolateDesktopUserDirs(t)
962
963 if err := NewApp().SaveProvider(ProviderView{
964 Name: "custom",
965 Kind: "openai",
966 BaseURL: "https://proxy.example.com/v1",
967 Models: []string{"text-only", "qwen-vl-plus"},
968 VisionModels: []string{},
969 VisionModelsSet: true,
970 Default: "text-only",
971 }); err != nil {
972 t.Fatalf("SaveProvider: %v", err)
973 }
974
975 cfg := config.LoadForEdit(config.UserConfigPath())
976 got, ok := cfg.Provider("custom")
977 if !ok {
978 t.Fatal("saved provider not found")
979 }
980 if got.VisionModels == nil || len(got.VisionModels) != 0 {
981 t.Fatalf("saved provider vision_models = %#v, want explicit empty list", got.VisionModels)
982 }
983 raw, err := os.ReadFile(config.UserConfigPath())
984 if err != nil {
985 t.Fatalf("read saved config: %v", err)
986 }
987 if !strings.Contains(string(raw), `vision_models = []`) {
988 t.Fatalf("saved config did not persist explicit empty vision_models:\n%s", raw)
989 }
990 }
991
992 func TestSaveProviderPersistsWebSearchOn(t *testing.T) {
993 isolateDesktopUserDirs(t)
994
995 if err := NewApp().SaveProvider(ProviderView{
996 Name: "deepseek-responses",
997 Kind: "responses",
998 BaseURL: "https://api.deepseek.com",
999 Models: []string{"deepseek-v4-flash"},
1000 Default: "deepseek-v4-flash",
1001 WebSearch: true,
1002 }); err != nil {
1003 t.Fatalf("SaveProvider: %v", err)
1004 }
1005
1006 cfg := config.LoadForEdit(config.UserConfigPath())
1007 got, ok := cfg.Provider("deepseek-responses")
1008 if !ok || got.WebSearch == nil || !*got.WebSearch {
1009 t.Fatalf("saved provider = %+v, found=%v; want web_search=true", got, ok)
1010 }
1011 raw, err := os.ReadFile(config.UserConfigPath())
1012 if err != nil {
1013 t.Fatalf("read saved config: %v", err)
1014 }
1015 if !strings.Contains(string(raw), "web_search = true") {
1016 t.Fatalf("saved config did not persist web_search:\n%s", raw)
1017 }
1018 }
1019
1020 func TestSaveProviderPersistsExplicitWebSearchOff(t *testing.T) {
1021 isolateDesktopUserDirs(t)
1022
1023 if err := NewApp().SaveProvider(ProviderView{
1024 Name: "deepseek-responses",
1025 Kind: "responses",
1026 BaseURL: "https://api.deepseek.com",
1027 Models: []string{"deepseek-v4-flash"},
1028 Default: "deepseek-v4-flash",
1029 WebSearch: false,
1030 }); err != nil {
1031 t.Fatalf("SaveProvider: %v", err)
1032 }
1033
1034 cfg := config.LoadForEdit(config.UserConfigPath())
1035 got, ok := cfg.Provider("deepseek-responses")
1036 if !ok || got.WebSearch == nil || *got.WebSearch || config.EffectiveWebSearch(got) {
1037 t.Fatalf("saved provider = %+v, found=%v; want explicit web_search=false", got, ok)
1038 }
1039 raw, err := os.ReadFile(config.UserConfigPath())
1040 if err != nil {
1041 t.Fatalf("read saved config: %v", err)
1042 }
1043 if !strings.Contains(string(raw), "web_search = false") {
1044 t.Fatalf("saved config did not persist web_search=false:\n%s", raw)
1045 }
1046 }
1047
1048 func TestOfficialMimoAPITemplateRemoved(t *testing.T) {
1049 if entries, keyEnv, err := officialProviderTemplate("mimo-api", "en"); err == nil {
1050 t.Fatalf("officialProviderTemplate(mimo-api) = entries=%v key=%q nil error, want unknown template", entries, keyEnv)
1051 }
1052 }
1053
1054 func TestOfficialDeepSeekTemplateUsesRegionalPricing(t *testing.T) {
1055 for _, tt := range []struct {
1056 language string
1057 currency string
1058 flashOutput float64
1059 proOutput float64
1060 }{
1061 {language: "en", currency: "$", flashOutput: 0.28, proOutput: 0.87},
1062 {language: "zh", currency: "¥", flashOutput: 2, proOutput: 6},
1063 } {
1064 entries, keyEnv, err := officialProviderTemplate("deepseek", tt.language)
1065 if err != nil {
1066 t.Fatalf("officialProviderTemplate(%s): %v", tt.language, err)
1067 }
1068 if keyEnv != "DEEPSEEK_API_KEY" || len(entries) != 1 {
1069 t.Fatalf("template = %v/%q, want one DEEPSEEK_API_KEY entry", entries, keyEnv)
1070 }
1071 got := entries[0]
1072 if price := got.Prices["deepseek-v4-flash"]; price == nil || price.Currency != tt.currency || price.Output != tt.flashOutput {
1073 t.Fatalf("%s deepseek-v4-flash price = %+v", tt.language, price)
1074 }
1075 if price := got.Prices["deepseek-v4-pro"]; price == nil || price.Currency != tt.currency || price.Output != tt.proOutput {
1076 t.Fatalf("%s deepseek-v4-pro price = %+v", tt.language, price)
1077 }
1078 }
1079 }
1080
1081 func TestSetAgentParamsIgnoresDeprecatedStepLimits(t *testing.T) {
1082 isolateDesktopUserDirs(t)
1083
1084 app := NewApp()
1085 if err := app.SetAgentParams(0.35, 37, 9, "custom system"); err != nil {
1086 t.Fatalf("SetAgentParams: %v", err)
1087 }
1088
1089 view := app.Settings()
1090 if view.Agent.MaxSteps != 0 || view.Agent.PlannerMaxSteps != 0 {
1091 t.Fatalf("Settings().Agent = %+v, want deprecated step limits normalized to zero", view.Agent)
1092 }
1093 if view.Agent.Temperature != 0.35 || view.Agent.SystemPrompt != "custom system" {
1094 t.Fatalf("Settings().Agent did not preserve other agent params: %+v", view.Agent)
1095 }
1096
1097 cfg := config.LoadForEdit(config.UserConfigPath())
1098 if cfg.Agent.MaxSteps != 0 || cfg.Agent.PlannerMaxSteps != 0 {
1099 t.Fatalf("saved config agent steps = max:%d planner:%d, want automatic 0/0", cfg.Agent.MaxSteps, cfg.Agent.PlannerMaxSteps)
1100 }
1101 if cfg.Agent.Temperature != 0.35 || cfg.Agent.SystemPrompt != "custom system" {
1102 t.Fatalf("saved config did not preserve other agent params: %+v", cfg.Agent)
1103 }
1104 }
1105
1106 func TestSetReasoningLanguagePersistsToUserConfig(t *testing.T) {
1107 isolateDesktopUserDirs(t)
1108
1109 app := NewApp()
1110 if err := app.SetReasoningLanguage("zh"); err != nil {
1111 t.Fatalf("SetReasoningLanguage: %v", err)
1112 }
1113
1114 view := app.Settings()
1115 if view.Agent.ReasoningLanguage != "zh" {
1116 t.Fatalf("Settings().Agent.ReasoningLanguage = %q, want zh", view.Agent.ReasoningLanguage)
1117 }
1118
1119 cfg := config.LoadForEdit(config.UserConfigPath())
1120 if cfg.Agent.ReasoningLanguage != "zh" || cfg.ReasoningLanguage() != "zh" {
1121 t.Fatalf("saved reasoning language = %q/%q, want zh", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())
1122 }
1123 }
1124
1125 func TestSetCompactRatioPersistsToUserConfig(t *testing.T) {
1126 isolateDesktopUserDirs(t)
1127
1128 app := NewApp()
1129 defaultView := app.Settings()
1130 if defaultView.Agent.CompactRatio != 0.8 || defaultView.Agent.EffectiveCompactRatio != 0.8 {
1131 t.Fatalf("default compact ratios = %v/%v, want 0.8/0.8", defaultView.Agent.CompactRatio, defaultView.Agent.EffectiveCompactRatio)
1132 }
1133 if err := app.SetCompactRatio(0.7); err != nil {
1134 t.Fatalf("SetCompactRatio: %v", err)
1135 }
1136
1137 view := app.Settings()
1138 if view.Agent.CompactRatio != 0.7 {
1139 t.Fatalf("Settings().Agent.CompactRatio = %v, want 0.7", view.Agent.CompactRatio)
1140 }
1141
1142 cfg := config.LoadForEdit(config.UserConfigPath())
1143 if cfg.Agent.CompactRatio != 0.7 {
1144 t.Fatalf("saved compact ratio = %v, want 0.7", cfg.Agent.CompactRatio)
1145 }
1146 if cfg.Agent.ToolResultSnipRatio != 0.6 || cfg.Agent.CompactForceRatio != 0.9 {
1147 t.Fatalf("setting compact ratio changed adjacent thresholds: %+v", cfg.Agent)
1148 }
1149
1150 if err := app.SetCompactRatio(0.9); err == nil {
1151 t.Fatal("SetCompactRatio should reject values outside the Desktop safety range")
1152 }
1153 cfg = config.LoadForEdit(config.UserConfigPath())
1154 if cfg.Agent.CompactRatio != 0.7 {
1155 t.Fatalf("rejected update changed saved compact ratio to %v", cfg.Agent.CompactRatio)
1156 }
1157 }
1158
1159 func TestSetCompactRatioRejectsActiveWorkBeforeSaving(t *testing.T) {
1160 isolateDesktopUserDirs(t)
1161
1162 app := NewApp()
1163 app.setTestCtrl(newBackgroundJobController(t, "compact-ratio-job"), "")
1164 err := app.SetCompactRatio(0.7)
1165 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
1166 t.Fatalf("SetCompactRatio with background job error = %v, want active-work guard", err)
1167 }
1168 if got := config.LoadForEdit(config.UserConfigPath()).Agent.CompactRatio; got != 0.8 {
1169 t.Fatalf("compact ratio changed after rejected update: %v", got)
1170 }
1171 }
1172
1173 func TestSetDesktopLanguagePersistsResponseLanguageAndUpdatesLiveTabs(t *testing.T) {
1174 isolateDesktopUserDirs(t)
1175 projectRoot := t.TempDir()
1176 if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte("language = \"zh\"\n"), 0o644); err != nil {
1177 t.Fatal(err)
1178 }
1179
1180 app := NewApp()
1181 userCtrl := control.New(control.Options{})
1182 projectCtrl := control.New(control.Options{})
1183 app.tabs = map[string]*WorkspaceTab{
1184 "user": {
1185 ID: "user",
1186 Scope: "global",
1187 Ctrl: userCtrl,
1188 Ready: true,
1189 disabledMCP: map[string]ServerView{},
1190 },
1191 "project": {
1192 ID: "project",
1193 Scope: "project",
1194 WorkspaceRoot: projectRoot,
1195 Ctrl: projectCtrl,
1196 Ready: true,
1197 disabledMCP: map[string]ServerView{},
1198 },
1199 }
1200 app.activeTabID = "user"
1201
1202 if err := app.SetDesktopLanguage("en"); err != nil {
1203 t.Fatalf("SetDesktopLanguage: %v", err)
1204 }
1205
1206 cfg := config.LoadForEdit(config.UserConfigPath())
1207 if cfg.DesktopLanguage() != "en" || cfg.Language != "en" {
1208 t.Fatalf("saved language prefs = desktop:%q response:%q, want en/en", cfg.DesktopLanguage(), cfg.Language)
1209 }
1210 got := userCtrl.Compose("解释这个函数")
1211 if !strings.Contains(got, "<response-language>") || !strings.Contains(got, "use English") {
1212 t.Fatalf("live controller Compose = %q, want English response language", got)
1213 }
1214 projectComposed := projectCtrl.Compose("explain this function")
1215 if !strings.Contains(projectComposed, "use Simplified Chinese") {
1216 t.Fatalf("project controller Compose = %q, want project zh response language", projectComposed)
1217 }
1218 }
1219
1220 func TestSetDesktopCurrencyPersistsRegionalOfficialPricing(t *testing.T) {
1221 isolateDesktopUserDirs(t)
1222
1223 app := NewApp()
1224 if err := app.SetDesktopCurrency("CNY"); err != nil {
1225 t.Fatalf("SetDesktopCurrency: %v", err)
1226 }
1227
1228 view := app.Settings()
1229 if view.DesktopCurrency != "CNY" {
1230 t.Fatalf("Settings().DesktopCurrency = %q, want CNY", view.DesktopCurrency)
1231 }
1232 cfg := config.LoadForEdit(config.UserConfigPath())
1233 flash, ok := cfg.Provider("deepseek-flash")
1234 if !ok || flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
1235 t.Fatalf("saved DeepSeek flash price = %+v, want CNY official price", flash)
1236 }
1237 }
1238
1239 func TestSetReasoningLanguageUpdatesLiveTabControllers(t *testing.T) {
1240 isolateDesktopUserDirs(t)
1241 projectRoot := t.TempDir()
1242 if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte("[agent]\nreasoning_language = \"en\"\n"), 0o644); err != nil {
1243 t.Fatal(err)
1244 }
1245
1246 app := NewApp()
1247 userCtrl := control.New(control.Options{ReasoningLanguage: "auto"})
1248 projectCtrl := control.New(control.Options{ReasoningLanguage: "auto"})
1249 app.tabs = map[string]*WorkspaceTab{
1250 "user": {
1251 ID: "user",
1252 Scope: "global",
1253 Ctrl: userCtrl,
1254 Ready: true,
1255 disabledMCP: map[string]ServerView{},
1256 },
1257 "project": {
1258 ID: "project",
1259 Scope: "project",
1260 WorkspaceRoot: projectRoot,
1261 Ctrl: projectCtrl,
1262 Ready: true,
1263 disabledMCP: map[string]ServerView{},
1264 },
1265 }
1266 app.activeTabID = "user"
1267
1268 if err := app.SetReasoningLanguage("zh"); err != nil {
1269 t.Fatalf("SetReasoningLanguage: %v", err)
1270 }
1271
1272 userComposed := userCtrl.Compose("hi")
1273 if !strings.Contains(userComposed, "简体中文") {
1274 t.Fatalf("user-level tab Compose = %q, want zh reasoning language", userComposed)
1275 }
1276 projectComposed := projectCtrl.Compose("hi")
1277 if !strings.Contains(projectComposed, "use English") {
1278 t.Fatalf("project override tab Compose = %q, want en reasoning language", projectComposed)
1279 }
1280 }
1281
1282 func TestSetAutoPlanCompatibilityCannotReenableRetiredFeature(t *testing.T) {
1283 isolateDesktopUserDirs(t)
1284
1285 app := NewApp()
1286 if err := app.SetAutoPlan("off"); err != nil {
1287 t.Fatalf("SetAutoPlan(off): %v", err)
1288 }
1289 if err := app.SetAutoPlan("on"); err == nil || !strings.Contains(err.Error(), "retired") {
1290 t.Fatalf("SetAutoPlan(on) error = %v, want retired error", err)
1291 }
1292 got := config.LoadForEdit(config.UserConfigPath())
1293 if got.Agent.AutoPlan != "off" || got.Agent.AutoPlanClassifier != "" {
1294 t.Fatalf("retired auto-plan state = (%q, %q), want off/empty", got.Agent.AutoPlan, got.Agent.AutoPlanClassifier)
1295 }
1296 }
1297
1298 func TestSetReasoningLanguageRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {
1299 isolateDesktopUserDirs(t)
1300
1301 app := NewApp()
1302 app.setTestCtrl(newBackgroundJobController(t, "reasoning-language-job"), "")
1303
1304 err := app.SetReasoningLanguage("zh")
1305 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
1306 t.Fatalf("SetReasoningLanguage with background job error = %v, want active-work guard", err)
1307 }
1308 cfg := config.LoadForEdit(config.UserConfigPath())
1309 if cfg.ReasoningLanguage() != "auto" {
1310 t.Fatalf("reasoning language changed after rejected update: %q", cfg.ReasoningLanguage())
1311 }
1312 }
1313
1314 func TestSetDesktopCheckUpdatesPersistsToUserConfig(t *testing.T) {
1315 isolateDesktopUserDirs(t)
1316
1317 app := NewApp()
1318 if !app.Settings().CheckUpdates {
1319 t.Fatal("Settings().CheckUpdates default = false, want true")
1320 }
1321 if err := app.SetDesktopCheckUpdates(false); err != nil {
1322 t.Fatalf("SetDesktopCheckUpdates: %v", err)
1323 }
1324 view := app.Settings()
1325 if view.CheckUpdates {
1326 t.Fatal("Settings().CheckUpdates = true, want false")
1327 }
1328 cfg := config.LoadForEdit(config.UserConfigPath())
1329 if cfg.Desktop.CheckUpdates == nil || *cfg.Desktop.CheckUpdates {
1330 t.Fatalf("desktop.check_updates = %+v, want false", cfg.Desktop.CheckUpdates)
1331 }
1332 if cfg.DesktopCheckUpdates() {
1333 t.Fatal("DesktopCheckUpdates() = true, want false")
1334 }
1335 }
1336
1337 func TestSetDesktopUpdateChannelMigratesToStable(t *testing.T) {
1338 isolateDesktopUserDirs(t)
1339
1340 app := NewApp()
1341 if got := app.Settings().UpdateChannel; got != "stable" {
1342 t.Fatalf("Settings().UpdateChannel default = %q, want stable", got)
1343 }
1344 if err := app.SetDesktopUpdateChannel("canary"); err != nil {
1345 t.Fatalf("SetDesktopUpdateChannel: %v", err)
1346 }
1347 view := app.Settings()
1348 if view.UpdateChannel != "stable" {
1349 t.Fatalf("Settings().UpdateChannel = %q, want stable", view.UpdateChannel)
1350 }
1351 cfg := config.LoadForEdit(config.UserConfigPath())
1352 if cfg.Desktop.UpdateChannel != "" {
1353 t.Fatalf("desktop.update_channel = %q, want omitted legacy field", cfg.Desktop.UpdateChannel)
1354 }
1355 if cfg.DesktopUpdateChannel() != "stable" {
1356 t.Fatalf("DesktopUpdateChannel() = %q, want stable", cfg.DesktopUpdateChannel())
1357 }
1358 }
1359
1360 func TestSetDesktopConversationWidthPersistsToUserConfig(t *testing.T) {
1361 isolateDesktopUserDirs(t)
1362
1363 app := NewApp()
1364 if got := app.Settings().ConversationWidth; got != "standard" {
1365 t.Fatalf("Settings().ConversationWidth default = %q, want standard", got)
1366 }
1367 if got := app.DesktopStartupSettings().ConversationWidth; got != "standard" {
1368 t.Fatalf("DesktopStartupSettings().ConversationWidth default = %q, want standard", got)
1369 }
1370 if err := app.SetDesktopConversationWidth("full"); err != nil {
1371 t.Fatalf("SetDesktopConversationWidth: %v", err)
1372 }
1373 if got := app.Settings().ConversationWidth; got != "full" {
1374 t.Fatalf("Settings().ConversationWidth = %q, want full", got)
1375 }
1376 if got := app.DesktopStartupSettings().ConversationWidth; got != "full" {
1377 t.Fatalf("DesktopStartupSettings().ConversationWidth = %q, want full", got)
1378 }
1379 cfg := config.LoadForEdit(config.UserConfigPath())
1380 if got := cfg.DesktopConversationWidth(); got != "full" {
1381 t.Fatalf("persisted conversation width = %q, want full", got)
1382 }
1383
1384 if err := app.SetDesktopConversationWidth("wide"); err == nil {
1385 t.Fatal("SetDesktopConversationWidth(wide) unexpectedly succeeded")
1386 }
1387 if got := config.LoadForEdit(config.UserConfigPath()).DesktopConversationWidth(); got != "full" {
1388 t.Fatalf("invalid update changed persisted conversation width to %q", got)
1389 }
1390
1391 raw, err := json.Marshal(app.DesktopStartupSettings())
1392 if err != nil {
1393 t.Fatalf("marshal DesktopStartupSettings: %v", err)
1394 }
1395 if !strings.Contains(string(raw), `"conversationWidth":"full"`) {
1396 t.Fatalf("startup bridge payload omitted conversationWidth: %s", raw)
1397 }
1398 }
1399
1400 func TestSetDefaultToolApprovalModePersistsToUserConfig(t *testing.T) {
1401 isolateDesktopUserDirs(t)
1402
1403 app := NewApp()
1404 if app.Settings().DefaultToolApprovalMode != control.ToolApprovalAuto {
1405 t.Fatalf("Settings().DefaultToolApprovalMode = %q, want auto", app.Settings().DefaultToolApprovalMode)
1406 }
1407 if err := app.SetDefaultToolApprovalMode(control.ToolApprovalAuto); err != nil {
1408 t.Fatalf("SetDefaultToolApprovalMode: %v", err)
1409 }
1410 view := app.Settings()
1411 if view.DefaultToolApprovalMode != control.ToolApprovalAuto {
1412 t.Fatalf("Settings().DefaultToolApprovalMode = %q, want auto", view.DefaultToolApprovalMode)
1413 }
1414 cfg := config.LoadForEdit(config.UserConfigPath())
1415 if cfg.Desktop.DefaultToolApprovalMode != control.ToolApprovalAuto {
1416 t.Fatalf("desktop.default_tool_approval_mode = %q, want auto", cfg.Desktop.DefaultToolApprovalMode)
1417 }
1418 if cfg.DesktopDefaultToolApprovalMode() != control.ToolApprovalAuto {
1419 t.Fatalf("DesktopDefaultToolApprovalMode() = %q, want auto", cfg.DesktopDefaultToolApprovalMode())
1420 }
1421 }
1422
1423 func TestRetiredAutoRecoveryCheckpointSettingsAreNoOps(t *testing.T) {
1424 isolateDesktopUserDirs(t)
1425
1426 cfgPath := config.UserConfigPath()
1427 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
1428 t.Fatalf("mkdir config: %v", err)
1429 }
1430 if err := os.WriteFile(cfgPath, []byte("[agent]\nauto_recovery_checkpoint = \"off\"\n"), 0o644); err != nil {
1431 t.Fatalf("write config: %v", err)
1432 }
1433 app := NewApp()
1434 if err := app.SetDefaultAutoRecoveryCheckpoint(false); err != nil {
1435 t.Fatalf("legacy setter: %v", err)
1436 }
1437 if !app.RecoveryCheckpointEnabled() || !app.RecoveryCheckpointEnabledTab("legacy") {
1438 t.Fatal("retired config or legacy setter disabled built-in Auto Guard")
1439 }
1440 }
1441
1442 func TestSetDesktopMetricsDefaultsOnAndPersistsOff(t *testing.T) {
1443 isolateDesktopUserDirs(t)
1444
1445 app := NewApp()
1446 if !app.Settings().Metrics {
1447 t.Fatal("Settings().Metrics default = false, want true")
1448 }
1449 if err := app.SetDesktopMetrics(false); err != nil {
1450 t.Fatalf("SetDesktopMetrics: %v", err)
1451 }
1452 view := app.Settings()
1453 if view.Metrics {
1454 t.Fatal("Settings().Metrics = true, want false")
1455 }
1456 cfg := config.LoadForEdit(config.UserConfigPath())
1457 if cfg.Desktop.Metrics == nil || *cfg.Desktop.Metrics {
1458 t.Fatalf("desktop.metrics = %+v, want false", cfg.Desktop.Metrics)
1459 }
1460 if cfg.DesktopMetrics() {
1461 t.Fatal("DesktopMetrics() = true, want false")
1462 }
1463 }
1464
1465 func TestSaveHooksSettingsPreservesUnknownSettingsKeys(t *testing.T) {
1466 isolateDesktopUserDirs(t)
1467 path := hook.GlobalSettingsPath("")
1468 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
1469 t.Fatal(err)
1470 }
1471 if err := os.WriteFile(path, []byte(`{"theme":"dark","hooks":{"Stop":[{"command":"old"}]}}`), 0o644); err != nil {
1472 t.Fatal(err)
1473 }
1474
1475 app := NewApp()
1476 if err := app.SaveHooksSettings("global", []HookConfigView{{
1477 Event: string(hook.PreToolUse),
1478 Match: "bash",
1479 Command: "echo guard",
1480 }}); err != nil {
1481 t.Fatalf("SaveHooksSettings: %v", err)
1482 }
1483
1484 var raw map[string]json.RawMessage
1485 body, err := os.ReadFile(path)
1486 if err != nil {
1487 t.Fatal(err)
1488 }
1489 if err := json.Unmarshal(body, &raw); err != nil {
1490 t.Fatal(err)
1491 }
1492 if string(raw["theme"]) != `"dark"` {
1493 t.Fatalf("theme key was not preserved: %s", raw["theme"])
1494 }
1495 view := app.HooksSettings("global")
1496 if len(view.Hooks) != 1 || view.Hooks[0].Event != string(hook.PreToolUse) || view.Hooks[0].Command != "echo guard" {
1497 t.Fatalf("HooksSettings = %+v, want saved PreToolUse hook", view)
1498 }
1499 }
1500
1501 func TestSaveHooksSettingsDecodesLegacyEncodedGlobalSettings(t *testing.T) {
1502 isolateDesktopUserDirs(t)
1503 path := hook.GlobalSettingsPath("")
1504 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
1505 t.Fatal(err)
1506 }
1507 legacy := `{"label":"中文","hooks":{"Stop":[{"command":"echo 旧"}]}}`
1508 if err := os.WriteFile(path, fileencoding.Encode(legacy, fileencoding.GB18030), 0o644); err != nil {
1509 t.Fatal(err)
1510 }
1511
1512 app := NewApp()
1513 before := app.HooksSettings("global")
1514 if len(before.Hooks) != 1 || before.Hooks[0].Command != "echo 旧" {
1515 t.Fatalf("HooksSettings before save = %+v, want decoded legacy hook", before.Hooks)
1516 }
1517 if err := app.SaveHooksSettings("global", []HookConfigView{{
1518 Event: string(hook.PreToolUse),
1519 Command: "echo 新",
1520 }}); err != nil {
1521 t.Fatalf("SaveHooksSettings: %v", err)
1522 }
1523
1524 body, err := os.ReadFile(path)
1525 if err != nil {
1526 t.Fatal(err)
1527 }
1528 var raw map[string]json.RawMessage
1529 if err := json.Unmarshal(body, &raw); err != nil {
1530 t.Fatalf("saved settings should be valid UTF-8 JSON: %v", err)
1531 }
1532 if string(raw["label"]) != `"中文"` {
1533 t.Fatalf("label key was not preserved after decoding legacy settings: %s", raw["label"])
1534 }
1535 view := app.HooksSettings("global")
1536 if len(view.Hooks) != 1 || view.Hooks[0].Command != "echo 新" {
1537 t.Fatalf("HooksSettings after save = %+v, want new decoded hook", view.Hooks)
1538 }
1539 }
1540
1541 func TestSaveHooksSettingsNormalizesQuotedNodeEvalHookCommand(t *testing.T) {
1542 isolateDesktopUserDirs(t)
1543 script := "const payload = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(payload.toolName)"
1544 bad := `node -e "\"` + script + `\""`
1545 want := hook.NormalizeCommand(bad)
1546 if want == bad {
1547 t.Fatal("test command did not normalize")
1548 }
1549
1550 app := NewApp()
1551 if err := app.SaveHooksSettings("global", []HookConfigView{{
1552 Event: string(hook.PreToolUse),
1553 Match: "bash",
1554 Command: bad,
1555 }}); err != nil {
1556 t.Fatalf("SaveHooksSettings: %v", err)
1557 }
1558
1559 view := app.HooksSettings("global")
1560 if len(view.Hooks) != 1 || view.Hooks[0].Command != want {
1561 t.Fatalf("HooksSettings = %+v, want normalized command %q", view.Hooks, want)
1562 }
1563 }
1564
1565 func TestProjectHooksSettingsUseActiveWorkspaceRootAndLoadByDefault(t *testing.T) {
1566 isolateDesktopUserDirs(t)
1567 project := t.TempDir()
1568 app := NewApp()
1569 app.tabs = map[string]*WorkspaceTab{
1570 "project": {ID: "project", Scope: "project", WorkspaceRoot: project, Ready: true},
1571 }
1572 app.activeTabID = "project"
1573
1574 if err := app.SaveHooksSettings("project", []HookConfigView{{
1575 Event: string(hook.Stop),
1576 Command: "echo done",
1577 Description: "Turn done",
1578 }}); err != nil {
1579 t.Fatalf("SaveHooksSettings(project): %v", err)
1580 }
1581 view := app.HooksSettings("project")
1582 if view.Scope != "project" || view.ProjectRoot != project || !view.Trusted {
1583 t.Fatalf("project hook view metadata = %+v", view)
1584 }
1585 if len(view.Hooks) != 1 || view.Hooks[0].Event != string(hook.Stop) || view.Hooks[0].Description != "Turn done" {
1586 t.Fatalf("project hooks = %+v", view.Hooks)
1587 }
1588 if _, err := os.Stat(filepath.Join(project, ".reasonix", "settings.json")); err != nil {
1589 t.Fatalf("project hooks settings file missing: %v", err)
1590 }
1591 loaded := hook.Load(hook.LoadOptions{ProjectRoot: project})
1592 if len(loaded) != 1 || loaded[0].Scope != hook.ScopeProject || loaded[0].Event != hook.Stop {
1593 t.Fatalf("project hooks should load by default: %+v", loaded)
1594 }
1595 }
1596
1597 func TestLegacyTrustProjectHooksMethodsAreNoOps(t *testing.T) {
1598 isolateDesktopUserDirs(t)
1599 app := NewApp()
1600 if err := app.TrustProjectHooks(); err != nil {
1601 t.Fatalf("TrustProjectHooks compatibility call: %v", err)
1602 }
1603 if err := app.TrustProjectHooksForRoot(t.TempDir()); err != nil {
1604 t.Fatalf("TrustProjectHooksForRoot compatibility call: %v", err)
1605 }
1606 }
1607
1608 func TestSaveHooksSettingsForRootUsesDisplayedProjectRoot(t *testing.T) {
1609 isolateDesktopUserDirs(t)
1610 projectA := t.TempDir()
1611 projectB := t.TempDir()
1612 app := NewApp()
1613 app.tabs = map[string]*WorkspaceTab{
1614 "a": {ID: "a", Scope: "project", WorkspaceRoot: projectA, Ready: true},
1615 "b": {ID: "b", Scope: "project", WorkspaceRoot: projectB, Ready: true},
1616 }
1617 app.activeTabID = "b"
1618
1619 if err := app.SaveHooksSettingsForRoot("project", projectA, []HookConfigView{{
1620 Event: string(hook.Stop),
1621 Command: "echo done",
1622 }}); err != nil {
1623 t.Fatalf("SaveHooksSettingsForRoot: %v", err)
1624 }
1625 if _, err := os.Stat(filepath.Join(projectA, ".reasonix", "settings.json")); err != nil {
1626 t.Fatalf("displayed project root settings missing: %v", err)
1627 }
1628 if _, err := os.Stat(filepath.Join(projectB, ".reasonix", "settings.json")); err == nil {
1629 t.Fatal("active project root was written instead of displayed project root")
1630 }
1631 }
1632
1633 // TestLoadDesktopUserConfigForViewDoesNotPersistLegacyProviderAccess locks the
1634 // read-path contract: loading a legacy-form config (configured providers but
1635 // no declared desktop.provider_access) through the View helpers returns a
1636 // normalized in-memory view while leaving the file bytes untouched. The
1637 // on-disk migration only happens once a locked write path runs.
1638 func TestLoadDesktopUserConfigForViewDoesNotPersistLegacyProviderAccess(t *testing.T) {
1639 isolateDesktopUserDirs(t)
1640 userPath := config.UserConfigPath()
1641 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
1642 t.Fatal(err)
1643 }
1644 legacy := "default_model = \"local/m1\"\n\n[[providers]]\nname = \"local\"\nbase_url = \"http://127.0.0.1:9999/v1\"\nmodels = [\"m1\"]\n"
1645 if err := os.WriteFile(userPath, []byte(legacy), 0o644); err != nil {
1646 t.Fatal(err)
1647 }
1648
1649 app := NewApp()
1650 for name, load := range map[string]func() (*config.Config, string, error){
1651 "view": app.loadDesktopUserConfigForView,
1652 "view-with-credentials": app.loadDesktopUserConfigForViewWithCredentials,
1653 } {
1654 cfg, _, err := load()
1655 if err != nil {
1656 t.Fatalf("%s load: %v", name, err)
1657 }
1658 if len(cfg.Desktop.ProviderAccess) == 0 {
1659 t.Fatalf("%s load should normalize legacy provider access in memory", name)
1660 }
1661 raw, err := os.ReadFile(userPath)
1662 if err != nil {
1663 t.Fatal(err)
1664 }
1665 if string(raw) != legacy {
1666 t.Fatalf("%s load must not rewrite the user config, got:\n%s", name, raw)
1667 }
1668 }
1669
1670 // The first locked write path persists the pending migration.
1671 if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
1672 t.Fatalf("applyConfigOnly: %v", err)
1673 }
1674 if !configDeclaresProviderAccess(userPath) {
1675 t.Fatal("locked write path should persist the provider access migration to disk")
1676 }
1677 migrated := config.LoadForEditWithoutCredentials(userPath)
1678 if len(migrated.Desktop.ProviderAccess) == 0 {
1679 t.Fatalf("migrated config lost provider access: %v", migrated.Desktop.ProviderAccess)
1680 }
1681 }
1682
1683 // TestLoadDesktopUserConfigViewKeepsLegacyBotConfigMigrationInMemory locks the
1684 // same contract for the legacy bot-config migration: read paths (including the
1685 // bot runtime's credential-loading view) see the merged bot config in memory
1686 // without any file being written; the locked write path performs the on-disk
1687 // migration.
1688 func TestLoadDesktopUserConfigViewKeepsLegacyBotConfigMigrationInMemory(t *testing.T) {
1689 isolateDesktopUserDirs(t)
1690 userPath := config.UserConfigPath()
1691 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
1692 t.Fatal(err)
1693 }
1694 userBody := "default_model = \"local/m1\"\n"
1695 if err := os.WriteFile(userPath, []byte(userBody), 0o644); err != nil {
1696 t.Fatal(err)
1697 }
1698 legacyRoot := t.TempDir()
1699 legacyPath := filepath.Join(legacyRoot, "reasonix.toml")
1700 legacyBody := "[bot]\nenabled = true\nmodel = \"local/m1\"\n"
1701 if err := os.WriteFile(legacyPath, []byte(legacyBody), 0o644); err != nil {
1702 t.Fatal(err)
1703 }
1704
1705 app := NewApp()
1706 app.tabs = map[string]*WorkspaceTab{
1707 "t": {ID: "t", Scope: "project", WorkspaceRoot: legacyRoot, Ready: true},
1708 }
1709 app.activeTabID = "t"
1710
1711 assertFilesUntouched := func(step string) {
1712 t.Helper()
1713 rawUser, err := os.ReadFile(userPath)
1714 if err != nil {
1715 t.Fatal(err)
1716 }
1717 if string(rawUser) != userBody {
1718 t.Fatalf("%s must not rewrite the user config, got:\n%s", step, rawUser)
1719 }
1720 rawLegacy, err := os.ReadFile(legacyPath)
1721 if err != nil {
1722 t.Fatal(err)
1723 }
1724 if string(rawLegacy) != legacyBody {
1725 t.Fatalf("%s must not rewrite the legacy config, got:\n%s", step, rawLegacy)
1726 }
1727 }
1728
1729 cfg, _, err := app.loadDesktopUserConfigForView()
1730 if err != nil {
1731 t.Fatalf("loadDesktopUserConfigForView: %v", err)
1732 }
1733 if !cfg.Bot.Enabled {
1734 t.Fatal("view load should merge the legacy bot config in memory")
1735 }
1736 assertFilesUntouched("loadDesktopUserConfigForView")
1737
1738 botCfg, err := app.loadDesktopBotConfig()
1739 if err != nil {
1740 t.Fatalf("loadDesktopBotConfig: %v", err)
1741 }
1742 if !botCfg.Bot.Enabled {
1743 t.Fatal("bot runtime load should see the merged legacy bot config")
1744 }
1745 assertFilesUntouched("loadDesktopBotConfig")
1746
1747 // The first locked write path migrates the bot config into the user file.
1748 if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
1749 t.Fatalf("applyConfigOnly: %v", err)
1750 }
1751 migrated := config.LoadForEditWithoutCredentials(userPath)
1752 if !migrated.Bot.Enabled {
1753 t.Fatal("locked write path should persist the legacy bot config migration")
1754 }
1755 rawLegacy, err := os.ReadFile(legacyPath)
1756 if err != nil {
1757 t.Fatal(err)
1758 }
1759 if string(rawLegacy) != legacyBody {
1760 t.Fatalf("migration must not rewrite the legacy config, got:\n%s", rawLegacy)
1761 }
1762 }
1763
1764 func TestLoadDesktopUserConfigForRootDoesNotFollowActiveTab(t *testing.T) {
1765 isolateDesktopUserDirs(t)
1766 userPath := config.UserConfigPath()
1767 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
1768 t.Fatal(err)
1769 }
1770 if err := os.WriteFile(userPath, []byte("default_model = \"local/m1\"\n"), 0o644); err != nil {
1771 t.Fatal(err)
1772 }
1773 targetRoot := t.TempDir()
1774 activeRoot := t.TempDir()
1775 if err := os.WriteFile(filepath.Join(targetRoot, "reasonix.toml"), []byte("[bot]\nenabled = true\nmodel = \"target\"\n"), 0o644); err != nil {
1776 t.Fatal(err)
1777 }
1778 if err := os.WriteFile(filepath.Join(activeRoot, "reasonix.toml"), []byte("[bot]\nenabled = true\nmodel = \"active\"\n"), 0o644); err != nil {
1779 t.Fatal(err)
1780 }
1781
1782 app := NewApp()
1783 app.tabs = map[string]*WorkspaceTab{
1784 "active": {ID: "active", Scope: "project", WorkspaceRoot: activeRoot, Ready: true},
1785 }
1786 app.activeTabID = "active"
1787
1788 cfg, _, err := app.loadDesktopUserConfigForViewForRoot(targetRoot)
1789 if err != nil {
1790 t.Fatalf("loadDesktopUserConfigForViewForRoot: %v", err)
1791 }
1792 if !cfg.Bot.Enabled || cfg.Bot.Model != "target" {
1793 t.Fatalf("root-specific view followed active tab: bot = %+v", cfg.Bot)
1794 }
1795
1796 unlock := config.LockUserConfigEdits()
1797 _, _, err = app.loadDesktopUserConfigForEditForRoot(targetRoot)
1798 unlock()
1799 if err != nil {
1800 t.Fatalf("loadDesktopUserConfigForEditForRoot: %v", err)
1801 }
1802 migrated := config.LoadForEditWithoutCredentials(userPath)
1803 if !migrated.Bot.Enabled || migrated.Bot.Model != "target" {
1804 t.Fatalf("root-specific edit migrated the active tab instead: bot = %+v", migrated.Bot)
1805 }
1806 }
1807
1808 func TestSetBotSettingsPreservesFeishuOutboundMediaRoots(t *testing.T) {
1809 isolateDesktopUserDirs(t)
1810 root := t.TempDir()
1811 cfg := config.Default()
1812 cfg.Bot.Feishu.OutboundMediaRoots = []string{root}
1813 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1814 t.Fatalf("save initial config: %v", err)
1815 }
1816
1817 app := NewApp()
1818 view := botSettingsView(cfg.Bot)
1819 view.QueueCap++
1820 if err := app.SetBotSettings(view); err != nil {
1821 t.Fatalf("SetBotSettings: %v", err)
1822 }
1823
1824 got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
1825 if !reflect.DeepEqual(got.Bot.Feishu.OutboundMediaRoots, []string{root}) {
1826 t.Fatalf("outbound media roots = %v, want preserved %q", got.Bot.Feishu.OutboundMediaRoots, root)
1827 }
1828 }
1829
1829 lines GO