| 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 |