返回 DeepSeek-Reasonix
app_test.go
根目录 / desktop / app_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "net/http/httptest"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "reflect"
16 "slices"
17 "strconv"
18 "strings"
19 "sync"
20 "sync/atomic"
21 "testing"
22 "time"
23
24 "reasonix/internal/agent"
25 "reasonix/internal/billing"
26 "reasonix/internal/boot"
27 "reasonix/internal/bot"
28 "reasonix/internal/command"
29 "reasonix/internal/config"
30 "reasonix/internal/control"
31 "reasonix/internal/event"
32 "reasonix/internal/evidence"
33 "reasonix/internal/history"
34 "reasonix/internal/instruction"
35 "reasonix/internal/jobs"
36 "reasonix/internal/mcplaunch"
37 "reasonix/internal/memory"
38 "reasonix/internal/plugin"
39 "reasonix/internal/pluginpkg"
40 "reasonix/internal/provider"
41 "reasonix/internal/sandbox"
42 "reasonix/internal/skill"
43 "reasonix/internal/stats"
44 "reasonix/internal/taskcatalog"
45 "reasonix/internal/tool"
46 )
47
48 type todoMetaController struct {
49 stubSessionAPI
50 todos []evidence.TodoItem
51 }
52
53 func (c *todoMetaController) Todos() []evidence.TodoItem {
54 return append([]evidence.TodoItem(nil), c.todos...)
55 }
56
57 func TestCanonicalTodosMetaWireContract(t *testing.T) {
58 if got := ctrlTodos(nil); got != nil {
59 t.Fatalf("nil controller todos = %+v, want unavailable", *got)
60 }
61
62 empty := Meta{CanonicalTodos: ctrlTodos(&todoMetaController{})}
63 raw, err := json.Marshal(empty)
64 if err != nil {
65 t.Fatalf("marshal empty canonical todos: %v", err)
66 }
67 if !strings.Contains(string(raw), `"canonicalTodos":[]`) {
68 t.Fatalf("empty canonical todos must encode as an authoritative empty array: %s", raw)
69 }
70
71 ctrl := &todoMetaController{todos: []evidence.TodoItem{{Content: "Ship", Status: "completed"}}}
72 got := ctrlTodos(ctrl)
73 if got == nil || len(*got) != 1 || (*got)[0].Status != "completed" {
74 t.Fatalf("canonical todos = %+v, want completed task", got)
75 }
76
77 unavailable, err := json.Marshal(Meta{CanonicalTodos: ctrlTodos(nil)})
78 if err != nil {
79 t.Fatalf("marshal unavailable canonical todos: %v", err)
80 }
81 if strings.Contains(string(unavailable), "canonicalTodos") {
82 t.Fatalf("unavailable canonical todos should preserve the legacy fallback contract: %s", unavailable)
83 }
84 }
85
86 func TestPluginToolsToViewPreservesSchemaError(t *testing.T) {
87 got := pluginToolsToView([]plugin.ToolInfo{{
88 Name: "generate_yso_bytes", Description: "Generate payload", ReadOnlyHint: true,
89 SchemaError: "invalid input schema: bad nested type",
90 }})
91 if len(got) != 1 || got[0].SchemaError != "invalid input schema: bad nested type" {
92 t.Fatalf("tool views = %+v", got)
93 }
94 }
95
96 func desktopMCPHTTPServer(t *testing.T) *httptest.Server {
97 return desktopMCPHTTPServerWithTool(t, "h", "greet")
98 }
99
100 func desktopMCPHTTPServerWithTool(t *testing.T, serverName, toolName string) *httptest.Server {
101 t.Helper()
102 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103 var req struct {
104 ID *int `json:"id"`
105 Method string `json:"method"`
106 Params json.RawMessage `json:"params"`
107 }
108 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
109 http.Error(w, "bad body", http.StatusBadRequest)
110 return
111 }
112 if req.ID == nil {
113 w.WriteHeader(http.StatusAccepted)
114 return
115 }
116 var result any
117 switch req.Method {
118 case "initialize":
119 result = map[string]any{
120 "protocolVersion": "2024-11-05",
121 "serverInfo": map[string]any{"name": serverName, "version": "0"},
122 }
123 case "tools/list":
124 result = map[string]any{"tools": []map[string]any{{
125 "name": toolName,
126 "description": "Greet someone.",
127 "inputSchema": map[string]any{"type": "object"},
128 }}}
129 default:
130 result = map[string]any{}
131 }
132 resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
133 w.Header().Set("Content-Type", "application/json")
134 _ = json.NewEncoder(w).Encode(resp)
135 }))
136 }
137
138 func TestDesktopMCPHelperProcess(t *testing.T) {
139 if os.Getenv("GO_WANT_DESKTOP_MCP_HELPER") != "1" {
140 return
141 }
142 if addr := os.Getenv("DESKTOP_MCP_START_GATE_ADDR"); addr != "" {
143 conn, err := net.Dial("tcp", addr)
144 if err != nil {
145 _, _ = fmt.Fprintf(os.Stderr, "connect MCP start gate %s: %v\n", addr, err)
146 os.Exit(24)
147 }
148 var release [1]byte
149 if _, err := io.ReadFull(conn, release[:]); err != nil {
150 _ = conn.Close()
151 _, _ = fmt.Fprintf(os.Stderr, "wait for MCP start gate %s: %v\n", addr, err)
152 os.Exit(25)
153 }
154 _ = conn.Close()
155 }
156 var instanceListener net.Listener
157 if addr := os.Getenv("DESKTOP_MCP_SINGLE_INSTANCE_ADDR"); addr != "" {
158 var err error
159 instanceListener, err = net.Listen("tcp", addr)
160 if err != nil {
161 _, _ = fmt.Fprintf(os.Stderr, "another MCP instance is already using %s: %v\n", addr, err)
162 os.Exit(23)
163 }
164 defer instanceListener.Close()
165 }
166 dec := json.NewDecoder(os.Stdin)
167 enc := json.NewEncoder(os.Stdout)
168 for {
169 var req struct {
170 ID *int `json:"id"`
171 Method string `json:"method"`
172 }
173 if err := dec.Decode(&req); err != nil {
174 if errors.Is(err, io.EOF) {
175 return
176 }
177 t.Fatalf("decode helper request: %v", err)
178 }
179 if req.ID == nil {
180 continue
181 }
182 var result any
183 switch req.Method {
184 case "initialize":
185 result = map[string]any{
186 "protocolVersion": "2024-11-05",
187 "serverInfo": map[string]any{"name": "desktop-helper", "version": "0"},
188 }
189 case "tools/list":
190 result = map[string]any{"tools": []map[string]any{{
191 "name": "greet", "description": "Greet someone.",
192 "inputSchema": map[string]any{"type": "object"},
193 }}}
194 default:
195 result = map[string]any{}
196 }
197 if err := enc.Encode(map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}); err != nil {
198 t.Fatalf("encode helper response: %v", err)
199 }
200 }
201 }
202
203 // setTestCtrl creates a minimal workspace tab (if needed) and sets its
204 // controller, so tests don't depend on the old App.ctrl field.
205 func (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {
206 if len(a.tabs) == 0 {
207 tab := &WorkspaceTab{
208 ID: "test",
209 Scope: "global",
210 Ready: true,
211 disabledMCP: map[string]ServerView{},
212 }
213 a.tabs = map[string]*WorkspaceTab{"test": tab}
214 a.activeTabID = "test"
215 }
216 tab := a.tabs["test"]
217 tab.Ctrl = ctrl
218 a.bindControllerDisplayRecorder(ctrl)
219 tab.model = model
220 }
221
222 func isolateDesktopUserDirs(t *testing.T) string {
223 t.Helper()
224 home := robustTempDir(t)
225 xdg := filepath.Join(home, ".config")
226 appData := filepath.Join(home, "AppData")
227 for _, dir := range []string{xdg, appData} {
228 if err := os.MkdirAll(dir, 0o755); err != nil {
229 t.Fatal(err)
230 }
231 }
232 t.Setenv("HOME", home)
233 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
234 t.Setenv("USERPROFILE", home)
235 t.Setenv("XDG_CONFIG_HOME", xdg)
236 t.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
237 t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
238 t.Setenv("AppData", appData)
239 // Close process-local SQLite handles before TempDir cleanup for Windows.
240 t.Cleanup(func() {
241 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
242 defer cancel()
243 desktopTopicState.close()
244 _ = history.CloseSharedCatalog(ctx)
245 _ = stats.CloseUsageCatalogs(ctx)
246 _ = taskcatalog.ShutdownShared(ctx)
247 })
248 return home
249 }
250
251 func setDesktopTestCredential(t *testing.T, key, value string) {
252 t.Helper()
253 if _, err := config.SetCredential(key, value); err != nil {
254 t.Fatalf("SetCredential(%s): %v", key, err)
255 }
256 }
257
258 func TestNeedsOnboardingIgnoresInheritedEnv(t *testing.T) {
259 isolateDesktopUserDirs(t)
260 t.Setenv(onboardingKeyEnv, "inherited-key")
261
262 app := NewApp()
263 if !app.NeedsOnboarding() {
264 t.Fatal("NeedsOnboarding should require a key saved in Reasonix global .env")
265 }
266 setDesktopTestCredential(t, onboardingKeyEnv, "saved-key")
267 if app.NeedsOnboarding() {
268 t.Fatal("NeedsOnboarding should be false after saving the global credential")
269 }
270 }
271
272 func TestNeedsOnboardingTreatsBlankSavedKeyAsMissing(t *testing.T) {
273 isolateDesktopUserDirs(t)
274 if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
275 t.Fatal(err)
276 }
277 if err := os.WriteFile(config.UserCredentialsPath(), []byte(onboardingKeyEnv+"=\n"), 0o600); err != nil {
278 t.Fatal(err)
279 }
280
281 app := NewApp()
282 if !app.NeedsOnboarding() {
283 t.Fatal("NeedsOnboarding should require a non-empty saved credential")
284 }
285 }
286
287 func TestNeedsOnboardingAcceptsConfiguredCustomProvider(t *testing.T) {
288 isolateDesktopUserDirs(t)
289 cfg := config.Default()
290 cfg.DefaultModel = "custom/custom-model"
291 cfg.Desktop.ProviderAccess = []string{"custom"}
292 cfg.Providers = []config.ProviderEntry{{
293 Name: "custom", Kind: "openai", BaseURL: "https://models.example.invalid/v1",
294 Model: "custom-model", APIKeyEnv: "CUSTOM_API_KEY",
295 }}
296 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
297 t.Fatalf("save custom provider config: %v", err)
298 }
299 setDesktopTestCredential(t, "CUSTOM_API_KEY", "saved-custom-key")
300
301 if NewApp().NeedsOnboarding() {
302 t.Fatal("NeedsOnboarding should be false when a custom provider is configured")
303 }
304 }
305
306 func TestNeedsOnboardingAcceptsNoAuthLocalProvider(t *testing.T) {
307 isolateDesktopUserDirs(t)
308 cfg := config.Default()
309 cfg.DefaultModel = "local/local-model"
310 cfg.Desktop.ProviderAccess = []string{"local"}
311 cfg.Providers = []config.ProviderEntry{{
312 Name: "local", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1",
313 Model: "local-model",
314 }}
315 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
316 t.Fatalf("save local provider config: %v", err)
317 }
318
319 if NewApp().NeedsOnboarding() {
320 t.Fatal("NeedsOnboarding should be false for a no-auth local provider")
321 }
322 }
323
324 func providerNamesFromView(providers []ProviderView) []string {
325 out := make([]string, 0, len(providers))
326 for _, p := range providers {
327 out = append(out, p.Name)
328 }
329 return out
330 }
331
332 func modelRefsFromView(models []ModelInfo) map[string]bool {
333 out := map[string]bool{}
334 for _, m := range models {
335 out[m.Ref] = true
336 }
337 return out
338 }
339
340 type desktopFakeTool struct {
341 name string
342 }
343
344 func (t desktopFakeTool) Name() string { return t.name }
345
346 func (desktopFakeTool) Description() string { return "fake desktop tool" }
347
348 func (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
349
350 func (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return "", nil }
351
352 func (desktopFakeTool) ReadOnly() bool { return true }
353
354 type desktopAskRuntimeRunner struct {
355 ask func(context.Context) error
356 }
357
358 func (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {
359 if r.ask == nil {
360 return nil
361 }
362 return r.ask(ctx)
363 }
364
365 func TestCommandsIncludesDocsAndEffortNotThinking(t *testing.T) {
366 app := NewApp()
367 cmds := app.Commands()
368 if !hasCommand(cmds, "docs") {
369 t.Fatalf("Commands() should include docs: %+v", cmds)
370 }
371 if !hasCommand(cmds, "effort") {
372 t.Fatalf("Commands() should include effort: %+v", cmds)
373 }
374 if !hasCommand(cmds, "reload") {
375 t.Fatalf("Commands() should include reload: %+v", cmds)
376 }
377 if hasCommand(cmds, "thinking") {
378 t.Fatalf("Commands() should not include thinking: %+v", cmds)
379 }
380 }
381
382 func TestCommandsDocsShowsOnlyRuntimeWinner(t *testing.T) {
383 tests := []struct {
384 name string
385 commands []command.Command
386 skills []skill.Skill
387 wantKind string
388 }{
389 {
390 name: "custom command shadows builtin",
391 commands: []command.Command{{Name: "docs", Description: "custom docs"}},
392 wantKind: "custom",
393 },
394 {
395 name: "skill shadows builtin",
396 skills: []skill.Skill{{Name: "docs", Description: "docs skill"}},
397 wantKind: "skill",
398 },
399 {
400 name: "custom command shadows skill and builtin",
401 commands: []command.Command{{Name: "docs", Description: "custom docs"}},
402 skills: []skill.Skill{{Name: "docs", Description: "docs skill"}},
403 wantKind: "custom",
404 },
405 }
406 for _, tt := range tests {
407 t.Run(tt.name, func(t *testing.T) {
408 ctrl := control.New(control.Options{Commands: tt.commands, Skills: tt.skills})
409 defer ctrl.Close()
410 app := NewApp()
411 app.setTestCtrl(ctrl, "")
412
413 var docs []CommandInfo
414 for _, cmd := range app.Commands() {
415 if cmd.Name == "docs" {
416 docs = append(docs, cmd)
417 }
418 }
419 if len(docs) != 1 || docs[0].Kind != tt.wantKind {
420 t.Fatalf("docs commands = %+v, want one %s entry", docs, tt.wantKind)
421 }
422 if fallback, ok := commandInfoByName(app.Commands(), control.ReasonixDocsSlashName); !ok || fallback.Kind != "builtin" {
423 t.Fatalf("qualified docs fallback = %+v, %v; want built-in", fallback, ok)
424 }
425 })
426 }
427 }
428
429 func commandInfoByName(commands []CommandInfo, name string) (CommandInfo, bool) {
430 for _, command := range commands {
431 if command.Name == name {
432 return command, true
433 }
434 }
435 return CommandInfo{}, false
436 }
437
438 func TestCommandsDocsAccountsForHiddenCompatibilityAliases(t *testing.T) {
439 tests := []struct {
440 name string
441 commands []command.Command
442 skills []skill.Skill
443 wantCanonical string
444 }{
445 {
446 name: "hidden plugin command alias",
447 commands: []command.Command{
448 {Name: "docs", Plugin: "manuals", Hidden: true},
449 {Name: "manuals:docs", Plugin: "manuals"},
450 },
451 wantCanonical: "manuals:docs",
452 },
453 {
454 name: "compatible plugin skill alias",
455 skills: []skill.Skill{{Name: "docs", Plugin: "manuals"}},
456 wantCanonical: "manuals:docs",
457 },
458 }
459
460 for _, tt := range tests {
461 t.Run(tt.name, func(t *testing.T) {
462 ctrl := control.New(control.Options{Commands: tt.commands, Skills: tt.skills})
463 defer ctrl.Close()
464 app := NewApp()
465 app.setTestCtrl(ctrl, "")
466 commands := app.Commands()
467 if _, ok := commandInfoByName(commands, "docs"); ok {
468 t.Fatalf("hidden runtime owner left a misleading docs entry: %+v", commands)
469 }
470 for _, want := range []string{control.ReasonixDocsSlashName, tt.wantCanonical} {
471 if _, ok := commandInfoByName(commands, want); !ok {
472 t.Fatalf("commands missing %q: %+v", want, commands)
473 }
474 }
475 })
476 }
477 }
478
479 func TestCommandsDocsDoesNotDisplaceQualifiedCustomCommands(t *testing.T) {
480 ctrl := control.New(control.Options{Commands: []command.Command{
481 {Name: "docs", Description: "custom docs"},
482 {Name: "reasonix:docs", Description: "qualified custom docs"},
483 {Name: "reasonix:builtin:docs", Description: "second qualified custom docs"},
484 }})
485 defer ctrl.Close()
486 app := NewApp()
487 app.setTestCtrl(ctrl, "")
488 commands := app.Commands()
489 for _, want := range []struct {
490 name string
491 kind string
492 }{
493 {name: "docs", kind: "custom"},
494 {name: "reasonix:docs", kind: "custom"},
495 {name: "reasonix:builtin:docs", kind: "custom"},
496 {name: "reasonix:builtin:docs:2", kind: "builtin"},
497 } {
498 if command, ok := commandInfoByName(commands, want.name); !ok || command.Kind != want.kind {
499 t.Fatalf("command %q = %+v, %v; want kind %q", want.name, command, ok, want.kind)
500 }
501 }
502 }
503
504 func TestCommandsClassifiesSubagentSkills(t *testing.T) {
505 ctrl := control.New(control.Options{Skills: []skill.Skill{
506 {Name: "init", Description: "inline skill", RunAs: skill.RunInline},
507 {Name: "explore", Description: "isolated skill", RunAs: skill.RunSubagent, Color: "amber"},
508 }})
509 defer ctrl.Close()
510 app := NewApp()
511 app.setTestCtrl(ctrl, "")
512
513 kinds := map[string]string{}
514 groups := map[string]string{}
515 colors := map[string]string{}
516 for _, cmd := range app.Commands() {
517 kinds[cmd.Name] = cmd.Kind
518 groups[cmd.Name] = cmd.Group
519 colors[cmd.Name] = cmd.Color
520 }
521 if kinds["init"] != "skill" {
522 t.Fatalf("inline skill kind = %q, want skill", kinds["init"])
523 }
524 if kinds["explore"] != "subagent" {
525 t.Fatalf("subagent skill kind = %q, want subagent", kinds["explore"])
526 }
527 if colors["explore"] != "amber" {
528 t.Fatalf("subagent skill color = %q, want amber", colors["explore"])
529 }
530 if groups["new"] != "actions" {
531 t.Fatalf("new command group = %q, want actions", groups["new"])
532 }
533 if groups["mcp"] != "integrations" || groups["plugins"] != "integrations" {
534 t.Fatalf("integration command groups = mcp:%q plugins:%q", groups["mcp"], groups["plugins"])
535 }
536 if groups["skill"] != "skills" {
537 t.Fatalf("skill command group = %q, want skills", groups["skill"])
538 }
539 }
540
541 func TestMetaForTabIncludesWorkspaceContext(t *testing.T) {
542 if _, err := exec.LookPath("git"); err != nil {
543 t.Skip("git not installed")
544 }
545 isolateDesktopUserDirs(t)
546 resetWorkspaceGitBranchMetaCacheForTest(t)
547
548 repo := t.TempDir()
549 configuredSandboxRoot := filepath.Join(t.TempDir(), "sandbox")
550 cfg := config.LoadForEdit(config.UserConfigPath())
551 cfg.Sandbox.WorkspaceRoot = configuredSandboxRoot
552 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
553 t.Fatal(err)
554 }
555
556 orig, err := os.Getwd()
557 if err != nil {
558 t.Fatal(err)
559 }
560 defer func() {
561 if err := os.Chdir(orig); err != nil {
562 t.Fatal(err)
563 }
564 }()
565 if err := os.Chdir(repo); err != nil {
566 t.Fatal(err)
567 }
568 runGit(t, "init")
569 runGit(t, "checkout", "-b", "feature/meta")
570
571 app := NewApp()
572 app.tabs = map[string]*WorkspaceTab{"tab-1": {
573 ID: "tab-1",
574 Scope: "project",
575 WorkspaceRoot: repo,
576 Ready: true,
577 disabledMCP: map[string]ServerView{},
578 }}
579 app.activeTabID = "tab-1"
580
581 got := app.MetaForTab("tab-1")
582 if got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {
583 t.Fatalf("workspace fields = cwd:%q root:%q path:%q, want %q", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)
584 }
585 if got.WorkspaceName != filepath.Base(repo) {
586 t.Fatalf("workspaceName = %q, want %q", got.WorkspaceName, filepath.Base(repo))
587 }
588 raw, err := json.Marshal(got)
589 if err != nil {
590 t.Fatalf("marshal meta: %v", err)
591 }
592 if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
593 t.Fatalf("meta should not expose configured sandbox root as sandboxPath: %s", raw)
594 }
595 // The first git process launch can be noticeably slower on Windows runners
596 // while MetaForTab intentionally keeps the caller path non-blocking.
597 deadline := time.Now().Add(5 * time.Second)
598 for {
599 if got = app.MetaForTab("tab-1"); got.GitBranch == "feature/meta" {
600 break
601 }
602 if time.Now().After(deadline) {
603 t.Fatalf("gitBranch = %q, want feature/meta after async refresh", got.GitBranch)
604 }
605 time.Sleep(10 * time.Millisecond)
606 }
607 }
608
609 func TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {
610 isolateDesktopUserDirs(t)
611 workspace := t.TempDir()
612 configuredSandboxRoot := filepath.Join(t.TempDir(), "sandbox")
613 cfg := config.LoadForEdit(config.UserConfigPath())
614 cfg.Sandbox.WorkspaceRoot = configuredSandboxRoot
615 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
616 t.Fatal(err)
617 }
618
619 app := NewApp()
620 app.tabs = map[string]*WorkspaceTab{"tab-1": {
621 ID: "tab-1",
622 Scope: "project",
623 WorkspaceRoot: workspace,
624 Ready: true,
625 disabledMCP: map[string]ServerView{},
626 }}
627 app.activeTabID = "tab-1"
628 app.tabOrder = []string{"tab-1"}
629
630 raw, err := json.Marshal(app.ListTabs())
631 if err != nil {
632 t.Fatalf("marshal tabs: %v", err)
633 }
634 if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
635 t.Fatalf("tab metadata should not expose configured sandbox root as sandboxPath: %s", raw)
636 }
637 }
638
639 func TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {
640 asks := make(chan event.Ask, 1)
641 done := make(chan event.Event, 1)
642 runner := &desktopAskRuntimeRunner{}
643 ctrl := control.New(control.Options{
644 Runner: runner,
645 Sink: event.FuncSink(func(e event.Event) {
646 switch e.Kind {
647 case event.AskRequest:
648 asks <- e.Ask
649 case event.TurnDone:
650 done <- e
651 }
652 }),
653 })
654 runner.ask = func(ctx context.Context) error {
655 _, err := ctrl.Ask(ctx, []event.AskQuestion{{
656 ID: "choice",
657 Prompt: "Pick one",
658 Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
659 }})
660 return err
661 }
662
663 app := NewApp()
664 app.setTestCtrl(ctrl, "prov/model")
665 app.tabOrder = []string{"test"}
666 ctrl.Send("ask user")
667 select {
668 case <-asks:
669 case <-time.After(2 * time.Second):
670 t.Fatal("timed out waiting for ask request")
671 }
672
673 tabs := app.ListTabs()
674 if len(tabs) != 1 {
675 t.Fatalf("tabs = %d, want 1", len(tabs))
676 }
677 if !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {
678 t.Fatalf("tab runtime = running:%v pending:%v cancellable:%v cancel:%v", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)
679 }
680
681 app.CancelTab("test")
682 select {
683 case <-done:
684 case <-time.After(2 * time.Second):
685 t.Fatal("timed out waiting for turn_done")
686 }
687 }
688
689 func TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {
690 isolateDesktopUserDirs(t)
691 workspace := t.TempDir()
692 app := NewApp()
693 app.tabs = map[string]*WorkspaceTab{"tab-1": {
694 ID: "tab-1",
695 Scope: "project",
696 WorkspaceRoot: workspace,
697 Ready: true,
698 disabledMCP: map[string]ServerView{},
699 }}
700 app.activeTabID = "tab-1"
701
702 if got := app.MetaForTab("tab-1"); got.GitBranch != "" {
703 t.Fatalf("gitBranch = %q, want empty", got.GitBranch)
704 }
705 }
706
707 func TestEffortDefaultsBeforeStartup(t *testing.T) {
708 isolateDesktopUserDirs(t)
709
710 got := NewApp().Effort()
711 if !got.Supported || got.Current != "auto" || got.Default != "high" || !hasLevel(got.Levels, "auto") {
712 t.Fatalf("pre-startup Effort() = %+v, want auto with DeepSeek default high", got)
713 }
714 }
715
716 func TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {
717 isolateDesktopUserDirs(t)
718
719 view := NewApp().Memory()
720 if view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil || view.InstructionDiagnostics == nil || view.Conflicts == nil || view.LastRecall.Hits == nil {
721 t.Fatalf("Memory() arrays must be non-nil before startup: %+v", view)
722 }
723 raw, err := json.Marshal(view)
724 if err != nil {
725 t.Fatalf("marshal Memory(): %v", err)
726 }
727 for _, bad := range []string{`"docs":null`, `"facts":null`, `"archives":null`, `"scopes":null`, `"instructionDiagnostics":null`, `"conflicts":null`, `"hits":null`} {
728 if strings.Contains(string(raw), bad) {
729 t.Fatalf("Memory() JSON contains %s; frontend expects []: %s", bad, raw)
730 }
731 }
732 if revisions := NewApp().MemoryRevisions("missing"); revisions == nil {
733 t.Fatal("MemoryRevisions must return [] before startup, not nil")
734 }
735 }
736
737 func TestMemoryViewIncludesRecallFreshnessAndOverrides(t *testing.T) {
738 isolateDesktopUserDirs(t)
739 root := t.TempDir()
740 store := memory.Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")}
741 if _, err := (memory.Store{Dir: store.GlobalDir}).Save(memory.Memory{
742 Name: "deploy-target", Title: "Deploy target", Description: "legacy deployment target", Scope: memory.FactScopeGlobal, Type: memory.TypeProject, Body: "Deploy payments to the legacy cluster.",
743 }); err != nil {
744 t.Fatal(err)
745 }
746 if _, err := (memory.Store{Dir: store.Dir}).Save(memory.Memory{
747 Name: "deploy-target", Title: "Deploy target", Description: "current deployment target", Scope: memory.FactScopeProject, Type: memory.TypeProject, Body: "Deploy payments to the green cluster.",
748 }); err != nil {
749 t.Fatal(err)
750 }
751 ctrl := control.New(control.Options{Memory: &memory.Set{Store: store}})
752 ctrl.Compose("deploy payments target cluster")
753 app := NewApp()
754 app.setTestCtrl(ctrl, "test-model")
755
756 view := app.Memory()
757 if len(view.Facts) != 2 || view.Facts[0].Freshness == "" || view.Facts[1].Freshness == "" {
758 t.Fatalf("facts with freshness = %+v", view.Facts)
759 }
760 if len(view.Conflicts) != 1 || view.Conflicts[0].Resolution != "project_over_global" {
761 t.Fatalf("conflicts = %+v", view.Conflicts)
762 }
763 if view.LastRecall.Query != "deploy payments target cluster" || len(view.LastRecall.Hits) != 1 || view.LastRecall.Hits[0].Scope != "project" {
764 t.Fatalf("last recall = %+v", view.LastRecall)
765 }
766 }
767
768 func TestMemoryRevisionAPIRestoresSelectedRevision(t *testing.T) {
769 isolateDesktopUserDirs(t)
770 store := memory.Store{Dir: t.TempDir()}
771 first, err := store.SaveWithOptions(memory.Memory{Name: "fact", Description: "one", Body: "v1"}, memory.SaveOptions{})
772 if err != nil {
773 t.Fatal(err)
774 }
775 if _, err := store.SaveWithOptions(memory.Memory{ID: first.Memory.ID, Name: "fact", Description: "two", Body: "v2"}, memory.SaveOptions{}); err != nil {
776 t.Fatal(err)
777 }
778 app := NewApp()
779 app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{Store: store}}), "test-model")
780
781 revisions := app.MemoryRevisions(first.Memory.ID)
782 if len(revisions) != 1 || revisions[0].Revision != 1 {
783 t.Fatalf("revisions = %+v", revisions)
784 }
785 restored, err := app.RestoreMemoryRevision(first.Memory.ID, 1)
786 if err != nil {
787 t.Fatal(err)
788 }
789 if restored.Revision != 3 || restored.Body != "v1" {
790 t.Fatalf("restored = %+v", restored)
791 }
792 }
793
794 func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
795 isolateDesktopUserDirs(t)
796 userDir := t.TempDir()
797 cwd := t.TempDir()
798 store := memory.Store{Dir: filepath.Join(userDir, "projects", "test", "memory")}
799 if _, err := store.Save(memory.Memory{
800 Name: "active-fact",
801 Title: "Active fact",
802 Description: "Still applies",
803 Type: memory.TypeProject,
804 Body: "Active body",
805 }); err != nil {
806 t.Fatal(err)
807 }
808 if _, err := store.Save(memory.Memory{
809 Name: "archived-fact",
810 Description: "No longer applies",
811 Type: memory.TypeFeedback,
812 Body: "Archived body",
813 }); err != nil {
814 t.Fatal(err)
815 }
816 if _, err := store.Archive("archived-fact"); err != nil {
817 t.Fatalf("Archive: %v", err)
818 }
819
820 app := NewApp()
821 app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{
822 Docs: []memory.Source{{
823 Path: filepath.Join(cwd, "AGENTS.md"), Scope: memory.ScopeProject, Directory: cwd,
824 Body: "Project instructions", Imports: []instruction.Import{{Path: filepath.Join(cwd, "shared.md"), SourcePath: filepath.Join(cwd, "AGENTS.md")}},
825 }},
826 InstructionDiagnostics: []instruction.Diagnostic{{Code: "import_cycle", Path: "shared.md", SourcePath: filepath.Join(cwd, "AGENTS.md"), Line: 3, Message: "cycle"}},
827 Store: store, CWD: cwd, UserDir: userDir,
828 }}), "test-model")
829
830 view := app.Memory()
831 if !view.Available || view.StoreDir != store.Dir {
832 t.Fatalf("Memory() availability/store = %v/%q, want true/%q", view.Available, view.StoreDir, store.Dir)
833 }
834 if len(view.Docs) != 1 || view.Docs[0].Scope != "project" || !strings.Contains(view.Docs[0].Body, "Project instructions") {
835 t.Fatalf("Memory() docs = %+v", view.Docs)
836 }
837 if view.Docs[0].Directory != cwd || len(view.Docs[0].Imports) != 1 || len(view.InstructionDiagnostics) != 1 || view.InstructionDiagnostics[0].Code != "import_cycle" {
838 t.Fatalf("Memory() instruction provenance = docs %+v diagnostics %+v", view.Docs, view.InstructionDiagnostics)
839 }
840 if len(view.Facts) != 1 || view.Facts[0].Name != "active-fact" || view.Facts[0].Type != "project" || view.Facts[0].Scope != "project" {
841 t.Fatalf("Memory() active facts = %+v", view.Facts)
842 }
843 if view.Facts[0].ID == "" || view.Facts[0].Revision != 1 || view.Facts[0].CreatedAt == "" || view.Facts[0].UpdatedAt == "" {
844 t.Fatalf("Memory() active fact metadata = %+v", view.Facts[0])
845 }
846 if len(view.Archives) != 1 || view.Archives[0].Name != "archived-fact" || view.Archives[0].Type != "feedback" || view.Archives[0].Scope != "project" ||
847 view.Archives[0].Path == "" || view.Archives[0].ArchivedAt == "" {
848 t.Fatalf("Memory() archived facts = %+v", view.Archives)
849 }
850 if view.Archives[0].ID == "" || view.Archives[0].Revision != 1 || view.Archives[0].CreatedAt == "" || view.Archives[0].UpdatedAt == "" {
851 t.Fatalf("Memory() archived fact metadata = %+v", view.Archives[0])
852 }
853 if len(view.Scopes) != 3 {
854 t.Fatalf("Memory() scopes = %+v, want user/project/local", view.Scopes)
855 }
856 }
857
858 func TestRestoreArchivedMemoryRecoversFactForCurrentSession(t *testing.T) {
859 isolateDesktopUserDirs(t)
860 userDir := t.TempDir()
861 cwd := t.TempDir()
862 store := memory.StoreFor(userDir, cwd)
863 first, err := store.SaveWithOptions(memory.Memory{
864 Name: "restorable-fact", Description: "recover me", Body: "Recovered guidance.",
865 }, memory.SaveOptions{})
866 if err != nil {
867 t.Fatal(err)
868 }
869 archivePath, err := store.Archive(first.Memory.ID)
870 if err != nil {
871 t.Fatal(err)
872 }
873
874 app := NewApp()
875 app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir}}), "test-model")
876 if _, err := app.RestoreArchivedMemory(archivePath); err != nil {
877 t.Fatal(err)
878 }
879 view := app.Memory()
880 if len(view.Facts) != 1 || view.Facts[0].ID != first.Memory.ID || view.Facts[0].Revision != 2 {
881 t.Fatalf("restored memory view = %+v", view)
882 }
883 if len(view.Archives) != 0 {
884 t.Fatalf("restored archive remained visible: %+v", view.Archives)
885 }
886 }
887
888 func TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {
889 isolateDesktopUserDirs(t)
890 consumeSystemQuitRequested()
891 t.Cleanup(func() { consumeSystemQuitRequested() })
892
893 userCfg := config.LoadForEdit(config.UserConfigPath())
894 if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
895 t.Fatal(err)
896 }
897 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
898 t.Fatal(err)
899 }
900
901 markSystemQuitRequested()
902 if prevent := NewApp().beforeClose(context.Background()); prevent {
903 t.Fatal("system quit should bypass background close-to-tray behavior")
904 }
905 if consumeSystemQuitRequested() {
906 t.Fatal("system quit marker should be consumed by beforeClose")
907 }
908 }
909
910 func TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {
911 tests := []struct {
912 goos string
913 want bool
914 }{
915 {goos: "darwin", want: true},
916 {goos: "windows", want: false},
917 {goos: "linux", want: false},
918 {goos: "freebsd", want: false},
919 }
920 for _, tt := range tests {
921 if got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {
922 t.Fatalf("backgroundCloseUsesApplicationHide(%q) = %v, want %v", tt.goos, got, tt.want)
923 }
924 }
925 }
926
927 func TestBackgroundCloseRequiresRestorePath(t *testing.T) {
928 tests := []struct {
929 name string
930 goos string
931 trayStarted bool
932 trayReady bool
933 want bool
934 }{
935 {name: "macOS restores from Dock", goos: "darwin", trayStarted: false, trayReady: false, want: true},
936 {name: "Windows tray ready", goos: "windows", trayStarted: true, trayReady: true, want: true},
937 {name: "Windows tray started but not ready", goos: "windows", trayStarted: true, trayReady: false, want: false},
938 {name: "Linux tray ready", goos: "linux", trayStarted: true, trayReady: true, want: true},
939 {name: "Linux tray started but not ready", goos: "linux", trayStarted: true, trayReady: false, want: false},
940 {name: "Linux no tray", goos: "linux", trayStarted: false, trayReady: false, want: false},
941 {name: "other Unix no tray", goos: "freebsd", trayStarted: false, trayReady: false, want: false},
942 }
943 for _, tt := range tests {
944 t.Run(tt.name, func(t *testing.T) {
945 if got := backgroundCloseHasRestorePathFor(tt.goos, tt.trayStarted, tt.trayReady); got != tt.want {
946 t.Fatalf("backgroundCloseHasRestorePathFor(%q, %v, %v) = %v, want %v", tt.goos, tt.trayStarted, tt.trayReady, got, tt.want)
947 }
948 })
949 }
950 }
951
952 func TestBackgroundCloseReadySignalRequiresCurrentReadyState(t *testing.T) {
953 app := NewApp()
954 tray := newDesktopTray()
955 app.mu.Lock()
956 app.tray = tray
957 app.mu.Unlock()
958
959 if app.waitForTrayReady(0) {
960 t.Fatal("tray should not be ready before its ready signal")
961 }
962
963 tray.markReady()
964 if app.waitForTrayReady(0) {
965 t.Fatal("closed ready signal should not count without the current ready state")
966 }
967
968 app.mu.Lock()
969 app.trayReady = true
970 app.mu.Unlock()
971 if !app.waitForTrayReady(0) {
972 t.Fatal("ready state should be accepted after the tray is marked ready")
973 }
974
975 app.mu.Lock()
976 app.trayReady = false
977 app.mu.Unlock()
978 if app.waitForTrayReady(0) {
979 t.Fatal("stale ready signal should not count after the tray exits")
980 }
981 }
982
983 func TestBackgroundCloseWaitsForTrayReadySignal(t *testing.T) {
984 app := NewApp()
985 tray := newDesktopTray()
986 app.mu.Lock()
987 app.tray = tray
988 app.mu.Unlock()
989
990 go func() {
991 time.Sleep(10 * time.Millisecond)
992 app.mu.Lock()
993 app.trayReady = true
994 app.mu.Unlock()
995 tray.markReady()
996 }()
997
998 if !app.waitForTrayReady(200 * time.Millisecond) {
999 t.Fatal("waitForTrayReady should observe the tray becoming ready")
1000 }
1001 }
1002
1003 func TestBackgroundRestoreMaximiseStrategy(t *testing.T) {
1004 tests := []struct {
1005 goos string
1006 maximised bool
1007 want bool
1008 }{
1009 {goos: "windows", maximised: true, want: true},
1010 {goos: "linux", maximised: true, want: true},
1011 {goos: "darwin", maximised: true, want: false},
1012 {goos: "windows", maximised: false, want: false},
1013 }
1014 for _, tt := range tests {
1015 if got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {
1016 t.Fatalf("backgroundRestoreShouldMaximise(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want)
1017 }
1018 }
1019 }
1020
1021 func TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {
1022 tests := []struct {
1023 name string
1024 goos string
1025 maximised bool
1026 want backgroundRestorePlan
1027 }{
1028 {
1029 name: "maximised Windows window",
1030 goos: "windows",
1031 maximised: true,
1032 want: backgroundRestorePlan{maximiseBeforeShow: true},
1033 },
1034 {
1035 name: "normal Windows window",
1036 goos: "windows",
1037 maximised: false,
1038 want: backgroundRestorePlan{unminimiseAfterShow: true},
1039 },
1040 }
1041 for _, tt := range tests {
1042 t.Run(tt.name, func(t *testing.T) {
1043 got := backgroundRestorePlanFor(tt.goos, tt.maximised)
1044 if !reflect.DeepEqual(got, tt.want) {
1045 t.Fatalf("backgroundRestorePlanFor(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want)
1046 }
1047 })
1048 }
1049 }
1050
1051 func TestEmitReadyInvokesReadyHook(t *testing.T) {
1052 app := NewApp()
1053 var calls atomic.Int32
1054 app.readyHook = func() {
1055 calls.Add(1)
1056 }
1057
1058 app.emitReady(context.TODO())
1059
1060 if got := calls.Load(); got != 1 {
1061 t.Fatalf("ready hook calls = %d, want 1", got)
1062 }
1063 }
1064
1065 func TestSetEffortPersistsAndAutoClears(t *testing.T) {
1066 isolateDesktopUserDirs(t)
1067
1068 app := NewApp()
1069 if err := app.SetEffort("max"); err != nil {
1070 t.Fatalf("SetEffort(max): %v", err)
1071 }
1072 if got := app.Effort().Current; got != "max" {
1073 t.Fatalf("Effort current = %q, want max", got)
1074 }
1075 if err := app.SetEffort("auto"); err != nil {
1076 t.Fatalf("SetEffort(auto): %v", err)
1077 }
1078 if got := app.Effort().Current; got != "auto" {
1079 t.Fatalf("Effort current = %q, want auto", got)
1080 }
1081 body, err := os.ReadFile(config.UserConfigPath())
1082 if err != nil {
1083 t.Fatalf("read saved config: %v", err)
1084 }
1085 if strings.Contains(string(body), `effort = "max"`) {
1086 t.Fatalf("auto should clear explicit max effort:\n%s", body)
1087 }
1088 }
1089
1090 func TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {
1091 isolateDesktopUserDirs(t)
1092
1093 project := robustTempDir(t)
1094 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
1095 [desktop]
1096 language = "zh"
1097 layout_style = "workbench"
1098 theme = "light"
1099 theme_style = "glacier"
1100 close_behavior = "quit"
1101 status_bar_style = "icon"
1102 status_bar_items = ["cost", "balance"]
1103 `), 0o644); err != nil {
1104 t.Fatalf("write project config: %v", err)
1105 }
1106
1107 userCfg := config.LoadForEdit(config.UserConfigPath())
1108 if err := userCfg.SetDesktopLanguage("en"); err != nil {
1109 t.Fatalf("set desktop language: %v", err)
1110 }
1111 if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil {
1112 t.Fatalf("set desktop layout style: %v", err)
1113 }
1114 if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
1115 t.Fatalf("set desktop appearance: %v", err)
1116 }
1117 if err := userCfg.SetDesktopTerminalTheme("light"); err != nil {
1118 t.Fatalf("set desktop terminal theme: %v", err)
1119 }
1120 if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
1121 t.Fatalf("set desktop close behavior: %v", err)
1122 }
1123 if err := userCfg.SetDesktopStatusBarStyle("text"); err != nil {
1124 t.Fatalf("set desktop status bar style: %v", err)
1125 }
1126 if err := userCfg.SetDesktopStatusBarItems([]string{"model", "balance", "cache"}); err != nil {
1127 t.Fatalf("set desktop status bar items: %v", err)
1128 }
1129 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
1130 t.Fatalf("save user config: %v", err)
1131 }
1132
1133 orig, _ := os.Getwd()
1134 defer func() { _ = os.Chdir(orig) }()
1135 if err := os.Chdir(project); err != nil {
1136 t.Fatalf("chdir project: %v", err)
1137 }
1138
1139 got := NewApp().Settings()
1140 if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DesktopTerminalTheme != "light" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" {
1141 t.Fatalf("desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)
1142 }
1143 if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.StatusBarItems, want) {
1144 t.Fatalf("desktop status bar items = %v, want user-level %v", got.StatusBarItems, want)
1145 }
1146 }
1147
1148 func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {
1149 isolateDesktopUserDirs(t)
1150
1151 userCfg := config.LoadForEdit(config.UserConfigPath())
1152 if err := userCfg.SetDesktopLanguage("en"); err != nil {
1153 t.Fatalf("set desktop language: %v", err)
1154 }
1155 if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil {
1156 t.Fatalf("set desktop layout style: %v", err)
1157 }
1158 if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
1159 t.Fatalf("set desktop appearance: %v", err)
1160 }
1161 if err := userCfg.SetDesktopTerminalTheme("light"); err != nil {
1162 t.Fatalf("set desktop terminal theme: %v", err)
1163 }
1164 if err := userCfg.SetDesktopStatusBarStyle("icon"); err != nil {
1165 t.Fatalf("set desktop status bar style: %v", err)
1166 }
1167 if err := userCfg.SetDesktopStatusBarItems([]string{"workspace", "git_branch", "model"}); err != nil {
1168 t.Fatalf("set desktop status bar items: %v", err)
1169 }
1170 if err := userCfg.SetDesktopCheckUpdates(false); err != nil {
1171 t.Fatalf("set desktop check updates: %v", err)
1172 }
1173 if err := userCfg.SetDesktopUpdateChannel("preview"); err != nil {
1174 t.Fatalf("set desktop update channel: %v", err)
1175 }
1176 userCfg.Bot.Enabled = true
1177 userCfg.Bot.Allowlist.Enabled = true
1178 userCfg.Bot.Allowlist.QQUsers = []string{"alice"}
1179 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
1180 t.Fatalf("save user config: %v", err)
1181 }
1182
1183 got := NewApp().DesktopStartupSettings()
1184 if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DesktopTerminalTheme != "light" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates || got.UpdateChannel != "stable" {
1185 t.Fatalf("DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs", got)
1186 }
1187 if want := []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got.StatusBarItems, want) {
1188 t.Fatalf("DesktopStartupSettings status bar items = %v, want %v", got.StatusBarItems, want)
1189 }
1190 if !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{"alice"}) {
1191 t.Fatalf("DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot", got.Bot)
1192 }
1193
1194 raw, err := json.Marshal(got)
1195 if err != nil {
1196 t.Fatalf("marshal DesktopStartupSettings: %v", err)
1197 }
1198 if strings.Contains(string(raw), "providers") || strings.Contains(string(raw), "officialProviders") || strings.Contains(string(raw), "providerKinds") {
1199 t.Fatalf("DesktopStartupSettings must not include full Settings provider payload: %s", raw)
1200 }
1201 }
1202
1203 func BenchmarkDesktopSettingsPayloads(b *testing.B) {
1204 home := b.TempDir()
1205 xdg := filepath.Join(home, ".config")
1206 appData := filepath.Join(home, "AppData")
1207 for _, dir := range []string{xdg, appData} {
1208 if err := os.MkdirAll(dir, 0o755); err != nil {
1209 b.Fatal(err)
1210 }
1211 }
1212 b.Setenv("HOME", home)
1213 b.Setenv("REASONIX_CREDENTIALS_STORE", "file")
1214 b.Setenv("USERPROFILE", home)
1215 b.Setenv("XDG_CONFIG_HOME", xdg)
1216 b.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
1217 b.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
1218 b.Setenv("AppData", appData)
1219 b.Setenv("SHARED_PROVIDER_KEY", "sk-test")
1220
1221 cfg := config.LoadForEdit(config.UserConfigPath())
1222 for i := range 40 {
1223 cfg.Providers = append(cfg.Providers, config.ProviderEntry{
1224 Name: fmt.Sprintf("custom-%02d", i),
1225 Kind: "openai",
1226 BaseURL: "https://example.invalid/v1",
1227 APIKeyEnv: "SHARED_PROVIDER_KEY",
1228 Models: []string{"model-a", "model-b"},
1229 Default: "model-a",
1230 })
1231 }
1232 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1233 b.Fatalf("save config: %v", err)
1234 }
1235 app := NewApp()
1236
1237 b.Run("Settings", func(b *testing.B) {
1238 for range b.N {
1239 _ = app.Settings()
1240 }
1241 })
1242 b.Run("DesktopStartupSettings", func(b *testing.B) {
1243 for range b.N {
1244 _ = app.DesktopStartupSettings()
1245 }
1246 })
1247 }
1248
1249 func TestSettingsIgnoresActiveWorkspaceDotEnvCredentialsWithUserConfig(t *testing.T) {
1250 isolateDesktopUserDirs(t)
1251
1252 project := robustTempDir(t)
1253 launch := robustTempDir(t)
1254 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("WORKSPACE_ONLY_KEY=from-project\n"), 0o600); err != nil {
1255 t.Fatalf("write project env: %v", err)
1256 }
1257 userCfg := config.LoadForEdit(config.UserConfigPath())
1258 if err := userCfg.UpsertProvider(config.ProviderEntry{
1259 Name: "workspace-provider",
1260 Kind: "openai",
1261 BaseURL: "https://workspace.example/v1",
1262 Model: "workspace-model",
1263 APIKeyEnv: "WORKSPACE_ONLY_KEY",
1264 }); err != nil {
1265 t.Fatalf("upsert provider: %v", err)
1266 }
1267 userCfg.Desktop.ProviderAccess = []string{"workspace-provider"}
1268 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
1269 t.Fatalf("save user config: %v", err)
1270 }
1271 t.Setenv("WORKSPACE_ONLY_KEY", "")
1272 os.Unsetenv("WORKSPACE_ONLY_KEY")
1273 orig, _ := os.Getwd()
1274 defer func() { _ = os.Chdir(orig) }()
1275 if err := os.Chdir(launch); err != nil {
1276 t.Fatalf("chdir launch: %v", err)
1277 }
1278
1279 app := NewApp()
1280 app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}}
1281 app.activeTabID = "project"
1282 got := app.Settings()
1283 for _, p := range got.Providers {
1284 if p.Name == "workspace-provider" {
1285 if p.KeySet {
1286 t.Fatalf("workspace provider keySet = true, want false because workspace .env is ignored: %+v", p)
1287 }
1288 if p.Configured {
1289 t.Fatalf("workspace provider configured = true, want false because workspace .env is ignored: %+v", p)
1290 }
1291 return
1292 }
1293 }
1294 t.Fatalf("workspace provider missing from settings: %+v", got.Providers)
1295 }
1296
1297 func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {
1298 isolateDesktopUserDirs(t)
1299
1300 project := robustTempDir(t)
1301 launch := robustTempDir(t)
1302 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("SHARED_SETTINGS_KEY=from-project\n"), 0o600); err != nil {
1303 t.Fatalf("write project env: %v", err)
1304 }
1305 if _, err := config.SetCredential("SHARED_SETTINGS_KEY", "from-credentials"); err != nil {
1306 t.Fatalf("SetCredential: %v", err)
1307 }
1308 userCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
1309 if err := userCfg.UpsertProvider(config.ProviderEntry{
1310 Name: "settings-provider",
1311 Kind: "openai",
1312 BaseURL: "https://settings.example/v1",
1313 Model: "settings-model",
1314 APIKeyEnv: "SHARED_SETTINGS_KEY",
1315 }); err != nil {
1316 t.Fatalf("upsert provider: %v", err)
1317 }
1318 userCfg.Desktop.ProviderAccess = []string{"settings-provider"}
1319 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
1320 t.Fatalf("save user config: %v", err)
1321 }
1322 t.Setenv("SHARED_SETTINGS_KEY", "from-project")
1323 orig, _ := os.Getwd()
1324 defer func() { _ = os.Chdir(orig) }()
1325 if err := os.Chdir(launch); err != nil {
1326 t.Fatalf("chdir launch: %v", err)
1327 }
1328
1329 app := NewApp()
1330 app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}}
1331 app.activeTabID = "project"
1332 got := app.Settings()
1333 for _, p := range got.Providers {
1334 if p.Name != "settings-provider" {
1335 continue
1336 }
1337 if !p.KeySet || !strings.Contains(p.KeySource, "Reasonix credentials") {
1338 t.Fatalf("settings-provider key = set:%v source:%q, want Reasonix credentials: %+v", p.KeySet, p.KeySource, p)
1339 }
1340 if env := os.Getenv("SHARED_SETTINGS_KEY"); env != "from-project" {
1341 t.Fatalf("Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env", env)
1342 }
1343 return
1344 }
1345 t.Fatalf("settings provider missing from settings: %+v", got.Providers)
1346 }
1347
1348 func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
1349 isolateDesktopUserDirs(t)
1350
1351 project := robustTempDir(t)
1352 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
1353 default_model = "legacy-provider/legacy-model"
1354
1355 [desktop]
1356 language = "zh"
1357 layout_style = "workbench"
1358 theme = "light"
1359 theme_style = "glacier"
1360 close_behavior = "quit"
1361 status_bar_style = "text"
1362 status_bar_items = ["model", "cache", "balance"]
1363 `), 0o644); err != nil {
1364 t.Fatalf("write project config: %v", err)
1365 }
1366
1367 orig, _ := os.Getwd()
1368 defer func() { _ = os.Chdir(orig) }()
1369 if err := os.Chdir(project); err != nil {
1370 t.Fatalf("chdir project: %v", err)
1371 }
1372
1373 app := NewApp()
1374 got := app.Settings()
1375 if got.ConfigPath != config.UserConfigPath() {
1376 t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
1377 }
1378 if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "icon" {
1379 t.Fatalf("Settings did not seed from legacy project config: %+v", got)
1380 }
1381 if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) {
1382 t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want)
1383 }
1384 if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
1385 t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
1386 }
1387 if err := app.SetDesktopLanguage("en"); err != nil {
1388 t.Fatalf("SetDesktopLanguage: %v", err)
1389 }
1390 userCfg := config.LoadForEdit(config.UserConfigPath())
1391 if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "icon" {
1392 t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())
1393 }
1394 if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {
1395 t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want)
1396 }
1397 }
1398
1399 func TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {
1400 isolateDesktopUserDirs(t)
1401 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1402 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1403 t.Fatalf("mkdir config dir: %v", err)
1404 }
1405 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1406 default_model = "deepseek/deepseek-v4-flash"
1407
1408 [[providers]]
1409 name = "deepseek"
1410 kind = "openai"
1411 base_url = "https://api.deepseek.com"
1412 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
1413 default = "deepseek-v4-flash"
1414 api_key_env = "DEEPSEEK_API_KEY"
1415 `), 0o644); err != nil {
1416 t.Fatalf("write config: %v", err)
1417 }
1418
1419 app := NewApp()
1420 if got := app.Settings().Agent.MaxSubagentDepth; got != agent.DefaultMaxSubagentDepth {
1421 t.Fatalf("default max subagent depth = %d, want %d", got, agent.DefaultMaxSubagentDepth)
1422 }
1423 if err := app.SetSubagentModel("deepseek/deepseek-v4-pro"); err != nil {
1424 t.Fatalf("SetSubagentModel: %v", err)
1425 }
1426 if err := app.SetSubagentEffort("max"); err != nil {
1427 t.Fatalf("SetSubagentEffort: %v", err)
1428 }
1429 if err := app.SetMaxSubagentDepth(1); err != nil {
1430 t.Fatalf("SetMaxSubagentDepth(1): %v", err)
1431 }
1432 if err := app.SetMaxSubagentDepth(2); err != nil {
1433 t.Fatalf("SetMaxSubagentDepth(2): %v", err)
1434 }
1435
1436 got := app.Settings()
1437 if got.SubagentModel != "deepseek/deepseek-v4-pro" || got.SubagentEffort != "max" {
1438 t.Fatalf("subagent settings = model:%q effort:%q", got.SubagentModel, got.SubagentEffort)
1439 }
1440 if got.Agent.MaxSubagentDepth != 2 {
1441 t.Fatalf("max subagent depth = %d, want 2", got.Agent.MaxSubagentDepth)
1442 }
1443 cfg := config.LoadForEdit(config.UserConfigPath())
1444 if cfg.Agent.SubagentModel != "deepseek/deepseek-v4-pro" || cfg.Agent.SubagentEffort != "max" {
1445 t.Fatalf("saved config = model:%q effort:%q", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)
1446 }
1447 if cfg.Agent.MaxSubagentDepth != 2 {
1448 t.Fatalf("saved max_subagent_depth = %d, want 2", cfg.Agent.MaxSubagentDepth)
1449 }
1450 }
1451
1452 func TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {
1453 isolateDesktopUserDirs(t)
1454
1455 got := NewApp().Settings()
1456 providers := providerAccessSet(providerNamesFromView(got.Providers))
1457 official := providerAccessSet(providerNamesFromView(got.OfficialProviders))
1458 if providers["mimo-api"] {
1459 t.Fatalf("mimo-api should not be mixed into configured providers: %+v", got.Providers)
1460 }
1461 if !official["deepseek"] || official["mimo-api"] || official["mimo-token-plan"] {
1462 t.Fatalf("official providers = %+v, want only deepseek", got.OfficialProviders)
1463 }
1464 }
1465
1466 func TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {
1467 isolateDesktopUserDirs(t)
1468 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1469 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1470 t.Fatalf("mkdir config dir: %v", err)
1471 }
1472 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1473 default_model = "deepseek-flash"
1474
1475 [[providers]]
1476 name = "deepseek-flash"
1477 kind = "openai"
1478 base_url = "https://api.deepseek.com"
1479 api_key_env = "DEEPSEEK_API_KEY"
1480 `), 0o644); err != nil {
1481 t.Fatalf("write config: %v", err)
1482 }
1483
1484 got := NewApp().Settings()
1485 for _, p := range got.Providers {
1486 if p.Name != "deepseek" {
1487 continue
1488 }
1489 if !p.BuiltIn {
1490 t.Fatalf("deepseek provider should be marked built-in for official endpoint: %+v", p)
1491 }
1492 if !p.Added || !p.KeySet || len(p.Models) != 3 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Models[2] != "deepseek-v4-flash-vision-exp" || !slices.Equal(p.VisionModels, []string{"deepseek-v4-flash-vision-exp"}) || p.Default != "deepseek-v4-flash" {
1493 t.Fatalf("deepseek provider = %+v, want added repaired official model list", p)
1494 }
1495 if got.DefaultModel != "deepseek/deepseek-v4-flash" {
1496 t.Fatalf("default_model = %q, want deepseek/deepseek-v4-flash", got.DefaultModel)
1497 }
1498 return
1499 }
1500 t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
1501 }
1502
1503 func TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {
1504 isolateDesktopUserDirs(t)
1505 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1506 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1507 t.Fatalf("mkdir config dir: %v", err)
1508 }
1509 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1510 default_model = "deepseek/deepseek-v4-Flash"
1511
1512 [desktop]
1513 provider_access = ["deepseek"]
1514
1515 [[providers]]
1516 name = "deepseek"
1517 kind = "openai"
1518 base_url = "https://opencode.ai/zen/go/v1"
1519 models = ["deepseek-v4-Flash", "deepseek-v4-pro", "glm-5"]
1520 default = "deepseek-v4-Flash"
1521 api_key_env = "DEEPSEEK_API_KEY"
1522 `), 0o644); err != nil {
1523 t.Fatalf("write config: %v", err)
1524 }
1525
1526 got := NewApp().Settings()
1527 var custom *ProviderView
1528 for i := range got.Providers {
1529 if got.Providers[i].Name == "deepseek" {
1530 custom = &got.Providers[i]
1531 break
1532 }
1533 }
1534 if custom == nil {
1535 t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
1536 }
1537 if custom.BuiltIn {
1538 t.Fatalf("external deepseek endpoint should be custom, got built-in provider: %+v", *custom)
1539 }
1540 if !custom.Added || !custom.KeySet || custom.BaseURL != "https://opencode.ai/zen/go/v1" {
1541 t.Fatalf("external deepseek provider = %+v, want added key-set custom opencode endpoint", *custom)
1542 }
1543 for _, p := range got.OfficialProviders {
1544 if p.Name == "deepseek" && p.Added {
1545 t.Fatalf("official DeepSeek template should not be marked added by external endpoint: %+v", p)
1546 }
1547 }
1548 }
1549
1550 func TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {
1551 isolateDesktopUserDirs(t)
1552 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1553 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
1554 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1555 t.Fatalf("mkdir config dir: %v", err)
1556 }
1557 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1558 default_model = "deepseek-flash/deepseek-v4-pro"
1559
1560 [[providers]]
1561 name = "deepseek-flash"
1562 kind = "openai"
1563 base_url = "https://api.deepseek.com"
1564 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
1565 default = "deepseek-v4-flash"
1566 api_key_env = "DEEPSEEK_API_KEY"
1567
1568 [[providers]]
1569 name = "mimo-pro"
1570 kind = "openai"
1571 base_url = "https://token-plan-cn.xiaomimimo.com/v1"
1572 model = "mimo-v2.5-pro"
1573 api_key_env = "MIMO_API_KEY"
1574 `), 0o644); err != nil {
1575 t.Fatalf("write config: %v", err)
1576 }
1577
1578 got := NewApp().Settings()
1579 providers := map[string]ProviderView{}
1580 for _, p := range got.Providers {
1581 providers[p.Name] = p
1582 }
1583 if !providers["deepseek"].Added || !providers["deepseek"].KeySet {
1584 t.Fatalf("deepseek provider = %+v, want inferred added key-set provider", providers["deepseek"])
1585 }
1586 if !providers["mimo-pro"].Added || !providers["mimo-pro"].KeySet || providers["mimo-pro"].BuiltIn {
1587 t.Fatalf("mimo-pro provider = %+v, want inferred custom key-set provider", providers["mimo-pro"])
1588 }
1589 if got.DefaultModel != "deepseek/deepseek-v4-pro" {
1590 t.Fatalf("default_model = %q, want deepseek/deepseek-v4-pro", got.DefaultModel)
1591 }
1592 }
1593
1594 func TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {
1595 isolateDesktopUserDirs(t)
1596 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1597 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1598 t.Fatalf("mkdir config dir: %v", err)
1599 }
1600 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1601 default_model = "deepseek-flash/deepseek-v4-flash"
1602
1603 [desktop]
1604 provider_access = []
1605
1606 [[providers]]
1607 name = "deepseek-flash"
1608 kind = "openai"
1609 base_url = "https://api.deepseek.com"
1610 models = ["deepseek-v4-flash"]
1611 default = "deepseek-v4-flash"
1612 api_key_env = "DEEPSEEK_API_KEY"
1613 `), 0o644); err != nil {
1614 t.Fatalf("write config: %v", err)
1615 }
1616
1617 got := NewApp().Settings()
1618 for _, p := range got.Providers {
1619 if p.Added {
1620 t.Fatalf("provider %+v should not be inferred as added when provider_access is explicit empty", p)
1621 }
1622 }
1623 }
1624
1625 func TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {
1626 isolateDesktopUserDirs(t)
1627 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1628 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
1629
1630 got := NewApp().Settings()
1631 providers := map[string]ProviderView{}
1632 for _, p := range got.Providers {
1633 providers[p.Name] = p
1634 }
1635 if !providers["deepseek"].Added || !providers["deepseek"].KeySet {
1636 t.Fatalf("deepseek provider = %+v, want inferred added provider from configured key", providers["deepseek"])
1637 }
1638 if _, ok := providers["mimo-token-plan"]; ok {
1639 t.Fatalf("mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v", providers["mimo-token-plan"])
1640 }
1641 }
1642
1643 func TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {
1644 isolateDesktopUserDirs(t)
1645 t.Setenv("DEEPSEEK_API_KEY", "")
1646 t.Setenv("MIMO_API_KEY", "")
1647
1648 got := NewApp().Settings()
1649 for _, p := range got.Providers {
1650 if p.Added {
1651 t.Fatalf("provider %+v should not be inferred as added without a configured key", p)
1652 }
1653 }
1654 }
1655
1656 func TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {
1657 isolateDesktopUserDirs(t)
1658 t.Setenv("DEEPSEEK_API_KEY", "")
1659 os.Unsetenv("DEEPSEEK_API_KEY")
1660 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1661 t.Fatalf("mkdir config dir: %v", err)
1662 }
1663 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1664 default_model = "deepseek-flash"
1665
1666 [[providers]]
1667 name = "deepseek-flash"
1668 kind = "openai"
1669 base_url = "https://api.deepseek.com"
1670 api_key_env = "DEEPSEEK_API_KEY"
1671 `), 0o644); err != nil {
1672 t.Fatalf("write config: %v", err)
1673 }
1674
1675 if _, err := NewApp().AddOfficialProviderAccess("deepseek", "test-key"); err != nil {
1676 t.Fatalf("AddOfficialProviderAccess: %v", err)
1677 }
1678 cfg := config.LoadForEdit(config.UserConfigPath())
1679 p, ok := cfg.Provider("deepseek")
1680 if !ok {
1681 t.Fatal("deepseek provider not saved")
1682 }
1683 if len(p.Models) != 3 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Models[2] != "deepseek-v4-flash-vision-exp" || !slices.Equal(p.VisionModels, []string{"deepseek-v4-flash-vision-exp"}) || p.Default != "deepseek-v4-flash" {
1684 t.Fatalf("deepseek provider after add = %+v, want official model list", p)
1685 }
1686 if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
1687 t.Fatalf("provider_access missing deepseek: %+v", cfg.Desktop.ProviderAccess)
1688 }
1689 if cfg.DefaultModel != "deepseek/deepseek-v4-flash" {
1690 t.Fatalf("default_model = %q, want deepseek/deepseek-v4-flash", cfg.DefaultModel)
1691 }
1692 }
1693
1694 func TestSettingsSurfacesCuratedProviderPresets(t *testing.T) {
1695 isolateDesktopUserDirs(t)
1696
1697 view := NewApp().Settings()
1698 if len(view.ProviderPresets) < 18 {
1699 t.Fatalf("Settings().ProviderPresets length = %d, want curated custom presets", len(view.ProviderPresets))
1700 }
1701 got := map[string]ProviderPresetView{}
1702 for _, preset := range view.ProviderPresets {
1703 got[preset.ID] = preset
1704 }
1705 for _, curated := range config.CuratedProviderPresets() {
1706 id := curated.ID
1707 preset, ok := got[id]
1708 if !ok {
1709 t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
1710 }
1711 if preset.KeyEnv == "" || len(preset.ProviderNames) == 0 || len(preset.Models) == 0 {
1712 t.Fatalf("preset %q view has missing fields: %+v", id, preset)
1713 }
1714 if preset.ID == "opencode-go-recommended" && (preset.DisplayGroup != "opencode" || preset.DisplaySection != "go" || preset.DisplayTier != "primary" || preset.RouteKind != "bundle") {
1715 t.Fatalf("recommended OpenCode metadata = %+v", preset)
1716 }
1717 }
1718 }
1719
1720 func providerPresetViewByID(t *testing.T, view SettingsView, id string) ProviderPresetView {
1721 t.Helper()
1722 for _, preset := range view.ProviderPresets {
1723 if preset.ID == id {
1724 return preset
1725 }
1726 }
1727 t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
1728 return ProviderPresetView{}
1729 }
1730
1731 func TestSettingsMarksPresetAddedWhenSameNameProviderExistsWithoutAccess(t *testing.T) {
1732 isolateDesktopUserDirs(t)
1733 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1734 t.Fatalf("mkdir config dir: %v", err)
1735 }
1736 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1737 [desktop]
1738 provider_access = []
1739
1740 [[providers]]
1741 name = "mimo-api"
1742 kind = "openai"
1743 base_url = "https://custom.example/v1"
1744 models = ["custom-model"]
1745 default = "custom-model"
1746 api_key_env = "MIMO_API_KEY"
1747 `), 0o644); err != nil {
1748 t.Fatalf("write config: %v", err)
1749 }
1750
1751 view := NewApp().Settings()
1752 presetView := providerPresetViewByID(t, view, "mimo-api")
1753 if !presetView.Added || presetView.Status != providerPresetStatusNameConflict || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
1754 t.Fatalf("mimo-api preset view = %+v, want name-conflict because a different same-name provider exists", presetView)
1755 }
1756
1757 var providerView *ProviderView
1758 for i := range view.Providers {
1759 if view.Providers[i].Name == "mimo-api" {
1760 providerView = &view.Providers[i]
1761 break
1762 }
1763 }
1764 if providerView == nil {
1765 t.Fatal("mimo-api provider view missing")
1766 }
1767 if providerView.Added {
1768 t.Fatalf("mimo-api provider Added = true, want false until provider_access explicitly enables it")
1769 }
1770 }
1771
1772 func TestSettingsMarksLegacyEquivalentPresetAsInstalled(t *testing.T) {
1773 isolateDesktopUserDirs(t)
1774 preset, ok := config.CuratedProviderPreset("mimo-api")
1775 if !ok || len(preset.Entries) == 0 {
1776 t.Fatal("missing mimo-api preset")
1777 }
1778 legacy := preset.Entries[0]
1779 legacy.PresetID = ""
1780 legacy.PresetVersion = 0
1781 cfg := config.Default()
1782 if err := cfg.UpsertProvider(legacy); err != nil {
1783 t.Fatalf("upsert legacy provider: %v", err)
1784 }
1785 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1786 t.Fatalf("save config: %v", err)
1787 }
1788
1789 view := NewApp().Settings()
1790 presetView := providerPresetViewByID(t, view, "mimo-api")
1791 if !presetView.Added || presetView.Status != providerPresetStatusInstalled || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
1792 t.Fatalf("mimo-api preset view = %+v, want installed for legacy equivalent config", presetView)
1793 }
1794 }
1795
1796 func TestSettingsMarksPresetWithChangedCoreConfigAsModified(t *testing.T) {
1797 isolateDesktopUserDirs(t)
1798 preset, ok := config.CuratedProviderPreset("mimo-api")
1799 if !ok || len(preset.Entries) == 0 {
1800 t.Fatal("missing mimo-api preset")
1801 }
1802 modified := preset.Entries[0]
1803 modified.BaseURL = "https://custom.example/v1"
1804 cfg := config.Default()
1805 if err := cfg.UpsertProvider(modified); err != nil {
1806 t.Fatalf("upsert modified provider: %v", err)
1807 }
1808 cfg.Desktop.ProviderAccess = []string{"mimo-api"}
1809 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1810 t.Fatalf("save config: %v", err)
1811 }
1812
1813 view := NewApp().Settings()
1814 presetView := providerPresetViewByID(t, view, "mimo-api")
1815 if !presetView.Added || presetView.Status != providerPresetStatusInstalledModified || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
1816 t.Fatalf("mimo-api preset view = %+v, want installed-modified for edited preset provider", presetView)
1817 }
1818 }
1819
1820 func TestSettingsPreservesStepFunRegionalPresetBaseURLs(t *testing.T) {
1821 isolateDesktopUserDirs(t)
1822
1823 cfg := config.Default()
1824 stepfun, ok := config.CuratedProviderPreset("stepfun")
1825 if !ok || len(stepfun.Entries) != 1 {
1826 t.Fatal("missing stepfun preset")
1827 }
1828 stepfunEntry := stepfun.Entries[0]
1829 stepfunEntry.BaseURL = "https://api.stepfun.ai/step_plan/v1"
1830 stepfunAnthropic, ok := config.CuratedProviderPreset("stepfun-anthropic")
1831 if !ok || len(stepfunAnthropic.Entries) != 1 {
1832 t.Fatal("missing stepfun-anthropic preset")
1833 }
1834 stepfunAnthropicEntry := stepfunAnthropic.Entries[0]
1835 stepfunAnthropicEntry.BaseURL = "https://api.stepfun.ai/step_plan"
1836 cfg.Providers = append(cfg.Providers, stepfunEntry, stepfunAnthropicEntry)
1837 cfg.Desktop.ProviderAccess = []string{"stepfun", "stepfun-anthropic"}
1838 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1839 t.Fatalf("save config: %v", err)
1840 }
1841
1842 view := NewApp().Settings()
1843 for _, id := range []string{"stepfun", "stepfun-anthropic"} {
1844 presetView := providerPresetViewByID(t, view, id)
1845 if !presetView.Added || presetView.Status != providerPresetStatusInstalledModified {
1846 t.Fatalf("%s preset view = %+v, want installed-modified for a preserved regional endpoint", id, presetView)
1847 }
1848 }
1849
1850 loaded := config.LoadForEdit(config.UserConfigPath())
1851 stepfunEntryView, ok := loaded.Provider("stepfun")
1852 if !ok {
1853 t.Fatal("stepfun provider missing after load")
1854 }
1855 if got := stepfunEntryView.BaseURL; got != "https://api.stepfun.ai/step_plan/v1" {
1856 t.Fatalf("stepfun base_url = %q, want preserved regional URL", got)
1857 }
1858 stepfunAnthropicEntryView, ok := loaded.Provider("stepfun-anthropic")
1859 if !ok {
1860 t.Fatal("stepfun-anthropic provider missing after load")
1861 }
1862 if got := stepfunAnthropicEntryView.BaseURL; got != "https://api.stepfun.ai/step_plan" {
1863 t.Fatalf("stepfun-anthropic base_url = %q, want preserved regional URL", got)
1864 }
1865 }
1866
1867 func TestSettingsMarksSimilarProviderPresetWithoutBlockingAdd(t *testing.T) {
1868 isolateDesktopUserDirs(t)
1869 preset, ok := config.CuratedProviderPreset("mimo-api")
1870 if !ok || len(preset.Entries) == 0 {
1871 t.Fatal("missing mimo-api preset")
1872 }
1873 similar := preset.Entries[0]
1874 similar.Name = "my-mimo"
1875 similar.PresetID = ""
1876 similar.PresetVersion = 0
1877 cfg := config.Default()
1878 if err := cfg.UpsertProvider(similar); err != nil {
1879 t.Fatalf("upsert similar provider: %v", err)
1880 }
1881 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1882 t.Fatalf("save config: %v", err)
1883 }
1884
1885 view := NewApp().Settings()
1886 presetView := providerPresetViewByID(t, view, "mimo-api")
1887 if presetView.Added || presetView.Status != providerPresetStatusSimilarExisting || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"my-mimo"}) {
1888 t.Fatalf("mimo-api preset view = %+v, want non-blocking similar-existing status", presetView)
1889 }
1890 }
1891
1892 func TestAddProviderPresetAccessSavesEditableProviderAndKey(t *testing.T) {
1893 isolateDesktopUserDirs(t)
1894 t.Setenv("MIMO_API_KEY", "")
1895 os.Unsetenv("MIMO_API_KEY")
1896
1897 if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-mimo"); err != nil {
1898 t.Fatalf("AddProviderPresetAccess: %v", err)
1899 } else if warning != "" {
1900 t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
1901 }
1902
1903 cfg := config.LoadForEdit(config.UserConfigPath())
1904 p, ok := cfg.Provider("mimo-api")
1905 if !ok {
1906 t.Fatal("mimo-api provider not saved")
1907 }
1908 if p.Kind != "openai" || p.BaseURL != "https://api.xiaomimimo.com/v1" || p.Default != "mimo-v2.5-pro" {
1909 t.Fatalf("mimo-api provider after preset add = %+v", p)
1910 }
1911 if p.PresetID != "mimo-api" || p.PresetVersion != config.ProviderPresetVersion {
1912 t.Fatalf("mimo-api preset metadata = %q/%d, want mimo-api/%d", p.PresetID, p.PresetVersion, config.ProviderPresetVersion)
1913 }
1914 if !p.NoProxy {
1915 t.Fatal("mimo-api preset should save no_proxy = true")
1916 }
1917 if !p.HasVisionModel("mimo-v2.5") || p.HasVisionModel("mimo-v2.5-pro") {
1918 t.Fatalf("mimo vision_models = %+v, want only vision-capable MiMo models", p.VisionModels)
1919 }
1920 if price := p.PriceForModel("mimo-v2.5-pro"); price == nil || price.Currency != "¥" {
1921 t.Fatalf("mimo-v2.5-pro price = %+v, want RMB pricing", price)
1922 }
1923 if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
1924 t.Fatalf("provider_access missing mimo-api: %+v", cfg.Desktop.ProviderAccess)
1925 }
1926 data, err := os.ReadFile(config.UserCredentialsPath())
1927 if err != nil {
1928 t.Fatalf("read saved credentials: %v", err)
1929 }
1930 if p.APIKeyEnv == "MIMO_API_KEY" || !strings.Contains(string(data), p.APIKeyEnv+"=sk-mimo") {
1931 t.Fatal("saved credentials missing the isolated MiMo key reference")
1932 }
1933
1934 view := NewApp().Settings()
1935 var presetView *ProviderPresetView
1936 var providerView *ProviderView
1937 for i := range view.ProviderPresets {
1938 if view.ProviderPresets[i].ID == "mimo-api" {
1939 presetView = &view.ProviderPresets[i]
1940 }
1941 }
1942 for i := range view.Providers {
1943 if view.Providers[i].Name == "mimo-api" {
1944 providerView = &view.Providers[i]
1945 }
1946 }
1947 if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet {
1948 t.Fatalf("mimo-api preset view = %+v, want installed/key-set", presetView)
1949 }
1950 if providerView == nil || providerView.BuiltIn || !providerView.Added || !providerView.KeySet {
1951 t.Fatalf("mimo provider view = %+v, want editable added custom provider with key", providerView)
1952 }
1953 }
1954
1955 func TestAddProviderPresetAccessDoesNotOverwriteExistingProvider(t *testing.T) {
1956 isolateDesktopUserDirs(t)
1957 t.Setenv("MIMO_API_KEY", "")
1958 os.Unsetenv("MIMO_API_KEY")
1959 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
1960
1961 cfg := config.Default()
1962 custom := config.ProviderEntry{
1963 Name: "mimo-api",
1964 Kind: "openai",
1965 BaseURL: "https://custom.example/v1",
1966 Models: []string{"custom-model"},
1967 Default: "custom-model",
1968 APIKeyEnv: "MIMO_API_KEY",
1969 Headers: map[string]string{"X-Custom": "keep-me"},
1970 }
1971 if err := cfg.UpsertProvider(custom); err != nil {
1972 t.Fatalf("upsert custom provider: %v", err)
1973 }
1974 cfg.Desktop.ProviderAccess = []string{"mimo-api"}
1975 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1976 t.Fatalf("save config: %v", err)
1977 }
1978
1979 if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-new"); err == nil {
1980 t.Fatal("AddProviderPresetAccess unexpectedly overwrote an existing provider")
1981 } else if !strings.Contains(err.Error(), "provider name(s) already exist") {
1982 t.Fatalf("AddProviderPresetAccess error = %v, want name-exists guard", err)
1983 } else if warning != "" {
1984 t.Fatalf("AddProviderPresetAccess warning = %q, want none on rejected add", warning)
1985 }
1986
1987 cfg = config.LoadForEdit(config.UserConfigPath())
1988 got, ok := cfg.Provider("mimo-api")
1989 if !ok {
1990 t.Fatal("mimo-api provider missing after rejected add")
1991 }
1992 if got.BaseURL != custom.BaseURL || got.DefaultModel() != custom.DefaultModel() || !reflect.DeepEqual(got.ModelList(), custom.ModelList()) || !reflect.DeepEqual(got.Headers, custom.Headers) {
1993 t.Fatalf("mimo-api provider was overwritten: %+v, want custom %+v", got, custom)
1994 }
1995 data, err := os.ReadFile(config.UserCredentialsPath())
1996 if err != nil {
1997 t.Fatalf("read saved credentials: %v", err)
1998 }
1999 if strings.Contains(string(data), "sk-new") || !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
2000 t.Fatalf("credentials changed after rejected add:\n%s", data)
2001 }
2002 }
2003
2004 func TestResetProviderPresetAccessOverwritesSameNameProvider(t *testing.T) {
2005 isolateDesktopUserDirs(t)
2006 t.Setenv("MIMO_API_KEY", "")
2007 os.Unsetenv("MIMO_API_KEY")
2008 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
2009
2010 cfg := config.Default()
2011 custom := config.ProviderEntry{
2012 Name: "mimo-api",
2013 Kind: "openai",
2014 BaseURL: "https://custom.example/v1",
2015 Models: []string{"custom-model"},
2016 Default: "custom-model",
2017 APIKeyEnv: "MIMO_API_KEY",
2018 Headers: map[string]string{"X-Custom": "remove-me"},
2019 }
2020 if err := cfg.UpsertProvider(custom); err != nil {
2021 t.Fatalf("upsert custom provider: %v", err)
2022 }
2023 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2024 t.Fatalf("save config: %v", err)
2025 }
2026
2027 if err := NewApp().ResetProviderPresetAccess("mimo-api"); err != nil {
2028 t.Fatalf("ResetProviderPresetAccess: %v", err)
2029 }
2030
2031 cfg = config.LoadForEdit(config.UserConfigPath())
2032 got, ok := cfg.Provider("mimo-api")
2033 if !ok {
2034 t.Fatal("mimo-api provider missing after reset")
2035 }
2036 if got.BaseURL != "https://api.xiaomimimo.com/v1" || got.DefaultModel() != "mimo-v2.5-pro" || got.PresetID != "mimo-api" || got.PresetVersion != config.ProviderPresetVersion {
2037 t.Fatalf("mimo-api provider after reset = %+v, want preset template", got)
2038 }
2039 if len(got.Headers) != 0 {
2040 t.Fatalf("mimo-api headers after reset = %+v, want preset headers", got.Headers)
2041 }
2042 if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
2043 t.Fatalf("provider_access missing mimo-api after reset: %+v", cfg.Desktop.ProviderAccess)
2044 }
2045 data, err := os.ReadFile(config.UserCredentialsPath())
2046 if err != nil {
2047 t.Fatalf("read saved credentials: %v", err)
2048 }
2049 if !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
2050 t.Fatalf("credentials changed after reset:\n%s", data)
2051 }
2052
2053 presetView := providerPresetViewByID(t, NewApp().Settings(), "mimo-api")
2054 if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
2055 t.Fatalf("mimo-api preset view = %+v, want installed after reset", presetView)
2056 }
2057 }
2058
2059 func TestResetProviderPresetAccessRejectsMissingSameNameProvider(t *testing.T) {
2060 isolateDesktopUserDirs(t)
2061
2062 if err := NewApp().ResetProviderPresetAccess("mimo-api"); err == nil {
2063 t.Fatal("ResetProviderPresetAccess unexpectedly reset a missing provider")
2064 } else if !strings.Contains(err.Error(), "no same-name provider exists") {
2065 t.Fatalf("ResetProviderPresetAccess error = %v, want missing same-name provider guard", err)
2066 }
2067 }
2068
2069 func TestAddEveryProviderPresetAccessInstallsTemplate(t *testing.T) {
2070 for _, preset := range config.CuratedProviderPresets() {
2071 t.Run(preset.ID, func(t *testing.T) {
2072 isolateDesktopUserDirs(t)
2073
2074 if warning, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-test"); err != nil {
2075 t.Fatalf("AddProviderPresetAccess(%q): %v", preset.ID, err)
2076 } else if warning != "" {
2077 t.Fatalf("AddProviderPresetAccess(%q) warning = %q, want none", preset.ID, warning)
2078 }
2079
2080 cfg := config.LoadForEdit(config.UserConfigPath())
2081 access := providerAccessSet(cfg.Desktop.ProviderAccess)
2082 for _, entry := range preset.Entries {
2083 got, ok := cfg.Provider(entry.Name)
2084 if !ok {
2085 t.Fatalf("provider %q from preset %q was not saved", entry.Name, preset.ID)
2086 }
2087 if !access[entry.Name] {
2088 t.Fatalf("provider_access for preset %q missing %q: %+v", preset.ID, entry.Name, cfg.Desktop.ProviderAccess)
2089 }
2090 if got.Kind != entry.Kind || got.BaseURL != entry.BaseURL || got.DefaultModel() != entry.DefaultModel() || got.APIKeyEnv == entry.APIKeyEnv || !config.CredentialStored(got.APIKeyEnv) || got.AuthHeader != entry.AuthHeader || got.NoProxy != entry.NoProxy {
2091 t.Fatalf("provider %q core fields = %+v, want template %+v", entry.Name, got, entry)
2092 }
2093 if got.PresetID != preset.ID || got.PresetVersion != config.ProviderPresetVersion {
2094 t.Fatalf("provider %q preset metadata = %q/%d, want %q/%d", entry.Name, got.PresetID, got.PresetVersion, preset.ID, config.ProviderPresetVersion)
2095 }
2096 if got.ContextWindow != entry.ContextWindow || got.Thinking != entry.Thinking || got.DefaultEffort != entry.DefaultEffort || got.ReasoningProtocol != entry.ReasoningProtocol {
2097 t.Fatalf("provider %q capability fields = %+v, want template %+v", entry.Name, got, entry)
2098 }
2099 if !reflect.DeepEqual(got.ModelList(), entry.ModelList()) || !reflect.DeepEqual(got.VisionModels, entry.VisionModels) || !reflect.DeepEqual(got.SupportedEfforts, entry.SupportedEfforts) {
2100 t.Fatalf("provider %q models/capabilities = %+v, want template %+v", entry.Name, got, entry)
2101 }
2102 if !reflect.DeepEqual(got.Headers, entry.Headers) || !reflect.DeepEqual(got.ExtraBody, entry.ExtraBody) {
2103 t.Fatalf("provider %q request extras = %+v, want template %+v", entry.Name, got, entry)
2104 }
2105 }
2106
2107 view := NewApp().Settings()
2108 var presetView *ProviderPresetView
2109 for i := range view.ProviderPresets {
2110 if view.ProviderPresets[i].ID == preset.ID {
2111 presetView = &view.ProviderPresets[i]
2112 break
2113 }
2114 }
2115 if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet || !presetView.Configured {
2116 t.Fatalf("preset view for %q = %+v, want installed/key-set/configured", preset.ID, presetView)
2117 }
2118 })
2119 }
2120 }
2121
2122 func TestAddOpenCodeGoRecommendedPresetCompletesMissingRoutes(t *testing.T) {
2123 isolateDesktopUserDirs(t)
2124 t.Setenv("OPENCODE_GO_API_KEY", "")
2125 os.Unsetenv("OPENCODE_GO_API_KEY")
2126
2127 preset, ok := config.CuratedProviderPreset("opencode-go-recommended")
2128 if !ok || len(preset.Entries) != 3 {
2129 t.Fatalf("recommended preset = %+v, found=%v", preset, ok)
2130 }
2131 cfg := config.Default()
2132 seed := preset.Entries[0]
2133 seed.PresetID = "opencode-go"
2134 if err := cfg.UpsertProvider(seed); err != nil {
2135 t.Fatalf("seed existing OpenCode Go route: %v", err)
2136 }
2137 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2138 t.Fatalf("save seed config: %v", err)
2139 }
2140 partial := providerPresetViewByID(t, NewApp().Settings(), preset.ID)
2141 if partial.Status != providerPresetStatusPartial || len(partial.MissingProviderNames) != 2 {
2142 t.Fatalf("recommended preset partial view = %+v, want two missing routes", partial)
2143 }
2144
2145 if warning, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-opencode"); err != nil {
2146 t.Fatalf("AddProviderPresetAccess: %v", err)
2147 } else if warning != "" {
2148 t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
2149 }
2150
2151 cfg = config.LoadForEdit(config.UserConfigPath())
2152 for _, entry := range preset.Entries {
2153 if _, ok := cfg.Provider(entry.Name); !ok {
2154 t.Fatalf("missing recommended route %q after completion", entry.Name)
2155 }
2156 }
2157 data, err := os.ReadFile(config.UserCredentialsPath())
2158 if err != nil {
2159 t.Fatalf("read saved credentials: %v", err)
2160 }
2161 for _, route := range preset.Entries {
2162 entry, _ := cfg.Provider(route.Name)
2163 if entry.APIKeyEnv == "OPENCODE_GO_API_KEY" || !strings.Contains(string(data), entry.APIKeyEnv+"=sk-opencode") {
2164 t.Fatalf("route %s did not receive an isolated credential reference", route.Name)
2165 }
2166 }
2167 }
2168
2169 func TestAddOpenCodeGoRecommendedPresetSelectsUsableDefaultForFreshSetup(t *testing.T) {
2170 isolateDesktopUserDirs(t)
2171 t.Setenv("OPENCODE_GO_API_KEY", "")
2172 os.Unsetenv("OPENCODE_GO_API_KEY")
2173
2174 cfg := config.Default()
2175 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2176 t.Fatalf("save fresh config: %v", err)
2177 }
2178 if _, err := NewApp().AddProviderPresetAccess("opencode-go-recommended", "sk-opencode"); err != nil {
2179 t.Fatalf("AddProviderPresetAccess: %v", err)
2180 }
2181
2182 got := config.LoadForEdit(config.UserConfigPath())
2183 if got.DefaultModel != "opencode-go/glm-5.3" {
2184 t.Fatalf("default model = %q, want ready-to-use OpenCode Go default", got.DefaultModel)
2185 }
2186 }
2187
2188 func TestAddOpenCodeGoRecommendedPresetPreservesConfiguredDefault(t *testing.T) {
2189 isolateDesktopUserDirs(t)
2190 t.Setenv("OPENCODE_GO_API_KEY", "")
2191 os.Unsetenv("OPENCODE_GO_API_KEY")
2192
2193 cfg := config.Default()
2194 if err := cfg.UpsertProvider(config.ProviderEntry{
2195 Name: "local-ready",
2196 Kind: "openai",
2197 BaseURL: "http://127.0.0.1:11434/v1",
2198 Models: []string{"local-model"},
2199 Default: "local-model",
2200 }); err != nil {
2201 t.Fatalf("upsert configured provider: %v", err)
2202 }
2203 if err := cfg.SetDefaultModel("local-ready/local-model"); err != nil {
2204 t.Fatalf("set configured default: %v", err)
2205 }
2206 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2207 t.Fatalf("save configured default: %v", err)
2208 }
2209
2210 if _, err := NewApp().AddProviderPresetAccess("opencode-go-recommended", "sk-opencode"); err != nil {
2211 t.Fatalf("AddProviderPresetAccess: %v", err)
2212 }
2213 if got := config.LoadForEdit(config.UserConfigPath()).DefaultModel; got != "local-ready/local-model" {
2214 t.Fatalf("default model = %q, want existing configured default preserved", got)
2215 }
2216 }
2217
2218 func TestAddOpenCodeGoRecommendedPresetPreservesModifiedRoute(t *testing.T) {
2219 isolateDesktopUserDirs(t)
2220 t.Setenv("OPENCODE_GO_API_KEY", "")
2221 os.Unsetenv("OPENCODE_GO_API_KEY")
2222
2223 preset, ok := config.CuratedProviderPreset("opencode-go-recommended")
2224 if !ok || len(preset.Entries) != 3 {
2225 t.Fatalf("recommended preset = %+v, found=%v", preset, ok)
2226 }
2227 cfg := config.Default()
2228 modified := preset.Entries[0]
2229 modified.BaseURL = "https://custom.example/v1"
2230 modified.PresetID = "opencode-go"
2231 if err := cfg.UpsertProvider(modified); err != nil {
2232 t.Fatalf("seed modified OpenCode Go route: %v", err)
2233 }
2234 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2235 t.Fatalf("save modified config: %v", err)
2236 }
2237
2238 if _, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-opencode"); err != nil {
2239 t.Fatalf("AddProviderPresetAccess: %v", err)
2240 }
2241 cfg = config.LoadForEdit(config.UserConfigPath())
2242 got, ok := cfg.Provider("opencode-go")
2243 if !ok || got.BaseURL != "https://custom.example/v1" {
2244 t.Fatalf("modified route = %+v, want preserved custom endpoint", got)
2245 }
2246 for _, name := range []string{"opencode-go-anthropic", "opencode-go-responses"} {
2247 if _, ok := cfg.Provider(name); !ok {
2248 t.Fatalf("missing route %q after completing bundle around modified route", name)
2249 }
2250 }
2251 }
2252
2253 func TestAddOpenCodeGoRecommendedPresetRejectsConflictAtomically(t *testing.T) {
2254 isolateDesktopUserDirs(t)
2255 t.Setenv("OPENCODE_GO_API_KEY", "")
2256 os.Unsetenv("OPENCODE_GO_API_KEY")
2257
2258 cfg := config.Default()
2259 conflict := config.ProviderEntry{
2260 Name: "opencode-go",
2261 Kind: "openai",
2262 BaseURL: "https://custom.example/v1",
2263 Models: []string{"custom-model"},
2264 Default: "custom-model",
2265 APIKeyEnv: "OPENCODE_GO_API_KEY",
2266 PresetID: "custom",
2267 PresetVersion: 1,
2268 }
2269 if err := cfg.UpsertProvider(conflict); err != nil {
2270 t.Fatalf("upsert conflicting provider: %v", err)
2271 }
2272 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2273 t.Fatalf("save conflict config: %v", err)
2274 }
2275
2276 if warning, err := NewApp().AddProviderPresetAccess("opencode-go-recommended", "sk-should-not-save"); err == nil {
2277 t.Fatal("AddProviderPresetAccess unexpectedly accepted same-name conflict")
2278 } else if !strings.Contains(err.Error(), "opencode-go") {
2279 t.Fatalf("AddProviderPresetAccess error = %v, want opencode-go conflict", err)
2280 } else if warning != "" {
2281 t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
2282 }
2283
2284 cfg = config.LoadForEdit(config.UserConfigPath())
2285 if _, ok := cfg.Provider("opencode-go-anthropic"); ok {
2286 t.Fatal("conflicting bundle partially installed Anthropic route")
2287 }
2288 if _, err := os.Stat(config.UserCredentialsPath()); err == nil {
2289 data, readErr := os.ReadFile(config.UserCredentialsPath())
2290 if readErr != nil {
2291 t.Fatalf("read credentials: %v", readErr)
2292 }
2293 if strings.Contains(string(data), "sk-should-not-save") {
2294 t.Fatalf("conflicting bundle saved credentials: %s", data)
2295 }
2296 }
2297 }
2298
2299 func TestAddOfficialProviderAccessPreservesBackgroundJobsWhenSavingKey(t *testing.T) {
2300 isolateDesktopUserDirs(t)
2301 t.Setenv("DEEPSEEK_API_KEY", "")
2302 os.Unsetenv("DEEPSEEK_API_KEY")
2303
2304 app := NewApp()
2305 app.readyHook = func() {}
2306 app.setTestCtrl(newBackgroundJobController(t, "provider-access-job"), "deepseek-flash/deepseek-v4-flash")
2307
2308 _, err := app.AddOfficialProviderAccess("deepseek", "sk-test")
2309 if err != nil || !controllerHasActiveRuntimeWork(app.activeCtrl()) {
2310 t.Fatalf("AddOfficialProviderAccess interrupted background work: %v", err)
2311 }
2312 p, _ := config.LoadForEdit(config.UserConfigPath()).Provider("deepseek")
2313 if !config.CredentialStored(p.APIKeyEnv) || p.APIKeyEnv == "DEEPSEEK_API_KEY" {
2314 t.Fatal("official key was not committed to a new reference")
2315 }
2316 }
2317
2318 func TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {
2319 isolateDesktopUserDirs(t)
2320 t.Setenv("DEEPSEEK_API_KEY", "")
2321 os.Unsetenv("DEEPSEEK_API_KEY")
2322 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
2323 t.Fatalf("mkdir config dir: %v", err)
2324 }
2325 if err := os.WriteFile(config.UserConfigPath(), []byte(`
2326 default_model = "deepseek/deepseek-v4-flash"
2327
2328 [desktop]
2329 provider_access = []
2330
2331 [[providers]]
2332 name = "deepseek"
2333 kind = "openai"
2334 base_url = "https://api.deepseek.com"
2335 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
2336 default = "deepseek-v4-flash"
2337 api_key_env = "DEEPSEEK_API_KEY"
2338 `), 0o644); err != nil {
2339 t.Fatalf("write config: %v", err)
2340 }
2341
2342 if _, err := NewApp().SetProviderKey("DEEPSEEK_API_KEY", "sk-test"); err != nil {
2343 t.Fatalf("SetProviderKey: %v", err)
2344 }
2345 cfg := config.LoadForEdit(config.UserConfigPath())
2346 if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
2347 t.Fatalf("provider_access = %+v, want deepseek restored", cfg.Desktop.ProviderAccess)
2348 }
2349 got := NewApp().Settings()
2350 for _, p := range got.Providers {
2351 if p.Name == "deepseek" {
2352 if !p.Added || !p.KeySet {
2353 t.Fatalf("deepseek settings = %+v, want added and key-set", p)
2354 }
2355 return
2356 }
2357 }
2358 t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
2359 }
2360
2361 func TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {
2362 isolateDesktopUserDirs(t)
2363 t.Setenv("PROXY_DEEPSEEK_KEY", "")
2364 os.Unsetenv("PROXY_DEEPSEEK_KEY")
2365 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
2366 t.Fatalf("mkdir config dir: %v", err)
2367 }
2368 if err := os.WriteFile(config.UserConfigPath(), []byte(`
2369 [desktop]
2370 provider_access = []
2371
2372 [[providers]]
2373 name = "deepseek-flash"
2374 kind = "openai"
2375 base_url = "https://proxy.example/v1"
2376 model = "deepseek-v4-flash"
2377 api_key_env = "PROXY_DEEPSEEK_KEY"
2378 `), 0o644); err != nil {
2379 t.Fatalf("write config: %v", err)
2380 }
2381
2382 if _, err := NewApp().SetProviderKey("PROXY_DEEPSEEK_KEY", "sk-test"); err != nil {
2383 t.Fatalf("SetProviderKey: %v", err)
2384 }
2385 cfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
2386 access := providerAccessSet(cfg.Desktop.ProviderAccess)
2387 if !access["deepseek-flash"] {
2388 t.Fatalf("provider_access = %+v, want custom alias deepseek-flash", cfg.Desktop.ProviderAccess)
2389 }
2390 if access["deepseek"] {
2391 t.Fatalf("provider_access = %+v, should not canonicalize custom proxy to deepseek", cfg.Desktop.ProviderAccess)
2392 }
2393 }
2394
2395 func TestSetProviderKeyLeaseHeldKeepsCurrentController(t *testing.T) {
2396 isolateDesktopUserDirs(t)
2397 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2398
2399 cfg := config.Default()
2400 cfg.DefaultModel = "old/old-model"
2401 cfg.Desktop.ProviderAccess = []string{"old"}
2402 cfg.Providers = []config.ProviderEntry{
2403 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
2404 {Name: "longcat", Kind: "openai", BaseURL: "https://longcat.example/v1", Model: "longcat-chat", APIKeyEnv: "LONGCAT_API_KEY"},
2405 }
2406 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2407 t.Fatalf("save config: %v", err)
2408 }
2409
2410 dir := config.SessionDir()
2411 if err := os.MkdirAll(dir, 0o755); err != nil {
2412 t.Fatalf("mkdir session dir: %v", err)
2413 }
2414 sessionPath := filepath.Join(dir, "externally-leased-provider-key.jsonl")
2415 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2416 t.Fatalf("write placeholder session: %v", err)
2417 }
2418 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2419 if err != nil {
2420 t.Fatalf("TryAcquireSessionLease: %v", err)
2421 }
2422 defer externalLease.Release()
2423
2424 oldSession := agent.NewSession("old system prompt")
2425 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
2426 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
2427 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2428 defer oldCtrl.Close()
2429
2430 app := NewApp()
2431 app.ctx = context.Background()
2432 tab := &WorkspaceTab{
2433 ID: "tab_provider",
2434 Scope: "global",
2435 SessionPath: sessionPath,
2436 Ready: true,
2437 model: "old/old-model",
2438 Ctrl: oldCtrl,
2439 sink: &tabEventSink{tabID: "tab_provider", app: app},
2440 disabledMCP: map[string]ServerView{},
2441 }
2442 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2443 app.tabOrder = []string{tab.ID}
2444 app.activeTabID = tab.ID
2445
2446 warning, err := app.SetProviderKey("LONGCAT_API_KEY", "sk-longcat")
2447 if err != nil {
2448 t.Fatalf("SetProviderKey: %v", err)
2449 }
2450 if warning != "" {
2451 t.Fatalf("SetProviderKey warning = %q; saving does not acquire the session lease", warning)
2452 }
2453 if strings.Contains(warning, sessionPath) || strings.Contains(warning, "held by") {
2454 t.Fatalf("SetProviderKey surfaced raw lease details: %v", warning)
2455 }
2456 if tab.Ctrl != oldCtrl {
2457 t.Fatalf("tab controller changed after failed provider-key rebuild")
2458 }
2459 if tab.StartupErr != "" {
2460 t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
2461 }
2462 if got := tab.Ctrl.History(); len(got) < 2 || got[1].Content != "hello" {
2463 t.Fatalf("history after failed provider-key rebuild = %+v", got)
2464 }
2465 if access := providerAccessSet(config.LoadForEditWithoutCredentials(config.UserConfigPath()).Desktop.ProviderAccess); !access["longcat"] {
2466 t.Fatalf("provider_access should still persist longcat after key save")
2467 }
2468 }
2469
2470 func TestSetProviderKeyPreservesInFlightStartupBuild(t *testing.T) {
2471 isolateDesktopUserDirs(t)
2472
2473 cfg := config.Default()
2474 cfg.DefaultModel = "old/old-model"
2475 cfg.Desktop.ProviderAccess = []string{"old"}
2476 cfg.Providers = []config.ProviderEntry{{
2477 Name: "old",
2478 Kind: "openai",
2479 BaseURL: "https://example.invalid/v1",
2480 Model: "old-model",
2481 APIKeyEnv: "OLD_MODEL_KEY",
2482 }}
2483 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2484 t.Fatalf("save config: %v", err)
2485 }
2486
2487 dir := config.SessionDir()
2488 if err := os.MkdirAll(dir, 0o755); err != nil {
2489 t.Fatalf("mkdir session dir: %v", err)
2490 }
2491 sessionPath := filepath.Join(dir, "startup-build-in-flight.jsonl")
2492 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2493 t.Fatalf("write placeholder session: %v", err)
2494 }
2495
2496 app := NewApp()
2497 app.ctx = context.Background()
2498 app.readyHook = func() {}
2499 // Model the async startup build still being in flight: no controller yet,
2500 // a live build generation, and a cancellable build context.
2501 buildCtx, buildCancel := context.WithCancel(context.Background())
2502 const startupGeneration = 1
2503 tab := &WorkspaceTab{
2504 ID: "tab_key_rebuild",
2505 Scope: "global",
2506 SessionPath: sessionPath,
2507 model: "old/old-model",
2508 buildGeneration: startupGeneration,
2509 buildCancel: buildCancel,
2510 disabledMCP: map[string]ServerView{},
2511 }
2512 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
2513 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2514 app.tabOrder = []string{tab.ID}
2515 app.activeTabID = tab.ID
2516 t.Cleanup(tab.releaseSessionLease)
2517
2518 if _, err := app.SetProviderKey("OLD_MODEL_KEY", "sk-new"); err != nil {
2519 t.Fatalf("SetProviderKey: %v", err)
2520 }
2521 if tab.Ctrl != nil || tab.buildGeneration != startupGeneration || buildCtx.Err() != nil {
2522 t.Fatal("saving a key published or cancelled an in-flight startup; publication owns its version check")
2523 }
2524 buildCancel()
2525 }
2526
2527 func TestSaveProviderWithKeyLeaseHeldPersistsCustomProvider(t *testing.T) {
2528 isolateDesktopUserDirs(t)
2529 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2530
2531 cfg := config.Default()
2532 cfg.DefaultModel = "old/old-model"
2533 cfg.Desktop.ProviderAccess = []string{"old"}
2534 cfg.Providers = []config.ProviderEntry{{
2535 Name: "old",
2536 Kind: "openai",
2537 BaseURL: "https://example.invalid/v1",
2538 Model: "old-model",
2539 APIKeyEnv: "OLD_MODEL_KEY",
2540 }}
2541 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2542 t.Fatalf("save config: %v", err)
2543 }
2544
2545 dir := config.SessionDir()
2546 if err := os.MkdirAll(dir, 0o755); err != nil {
2547 t.Fatalf("mkdir session dir: %v", err)
2548 }
2549 sessionPath := filepath.Join(dir, "externally-leased-custom-provider.jsonl")
2550 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2551 t.Fatalf("write placeholder session: %v", err)
2552 }
2553 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2554 if err != nil {
2555 t.Fatalf("TryAcquireSessionLease: %v", err)
2556 }
2557 defer externalLease.Release()
2558
2559 oldSession := agent.NewSession("old system prompt")
2560 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
2561 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
2562 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2563 defer oldCtrl.Close()
2564
2565 app := NewApp()
2566 app.ctx = context.Background()
2567 tab := &WorkspaceTab{
2568 ID: "tab_custom_provider",
2569 Scope: "global",
2570 SessionPath: sessionPath,
2571 Ready: true,
2572 model: "old/old-model",
2573 Ctrl: oldCtrl,
2574 sink: &tabEventSink{tabID: "tab_custom_provider", app: app},
2575 disabledMCP: map[string]ServerView{},
2576 }
2577 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2578 app.tabOrder = []string{tab.ID}
2579 app.activeTabID = tab.ID
2580
2581 warning, err := app.SaveProviderWithKey(ProviderView{
2582 Name: "proxy",
2583 Kind: "openai",
2584 BaseURL: "https://proxy.example/v1",
2585 Models: []string{"model-a", "model-b"},
2586 Default: "model-a",
2587 APIKeyEnv: "PROXY_API_KEY",
2588 }, "sk-proxy")
2589 if err != nil {
2590 t.Fatalf("SaveProviderWithKey: %v", err)
2591 }
2592 if warning != "" {
2593 t.Fatalf("SaveProviderWithKey warning = %q; saving does not acquire the session lease", warning)
2594 }
2595 if strings.Contains(warning, sessionPath) || strings.Contains(warning, "held by") {
2596 t.Fatalf("SaveProviderWithKey surfaced raw lease details: %v", warning)
2597 }
2598 if tab.Ctrl != oldCtrl {
2599 t.Fatalf("tab controller changed after failed provider rebuild")
2600 }
2601 gotCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
2602 got, ok := gotCfg.Provider("proxy")
2603 if !ok {
2604 t.Fatal("custom provider was not saved")
2605 }
2606 if want := []string{"model-a", "model-b"}; !reflect.DeepEqual(got.ModelList(), want) {
2607 t.Fatalf("custom provider models = %v, want %v", got.ModelList(), want)
2608 }
2609 if !providerAccessSet(gotCfg.Desktop.ProviderAccess)["proxy"] {
2610 t.Fatalf("provider_access = %+v, want proxy", gotCfg.Desktop.ProviderAccess)
2611 }
2612 data, err := os.ReadFile(config.UserCredentialsPath())
2613 if err != nil {
2614 t.Fatalf("read credentials: %v", err)
2615 }
2616 if got.APIKeyEnv == "PROXY_API_KEY" || !strings.Contains(string(data), got.APIKeyEnv+"=sk-proxy") {
2617 t.Fatal("provider key was not saved with an isolated reference")
2618 }
2619 }
2620
2621 func TestConfigChangeLeaseHeldPersistsAndDefersRefresh(t *testing.T) {
2622 isolateDesktopUserDirs(t)
2623 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2624
2625 cfg := config.Default()
2626 cfg.DefaultModel = "old/old-model"
2627 cfg.Desktop.ProviderAccess = []string{"old"}
2628 cfg.Providers = []config.ProviderEntry{{
2629 Name: "old",
2630 Kind: "openai",
2631 BaseURL: "https://example.invalid/v1",
2632 Model: "old-model",
2633 APIKeyEnv: "OLD_MODEL_KEY",
2634 }}
2635 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2636 t.Fatalf("save config: %v", err)
2637 }
2638
2639 dir := config.SessionDir()
2640 if err := os.MkdirAll(dir, 0o755); err != nil {
2641 t.Fatalf("mkdir session dir: %v", err)
2642 }
2643 sessionPath := filepath.Join(dir, "externally-leased-settings.jsonl")
2644 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2645 t.Fatalf("write placeholder session: %v", err)
2646 }
2647 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2648 if err != nil {
2649 t.Fatalf("TryAcquireSessionLease: %v", err)
2650 }
2651 defer externalLease.Release()
2652
2653 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
2654 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2655 defer oldCtrl.Close()
2656
2657 app := NewApp()
2658 app.ctx = context.Background()
2659 tab := &WorkspaceTab{
2660 ID: "tab_settings",
2661 Scope: "global",
2662 SessionPath: sessionPath,
2663 Ready: true,
2664 model: "old/old-model",
2665 Ctrl: oldCtrl,
2666 sink: &tabEventSink{tabID: "tab_settings", app: app},
2667 disabledMCP: map[string]ServerView{},
2668 }
2669 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2670 app.tabOrder = []string{tab.ID}
2671 app.activeTabID = tab.ID
2672
2673 if err := app.SetMaxSubagentDepth(1); err != nil {
2674 t.Fatalf("SetMaxSubagentDepth should defer lease-held refresh instead of failing: %v", err)
2675 }
2676 if tab.Ctrl != oldCtrl {
2677 t.Fatalf("tab controller changed after deferred settings rebuild")
2678 }
2679 got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
2680 if got.Agent.MaxSubagentDepth != 1 {
2681 t.Fatalf("saved max_subagent_depth = %d, want 1", got.Agent.MaxSubagentDepth)
2682 }
2683 }
2684
2685 func TestDeferredRebuildRetryAppliesAfterLeaseRelease(t *testing.T) {
2686 isolateDesktopUserDirs(t)
2687 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2688
2689 cfg := config.Default()
2690 cfg.DefaultModel = "old/old-model"
2691 cfg.Desktop.ProviderAccess = []string{"old"}
2692 cfg.Providers = []config.ProviderEntry{{
2693 Name: "old",
2694 Kind: "openai",
2695 BaseURL: "https://example.invalid/v1",
2696 Model: "old-model",
2697 APIKeyEnv: "OLD_MODEL_KEY",
2698 }}
2699 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2700 t.Fatalf("save config: %v", err)
2701 }
2702
2703 dir := config.SessionDir()
2704 if err := os.MkdirAll(dir, 0o755); err != nil {
2705 t.Fatalf("mkdir session dir: %v", err)
2706 }
2707 sessionPath := filepath.Join(dir, "deferred-rebuild-retry.jsonl")
2708 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2709 t.Fatalf("write placeholder session: %v", err)
2710 }
2711 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2712 if err != nil {
2713 t.Fatalf("TryAcquireSessionLease: %v", err)
2714 }
2715 released := false
2716 defer func() {
2717 if !released {
2718 externalLease.Release()
2719 }
2720 }()
2721
2722 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
2723 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2724 defer oldCtrl.Close()
2725
2726 app := NewApp()
2727 app.ctx = context.Background()
2728 app.readyHook = func() {}
2729 tab := &WorkspaceTab{
2730 ID: "tab_deferred_retry",
2731 Scope: "global",
2732 SessionPath: sessionPath,
2733 Ready: true,
2734 model: "old/old-model",
2735 Ctrl: oldCtrl,
2736 sink: &tabEventSink{tabID: "tab_deferred_retry", app: app},
2737 disabledMCP: map[string]ServerView{},
2738 }
2739 installNoopRuntimeEvents(app, tab.sink)
2740 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2741 app.tabOrder = []string{tab.ID}
2742 app.activeTabID = tab.ID
2743 t.Cleanup(func() {
2744 if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
2745 c.Close()
2746 }
2747 tab.releaseSessionLease()
2748 })
2749
2750 if err := app.SetAgentParams(0.2, 0, 0, "updated prompt"); err != nil {
2751 t.Fatalf("SetAgentParams: %v", err)
2752 }
2753 if !app.deferredRebuildPending(tab.ID) {
2754 t.Fatal("deferred rebuild was not scheduled while the lease is held")
2755 }
2756 if app.controllerForTab(tab) != oldCtrl {
2757 t.Fatal("controller changed while the lease is still held")
2758 }
2759
2760 externalLease.Release()
2761 released = true
2762
2763 app.deferredRebuildTick(false)
2764 if app.deferredRebuildPending(tab.ID) {
2765 t.Fatal("deferred rebuild is still pending after the lease was released")
2766 }
2767 if c := app.controllerForTab(tab); c == nil || c == oldCtrl {
2768 t.Fatalf("controller was not rebuilt after the lease release: got %p", c)
2769 }
2770 }
2771
2772 func TestDeferredRebuildScheduleAfterStopIsNoop(t *testing.T) {
2773 app := NewApp()
2774 app.stopDeferredRebuildRetry()
2775 app.scheduleDeferredRebuild("tab_x", "settings")
2776 if app.deferredRebuildPending("tab_x") {
2777 t.Fatal("schedule after stop should not register pending work")
2778 }
2779 }
2780
2781 func TestDeferredRebuildAppliesToItsInactiveTarget(t *testing.T) {
2782 isolateDesktopUserDirs(t)
2783 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2784
2785 cfg := config.Default()
2786 cfg.DefaultModel = "old/old-model"
2787 cfg.Desktop.ProviderAccess = []string{"old"}
2788 cfg.Providers = []config.ProviderEntry{{
2789 Name: "old",
2790 Kind: "openai",
2791 BaseURL: "https://example.invalid/v1",
2792 Model: "old-model",
2793 APIKeyEnv: "OLD_MODEL_KEY",
2794 }}
2795 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2796 t.Fatalf("save config: %v", err)
2797 }
2798
2799 dir := config.SessionDir()
2800 if err := os.MkdirAll(dir, 0o755); err != nil {
2801 t.Fatalf("mkdir session dir: %v", err)
2802 }
2803 sessionPath := filepath.Join(dir, "deferred-rebuild-inactive.jsonl")
2804 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2805 t.Fatalf("write placeholder session: %v", err)
2806 }
2807 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2808 if err != nil {
2809 t.Fatalf("TryAcquireSessionLease: %v", err)
2810 }
2811 released := false
2812 defer func() {
2813 if !released {
2814 externalLease.Release()
2815 }
2816 }()
2817
2818 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
2819 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2820 defer oldCtrl.Close()
2821
2822 otherCtrl := control.New(control.Options{Label: "other"})
2823 defer otherCtrl.Close()
2824
2825 app := NewApp()
2826 app.ctx = context.Background()
2827 app.readyHook = func() {}
2828 tab := &WorkspaceTab{
2829 ID: "tab_pending",
2830 Scope: "global",
2831 SessionPath: sessionPath,
2832 Ready: true,
2833 model: "old/old-model",
2834 Ctrl: oldCtrl,
2835 sink: &tabEventSink{tabID: "tab_pending", app: app},
2836 disabledMCP: map[string]ServerView{},
2837 }
2838 installNoopRuntimeEvents(app, tab.sink)
2839 other := &WorkspaceTab{
2840 ID: "tab_other",
2841 Scope: "global",
2842 Ready: true,
2843 model: "old/old-model",
2844 Ctrl: otherCtrl,
2845 sink: &tabEventSink{tabID: "tab_other", app: app},
2846 disabledMCP: map[string]ServerView{},
2847 }
2848 app.tabs = map[string]*WorkspaceTab{tab.ID: tab, other.ID: other}
2849 app.tabOrder = []string{tab.ID, other.ID}
2850 app.activeTabID = tab.ID
2851 t.Cleanup(func() {
2852 if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
2853 c.Close()
2854 }
2855 tab.releaseSessionLease()
2856 })
2857
2858 if err := app.SetAgentParams(0.2, 0, 0, "updated prompt"); err != nil {
2859 t.Fatalf("SetAgentParams: %v", err)
2860 }
2861 if !app.deferredRebuildPending(tab.ID) {
2862 t.Fatal("deferred rebuild was not scheduled while the lease is held")
2863 }
2864
2865 // A concrete target retains its rebuild ownership when focus moves.
2866 app.mu.Lock()
2867 app.activeTabID = other.ID
2868 app.mu.Unlock()
2869 externalLease.Release()
2870 released = true
2871 app.deferredRebuildTick(false)
2872 if app.deferredRebuildPending(tab.ID) || app.controllerForTab(tab) == oldCtrl {
2873 t.Fatal("inactive target was not rebuilt")
2874 }
2875 if app.controllerForTab(other) != otherCtrl {
2876 t.Fatal("retry rebuilt the focused sibling")
2877 }
2878 }
2879
2880 func TestSetEffortForTabLeaseHeldKeepsOldControllerAlive(t *testing.T) {
2881 isolateDesktopUserDirs(t)
2882 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2883
2884 cfg := config.Default()
2885 cfg.DefaultModel = "old/old-model"
2886 cfg.Desktop.ProviderAccess = []string{"old"}
2887 cfg.Providers = []config.ProviderEntry{{
2888 Name: "old",
2889 Kind: "openai",
2890 BaseURL: "https://example.invalid/v1",
2891 Model: "old-model",
2892 APIKeyEnv: "OLD_MODEL_KEY",
2893 SupportedEfforts: []string{"low", "max"},
2894 }}
2895 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2896 t.Fatalf("save config: %v", err)
2897 }
2898
2899 dir := config.SessionDir()
2900 if err := os.MkdirAll(dir, 0o755); err != nil {
2901 t.Fatalf("mkdir session dir: %v", err)
2902 }
2903 sessionPath := filepath.Join(dir, "externally-leased-effort-switch.jsonl")
2904 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2905 t.Fatalf("write placeholder session: %v", err)
2906 }
2907 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2908 if err != nil {
2909 t.Fatalf("TryAcquireSessionLease: %v", err)
2910 }
2911 released := false
2912 defer func() {
2913 if !released {
2914 externalLease.Release()
2915 }
2916 }()
2917
2918 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
2919 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2920 defer oldCtrl.Close()
2921
2922 app := NewApp()
2923 app.ctx = context.Background()
2924 tab := &WorkspaceTab{
2925 ID: "tab_effort",
2926 Scope: "global",
2927 SessionPath: sessionPath,
2928 Ready: true,
2929 model: "old/old-model",
2930 Ctrl: oldCtrl,
2931 sink: &tabEventSink{tabID: "tab_effort", app: app},
2932 disabledMCP: map[string]ServerView{},
2933 }
2934 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2935 app.tabOrder = []string{tab.ID}
2936 app.activeTabID = tab.ID
2937 t.Cleanup(func() {
2938 if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
2939 c.Close()
2940 }
2941 tab.releaseSessionLease()
2942 })
2943
2944 err = app.SetEffortForTab(tab.ID, "max")
2945 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
2946 t.Fatalf("SetEffortForTab err = %v, want ErrSessionLeaseHeld", err)
2947 }
2948 if strings.Contains(err.Error(), sessionPath) || strings.Contains(err.Error(), "held by") {
2949 t.Fatalf("SetEffortForTab surfaced raw lease details: %v", err)
2950 }
2951 if tab.Ctrl != oldCtrl {
2952 t.Fatal("tab controller changed after failed effort switch")
2953 }
2954
2955 // The failed switch must leave the old runtime alive: after the other
2956 // window releases the lease, retrying from the same tab has to succeed.
2957 // (The old code closed the old controller before acquiring the lease, so
2958 // this retry died on a snapshot of a closed session.)
2959 externalLease.Release()
2960 released = true
2961 if err := app.SetEffortForTab(tab.ID, "max"); err != nil {
2962 t.Fatalf("SetEffortForTab retry after lease release: %v", err)
2963 }
2964 if tab.Ctrl == oldCtrl {
2965 t.Fatal("retry did not rebuild the controller")
2966 }
2967 }
2968
2969 func TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {
2970 isolateDesktopUserDirs(t)
2971 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
2972 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
2973 t.Fatalf("mkdir config dir: %v", err)
2974 }
2975 if err := os.WriteFile(config.UserConfigPath(), []byte(`
2976 default_model = "deepseek-flash/deepseek-v4-pro"
2977
2978 [desktop]
2979 provider_access = ["deepseek-flash", "mimo-pro"]
2980
2981 [[providers]]
2982 name = "deepseek-flash"
2983 kind = "openai"
2984 base_url = "https://api.deepseek.com"
2985 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
2986 default = "deepseek-v4-flash"
2987 api_key_env = "DEEPSEEK_API_KEY"
2988
2989 [[providers]]
2990 name = "mimo-pro"
2991 kind = "openai"
2992 base_url = "https://token-plan-cn.xiaomimimo.com/v1"
2993 model = "mimo-v2.5-pro"
2994 api_key_env = "MIMO_API_KEY"
2995 `), 0o644); err != nil {
2996 t.Fatalf("write config: %v", err)
2997 }
2998
2999 if err := NewApp().RemoveProviderAccess("deepseek"); err != nil {
3000 t.Fatalf("RemoveProviderAccess: %v", err)
3001 }
3002 cfg := config.LoadForEdit(config.UserConfigPath())
3003 access := providerAccessSet(cfg.Desktop.ProviderAccess)
3004 if access["deepseek"] || !access["mimo-pro"] {
3005 t.Fatalf("provider_access = %+v, want only mimo-pro", cfg.Desktop.ProviderAccess)
3006 }
3007 if cfg.DefaultModel != "mimo-pro/mimo-v2.5-pro" {
3008 t.Fatalf("default_model = %q, want mimo-pro/mimo-v2.5-pro", cfg.DefaultModel)
3009 }
3010 }
3011
3012 func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
3013 isolateDesktopUserDirs(t)
3014 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
3015 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
3016
3017 cfg := config.Default()
3018 cfg.DefaultModel = "deepseek/deepseek-v4-flash"
3019 cfg.Desktop.ProviderAccess = []string{"deepseek", "mimo-pro"}
3020 cfg.Providers = append(cfg.Providers, config.ProviderEntry{
3021 Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
3022 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, Default: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
3023 })
3024 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3025 t.Fatalf("save config: %v", err)
3026 }
3027
3028 models := NewApp().Models()
3029 refs := modelRefsFromView(models)
3030 for _, want := range []string{
3031 "deepseek/deepseek-v4-flash",
3032 "deepseek/deepseek-v4-pro",
3033 "mimo-pro/mimo-v2.5-pro",
3034 "mimo-pro/mimo-v2.5",
3035 } {
3036 if !refs[want] {
3037 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3038 }
3039 }
3040 for _, hidden := range []string{
3041 "deepseek-pro/deepseek-v4-pro",
3042 "mimo-flash/mimo-v2.5",
3043 } {
3044 if refs[hidden] {
3045 t.Fatalf("Models() refs = %+v, should not include hidden provider %s", models, hidden)
3046 }
3047 }
3048 if len(models) != 5 {
3049 t.Fatalf("Models() len = %d, want 5: %+v", len(models), models)
3050 }
3051 }
3052
3053 func TestModelsForTabListsNothingWhenProviderAccessExplicitlyEmpty(t *testing.T) {
3054 isolateDesktopUserDirs(t)
3055 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
3056
3057 cfg := config.Default()
3058 cfg.Desktop.ProviderAccess = []string{}
3059 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3060 t.Fatalf("save config: %v", err)
3061 }
3062
3063 if models := NewApp().Models(); len(models) != 0 {
3064 t.Fatalf("Models() = %+v, want no models when provider access is explicitly empty", models)
3065 }
3066 }
3067
3068 func TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {
3069 isolateDesktopUserDirs(t)
3070 setDesktopTestCredential(t, "LOCAL_API_KEY", "sk-test")
3071 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
3072 t.Fatalf("mkdir config dir: %v", err)
3073 }
3074 if err := os.WriteFile(config.UserConfigPath(), []byte(`
3075 default_model = "local/model-a"
3076
3077 [desktop]
3078 provider_access = ["local"]
3079
3080 [[providers]]
3081 name = "local"
3082 kind = "openai"
3083 base_url = "http://127.0.0.1:23333/v1"
3084 models = ["model-a", "model-b"]
3085 default = "model-a"
3086 api_key_env = "LOCAL_API_KEY"
3087 `), 0o644); err != nil {
3088 t.Fatalf("write config: %v", err)
3089 }
3090
3091 models := NewApp().Models()
3092 refs := modelRefsFromView(models)
3093 for _, want := range []string{"local/model-a", "local/model-b"} {
3094 if !refs[want] {
3095 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3096 }
3097 }
3098 if len(models) != 2 {
3099 t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
3100 }
3101 }
3102
3103 func TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {
3104 isolateDesktopUserDirs(t)
3105 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
3106 t.Fatalf("mkdir config dir: %v", err)
3107 }
3108 if err := os.WriteFile(config.UserConfigPath(), []byte(`
3109 default_model = "local/model-a"
3110
3111 [desktop]
3112 provider_access = ["local"]
3113
3114 [[providers]]
3115 name = "local"
3116 kind = "openai"
3117 base_url = "http://127.0.0.1:23333/v1"
3118 models = ["model-a", "model-b"]
3119 default = "model-a"
3120 `), 0o644); err != nil {
3121 t.Fatalf("write config: %v", err)
3122 }
3123
3124 models := NewApp().Models()
3125 refs := modelRefsFromView(models)
3126 for _, want := range []string{"local/model-a", "local/model-b"} {
3127 if !refs[want] {
3128 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3129 }
3130 }
3131 }
3132
3133 func TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {
3134 isolateDesktopUserDirs(t)
3135 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
3136 t.Fatalf("mkdir config dir: %v", err)
3137 }
3138 if err := os.WriteFile(config.UserConfigPath(), []byte(`
3139 default_model = "local/model-a"
3140
3141 [desktop]
3142 provider_access = ["local"]
3143
3144 [[providers]]
3145 name = "local"
3146 kind = "openai"
3147 base_url = "http://127.0.0.1:23333/v1"
3148 models = ["model-a", "model-b"]
3149 default = "model-a"
3150 api_key_env = "LOCAL_API_KEY"
3151 `), 0o644); err != nil {
3152 t.Fatalf("write config: %v", err)
3153 }
3154
3155 models := NewApp().Models()
3156 refs := modelRefsFromView(models)
3157 for _, want := range []string{"local/model-a", "local/model-b"} {
3158 if !refs[want] {
3159 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3160 }
3161 }
3162 }
3163
3164 func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
3165 isolateDesktopUserDirs(t)
3166 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
3167
3168 cfg := config.Default()
3169 preset, ok := config.CuratedProviderPreset("mimo-api")
3170 if !ok || len(preset.Entries) == 0 {
3171 t.Fatal("mimo-api preset missing")
3172 }
3173 if err := cfg.UpsertProvider(preset.Entries[0]); err != nil {
3174 t.Fatalf("upsert mimo-api preset: %v", err)
3175 }
3176 cfg.DefaultModel = "mimo-api/mimo-v2.5-pro"
3177 cfg.Desktop.ProviderAccess = []string{"mimo-api"}
3178 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3179 t.Fatalf("save config: %v", err)
3180 }
3181
3182 models := NewApp().Models()
3183 refs := modelRefsFromView(models)
3184 for _, want := range []string{
3185 "mimo-api/mimo-v2.5-pro",
3186 "mimo-api/mimo-v2.5",
3187 } {
3188 if !refs[want] {
3189 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3190 }
3191 }
3192 if len(models) != 2 {
3193 t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
3194 }
3195 }
3196
3197 func TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {
3198 isolateDesktopUserDirs(t)
3199 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
3200 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
3201
3202 userCfg := config.Default()
3203 userCfg.DefaultModel = "mimo-pro/mimo-v2.5-pro"
3204 userCfg.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"}
3205 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
3206 t.Fatalf("save user config: %v", err)
3207 }
3208
3209 projectRoot := t.TempDir()
3210 projectConfig := `default_model = "deepseek-flash/deepseek-v4-flash"
3211
3212 [desktop]
3213 provider_access = ["deepseek-flash"]
3214
3215 [[providers]]
3216 name = "deepseek-flash"
3217 kind = "openai"
3218 base_url = "https://api.deepseek.com"
3219 model = "deepseek-v4-flash"
3220 api_key_env = "DEEPSEEK_API_KEY"
3221 `
3222 if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil {
3223 t.Fatalf("write project config: %v", err)
3224 }
3225
3226 app := NewApp()
3227 tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true}
3228 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3229 app.activeTabID = tab.ID
3230
3231 models := app.ModelsForTab(tab.ID)
3232 refs := modelRefsFromView(models)
3233 for _, want := range []string{
3234 "deepseek/deepseek-v4-flash",
3235 "mimo-pro/mimo-v2.5-pro",
3236 } {
3237 if !refs[want] {
3238 t.Fatalf("ModelsForTab refs = %+v, missing %s", models, want)
3239 }
3240 }
3241 }
3242
3243 func TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {
3244 isolateDesktopUserDirs(t)
3245 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
3246 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
3247
3248 cfg := config.Default()
3249 cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash"
3250 cfg.Desktop.ProviderAccess = []string{"deepseek-flash"}
3251 cfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: "other", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "other-model"})
3252 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3253 t.Fatalf("save config: %v", err)
3254 }
3255
3256 app := NewApp()
3257 app.ctx = context.Background()
3258 tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ready: true, model: "deepseek-flash/deepseek-v4-flash"}
3259 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3260 app.tabOrder = []string{tab.ID}
3261 app.activeTabID = tab.ID
3262
3263 err := app.SetModelForTab(tab.ID, "other/other-model")
3264 if err == nil || !strings.Contains(err.Error(), "not available") {
3265 t.Fatalf("SetModelForTab hidden provider error = %v, want not available", err)
3266 }
3267 }
3268
3269 func TestSetModelForTabRefreshesCarriedSystemPromptWithoutChangingDefaults(t *testing.T) {
3270 isolateDesktopUserDirs(t)
3271 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3272 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3273
3274 cfg := config.Default()
3275 cfg.DefaultModel = "old/old-model"
3276 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3277 cfg.Providers = []config.ProviderEntry{
3278 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3279 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3280 }
3281 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3282 t.Fatalf("save config: %v", err)
3283 }
3284 if err := os.MkdirAll(config.MemoryUserDir(), 0o755); err != nil {
3285 t.Fatalf("mkdir memory dir: %v", err)
3286 }
3287 const freshRule = "Fresh global AGENTS rule for model switch"
3288 if err := os.WriteFile(filepath.Join(config.MemoryUserDir(), "AGENTS.md"), []byte(freshRule), 0o644); err != nil {
3289 t.Fatalf("write global AGENTS.md: %v", err)
3290 }
3291
3292 dir := config.SessionDir()
3293 if err := os.MkdirAll(dir, 0o755); err != nil {
3294 t.Fatalf("mkdir session dir: %v", err)
3295 }
3296 oldSession := agent.NewSession("old system prompt without memory")
3297 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
3298 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3299 oldPath := filepath.Join(dir, "old.jsonl")
3300 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3301
3302 app := NewApp()
3303 app.ctx = context.Background()
3304 tab := &WorkspaceTab{
3305 ID: "tab_a",
3306 Scope: "global",
3307 Ready: true,
3308 model: "old/old-model",
3309 Ctrl: oldCtrl,
3310 sink: &tabEventSink{tabID: "tab_a", app: app},
3311 disabledMCP: map[string]ServerView{},
3312 }
3313 sibling := &WorkspaceTab{
3314 ID: "tab_b",
3315 Scope: "global",
3316 Ready: true,
3317 model: "old/old-model",
3318 disabledMCP: map[string]ServerView{},
3319 }
3320 app.tabs = map[string]*WorkspaceTab{tab.ID: tab, sibling.ID: sibling}
3321 app.tabOrder = []string{tab.ID, sibling.ID}
3322 app.activeTabID = tab.ID
3323 var switchTiming modelSwitchTiming
3324 app.modelSwitchTimingHook = func(timing modelSwitchTiming) { switchTiming = timing }
3325 t.Cleanup(func() {
3326 if tab.Ctrl != nil {
3327 tab.Ctrl.Close()
3328 }
3329 })
3330
3331 if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
3332 t.Fatalf("SetModelForTab: %v", err)
3333 }
3334 history := tab.Ctrl.History()
3335 if len(history) < 2 {
3336 t.Fatalf("history length = %d, want system + user", len(history))
3337 }
3338 if history[0].Role != provider.RoleSystem {
3339 t.Fatalf("first message role = %s, want system", history[0].Role)
3340 }
3341 if !strings.Contains(history[0].Content, freshRule) {
3342 t.Fatalf("refreshed system prompt missing global AGENTS rule:\n%s", history[0].Content)
3343 }
3344 if history[1].Role != provider.RoleUser || history[1].Content != "hello" {
3345 t.Fatalf("carried user message changed: %+v", history[1])
3346 }
3347 if got := config.LoadForEdit(config.UserConfigPath()).DefaultModel; got != "old/old-model" {
3348 t.Fatalf("default model after session switch = %q, want old/old-model", got)
3349 }
3350 if sibling.model != "old/old-model" {
3351 t.Fatalf("sibling tab model after session switch = %q, want old/old-model", sibling.model)
3352 }
3353 if switchTiming.Outcome != "ok" || switchTiming.Total <= 0 {
3354 t.Fatalf("model switch timing = %+v, want successful non-zero observation", switchTiming)
3355 }
3356 if switchTiming.Build < 0 || switchTiming.LeaseAndResume < 0 || switchTiming.SwapAndPersist < 0 {
3357 t.Fatalf("model switch stage timing incomplete: %+v", switchTiming)
3358 }
3359 }
3360
3361 // TestSetModelForTabRestoresSessionAuthorizations pins the fix for a model
3362 // switch dropping same-session "Allow for this session" tool grants and
3363 // Plan-mode read-only command trust, forcing the user to re-approve
3364 // something already granted this session after every model/effort/token-mode
3365 // switch.
3366 // TestRebuildSettingLockedRestoresSessionAuthorizations covers the same
3367 // dropped-session-authorization bug for the settings-change rebuild path
3368 // (also used by the deferred-rebuild retry loop), independent from
3369 // SetModelForTab's own rebuild.
3370 func TestRebuildSettingLockedRestoresSessionAuthorizations(t *testing.T) {
3371 isolateDesktopUserDirs(t)
3372 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3373
3374 cfg := config.Default()
3375 cfg.DefaultModel = "old/old-model"
3376 cfg.Desktop.ProviderAccess = []string{"old"}
3377 cfg.Providers = []config.ProviderEntry{
3378 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3379 }
3380 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3381 t.Fatalf("save config: %v", err)
3382 }
3383
3384 dir := config.SessionDir()
3385 if err := os.MkdirAll(dir, 0o755); err != nil {
3386 t.Fatalf("mkdir session dir: %v", err)
3387 }
3388 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
3389 oldPath := filepath.Join(dir, "old.jsonl")
3390 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3391 oldCtrl.RestoreSessionAuthorizations(control.SessionAuthorizations{
3392 Grants: []string{"bash|go test ./..."},
3393 PlanModeReadOnlyCommands: []string{"go test ./..."},
3394 })
3395
3396 app := NewApp()
3397 app.ctx = context.Background()
3398 tab := &WorkspaceTab{
3399 ID: "tab_a",
3400 Scope: "global",
3401 Ready: true,
3402 model: "old/old-model",
3403 Ctrl: oldCtrl,
3404 sink: &tabEventSink{tabID: "tab_a", app: app},
3405 disabledMCP: map[string]ServerView{},
3406 }
3407 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3408 app.tabOrder = []string{tab.ID}
3409 app.activeTabID = tab.ID
3410 app.readyHook = func() {}
3411 t.Cleanup(func() {
3412 if tab.Ctrl != nil {
3413 tab.Ctrl.Close()
3414 }
3415 })
3416
3417 if err := app.rebuildSetting("settings"); err != nil {
3418 t.Fatalf("rebuildSetting: %v", err)
3419 }
3420
3421 newCtrl, ok := tab.Ctrl.(*control.Controller)
3422 if !ok {
3423 t.Fatalf("tab.Ctrl = %T, want *control.Controller", tab.Ctrl)
3424 }
3425 got := newCtrl.SessionAuthorizations()
3426 if len(got.Grants) != 1 || got.Grants[0] != "bash|go test ./..." {
3427 t.Fatalf("restored grants = %+v, want [\"bash|go test ./...\"]", got.Grants)
3428 }
3429 if len(got.PlanModeReadOnlyCommands) != 1 || got.PlanModeReadOnlyCommands[0] != "go test ./..." {
3430 t.Fatalf("restored plan-mode read-only commands = %+v, want [\"go test ./...\"]", got.PlanModeReadOnlyCommands)
3431 }
3432 }
3433
3434 func TestSetModelForTabReusesCurrentSessionLease(t *testing.T) {
3435 isolateDesktopUserDirs(t)
3436 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3437 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3438
3439 cfg := config.Default()
3440 cfg.DefaultModel = "old/old-model"
3441 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3442 cfg.Providers = []config.ProviderEntry{
3443 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3444 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3445 }
3446 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3447 t.Fatalf("save config: %v", err)
3448 }
3449
3450 dir := config.SessionDir()
3451 if err := os.MkdirAll(dir, 0o755); err != nil {
3452 t.Fatalf("mkdir session dir: %v", err)
3453 }
3454 oldSession := agent.NewSession("old system prompt")
3455 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
3456 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3457 oldPath := filepath.Join(dir, "leased-model-switch.jsonl")
3458 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3459
3460 app := NewApp()
3461 app.ctx = context.Background()
3462 tab := &WorkspaceTab{
3463 ID: "tab_a",
3464 Scope: "global",
3465 Ready: true,
3466 model: "old/old-model",
3467 Ctrl: oldCtrl,
3468 sink: &tabEventSink{tabID: "tab_a", app: app},
3469 disabledMCP: map[string]ServerView{},
3470 }
3471 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3472 app.tabOrder = []string{tab.ID}
3473 app.activeTabID = tab.ID
3474 t.Cleanup(func() {
3475 if tab.Ctrl != nil {
3476 tab.Ctrl.Close()
3477 }
3478 tab.releaseSessionLease()
3479 })
3480
3481 if err := tab.ensureSessionLease(oldPath); err != nil {
3482 t.Fatalf("ensureSessionLease: %v", err)
3483 }
3484 if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
3485 t.Fatalf("SetModelForTab: %v", err)
3486 }
3487 if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
3488 t.Fatalf("tab controller was not rebuilt")
3489 }
3490 if got := tab.model; got != "new/new-model" {
3491 t.Fatalf("tab model = %q, want new/new-model", got)
3492 }
3493 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(oldPath) {
3494 t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), oldPath)
3495 }
3496 history := tab.Ctrl.History()
3497 if len(history) < 2 || history[1].Role != provider.RoleUser || history[1].Content != "hello" {
3498 t.Fatalf("carried history = %+v, want original user message", history)
3499 }
3500 }
3501
3502 func TestSetModelForTabWaitsForConcurrentBlankSessionLease(t *testing.T) {
3503 isolateDesktopUserDirs(t)
3504 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3505 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3506
3507 cfg := config.Default()
3508 cfg.DefaultModel = "old/old-model"
3509 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3510 cfg.Providers = []config.ProviderEntry{
3511 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3512 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3513 }
3514 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3515 t.Fatalf("save config: %v", err)
3516 }
3517
3518 dir := desktopSessionDir(globalTabWorkspaceRoot())
3519 if err := os.MkdirAll(dir, 0o755); err != nil {
3520 t.Fatalf("mkdir sessions: %v", err)
3521 }
3522 path := filepath.Join(dir, "blank-model-switch-race.jsonl")
3523 if err := os.WriteFile(path, nil, 0o644); err != nil {
3524 t.Fatalf("write blank session: %v", err)
3525 }
3526
3527 app := NewApp()
3528 app.ctx = context.Background()
3529 tab := &WorkspaceTab{
3530 ID: "tab_blank_race",
3531 Scope: "global",
3532 WorkspaceRoot: globalTabWorkspaceRoot(),
3533 SessionPath: path,
3534 Ready: true,
3535 model: "old/old-model",
3536 sink: &tabEventSink{tabID: "tab_blank_race", app: app},
3537 disabledMCP: map[string]ServerView{},
3538 }
3539 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3540 app.tabOrder = []string{tab.ID}
3541 app.activeTabID = tab.ID
3542 t.Cleanup(func() {
3543 if tab.Ctrl != nil {
3544 tab.Ctrl.Close()
3545 }
3546 tab.releaseSessionLease()
3547 })
3548
3549 acquired := make(chan struct{})
3550 releaseHook := make(chan struct{})
3551 var once sync.Once
3552 sessionLeaseAcquireHookForTest = func() {
3553 once.Do(func() {
3554 close(acquired)
3555 <-releaseHook
3556 })
3557 }
3558 t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
3559
3560 buildErr := make(chan error, 1)
3561 go func() {
3562 buildErr <- tab.ensureSessionLease(path)
3563 }()
3564
3565 select {
3566 case <-acquired:
3567 case err := <-buildErr:
3568 t.Fatalf("background lease acquire returned before hook: %v", err)
3569 case <-time.After(2 * time.Second):
3570 t.Fatal("background lease acquire did not start")
3571 }
3572
3573 switchErr := make(chan error, 1)
3574 go func() {
3575 switchErr <- app.SetModelForTab(tab.ID, "new/new-model")
3576 }()
3577
3578 select {
3579 case err := <-switchErr:
3580 t.Fatalf("SetModelForTab returned before concurrent lease was bound: %v", err)
3581 case <-time.After(50 * time.Millisecond):
3582 }
3583
3584 close(releaseHook)
3585 if err := <-buildErr; err != nil {
3586 t.Fatalf("background ensureSessionLease: %v", err)
3587 }
3588 if err := <-switchErr; err != nil {
3589 t.Fatalf("SetModelForTab: %v", err)
3590 }
3591 if tab.Ctrl == nil {
3592 t.Fatal("model switch did not build a controller")
3593 }
3594 if got := tab.model; got != "new/new-model" {
3595 t.Fatalf("tab model = %q, want new/new-model", got)
3596 }
3597 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) {
3598 t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
3599 }
3600 }
3601
3602 func TestSetModelForTabLeaseHeldKeepsCurrentController(t *testing.T) {
3603 isolateDesktopUserDirs(t)
3604 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3605 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3606
3607 cfg := config.Default()
3608 cfg.DefaultModel = "old/old-model"
3609 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3610 cfg.Providers = []config.ProviderEntry{
3611 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3612 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3613 }
3614 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3615 t.Fatalf("save config: %v", err)
3616 }
3617
3618 dir := config.SessionDir()
3619 if err := os.MkdirAll(dir, 0o755); err != nil {
3620 t.Fatalf("mkdir session dir: %v", err)
3621 }
3622 oldPath := filepath.Join(dir, "externally-leased-model-switch.jsonl")
3623 if err := os.WriteFile(oldPath, nil, 0o644); err != nil {
3624 t.Fatalf("write placeholder session: %v", err)
3625 }
3626 externalLease, err := agent.TryAcquireSessionLease(oldPath)
3627 if err != nil {
3628 t.Fatalf("TryAcquireSessionLease: %v", err)
3629 }
3630 defer externalLease.Release()
3631
3632 oldSession := agent.NewSession("old system prompt")
3633 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
3634 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3635 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3636 defer oldCtrl.Close()
3637
3638 app := NewApp()
3639 app.ctx = context.Background()
3640 tab := &WorkspaceTab{
3641 ID: "tab_a",
3642 Scope: "global",
3643 Ready: true,
3644 model: "old/old-model",
3645 Ctrl: oldCtrl,
3646 sink: &tabEventSink{tabID: "tab_a", app: app},
3647 disabledMCP: map[string]ServerView{},
3648 }
3649 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3650 app.tabOrder = []string{tab.ID}
3651 app.activeTabID = tab.ID
3652
3653 err = app.SetModelForTab(tab.ID, "new/new-model")
3654 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
3655 t.Fatalf("SetModelForTab err = %v, want ErrSessionLeaseHeld", err)
3656 }
3657 if strings.Contains(err.Error(), oldPath) || strings.Contains(err.Error(), "held by") {
3658 t.Fatalf("SetModelForTab surfaced raw lease details: %v", err)
3659 }
3660 if tab.Ctrl != oldCtrl {
3661 t.Fatalf("tab controller changed after failed switch")
3662 }
3663 if got := tab.model; got != "old/old-model" {
3664 t.Fatalf("tab model = %q, want old/old-model", got)
3665 }
3666 info, err := os.Stat(oldPath)
3667 if err != nil {
3668 t.Fatalf("stat session: %v", err)
3669 }
3670 if info.Size() != 0 {
3671 t.Fatalf("session file size = %d, want unchanged empty file", info.Size())
3672 }
3673 }
3674
3675 func TestSetModelForTabReattachesDetachedRuntime(t *testing.T) {
3676 isolateDesktopUserDirs(t)
3677 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3678 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3679
3680 cfg := config.Default()
3681 cfg.DefaultModel = "old/old-model"
3682 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3683 cfg.Providers = []config.ProviderEntry{
3684 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3685 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3686 }
3687 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3688 t.Fatalf("save config: %v", err)
3689 }
3690
3691 dir := desktopSessionDir(globalTabWorkspaceRoot())
3692 if err := os.MkdirAll(dir, 0o755); err != nil {
3693 t.Fatalf("mkdir session dir: %v", err)
3694 }
3695 path := filepath.Join(dir, "detached-model-switch.jsonl")
3696 oldSession := agent.NewSession("old system prompt")
3697 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello from detached"})
3698 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3699 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
3700 lease, err := agent.TryAcquireSessionLease(path)
3701 if err != nil {
3702 t.Fatalf("TryAcquireSessionLease: %v", err)
3703 }
3704
3705 app := NewApp()
3706 app.ctx = context.Background()
3707 key := sessionRuntimeKey(path)
3708 detached := &WorkspaceTab{
3709 ID: detachedRuntimeTabID(key),
3710 Scope: "global",
3711 SessionPath: path,
3712 Ctrl: oldCtrl,
3713 Ready: true,
3714 model: "old/old-model",
3715 disabledMCP: map[string]ServerView{},
3716 SharedHostKey: "detached-host",
3717 ActivityStatus: "",
3718 }
3719 detached.adoptSessionLease(lease)
3720 tab := &WorkspaceTab{
3721 ID: "tab_a",
3722 Scope: "global",
3723 SessionPath: path,
3724 Ready: true,
3725 model: "old/old-model",
3726 sink: &tabEventSink{tabID: "tab_a", app: app},
3727 disabledMCP: map[string]ServerView{},
3728 }
3729 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3730 app.detachedSessions = map[string]*WorkspaceTab{key: detached}
3731 app.tabOrder = []string{tab.ID}
3732 app.activeTabID = tab.ID
3733 t.Cleanup(func() {
3734 if tab.Ctrl != nil {
3735 tab.Ctrl.Close()
3736 }
3737 tab.releaseSessionLease()
3738 if detached.sessionLease != nil {
3739 detached.releaseSessionLease()
3740 }
3741 })
3742
3743 if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
3744 t.Fatalf("SetModelForTab: %v", err)
3745 }
3746 if _, ok := app.detachedSessions[key]; ok {
3747 t.Fatal("detached runtime was not consumed")
3748 }
3749 if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
3750 t.Fatalf("tab controller was not rebuilt from detached runtime")
3751 }
3752 if got := tab.model; got != "new/new-model" {
3753 t.Fatalf("tab model = %q, want new/new-model", got)
3754 }
3755 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != key {
3756 t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
3757 }
3758 history := tab.Ctrl.History()
3759 if len(history) < 2 || history[1].Content != "hello from detached" {
3760 t.Fatalf("carried history = %+v, want detached user message", history)
3761 }
3762 }
3763
3764 type staleWorkspaceBindingFixture struct {
3765 app *App
3766 tab *WorkspaceTab
3767 oldCtrl control.SessionAPI
3768 projectA string
3769 sessionDirA string
3770 sessionPathA string
3771 }
3772
3773 func newStaleWorkspaceBindingFixture(t *testing.T, suffix string) staleWorkspaceBindingFixture {
3774 return newStaleWorkspaceBindingFixtureWithLayout(t, suffix, "")
3775 }
3776
3777 func newStaleWorkspaceBindingFixtureWithLayout(t *testing.T, suffix, layoutStyle string) staleWorkspaceBindingFixture {
3778 t.Helper()
3779 isolateDesktopUserDirs(t)
3780 setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
3781
3782 // Submitted turns use a real provider, so the fixture must complete them
3783 // instantly instead of pointing at an unreachable host.
3784 providerStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
3785 w.Header().Set("Content-Type", "text/event-stream")
3786 w.WriteHeader(http.StatusOK)
3787 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
3788 }))
3789 t.Cleanup(providerStub.Close)
3790
3791 cfg := config.Default()
3792 cfg.DefaultModel = "test/test-model"
3793 cfg.Desktop.ProviderAccess = []string{"test"}
3794 cfg.Providers = []config.ProviderEntry{
3795 {Name: "test", Kind: "openai", BaseURL: providerStub.URL, Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
3796 }
3797 if strings.TrimSpace(layoutStyle) != "" {
3798 if err := cfg.SetDesktopLayoutStyle(layoutStyle); err != nil {
3799 t.Fatalf("set desktop layout style: %v", err)
3800 }
3801 }
3802 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3803 t.Fatalf("save config: %v", err)
3804 }
3805
3806 projectA := t.TempDir()
3807 projectB := t.TempDir()
3808 if err := addProject(projectA, "Project A"); err != nil {
3809 t.Fatalf("add project A: %v", err)
3810 }
3811 if err := addProject(projectB, "Project B"); err != nil {
3812 t.Fatalf("add project B: %v", err)
3813 }
3814
3815 topicID := "topic_" + suffix
3816 topicTitle := "Rebuild workspace " + suffix
3817 sessionDirA := desktopSessionDir(projectA)
3818 sessionDirB := desktopSessionDir(projectB)
3819 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
3820 t.Fatalf("mkdir project A sessions: %v", err)
3821 }
3822 if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
3823 t.Fatalf("mkdir project B sessions: %v", err)
3824 }
3825 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
3826 sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
3827
3828 oldSession := agent.NewSession("old system prompt")
3829 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "carry me"})
3830 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3831 oldCtrl := control.New(control.Options{
3832 Executor: oldExec,
3833 SessionDir: sessionDirB,
3834 SessionPath: sessionPathB,
3835 Label: "test/test-model",
3836 ModelRef: "test/test-model",
3837 WorkspaceRoot: projectB,
3838 Sink: event.Discard,
3839 })
3840
3841 app := NewApp()
3842 app.readyHook = func() {}
3843 tab := &WorkspaceTab{
3844 ID: "tab_stale_workspace_" + suffix,
3845 Scope: "project",
3846 WorkspaceRoot: projectB,
3847 TopicID: topicID,
3848 TopicTitle: topicTitle,
3849 SessionPath: sessionPathA,
3850 Ready: true,
3851 model: "test/test-model",
3852 Ctrl: oldCtrl,
3853 sink: &tabEventSink{tabID: "tab_stale_workspace_" + suffix, app: app},
3854 disabledMCP: map[string]ServerView{},
3855 }
3856 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3857 app.tabOrder = []string{tab.ID}
3858 app.activeTabID = tab.ID
3859 t.Cleanup(func() {
3860 if tab.Ctrl != nil {
3861 tab.Ctrl.Close()
3862 }
3863 })
3864
3865 return staleWorkspaceBindingFixture{
3866 app: app,
3867 tab: tab,
3868 oldCtrl: oldCtrl,
3869 projectA: projectA,
3870 sessionDirA: sessionDirA,
3871 sessionPathA: sessionPathA,
3872 }
3873 }
3874
3875 type blockingSnapshotCtrl struct {
3876 control.SessionAPI
3877
3878 firstSnapshotStarted chan struct{}
3879 secondSnapshotStarted chan struct{}
3880 releaseSnapshot chan struct{}
3881 firstOnce sync.Once
3882 secondOnce sync.Once
3883 snapshotCount atomic.Int32
3884 closeCount atomic.Int32
3885 }
3886
3887 func newBlockingSnapshotCtrl(ctrl control.SessionAPI) *blockingSnapshotCtrl {
3888 return &blockingSnapshotCtrl{
3889 SessionAPI: ctrl,
3890 firstSnapshotStarted: make(chan struct{}),
3891 secondSnapshotStarted: make(chan struct{}),
3892 releaseSnapshot: make(chan struct{}),
3893 }
3894 }
3895
3896 func (c *blockingSnapshotCtrl) Snapshot() error {
3897 count := c.snapshotCount.Add(1)
3898 switch count {
3899 case 1:
3900 c.firstOnce.Do(func() { close(c.firstSnapshotStarted) })
3901 case 2:
3902 c.secondOnce.Do(func() { close(c.secondSnapshotStarted) })
3903 }
3904 <-c.releaseSnapshot
3905 if c.SessionAPI == nil {
3906 return nil
3907 }
3908 return c.SessionAPI.Snapshot()
3909 }
3910
3911 func (c *blockingSnapshotCtrl) Close() {
3912 c.closeCount.Add(1)
3913 if c.SessionAPI != nil {
3914 c.SessionAPI.Close()
3915 }
3916 }
3917
3918 func TestDescribeSessionBindingWorkspaceKeepsWindowsPathReadable(t *testing.T) {
3919 path := `C:\Users\Jane Doe\Reasonix`
3920 want := `project workspace "C:\Users\Jane Doe\Reasonix"`
3921 if got := describeSessionBindingWorkspace("project", path); got != want {
3922 t.Fatalf("describeSessionBindingWorkspace = %q, want %q", got, want)
3923 }
3924 }
3925
3926 func TestEffortCommandUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
3927 isolateDesktopUserDirs(t)
3928 setDesktopTestCredential(t, "OWNER_MODEL_KEY", "sk-test")
3929 setDesktopTestCredential(t, "STALE_MODEL_KEY", "sk-test")
3930
3931 projectA := t.TempDir()
3932 projectB := t.TempDir()
3933 if err := addProject(projectA, "Project A"); err != nil {
3934 t.Fatalf("add project A: %v", err)
3935 }
3936 if err := addProject(projectB, "Project B"); err != nil {
3937 t.Fatalf("add project B: %v", err)
3938 }
3939 ownerConfig := `default_model = "owner/owner-model"
3940 [[providers]]
3941 name = "owner"
3942 kind = "openai"
3943 base_url = "https://owner.example.invalid/v1"
3944 model = "owner-model"
3945 api_key_env = "OWNER_MODEL_KEY"
3946 supported_efforts = ["max"]
3947 default_effort = "max"
3948 `
3949 if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil {
3950 t.Fatal(err)
3951 }
3952 staleConfig := `default_model = "stale/stale-model"
3953 [[providers]]
3954 name = "stale"
3955 kind = "openai"
3956 base_url = "https://stale.example.invalid/v1"
3957 model = "stale-model"
3958 api_key_env = "STALE_MODEL_KEY"
3959 reasoning_protocol = "none"
3960 `
3961 if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil {
3962 t.Fatal(err)
3963 }
3964
3965 topicID := "topic_effort_owner"
3966 topicTitle := "Effort owner"
3967 sessionDirA := desktopSessionDir(projectA)
3968 sessionDirB := desktopSessionDir(projectB)
3969 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
3970 t.Fatalf("mkdir project A sessions: %v", err)
3971 }
3972 if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
3973 t.Fatalf("mkdir project B sessions: %v", err)
3974 }
3975 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
3976 oldCtrl := control.New(control.Options{
3977 SessionDir: sessionDirB,
3978 SessionPath: filepath.Join(sessionDirB, "wrong.jsonl"),
3979 WorkspaceRoot: projectB,
3980 Sink: event.Discard,
3981 })
3982
3983 app := NewApp()
3984 app.readyHook = func() {}
3985 tab := &WorkspaceTab{
3986 ID: "tab_stale_effort",
3987 Scope: "project",
3988 WorkspaceRoot: projectB,
3989 TopicID: topicID,
3990 TopicTitle: topicTitle,
3991 SessionPath: sessionPathA,
3992 Ready: true,
3993 Ctrl: oldCtrl,
3994 sink: &tabEventSink{tabID: "tab_stale_effort", app: app},
3995 disabledMCP: map[string]ServerView{},
3996 }
3997 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3998 app.tabOrder = []string{tab.ID}
3999 app.activeTabID = tab.ID
4000 t.Cleanup(func() {
4001 if tab.Ctrl != nil {
4002 tab.Ctrl.Close()
4003 }
4004 })
4005
4006 if err := app.SubmitToTab(tab.ID, "/effort max"); err != nil {
4007 t.Fatalf("SubmitToTab(/effort max): %v", err)
4008 }
4009 waitNotRunning(t, tab.Ctrl)
4010 if tab.effort == nil || *tab.effort != "max" {
4011 t.Fatalf("tab effort = %#v, want max from pinned project A provider", tab.effort)
4012 }
4013 if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
4014 t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
4015 }
4016 if got := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
4017 t.Fatalf("controller workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
4018 }
4019 }
4020
4021 // A config file written before the classic style was removed still holds the
4022 // retired value. It must read as workbench — not fail, and not resurrect the
4023 // multi-tab model that value used to select, which the UI can no longer show.
4024 func TestRetiredClassicLayoutReadsAsWorkbench(t *testing.T) {
4025 isolateDesktopUserDirs(t)
4026 if err := editUserConfig(func(c *config.Config) error {
4027 c.Desktop.LayoutStyle = "classic"
4028 return nil
4029 }); err != nil {
4030 t.Fatalf("write the retired layout style: %v", err)
4031 }
4032 cfg, err := config.Load()
4033 if err != nil {
4034 t.Fatalf("load config: %v", err)
4035 }
4036 if got := cfg.DesktopLayoutStyle(); got != "workbench" {
4037 t.Fatalf("retired classic reads as %q, want workbench", got)
4038 }
4039 }
4040
4041 func TestListSessionsUsesPinnedSessionOwnerBeforeStaleRuntimeDir(t *testing.T) {
4042 isolateDesktopUserDirs(t)
4043
4044 projectA := t.TempDir()
4045 projectB := t.TempDir()
4046 if err := addProject(projectA, "Project A"); err != nil {
4047 t.Fatalf("add project A: %v", err)
4048 }
4049 if err := addProject(projectB, "Project B"); err != nil {
4050 t.Fatalf("add project B: %v", err)
4051 }
4052 sessionDirA := desktopSessionDir(projectA)
4053 sessionDirB := desktopSessionDir(projectB)
4054 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
4055 t.Fatalf("mkdir project A sessions: %v", err)
4056 }
4057 if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
4058 t.Fatalf("mkdir project B sessions: %v", err)
4059 }
4060 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_project_a", "Project A topic", projectA, "project A prompt", time.Now())
4061 sessionPathB := writeTopicSessionWithPrompt(t, sessionDirB, "project-b.jsonl", "topic_project_b", "Project B topic", projectB, "project B prompt", time.Now().Add(time.Minute))
4062
4063 app := NewApp()
4064 oldCtrl := control.New(control.Options{
4065 SessionDir: sessionDirB,
4066 SessionPath: sessionPathB,
4067 WorkspaceRoot: projectB,
4068 Sink: event.Discard,
4069 })
4070 tab := &WorkspaceTab{
4071 ID: "tab_stale_runtime_dir",
4072 Scope: "project",
4073 WorkspaceRoot: projectB,
4074 TopicID: "topic_project_a",
4075 TopicTitle: "Project A topic",
4076 SessionPath: sessionPathA,
4077 Ready: true,
4078 Ctrl: oldCtrl,
4079 disabledMCP: map[string]ServerView{},
4080 }
4081 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4082 app.tabOrder = []string{tab.ID}
4083 app.activeTabID = tab.ID
4084 installSessionCatalogForTest(t, app, sessionDirA, "project", projectA)
4085 t.Cleanup(oldCtrl.Close)
4086 sessions := listSessionsAfterPinnedOwnerReconcile(t, app, sessionDirA, projectA)
4087 if len(sessions) == 0 {
4088 t.Fatal("ListSessions() returned no sessions")
4089 }
4090 if filepath.Clean(sessions[0].Path) != filepath.Clean(sessionPathA) {
4091 t.Fatalf("ListSessions()[0].Path = %q, want pinned project A session %q", sessions[0].Path, sessionPathA)
4092 }
4093 for _, item := range sessions {
4094 if filepath.Clean(item.Path) == filepath.Clean(sessionPathB) {
4095 t.Fatalf("ListSessions() included stale project B runtime session: %+v", sessions)
4096 }
4097 }
4098 if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
4099 t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
4100 }
4101 }
4102
4103 func TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {
4104 isolateDesktopUserDirs(t)
4105 t.Setenv("MIMO_API_KEY", "")
4106
4107 cfg := config.Default()
4108 cfg.Desktop.ProviderAccess = []string{"mimo-api"}
4109 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4110 t.Fatalf("save config: %v", err)
4111 }
4112
4113 app := NewApp()
4114 tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ready: true, model: "deepseek-flash/deepseek-v4-flash"}
4115 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4116 app.tabOrder = []string{tab.ID}
4117 app.activeTabID = tab.ID
4118
4119 err := app.SetDefaultModel("mimo-api/mimo-v2.5-pro")
4120 if err == nil || !strings.Contains(err.Error(), "has no key") {
4121 t.Fatalf("SetDefaultModel no-key error = %v, want has no key", err)
4122 }
4123 if tab.model != "deepseek-flash/deepseek-v4-flash" {
4124 t.Fatalf("tab model after failed default change = %q, want previous", tab.model)
4125 }
4126 }
4127
4128 func TestSaveProviderPersistsReasoningProtocol(t *testing.T) {
4129 isolateDesktopUserDirs(t)
4130
4131 app := NewApp()
4132 if err := app.SaveProvider(ProviderView{
4133 Name: "deepseek-proxy",
4134 Kind: "openai",
4135 BaseURL: "https://proxy.example.com/v1",
4136 Models: []string{"deepseek-v4-flash"},
4137 Default: "deepseek-v4-flash",
4138 APIKeyEnv: "DEEPSEEK_PROXY_KEY",
4139 ReasoningProtocol: "none",
4140 SupportedEfforts: []string{"high", "max"},
4141 DefaultEffort: "max",
4142 }); err != nil {
4143 t.Fatalf("SaveProvider: %v", err)
4144 }
4145
4146 cfg := config.LoadForEdit(config.UserConfigPath())
4147 got, ok := cfg.Provider("deepseek-proxy")
4148 if !ok {
4149 t.Fatal("saved provider not found")
4150 }
4151 if got.ReasoningProtocol != "none" || got.DefaultEffort != "max" {
4152 t.Fatalf("saved provider = %+v, want reasoning_protocol none and default_effort max", got)
4153 }
4154
4155 view := app.Settings()
4156 for _, p := range view.Providers {
4157 if p.Name == "deepseek-proxy" {
4158 if p.ReasoningProtocol != "none" {
4159 t.Fatalf("settings reasoningProtocol = %q, want none", p.ReasoningProtocol)
4160 }
4161 return
4162 }
4163 }
4164 t.Fatalf("Settings() missing saved provider: %+v", view.Providers)
4165 }
4166
4167 func TestDeleteProviderMigratesConfigAndPreservesOpenTabs(t *testing.T) {
4168 isolateDesktopUserDirs(t)
4169 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4170
4171 cfg := config.Default()
4172 cfg.DefaultModel = "prov-a/model-a2"
4173 cfg.Providers = []config.ProviderEntry{
4174 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", Models: []string{"model-a1", "model-a2"}, APIKeyEnv: "REASONIX_TEST_KEY"},
4175 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4176 }
4177 cfg.Agent.PlannerModel = "prov-a"
4178 cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
4179 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4180 t.Fatalf("save config: %v", err)
4181 }
4182
4183 ctrl := control.New(control.Options{Label: "old"})
4184 defer ctrl.Close()
4185 app := NewApp()
4186 tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ctrl: ctrl, Label: "prov-a/model-a1", Ready: true, model: "prov-a/model-a1"}
4187 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4188 app.tabOrder = []string{tab.ID}
4189 app.activeTabID = tab.ID
4190
4191 if err := app.DeleteProvider("prov-a"); err != nil {
4192 t.Fatalf("DeleteProvider: %v", err)
4193 }
4194
4195 got := config.LoadForEdit(config.UserConfigPath())
4196 if _, ok := got.Provider("prov-a"); ok {
4197 t.Fatal("prov-a should be removed")
4198 }
4199 if got.DefaultModel != "prov-b" || got.Agent.PlannerModel != "prov-b" {
4200 t.Fatalf("model refs after delete = default:%q planner:%q, want prov-b", got.DefaultModel, got.Agent.PlannerModel)
4201 }
4202 if providerAccessSet(got.Desktop.ProviderAccess)["prov-a"] {
4203 t.Fatalf("provider access still contains prov-a: %+v", got.Desktop.ProviderAccess)
4204 }
4205 if tab.model != "prov-a/model-a1" || tab.Label != "prov-a/model-a1" {
4206 t.Fatalf("saving deletion changed current identity: model:%q label:%q", tab.model, tab.Label)
4207 }
4208 if tab.Ctrl != ctrl {
4209 t.Fatal("saving deletion closed the current controller")
4210 }
4211 }
4212
4213 // assertTabBuildSuperseded checks that the startup build registered before the
4214 // mutation (generation) can no longer install its controller and that its
4215 // build context was cancelled.
4216 func assertTabBuildSuperseded(t *testing.T, app *App, tab *WorkspaceTab, generation uint64, buildCtx context.Context) {
4217 t.Helper()
4218 app.mu.Lock()
4219 superseded := app.tabBuildSupersededLocked(tab, generation)
4220 app.mu.Unlock()
4221 if !superseded {
4222 t.Fatal("in-flight startup build was not superseded; finishing it would reinstall a stale controller")
4223 }
4224 select {
4225 case <-buildCtx.Done():
4226 default:
4227 t.Fatal("in-flight startup build context was not cancelled")
4228 }
4229 if tab.buildCancel != nil {
4230 t.Fatal("build cancel was not cleared")
4231 }
4232 }
4233
4234 func TestDeleteProviderLeavesStartupPublicationToVersionFence(t *testing.T) {
4235 isolateDesktopUserDirs(t)
4236 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4237
4238 cfg := config.Default()
4239 cfg.DefaultModel = "prov-b/model-b1"
4240 cfg.Providers = []config.ProviderEntry{
4241 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4242 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4243 }
4244 cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
4245 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4246 t.Fatalf("save config: %v", err)
4247 }
4248
4249 app := NewApp()
4250 // Model the async startup build still being in flight for the affected
4251 // tab: no controller yet, a live generation, a cancellable build context.
4252 buildCtx, buildCancel := context.WithCancel(context.Background())
4253 tab := &WorkspaceTab{
4254 ID: "tab_a",
4255 Scope: "global",
4256 model: "prov-a/model-a1",
4257 buildGeneration: 1,
4258 buildCancel: buildCancel,
4259 }
4260 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4261 app.tabOrder = []string{tab.ID}
4262 app.activeTabID = tab.ID
4263
4264 if err := app.DeleteProvider("prov-a"); err != nil {
4265 t.Fatalf("DeleteProvider: %v", err)
4266 }
4267 if tab.buildGeneration != 1 || buildCtx.Err() != nil || tab.model != "prov-a/model-a1" {
4268 t.Fatal("saving deletion changed an in-flight startup before its publication fence")
4269 }
4270 buildCancel()
4271 }
4272
4273 func TestRemoveBuiltInProviderAccessLeavesStartupPublicationToVersionFence(t *testing.T) {
4274 isolateDesktopUserDirs(t)
4275 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4276
4277 cfg := config.Default()
4278 cfg.DefaultModel = "prov-b/model-b1"
4279 cfg.Providers = []config.ProviderEntry{
4280 {Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-chat", APIKeyEnv: "REASONIX_TEST_KEY"},
4281 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4282 }
4283 cfg.Desktop.ProviderAccess = []string{"deepseek", "prov-b"}
4284 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4285 t.Fatalf("save config: %v", err)
4286 }
4287
4288 app := NewApp()
4289 buildCtx, buildCancel := context.WithCancel(context.Background())
4290 tab := &WorkspaceTab{
4291 ID: "tab_ds",
4292 Scope: "global",
4293 model: "deepseek/deepseek-chat",
4294 buildGeneration: 1,
4295 buildCancel: buildCancel,
4296 }
4297 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4298 app.tabOrder = []string{tab.ID}
4299 app.activeTabID = tab.ID
4300
4301 if err := app.RemoveProviderAccess("deepseek"); err != nil {
4302 t.Fatalf("RemoveProviderAccess: %v", err)
4303 }
4304 if tab.buildGeneration != 1 || buildCtx.Err() != nil || tab.model != "deepseek/deepseek-chat" {
4305 t.Fatal("saving access removal changed an in-flight startup before its publication fence")
4306 }
4307 buildCancel()
4308 }
4309
4310 func TestClearActiveSessionRuntimeSupersedesInFlightStartupBuild(t *testing.T) {
4311 isolateDesktopUserDirs(t)
4312 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
4313
4314 cfg := config.Default()
4315 cfg.DefaultModel = "old/old-model"
4316 cfg.Desktop.ProviderAccess = []string{"old"}
4317 cfg.Providers = []config.ProviderEntry{{
4318 Name: "old",
4319 Kind: "openai",
4320 BaseURL: "https://example.invalid/v1",
4321 Model: "old-model",
4322 APIKeyEnv: "OLD_MODEL_KEY",
4323 }}
4324 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4325 t.Fatalf("save config: %v", err)
4326 }
4327
4328 dir := config.SessionDir()
4329 if err := os.MkdirAll(dir, 0o755); err != nil {
4330 t.Fatalf("mkdir session dir: %v", err)
4331 }
4332 sessionPath := filepath.Join(dir, "clear-runtime-in-flight.jsonl")
4333 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
4334 t.Fatalf("write placeholder session: %v", err)
4335 }
4336
4337 oldSession := agent.NewSession("old system prompt")
4338 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
4339 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
4340
4341 app := NewApp()
4342 // A runtime is attached while an older async build is still in flight
4343 // (e.g. attached via topic activation); destroying the session must
4344 // invalidate that build so it cannot resurrect the destroyed session.
4345 buildCtx, buildCancel := context.WithCancel(context.Background())
4346 tab := &WorkspaceTab{
4347 ID: "tab_clear",
4348 Scope: "global",
4349 SessionPath: sessionPath,
4350 model: "old/old-model",
4351 Ready: true,
4352 Ctrl: oldCtrl,
4353 buildGeneration: 1,
4354 buildCancel: buildCancel,
4355 disabledMCP: map[string]ServerView{},
4356 }
4357 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
4358 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4359 app.tabOrder = []string{tab.ID}
4360 app.activeTabID = tab.ID
4361 t.Cleanup(tab.releaseSessionLease)
4362
4363 if _, err := app.clearActiveSessionRuntime(tab, oldCtrl); err != nil {
4364 t.Fatalf("clearActiveSessionRuntime: %v", err)
4365 }
4366 if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
4367 t.Fatalf("clear did not install a fresh controller (ctrl=%v)", tab.Ctrl)
4368 }
4369 defer tab.Ctrl.Close()
4370 assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
4371 }
4372
4373 func TestClearActiveSessionRuntimeReleasesResourcesWhenTabReplaced(t *testing.T) {
4374 isolateDesktopUserDirs(t)
4375 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
4376
4377 cfg := config.Default()
4378 cfg.DefaultModel = "old/old-model"
4379 cfg.Desktop.ProviderAccess = []string{"old"}
4380 cfg.Providers = []config.ProviderEntry{{
4381 Name: "old",
4382 Kind: "openai",
4383 BaseURL: "https://example.invalid/v1",
4384 Model: "old-model",
4385 APIKeyEnv: "OLD_MODEL_KEY",
4386 }}
4387 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4388 t.Fatalf("save config: %v", err)
4389 }
4390
4391 dir := config.SessionDir()
4392 if err := os.MkdirAll(dir, 0o755); err != nil {
4393 t.Fatalf("mkdir session dir: %v", err)
4394 }
4395 sessionPath := filepath.Join(dir, "clear-runtime-replaced-tab.jsonl")
4396 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
4397 t.Fatalf("write placeholder session: %v", err)
4398 }
4399
4400 oldSession := agent.NewSession("old system prompt")
4401 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
4402 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
4403
4404 app := NewApp()
4405 tab := &WorkspaceTab{
4406 ID: "tab_replaced",
4407 Scope: "global",
4408 SessionPath: sessionPath,
4409 model: "old/old-model",
4410 Ready: true,
4411 Ctrl: oldCtrl,
4412 disabledMCP: map[string]ServerView{},
4413 }
4414 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
4415 // The tab entry now points at a replacement struct (the tab was closed and
4416 // reopened while the clear ran off-lock), so the swap must not apply.
4417 replacement := &WorkspaceTab{ID: tab.ID, Scope: "global"}
4418 app.tabs = map[string]*WorkspaceTab{tab.ID: replacement}
4419 app.tabOrder = []string{tab.ID}
4420 app.activeTabID = tab.ID
4421 t.Cleanup(tab.releaseSessionLease)
4422
4423 _, err := app.clearActiveSessionRuntime(tab, oldCtrl)
4424 if err == nil || !strings.Contains(err.Error(), "changed while clearing") {
4425 t.Fatalf("clearActiveSessionRuntime error = %v, want tab-changed error", err)
4426 }
4427 if replacement.Ctrl != nil {
4428 t.Fatalf("replacement tab controller = %v, want untouched nil", replacement.Ctrl)
4429 }
4430 if tab.Ctrl != oldCtrl {
4431 t.Fatalf("replaced tab controller = %v, want left on the destroyed runtime", tab.Ctrl)
4432 }
4433 if key := tab.sessionLeaseRuntimeKey(); key != "" {
4434 t.Fatalf("replaced tab still holds a session lease for %q; the fresh lease leaked", key)
4435 }
4436 if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
4437 t.Fatalf("old session artifacts were not destroyed (stat err=%v)", err)
4438 }
4439 }
4440
4441 func TestDeleteProviderPreservesRunningAffectedTab(t *testing.T) {
4442 isolateDesktopUserDirs(t)
4443 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4444
4445 cfg := config.Default()
4446 cfg.DefaultModel = "prov-a/model-a1"
4447 cfg.Providers = []config.ProviderEntry{
4448 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4449 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4450 }
4451 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4452 t.Fatalf("save config: %v", err)
4453 }
4454
4455 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
4456 app := NewApp()
4457 app.setTestCtrl(control.New(control.Options{Runner: runner}), "prov-a/model-a1")
4458 ctrl := app.activeCtrl()
4459 ctrl.Submit("work")
4460 <-runner.started
4461
4462 err := app.DeleteProvider("prov-a")
4463 if err != nil || app.activeCtrl() != ctrl || !ctrl.RuntimeStatus().Running {
4464 t.Fatalf("DeleteProvider interrupted accepted work: %v", err)
4465 }
4466 if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); ok {
4467 t.Fatal("provider deletion was not persisted during the run")
4468 }
4469
4470 close(runner.release)
4471 waitNotRunning(t, ctrl)
4472 ctrl.Close()
4473 }
4474
4475 func TestDeleteProviderDoesNotWaitForRuntimeReconstruction(t *testing.T) {
4476 isolateDesktopUserDirs(t)
4477 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4478 cfg := config.Default()
4479 cfg.DefaultModel = "prov-a/model-a1"
4480 cfg.Providers = []config.ProviderEntry{
4481 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4482 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4483 }
4484 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4485 t.Fatalf("save config: %v", err)
4486 }
4487
4488 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
4489 app := NewApp()
4490 app.setTestCtrl(control.New(control.Options{Runner: runner}), "prov-a/model-a1")
4491 ctrl := app.activeCtrl()
4492 app.runtimeRebuildMu.Lock()
4493 defer app.runtimeRebuildMu.Unlock()
4494 ctrl.Submit("work")
4495 <-runner.started
4496 done := make(chan error, 1)
4497 go func() { done <- app.DeleteProvider("prov-a") }()
4498 select {
4499 case err := <-done:
4500 if err != nil {
4501 t.Fatal(err)
4502 }
4503 case <-time.After(5 * time.Second):
4504 t.Fatal("saving provider deletion waited for runtime reconstruction")
4505 }
4506 if app.activeCtrl() != ctrl || !ctrl.RuntimeStatus().Running {
4507 t.Fatal("saving deletion interrupted accepted work")
4508 }
4509 if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); ok {
4510 t.Fatal("deletion was not committed")
4511 }
4512 close(runner.release)
4513 waitNotRunning(t, ctrl)
4514 ctrl.Close()
4515 }
4516
4517 func TestDeleteProviderPreservesAffectedTabSharedHostReference(t *testing.T) {
4518 isolateDesktopUserDirs(t)
4519 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4520 cfg := config.Default()
4521 cfg.DefaultModel = "prov-a/model-a1"
4522 cfg.Providers = []config.ProviderEntry{
4523 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4524 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4525 }
4526 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4527 t.Fatalf("save config: %v", err)
4528 }
4529
4530 app := NewApp()
4531 hostKey := "provider-shared-host"
4532 host := app.acquireSharedHost(hostKey)
4533 ctrl := control.New(control.Options{Host: host})
4534 tab := &WorkspaceTab{
4535 ID: "affected", Scope: "global", Ready: true, Ctrl: ctrl,
4536 model: "prov-a/model-a1", SharedHostKey: hostKey, disabledMCP: map[string]ServerView{},
4537 }
4538 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4539 app.tabOrder = []string{tab.ID}
4540 app.activeTabID = tab.ID
4541
4542 if err := app.DeleteProvider("prov-a"); err != nil {
4543 t.Fatalf("DeleteProvider: %v", err)
4544 }
4545 if tab.SharedHostKey != hostKey || tab.Ctrl != ctrl {
4546 t.Fatal("saving deletion released the current runtime's shared host")
4547 }
4548 app.sharedHostsMu.Lock()
4549 _, retained := app.sharedHosts[hostKey]
4550 app.sharedHostsMu.Unlock()
4551 if !retained {
4552 t.Fatal("provider deletion released an owned shared host reference")
4553 }
4554 ctrl.Close()
4555 app.releaseSharedHost(hostKey)
4556 }
4557
4558 func TestRemoveBuiltInProviderAccessPreservesAffectedTabSharedHostReference(t *testing.T) {
4559 isolateDesktopUserDirs(t)
4560 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4561 cfg := config.Default()
4562 cfg.DefaultModel = "deepseek/deepseek-chat"
4563 cfg.Providers = []config.ProviderEntry{
4564 {Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-chat", APIKeyEnv: "REASONIX_TEST_KEY"},
4565 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4566 }
4567 cfg.Desktop.ProviderAccess = []string{"deepseek", "prov-b"}
4568 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4569 t.Fatalf("save config: %v", err)
4570 }
4571
4572 app := NewApp()
4573 hostKey := "provider-access-shared-host"
4574 host := app.acquireSharedHost(hostKey)
4575 ctrl := control.New(control.Options{Host: host})
4576 tab := &WorkspaceTab{
4577 ID: "affected", Scope: "global", Ready: true, Ctrl: ctrl,
4578 model: "deepseek/deepseek-chat", SharedHostKey: hostKey, disabledMCP: map[string]ServerView{},
4579 }
4580 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4581 app.tabOrder = []string{tab.ID}
4582 app.activeTabID = tab.ID
4583
4584 if err := app.RemoveProviderAccess("deepseek"); err != nil {
4585 t.Fatalf("RemoveProviderAccess: %v", err)
4586 }
4587 if tab.SharedHostKey != hostKey || tab.Ctrl != ctrl {
4588 t.Fatal("saving access removal released the current runtime's shared host")
4589 }
4590 app.sharedHostsMu.Lock()
4591 _, retained := app.sharedHosts[hostKey]
4592 app.sharedHostsMu.Unlock()
4593 if !retained {
4594 t.Fatal("provider access removal released an owned shared host reference")
4595 }
4596 ctrl.Close()
4597 app.releaseSharedHost(hostKey)
4598 }
4599
4600 func TestDeleteProviderPreservesAffectedBackgroundJobs(t *testing.T) {
4601 isolateDesktopUserDirs(t)
4602 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4603
4604 cfg := config.Default()
4605 cfg.DefaultModel = "prov-a/model-a1"
4606 cfg.Providers = []config.ProviderEntry{
4607 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4608 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4609 }
4610 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4611 t.Fatalf("save config: %v", err)
4612 }
4613
4614 dir := config.SessionDir()
4615 if err := os.MkdirAll(dir, 0o755); err != nil {
4616 t.Fatalf("mkdir session dir: %v", err)
4617 }
4618 path := filepath.Join(dir, "provider-job.jsonl")
4619 jm := jobs.NewManager(event.Discard)
4620 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
4621 defer ctrl.Close()
4622 app := NewApp()
4623 app.setTestCtrl(ctrl, "prov-a/model-a1")
4624 jm.StartForSession(agent.BranchID(path), "bash", "provider job", func(ctx context.Context, _ io.Writer) (string, error) {
4625 <-ctx.Done()
4626 return "", ctx.Err()
4627 })
4628
4629 err := app.DeleteProvider("prov-a")
4630 if err != nil || !controllerHasActiveRuntimeWork(ctrl) {
4631 t.Fatalf("DeleteProvider interrupted background work: %v", err)
4632 }
4633 if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); ok {
4634 t.Fatal("provider deletion was not persisted")
4635 }
4636 }
4637
4638 func TestDeleteProviderPreservesUnaffectedBackgroundJobsWhenSavingConfig(t *testing.T) {
4639 isolateDesktopUserDirs(t)
4640 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4641
4642 cfg := config.Default()
4643 cfg.DefaultModel = "prov-b/model-b1"
4644 cfg.Providers = []config.ProviderEntry{
4645 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4646 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4647 }
4648 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4649 t.Fatalf("save config: %v", err)
4650 }
4651
4652 app := NewApp()
4653 app.ctx = context.Background()
4654 app.setTestCtrl(newBackgroundJobController(t, "provider-unaffected-job"), "prov-b/model-b1")
4655
4656 err := app.DeleteProvider("prov-a")
4657 if err != nil || !controllerHasActiveRuntimeWork(app.activeCtrl()) {
4658 t.Fatalf("DeleteProvider interrupted unrelated background work: %v", err)
4659 }
4660 if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); ok {
4661 t.Fatal("provider deletion was not persisted")
4662 }
4663 }
4664
4665 func TestRemoveBuiltInProviderAccessPreservesBackgroundJobsWhenSavingConfig(t *testing.T) {
4666 isolateDesktopUserDirs(t)
4667 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
4668 t.Fatalf("mkdir config dir: %v", err)
4669 }
4670 if err := os.WriteFile(config.UserConfigPath(), []byte(`
4671 default_model = "mimo-pro/mimo-v2.5-pro"
4672
4673 [desktop]
4674 provider_access = ["deepseek-flash", "mimo-pro"]
4675
4676 [[providers]]
4677 name = "deepseek-flash"
4678 kind = "openai"
4679 base_url = "https://api.deepseek.com"
4680 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
4681 default = "deepseek-v4-flash"
4682 api_key_env = "DEEPSEEK_API_KEY"
4683
4684 [[providers]]
4685 name = "mimo-pro"
4686 kind = "openai"
4687 base_url = "https://token-plan-cn.xiaomimimo.com/v1"
4688 model = "mimo-v2.5-pro"
4689 api_key_env = "MIMO_API_KEY"
4690 `), 0o644); err != nil {
4691 t.Fatalf("write config: %v", err)
4692 }
4693
4694 app := NewApp()
4695 app.ctx = context.Background()
4696 app.setTestCtrl(newBackgroundJobController(t, "provider-access-unaffected-job"), "mimo-token-plan/mimo-v2.5-pro")
4697
4698 err := app.RemoveProviderAccess("deepseek")
4699 if err != nil || !controllerHasActiveRuntimeWork(app.activeCtrl()) {
4700 t.Fatalf("RemoveProviderAccess interrupted background work: %v", err)
4701 }
4702 cfg := config.LoadForEdit(config.UserConfigPath())
4703 access := providerAccessSet(cfg.Desktop.ProviderAccess)
4704 if access["deepseek"] || access["deepseek-flash"] {
4705 t.Fatalf("provider access removal was not persisted: %+v", cfg.Desktop.ProviderAccess)
4706 }
4707 }
4708
4709 func TestConnectKeySavesWhileBackgroundJobKeepsItsController(t *testing.T) {
4710 isolateDesktopUserDirs(t)
4711 t.Setenv("DEEPSEEK_API_KEY", "")
4712 os.Unsetenv("DEEPSEEK_API_KEY")
4713 oldFetch := connectKeyBalanceFetch
4714 connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
4715 return &billing.Balance{Available: true}, nil
4716 }
4717 t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
4718
4719 app := NewApp()
4720 app.ctx = context.Background()
4721 app.setTestCtrl(newBackgroundJobController(t, "connect-key-job"), "deepseek-flash/deepseek-v4-flash")
4722 oldCtrl := app.activeCtrl()
4723
4724 _, err := app.ConnectKey("sk-test")
4725 if err != nil {
4726 t.Fatalf("ConnectKey with background job: %v", err)
4727 }
4728 p, ok := config.LoadForEdit(config.UserConfigPath()).Provider("deepseek")
4729 if !ok || !p.Configured() || app.activeCtrl() != oldCtrl {
4730 t.Fatal("key save must configure the connection and preserve the background runtime")
4731 }
4732 }
4733
4734 func TestConnectKeyRestoresDeepSeekProviderAccess(t *testing.T) {
4735 isolateDesktopUserDirs(t)
4736 cfg := config.Default()
4737 cfg.DefaultModel = "custom/custom-model"
4738 cfg.Desktop.ProviderAccess = []string{"custom"}
4739 cfg.Providers = []config.ProviderEntry{{
4740 Name: "custom", Kind: "openai", BaseURL: "https://models.example.invalid/v1",
4741 Model: "custom-model", APIKeyEnv: "CUSTOM_API_KEY",
4742 }}
4743 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4744 t.Fatalf("save custom provider config: %v", err)
4745 }
4746
4747 oldFetch := connectKeyBalanceFetch
4748 connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
4749 return &billing.Balance{Available: true}, nil
4750 }
4751 t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
4752
4753 app := NewApp()
4754 app.ctx = context.Background()
4755 app.readyHook = func() {}
4756 app.setTestCtrl(control.New(control.Options{Label: "custom"}), "custom/custom-model")
4757 defer func() {
4758 if ctrl := app.activeCtrl(); ctrl != nil {
4759 ctrl.Close()
4760 }
4761 }()
4762 if _, err := app.ConnectKey("sk-test"); err != nil {
4763 t.Fatalf("ConnectKey: %v", err)
4764 }
4765
4766 got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
4767 if !providerAccessSet(got.Desktop.ProviderAccess)["deepseek"] {
4768 t.Fatalf("provider_access = %v, want DeepSeek restored", got.Desktop.ProviderAccess)
4769 }
4770 if _, ok := got.Provider("deepseek"); !ok {
4771 t.Fatal("DeepSeek provider template should be restored")
4772 }
4773 if app.NeedsOnboarding() {
4774 t.Fatal("restored DeepSeek access and saved key should satisfy onboarding")
4775 }
4776 }
4777
4778 func TestConnectKeyFreshInstallUsesDeepSeekChatAndIndependentSearchDefaults(t *testing.T) {
4779 isolateDesktopUserDirs(t)
4780 oldFetch := connectKeyBalanceFetch
4781 connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
4782 return &billing.Balance{Available: true}, nil
4783 }
4784 t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
4785
4786 app := NewApp()
4787 app.ctx = context.Background()
4788 app.readyHook = func() {}
4789 app.setTestCtrl(control.New(control.Options{Label: "fresh-install"}), "deepseek-flash/deepseek-v4-flash")
4790 workspace := t.TempDir()
4791 app.tabs["test"].WorkspaceRoot = workspace
4792 defer func() {
4793 if ctrl := app.activeCtrl(); ctrl != nil {
4794 ctrl.Close()
4795 }
4796 }()
4797
4798 if _, err := app.ConnectKey("sk-test"); err != nil {
4799 t.Fatalf("ConnectKey: %v", err)
4800 }
4801 cfg, err := config.LoadForRootReadOnly(workspace)
4802 if err != nil {
4803 t.Fatalf("load fresh-install config: %v", err)
4804 }
4805 entry, ok := cfg.ResolveModel(cfg.DefaultModel)
4806 if !ok {
4807 t.Fatalf("default model %q did not resolve", cfg.DefaultModel)
4808 }
4809 if entry.Kind != "openai" || entry.BaseURL != "https://api.deepseek.com" ||
4810 entry.Thinking != "enabled" || !config.EffectiveIndependentWebSearch(entry) || !config.EffectiveVision(entry) {
4811 t.Fatalf("fresh-install DeepSeek entry = %+v; want Chat Completions, thinking, independent search, and native image input", entry)
4812 }
4813 if app.NeedsOnboarding() {
4814 t.Fatal("fresh-install onboarding should close after the validated DeepSeek key is stored")
4815 }
4816 }
4817
4818 func TestBalanceForTabUsesDesktopPricingCurrency(t *testing.T) {
4819 isolateDesktopUserDirs(t)
4820 cfg := config.Default()
4821 cfg.Desktop.Currency = "USD"
4822 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4823 t.Fatalf("save USD desktop currency: %v", err)
4824 }
4825
4826 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
4827 w.Header().Set("Content-Type", "application/json")
4828 _, _ = io.WriteString(w, `{"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"70.16"},{"currency":"USD","total_balance":"9.82"}]}`)
4829 }))
4830 defer srv.Close()
4831
4832 app := NewApp()
4833 app.ctx = context.Background()
4834 ctrl := control.New(control.Options{BalanceURL: srv.URL, BalanceClient: srv.Client()})
4835 t.Cleanup(ctrl.Close)
4836 app.setTestCtrl(ctrl, "deepseek/deepseek-v4-flash")
4837
4838 got := app.BalanceForTab("test")
4839 // Prefer the matching USD wallet exactly; no FX approximation is used.
4840 if !got.Available || got.Err != "" {
4841 t.Fatalf("USD desktop balance = %+v, want available", got)
4842 }
4843 if !strings.Contains(got.Display, "9.82") && !strings.Contains(got.Display, "$9.82") {
4844 t.Fatalf("USD desktop balance display = %q, want USD 9.82", got.Display)
4845 }
4846 }
4847
4848 func TestConnectKeyRebuildLeaseHeldKeepsCurrentController(t *testing.T) {
4849 isolateDesktopUserDirs(t)
4850 t.Setenv(onboardingKeyEnv, "")
4851 os.Unsetenv(onboardingKeyEnv)
4852 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
4853
4854 oldFetch := connectKeyBalanceFetch
4855 connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
4856 return &billing.Balance{Available: true}, nil
4857 }
4858 t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
4859
4860 cfg := config.Default()
4861 cfg.DefaultModel = "old/old-model"
4862 cfg.Desktop.ProviderAccess = []string{"old"}
4863 cfg.Providers = []config.ProviderEntry{
4864 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
4865 }
4866 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4867 t.Fatalf("save config: %v", err)
4868 }
4869
4870 dir := config.SessionDir()
4871 if err := os.MkdirAll(dir, 0o755); err != nil {
4872 t.Fatalf("mkdir session dir: %v", err)
4873 }
4874 sessionPath := filepath.Join(dir, "externally-leased-connect-key.jsonl")
4875 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
4876 t.Fatalf("write placeholder session: %v", err)
4877 }
4878 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
4879 if err != nil {
4880 t.Fatalf("TryAcquireSessionLease: %v", err)
4881 }
4882 defer externalLease.Release()
4883
4884 oldSession := agent.NewSession("old system prompt")
4885 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
4886 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
4887 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
4888 defer oldCtrl.Close()
4889
4890 app := NewApp()
4891 app.ctx = context.Background()
4892 tab := &WorkspaceTab{
4893 ID: "tab_connect",
4894 Scope: "global",
4895 SessionPath: sessionPath,
4896 Ready: true,
4897 model: "old/old-model",
4898 Ctrl: oldCtrl,
4899 sink: &tabEventSink{tabID: "tab_connect", app: app},
4900 disabledMCP: map[string]ServerView{},
4901 }
4902 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4903 app.tabOrder = []string{tab.ID}
4904 app.activeTabID = tab.ID
4905
4906 warning, err := app.ConnectKey("sk-test")
4907 if err != nil {
4908 t.Fatalf("ConnectKey: %v", err)
4909 }
4910 if warning != "" {
4911 t.Fatalf("ConnectKey warning = %q; saving does not acquire a runtime lease", warning)
4912 }
4913 if tab.Ctrl != oldCtrl {
4914 t.Fatalf("tab controller changed after failed connect-key rebuild")
4915 }
4916 if tab.StartupErr != "" {
4917 t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
4918 }
4919 p, ok := config.LoadForEdit(config.UserConfigPath()).Provider("deepseek")
4920 if !ok || !p.Configured() {
4921 t.Fatal("onboarding key should be persisted under the new connection reference")
4922 }
4923 }
4924
4925 func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {
4926 isolateDesktopUserDirs(t)
4927
4928 userCfg := config.LoadForEdit(config.UserConfigPath())
4929 if err := userCfg.SetDesktopLanguage("en"); err != nil {
4930 t.Fatalf("set desktop language: %v", err)
4931 }
4932 if err := userCfg.SetDesktopLayoutStyle("workbench"); err != nil {
4933 t.Fatalf("set desktop layout style: %v", err)
4934 }
4935 if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
4936 t.Fatalf("set desktop appearance: %v", err)
4937 }
4938 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
4939 t.Fatalf("save user config: %v", err)
4940 }
4941
4942 if err := NewApp().MigrateDesktopPreferences("zh", "light", "glacier"); err != nil {
4943 t.Fatalf("migrate desktop preferences: %v", err)
4944 }
4945
4946 got := config.LoadForEdit(config.UserConfigPath())
4947 if got.DesktopLanguage() != "en" || got.DesktopLayoutStyle() != "workbench" || got.DesktopTheme() != "dark" || got.DesktopThemeStyle() != "graphite" {
4948 t.Fatalf("desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())
4949 }
4950 }
4951
4952 func TestSetEffortRebuildsController(t *testing.T) {
4953 isolateDesktopUserDirs(t)
4954
4955 app := NewApp()
4956 app.ctx = context.Background()
4957 app.readyHook = func() {}
4958 old := control.New(control.Options{Label: "old-controller"})
4959 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
4960 defer func() {
4961 if c := app.activeCtrl(); c != nil {
4962 c.Close()
4963 }
4964 }()
4965
4966 if err := app.SetEffort("max"); err != nil {
4967 t.Fatalf("SetEffort(max): %v", err)
4968 }
4969 if c := app.activeCtrl(); c == nil {
4970 t.Fatal("SetEffort should leave a rebuilt controller")
4971 }
4972 if c := app.activeCtrl(); c == old {
4973 t.Fatal("SetEffort should rebuild the active controller so the provider sees the new effort")
4974 }
4975 if got := app.Effort().Current; got != "max" {
4976 t.Fatalf("Effort current = %q, want max", got)
4977 }
4978 }
4979
4980 func TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
4981 isolateDesktopUserDirs(t)
4982 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
4983
4984 cfg := config.Default()
4985 cfg.DefaultModel = "deepseek/deepseek-v4-flash"
4986 cfg.Desktop.ProviderAccess = []string{"deepseek"}
4987 cfg.Providers = []config.ProviderEntry{{
4988 Name: "deepseek",
4989 Kind: "openai",
4990 BaseURL: "https://api.deepseek.com",
4991 Model: "glm-5",
4992 APIKeyEnv: "DEEPSEEK_API_KEY",
4993 }}
4994 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4995 t.Fatalf("save config: %v", err)
4996 }
4997
4998 app := NewApp()
4999 app.ctx = context.Background()
5000 app.readyHook = func() {}
5001 old := control.New(control.Options{Label: "old-controller"})
5002 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
5003 defer func() {
5004 if c := app.activeCtrl(); c != nil {
5005 c.Close()
5006 }
5007 }()
5008
5009 if err := app.SetEffort("max"); err != nil {
5010 t.Fatalf("SetEffort(max): %v", err)
5011 }
5012 tab := app.activeTab()
5013 if tab == nil {
5014 t.Fatal("active tab missing")
5015 }
5016 if tab.model != "deepseek/deepseek-v4-flash" {
5017 t.Fatalf("tab model = %q, want migrated official ref", tab.model)
5018 }
5019 }
5020
5021 func captureTabNotices(app *App, tab *WorkspaceTab) *[]string {
5022 var notices []string
5023 if tab.sink == nil {
5024 tab.sink = &tabEventSink{tabID: tab.ID, app: app, ctx: context.Background()}
5025 }
5026 tab.sink.SetBotSink(event.FuncSink(func(e event.Event) {
5027 if e.Kind == event.Notice && strings.TrimSpace(e.Text) != "" {
5028 notices = append(notices, e.Text)
5029 }
5030 }))
5031 return &notices
5032 }
5033
5034 func assertDeprecatedExecutionModeNoop(t *testing.T, app *App, tab *WorkspaceTab, old control.SessionAPI, notices []string) {
5035 t.Helper()
5036 if tab == nil {
5037 t.Fatal("tab missing")
5038 }
5039 if tab.Ctrl == nil || tab.Ctrl != old {
5040 t.Fatalf("controller identity changed: got %p want %p", tab.Ctrl, old)
5041 }
5042 if got := old.AgentPreset(); got != boot.AgentPresetStandard {
5043 t.Fatalf("controller AgentPreset = %q, want standard (light folds)", got)
5044 }
5045 if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
5046 t.Fatalf("token mode = %q, want full", got)
5047 }
5048 meta := app.MetaForTab(tab.ID)
5049 if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetStandard {
5050 t.Fatalf("meta token/preset = %q/%q, want full/standard", meta.TokenMode, meta.AgentPreset)
5051 }
5052 }
5053
5054 func assertSetTokenModeDidNotPersistLiveModes(t *testing.T) {
5055 t.Helper()
5056 for _, entry := range loadTabsFile().Tabs {
5057 if entry.TokenMode == "economy" || entry.TokenMode == "light" {
5058 t.Fatalf("SetTokenMode persisted folded mode %q", entry.TokenMode)
5059 }
5060 if entry.AgentPreset == "light" || entry.AgentPreset == "balanced" {
5061 t.Fatalf("SetTokenMode persisted non-floor preset %q", entry.AgentPreset)
5062 }
5063 }
5064 }
5065
5066 func assertPinnedCompatPersisted(t *testing.T, app *App, tab *WorkspaceTab) {
5067 t.Helper()
5068 app.persistTabTokenMode(tab)
5069 saved := loadTabsFile()
5070 if len(saved.Tabs) != 1 {
5071 t.Fatalf("saved tabs = %+v, want 1", saved.Tabs)
5072 }
5073 if saved.Tabs[0].TokenMode != boot.TokenModeFull {
5074 t.Fatalf("saved compat token = %q, want full", saved.Tabs[0].TokenMode)
5075 }
5076 }
5077
5078 func TestSetTokenModeRebuildsController(t *testing.T) {
5079 // Name kept for history; SetTokenMode is a deprecated no-op wrapper.
5080 isolateDesktopUserDirs(t)
5081
5082 app := NewApp()
5083 app.ctx = context.Background()
5084 app.readyHook = func() {}
5085 old := control.New(control.Options{Label: "old-controller"})
5086 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
5087 defer func() {
5088 if c := app.activeCtrl(); c != nil {
5089 c.Close()
5090 }
5091 }()
5092 tab := app.activeTab()
5093 notices := captureTabNotices(app, tab)
5094
5095 if err := app.SetTokenMode("economy"); err != nil {
5096 t.Fatalf("SetTokenMode(economy): %v", err)
5097 }
5098 assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
5099 assertSetTokenModeDidNotPersistLiveModes(t)
5100 assertPinnedCompatPersisted(t, app, tab)
5101 }
5102
5103 func TestSetTokenModeDeliveryIsCompatibilityNoOp(t *testing.T) {
5104 isolateDesktopUserDirs(t)
5105
5106 app := NewApp()
5107 app.ctx = context.Background()
5108 app.readyHook = func() {}
5109 old := control.New(control.Options{Label: "old-controller"})
5110 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
5111 defer func() {
5112 if c := app.activeCtrl(); c != nil {
5113 c.Close()
5114 }
5115 }()
5116 tab := app.activeTab()
5117 notices := captureTabNotices(app, tab)
5118
5119 if err := app.SetTokenMode(boot.TokenModeDelivery); err != nil {
5120 t.Fatalf("SetTokenMode(delivery): %v", err)
5121 }
5122 if tab.Ctrl == nil || tab.Ctrl != old {
5123 t.Fatalf("controller identity changed: got %p want %p", tab.Ctrl, old)
5124 }
5125 if got := old.QualityFloor(); got != control.QualityFloorStandard {
5126 t.Fatalf("controller QualityFloor = %q, want standard", got)
5127 }
5128 if got := derivedQualityFloor(tab).floor; got != control.QualityFloorStandard {
5129 t.Fatalf("tab qualityFloor = %q, want standard", got)
5130 }
5131
5132 if err := app.SetTokenMode(boot.TokenModeFull); err != nil {
5133 t.Fatalf("SetTokenMode(full): %v", err)
5134 }
5135 assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
5136 assertPinnedCompatPersisted(t, app, tab)
5137 }
5138
5139 func TestSetTokenModeReusesCurrentSessionLease(t *testing.T) {
5140 isolateDesktopUserDirs(t)
5141 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
5142
5143 cfg := config.Default()
5144 cfg.DefaultModel = "old/old-model"
5145 cfg.Desktop.ProviderAccess = []string{"old"}
5146 cfg.Providers = []config.ProviderEntry{
5147 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
5148 }
5149 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5150 t.Fatalf("save config: %v", err)
5151 }
5152
5153 dir := config.SessionDir()
5154 if err := os.MkdirAll(dir, 0o755); err != nil {
5155 t.Fatalf("mkdir session dir: %v", err)
5156 }
5157 session := agent.NewSession("old system prompt")
5158 session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
5159 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
5160 path := filepath.Join(dir, "leased-token-mode-switch.jsonl")
5161 oldCtrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
5162
5163 app := NewApp()
5164 app.ctx = context.Background()
5165 tab := &WorkspaceTab{
5166 ID: "tab_a",
5167 Scope: "global",
5168 Ready: true,
5169 model: "old/old-model",
5170 Ctrl: oldCtrl,
5171 sink: &tabEventSink{tabID: "tab_a", app: app},
5172 disabledMCP: map[string]ServerView{},
5173 }
5174 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
5175 app.tabOrder = []string{tab.ID}
5176 app.activeTabID = tab.ID
5177 t.Cleanup(func() {
5178 if tab.Ctrl != nil {
5179 tab.Ctrl.Close()
5180 }
5181 tab.releaseSessionLease()
5182 })
5183
5184 if err := tab.ensureSessionLease(path); err != nil {
5185 t.Fatalf("ensureSessionLease: %v", err)
5186 }
5187 notices := captureTabNotices(app, tab)
5188 if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
5189 t.Fatalf("SetTokenModeForTab: %v", err)
5190 }
5191 assertDeprecatedExecutionModeNoop(t, app, tab, oldCtrl, *notices)
5192 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) {
5193 t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
5194 }
5195 history := tab.Ctrl.History()
5196 if len(history) < 2 || history[1].Role != provider.RoleUser || history[1].Content != "hello" {
5197 t.Fatalf("carried history = %+v, want original user message", history)
5198 }
5199 }
5200
5201 func TestSetTokenModeLeaseHeldKeepsCurrentController(t *testing.T) {
5202 isolateDesktopUserDirs(t)
5203 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
5204
5205 cfg := config.Default()
5206 cfg.DefaultModel = "old/old-model"
5207 cfg.Desktop.ProviderAccess = []string{"old"}
5208 cfg.Providers = []config.ProviderEntry{
5209 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
5210 }
5211 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5212 t.Fatalf("save config: %v", err)
5213 }
5214
5215 dir := config.SessionDir()
5216 if err := os.MkdirAll(dir, 0o755); err != nil {
5217 t.Fatalf("mkdir session dir: %v", err)
5218 }
5219 path := filepath.Join(dir, "externally-leased-token-mode-switch.jsonl")
5220 if err := os.WriteFile(path, nil, 0o644); err != nil {
5221 t.Fatalf("write placeholder session: %v", err)
5222 }
5223 externalLease, err := agent.TryAcquireSessionLease(path)
5224 if err != nil {
5225 t.Fatalf("TryAcquireSessionLease: %v", err)
5226 }
5227 defer externalLease.Release()
5228
5229 session := agent.NewSession("old system prompt")
5230 session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
5231 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
5232 oldCtrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
5233 defer oldCtrl.Close()
5234
5235 app := NewApp()
5236 app.ctx = context.Background()
5237 tab := &WorkspaceTab{
5238 ID: "tab_a",
5239 Scope: "global",
5240 Ready: true,
5241 model: "old/old-model",
5242 Ctrl: oldCtrl,
5243 sink: &tabEventSink{tabID: "tab_a", app: app},
5244 disabledMCP: map[string]ServerView{},
5245 }
5246 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
5247 app.tabOrder = []string{tab.ID}
5248 app.activeTabID = tab.ID
5249
5250 // Deprecated wrapper must not re-acquire the session lease, so an
5251 // externally held lease does not block the call or replace the controller.
5252 notices := captureTabNotices(app, tab)
5253 if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
5254 t.Fatalf("SetTokenModeForTab: %v", err)
5255 }
5256 assertDeprecatedExecutionModeNoop(t, app, tab, oldCtrl, *notices)
5257 meta := app.MetaForTab(tab.ID)
5258 if !meta.Ready || meta.Runtime.Phase != sessionRuntimeReady {
5259 t.Fatalf("deprecated mode call disabled current runtime: ready=%v phase=%q", meta.Ready, meta.Runtime.Phase)
5260 }
5261 }
5262
5263 func TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
5264 isolateDesktopUserDirs(t)
5265 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
5266
5267 cfg := config.Default()
5268 cfg.DefaultModel = "deepseek/deepseek-v4-flash"
5269 cfg.Desktop.ProviderAccess = []string{"deepseek"}
5270 cfg.Providers = []config.ProviderEntry{{
5271 Name: "deepseek",
5272 Kind: "openai",
5273 BaseURL: "https://api.deepseek.com",
5274 Model: "glm-5",
5275 APIKeyEnv: "DEEPSEEK_API_KEY",
5276 }}
5277 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5278 t.Fatalf("save config: %v", err)
5279 }
5280
5281 app := NewApp()
5282 app.ctx = context.Background()
5283 app.readyHook = func() {}
5284 old := control.New(control.Options{Label: "old-controller"})
5285 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
5286 defer func() {
5287 if c := app.activeCtrl(); c != nil {
5288 c.Close()
5289 }
5290 }()
5291
5292 tab := app.activeTab()
5293 notices := captureTabNotices(app, tab)
5294 if err := app.SetTokenMode("economy"); err != nil {
5295 t.Fatalf("SetTokenMode(economy): %v", err)
5296 }
5297 if tab == nil {
5298 t.Fatal("active tab missing")
5299 }
5300 // SetTokenMode does not rebuild, so stale model aliases stay put
5301 // (migration still runs on model/effort rebuilds).
5302 if tab.model != "deepseek-flash/deepseek-v4-flash" {
5303 t.Fatalf("tab model = %q, want unchanged stale ref without rebuild", tab.model)
5304 }
5305 assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
5306 }
5307
5308 func TestMetaForTabReportsImageInputCapability(t *testing.T) {
5309 isolateDesktopUserDirs(t)
5310 setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test")
5311
5312 cfg := config.Default()
5313 cfg.DefaultModel = "custom/text-only"
5314 cfg.Desktop.ProviderAccess = []string{"custom"}
5315 cfg.Providers = []config.ProviderEntry{{
5316 Name: "custom",
5317 Kind: "openai",
5318 BaseURL: "https://example.invalid/v1",
5319 APIKeyEnv: "CUSTOM_KEY",
5320 Models: []string{"text-only", "vision-pro"},
5321 VisionModels: []string{"vision-pro"},
5322 }}
5323 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5324 t.Fatalf("save config: %v", err)
5325 }
5326
5327 app := NewApp()
5328 app.ctx = context.Background()
5329 app.readyHook = func() {}
5330 app.setTestCtrl(control.New(control.Options{Label: "custom/text-only"}), "custom/text-only")
5331 defer func() {
5332 if c := app.activeCtrl(); c != nil {
5333 c.Close()
5334 }
5335 }()
5336
5337 if got := app.Meta().ImageInputEnabled; got {
5338 t.Fatal("text-only meta should disable image input")
5339 }
5340 if err := app.SetModel("custom/vision-pro"); err != nil {
5341 t.Fatalf("SetModel(custom/vision-pro): %v", err)
5342 }
5343 // ImageInputEnabled is served from the per-tab cache; the model change
5344 // invalidates it and a background refresh repopulates it (tab:meta).
5345 waitForMetaImageInput(t, app, true)
5346 }
5347
5348 // waitForMetaImageInput polls until the cached image-input capability reaches
5349 // the expected value. MetaForTab serves the background-refreshed cache, so the
5350 // value flips asynchronously after a model/settings change.
5351 func waitForMetaImageInput(t *testing.T, app *App, want bool) {
5352 t.Helper()
5353 deadline := time.Now().Add(10 * time.Second)
5354 for time.Now().Before(deadline) {
5355 if app.Meta().ImageInputEnabled == want {
5356 return
5357 }
5358 time.Sleep(10 * time.Millisecond)
5359 }
5360 t.Fatalf("Meta().ImageInputEnabled did not become %v", want)
5361 }
5362
5363 func TestMetaForTabImageInputCapabilityUsesCurrentRef(t *testing.T) {
5364 isolateDesktopUserDirs(t)
5365 setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test")
5366
5367 cfg := config.Default()
5368 cfg.DefaultModel = "custom/vision-pro"
5369 cfg.Desktop.ProviderAccess = []string{"custom"}
5370 cfg.Providers = []config.ProviderEntry{{
5371 Name: "custom",
5372 Kind: "openai",
5373 BaseURL: "https://example.invalid/v1",
5374 APIKeyEnv: "CUSTOM_KEY",
5375 Models: []string{"text-only", "vision-pro"},
5376 VisionModels: []string{"vision-pro"},
5377 }}
5378 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5379 t.Fatalf("save config: %v", err)
5380 }
5381
5382 app := NewApp()
5383 app.ctx = context.Background()
5384 app.readyHook = func() {}
5385 app.setTestCtrl(control.New(control.Options{Label: "deleted/model"}), "deleted/model")
5386 defer func() {
5387 if c := app.activeCtrl(); c != nil {
5388 c.Close()
5389 }
5390 }()
5391
5392 if got := app.Meta().ImageInputEnabled; got {
5393 t.Fatal("unknown model ref should not inherit image input from the default fallback model")
5394 }
5395 }
5396
5397 func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {
5398 // Name kept for history; an unknown model must not block the deprecated no-op.
5399 isolateDesktopUserDirs(t)
5400 t.Setenv("DEEPSEEK_API_KEY", "")
5401 t.Setenv("MIMO_API_KEY", "")
5402
5403 app := NewApp()
5404 app.ctx = context.Background()
5405 app.readyHook = func() {}
5406 old := control.New(control.Options{Label: "old-controller"})
5407 app.setTestCtrl(old, "missing-token-mode-model")
5408 defer func() {
5409 if c := app.activeCtrl(); c != nil {
5410 c.Close()
5411 }
5412 }()
5413 tab := app.activeTab()
5414 notices := captureTabNotices(app, tab)
5415
5416 if err := app.SetTokenMode("economy"); err != nil {
5417 t.Fatalf("SetTokenMode(economy): %v", err)
5418 }
5419 assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
5420 }
5421
5422 func TestSetEffortRejectsRunningTurn(t *testing.T) {
5423 isolateDesktopUserDirs(t)
5424
5425 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
5426 app := NewApp()
5427 app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
5428 app.activeCtrl().Submit("work")
5429 <-runner.started
5430
5431 err := app.SetEffort("max")
5432 if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
5433 t.Fatalf("SetEffort while running error = %v, want finish/cancel guard", err)
5434 }
5435
5436 close(runner.release)
5437 waitNotRunning(t, app.activeCtrl())
5438 }
5439
5440 func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
5441 // Name kept for history; the deprecated wrapper does not require an idle tab.
5442 isolateDesktopUserDirs(t)
5443
5444 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
5445 app := NewApp()
5446 old := control.New(control.Options{Runner: runner})
5447 app.setTestCtrl(old, "")
5448 tab := app.activeTab()
5449 notices := captureTabNotices(app, tab)
5450 old.Submit("work")
5451 <-runner.started
5452
5453 if err := app.SetTokenMode("economy"); err != nil {
5454 t.Fatalf("SetTokenMode while running: %v", err)
5455 }
5456 assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
5457
5458 close(runner.release)
5459 waitNotRunning(t, app.activeCtrl())
5460 }
5461
5462 func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
5463 // Name kept for history; background jobs must not block the deprecated wrapper.
5464 isolateDesktopUserDirs(t)
5465 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
5466
5467 cfg := config.Default()
5468 cfg.DefaultModel = "old/old-model"
5469 cfg.Desktop.ProviderAccess = []string{"old"}
5470 cfg.Providers = []config.ProviderEntry{
5471 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
5472 }
5473 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5474 t.Fatalf("save config: %v", err)
5475 }
5476
5477 dir := config.SessionDir()
5478 if err := os.MkdirAll(dir, 0o755); err != nil {
5479 t.Fatalf("mkdir session dir: %v", err)
5480 }
5481 path := filepath.Join(dir, "jobs.jsonl")
5482 jm := jobs.NewManager(event.Discard)
5483 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
5484 app := NewApp()
5485 app.ctx = context.Background()
5486 app.setTestCtrl(ctrl, "old/old-model")
5487 t.Cleanup(func() {
5488 if current := app.activeCtrl(); current != nil {
5489 current.Close()
5490 }
5491 })
5492 tab := app.activeTab()
5493 notices := captureTabNotices(app, tab)
5494
5495 release := make(chan struct{})
5496 job := jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
5497 select {
5498 case <-ctx.Done():
5499 return "", ctx.Err()
5500 case <-release:
5501 return "", nil
5502 }
5503 })
5504 t.Cleanup(func() { close(release) })
5505
5506 if err := app.SetTokenMode("economy"); err != nil {
5507 t.Fatalf("SetTokenMode with background job: %v", err)
5508 }
5509 assertDeprecatedExecutionModeNoop(t, app, tab, ctrl, *notices)
5510 cancelled, err := app.CancelJobForTab("", job.ID)
5511 if err != nil || !cancelled {
5512 t.Fatalf("CancelJobForTab = %v, %v, want true, nil", cancelled, err)
5513 }
5514 if result := jm.WaitForSession(context.Background(), agent.BranchID(path), []string{job.ID}, 5); len(result) != 1 || result[0].Status != jobs.Killed {
5515 t.Fatalf("stopped background job = %+v, want one killed result", result)
5516 }
5517 }
5518
5519 func TestSetTokenModeUnknownTabErrors(t *testing.T) {
5520 isolateDesktopUserDirs(t)
5521 app := NewApp()
5522 err := app.SetTokenModeForTab("missing-tab", "economy")
5523 if err == nil || !strings.Contains(err.Error(), `tab "missing-tab" not found`) {
5524 t.Fatalf("SetTokenModeForTab(unknown) = %v, want tab not found", err)
5525 }
5526 err = app.SetAgentPresetForTab("missing-tab", "light")
5527 if err == nil || !strings.Contains(err.Error(), `tab "missing-tab" not found`) {
5528 t.Fatalf("SetAgentPresetForTab(unknown) = %v, want tab not found", err)
5529 }
5530 }
5531
5532 func TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {
5533 isolateDesktopUserDirs(t)
5534
5535 dir := config.SessionDir()
5536 if err := os.MkdirAll(dir, 0o755); err != nil {
5537 t.Fatalf("mkdir session dir: %v", err)
5538 }
5539 path := filepath.Join(dir, "settings-job.jsonl")
5540 jm := jobs.NewManager(event.Discard)
5541 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
5542 defer ctrl.Close()
5543 app := NewApp()
5544 app.ctx = context.Background()
5545 app.setTestCtrl(ctrl, "deepseek-flash/deepseek-v4-flash")
5546
5547 jm.StartForSession(agent.BranchID(path), "bash", "settings job", func(ctx context.Context, _ io.Writer) (string, error) {
5548 <-ctx.Done()
5549 return "", ctx.Err()
5550 })
5551
5552 err := app.SetSandbox("enforce", true, "", nil, "")
5553 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
5554 t.Fatalf("SetSandbox with background job error = %v, want background-job guard", err)
5555 }
5556 }
5557
5558 func TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {
5559 isolateDesktopUserDirs(t)
5560
5561 dir := config.SessionDir()
5562 if err := os.MkdirAll(dir, 0o755); err != nil {
5563 t.Fatalf("mkdir session dir: %v", err)
5564 }
5565 path := filepath.Join(dir, "clear-running.jsonl")
5566 if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
5567 t.Fatalf("write session: %v", err)
5568 }
5569 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
5570 oldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"})
5571 app := NewApp()
5572 app.projectTreeChangedHook = func() {}
5573 app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash")
5574 app.tabs["test"].TopicID = "topic_clear"
5575 app.tabs["test"].TopicTitle = "Clear topic"
5576 defer func() {
5577 if c := app.activeCtrl(); c != nil {
5578 c.Close()
5579 }
5580 }()
5581
5582 oldCtrl.Submit("work")
5583 <-runner.started
5584 if _, err := app.ClearSession(); err != nil {
5585 t.Fatalf("ClearSession: %v", err)
5586 }
5587 waitNotRunning(t, oldCtrl)
5588 tab := app.activeTab()
5589 if tab == nil || tab.Ctrl == nil {
5590 t.Fatalf("active tab/controller missing after clear")
5591 }
5592 if tab.Ctrl == oldCtrl {
5593 t.Fatalf("clear should replace the active controller after cancelling old work")
5594 }
5595 if tab.TopicID != "topic_clear" || tab.TopicTitle != "Clear topic" {
5596 t.Fatalf("clear changed topic identity: %+v", tab)
5597 }
5598 if _, err := os.Stat(path); !os.IsNotExist(err) {
5599 t.Fatalf("old cleared session artifacts should be removed, stat err = %v", err)
5600 }
5601 if got := tab.currentSessionPath(); got == "" || got == path {
5602 t.Fatalf("new session path = %q, want fresh path", got)
5603 }
5604 }
5605
5606 func TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {
5607 isolateDesktopUserDirs(t)
5608
5609 dir := config.SessionDir()
5610 if err := os.MkdirAll(dir, 0o755); err != nil {
5611 t.Fatalf("mkdir session dir: %v", err)
5612 }
5613 path := filepath.Join(dir, "clear-running-job.jsonl")
5614 if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
5615 t.Fatalf("write session: %v", err)
5616 }
5617 jm := jobs.NewManager(event.Discard)
5618 oldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
5619 app := NewApp()
5620 app.projectTreeChangedHook = func() {}
5621 app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash")
5622 defer func() {
5623 if c := app.activeCtrl(); c != nil {
5624 c.Close()
5625 }
5626 }()
5627
5628 started := make(chan struct{})
5629 jm.StartForSession(agent.BranchID(path), "bash", "clear artifact", func(ctx context.Context, _ io.Writer) (string, error) {
5630 close(started)
5631 <-ctx.Done()
5632 return "", ctx.Err()
5633 })
5634 <-started
5635 jobsDir := jobs.ArtifactDir(path)
5636 if _, err := os.Stat(jobsDir); err != nil {
5637 t.Fatalf("job sidecar should exist before clear: %v", err)
5638 }
5639
5640 if _, err := app.ClearSession(); err != nil {
5641 t.Fatalf("ClearSession: %v", err)
5642 }
5643 if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
5644 t.Fatalf("old job sidecar should be removed after clear, stat err = %v", err)
5645 }
5646 }
5647
5648 func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
5649 orig, _ := os.Getwd()
5650 defer os.Chdir(orig)
5651
5652 dir := robustTempDir(t)
5653 if err := os.MkdirAll(filepath.Join(dir, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
5654 t.Fatal(err)
5655 }
5656 if err := os.WriteFile(filepath.Join(dir, "frontend", "wailsjs", "runtime", "runtime.js"), []byte("x"), 0o644); err != nil {
5657 t.Fatal(err)
5658 }
5659 if err := os.WriteFile(filepath.Join(dir, "frontend", "Thumbs.db"), []byte("noise"), 0o644); err != nil {
5660 t.Fatal(err)
5661 }
5662 if err := os.WriteFile(filepath.Join(dir, "frontend", ".DS_Store"), []byte("noise"), 0o644); err != nil {
5663 t.Fatal(err)
5664 }
5665 if err := os.MkdirAll(filepath.Join(dir, "node_modules", "pkg"), 0o755); err != nil {
5666 t.Fatal(err)
5667 }
5668 if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "runtime.js"), []byte("noise"), 0o644); err != nil {
5669 t.Fatal(err)
5670 }
5671 for _, noise := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
5672 if err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {
5673 t.Fatal(err)
5674 }
5675 if err := os.WriteFile(filepath.Join(dir, noise, "runtime.js"), []byte("noise"), 0o644); err != nil {
5676 t.Fatal(err)
5677 }
5678 }
5679 if err := os.MkdirAll(filepath.Join(dir, "desktop", "frontend", "wailsjs"), 0o755); err != nil {
5680 t.Fatal(err)
5681 }
5682 if err := os.WriteFile(filepath.Join(dir, "desktop", "frontend", "wailsjs", "runtime.js"), []byte("generated"), 0o644); err != nil {
5683 t.Fatal(err)
5684 }
5685 if err := os.MkdirAll(filepath.Join(dir, "product", "bin"), 0o755); err != nil {
5686 t.Fatal(err)
5687 }
5688 if err := os.WriteFile(filepath.Join(dir, "product", "bin", "runtime.js"), []byte("real"), 0o644); err != nil {
5689 t.Fatal(err)
5690 }
5691 if err := os.Chdir(dir); err != nil {
5692 t.Fatal(err)
5693 }
5694
5695 app := &App{}
5696 listed := app.ListDir("")
5697 for _, hidden := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
5698 if hasDirEntry(listed, hidden) {
5699 t.Fatalf("ListDir should hide local noise %q, got %+v", hidden, listed)
5700 }
5701 }
5702 desktopFrontend := app.ListDir("desktop/frontend")
5703 if hasDirEntry(desktopFrontend, "wailsjs") {
5704 t.Fatalf("ListDir should hide generated Wails bindings, got %+v", desktopFrontend)
5705 }
5706 frontendEntries := app.ListDir("frontend")
5707 for _, hidden := range []string{".DS_Store", "Thumbs.db"} {
5708 if hasDirEntry(frontendEntries, hidden) {
5709 t.Fatalf("ListDir should hide local noise file %q, got %+v", hidden, frontendEntries)
5710 }
5711 }
5712
5713 got := app.SearchFileRefs("runtime.js")
5714 if !hasDirEntry(got, "frontend/wailsjs/runtime/runtime.js") {
5715 t.Fatalf("SearchFileRefs(runtime.js) should find nested workspace file, got %+v", got)
5716 }
5717 if !hasDirEntry(got, "product/bin/runtime.js") {
5718 t.Fatalf("SearchFileRefs should keep non-root bin directories searchable, got %+v", got)
5719 }
5720 if hasDirEntry(got, "node_modules/pkg/runtime.js") {
5721 t.Fatalf("SearchFileRefs should skip node_modules noise, got %+v", got)
5722 }
5723 for _, hidden := range []string{
5724 ".codex/runtime.js",
5725 ".npm/runtime.js",
5726 ".pnpm-store/runtime.js",
5727 "bin/runtime.js",
5728 "desktop/frontend/wailsjs/runtime.js",
5729 "dist/runtime.js",
5730 "stage/runtime.js",
5731 "tmp/runtime.js",
5732 } {
5733 if hasDirEntry(got, hidden) {
5734 t.Fatalf("SearchFileRefs should skip local noise %q, got %+v", hidden, got)
5735 }
5736 }
5737 if noise := app.SearchFileRefs("Thumbs"); hasDirEntry(noise, "frontend/Thumbs.db") {
5738 t.Fatalf("SearchFileRefs should skip Thumbs.db noise, got %+v", noise)
5739 }
5740 if noise := app.SearchFileRefs(".DS"); hasDirEntry(noise, "frontend/.DS_Store") {
5741 t.Fatalf("SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v", noise)
5742 }
5743 }
5744
5745 func TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {
5746 orig, _ := os.Getwd()
5747 defer os.Chdir(orig)
5748
5749 launchRoot := robustTempDir(t)
5750 projectRoot := robustTempDir(t)
5751 if err := os.WriteFile(filepath.Join(launchRoot, "launch-only.txt"), []byte("wrong"), 0o644); err != nil {
5752 t.Fatal(err)
5753 }
5754 if err := os.MkdirAll(filepath.Join(projectRoot, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
5755 t.Fatal(err)
5756 }
5757 projectFile := filepath.Join(projectRoot, "frontend", "wailsjs", "runtime", "runtime.js")
5758 if err := os.WriteFile(projectFile, []byte("right workspace"), 0o644); err != nil {
5759 t.Fatal(err)
5760 }
5761 if err := os.Chdir(launchRoot); err != nil {
5762 t.Fatal(err)
5763 }
5764
5765 app := NewApp()
5766 tab := &WorkspaceTab{ID: "project", Scope: "project", WorkspaceRoot: projectRoot}
5767 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
5768 app.activeTabID = tab.ID
5769
5770 listed := app.ListDir("")
5771 if !hasDirEntry(listed, "frontend") {
5772 t.Fatalf("ListDir should list active project root, got %+v", listed)
5773 }
5774 if hasDirEntry(listed, "launch-only.txt") {
5775 t.Fatalf("ListDir leaked launch cwd entries, got %+v", listed)
5776 }
5777
5778 found := app.SearchFileRefs("runtime.js")
5779 if !hasDirEntry(found, "frontend/wailsjs/runtime/runtime.js") {
5780 t.Fatalf("SearchFileRefs should search active project root, got %+v", found)
5781 }
5782 preview := app.ReadFile("frontend/wailsjs/runtime/runtime.js")
5783 if preview.Err != "" || preview.Body != "right workspace" {
5784 t.Fatalf("ReadFile active project preview = %+v, want project file", preview)
5785 }
5786 }
5787
5788 func TestFileRefsForTabIgnoreActiveParentWorkspace(t *testing.T) {
5789 parentRoot := robustTempDir(t)
5790 childRoot := filepath.Join(parentRoot, "child")
5791 if err := os.MkdirAll(childRoot, 0o755); err != nil {
5792 t.Fatal(err)
5793 }
5794 if err := os.WriteFile(filepath.Join(parentRoot, "parent-only.txt"), []byte("parent"), 0o644); err != nil {
5795 t.Fatal(err)
5796 }
5797 if err := os.WriteFile(filepath.Join(parentRoot, "shared.txt"), []byte("parent shared"), 0o644); err != nil {
5798 t.Fatal(err)
5799 }
5800 if err := os.WriteFile(filepath.Join(childRoot, "child-only.txt"), []byte("child"), 0o644); err != nil {
5801 t.Fatal(err)
5802 }
5803 if err := os.WriteFile(filepath.Join(childRoot, "shared.txt"), []byte("child shared"), 0o644); err != nil {
5804 t.Fatal(err)
5805 }
5806
5807 app := &App{
5808 tabs: map[string]*WorkspaceTab{
5809 "parent": {ID: "parent", Scope: "project", WorkspaceRoot: parentRoot},
5810 "child": {ID: "child", Scope: "project", WorkspaceRoot: childRoot},
5811 },
5812 activeTabID: "parent",
5813 }
5814
5815 listed := app.ListDirForTab("child", "")
5816 if !hasDirEntry(listed, "child-only.txt") || hasDirEntry(listed, "parent-only.txt") {
5817 t.Fatalf("ListDirForTab(child) = %+v, want only child workspace entries", listed)
5818 }
5819 found := app.SearchFileRefsForTab("child", "child-only")
5820 if !hasDirEntry(found, "child-only.txt") {
5821 t.Fatalf("SearchFileRefsForTab(child) = %+v, want child-only.txt", found)
5822 }
5823 preview := app.ReadFileForTab("child", "shared.txt")
5824 if preview.Err != "" || preview.Body != "child shared" {
5825 t.Fatalf("ReadFileForTab(child) = %+v, want child workspace file", preview)
5826 }
5827 path, ok, err := app.workspaceOrExternalPathForTab("child", "shared.txt")
5828 if err != nil || !ok || path != filepath.Join(childRoot, "shared.txt") {
5829 t.Fatalf("workspaceOrExternalPathForTab(child) = (%q, %v, %v)", path, ok, err)
5830 }
5831
5832 legacy := app.ReadFile("shared.txt")
5833 if legacy.Err != "" || legacy.Body != "parent shared" {
5834 t.Fatalf("ReadFile legacy active-tab behavior = %+v, want parent workspace file", legacy)
5835 }
5836 }
5837
5838 func TestFileRefsIncludeRegisteredExternalFolderChildren(t *testing.T) {
5839 workspace := robustTempDir(t)
5840 external := filepath.Join(robustTempDir(t), "Folder With Spaces")
5841 if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil {
5842 t.Fatal(err)
5843 }
5844 if err := os.WriteFile(filepath.Join(external, "src", "outside.txt"), []byte("outside"), 0o644); err != nil {
5845 t.Fatal(err)
5846 }
5847 expectedExternal := external
5848 if resolved, err := filepath.EvalSymlinks(external); err == nil {
5849 expectedExternal = resolved
5850 }
5851 expectedDisplayPath := filepath.ToSlash(expectedExternal)
5852
5853 ctrl := &control.Controller{}
5854 token, _, err := ctrl.RegisterExternalFolderRef(external)
5855 if err != nil {
5856 t.Fatalf("RegisterExternalFolderRef: %v", err)
5857 }
5858 app := &App{
5859 tabs: map[string]*WorkspaceTab{
5860 "project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
5861 "other": {ID: "other", WorkspaceRoot: robustTempDir(t)},
5862 },
5863 activeTabID: "other",
5864 }
5865
5866 listed := app.ListDirForTab("project", token+"/src/")
5867 if len(listed) != 1 ||
5868 listed[0].Name != "outside.txt" ||
5869 listed[0].Path != token+"/src/outside.txt" ||
5870 listed[0].DisplayPath != expectedDisplayPath+"/src/outside.txt" {
5871 t.Fatalf("ListDir external src = %+v, want outside token/display path", listed)
5872 }
5873
5874 found := app.SearchFileRefsForTab("project", "outside")
5875 var externalHit *DirEntry
5876 for i := range found {
5877 if found[i].Path == token+"/src/outside.txt" {
5878 externalHit = &found[i]
5879 break
5880 }
5881 }
5882 if externalHit == nil || externalHit.DisplayName != "Folder With Spaces/src/outside.txt" || externalHit.DisplayPath != expectedDisplayPath+"/src/outside.txt" {
5883 t.Fatalf("SearchFileRefs external hit = %+v, all results %+v", externalHit, found)
5884 }
5885
5886 preview := app.ReadFileForTab("project", token+"/src/outside.txt")
5887 if preview.Err != "" || preview.Body != "outside" {
5888 t.Fatalf("ReadFile external token preview = %+v, want outside file body", preview)
5889 }
5890 }
5891
5892 func TestLegacyDeleteSessionCancelsActiveRuntime(t *testing.T) {
5893 isolateDesktopUserDirs(t)
5894
5895 dir := config.SessionDir()
5896 if err := os.MkdirAll(dir, 0o755); err != nil {
5897 t.Fatalf("mkdir session dir: %v", err)
5898 }
5899 path := filepath.Join(dir, "active.jsonl")
5900 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
5901 t.Fatalf("write session: %v", err)
5902 }
5903
5904 app := NewApp()
5905 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"})
5906 keepPath := filepath.Join(dir, "keep.jsonl")
5907 if err := os.WriteFile(keepPath, []byte(`{"role":"user","content":"keep"}`+"\n"), 0o644); err != nil {
5908 t.Fatalf("write keep session: %v", err)
5909 }
5910 keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"})
5911 defer keepCtrl.Close()
5912 app.setTestCtrl(activeCtrl, "")
5913 app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true}
5914 app.tabOrder = []string{"test", "keep"}
5915
5916 if err := app.deleteSession(filepath.Base(path)); err != nil {
5917 t.Fatalf("DeleteSession(active basename): %v", err)
5918 }
5919 if _, ok := app.tabs["test"]; ok {
5920 t.Fatalf("deleted active session runtime should be removed")
5921 }
5922 if got := app.activeTabID; got != "keep" {
5923 t.Fatalf("active tab after delete = %q, want keep", got)
5924 }
5925 if _, err := os.Stat(path); !os.IsNotExist(err) {
5926 t.Fatalf("active session should be moved out of active history, stat err = %v", err)
5927 }
5928 trashPath := filepath.Join(dir, sessionTrashDir, "active.jsonl", "active.jsonl")
5929 if _, err := os.Stat(trashPath); err != nil {
5930 t.Fatalf("active session should be moved to trash: %v", err)
5931 }
5932 }
5933
5934 func TestLegacyDeleteSessionCancelsPreReadyBlankBuild(t *testing.T) {
5935 isolateDesktopUserDirs(t)
5936
5937 globalRoot := globalTabWorkspaceRoot()
5938 dir := desktopSessionDir(globalRoot)
5939 if err := os.MkdirAll(dir, 0o755); err != nil {
5940 t.Fatalf("mkdir session dir: %v", err)
5941 }
5942 path := filepath.Join(dir, "pre-ready-blank.jsonl")
5943 if err := os.WriteFile(path, nil, 0o644); err != nil {
5944 t.Fatalf("write blank session: %v", err)
5945 }
5946 cancelled := false
5947 blank := &WorkspaceTab{
5948 ID: "blank",
5949 Scope: "global",
5950 WorkspaceRoot: globalRoot,
5951 SessionPath: path,
5952 buildCancel: func() { cancelled = true },
5953 disabledMCP: map[string]ServerView{},
5954 }
5955 keep := &WorkspaceTab{
5956 ID: "keep",
5957 Scope: "global",
5958 WorkspaceRoot: globalRoot,
5959 Ready: true,
5960 disabledMCP: map[string]ServerView{},
5961 }
5962 app := &App{
5963 tabs: map[string]*WorkspaceTab{"blank": blank, "keep": keep},
5964 tabOrder: []string{"blank", "keep"},
5965 activeTabID: "blank",
5966 }
5967
5968 if err := app.deleteSession(filepath.Base(path)); err != nil {
5969 t.Fatalf("DeleteSession(pre-ready blank): %v", err)
5970 }
5971 if !cancelled {
5972 t.Fatal("pre-ready blank build was not cancelled")
5973 }
5974 if !blank.removed {
5975 t.Fatal("pre-ready blank tab was not marked removed")
5976 }
5977 if _, ok := app.tabs["blank"]; ok {
5978 t.Fatal("pre-ready blank tab should be removed")
5979 }
5980 if _, err := os.Stat(path); !os.IsNotExist(err) {
5981 t.Fatalf("blank session should be moved out of active history, stat err = %v", err)
5982 }
5983 }
5984
5985 func TestLegacyDeleteLastTopicSessionFallbackDoesNotReuseDeletedTopic(t *testing.T) {
5986 isolateDesktopUserDirs(t)
5987
5988 projectRoot := t.TempDir()
5989 topicID := "topic_delete_last"
5990 if err := addProject(projectRoot, ""); err != nil {
5991 t.Fatalf("add project: %v", err)
5992 }
5993 if err := setTopicTitle(projectRoot, topicID, "Delete last"); err != nil {
5994 t.Fatalf("set topic title: %v", err)
5995 }
5996 dir := config.SessionDir()
5997 if err := os.MkdirAll(dir, 0o755); err != nil {
5998 t.Fatalf("mkdir session dir: %v", err)
5999 }
6000 path := writeTopicSession(t, dir, "delete-last.jsonl", topicID, "Delete last", projectRoot)
6001 ctrl := controllerWithContent(t, path)
6002 app := &App{
6003 tabs: map[string]*WorkspaceTab{
6004 "only": {
6005 ID: "only",
6006 Scope: "project",
6007 WorkspaceRoot: projectRoot,
6008 TopicID: topicID,
6009 TopicTitle: "Delete last",
6010 Ctrl: ctrl,
6011 Ready: true,
6012 disabledMCP: map[string]ServerView{},
6013 },
6014 },
6015 tabOrder: []string{"only"},
6016 activeTabID: "only",
6017 }
6018
6019 if err := app.deleteSession(path); err != nil {
6020 t.Fatalf("DeleteSession(last topic session): %v", err)
6021 }
6022
6023 if _, ok := app.tabs["only"]; ok {
6024 t.Fatalf("deleted topic session tab should be removed")
6025 }
6026 // The deleted topic owns no content, so nothing is re-activated and no
6027 // replacement blank session is created: the frontend lands on the draft.
6028 assertNoVisibleRuntime(t, app)
6029 trashPath := filepath.Join(dir, sessionTrashDir, "delete-last.jsonl", "delete-last.jsonl")
6030 if _, err := os.Stat(trashPath); err != nil {
6031 t.Fatalf("deleted session should be moved to trash: %v", err)
6032 }
6033 }
6034
6035 func TestLegacyDeleteSessionWithStuckJobUsesSingleGrace(t *testing.T) {
6036 isolateDesktopUserDirs(t)
6037
6038 dir := config.SessionDir()
6039 if err := os.MkdirAll(dir, 0o755); err != nil {
6040 t.Fatalf("mkdir session dir: %v", err)
6041 }
6042 path := filepath.Join(dir, "stuck-delete.jsonl")
6043 keepPath := filepath.Join(dir, "keep.jsonl")
6044 for _, p := range []string{path, keepPath} {
6045 if err := os.WriteFile(p, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6046 t.Fatalf("write session %s: %v", p, err)
6047 }
6048 }
6049
6050 grace := 500 * time.Millisecond
6051 teardownNotices := make(chan event.Event, 2)
6052 jm := jobs.NewManager(teardownNoticeSink(teardownNotices), jobs.WithTeardownGrace(grace))
6053 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
6054 keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"})
6055 releaseJob := startNonCooperativeSessionJob(t, jm, path)
6056 defer func() {
6057 releaseJob()
6058 ctrl.Close()
6059 keepCtrl.Close()
6060 }()
6061
6062 app := NewApp()
6063 app.setTestCtrl(ctrl, "")
6064 app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true}
6065 app.tabOrder = []string{"test", "keep"}
6066
6067 if err := app.deleteSession(filepath.Base(path)); err != nil {
6068 t.Fatalf("DeleteSession(stuck job): %v", err)
6069 }
6070 // The single timeout notice and cleanup marker prove that deletion used the
6071 // bounded teardown path. Host filesystem latency after that boundary is not
6072 // a Go correctness property and must not be sampled by this unit test.
6073 assertSingleTeardownTimeoutNotice(t, teardownNotices, grace)
6074 if !agent.IsCleanupPending(path) {
6075 t.Fatalf("stuck delete should mark cleanup pending")
6076 }
6077 if _, err := os.Stat(path); err != nil {
6078 t.Fatalf("stuck session file should remain until delayed cleanup: %v", err)
6079 }
6080 }
6081
6082 func TestLegacyDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {
6083 isolateDesktopUserDirs(t)
6084
6085 dir := config.SessionDir()
6086 if err := os.MkdirAll(dir, 0o755); err != nil {
6087 t.Fatalf("mkdir session dir: %v", err)
6088 }
6089 path := filepath.Join(dir, "active-conflict.jsonl")
6090 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6091 t.Fatalf("write session: %v", err)
6092 }
6093 if err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {
6094 t.Fatalf("create trash conflict: %v", err)
6095 }
6096
6097 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
6098 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"})
6099 app := NewApp()
6100 app.setTestCtrl(ctrl, "")
6101 defer ctrl.Close()
6102 ctrl.Submit("work")
6103 <-runner.started
6104
6105 err := app.deleteSession(filepath.Base(path))
6106 if err != nil {
6107 t.Fatalf("DeleteSession should succeed after cleaning empty trash dir: %v", err)
6108 }
6109 if _, ok := app.tabs["test"]; ok {
6110 t.Fatalf("deleted session runtime should be removed from tabs")
6111 }
6112 if _, err := os.Stat(path); !os.IsNotExist(err) {
6113 t.Fatalf("session file should be moved out of active history, stat err = %v", err)
6114 }
6115 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6116 if _, err := os.Stat(trashPath); err != nil {
6117 t.Fatalf("session should be moved to trash: %v", err)
6118 }
6119
6120 close(runner.release)
6121 waitNotRunning(t, ctrl)
6122 }
6123
6124 func TestLegacyDeleteSessionValidTrashRemovesEmptyLiveStub(t *testing.T) {
6125 isolateDesktopUserDirs(t)
6126
6127 dir := config.SessionDir()
6128 if err := os.MkdirAll(dir, 0o755); err != nil {
6129 t.Fatalf("mkdir session dir: %v", err)
6130 }
6131 path := filepath.Join(dir, "stale-live.jsonl")
6132 if err := os.WriteFile(path, nil, 0o644); err != nil {
6133 t.Fatalf("write live stub: %v", err)
6134 }
6135 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6136 if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
6137 t.Fatalf("create trash dir: %v", err)
6138 }
6139 if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
6140 t.Fatalf("write trash session: %v", err)
6141 }
6142
6143 activePath := filepath.Join(dir, "active.jsonl")
6144 if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
6145 t.Fatalf("write active session: %v", err)
6146 }
6147 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
6148 defer activeCtrl.Close()
6149 app := &App{
6150 tabs: map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
6151 activeTabID: "active",
6152 tabOrder: []string{"active"},
6153 }
6154
6155 if err := app.deleteSession(filepath.Base(path)); err != nil {
6156 t.Fatalf("DeleteSession should remove stale live stub: %v", err)
6157 }
6158 if _, err := os.Stat(path); !os.IsNotExist(err) {
6159 t.Fatalf("live stub should be removed, stat err = %v", err)
6160 }
6161 if _, err := os.Stat(trashPath); err != nil {
6162 t.Fatalf("existing trash should remain authoritative: %v", err)
6163 }
6164 }
6165
6166 func TestLegacyDeleteSessionValidTrashRemovesDuplicateLiveSession(t *testing.T) {
6167 isolateDesktopUserDirs(t)
6168
6169 dir := config.SessionDir()
6170 if err := os.MkdirAll(dir, 0o755); err != nil {
6171 t.Fatalf("mkdir session dir: %v", err)
6172 }
6173 path := filepath.Join(dir, "duplicate-recovery.jsonl")
6174 content := []byte(`{"role":"user","content":"same recovery"}` + "\n")
6175 if err := os.WriteFile(path, content, 0o644); err != nil {
6176 t.Fatalf("write live session: %v", err)
6177 }
6178 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6179 if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
6180 t.Fatalf("create trash dir: %v", err)
6181 }
6182 if err := os.WriteFile(trashPath, content, 0o644); err != nil {
6183 t.Fatalf("write trash session: %v", err)
6184 }
6185
6186 activePath := filepath.Join(dir, "active.jsonl")
6187 if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
6188 t.Fatalf("write active session: %v", err)
6189 }
6190 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
6191 defer activeCtrl.Close()
6192 app := &App{
6193 tabs: map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
6194 activeTabID: "active",
6195 tabOrder: []string{"active"},
6196 }
6197
6198 if err := app.deleteSession(filepath.Base(path)); err != nil {
6199 t.Fatalf("DeleteSession should remove duplicate live session: %v", err)
6200 }
6201 if _, err := os.Stat(path); !os.IsNotExist(err) {
6202 t.Fatalf("duplicate live session should be removed, stat err = %v", err)
6203 }
6204 if got, err := os.ReadFile(trashPath); err != nil || string(got) != string(content) {
6205 t.Fatalf("existing trash should remain authoritative, got %q err=%v", string(got), err)
6206 }
6207 }
6208
6209 func TestRestoreSessionRejectsOpenEmptyLiveStub(t *testing.T) {
6210 isolateDesktopUserDirs(t)
6211
6212 dir := config.SessionDir()
6213 if err := os.MkdirAll(dir, 0o755); err != nil {
6214 t.Fatalf("mkdir session dir: %v", err)
6215 }
6216 path := filepath.Join(dir, "restore-open.jsonl")
6217 if err := os.WriteFile(path, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
6218 t.Fatalf("write trash source: %v", err)
6219 }
6220 if err := deleteSessionFile(dir, path); err != nil {
6221 t.Fatalf("trash source: %v", err)
6222 }
6223 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6224 if err := os.WriteFile(path, nil, 0o644); err != nil {
6225 t.Fatalf("write live stub: %v", err)
6226 }
6227 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "open"})
6228 defer ctrl.Close()
6229 app := &App{
6230 tabs: map[string]*WorkspaceTab{"open": {ID: "open", Scope: "global", Ctrl: ctrl, Ready: true}},
6231 tabOrder: []string{"open"},
6232 activeTabID: "open",
6233 }
6234
6235 err := app.RestoreSession(trashPath)
6236 if err == nil || !strings.Contains(err.Error(), "session is open") {
6237 t.Fatalf("RestoreSession error = %v, want open-session rejection", err)
6238 }
6239 if info, statErr := os.Stat(path); statErr != nil || info.Size() != 0 {
6240 t.Fatalf("open live stub should remain empty, info=%v err=%v", info, statErr)
6241 }
6242 if _, err := os.Stat(trashPath); err != nil {
6243 t.Fatalf("trash session should remain after rejected restore: %v", err)
6244 }
6245 }
6246
6247 func TestLegacyDeleteSessionValidTrashRenamesDifferentLiveConflict(t *testing.T) {
6248 isolateDesktopUserDirs(t)
6249
6250 dir := config.SessionDir()
6251 if err := os.MkdirAll(dir, 0o755); err != nil {
6252 t.Fatalf("mkdir session dir: %v", err)
6253 }
6254 path := filepath.Join(dir, "real-live.jsonl")
6255 if err := os.WriteFile(path, []byte(`{"role":"user","content":"new work"}`+"\n"), 0o644); err != nil {
6256 t.Fatalf("write live session: %v", err)
6257 }
6258 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6259 if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
6260 t.Fatalf("create trash dir: %v", err)
6261 }
6262 if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
6263 t.Fatalf("write trash session: %v", err)
6264 }
6265
6266 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "active"})
6267 defer activeCtrl.Close()
6268 app := NewApp()
6269 app.setTestCtrl(activeCtrl, "")
6270
6271 if err := app.deleteSession(filepath.Base(path)); err != nil {
6272 t.Fatalf("DeleteSession should move different live session to a unique trash item: %v", err)
6273 }
6274 if _, err := os.Stat(path); !os.IsNotExist(err) {
6275 t.Fatalf("live session should be moved out of active history, stat err = %v", err)
6276 }
6277 if got, err := os.ReadFile(trashPath); err != nil || !strings.Contains(string(got), "trashed") {
6278 t.Fatalf("original trash session should remain, got %q err=%v", string(got), err)
6279 }
6280 trashed, err := listTrashedSessionFiles(dir)
6281 if err != nil {
6282 t.Fatalf("list trash: %v", err)
6283 }
6284 var renamedPath string
6285 for _, candidate := range trashed {
6286 if candidate != trashPath && filepath.Base(candidate) == filepath.Base(path) {
6287 renamedPath = candidate
6288 break
6289 }
6290 }
6291 if renamedPath == "" {
6292 t.Fatalf("renamed trash copy not found in %#v", trashed)
6293 }
6294 if filepath.Base(filepath.Dir(renamedPath)) == filepath.Base(path) {
6295 t.Fatalf("renamed trash copy reused fixed trash item dir: %s", renamedPath)
6296 }
6297 if got, err := os.ReadFile(renamedPath); err != nil || !strings.Contains(string(got), "new work") {
6298 t.Fatalf("renamed trash session = %q err=%v, want live content", string(got), err)
6299 }
6300 }
6301
6302 func TestLegacyDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {
6303 isolateDesktopUserDirs(t)
6304
6305 dir := config.SessionDir()
6306 if err := os.MkdirAll(dir, 0o755); err != nil {
6307 t.Fatalf("mkdir session dir: %v", err)
6308 }
6309 activePath := filepath.Join(dir, "active.jsonl")
6310 inactivePath := filepath.Join(dir, "inactive.jsonl")
6311 otherPath := filepath.Join(dir, "other.jsonl")
6312 for _, path := range []string{activePath, inactivePath, otherPath} {
6313 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6314 t.Fatalf("write session %s: %v", path, err)
6315 }
6316 }
6317
6318 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
6319 inactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: "inactive"})
6320 defer activeCtrl.Close()
6321 defer inactiveCtrl.Close()
6322
6323 app := &App{
6324 tabs: map[string]*WorkspaceTab{
6325 "active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
6326 "inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
6327 },
6328 tabOrder: []string{"active", "inactive"},
6329 activeTabID: "active",
6330 }
6331 installSessionCatalogForTest(t, app, dir, "global", "")
6332 if err := app.deleteSession(filepath.Base(inactivePath)); err != nil {
6333 t.Fatalf("DeleteSession(inactive open basename): %v", err)
6334 }
6335 if _, ok := app.tabs["inactive"]; ok {
6336 t.Fatalf("deleted inactive session runtime should be removed")
6337 }
6338 if _, err := os.Stat(inactivePath); !os.IsNotExist(err) {
6339 t.Fatalf("inactive open session should be moved out of active history, stat err = %v", err)
6340 }
6341 trashPath := filepath.Join(dir, sessionTrashDir, "inactive.jsonl", "inactive.jsonl")
6342 if _, err := os.Stat(trashPath); err != nil {
6343 t.Fatalf("inactive open session should be moved to trash: %v", err)
6344 }
6345
6346 sessions := app.ListSessions()
6347 current := map[string]bool{}
6348 open := map[string]bool{}
6349 for _, s := range sessions {
6350 current[filepath.Base(s.Path)] = s.Current
6351 open[filepath.Base(s.Path)] = s.Open
6352 }
6353 if !current[filepath.Base(activePath)] {
6354 t.Fatalf("ListSessions should mark active session current, got %#v", current)
6355 }
6356 if current[filepath.Base(otherPath)] {
6357 t.Fatalf("ListSessions marked unopened session current, got %#v", current)
6358 }
6359 if !open[filepath.Base(activePath)] {
6360 t.Fatalf("ListSessions should mark active and inactive open sessions open, got %#v", open)
6361 }
6362 if open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {
6363 t.Fatalf("ListSessions marked unopened session open, got %#v", open)
6364 }
6365 }
6366
6367 func TestTrashTopicRejectsBackgroundJob(t *testing.T) {
6368 isolateDesktopUserDirs(t)
6369
6370 projectRoot := t.TempDir()
6371 topicID := "topic_stuck_trash"
6372 if err := addProject(projectRoot, ""); err != nil {
6373 t.Fatalf("add project: %v", err)
6374 }
6375 if err := setTopicTitle(projectRoot, topicID, "Stuck trash"); err != nil {
6376 t.Fatalf("set topic title: %v", err)
6377 }
6378 dir := config.SessionDir()
6379 if err := os.MkdirAll(dir, 0o755); err != nil {
6380 t.Fatalf("mkdir sessions: %v", err)
6381 }
6382 sessionPath := writeTopicSession(t, dir, "stuck-topic.jsonl", topicID, "Stuck trash", projectRoot)
6383
6384 jm := jobs.NewManager(event.Discard)
6385 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", Jobs: jm, WorkspaceRoot: projectRoot})
6386 releaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)
6387 defer func() {
6388 releaseJob()
6389 ctrl.Close()
6390 }()
6391
6392 app := &App{
6393 tabs: map[string]*WorkspaceTab{
6394 "stuck": {
6395 ID: "stuck",
6396 Scope: "project",
6397 WorkspaceRoot: projectRoot,
6398 TopicID: topicID,
6399 TopicTitle: "Stuck trash",
6400 Ctrl: ctrl,
6401 Ready: true,
6402 disabledMCP: map[string]ServerView{},
6403 },
6404 "keep": {
6405 ID: "keep",
6406 Scope: "project",
6407 WorkspaceRoot: projectRoot,
6408 TopicID: "topic_keep",
6409 TopicTitle: "Keep",
6410 Ready: true,
6411 disabledMCP: map[string]ServerView{},
6412 },
6413 },
6414 tabOrder: []string{"stuck", "keep"},
6415 activeTabID: "stuck",
6416 }
6417
6418 if err := app.TrashTopic(topicID); !errors.Is(err, errTopicHasActiveWork) {
6419 t.Fatalf("TrashTopic(background job) error = %v, want %v", err, errTopicHasActiveWork)
6420 }
6421 if _, ok := app.tabs["stuck"]; !ok {
6422 t.Fatal("rejected archive should keep the background-job topic tab")
6423 }
6424 if agent.IsCleanupPending(sessionPath) {
6425 t.Fatal("rejected archive should not mark session cleanup pending")
6426 }
6427 if _, err := os.Stat(sessionPath); err != nil {
6428 t.Fatalf("rejected archive should preserve the live session: %v", err)
6429 }
6430 trashPath := filepath.Join(dir, sessionTrashDir, "stuck-topic.jsonl", "stuck-topic.jsonl")
6431 if _, err := os.Stat(trashPath); !os.IsNotExist(err) {
6432 t.Fatalf("rejected archive created a trash entry, stat err = %v", err)
6433 }
6434 if got := loadTopicTitle(projectRoot, topicID); got != "Stuck trash" {
6435 t.Fatalf("rejected archive topic title = %q, want Stuck trash", got)
6436 }
6437 }
6438
6439 func teardownNoticeSink(out chan<- event.Event) event.Sink {
6440 return event.FuncSink(func(e event.Event) {
6441 if e.Kind == event.Notice && strings.Contains(e.Detail, "background job teardown timed out") {
6442 out <- e
6443 }
6444 })
6445 }
6446
6447 func assertSingleTeardownTimeoutNotice(t *testing.T, notices <-chan event.Event, grace time.Duration) {
6448 t.Helper()
6449 var notice event.Event
6450 select {
6451 case notice = <-notices:
6452 default:
6453 t.Fatal("missing background-job teardown timeout notice")
6454 }
6455 var waited time.Duration
6456 for field := range strings.FieldsSeq(notice.Detail) {
6457 if !strings.HasPrefix(field, "waited=") {
6458 continue
6459 }
6460 parsed, err := time.ParseDuration(strings.TrimSuffix(strings.TrimPrefix(field, "waited="), ";"))
6461 if err != nil {
6462 t.Fatalf("parse teardown waited field %q: %v", field, err)
6463 }
6464 waited = parsed
6465 break
6466 }
6467 if waited < grace-10*time.Millisecond || waited > grace+250*time.Millisecond {
6468 t.Fatalf("teardown notice waited %s, want one %s grace; detail: %s", waited, grace, notice.Detail)
6469 }
6470 select {
6471 case extra := <-notices:
6472 t.Fatalf("duplicate teardown timeout notice: %+v", extra)
6473 default:
6474 }
6475 }
6476
6477 func TestWaitDestroyHandlesWaitsConcurrently(t *testing.T) {
6478 started := make(chan int, 2)
6479 release := make(chan struct{})
6480 var releaseOnce sync.Once
6481 releaseAll := func() { releaseOnce.Do(func() { close(release) }) }
6482 defer releaseAll()
6483
6484 handle := func(id int) control.SessionDestroyHandle {
6485 return control.SessionDestroyHandle{Wait: func() jobs.TeardownResult {
6486 started <- id
6487 <-release
6488 return jobs.TeardownResult{TimedOut: []jobs.TeardownJob{{ID: strconv.Itoa(id)}}}
6489 }}
6490 }
6491 done := make(chan bool, 1)
6492 go func() { done <- waitDestroyHandles([]control.SessionDestroyHandle{handle(1), handle(2)}) }()
6493
6494 seen := map[int]bool{}
6495 for len(seen) < 2 {
6496 select {
6497 case id := <-started:
6498 seen[id] = true
6499 case <-time.After(2 * time.Second):
6500 t.Fatalf("destroy waits did not start concurrently; started=%v", seen)
6501 }
6502 }
6503 releaseAll()
6504 select {
6505 case timedOut := <-done:
6506 if !timedOut {
6507 t.Fatal("waitDestroyHandles lost timed-out result")
6508 }
6509 case <-time.After(2 * time.Second):
6510 t.Fatal("waitDestroyHandles did not return after all waits completed")
6511 }
6512 }
6513
6514 func TestRestoreSessionRejectsDestroyingSession(t *testing.T) {
6515 isolateDesktopUserDirs(t)
6516
6517 dir := config.SessionDir()
6518 if err := os.MkdirAll(dir, 0o755); err != nil {
6519 t.Fatalf("mkdir session dir: %v", err)
6520 }
6521 sessionPath := filepath.Join(dir, "trash-me.jsonl")
6522 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6523 t.Fatalf("write session: %v", err)
6524 }
6525 if err := deleteSessionFile(dir, sessionPath); err != nil {
6526 t.Fatalf("deleteSessionFile: %v", err)
6527 }
6528 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
6529
6530 jm := jobs.NewManager(event.Discard)
6531 defer jm.Close()
6532 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "active", Jobs: jm})
6533 defer ctrl.Close()
6534 destroy := ctrl.BeginDestroySession(sessionPath)
6535 defer destroy.Finish()
6536
6537 app := NewApp()
6538 app.setTestCtrl(ctrl, "")
6539 if err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), "cleanup is still in progress") {
6540 t.Fatalf("RestoreSession while destroying error = %v, want cleanup-in-progress", err)
6541 }
6542 if _, err := os.Stat(trashPath); err != nil {
6543 t.Fatalf("trashed session should remain after rejected restore: %v", err)
6544 }
6545
6546 destroy.Finish()
6547 if err := app.RestoreSession(trashPath); err != nil {
6548 t.Fatalf("RestoreSession after finish: %v", err)
6549 }
6550 assertLegacyLifecycle(t, app, trashPath, "active")
6551 }
6552
6553 func TestLegacyDeleteSessionClearsAutoBotSessionMapping(t *testing.T) {
6554 isolateDesktopUserDirs(t)
6555
6556 dir := config.SessionDir()
6557 if err := os.MkdirAll(dir, 0o755); err != nil {
6558 t.Fatalf("mkdir session dir: %v", err)
6559 }
6560 path := filepath.Join(dir, "bot-channel.jsonl")
6561 if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
6562 t.Fatalf("write session: %v", err)
6563 }
6564 other := filepath.Join(dir, "other-channel.jsonl")
6565 cfg := config.Default()
6566 cfg.Bot.Connections = []config.BotConnectionConfig{{
6567 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
6568 SessionMappings: []config.BotConnectionSessionMapping{
6569 {RemoteID: "remove-auto", SessionID: "path:" + path, SessionSource: "auto"},
6570 {RemoteID: "keep-explicit", SessionID: "path:" + path},
6571 {RemoteID: "keep-other-auto", SessionID: "path:" + other, SessionSource: "auto"},
6572 },
6573 }}
6574 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
6575 t.Fatalf("save config: %v", err)
6576 }
6577
6578 app := NewApp()
6579 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
6580 app.setTestCtrl(ctrl, "")
6581 defer app.activeCtrl().Close()
6582
6583 if err := app.deleteSession(path); err != nil {
6584 t.Fatalf("DeleteSession: %v", err)
6585 }
6586
6587 got := config.LoadForEdit(config.UserConfigPath())
6588 mappings := got.Bot.Connections[0].SessionMappings
6589 if len(mappings) != 2 {
6590 t.Fatalf("session mappings = %+v, want explicit and other auto mappings preserved", mappings)
6591 }
6592 for _, mapping := range mappings {
6593 if mapping.RemoteID == "remove-auto" {
6594 t.Fatalf("deleted session auto mapping was preserved: %+v", mappings)
6595 }
6596 }
6597 }
6598
6599 func TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {
6600 isolateDesktopUserDirs(t)
6601
6602 dir := config.SessionDir()
6603 if err := os.MkdirAll(dir, 0o755); err != nil {
6604 t.Fatalf("mkdir session dir: %v", err)
6605 }
6606 path := filepath.Join(dir, "bot-channel.jsonl")
6607 if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
6608 t.Fatalf("write session: %v", err)
6609 }
6610
6611 app := NewApp()
6612 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
6613 app.setTestCtrl(ctrl, "")
6614 defer app.activeCtrl().Close()
6615
6616 if _, err := app.OpenChannelSessionForTab("test", path); err != nil {
6617 t.Fatalf("OpenChannelSessionForTab: %v", err)
6618 }
6619 if meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {
6620 t.Fatalf("channel tab should be read-only: %+v", meta)
6621 }
6622 before, err := os.ReadFile(path)
6623 if err != nil {
6624 t.Fatalf("read before: %v", err)
6625 }
6626 app.SubmitToTab("test", "must not append")
6627 app.RunShellForTab("test", "echo must-not-run")
6628 after, err := os.ReadFile(path)
6629 if err != nil {
6630 t.Fatalf("read after: %v", err)
6631 }
6632 if string(after) != string(before) {
6633 t.Fatalf("read-only channel transcript changed:\nbefore=%s\nafter=%s", before, after)
6634 }
6635
6636 f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
6637 if err != nil {
6638 t.Fatalf("open append: %v", err)
6639 }
6640 if _, err := f.WriteString(`{"role":"user","content":"external follow-up"}` + "\n"); err != nil {
6641 f.Close()
6642 t.Fatalf("append external message: %v", err)
6643 }
6644 if err := f.Close(); err != nil {
6645 t.Fatalf("close append: %v", err)
6646 }
6647 app.snapshotAllTabs()
6648 afterSnapshot, err := os.ReadFile(path)
6649 if err != nil {
6650 t.Fatalf("read after snapshot: %v", err)
6651 }
6652 if !strings.Contains(string(afterSnapshot), "external follow-up") {
6653 t.Fatalf("read-only channel snapshot overwrote external append:\n%s", afterSnapshot)
6654 }
6655 }
6656
6657 func TestUserTriggeredCommandsReturnErrorsWhenUnavailable(t *testing.T) {
6658 tests := []struct {
6659 name string
6660 app *App
6661 call func(*App) error
6662 want string
6663 }{
6664 {
6665 name: "submit read-only",
6666 app: &App{
6667 tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global", ReadOnly: true}},
6668 activeTabID: "test",
6669 },
6670 call: func(app *App) error { return app.SubmitToTab("test", "hello") },
6671 want: "read-only",
6672 },
6673 {
6674 name: "submit workspace unavailable",
6675 app: &App{
6676 tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global", StartupErr: "boom"}},
6677 activeTabID: "test",
6678 },
6679 call: func(app *App) error { return app.SubmitToTab("test", "hello") },
6680 want: "workspace failed to start: boom",
6681 },
6682 {
6683 name: "run shell workspace unavailable",
6684 app: &App{
6685 tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global"}},
6686 activeTabID: "test",
6687 },
6688 call: func(app *App) error { return app.RunShellForTab("test", "echo hi") },
6689 want: "workspace is still starting",
6690 },
6691 {
6692 name: "steer workspace unavailable",
6693 app: &App{
6694 tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global"}},
6695 activeTabID: "test",
6696 },
6697 call: func(app *App) error { return app.SteerForTab("test", "please continue") },
6698 want: "workspace is still starting",
6699 },
6700 }
6701
6702 for _, tt := range tests {
6703 t.Run(tt.name, func(t *testing.T) {
6704 err := tt.call(tt.app)
6705 if err == nil {
6706 t.Fatalf("expected error containing %q", tt.want)
6707 }
6708 if !strings.Contains(err.Error(), tt.want) {
6709 t.Fatalf("error = %q, want to contain %q", err, tt.want)
6710 }
6711 })
6712 }
6713 }
6714
6715 func TestSubmitEntryPointsRejectEmptyProviderInput(t *testing.T) {
6716 app := NewApp()
6717 for _, tt := range []struct {
6718 name string
6719 call func() error
6720 }{
6721 {name: "plain", call: func() error { return app.SubmitToTab("missing", " \n\t ") }},
6722 {name: "display", call: func() error { return app.SubmitDisplayToTab("missing", "visible prompt", " ") }},
6723 {name: "delivery recovery", call: func() error {
6724 return app.SubmitDeliveryRecoveryToTab("missing", "visible prompt", "")
6725 }},
6726 {name: "invocations", call: func() error {
6727 return app.SubmitInvocationsToTab("missing", "/skill visible", "", nil)
6728 }},
6729 {name: "edited display", call: func() error {
6730 return app.SubmitEditedDisplayToTab("missing", "visible prompt", "\n", "original prompt")
6731 }},
6732 {name: "initial goal", call: func() error {
6733 _, err := app.SubmitInitialGoalToTab("missing", "goal", "visible prompt", "", nil, "normal", "auto")
6734 return err
6735 }},
6736 } {
6737 t.Run(tt.name, func(t *testing.T) {
6738 if err := tt.call(); !errors.Is(err, errEmptyTurnInput) {
6739 t.Fatalf("error = %v, want errEmptyTurnInput", err)
6740 }
6741 })
6742 }
6743 }
6744
6745 func TestInvocationEntryPointsAllowEmptyExplicitTaskForSkillOnlyTurn(t *testing.T) {
6746 invocations := []InvocationRequest{{Name: "skill", Kind: "skill"}}
6747 if err := validateInvocationTurnInput("", invocations); err != nil {
6748 t.Fatalf("skill-only invocation input rejected: %v", err)
6749 }
6750 if err := validateInvocationTurnInput("", nil); !errors.Is(err, errEmptyTurnInput) {
6751 t.Fatalf("empty input without invocations = %v, want errEmptyTurnInput", err)
6752 }
6753 }
6754
6755 func TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {
6756 isolateDesktopUserDirs(t)
6757
6758 dir := config.SessionDir()
6759 if err := os.MkdirAll(dir, 0o755); err != nil {
6760 t.Fatalf("mkdir session dir: %v", err)
6761 }
6762 path := filepath.Join(dir, "bot-channel.jsonl")
6763 if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
6764 t.Fatalf("write session: %v", err)
6765 }
6766
6767 app := NewApp()
6768 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
6769 app.setTestCtrl(ctrl, "")
6770 defer ctrl.Close()
6771
6772 if _, err := app.OpenChannelSessionForTab("test", path); err != nil {
6773 t.Fatalf("OpenChannelSessionForTab: %v", err)
6774 }
6775 app.mu.Lock()
6776 app.tabs["survivor"] = &WorkspaceTab{ID: "survivor", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
6777 app.tabOrder = []string{"test", "survivor"}
6778 app.activeTabID = "test"
6779 app.mu.Unlock()
6780
6781 f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
6782 if err != nil {
6783 t.Fatalf("open append: %v", err)
6784 }
6785 if _, err := f.WriteString(`{"role":"user","content":"external close follow-up"}` + "\n"); err != nil {
6786 f.Close()
6787 t.Fatalf("append external message: %v", err)
6788 }
6789 if err := f.Close(); err != nil {
6790 t.Fatalf("close append: %v", err)
6791 }
6792
6793 if err := app.CloseTab("test"); err != nil {
6794 t.Fatalf("CloseTab: %v", err)
6795 }
6796 afterClose, err := os.ReadFile(path)
6797 if err != nil {
6798 t.Fatalf("read after close: %v", err)
6799 }
6800 if !strings.Contains(string(afterClose), "external close follow-up") {
6801 t.Fatalf("closing read-only channel tab overwrote external append:\n%s", afterClose)
6802 }
6803 }
6804
6805 func TestResumeSessionRejectsCleanupPending(t *testing.T) {
6806 isolateDesktopUserDirs(t)
6807
6808 dir := config.SessionDir()
6809 if err := os.MkdirAll(dir, 0o755); err != nil {
6810 t.Fatalf("mkdir session dir: %v", err)
6811 }
6812 activePath := filepath.Join(dir, "active.jsonl")
6813 pendingPath := filepath.Join(dir, "pending.jsonl")
6814 for _, path := range []string{activePath, pendingPath} {
6815 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6816 t.Fatalf("write %s: %v", path, err)
6817 }
6818 }
6819 if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil {
6820 t.Fatal(err)
6821 }
6822
6823 app := NewApp()
6824 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "test"})
6825 app.setTestCtrl(ctrl, "")
6826 defer app.activeCtrl().Close()
6827
6828 if _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), "pending cleanup") {
6829 t.Fatalf("ResumeSession cleanup-pending error = %v, want pending cleanup", err)
6830 }
6831 if got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {
6832 t.Fatalf("active session path after rejected resume = %q, want %q", got, activePath)
6833 }
6834 if _, err := app.OpenChannelSessionForTab("test", pendingPath); err == nil || !strings.Contains(err.Error(), "pending cleanup") {
6835 t.Fatalf("OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup", err)
6836 }
6837 if meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {
6838 t.Fatalf("rejected channel open should not make tab read-only: %+v", meta)
6839 }
6840 }
6841
6842 func TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {
6843 dirA := t.TempDir()
6844 dirB := t.TempDir()
6845 activePath := filepath.Join(dirA, "active.jsonl")
6846 outsidePath := filepath.Join(dirB, "outside.jsonl")
6847 for _, path := range []string{activePath, outsidePath} {
6848 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6849 t.Fatalf("write %s: %v", path, err)
6850 }
6851 }
6852
6853 app := NewApp()
6854 app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: "test"}), "")
6855 defer app.activeCtrl().Close()
6856
6857 if _, err := app.ResumeSession(outsidePath); err == nil {
6858 t.Fatal("ResumeSession should reject a transcript outside the active session dir")
6859 }
6860 if _, err := app.PreviewSession(outsidePath); err == nil {
6861 t.Fatal("PreviewSession should reject a transcript outside the active session dir")
6862 }
6863 }
6864
6865 func BenchmarkDesktopListSessionsScoped(b *testing.B) {
6866 dirA := filepath.Join(b.TempDir(), "workspace-a-sessions")
6867 dirB := filepath.Join(b.TempDir(), "workspace-b-sessions")
6868 for _, dir := range []string{dirA, dirB} {
6869 if err := os.MkdirAll(dir, 0o755); err != nil {
6870 b.Fatalf("mkdir %s: %v", dir, err)
6871 }
6872 for i := range 120 {
6873 path := filepath.Join(dir, fmt.Sprintf("session-%03d.jsonl", i))
6874 body := fmt.Sprintf(`{"role":"user","content":"session %03d"}`+"\n", i)
6875 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
6876 b.Fatalf("write session: %v", err)
6877 }
6878 }
6879 }
6880
6881 app := NewApp()
6882 app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, "session-000.jsonl"), Label: "test"}), "")
6883 defer app.activeCtrl().Close()
6884
6885 b.ReportAllocs()
6886 b.ResetTimer()
6887 for range b.N {
6888 sessions := app.ListSessions()
6889 if len(sessions) != 120 {
6890 b.Fatalf("ListSessions len = %d, want 120", len(sessions))
6891 }
6892 }
6893 }
6894
6895 type appendingDesktopRunner struct {
6896 session *agent.Session
6897 started chan string
6898 }
6899
6900 func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
6901 r.started <- input
6902 r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
6903 r.session.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
6904 return nil
6905 }
6906
6907 func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
6908 isolateDesktopUserDirs(t)
6909 dir := robustTempDir(t)
6910 t.Chdir(dir)
6911 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
6912 [[plugins]]
6913 name = "playwright"
6914 command = "npx"
6915 args = ["-y", "@playwright/mcp"]
6916 `), 0o644); err != nil {
6917 t.Fatal(err)
6918 }
6919
6920 app := NewApp()
6921 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
6922 defer func() {
6923 if c := app.activeCtrl(); c != nil {
6924 c.Close()
6925 }
6926 }()
6927
6928 view := app.Capabilities()
6929 for _, s := range view.Servers {
6930 if s.Name == "playwright" {
6931 if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
6932 t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
6933 }
6934 return
6935 }
6936 }
6937 t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
6938 }
6939
6940 func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
6941 isolateDesktopUserDirs(t)
6942 reasonixHome := config.ReasonixHomeDir()
6943 root := filepath.Join(reasonixHome, "plugins", "superpowers")
6944 if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
6945 t.Fatal(err)
6946 }
6947 if err := os.MkdirAll(filepath.Join(root, "skills", "plan"), 0o755); err != nil {
6948 t.Fatal(err)
6949 }
6950 if err := os.WriteFile(filepath.Join(root, "skills", "plan", "SKILL.md"), []byte("---\ndescription: Plan work\n---\nbody"), 0o644); err != nil {
6951 t.Fatal(err)
6952 }
6953 if err := os.MkdirAll(filepath.Join(root, ".codex-plugin"), 0o755); err != nil {
6954 t.Fatal(err)
6955 }
6956 if err := os.WriteFile(filepath.Join(root, ".codex-plugin", "plugin.json"), []byte(`{
6957 "name": "superpowers",
6958 "version": "6.1.0",
6959 "description": "Planning workflows",
6960 "skills": "./skills/"
6961 }`), 0o644); err != nil {
6962 t.Fatal(err)
6963 }
6964 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
6965 Name: "superpowers",
6966 Root: "plugins/superpowers",
6967 Version: "6.1.0",
6968 Description: "Planning workflows",
6969 ManifestKind: "codex",
6970 Enabled: true,
6971 }); err != nil {
6972 t.Fatal(err)
6973 }
6974
6975 app := NewApp()
6976 plugins := app.Capabilities().Plugins
6977 if len(plugins) != 1 || plugins[0].Name != "superpowers" || plugins[0].Skills != 1 {
6978 t.Fatalf("Capabilities().Plugins = %+v", plugins)
6979 }
6980 if len(plugins[0].SkillDetails) != 1 || plugins[0].SkillDetails[0].Invocation != "/superpowers:plan" {
6981 t.Fatalf("Capabilities().Plugins skill details = %+v", plugins[0].SkillDetails)
6982 }
6983 }
6984
6985 func TestDesktopSharedHostProjectMCPConnectsWithoutLaunchApproval(t *testing.T) {
6986 if testing.Short() {
6987 t.Skip("skipping background MCP boot integration test in short mode")
6988 }
6989
6990 isolateDesktopUserDirs(t)
6991 dir := robustTempDir(t)
6992 t.Chdir(dir)
6993
6994 srv := desktopMCPHTTPServer(t)
6995 defer srv.Close()
6996 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
6997 [[plugins]]
6998 name = "h"
6999 type = "http"
7000 url = %q
7001 `, srv.URL), 0o644); err != nil {
7002 t.Fatal(err)
7003 }
7004
7005 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
7006 defer cancel()
7007 sharedHost := plugin.NewHost()
7008 defer sharedHost.Close()
7009 ctrl, err := boot.Build(ctx, boot.Options{
7010 WorkspaceRoot: dir,
7011 SessionDir: filepath.Join(dir, "sessions"),
7012 SharedHost: sharedHost,
7013 Stderr: io.Discard,
7014 })
7015 if err != nil {
7016 t.Fatalf("boot.Build: %v", err)
7017 }
7018 defer ctrl.Close()
7019
7020 deadline := time.Now().Add(3 * time.Second)
7021 for !sharedHost.HasClient("h") && time.Now().Before(deadline) {
7022 time.Sleep(25 * time.Millisecond)
7023 }
7024 if !sharedHost.HasClient("h") {
7025 t.Fatalf("project MCP did not connect automatically; failures=%+v", sharedHost.Failures())
7026 }
7027 for _, failure := range sharedHost.Failures() {
7028 if failure.Name == "h" && failure.RequiresLaunchApproval {
7029 t.Fatalf("project MCP unexpectedly requested launch approval: %+v", failure)
7030 }
7031 }
7032
7033 app := NewApp()
7034 app.tabs = map[string]*WorkspaceTab{
7035 "test": {
7036 ID: "test",
7037 Scope: "global",
7038 WorkspaceRoot: dir,
7039 Ready: true,
7040 Ctrl: ctrl,
7041 SharedHostKey: dir,
7042 disabledMCP: map[string]ServerView{},
7043 },
7044 }
7045 app.activeTabID = "test"
7046
7047 view := app.MCPServers()
7048 if len(view) != 1 || view[0].Name != "h" || view[0].Status != "connected" || view[0].RuntimeState != "ready" || view[0].RequiresLaunchApproval {
7049 t.Fatalf("MCPServers() = %+v, want trusted connected project h", view)
7050 }
7051 }
7052
7053 func TestProjectMCPViewIsTrustedAndKeepsProjectSource(t *testing.T) {
7054 entry := config.PluginEntry{Name: "project", Source: config.MCPSourceProjectConfig}
7055 connected := withPluginConfig(ServerView{Name: entry.Name, Status: "connected"}, entry)
7056 if connected.RequiresLaunchApproval {
7057 t.Fatalf("connected project MCP still requires launch approval: %+v", connected)
7058 }
7059 blocked := withPluginConfig(ServerView{
7060 Name: entry.Name, Status: "failed", RequiresLaunchApproval: true,
7061 }, entry)
7062 if blocked.RequiresLaunchApproval {
7063 t.Fatalf("project MCP exposed obsolete launch approval action: %+v", blocked)
7064 }
7065 if blocked.Source != "project" || blocked.ConfigSource != "reasonix.toml" {
7066 t.Fatalf("blocked project MCP source = %q/%q, want project/reasonix.toml", blocked.Source, blocked.ConfigSource)
7067 }
7068
7069 user := withPluginConfig(ServerView{Name: "user", Status: "connected"},
7070 config.PluginEntry{Name: "user", Source: config.MCPSourceUserConfig})
7071 if user.RequiresLaunchApproval {
7072 t.Fatalf("user-config MCP must not be launch-gate governed: %+v", user)
7073 }
7074 if user.Source != "user" || user.ConfigSource != "config.toml" {
7075 t.Fatalf("user MCP source = %q/%q, want user/config.toml", user.Source, user.ConfigSource)
7076 }
7077 }
7078
7079 func TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {
7080 isolateDesktopUserDirs(t)
7081 dir := robustTempDir(t)
7082 t.Chdir(dir)
7083 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
7084 [[plugins]]
7085 name = "playwright"
7086 command = "npx"
7087 args = ["-y", "@playwright/mcp"]
7088 `), 0o644); err != nil {
7089 t.Fatal(err)
7090 }
7091
7092 app := NewApp()
7093 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
7094 defer app.activeCtrl().Close()
7095
7096 if got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {
7097 t.Fatalf("MCPServers() = %+v, want Capabilities().Servers %+v", got, want)
7098 }
7099 }
7100
7101 func TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {
7102 isolateDesktopUserDirs(t)
7103 dir := robustTempDir(t)
7104 t.Chdir(dir)
7105 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
7106 [[plugins]]
7107 name = "time"
7108 command = "custom-time"
7109 args = ["serve"]
7110 tier = "lazy"
7111 `), 0o644); err != nil {
7112 t.Fatal(err)
7113 }
7114
7115 app := NewApp()
7116 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
7117 defer app.activeCtrl().Close()
7118
7119 view := app.Capabilities()
7120 found := false
7121 for _, s := range view.Servers {
7122 if s.Name != "time" {
7123 continue
7124 }
7125 found = true
7126 if s.BuiltIn || !s.Configured || s.Command != "custom-time" || !reflect.DeepEqual(s.Args, []string{"serve"}) {
7127 t.Fatalf("configured time view = %+v, want ordinary user MCP config", s)
7128 }
7129 }
7130 if !found {
7131 t.Fatalf("configured time server missing from Capabilities: %+v", view.Servers)
7132 }
7133
7134 if err := app.SetMCPServerEnabled("time", false); err != nil {
7135 t.Fatalf("SetMCPServerEnabled(time,false): %v", err)
7136 }
7137 view = app.Capabilities()
7138 for _, s := range view.Servers {
7139 if s.Name == "time" {
7140 if s.Status != "disabled" || s.BuiltIn || s.Command != "custom-time" {
7141 t.Fatalf("disabled configured time view = %+v, want disabled external config", s)
7142 }
7143 return
7144 }
7145 }
7146 t.Fatalf("time missing after disable: %+v", view.Servers)
7147 }
7148
7149 func TestSetMCPServerEnabledRestoresOnDemandWithoutConnecting(t *testing.T) {
7150 isolateDesktopUserDirs(t)
7151 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
7152 dir := robustTempDir(t)
7153 t.Chdir(dir)
7154 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
7155 [[plugins]]
7156 name = "offline"
7157 type = "http"
7158 url = "http://127.0.0.1:1/mcp"
7159 `), 0o644); err != nil {
7160 t.Fatal(err)
7161 }
7162
7163 host := plugin.NewHost()
7164 defer host.Close()
7165 reg := tool.NewRegistry()
7166 ctrl := control.New(control.Options{Host: host, Registry: reg, PluginCtx: context.Background(), WorkspaceRoot: dir})
7167 app := NewApp()
7168 app.setTestCtrl(ctrl, "")
7169 app.tabs["test"].WorkspaceRoot = dir
7170
7171 if err := app.SetMCPServerEnabled("offline", false); err != nil {
7172 t.Fatalf("SetMCPServerEnabled(false): %v", err)
7173 }
7174 if err := app.SetMCPServerEnabled("offline", true); err != nil {
7175 t.Fatalf("SetMCPServerEnabled(true) forced an unavailable connection: %v", err)
7176 }
7177 if host.HasClient("offline") {
7178 t.Fatal("durable enable started the disconnected MCP server")
7179 }
7180 if _, ok := reg.Get("mcp__offline__connect"); !ok {
7181 t.Fatalf("on-demand connect stub missing after enable; names=%v", reg.Names())
7182 }
7183 }
7184
7185 func TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {
7186 isolateDesktopUserDirs(t)
7187 dir := robustTempDir(t)
7188 t.Chdir(dir)
7189
7190 srv := desktopMCPHTTPServer(t)
7191 defer srv.Close()
7192 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
7193 [[plugins]]
7194 name = "h"
7195 type = "http"
7196 url = %q
7197 `, srv.URL), 0o644); err != nil {
7198 t.Fatal(err)
7199 }
7200
7201 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
7202 defer cancel()
7203 sharedHost := plugin.NewHost()
7204 defer sharedHost.Close()
7205 tools, err := sharedHost.Add(ctx, plugin.Spec{Name: "h", Type: "http", URL: srv.URL})
7206 if err != nil {
7207 t.Fatalf("sharedHost.Add: %v", err)
7208 }
7209
7210 activeRegistry := tool.NewRegistry()
7211 siblingRegistry := tool.NewRegistry()
7212 for _, mt := range tools {
7213 activeRegistry.Add(mt)
7214 siblingRegistry.Add(mt)
7215 }
7216 activeCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})
7217 siblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})
7218 app := NewApp()
7219 app.tabs = map[string]*WorkspaceTab{
7220 "active": {
7221 ID: "active",
7222 Scope: "global",
7223 WorkspaceRoot: dir,
7224 Ready: true,
7225 Ctrl: activeCtrl,
7226 SharedHostKey: dir,
7227 disabledMCP: map[string]ServerView{},
7228 },
7229 "sibling": {
7230 ID: "sibling",
7231 Scope: "global",
7232 WorkspaceRoot: dir,
7233 Ready: true,
7234 Ctrl: siblingCtrl,
7235 SharedHostKey: dir,
7236 disabledMCP: map[string]ServerView{},
7237 },
7238 }
7239 app.activeTabID = "active"
7240
7241 if err := app.SetMCPServerEnabled("h", false); err != nil {
7242 t.Fatalf("SetMCPServerEnabled(h,false): %v", err)
7243 }
7244 if _, found := activeRegistry.Get("mcp__h__greet"); found {
7245 t.Fatal("active tab still has h tools after disabling the shared server")
7246 }
7247 if _, found := siblingRegistry.Get("mcp__h__greet"); !found {
7248 t.Fatal("sibling tab lost h tools when active tab disabled the shared server")
7249 }
7250 if !sharedHost.HasClient("h") {
7251 t.Fatal("shared host client was removed by a per-tab disable")
7252 }
7253 view := app.Capabilities()
7254 if len(view.Servers) != 1 || view.Servers[0].Name != "h" || view.Servers[0].Status != "disabled" {
7255 t.Fatalf("Capabilities after disable = %+v, want h disabled for the active tab", view.Servers)
7256 }
7257
7258 if err := app.SetMCPServerEnabled("h", true); err != nil {
7259 t.Fatalf("SetMCPServerEnabled(h,true): %v", err)
7260 }
7261 if _, found := activeRegistry.Get("mcp__h__greet"); !found {
7262 t.Fatal("active tab did not re-register h tools from the existing shared client")
7263 }
7264 view = app.Capabilities()
7265 if len(view.Servers) != 1 || view.Servers[0].Name != "h" || view.Servers[0].Status != "connected" {
7266 t.Fatalf("Capabilities after re-enable = %+v, want h connected for the active tab", view.Servers)
7267 }
7268 }
7269
7270 func TestAuthorizeAndConnectMCPServerStartsProjectOnlyOnce(t *testing.T) {
7271 gateAddr, attempts := newDesktopMCPStartGate(t, func(_ int, conn net.Conn) {
7272 _, _ = conn.Write([]byte{1})
7273 })
7274 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
7275 waitForDesktopMCPStartAttempt(t, attempts, 1)
7276 oldSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7277 if !found {
7278 t.Fatal("sibling registry missing initial h tool")
7279 }
7280
7281 if err := fixture.app.AuthorizeAndConnectMCPServer("h"); err != nil {
7282 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
7283 }
7284 waitForDesktopMCPStartAttempt(t, attempts, 2)
7285 select {
7286 case attempt := <-attempts:
7287 t.Fatalf("project authorization started a temporary connection process (unexpected attempt %d)", attempt)
7288 case <-time.After(250 * time.Millisecond):
7289 }
7290 if !fixture.sharedHost.HasClient("h") {
7291 t.Fatal("project authorization did not leave h connected")
7292 }
7293 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7294 t.Fatal("active registry was not refreshed after project authorization")
7295 }
7296 newSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7297 if !found || newSiblingTool == oldSiblingTool {
7298 t.Fatal("sibling registry did not receive the single new project connection")
7299 }
7300 if _, found := fixture.disabledRegistry.Get("mcp__h__greet"); found {
7301 t.Fatal("project authorization re-enabled h in a disabled sibling tab")
7302 }
7303 }
7304
7305 func TestReconnectMCPServerRefreshesEverySharedHostRegistry(t *testing.T) {
7306 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7307 oldSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7308 if !found {
7309 t.Fatal("sibling registry missing initial h tool")
7310 }
7311 if err := fixture.app.ReconnectMCPServer("h"); err != nil {
7312 t.Fatalf("ReconnectMCPServer(h): %v", err)
7313 }
7314 if !fixture.sharedHost.HasClient("h") {
7315 t.Fatal("shared host did not reconnect h")
7316 }
7317 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7318 t.Fatal("active registry was not refreshed")
7319 }
7320 newSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7321 if !found || newSiblingTool == oldSiblingTool {
7322 t.Fatal("sibling registry retained the tool backed by the disconnected client")
7323 }
7324 if _, found := fixture.disabledRegistry.Get("mcp__h__greet"); found {
7325 t.Fatal("reconnect re-enabled a tab where the server was disabled")
7326 }
7327 }
7328
7329 func TestReconnectMCPServerUsesEffectiveProjectConfigWhenUserNameIsShadowed(t *testing.T) {
7330 isolateDesktopUserDirs(t)
7331 dir := robustTempDir(t)
7332 userServer := desktopMCPHTTPServerWithTool(t, "user-shadow", "user_tool")
7333 defer userServer.Close()
7334 projectServer := desktopMCPHTTPServerWithTool(t, "project-effective", "project_tool")
7335 defer projectServer.Close()
7336
7337 userCfg := config.LoadForEdit(config.UserConfigPath())
7338 userCfg.Plugins = []config.PluginEntry{{Name: "h", Type: "http", URL: userServer.URL}}
7339 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
7340 t.Fatal(err)
7341 }
7342 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
7343 [[plugins]]
7344 name = "h"
7345 type = "http"
7346 url = %q
7347 `, projectServer.URL), 0o644); err != nil {
7348 t.Fatal(err)
7349 }
7350
7351 host := plugin.NewHost()
7352 t.Cleanup(host.Close)
7353 registry := tool.NewRegistry()
7354 ctrl := control.New(control.Options{
7355 Host: host, Registry: registry, PluginCtx: context.Background(), WorkspaceRoot: dir,
7356 MCPConfigureSpec: func(spec *plugin.Spec) {
7357 // This test isolates effective-source selection from the project launch
7358 // approval flow, which has its own end-to-end coverage.
7359 spec.RequireLaunchApproval = false
7360 spec.Authorized = true
7361 },
7362 })
7363 app := NewApp()
7364 app.tabs = map[string]*WorkspaceTab{
7365 "active": {
7366 ID: "active", Scope: "global", WorkspaceRoot: dir, Ready: true,
7367 Ctrl: ctrl, disabledMCP: map[string]ServerView{},
7368 },
7369 }
7370 app.activeTabID = "active"
7371
7372 if err := app.ReconnectMCPServer("h"); err != nil {
7373 t.Fatalf("ReconnectMCPServer(h): %v", err)
7374 }
7375 if _, found := registry.Get("mcp__h__project_tool"); !found {
7376 t.Fatal("reconnect did not use the effective project MCP configuration")
7377 }
7378 if _, found := registry.Get("mcp__h__user_tool"); found {
7379 t.Fatal("reconnect used the shadowed user MCP configuration")
7380 }
7381 }
7382
7383 func TestUpdateMCPServerRefreshesEverySharedHostRegistry(t *testing.T) {
7384 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7385 oldSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7386 if !found {
7387 t.Fatal("sibling registry missing initial h tool")
7388 }
7389 root := fixture.app.tabs["active"].WorkspaceRoot
7390 cfg, err := config.LoadForRoot(root)
7391 if err != nil {
7392 t.Fatal(err)
7393 }
7394 entry, found := findPluginEntry(cfg.Plugins, "h")
7395 if !found {
7396 t.Fatal("fixture config missing h")
7397 }
7398 if err := fixture.app.UpdateMCPServer("h", MCPServerInput{
7399 Name: "h", Transport: entry.Type, Command: entry.Command, Args: entry.Args,
7400 }); err != nil {
7401 t.Fatalf("UpdateMCPServer(h): %v", err)
7402 }
7403 if !fixture.sharedHost.HasClient("h") {
7404 t.Fatal("shared host did not reconnect h")
7405 }
7406 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7407 t.Fatal("active registry was not refreshed")
7408 }
7409 newSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7410 if !found || newSiblingTool == oldSiblingTool {
7411 t.Fatal("sibling registry retained the tool backed by the disconnected client")
7412 }
7413 if _, found := fixture.disabledRegistry.Get("mcp__h__greet"); found {
7414 t.Fatal("update re-enabled a tab where the server was disabled")
7415 }
7416 }
7417
7418 func TestClearMCPServerAuthenticationClearsEverySharedHostRegistry(t *testing.T) {
7419 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7420 if err := fixture.app.ClearMCPServerAuthentication("h"); err != nil {
7421 t.Fatalf("ClearMCPServerAuthentication(h): %v", err)
7422 }
7423 if fixture.sharedHost.HasClient("h") {
7424 t.Fatal("shared host retained h after clearing authentication")
7425 }
7426 for label, registry := range map[string]*tool.Registry{
7427 "active": fixture.activeRegistry, "sibling": fixture.siblingRegistry, "disabled": fixture.disabledRegistry,
7428 } {
7429 if _, found := registry.Get("mcp__h__greet"); found {
7430 t.Fatalf("%s registry retained h after clearing authentication", label)
7431 }
7432 }
7433 }
7434
7435 func TestRemoveMCPServerClearsEverySharedHostRegistry(t *testing.T) {
7436 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7437 if err := fixture.app.RemoveMCPServer("h"); err != nil {
7438 t.Fatalf("RemoveMCPServer(h): %v", err)
7439 }
7440 if fixture.sharedHost.HasClient("h") {
7441 t.Fatal("shared host retained the removed server")
7442 }
7443 for label, registry := range map[string]*tool.Registry{
7444 "active": fixture.activeRegistry, "sibling": fixture.siblingRegistry, "disabled": fixture.disabledRegistry,
7445 } {
7446 if _, found := registry.Get("mcp__h__greet"); found {
7447 t.Fatalf("%s registry retained the removed server tool", label)
7448 }
7449 }
7450 for id, tab := range fixture.app.tabs {
7451 if _, disabled := tab.disabledMCP["h"]; disabled {
7452 t.Fatalf("tab %s retained removed-server disabled state", id)
7453 }
7454 }
7455 }
7456
7457 type gatedDesktopMCPLaunchFixture struct {
7458 app *App
7459 sharedHost *plugin.Host
7460 activeRegistry *tool.Registry
7461 siblingRegistry *tool.Registry
7462 disabledRegistry *tool.Registry
7463 }
7464
7465 func newGatedDesktopMCPLaunchFixture(t *testing.T, startGateAddr string) gatedDesktopMCPLaunchFixture {
7466 t.Helper()
7467 isolateDesktopUserDirs(t)
7468 dir := robustTempDir(t)
7469 t.Chdir(dir)
7470
7471 exe, err := os.Executable()
7472 if err != nil {
7473 t.Fatal(err)
7474 }
7475 listener, err := net.Listen("tcp", "127.0.0.1:0")
7476 if err != nil {
7477 t.Fatal(err)
7478 }
7479 singleInstanceAddr := listener.Addr().String()
7480 if err := listener.Close(); err != nil {
7481 t.Fatal(err)
7482 }
7483 gateConfig := ""
7484 if startGateAddr != "" {
7485 gateConfig = fmt.Sprintf("DESKTOP_MCP_START_GATE_ADDR = %q\n", startGateAddr)
7486 }
7487 helperArgs := []string{"-test.run=TestDesktopMCPHelperProcess", "--"}
7488 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
7489 [[plugins]]
7490 name = "h"
7491 command = %q
7492 args = ["-test.run=TestDesktopMCPHelperProcess", "--"]
7493
7494 [plugins.env]
7495 GO_WANT_DESKTOP_MCP_HELPER = "1"
7496 DESKTOP_MCP_SINGLE_INSTANCE_ADDR = %q
7497 %s
7498 [sandbox]
7499 network = true
7500 `, exe, singleInstanceAddr, gateConfig), 0o644); err != nil {
7501 t.Fatal(err)
7502 }
7503
7504 entry := config.PluginEntry{
7505 Name: "h", Command: exe, Args: helperArgs,
7506 Env: map[string]string{
7507 "GO_WANT_DESKTOP_MCP_HELPER": "1",
7508 "DESKTOP_MCP_SINGLE_INSTANCE_ADDR": singleInstanceAddr,
7509 },
7510 }
7511 if startGateAddr != "" {
7512 entry.Env["DESKTOP_MCP_START_GATE_ADDR"] = startGateAddr
7513 }
7514 cfg, err := config.LoadForRoot(dir)
7515 if err != nil {
7516 t.Fatal(err)
7517 }
7518 runtimeSpecs := boot.PluginSpecsForRootWithOptions([]config.PluginEntry{entry}, dir, boot.PluginSpecOptions{
7519 DefaultCallTimeout: time.Duration(cfg.MCPCallTimeoutSeconds()) * time.Second,
7520 LaunchManager: mcplaunch.ForWorkspace(config.ReasonixHomeDir(), dir),
7521 ConfigSource: "workspace_config",
7522 StateHome: config.ReasonixHomeDir(),
7523 WriterRoots: cfg.WriteRootsForRoot(dir),
7524 ForbidReadRoots: cfg.ForbidReadRootsForRoot(dir),
7525 Network: cfg.Sandbox.Network,
7526 })
7527 if len(runtimeSpecs) != 1 {
7528 t.Fatalf("runtime specs = %d, want 1", len(runtimeSpecs))
7529 }
7530 runtimeSpec := runtimeSpecs[0]
7531 configure := func(spec *plugin.Spec) { *spec = runtimeSpec }
7532 lifeCtx, lifeCancel := context.WithCancel(context.Background())
7533 t.Cleanup(lifeCancel)
7534 callCtx, callCancel := context.WithTimeout(context.Background(), 10*time.Second)
7535 defer callCancel()
7536 sharedHost := plugin.NewHost()
7537 t.Cleanup(sharedHost.Close)
7538 tools, err := sharedHost.AddWithLifecycle(lifeCtx, callCtx, runtimeSpec)
7539 if err != nil {
7540 t.Fatalf("sharedHost.Add: %v", err)
7541 }
7542
7543 activeRegistry := tool.NewRegistry()
7544 siblingRegistry := tool.NewRegistry()
7545 disabledRegistry := tool.NewRegistry()
7546 for _, mt := range tools {
7547 activeRegistry.Add(mt)
7548 siblingRegistry.Add(mt)
7549 disabledRegistry.Add(mt)
7550 }
7551 activeCtrl := control.New(control.Options{
7552 Host: sharedHost, Registry: activeRegistry, PluginCtx: lifeCtx,
7553 MCPConfigureSpec: configure, WorkspaceRoot: dir,
7554 })
7555 siblingCtrl := control.New(control.Options{
7556 Host: sharedHost, Registry: siblingRegistry, PluginCtx: lifeCtx,
7557 MCPConfigureSpec: configure, WorkspaceRoot: dir,
7558 })
7559 disabledCtrl := control.New(control.Options{
7560 Host: sharedHost, Registry: disabledRegistry, PluginCtx: lifeCtx,
7561 MCPConfigureSpec: configure, WorkspaceRoot: dir,
7562 })
7563 disabledCtrl.UnregisterMCPServerTools("h")
7564 app := NewApp()
7565 app.tabs = map[string]*WorkspaceTab{
7566 "active": {
7567 ID: "active", Scope: "global", WorkspaceRoot: dir, Ready: true,
7568 Ctrl: activeCtrl, SharedHostKey: dir, disabledMCP: map[string]ServerView{},
7569 },
7570 "sibling": {
7571 ID: "sibling", Scope: "global", WorkspaceRoot: dir, Ready: true,
7572 Ctrl: siblingCtrl, SharedHostKey: dir, disabledMCP: map[string]ServerView{},
7573 },
7574 "disabled": {
7575 ID: "disabled", Scope: "global", WorkspaceRoot: dir, Ready: true,
7576 Ctrl: disabledCtrl, SharedHostKey: dir,
7577 disabledMCP: map[string]ServerView{"h": {Name: "h", Status: "disabled"}},
7578 },
7579 }
7580 app.activeTabID = "active"
7581 return gatedDesktopMCPLaunchFixture{
7582 app: app, sharedHost: sharedHost,
7583 activeRegistry: activeRegistry, siblingRegistry: siblingRegistry, disabledRegistry: disabledRegistry,
7584 }
7585 }
7586
7587 func newDesktopMCPStartGate(t *testing.T, handle func(attempt int, conn net.Conn)) (string, <-chan int) {
7588 t.Helper()
7589 listener, err := net.Listen("tcp", "127.0.0.1:0")
7590 if err != nil {
7591 t.Fatal(err)
7592 }
7593 t.Cleanup(func() { _ = listener.Close() })
7594 attempts := make(chan int, 8)
7595 go func() {
7596 for attempt := 1; ; attempt++ {
7597 conn, err := listener.Accept()
7598 if err != nil {
7599 return
7600 }
7601 attempts <- attempt
7602 handle(attempt, conn)
7603 _ = conn.Close()
7604 }
7605 }()
7606 return listener.Addr().String(), attempts
7607 }
7608
7609 func waitForDesktopMCPStartAttempt(t *testing.T, attempts <-chan int, want int) {
7610 t.Helper()
7611 deadline := time.NewTimer(5 * time.Second)
7612 defer deadline.Stop()
7613 for {
7614 select {
7615 case got := <-attempts:
7616 if got == want {
7617 return
7618 }
7619 case <-deadline.C:
7620 t.Fatalf("timed out waiting for MCP start attempt %d", want)
7621 }
7622 }
7623 }
7624
7625 func TestAuthorizeAndConnectMCPServerSerializesConcurrentDisable(t *testing.T) {
7626 releaseConnection := make(chan struct{})
7627 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
7628 if attempt == 2 {
7629 <-releaseConnection
7630 }
7631 _, _ = conn.Write([]byte{1})
7632 })
7633 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
7634
7635 disableEntered := make(chan struct{})
7636 var disableOnce sync.Once
7637 fixture.app.runtimeMutationBeforeLockHook = func(operation string) {
7638 if operation == "set-enabled" {
7639 disableOnce.Do(func() { close(disableEntered) })
7640 }
7641 }
7642 authorizeDone := make(chan error, 1)
7643 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
7644 waitForDesktopMCPStartAttempt(t, attempts, 2)
7645 disableDone := make(chan error, 1)
7646 go func() { disableDone <- fixture.app.SetMCPServerEnabled("h", false) }()
7647 select {
7648 case <-disableEntered:
7649 case <-time.After(5 * time.Second):
7650 t.Fatal("concurrent disable did not reach the MCP lifecycle lock")
7651 }
7652 select {
7653 case err := <-disableDone:
7654 t.Fatalf("concurrent disable bypassed authorization serialization: %v", err)
7655 case <-time.After(200 * time.Millisecond):
7656 }
7657 close(releaseConnection)
7658 if err := <-authorizeDone; err != nil {
7659 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
7660 }
7661 if err := <-disableDone; err != nil {
7662 t.Fatalf("SetMCPServerEnabled(h,false): %v", err)
7663 }
7664 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); found {
7665 t.Fatal("authorization reconnect overrode the later per-tab disable")
7666 }
7667 if _, disabled := fixture.app.tabs["active"].disabledMCP["h"]; !disabled {
7668 t.Fatal("active tab did not retain the later disable decision")
7669 }
7670 if _, found := fixture.siblingRegistry.Get("mcp__h__greet"); !found {
7671 t.Fatal("active-tab disable removed the shared MCP from its enabled sibling")
7672 }
7673 }
7674
7675 // RemovePlugin disconnects the uninstalled plugin's MCP servers, so it must
7676 // serialize on the MCP lifecycle lock: an unlocked disconnect interleaving
7677 // with authorization lets the reconnect relaunch the just-removed
7678 // server from its stale snapshot. The plugin does not need to exist — the
7679 // lock is taken before the uninstall runs, which is the contract under test.
7680 func TestRemovePluginSerializesWithMCPAuthorization(t *testing.T) {
7681 releaseConnection := make(chan struct{})
7682 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
7683 if attempt == 2 {
7684 <-releaseConnection
7685 }
7686 _, _ = conn.Write([]byte{1})
7687 })
7688 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
7689
7690 removeEntered := make(chan struct{})
7691 var removeOnce sync.Once
7692 fixture.app.runtimeMutationBeforeLockHook = func(operation string) {
7693 if operation == "remove-plugin" {
7694 removeOnce.Do(func() { close(removeEntered) })
7695 }
7696 }
7697 authorizeDone := make(chan error, 1)
7698 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
7699 waitForDesktopMCPStartAttempt(t, attempts, 2)
7700 removeDone := make(chan error, 1)
7701 go func() { removeDone <- fixture.app.RemovePlugin("not-an-installed-plugin") }()
7702 select {
7703 case <-removeEntered:
7704 case <-time.After(5 * time.Second):
7705 t.Fatal("RemovePlugin did not reach the MCP lifecycle lock")
7706 }
7707 select {
7708 case err := <-removeDone:
7709 t.Fatalf("RemovePlugin bypassed authorization serialization: %v", err)
7710 case <-time.After(200 * time.Millisecond):
7711 }
7712 close(releaseConnection)
7713 if err := <-authorizeDone; err != nil {
7714 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
7715 }
7716 // The uninstall itself is expected to fail (the plugin is not installed);
7717 // only the ordering matters. It must complete once the lock is free.
7718 select {
7719 case <-removeDone:
7720 case <-time.After(5 * time.Second):
7721 t.Fatal("RemovePlugin did not complete after the trust connection released the lock")
7722 }
7723 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7724 t.Fatal("authorization reconnect result was lost after the serialized RemovePlugin")
7725 }
7726 }
7727
7728 // installGatedTestPluginPackage registers an installed plugin package whose
7729 // manifest declares the gated fixture's MCP server, so RemovePlugin exercises
7730 // the real uninstall and MCP disconnect flow. Returns the plugin root.
7731 func installGatedTestPluginPackage(t *testing.T, mcpServerName string) string {
7732 t.Helper()
7733 reasonixHome := config.ReasonixHomeDir()
7734 root := filepath.Join(reasonixHome, "plugins", "review-helper")
7735 if err := os.MkdirAll(root, 0o755); err != nil {
7736 t.Fatal(err)
7737 }
7738 if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), fmt.Appendf(nil, `{"apiVersion": "reasonix.io/plugin/v2",
7739 "name": "review-helper",
7740 "version": "1.0.0",
7741 "mcpServers": {
7742 %q: { "type": "stdio", "command": "helper" }
7743 }
7744 }`, mcpServerName), 0o644); err != nil {
7745 t.Fatal(err)
7746 }
7747 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
7748 Name: "review-helper",
7749 Root: "plugins/review-helper",
7750 Version: "1.0.0",
7751 ManifestKind: "reasonix",
7752 Enabled: true,
7753 }); err != nil {
7754 t.Fatal(err)
7755 }
7756 return root
7757 }
7758
7759 func installedPluginNamed(t *testing.T, name string) bool {
7760 t.Helper()
7761 st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
7762 if err != nil {
7763 t.Fatal(err)
7764 }
7765 for _, p := range st.Plugins {
7766 if p.Name == name {
7767 return true
7768 }
7769 }
7770 return false
7771 }
7772
7773 // A global plugin uninstall must clean every runtime, not only the active tab:
7774 // sibling registries on the shared Host would otherwise keep provider-visible
7775 // tools backed by the closed client, and other workspaces would keep running
7776 // the uninstalled server.
7777 func TestRemovePluginDisconnectsEveryRuntime(t *testing.T) {
7778 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7779 pluginRoot := installGatedTestPluginPackage(t, "h")
7780
7781 if err := fixture.app.RemovePlugin("review-helper"); err != nil {
7782 t.Fatalf("RemovePlugin(review-helper): %v", err)
7783 }
7784 if fixture.sharedHost.HasClient("h") {
7785 t.Fatal("uninstall left the shared MCP client connected")
7786 }
7787 for name, reg := range map[string]*tool.Registry{
7788 "active": fixture.activeRegistry,
7789 "sibling": fixture.siblingRegistry,
7790 "disabled": fixture.disabledRegistry,
7791 } {
7792 if _, found := reg.Get("mcp__h__greet"); found {
7793 t.Fatalf("%s registry still exposes the uninstalled MCP tool", name)
7794 }
7795 }
7796 if _, err := os.Stat(pluginRoot); !os.IsNotExist(err) {
7797 t.Fatalf("plugin root still present after uninstall (err=%v)", err)
7798 }
7799 if installedPluginNamed(t, "review-helper") {
7800 t.Fatal("plugin state still lists the uninstalled plugin")
7801 }
7802 }
7803
7804 // The pre-lock active-work check can go stale during the lifecycle-lock wait.
7805 // Work that starts mid-wait must fail the removal before anything is deleted;
7806 // the old order deleted the plugin first and only then reported the failure.
7807 func TestRemovePluginRechecksActiveWorkUnderLock(t *testing.T) {
7808 releaseConnection := make(chan struct{})
7809 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
7810 if attempt == 2 {
7811 <-releaseConnection
7812 }
7813 _, _ = conn.Write([]byte{1})
7814 })
7815 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
7816 installGatedTestPluginPackage(t, "h")
7817
7818 removeEntered := make(chan struct{})
7819 var removeOnce sync.Once
7820 fixture.app.runtimeMutationBeforeLockHook = func(operation string) {
7821 if operation == "remove-plugin" {
7822 removeOnce.Do(func() { close(removeEntered) })
7823 }
7824 }
7825 authorizeDone := make(chan error, 1)
7826 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
7827 waitForDesktopMCPStartAttempt(t, attempts, 2)
7828 removeDone := make(chan error, 1)
7829 go func() { removeDone <- fixture.app.RemovePlugin("review-helper") }()
7830 select {
7831 case <-removeEntered:
7832 case <-time.After(5 * time.Second):
7833 t.Fatal("RemovePlugin did not reach the MCP lifecycle lock")
7834 }
7835 // While RemovePlugin waits for the lock, background work starts on the
7836 // active tab — exactly the window the pre-lock check cannot see.
7837 busy := newBackgroundJobController(t, "remove-plugin-active-work")
7838 fixture.app.mu.Lock()
7839 fixture.app.tabs["active"].Ctrl = busy
7840 fixture.app.mu.Unlock()
7841 close(releaseConnection)
7842 if err := <-authorizeDone; err != nil {
7843 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
7844 }
7845 err := <-removeDone
7846 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
7847 t.Fatalf("RemovePlugin during background work error = %v, want active-work guard", err)
7848 }
7849 if !installedPluginNamed(t, "review-helper") {
7850 t.Fatal("active-work guard fired only after the plugin was already uninstalled")
7851 }
7852 }
7853
7854 // A global uninstall disconnects every runtime, so the busy guard must cover
7855 // every runtime too: a background job on a sibling tab must fail the removal
7856 // before anything is deleted, not silently lose its plugin MCP mid-run.
7857 func TestRemovePluginRejectsBusySiblingRuntime(t *testing.T) {
7858 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7859 installGatedTestPluginPackage(t, "h")
7860 busy := newBackgroundJobController(t, "sibling-busy")
7861 fixture.app.mu.Lock()
7862 fixture.app.tabs["sibling"].Ctrl = busy
7863 fixture.app.mu.Unlock()
7864
7865 err := fixture.app.RemovePlugin("review-helper")
7866 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
7867 t.Fatalf("RemovePlugin with busy sibling error = %v, want active-work guard", err)
7868 }
7869 if !installedPluginNamed(t, "review-helper") {
7870 t.Fatal("plugin was uninstalled despite a busy sibling runtime")
7871 }
7872 if !fixture.sharedHost.HasClient("h") {
7873 t.Fatal("busy-sibling guard still disconnected the shared MCP client")
7874 }
7875 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7876 t.Fatal("busy-sibling guard still removed active registry tools")
7877 }
7878 }
7879
7880 // Detached runtimes keep running after their tab is closed; the uninstall busy
7881 // guard must see them through the same gate sweep as visible tabs.
7882 func TestRemovePluginRejectsBusyDetachedRuntime(t *testing.T) {
7883 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7884 installGatedTestPluginPackage(t, "h")
7885 busy := newBackgroundJobController(t, "detached-busy")
7886 fixture.app.mu.Lock()
7887 fixture.app.detachedSessions = map[string]*WorkspaceTab{
7888 "detached": {
7889 ID: "detached", Scope: "global", Ready: true,
7890 Ctrl: busy, disabledMCP: map[string]ServerView{},
7891 },
7892 }
7893 fixture.app.mu.Unlock()
7894
7895 err := fixture.app.RemovePlugin("review-helper")
7896 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
7897 t.Fatalf("RemovePlugin with busy detached runtime error = %v, want active-work guard", err)
7898 }
7899 if !installedPluginNamed(t, "review-helper") {
7900 t.Fatal("plugin was uninstalled despite a busy detached runtime")
7901 }
7902 }
7903
7904 // A turn start holds its tab's turn gate before the controller reports active
7905 // work, so an idle check done without the gate can go stale immediately. The
7906 // authorization must wait on the sibling's gate — never disconnect first — and must
7907 // fail once the gated re-check sees the started work.
7908 func TestAuthorizeAndConnectMCPServerWaitsForSiblingTurnGate(t *testing.T) {
7909 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
7910 _, _ = conn.Write([]byte{1})
7911 })
7912 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
7913 waitForDesktopMCPStartAttempt(t, attempts, 1) // drain the fixture's initial connect
7914 sibling := fixture.app.tabs["sibling"]
7915
7916 // Simulate the racing turn: it takes the gate first, and only transitions
7917 // its controller to busy while holding it.
7918 sibling.turnStartMu.Lock()
7919 authorizeDone := make(chan error, 1)
7920 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
7921 select {
7922 case got := <-attempts:
7923 t.Fatalf("trust connection launched (attempt %d) while a sibling turn gate was held", got)
7924 case err := <-authorizeDone:
7925 t.Fatalf("AuthorizeAndConnectMCPServer returned %v without waiting for the sibling turn gate", err)
7926 case <-time.After(700 * time.Millisecond):
7927 }
7928 if !fixture.sharedHost.HasClient("h") {
7929 t.Fatal("authorization disconnected the shared client while a sibling turn gate was held")
7930 }
7931 busy := newBackgroundJobController(t, "sibling-turn")
7932 fixture.app.mu.Lock()
7933 sibling.Ctrl = busy
7934 fixture.app.mu.Unlock()
7935 sibling.turnStartMu.Unlock()
7936
7937 err := <-authorizeDone
7938 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
7939 t.Fatalf("AuthorizeAndConnectMCPServer after sibling turn start error = %v, want active-work guard", err)
7940 }
7941 if !fixture.sharedHost.HasClient("h") {
7942 t.Fatal("failed authorization left the shared client disconnected")
7943 }
7944 if _, found := fixture.siblingRegistry.Get("mcp__h__greet"); !found {
7945 t.Fatal("authorization stripped the busy sibling of its MCP tools")
7946 }
7947 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7948 t.Fatal("authorization stripped the active tab of its MCP tools")
7949 }
7950 }
7951
7952 // waitForRuntimeAdmissionBarrier polls until the work-admission write lock is
7953 // held, marking the point where a lifecycle mutation froze new admissions.
7954 func waitForRuntimeAdmissionBarrier(t *testing.T, app *App) {
7955 t.Helper()
7956 deadline := time.Now().Add(5 * time.Second)
7957 for time.Now().Before(deadline) {
7958 if app.runtimeAdmissionMu.TryRLock() {
7959 app.runtimeAdmissionMu.RUnlock()
7960 time.Sleep(2 * time.Millisecond)
7961 continue
7962 }
7963 return
7964 }
7965 t.Fatal("lifecycle mutation never acquired the work-admission barrier")
7966 }
7967
7968 func TestBridgeDriveReleasesRuntimeAdmissionWhenTakeoverWasReclaimed(t *testing.T) {
7969 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7970 fixture.app.tabs["active"].sink = &tabEventSink{tabID: "active", app: fixture.app}
7971 fixture.app.botBridge = &botBridgeHub{
7972 takeovers: make(map[string]bot.DesktopWatchRoute),
7973 takeoverTabs: make(map[string]string),
7974 }
7975
7976 err := fixture.app.bridgeDrive("active", "hello", bot.DesktopWatchRoute{})
7977 if err == nil || !strings.Contains(err.Error(), "接管已解除") {
7978 t.Fatalf("bridgeDrive error = %v, want lost-takeover error", err)
7979 }
7980 if !fixture.app.runtimeAdmissionMu.TryLock() {
7981 t.Fatal("bridgeDrive leaked the runtime-admission read lock")
7982 }
7983 fixture.app.runtimeAdmissionMu.Unlock()
7984 }
7985
7986 func TestBeginTabTurnWorkspaceRepairStaysOutsideLifecycleAdmission(t *testing.T) {
7987 fixture := newStaleWorkspaceBindingFixture(t, "admission_writer")
7988 fixture.tab.reconcileMu.Lock()
7989
7990 turnDone := make(chan error, 1)
7991 go func() {
7992 admission, _, err := fixture.app.beginTabTurn(fixture.tab.ID, false)
7993 if admission != nil {
7994 admission.abort()
7995 }
7996 turnDone <- err
7997 }()
7998 writerRebuildLocked := make(chan struct{})
7999 writerAdmissionLocked := make(chan struct{})
8000 writerDone := make(chan struct{})
8001 go func() {
8002 fixture.app.runtimeRebuildMu.Lock()
8003 close(writerRebuildLocked)
8004 fixture.app.runtimeAdmissionMu.Lock()
8005 close(writerAdmissionLocked)
8006 fixture.app.runtimeAdmissionMu.Unlock()
8007 fixture.app.runtimeRebuildMu.Unlock()
8008 close(writerDone)
8009 }()
8010 <-writerRebuildLocked
8011 select {
8012 case <-writerAdmissionLocked:
8013 // The repair is still blocked on reconcileMu; acquiring the lifecycle
8014 // writer here proves no slow repair/build I/O owns the read side.
8015 case <-t.Context().Done():
8016 fixture.tab.reconcileMu.Unlock()
8017 t.Fatal("workspace repair held runtimeAdmissionMu while waiting")
8018 }
8019 fixture.tab.reconcileMu.Unlock()
8020
8021 select {
8022 case err := <-turnDone:
8023 if err != nil {
8024 t.Fatalf("beginTabTurn after workspace repair: %v", err)
8025 }
8026 case <-t.Context().Done():
8027 t.Fatal("workspace repair did not complete after lifecycle writer released")
8028 }
8029 select {
8030 case <-writerDone:
8031 case <-t.Context().Done():
8032 t.Fatal("lifecycle writer did not complete after repaired turn admission")
8033 }
8034 }
8035
8036 func TestAuthorizeAndConnectMCPServerSerializesCloseOfCapturedRuntime(t *testing.T) {
8037 releaseConnection := make(chan struct{})
8038 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
8039 if attempt == 2 {
8040 <-releaseConnection
8041 }
8042 _, _ = conn.Write([]byte{1})
8043 })
8044 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
8045 waitForDesktopMCPStartAttempt(t, attempts, 1)
8046
8047 dir := fixture.app.tabs["active"].WorkspaceRoot
8048 otherRoot := robustTempDir(t)
8049 otherHost := plugin.NewHost()
8050 t.Cleanup(otherHost.Close)
8051 otherCtrl := control.New(control.Options{Host: otherHost, WorkspaceRoot: otherRoot})
8052 t.Cleanup(otherCtrl.Close)
8053 fixture.app.tabs = map[string]*WorkspaceTab{
8054 "active": fixture.app.tabs["active"],
8055 "other": {
8056 ID: "other", Scope: "project", WorkspaceRoot: otherRoot, Ready: true,
8057 Ctrl: otherCtrl, disabledMCP: map[string]ServerView{},
8058 },
8059 }
8060 fixture.app.tabOrder = []string{"active", "other"}
8061 fixture.app.activeTabID = "active"
8062 fixture.app.sharedHosts = map[string]*sharedPluginHost{
8063 dir: {host: fixture.sharedHost, refs: 1},
8064 }
8065
8066 authorizeDone := make(chan error, 1)
8067 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8068 waitForDesktopMCPStartAttempt(t, attempts, 2)
8069 closeDone := make(chan error, 1)
8070 go func() { closeDone <- fixture.app.CloseTab("active") }()
8071 select {
8072 case err := <-closeDone:
8073 t.Fatalf("CloseTab bypassed the MCP lifecycle barrier: %v", err)
8074 case <-time.After(300 * time.Millisecond):
8075 }
8076
8077 close(releaseConnection)
8078 if err := <-authorizeDone; err != nil {
8079 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
8080 }
8081 if err := <-closeDone; err != nil {
8082 t.Fatalf("CloseTab(active) after trust: %v", err)
8083 }
8084 }
8085
8086 func TestCloseTabWaitsForPendingTurnAdmission(t *testing.T) {
8087 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8088 admission, _, err := fixture.app.beginTabTurn("active", false)
8089 if err != nil {
8090 t.Fatalf("beginTabTurn(active): %v", err)
8091 }
8092 released := false
8093 defer func() {
8094 if !released {
8095 admission.abort()
8096 }
8097 }()
8098
8099 closeDone := make(chan error, 1)
8100 go func() { closeDone <- fixture.app.CloseTab("active") }()
8101 select {
8102 case err := <-closeDone:
8103 admission.abort()
8104 released = true
8105 t.Fatalf("CloseTab bypassed a pending turn admission and closed its controller: %v", err)
8106 case <-time.After(300 * time.Millisecond):
8107 }
8108
8109 admission.abort()
8110 released = true
8111 select {
8112 case err := <-closeDone:
8113 if err != nil {
8114 t.Fatalf("CloseTab(active) after turn admission release: %v", err)
8115 }
8116 case <-time.After(5 * time.Second):
8117 t.Fatal("CloseTab did not resume after the pending turn admission was released")
8118 }
8119 }
8120
8121 func TestCloseTabRemainsVisibleToPendingMCPHostGateSnapshot(t *testing.T) {
8122 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8123 active := fixture.app.tabs["active"]
8124 active.turnStartMu.Lock()
8125
8126 authorizeDone := make(chan error, 1)
8127 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8128 waitForRuntimeAdmissionBarrier(t, fixture.app)
8129
8130 closeDone := make(chan error, 1)
8131 go func() { closeDone <- fixture.app.CloseTab("active") }()
8132 time.Sleep(300 * time.Millisecond)
8133 fixture.app.mu.RLock()
8134 stillVisible := fixture.app.tabs["active"] == active
8135 fixture.app.mu.RUnlock()
8136 if !stillVisible {
8137 active.turnStartMu.Unlock()
8138 t.Fatal("CloseTab unlinked the runtime before the pending MCP Host gate snapshot")
8139 }
8140 select {
8141 case err := <-closeDone:
8142 active.turnStartMu.Unlock()
8143 t.Fatalf("CloseTab bypassed the lifecycle barrier: %v", err)
8144 default:
8145 }
8146
8147 active.turnStartMu.Unlock()
8148 if err := <-authorizeDone; err != nil {
8149 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
8150 }
8151 if err := <-closeDone; err != nil {
8152 t.Fatalf("CloseTab(active): %v", err)
8153 }
8154 }
8155
8156 func TestAuthorizeAndConnectMCPServerKeepsInvokingWorkspaceWhenActiveTabChanges(t *testing.T) {
8157 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8158 otherRoot := robustTempDir(t)
8159 otherCtrl := control.New(control.Options{Host: plugin.NewHost(), WorkspaceRoot: otherRoot})
8160 t.Cleanup(otherCtrl.Close)
8161 fixture.app.tabs["other"] = &WorkspaceTab{
8162 ID: "other", Scope: "project", WorkspaceRoot: otherRoot, Ready: true,
8163 Ctrl: otherCtrl, disabledMCP: map[string]ServerView{},
8164 }
8165 fixture.app.tabOrder = []string{"active", "sibling", "disabled", "other"}
8166
8167 sibling := fixture.app.tabs["sibling"]
8168 sibling.turnStartMu.Lock()
8169 authorizeDone := make(chan error, 1)
8170 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8171 waitForRuntimeAdmissionBarrier(t, fixture.app)
8172 if err := fixture.app.SetActiveTab("other"); err != nil {
8173 sibling.turnStartMu.Unlock()
8174 t.Fatalf("SetActiveTab(other): %v", err)
8175 }
8176 sibling.turnStartMu.Unlock()
8177
8178 if err := <-authorizeDone; err != nil {
8179 t.Fatalf("authorization operation drifted from its invoking workspace: %v", err)
8180 }
8181 }
8182
8183 // Work admission — not tab-set stability — is the gate invariant: a runtime
8184 // added after the gate snapshot must not be able to start a turn while the
8185 // uninstall holds the barrier. The late turn goes through the real
8186 // beginTabTurn admission path and must only be admitted after the uninstall.
8187 func TestRemovePluginBlocksLateTurnAdmission(t *testing.T) {
8188 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8189 installGatedTestPluginPackage(t, "h")
8190 dir := fixture.app.tabs["active"].WorkspaceRoot
8191
8192 // Hold an existing tab's gate so the uninstall blocks mid-acquisition
8193 // with the admission barrier already held.
8194 sibling := fixture.app.tabs["sibling"]
8195 sibling.turnStartMu.Lock()
8196 removeDone := make(chan error, 1)
8197 go func() { removeDone <- fixture.app.RemovePlugin("review-helper") }()
8198 waitForRuntimeAdmissionBarrier(t, fixture.app)
8199
8200 lateCtrl := control.New(control.Options{Host: plugin.NewHost(), WorkspaceRoot: dir})
8201 t.Cleanup(lateCtrl.Close)
8202 fixture.app.mu.Lock()
8203 fixture.app.tabs["late"] = &WorkspaceTab{
8204 ID: "late", Scope: "global", WorkspaceRoot: dir, Ready: true,
8205 Ctrl: lateCtrl, disabledMCP: map[string]ServerView{},
8206 }
8207 fixture.app.mu.Unlock()
8208 type admission struct {
8209 turn *tabTurnAdmission
8210 ctrl control.SessionAPI
8211 err error
8212 }
8213 admitted := make(chan admission, 1)
8214 go func() {
8215 turn, ctrl, err := fixture.app.beginTabTurn("late", false)
8216 admitted <- admission{turn: turn, ctrl: ctrl, err: err}
8217 }()
8218 select {
8219 case got := <-admitted:
8220 t.Fatalf("late turn was admitted (err=%v) while the uninstall held the admission barrier", got.err)
8221 case <-time.After(300 * time.Millisecond):
8222 }
8223
8224 sibling.turnStartMu.Unlock()
8225 if err := <-removeDone; err != nil {
8226 t.Fatalf("RemovePlugin(review-helper): %v", err)
8227 }
8228 got := <-admitted
8229 if got.err != nil {
8230 t.Fatalf("late turn admission after uninstall: %v", got.err)
8231 }
8232 got.turn.abort()
8233 if installedPluginNamed(t, "review-helper") {
8234 t.Fatal("uninstall did not complete before the late turn was admitted")
8235 }
8236 }
8237
8238 // A tab created during the 30s trust connection must not complete its async
8239 // controller build — attaching to the shared Host mid-connection can relaunch a
8240 // single-instance server or leave a registry the authorization never saw. The build
8241 // goes through the real startTabControllerBuild path and must only run after
8242 // the authorization releases the barrier.
8243 func TestAuthorizeAndConnectMCPServerBlocksLateControllerBuild(t *testing.T) {
8244 releaseConnection := make(chan struct{})
8245 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
8246 if attempt == 2 {
8247 <-releaseConnection
8248 }
8249 _, _ = conn.Write([]byte{1})
8250 })
8251 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
8252 waitForDesktopMCPStartAttempt(t, attempts, 1) // drain the fixture's initial connect
8253 dir := fixture.app.tabs["active"].WorkspaceRoot
8254
8255 authorizeDone := make(chan error, 1)
8256 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8257 waitForDesktopMCPStartAttempt(t, attempts, 2) // connection launched: gates held
8258
8259 late := &WorkspaceTab{
8260 ID: "late", Scope: "global", WorkspaceRoot: dir,
8261 disabledMCP: map[string]ServerView{},
8262 }
8263 late.sink = &tabEventSink{tabID: "late", app: fixture.app}
8264 fixture.app.mu.Lock()
8265 fixture.app.tabs["late"] = late
8266 fixture.app.mu.Unlock()
8267 buildDone := make(chan struct{})
8268 go func() {
8269 // a.ctx is nil in this fixture, so the build runs synchronously on
8270 // this goroutine — through the real buildTabControllerWithContext.
8271 fixture.app.startTabControllerBuild(late)
8272 close(buildDone)
8273 }()
8274 select {
8275 case <-buildDone:
8276 t.Fatal("late controller build completed while the trust connection held the admission barrier")
8277 case <-time.After(400 * time.Millisecond):
8278 }
8279
8280 close(releaseConnection)
8281 if err := <-authorizeDone; err != nil {
8282 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
8283 }
8284 select {
8285 case <-buildDone:
8286 case <-time.After(10 * time.Second):
8287 t.Fatal("late controller build never ran after the authorization released the barrier")
8288 }
8289 if !fixture.sharedHost.HasClient("h") {
8290 t.Fatal("authorization did not leave the shared client reconnected")
8291 }
8292 }
8293
8294 func TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {
8295 isolateDesktopUserDirs(t)
8296
8297 app := NewApp()
8298 app.setTestCtrl(newBackgroundJobController(t, "mcp-enabled-job"), "")
8299
8300 err := app.SetMCPServerEnabled("time", false)
8301 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
8302 t.Fatalf("SetMCPServerEnabled with background job error = %v, want active-work guard", err)
8303 }
8304 if tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {
8305 t.Fatalf("disabled MCP state changed after rejected toggle: %+v", tab)
8306 }
8307 }
8308
8309 func TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {
8310 isolateDesktopUserDirs(t)
8311 dir := robustTempDir(t)
8312 t.Chdir(dir)
8313 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
8314 [[plugins]]
8315 name = "time"
8316 command = "custom-time"
8317 args = ["serve"]
8318 `), 0o644); err != nil {
8319 t.Fatal(err)
8320 }
8321
8322 app := NewApp()
8323 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8324 defer app.activeCtrl().Close()
8325 app.activeTab().disabledMCP["time"] = ServerView{Name: "time", Status: "disabled", Enabled: false}
8326
8327 if err := app.UpdateMCPServer("time", MCPServerInput{
8328 Name: "time",
8329 Transport: "stdio",
8330 Command: "updated-time",
8331 Args: []string{"run"},
8332 }); err != nil {
8333 t.Fatalf("UpdateMCPServer(time): %v", err)
8334 }
8335 cfg, err := config.LoadForRoot(dir)
8336 if err != nil {
8337 t.Fatal(err)
8338 }
8339 updated, ok := findPluginEntry(cfg.Plugins, "time")
8340 if !ok || updated.Command != "updated-time" || !reflect.DeepEqual(updated.Args, []string{"run"}) {
8341 t.Fatalf("updated time plugin = %+v, found=%v", updated, ok)
8342 }
8343
8344 if err := app.RemoveMCPServer("time"); err != nil {
8345 t.Fatalf("RemoveMCPServer(time): %v", err)
8346 }
8347 cfg, err = config.LoadForRoot(dir)
8348 if err != nil {
8349 t.Fatal(err)
8350 }
8351 if _, ok := findPluginEntry(cfg.Plugins, "time"); ok {
8352 t.Fatalf("time plugin still configured after remove: %+v", cfg.Plugins)
8353 }
8354 }
8355
8356 func TestRemoveProjectMCPRevealsAndRegistersGlobalFallback(t *testing.T) {
8357 isolateDesktopUserDirs(t)
8358 dir := robustTempDir(t)
8359 t.Chdir(dir)
8360 userCfg := config.LoadForEdit(config.UserConfigPath())
8361 userCfg.Plugins = []config.PluginEntry{{Name: "docs", Command: "global-docs"}}
8362 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
8363 t.Fatal(err)
8364 }
8365 projectPath := filepath.Join(dir, "reasonix.toml")
8366 if err := os.WriteFile(projectPath, []byte(`
8367 [[plugins]]
8368 name = "docs"
8369 command = "project-docs"
8370 `), 0o644); err != nil {
8371 t.Fatal(err)
8372 }
8373
8374 reg := tool.NewRegistry()
8375 var configured []plugin.Spec
8376 ctrl := control.New(control.Options{
8377 Host: plugin.NewHost(),
8378 Registry: reg,
8379 WorkspaceRoot: dir,
8380 MCPConfigureSpec: func(spec *plugin.Spec) {
8381 configured = append(configured, *spec)
8382 },
8383 })
8384 defer ctrl.Close()
8385 projectEntry, found, err := desktopEffectiveMCPServer(dir, "docs")
8386 if err != nil || !found {
8387 t.Fatalf("load project docs: entry=%+v found=%v err=%v", projectEntry, found, err)
8388 }
8389 if _, err := ctrl.RegisterMCPServerOnDemand(projectEntry); err != nil {
8390 t.Fatalf("register project docs: %v", err)
8391 }
8392
8393 app := NewApp()
8394 app.setTestCtrl(ctrl, "")
8395 app.activeTab().WorkspaceRoot = dir
8396 if err := app.RemoveMCPServer("docs"); err != nil {
8397 t.Fatalf("RemoveMCPServer(docs): %v", err)
8398 }
8399
8400 projectCfg := config.LoadForEdit(projectPath)
8401 if _, found := findPluginEntry(projectCfg.Plugins, "docs"); found {
8402 t.Fatalf("project docs still configured after removal: %+v", projectCfg.Plugins)
8403 }
8404 globalCfg := config.LoadForEdit(config.UserConfigPath())
8405 globalEntry, found := findPluginEntry(globalCfg.Plugins, "docs")
8406 if !found || globalEntry.Command != "global-docs" {
8407 t.Fatalf("global docs fallback = %+v, found=%v", globalEntry, found)
8408 }
8409 effective, found, err := desktopEffectiveMCPServer(dir, "docs")
8410 if err != nil || !found || effective.Source != config.MCPSourceUserConfig || effective.Command != "global-docs" {
8411 t.Fatalf("effective docs fallback = %+v, found=%v err=%v", effective, found, err)
8412 }
8413 if len(configured) < 2 || configured[len(configured)-1].Command != "global-docs" {
8414 t.Fatalf("registered specs = %+v, want global fallback registered last", configured)
8415 }
8416 if _, found := reg.Get("mcp__docs__connect"); !found {
8417 t.Fatalf("global fallback connect surface missing; names=%v", reg.Names())
8418 }
8419 }
8420
8421 func TestRemoveMCPServerClearsRecordedStartupFailure(t *testing.T) {
8422 isolateDesktopUserDirs(t)
8423 dir := robustTempDir(t)
8424 t.Chdir(dir)
8425 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
8426 [[plugins]]
8427 name = "broken"
8428 command = "reasonix-missing-mcp-binary"
8429 `), 0o644); err != nil {
8430 t.Fatal(err)
8431 }
8432
8433 app := NewApp()
8434 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8435 defer app.activeCtrl().Close()
8436 recordMCPFailure(app.activeCtrl(), config.PluginEntry{
8437 Name: "broken",
8438 Command: "reasonix-missing-mcp-binary",
8439 }, errors.New("connect: missing binary"))
8440
8441 view := app.Capabilities()
8442 if len(view.Servers) != 1 || view.Servers[0].Name != "broken" || view.Servers[0].Status != "failed" {
8443 t.Fatalf("Capabilities before remove = %+v, want broken failed", view.Servers)
8444 }
8445
8446 if err := app.RemoveMCPServer("broken"); err != nil {
8447 t.Fatalf("RemoveMCPServer(broken): %v", err)
8448 }
8449 if mcpFailed(app.activeCtrl(), "broken") {
8450 t.Fatalf("Host.Failures() still contains broken after remove: %+v", app.activeCtrl().Host().Failures())
8451 }
8452 view = app.Capabilities()
8453 for _, s := range view.Servers {
8454 if s.Name == "broken" {
8455 t.Fatalf("Capabilities after remove still contains broken: %+v", view.Servers)
8456 }
8457 }
8458 }
8459
8460 func TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {
8461 isolateDesktopUserDirs(t)
8462 dir := robustTempDir(t)
8463 t.Chdir(dir)
8464 if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{
8465 "mcpServers": {
8466 "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] },
8467 "keep": { "command": "keep-mcp" }
8468 }
8469 }`), 0o644); err != nil {
8470 t.Fatal(err)
8471 }
8472
8473 app := NewApp()
8474 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8475 defer app.activeCtrl().Close()
8476
8477 if err := app.RemoveMCPServer("codegraph"); err != nil {
8478 t.Fatalf("RemoveMCPServer(.mcp.json codegraph): %v", err)
8479 }
8480 cfg, err := config.LoadForRoot(dir)
8481 if err != nil {
8482 t.Fatal(err)
8483 }
8484 if _, ok := findPluginEntry(cfg.Plugins, "codegraph"); ok {
8485 t.Fatalf("codegraph still merged after remove: %+v", cfg.Plugins)
8486 }
8487 if _, ok := findPluginEntry(cfg.Plugins, "keep"); !ok {
8488 t.Fatalf("unrelated .mcp.json server should be preserved: %+v", cfg.Plugins)
8489 }
8490 }
8491
8492 func TestRemoveMCPServerRejectsPluginManagedServerWithoutDisconnecting(t *testing.T) {
8493 isolateDesktopUserDirs(t)
8494 dir := robustTempDir(t)
8495 t.Chdir(dir)
8496
8497 srv := desktopMCPHTTPServer(t)
8498 defer srv.Close()
8499 reasonixHome := config.ReasonixHomeDir()
8500 root := filepath.Join(reasonixHome, "plugins", "superpowers")
8501 if err := os.MkdirAll(root, 0o755); err != nil {
8502 t.Fatal(err)
8503 }
8504 if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), fmt.Appendf(nil, `{"apiVersion": "reasonix.io/plugin/v2",
8505 "name": "superpowers",
8506 "version": "1.0.0",
8507 "mcpServers": {
8508 "helper": { "type": "http", "url": %q }
8509 }
8510 }`, srv.URL), 0o644); err != nil {
8511 t.Fatal(err)
8512 }
8513 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
8514 Name: "superpowers",
8515 Root: "plugins/superpowers",
8516 Version: "1.0.0",
8517 ManifestKind: "reasonix",
8518 Enabled: true,
8519 }); err != nil {
8520 t.Fatal(err)
8521 }
8522
8523 cfg, err := config.LoadForRoot(dir)
8524 if err != nil {
8525 t.Fatal(err)
8526 }
8527 entry, ok := findPluginEntry(cfg.Plugins, "helper")
8528 if !ok {
8529 t.Fatalf("plugin-managed MCP missing from config: %+v", cfg.Plugins)
8530 }
8531 ctrl := control.New(control.Options{Host: plugin.NewHost()})
8532 defer ctrl.Close()
8533 if _, err := ctrl.ConnectMCPServer(entry); err != nil {
8534 t.Fatalf("connect plugin-managed MCP: %v", err)
8535 }
8536
8537 app := NewApp()
8538 app.setTestCtrl(ctrl, "")
8539 app.activeTab().WorkspaceRoot = dir
8540 err = app.RemoveMCPServer("helper")
8541 if err == nil || !strings.Contains(err.Error(), "managed by plugin") || !strings.Contains(err.Error(), "superpowers") {
8542 t.Fatalf("RemoveMCPServer(plugin-managed) error = %v", err)
8543 }
8544 if !mcpConnected(ctrl, "helper") {
8545 t.Fatal("plugin-managed MCP was disconnected despite rejected removal")
8546 }
8547 for action, actionErr := range map[string]error{
8548 "clear auth": app.ClearMCPServerAuthentication("helper"),
8549 "update": app.UpdateMCPServer("helper", MCPServerInput{Name: "helper", Transport: "http", URL: srv.URL}),
8550 } {
8551 if actionErr == nil || !strings.Contains(actionErr.Error(), "managed by plugin") {
8552 t.Fatalf("%s plugin-managed MCP error = %v", action, actionErr)
8553 }
8554 }
8555 if _, found := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, "helper"); found {
8556 t.Fatal("plugin-managed MCP mutation created a user-config shadow")
8557 }
8558 servers := app.MCPServers()
8559 if len(servers) != 1 || servers[0].Name != "helper" || servers[0].ManagedByPlugin != "superpowers" {
8560 t.Fatalf("MCPServers() = %+v, want helper managed by superpowers", servers)
8561 }
8562 }
8563
8564 func TestRemoveMCPServerRejectsRuntimeOnlyServerWithoutDisconnecting(t *testing.T) {
8565 isolateDesktopUserDirs(t)
8566 dir := robustTempDir(t)
8567 t.Chdir(dir)
8568
8569 srv := desktopMCPHTTPServer(t)
8570 defer srv.Close()
8571 ctrl := control.New(control.Options{Host: plugin.NewHost()})
8572 defer ctrl.Close()
8573 if _, err := ctrl.ConnectMCPServer(config.PluginEntry{Name: "runtime-only", Type: "http", URL: srv.URL}); err != nil {
8574 t.Fatalf("connect runtime-only MCP: %v", err)
8575 }
8576
8577 app := NewApp()
8578 app.setTestCtrl(ctrl, "")
8579 app.activeTab().WorkspaceRoot = dir
8580 err := app.RemoveMCPServer("runtime-only")
8581 if err == nil || !strings.Contains(err.Error(), "no removable MCP server") {
8582 t.Fatalf("RemoveMCPServer(runtime-only) error = %v", err)
8583 }
8584 if !mcpConnected(ctrl, "runtime-only") {
8585 t.Fatal("runtime-only MCP was disconnected despite failed persistence removal")
8586 }
8587 }
8588
8589 func TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {
8590 isolateDesktopUserDirs(t)
8591 dir := robustTempDir(t)
8592 t.Chdir(dir)
8593 if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{
8594 "mcpServers": {
8595 "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] }
8596 }
8597 }`), 0o644); err != nil {
8598 t.Fatal(err)
8599 }
8600
8601 app := NewApp()
8602 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8603 defer app.activeCtrl().Close()
8604 entry, ok, err := desktopEffectiveMCPServer(dir, "codegraph")
8605 if err != nil || !ok {
8606 t.Fatalf("load codegraph entry: found=%v err=%v", ok, err)
8607 }
8608 if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
8609 t.Fatal(err)
8610 }
8611 app.activeTab().disabledMCP["codegraph"] = ServerView{}
8612
8613 if err := app.UpdateMCPServer("codegraph", MCPServerInput{
8614 Name: "codegraph",
8615 Transport: "stdio",
8616 Command: "reasonix-missing-mcp-binary",
8617 Args: []string{"serve", "--mcp"},
8618 Env: map[string]string{"CODEGRAPH_LOG": "debug"},
8619 }); err != nil {
8620 t.Fatalf("UpdateMCPServer(.mcp.json codegraph): %v", err)
8621 }
8622
8623 raw, err := os.ReadFile(filepath.Join(dir, ".mcp.json"))
8624 if err != nil {
8625 t.Fatal(err)
8626 }
8627 var doc struct {
8628 MCPServers map[string]struct {
8629 Command string `json:"command"`
8630 Args []string `json:"args"`
8631 Env map[string]string `json:"env"`
8632 } `json:"mcpServers"`
8633 }
8634 if err := json.Unmarshal(raw, &doc); err != nil {
8635 t.Fatal(err)
8636 }
8637 got := doc.MCPServers["codegraph"]
8638 if got.Command != "reasonix-missing-mcp-binary" || !reflect.DeepEqual(got.Args, []string{"serve", "--mcp"}) || got.Env["CODEGRAPH_LOG"] != "debug" {
8639 t.Fatalf(".mcp.json codegraph = %+v, want updated command/args/env", got)
8640 }
8641 if _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, "codegraph"); ok {
8642 t.Fatalf(".mcp.json update should not create a user config shadow entry")
8643 }
8644 }
8645
8646 func TestUpdateMCPServerPreservesProjectTOMLSourceAndGlobalShadow(t *testing.T) {
8647 isolateDesktopUserDirs(t)
8648 dir := robustTempDir(t)
8649 t.Chdir(dir)
8650 userCfg := config.LoadForEdit(config.UserConfigPath())
8651 userCfg.Plugins = []config.PluginEntry{{Name: "docs", Command: "global-docs"}}
8652 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
8653 t.Fatal(err)
8654 }
8655 projectPath := filepath.Join(dir, "reasonix.toml")
8656 if err := os.WriteFile(projectPath, []byte(`
8657 [[plugins]]
8658 name = "docs"
8659 command = "project-docs"
8660 `), 0o644); err != nil {
8661 t.Fatal(err)
8662 }
8663
8664 app := NewApp()
8665 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), WorkspaceRoot: dir}), "")
8666 defer app.activeCtrl().Close()
8667 app.activeTab().WorkspaceRoot = dir
8668 entry, ok, err := desktopEffectiveMCPServer(dir, "docs")
8669 if err != nil || !ok || entry.Source != config.MCPSourceProjectConfig {
8670 t.Fatalf("load project docs entry: entry=%+v found=%v err=%v", entry, ok, err)
8671 }
8672 if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
8673 t.Fatal(err)
8674 }
8675 app.activeTab().disabledMCP["docs"] = ServerView{}
8676
8677 if err := app.UpdateMCPServer("docs", MCPServerInput{
8678 Name: "docs", Transport: "stdio", Command: "project-docs-updated",
8679 }); err != nil {
8680 t.Fatalf("UpdateMCPServer(project reasonix.toml docs): %v", err)
8681 }
8682
8683 projectCfg := config.LoadForEdit(projectPath)
8684 projectEntry, found := findPluginEntry(projectCfg.Plugins, "docs")
8685 if !found || projectEntry.Command != "project-docs-updated" {
8686 t.Fatalf("project docs entry = %+v, found=%v", projectEntry, found)
8687 }
8688 globalCfg := config.LoadForEdit(config.UserConfigPath())
8689 globalEntry, found := findPluginEntry(globalCfg.Plugins, "docs")
8690 if !found || globalEntry.Command != "global-docs" {
8691 t.Fatalf("global shadow changed while editing project entry: %+v, found=%v", globalEntry, found)
8692 }
8693 effective, found, err := desktopEffectiveMCPServer(dir, "docs")
8694 if err != nil || !found || effective.Source != config.MCPSourceProjectConfig || effective.Command != "project-docs-updated" {
8695 t.Fatalf("effective docs after edit = %+v, found=%v err=%v", effective, found, err)
8696 }
8697 }
8698
8699 func TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {
8700 isolateDesktopUserDirs(t)
8701 dir := robustTempDir(t)
8702 t.Chdir(dir)
8703 t.Setenv("STRIPE_TOKEN", "stripe-test-token")
8704 srv := desktopMCPHTTPServer(t)
8705 defer srv.Close()
8706
8707 app := NewApp()
8708 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8709 defer app.activeCtrl().Close()
8710
8711 tools, err := app.AddMCPServer(MCPServerInput{
8712 Name: "stripe",
8713 Transport: "http",
8714 URL: srv.URL,
8715 Headers: map[string]string{
8716 "Authorization": "Bearer ${STRIPE_TOKEN}",
8717 "X-Org": "team",
8718 },
8719 })
8720 if err != nil {
8721 t.Fatalf("AddMCPServer(stripe): %v", err)
8722 }
8723 if tools != 1 {
8724 t.Fatalf("tools = %d, want 1", tools)
8725 }
8726
8727 cfg, err := config.LoadForRoot(dir)
8728 if err != nil {
8729 t.Fatal(err)
8730 }
8731 p, ok := findPluginEntry(cfg.Plugins, "stripe")
8732 if !ok {
8733 t.Fatalf("stripe plugin missing from config: %+v", cfg.Plugins)
8734 }
8735 if p.Type != "http" || p.URL != srv.URL {
8736 t.Fatalf("stripe plugin transport = %q url = %q", p.Type, p.URL)
8737 }
8738 if p.Headers["Authorization"] != "Bearer ${STRIPE_TOKEN}" || p.Headers["X-Org"] != "team" {
8739 t.Fatalf("stripe headers = %+v", p.Headers)
8740 }
8741
8742 view := app.MCPServers()
8743 for _, s := range view {
8744 if s.Name == "stripe" {
8745 if !reflect.DeepEqual(s.HeaderKeys, []string{"Authorization", "X-Org"}) {
8746 t.Fatalf("stripe header keys = %+v", s.HeaderKeys)
8747 }
8748 return
8749 }
8750 }
8751 t.Fatalf("stripe MCP missing from view: %+v", view)
8752 }
8753
8754 func TestInstallMCPServerHandshakeFailureDoesNotPersist(t *testing.T) {
8755 isolateDesktopUserDirs(t)
8756 dir := robustTempDir(t)
8757 t.Chdir(dir)
8758
8759 app := NewApp()
8760 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8761 defer app.activeCtrl().Close()
8762
8763 result, err := app.InstallMCPServer(MCPServerInput{
8764 Name: "broken", Transport: "stdio", Command: "reasonix-missing-mcp-binary",
8765 })
8766 if err != nil {
8767 t.Fatalf("InstallMCPServer returned transport error instead of structured issue: %v", err)
8768 }
8769 if result.State != "issue" || result.Action != "retry" {
8770 t.Fatalf("install result = %+v, want retryable issue", result)
8771 }
8772 cfg, err := config.LoadForRoot(dir)
8773 if err != nil {
8774 t.Fatal(err)
8775 }
8776 if _, ok := findPluginEntry(cfg.Plugins, "broken"); ok {
8777 t.Fatalf("failed candidate was persisted: %+v", cfg.Plugins)
8778 }
8779 for _, server := range app.MCPServers() {
8780 if server.Name == "broken" {
8781 t.Fatalf("failed candidate leaked into the installed server list: %+v", server)
8782 }
8783 }
8784 }
8785
8786 func TestInstallMCPServerAuthenticationRequiredPersistsForResume(t *testing.T) {
8787 isolateDesktopUserDirs(t)
8788 dir := robustTempDir(t)
8789 t.Chdir(dir)
8790 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
8791 http.Error(w, "unauthorized", http.StatusUnauthorized)
8792 }))
8793 defer srv.Close()
8794
8795 app := NewApp()
8796 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8797 defer app.activeCtrl().Close()
8798
8799 result, err := app.InstallMCPServer(MCPServerInput{Name: "oauth", Transport: "http", URL: srv.URL})
8800 if err != nil {
8801 t.Fatalf("InstallMCPServer auth result: %v", err)
8802 }
8803 if result.State != "action_required" || result.Action != "authenticate" {
8804 t.Fatalf("install result = %+v, want authentication action", result)
8805 }
8806 cfg, err := config.LoadForRoot(dir)
8807 if err != nil {
8808 t.Fatal(err)
8809 }
8810 if _, ok := findPluginEntry(cfg.Plugins, "oauth"); !ok {
8811 t.Fatalf("auth-pending candidate must persist for resume: %+v", cfg.Plugins)
8812 }
8813 }
8814
8815 func TestAddMCPServerPersistsConnectionConfiguration(t *testing.T) {
8816 isolateDesktopUserDirs(t)
8817 dir := robustTempDir(t)
8818 t.Chdir(dir)
8819 srv := desktopMCPHTTPServer(t)
8820 defer srv.Close()
8821
8822 app := NewApp()
8823 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8824 defer app.activeCtrl().Close()
8825 autoStart := false
8826 callTimeout := 45
8827 _, err := app.AddMCPServer(MCPServerInput{
8828 Name: "admin",
8829 Transport: "streamable-http",
8830 URL: srv.URL,
8831 AutoStart: &autoStart,
8832 CallTimeoutSeconds: &callTimeout,
8833 ToolTimeoutSeconds: map[string]int{
8834 "wipe": 120,
8835 },
8836 })
8837 if err != nil {
8838 t.Fatal(err)
8839 }
8840
8841 cfg, err := config.LoadForRoot(dir)
8842 if err != nil {
8843 t.Fatal(err)
8844 }
8845 entry, ok := findPluginEntry(cfg.Plugins, "admin")
8846 if !ok || entry.Type != "http" || entry.AutoStart == nil || *entry.AutoStart ||
8847 entry.CallTimeoutSeconds != 45 || entry.ToolTimeoutSeconds["wipe"] != 120 {
8848 t.Fatalf("persisted advanced MCP entry = %+v, found=%v", entry, ok)
8849 }
8850
8851 views := app.MCPServers()
8852 if len(views) != 1 || views[0].Transport != "http" || views[0].AutoStart ||
8853 views[0].CallTimeoutSeconds != 45 || views[0].ToolTimeoutSeconds["wipe"] != 120 {
8854 t.Fatalf("advanced MCP ServerView = %+v", views)
8855 }
8856 }
8857
8858 func TestUpdateMCPServerPreservesAbsentFieldsAndClearsExplicitOnes(t *testing.T) {
8859 isolateDesktopUserDirs(t)
8860 dir := robustTempDir(t)
8861 t.Chdir(dir)
8862 srv := desktopMCPHTTPServer(t)
8863 defer srv.Close()
8864
8865 app := NewApp()
8866 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8867 defer app.activeCtrl().Close()
8868 callTimeout := 45
8869 if _, err := app.AddMCPServer(MCPServerInput{
8870 Name: "admin",
8871 Transport: "http",
8872 URL: srv.URL,
8873 CallTimeoutSeconds: &callTimeout,
8874 ToolTimeoutSeconds: map[string]int{"wipe": 120},
8875 }); err != nil {
8876 t.Fatal(err)
8877 }
8878
8879 // An old frontend (or a partial payload) omits optional timeout fields.
8880 if err := app.UpdateMCPServer("admin", MCPServerInput{Name: "admin", Transport: "http", URL: srv.URL}); err != nil {
8881 t.Fatal(err)
8882 }
8883 cfg, err := config.LoadForRoot(dir)
8884 if err != nil {
8885 t.Fatal(err)
8886 }
8887 entry, ok := findPluginEntry(cfg.Plugins, "admin")
8888 if !ok || entry.CallTimeoutSeconds != 45 || entry.ToolTimeoutSeconds["wipe"] != 120 {
8889 t.Fatalf("absent input fields must preserve persisted values, entry = %+v, found=%v", entry, ok)
8890 }
8891
8892 // Explicit zero values are the editor's clear semantics.
8893 cleared := 0
8894 if err := app.UpdateMCPServer("admin", MCPServerInput{
8895 Name: "admin",
8896 Transport: "http",
8897 URL: srv.URL,
8898 CallTimeoutSeconds: &cleared,
8899 ToolTimeoutSeconds: map[string]int{},
8900 }); err != nil {
8901 t.Fatal(err)
8902 }
8903 cfg, err = config.LoadForRoot(dir)
8904 if err != nil {
8905 t.Fatal(err)
8906 }
8907 entry, ok = findPluginEntry(cfg.Plugins, "admin")
8908 if !ok || entry.CallTimeoutSeconds != 0 || len(entry.ToolTimeoutSeconds) != 0 {
8909 t.Fatalf("explicit empty fields must clear persisted values, entry = %+v, found=%v", entry, ok)
8910 }
8911 }
8912
8913 func TestUpdateMCPServerFailedCandidateRollsBackConfigAndConnection(t *testing.T) {
8914 isolateDesktopUserDirs(t)
8915 dir := robustTempDir(t)
8916 t.Chdir(dir)
8917 srv := desktopMCPHTTPServer(t)
8918 defer srv.Close()
8919
8920 app := NewApp()
8921 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8922 defer app.activeCtrl().Close()
8923 if _, err := app.AddMCPServer(MCPServerInput{Name: "stable", Transport: "http", URL: srv.URL}); err != nil {
8924 t.Fatal(err)
8925 }
8926
8927 err := app.UpdateMCPServer("stable", MCPServerInput{
8928 Name: "stable", Transport: "stdio", Command: "reasonix-missing-mcp-binary",
8929 })
8930 if err == nil {
8931 t.Fatal("broken update candidate should fail")
8932 }
8933 cfg, err := config.LoadForRoot(dir)
8934 if err != nil {
8935 t.Fatal(err)
8936 }
8937 entry, ok := findPluginEntry(cfg.Plugins, "stable")
8938 if !ok || entry.Type != "http" || entry.URL != srv.URL {
8939 t.Fatalf("failed update changed durable config: %+v, found=%v", entry, ok)
8940 }
8941 if !app.activeCtrl().Host().HasClient("stable") {
8942 t.Fatal("previous MCP connection was not restored after failed update")
8943 }
8944 }
8945
8946 func TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {
8947 isolateDesktopUserDirs(t)
8948 dir := robustTempDir(t)
8949 t.Chdir(dir)
8950 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
8951 [[plugins]]
8952 name = "dida"
8953 type = "http"
8954 url = "https://mcp.dida365.com"
8955 tier = "lazy"
8956 `), 0o644); err != nil {
8957 t.Fatal(err)
8958 }
8959
8960 app := NewApp()
8961 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8962 defer app.activeCtrl().Close()
8963
8964 view := app.Capabilities()
8965 for _, s := range view.Servers {
8966 if s.Name == "dida" {
8967 if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" || s.AuthStatus != "possible" || s.AuthURL != "https://mcp.dida365.com" {
8968 t.Fatalf("dida auth diagnosis = %+v", s)
8969 }
8970 return
8971 }
8972 }
8973 t.Fatalf("dida MCP missing from Capabilities: %+v", view.Servers)
8974 }
8975
8976 func TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {
8977 isolateDesktopUserDirs(t)
8978 dir := robustTempDir(t)
8979 t.Chdir(dir)
8980 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
8981 [[plugins]]
8982 name = "stripe"
8983 type = "http"
8984 url = "https://mcp.stripe.com"
8985 headers = { Authorization = "Bearer ${STRIPE_TOKEN}" }
8986 tier = "lazy"
8987 `), 0o644); err != nil {
8988 t.Fatal(err)
8989 }
8990
8991 app := NewApp()
8992 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8993 defer app.activeCtrl().Close()
8994
8995 view := app.Capabilities()
8996 for _, s := range view.Servers {
8997 if s.Name == "stripe" {
8998 if s.AuthStatus != "none" {
8999 t.Fatalf("stripe auth status = %q, want none; server = %+v", s.AuthStatus, s)
9000 }
9001 return
9002 }
9003 }
9004 t.Fatalf("stripe MCP missing from Capabilities: %+v", view.Servers)
9005 }
9006
9007 func TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {
9008 isolateDesktopUserDirs(t)
9009 dir := robustTempDir(t)
9010 t.Chdir(dir)
9011 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9012 [[plugins]]
9013 name = "figma"
9014 type = "http"
9015 url = "https://mcp.figma.com/mcp"
9016 tier = "lazy"
9017 `), 0o644); err != nil {
9018 t.Fatal(err)
9019 }
9020
9021 host := plugin.NewHost()
9022 host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
9023 app := NewApp()
9024 app.setTestCtrl(control.New(control.Options{Host: host}), "")
9025 defer app.activeCtrl().Close()
9026
9027 view := app.Capabilities()
9028 for _, s := range view.Servers {
9029 if s.Name == "figma" {
9030 if s.Status != "failed" || s.AuthStatus != "required" || s.AuthURL != "https://mcp.figma.com/mcp" {
9031 t.Fatalf("figma auth diagnosis = %+v", s)
9032 }
9033 return
9034 }
9035 }
9036 t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
9037 }
9038
9039 func TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {
9040 isolateDesktopUserDirs(t)
9041 dir := robustTempDir(t)
9042 t.Chdir(dir)
9043 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9044 [[plugins]]
9045 name = "figma"
9046 type = "http"
9047 url = "https://mcp.figma.com/mcp?access_token=abc&workspace=main"
9048 headers = { Authorization = "Bearer ${FIGMA_TOKEN}", "X-Org" = "team" }
9049 env = { FIGMA_TOKEN = "${FIGMA_TOKEN}", DEBUG = "1" }
9050 tier = "lazy"
9051 `), 0o644); err != nil {
9052 t.Fatal(err)
9053 }
9054
9055 host := plugin.NewHost()
9056 host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
9057 app := NewApp()
9058 app.setTestCtrl(control.New(control.Options{Host: host}), "")
9059 defer app.activeCtrl().Close()
9060
9061 if err := app.ClearMCPServerAuthentication("figma"); err != nil {
9062 t.Fatalf("ClearMCPServerAuthentication: %v", err)
9063 }
9064 if failures := host.Failures(); len(failures) != 0 {
9065 t.Fatalf("failure should be cleared: %+v", failures)
9066 }
9067 cfg, err := config.Load()
9068 if err != nil {
9069 t.Fatal(err)
9070 }
9071 p := cfg.Plugins[0]
9072 if p.URL != "https://mcp.figma.com/mcp?workspace=main" {
9073 t.Fatalf("url = %q", p.URL)
9074 }
9075 if _, ok := p.Headers["Authorization"]; ok {
9076 t.Fatalf("auth header should be removed: %v", p.Headers)
9077 }
9078 if p.Headers["X-Org"] != "team" {
9079 t.Fatalf("ordinary header should be preserved: %v", p.Headers)
9080 }
9081 if _, ok := p.Env["FIGMA_TOKEN"]; ok {
9082 t.Fatalf("auth env should be removed: %v", p.Env)
9083 }
9084 if p.Env["DEBUG"] != "1" {
9085 t.Fatalf("ordinary env should be preserved: %v", p.Env)
9086 }
9087 view := app.Capabilities()
9088 for _, s := range view.Servers {
9089 if s.Name == "figma" {
9090 if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" || s.AuthStatus != "possible" {
9091 t.Fatalf("figma should return to background possible auth: %+v", s)
9092 }
9093 return
9094 }
9095 }
9096 t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
9097 }
9098
9099 func TestUpdateMCPServerMigratesLegacyTierInProjectSource(t *testing.T) {
9100 isolateDesktopUserDirs(t)
9101 dir := robustTempDir(t)
9102 t.Chdir(dir)
9103 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9104 [[plugins]]
9105 name = "playwright"
9106 command = "npx"
9107 args = ["-y", "@playwright/mcp"]
9108 env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
9109 tier = "lazy"
9110 `), 0o644); err != nil {
9111 t.Fatal(err)
9112 }
9113
9114 app := NewApp()
9115 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9116 defer func() {
9117 if c := app.activeCtrl(); c != nil {
9118 c.Close()
9119 }
9120 }()
9121 entry, ok, err := desktopEffectiveMCPServer(dir, "playwright")
9122 if err != nil || !ok {
9123 t.Fatalf("load playwright entry: found=%v err=%v", ok, err)
9124 }
9125 if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
9126 t.Fatal(err)
9127 }
9128 app.activeTab().disabledMCP["playwright"] = ServerView{Name: "playwright", Status: "disabled", Enabled: false}
9129
9130 if err := app.UpdateMCPServer("playwright", MCPServerInput{
9131 Name: "playwright",
9132 Transport: "stdio",
9133 Command: "node",
9134 Args: []string{"server.js"},
9135 }); err != nil {
9136 t.Fatalf("UpdateMCPServer: %v", err)
9137 }
9138 cfg, err := config.Load()
9139 if err != nil {
9140 t.Fatal(err)
9141 }
9142 if got := cfg.Plugins[0].Command; got != "node" {
9143 t.Fatalf("updated command = %q, want node", got)
9144 }
9145 if got := cfg.Plugins[0].Env["TOKEN"]; got != "${PLAYWRIGHT_TOKEN}" {
9146 t.Fatalf("env TOKEN = %q, want preserved env", got)
9147 }
9148 userCfg := config.LoadForEdit(config.UserConfigPath())
9149 if _, ok := findPluginEntry(userCfg.Plugins, "playwright"); ok {
9150 t.Fatalf("project plugin should not be copied to user config: %+v", userCfg.Plugins)
9151 }
9152 projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
9153 projectPlugin, ok := findPluginEntry(projectCfg.Plugins, "playwright")
9154 if !ok {
9155 t.Fatalf("playwright should remain in project config: %+v", projectCfg.Plugins)
9156 }
9157 if projectPlugin.Command != "node" || projectPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
9158 t.Fatalf("project plugin after update = %+v", projectPlugin)
9159 }
9160 if projectPlugin.Tier != "" {
9161 t.Fatalf("project plugin tier = %q, want migrated empty", projectPlugin.Tier)
9162 }
9163 view := app.Capabilities()
9164 for _, s := range view.Servers {
9165 if s.Name == "playwright" {
9166 if s.Status != "disabled" {
9167 t.Fatalf("updated MCP status = %q, want disabled without a readiness probe; server = %+v", s.Status, s)
9168 }
9169 if s.Command != "node" || len(s.Args) != 1 || s.Args[0] != "server.js" {
9170 t.Fatalf("server command not refreshed: %+v", s)
9171 }
9172 return
9173 }
9174 }
9175 t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
9176 }
9177
9178 func TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {
9179 isolateDesktopUserDirs(t)
9180 dir := t.TempDir()
9181 t.Chdir(dir)
9182 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9183 [[plugins]]
9184 name = "playwright"
9185 command = "npx"
9186 args = ["-y", "@playwright/mcp"]
9187 `), 0o644); err != nil {
9188 t.Fatal(err)
9189 }
9190
9191 app := NewApp()
9192 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9193 defer app.activeCtrl().Close()
9194 app.activeTab().disabledMCP["playwright"] = ServerView{}
9195
9196 if err := app.UpdateMCPServer("playwright", MCPServerInput{
9197 Name: "playwright",
9198 Transport: "stdio",
9199 Command: "npx -y @modelcontextprotocol/server-filesystem .",
9200 }); err != nil {
9201 t.Fatalf("UpdateMCPServer: %v", err)
9202 }
9203 cfg, err := config.Load()
9204 if err != nil {
9205 t.Fatal(err)
9206 }
9207 p := cfg.Plugins[0]
9208 if p.Command != "npx" {
9209 t.Fatalf("command = %q, want npx", p.Command)
9210 }
9211 if got := strings.Join(p.Args, "\x00"); got != strings.Join([]string{"-y", "@modelcontextprotocol/server-filesystem", "."}, "\x00") {
9212 t.Fatalf("args = %v", p.Args)
9213 }
9214 }
9215
9216 func TestUpdateMCPServerRejectsReconnectFailureWithoutPersisting(t *testing.T) {
9217 isolateDesktopUserDirs(t)
9218 dir := robustTempDir(t)
9219 t.Chdir(dir)
9220 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9221 [[plugins]]
9222 name = "broken"
9223 command = "reasonix-old-missing-mcp-binary"
9224 tier = "background"
9225 `), 0o644); err != nil {
9226 t.Fatal(err)
9227 }
9228
9229 app := NewApp()
9230 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9231 defer app.activeCtrl().Close()
9232
9233 if err := app.UpdateMCPServer("broken", MCPServerInput{
9234 Name: "broken",
9235 Transport: "stdio",
9236 Command: "reasonix-missing-mcp-binary",
9237 }); err == nil {
9238 t.Fatal("UpdateMCPServer should reject an unusable candidate")
9239 }
9240 cfg, err := config.Load()
9241 if err != nil {
9242 t.Fatal(err)
9243 }
9244 if got := cfg.Plugins[0].Command; got != "reasonix-old-missing-mcp-binary" {
9245 t.Fatalf("failed update command = %q, want original command", got)
9246 }
9247 if got := cfg.Plugins[0].Tier; got != "" {
9248 t.Fatalf("loaded legacy tier = %q, want normalized empty", got)
9249 }
9250 if !mcpFailed(app.activeCtrl(), "broken") {
9251 t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
9252 }
9253 view := app.Capabilities()
9254 for _, s := range view.Servers {
9255 if s.Name == "broken" {
9256 if s.Status != "failed" {
9257 t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
9258 }
9259 if s.Command != "reasonix-old-missing-mcp-binary" || s.Tier != "background" {
9260 t.Fatalf("failed candidate leaked into server config: %+v", s)
9261 }
9262 return
9263 }
9264 }
9265 t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
9266 }
9267
9268 func TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {
9269 isolateDesktopUserDirs(t)
9270 dir := robustTempDir(t)
9271 t.Chdir(dir)
9272 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9273 [[plugins]]
9274 name = "codegraph"
9275 `), 0o644); err != nil {
9276 t.Fatal(err)
9277 }
9278
9279 reg := tool.NewRegistry()
9280 reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
9281 app := NewApp()
9282 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), "")
9283 defer app.activeCtrl().Close()
9284
9285 view := app.Capabilities()
9286 foundIdle := false
9287 for _, s := range view.Servers {
9288 if s.Name == "codegraph" {
9289 foundIdle = true
9290 if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
9291 t.Fatalf("initial codegraph server = %+v, want automatic idle background state", s)
9292 }
9293 }
9294 }
9295 if !foundIdle {
9296 t.Fatalf("codegraph missing before reconnect: %+v", view.Servers)
9297 }
9298 if _, ok := reg.Get("mcp__codegraph__connect"); !ok {
9299 t.Fatal("test setup expected stale codegraph connect placeholder")
9300 }
9301
9302 if err := app.ReconnectMCPServer("codegraph"); err == nil || !strings.Contains(err.Error(), "command is required") {
9303 t.Fatalf("ReconnectMCPServer error = %v, want missing command", err)
9304 }
9305 if _, ok := reg.Get("mcp__codegraph__connect"); ok {
9306 t.Fatalf("stale codegraph placeholder still registered after reconnect failure; names=%v", reg.Names())
9307 }
9308 if !mcpFailed(app.activeCtrl(), "codegraph") {
9309 t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded", app.activeCtrl().Host().Failures())
9310 }
9311
9312 view = app.Capabilities()
9313 for _, s := range view.Servers {
9314 if s.Name == "codegraph" {
9315 if s.Status != "failed" || s.Error == "" {
9316 t.Fatalf("codegraph after failed reconnect = %+v, want failed with error", s)
9317 }
9318 return
9319 }
9320 }
9321 t.Fatalf("codegraph missing after reconnect: %+v", view.Servers)
9322 }
9323
9324 func TestSetMCPServerTierPreservesProjectSourceAndRecordsConnectFailure(t *testing.T) {
9325 isolateDesktopUserDirs(t)
9326 dir := robustTempDir(t)
9327 t.Chdir(dir)
9328 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9329 [[plugins]]
9330 name = "broken"
9331 command = "reasonix-missing-mcp-binary"
9332 tier = "lazy"
9333 `), 0o644); err != nil {
9334 t.Fatal(err)
9335 }
9336
9337 app := NewApp()
9338 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9339 defer func() {
9340 if c := app.activeCtrl(); c != nil {
9341 c.Close()
9342 }
9343 }()
9344
9345 if err := app.SetMCPServerTier("broken", "background"); err != nil {
9346 t.Fatalf("SetMCPServerTier legacy binding: %v", err)
9347 }
9348 cfg, err := config.Load()
9349 if err != nil {
9350 t.Fatal(err)
9351 }
9352 if got := cfg.Plugins[0].Tier; got != "" {
9353 t.Fatalf("saved tier = %q, want migrated empty", got)
9354 }
9355 userCfg := config.LoadForEdit(config.UserConfigPath())
9356 if _, ok := findPluginEntry(userCfg.Plugins, "broken"); ok {
9357 t.Fatalf("project plugin should not be copied to user config: %+v", userCfg.Plugins)
9358 }
9359 projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
9360 projectPlugin, ok := findPluginEntry(projectCfg.Plugins, "broken")
9361 if !ok {
9362 t.Fatalf("broken should remain in project config: %+v", projectCfg.Plugins)
9363 }
9364 if projectPlugin.Tier != "" {
9365 t.Fatalf("project plugin tier = %q, want migrated empty", projectPlugin.Tier)
9366 }
9367 if !mcpFailed(app.activeCtrl(), "broken") {
9368 t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
9369 }
9370 view := app.Capabilities()
9371 for _, s := range view.Servers {
9372 if s.Name == "broken" {
9373 if s.Status != "failed" {
9374 t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
9375 }
9376 if s.Tier != "background" {
9377 t.Fatalf("server tier = %q, want background so radio selection does not jump back", s.Tier)
9378 }
9379 return
9380 }
9381 }
9382 t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
9383 }
9384
9385 func TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {
9386 isolateDesktopUserDirs(t)
9387 dir := robustTempDir(t)
9388 t.Chdir(dir)
9389 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
9390 t.Fatalf("mkdir config dir: %v", err)
9391 }
9392 if err := os.WriteFile(config.UserConfigPath(), []byte(`
9393 [[plugins]]
9394 name = "broken"
9395 command = "reasonix-missing-mcp-binary"
9396 tier = "lazy"
9397 `), 0o644); err != nil {
9398 t.Fatal(err)
9399 }
9400
9401 app := NewApp()
9402 app.setTestCtrl(newBackgroundJobController(t, "mcp-tier-job"), "")
9403
9404 err := app.SetMCPServerTier("broken", "background")
9405 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
9406 t.Fatalf("SetMCPServerTier with background job error = %v, want active-work guard", err)
9407 }
9408 data, readErr := os.ReadFile(config.UserConfigPath())
9409 if readErr != nil {
9410 t.Fatalf("read config: %v", readErr)
9411 }
9412 if !strings.Contains(string(data), `tier = "lazy"`) {
9413 t.Fatalf("plugin config changed after rejected tier update:\n%s", data)
9414 }
9415 }
9416
9417 func TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {
9418 isolateDesktopUserDirs(t)
9419 dir := robustTempDir(t)
9420 t.Chdir(dir)
9421 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9422 [[plugins]]
9423 name = "broken"
9424 command = "reasonix-missing-mcp-binary"
9425 tier = "eager"
9426 `), 0o644); err != nil {
9427 t.Fatal(err)
9428 }
9429
9430 app := NewApp()
9431 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9432 defer app.activeCtrl().Close()
9433 recordMCPFailure(app.activeCtrl(), config.PluginEntry{
9434 Name: "broken",
9435 Command: "reasonix-missing-mcp-binary",
9436 Tier: "eager",
9437 }, errors.New("connect: missing binary"))
9438
9439 view := app.Capabilities()
9440 for _, s := range view.Servers {
9441 if s.Name == "broken" {
9442 if s.Status != "failed" {
9443 t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
9444 }
9445 if s.Tier != "background" {
9446 t.Fatalf("server tier = %q, want migrated background default", s.Tier)
9447 }
9448 if !s.Configured {
9449 t.Fatalf("server configured = false, want true; server = %+v", s)
9450 }
9451 return
9452 }
9453 }
9454 t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
9455 }
9456
9457 func TestRunShellForTabRoutesToRequestedTab(t *testing.T) {
9458 isolateDesktopUserDirs(t)
9459
9460 activeEvents := make(chan event.Event, 16)
9461 inactiveEvents := make(chan event.Event, 16)
9462 activeCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})
9463 inactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})
9464 defer activeCtrl.Close()
9465 defer inactiveCtrl.Close()
9466
9467 app := &App{
9468 tabs: map[string]*WorkspaceTab{
9469 "active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
9470 "inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
9471 },
9472 tabOrder: []string{"active", "inactive"},
9473 activeTabID: "active",
9474 }
9475
9476 app.RunShellForTab("inactive", "echo route-test")
9477
9478 sawDispatch := false
9479 deadline := time.After(3 * time.Second)
9480 for {
9481 select {
9482 case e := <-inactiveEvents:
9483 if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, "route-test") {
9484 sawDispatch = true
9485 }
9486 if e.Kind == event.TurnDone {
9487 if !sawDispatch {
9488 t.Fatal("inactive tab finished without receiving shell dispatch")
9489 }
9490 select {
9491 case active := <-activeEvents:
9492 t.Fatalf("active tab received event for inactive shell: %+v", active)
9493 default:
9494 }
9495 return
9496 }
9497 case <-deadline:
9498 t.Fatal("timed out waiting for inactive shell turn")
9499 }
9500 }
9501 }
9502
9503 func TestRunShellForTabStaysBoundDuringRapidProjectTabSwitching(t *testing.T) {
9504 if testing.Short() {
9505 t.Skip("skipping shell cancellation integration test in short mode")
9506 }
9507
9508 isolateDesktopUserDirs(t)
9509
9510 projectA := t.TempDir()
9511 projectB := t.TempDir()
9512 globalRoot := t.TempDir()
9513 shellEvents := make(chan event.Event, 64)
9514 projectEvents := make(chan event.Event, 64)
9515 globalEvents := make(chan event.Event, 64)
9516 shellCtrl := control.New(control.Options{
9517 Sink: event.FuncSink(func(e event.Event) { shellEvents <- e }),
9518 WorkspaceRoot: projectA,
9519 })
9520 projectCtrl := control.New(control.Options{
9521 Sink: event.FuncSink(func(e event.Event) { projectEvents <- e }),
9522 WorkspaceRoot: projectB,
9523 })
9524 globalCtrl := control.New(control.Options{
9525 Sink: event.FuncSink(func(e event.Event) { globalEvents <- e }),
9526 WorkspaceRoot: globalRoot,
9527 })
9528 defer shellCtrl.Close()
9529 defer projectCtrl.Close()
9530 defer globalCtrl.Close()
9531
9532 app := &App{
9533 tabs: map[string]*WorkspaceTab{
9534 "shell": {ID: "shell", Scope: "project", WorkspaceRoot: projectA, Ctrl: shellCtrl, Ready: true},
9535 "project-b": {ID: "project-b", Scope: "project", WorkspaceRoot: projectB, Ctrl: projectCtrl, Ready: true},
9536 "global": {ID: "global", Scope: "global", WorkspaceRoot: globalRoot, Ctrl: globalCtrl, Ready: true},
9537 },
9538 tabOrder: []string{"shell", "project-b", "global"},
9539 activeTabID: "shell",
9540 }
9541
9542 marker := "shell-route-marker.txt"
9543 if err := app.RunShellForTab("shell", longRunningMarkerCommand(marker)); err != nil {
9544 t.Fatalf("RunShellForTab: %v", err)
9545 }
9546 waitForShellDispatch(t, shellEvents, marker)
9547 waitForFile(t, filepath.Join(projectA, marker), "shell")
9548
9549 for range 8 {
9550 if err := app.SetActiveTab("project-b"); err != nil {
9551 t.Fatalf("SetActiveTab(project-b): %v", err)
9552 }
9553 if err := app.SetActiveTab("global"); err != nil {
9554 t.Fatalf("SetActiveTab(global): %v", err)
9555 }
9556 if err := app.SetActiveTab("shell"); err != nil {
9557 t.Fatalf("SetActiveTab(shell): %v", err)
9558 }
9559 }
9560 if err := app.SetActiveTab("project-b"); err != nil {
9561 t.Fatalf("SetActiveTab(project-b final): %v", err)
9562 }
9563 app.CancelTab("shell")
9564
9565 cancelled := false
9566 deadline := time.After(15 * time.Second)
9567 for {
9568 select {
9569 case e := <-shellEvents:
9570 if e.Kind == event.ToolResult && e.Tool.Name == "bash" {
9571 cancelled = e.Tool.Err != ""
9572 }
9573 if e.Kind == event.TurnDone {
9574 if !cancelled {
9575 t.Fatal("shell tab finished without a cancelled shell result")
9576 }
9577 if _, err := os.Stat(filepath.Join(projectB, marker)); !errors.Is(err, os.ErrNotExist) {
9578 t.Fatalf("shell marker appeared in project-b workspace: %v", err)
9579 }
9580 if got := activeTabIDForTest(app); got != "project-b" {
9581 t.Fatalf("active tab = %q, want project-b after background shell cancel", got)
9582 }
9583 assertNoEvents(t, projectEvents, "project-b")
9584 assertNoEvents(t, globalEvents, "global")
9585 return
9586 }
9587 case <-deadline:
9588 t.Fatal("timed out waiting for shell tab cancellation")
9589 }
9590 }
9591 }
9592
9593 func longRunningMarkerCommand(marker string) string {
9594 if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell {
9595 return fmt.Sprintf("Set-Content -LiteralPath %s -Value shell; Start-Sleep -Seconds 30", marker)
9596 }
9597 return fmt.Sprintf("printf shell > %s; sleep 30", marker)
9598 }
9599
9600 func waitForShellDispatch(t *testing.T, ch <-chan event.Event, marker string) {
9601 t.Helper()
9602 deadline := time.After(5 * time.Second)
9603 for {
9604 select {
9605 case e := <-ch:
9606 if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, marker) {
9607 return
9608 }
9609 case <-deadline:
9610 t.Fatal("timed out waiting for shell dispatch")
9611 }
9612 }
9613 }
9614
9615 func activeTabIDForTest(app *App) string {
9616 app.mu.RLock()
9617 defer app.mu.RUnlock()
9618 return app.activeTabID
9619 }
9620
9621 func assertNoEvents(t *testing.T, ch <-chan event.Event, name string) {
9622 t.Helper()
9623 select {
9624 case e := <-ch:
9625 t.Fatalf("%s received event while shell ran in another tab: %+v", name, e)
9626 default:
9627 }
9628 }
9629
9630 type blockingRunner struct {
9631 started chan struct{}
9632 release chan struct{}
9633 }
9634
9635 func (r *blockingRunner) Run(ctx context.Context, _ string) error {
9636 close(r.started)
9637 select {
9638 case <-ctx.Done():
9639 return ctx.Err()
9640 case <-r.release:
9641 return nil
9642 }
9643 }
9644
9645 func startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {
9646 t.Helper()
9647 started := make(chan struct{})
9648 release := make(chan struct{})
9649 jm.StartForSession(agent.BranchID(sessionPath), "bash", "stuck job", func(ctx context.Context, _ io.Writer) (string, error) {
9650 close(started)
9651 <-ctx.Done()
9652 <-release
9653 return "", ctx.Err()
9654 })
9655 select {
9656 case <-started:
9657 case <-time.After(2 * time.Second):
9658 t.Fatal("background job never started")
9659 }
9660 released := false
9661 return func() {
9662 if released {
9663 return
9664 }
9665 released = true
9666 close(release)
9667 }
9668 }
9669
9670 func newBackgroundJobController(t *testing.T, label string) *control.Controller {
9671 t.Helper()
9672 dir := config.SessionDir()
9673 if err := os.MkdirAll(dir, 0o755); err != nil {
9674 t.Fatalf("mkdir session dir: %v", err)
9675 }
9676 path := filepath.Join(dir, label+".jsonl")
9677 jm := jobs.NewManager(event.Discard)
9678 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
9679 t.Cleanup(ctrl.Close)
9680 jm.StartForSession(agent.BranchID(path), "bash", label, func(ctx context.Context, _ io.Writer) (string, error) {
9681 <-ctx.Done()
9682 return "", ctx.Err()
9683 })
9684 return ctrl
9685 }
9686
9687 func hasLevel(levels []string, want string) bool {
9688 return slices.Contains(levels, want)
9689 }
9690
9691 func hasCommand(cmds []CommandInfo, name string) bool {
9692 for _, cmd := range cmds {
9693 if cmd.Name == name {
9694 return true
9695 }
9696 }
9697 return false
9698 }
9699
9700 func hasDirEntry(entries []DirEntry, name string) bool {
9701 for _, entry := range entries {
9702 if entry.Name == name {
9703 return true
9704 }
9705 }
9706 return false
9707 }
9708
9709 func TestSessionActionsWithoutControllerReturnError(t *testing.T) {
9710 app := &App{tabs: map[string]*WorkspaceTab{}}
9711 if err := app.NewSession(); err == nil {
9712 t.Error("NewSession with no controller must surface an error, not silently no-op")
9713 }
9714 if _, err := app.ClearSession(); err == nil {
9715 t.Error("ClearSession with no controller must surface an error")
9716 }
9717
9718 app = &App{
9719 tabs: map[string]*WorkspaceTab{"t1": {ID: "t1", StartupErr: "boot exploded"}},
9720 activeTabID: "t1",
9721 }
9722 err := app.NewSession()
9723 if err == nil || !strings.Contains(err.Error(), "boot exploded") {
9724 t.Errorf("error should carry the tab's startup failure, got %v", err)
9725 }
9726 }
9727
9728 // Prompt history scanning tests
9729
9730 func identityPromptDisplay(text string) string { return text }
9731
9732 // TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format
9733 // {"kind":"user.message","text":"..."} is correctly extracted.
9734 func TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {
9735 dir := t.TempDir()
9736 path := filepath.Join(dir, "session.jsonl")
9737 if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"hello world"}
9738 {"kind":"user.message","text":"second prompt"}
9739 {"kind":"model.final","content":"response"}
9740 `), 0o644); err != nil {
9741 t.Fatal(err)
9742 }
9743 info, err := os.Stat(path)
9744 if err != nil {
9745 t.Fatal(err)
9746 }
9747 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9748 if err != nil {
9749 t.Fatal(err)
9750 }
9751 if len(entries) != 2 {
9752 t.Fatalf("expected 2 entries, got %d", len(entries))
9753 }
9754 if entries[0].Text != "hello world" {
9755 t.Errorf("expected 'hello world', got %q", entries[0].Text)
9756 }
9757 if entries[1].Text != "second prompt" {
9758 t.Errorf("expected 'second prompt', got %q", entries[1].Text)
9759 }
9760 if entries[0].Turn != 0 || entries[1].Turn != 1 {
9761 t.Errorf("expected turns 0,1; got %d,%d", entries[0].Turn, entries[1].Turn)
9762 }
9763 if entries[0].SessionPath != path {
9764 t.Errorf("expected session path %q, got %q", path, entries[0].SessionPath)
9765 }
9766 }
9767
9768 // TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event
9769 // format {"type":"user.message","text":"..."} is correctly extracted.
9770 func TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {
9771 dir := t.TempDir()
9772 path := filepath.Join(dir, "session.jsonl")
9773 if err := os.WriteFile(path, []byte(`{"type":"user.message","text":"v0 prompt"}
9774 {"type":"model.final","content":"response"}
9775 `), 0o644); err != nil {
9776 t.Fatal(err)
9777 }
9778 info, err := os.Stat(path)
9779 if err != nil {
9780 t.Fatal(err)
9781 }
9782 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9783 if err != nil {
9784 t.Fatal(err)
9785 }
9786 if len(entries) != 1 {
9787 t.Fatalf("expected 1 entry, got %d", len(entries))
9788 }
9789 if entries[0].Text != "v0 prompt" {
9790 t.Errorf("expected 'v0 prompt', got %q", entries[0].Text)
9791 }
9792 }
9793
9794 // TestCollectPromptHistoryEntriesProviderMessage verifies that the current
9795 // provider.Message format {"role":"user","content":"..."} is correctly extracted.
9796 func TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {
9797 dir := t.TempDir()
9798 path := filepath.Join(dir, "session.jsonl")
9799 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello from provider"}
9800 {"role":"assistant","content":"response"}
9801 {"role":"user","content":"another prompt"}
9802 `), 0o644); err != nil {
9803 t.Fatal(err)
9804 }
9805 info, err := os.Stat(path)
9806 if err != nil {
9807 t.Fatal(err)
9808 }
9809 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9810 if err != nil {
9811 t.Fatal(err)
9812 }
9813 if len(entries) != 2 {
9814 t.Fatalf("expected 2 entries, got %d", len(entries))
9815 }
9816 if entries[0].Text != "hello from provider" {
9817 t.Errorf("expected 'hello from provider', got %q", entries[0].Text)
9818 }
9819 if entries[1].Text != "another prompt" {
9820 t.Errorf("expected 'another prompt', got %q", entries[1].Text)
9821 }
9822 }
9823
9824 // TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the
9825 // same file are extracted.
9826 func TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {
9827 dir := t.TempDir()
9828 path := filepath.Join(dir, "session.jsonl")
9829 if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy prompt"}
9830 {"role":"user","content":"modern prompt"}
9831 `), 0o644); err != nil {
9832 t.Fatal(err)
9833 }
9834 info, err := os.Stat(path)
9835 if err != nil {
9836 t.Fatal(err)
9837 }
9838 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9839 if err != nil {
9840 t.Fatal(err)
9841 }
9842 if len(entries) != 2 {
9843 t.Fatalf("expected 2 entries, got %d", len(entries))
9844 }
9845 if entries[0].Text != "legacy prompt" {
9846 t.Errorf("expected 'legacy prompt', got %q", entries[0].Text)
9847 }
9848 if entries[1].Text != "modern prompt" {
9849 t.Errorf("expected 'modern prompt', got %q", entries[1].Text)
9850 }
9851 }
9852
9853 func TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {
9854 dir := t.TempDir()
9855 path := filepath.Join(dir, "session.jsonl")
9856 rfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)
9857 if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy timed","time":1800000000123}
9858 {"role":"user","content":"modern timed","createdAt":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}
9859 `), 0o644); err != nil {
9860 t.Fatal(err)
9861 }
9862 info, err := os.Stat(path)
9863 if err != nil {
9864 t.Fatal(err)
9865 }
9866 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9867 if err != nil {
9868 t.Fatal(err)
9869 }
9870 if len(entries) != 2 {
9871 t.Fatalf("expected 2 entries, got %d", len(entries))
9872 }
9873 if entries[0].At != 1800000000123 {
9874 t.Errorf("numeric event time = %d, want 1800000000123", entries[0].At)
9875 }
9876 if entries[1].At != rfcTime.UnixMilli() {
9877 t.Errorf("RFC3339 event time = %d, want %d", entries[1].At, rfcTime.UnixMilli())
9878 }
9879 }
9880
9881 // TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses
9882 // the user-visible prompt text, not the controller-expanded model input.
9883 func TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {
9884 dir := t.TempDir()
9885 path := filepath.Join(dir, "session.jsonl")
9886 expanded := "<memory-update>\nSaved memory\n</memory-update>\n\nvisible prompt"
9887 if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(expanded)+`}`+"\n"), 0o644); err != nil {
9888 t.Fatal(err)
9889 }
9890 if err := recordSessionDisplay(dir, path, expanded, "visible prompt"); err != nil {
9891 t.Fatal(err)
9892 }
9893 info, err := os.Stat(path)
9894 if err != nil {
9895 t.Fatal(err)
9896 }
9897 entries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))
9898 if err != nil {
9899 t.Fatal(err)
9900 }
9901 if len(entries) != 1 {
9902 t.Fatalf("expected 1 entry, got %d", len(entries))
9903 }
9904 if entries[0].Text != "visible prompt" {
9905 t.Errorf("expected visible prompt, got %q", entries[0].Text)
9906 }
9907 }
9908
9909 func TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {
9910 dir := t.TempDir()
9911 path := filepath.Join(dir, "session.jsonl")
9912 if err := os.WriteFile(path, []byte(`{"role":"user","content":"Plan approved — plan mode is off"}
9913 {"role":"user","content":"real prompt"}
9914 `), 0o644); err != nil {
9915 t.Fatal(err)
9916 }
9917 info, err := os.Stat(path)
9918 if err != nil {
9919 t.Fatal(err)
9920 }
9921 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9922 if err != nil {
9923 t.Fatal(err)
9924 }
9925 if len(entries) != 1 {
9926 t.Fatalf("expected 1 entry, got %d", len(entries))
9927 }
9928 if entries[0].Text != "real prompt" {
9929 t.Errorf("expected real prompt, got %q", entries[0].Text)
9930 }
9931 }
9932
9933 // TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only
9934 // assistant/tool messages returns no entries.
9935 func TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {
9936 dir := t.TempDir()
9937 path := filepath.Join(dir, "session.jsonl")
9938 if err := os.WriteFile(path, []byte(`{"kind":"model.final","content":"response"}
9939 {"kind":"tool.result","output":"done"}
9940 `), 0o644); err != nil {
9941 t.Fatal(err)
9942 }
9943 info, err := os.Stat(path)
9944 if err != nil {
9945 t.Fatal(err)
9946 }
9947 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9948 if err != nil {
9949 t.Fatal(err)
9950 }
9951 if len(entries) != 0 {
9952 t.Errorf("expected 0 entries, got %d", len(entries))
9953 }
9954 }
9955
9956 // TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file
9957 // returns no entries without error.
9958 func TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {
9959 dir := t.TempDir()
9960 path := filepath.Join(dir, "empty.jsonl")
9961 if err := os.WriteFile(path, nil, 0o644); err != nil {
9962 t.Fatal(err)
9963 }
9964 info, err := os.Stat(path)
9965 if err != nil {
9966 t.Fatal(err)
9967 }
9968 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9969 if err != nil {
9970 t.Fatal(err)
9971 }
9972 if len(entries) != 0 {
9973 t.Errorf("expected 0 entries, got %d", len(entries))
9974 }
9975 }
9976
9977 // TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans
9978 // multiple JSONL files and returns prompts newest-first.
9979 func TestScanPromptHistoryFromDir(t *testing.T) {
9980 app := &App{tabs: map[string]*WorkspaceTab{"t1": {ID: "t1", Ctrl: nil, WorkspaceRoot: ""}}}
9981 _ = app
9982
9983 dir := t.TempDir()
9984 // Write two session files with different mtimes (sleep to ensure ordering).
9985 if err := os.WriteFile(filepath.Join(dir, "a.jsonl"), []byte(`{"role":"user","content":"older prompt"}
9986 `), 0o644); err != nil {
9987 t.Fatal(err)
9988 }
9989 time.Sleep(10 * time.Millisecond)
9990 if err := os.WriteFile(filepath.Join(dir, "b.jsonl"), []byte(`{"role":"user","content":"newer prompt"}
9991 `), 0o644); err != nil {
9992 t.Fatal(err)
9993 }
9994
9995 entries, err := app.scanPromptHistoryFromDir(dir)
9996 if err != nil {
9997 t.Fatal(err)
9998 }
9999 if len(entries) != 2 {
10000 t.Fatalf("expected 2 entries, got %d", len(entries))
10001 }
10002 // Newest-first: "newer prompt" should be first.
10003 if entries[0].Text != "newer prompt" {
10004 t.Errorf("expected 'newer prompt' first, got %q", entries[0].Text)
10005 }
10006 if entries[1].Text != "older prompt" {
10007 t.Errorf("expected 'older prompt' second, got %q", entries[1].Text)
10008 }
10009 }
10010
10011 func TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {
10012 app := &App{}
10013 dir := t.TempDir()
10014 base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10015 early := filepath.Join(dir, "early.jsonl")
10016 late := filepath.Join(dir, "late.jsonl")
10017
10018 if err := os.WriteFile(early, fmt.Appendf(nil, `{"role":"user","content":"early first","time":%d}
10019 {"role":"assistant","content":"ok"}
10020 {"role":"user","content":"early second","time":%d}
10021 `, base.UnixMilli(), base.Add(time.Minute).UnixMilli()), 0o644); err != nil {
10022 t.Fatal(err)
10023 }
10024 if err := os.WriteFile(late, fmt.Appendf(nil, `{"role":"user","content":"late newest","time":%d}
10025 `, base.Add(2*time.Minute).UnixMilli()), 0o644); err != nil {
10026 t.Fatal(err)
10027 }
10028 // Invert file mtimes: session activity should keep each session grouped
10029 // before event timestamps are considered within that session.
10030 if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {
10031 t.Fatal(err)
10032 }
10033 if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {
10034 t.Fatal(err)
10035 }
10036
10037 entries, err := app.scanPromptHistoryFromDir(dir)
10038 if err != nil {
10039 t.Fatal(err)
10040 }
10041 if len(entries) != 3 {
10042 t.Fatalf("expected 3 entries, got %d", len(entries))
10043 }
10044 want := []string{"early second", "early first", "late newest"}
10045 for i, w := range want {
10046 if entries[i].Text != w {
10047 t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries)
10048 }
10049 }
10050 }
10051
10052 func TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {
10053 app := &App{}
10054 dir := t.TempDir()
10055 base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10056 early := filepath.Join(dir, "early.jsonl")
10057 late := filepath.Join(dir, "late.jsonl")
10058
10059 if err := os.WriteFile(early, []byte(`{"role":"user","content":"early first"}
10060 {"role":"assistant","content":"ok"}
10061 {"role":"user","content":"early second"}
10062 `), 0o644); err != nil {
10063 t.Fatal(err)
10064 }
10065 if err := os.WriteFile(late, []byte(`{"role":"user","content":"late newest"}
10066 `), 0o644); err != nil {
10067 t.Fatal(err)
10068 }
10069 if err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{
10070 CreatedAt: base,
10071 UpdatedAt: base.Add(time.Minute),
10072 }); err != nil {
10073 t.Fatal(err)
10074 }
10075 if err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{
10076 CreatedAt: base.Add(time.Minute),
10077 UpdatedAt: base.Add(2 * time.Minute),
10078 }); err != nil {
10079 t.Fatal(err)
10080 }
10081 // Invert file mtimes: branch UpdatedAt should be the activity clock.
10082 if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {
10083 t.Fatal(err)
10084 }
10085 if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {
10086 t.Fatal(err)
10087 }
10088
10089 entries, err := app.scanPromptHistoryFromDir(dir)
10090 if err != nil {
10091 t.Fatal(err)
10092 }
10093 if len(entries) != 3 {
10094 t.Fatalf("expected 3 entries, got %d", len(entries))
10095 }
10096 want := []string{"late newest", "early second", "early first"}
10097 for i, w := range want {
10098 if entries[i].Text != w {
10099 t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries)
10100 }
10101 }
10102 }
10103
10104 func TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {
10105 app := &App{}
10106 dir := t.TempDir()
10107 base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10108 empty := filepath.Join(dir, "empty.jsonl")
10109 real := filepath.Join(dir, "real.jsonl")
10110
10111 if err := os.WriteFile(empty, nil, 0o644); err != nil {
10112 t.Fatal(err)
10113 }
10114 if err := os.WriteFile(real, []byte(`{"role":"user","content":"real prompt"}
10115 `), 0o644); err != nil {
10116 t.Fatal(err)
10117 }
10118 if err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{
10119 CreatedAt: base,
10120 UpdatedAt: base.Add(time.Hour),
10121 }); err != nil {
10122 t.Fatal(err)
10123 }
10124 if err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{
10125 CreatedAt: base,
10126 UpdatedAt: base,
10127 }); err != nil {
10128 t.Fatal(err)
10129 }
10130
10131 entries, err := app.scanPromptHistoryFromDir(dir)
10132 if err != nil {
10133 t.Fatal(err)
10134 }
10135 if len(entries) != 1 || entries[0].Text != "real prompt" {
10136 t.Fatalf("entries = %+v, want only real prompt after skipping empty session", entries)
10137 }
10138 }
10139
10140 func TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {
10141 dir := t.TempDir()
10142 current := filepath.Join(dir, "current.jsonl")
10143 other := filepath.Join(dir, "other.jsonl")
10144 if err := os.WriteFile(current, []byte(`{"role":"user","content":"current first"}
10145 {"role":"assistant","content":"ok"}
10146 {"role":"user","content":"current second"}
10147 `), 0o644); err != nil {
10148 t.Fatal(err)
10149 }
10150 if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"}
10151 `), 0o644); err != nil {
10152 t.Fatal(err)
10153 }
10154 now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10155 if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{
10156 CreatedAt: now,
10157 UpdatedAt: now,
10158 }); err != nil {
10159 t.Fatal(err)
10160 }
10161 if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{
10162 CreatedAt: now.Add(time.Minute),
10163 UpdatedAt: now.Add(time.Minute),
10164 }); err != nil {
10165 t.Fatal(err)
10166 }
10167
10168 app := NewApp()
10169 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"})
10170 defer ctrl.Close()
10171 app.setTestCtrl(ctrl, "")
10172
10173 result, err := app.ScanPromptHistory("")
10174 if err != nil {
10175 t.Fatal(err)
10176 }
10177 if len(result.Entries) != 3 {
10178 t.Fatalf("expected current-session entries followed by cross-session fallback, got %d: %+v", len(result.Entries), result.Entries)
10179 }
10180 want := []string{"current second", "current first", "other newest"}
10181 for i, w := range want {
10182 if result.Entries[i].Text != w {
10183 t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, result.Entries[i].Text, w, result.Entries)
10184 }
10185 }
10186 }
10187
10188 func TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {
10189 dir := t.TempDir()
10190 current := filepath.Join(dir, "current.jsonl")
10191 other := filepath.Join(dir, "other.jsonl")
10192 var lines []byte
10193 for i := range 55 {
10194 lines = append(lines, fmt.Appendf(nil, `{"role":"user","content":"current %d"}
10195 `, i)...)
10196 }
10197 if err := os.WriteFile(current, lines, 0o644); err != nil {
10198 t.Fatal(err)
10199 }
10200 if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"}
10201 `), 0o644); err != nil {
10202 t.Fatal(err)
10203 }
10204 now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10205 if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{
10206 CreatedAt: now,
10207 UpdatedAt: now,
10208 }); err != nil {
10209 t.Fatal(err)
10210 }
10211 if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{
10212 CreatedAt: now.Add(time.Minute),
10213 UpdatedAt: now.Add(time.Minute),
10214 }); err != nil {
10215 t.Fatal(err)
10216 }
10217
10218 app := NewApp()
10219 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"})
10220 defer ctrl.Close()
10221 app.setTestCtrl(ctrl, "")
10222
10223 result, err := app.ScanPromptHistory("")
10224 if err != nil {
10225 t.Fatal(err)
10226 }
10227 if len(result.Entries) != promptHistoryPageLimit {
10228 t.Fatalf("expected %d entries, got %d", promptHistoryPageLimit, len(result.Entries))
10229 }
10230 if result.Entries[0].Text != "current 54" {
10231 t.Fatalf("first entry = %q, want current 54", result.Entries[0].Text)
10232 }
10233 if result.Entries[len(result.Entries)-1].Text != "current 5" {
10234 t.Fatalf("last first-page entry = %q, want current 5", result.Entries[len(result.Entries)-1].Text)
10235 }
10236 if !result.HasOlder || result.OlderCursor == "" {
10237 t.Fatalf("first page should expose an older cursor: %+v", result)
10238 }
10239 for _, entry := range result.Entries {
10240 if entry.Text == "other newest" {
10241 t.Fatalf("cross-session entry appeared before current-session page was exhausted: %+v", result.Entries)
10242 }
10243 }
10244
10245 nextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})
10246 if err != nil {
10247 t.Fatal(err)
10248 }
10249 next, err := app.ScanPromptHistory(string(nextRequest))
10250 if err != nil {
10251 t.Fatal(err)
10252 }
10253 want := []string{"current 4", "current 3", "current 2", "current 1", "current 0", "other newest"}
10254 if len(next.Entries) != len(want) {
10255 t.Fatalf("second page entries = %+v, want %d entries", next.Entries, len(want))
10256 }
10257 for i, w := range want {
10258 if next.Entries[i].Text != w {
10259 t.Fatalf("second page entries[%d] = %q, want %q; all=%+v", i, next.Entries[i].Text, w, next.Entries)
10260 }
10261 }
10262 }
10263
10264 func TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {
10265 app := &App{}
10266 dir := t.TempDir()
10267 var lines []byte
10268 for i := range 250 {
10269 lines = append(lines, fmt.Appendf(nil, `{"role":"user","content":"prompt %d"}
10270 `, i)...)
10271 }
10272 if err := os.WriteFile(filepath.Join(dir, "many.jsonl"), lines, 0o644); err != nil {
10273 t.Fatal(err)
10274 }
10275 entries, err := app.scanPromptHistoryFromDir(dir)
10276 if err != nil {
10277 t.Fatal(err)
10278 }
10279 if len(entries) != 250 {
10280 t.Fatalf("expected 250 entries, got %d", len(entries))
10281 }
10282 if entries[0].Text != "prompt 249" {
10283 t.Errorf("expected newest 'prompt 249' first, got %q", entries[0].Text)
10284 }
10285 }
10286
10287 // TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.
10288 func TestScanPromptHistoryFromDirEmpty(t *testing.T) {
10289 app := &App{}
10290 dir := t.TempDir()
10291 entries, err := app.scanPromptHistoryFromDir(dir)
10292 if err != nil {
10293 t.Fatal(err)
10294 }
10295 if len(entries) != 0 {
10296 t.Errorf("expected 0 entries, got %d", len(entries))
10297 }
10298 }
10299
10300 // TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil
10301 // on cache hit (nonce matches).
10302 func TestScanPromptHistoryCacheHit(t *testing.T) {
10303 app := &App{tabs: map[string]*WorkspaceTab{}}
10304 result, err := app.ScanPromptHistory("")
10305 if err != nil {
10306 t.Fatal(err)
10307 }
10308 nonce := result.Nonce
10309 if nonce == "" {
10310 t.Error("expected a non-empty nonce on first call")
10311 }
10312
10313 // Second call with the same nonce should be a cache hit (nil entries).
10314 result2, err := app.ScanPromptHistory(nonce)
10315 if err != nil {
10316 t.Fatal(err)
10317 }
10318 if result2.Entries != nil {
10319 t.Error("expected nil entries on cache hit")
10320 }
10321 if result2.Nonce != nonce {
10322 t.Errorf("expected nonce %q unchanged, got %q", nonce, result2.Nonce)
10323 }
10324 }
10325
10326 func TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {
10327 dirA := t.TempDir()
10328 dirB := t.TempDir()
10329 pathA := filepath.Join(dirA, "a.jsonl")
10330 pathB := filepath.Join(dirB, "b.jsonl")
10331 if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"}
10332 `), 0o644); err != nil {
10333 t.Fatal(err)
10334 }
10335 if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"}
10336 `), 0o644); err != nil {
10337 t.Fatal(err)
10338 }
10339
10340 app := NewApp()
10341 ctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"})
10342 ctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: "test"})
10343 defer ctrlA.Close()
10344 defer ctrlB.Close()
10345
10346 app.setTestCtrl(ctrlA, "")
10347 first, err := app.ScanPromptHistory("")
10348 if err != nil {
10349 t.Fatal(err)
10350 }
10351 if len(first.Entries) != 1 || first.Entries[0].Text != "workspace A" {
10352 t.Fatalf("first entries = %+v, want workspace A", first.Entries)
10353 }
10354
10355 app.setTestCtrl(ctrlB, "")
10356 second, err := app.ScanPromptHistory(first.Nonce)
10357 if err != nil {
10358 t.Fatal(err)
10359 }
10360 if second.Entries == nil {
10361 t.Fatal("expected rescan after session dir changes, got cache hit")
10362 }
10363 if len(second.Entries) != 1 || second.Entries[0].Text != "workspace B" {
10364 t.Fatalf("second entries = %+v, want workspace B", second.Entries)
10365 }
10366 }
10367
10368 func TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {
10369 dir := t.TempDir()
10370 pathA := filepath.Join(dir, "a.jsonl")
10371 pathB := filepath.Join(dir, "b.jsonl")
10372 if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"session A"}
10373 `), 0o644); err != nil {
10374 t.Fatal(err)
10375 }
10376 if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"session B"}
10377 `), 0o644); err != nil {
10378 t.Fatal(err)
10379 }
10380
10381 app := NewApp()
10382 ctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: "test"})
10383 ctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: "test"})
10384 defer ctrlA.Close()
10385 defer ctrlB.Close()
10386
10387 app.setTestCtrl(ctrlA, "")
10388 first, err := app.ScanPromptHistory("")
10389 if err != nil {
10390 t.Fatal(err)
10391 }
10392 if len(first.Entries) != 2 || first.Entries[0].Text != "session A" || first.Entries[1].Text != "session B" {
10393 t.Fatalf("first entries = %+v, want session A followed by session B", first.Entries)
10394 }
10395
10396 app.setTestCtrl(ctrlB, "")
10397 second, err := app.ScanPromptHistory(first.Nonce)
10398 if err != nil {
10399 t.Fatal(err)
10400 }
10401 if second.Entries == nil {
10402 t.Fatal("expected rescan after session path changes, got cache hit")
10403 }
10404 if len(second.Entries) != 2 || second.Entries[0].Text != "session B" || second.Entries[1].Text != "session A" {
10405 t.Fatalf("second entries = %+v, want session B followed by session A", second.Entries)
10406 }
10407 }
10408
10408 lines GO