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