返回 DeepSeek-Reasonix
new_session_inherit_test.go
根目录 / desktop / new_session_inherit_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/config"
14 "reasonix/internal/control"
15 )
16
17 func requireTabSessionModel(t *testing.T, tab *WorkspaceTab, want string) {
18 t.Helper()
19 if tab == nil || tab.Ctrl == nil || tab.SessionID == "" || tab.SessionPath != "" {
20 t.Fatalf("tab has no exclusive v3 identity: %+v", tab)
21 }
22 identity, ok := tab.Ctrl.(control.IdentityLifecycle)
23 if !ok {
24 t.Fatal("controller has no v3 identity lifecycle")
25 }
26 ref, bound := identity.SessionRef()
27 if !bound || ref.SessionID != tab.SessionID {
28 t.Fatalf("controller identity = %+v, %v; tab session = %q", ref, bound, tab.SessionID)
29 }
30 snapshot, err := identity.SessionService().Query().Snapshot(t.Context(), ref)
31 if err != nil {
32 t.Fatalf("query v3 session: %v", err)
33 }
34 if got := snapshot.Projection.ModelRef; got != want {
35 t.Fatalf("v3 session model = %q, want %q", got, want)
36 }
37 }
38
39 func TestEnsureBlankTabInheritsActiveTabLocalSettings(t *testing.T) {
40 isolateDesktopUserDirs(t)
41 workspace := robustTempDir(t)
42 if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"),
43 []byte(""), 0o644); err != nil {
44 t.Fatalf("write workspace config: %v", err)
45 }
46
47 effort := "max"
48 app := NewApp()
49 src := &WorkspaceTab{
50 ID: "src",
51 Scope: "project",
52 WorkspaceRoot: workspace,
53 TopicID: "topic_src",
54 SessionPath: filepath.Join(workspace, "src.jsonl"), // non-empty so src isn't reused as the blank tab
55 model: "inherit/model",
56 effort: &effort,
57 mode: "plan",
58 toolApprovalMode: control.ToolApprovalYolo,
59 disabledMCP: map[string]ServerView{"srv-x": {}},
60 mcpOrder: []string{"srv-x", "srv-y"},
61 }
62 src.sink = &tabEventSink{tabID: "src", app: app}
63 app.tabs["src"] = src
64 app.tabOrder = []string{"src"}
65 app.activeTabID = "src"
66
67 meta, err := app.EnsureBlankTab("project", workspace)
68 if err != nil {
69 t.Fatalf("EnsureBlankTab: %v", err)
70 }
71 if meta.ID == "" || meta.ID == "src" {
72 t.Fatalf("expected a fresh tab, got meta.ID=%q", meta.ID)
73 }
74
75 created := app.tabs[meta.ID]
76 if created == nil {
77 t.Fatalf("new tab %q missing from app.tabs", meta.ID)
78 }
79 if created.effort == nil || *created.effort != "max" {
80 t.Fatalf("effort = %v, want inherited \"max\"", created.effort)
81 }
82 if created.qualityFloor != "" && created.qualityFloor != control.QualityFloorStandard {
83 t.Fatalf("qualityFloor = %q, want standard default (must not inherit economy)", created.qualityFloor)
84 }
85 if created.toolApprovalMode != control.ToolApprovalAuto {
86 t.Fatalf("toolApprovalMode = %q, want global default auto", created.toolApprovalMode)
87 }
88 if created.mode != "normal" {
89 t.Fatalf("mode = %q, want global default normal", created.mode)
90 }
91 if _, ok := created.disabledMCP["srv-x"]; !ok {
92 t.Fatalf("disabledMCP = %v, want inherited key \"srv-x\"", created.disabledMCP)
93 }
94 if len(created.mcpOrder) != 2 || created.mcpOrder[0] != "srv-x" {
95 t.Fatalf("mcpOrder = %v, want inherited [srv-x srv-y]", created.mcpOrder)
96 }
97 }
98
99 func TestEnsureBlankTabUsesGlobalSessionDefaultsForModelAndToolApproval(t *testing.T) {
100 isolateDesktopUserDirs(t)
101 // Seed the key into Reasonix's user credentials store (the only source
102 // Configured() consults) so the default resolves as configured and is
103 // inherited verbatim regardless of the developer machine's environment.
104 seedUserCredentials(t, "DEEPSEEK_API_KEY=test-key\n")
105 workspace := robustTempDir(t)
106 if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"),
107 []byte(""), 0o644); err != nil {
108 t.Fatalf("write workspace config: %v", err)
109 }
110
111 cfg := config.LoadForEdit(config.UserConfigPath())
112 if err := cfg.SetDefaultModel("deepseek-pro/deepseek-v4-pro"); err != nil {
113 t.Fatalf("SetDefaultModel: %v", err)
114 }
115 if err := cfg.SetDesktopDefaultToolApprovalMode(control.ToolApprovalAuto); err != nil {
116 t.Fatalf("SetDesktopDefaultToolApprovalMode: %v", err)
117 }
118 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
119 t.Fatalf("save user config: %v", err)
120 }
121
122 app := NewApp()
123 src := &WorkspaceTab{
124 ID: "src",
125 Scope: "project",
126 WorkspaceRoot: workspace,
127 TopicID: "topic_src",
128 SessionPath: filepath.Join(workspace, "src.jsonl"),
129 model: "deepseek-flash/deepseek-v4-flash",
130 mode: "plan",
131 toolApprovalMode: control.ToolApprovalAsk,
132 disabledMCP: map[string]ServerView{},
133 }
134 src.sink = &tabEventSink{tabID: "src", app: app}
135 app.tabs["src"] = src
136 app.tabOrder = []string{"src"}
137 app.activeTabID = "src"
138
139 meta, err := app.EnsureBlankTab("project", workspace)
140 if err != nil {
141 t.Fatalf("EnsureBlankTab: %v", err)
142 }
143 created := app.tabs[meta.ID]
144 if created == nil {
145 t.Fatalf("new tab %q missing from app.tabs", meta.ID)
146 }
147 if created.model != "deepseek-pro/deepseek-v4-pro" {
148 t.Fatalf("new tab model = %q, want global default model", created.model)
149 }
150 if created.toolApprovalMode != control.ToolApprovalAuto {
151 t.Fatalf("new tab toolApprovalMode = %q, want global default auto", created.toolApprovalMode)
152 }
153 if src.model != "deepseek-flash/deepseek-v4-flash" || src.toolApprovalMode != control.ToolApprovalAsk {
154 t.Fatalf("existing tab should not be overwritten, got model=%q approval=%q", src.model, src.toolApprovalMode)
155 }
156 requireTabSessionModel(t, created, "deepseek-pro/deepseek-v4-pro")
157 }
158
159 func TestEnsureBlankTabRetargetsReusedSameNameModelToDefaultProvider(t *testing.T) {
160 isolateDesktopUserDirs(t)
161 model, oldRef, defaultRef := configureSameNameModelProviders(t)
162
163 globalRoot := globalWorkspaceRoot()
164 path, err := createEmptySessionFile(desktopSessionDir(globalRoot), model)
165 if err != nil {
166 t.Fatalf("create empty session: %v", err)
167 }
168 if err := agent.SetBranchModelPreserveUpdated(path, oldRef); err != nil {
169 t.Fatalf("seed old session model: %v", err)
170 }
171
172 app := NewApp()
173 app.ctx = context.Background()
174 tab := testTab("reused", globalRoot)
175 tab.Scope = "global"
176 tab.WorkspaceRoot = globalRoot
177 tab.SessionPath = path
178 tab.model = oldRef
179 tab.Ctrl.SetSessionPath(path)
180 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
181 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
182 app.tabOrder = []string{tab.ID}
183 app.activeTabID = tab.ID
184 t.Cleanup(func() {
185 if tab.Ctrl != nil {
186 tab.Ctrl.Close()
187 }
188 tab.releaseSessionLease()
189 })
190
191 meta, err := app.EnsureBlankTab("global", "")
192 if err != nil {
193 t.Fatalf("EnsureBlankTab: %v", err)
194 }
195 if meta.ID != tab.ID {
196 t.Fatalf("reused tab = %q, want %q", meta.ID, tab.ID)
197 }
198 if tab.model != defaultRef || tab.Ctrl.ModelRef() != defaultRef {
199 t.Fatalf("reused runtime model = tab:%q controller:%q, want %q", tab.model, tab.Ctrl.ModelRef(), defaultRef)
200 }
201 requireTabSessionModel(t, tab, defaultRef)
202
203 current := ""
204 for _, info := range app.ModelsForTab(tab.ID) {
205 if info.Current {
206 if current != "" {
207 t.Fatalf("multiple current models: %q and %q", current, info.Ref)
208 }
209 current = info.Ref
210 }
211 }
212 if current != defaultRef {
213 t.Fatalf("model switcher current ref = %q, want %q", current, defaultRef)
214 }
215 }
216
217 func TestEnsureBlankTabRepairsStaleStoredProviderWhenRuntimeAlreadyDefault(t *testing.T) {
218 isolateDesktopUserDirs(t)
219 model, oldRef, defaultRef := configureSameNameModelProviders(t)
220
221 globalRoot := globalWorkspaceRoot()
222 path, err := createEmptySessionFile(desktopSessionDir(globalRoot), model)
223 if err != nil {
224 t.Fatalf("create empty session: %v", err)
225 }
226
227 app := NewApp()
228 app.ctx = context.Background()
229 tab := testTab("stale-meta", globalRoot)
230 tab.Scope = "global"
231 tab.WorkspaceRoot = globalRoot
232 tab.SessionPath = path
233 tab.model = oldRef
234 tab.Ctrl.SetSessionPath(path)
235 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
236 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
237 app.tabOrder = []string{tab.ID}
238 app.activeTabID = tab.ID
239 t.Cleanup(func() {
240 if tab.Ctrl != nil {
241 tab.Ctrl.Close()
242 }
243 tab.releaseSessionLease()
244 })
245
246 if err := app.SetModelForTab(tab.ID, defaultRef); err != nil {
247 t.Fatalf("seed default runtime: %v", err)
248 }
249 if err := agent.SetBranchModelPreserveUpdated(path, oldRef); err != nil {
250 t.Fatalf("seed stale stored model: %v", err)
251 }
252
253 if _, err := app.EnsureBlankTab("global", ""); err != nil {
254 t.Fatalf("EnsureBlankTab: %v", err)
255 }
256 if tab.model != defaultRef || tab.Ctrl.ModelRef() != defaultRef {
257 t.Fatalf("runtime model = tab:%q controller:%q, want %q", tab.model, tab.Ctrl.ModelRef(), defaultRef)
258 }
259 requireTabSessionModel(t, tab, defaultRef)
260 if stored, ok := agent.LoadSessionModel(path); !ok || stored != oldRef {
261 t.Fatalf("legacy source model = %q, %v, want unchanged %q", stored, ok, oldRef)
262 }
263 }
264
265 func TestEnsureBlankTabConcurrentModelSwitchKeepsLastSelection(t *testing.T) {
266 isolateDesktopUserDirs(t)
267 model, oldRef, defaultRef := configureSameNameModelProviders(t)
268
269 globalRoot := globalWorkspaceRoot()
270 path, err := createEmptySessionFile(desktopSessionDir(globalRoot), model)
271 if err != nil {
272 t.Fatalf("create empty session: %v", err)
273 }
274 if err := agent.SetBranchModelPreserveUpdated(path, oldRef); err != nil {
275 t.Fatalf("seed old session model: %v", err)
276 }
277
278 app := NewApp()
279 app.ctx = context.Background()
280 tab := testTab("last-click", globalRoot)
281 tab.Scope = "global"
282 tab.WorkspaceRoot = globalRoot
283 tab.SessionPath = path
284 tab.model = oldRef
285 tab.Ctrl.SetSessionPath(path)
286 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
287 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
288 app.tabOrder = []string{tab.ID}
289 app.activeTabID = tab.ID
290 t.Cleanup(func() {
291 if tab.Ctrl != nil {
292 tab.Ctrl.Close()
293 }
294 tab.releaseSessionLease()
295 })
296
297 firstSwitchReturned := make(chan struct{})
298 releaseFirstSwitch := make(chan struct{})
299 var switchCount atomic.Int32
300 app.modelSwitchTimingHook = func(modelSwitchTiming) {
301 if switchCount.Add(1) == 1 {
302 close(firstSwitchReturned)
303 <-releaseFirstSwitch
304 }
305 }
306
307 ensureDone := make(chan error, 1)
308 go func() {
309 _, err := app.EnsureBlankTab("global", "")
310 ensureDone <- err
311 }()
312
313 select {
314 case <-firstSwitchReturned:
315 case err := <-ensureDone:
316 close(releaseFirstSwitch)
317 t.Fatalf("EnsureBlankTab completed before the default model switch: %v", err)
318 case <-time.After(30 * time.Second):
319 close(releaseFirstSwitch)
320 t.Fatal("timed out waiting for default model switch")
321 }
322
323 // Model selection remains available while EnsureBlankTab is completing.
324 // The explicit second switch is the user's last click and must own both the
325 // live runtime and the persisted provider identity.
326 lastSwitchErr := app.SetModelForTab(tab.ID, oldRef)
327 close(releaseFirstSwitch)
328 if lastSwitchErr != nil {
329 t.Fatalf("last model switch: %v", lastSwitchErr)
330 }
331 select {
332 case err := <-ensureDone:
333 if err != nil {
334 t.Fatalf("EnsureBlankTab: %v", err)
335 }
336 case <-time.After(30 * time.Second):
337 t.Fatal("timed out waiting for EnsureBlankTab")
338 }
339
340 if tab.model != oldRef || tab.Ctrl.ModelRef() != oldRef {
341 t.Fatalf("last selected runtime = tab:%q controller:%q, want %q (default was %q)", tab.model, tab.Ctrl.ModelRef(), oldRef, defaultRef)
342 }
343 requireTabSessionModel(t, tab, oldRef)
344 }
345
346 func TestEnsureBlankTabRestartsStartingBlankWithDefaultProvider(t *testing.T) {
347 isolateDesktopUserDirs(t)
348 model, oldRef, defaultRef := configureSameNameModelProviders(t)
349
350 globalRoot := globalWorkspaceRoot()
351 path, err := createEmptySessionFile(desktopSessionDir(globalRoot), model)
352 if err != nil {
353 t.Fatalf("create empty session: %v", err)
354 }
355 if err := agent.SetBranchModelPreserveUpdated(path, oldRef); err != nil {
356 t.Fatalf("seed old session model: %v", err)
357 }
358
359 cancelled := false
360 app := NewApp()
361 tab := &WorkspaceTab{
362 ID: "starting",
363 Scope: "global",
364 WorkspaceRoot: globalRoot,
365 SessionPath: path,
366 model: oldRef,
367 buildCancel: func() { cancelled = true },
368 disabledMCP: map[string]ServerView{},
369 }
370 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
371 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
372 app.tabOrder = []string{tab.ID}
373 app.activeTabID = tab.ID
374 t.Cleanup(func() {
375 if tab.Ctrl != nil {
376 tab.Ctrl.Close()
377 }
378 tab.releaseSessionLease()
379 })
380
381 meta, err := app.EnsureBlankTab("global", "")
382 if err != nil {
383 t.Fatalf("EnsureBlankTab: %v", err)
384 }
385 if meta.ID != tab.ID || !cancelled {
386 t.Fatalf("starting blank reuse = id:%q cancelled:%v, want %q and cancelled old build", meta.ID, cancelled, tab.ID)
387 }
388 if !tab.Ready || tab.Ctrl == nil || tab.model != defaultRef || tab.Ctrl.ModelRef() != defaultRef {
389 t.Fatalf("restarted blank runtime = ready:%v controller:%v tab:%q, want %q", tab.Ready, tab.Ctrl != nil, tab.model, defaultRef)
390 }
391 requireTabSessionModel(t, tab, defaultRef)
392 }
393
394 func configureSameNameModelProviders(t *testing.T) (model, oldRef, defaultRef string) {
395 t.Helper()
396 seedUserCredentials(t, "DUPLICATE_MODEL_KEY=test-key\n")
397 model = "deepseek-v4-flash-0731"
398 oldRef = "qwen/" + model
399 defaultRef = "token-rhythm/" + model
400 cfg := config.LoadForEdit(config.UserConfigPath())
401 cfg.DefaultModel = defaultRef
402 cfg.Desktop.ProviderAccess = []string{"qwen", "token-rhythm"}
403 cfg.Providers = []config.ProviderEntry{
404 {Name: "qwen", Kind: "openai", BaseURL: "https://qwen.example.invalid/v1", Model: model, APIKeyEnv: "DUPLICATE_MODEL_KEY"},
405 {Name: "token-rhythm", Kind: "openai", BaseURL: "https://token-rhythm.example.invalid/v1", Model: model, APIKeyEnv: "DUPLICATE_MODEL_KEY"},
406 }
407 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
408 t.Fatalf("save config: %v", err)
409 }
410 return model, oldRef, defaultRef
411 }
412
413 func TestDesktopNewSessionDefaultsHonorExplicitAsk(t *testing.T) {
414 isolateDesktopUserDirs(t)
415 cfg := config.LoadForEdit(config.UserConfigPath())
416 if err := cfg.SetDesktopDefaultToolApprovalMode(control.ToolApprovalAsk); err != nil {
417 t.Fatalf("SetDesktopDefaultToolApprovalMode: %v", err)
418 }
419 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
420 t.Fatalf("save user config: %v", err)
421 }
422
423 _, approvalMode := desktopNewSessionDefaults("global", "")
424 if approvalMode != control.ToolApprovalAsk {
425 t.Fatalf("explicit desktop approval default = %q, want ask", approvalMode)
426 }
427 }
428
429 // seedUserCredentials writes key=value lines into Reasonix's user credentials
430 // store. ProviderEntry.Configured() resolves keys only from that store, not
431 // from process env, so tests must seed it explicitly.
432 func seedUserCredentials(t *testing.T, data string) {
433 t.Helper()
434 if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
435 t.Fatalf("mkdir credentials dir: %v", err)
436 }
437 if err := os.WriteFile(config.UserCredentialsPath(), []byte(data), 0o600); err != nil {
438 t.Fatalf("write user credentials: %v", err)
439 }
440 }
441
442 func TestDesktopNewSessionDefaultsSkipsKeylessDefaultModel(t *testing.T) {
443 isolateDesktopUserDirs(t)
444 seedUserCredentials(t, "REASONIX_TEST_SESSION_KEY=test-key\n")
445
446 cfg := config.LoadForEdit(config.UserConfigPath())
447 cfg.Providers = append(cfg.Providers, config.ProviderEntry{
448 Name: "test-prov",
449 Kind: "openai",
450 BaseURL: "https://example.com",
451 Model: "test-model",
452 APIKeyEnv: "REASONIX_TEST_SESSION_KEY",
453 })
454 if err := cfg.SetDefaultModel("deepseek-pro/deepseek-v4-pro"); err != nil {
455 t.Fatalf("SetDefaultModel: %v", err)
456 }
457 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
458 t.Fatalf("save user config: %v", err)
459 }
460
461 model, _ := desktopNewSessionDefaults("global", "")
462 if model != "test-prov/test-model" {
463 t.Fatalf("new session model = %q, want fallback to configured provider test-prov/test-model", model)
464 }
465 }
466
467 func TestDesktopNewSessionDefaultsKeepsConfiguredDefaultModel(t *testing.T) {
468 isolateDesktopUserDirs(t)
469 seedUserCredentials(t, "DEEPSEEK_API_KEY=test-key\n")
470
471 cfg := config.LoadForEdit(config.UserConfigPath())
472 if err := cfg.SetDefaultModel("deepseek-pro/deepseek-v4-pro"); err != nil {
473 t.Fatalf("SetDefaultModel: %v", err)
474 }
475 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
476 t.Fatalf("save user config: %v", err)
477 }
478
479 model, _ := desktopNewSessionDefaults("global", "")
480 if model != "deepseek-pro/deepseek-v4-pro" {
481 t.Fatalf("new session model = %q, want configured default verbatim", model)
482 }
483 }
484
485 func TestDesktopNewSessionDefaultsKeepsKeylessDefaultWhenNothingConfigured(t *testing.T) {
486 isolateDesktopUserDirs(t)
487 // No credentials seeded: every provider resolves as unconfigured.
488
489 cfg := config.LoadForEdit(config.UserConfigPath())
490 if err := cfg.SetDefaultModel("deepseek-pro/deepseek-v4-pro"); err != nil {
491 t.Fatalf("SetDefaultModel: %v", err)
492 }
493 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
494 t.Fatalf("save user config: %v", err)
495 }
496
497 // With no configured provider at all, the raw default must survive so the
498 // boot-time missing-key notice still tells the user what to fix.
499 model, _ := desktopNewSessionDefaults("global", "")
500 if model != "deepseek-pro/deepseek-v4-pro" {
501 t.Fatalf("new session model = %q, want raw keyless default preserved", model)
502 }
503 }
504
505 func TestDesktopNewSessionDefaultsRejectsNonChatDefaultWithoutFallback(t *testing.T) {
506 isolateDesktopUserDirs(t)
507 seedUserCredentials(t, "REASONIX_TEST_AUDIO_KEY=test-key\n")
508
509 cfg := config.LoadForEdit(config.UserConfigPath())
510 cfg.Providers = []config.ProviderEntry{{
511 Name: "audio",
512 Kind: "openai",
513 BaseURL: "https://audio.example.com",
514 Model: "tts-1",
515 APIKeyEnv: "REASONIX_TEST_AUDIO_KEY",
516 }}
517 cfg.Desktop.ProviderAccess = []string{"audio"}
518 if err := cfg.SetDefaultModel("audio/tts-1"); err != nil {
519 t.Fatalf("SetDefaultModel: %v", err)
520 }
521 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
522 t.Fatalf("save user config: %v", err)
523 }
524
525 app := NewApp()
526 _, err := app.EnsureBlankTab("global", "")
527 if err == nil || !strings.Contains(err.Error(), "chat-capable provider") {
528 t.Fatalf("EnsureBlankTab error = %v, want actionable no-chat-model error", err)
529 }
530 created := app.activeTabLocked()
531 if created.model != "" {
532 t.Fatalf("new session model = %q, want no ineligible model selected", created.model)
533 }
534 if created.Ctrl != nil || !strings.Contains(created.StartupErr, "chat-capable provider") {
535 t.Fatalf("new session runtime = (%v, %q), want an actionable no-chat-model startup error", created.Ctrl, created.StartupErr)
536 }
537 }
538
539 func TestDesktopNewSessionDefaultsHonorExplicitEmptyProviderAccess(t *testing.T) {
540 isolateDesktopUserDirs(t)
541 seedUserCredentials(t, "REASONIX_TEST_SESSION_KEY=test-key\n")
542
543 cfg := config.LoadForEdit(config.UserConfigPath())
544 cfg.Providers = []config.ProviderEntry{{
545 Name: "test-prov",
546 Kind: "openai",
547 BaseURL: "https://example.com",
548 Model: "test-model",
549 APIKeyEnv: "REASONIX_TEST_SESSION_KEY",
550 }}
551 cfg.Desktop.ProviderAccess = []string{}
552 if err := cfg.SetDefaultModel("test-prov/test-model"); err != nil {
553 t.Fatalf("SetDefaultModel: %v", err)
554 }
555 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
556 t.Fatalf("save user config: %v", err)
557 }
558
559 app := NewApp()
560 _, err := app.EnsureBlankTab("global", "")
561 if err == nil || !strings.Contains(err.Error(), "chat-capable provider") {
562 t.Fatalf("EnsureBlankTab error = %v, want actionable no-provider error", err)
563 }
564 created := app.activeTabLocked()
565 if created.model != "" {
566 t.Fatalf("new session model = %q, want no provider selected", created.model)
567 }
568 if created.Ctrl != nil || !strings.Contains(created.StartupErr, "chat-capable provider") {
569 t.Fatalf("new session runtime = (%v, %q), want an actionable no-provider startup error", created.Ctrl, created.StartupErr)
570 }
571 if !app.NeedsOnboarding() {
572 t.Fatal("NeedsOnboarding should be true when provider access is explicitly empty")
573 }
574 }
575
576 func TestDesktopNewSessionDefaultsUsesProjectDefaultAndSkipsItsKeylessProvider(t *testing.T) {
577 isolateDesktopUserDirs(t)
578 seedUserCredentials(t, "REASONIX_TEST_SESSION_KEY=test-key\n")
579
580 userCfg := config.LoadForEdit(config.UserConfigPath())
581 userCfg.Providers = append(userCfg.Providers, config.ProviderEntry{
582 Name: "test-prov",
583 Kind: "openai",
584 BaseURL: "https://example.com",
585 Model: "test-model",
586 APIKeyEnv: "REASONIX_TEST_SESSION_KEY",
587 })
588 if err := userCfg.SetDefaultModel("test-prov/test-model"); err != nil {
589 t.Fatalf("SetDefaultModel: %v", err)
590 }
591 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
592 t.Fatalf("save user config: %v", err)
593 }
594
595 workspace := robustTempDir(t)
596 if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(`default_model = "deepseek-pro/deepseek-v4-pro"
597 `), 0o644); err != nil {
598 t.Fatalf("write project config: %v", err)
599 }
600
601 app := NewApp()
602 meta, err := app.EnsureBlankTab("project", workspace)
603 if err != nil {
604 t.Fatalf("EnsureBlankTab: %v", err)
605 }
606 created := app.tabs[meta.ID]
607 if created == nil {
608 t.Fatalf("new tab %q missing from app.tabs", meta.ID)
609 }
610 if created.model != "test-prov/test-model" {
611 t.Fatalf("new project session model = %q, want configured fallback test-prov/test-model", created.model)
612 }
613 requireTabSessionModel(t, created, "test-prov/test-model")
614 }
615
616 func TestSetDefaultModelPreservesExistingSession(t *testing.T) {
617 isolateDesktopUserDirs(t)
618 oldRef, newRef := configureSwitchableDefaultModels(t)
619 cfg := config.LoadForEdit(config.UserConfigPath())
620 cfg.DefaultModel = oldRef
621 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
622 t.Fatalf("seed previous default: %v", err)
623 }
624
625 globalRoot := globalWorkspaceRoot()
626 path, err := createEmptySessionFile(desktopSessionDir(globalRoot), "old-model")
627 if err != nil {
628 t.Fatalf("create empty session: %v", err)
629 }
630 if err := agent.SetBranchModelPreserveUpdated(path, oldRef); err != nil {
631 t.Fatalf("seed old session model: %v", err)
632 }
633
634 app := NewApp()
635 tab := testTab("default-sidecar", globalRoot)
636 tab.Scope = "global"
637 tab.WorkspaceRoot = globalRoot
638 tab.SessionPath = path
639 tab.model = oldRef
640 tab.Ctrl.SetSessionPath(path)
641 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
642 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
643 app.tabOrder = []string{tab.ID}
644 app.activeTabID = tab.ID
645 t.Cleanup(func() {
646 if tab.Ctrl != nil {
647 tab.Ctrl.Close()
648 }
649 })
650
651 if err := app.SetDefaultModel(newRef); err != nil {
652 t.Fatalf("SetDefaultModel: %v", err)
653 }
654 if got := config.LoadForEdit(config.UserConfigPath()).DefaultModel; got != newRef {
655 t.Fatalf("default_model = %q, want %q", got, newRef)
656 }
657 if tab.model != oldRef {
658 t.Fatalf("tab model = %q, want %q", tab.model, oldRef)
659 }
660 if stored, ok := agent.LoadSessionModel(path); !ok || stored != oldRef {
661 t.Fatalf("stored session model = %q, %v, want %q", stored, ok, oldRef)
662 }
663 }
664
665 func TestNewSessionForTabUsesConfiguredDefaultModel(t *testing.T) {
666 isolateDesktopUserDirs(t)
667 oldRef, newRef := configureSwitchableDefaultModels(t)
668 app := NewApp()
669 app.ctx = context.Background()
670 app.tabs = map[string]*WorkspaceTab{}
671 root := globalWorkspaceRoot()
672 tab := modelSettingsBootTab(t, app, "new-session-default", root, oldRef)
673 tab.Scope = "global"
674 app.activeTabID = tab.ID
675 identity := tab.Ctrl.(control.IdentityLifecycle)
676 oldSessionRef, _ := identity.SessionRef()
677
678 if err := app.NewSessionForTab(tab.ID); err != nil {
679 t.Fatalf("NewSessionForTab: %v", err)
680 }
681 if tab.model != newRef {
682 t.Fatalf("new session tab model = %q, want configured default %q", tab.model, newRef)
683 }
684 requireTabSessionModel(t, tab, newRef)
685 oldSnapshot, err := identity.SessionService().Query().Snapshot(t.Context(), oldSessionRef)
686 if err != nil || oldSnapshot.Projection.ModelRef != oldRef {
687 t.Fatalf("previous v3 session model = %q, err=%v, want unchanged %q", oldSnapshot.Projection.ModelRef, err, oldRef)
688 }
689 }
690
691 func TestNewSessionForTabAlignsBlankTabToDefault(t *testing.T) {
692 isolateDesktopUserDirs(t)
693 oldRef, newRef := configureSwitchableDefaultModels(t)
694 cfg := config.LoadForEdit(config.UserConfigPath())
695 cfg.DefaultModel = oldRef
696 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
697 t.Fatal(err)
698 }
699 app := NewApp()
700 app.ctx = context.Background()
701 meta, err := app.EnsureBlankTab("global", "")
702 if err != nil {
703 t.Fatal(err)
704 }
705 tab := app.tabs[meta.ID]
706 t.Cleanup(func() {
707 if tab.Ctrl != nil {
708 tab.Ctrl.Close()
709 }
710 })
711 cfg = config.LoadForEdit(config.UserConfigPath())
712 cfg.DefaultModel = newRef
713 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
714 t.Fatal(err)
715 }
716
717 if err := app.NewSessionForTab(tab.ID); err != nil {
718 t.Fatalf("NewSessionForTab: %v", err)
719 }
720 if tab.model != newRef {
721 t.Fatalf("blank tab model = %q, want configured default %q", tab.model, newRef)
722 }
723 requireTabSessionModel(t, tab, newRef)
724 }
725
726 func configureSwitchableDefaultModels(t *testing.T) (oldRef, newRef string) {
727 t.Helper()
728 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
729 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
730 oldRef = "old/old-model"
731 newRef = "new/new-model"
732 cfg := config.Default()
733 cfg.DefaultModel = newRef
734 cfg.Desktop.ProviderAccess = []string{"old", "new"}
735 cfg.Providers = []config.ProviderEntry{
736 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
737 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
738 }
739 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
740 t.Fatalf("save config: %v", err)
741 }
742 return oldRef, newRef
743 }
744
744 lines GO