返回 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 approveWorkspace(t, project)
1107
1108 userCfg := config.LoadForEdit(config.UserConfigPath())
1109 if err := userCfg.SetDesktopLanguage("en"); err != nil {
1110 t.Fatalf("set desktop language: %v", err)
1111 }
1112 if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil {
1113 t.Fatalf("set desktop layout style: %v", err)
1114 }
1115 if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
1116 t.Fatalf("set desktop appearance: %v", err)
1117 }
1118 if err := userCfg.SetDesktopTerminalTheme("light"); err != nil {
1119 t.Fatalf("set desktop terminal theme: %v", err)
1120 }
1121 if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
1122 t.Fatalf("set desktop close behavior: %v", err)
1123 }
1124 if err := userCfg.SetDesktopStatusBarStyle("text"); err != nil {
1125 t.Fatalf("set desktop status bar style: %v", err)
1126 }
1127 if err := userCfg.SetDesktopStatusBarItems([]string{"model", "balance", "cache"}); err != nil {
1128 t.Fatalf("set desktop status bar items: %v", err)
1129 }
1130 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
1131 t.Fatalf("save user config: %v", err)
1132 }
1133
1134 orig, _ := os.Getwd()
1135 defer func() { _ = os.Chdir(orig) }()
1136 if err := os.Chdir(project); err != nil {
1137 t.Fatalf("chdir project: %v", err)
1138 }
1139
1140 got := NewApp().Settings()
1141 if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DesktopTerminalTheme != "light" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" {
1142 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)
1143 }
1144 if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.StatusBarItems, want) {
1145 t.Fatalf("desktop status bar items = %v, want user-level %v", got.StatusBarItems, want)
1146 }
1147 }
1148
1149 func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {
1150 isolateDesktopUserDirs(t)
1151
1152 userCfg := config.LoadForEdit(config.UserConfigPath())
1153 if err := userCfg.SetDesktopLanguage("en"); err != nil {
1154 t.Fatalf("set desktop language: %v", err)
1155 }
1156 if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil {
1157 t.Fatalf("set desktop layout style: %v", err)
1158 }
1159 if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
1160 t.Fatalf("set desktop appearance: %v", err)
1161 }
1162 if err := userCfg.SetDesktopTerminalTheme("light"); err != nil {
1163 t.Fatalf("set desktop terminal theme: %v", err)
1164 }
1165 if err := userCfg.SetDesktopStatusBarStyle("icon"); err != nil {
1166 t.Fatalf("set desktop status bar style: %v", err)
1167 }
1168 if err := userCfg.SetDesktopStatusBarItems([]string{"workspace", "git_branch", "model"}); err != nil {
1169 t.Fatalf("set desktop status bar items: %v", err)
1170 }
1171 if err := userCfg.SetDesktopCheckUpdates(false); err != nil {
1172 t.Fatalf("set desktop check updates: %v", err)
1173 }
1174 if err := userCfg.SetDesktopUpdateChannel("preview"); err != nil {
1175 t.Fatalf("set desktop update channel: %v", err)
1176 }
1177 userCfg.Bot.Enabled = true
1178 userCfg.Bot.Allowlist.Enabled = true
1179 userCfg.Bot.Allowlist.QQUsers = []string{"alice"}
1180 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
1181 t.Fatalf("save user config: %v", err)
1182 }
1183
1184 got := NewApp().DesktopStartupSettings()
1185 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" {
1186 t.Fatalf("DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs", got)
1187 }
1188 if want := []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got.StatusBarItems, want) {
1189 t.Fatalf("DesktopStartupSettings status bar items = %v, want %v", got.StatusBarItems, want)
1190 }
1191 if !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{"alice"}) {
1192 t.Fatalf("DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot", got.Bot)
1193 }
1194
1195 raw, err := json.Marshal(got)
1196 if err != nil {
1197 t.Fatalf("marshal DesktopStartupSettings: %v", err)
1198 }
1199 if strings.Contains(string(raw), "providers") || strings.Contains(string(raw), "officialProviders") || strings.Contains(string(raw), "providerKinds") {
1200 t.Fatalf("DesktopStartupSettings must not include full Settings provider payload: %s", raw)
1201 }
1202 }
1203
1204 func BenchmarkDesktopSettingsPayloads(b *testing.B) {
1205 home := b.TempDir()
1206 xdg := filepath.Join(home, ".config")
1207 appData := filepath.Join(home, "AppData")
1208 for _, dir := range []string{xdg, appData} {
1209 if err := os.MkdirAll(dir, 0o755); err != nil {
1210 b.Fatal(err)
1211 }
1212 }
1213 b.Setenv("HOME", home)
1214 b.Setenv("REASONIX_CREDENTIALS_STORE", "file")
1215 b.Setenv("USERPROFILE", home)
1216 b.Setenv("XDG_CONFIG_HOME", xdg)
1217 b.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
1218 b.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
1219 b.Setenv("AppData", appData)
1220 b.Setenv("SHARED_PROVIDER_KEY", "sk-test")
1221
1222 cfg := config.LoadForEdit(config.UserConfigPath())
1223 for i := range 40 {
1224 cfg.Providers = append(cfg.Providers, config.ProviderEntry{
1225 Name: fmt.Sprintf("custom-%02d", i),
1226 Kind: "openai",
1227 BaseURL: "https://example.invalid/v1",
1228 APIKeyEnv: "SHARED_PROVIDER_KEY",
1229 Models: []string{"model-a", "model-b"},
1230 Default: "model-a",
1231 })
1232 }
1233 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1234 b.Fatalf("save config: %v", err)
1235 }
1236 app := NewApp()
1237
1238 b.Run("Settings", func(b *testing.B) {
1239 for range b.N {
1240 _ = app.Settings()
1241 }
1242 })
1243 b.Run("DesktopStartupSettings", func(b *testing.B) {
1244 for range b.N {
1245 _ = app.DesktopStartupSettings()
1246 }
1247 })
1248 }
1249
1250 func TestSettingsIgnoresActiveWorkspaceDotEnvCredentialsWithUserConfig(t *testing.T) {
1251 isolateDesktopUserDirs(t)
1252
1253 project := robustTempDir(t)
1254 launch := robustTempDir(t)
1255 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("WORKSPACE_ONLY_KEY=from-project\n"), 0o600); err != nil {
1256 t.Fatalf("write project env: %v", err)
1257 }
1258 userCfg := config.LoadForEdit(config.UserConfigPath())
1259 if err := userCfg.UpsertProvider(config.ProviderEntry{
1260 Name: "workspace-provider",
1261 Kind: "openai",
1262 BaseURL: "https://workspace.example/v1",
1263 Model: "workspace-model",
1264 APIKeyEnv: "WORKSPACE_ONLY_KEY",
1265 }); err != nil {
1266 t.Fatalf("upsert provider: %v", err)
1267 }
1268 userCfg.Desktop.ProviderAccess = []string{"workspace-provider"}
1269 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
1270 t.Fatalf("save user config: %v", err)
1271 }
1272 t.Setenv("WORKSPACE_ONLY_KEY", "")
1273 os.Unsetenv("WORKSPACE_ONLY_KEY")
1274 orig, _ := os.Getwd()
1275 defer func() { _ = os.Chdir(orig) }()
1276 if err := os.Chdir(launch); err != nil {
1277 t.Fatalf("chdir launch: %v", err)
1278 }
1279
1280 app := NewApp()
1281 app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}}
1282 app.activeTabID = "project"
1283 got := app.Settings()
1284 for _, p := range got.Providers {
1285 if p.Name == "workspace-provider" {
1286 if p.KeySet {
1287 t.Fatalf("workspace provider keySet = true, want false because workspace .env is ignored: %+v", p)
1288 }
1289 if p.Configured {
1290 t.Fatalf("workspace provider configured = true, want false because workspace .env is ignored: %+v", p)
1291 }
1292 return
1293 }
1294 }
1295 t.Fatalf("workspace provider missing from settings: %+v", got.Providers)
1296 }
1297
1298 func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {
1299 isolateDesktopUserDirs(t)
1300
1301 project := robustTempDir(t)
1302 launch := robustTempDir(t)
1303 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("SHARED_SETTINGS_KEY=from-project\n"), 0o600); err != nil {
1304 t.Fatalf("write project env: %v", err)
1305 }
1306 if _, err := config.SetCredential("SHARED_SETTINGS_KEY", "from-credentials"); err != nil {
1307 t.Fatalf("SetCredential: %v", err)
1308 }
1309 userCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
1310 if err := userCfg.UpsertProvider(config.ProviderEntry{
1311 Name: "settings-provider",
1312 Kind: "openai",
1313 BaseURL: "https://settings.example/v1",
1314 Model: "settings-model",
1315 APIKeyEnv: "SHARED_SETTINGS_KEY",
1316 }); err != nil {
1317 t.Fatalf("upsert provider: %v", err)
1318 }
1319 userCfg.Desktop.ProviderAccess = []string{"settings-provider"}
1320 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
1321 t.Fatalf("save user config: %v", err)
1322 }
1323 t.Setenv("SHARED_SETTINGS_KEY", "from-project")
1324 orig, _ := os.Getwd()
1325 defer func() { _ = os.Chdir(orig) }()
1326 if err := os.Chdir(launch); err != nil {
1327 t.Fatalf("chdir launch: %v", err)
1328 }
1329
1330 app := NewApp()
1331 app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}}
1332 app.activeTabID = "project"
1333 got := app.Settings()
1334 for _, p := range got.Providers {
1335 if p.Name != "settings-provider" {
1336 continue
1337 }
1338 if !p.KeySet || !strings.Contains(p.KeySource, "Reasonix credentials") {
1339 t.Fatalf("settings-provider key = set:%v source:%q, want Reasonix credentials: %+v", p.KeySet, p.KeySource, p)
1340 }
1341 if env := os.Getenv("SHARED_SETTINGS_KEY"); env != "from-project" {
1342 t.Fatalf("Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env", env)
1343 }
1344 return
1345 }
1346 t.Fatalf("settings provider missing from settings: %+v", got.Providers)
1347 }
1348
1349 // With no user config yet, Settings start from the defaults, not from the
1350 // checkout's reasonix.toml, and the first edit writes only the user's choice.
1351 func TestSettingsNeverSeedTheUserConfigFromTheProjectFile(t *testing.T) {
1352 isolateDesktopUserDirs(t)
1353 project := robustTempDir(t)
1354 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\ntheme = \"light\"\n\n[permissions]\nmode = \"allow\"\n"), 0o644); err != nil {
1355 t.Fatalf("write project config: %v", err)
1356 }
1357 approveWorkspace(t, project)
1358 t.Chdir(project)
1359 app := NewApp()
1360 if got := app.Settings(); got.ConfigPath != config.UserConfigPath() || got.DesktopTheme == "light" {
1361 t.Fatalf("Settings = path %q theme %q, want the user config's defaults", got.ConfigPath, got.DesktopTheme)
1362 }
1363 if err := app.SetDesktopLanguage("en"); err != nil {
1364 t.Fatalf("SetDesktopLanguage: %v", err)
1365 }
1366 userCfg := config.LoadForEdit(config.UserConfigPath())
1367 if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() == "light" || userCfg.Permissions.Mode == "allow" {
1368 t.Fatalf("user config took the checkout's values: theme %q mode %q", userCfg.DesktopTheme(), userCfg.Permissions.Mode)
1369 }
1370 }
1371
1372 func TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {
1373 isolateDesktopUserDirs(t)
1374 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1375 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1376 t.Fatalf("mkdir config dir: %v", err)
1377 }
1378 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1379 default_model = "deepseek/deepseek-v4-flash"
1380
1381 [[providers]]
1382 name = "deepseek"
1383 kind = "openai"
1384 base_url = "https://api.deepseek.com"
1385 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
1386 default = "deepseek-v4-flash"
1387 api_key_env = "DEEPSEEK_API_KEY"
1388 `), 0o644); err != nil {
1389 t.Fatalf("write config: %v", err)
1390 }
1391
1392 app := NewApp()
1393 if got := app.Settings().Agent.MaxSubagentDepth; got != agent.DefaultMaxSubagentDepth {
1394 t.Fatalf("default max subagent depth = %d, want %d", got, agent.DefaultMaxSubagentDepth)
1395 }
1396 if err := app.SetSubagentModel("deepseek/deepseek-v4-pro"); err != nil {
1397 t.Fatalf("SetSubagentModel: %v", err)
1398 }
1399 if err := app.SetSubagentEffort("max"); err != nil {
1400 t.Fatalf("SetSubagentEffort: %v", err)
1401 }
1402 if err := app.SetMaxSubagentDepth(1); err != nil {
1403 t.Fatalf("SetMaxSubagentDepth(1): %v", err)
1404 }
1405 if err := app.SetMaxSubagentDepth(2); err != nil {
1406 t.Fatalf("SetMaxSubagentDepth(2): %v", err)
1407 }
1408
1409 got := app.Settings()
1410 if got.SubagentModel != "deepseek/deepseek-v4-pro" || got.SubagentEffort != "max" {
1411 t.Fatalf("subagent settings = model:%q effort:%q", got.SubagentModel, got.SubagentEffort)
1412 }
1413 if got.Agent.MaxSubagentDepth != 2 {
1414 t.Fatalf("max subagent depth = %d, want 2", got.Agent.MaxSubagentDepth)
1415 }
1416 cfg := config.LoadForEdit(config.UserConfigPath())
1417 if cfg.Agent.SubagentModel != "deepseek/deepseek-v4-pro" || cfg.Agent.SubagentEffort != "max" {
1418 t.Fatalf("saved config = model:%q effort:%q", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)
1419 }
1420 if cfg.Agent.MaxSubagentDepth != 2 {
1421 t.Fatalf("saved max_subagent_depth = %d, want 2", cfg.Agent.MaxSubagentDepth)
1422 }
1423 }
1424
1425 func TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {
1426 isolateDesktopUserDirs(t)
1427
1428 got := NewApp().Settings()
1429 providers := providerAccessSet(providerNamesFromView(got.Providers))
1430 official := providerAccessSet(providerNamesFromView(got.OfficialProviders))
1431 if providers["mimo-api"] {
1432 t.Fatalf("mimo-api should not be mixed into configured providers: %+v", got.Providers)
1433 }
1434 if !official["deepseek"] || official["mimo-api"] || official["mimo-token-plan"] {
1435 t.Fatalf("official providers = %+v, want only deepseek", got.OfficialProviders)
1436 }
1437 }
1438
1439 func TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {
1440 isolateDesktopUserDirs(t)
1441 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1442 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1443 t.Fatalf("mkdir config dir: %v", err)
1444 }
1445 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1446 default_model = "deepseek-flash"
1447
1448 [[providers]]
1449 name = "deepseek-flash"
1450 kind = "openai"
1451 base_url = "https://api.deepseek.com"
1452 api_key_env = "DEEPSEEK_API_KEY"
1453 `), 0o644); err != nil {
1454 t.Fatalf("write config: %v", err)
1455 }
1456
1457 got := NewApp().Settings()
1458 for _, p := range got.Providers {
1459 if p.Name != "deepseek" {
1460 continue
1461 }
1462 if !p.BuiltIn {
1463 t.Fatalf("deepseek provider should be marked built-in for official endpoint: %+v", p)
1464 }
1465 if !p.Added || !p.KeySet || !slices.Equal(p.Models, []string{"deepseek-flash", "deepseek-v4-pro", "deepseek-v4-flash"}) || len(p.VisionModels) != 0 || p.Default != "deepseek-v4-flash" {
1466 t.Fatalf("deepseek provider = %+v, want added repaired official model list", p)
1467 }
1468 if got.DefaultModel != "deepseek/deepseek-v4-flash" {
1469 t.Fatalf("default_model = %q, want deepseek/deepseek-v4-flash", got.DefaultModel)
1470 }
1471 return
1472 }
1473 t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
1474 }
1475
1476 func TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {
1477 isolateDesktopUserDirs(t)
1478 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1479 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1480 t.Fatalf("mkdir config dir: %v", err)
1481 }
1482 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1483 default_model = "deepseek/deepseek-v4-Flash"
1484
1485 [desktop]
1486 provider_access = ["deepseek"]
1487
1488 [[providers]]
1489 name = "deepseek"
1490 kind = "openai"
1491 base_url = "https://opencode.ai/zen/go/v1"
1492 models = ["deepseek-v4-Flash", "deepseek-v4-pro", "glm-5"]
1493 default = "deepseek-v4-Flash"
1494 api_key_env = "DEEPSEEK_API_KEY"
1495 `), 0o644); err != nil {
1496 t.Fatalf("write config: %v", err)
1497 }
1498
1499 got := NewApp().Settings()
1500 var custom *ProviderView
1501 for i := range got.Providers {
1502 if got.Providers[i].Name == "deepseek" {
1503 custom = &got.Providers[i]
1504 break
1505 }
1506 }
1507 if custom == nil {
1508 t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
1509 }
1510 if custom.BuiltIn {
1511 t.Fatalf("external deepseek endpoint should be custom, got built-in provider: %+v", *custom)
1512 }
1513 if !custom.Added || !custom.KeySet || custom.BaseURL != "https://opencode.ai/zen/go/v1" {
1514 t.Fatalf("external deepseek provider = %+v, want added key-set custom opencode endpoint", *custom)
1515 }
1516 for _, p := range got.OfficialProviders {
1517 if p.Name == "deepseek" && p.Added {
1518 t.Fatalf("official DeepSeek template should not be marked added by external endpoint: %+v", p)
1519 }
1520 }
1521 }
1522
1523 func TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {
1524 isolateDesktopUserDirs(t)
1525 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1526 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
1527 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1528 t.Fatalf("mkdir config dir: %v", err)
1529 }
1530 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1531 default_model = "deepseek-flash/deepseek-v4-pro"
1532
1533 [[providers]]
1534 name = "deepseek-flash"
1535 kind = "openai"
1536 base_url = "https://api.deepseek.com"
1537 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
1538 default = "deepseek-v4-flash"
1539 api_key_env = "DEEPSEEK_API_KEY"
1540
1541 [[providers]]
1542 name = "mimo-pro"
1543 kind = "openai"
1544 base_url = "https://token-plan-cn.xiaomimimo.com/v1"
1545 model = "mimo-v2.5-pro"
1546 api_key_env = "MIMO_API_KEY"
1547 `), 0o644); err != nil {
1548 t.Fatalf("write config: %v", err)
1549 }
1550
1551 got := NewApp().Settings()
1552 providers := map[string]ProviderView{}
1553 for _, p := range got.Providers {
1554 providers[p.Name] = p
1555 }
1556 if !providers["deepseek"].Added || !providers["deepseek"].KeySet {
1557 t.Fatalf("deepseek provider = %+v, want inferred added key-set provider", providers["deepseek"])
1558 }
1559 if !providers["mimo-pro"].Added || !providers["mimo-pro"].KeySet || providers["mimo-pro"].BuiltIn {
1560 t.Fatalf("mimo-pro provider = %+v, want inferred custom key-set provider", providers["mimo-pro"])
1561 }
1562 if got.DefaultModel != "deepseek/deepseek-v4-pro" {
1563 t.Fatalf("default_model = %q, want deepseek/deepseek-v4-pro", got.DefaultModel)
1564 }
1565 }
1566
1567 func TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {
1568 isolateDesktopUserDirs(t)
1569 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1570 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1571 t.Fatalf("mkdir config dir: %v", err)
1572 }
1573 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1574 default_model = "deepseek-flash/deepseek-v4-flash"
1575
1576 [desktop]
1577 provider_access = []
1578
1579 [[providers]]
1580 name = "deepseek-flash"
1581 kind = "openai"
1582 base_url = "https://api.deepseek.com"
1583 models = ["deepseek-v4-flash"]
1584 default = "deepseek-v4-flash"
1585 api_key_env = "DEEPSEEK_API_KEY"
1586 `), 0o644); err != nil {
1587 t.Fatalf("write config: %v", err)
1588 }
1589
1590 got := NewApp().Settings()
1591 for _, p := range got.Providers {
1592 if p.Added {
1593 t.Fatalf("provider %+v should not be inferred as added when provider_access is explicit empty", p)
1594 }
1595 }
1596 }
1597
1598 func TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {
1599 isolateDesktopUserDirs(t)
1600 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1601 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
1602
1603 got := NewApp().Settings()
1604 providers := map[string]ProviderView{}
1605 for _, p := range got.Providers {
1606 providers[p.Name] = p
1607 }
1608 if !providers["deepseek"].Added || !providers["deepseek"].KeySet {
1609 t.Fatalf("deepseek provider = %+v, want inferred added provider from configured key", providers["deepseek"])
1610 }
1611 if _, ok := providers["mimo-token-plan"]; ok {
1612 t.Fatalf("mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v", providers["mimo-token-plan"])
1613 }
1614 }
1615
1616 func TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {
1617 isolateDesktopUserDirs(t)
1618 t.Setenv("DEEPSEEK_API_KEY", "")
1619 t.Setenv("MIMO_API_KEY", "")
1620
1621 got := NewApp().Settings()
1622 for _, p := range got.Providers {
1623 if p.Added {
1624 t.Fatalf("provider %+v should not be inferred as added without a configured key", p)
1625 }
1626 }
1627 }
1628
1629 func TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {
1630 isolateDesktopUserDirs(t)
1631 t.Setenv("DEEPSEEK_API_KEY", "")
1632 os.Unsetenv("DEEPSEEK_API_KEY")
1633 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1634 t.Fatalf("mkdir config dir: %v", err)
1635 }
1636 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1637 default_model = "deepseek-flash"
1638
1639 [[providers]]
1640 name = "deepseek-flash"
1641 kind = "openai"
1642 base_url = "https://api.deepseek.com"
1643 api_key_env = "DEEPSEEK_API_KEY"
1644 `), 0o644); err != nil {
1645 t.Fatalf("write config: %v", err)
1646 }
1647
1648 if _, err := NewApp().AddOfficialProviderAccess("deepseek", "test-key"); err != nil {
1649 t.Fatalf("AddOfficialProviderAccess: %v", err)
1650 }
1651 cfg := config.LoadForEdit(config.UserConfigPath())
1652 p, ok := cfg.Provider("deepseek")
1653 if !ok {
1654 t.Fatal("deepseek provider not saved")
1655 }
1656 if !slices.Equal(p.Models, []string{"deepseek-flash", "deepseek-v4-pro"}) || len(p.VisionModels) != 0 || p.Default != "deepseek-flash" {
1657 t.Fatalf("deepseek provider after add = %+v, want official model list", p)
1658 }
1659 if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
1660 t.Fatalf("provider_access missing deepseek: %+v", cfg.Desktop.ProviderAccess)
1661 }
1662 if cfg.DefaultModel != "deepseek/deepseek-v4-flash" {
1663 t.Fatalf("default_model = %q, want preserved legacy Flash choice", cfg.DefaultModel)
1664 }
1665 if resolved, ok := cfg.ResolveModel(cfg.DefaultModel); !ok || resolved.Model != "deepseek-v4-flash" {
1666 t.Fatalf("legacy default model no longer resolves: entry=%+v, ok=%t", resolved, ok)
1667 }
1668 }
1669
1670 func TestSettingsSurfacesCuratedProviderPresets(t *testing.T) {
1671 isolateDesktopUserDirs(t)
1672
1673 view := NewApp().Settings()
1674 if len(view.ProviderPresets) < 18 {
1675 t.Fatalf("Settings().ProviderPresets length = %d, want curated custom presets", len(view.ProviderPresets))
1676 }
1677 got := map[string]ProviderPresetView{}
1678 for _, preset := range view.ProviderPresets {
1679 got[preset.ID] = preset
1680 }
1681 for _, curated := range config.CuratedProviderPresets() {
1682 id := curated.ID
1683 preset, ok := got[id]
1684 if !ok {
1685 t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
1686 }
1687 if preset.KeyEnv == "" || len(preset.ProviderNames) == 0 || len(preset.Models) == 0 {
1688 t.Fatalf("preset %q view has missing fields: %+v", id, preset)
1689 }
1690 if preset.ID == "opencode-go-recommended" && (preset.DisplayGroup != "opencode" || preset.DisplaySection != "go" || preset.DisplayTier != "primary" || preset.RouteKind != "bundle") {
1691 t.Fatalf("recommended OpenCode metadata = %+v", preset)
1692 }
1693 }
1694 }
1695
1696 func providerPresetViewByID(t *testing.T, view SettingsView, id string) ProviderPresetView {
1697 t.Helper()
1698 for _, preset := range view.ProviderPresets {
1699 if preset.ID == id {
1700 return preset
1701 }
1702 }
1703 t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
1704 return ProviderPresetView{}
1705 }
1706
1707 func TestSettingsMarksPresetAddedWhenSameNameProviderExistsWithoutAccess(t *testing.T) {
1708 isolateDesktopUserDirs(t)
1709 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1710 t.Fatalf("mkdir config dir: %v", err)
1711 }
1712 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1713 [desktop]
1714 provider_access = []
1715
1716 [[providers]]
1717 name = "mimo-api"
1718 kind = "openai"
1719 base_url = "https://custom.example/v1"
1720 models = ["custom-model"]
1721 default = "custom-model"
1722 api_key_env = "MIMO_API_KEY"
1723 `), 0o644); err != nil {
1724 t.Fatalf("write config: %v", err)
1725 }
1726
1727 view := NewApp().Settings()
1728 presetView := providerPresetViewByID(t, view, "mimo-api")
1729 if !presetView.Added || presetView.Status != providerPresetStatusNameConflict || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
1730 t.Fatalf("mimo-api preset view = %+v, want name-conflict because a different same-name provider exists", presetView)
1731 }
1732
1733 var providerView *ProviderView
1734 for i := range view.Providers {
1735 if view.Providers[i].Name == "mimo-api" {
1736 providerView = &view.Providers[i]
1737 break
1738 }
1739 }
1740 if providerView == nil {
1741 t.Fatal("mimo-api provider view missing")
1742 }
1743 if providerView.Added {
1744 t.Fatalf("mimo-api provider Added = true, want false until provider_access explicitly enables it")
1745 }
1746 }
1747
1748 func TestSettingsMarksLegacyEquivalentPresetAsInstalled(t *testing.T) {
1749 isolateDesktopUserDirs(t)
1750 preset, ok := config.CuratedProviderPreset("mimo-api")
1751 if !ok || len(preset.Entries) == 0 {
1752 t.Fatal("missing mimo-api preset")
1753 }
1754 legacy := preset.Entries[0]
1755 legacy.PresetID = ""
1756 legacy.PresetVersion = 0
1757 cfg := config.Default()
1758 if err := cfg.UpsertProvider(legacy); err != nil {
1759 t.Fatalf("upsert legacy provider: %v", err)
1760 }
1761 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1762 t.Fatalf("save config: %v", err)
1763 }
1764
1765 view := NewApp().Settings()
1766 presetView := providerPresetViewByID(t, view, "mimo-api")
1767 if !presetView.Added || presetView.Status != providerPresetStatusInstalled || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
1768 t.Fatalf("mimo-api preset view = %+v, want installed for legacy equivalent config", presetView)
1769 }
1770 }
1771
1772 func TestSettingsMarksPresetWithChangedCoreConfigAsModified(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 modified := preset.Entries[0]
1779 modified.BaseURL = "https://custom.example/v1"
1780 cfg := config.Default()
1781 if err := cfg.UpsertProvider(modified); err != nil {
1782 t.Fatalf("upsert modified provider: %v", err)
1783 }
1784 cfg.Desktop.ProviderAccess = []string{"mimo-api"}
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 != providerPresetStatusInstalledModified || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
1792 t.Fatalf("mimo-api preset view = %+v, want installed-modified for edited preset provider", presetView)
1793 }
1794 }
1795
1796 func TestSettingsPreservesStepFunRegionalPresetBaseURLs(t *testing.T) {
1797 isolateDesktopUserDirs(t)
1798
1799 cfg := config.Default()
1800 stepfun, ok := config.CuratedProviderPreset("stepfun")
1801 if !ok || len(stepfun.Entries) != 1 {
1802 t.Fatal("missing stepfun preset")
1803 }
1804 stepfunEntry := stepfun.Entries[0]
1805 stepfunEntry.BaseURL = "https://api.stepfun.ai/step_plan/v1"
1806 stepfunAnthropic, ok := config.CuratedProviderPreset("stepfun-anthropic")
1807 if !ok || len(stepfunAnthropic.Entries) != 1 {
1808 t.Fatal("missing stepfun-anthropic preset")
1809 }
1810 stepfunAnthropicEntry := stepfunAnthropic.Entries[0]
1811 stepfunAnthropicEntry.BaseURL = "https://api.stepfun.ai/step_plan"
1812 cfg.Providers = append(cfg.Providers, stepfunEntry, stepfunAnthropicEntry)
1813 cfg.Desktop.ProviderAccess = []string{"stepfun", "stepfun-anthropic"}
1814 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1815 t.Fatalf("save config: %v", err)
1816 }
1817
1818 view := NewApp().Settings()
1819 for _, id := range []string{"stepfun", "stepfun-anthropic"} {
1820 presetView := providerPresetViewByID(t, view, id)
1821 if !presetView.Added || presetView.Status != providerPresetStatusInstalledModified {
1822 t.Fatalf("%s preset view = %+v, want installed-modified for a preserved regional endpoint", id, presetView)
1823 }
1824 }
1825
1826 loaded := config.LoadForEdit(config.UserConfigPath())
1827 stepfunEntryView, ok := loaded.Provider("stepfun")
1828 if !ok {
1829 t.Fatal("stepfun provider missing after load")
1830 }
1831 if got := stepfunEntryView.BaseURL; got != "https://api.stepfun.ai/step_plan/v1" {
1832 t.Fatalf("stepfun base_url = %q, want preserved regional URL", got)
1833 }
1834 stepfunAnthropicEntryView, ok := loaded.Provider("stepfun-anthropic")
1835 if !ok {
1836 t.Fatal("stepfun-anthropic provider missing after load")
1837 }
1838 if got := stepfunAnthropicEntryView.BaseURL; got != "https://api.stepfun.ai/step_plan" {
1839 t.Fatalf("stepfun-anthropic base_url = %q, want preserved regional URL", got)
1840 }
1841 }
1842
1843 func TestSettingsMarksSimilarProviderPresetWithoutBlockingAdd(t *testing.T) {
1844 isolateDesktopUserDirs(t)
1845 preset, ok := config.CuratedProviderPreset("mimo-api")
1846 if !ok || len(preset.Entries) == 0 {
1847 t.Fatal("missing mimo-api preset")
1848 }
1849 similar := preset.Entries[0]
1850 similar.Name = "my-mimo"
1851 similar.PresetID = ""
1852 similar.PresetVersion = 0
1853 cfg := config.Default()
1854 if err := cfg.UpsertProvider(similar); err != nil {
1855 t.Fatalf("upsert similar provider: %v", err)
1856 }
1857 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1858 t.Fatalf("save config: %v", err)
1859 }
1860
1861 view := NewApp().Settings()
1862 presetView := providerPresetViewByID(t, view, "mimo-api")
1863 if presetView.Added || presetView.Status != providerPresetStatusSimilarExisting || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"my-mimo"}) {
1864 t.Fatalf("mimo-api preset view = %+v, want non-blocking similar-existing status", presetView)
1865 }
1866 }
1867
1868 func TestAddProviderPresetAccessSavesEditableProviderAndKey(t *testing.T) {
1869 isolateDesktopUserDirs(t)
1870 t.Setenv("MIMO_API_KEY", "")
1871 os.Unsetenv("MIMO_API_KEY")
1872
1873 if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-mimo"); err != nil {
1874 t.Fatalf("AddProviderPresetAccess: %v", err)
1875 } else if warning != "" {
1876 t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
1877 }
1878
1879 cfg := config.LoadForEdit(config.UserConfigPath())
1880 p, ok := cfg.Provider("mimo-api")
1881 if !ok {
1882 t.Fatal("mimo-api provider not saved")
1883 }
1884 if p.Kind != "openai" || p.BaseURL != "https://api.xiaomimimo.com/v1" || p.Default != "mimo-v2.6-pro" {
1885 t.Fatalf("mimo-api provider after preset add = %+v", p)
1886 }
1887 if p.PresetID != "mimo-api" || p.PresetVersion != config.ProviderPresetVersion {
1888 t.Fatalf("mimo-api preset metadata = %q/%d, want mimo-api/%d", p.PresetID, p.PresetVersion, config.ProviderPresetVersion)
1889 }
1890 if !p.NoProxy {
1891 t.Fatal("mimo-api preset should save no_proxy = true")
1892 }
1893 if !p.HasVisionModel("mimo-v2.5") || p.HasVisionModel("mimo-v2.5-pro") {
1894 t.Fatalf("mimo vision_models = %+v, want only vision-capable MiMo models", p.VisionModels)
1895 }
1896 if price := p.PriceForModel("mimo-v2.5-pro"); price == nil || price.Currency != "¥" {
1897 t.Fatalf("mimo-v2.5-pro price = %+v, want RMB pricing", price)
1898 }
1899 if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
1900 t.Fatalf("provider_access missing mimo-api: %+v", cfg.Desktop.ProviderAccess)
1901 }
1902 data, err := os.ReadFile(config.UserCredentialsPath())
1903 if err != nil {
1904 t.Fatalf("read saved credentials: %v", err)
1905 }
1906 if p.APIKeyEnv == "MIMO_API_KEY" || !strings.Contains(string(data), p.APIKeyEnv+"=sk-mimo") {
1907 t.Fatal("saved credentials missing the isolated MiMo key reference")
1908 }
1909
1910 view := NewApp().Settings()
1911 var presetView *ProviderPresetView
1912 var providerView *ProviderView
1913 for i := range view.ProviderPresets {
1914 if view.ProviderPresets[i].ID == "mimo-api" {
1915 presetView = &view.ProviderPresets[i]
1916 }
1917 }
1918 for i := range view.Providers {
1919 if view.Providers[i].Name == "mimo-api" {
1920 providerView = &view.Providers[i]
1921 }
1922 }
1923 if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet {
1924 t.Fatalf("mimo-api preset view = %+v, want installed/key-set", presetView)
1925 }
1926 if providerView == nil || providerView.BuiltIn || !providerView.Added || !providerView.KeySet {
1927 t.Fatalf("mimo provider view = %+v, want editable added custom provider with key", providerView)
1928 }
1929 }
1930
1931 func TestAddProviderPresetAccessDoesNotOverwriteExistingProvider(t *testing.T) {
1932 isolateDesktopUserDirs(t)
1933 t.Setenv("MIMO_API_KEY", "")
1934 os.Unsetenv("MIMO_API_KEY")
1935 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
1936
1937 cfg := config.Default()
1938 custom := config.ProviderEntry{
1939 Name: "mimo-api",
1940 Kind: "openai",
1941 BaseURL: "https://custom.example/v1",
1942 Models: []string{"custom-model"},
1943 Default: "custom-model",
1944 APIKeyEnv: "MIMO_API_KEY",
1945 Headers: map[string]string{"X-Custom": "keep-me"},
1946 }
1947 if err := cfg.UpsertProvider(custom); err != nil {
1948 t.Fatalf("upsert custom provider: %v", err)
1949 }
1950 cfg.Desktop.ProviderAccess = []string{"mimo-api"}
1951 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1952 t.Fatalf("save config: %v", err)
1953 }
1954
1955 if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-new"); err == nil {
1956 t.Fatal("AddProviderPresetAccess unexpectedly overwrote an existing provider")
1957 } else if !strings.Contains(err.Error(), "provider name(s) already exist") {
1958 t.Fatalf("AddProviderPresetAccess error = %v, want name-exists guard", err)
1959 } else if warning != "" {
1960 t.Fatalf("AddProviderPresetAccess warning = %q, want none on rejected add", warning)
1961 }
1962
1963 cfg = config.LoadForEdit(config.UserConfigPath())
1964 got, ok := cfg.Provider("mimo-api")
1965 if !ok {
1966 t.Fatal("mimo-api provider missing after rejected add")
1967 }
1968 if got.BaseURL != custom.BaseURL || got.DefaultModel() != custom.DefaultModel() || !reflect.DeepEqual(got.ModelList(), custom.ModelList()) || !reflect.DeepEqual(got.Headers, custom.Headers) {
1969 t.Fatalf("mimo-api provider was overwritten: %+v, want custom %+v", got, custom)
1970 }
1971 data, err := os.ReadFile(config.UserCredentialsPath())
1972 if err != nil {
1973 t.Fatalf("read saved credentials: %v", err)
1974 }
1975 if strings.Contains(string(data), "sk-new") || !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
1976 t.Fatalf("credentials changed after rejected add:\n%s", data)
1977 }
1978 }
1979
1980 func TestResetProviderPresetAccessOverwritesSameNameProvider(t *testing.T) {
1981 isolateDesktopUserDirs(t)
1982 t.Setenv("MIMO_API_KEY", "")
1983 os.Unsetenv("MIMO_API_KEY")
1984 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
1985
1986 cfg := config.Default()
1987 custom := config.ProviderEntry{
1988 Name: "mimo-api",
1989 Kind: "openai",
1990 BaseURL: "https://custom.example/v1",
1991 Models: []string{"custom-model"},
1992 Default: "custom-model",
1993 APIKeyEnv: "MIMO_API_KEY",
1994 Headers: map[string]string{"X-Custom": "remove-me"},
1995 }
1996 if err := cfg.UpsertProvider(custom); err != nil {
1997 t.Fatalf("upsert custom provider: %v", err)
1998 }
1999 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2000 t.Fatalf("save config: %v", err)
2001 }
2002
2003 if err := NewApp().ResetProviderPresetAccess("mimo-api"); err != nil {
2004 t.Fatalf("ResetProviderPresetAccess: %v", err)
2005 }
2006
2007 cfg = config.LoadForEdit(config.UserConfigPath())
2008 got, ok := cfg.Provider("mimo-api")
2009 if !ok {
2010 t.Fatal("mimo-api provider missing after reset")
2011 }
2012 if got.BaseURL != "https://api.xiaomimimo.com/v1" || got.DefaultModel() != "mimo-v2.6-pro" || got.PresetID != "mimo-api" || got.PresetVersion != config.ProviderPresetVersion {
2013 t.Fatalf("mimo-api provider after reset = %+v, want preset template", got)
2014 }
2015 if len(got.Headers) != 0 {
2016 t.Fatalf("mimo-api headers after reset = %+v, want preset headers", got.Headers)
2017 }
2018 if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
2019 t.Fatalf("provider_access missing mimo-api after reset: %+v", cfg.Desktop.ProviderAccess)
2020 }
2021 data, err := os.ReadFile(config.UserCredentialsPath())
2022 if err != nil {
2023 t.Fatalf("read saved credentials: %v", err)
2024 }
2025 if !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
2026 t.Fatalf("credentials changed after reset:\n%s", data)
2027 }
2028
2029 presetView := providerPresetViewByID(t, NewApp().Settings(), "mimo-api")
2030 if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
2031 t.Fatalf("mimo-api preset view = %+v, want installed after reset", presetView)
2032 }
2033 }
2034
2035 func TestResetProviderPresetAccessRejectsMissingSameNameProvider(t *testing.T) {
2036 isolateDesktopUserDirs(t)
2037
2038 if err := NewApp().ResetProviderPresetAccess("mimo-api"); err == nil {
2039 t.Fatal("ResetProviderPresetAccess unexpectedly reset a missing provider")
2040 } else if !strings.Contains(err.Error(), "no same-name provider exists") {
2041 t.Fatalf("ResetProviderPresetAccess error = %v, want missing same-name provider guard", err)
2042 }
2043 }
2044
2045 func TestAddEveryProviderPresetAccessInstallsTemplate(t *testing.T) {
2046 for _, preset := range config.CuratedProviderPresets() {
2047 t.Run(preset.ID, func(t *testing.T) {
2048 isolateDesktopUserDirs(t)
2049
2050 if warning, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-test"); err != nil {
2051 t.Fatalf("AddProviderPresetAccess(%q): %v", preset.ID, err)
2052 } else if warning != "" {
2053 t.Fatalf("AddProviderPresetAccess(%q) warning = %q, want none", preset.ID, warning)
2054 }
2055
2056 cfg := config.LoadForEdit(config.UserConfigPath())
2057 access := providerAccessSet(cfg.Desktop.ProviderAccess)
2058 for _, entry := range preset.Entries {
2059 got, ok := cfg.Provider(entry.Name)
2060 if !ok {
2061 t.Fatalf("provider %q from preset %q was not saved", entry.Name, preset.ID)
2062 }
2063 if !access[entry.Name] {
2064 t.Fatalf("provider_access for preset %q missing %q: %+v", preset.ID, entry.Name, cfg.Desktop.ProviderAccess)
2065 }
2066 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 {
2067 t.Fatalf("provider %q core fields = %+v, want template %+v", entry.Name, got, entry)
2068 }
2069 if got.PresetID != preset.ID || got.PresetVersion != config.ProviderPresetVersion {
2070 t.Fatalf("provider %q preset metadata = %q/%d, want %q/%d", entry.Name, got.PresetID, got.PresetVersion, preset.ID, config.ProviderPresetVersion)
2071 }
2072 if got.ContextWindow != entry.ContextWindow || got.Thinking != entry.Thinking || got.DefaultEffort != entry.DefaultEffort || got.ReasoningProtocol != entry.ReasoningProtocol {
2073 t.Fatalf("provider %q capability fields = %+v, want template %+v", entry.Name, got, entry)
2074 }
2075 if !reflect.DeepEqual(got.ModelList(), entry.ModelList()) || !reflect.DeepEqual(got.VisionModels, entry.VisionModels) || !reflect.DeepEqual(got.SupportedEfforts, entry.SupportedEfforts) {
2076 t.Fatalf("provider %q models/capabilities = %+v, want template %+v", entry.Name, got, entry)
2077 }
2078 if !reflect.DeepEqual(got.Headers, entry.Headers) || !reflect.DeepEqual(got.ExtraBody, entry.ExtraBody) {
2079 t.Fatalf("provider %q request extras = %+v, want template %+v", entry.Name, got, entry)
2080 }
2081 }
2082
2083 view := NewApp().Settings()
2084 var presetView *ProviderPresetView
2085 for i := range view.ProviderPresets {
2086 if view.ProviderPresets[i].ID == preset.ID {
2087 presetView = &view.ProviderPresets[i]
2088 break
2089 }
2090 }
2091 if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet || !presetView.Configured {
2092 t.Fatalf("preset view for %q = %+v, want installed/key-set/configured", preset.ID, presetView)
2093 }
2094 })
2095 }
2096 }
2097
2098 func TestAddOpenCodeGoRecommendedPresetCompletesMissingRoutes(t *testing.T) {
2099 isolateDesktopUserDirs(t)
2100 t.Setenv("OPENCODE_GO_API_KEY", "")
2101 os.Unsetenv("OPENCODE_GO_API_KEY")
2102
2103 preset, ok := config.CuratedProviderPreset("opencode-go-recommended")
2104 if !ok || len(preset.Entries) != 3 {
2105 t.Fatalf("recommended preset = %+v, found=%v", preset, ok)
2106 }
2107 cfg := config.Default()
2108 seed := preset.Entries[0]
2109 seed.PresetID = "opencode-go"
2110 if err := cfg.UpsertProvider(seed); err != nil {
2111 t.Fatalf("seed existing OpenCode Go route: %v", err)
2112 }
2113 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2114 t.Fatalf("save seed config: %v", err)
2115 }
2116 partial := providerPresetViewByID(t, NewApp().Settings(), preset.ID)
2117 if partial.Status != providerPresetStatusPartial || len(partial.MissingProviderNames) != 2 {
2118 t.Fatalf("recommended preset partial view = %+v, want two missing routes", partial)
2119 }
2120
2121 if warning, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-opencode"); err != nil {
2122 t.Fatalf("AddProviderPresetAccess: %v", err)
2123 } else if warning != "" {
2124 t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
2125 }
2126
2127 cfg = config.LoadForEdit(config.UserConfigPath())
2128 for _, entry := range preset.Entries {
2129 if _, ok := cfg.Provider(entry.Name); !ok {
2130 t.Fatalf("missing recommended route %q after completion", entry.Name)
2131 }
2132 }
2133 data, err := os.ReadFile(config.UserCredentialsPath())
2134 if err != nil {
2135 t.Fatalf("read saved credentials: %v", err)
2136 }
2137 for _, route := range preset.Entries {
2138 entry, _ := cfg.Provider(route.Name)
2139 if entry.APIKeyEnv == "OPENCODE_GO_API_KEY" || !strings.Contains(string(data), entry.APIKeyEnv+"=sk-opencode") {
2140 t.Fatalf("route %s did not receive an isolated credential reference", route.Name)
2141 }
2142 }
2143 }
2144
2145 func TestAddOpenCodeGoRecommendedPresetSelectsUsableDefaultForFreshSetup(t *testing.T) {
2146 isolateDesktopUserDirs(t)
2147 t.Setenv("OPENCODE_GO_API_KEY", "")
2148 os.Unsetenv("OPENCODE_GO_API_KEY")
2149
2150 cfg := config.Default()
2151 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2152 t.Fatalf("save fresh config: %v", err)
2153 }
2154 if _, err := NewApp().AddProviderPresetAccess("opencode-go-recommended", "sk-opencode"); err != nil {
2155 t.Fatalf("AddProviderPresetAccess: %v", err)
2156 }
2157
2158 got := config.LoadForEdit(config.UserConfigPath())
2159 if got.DefaultModel != "opencode-go/glm-5.3" {
2160 t.Fatalf("default model = %q, want ready-to-use OpenCode Go default", got.DefaultModel)
2161 }
2162 }
2163
2164 func TestAddOpenCodeGoRecommendedPresetPreservesConfiguredDefault(t *testing.T) {
2165 isolateDesktopUserDirs(t)
2166 t.Setenv("OPENCODE_GO_API_KEY", "")
2167 os.Unsetenv("OPENCODE_GO_API_KEY")
2168
2169 cfg := config.Default()
2170 if err := cfg.UpsertProvider(config.ProviderEntry{
2171 Name: "local-ready",
2172 Kind: "openai",
2173 BaseURL: "http://127.0.0.1:11434/v1",
2174 Models: []string{"local-model"},
2175 Default: "local-model",
2176 }); err != nil {
2177 t.Fatalf("upsert configured provider: %v", err)
2178 }
2179 if err := cfg.SetDefaultModel("local-ready/local-model"); err != nil {
2180 t.Fatalf("set configured default: %v", err)
2181 }
2182 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2183 t.Fatalf("save configured default: %v", err)
2184 }
2185
2186 if _, err := NewApp().AddProviderPresetAccess("opencode-go-recommended", "sk-opencode"); err != nil {
2187 t.Fatalf("AddProviderPresetAccess: %v", err)
2188 }
2189 if got := config.LoadForEdit(config.UserConfigPath()).DefaultModel; got != "local-ready/local-model" {
2190 t.Fatalf("default model = %q, want existing configured default preserved", got)
2191 }
2192 }
2193
2194 func TestAddOpenCodeGoRecommendedPresetPreservesModifiedRoute(t *testing.T) {
2195 isolateDesktopUserDirs(t)
2196 t.Setenv("OPENCODE_GO_API_KEY", "")
2197 os.Unsetenv("OPENCODE_GO_API_KEY")
2198
2199 preset, ok := config.CuratedProviderPreset("opencode-go-recommended")
2200 if !ok || len(preset.Entries) != 3 {
2201 t.Fatalf("recommended preset = %+v, found=%v", preset, ok)
2202 }
2203 cfg := config.Default()
2204 modified := preset.Entries[0]
2205 modified.BaseURL = "https://custom.example/v1"
2206 modified.PresetID = "opencode-go"
2207 if err := cfg.UpsertProvider(modified); err != nil {
2208 t.Fatalf("seed modified OpenCode Go route: %v", err)
2209 }
2210 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2211 t.Fatalf("save modified config: %v", err)
2212 }
2213
2214 if _, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-opencode"); err != nil {
2215 t.Fatalf("AddProviderPresetAccess: %v", err)
2216 }
2217 cfg = config.LoadForEdit(config.UserConfigPath())
2218 got, ok := cfg.Provider("opencode-go")
2219 if !ok || got.BaseURL != "https://custom.example/v1" {
2220 t.Fatalf("modified route = %+v, want preserved custom endpoint", got)
2221 }
2222 for _, name := range []string{"opencode-go-anthropic", "opencode-go-responses"} {
2223 if _, ok := cfg.Provider(name); !ok {
2224 t.Fatalf("missing route %q after completing bundle around modified route", name)
2225 }
2226 }
2227 }
2228
2229 func TestAddOpenCodeGoRecommendedPresetRejectsConflictAtomically(t *testing.T) {
2230 isolateDesktopUserDirs(t)
2231 t.Setenv("OPENCODE_GO_API_KEY", "")
2232 os.Unsetenv("OPENCODE_GO_API_KEY")
2233
2234 cfg := config.Default()
2235 conflict := config.ProviderEntry{
2236 Name: "opencode-go",
2237 Kind: "openai",
2238 BaseURL: "https://custom.example/v1",
2239 Models: []string{"custom-model"},
2240 Default: "custom-model",
2241 APIKeyEnv: "OPENCODE_GO_API_KEY",
2242 PresetID: "custom",
2243 PresetVersion: 1,
2244 }
2245 if err := cfg.UpsertProvider(conflict); err != nil {
2246 t.Fatalf("upsert conflicting provider: %v", err)
2247 }
2248 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2249 t.Fatalf("save conflict config: %v", err)
2250 }
2251
2252 if warning, err := NewApp().AddProviderPresetAccess("opencode-go-recommended", "sk-should-not-save"); err == nil {
2253 t.Fatal("AddProviderPresetAccess unexpectedly accepted same-name conflict")
2254 } else if !strings.Contains(err.Error(), "opencode-go") {
2255 t.Fatalf("AddProviderPresetAccess error = %v, want opencode-go conflict", err)
2256 } else if warning != "" {
2257 t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
2258 }
2259
2260 cfg = config.LoadForEdit(config.UserConfigPath())
2261 if _, ok := cfg.Provider("opencode-go-anthropic"); ok {
2262 t.Fatal("conflicting bundle partially installed Anthropic route")
2263 }
2264 if _, err := os.Stat(config.UserCredentialsPath()); err == nil {
2265 data, readErr := os.ReadFile(config.UserCredentialsPath())
2266 if readErr != nil {
2267 t.Fatalf("read credentials: %v", readErr)
2268 }
2269 if strings.Contains(string(data), "sk-should-not-save") {
2270 t.Fatalf("conflicting bundle saved credentials: %s", data)
2271 }
2272 }
2273 }
2274
2275 func TestAddOfficialProviderAccessPreservesBackgroundJobsWhenSavingKey(t *testing.T) {
2276 isolateDesktopUserDirs(t)
2277 t.Setenv("DEEPSEEK_API_KEY", "")
2278 os.Unsetenv("DEEPSEEK_API_KEY")
2279
2280 app := NewApp()
2281 app.readyHook = func() {}
2282 app.setTestCtrl(newBackgroundJobController(t, "provider-access-job"), "deepseek-flash/deepseek-v4-flash")
2283
2284 _, err := app.AddOfficialProviderAccess("deepseek", "sk-test")
2285 if err != nil || !controllerHasActiveRuntimeWork(app.activeCtrl()) {
2286 t.Fatalf("AddOfficialProviderAccess interrupted background work: %v", err)
2287 }
2288 p, _ := config.LoadForEdit(config.UserConfigPath()).Provider("deepseek")
2289 if !config.CredentialStored(p.APIKeyEnv) || p.APIKeyEnv == "DEEPSEEK_API_KEY" {
2290 t.Fatal("official key was not committed to a new reference")
2291 }
2292 }
2293
2294 func TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {
2295 isolateDesktopUserDirs(t)
2296 t.Setenv("DEEPSEEK_API_KEY", "")
2297 os.Unsetenv("DEEPSEEK_API_KEY")
2298 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
2299 t.Fatalf("mkdir config dir: %v", err)
2300 }
2301 if err := os.WriteFile(config.UserConfigPath(), []byte(`
2302 default_model = "deepseek/deepseek-v4-flash"
2303
2304 [desktop]
2305 provider_access = []
2306
2307 [[providers]]
2308 name = "deepseek"
2309 kind = "openai"
2310 base_url = "https://api.deepseek.com"
2311 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
2312 default = "deepseek-v4-flash"
2313 api_key_env = "DEEPSEEK_API_KEY"
2314 `), 0o644); err != nil {
2315 t.Fatalf("write config: %v", err)
2316 }
2317
2318 if _, err := NewApp().SetProviderKey("DEEPSEEK_API_KEY", "sk-test"); err != nil {
2319 t.Fatalf("SetProviderKey: %v", err)
2320 }
2321 cfg := config.LoadForEdit(config.UserConfigPath())
2322 if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
2323 t.Fatalf("provider_access = %+v, want deepseek restored", cfg.Desktop.ProviderAccess)
2324 }
2325 got := NewApp().Settings()
2326 for _, p := range got.Providers {
2327 if p.Name == "deepseek" {
2328 if !p.Added || !p.KeySet {
2329 t.Fatalf("deepseek settings = %+v, want added and key-set", p)
2330 }
2331 return
2332 }
2333 }
2334 t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
2335 }
2336
2337 func TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {
2338 isolateDesktopUserDirs(t)
2339 t.Setenv("PROXY_DEEPSEEK_KEY", "")
2340 os.Unsetenv("PROXY_DEEPSEEK_KEY")
2341 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
2342 t.Fatalf("mkdir config dir: %v", err)
2343 }
2344 if err := os.WriteFile(config.UserConfigPath(), []byte(`
2345 [desktop]
2346 provider_access = []
2347
2348 [[providers]]
2349 name = "deepseek-flash"
2350 kind = "openai"
2351 base_url = "https://proxy.example/v1"
2352 model = "deepseek-v4-flash"
2353 api_key_env = "PROXY_DEEPSEEK_KEY"
2354 `), 0o644); err != nil {
2355 t.Fatalf("write config: %v", err)
2356 }
2357
2358 if _, err := NewApp().SetProviderKey("PROXY_DEEPSEEK_KEY", "sk-test"); err != nil {
2359 t.Fatalf("SetProviderKey: %v", err)
2360 }
2361 cfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
2362 access := providerAccessSet(cfg.Desktop.ProviderAccess)
2363 if !access["deepseek-flash"] {
2364 t.Fatalf("provider_access = %+v, want custom alias deepseek-flash", cfg.Desktop.ProviderAccess)
2365 }
2366 if access["deepseek"] {
2367 t.Fatalf("provider_access = %+v, should not canonicalize custom proxy to deepseek", cfg.Desktop.ProviderAccess)
2368 }
2369 }
2370
2371 func TestSetProviderKeyLeaseHeldKeepsCurrentController(t *testing.T) {
2372 isolateDesktopUserDirs(t)
2373 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2374
2375 cfg := config.Default()
2376 cfg.DefaultModel = "old/old-model"
2377 cfg.Desktop.ProviderAccess = []string{"old"}
2378 cfg.Providers = []config.ProviderEntry{
2379 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
2380 {Name: "longcat", Kind: "openai", BaseURL: "https://longcat.example/v1", Model: "longcat-chat", APIKeyEnv: "LONGCAT_API_KEY"},
2381 }
2382 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2383 t.Fatalf("save config: %v", err)
2384 }
2385
2386 dir := config.SessionDir()
2387 if err := os.MkdirAll(dir, 0o755); err != nil {
2388 t.Fatalf("mkdir session dir: %v", err)
2389 }
2390 sessionPath := filepath.Join(dir, "externally-leased-provider-key.jsonl")
2391 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2392 t.Fatalf("write placeholder session: %v", err)
2393 }
2394 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2395 if err != nil {
2396 t.Fatalf("TryAcquireSessionLease: %v", err)
2397 }
2398 defer externalLease.Release()
2399
2400 oldSession := agent.NewSession("old system prompt")
2401 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
2402 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
2403 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2404 defer oldCtrl.Close()
2405
2406 app := NewApp()
2407 app.ctx = context.Background()
2408 tab := &WorkspaceTab{
2409 ID: "tab_provider",
2410 Scope: "global",
2411 SessionPath: sessionPath,
2412 Ready: true,
2413 model: "old/old-model",
2414 Ctrl: oldCtrl,
2415 sink: &tabEventSink{tabID: "tab_provider", app: app},
2416 disabledMCP: map[string]ServerView{},
2417 }
2418 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2419 app.tabOrder = []string{tab.ID}
2420 app.activeTabID = tab.ID
2421
2422 warning, err := app.SetProviderKey("LONGCAT_API_KEY", "sk-longcat")
2423 if err != nil {
2424 t.Fatalf("SetProviderKey: %v", err)
2425 }
2426 if warning != "" {
2427 t.Fatalf("SetProviderKey warning = %q; saving does not acquire the session lease", warning)
2428 }
2429 if strings.Contains(warning, sessionPath) || strings.Contains(warning, "held by") {
2430 t.Fatalf("SetProviderKey surfaced raw lease details: %v", warning)
2431 }
2432 if tab.Ctrl != oldCtrl {
2433 t.Fatalf("tab controller changed after failed provider-key rebuild")
2434 }
2435 if tab.StartupErr != "" {
2436 t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
2437 }
2438 if got := tab.Ctrl.History(); len(got) < 2 || got[1].Content != "hello" {
2439 t.Fatalf("history after failed provider-key rebuild = %+v", got)
2440 }
2441 if access := providerAccessSet(config.LoadForEditWithoutCredentials(config.UserConfigPath()).Desktop.ProviderAccess); !access["longcat"] {
2442 t.Fatalf("provider_access should still persist longcat after key save")
2443 }
2444 }
2445
2446 func TestSetProviderKeyPreservesInFlightStartupBuild(t *testing.T) {
2447 isolateDesktopUserDirs(t)
2448
2449 cfg := config.Default()
2450 cfg.DefaultModel = "old/old-model"
2451 cfg.Desktop.ProviderAccess = []string{"old"}
2452 cfg.Providers = []config.ProviderEntry{{
2453 Name: "old",
2454 Kind: "openai",
2455 BaseURL: "https://example.invalid/v1",
2456 Model: "old-model",
2457 APIKeyEnv: "OLD_MODEL_KEY",
2458 }}
2459 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2460 t.Fatalf("save config: %v", err)
2461 }
2462
2463 dir := config.SessionDir()
2464 if err := os.MkdirAll(dir, 0o755); err != nil {
2465 t.Fatalf("mkdir session dir: %v", err)
2466 }
2467 sessionPath := filepath.Join(dir, "startup-build-in-flight.jsonl")
2468 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2469 t.Fatalf("write placeholder session: %v", err)
2470 }
2471
2472 app := NewApp()
2473 app.ctx = context.Background()
2474 app.readyHook = func() {}
2475 // Model the async startup build still being in flight: no controller yet,
2476 // a live build generation, and a cancellable build context.
2477 buildCtx, buildCancel := context.WithCancel(context.Background())
2478 const startupGeneration = 1
2479 tab := &WorkspaceTab{
2480 ID: "tab_key_rebuild",
2481 Scope: "global",
2482 SessionPath: sessionPath,
2483 model: "old/old-model",
2484 buildGeneration: startupGeneration,
2485 buildCancel: buildCancel,
2486 disabledMCP: map[string]ServerView{},
2487 }
2488 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
2489 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2490 app.tabOrder = []string{tab.ID}
2491 app.activeTabID = tab.ID
2492 t.Cleanup(tab.releaseSessionLease)
2493
2494 if _, err := app.SetProviderKey("OLD_MODEL_KEY", "sk-new"); err != nil {
2495 t.Fatalf("SetProviderKey: %v", err)
2496 }
2497 if tab.Ctrl != nil || tab.buildGeneration != startupGeneration || buildCtx.Err() != nil {
2498 t.Fatal("saving a key published or cancelled an in-flight startup; publication owns its version check")
2499 }
2500 buildCancel()
2501 }
2502
2503 func TestSaveProviderWithKeyLeaseHeldPersistsCustomProvider(t *testing.T) {
2504 isolateDesktopUserDirs(t)
2505 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2506
2507 cfg := config.Default()
2508 cfg.DefaultModel = "old/old-model"
2509 cfg.Desktop.ProviderAccess = []string{"old"}
2510 cfg.Providers = []config.ProviderEntry{{
2511 Name: "old",
2512 Kind: "openai",
2513 BaseURL: "https://example.invalid/v1",
2514 Model: "old-model",
2515 APIKeyEnv: "OLD_MODEL_KEY",
2516 }}
2517 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2518 t.Fatalf("save config: %v", err)
2519 }
2520
2521 dir := config.SessionDir()
2522 if err := os.MkdirAll(dir, 0o755); err != nil {
2523 t.Fatalf("mkdir session dir: %v", err)
2524 }
2525 sessionPath := filepath.Join(dir, "externally-leased-custom-provider.jsonl")
2526 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2527 t.Fatalf("write placeholder session: %v", err)
2528 }
2529 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2530 if err != nil {
2531 t.Fatalf("TryAcquireSessionLease: %v", err)
2532 }
2533 defer externalLease.Release()
2534
2535 oldSession := agent.NewSession("old system prompt")
2536 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
2537 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
2538 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2539 defer oldCtrl.Close()
2540
2541 app := NewApp()
2542 app.ctx = context.Background()
2543 tab := &WorkspaceTab{
2544 ID: "tab_custom_provider",
2545 Scope: "global",
2546 SessionPath: sessionPath,
2547 Ready: true,
2548 model: "old/old-model",
2549 Ctrl: oldCtrl,
2550 sink: &tabEventSink{tabID: "tab_custom_provider", app: app},
2551 disabledMCP: map[string]ServerView{},
2552 }
2553 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2554 app.tabOrder = []string{tab.ID}
2555 app.activeTabID = tab.ID
2556
2557 warning, err := app.SaveProviderWithKey(ProviderView{
2558 Name: "proxy",
2559 Kind: "openai",
2560 BaseURL: "https://proxy.example/v1",
2561 Models: []string{"model-a", "model-b"},
2562 Default: "model-a",
2563 APIKeyEnv: "PROXY_API_KEY",
2564 }, "sk-proxy")
2565 if err != nil {
2566 t.Fatalf("SaveProviderWithKey: %v", err)
2567 }
2568 if warning != "" {
2569 t.Fatalf("SaveProviderWithKey warning = %q; saving does not acquire the session lease", warning)
2570 }
2571 if strings.Contains(warning, sessionPath) || strings.Contains(warning, "held by") {
2572 t.Fatalf("SaveProviderWithKey surfaced raw lease details: %v", warning)
2573 }
2574 if tab.Ctrl != oldCtrl {
2575 t.Fatalf("tab controller changed after failed provider rebuild")
2576 }
2577 gotCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
2578 got, ok := gotCfg.Provider("proxy")
2579 if !ok {
2580 t.Fatal("custom provider was not saved")
2581 }
2582 if want := []string{"model-a", "model-b"}; !reflect.DeepEqual(got.ModelList(), want) {
2583 t.Fatalf("custom provider models = %v, want %v", got.ModelList(), want)
2584 }
2585 if !providerAccessSet(gotCfg.Desktop.ProviderAccess)["proxy"] {
2586 t.Fatalf("provider_access = %+v, want proxy", gotCfg.Desktop.ProviderAccess)
2587 }
2588 data, err := os.ReadFile(config.UserCredentialsPath())
2589 if err != nil {
2590 t.Fatalf("read credentials: %v", err)
2591 }
2592 if got.APIKeyEnv == "PROXY_API_KEY" || !strings.Contains(string(data), got.APIKeyEnv+"=sk-proxy") {
2593 t.Fatal("provider key was not saved with an isolated reference")
2594 }
2595 }
2596
2597 func TestConfigChangeLeaseHeldPersistsAndDefersRefresh(t *testing.T) {
2598 isolateDesktopUserDirs(t)
2599 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2600
2601 cfg := config.Default()
2602 cfg.DefaultModel = "old/old-model"
2603 cfg.Desktop.ProviderAccess = []string{"old"}
2604 cfg.Providers = []config.ProviderEntry{{
2605 Name: "old",
2606 Kind: "openai",
2607 BaseURL: "https://example.invalid/v1",
2608 Model: "old-model",
2609 APIKeyEnv: "OLD_MODEL_KEY",
2610 }}
2611 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2612 t.Fatalf("save config: %v", err)
2613 }
2614
2615 dir := config.SessionDir()
2616 if err := os.MkdirAll(dir, 0o755); err != nil {
2617 t.Fatalf("mkdir session dir: %v", err)
2618 }
2619 sessionPath := filepath.Join(dir, "externally-leased-settings.jsonl")
2620 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2621 t.Fatalf("write placeholder session: %v", err)
2622 }
2623 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2624 if err != nil {
2625 t.Fatalf("TryAcquireSessionLease: %v", err)
2626 }
2627 defer externalLease.Release()
2628
2629 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
2630 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2631 defer oldCtrl.Close()
2632
2633 app := NewApp()
2634 app.ctx = context.Background()
2635 tab := &WorkspaceTab{
2636 ID: "tab_settings",
2637 Scope: "global",
2638 SessionPath: sessionPath,
2639 Ready: true,
2640 model: "old/old-model",
2641 Ctrl: oldCtrl,
2642 sink: &tabEventSink{tabID: "tab_settings", app: app},
2643 disabledMCP: map[string]ServerView{},
2644 }
2645 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2646 app.tabOrder = []string{tab.ID}
2647 app.activeTabID = tab.ID
2648
2649 if err := app.SetMaxSubagentDepth(1); err != nil {
2650 t.Fatalf("SetMaxSubagentDepth should defer lease-held refresh instead of failing: %v", err)
2651 }
2652 if tab.Ctrl != oldCtrl {
2653 t.Fatalf("tab controller changed after deferred settings rebuild")
2654 }
2655 got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
2656 if got.Agent.MaxSubagentDepth != 1 {
2657 t.Fatalf("saved max_subagent_depth = %d, want 1", got.Agent.MaxSubagentDepth)
2658 }
2659 }
2660
2661 func TestDeferredRebuildRetryAppliesAfterLeaseRelease(t *testing.T) {
2662 isolateDesktopUserDirs(t)
2663 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2664
2665 cfg := config.Default()
2666 cfg.DefaultModel = "old/old-model"
2667 cfg.Desktop.ProviderAccess = []string{"old"}
2668 cfg.Providers = []config.ProviderEntry{{
2669 Name: "old",
2670 Kind: "openai",
2671 BaseURL: "https://example.invalid/v1",
2672 Model: "old-model",
2673 APIKeyEnv: "OLD_MODEL_KEY",
2674 }}
2675 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2676 t.Fatalf("save config: %v", err)
2677 }
2678
2679 dir := config.SessionDir()
2680 if err := os.MkdirAll(dir, 0o755); err != nil {
2681 t.Fatalf("mkdir session dir: %v", err)
2682 }
2683 sessionPath := filepath.Join(dir, "deferred-rebuild-retry.jsonl")
2684 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2685 t.Fatalf("write placeholder session: %v", err)
2686 }
2687 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2688 if err != nil {
2689 t.Fatalf("TryAcquireSessionLease: %v", err)
2690 }
2691 released := false
2692 defer func() {
2693 if !released {
2694 externalLease.Release()
2695 }
2696 }()
2697
2698 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
2699 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2700 defer oldCtrl.Close()
2701
2702 app := NewApp()
2703 app.ctx = context.Background()
2704 app.readyHook = func() {}
2705 tab := &WorkspaceTab{
2706 ID: "tab_deferred_retry",
2707 Scope: "global",
2708 SessionPath: sessionPath,
2709 Ready: true,
2710 model: "old/old-model",
2711 Ctrl: oldCtrl,
2712 sink: &tabEventSink{tabID: "tab_deferred_retry", app: app},
2713 disabledMCP: map[string]ServerView{},
2714 }
2715 installNoopRuntimeEvents(app, tab.sink)
2716 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2717 app.tabOrder = []string{tab.ID}
2718 app.activeTabID = tab.ID
2719 t.Cleanup(func() {
2720 if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
2721 c.Close()
2722 }
2723 tab.releaseSessionLease()
2724 })
2725
2726 if err := app.SetAgentParams(0.2, 0, 0, "updated prompt"); err != nil {
2727 t.Fatalf("SetAgentParams: %v", err)
2728 }
2729 if !app.deferredRebuildPending(tab.ID) {
2730 t.Fatal("deferred rebuild was not scheduled while the lease is held")
2731 }
2732 if app.controllerForTab(tab) != oldCtrl {
2733 t.Fatal("controller changed while the lease is still held")
2734 }
2735
2736 externalLease.Release()
2737 released = true
2738
2739 app.deferredRebuildTick(false)
2740 if app.deferredRebuildPending(tab.ID) {
2741 t.Fatal("deferred rebuild is still pending after the lease was released")
2742 }
2743 if c := app.controllerForTab(tab); c == nil || c == oldCtrl {
2744 t.Fatalf("controller was not rebuilt after the lease release: got %p", c)
2745 }
2746 }
2747
2748 func TestDeferredRebuildScheduleAfterStopIsNoop(t *testing.T) {
2749 app := NewApp()
2750 app.stopDeferredRebuildRetry()
2751 app.scheduleDeferredRebuild("tab_x", "settings")
2752 if app.deferredRebuildPending("tab_x") {
2753 t.Fatal("schedule after stop should not register pending work")
2754 }
2755 }
2756
2757 func TestDeferredRebuildAppliesToItsInactiveTarget(t *testing.T) {
2758 isolateDesktopUserDirs(t)
2759 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2760
2761 cfg := config.Default()
2762 cfg.DefaultModel = "old/old-model"
2763 cfg.Desktop.ProviderAccess = []string{"old"}
2764 cfg.Providers = []config.ProviderEntry{{
2765 Name: "old",
2766 Kind: "openai",
2767 BaseURL: "https://example.invalid/v1",
2768 Model: "old-model",
2769 APIKeyEnv: "OLD_MODEL_KEY",
2770 }}
2771 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2772 t.Fatalf("save config: %v", err)
2773 }
2774
2775 dir := config.SessionDir()
2776 if err := os.MkdirAll(dir, 0o755); err != nil {
2777 t.Fatalf("mkdir session dir: %v", err)
2778 }
2779 sessionPath := filepath.Join(dir, "deferred-rebuild-inactive.jsonl")
2780 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2781 t.Fatalf("write placeholder session: %v", err)
2782 }
2783 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2784 if err != nil {
2785 t.Fatalf("TryAcquireSessionLease: %v", err)
2786 }
2787 released := false
2788 defer func() {
2789 if !released {
2790 externalLease.Release()
2791 }
2792 }()
2793
2794 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
2795 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2796 defer oldCtrl.Close()
2797
2798 otherCtrl := control.New(control.Options{Label: "other"})
2799 defer otherCtrl.Close()
2800
2801 app := NewApp()
2802 app.ctx = context.Background()
2803 app.readyHook = func() {}
2804 tab := &WorkspaceTab{
2805 ID: "tab_pending",
2806 Scope: "global",
2807 SessionPath: sessionPath,
2808 Ready: true,
2809 model: "old/old-model",
2810 Ctrl: oldCtrl,
2811 sink: &tabEventSink{tabID: "tab_pending", app: app},
2812 disabledMCP: map[string]ServerView{},
2813 }
2814 installNoopRuntimeEvents(app, tab.sink)
2815 other := &WorkspaceTab{
2816 ID: "tab_other",
2817 Scope: "global",
2818 Ready: true,
2819 model: "old/old-model",
2820 Ctrl: otherCtrl,
2821 sink: &tabEventSink{tabID: "tab_other", app: app},
2822 disabledMCP: map[string]ServerView{},
2823 }
2824 app.tabs = map[string]*WorkspaceTab{tab.ID: tab, other.ID: other}
2825 app.tabOrder = []string{tab.ID, other.ID}
2826 app.activeTabID = tab.ID
2827 t.Cleanup(func() {
2828 if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
2829 c.Close()
2830 }
2831 tab.releaseSessionLease()
2832 })
2833
2834 if err := app.SetAgentParams(0.2, 0, 0, "updated prompt"); err != nil {
2835 t.Fatalf("SetAgentParams: %v", err)
2836 }
2837 if !app.deferredRebuildPending(tab.ID) {
2838 t.Fatal("deferred rebuild was not scheduled while the lease is held")
2839 }
2840
2841 // A concrete target retains its rebuild ownership when focus moves.
2842 app.mu.Lock()
2843 app.activeTabID = other.ID
2844 app.mu.Unlock()
2845 externalLease.Release()
2846 released = true
2847 app.deferredRebuildTick(false)
2848 if app.deferredRebuildPending(tab.ID) || app.controllerForTab(tab) == oldCtrl {
2849 t.Fatal("inactive target was not rebuilt")
2850 }
2851 if app.controllerForTab(other) != otherCtrl {
2852 t.Fatal("retry rebuilt the focused sibling")
2853 }
2854 }
2855
2856 func TestSetEffortForTabLeaseHeldKeepsOldControllerAlive(t *testing.T) {
2857 isolateDesktopUserDirs(t)
2858 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2859
2860 cfg := config.Default()
2861 cfg.DefaultModel = "old/old-model"
2862 cfg.Desktop.ProviderAccess = []string{"old"}
2863 cfg.Providers = []config.ProviderEntry{{
2864 Name: "old",
2865 Kind: "openai",
2866 BaseURL: "https://example.invalid/v1",
2867 Model: "old-model",
2868 APIKeyEnv: "OLD_MODEL_KEY",
2869 SupportedEfforts: []string{"low", "max"},
2870 }}
2871 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2872 t.Fatalf("save config: %v", err)
2873 }
2874
2875 dir := config.SessionDir()
2876 if err := os.MkdirAll(dir, 0o755); err != nil {
2877 t.Fatalf("mkdir session dir: %v", err)
2878 }
2879 sessionPath := filepath.Join(dir, "externally-leased-effort-switch.jsonl")
2880 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2881 t.Fatalf("write placeholder session: %v", err)
2882 }
2883 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2884 if err != nil {
2885 t.Fatalf("TryAcquireSessionLease: %v", err)
2886 }
2887 released := false
2888 defer func() {
2889 if !released {
2890 externalLease.Release()
2891 }
2892 }()
2893
2894 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
2895 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2896 defer oldCtrl.Close()
2897
2898 app := NewApp()
2899 app.ctx = context.Background()
2900 tab := &WorkspaceTab{
2901 ID: "tab_effort",
2902 Scope: "global",
2903 SessionPath: sessionPath,
2904 Ready: true,
2905 model: "old/old-model",
2906 Ctrl: oldCtrl,
2907 sink: &tabEventSink{tabID: "tab_effort", app: app},
2908 disabledMCP: map[string]ServerView{},
2909 }
2910 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2911 app.tabOrder = []string{tab.ID}
2912 app.activeTabID = tab.ID
2913 t.Cleanup(func() {
2914 if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
2915 c.Close()
2916 }
2917 tab.releaseSessionLease()
2918 })
2919
2920 err = app.SetEffortForTab(tab.ID, "max")
2921 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
2922 t.Fatalf("SetEffortForTab err = %v, want ErrSessionLeaseHeld", err)
2923 }
2924 if strings.Contains(err.Error(), sessionPath) || strings.Contains(err.Error(), "held by") {
2925 t.Fatalf("SetEffortForTab surfaced raw lease details: %v", err)
2926 }
2927 if tab.Ctrl != oldCtrl {
2928 t.Fatal("tab controller changed after failed effort switch")
2929 }
2930
2931 // The failed switch must leave the old runtime alive: after the other
2932 // window releases the lease, retrying from the same tab has to succeed.
2933 // (The old code closed the old controller before acquiring the lease, so
2934 // this retry died on a snapshot of a closed session.)
2935 externalLease.Release()
2936 released = true
2937 if err := app.SetEffortForTab(tab.ID, "max"); err != nil {
2938 t.Fatalf("SetEffortForTab retry after lease release: %v", err)
2939 }
2940 if tab.Ctrl == oldCtrl {
2941 t.Fatal("retry did not rebuild the controller")
2942 }
2943 }
2944
2945 func TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {
2946 isolateDesktopUserDirs(t)
2947 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
2948 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
2949 t.Fatalf("mkdir config dir: %v", err)
2950 }
2951 if err := os.WriteFile(config.UserConfigPath(), []byte(`
2952 default_model = "deepseek-flash/deepseek-v4-pro"
2953
2954 [desktop]
2955 provider_access = ["deepseek-flash", "mimo-pro"]
2956
2957 [[providers]]
2958 name = "deepseek-flash"
2959 kind = "openai"
2960 base_url = "https://api.deepseek.com"
2961 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
2962 default = "deepseek-v4-flash"
2963 api_key_env = "DEEPSEEK_API_KEY"
2964
2965 [[providers]]
2966 name = "mimo-pro"
2967 kind = "openai"
2968 base_url = "https://token-plan-cn.xiaomimimo.com/v1"
2969 model = "mimo-v2.5-pro"
2970 api_key_env = "MIMO_API_KEY"
2971 `), 0o644); err != nil {
2972 t.Fatalf("write config: %v", err)
2973 }
2974
2975 if err := NewApp().RemoveProviderAccess("deepseek"); err != nil {
2976 t.Fatalf("RemoveProviderAccess: %v", err)
2977 }
2978 cfg := config.LoadForEdit(config.UserConfigPath())
2979 access := providerAccessSet(cfg.Desktop.ProviderAccess)
2980 if access["deepseek"] || !access["mimo-pro"] {
2981 t.Fatalf("provider_access = %+v, want only mimo-pro", cfg.Desktop.ProviderAccess)
2982 }
2983 if cfg.DefaultModel != "mimo-pro/mimo-v2.5-pro" {
2984 t.Fatalf("default_model = %q, want mimo-pro/mimo-v2.5-pro", cfg.DefaultModel)
2985 }
2986 }
2987
2988 func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
2989 isolateDesktopUserDirs(t)
2990 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
2991 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
2992
2993 cfg := config.Default()
2994 cfg.DefaultModel = "deepseek/deepseek-v4-flash"
2995 cfg.Desktop.ProviderAccess = []string{"deepseek", "mimo-pro"}
2996 cfg.Providers = append(cfg.Providers, config.ProviderEntry{
2997 Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
2998 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, Default: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
2999 })
3000 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3001 t.Fatalf("save config: %v", err)
3002 }
3003
3004 models := NewApp().Models()
3005 refs := modelRefsFromView(models)
3006 for _, want := range []string{
3007 "deepseek/deepseek-v4-flash",
3008 "deepseek/deepseek-v4-pro",
3009 "mimo-pro/mimo-v2.5-pro",
3010 "mimo-pro/mimo-v2.5",
3011 } {
3012 if !refs[want] {
3013 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3014 }
3015 }
3016 for _, hidden := range []string{
3017 "deepseek-pro/deepseek-v4-pro",
3018 "mimo-flash/mimo-v2.5",
3019 } {
3020 if refs[hidden] {
3021 t.Fatalf("Models() refs = %+v, should not include hidden provider %s", models, hidden)
3022 }
3023 }
3024 if len(models) != 4 {
3025 t.Fatalf("Models() len = %d, want only explicitly selected provider models: %+v", len(models), models)
3026 }
3027 }
3028
3029 func TestModelsForTabListsNothingWhenProviderAccessExplicitlyEmpty(t *testing.T) {
3030 isolateDesktopUserDirs(t)
3031 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
3032
3033 cfg := config.Default()
3034 cfg.Desktop.ProviderAccess = []string{}
3035 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3036 t.Fatalf("save config: %v", err)
3037 }
3038
3039 if models := NewApp().Models(); len(models) != 0 {
3040 t.Fatalf("Models() = %+v, want no models when provider access is explicitly empty", models)
3041 }
3042 }
3043
3044 func TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {
3045 isolateDesktopUserDirs(t)
3046 setDesktopTestCredential(t, "LOCAL_API_KEY", "sk-test")
3047 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
3048 t.Fatalf("mkdir config dir: %v", err)
3049 }
3050 if err := os.WriteFile(config.UserConfigPath(), []byte(`
3051 default_model = "local/model-a"
3052
3053 [desktop]
3054 provider_access = ["local"]
3055
3056 [[providers]]
3057 name = "local"
3058 kind = "openai"
3059 base_url = "http://127.0.0.1:23333/v1"
3060 models = ["model-a", "model-b"]
3061 default = "model-a"
3062 api_key_env = "LOCAL_API_KEY"
3063 `), 0o644); err != nil {
3064 t.Fatalf("write config: %v", err)
3065 }
3066
3067 models := NewApp().Models()
3068 refs := modelRefsFromView(models)
3069 for _, want := range []string{"local/model-a", "local/model-b"} {
3070 if !refs[want] {
3071 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3072 }
3073 }
3074 if len(models) != 2 {
3075 t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
3076 }
3077 }
3078
3079 func TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {
3080 isolateDesktopUserDirs(t)
3081 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
3082 t.Fatalf("mkdir config dir: %v", err)
3083 }
3084 if err := os.WriteFile(config.UserConfigPath(), []byte(`
3085 default_model = "local/model-a"
3086
3087 [desktop]
3088 provider_access = ["local"]
3089
3090 [[providers]]
3091 name = "local"
3092 kind = "openai"
3093 base_url = "http://127.0.0.1:23333/v1"
3094 models = ["model-a", "model-b"]
3095 default = "model-a"
3096 `), 0o644); err != nil {
3097 t.Fatalf("write config: %v", err)
3098 }
3099
3100 models := NewApp().Models()
3101 refs := modelRefsFromView(models)
3102 for _, want := range []string{"local/model-a", "local/model-b"} {
3103 if !refs[want] {
3104 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3105 }
3106 }
3107 }
3108
3109 func TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {
3110 isolateDesktopUserDirs(t)
3111 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
3112 t.Fatalf("mkdir config dir: %v", err)
3113 }
3114 if err := os.WriteFile(config.UserConfigPath(), []byte(`
3115 default_model = "local/model-a"
3116
3117 [desktop]
3118 provider_access = ["local"]
3119
3120 [[providers]]
3121 name = "local"
3122 kind = "openai"
3123 base_url = "http://127.0.0.1:23333/v1"
3124 models = ["model-a", "model-b"]
3125 default = "model-a"
3126 api_key_env = "LOCAL_API_KEY"
3127 `), 0o644); err != nil {
3128 t.Fatalf("write config: %v", err)
3129 }
3130
3131 models := NewApp().Models()
3132 refs := modelRefsFromView(models)
3133 for _, want := range []string{"local/model-a", "local/model-b"} {
3134 if !refs[want] {
3135 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3136 }
3137 }
3138 }
3139
3140 func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
3141 isolateDesktopUserDirs(t)
3142 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
3143
3144 cfg := config.Default()
3145 preset, ok := config.CuratedProviderPreset("mimo-api")
3146 if !ok || len(preset.Entries) == 0 {
3147 t.Fatal("mimo-api preset missing")
3148 }
3149 if err := cfg.UpsertProvider(preset.Entries[0]); err != nil {
3150 t.Fatalf("upsert mimo-api preset: %v", err)
3151 }
3152 cfg.DefaultModel = "mimo-api/mimo-v2.5-pro"
3153 cfg.Desktop.ProviderAccess = []string{"mimo-api"}
3154 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3155 t.Fatalf("save config: %v", err)
3156 }
3157
3158 models := NewApp().Models()
3159 refs := modelRefsFromView(models)
3160 for _, want := range []string{
3161 "mimo-api/mimo-v2.6-pro",
3162 "mimo-api/mimo-v2.6-flash",
3163 "mimo-api/mimo-v2.5-pro",
3164 "mimo-api/mimo-v2.5",
3165 } {
3166 if !refs[want] {
3167 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3168 }
3169 }
3170 if len(models) != 4 {
3171 t.Fatalf("Models() len = %d, want 4: %+v", len(models), models)
3172 }
3173 }
3174
3175 func TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {
3176 isolateDesktopUserDirs(t)
3177 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
3178 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
3179
3180 userCfg := config.Default()
3181 userCfg.DefaultModel = "mimo-pro/mimo-v2.5-pro"
3182 userCfg.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"}
3183 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
3184 t.Fatalf("save user config: %v", err)
3185 }
3186
3187 projectRoot := t.TempDir()
3188 projectConfig := `default_model = "deepseek-flash/deepseek-v4-flash"
3189
3190 [desktop]
3191 provider_access = ["deepseek-flash"]
3192
3193 [[providers]]
3194 name = "deepseek-flash"
3195 kind = "openai"
3196 base_url = "https://api.deepseek.com"
3197 model = "deepseek-v4-flash"
3198 api_key_env = "DEEPSEEK_API_KEY"
3199 `
3200 if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil {
3201 t.Fatalf("write project config: %v", err)
3202 }
3203 approveWorkspace(t, projectRoot)
3204
3205 app := NewApp()
3206 tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true}
3207 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3208 app.activeTabID = tab.ID
3209
3210 models := app.ModelsForTab(tab.ID)
3211 refs := modelRefsFromView(models)
3212 for _, want := range []string{
3213 "deepseek/deepseek-flash",
3214 "mimo-pro/mimo-v2.5-pro",
3215 } {
3216 if !refs[want] {
3217 t.Fatalf("ModelsForTab refs = %+v, missing %s", models, want)
3218 }
3219 }
3220 }
3221
3222 func TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {
3223 isolateDesktopUserDirs(t)
3224 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
3225 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
3226
3227 cfg := config.Default()
3228 cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash"
3229 cfg.Desktop.ProviderAccess = []string{"deepseek-flash"}
3230 cfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: "other", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "other-model"})
3231 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3232 t.Fatalf("save config: %v", err)
3233 }
3234
3235 app := NewApp()
3236 app.ctx = context.Background()
3237 tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ready: true, model: "deepseek-flash/deepseek-v4-flash"}
3238 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3239 app.tabOrder = []string{tab.ID}
3240 app.activeTabID = tab.ID
3241
3242 err := app.SetModelForTab(tab.ID, "other/other-model")
3243 if err == nil || !strings.Contains(err.Error(), "not available") {
3244 t.Fatalf("SetModelForTab hidden provider error = %v, want not available", err)
3245 }
3246 }
3247
3248 func TestSetModelForTabRefreshesCarriedSystemPromptWithoutChangingDefaults(t *testing.T) {
3249 isolateDesktopUserDirs(t)
3250 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3251 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3252
3253 cfg := config.Default()
3254 cfg.DefaultModel = "old/old-model"
3255 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3256 cfg.Providers = []config.ProviderEntry{
3257 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3258 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3259 }
3260 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3261 t.Fatalf("save config: %v", err)
3262 }
3263 if err := os.MkdirAll(config.MemoryUserDir(), 0o755); err != nil {
3264 t.Fatalf("mkdir memory dir: %v", err)
3265 }
3266 const freshRule = "Fresh global AGENTS rule for model switch"
3267 if err := os.WriteFile(filepath.Join(config.MemoryUserDir(), "AGENTS.md"), []byte(freshRule), 0o644); err != nil {
3268 t.Fatalf("write global AGENTS.md: %v", err)
3269 }
3270
3271 dir := config.SessionDir()
3272 if err := os.MkdirAll(dir, 0o755); err != nil {
3273 t.Fatalf("mkdir session dir: %v", err)
3274 }
3275 oldSession := agent.NewSession("old system prompt without memory")
3276 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
3277 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3278 oldPath := filepath.Join(dir, "old.jsonl")
3279 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3280
3281 app := NewApp()
3282 app.ctx = context.Background()
3283 tab := &WorkspaceTab{
3284 ID: "tab_a",
3285 Scope: "global",
3286 Ready: true,
3287 model: "old/old-model",
3288 Ctrl: oldCtrl,
3289 sink: &tabEventSink{tabID: "tab_a", app: app},
3290 disabledMCP: map[string]ServerView{},
3291 }
3292 sibling := &WorkspaceTab{
3293 ID: "tab_b",
3294 Scope: "global",
3295 Ready: true,
3296 model: "old/old-model",
3297 disabledMCP: map[string]ServerView{},
3298 }
3299 app.tabs = map[string]*WorkspaceTab{tab.ID: tab, sibling.ID: sibling}
3300 app.tabOrder = []string{tab.ID, sibling.ID}
3301 app.activeTabID = tab.ID
3302 var switchTiming modelSwitchTiming
3303 app.modelSwitchTimingHook = func(timing modelSwitchTiming) { switchTiming = timing }
3304 t.Cleanup(func() {
3305 if tab.Ctrl != nil {
3306 tab.Ctrl.Close()
3307 }
3308 })
3309
3310 if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
3311 t.Fatalf("SetModelForTab: %v", err)
3312 }
3313 history := tab.Ctrl.History()
3314 if len(history) < 2 {
3315 t.Fatalf("history length = %d, want system + user", len(history))
3316 }
3317 if history[0].Role != provider.RoleSystem {
3318 t.Fatalf("first message role = %s, want system", history[0].Role)
3319 }
3320 if !strings.Contains(history[0].Content, freshRule) {
3321 t.Fatalf("refreshed system prompt missing global AGENTS rule:\n%s", history[0].Content)
3322 }
3323 if history[1].Role != provider.RoleUser || history[1].Content != "hello" {
3324 t.Fatalf("carried user message changed: %+v", history[1])
3325 }
3326 if got := config.LoadForEdit(config.UserConfigPath()).DefaultModel; got != "old/old-model" {
3327 t.Fatalf("default model after session switch = %q, want old/old-model", got)
3328 }
3329 if sibling.model != "old/old-model" {
3330 t.Fatalf("sibling tab model after session switch = %q, want old/old-model", sibling.model)
3331 }
3332 if switchTiming.Outcome != "ok" || switchTiming.Total <= 0 {
3333 t.Fatalf("model switch timing = %+v, want successful non-zero observation", switchTiming)
3334 }
3335 if switchTiming.Build < 0 || switchTiming.LeaseAndResume < 0 || switchTiming.SwapAndPersist < 0 {
3336 t.Fatalf("model switch stage timing incomplete: %+v", switchTiming)
3337 }
3338 }
3339
3340 // TestSetModelForTabRestoresSessionAuthorizations pins the fix for a model
3341 // switch dropping same-session "Allow for this session" tool grants and
3342 // Plan-mode read-only command trust, forcing the user to re-approve
3343 // something already granted this session after every model/effort/token-mode
3344 // switch.
3345 // TestRebuildSettingLockedRestoresSessionAuthorizations covers the same
3346 // dropped-session-authorization bug for the settings-change rebuild path
3347 // (also used by the deferred-rebuild retry loop), independent from
3348 // SetModelForTab's own rebuild.
3349 func TestRebuildSettingLockedRestoresSessionAuthorizations(t *testing.T) {
3350 isolateDesktopUserDirs(t)
3351 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3352
3353 cfg := config.Default()
3354 cfg.DefaultModel = "old/old-model"
3355 cfg.Desktop.ProviderAccess = []string{"old"}
3356 cfg.Providers = []config.ProviderEntry{
3357 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3358 }
3359 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3360 t.Fatalf("save config: %v", err)
3361 }
3362
3363 dir := config.SessionDir()
3364 if err := os.MkdirAll(dir, 0o755); err != nil {
3365 t.Fatalf("mkdir session dir: %v", err)
3366 }
3367 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
3368 oldPath := filepath.Join(dir, "old.jsonl")
3369 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3370 oldCtrl.RestoreSessionAuthorizations(control.SessionAuthorizations{
3371 Grants: []string{"bash|go test ./..."},
3372 PlanModeReadOnlyCommands: []string{"go test ./..."},
3373 })
3374
3375 app := NewApp()
3376 app.ctx = context.Background()
3377 tab := &WorkspaceTab{
3378 ID: "tab_a",
3379 Scope: "global",
3380 Ready: true,
3381 model: "old/old-model",
3382 Ctrl: oldCtrl,
3383 sink: &tabEventSink{tabID: "tab_a", app: app},
3384 disabledMCP: map[string]ServerView{},
3385 }
3386 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3387 app.tabOrder = []string{tab.ID}
3388 app.activeTabID = tab.ID
3389 app.readyHook = func() {}
3390 t.Cleanup(func() {
3391 if tab.Ctrl != nil {
3392 tab.Ctrl.Close()
3393 }
3394 })
3395
3396 if err := app.rebuildSetting("settings"); err != nil {
3397 t.Fatalf("rebuildSetting: %v", err)
3398 }
3399
3400 newCtrl, ok := tab.Ctrl.(*control.Controller)
3401 if !ok {
3402 t.Fatalf("tab.Ctrl = %T, want *control.Controller", tab.Ctrl)
3403 }
3404 got := newCtrl.SessionAuthorizations()
3405 if len(got.Grants) != 1 || got.Grants[0] != "bash|go test ./..." {
3406 t.Fatalf("restored grants = %+v, want [\"bash|go test ./...\"]", got.Grants)
3407 }
3408 if len(got.PlanModeReadOnlyCommands) != 1 || got.PlanModeReadOnlyCommands[0] != "go test ./..." {
3409 t.Fatalf("restored plan-mode read-only commands = %+v, want [\"go test ./...\"]", got.PlanModeReadOnlyCommands)
3410 }
3411 }
3412
3413 func TestSetModelForTabReusesCurrentSessionLease(t *testing.T) {
3414 isolateDesktopUserDirs(t)
3415 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3416 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3417
3418 cfg := config.Default()
3419 cfg.DefaultModel = "old/old-model"
3420 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3421 cfg.Providers = []config.ProviderEntry{
3422 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3423 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3424 }
3425 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3426 t.Fatalf("save config: %v", err)
3427 }
3428
3429 dir := config.SessionDir()
3430 if err := os.MkdirAll(dir, 0o755); err != nil {
3431 t.Fatalf("mkdir session dir: %v", err)
3432 }
3433 oldSession := agent.NewSession("old system prompt")
3434 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
3435 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3436 oldPath := filepath.Join(dir, "leased-model-switch.jsonl")
3437 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3438
3439 app := NewApp()
3440 app.ctx = context.Background()
3441 tab := &WorkspaceTab{
3442 ID: "tab_a",
3443 Scope: "global",
3444 Ready: true,
3445 model: "old/old-model",
3446 Ctrl: oldCtrl,
3447 sink: &tabEventSink{tabID: "tab_a", app: app},
3448 disabledMCP: map[string]ServerView{},
3449 }
3450 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3451 app.tabOrder = []string{tab.ID}
3452 app.activeTabID = tab.ID
3453 t.Cleanup(func() {
3454 if tab.Ctrl != nil {
3455 tab.Ctrl.Close()
3456 }
3457 tab.releaseSessionLease()
3458 })
3459
3460 if err := tab.ensureSessionLease(oldPath); err != nil {
3461 t.Fatalf("ensureSessionLease: %v", err)
3462 }
3463 if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
3464 t.Fatalf("SetModelForTab: %v", err)
3465 }
3466 if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
3467 t.Fatalf("tab controller was not rebuilt")
3468 }
3469 if got := tab.model; got != "new/new-model" {
3470 t.Fatalf("tab model = %q, want new/new-model", got)
3471 }
3472 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(oldPath) {
3473 t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), oldPath)
3474 }
3475 history := tab.Ctrl.History()
3476 if len(history) < 2 || history[1].Role != provider.RoleUser || history[1].Content != "hello" {
3477 t.Fatalf("carried history = %+v, want original user message", history)
3478 }
3479 }
3480
3481 func TestSetModelForTabWaitsForConcurrentBlankSessionLease(t *testing.T) {
3482 isolateDesktopUserDirs(t)
3483 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3484 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3485
3486 cfg := config.Default()
3487 cfg.DefaultModel = "old/old-model"
3488 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3489 cfg.Providers = []config.ProviderEntry{
3490 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3491 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3492 }
3493 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3494 t.Fatalf("save config: %v", err)
3495 }
3496
3497 dir := desktopSessionDir(globalTabWorkspaceRoot())
3498 if err := os.MkdirAll(dir, 0o755); err != nil {
3499 t.Fatalf("mkdir sessions: %v", err)
3500 }
3501 path := filepath.Join(dir, "blank-model-switch-race.jsonl")
3502 if err := os.WriteFile(path, nil, 0o644); err != nil {
3503 t.Fatalf("write blank session: %v", err)
3504 }
3505
3506 app := NewApp()
3507 app.ctx = context.Background()
3508 tab := &WorkspaceTab{
3509 ID: "tab_blank_race",
3510 Scope: "global",
3511 WorkspaceRoot: globalTabWorkspaceRoot(),
3512 SessionPath: path,
3513 Ready: true,
3514 model: "old/old-model",
3515 sink: &tabEventSink{tabID: "tab_blank_race", app: app},
3516 disabledMCP: map[string]ServerView{},
3517 }
3518 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3519 app.tabOrder = []string{tab.ID}
3520 app.activeTabID = tab.ID
3521 t.Cleanup(func() {
3522 if tab.Ctrl != nil {
3523 tab.Ctrl.Close()
3524 }
3525 tab.releaseSessionLease()
3526 })
3527
3528 acquired := make(chan struct{})
3529 releaseHook := make(chan struct{})
3530 var once sync.Once
3531 sessionLeaseAcquireHookForTest = func() {
3532 once.Do(func() {
3533 close(acquired)
3534 <-releaseHook
3535 })
3536 }
3537 t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
3538
3539 buildErr := make(chan error, 1)
3540 go func() {
3541 buildErr <- tab.ensureSessionLease(path)
3542 }()
3543
3544 select {
3545 case <-acquired:
3546 case err := <-buildErr:
3547 t.Fatalf("background lease acquire returned before hook: %v", err)
3548 case <-time.After(2 * time.Second):
3549 t.Fatal("background lease acquire did not start")
3550 }
3551
3552 switchErr := make(chan error, 1)
3553 go func() {
3554 switchErr <- app.SetModelForTab(tab.ID, "new/new-model")
3555 }()
3556
3557 select {
3558 case err := <-switchErr:
3559 t.Fatalf("SetModelForTab returned before concurrent lease was bound: %v", err)
3560 case <-time.After(50 * time.Millisecond):
3561 }
3562
3563 close(releaseHook)
3564 if err := <-buildErr; err != nil {
3565 t.Fatalf("background ensureSessionLease: %v", err)
3566 }
3567 if err := <-switchErr; err != nil {
3568 t.Fatalf("SetModelForTab: %v", err)
3569 }
3570 if tab.Ctrl == nil {
3571 t.Fatal("model switch did not build a controller")
3572 }
3573 if got := tab.model; got != "new/new-model" {
3574 t.Fatalf("tab model = %q, want new/new-model", got)
3575 }
3576 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) {
3577 t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
3578 }
3579 }
3580
3581 func TestSetModelForTabLeaseHeldKeepsCurrentController(t *testing.T) {
3582 isolateDesktopUserDirs(t)
3583 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3584 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3585
3586 cfg := config.Default()
3587 cfg.DefaultModel = "old/old-model"
3588 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3589 cfg.Providers = []config.ProviderEntry{
3590 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3591 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3592 }
3593 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3594 t.Fatalf("save config: %v", err)
3595 }
3596
3597 dir := config.SessionDir()
3598 if err := os.MkdirAll(dir, 0o755); err != nil {
3599 t.Fatalf("mkdir session dir: %v", err)
3600 }
3601 oldPath := filepath.Join(dir, "externally-leased-model-switch.jsonl")
3602 if err := os.WriteFile(oldPath, nil, 0o644); err != nil {
3603 t.Fatalf("write placeholder session: %v", err)
3604 }
3605 externalLease, err := agent.TryAcquireSessionLease(oldPath)
3606 if err != nil {
3607 t.Fatalf("TryAcquireSessionLease: %v", err)
3608 }
3609 defer externalLease.Release()
3610
3611 oldSession := agent.NewSession("old system prompt")
3612 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
3613 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3614 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3615 defer oldCtrl.Close()
3616
3617 app := NewApp()
3618 app.ctx = context.Background()
3619 tab := &WorkspaceTab{
3620 ID: "tab_a",
3621 Scope: "global",
3622 Ready: true,
3623 model: "old/old-model",
3624 Ctrl: oldCtrl,
3625 sink: &tabEventSink{tabID: "tab_a", app: app},
3626 disabledMCP: map[string]ServerView{},
3627 }
3628 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3629 app.tabOrder = []string{tab.ID}
3630 app.activeTabID = tab.ID
3631
3632 err = app.SetModelForTab(tab.ID, "new/new-model")
3633 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
3634 t.Fatalf("SetModelForTab err = %v, want ErrSessionLeaseHeld", err)
3635 }
3636 if strings.Contains(err.Error(), oldPath) || strings.Contains(err.Error(), "held by") {
3637 t.Fatalf("SetModelForTab surfaced raw lease details: %v", err)
3638 }
3639 if tab.Ctrl != oldCtrl {
3640 t.Fatalf("tab controller changed after failed switch")
3641 }
3642 if got := tab.model; got != "old/old-model" {
3643 t.Fatalf("tab model = %q, want old/old-model", got)
3644 }
3645 info, err := os.Stat(oldPath)
3646 if err != nil {
3647 t.Fatalf("stat session: %v", err)
3648 }
3649 if info.Size() != 0 {
3650 t.Fatalf("session file size = %d, want unchanged empty file", info.Size())
3651 }
3652 }
3653
3654 func TestSetModelForTabReattachesDetachedRuntime(t *testing.T) {
3655 isolateDesktopUserDirs(t)
3656 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3657 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3658
3659 cfg := config.Default()
3660 cfg.DefaultModel = "old/old-model"
3661 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3662 cfg.Providers = []config.ProviderEntry{
3663 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3664 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3665 }
3666 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3667 t.Fatalf("save config: %v", err)
3668 }
3669
3670 dir := desktopSessionDir(globalTabWorkspaceRoot())
3671 if err := os.MkdirAll(dir, 0o755); err != nil {
3672 t.Fatalf("mkdir session dir: %v", err)
3673 }
3674 path := filepath.Join(dir, "detached-model-switch.jsonl")
3675 oldSession := agent.NewSession("old system prompt")
3676 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello from detached"})
3677 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3678 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
3679 lease, err := agent.TryAcquireSessionLease(path)
3680 if err != nil {
3681 t.Fatalf("TryAcquireSessionLease: %v", err)
3682 }
3683
3684 app := NewApp()
3685 app.ctx = context.Background()
3686 key := sessionRuntimeKey(path)
3687 detached := &WorkspaceTab{
3688 ID: detachedRuntimeTabID(key),
3689 Scope: "global",
3690 SessionPath: path,
3691 Ctrl: oldCtrl,
3692 Ready: true,
3693 model: "old/old-model",
3694 disabledMCP: map[string]ServerView{},
3695 SharedHostKey: "detached-host",
3696 ActivityStatus: "",
3697 }
3698 detached.adoptSessionLease(lease)
3699 tab := &WorkspaceTab{
3700 ID: "tab_a",
3701 Scope: "global",
3702 SessionPath: path,
3703 Ready: true,
3704 model: "old/old-model",
3705 sink: &tabEventSink{tabID: "tab_a", app: app},
3706 disabledMCP: map[string]ServerView{},
3707 }
3708 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3709 app.detachedSessions = map[string]*WorkspaceTab{key: detached}
3710 app.tabOrder = []string{tab.ID}
3711 app.activeTabID = tab.ID
3712 t.Cleanup(func() {
3713 if tab.Ctrl != nil {
3714 tab.Ctrl.Close()
3715 }
3716 tab.releaseSessionLease()
3717 if detached.sessionLease != nil {
3718 detached.releaseSessionLease()
3719 }
3720 })
3721
3722 if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
3723 t.Fatalf("SetModelForTab: %v", err)
3724 }
3725 if _, ok := app.detachedSessions[key]; ok {
3726 t.Fatal("detached runtime was not consumed")
3727 }
3728 if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
3729 t.Fatalf("tab controller was not rebuilt from detached runtime")
3730 }
3731 if got := tab.model; got != "new/new-model" {
3732 t.Fatalf("tab model = %q, want new/new-model", got)
3733 }
3734 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != key {
3735 t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
3736 }
3737 history := tab.Ctrl.History()
3738 if len(history) < 2 || history[1].Content != "hello from detached" {
3739 t.Fatalf("carried history = %+v, want detached user message", history)
3740 }
3741 }
3742
3743 type staleWorkspaceBindingFixture struct {
3744 app *App
3745 tab *WorkspaceTab
3746 oldCtrl control.SessionAPI
3747 projectA string
3748 sessionDirA string
3749 sessionPathA string
3750 }
3751
3752 func newStaleWorkspaceBindingFixture(t *testing.T, suffix string) staleWorkspaceBindingFixture {
3753 return newStaleWorkspaceBindingFixtureWithLayout(t, suffix, "")
3754 }
3755
3756 func newStaleWorkspaceBindingFixtureWithLayout(t *testing.T, suffix, layoutStyle string) staleWorkspaceBindingFixture {
3757 t.Helper()
3758 isolateDesktopUserDirs(t)
3759 setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
3760
3761 // Submitted turns use a real provider, so the fixture must complete them
3762 // instantly instead of pointing at an unreachable host.
3763 providerStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
3764 w.Header().Set("Content-Type", "text/event-stream")
3765 w.WriteHeader(http.StatusOK)
3766 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
3767 }))
3768 t.Cleanup(providerStub.Close)
3769
3770 cfg := config.Default()
3771 cfg.DefaultModel = "test/test-model"
3772 cfg.Desktop.ProviderAccess = []string{"test"}
3773 cfg.Providers = []config.ProviderEntry{
3774 {Name: "test", Kind: "openai", BaseURL: providerStub.URL, Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
3775 }
3776 if strings.TrimSpace(layoutStyle) != "" {
3777 if err := cfg.SetDesktopLayoutStyle(layoutStyle); err != nil {
3778 t.Fatalf("set desktop layout style: %v", err)
3779 }
3780 }
3781 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3782 t.Fatalf("save config: %v", err)
3783 }
3784
3785 projectA := t.TempDir()
3786 projectB := t.TempDir()
3787 if err := addProject(projectA, "Project A"); err != nil {
3788 t.Fatalf("add project A: %v", err)
3789 }
3790 if err := addProject(projectB, "Project B"); err != nil {
3791 t.Fatalf("add project B: %v", err)
3792 }
3793
3794 topicID := "topic_" + suffix
3795 topicTitle := "Rebuild workspace " + suffix
3796 sessionDirA := desktopSessionDir(projectA)
3797 sessionDirB := desktopSessionDir(projectB)
3798 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
3799 t.Fatalf("mkdir project A sessions: %v", err)
3800 }
3801 if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
3802 t.Fatalf("mkdir project B sessions: %v", err)
3803 }
3804 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
3805 sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
3806
3807 oldSession := agent.NewSession("old system prompt")
3808 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "carry me"})
3809 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3810 oldCtrl := control.New(control.Options{
3811 Executor: oldExec,
3812 SessionDir: sessionDirB,
3813 SessionPath: sessionPathB,
3814 Label: "test/test-model",
3815 ModelRef: "test/test-model",
3816 WorkspaceRoot: projectB,
3817 Sink: event.Discard,
3818 })
3819
3820 app := NewApp()
3821 app.readyHook = func() {}
3822 tab := &WorkspaceTab{
3823 ID: "tab_stale_workspace_" + suffix,
3824 Scope: "project",
3825 WorkspaceRoot: projectB,
3826 TopicID: topicID,
3827 TopicTitle: topicTitle,
3828 SessionPath: sessionPathA,
3829 Ready: true,
3830 model: "test/test-model",
3831 Ctrl: oldCtrl,
3832 sink: &tabEventSink{tabID: "tab_stale_workspace_" + suffix, app: app},
3833 disabledMCP: map[string]ServerView{},
3834 }
3835 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3836 app.tabOrder = []string{tab.ID}
3837 app.activeTabID = tab.ID
3838 t.Cleanup(func() {
3839 if tab.Ctrl != nil {
3840 tab.Ctrl.Close()
3841 }
3842 })
3843
3844 return staleWorkspaceBindingFixture{
3845 app: app,
3846 tab: tab,
3847 oldCtrl: oldCtrl,
3848 projectA: projectA,
3849 sessionDirA: sessionDirA,
3850 sessionPathA: sessionPathA,
3851 }
3852 }
3853
3854 type blockingSnapshotCtrl struct {
3855 control.SessionAPI
3856
3857 firstSnapshotStarted chan struct{}
3858 secondSnapshotStarted chan struct{}
3859 releaseSnapshot chan struct{}
3860 firstOnce sync.Once
3861 secondOnce sync.Once
3862 snapshotCount atomic.Int32
3863 closeCount atomic.Int32
3864 }
3865
3866 func newBlockingSnapshotCtrl(ctrl control.SessionAPI) *blockingSnapshotCtrl {
3867 return &blockingSnapshotCtrl{
3868 SessionAPI: ctrl,
3869 firstSnapshotStarted: make(chan struct{}),
3870 secondSnapshotStarted: make(chan struct{}),
3871 releaseSnapshot: make(chan struct{}),
3872 }
3873 }
3874
3875 func (c *blockingSnapshotCtrl) Snapshot() error {
3876 count := c.snapshotCount.Add(1)
3877 switch count {
3878 case 1:
3879 c.firstOnce.Do(func() { close(c.firstSnapshotStarted) })
3880 case 2:
3881 c.secondOnce.Do(func() { close(c.secondSnapshotStarted) })
3882 }
3883 <-c.releaseSnapshot
3884 if c.SessionAPI == nil {
3885 return nil
3886 }
3887 return c.SessionAPI.Snapshot()
3888 }
3889
3890 func (c *blockingSnapshotCtrl) Close() {
3891 c.closeCount.Add(1)
3892 if c.SessionAPI != nil {
3893 c.SessionAPI.Close()
3894 }
3895 }
3896
3897 func TestDescribeSessionBindingWorkspaceKeepsWindowsPathReadable(t *testing.T) {
3898 path := `C:\Users\Jane Doe\Reasonix`
3899 want := `project workspace "C:\Users\Jane Doe\Reasonix"`
3900 if got := describeSessionBindingWorkspace("project", path); got != want {
3901 t.Fatalf("describeSessionBindingWorkspace = %q, want %q", got, want)
3902 }
3903 }
3904
3905 func TestEffortCommandUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
3906 isolateDesktopUserDirs(t)
3907 setDesktopTestCredential(t, "OWNER_MODEL_KEY", "sk-test")
3908 setDesktopTestCredential(t, "STALE_MODEL_KEY", "sk-test")
3909
3910 projectA := t.TempDir()
3911 projectB := t.TempDir()
3912 if err := addProject(projectA, "Project A"); err != nil {
3913 t.Fatalf("add project A: %v", err)
3914 }
3915 if err := addProject(projectB, "Project B"); err != nil {
3916 t.Fatalf("add project B: %v", err)
3917 }
3918 ownerConfig := `default_model = "owner/owner-model"
3919 [[providers]]
3920 name = "owner"
3921 kind = "openai"
3922 base_url = "https://owner.example.invalid/v1"
3923 model = "owner-model"
3924 api_key_env = "OWNER_MODEL_KEY"
3925 supported_efforts = ["max"]
3926 default_effort = "max"
3927 `
3928 if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil {
3929 t.Fatal(err)
3930 }
3931 approveWorkspace(t, projectA)
3932 staleConfig := `default_model = "stale/stale-model"
3933 [[providers]]
3934 name = "stale"
3935 kind = "openai"
3936 base_url = "https://stale.example.invalid/v1"
3937 model = "stale-model"
3938 api_key_env = "STALE_MODEL_KEY"
3939 reasoning_protocol = "none"
3940 `
3941 if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil {
3942 t.Fatal(err)
3943 }
3944 approveWorkspace(t, projectB)
3945
3946 topicID := "topic_effort_owner"
3947 topicTitle := "Effort owner"
3948 sessionDirA := desktopSessionDir(projectA)
3949 sessionDirB := desktopSessionDir(projectB)
3950 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
3951 t.Fatalf("mkdir project A sessions: %v", err)
3952 }
3953 if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
3954 t.Fatalf("mkdir project B sessions: %v", err)
3955 }
3956 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
3957 oldCtrl := control.New(control.Options{
3958 SessionDir: sessionDirB,
3959 SessionPath: filepath.Join(sessionDirB, "wrong.jsonl"),
3960 WorkspaceRoot: projectB,
3961 Sink: event.Discard,
3962 })
3963
3964 app := NewApp()
3965 app.readyHook = func() {}
3966 tab := &WorkspaceTab{
3967 ID: "tab_stale_effort",
3968 Scope: "project",
3969 WorkspaceRoot: projectB,
3970 TopicID: topicID,
3971 TopicTitle: topicTitle,
3972 SessionPath: sessionPathA,
3973 Ready: true,
3974 Ctrl: oldCtrl,
3975 sink: &tabEventSink{tabID: "tab_stale_effort", app: app},
3976 disabledMCP: map[string]ServerView{},
3977 }
3978 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3979 app.tabOrder = []string{tab.ID}
3980 app.activeTabID = tab.ID
3981 t.Cleanup(func() {
3982 if tab.Ctrl != nil {
3983 tab.Ctrl.Close()
3984 }
3985 })
3986
3987 if err := app.SubmitToTab(tab.ID, "/effort max"); err != nil {
3988 t.Fatalf("SubmitToTab(/effort max): %v", err)
3989 }
3990 waitNotRunning(t, tab.Ctrl)
3991 if tab.effort == nil || *tab.effort != "max" {
3992 t.Fatalf("tab effort = %#v, want max from pinned project A provider", tab.effort)
3993 }
3994 if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
3995 t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
3996 }
3997 if got := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
3998 t.Fatalf("controller workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
3999 }
4000 }
4001
4002 // A config file written before the classic style was removed still holds the
4003 // retired value. It must read as workbench — not fail, and not resurrect the
4004 // multi-tab model that value used to select, which the UI can no longer show.
4005 func TestRetiredClassicLayoutReadsAsWorkbench(t *testing.T) {
4006 isolateDesktopUserDirs(t)
4007 if err := editUserConfig(func(c *config.Config) error {
4008 c.Desktop.LayoutStyle = "classic"
4009 return nil
4010 }); err != nil {
4011 t.Fatalf("write the retired layout style: %v", err)
4012 }
4013 cfg, err := config.Load()
4014 if err != nil {
4015 t.Fatalf("load config: %v", err)
4016 }
4017 if got := cfg.DesktopLayoutStyle(); got != "workbench" {
4018 t.Fatalf("retired classic reads as %q, want workbench", got)
4019 }
4020 }
4021
4022 func TestListSessionsUsesPinnedSessionOwnerBeforeStaleRuntimeDir(t *testing.T) {
4023 isolateDesktopUserDirs(t)
4024
4025 projectA := t.TempDir()
4026 projectB := t.TempDir()
4027 if err := addProject(projectA, "Project A"); err != nil {
4028 t.Fatalf("add project A: %v", err)
4029 }
4030 if err := addProject(projectB, "Project B"); err != nil {
4031 t.Fatalf("add project B: %v", err)
4032 }
4033 sessionDirA := desktopSessionDir(projectA)
4034 sessionDirB := desktopSessionDir(projectB)
4035 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
4036 t.Fatalf("mkdir project A sessions: %v", err)
4037 }
4038 if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
4039 t.Fatalf("mkdir project B sessions: %v", err)
4040 }
4041 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_project_a", "Project A topic", projectA, "project A prompt", time.Now())
4042 sessionPathB := writeTopicSessionWithPrompt(t, sessionDirB, "project-b.jsonl", "topic_project_b", "Project B topic", projectB, "project B prompt", time.Now().Add(time.Minute))
4043
4044 app := NewApp()
4045 oldCtrl := control.New(control.Options{
4046 SessionDir: sessionDirB,
4047 SessionPath: sessionPathB,
4048 WorkspaceRoot: projectB,
4049 Sink: event.Discard,
4050 })
4051 tab := &WorkspaceTab{
4052 ID: "tab_stale_runtime_dir",
4053 Scope: "project",
4054 WorkspaceRoot: projectB,
4055 TopicID: "topic_project_a",
4056 TopicTitle: "Project A topic",
4057 SessionPath: sessionPathA,
4058 Ready: true,
4059 Ctrl: oldCtrl,
4060 disabledMCP: map[string]ServerView{},
4061 }
4062 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4063 app.tabOrder = []string{tab.ID}
4064 app.activeTabID = tab.ID
4065 installSessionCatalogForTest(t, app, sessionDirA, "project", projectA)
4066 t.Cleanup(oldCtrl.Close)
4067 sessions := listSessionsAfterPinnedOwnerReconcile(t, app, sessionDirA, projectA)
4068 if len(sessions) == 0 {
4069 t.Fatal("ListSessions() returned no sessions")
4070 }
4071 if filepath.Clean(sessions[0].Path) != filepath.Clean(sessionPathA) {
4072 t.Fatalf("ListSessions()[0].Path = %q, want pinned project A session %q", sessions[0].Path, sessionPathA)
4073 }
4074 for _, item := range sessions {
4075 if filepath.Clean(item.Path) == filepath.Clean(sessionPathB) {
4076 t.Fatalf("ListSessions() included stale project B runtime session: %+v", sessions)
4077 }
4078 }
4079 if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
4080 t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
4081 }
4082 }
4083
4084 func TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {
4085 isolateDesktopUserDirs(t)
4086 t.Setenv("MIMO_API_KEY", "")
4087
4088 cfg := config.Default()
4089 cfg.Desktop.ProviderAccess = []string{"mimo-api"}
4090 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4091 t.Fatalf("save config: %v", err)
4092 }
4093
4094 app := NewApp()
4095 tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ready: true, model: "deepseek-flash/deepseek-v4-flash"}
4096 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4097 app.tabOrder = []string{tab.ID}
4098 app.activeTabID = tab.ID
4099
4100 err := app.SetDefaultModel("mimo-api/mimo-v2.5-pro")
4101 if err == nil || !strings.Contains(err.Error(), "has no key") {
4102 t.Fatalf("SetDefaultModel no-key error = %v, want has no key", err)
4103 }
4104 if tab.model != "deepseek-flash/deepseek-v4-flash" {
4105 t.Fatalf("tab model after failed default change = %q, want previous", tab.model)
4106 }
4107 }
4108
4109 func TestSaveProviderPersistsReasoningProtocol(t *testing.T) {
4110 isolateDesktopUserDirs(t)
4111
4112 app := NewApp()
4113 if err := app.SaveProvider(ProviderView{
4114 Name: "deepseek-proxy",
4115 Kind: "openai",
4116 BaseURL: "https://proxy.example.com/v1",
4117 Models: []string{"deepseek-v4-flash"},
4118 Default: "deepseek-v4-flash",
4119 APIKeyEnv: "DEEPSEEK_PROXY_KEY",
4120 ReasoningProtocol: "none",
4121 SupportedEfforts: []string{"high", "max"},
4122 DefaultEffort: "max",
4123 }); err != nil {
4124 t.Fatalf("SaveProvider: %v", err)
4125 }
4126
4127 cfg := config.LoadForEdit(config.UserConfigPath())
4128 got, ok := cfg.Provider("deepseek-proxy")
4129 if !ok {
4130 t.Fatal("saved provider not found")
4131 }
4132 if got.ReasoningProtocol != "none" || got.DefaultEffort != "max" {
4133 t.Fatalf("saved provider = %+v, want reasoning_protocol none and default_effort max", got)
4134 }
4135
4136 view := app.Settings()
4137 for _, p := range view.Providers {
4138 if p.Name == "deepseek-proxy" {
4139 if p.ReasoningProtocol != "none" {
4140 t.Fatalf("settings reasoningProtocol = %q, want none", p.ReasoningProtocol)
4141 }
4142 return
4143 }
4144 }
4145 t.Fatalf("Settings() missing saved provider: %+v", view.Providers)
4146 }
4147
4148 func TestDeleteProviderMigratesConfigAndPreservesOpenTabs(t *testing.T) {
4149 isolateDesktopUserDirs(t)
4150 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4151
4152 cfg := config.Default()
4153 cfg.DefaultModel = "prov-a/model-a2"
4154 cfg.Providers = []config.ProviderEntry{
4155 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", Models: []string{"model-a1", "model-a2"}, APIKeyEnv: "REASONIX_TEST_KEY"},
4156 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4157 }
4158 cfg.Agent.PlannerModel = "prov-a"
4159 cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
4160 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4161 t.Fatalf("save config: %v", err)
4162 }
4163
4164 ctrl := control.New(control.Options{Label: "old"})
4165 defer ctrl.Close()
4166 app := NewApp()
4167 tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ctrl: ctrl, Label: "prov-a/model-a1", Ready: true, model: "prov-a/model-a1"}
4168 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4169 app.tabOrder = []string{tab.ID}
4170 app.activeTabID = tab.ID
4171
4172 if err := app.DeleteProvider("prov-a"); err != nil {
4173 t.Fatalf("DeleteProvider: %v", err)
4174 }
4175
4176 got := config.LoadForEdit(config.UserConfigPath())
4177 if _, ok := got.Provider("prov-a"); ok {
4178 t.Fatal("prov-a should be removed")
4179 }
4180 if got.DefaultModel != "prov-b" || got.Agent.PlannerModel != "prov-b" {
4181 t.Fatalf("model refs after delete = default:%q planner:%q, want prov-b", got.DefaultModel, got.Agent.PlannerModel)
4182 }
4183 if providerAccessSet(got.Desktop.ProviderAccess)["prov-a"] {
4184 t.Fatalf("provider access still contains prov-a: %+v", got.Desktop.ProviderAccess)
4185 }
4186 if tab.model != "prov-a/model-a1" || tab.Label != "prov-a/model-a1" {
4187 t.Fatalf("saving deletion changed current identity: model:%q label:%q", tab.model, tab.Label)
4188 }
4189 if tab.Ctrl != ctrl {
4190 t.Fatal("saving deletion closed the current controller")
4191 }
4192 }
4193
4194 // assertTabBuildSuperseded checks that the startup build registered before the
4195 // mutation (generation) can no longer install its controller and that its
4196 // build context was cancelled.
4197 func assertTabBuildSuperseded(t *testing.T, app *App, tab *WorkspaceTab, generation uint64, buildCtx context.Context) {
4198 t.Helper()
4199 app.mu.Lock()
4200 superseded := app.tabBuildSupersededLocked(tab, generation)
4201 app.mu.Unlock()
4202 if !superseded {
4203 t.Fatal("in-flight startup build was not superseded; finishing it would reinstall a stale controller")
4204 }
4205 select {
4206 case <-buildCtx.Done():
4207 default:
4208 t.Fatal("in-flight startup build context was not cancelled")
4209 }
4210 if tab.buildCancel != nil {
4211 t.Fatal("build cancel was not cleared")
4212 }
4213 }
4214
4215 func TestDeleteProviderLeavesStartupPublicationToVersionFence(t *testing.T) {
4216 isolateDesktopUserDirs(t)
4217 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4218
4219 cfg := config.Default()
4220 cfg.DefaultModel = "prov-b/model-b1"
4221 cfg.Providers = []config.ProviderEntry{
4222 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4223 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4224 }
4225 cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
4226 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4227 t.Fatalf("save config: %v", err)
4228 }
4229
4230 app := NewApp()
4231 // Model the async startup build still being in flight for the affected
4232 // tab: no controller yet, a live generation, a cancellable build context.
4233 buildCtx, buildCancel := context.WithCancel(context.Background())
4234 tab := &WorkspaceTab{
4235 ID: "tab_a",
4236 Scope: "global",
4237 model: "prov-a/model-a1",
4238 buildGeneration: 1,
4239 buildCancel: buildCancel,
4240 }
4241 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4242 app.tabOrder = []string{tab.ID}
4243 app.activeTabID = tab.ID
4244
4245 if err := app.DeleteProvider("prov-a"); err != nil {
4246 t.Fatalf("DeleteProvider: %v", err)
4247 }
4248 if tab.buildGeneration != 1 || buildCtx.Err() != nil || tab.model != "prov-a/model-a1" {
4249 t.Fatal("saving deletion changed an in-flight startup before its publication fence")
4250 }
4251 buildCancel()
4252 }
4253
4254 func TestRemoveBuiltInProviderAccessLeavesStartupPublicationToVersionFence(t *testing.T) {
4255 isolateDesktopUserDirs(t)
4256 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4257
4258 cfg := config.Default()
4259 cfg.DefaultModel = "prov-b/model-b1"
4260 cfg.Providers = []config.ProviderEntry{
4261 {Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-chat", APIKeyEnv: "REASONIX_TEST_KEY"},
4262 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4263 }
4264 cfg.Desktop.ProviderAccess = []string{"deepseek", "prov-b"}
4265 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4266 t.Fatalf("save config: %v", err)
4267 }
4268
4269 app := NewApp()
4270 buildCtx, buildCancel := context.WithCancel(context.Background())
4271 tab := &WorkspaceTab{
4272 ID: "tab_ds",
4273 Scope: "global",
4274 model: "deepseek/deepseek-chat",
4275 buildGeneration: 1,
4276 buildCancel: buildCancel,
4277 }
4278 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4279 app.tabOrder = []string{tab.ID}
4280 app.activeTabID = tab.ID
4281
4282 if err := app.RemoveProviderAccess("deepseek"); err != nil {
4283 t.Fatalf("RemoveProviderAccess: %v", err)
4284 }
4285 if tab.buildGeneration != 1 || buildCtx.Err() != nil || tab.model != "deepseek/deepseek-chat" {
4286 t.Fatal("saving access removal changed an in-flight startup before its publication fence")
4287 }
4288 buildCancel()
4289 }
4290
4291 func TestClearActiveSessionRuntimeSupersedesInFlightStartupBuild(t *testing.T) {
4292 isolateDesktopUserDirs(t)
4293 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
4294
4295 cfg := config.Default()
4296 cfg.DefaultModel = "old/old-model"
4297 cfg.Desktop.ProviderAccess = []string{"old"}
4298 cfg.Providers = []config.ProviderEntry{{
4299 Name: "old",
4300 Kind: "openai",
4301 BaseURL: "https://example.invalid/v1",
4302 Model: "old-model",
4303 APIKeyEnv: "OLD_MODEL_KEY",
4304 }}
4305 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4306 t.Fatalf("save config: %v", err)
4307 }
4308
4309 dir := config.SessionDir()
4310 if err := os.MkdirAll(dir, 0o755); err != nil {
4311 t.Fatalf("mkdir session dir: %v", err)
4312 }
4313 sessionPath := filepath.Join(dir, "clear-runtime-in-flight.jsonl")
4314 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
4315 t.Fatalf("write placeholder session: %v", err)
4316 }
4317
4318 oldSession := agent.NewSession("old system prompt")
4319 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
4320 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
4321
4322 app := NewApp()
4323 // A runtime is attached while an older async build is still in flight
4324 // (e.g. attached via topic activation); destroying the session must
4325 // invalidate that build so it cannot resurrect the destroyed session.
4326 buildCtx, buildCancel := context.WithCancel(context.Background())
4327 tab := &WorkspaceTab{
4328 ID: "tab_clear",
4329 Scope: "global",
4330 SessionPath: sessionPath,
4331 model: "old/old-model",
4332 Ready: true,
4333 Ctrl: oldCtrl,
4334 buildGeneration: 1,
4335 buildCancel: buildCancel,
4336 disabledMCP: map[string]ServerView{},
4337 }
4338 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
4339 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4340 app.tabOrder = []string{tab.ID}
4341 app.activeTabID = tab.ID
4342 t.Cleanup(tab.releaseSessionLease)
4343
4344 if _, err := app.clearActiveSessionRuntime(tab, oldCtrl); err != nil {
4345 t.Fatalf("clearActiveSessionRuntime: %v", err)
4346 }
4347 if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
4348 t.Fatalf("clear did not install a fresh controller (ctrl=%v)", tab.Ctrl)
4349 }
4350 defer tab.Ctrl.Close()
4351 assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
4352 }
4353
4354 func TestClearActiveSessionRuntimeReleasesResourcesWhenTabReplaced(t *testing.T) {
4355 isolateDesktopUserDirs(t)
4356 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
4357
4358 cfg := config.Default()
4359 cfg.DefaultModel = "old/old-model"
4360 cfg.Desktop.ProviderAccess = []string{"old"}
4361 cfg.Providers = []config.ProviderEntry{{
4362 Name: "old",
4363 Kind: "openai",
4364 BaseURL: "https://example.invalid/v1",
4365 Model: "old-model",
4366 APIKeyEnv: "OLD_MODEL_KEY",
4367 }}
4368 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4369 t.Fatalf("save config: %v", err)
4370 }
4371
4372 dir := config.SessionDir()
4373 if err := os.MkdirAll(dir, 0o755); err != nil {
4374 t.Fatalf("mkdir session dir: %v", err)
4375 }
4376 sessionPath := filepath.Join(dir, "clear-runtime-replaced-tab.jsonl")
4377 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
4378 t.Fatalf("write placeholder session: %v", err)
4379 }
4380
4381 oldSession := agent.NewSession("old system prompt")
4382 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
4383 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
4384
4385 app := NewApp()
4386 tab := &WorkspaceTab{
4387 ID: "tab_replaced",
4388 Scope: "global",
4389 SessionPath: sessionPath,
4390 model: "old/old-model",
4391 Ready: true,
4392 Ctrl: oldCtrl,
4393 disabledMCP: map[string]ServerView{},
4394 }
4395 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
4396 // The tab entry now points at a replacement struct (the tab was closed and
4397 // reopened while the clear ran off-lock), so the swap must not apply.
4398 replacement := &WorkspaceTab{ID: tab.ID, Scope: "global"}
4399 app.tabs = map[string]*WorkspaceTab{tab.ID: replacement}
4400 app.tabOrder = []string{tab.ID}
4401 app.activeTabID = tab.ID
4402 t.Cleanup(tab.releaseSessionLease)
4403
4404 _, err := app.clearActiveSessionRuntime(tab, oldCtrl)
4405 if err == nil || !strings.Contains(err.Error(), "changed while clearing") {
4406 t.Fatalf("clearActiveSessionRuntime error = %v, want tab-changed error", err)
4407 }
4408 if replacement.Ctrl != nil {
4409 t.Fatalf("replacement tab controller = %v, want untouched nil", replacement.Ctrl)
4410 }
4411 if tab.Ctrl != oldCtrl {
4412 t.Fatalf("replaced tab controller = %v, want left on the destroyed runtime", tab.Ctrl)
4413 }
4414 if key := tab.sessionLeaseRuntimeKey(); key != "" {
4415 t.Fatalf("replaced tab still holds a session lease for %q; the fresh lease leaked", key)
4416 }
4417 if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
4418 t.Fatalf("old session artifacts were not destroyed (stat err=%v)", err)
4419 }
4420 }
4421
4422 func TestDeleteProviderPreservesRunningAffectedTab(t *testing.T) {
4423 isolateDesktopUserDirs(t)
4424 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4425
4426 cfg := config.Default()
4427 cfg.DefaultModel = "prov-a/model-a1"
4428 cfg.Providers = []config.ProviderEntry{
4429 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4430 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4431 }
4432 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4433 t.Fatalf("save config: %v", err)
4434 }
4435
4436 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
4437 app := NewApp()
4438 app.setTestCtrl(control.New(control.Options{Runner: runner}), "prov-a/model-a1")
4439 ctrl := app.activeCtrl()
4440 ctrl.Submit("work")
4441 <-runner.started
4442
4443 err := app.DeleteProvider("prov-a")
4444 if err != nil || app.activeCtrl() != ctrl || !ctrl.RuntimeStatus().Running {
4445 t.Fatalf("DeleteProvider interrupted accepted work: %v", err)
4446 }
4447 if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); ok {
4448 t.Fatal("provider deletion was not persisted during the run")
4449 }
4450
4451 close(runner.release)
4452 waitNotRunning(t, ctrl)
4453 ctrl.Close()
4454 }
4455
4456 func TestDeleteProviderDoesNotWaitForRuntimeReconstruction(t *testing.T) {
4457 isolateDesktopUserDirs(t)
4458 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4459 cfg := config.Default()
4460 cfg.DefaultModel = "prov-a/model-a1"
4461 cfg.Providers = []config.ProviderEntry{
4462 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4463 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4464 }
4465 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4466 t.Fatalf("save config: %v", err)
4467 }
4468
4469 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
4470 app := NewApp()
4471 app.setTestCtrl(control.New(control.Options{Runner: runner}), "prov-a/model-a1")
4472 ctrl := app.activeCtrl()
4473 app.runtimeRebuildMu.Lock()
4474 defer app.runtimeRebuildMu.Unlock()
4475 ctrl.Submit("work")
4476 <-runner.started
4477 done := make(chan error, 1)
4478 go func() { done <- app.DeleteProvider("prov-a") }()
4479 select {
4480 case err := <-done:
4481 if err != nil {
4482 t.Fatal(err)
4483 }
4484 case <-time.After(5 * time.Second):
4485 t.Fatal("saving provider deletion waited for runtime reconstruction")
4486 }
4487 if app.activeCtrl() != ctrl || !ctrl.RuntimeStatus().Running {
4488 t.Fatal("saving deletion interrupted accepted work")
4489 }
4490 if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); ok {
4491 t.Fatal("deletion was not committed")
4492 }
4493 close(runner.release)
4494 waitNotRunning(t, ctrl)
4495 ctrl.Close()
4496 }
4497
4498 func TestDeleteProviderPreservesAffectedTabSharedHostReference(t *testing.T) {
4499 isolateDesktopUserDirs(t)
4500 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4501 cfg := config.Default()
4502 cfg.DefaultModel = "prov-a/model-a1"
4503 cfg.Providers = []config.ProviderEntry{
4504 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4505 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4506 }
4507 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4508 t.Fatalf("save config: %v", err)
4509 }
4510
4511 app := NewApp()
4512 hostKey := "provider-shared-host"
4513 host := app.acquireSharedHost(hostKey)
4514 ctrl := control.New(control.Options{Host: host})
4515 tab := &WorkspaceTab{
4516 ID: "affected", Scope: "global", Ready: true, Ctrl: ctrl,
4517 model: "prov-a/model-a1", SharedHostKey: hostKey, disabledMCP: map[string]ServerView{},
4518 }
4519 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4520 app.tabOrder = []string{tab.ID}
4521 app.activeTabID = tab.ID
4522
4523 if err := app.DeleteProvider("prov-a"); err != nil {
4524 t.Fatalf("DeleteProvider: %v", err)
4525 }
4526 if tab.SharedHostKey != hostKey || tab.Ctrl != ctrl {
4527 t.Fatal("saving deletion released the current runtime's shared host")
4528 }
4529 app.sharedHostsMu.Lock()
4530 _, retained := app.sharedHosts[hostKey]
4531 app.sharedHostsMu.Unlock()
4532 if !retained {
4533 t.Fatal("provider deletion released an owned shared host reference")
4534 }
4535 ctrl.Close()
4536 app.releaseSharedHost(hostKey)
4537 }
4538
4539 func TestRemoveBuiltInProviderAccessPreservesAffectedTabSharedHostReference(t *testing.T) {
4540 isolateDesktopUserDirs(t)
4541 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4542 cfg := config.Default()
4543 cfg.DefaultModel = "deepseek/deepseek-chat"
4544 cfg.Providers = []config.ProviderEntry{
4545 {Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-chat", APIKeyEnv: "REASONIX_TEST_KEY"},
4546 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4547 }
4548 cfg.Desktop.ProviderAccess = []string{"deepseek", "prov-b"}
4549 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4550 t.Fatalf("save config: %v", err)
4551 }
4552
4553 app := NewApp()
4554 hostKey := "provider-access-shared-host"
4555 host := app.acquireSharedHost(hostKey)
4556 ctrl := control.New(control.Options{Host: host})
4557 tab := &WorkspaceTab{
4558 ID: "affected", Scope: "global", Ready: true, Ctrl: ctrl,
4559 model: "deepseek/deepseek-chat", SharedHostKey: hostKey, disabledMCP: map[string]ServerView{},
4560 }
4561 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4562 app.tabOrder = []string{tab.ID}
4563 app.activeTabID = tab.ID
4564
4565 if err := app.RemoveProviderAccess("deepseek"); err != nil {
4566 t.Fatalf("RemoveProviderAccess: %v", err)
4567 }
4568 if tab.SharedHostKey != hostKey || tab.Ctrl != ctrl {
4569 t.Fatal("saving access removal released the current runtime's shared host")
4570 }
4571 app.sharedHostsMu.Lock()
4572 _, retained := app.sharedHosts[hostKey]
4573 app.sharedHostsMu.Unlock()
4574 if !retained {
4575 t.Fatal("provider access removal released an owned shared host reference")
4576 }
4577 ctrl.Close()
4578 app.releaseSharedHost(hostKey)
4579 }
4580
4581 func TestDeleteProviderPreservesAffectedBackgroundJobs(t *testing.T) {
4582 isolateDesktopUserDirs(t)
4583 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4584
4585 cfg := config.Default()
4586 cfg.DefaultModel = "prov-a/model-a1"
4587 cfg.Providers = []config.ProviderEntry{
4588 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4589 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4590 }
4591 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4592 t.Fatalf("save config: %v", err)
4593 }
4594
4595 dir := config.SessionDir()
4596 if err := os.MkdirAll(dir, 0o755); err != nil {
4597 t.Fatalf("mkdir session dir: %v", err)
4598 }
4599 path := filepath.Join(dir, "provider-job.jsonl")
4600 jm := jobs.NewManager(event.Discard)
4601 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
4602 defer ctrl.Close()
4603 app := NewApp()
4604 app.setTestCtrl(ctrl, "prov-a/model-a1")
4605 jm.StartForSession(agent.BranchID(path), "bash", "provider job", func(ctx context.Context, _ io.Writer) (string, error) {
4606 <-ctx.Done()
4607 return "", ctx.Err()
4608 })
4609
4610 err := app.DeleteProvider("prov-a")
4611 if err != nil || !controllerHasActiveRuntimeWork(ctrl) {
4612 t.Fatalf("DeleteProvider interrupted background work: %v", err)
4613 }
4614 if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); ok {
4615 t.Fatal("provider deletion was not persisted")
4616 }
4617 }
4618
4619 func TestDeleteProviderPreservesUnaffectedBackgroundJobsWhenSavingConfig(t *testing.T) {
4620 isolateDesktopUserDirs(t)
4621 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4622
4623 cfg := config.Default()
4624 cfg.DefaultModel = "prov-b/model-b1"
4625 cfg.Providers = []config.ProviderEntry{
4626 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4627 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4628 }
4629 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4630 t.Fatalf("save config: %v", err)
4631 }
4632
4633 app := NewApp()
4634 app.ctx = context.Background()
4635 app.setTestCtrl(newBackgroundJobController(t, "provider-unaffected-job"), "prov-b/model-b1")
4636
4637 err := app.DeleteProvider("prov-a")
4638 if err != nil || !controllerHasActiveRuntimeWork(app.activeCtrl()) {
4639 t.Fatalf("DeleteProvider interrupted unrelated background work: %v", err)
4640 }
4641 if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); ok {
4642 t.Fatal("provider deletion was not persisted")
4643 }
4644 }
4645
4646 func TestRemoveBuiltInProviderAccessPreservesBackgroundJobsWhenSavingConfig(t *testing.T) {
4647 isolateDesktopUserDirs(t)
4648 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
4649 t.Fatalf("mkdir config dir: %v", err)
4650 }
4651 if err := os.WriteFile(config.UserConfigPath(), []byte(`
4652 default_model = "mimo-pro/mimo-v2.5-pro"
4653
4654 [desktop]
4655 provider_access = ["deepseek-flash", "mimo-pro"]
4656
4657 [[providers]]
4658 name = "deepseek-flash"
4659 kind = "openai"
4660 base_url = "https://api.deepseek.com"
4661 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
4662 default = "deepseek-v4-flash"
4663 api_key_env = "DEEPSEEK_API_KEY"
4664
4665 [[providers]]
4666 name = "mimo-pro"
4667 kind = "openai"
4668 base_url = "https://token-plan-cn.xiaomimimo.com/v1"
4669 model = "mimo-v2.5-pro"
4670 api_key_env = "MIMO_API_KEY"
4671 `), 0o644); err != nil {
4672 t.Fatalf("write config: %v", err)
4673 }
4674
4675 app := NewApp()
4676 app.ctx = context.Background()
4677 app.setTestCtrl(newBackgroundJobController(t, "provider-access-unaffected-job"), "mimo-token-plan/mimo-v2.5-pro")
4678
4679 err := app.RemoveProviderAccess("deepseek")
4680 if err != nil || !controllerHasActiveRuntimeWork(app.activeCtrl()) {
4681 t.Fatalf("RemoveProviderAccess interrupted background work: %v", err)
4682 }
4683 cfg := config.LoadForEdit(config.UserConfigPath())
4684 access := providerAccessSet(cfg.Desktop.ProviderAccess)
4685 if access["deepseek"] || access["deepseek-flash"] {
4686 t.Fatalf("provider access removal was not persisted: %+v", cfg.Desktop.ProviderAccess)
4687 }
4688 }
4689
4690 func TestConnectKeySavesWhileBackgroundJobKeepsItsController(t *testing.T) {
4691 isolateDesktopUserDirs(t)
4692 t.Setenv("DEEPSEEK_API_KEY", "")
4693 os.Unsetenv("DEEPSEEK_API_KEY")
4694 oldFetch := connectKeyBalanceFetch
4695 connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
4696 return &billing.Balance{Available: true}, nil
4697 }
4698 t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
4699
4700 app := NewApp()
4701 app.ctx = context.Background()
4702 app.setTestCtrl(newBackgroundJobController(t, "connect-key-job"), "deepseek-flash/deepseek-v4-flash")
4703 oldCtrl := app.activeCtrl()
4704
4705 _, err := app.ConnectKey("sk-test")
4706 if err != nil {
4707 t.Fatalf("ConnectKey with background job: %v", err)
4708 }
4709 p, ok := config.LoadForEdit(config.UserConfigPath()).Provider("deepseek")
4710 if !ok || !p.Configured() || app.activeCtrl() != oldCtrl {
4711 t.Fatal("key save must configure the connection and preserve the background runtime")
4712 }
4713 }
4714
4715 func TestConnectKeyRestoresDeepSeekProviderAccess(t *testing.T) {
4716 isolateDesktopUserDirs(t)
4717 cfg := config.Default()
4718 cfg.DefaultModel = "custom/custom-model"
4719 cfg.Desktop.ProviderAccess = []string{"custom"}
4720 cfg.Providers = []config.ProviderEntry{{
4721 Name: "custom", Kind: "openai", BaseURL: "https://models.example.invalid/v1",
4722 Model: "custom-model", APIKeyEnv: "CUSTOM_API_KEY",
4723 }}
4724 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4725 t.Fatalf("save custom provider config: %v", err)
4726 }
4727
4728 oldFetch := connectKeyBalanceFetch
4729 connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
4730 return &billing.Balance{Available: true}, nil
4731 }
4732 t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
4733
4734 app := NewApp()
4735 app.ctx = context.Background()
4736 app.readyHook = func() {}
4737 app.setTestCtrl(control.New(control.Options{Label: "custom"}), "custom/custom-model")
4738 defer func() {
4739 if ctrl := app.activeCtrl(); ctrl != nil {
4740 ctrl.Close()
4741 }
4742 }()
4743 if _, err := app.ConnectKey("sk-test"); err != nil {
4744 t.Fatalf("ConnectKey: %v", err)
4745 }
4746
4747 got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
4748 if !providerAccessSet(got.Desktop.ProviderAccess)["deepseek"] {
4749 t.Fatalf("provider_access = %v, want DeepSeek restored", got.Desktop.ProviderAccess)
4750 }
4751 if _, ok := got.Provider("deepseek"); !ok {
4752 t.Fatal("DeepSeek provider template should be restored")
4753 }
4754 if app.NeedsOnboarding() {
4755 t.Fatal("restored DeepSeek access and saved key should satisfy onboarding")
4756 }
4757 }
4758
4759 func TestConnectKeyFreshInstallUsesDeepSeekChatAndIndependentSearchDefaults(t *testing.T) {
4760 isolateDesktopUserDirs(t)
4761 oldFetch := connectKeyBalanceFetch
4762 connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
4763 return &billing.Balance{Available: true}, nil
4764 }
4765 t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
4766
4767 app := NewApp()
4768 app.ctx = context.Background()
4769 app.readyHook = func() {}
4770 app.setTestCtrl(control.New(control.Options{Label: "fresh-install"}), "deepseek-flash/deepseek-v4-flash")
4771 workspace := t.TempDir()
4772 app.tabs["test"].WorkspaceRoot = workspace
4773 defer func() {
4774 if ctrl := app.activeCtrl(); ctrl != nil {
4775 ctrl.Close()
4776 }
4777 }()
4778
4779 if _, err := app.ConnectKey("sk-test"); err != nil {
4780 t.Fatalf("ConnectKey: %v", err)
4781 }
4782 cfg, err := config.LoadForRootReadOnly(workspace)
4783 if err != nil {
4784 t.Fatalf("load fresh-install config: %v", err)
4785 }
4786 entry, ok := cfg.ResolveModel(cfg.DefaultModel)
4787 if !ok {
4788 t.Fatalf("default model %q did not resolve", cfg.DefaultModel)
4789 }
4790 if entry.Kind != "openai" || entry.BaseURL != "https://api.deepseek.com" ||
4791 entry.Thinking != "enabled" || !config.EffectiveIndependentWebSearch(entry) || !config.EffectiveVision(entry) {
4792 t.Fatalf("fresh-install DeepSeek entry = %+v; want Chat Completions, thinking, independent search, and native image input", entry)
4793 }
4794 if app.NeedsOnboarding() {
4795 t.Fatal("fresh-install onboarding should close after the validated DeepSeek key is stored")
4796 }
4797 }
4798
4799 func TestBalanceForTabUsesDesktopPricingCurrency(t *testing.T) {
4800 isolateDesktopUserDirs(t)
4801 cfg := config.Default()
4802 cfg.Desktop.Currency = "USD"
4803 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4804 t.Fatalf("save USD desktop currency: %v", err)
4805 }
4806
4807 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
4808 w.Header().Set("Content-Type", "application/json")
4809 _, _ = io.WriteString(w, `{"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"70.16"},{"currency":"USD","total_balance":"9.82"}]}`)
4810 }))
4811 defer srv.Close()
4812
4813 app := NewApp()
4814 app.ctx = context.Background()
4815 ctrl := control.New(control.Options{BalanceURL: srv.URL, BalanceClient: srv.Client()})
4816 t.Cleanup(ctrl.Close)
4817 app.setTestCtrl(ctrl, "deepseek/deepseek-v4-flash")
4818
4819 got := app.BalanceForTab("test")
4820 // Prefer the matching USD wallet exactly; no FX approximation is used.
4821 if !got.Available || got.Err != "" {
4822 t.Fatalf("USD desktop balance = %+v, want available", got)
4823 }
4824 if !strings.Contains(got.Display, "9.82") && !strings.Contains(got.Display, "$9.82") {
4825 t.Fatalf("USD desktop balance display = %q, want USD 9.82", got.Display)
4826 }
4827 }
4828
4829 func TestConnectKeyRebuildLeaseHeldKeepsCurrentController(t *testing.T) {
4830 isolateDesktopUserDirs(t)
4831 t.Setenv(onboardingKeyEnv, "")
4832 os.Unsetenv(onboardingKeyEnv)
4833 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
4834
4835 oldFetch := connectKeyBalanceFetch
4836 connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
4837 return &billing.Balance{Available: true}, nil
4838 }
4839 t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
4840
4841 cfg := config.Default()
4842 cfg.DefaultModel = "old/old-model"
4843 cfg.Desktop.ProviderAccess = []string{"old"}
4844 cfg.Providers = []config.ProviderEntry{
4845 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
4846 }
4847 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4848 t.Fatalf("save config: %v", err)
4849 }
4850
4851 dir := config.SessionDir()
4852 if err := os.MkdirAll(dir, 0o755); err != nil {
4853 t.Fatalf("mkdir session dir: %v", err)
4854 }
4855 sessionPath := filepath.Join(dir, "externally-leased-connect-key.jsonl")
4856 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
4857 t.Fatalf("write placeholder session: %v", err)
4858 }
4859 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
4860 if err != nil {
4861 t.Fatalf("TryAcquireSessionLease: %v", err)
4862 }
4863 defer externalLease.Release()
4864
4865 oldSession := agent.NewSession("old system prompt")
4866 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
4867 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
4868 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
4869 defer oldCtrl.Close()
4870
4871 app := NewApp()
4872 app.ctx = context.Background()
4873 tab := &WorkspaceTab{
4874 ID: "tab_connect",
4875 Scope: "global",
4876 SessionPath: sessionPath,
4877 Ready: true,
4878 model: "old/old-model",
4879 Ctrl: oldCtrl,
4880 sink: &tabEventSink{tabID: "tab_connect", app: app},
4881 disabledMCP: map[string]ServerView{},
4882 }
4883 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4884 app.tabOrder = []string{tab.ID}
4885 app.activeTabID = tab.ID
4886
4887 warning, err := app.ConnectKey("sk-test")
4888 if err != nil {
4889 t.Fatalf("ConnectKey: %v", err)
4890 }
4891 if warning != "" {
4892 t.Fatalf("ConnectKey warning = %q; saving does not acquire a runtime lease", warning)
4893 }
4894 if tab.Ctrl != oldCtrl {
4895 t.Fatalf("tab controller changed after failed connect-key rebuild")
4896 }
4897 if tab.StartupErr != "" {
4898 t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
4899 }
4900 p, ok := config.LoadForEdit(config.UserConfigPath()).Provider("deepseek")
4901 if !ok || !p.Configured() {
4902 t.Fatal("onboarding key should be persisted under the new connection reference")
4903 }
4904 }
4905
4906 func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {
4907 isolateDesktopUserDirs(t)
4908
4909 userCfg := config.LoadForEdit(config.UserConfigPath())
4910 if err := userCfg.SetDesktopLanguage("en"); err != nil {
4911 t.Fatalf("set desktop language: %v", err)
4912 }
4913 if err := userCfg.SetDesktopLayoutStyle("workbench"); err != nil {
4914 t.Fatalf("set desktop layout style: %v", err)
4915 }
4916 if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
4917 t.Fatalf("set desktop appearance: %v", err)
4918 }
4919 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
4920 t.Fatalf("save user config: %v", err)
4921 }
4922
4923 if err := NewApp().MigrateDesktopPreferences("zh", "light", "glacier"); err != nil {
4924 t.Fatalf("migrate desktop preferences: %v", err)
4925 }
4926
4927 got := config.LoadForEdit(config.UserConfigPath())
4928 if got.DesktopLanguage() != "en" || got.DesktopLayoutStyle() != "workbench" || got.DesktopTheme() != "dark" || got.DesktopThemeStyle() != "graphite" {
4929 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())
4930 }
4931 }
4932
4933 func TestSetEffortRebuildsController(t *testing.T) {
4934 isolateDesktopUserDirs(t)
4935
4936 app := NewApp()
4937 app.ctx = context.Background()
4938 app.readyHook = func() {}
4939 old := control.New(control.Options{Label: "old-controller"})
4940 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
4941 defer func() {
4942 if c := app.activeCtrl(); c != nil {
4943 c.Close()
4944 }
4945 }()
4946
4947 if err := app.SetEffort("max"); err != nil {
4948 t.Fatalf("SetEffort(max): %v", err)
4949 }
4950 if c := app.activeCtrl(); c == nil {
4951 t.Fatal("SetEffort should leave a rebuilt controller")
4952 }
4953 if c := app.activeCtrl(); c == old {
4954 t.Fatal("SetEffort should rebuild the active controller so the provider sees the new effort")
4955 }
4956 if got := app.Effort().Current; got != "max" {
4957 t.Fatalf("Effort current = %q, want max", got)
4958 }
4959 }
4960
4961 func TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
4962 isolateDesktopUserDirs(t)
4963 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
4964
4965 cfg := config.Default()
4966 cfg.DefaultModel = "deepseek/deepseek-v4-flash"
4967 cfg.Desktop.ProviderAccess = []string{"deepseek"}
4968 cfg.Providers = []config.ProviderEntry{{
4969 Name: "deepseek",
4970 Kind: "openai",
4971 BaseURL: "https://api.deepseek.com",
4972 Model: "glm-5",
4973 APIKeyEnv: "DEEPSEEK_API_KEY",
4974 }}
4975 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4976 t.Fatalf("save config: %v", err)
4977 }
4978
4979 app := NewApp()
4980 app.ctx = context.Background()
4981 app.readyHook = func() {}
4982 old := control.New(control.Options{Label: "old-controller"})
4983 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
4984 defer func() {
4985 if c := app.activeCtrl(); c != nil {
4986 c.Close()
4987 }
4988 }()
4989
4990 if err := app.SetEffort("max"); err != nil {
4991 t.Fatalf("SetEffort(max): %v", err)
4992 }
4993 tab := app.activeTab()
4994 if tab == nil {
4995 t.Fatal("active tab missing")
4996 }
4997 if tab.model != "deepseek/deepseek-v4-flash" {
4998 t.Fatalf("tab model = %q, want migrated official ref", tab.model)
4999 }
5000 }
5001
5002 func captureTabNotices(app *App, tab *WorkspaceTab) *[]string {
5003 var notices []string
5004 if tab.sink == nil {
5005 tab.sink = &tabEventSink{tabID: tab.ID, app: app, ctx: context.Background()}
5006 }
5007 tab.sink.SetBotSink(event.FuncSink(func(e event.Event) {
5008 if e.Kind == event.Notice && strings.TrimSpace(e.Text) != "" {
5009 notices = append(notices, e.Text)
5010 }
5011 }))
5012 return &notices
5013 }
5014
5015 func assertDeprecatedExecutionModeNoop(t *testing.T, app *App, tab *WorkspaceTab, old control.SessionAPI, notices []string) {
5016 t.Helper()
5017 if tab == nil {
5018 t.Fatal("tab missing")
5019 }
5020 if tab.Ctrl == nil || tab.Ctrl != old {
5021 t.Fatalf("controller identity changed: got %p want %p", tab.Ctrl, old)
5022 }
5023 if got := old.AgentPreset(); got != boot.AgentPresetStandard {
5024 t.Fatalf("controller AgentPreset = %q, want standard (light folds)", got)
5025 }
5026 if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
5027 t.Fatalf("token mode = %q, want full", got)
5028 }
5029 meta := app.MetaForTab(tab.ID)
5030 if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetStandard {
5031 t.Fatalf("meta token/preset = %q/%q, want full/standard", meta.TokenMode, meta.AgentPreset)
5032 }
5033 }
5034
5035 func assertSetTokenModeDidNotPersistLiveModes(t *testing.T) {
5036 t.Helper()
5037 for _, entry := range loadTabsFile().Tabs {
5038 if entry.TokenMode == "economy" || entry.TokenMode == "light" {
5039 t.Fatalf("SetTokenMode persisted folded mode %q", entry.TokenMode)
5040 }
5041 if entry.AgentPreset == "light" || entry.AgentPreset == "balanced" {
5042 t.Fatalf("SetTokenMode persisted non-floor preset %q", entry.AgentPreset)
5043 }
5044 }
5045 }
5046
5047 func assertPinnedCompatPersisted(t *testing.T, app *App, tab *WorkspaceTab) {
5048 t.Helper()
5049 app.persistTabTokenMode(tab)
5050 saved := loadTabsFile()
5051 if len(saved.Tabs) != 1 {
5052 t.Fatalf("saved tabs = %+v, want 1", saved.Tabs)
5053 }
5054 if saved.Tabs[0].TokenMode != boot.TokenModeFull {
5055 t.Fatalf("saved compat token = %q, want full", saved.Tabs[0].TokenMode)
5056 }
5057 }
5058
5059 func TestSetTokenModeRebuildsController(t *testing.T) {
5060 // Name kept for history; SetTokenMode is a deprecated no-op wrapper.
5061 isolateDesktopUserDirs(t)
5062
5063 app := NewApp()
5064 app.ctx = context.Background()
5065 app.readyHook = func() {}
5066 old := control.New(control.Options{Label: "old-controller"})
5067 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
5068 defer func() {
5069 if c := app.activeCtrl(); c != nil {
5070 c.Close()
5071 }
5072 }()
5073 tab := app.activeTab()
5074 notices := captureTabNotices(app, tab)
5075
5076 if err := app.SetTokenMode("economy"); err != nil {
5077 t.Fatalf("SetTokenMode(economy): %v", err)
5078 }
5079 assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
5080 assertSetTokenModeDidNotPersistLiveModes(t)
5081 assertPinnedCompatPersisted(t, app, tab)
5082 }
5083
5084 func TestSetTokenModeDeliveryIsCompatibilityNoOp(t *testing.T) {
5085 isolateDesktopUserDirs(t)
5086
5087 app := NewApp()
5088 app.ctx = context.Background()
5089 app.readyHook = func() {}
5090 old := control.New(control.Options{Label: "old-controller"})
5091 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
5092 defer func() {
5093 if c := app.activeCtrl(); c != nil {
5094 c.Close()
5095 }
5096 }()
5097 tab := app.activeTab()
5098 notices := captureTabNotices(app, tab)
5099
5100 if err := app.SetTokenMode(boot.TokenModeDelivery); err != nil {
5101 t.Fatalf("SetTokenMode(delivery): %v", err)
5102 }
5103 if tab.Ctrl == nil || tab.Ctrl != old {
5104 t.Fatalf("controller identity changed: got %p want %p", tab.Ctrl, old)
5105 }
5106 if got := old.QualityFloor(); got != control.QualityFloorStandard {
5107 t.Fatalf("controller QualityFloor = %q, want standard", got)
5108 }
5109 if got := derivedQualityFloor(tab).floor; got != control.QualityFloorStandard {
5110 t.Fatalf("tab qualityFloor = %q, want standard", got)
5111 }
5112
5113 if err := app.SetTokenMode(boot.TokenModeFull); err != nil {
5114 t.Fatalf("SetTokenMode(full): %v", err)
5115 }
5116 assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
5117 assertPinnedCompatPersisted(t, app, tab)
5118 }
5119
5120 func TestSetTokenModeReusesCurrentSessionLease(t *testing.T) {
5121 isolateDesktopUserDirs(t)
5122 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
5123
5124 cfg := config.Default()
5125 cfg.DefaultModel = "old/old-model"
5126 cfg.Desktop.ProviderAccess = []string{"old"}
5127 cfg.Providers = []config.ProviderEntry{
5128 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
5129 }
5130 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5131 t.Fatalf("save config: %v", err)
5132 }
5133
5134 dir := config.SessionDir()
5135 if err := os.MkdirAll(dir, 0o755); err != nil {
5136 t.Fatalf("mkdir session dir: %v", err)
5137 }
5138 session := agent.NewSession("old system prompt")
5139 session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
5140 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
5141 path := filepath.Join(dir, "leased-token-mode-switch.jsonl")
5142 oldCtrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
5143
5144 app := NewApp()
5145 app.ctx = context.Background()
5146 tab := &WorkspaceTab{
5147 ID: "tab_a",
5148 Scope: "global",
5149 Ready: true,
5150 model: "old/old-model",
5151 Ctrl: oldCtrl,
5152 sink: &tabEventSink{tabID: "tab_a", app: app},
5153 disabledMCP: map[string]ServerView{},
5154 }
5155 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
5156 app.tabOrder = []string{tab.ID}
5157 app.activeTabID = tab.ID
5158 t.Cleanup(func() {
5159 if tab.Ctrl != nil {
5160 tab.Ctrl.Close()
5161 }
5162 tab.releaseSessionLease()
5163 })
5164
5165 if err := tab.ensureSessionLease(path); err != nil {
5166 t.Fatalf("ensureSessionLease: %v", err)
5167 }
5168 notices := captureTabNotices(app, tab)
5169 if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
5170 t.Fatalf("SetTokenModeForTab: %v", err)
5171 }
5172 assertDeprecatedExecutionModeNoop(t, app, tab, oldCtrl, *notices)
5173 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) {
5174 t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
5175 }
5176 history := tab.Ctrl.History()
5177 if len(history) < 2 || history[1].Role != provider.RoleUser || history[1].Content != "hello" {
5178 t.Fatalf("carried history = %+v, want original user message", history)
5179 }
5180 }
5181
5182 func TestSetTokenModeLeaseHeldKeepsCurrentController(t *testing.T) {
5183 isolateDesktopUserDirs(t)
5184 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
5185
5186 cfg := config.Default()
5187 cfg.DefaultModel = "old/old-model"
5188 cfg.Desktop.ProviderAccess = []string{"old"}
5189 cfg.Providers = []config.ProviderEntry{
5190 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
5191 }
5192 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5193 t.Fatalf("save config: %v", err)
5194 }
5195
5196 dir := config.SessionDir()
5197 if err := os.MkdirAll(dir, 0o755); err != nil {
5198 t.Fatalf("mkdir session dir: %v", err)
5199 }
5200 path := filepath.Join(dir, "externally-leased-token-mode-switch.jsonl")
5201 if err := os.WriteFile(path, nil, 0o644); err != nil {
5202 t.Fatalf("write placeholder session: %v", err)
5203 }
5204 externalLease, err := agent.TryAcquireSessionLease(path)
5205 if err != nil {
5206 t.Fatalf("TryAcquireSessionLease: %v", err)
5207 }
5208 defer externalLease.Release()
5209
5210 session := agent.NewSession("old system prompt")
5211 session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
5212 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
5213 oldCtrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
5214 defer oldCtrl.Close()
5215
5216 app := NewApp()
5217 app.ctx = context.Background()
5218 tab := &WorkspaceTab{
5219 ID: "tab_a",
5220 Scope: "global",
5221 Ready: true,
5222 model: "old/old-model",
5223 Ctrl: oldCtrl,
5224 sink: &tabEventSink{tabID: "tab_a", app: app},
5225 disabledMCP: map[string]ServerView{},
5226 }
5227 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
5228 app.tabOrder = []string{tab.ID}
5229 app.activeTabID = tab.ID
5230
5231 // Deprecated wrapper must not re-acquire the session lease, so an
5232 // externally held lease does not block the call or replace the controller.
5233 notices := captureTabNotices(app, tab)
5234 if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
5235 t.Fatalf("SetTokenModeForTab: %v", err)
5236 }
5237 assertDeprecatedExecutionModeNoop(t, app, tab, oldCtrl, *notices)
5238 meta := app.MetaForTab(tab.ID)
5239 if !meta.Ready || meta.Runtime.Phase != sessionRuntimeReady {
5240 t.Fatalf("deprecated mode call disabled current runtime: ready=%v phase=%q", meta.Ready, meta.Runtime.Phase)
5241 }
5242 }
5243
5244 func TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
5245 isolateDesktopUserDirs(t)
5246 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
5247
5248 cfg := config.Default()
5249 cfg.DefaultModel = "deepseek/deepseek-v4-flash"
5250 cfg.Desktop.ProviderAccess = []string{"deepseek"}
5251 cfg.Providers = []config.ProviderEntry{{
5252 Name: "deepseek",
5253 Kind: "openai",
5254 BaseURL: "https://api.deepseek.com",
5255 Model: "glm-5",
5256 APIKeyEnv: "DEEPSEEK_API_KEY",
5257 }}
5258 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5259 t.Fatalf("save config: %v", err)
5260 }
5261
5262 app := NewApp()
5263 app.ctx = context.Background()
5264 app.readyHook = func() {}
5265 old := control.New(control.Options{Label: "old-controller"})
5266 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
5267 defer func() {
5268 if c := app.activeCtrl(); c != nil {
5269 c.Close()
5270 }
5271 }()
5272
5273 tab := app.activeTab()
5274 notices := captureTabNotices(app, tab)
5275 if err := app.SetTokenMode("economy"); err != nil {
5276 t.Fatalf("SetTokenMode(economy): %v", err)
5277 }
5278 if tab == nil {
5279 t.Fatal("active tab missing")
5280 }
5281 // SetTokenMode does not rebuild, so stale model aliases stay put
5282 // (migration still runs on model/effort rebuilds).
5283 if tab.model != "deepseek-flash/deepseek-v4-flash" {
5284 t.Fatalf("tab model = %q, want unchanged stale ref without rebuild", tab.model)
5285 }
5286 assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
5287 }
5288
5289 func TestMetaForTabReportsImageInputCapability(t *testing.T) {
5290 isolateDesktopUserDirs(t)
5291 setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test")
5292
5293 cfg := config.Default()
5294 cfg.DefaultModel = "custom/text-only"
5295 cfg.Desktop.ProviderAccess = []string{"custom"}
5296 cfg.Providers = []config.ProviderEntry{{
5297 Name: "custom",
5298 Kind: "openai",
5299 BaseURL: "https://example.invalid/v1",
5300 APIKeyEnv: "CUSTOM_KEY",
5301 Models: []string{"text-only", "vision-pro"},
5302 VisionModels: []string{"vision-pro"},
5303 }}
5304 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5305 t.Fatalf("save config: %v", err)
5306 }
5307
5308 app := NewApp()
5309 app.ctx = context.Background()
5310 app.readyHook = func() {}
5311 app.setTestCtrl(control.New(control.Options{Label: "custom/text-only"}), "custom/text-only")
5312 defer func() {
5313 if c := app.activeCtrl(); c != nil {
5314 c.Close()
5315 }
5316 }()
5317
5318 if got := app.Meta().ImageInputEnabled; got {
5319 t.Fatal("text-only meta should disable image input")
5320 }
5321 if err := app.SetModel("custom/vision-pro"); err != nil {
5322 t.Fatalf("SetModel(custom/vision-pro): %v", err)
5323 }
5324 // ImageInputEnabled is served from the per-tab cache; the model change
5325 // invalidates it and a background refresh repopulates it (tab:meta).
5326 waitForMetaImageInput(t, app, true)
5327 }
5328
5329 // waitForMetaImageInput polls until the cached image-input capability reaches
5330 // the expected value. MetaForTab serves the background-refreshed cache, so the
5331 // value flips asynchronously after a model/settings change.
5332 func waitForMetaImageInput(t *testing.T, app *App, want bool) {
5333 t.Helper()
5334 deadline := time.Now().Add(10 * time.Second)
5335 for time.Now().Before(deadline) {
5336 if app.Meta().ImageInputEnabled == want {
5337 return
5338 }
5339 time.Sleep(10 * time.Millisecond)
5340 }
5341 t.Fatalf("Meta().ImageInputEnabled did not become %v", want)
5342 }
5343
5344 func TestMetaForTabImageInputCapabilityUsesCurrentRef(t *testing.T) {
5345 isolateDesktopUserDirs(t)
5346 setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test")
5347
5348 cfg := config.Default()
5349 cfg.DefaultModel = "custom/vision-pro"
5350 cfg.Desktop.ProviderAccess = []string{"custom"}
5351 cfg.Providers = []config.ProviderEntry{{
5352 Name: "custom",
5353 Kind: "openai",
5354 BaseURL: "https://example.invalid/v1",
5355 APIKeyEnv: "CUSTOM_KEY",
5356 Models: []string{"text-only", "vision-pro"},
5357 VisionModels: []string{"vision-pro"},
5358 }}
5359 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5360 t.Fatalf("save config: %v", err)
5361 }
5362
5363 app := NewApp()
5364 app.ctx = context.Background()
5365 app.readyHook = func() {}
5366 app.setTestCtrl(control.New(control.Options{Label: "deleted/model"}), "deleted/model")
5367 defer func() {
5368 if c := app.activeCtrl(); c != nil {
5369 c.Close()
5370 }
5371 }()
5372
5373 if got := app.Meta().ImageInputEnabled; got {
5374 t.Fatal("unknown model ref should not inherit image input from the default fallback model")
5375 }
5376 }
5377
5378 func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {
5379 // Name kept for history; an unknown model must not block the deprecated no-op.
5380 isolateDesktopUserDirs(t)
5381 t.Setenv("DEEPSEEK_API_KEY", "")
5382 t.Setenv("MIMO_API_KEY", "")
5383
5384 app := NewApp()
5385 app.ctx = context.Background()
5386 app.readyHook = func() {}
5387 old := control.New(control.Options{Label: "old-controller"})
5388 app.setTestCtrl(old, "missing-token-mode-model")
5389 defer func() {
5390 if c := app.activeCtrl(); c != nil {
5391 c.Close()
5392 }
5393 }()
5394 tab := app.activeTab()
5395 notices := captureTabNotices(app, tab)
5396
5397 if err := app.SetTokenMode("economy"); err != nil {
5398 t.Fatalf("SetTokenMode(economy): %v", err)
5399 }
5400 assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
5401 }
5402
5403 func TestSetEffortRejectsRunningTurn(t *testing.T) {
5404 isolateDesktopUserDirs(t)
5405
5406 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
5407 app := NewApp()
5408 app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
5409 app.activeCtrl().Submit("work")
5410 <-runner.started
5411
5412 err := app.SetEffort("max")
5413 if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
5414 t.Fatalf("SetEffort while running error = %v, want finish/cancel guard", err)
5415 }
5416
5417 close(runner.release)
5418 waitNotRunning(t, app.activeCtrl())
5419 }
5420
5421 func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
5422 // Name kept for history; the deprecated wrapper does not require an idle tab.
5423 isolateDesktopUserDirs(t)
5424
5425 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
5426 app := NewApp()
5427 old := control.New(control.Options{Runner: runner})
5428 app.setTestCtrl(old, "")
5429 tab := app.activeTab()
5430 notices := captureTabNotices(app, tab)
5431 old.Submit("work")
5432 <-runner.started
5433
5434 if err := app.SetTokenMode("economy"); err != nil {
5435 t.Fatalf("SetTokenMode while running: %v", err)
5436 }
5437 assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
5438
5439 close(runner.release)
5440 waitNotRunning(t, app.activeCtrl())
5441 }
5442
5443 func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
5444 // Name kept for history; background jobs must not block the deprecated wrapper.
5445 isolateDesktopUserDirs(t)
5446 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
5447
5448 cfg := config.Default()
5449 cfg.DefaultModel = "old/old-model"
5450 cfg.Desktop.ProviderAccess = []string{"old"}
5451 cfg.Providers = []config.ProviderEntry{
5452 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
5453 }
5454 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5455 t.Fatalf("save config: %v", err)
5456 }
5457
5458 dir := config.SessionDir()
5459 if err := os.MkdirAll(dir, 0o755); err != nil {
5460 t.Fatalf("mkdir session dir: %v", err)
5461 }
5462 path := filepath.Join(dir, "jobs.jsonl")
5463 jm := jobs.NewManager(event.Discard)
5464 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
5465 app := NewApp()
5466 app.ctx = context.Background()
5467 app.setTestCtrl(ctrl, "old/old-model")
5468 t.Cleanup(func() {
5469 if current := app.activeCtrl(); current != nil {
5470 current.Close()
5471 }
5472 })
5473 tab := app.activeTab()
5474 notices := captureTabNotices(app, tab)
5475
5476 release := make(chan struct{})
5477 job := jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
5478 select {
5479 case <-ctx.Done():
5480 return "", ctx.Err()
5481 case <-release:
5482 return "", nil
5483 }
5484 })
5485 t.Cleanup(func() { close(release) })
5486
5487 if err := app.SetTokenMode("economy"); err != nil {
5488 t.Fatalf("SetTokenMode with background job: %v", err)
5489 }
5490 assertDeprecatedExecutionModeNoop(t, app, tab, ctrl, *notices)
5491 cancelled, err := app.CancelJobForTab("", job.ID)
5492 if err != nil || !cancelled {
5493 t.Fatalf("CancelJobForTab = %v, %v, want true, nil", cancelled, err)
5494 }
5495 if result := jm.WaitForSession(context.Background(), agent.BranchID(path), []string{job.ID}, 5); len(result) != 1 || result[0].Status != jobs.Killed {
5496 t.Fatalf("stopped background job = %+v, want one killed result", result)
5497 }
5498 }
5499
5500 func TestSetTokenModeUnknownTabErrors(t *testing.T) {
5501 isolateDesktopUserDirs(t)
5502 app := NewApp()
5503 err := app.SetTokenModeForTab("missing-tab", "economy")
5504 if err == nil || !strings.Contains(err.Error(), `tab "missing-tab" not found`) {
5505 t.Fatalf("SetTokenModeForTab(unknown) = %v, want tab not found", err)
5506 }
5507 err = app.SetAgentPresetForTab("missing-tab", "light")
5508 if err == nil || !strings.Contains(err.Error(), `tab "missing-tab" not found`) {
5509 t.Fatalf("SetAgentPresetForTab(unknown) = %v, want tab not found", err)
5510 }
5511 }
5512
5513 func TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {
5514 isolateDesktopUserDirs(t)
5515
5516 dir := config.SessionDir()
5517 if err := os.MkdirAll(dir, 0o755); err != nil {
5518 t.Fatalf("mkdir session dir: %v", err)
5519 }
5520 path := filepath.Join(dir, "settings-job.jsonl")
5521 jm := jobs.NewManager(event.Discard)
5522 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
5523 defer ctrl.Close()
5524 app := NewApp()
5525 app.ctx = context.Background()
5526 app.setTestCtrl(ctrl, "deepseek-flash/deepseek-v4-flash")
5527
5528 jm.StartForSession(agent.BranchID(path), "bash", "settings job", func(ctx context.Context, _ io.Writer) (string, error) {
5529 <-ctx.Done()
5530 return "", ctx.Err()
5531 })
5532
5533 err := app.SetSandbox("enforce", true, "", nil, "")
5534 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
5535 t.Fatalf("SetSandbox with background job error = %v, want background-job guard", err)
5536 }
5537 }
5538
5539 func TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {
5540 isolateDesktopUserDirs(t)
5541
5542 dir := config.SessionDir()
5543 if err := os.MkdirAll(dir, 0o755); err != nil {
5544 t.Fatalf("mkdir session dir: %v", err)
5545 }
5546 path := filepath.Join(dir, "clear-running.jsonl")
5547 if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
5548 t.Fatalf("write session: %v", err)
5549 }
5550 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
5551 oldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"})
5552 app := NewApp()
5553 app.projectTreeChangedHook = func() {}
5554 app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash")
5555 app.tabs["test"].TopicID = "topic_clear"
5556 app.tabs["test"].TopicTitle = "Clear topic"
5557 defer func() {
5558 if c := app.activeCtrl(); c != nil {
5559 c.Close()
5560 }
5561 }()
5562
5563 oldCtrl.Submit("work")
5564 <-runner.started
5565 if _, err := app.ClearSession(); err != nil {
5566 t.Fatalf("ClearSession: %v", err)
5567 }
5568 waitNotRunning(t, oldCtrl)
5569 tab := app.activeTab()
5570 if tab == nil || tab.Ctrl == nil {
5571 t.Fatalf("active tab/controller missing after clear")
5572 }
5573 if tab.Ctrl == oldCtrl {
5574 t.Fatalf("clear should replace the active controller after cancelling old work")
5575 }
5576 if tab.TopicID != "topic_clear" || tab.TopicTitle != "Clear topic" {
5577 t.Fatalf("clear changed topic identity: %+v", tab)
5578 }
5579 if _, err := os.Stat(path); !os.IsNotExist(err) {
5580 t.Fatalf("old cleared session artifacts should be removed, stat err = %v", err)
5581 }
5582 if got := tab.currentSessionPath(); got == "" || got == path {
5583 t.Fatalf("new session path = %q, want fresh path", got)
5584 }
5585 }
5586
5587 func TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {
5588 isolateDesktopUserDirs(t)
5589
5590 dir := config.SessionDir()
5591 if err := os.MkdirAll(dir, 0o755); err != nil {
5592 t.Fatalf("mkdir session dir: %v", err)
5593 }
5594 path := filepath.Join(dir, "clear-running-job.jsonl")
5595 if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
5596 t.Fatalf("write session: %v", err)
5597 }
5598 jm := jobs.NewManager(event.Discard)
5599 oldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
5600 app := NewApp()
5601 app.projectTreeChangedHook = func() {}
5602 app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash")
5603 defer func() {
5604 if c := app.activeCtrl(); c != nil {
5605 c.Close()
5606 }
5607 }()
5608
5609 started := make(chan struct{})
5610 jm.StartForSession(agent.BranchID(path), "bash", "clear artifact", func(ctx context.Context, _ io.Writer) (string, error) {
5611 close(started)
5612 <-ctx.Done()
5613 return "", ctx.Err()
5614 })
5615 <-started
5616 jobsDir := jobs.ArtifactDir(path)
5617 if _, err := os.Stat(jobsDir); err != nil {
5618 t.Fatalf("job sidecar should exist before clear: %v", err)
5619 }
5620
5621 if _, err := app.ClearSession(); err != nil {
5622 t.Fatalf("ClearSession: %v", err)
5623 }
5624 if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
5625 t.Fatalf("old job sidecar should be removed after clear, stat err = %v", err)
5626 }
5627 }
5628
5629 func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
5630 orig, _ := os.Getwd()
5631 defer os.Chdir(orig)
5632
5633 dir := robustTempDir(t)
5634 if err := os.MkdirAll(filepath.Join(dir, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
5635 t.Fatal(err)
5636 }
5637 if err := os.WriteFile(filepath.Join(dir, "frontend", "wailsjs", "runtime", "runtime.js"), []byte("x"), 0o644); err != nil {
5638 t.Fatal(err)
5639 }
5640 if err := os.WriteFile(filepath.Join(dir, "frontend", "Thumbs.db"), []byte("noise"), 0o644); err != nil {
5641 t.Fatal(err)
5642 }
5643 if err := os.WriteFile(filepath.Join(dir, "frontend", ".DS_Store"), []byte("noise"), 0o644); err != nil {
5644 t.Fatal(err)
5645 }
5646 if err := os.MkdirAll(filepath.Join(dir, "node_modules", "pkg"), 0o755); err != nil {
5647 t.Fatal(err)
5648 }
5649 if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "runtime.js"), []byte("noise"), 0o644); err != nil {
5650 t.Fatal(err)
5651 }
5652 for _, noise := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
5653 if err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {
5654 t.Fatal(err)
5655 }
5656 if err := os.WriteFile(filepath.Join(dir, noise, "runtime.js"), []byte("noise"), 0o644); err != nil {
5657 t.Fatal(err)
5658 }
5659 }
5660 if err := os.MkdirAll(filepath.Join(dir, "desktop", "frontend", "wailsjs"), 0o755); err != nil {
5661 t.Fatal(err)
5662 }
5663 if err := os.WriteFile(filepath.Join(dir, "desktop", "frontend", "wailsjs", "runtime.js"), []byte("generated"), 0o644); err != nil {
5664 t.Fatal(err)
5665 }
5666 if err := os.MkdirAll(filepath.Join(dir, "product", "bin"), 0o755); err != nil {
5667 t.Fatal(err)
5668 }
5669 if err := os.WriteFile(filepath.Join(dir, "product", "bin", "runtime.js"), []byte("real"), 0o644); err != nil {
5670 t.Fatal(err)
5671 }
5672 if err := os.Chdir(dir); err != nil {
5673 t.Fatal(err)
5674 }
5675
5676 app := &App{}
5677 listed := app.ListDir("")
5678 for name, shown := range map[string]bool{".codex": false, ".npm": false, ".pnpm-store": false, "dist": false, "bin": true, "stage": true, "tmp": true} {
5679 if hasDirEntry(listed, name) != shown {
5680 t.Fatalf("ListDir shows %q = %v, want %v (only @-search skips generic names); got %+v", name, !shown, shown, listed)
5681 }
5682 }
5683 desktopFrontend := app.ListDir("desktop/frontend")
5684 if !hasDirEntry(desktopFrontend, "wailsjs") {
5685 t.Fatalf("ListDir should show desktop/frontend/wailsjs, got %+v", desktopFrontend)
5686 }
5687 frontendEntries := app.ListDir("frontend")
5688 for _, hidden := range []string{".DS_Store", "Thumbs.db"} {
5689 if hasDirEntry(frontendEntries, hidden) {
5690 t.Fatalf("ListDir should hide local noise file %q, got %+v", hidden, frontendEntries)
5691 }
5692 }
5693
5694 got := app.SearchFileRefs("runtime.js")
5695 if !hasDirEntry(got, "frontend/wailsjs/runtime/runtime.js") {
5696 t.Fatalf("SearchFileRefs(runtime.js) should find nested workspace file, got %+v", got)
5697 }
5698 if !hasDirEntry(got, "product/bin/runtime.js") {
5699 t.Fatalf("SearchFileRefs should keep non-root bin directories searchable, got %+v", got)
5700 }
5701 if hasDirEntry(got, "node_modules/pkg/runtime.js") {
5702 t.Fatalf("SearchFileRefs should skip node_modules noise, got %+v", got)
5703 }
5704 for _, hidden := range []string{
5705 ".codex/runtime.js",
5706 ".npm/runtime.js",
5707 ".pnpm-store/runtime.js",
5708 "bin/runtime.js",
5709 "desktop/frontend/wailsjs/runtime.js",
5710 "dist/runtime.js",
5711 "stage/runtime.js",
5712 "tmp/runtime.js",
5713 } {
5714 if hasDirEntry(got, hidden) {
5715 t.Fatalf("SearchFileRefs should skip local noise %q, got %+v", hidden, got)
5716 }
5717 }
5718 if noise := app.SearchFileRefs("Thumbs"); hasDirEntry(noise, "frontend/Thumbs.db") {
5719 t.Fatalf("SearchFileRefs should skip Thumbs.db noise, got %+v", noise)
5720 }
5721 if noise := app.SearchFileRefs(".DS"); hasDirEntry(noise, "frontend/.DS_Store") {
5722 t.Fatalf("SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v", noise)
5723 }
5724 }
5725
5726 func TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {
5727 orig, _ := os.Getwd()
5728 defer os.Chdir(orig)
5729
5730 launchRoot := robustTempDir(t)
5731 projectRoot := robustTempDir(t)
5732 if err := os.WriteFile(filepath.Join(launchRoot, "launch-only.txt"), []byte("wrong"), 0o644); err != nil {
5733 t.Fatal(err)
5734 }
5735 if err := os.MkdirAll(filepath.Join(projectRoot, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
5736 t.Fatal(err)
5737 }
5738 projectFile := filepath.Join(projectRoot, "frontend", "wailsjs", "runtime", "runtime.js")
5739 if err := os.WriteFile(projectFile, []byte("right workspace"), 0o644); err != nil {
5740 t.Fatal(err)
5741 }
5742 if err := os.Chdir(launchRoot); err != nil {
5743 t.Fatal(err)
5744 }
5745
5746 app := NewApp()
5747 tab := &WorkspaceTab{ID: "project", Scope: "project", WorkspaceRoot: projectRoot}
5748 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
5749 app.activeTabID = tab.ID
5750
5751 listed := app.ListDir("")
5752 if !hasDirEntry(listed, "frontend") {
5753 t.Fatalf("ListDir should list active project root, got %+v", listed)
5754 }
5755 if hasDirEntry(listed, "launch-only.txt") {
5756 t.Fatalf("ListDir leaked launch cwd entries, got %+v", listed)
5757 }
5758
5759 found := app.SearchFileRefs("runtime.js")
5760 if !hasDirEntry(found, "frontend/wailsjs/runtime/runtime.js") {
5761 t.Fatalf("SearchFileRefs should search active project root, got %+v", found)
5762 }
5763 preview := app.ReadFile("frontend/wailsjs/runtime/runtime.js")
5764 if preview.Err != "" || preview.Body != "right workspace" {
5765 t.Fatalf("ReadFile active project preview = %+v, want project file", preview)
5766 }
5767 }
5768
5769 func TestFileRefsForTabIgnoreActiveParentWorkspace(t *testing.T) {
5770 parentRoot := robustTempDir(t)
5771 childRoot := filepath.Join(parentRoot, "child")
5772 if err := os.MkdirAll(childRoot, 0o755); err != nil {
5773 t.Fatal(err)
5774 }
5775 if err := os.WriteFile(filepath.Join(parentRoot, "parent-only.txt"), []byte("parent"), 0o644); err != nil {
5776 t.Fatal(err)
5777 }
5778 if err := os.WriteFile(filepath.Join(parentRoot, "shared.txt"), []byte("parent shared"), 0o644); err != nil {
5779 t.Fatal(err)
5780 }
5781 if err := os.WriteFile(filepath.Join(childRoot, "child-only.txt"), []byte("child"), 0o644); err != nil {
5782 t.Fatal(err)
5783 }
5784 if err := os.WriteFile(filepath.Join(childRoot, "shared.txt"), []byte("child shared"), 0o644); err != nil {
5785 t.Fatal(err)
5786 }
5787
5788 app := &App{
5789 tabs: map[string]*WorkspaceTab{
5790 "parent": {ID: "parent", Scope: "project", WorkspaceRoot: parentRoot},
5791 "child": {ID: "child", Scope: "project", WorkspaceRoot: childRoot},
5792 },
5793 activeTabID: "parent",
5794 }
5795
5796 listed := app.ListDirForTab("child", "")
5797 if !hasDirEntry(listed, "child-only.txt") || hasDirEntry(listed, "parent-only.txt") {
5798 t.Fatalf("ListDirForTab(child) = %+v, want only child workspace entries", listed)
5799 }
5800 found := app.SearchFileRefsForTab("child", "child-only")
5801 if !hasDirEntry(found, "child-only.txt") {
5802 t.Fatalf("SearchFileRefsForTab(child) = %+v, want child-only.txt", found)
5803 }
5804 preview := app.ReadFileForTab("child", "shared.txt")
5805 if preview.Err != "" || preview.Body != "child shared" {
5806 t.Fatalf("ReadFileForTab(child) = %+v, want child workspace file", preview)
5807 }
5808 path, ok, err := app.workspaceOrExternalPathForTab("child", "shared.txt")
5809 if err != nil || !ok || path != filepath.Join(childRoot, "shared.txt") {
5810 t.Fatalf("workspaceOrExternalPathForTab(child) = (%q, %v, %v)", path, ok, err)
5811 }
5812
5813 legacy := app.ReadFile("shared.txt")
5814 if legacy.Err != "" || legacy.Body != "parent shared" {
5815 t.Fatalf("ReadFile legacy active-tab behavior = %+v, want parent workspace file", legacy)
5816 }
5817 }
5818
5819 func TestFileRefsIncludeRegisteredExternalFolderChildren(t *testing.T) {
5820 workspace := robustTempDir(t)
5821 external := filepath.Join(robustTempDir(t), "Folder With Spaces")
5822 if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil {
5823 t.Fatal(err)
5824 }
5825 if err := os.WriteFile(filepath.Join(external, "src", "outside.txt"), []byte("outside"), 0o644); err != nil {
5826 t.Fatal(err)
5827 }
5828 expectedExternal := external
5829 if resolved, err := filepath.EvalSymlinks(external); err == nil {
5830 expectedExternal = resolved
5831 }
5832 expectedDisplayPath := filepath.ToSlash(expectedExternal)
5833
5834 ctrl := &control.Controller{}
5835 token, _, err := ctrl.RegisterExternalFolderRef(external)
5836 if err != nil {
5837 t.Fatalf("RegisterExternalFolderRef: %v", err)
5838 }
5839 app := &App{
5840 tabs: map[string]*WorkspaceTab{
5841 "project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
5842 "other": {ID: "other", WorkspaceRoot: robustTempDir(t)},
5843 },
5844 activeTabID: "other",
5845 }
5846
5847 listed := app.ListDirForTab("project", token+"/src/")
5848 if len(listed) != 1 ||
5849 listed[0].Name != "outside.txt" ||
5850 listed[0].Path != token+"/src/outside.txt" ||
5851 listed[0].DisplayPath != expectedDisplayPath+"/src/outside.txt" {
5852 t.Fatalf("ListDir external src = %+v, want outside token/display path", listed)
5853 }
5854
5855 found := app.SearchFileRefsForTab("project", "outside")
5856 var externalHit *DirEntry
5857 for i := range found {
5858 if found[i].Path == token+"/src/outside.txt" {
5859 externalHit = &found[i]
5860 break
5861 }
5862 }
5863 if externalHit == nil || externalHit.DisplayName != "Folder With Spaces/src/outside.txt" || externalHit.DisplayPath != expectedDisplayPath+"/src/outside.txt" {
5864 t.Fatalf("SearchFileRefs external hit = %+v, all results %+v", externalHit, found)
5865 }
5866
5867 preview := app.ReadFileForTab("project", token+"/src/outside.txt")
5868 if preview.Err != "" || preview.Body != "outside" {
5869 t.Fatalf("ReadFile external token preview = %+v, want outside file body", preview)
5870 }
5871 }
5872
5873 func TestLegacyDeleteSessionCancelsActiveRuntime(t *testing.T) {
5874 isolateDesktopUserDirs(t)
5875
5876 dir := config.SessionDir()
5877 if err := os.MkdirAll(dir, 0o755); err != nil {
5878 t.Fatalf("mkdir session dir: %v", err)
5879 }
5880 path := filepath.Join(dir, "active.jsonl")
5881 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
5882 t.Fatalf("write session: %v", err)
5883 }
5884
5885 app := NewApp()
5886 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"})
5887 keepPath := filepath.Join(dir, "keep.jsonl")
5888 if err := os.WriteFile(keepPath, []byte(`{"role":"user","content":"keep"}`+"\n"), 0o644); err != nil {
5889 t.Fatalf("write keep session: %v", err)
5890 }
5891 keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"})
5892 defer keepCtrl.Close()
5893 app.setTestCtrl(activeCtrl, "")
5894 app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true}
5895 app.tabOrder = []string{"test", "keep"}
5896
5897 if err := app.deleteSession(filepath.Base(path)); err != nil {
5898 t.Fatalf("DeleteSession(active basename): %v", err)
5899 }
5900 if _, ok := app.tabs["test"]; ok {
5901 t.Fatalf("deleted active session runtime should be removed")
5902 }
5903 if got := app.activeTabID; got != "keep" {
5904 t.Fatalf("active tab after delete = %q, want keep", got)
5905 }
5906 if _, err := os.Stat(path); !os.IsNotExist(err) {
5907 t.Fatalf("active session should be moved out of active history, stat err = %v", err)
5908 }
5909 trashPath := filepath.Join(dir, sessionTrashDir, "active.jsonl", "active.jsonl")
5910 if _, err := os.Stat(trashPath); err != nil {
5911 t.Fatalf("active session should be moved to trash: %v", err)
5912 }
5913 }
5914
5915 func TestLegacyDeleteSessionCancelsPreReadyBlankBuild(t *testing.T) {
5916 isolateDesktopUserDirs(t)
5917
5918 globalRoot := globalTabWorkspaceRoot()
5919 dir := desktopSessionDir(globalRoot)
5920 if err := os.MkdirAll(dir, 0o755); err != nil {
5921 t.Fatalf("mkdir session dir: %v", err)
5922 }
5923 path := filepath.Join(dir, "pre-ready-blank.jsonl")
5924 if err := os.WriteFile(path, nil, 0o644); err != nil {
5925 t.Fatalf("write blank session: %v", err)
5926 }
5927 cancelled := false
5928 blank := &WorkspaceTab{
5929 ID: "blank",
5930 Scope: "global",
5931 WorkspaceRoot: globalRoot,
5932 SessionPath: path,
5933 buildCancel: func() { cancelled = true },
5934 disabledMCP: map[string]ServerView{},
5935 }
5936 keep := &WorkspaceTab{
5937 ID: "keep",
5938 Scope: "global",
5939 WorkspaceRoot: globalRoot,
5940 Ready: true,
5941 disabledMCP: map[string]ServerView{},
5942 }
5943 app := &App{
5944 tabs: map[string]*WorkspaceTab{"blank": blank, "keep": keep},
5945 tabOrder: []string{"blank", "keep"},
5946 activeTabID: "blank",
5947 }
5948
5949 if err := app.deleteSession(filepath.Base(path)); err != nil {
5950 t.Fatalf("DeleteSession(pre-ready blank): %v", err)
5951 }
5952 if !cancelled {
5953 t.Fatal("pre-ready blank build was not cancelled")
5954 }
5955 if !blank.removed {
5956 t.Fatal("pre-ready blank tab was not marked removed")
5957 }
5958 if _, ok := app.tabs["blank"]; ok {
5959 t.Fatal("pre-ready blank tab should be removed")
5960 }
5961 if _, err := os.Stat(path); !os.IsNotExist(err) {
5962 t.Fatalf("blank session should be moved out of active history, stat err = %v", err)
5963 }
5964 }
5965
5966 func TestLegacyDeleteLastTopicSessionFallbackDoesNotReuseDeletedTopic(t *testing.T) {
5967 isolateDesktopUserDirs(t)
5968
5969 projectRoot := t.TempDir()
5970 topicID := "topic_delete_last"
5971 if err := addProject(projectRoot, ""); err != nil {
5972 t.Fatalf("add project: %v", err)
5973 }
5974 if err := setTopicTitle(projectRoot, topicID, "Delete last"); err != nil {
5975 t.Fatalf("set topic title: %v", err)
5976 }
5977 dir := config.SessionDir()
5978 if err := os.MkdirAll(dir, 0o755); err != nil {
5979 t.Fatalf("mkdir session dir: %v", err)
5980 }
5981 path := writeTopicSession(t, dir, "delete-last.jsonl", topicID, "Delete last", projectRoot)
5982 ctrl := controllerWithContent(t, path)
5983 app := &App{
5984 tabs: map[string]*WorkspaceTab{
5985 "only": {
5986 ID: "only",
5987 Scope: "project",
5988 WorkspaceRoot: projectRoot,
5989 TopicID: topicID,
5990 TopicTitle: "Delete last",
5991 Ctrl: ctrl,
5992 Ready: true,
5993 disabledMCP: map[string]ServerView{},
5994 },
5995 },
5996 tabOrder: []string{"only"},
5997 activeTabID: "only",
5998 }
5999
6000 if err := app.deleteSession(path); err != nil {
6001 t.Fatalf("DeleteSession(last topic session): %v", err)
6002 }
6003
6004 if _, ok := app.tabs["only"]; ok {
6005 t.Fatalf("deleted topic session tab should be removed")
6006 }
6007 // The deleted topic owns no content, so nothing is re-activated and no
6008 // replacement blank session is created: the frontend lands on the draft.
6009 assertNoVisibleRuntime(t, app)
6010 trashPath := filepath.Join(dir, sessionTrashDir, "delete-last.jsonl", "delete-last.jsonl")
6011 if _, err := os.Stat(trashPath); err != nil {
6012 t.Fatalf("deleted session should be moved to trash: %v", err)
6013 }
6014 }
6015
6016 func TestLegacyDeleteSessionWithStuckJobUsesSingleGrace(t *testing.T) {
6017 isolateDesktopUserDirs(t)
6018
6019 dir := config.SessionDir()
6020 if err := os.MkdirAll(dir, 0o755); err != nil {
6021 t.Fatalf("mkdir session dir: %v", err)
6022 }
6023 path := filepath.Join(dir, "stuck-delete.jsonl")
6024 keepPath := filepath.Join(dir, "keep.jsonl")
6025 for _, p := range []string{path, keepPath} {
6026 if err := os.WriteFile(p, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6027 t.Fatalf("write session %s: %v", p, err)
6028 }
6029 }
6030
6031 grace := 500 * time.Millisecond
6032 teardownNotices := make(chan event.Event, 2)
6033 jm := jobs.NewManager(teardownNoticeSink(teardownNotices), jobs.WithTeardownGrace(grace))
6034 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
6035 keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"})
6036 releaseJob := startNonCooperativeSessionJob(t, jm, path)
6037 defer func() {
6038 releaseJob()
6039 ctrl.Close()
6040 keepCtrl.Close()
6041 }()
6042
6043 app := NewApp()
6044 app.setTestCtrl(ctrl, "")
6045 app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true}
6046 app.tabOrder = []string{"test", "keep"}
6047
6048 if err := app.deleteSession(filepath.Base(path)); err != nil {
6049 t.Fatalf("DeleteSession(stuck job): %v", err)
6050 }
6051 // The single timeout notice and cleanup marker prove that deletion used the
6052 // bounded teardown path. Host filesystem latency after that boundary is not
6053 // a Go correctness property and must not be sampled by this unit test.
6054 assertSingleTeardownTimeoutNotice(t, teardownNotices, grace)
6055 if !agent.IsCleanupPending(path) {
6056 t.Fatalf("stuck delete should mark cleanup pending")
6057 }
6058 if _, err := os.Stat(path); err != nil {
6059 t.Fatalf("stuck session file should remain until delayed cleanup: %v", err)
6060 }
6061 }
6062
6063 func TestLegacyDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {
6064 isolateDesktopUserDirs(t)
6065
6066 dir := config.SessionDir()
6067 if err := os.MkdirAll(dir, 0o755); err != nil {
6068 t.Fatalf("mkdir session dir: %v", err)
6069 }
6070 path := filepath.Join(dir, "active-conflict.jsonl")
6071 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6072 t.Fatalf("write session: %v", err)
6073 }
6074 if err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {
6075 t.Fatalf("create trash conflict: %v", err)
6076 }
6077
6078 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
6079 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"})
6080 app := NewApp()
6081 app.setTestCtrl(ctrl, "")
6082 defer ctrl.Close()
6083 ctrl.Submit("work")
6084 <-runner.started
6085
6086 err := app.deleteSession(filepath.Base(path))
6087 if err != nil {
6088 t.Fatalf("DeleteSession should succeed after cleaning empty trash dir: %v", err)
6089 }
6090 if _, ok := app.tabs["test"]; ok {
6091 t.Fatalf("deleted session runtime should be removed from tabs")
6092 }
6093 if _, err := os.Stat(path); !os.IsNotExist(err) {
6094 t.Fatalf("session file should be moved out of active history, stat err = %v", err)
6095 }
6096 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6097 if _, err := os.Stat(trashPath); err != nil {
6098 t.Fatalf("session should be moved to trash: %v", err)
6099 }
6100
6101 close(runner.release)
6102 waitNotRunning(t, ctrl)
6103 }
6104
6105 func TestLegacyDeleteSessionValidTrashRemovesEmptyLiveStub(t *testing.T) {
6106 isolateDesktopUserDirs(t)
6107
6108 dir := config.SessionDir()
6109 if err := os.MkdirAll(dir, 0o755); err != nil {
6110 t.Fatalf("mkdir session dir: %v", err)
6111 }
6112 path := filepath.Join(dir, "stale-live.jsonl")
6113 if err := os.WriteFile(path, nil, 0o644); err != nil {
6114 t.Fatalf("write live stub: %v", err)
6115 }
6116 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6117 if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
6118 t.Fatalf("create trash dir: %v", err)
6119 }
6120 if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
6121 t.Fatalf("write trash session: %v", err)
6122 }
6123
6124 activePath := filepath.Join(dir, "active.jsonl")
6125 if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
6126 t.Fatalf("write active session: %v", err)
6127 }
6128 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
6129 defer activeCtrl.Close()
6130 app := &App{
6131 tabs: map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
6132 activeTabID: "active",
6133 tabOrder: []string{"active"},
6134 }
6135
6136 if err := app.deleteSession(filepath.Base(path)); err != nil {
6137 t.Fatalf("DeleteSession should remove stale live stub: %v", err)
6138 }
6139 if _, err := os.Stat(path); !os.IsNotExist(err) {
6140 t.Fatalf("live stub should be removed, stat err = %v", err)
6141 }
6142 if _, err := os.Stat(trashPath); err != nil {
6143 t.Fatalf("existing trash should remain authoritative: %v", err)
6144 }
6145 }
6146
6147 func TestLegacyDeleteSessionValidTrashRemovesDuplicateLiveSession(t *testing.T) {
6148 isolateDesktopUserDirs(t)
6149
6150 dir := config.SessionDir()
6151 if err := os.MkdirAll(dir, 0o755); err != nil {
6152 t.Fatalf("mkdir session dir: %v", err)
6153 }
6154 path := filepath.Join(dir, "duplicate-recovery.jsonl")
6155 content := []byte(`{"role":"user","content":"same recovery"}` + "\n")
6156 if err := os.WriteFile(path, content, 0o644); err != nil {
6157 t.Fatalf("write live session: %v", err)
6158 }
6159 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6160 if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
6161 t.Fatalf("create trash dir: %v", err)
6162 }
6163 if err := os.WriteFile(trashPath, content, 0o644); err != nil {
6164 t.Fatalf("write trash session: %v", err)
6165 }
6166
6167 activePath := filepath.Join(dir, "active.jsonl")
6168 if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
6169 t.Fatalf("write active session: %v", err)
6170 }
6171 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
6172 defer activeCtrl.Close()
6173 app := &App{
6174 tabs: map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
6175 activeTabID: "active",
6176 tabOrder: []string{"active"},
6177 }
6178
6179 if err := app.deleteSession(filepath.Base(path)); err != nil {
6180 t.Fatalf("DeleteSession should remove duplicate live session: %v", err)
6181 }
6182 if _, err := os.Stat(path); !os.IsNotExist(err) {
6183 t.Fatalf("duplicate live session should be removed, stat err = %v", err)
6184 }
6185 if got, err := os.ReadFile(trashPath); err != nil || string(got) != string(content) {
6186 t.Fatalf("existing trash should remain authoritative, got %q err=%v", string(got), err)
6187 }
6188 }
6189
6190 func TestRestoreSessionRejectsOpenEmptyLiveStub(t *testing.T) {
6191 isolateDesktopUserDirs(t)
6192
6193 dir := config.SessionDir()
6194 if err := os.MkdirAll(dir, 0o755); err != nil {
6195 t.Fatalf("mkdir session dir: %v", err)
6196 }
6197 path := filepath.Join(dir, "restore-open.jsonl")
6198 if err := os.WriteFile(path, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
6199 t.Fatalf("write trash source: %v", err)
6200 }
6201 if err := deleteSessionFile(dir, path); err != nil {
6202 t.Fatalf("trash source: %v", err)
6203 }
6204 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6205 if err := os.WriteFile(path, nil, 0o644); err != nil {
6206 t.Fatalf("write live stub: %v", err)
6207 }
6208 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "open"})
6209 defer ctrl.Close()
6210 app := &App{
6211 tabs: map[string]*WorkspaceTab{"open": {ID: "open", Scope: "global", Ctrl: ctrl, Ready: true}},
6212 tabOrder: []string{"open"},
6213 activeTabID: "open",
6214 }
6215
6216 err := app.RestoreSession(trashPath)
6217 if err == nil || !strings.Contains(err.Error(), "session is open") {
6218 t.Fatalf("RestoreSession error = %v, want open-session rejection", err)
6219 }
6220 if info, statErr := os.Stat(path); statErr != nil || info.Size() != 0 {
6221 t.Fatalf("open live stub should remain empty, info=%v err=%v", info, statErr)
6222 }
6223 if _, err := os.Stat(trashPath); err != nil {
6224 t.Fatalf("trash session should remain after rejected restore: %v", err)
6225 }
6226 }
6227
6228 func TestLegacyDeleteSessionValidTrashRenamesDifferentLiveConflict(t *testing.T) {
6229 isolateDesktopUserDirs(t)
6230
6231 dir := config.SessionDir()
6232 if err := os.MkdirAll(dir, 0o755); err != nil {
6233 t.Fatalf("mkdir session dir: %v", err)
6234 }
6235 path := filepath.Join(dir, "real-live.jsonl")
6236 if err := os.WriteFile(path, []byte(`{"role":"user","content":"new work"}`+"\n"), 0o644); err != nil {
6237 t.Fatalf("write live session: %v", err)
6238 }
6239 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6240 if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
6241 t.Fatalf("create trash dir: %v", err)
6242 }
6243 if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
6244 t.Fatalf("write trash session: %v", err)
6245 }
6246
6247 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "active"})
6248 defer activeCtrl.Close()
6249 app := NewApp()
6250 app.setTestCtrl(activeCtrl, "")
6251
6252 if err := app.deleteSession(filepath.Base(path)); err != nil {
6253 t.Fatalf("DeleteSession should move different live session to a unique trash item: %v", err)
6254 }
6255 if _, err := os.Stat(path); !os.IsNotExist(err) {
6256 t.Fatalf("live session should be moved out of active history, stat err = %v", err)
6257 }
6258 if got, err := os.ReadFile(trashPath); err != nil || !strings.Contains(string(got), "trashed") {
6259 t.Fatalf("original trash session should remain, got %q err=%v", string(got), err)
6260 }
6261 trashed, err := listTrashedSessionFiles(dir)
6262 if err != nil {
6263 t.Fatalf("list trash: %v", err)
6264 }
6265 var renamedPath string
6266 for _, candidate := range trashed {
6267 if candidate != trashPath && filepath.Base(candidate) == filepath.Base(path) {
6268 renamedPath = candidate
6269 break
6270 }
6271 }
6272 if renamedPath == "" {
6273 t.Fatalf("renamed trash copy not found in %#v", trashed)
6274 }
6275 if filepath.Base(filepath.Dir(renamedPath)) == filepath.Base(path) {
6276 t.Fatalf("renamed trash copy reused fixed trash item dir: %s", renamedPath)
6277 }
6278 if got, err := os.ReadFile(renamedPath); err != nil || !strings.Contains(string(got), "new work") {
6279 t.Fatalf("renamed trash session = %q err=%v, want live content", string(got), err)
6280 }
6281 }
6282
6283 func TestLegacyDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {
6284 isolateDesktopUserDirs(t)
6285
6286 dir := config.SessionDir()
6287 if err := os.MkdirAll(dir, 0o755); err != nil {
6288 t.Fatalf("mkdir session dir: %v", err)
6289 }
6290 activePath := filepath.Join(dir, "active.jsonl")
6291 inactivePath := filepath.Join(dir, "inactive.jsonl")
6292 otherPath := filepath.Join(dir, "other.jsonl")
6293 for _, path := range []string{activePath, inactivePath, otherPath} {
6294 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6295 t.Fatalf("write session %s: %v", path, err)
6296 }
6297 }
6298
6299 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
6300 inactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: "inactive"})
6301 defer activeCtrl.Close()
6302 defer inactiveCtrl.Close()
6303
6304 app := &App{
6305 tabs: map[string]*WorkspaceTab{
6306 "active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
6307 "inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
6308 },
6309 tabOrder: []string{"active", "inactive"},
6310 activeTabID: "active",
6311 }
6312 installSessionCatalogForTest(t, app, dir, "global", "")
6313 if err := app.deleteSession(filepath.Base(inactivePath)); err != nil {
6314 t.Fatalf("DeleteSession(inactive open basename): %v", err)
6315 }
6316 if _, ok := app.tabs["inactive"]; ok {
6317 t.Fatalf("deleted inactive session runtime should be removed")
6318 }
6319 if _, err := os.Stat(inactivePath); !os.IsNotExist(err) {
6320 t.Fatalf("inactive open session should be moved out of active history, stat err = %v", err)
6321 }
6322 trashPath := filepath.Join(dir, sessionTrashDir, "inactive.jsonl", "inactive.jsonl")
6323 if _, err := os.Stat(trashPath); err != nil {
6324 t.Fatalf("inactive open session should be moved to trash: %v", err)
6325 }
6326
6327 sessions := app.ListSessions()
6328 current := map[string]bool{}
6329 open := map[string]bool{}
6330 for _, s := range sessions {
6331 current[filepath.Base(s.Path)] = s.Current
6332 open[filepath.Base(s.Path)] = s.Open
6333 }
6334 if !current[filepath.Base(activePath)] {
6335 t.Fatalf("ListSessions should mark active session current, got %#v", current)
6336 }
6337 if current[filepath.Base(otherPath)] {
6338 t.Fatalf("ListSessions marked unopened session current, got %#v", current)
6339 }
6340 if !open[filepath.Base(activePath)] {
6341 t.Fatalf("ListSessions should mark active and inactive open sessions open, got %#v", open)
6342 }
6343 if open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {
6344 t.Fatalf("ListSessions marked unopened session open, got %#v", open)
6345 }
6346 }
6347
6348 func TestTrashTopicRejectsBackgroundJob(t *testing.T) {
6349 isolateDesktopUserDirs(t)
6350
6351 projectRoot := t.TempDir()
6352 topicID := "topic_stuck_trash"
6353 if err := addProject(projectRoot, ""); err != nil {
6354 t.Fatalf("add project: %v", err)
6355 }
6356 if err := setTopicTitle(projectRoot, topicID, "Stuck trash"); err != nil {
6357 t.Fatalf("set topic title: %v", err)
6358 }
6359 dir := config.SessionDir()
6360 if err := os.MkdirAll(dir, 0o755); err != nil {
6361 t.Fatalf("mkdir sessions: %v", err)
6362 }
6363 sessionPath := writeTopicSession(t, dir, "stuck-topic.jsonl", topicID, "Stuck trash", projectRoot)
6364
6365 jm := jobs.NewManager(event.Discard)
6366 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", Jobs: jm, WorkspaceRoot: projectRoot})
6367 releaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)
6368 defer func() {
6369 releaseJob()
6370 ctrl.Close()
6371 }()
6372
6373 app := &App{
6374 tabs: map[string]*WorkspaceTab{
6375 "stuck": {
6376 ID: "stuck",
6377 Scope: "project",
6378 WorkspaceRoot: projectRoot,
6379 TopicID: topicID,
6380 TopicTitle: "Stuck trash",
6381 Ctrl: ctrl,
6382 Ready: true,
6383 disabledMCP: map[string]ServerView{},
6384 },
6385 "keep": {
6386 ID: "keep",
6387 Scope: "project",
6388 WorkspaceRoot: projectRoot,
6389 TopicID: "topic_keep",
6390 TopicTitle: "Keep",
6391 Ready: true,
6392 disabledMCP: map[string]ServerView{},
6393 },
6394 },
6395 tabOrder: []string{"stuck", "keep"},
6396 activeTabID: "stuck",
6397 }
6398
6399 if err := app.TrashTopic(topicID); !errors.Is(err, errTopicHasActiveWork) {
6400 t.Fatalf("TrashTopic(background job) error = %v, want %v", err, errTopicHasActiveWork)
6401 }
6402 if _, ok := app.tabs["stuck"]; !ok {
6403 t.Fatal("rejected archive should keep the background-job topic tab")
6404 }
6405 if agent.IsCleanupPending(sessionPath) {
6406 t.Fatal("rejected archive should not mark session cleanup pending")
6407 }
6408 if _, err := os.Stat(sessionPath); err != nil {
6409 t.Fatalf("rejected archive should preserve the live session: %v", err)
6410 }
6411 trashPath := filepath.Join(dir, sessionTrashDir, "stuck-topic.jsonl", "stuck-topic.jsonl")
6412 if _, err := os.Stat(trashPath); !os.IsNotExist(err) {
6413 t.Fatalf("rejected archive created a trash entry, stat err = %v", err)
6414 }
6415 if got := loadTopicTitle(projectRoot, topicID); got != "Stuck trash" {
6416 t.Fatalf("rejected archive topic title = %q, want Stuck trash", got)
6417 }
6418 }
6419
6420 func teardownNoticeSink(out chan<- event.Event) event.Sink {
6421 return event.FuncSink(func(e event.Event) {
6422 if e.Kind == event.Notice && strings.Contains(e.Detail, "background job teardown timed out") {
6423 out <- e
6424 }
6425 })
6426 }
6427
6428 func assertSingleTeardownTimeoutNotice(t *testing.T, notices <-chan event.Event, grace time.Duration) {
6429 t.Helper()
6430 var notice event.Event
6431 select {
6432 case notice = <-notices:
6433 default:
6434 t.Fatal("missing background-job teardown timeout notice")
6435 }
6436 var waited time.Duration
6437 for field := range strings.FieldsSeq(notice.Detail) {
6438 if !strings.HasPrefix(field, "waited=") {
6439 continue
6440 }
6441 parsed, err := time.ParseDuration(strings.TrimSuffix(strings.TrimPrefix(field, "waited="), ";"))
6442 if err != nil {
6443 t.Fatalf("parse teardown waited field %q: %v", field, err)
6444 }
6445 waited = parsed
6446 break
6447 }
6448 if waited < grace-10*time.Millisecond || waited > grace+250*time.Millisecond {
6449 t.Fatalf("teardown notice waited %s, want one %s grace; detail: %s", waited, grace, notice.Detail)
6450 }
6451 select {
6452 case extra := <-notices:
6453 t.Fatalf("duplicate teardown timeout notice: %+v", extra)
6454 default:
6455 }
6456 }
6457
6458 func TestWaitDestroyHandlesWaitsConcurrently(t *testing.T) {
6459 started := make(chan int, 2)
6460 release := make(chan struct{})
6461 var releaseOnce sync.Once
6462 releaseAll := func() { releaseOnce.Do(func() { close(release) }) }
6463 defer releaseAll()
6464
6465 handle := func(id int) control.SessionDestroyHandle {
6466 return control.SessionDestroyHandle{Wait: func() jobs.TeardownResult {
6467 started <- id
6468 <-release
6469 return jobs.TeardownResult{TimedOut: []jobs.TeardownJob{{ID: strconv.Itoa(id)}}}
6470 }}
6471 }
6472 done := make(chan bool, 1)
6473 go func() { done <- waitDestroyHandles([]control.SessionDestroyHandle{handle(1), handle(2)}) }()
6474
6475 seen := map[int]bool{}
6476 for len(seen) < 2 {
6477 select {
6478 case id := <-started:
6479 seen[id] = true
6480 case <-time.After(2 * time.Second):
6481 t.Fatalf("destroy waits did not start concurrently; started=%v", seen)
6482 }
6483 }
6484 releaseAll()
6485 select {
6486 case timedOut := <-done:
6487 if !timedOut {
6488 t.Fatal("waitDestroyHandles lost timed-out result")
6489 }
6490 case <-time.After(2 * time.Second):
6491 t.Fatal("waitDestroyHandles did not return after all waits completed")
6492 }
6493 }
6494
6495 func TestRestoreSessionRejectsDestroyingSession(t *testing.T) {
6496 isolateDesktopUserDirs(t)
6497
6498 dir := config.SessionDir()
6499 if err := os.MkdirAll(dir, 0o755); err != nil {
6500 t.Fatalf("mkdir session dir: %v", err)
6501 }
6502 sessionPath := filepath.Join(dir, "trash-me.jsonl")
6503 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6504 t.Fatalf("write session: %v", err)
6505 }
6506 if err := deleteSessionFile(dir, sessionPath); err != nil {
6507 t.Fatalf("deleteSessionFile: %v", err)
6508 }
6509 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
6510
6511 jm := jobs.NewManager(event.Discard)
6512 defer jm.Close()
6513 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "active", Jobs: jm})
6514 defer ctrl.Close()
6515 destroy := ctrl.BeginDestroySession(sessionPath)
6516 defer destroy.Finish()
6517
6518 app := NewApp()
6519 app.setTestCtrl(ctrl, "")
6520 if err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), "cleanup is still in progress") {
6521 t.Fatalf("RestoreSession while destroying error = %v, want cleanup-in-progress", err)
6522 }
6523 if _, err := os.Stat(trashPath); err != nil {
6524 t.Fatalf("trashed session should remain after rejected restore: %v", err)
6525 }
6526
6527 destroy.Finish()
6528 if err := app.RestoreSession(trashPath); err != nil {
6529 t.Fatalf("RestoreSession after finish: %v", err)
6530 }
6531 assertLegacyLifecycle(t, app, trashPath, "active")
6532 }
6533
6534 func TestLegacyDeleteSessionClearsAutoBotSessionMapping(t *testing.T) {
6535 isolateDesktopUserDirs(t)
6536
6537 dir := config.SessionDir()
6538 if err := os.MkdirAll(dir, 0o755); err != nil {
6539 t.Fatalf("mkdir session dir: %v", err)
6540 }
6541 path := filepath.Join(dir, "bot-channel.jsonl")
6542 if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
6543 t.Fatalf("write session: %v", err)
6544 }
6545 other := filepath.Join(dir, "other-channel.jsonl")
6546 cfg := config.Default()
6547 cfg.Bot.Connections = []config.BotConnectionConfig{{
6548 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
6549 SessionMappings: []config.BotConnectionSessionMapping{
6550 {RemoteID: "remove-auto", SessionID: "path:" + path, SessionSource: "auto"},
6551 {RemoteID: "keep-explicit", SessionID: "path:" + path},
6552 {RemoteID: "keep-other-auto", SessionID: "path:" + other, SessionSource: "auto"},
6553 },
6554 }}
6555 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
6556 t.Fatalf("save config: %v", err)
6557 }
6558
6559 app := NewApp()
6560 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
6561 app.setTestCtrl(ctrl, "")
6562 defer app.activeCtrl().Close()
6563
6564 if err := app.deleteSession(path); err != nil {
6565 t.Fatalf("DeleteSession: %v", err)
6566 }
6567
6568 got := config.LoadForEdit(config.UserConfigPath())
6569 mappings := got.Bot.Connections[0].SessionMappings
6570 if len(mappings) != 2 {
6571 t.Fatalf("session mappings = %+v, want explicit and other auto mappings preserved", mappings)
6572 }
6573 for _, mapping := range mappings {
6574 if mapping.RemoteID == "remove-auto" {
6575 t.Fatalf("deleted session auto mapping was preserved: %+v", mappings)
6576 }
6577 }
6578 }
6579
6580 func TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {
6581 isolateDesktopUserDirs(t)
6582
6583 dir := config.SessionDir()
6584 if err := os.MkdirAll(dir, 0o755); err != nil {
6585 t.Fatalf("mkdir session dir: %v", err)
6586 }
6587 path := filepath.Join(dir, "bot-channel.jsonl")
6588 if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
6589 t.Fatalf("write session: %v", err)
6590 }
6591
6592 app := NewApp()
6593 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
6594 app.setTestCtrl(ctrl, "")
6595 defer app.activeCtrl().Close()
6596
6597 if _, err := app.OpenChannelSessionForTab("test", path); err != nil {
6598 t.Fatalf("OpenChannelSessionForTab: %v", err)
6599 }
6600 if meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {
6601 t.Fatalf("channel tab should be read-only: %+v", meta)
6602 }
6603 before, err := os.ReadFile(path)
6604 if err != nil {
6605 t.Fatalf("read before: %v", err)
6606 }
6607 app.SubmitToTab("test", "must not append")
6608 app.RunShellForTab("test", "echo must-not-run")
6609 after, err := os.ReadFile(path)
6610 if err != nil {
6611 t.Fatalf("read after: %v", err)
6612 }
6613 if string(after) != string(before) {
6614 t.Fatalf("read-only channel transcript changed:\nbefore=%s\nafter=%s", before, after)
6615 }
6616
6617 f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
6618 if err != nil {
6619 t.Fatalf("open append: %v", err)
6620 }
6621 if _, err := f.WriteString(`{"role":"user","content":"external follow-up"}` + "\n"); err != nil {
6622 f.Close()
6623 t.Fatalf("append external message: %v", err)
6624 }
6625 if err := f.Close(); err != nil {
6626 t.Fatalf("close append: %v", err)
6627 }
6628 app.snapshotAllTabs()
6629 afterSnapshot, err := os.ReadFile(path)
6630 if err != nil {
6631 t.Fatalf("read after snapshot: %v", err)
6632 }
6633 if !strings.Contains(string(afterSnapshot), "external follow-up") {
6634 t.Fatalf("read-only channel snapshot overwrote external append:\n%s", afterSnapshot)
6635 }
6636 }
6637
6638 func TestUserTriggeredCommandsReturnErrorsWhenUnavailable(t *testing.T) {
6639 tests := []struct {
6640 name string
6641 app *App
6642 call func(*App) error
6643 want string
6644 }{
6645 {
6646 name: "submit read-only",
6647 app: &App{
6648 tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global", ReadOnly: true}},
6649 activeTabID: "test",
6650 },
6651 call: func(app *App) error { return app.SubmitToTab("test", "hello") },
6652 want: "read-only",
6653 },
6654 {
6655 name: "submit workspace unavailable",
6656 app: &App{
6657 tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global", StartupErr: "boom"}},
6658 activeTabID: "test",
6659 },
6660 call: func(app *App) error { return app.SubmitToTab("test", "hello") },
6661 want: "workspace failed to start: boom",
6662 },
6663 {
6664 name: "run shell workspace unavailable",
6665 app: &App{
6666 tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global"}},
6667 activeTabID: "test",
6668 },
6669 call: func(app *App) error { return app.RunShellForTab("test", "echo hi") },
6670 want: "workspace is still starting",
6671 },
6672 {
6673 name: "steer workspace unavailable",
6674 app: &App{
6675 tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global"}},
6676 activeTabID: "test",
6677 },
6678 call: func(app *App) error { return app.SteerForTab("test", "please continue") },
6679 want: "workspace is still starting",
6680 },
6681 }
6682
6683 for _, tt := range tests {
6684 t.Run(tt.name, func(t *testing.T) {
6685 err := tt.call(tt.app)
6686 if err == nil {
6687 t.Fatalf("expected error containing %q", tt.want)
6688 }
6689 if !strings.Contains(err.Error(), tt.want) {
6690 t.Fatalf("error = %q, want to contain %q", err, tt.want)
6691 }
6692 })
6693 }
6694 }
6695
6696 func TestSubmitEntryPointsRejectEmptyProviderInput(t *testing.T) {
6697 app := NewApp()
6698 for _, tt := range []struct {
6699 name string
6700 call func() error
6701 }{
6702 {name: "plain", call: func() error { return app.SubmitToTab("missing", " \n\t ") }},
6703 {name: "display", call: func() error { return app.SubmitDisplayToTab("missing", "visible prompt", " ") }},
6704 {name: "delivery recovery", call: func() error {
6705 return app.SubmitDeliveryRecoveryToTab("missing", "visible prompt", "")
6706 }},
6707 {name: "invocations", call: func() error {
6708 return app.SubmitInvocationsToTab("missing", "/skill visible", "", nil)
6709 }},
6710 {name: "edited display", call: func() error {
6711 return app.SubmitEditedDisplayToTab("missing", "visible prompt", "\n", "original prompt")
6712 }},
6713 {name: "initial goal", call: func() error {
6714 _, err := app.SubmitInitialGoalToTab("missing", "goal", "visible prompt", "", nil, "normal", "auto")
6715 return err
6716 }},
6717 } {
6718 t.Run(tt.name, func(t *testing.T) {
6719 if err := tt.call(); !errors.Is(err, errEmptyTurnInput) {
6720 t.Fatalf("error = %v, want errEmptyTurnInput", err)
6721 }
6722 })
6723 }
6724 }
6725
6726 func TestInvocationEntryPointsAllowEmptyExplicitTaskForSkillOnlyTurn(t *testing.T) {
6727 invocations := []InvocationRequest{{Name: "skill", Kind: "skill"}}
6728 if err := validateInvocationTurnInput("", invocations); err != nil {
6729 t.Fatalf("skill-only invocation input rejected: %v", err)
6730 }
6731 if err := validateInvocationTurnInput("", nil); !errors.Is(err, errEmptyTurnInput) {
6732 t.Fatalf("empty input without invocations = %v, want errEmptyTurnInput", err)
6733 }
6734 }
6735
6736 func TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {
6737 isolateDesktopUserDirs(t)
6738
6739 dir := config.SessionDir()
6740 if err := os.MkdirAll(dir, 0o755); err != nil {
6741 t.Fatalf("mkdir session dir: %v", err)
6742 }
6743 path := filepath.Join(dir, "bot-channel.jsonl")
6744 if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
6745 t.Fatalf("write session: %v", err)
6746 }
6747
6748 app := NewApp()
6749 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
6750 app.setTestCtrl(ctrl, "")
6751 defer ctrl.Close()
6752
6753 if _, err := app.OpenChannelSessionForTab("test", path); err != nil {
6754 t.Fatalf("OpenChannelSessionForTab: %v", err)
6755 }
6756 app.mu.Lock()
6757 app.tabs["survivor"] = &WorkspaceTab{ID: "survivor", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
6758 app.tabOrder = []string{"test", "survivor"}
6759 app.activeTabID = "test"
6760 app.mu.Unlock()
6761
6762 f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
6763 if err != nil {
6764 t.Fatalf("open append: %v", err)
6765 }
6766 if _, err := f.WriteString(`{"role":"user","content":"external close follow-up"}` + "\n"); err != nil {
6767 f.Close()
6768 t.Fatalf("append external message: %v", err)
6769 }
6770 if err := f.Close(); err != nil {
6771 t.Fatalf("close append: %v", err)
6772 }
6773
6774 if err := app.CloseTab("test"); err != nil {
6775 t.Fatalf("CloseTab: %v", err)
6776 }
6777 afterClose, err := os.ReadFile(path)
6778 if err != nil {
6779 t.Fatalf("read after close: %v", err)
6780 }
6781 if !strings.Contains(string(afterClose), "external close follow-up") {
6782 t.Fatalf("closing read-only channel tab overwrote external append:\n%s", afterClose)
6783 }
6784 }
6785
6786 func TestResumeSessionRejectsCleanupPending(t *testing.T) {
6787 isolateDesktopUserDirs(t)
6788
6789 dir := config.SessionDir()
6790 if err := os.MkdirAll(dir, 0o755); err != nil {
6791 t.Fatalf("mkdir session dir: %v", err)
6792 }
6793 activePath := filepath.Join(dir, "active.jsonl")
6794 pendingPath := filepath.Join(dir, "pending.jsonl")
6795 for _, path := range []string{activePath, pendingPath} {
6796 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6797 t.Fatalf("write %s: %v", path, err)
6798 }
6799 }
6800 if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil {
6801 t.Fatal(err)
6802 }
6803
6804 app := NewApp()
6805 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "test"})
6806 app.setTestCtrl(ctrl, "")
6807 defer app.activeCtrl().Close()
6808
6809 if _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), "pending cleanup") {
6810 t.Fatalf("ResumeSession cleanup-pending error = %v, want pending cleanup", err)
6811 }
6812 if got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {
6813 t.Fatalf("active session path after rejected resume = %q, want %q", got, activePath)
6814 }
6815 if _, err := app.OpenChannelSessionForTab("test", pendingPath); err == nil || !strings.Contains(err.Error(), "pending cleanup") {
6816 t.Fatalf("OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup", err)
6817 }
6818 if meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {
6819 t.Fatalf("rejected channel open should not make tab read-only: %+v", meta)
6820 }
6821 }
6822
6823 func TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {
6824 dirA := t.TempDir()
6825 dirB := t.TempDir()
6826 activePath := filepath.Join(dirA, "active.jsonl")
6827 outsidePath := filepath.Join(dirB, "outside.jsonl")
6828 for _, path := range []string{activePath, outsidePath} {
6829 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6830 t.Fatalf("write %s: %v", path, err)
6831 }
6832 }
6833
6834 app := NewApp()
6835 app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: "test"}), "")
6836 defer app.activeCtrl().Close()
6837
6838 if _, err := app.ResumeSession(outsidePath); err == nil {
6839 t.Fatal("ResumeSession should reject a transcript outside the active session dir")
6840 }
6841 if _, err := app.PreviewSession(outsidePath); err == nil {
6842 t.Fatal("PreviewSession should reject a transcript outside the active session dir")
6843 }
6844 }
6845
6846 func BenchmarkDesktopListSessionsScoped(b *testing.B) {
6847 dirA := filepath.Join(b.TempDir(), "workspace-a-sessions")
6848 dirB := filepath.Join(b.TempDir(), "workspace-b-sessions")
6849 for _, dir := range []string{dirA, dirB} {
6850 if err := os.MkdirAll(dir, 0o755); err != nil {
6851 b.Fatalf("mkdir %s: %v", dir, err)
6852 }
6853 for i := range 120 {
6854 path := filepath.Join(dir, fmt.Sprintf("session-%03d.jsonl", i))
6855 body := fmt.Sprintf(`{"role":"user","content":"session %03d"}`+"\n", i)
6856 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
6857 b.Fatalf("write session: %v", err)
6858 }
6859 }
6860 }
6861
6862 app := NewApp()
6863 app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, "session-000.jsonl"), Label: "test"}), "")
6864 defer app.activeCtrl().Close()
6865
6866 b.ReportAllocs()
6867 b.ResetTimer()
6868 for range b.N {
6869 sessions := app.ListSessions()
6870 if len(sessions) != 120 {
6871 b.Fatalf("ListSessions len = %d, want 120", len(sessions))
6872 }
6873 }
6874 }
6875
6876 type appendingDesktopRunner struct {
6877 session *agent.Session
6878 started chan string
6879 }
6880
6881 func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
6882 r.started <- input
6883 r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
6884 r.session.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
6885 return nil
6886 }
6887
6888 func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
6889 isolateDesktopUserDirs(t)
6890 reasonixHome := config.ReasonixHomeDir()
6891 root := filepath.Join(reasonixHome, "plugins", "superpowers")
6892 if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
6893 t.Fatal(err)
6894 }
6895 if err := os.MkdirAll(filepath.Join(root, "skills", "plan"), 0o755); err != nil {
6896 t.Fatal(err)
6897 }
6898 if err := os.WriteFile(filepath.Join(root, "skills", "plan", "SKILL.md"), []byte("---\ndescription: Plan work\n---\nbody"), 0o644); err != nil {
6899 t.Fatal(err)
6900 }
6901 if err := os.MkdirAll(filepath.Join(root, ".codex-plugin"), 0o755); err != nil {
6902 t.Fatal(err)
6903 }
6904 if err := os.WriteFile(filepath.Join(root, ".codex-plugin", "plugin.json"), []byte(`{
6905 "name": "superpowers",
6906 "version": "6.1.0",
6907 "description": "Planning workflows",
6908 "skills": "./skills/"
6909 }`), 0o644); err != nil {
6910 t.Fatal(err)
6911 }
6912 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
6913 Name: "superpowers",
6914 Root: "plugins/superpowers",
6915 Version: "6.1.0",
6916 Description: "Planning workflows",
6917 ManifestKind: "codex",
6918 Enabled: true,
6919 }); err != nil {
6920 t.Fatal(err)
6921 }
6922
6923 app := NewApp()
6924 plugins := app.Capabilities().Plugins
6925 if len(plugins) != 1 || plugins[0].Name != "superpowers" || plugins[0].Skills != 1 {
6926 t.Fatalf("Capabilities().Plugins = %+v", plugins)
6927 }
6928 if len(plugins[0].SkillDetails) != 1 || plugins[0].SkillDetails[0].Invocation != "/superpowers:plan" {
6929 t.Fatalf("Capabilities().Plugins skill details = %+v", plugins[0].SkillDetails)
6930 }
6931 }
6932
6933 func TestDesktopSharedHostProjectMCPConnectsWithoutLaunchApproval(t *testing.T) {
6934 if testing.Short() {
6935 t.Skip("skipping background MCP boot integration test in short mode")
6936 }
6937
6938 isolateDesktopUserDirs(t)
6939 dir := robustTempDir(t)
6940 t.Chdir(dir)
6941
6942 srv := desktopMCPHTTPServer(t)
6943 defer srv.Close()
6944 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
6945 [[plugins]]
6946 name = "h"
6947 type = "http"
6948 url = %q
6949 `, srv.URL), 0o644); err != nil {
6950 t.Fatal(err)
6951 }
6952 approveWorkspace(t, dir)
6953 enableProjectMCPForTest(t, dir)
6954
6955 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
6956 defer cancel()
6957 sharedHost := plugin.NewHost()
6958 defer sharedHost.Close()
6959 ctrl, err := boot.Build(ctx, boot.Options{
6960 WorkspaceRoot: dir,
6961 SessionDir: filepath.Join(dir, "sessions"),
6962 SharedHost: sharedHost,
6963 Stderr: io.Discard,
6964 })
6965 if err != nil {
6966 t.Fatalf("boot.Build: %v", err)
6967 }
6968 defer ctrl.Close()
6969
6970 deadline := time.Now().Add(3 * time.Second)
6971 for !sharedHost.HasClient("h") && time.Now().Before(deadline) {
6972 time.Sleep(25 * time.Millisecond)
6973 }
6974 if !sharedHost.HasClient("h") {
6975 t.Fatalf("project MCP did not connect automatically; failures=%+v", sharedHost.Failures())
6976 }
6977 for _, failure := range sharedHost.Failures() {
6978 if failure.Name == "h" && failure.RequiresLaunchApproval {
6979 t.Fatalf("project MCP unexpectedly requested launch approval: %+v", failure)
6980 }
6981 }
6982
6983 app := NewApp()
6984 app.tabs = map[string]*WorkspaceTab{
6985 "test": {
6986 ID: "test",
6987 Scope: "global",
6988 WorkspaceRoot: dir,
6989 Ready: true,
6990 Ctrl: ctrl,
6991 SharedHostKey: dir,
6992 disabledMCP: map[string]ServerView{},
6993 },
6994 }
6995 app.activeTabID = "test"
6996
6997 view := app.MCPServers()
6998 if len(view) != 1 || view[0].Name != "h" || view[0].Status != "connected" || view[0].RuntimeState != "ready" || view[0].RequiresLaunchApproval {
6999 t.Fatalf("MCPServers() = %+v, want trusted connected project h", view)
7000 }
7001 }
7002
7003 func TestProjectMCPViewIsTrustedAndKeepsProjectSource(t *testing.T) {
7004 entry := config.PluginEntry{Name: "project", Source: config.MCPSourceProjectConfig}
7005 connected := withPluginConfig(ServerView{Name: entry.Name, Status: "connected"}, entry)
7006 if connected.RequiresLaunchApproval {
7007 t.Fatalf("connected project MCP still requires launch approval: %+v", connected)
7008 }
7009 blocked := withPluginConfig(ServerView{
7010 Name: entry.Name, Status: "failed", RequiresLaunchApproval: true,
7011 }, entry)
7012 if blocked.RequiresLaunchApproval {
7013 t.Fatalf("project MCP exposed obsolete launch approval action: %+v", blocked)
7014 }
7015 if blocked.Source != "project" || blocked.ConfigSource != "reasonix.toml" {
7016 t.Fatalf("blocked project MCP source = %q/%q, want project/reasonix.toml", blocked.Source, blocked.ConfigSource)
7017 }
7018
7019 user := withPluginConfig(ServerView{Name: "user", Status: "connected"},
7020 config.PluginEntry{Name: "user", Source: config.MCPSourceUserConfig})
7021 if user.RequiresLaunchApproval {
7022 t.Fatalf("user-config MCP must not be launch-gate governed: %+v", user)
7023 }
7024 if user.Source != "user" || user.ConfigSource != "config.toml" {
7025 t.Fatalf("user MCP source = %q/%q, want user/config.toml", user.Source, user.ConfigSource)
7026 }
7027 }
7028
7029 func TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {
7030 isolateDesktopUserDirs(t)
7031 dir := robustTempDir(t)
7032 t.Chdir(dir)
7033 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
7034 [[plugins]]
7035 name = "playwright"
7036 command = "npx"
7037 args = ["-y", "@playwright/mcp"]
7038 `), 0o644); err != nil {
7039 t.Fatal(err)
7040 }
7041 approveWorkspace(t, dir)
7042
7043 app := NewApp()
7044 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
7045 defer app.activeCtrl().Close()
7046
7047 if got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {
7048 t.Fatalf("MCPServers() = %+v, want Capabilities().Servers %+v", got, want)
7049 }
7050 }
7051
7052 func TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {
7053 isolateDesktopUserDirs(t)
7054 dir := robustTempDir(t)
7055 t.Chdir(dir)
7056 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
7057 [[plugins]]
7058 name = "time"
7059 command = "custom-time"
7060 args = ["serve"]
7061 tier = "lazy"
7062 `), 0o644); err != nil {
7063 t.Fatal(err)
7064 }
7065 approveWorkspace(t, dir)
7066
7067 app := NewApp()
7068 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
7069 defer app.activeCtrl().Close()
7070
7071 view := app.Capabilities()
7072 found := false
7073 for _, s := range view.Servers {
7074 if s.Name != "time" {
7075 continue
7076 }
7077 found = true
7078 if s.BuiltIn || !s.Configured || s.Command != "custom-time" || !reflect.DeepEqual(s.Args, []string{"serve"}) {
7079 t.Fatalf("configured time view = %+v, want ordinary user MCP config", s)
7080 }
7081 }
7082 if !found {
7083 t.Fatalf("configured time server missing from Capabilities: %+v", view.Servers)
7084 }
7085
7086 if err := app.SetMCPServerEnabled("time", false); err != nil {
7087 t.Fatalf("SetMCPServerEnabled(time,false): %v", err)
7088 }
7089 view = app.Capabilities()
7090 for _, s := range view.Servers {
7091 if s.Name == "time" {
7092 if s.Status != "disabled" || s.BuiltIn || s.Command != "custom-time" {
7093 t.Fatalf("disabled configured time view = %+v, want disabled external config", s)
7094 }
7095 return
7096 }
7097 }
7098 t.Fatalf("time missing after disable: %+v", view.Servers)
7099 }
7100
7101 func TestSetMCPServerEnabledRestoresOnDemandWithoutConnecting(t *testing.T) {
7102 isolateDesktopUserDirs(t)
7103 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
7104 dir := robustTempDir(t)
7105 t.Chdir(dir)
7106 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
7107 [[plugins]]
7108 name = "offline"
7109 type = "http"
7110 url = "http://127.0.0.1:1/mcp"
7111 `), 0o644); err != nil {
7112 t.Fatal(err)
7113 }
7114 approveWorkspace(t, dir)
7115
7116 host := plugin.NewHost()
7117 defer host.Close()
7118 reg := tool.NewRegistry()
7119 ctrl := control.New(control.Options{Host: host, Registry: reg, PluginCtx: context.Background(), WorkspaceRoot: dir})
7120 app := NewApp()
7121 app.setTestCtrl(ctrl, "")
7122 app.tabs["test"].WorkspaceRoot = dir
7123
7124 if err := app.SetMCPServerEnabled("offline", false); err != nil {
7125 t.Fatalf("SetMCPServerEnabled(false): %v", err)
7126 }
7127 if err := app.SetMCPServerEnabled("offline", true); err != nil {
7128 t.Fatalf("SetMCPServerEnabled(true) forced an unavailable connection: %v", err)
7129 }
7130 if host.HasClient("offline") {
7131 t.Fatal("durable enable started the disconnected MCP server")
7132 }
7133 if _, ok := reg.Get("mcp__offline__connect"); !ok {
7134 t.Fatalf("on-demand connect stub missing after enable; names=%v", reg.Names())
7135 }
7136 }
7137
7138 func TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {
7139 isolateDesktopUserDirs(t)
7140 dir := robustTempDir(t)
7141 t.Chdir(dir)
7142
7143 srv := desktopMCPHTTPServer(t)
7144 defer srv.Close()
7145 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
7146 [[plugins]]
7147 name = "h"
7148 type = "http"
7149 url = %q
7150 `, srv.URL), 0o644); err != nil {
7151 t.Fatal(err)
7152 }
7153 approveWorkspace(t, dir)
7154
7155 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
7156 defer cancel()
7157 sharedHost := plugin.NewHost()
7158 defer sharedHost.Close()
7159 tools, err := sharedHost.Add(ctx, plugin.Spec{Name: "h", Type: "http", URL: srv.URL})
7160 if err != nil {
7161 t.Fatalf("sharedHost.Add: %v", err)
7162 }
7163
7164 activeRegistry := tool.NewRegistry()
7165 siblingRegistry := tool.NewRegistry()
7166 for _, mt := range tools {
7167 activeRegistry.Add(mt)
7168 siblingRegistry.Add(mt)
7169 }
7170 activeCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})
7171 siblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})
7172 app := NewApp()
7173 app.tabs = map[string]*WorkspaceTab{
7174 "active": {
7175 ID: "active",
7176 Scope: "global",
7177 WorkspaceRoot: dir,
7178 Ready: true,
7179 Ctrl: activeCtrl,
7180 SharedHostKey: dir,
7181 disabledMCP: map[string]ServerView{},
7182 },
7183 "sibling": {
7184 ID: "sibling",
7185 Scope: "global",
7186 WorkspaceRoot: dir,
7187 Ready: true,
7188 Ctrl: siblingCtrl,
7189 SharedHostKey: dir,
7190 disabledMCP: map[string]ServerView{},
7191 },
7192 }
7193 app.activeTabID = "active"
7194
7195 if err := app.SetMCPServerEnabled("h", false); err != nil {
7196 t.Fatalf("SetMCPServerEnabled(h,false): %v", err)
7197 }
7198 if _, found := activeRegistry.Get("mcp__h__greet"); found {
7199 t.Fatal("active tab still has h tools after disabling the shared server")
7200 }
7201 if _, found := siblingRegistry.Get("mcp__h__greet"); !found {
7202 t.Fatal("sibling tab lost h tools when active tab disabled the shared server")
7203 }
7204 if !sharedHost.HasClient("h") {
7205 t.Fatal("shared host client was removed by a per-tab disable")
7206 }
7207 view := app.Capabilities()
7208 if len(view.Servers) != 1 || view.Servers[0].Name != "h" || view.Servers[0].Status != "disabled" {
7209 t.Fatalf("Capabilities after disable = %+v, want h disabled for the active tab", view.Servers)
7210 }
7211
7212 if err := app.SetMCPServerEnabled("h", true); err != nil {
7213 t.Fatalf("SetMCPServerEnabled(h,true): %v", err)
7214 }
7215 if _, found := activeRegistry.Get("mcp__h__greet"); !found {
7216 t.Fatal("active tab did not re-register h tools from the existing shared client")
7217 }
7218 view = app.Capabilities()
7219 if len(view.Servers) != 1 || view.Servers[0].Name != "h" || view.Servers[0].Status != "connected" {
7220 t.Fatalf("Capabilities after re-enable = %+v, want h connected for the active tab", view.Servers)
7221 }
7222 }
7223
7224 func TestAuthorizeAndConnectMCPServerStartsProjectOnlyOnce(t *testing.T) {
7225 gateAddr, attempts := newDesktopMCPStartGate(t, func(_ int, conn net.Conn) {
7226 _, _ = conn.Write([]byte{1})
7227 })
7228 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
7229 waitForDesktopMCPStartAttempt(t, attempts, 1)
7230 oldSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7231 if !found {
7232 t.Fatal("sibling registry missing initial h tool")
7233 }
7234
7235 if err := fixture.app.AuthorizeAndConnectMCPServer("h"); err != nil {
7236 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
7237 }
7238 waitForDesktopMCPStartAttempt(t, attempts, 2)
7239 select {
7240 case attempt := <-attempts:
7241 t.Fatalf("project authorization started a temporary connection process (unexpected attempt %d)", attempt)
7242 case <-time.After(250 * time.Millisecond):
7243 }
7244 if !fixture.sharedHost.HasClient("h") {
7245 t.Fatal("project authorization did not leave h connected")
7246 }
7247 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7248 t.Fatal("active registry was not refreshed after project authorization")
7249 }
7250 newSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7251 if !found || newSiblingTool == oldSiblingTool {
7252 t.Fatal("sibling registry did not receive the single new project connection")
7253 }
7254 if _, found := fixture.disabledRegistry.Get("mcp__h__greet"); found {
7255 t.Fatal("project authorization re-enabled h in a disabled sibling tab")
7256 }
7257 }
7258
7259 func TestReconnectMCPServerRefreshesEverySharedHostRegistry(t *testing.T) {
7260 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7261 oldSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7262 if !found {
7263 t.Fatal("sibling registry missing initial h tool")
7264 }
7265 if err := fixture.app.ReconnectMCPServer("h"); err != nil {
7266 t.Fatalf("ReconnectMCPServer(h): %v", err)
7267 }
7268 if !fixture.sharedHost.HasClient("h") {
7269 t.Fatal("shared host did not reconnect h")
7270 }
7271 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7272 t.Fatal("active registry was not refreshed")
7273 }
7274 newSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7275 if !found || newSiblingTool == oldSiblingTool {
7276 t.Fatal("sibling registry retained the tool backed by the disconnected client")
7277 }
7278 if _, found := fixture.disabledRegistry.Get("mcp__h__greet"); found {
7279 t.Fatal("reconnect re-enabled a tab where the server was disabled")
7280 }
7281 }
7282
7283 func TestReconnectMCPServerUsesEffectiveProjectConfigWhenUserNameIsShadowed(t *testing.T) {
7284 isolateDesktopUserDirs(t)
7285 dir := robustTempDir(t)
7286 userServer := desktopMCPHTTPServerWithTool(t, "user-shadow", "user_tool")
7287 defer userServer.Close()
7288 projectServer := desktopMCPHTTPServerWithTool(t, "project-effective", "project_tool")
7289 defer projectServer.Close()
7290
7291 userCfg := config.LoadForEdit(config.UserConfigPath())
7292 userCfg.Plugins = []config.PluginEntry{{Name: "h", Type: "http", URL: userServer.URL}}
7293 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
7294 t.Fatal(err)
7295 }
7296 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
7297 [[plugins]]
7298 name = "h"
7299 type = "http"
7300 url = %q
7301 `, projectServer.URL), 0o644); err != nil {
7302 t.Fatal(err)
7303 }
7304 approveWorkspace(t, dir)
7305
7306 host := plugin.NewHost()
7307 t.Cleanup(host.Close)
7308 registry := tool.NewRegistry()
7309 ctrl := control.New(control.Options{
7310 Host: host, Registry: registry, PluginCtx: context.Background(), WorkspaceRoot: dir,
7311 MCPConfigureSpec: func(spec *plugin.Spec) {
7312 // This test isolates effective-source selection from the project launch
7313 // approval flow, which has its own end-to-end coverage.
7314 spec.RequireLaunchApproval = false
7315 spec.Authorized = true
7316 },
7317 })
7318 app := NewApp()
7319 app.tabs = map[string]*WorkspaceTab{
7320 "active": {
7321 ID: "active", Scope: "global", WorkspaceRoot: dir, Ready: true,
7322 Ctrl: ctrl, disabledMCP: map[string]ServerView{},
7323 },
7324 }
7325 app.activeTabID = "active"
7326
7327 if err := app.ReconnectMCPServer("h"); err != nil {
7328 t.Fatalf("ReconnectMCPServer(h): %v", err)
7329 }
7330 if _, found := registry.Get("mcp__h__project_tool"); !found {
7331 t.Fatal("reconnect did not use the effective project MCP configuration")
7332 }
7333 if _, found := registry.Get("mcp__h__user_tool"); found {
7334 t.Fatal("reconnect used the shadowed user MCP configuration")
7335 }
7336 }
7337
7338 func TestUpdateMCPServerRefreshesEverySharedHostRegistry(t *testing.T) {
7339 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7340 oldSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7341 if !found {
7342 t.Fatal("sibling registry missing initial h tool")
7343 }
7344 root := fixture.app.tabs["active"].WorkspaceRoot
7345 cfg, err := config.LoadForRoot(root)
7346 if err != nil {
7347 t.Fatal(err)
7348 }
7349 entry, found := findPluginEntry(cfg.Plugins, "h")
7350 if !found {
7351 t.Fatal("fixture config missing h")
7352 }
7353 if err := fixture.app.UpdateMCPServer("h", MCPServerInput{
7354 Name: "h", Transport: entry.Type, Command: entry.Command, Args: entry.Args,
7355 }); err != nil {
7356 t.Fatalf("UpdateMCPServer(h): %v", err)
7357 }
7358 if !fixture.sharedHost.HasClient("h") {
7359 t.Fatal("shared host did not reconnect h")
7360 }
7361 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7362 t.Fatal("active registry was not refreshed")
7363 }
7364 newSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7365 if !found || newSiblingTool == oldSiblingTool {
7366 t.Fatal("sibling registry retained the tool backed by the disconnected client")
7367 }
7368 if _, found := fixture.disabledRegistry.Get("mcp__h__greet"); found {
7369 t.Fatal("update re-enabled a tab where the server was disabled")
7370 }
7371 }
7372
7373 func TestClearMCPServerAuthenticationClearsEverySharedHostRegistry(t *testing.T) {
7374 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7375 if err := fixture.app.ClearMCPServerAuthentication("h"); err != nil {
7376 t.Fatalf("ClearMCPServerAuthentication(h): %v", err)
7377 }
7378 if fixture.sharedHost.HasClient("h") {
7379 t.Fatal("shared host retained h after clearing authentication")
7380 }
7381 for label, registry := range map[string]*tool.Registry{
7382 "active": fixture.activeRegistry, "sibling": fixture.siblingRegistry, "disabled": fixture.disabledRegistry,
7383 } {
7384 if _, found := registry.Get("mcp__h__greet"); found {
7385 t.Fatalf("%s registry retained h after clearing authentication", label)
7386 }
7387 }
7388 }
7389
7390 func TestRemoveMCPServerClearsEverySharedHostRegistry(t *testing.T) {
7391 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7392 if err := fixture.app.RemoveMCPServer("h"); err != nil {
7393 t.Fatalf("RemoveMCPServer(h): %v", err)
7394 }
7395 if fixture.sharedHost.HasClient("h") {
7396 t.Fatal("shared host retained the removed server")
7397 }
7398 for label, registry := range map[string]*tool.Registry{
7399 "active": fixture.activeRegistry, "sibling": fixture.siblingRegistry, "disabled": fixture.disabledRegistry,
7400 } {
7401 if _, found := registry.Get("mcp__h__greet"); found {
7402 t.Fatalf("%s registry retained the removed server tool", label)
7403 }
7404 }
7405 for id, tab := range fixture.app.tabs {
7406 if _, disabled := tab.disabledMCP["h"]; disabled {
7407 t.Fatalf("tab %s retained removed-server disabled state", id)
7408 }
7409 }
7410 }
7411
7412 type gatedDesktopMCPLaunchFixture struct {
7413 app *App
7414 sharedHost *plugin.Host
7415 activeRegistry *tool.Registry
7416 siblingRegistry *tool.Registry
7417 disabledRegistry *tool.Registry
7418 }
7419
7420 func newGatedDesktopMCPLaunchFixture(t *testing.T, startGateAddr string) gatedDesktopMCPLaunchFixture {
7421 t.Helper()
7422 isolateDesktopUserDirs(t)
7423 dir := robustTempDir(t)
7424 t.Chdir(dir)
7425
7426 exe, err := os.Executable()
7427 if err != nil {
7428 t.Fatal(err)
7429 }
7430 listener, err := net.Listen("tcp", "127.0.0.1:0")
7431 if err != nil {
7432 t.Fatal(err)
7433 }
7434 singleInstanceAddr := listener.Addr().String()
7435 if err := listener.Close(); err != nil {
7436 t.Fatal(err)
7437 }
7438 gateConfig := ""
7439 if startGateAddr != "" {
7440 gateConfig = fmt.Sprintf("DESKTOP_MCP_START_GATE_ADDR = %q\n", startGateAddr)
7441 }
7442 helperArgs := []string{"-test.run=TestDesktopMCPHelperProcess", "--"}
7443 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
7444 [[plugins]]
7445 name = "h"
7446 command = %q
7447 args = ["-test.run=TestDesktopMCPHelperProcess", "--"]
7448
7449 [plugins.env]
7450 GO_WANT_DESKTOP_MCP_HELPER = "1"
7451 DESKTOP_MCP_SINGLE_INSTANCE_ADDR = %q
7452 %s
7453 [sandbox]
7454 network = true
7455 `, exe, singleInstanceAddr, gateConfig), 0o644); err != nil {
7456 t.Fatal(err)
7457 }
7458 approveWorkspace(t, dir)
7459
7460 entry := config.PluginEntry{
7461 Name: "h", Command: exe, Args: helperArgs,
7462 Env: map[string]string{
7463 "GO_WANT_DESKTOP_MCP_HELPER": "1",
7464 "DESKTOP_MCP_SINGLE_INSTANCE_ADDR": singleInstanceAddr,
7465 },
7466 }
7467 if startGateAddr != "" {
7468 entry.Env["DESKTOP_MCP_START_GATE_ADDR"] = startGateAddr
7469 }
7470 cfg, err := config.LoadForRoot(dir)
7471 if err != nil {
7472 t.Fatal(err)
7473 }
7474 runtimeSpecs := boot.PluginSpecsForRootWithOptions([]config.PluginEntry{entry}, dir, boot.PluginSpecOptions{
7475 DefaultCallTimeout: time.Duration(cfg.MCPCallTimeoutSeconds()) * time.Second,
7476 LaunchManager: mcplaunch.ForWorkspace(config.ReasonixHomeDir(), dir),
7477 ConfigSource: "workspace_config",
7478 StateHome: config.ReasonixHomeDir(),
7479 WriterRoots: cfg.WriteRootsForRoot(dir),
7480 ForbidReadRoots: cfg.ForbidReadRootsForRoot(dir),
7481 Network: cfg.Sandbox.Network,
7482 })
7483 if len(runtimeSpecs) != 1 {
7484 t.Fatalf("runtime specs = %d, want 1", len(runtimeSpecs))
7485 }
7486 runtimeSpec := runtimeSpecs[0]
7487 configure := func(spec *plugin.Spec) { *spec = runtimeSpec }
7488 lifeCtx, lifeCancel := context.WithCancel(context.Background())
7489 t.Cleanup(lifeCancel)
7490 callCtx, callCancel := context.WithTimeout(context.Background(), 10*time.Second)
7491 defer callCancel()
7492 sharedHost := plugin.NewHost()
7493 t.Cleanup(sharedHost.Close)
7494 tools, err := sharedHost.AddWithLifecycle(lifeCtx, callCtx, runtimeSpec)
7495 if err != nil {
7496 t.Fatalf("sharedHost.Add: %v", err)
7497 }
7498
7499 activeRegistry := tool.NewRegistry()
7500 siblingRegistry := tool.NewRegistry()
7501 disabledRegistry := tool.NewRegistry()
7502 for _, mt := range tools {
7503 activeRegistry.Add(mt)
7504 siblingRegistry.Add(mt)
7505 disabledRegistry.Add(mt)
7506 }
7507 activeCtrl := control.New(control.Options{
7508 Host: sharedHost, Registry: activeRegistry, PluginCtx: lifeCtx,
7509 MCPConfigureSpec: configure, WorkspaceRoot: dir,
7510 })
7511 siblingCtrl := control.New(control.Options{
7512 Host: sharedHost, Registry: siblingRegistry, PluginCtx: lifeCtx,
7513 MCPConfigureSpec: configure, WorkspaceRoot: dir,
7514 })
7515 disabledCtrl := control.New(control.Options{
7516 Host: sharedHost, Registry: disabledRegistry, PluginCtx: lifeCtx,
7517 MCPConfigureSpec: configure, WorkspaceRoot: dir,
7518 })
7519 disabledCtrl.UnregisterMCPServerTools("h")
7520 app := NewApp()
7521 app.tabs = map[string]*WorkspaceTab{
7522 "active": {
7523 ID: "active", Scope: "global", WorkspaceRoot: dir, Ready: true,
7524 Ctrl: activeCtrl, SharedHostKey: dir, disabledMCP: map[string]ServerView{},
7525 },
7526 "sibling": {
7527 ID: "sibling", Scope: "global", WorkspaceRoot: dir, Ready: true,
7528 Ctrl: siblingCtrl, SharedHostKey: dir, disabledMCP: map[string]ServerView{},
7529 },
7530 "disabled": {
7531 ID: "disabled", Scope: "global", WorkspaceRoot: dir, Ready: true,
7532 Ctrl: disabledCtrl, SharedHostKey: dir,
7533 disabledMCP: map[string]ServerView{"h": {Name: "h", Status: "disabled"}},
7534 },
7535 }
7536 app.activeTabID = "active"
7537 return gatedDesktopMCPLaunchFixture{
7538 app: app, sharedHost: sharedHost,
7539 activeRegistry: activeRegistry, siblingRegistry: siblingRegistry, disabledRegistry: disabledRegistry,
7540 }
7541 }
7542
7543 func newDesktopMCPStartGate(t *testing.T, handle func(attempt int, conn net.Conn)) (string, <-chan int) {
7544 t.Helper()
7545 listener, err := net.Listen("tcp", "127.0.0.1:0")
7546 if err != nil {
7547 t.Fatal(err)
7548 }
7549 t.Cleanup(func() { _ = listener.Close() })
7550 attempts := make(chan int, 8)
7551 go func() {
7552 for attempt := 1; ; attempt++ {
7553 conn, err := listener.Accept()
7554 if err != nil {
7555 return
7556 }
7557 attempts <- attempt
7558 handle(attempt, conn)
7559 _ = conn.Close()
7560 }
7561 }()
7562 return listener.Addr().String(), attempts
7563 }
7564
7565 func waitForDesktopMCPStartAttempt(t *testing.T, attempts <-chan int, want int) {
7566 t.Helper()
7567 deadline := time.NewTimer(5 * time.Second)
7568 defer deadline.Stop()
7569 for {
7570 select {
7571 case got := <-attempts:
7572 if got == want {
7573 return
7574 }
7575 case <-deadline.C:
7576 t.Fatalf("timed out waiting for MCP start attempt %d", want)
7577 }
7578 }
7579 }
7580
7581 func TestAuthorizeAndConnectMCPServerSerializesConcurrentDisable(t *testing.T) {
7582 releaseConnection := make(chan struct{})
7583 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
7584 if attempt == 2 {
7585 <-releaseConnection
7586 }
7587 _, _ = conn.Write([]byte{1})
7588 })
7589 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
7590
7591 disableEntered := make(chan struct{})
7592 var disableOnce sync.Once
7593 fixture.app.runtimeMutationBeforeLockHook = func(operation string) {
7594 if operation == "set-enabled" {
7595 disableOnce.Do(func() { close(disableEntered) })
7596 }
7597 }
7598 authorizeDone := make(chan error, 1)
7599 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
7600 waitForDesktopMCPStartAttempt(t, attempts, 2)
7601 disableDone := make(chan error, 1)
7602 go func() { disableDone <- fixture.app.SetMCPServerEnabled("h", false) }()
7603 select {
7604 case <-disableEntered:
7605 case <-time.After(5 * time.Second):
7606 t.Fatal("concurrent disable did not reach the MCP lifecycle lock")
7607 }
7608 select {
7609 case err := <-disableDone:
7610 t.Fatalf("concurrent disable bypassed authorization serialization: %v", err)
7611 case <-time.After(200 * time.Millisecond):
7612 }
7613 close(releaseConnection)
7614 if err := <-authorizeDone; err != nil {
7615 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
7616 }
7617 if err := <-disableDone; err != nil {
7618 t.Fatalf("SetMCPServerEnabled(h,false): %v", err)
7619 }
7620 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); found {
7621 t.Fatal("authorization reconnect overrode the later per-tab disable")
7622 }
7623 if _, disabled := fixture.app.tabs["active"].disabledMCP["h"]; !disabled {
7624 t.Fatal("active tab did not retain the later disable decision")
7625 }
7626 if _, found := fixture.siblingRegistry.Get("mcp__h__greet"); !found {
7627 t.Fatal("active-tab disable removed the shared MCP from its enabled sibling")
7628 }
7629 }
7630
7631 // RemovePlugin disconnects the uninstalled plugin's MCP servers, so it must
7632 // serialize on the MCP lifecycle lock: an unlocked disconnect interleaving
7633 // with authorization lets the reconnect relaunch the just-removed
7634 // server from its stale snapshot. The plugin does not need to exist — the
7635 // lock is taken before the uninstall runs, which is the contract under test.
7636 func TestRemovePluginSerializesWithMCPAuthorization(t *testing.T) {
7637 releaseConnection := make(chan struct{})
7638 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
7639 if attempt == 2 {
7640 <-releaseConnection
7641 }
7642 _, _ = conn.Write([]byte{1})
7643 })
7644 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
7645
7646 removeEntered := make(chan struct{})
7647 var removeOnce sync.Once
7648 fixture.app.runtimeMutationBeforeLockHook = func(operation string) {
7649 if operation == "remove-plugin" {
7650 removeOnce.Do(func() { close(removeEntered) })
7651 }
7652 }
7653 authorizeDone := make(chan error, 1)
7654 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
7655 waitForDesktopMCPStartAttempt(t, attempts, 2)
7656 removeDone := make(chan error, 1)
7657 go func() { removeDone <- fixture.app.RemovePlugin("not-an-installed-plugin") }()
7658 select {
7659 case <-removeEntered:
7660 case <-time.After(5 * time.Second):
7661 t.Fatal("RemovePlugin did not reach the MCP lifecycle lock")
7662 }
7663 select {
7664 case err := <-removeDone:
7665 t.Fatalf("RemovePlugin bypassed authorization serialization: %v", err)
7666 case <-time.After(200 * time.Millisecond):
7667 }
7668 close(releaseConnection)
7669 if err := <-authorizeDone; err != nil {
7670 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
7671 }
7672 // The uninstall itself is expected to fail (the plugin is not installed);
7673 // only the ordering matters. It must complete once the lock is free.
7674 select {
7675 case <-removeDone:
7676 case <-time.After(5 * time.Second):
7677 t.Fatal("RemovePlugin did not complete after the trust connection released the lock")
7678 }
7679 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7680 t.Fatal("authorization reconnect result was lost after the serialized RemovePlugin")
7681 }
7682 }
7683
7684 // installGatedTestPluginPackage registers an installed plugin package whose
7685 // manifest declares the gated fixture's MCP server, so RemovePlugin exercises
7686 // the real uninstall and MCP disconnect flow. Returns the plugin root.
7687 func installGatedTestPluginPackage(t *testing.T, mcpServerName string) string {
7688 t.Helper()
7689 reasonixHome := config.ReasonixHomeDir()
7690 root := filepath.Join(reasonixHome, "plugins", "review-helper")
7691 if err := os.MkdirAll(root, 0o755); err != nil {
7692 t.Fatal(err)
7693 }
7694 if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), fmt.Appendf(nil, `{"apiVersion": "reasonix.io/plugin/v2",
7695 "name": "review-helper",
7696 "version": "1.0.0",
7697 "mcpServers": {
7698 %q: { "type": "stdio", "command": "helper" }
7699 }
7700 }`, mcpServerName), 0o644); err != nil {
7701 t.Fatal(err)
7702 }
7703 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
7704 Name: "review-helper",
7705 Root: "plugins/review-helper",
7706 Version: "1.0.0",
7707 ManifestKind: "reasonix",
7708 Enabled: true,
7709 }); err != nil {
7710 t.Fatal(err)
7711 }
7712 return root
7713 }
7714
7715 func installedPluginNamed(t *testing.T, name string) bool {
7716 t.Helper()
7717 st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
7718 if err != nil {
7719 t.Fatal(err)
7720 }
7721 for _, p := range st.Plugins {
7722 if p.Name == name {
7723 return true
7724 }
7725 }
7726 return false
7727 }
7728
7729 // A global plugin uninstall must clean every runtime, not only the active tab:
7730 // sibling registries on the shared Host would otherwise keep provider-visible
7731 // tools backed by the closed client, and other workspaces would keep running
7732 // the uninstalled server.
7733 func TestRemovePluginDisconnectsEveryRuntime(t *testing.T) {
7734 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7735 pluginRoot := installGatedTestPluginPackage(t, "h")
7736
7737 if err := fixture.app.RemovePlugin("review-helper"); err != nil {
7738 t.Fatalf("RemovePlugin(review-helper): %v", err)
7739 }
7740 if fixture.sharedHost.HasClient("h") {
7741 t.Fatal("uninstall left the shared MCP client connected")
7742 }
7743 for name, reg := range map[string]*tool.Registry{
7744 "active": fixture.activeRegistry,
7745 "sibling": fixture.siblingRegistry,
7746 "disabled": fixture.disabledRegistry,
7747 } {
7748 if _, found := reg.Get("mcp__h__greet"); found {
7749 t.Fatalf("%s registry still exposes the uninstalled MCP tool", name)
7750 }
7751 }
7752 if _, err := os.Stat(pluginRoot); !os.IsNotExist(err) {
7753 t.Fatalf("plugin root still present after uninstall (err=%v)", err)
7754 }
7755 if installedPluginNamed(t, "review-helper") {
7756 t.Fatal("plugin state still lists the uninstalled plugin")
7757 }
7758 }
7759
7760 // The pre-lock active-work check can go stale during the lifecycle-lock wait.
7761 // Work that starts mid-wait must fail the removal before anything is deleted;
7762 // the old order deleted the plugin first and only then reported the failure.
7763 func TestRemovePluginRechecksActiveWorkUnderLock(t *testing.T) {
7764 releaseConnection := make(chan struct{})
7765 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
7766 if attempt == 2 {
7767 <-releaseConnection
7768 }
7769 _, _ = conn.Write([]byte{1})
7770 })
7771 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
7772 installGatedTestPluginPackage(t, "h")
7773
7774 removeEntered := make(chan struct{})
7775 var removeOnce sync.Once
7776 fixture.app.runtimeMutationBeforeLockHook = func(operation string) {
7777 if operation == "remove-plugin" {
7778 removeOnce.Do(func() { close(removeEntered) })
7779 }
7780 }
7781 authorizeDone := make(chan error, 1)
7782 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
7783 waitForDesktopMCPStartAttempt(t, attempts, 2)
7784 removeDone := make(chan error, 1)
7785 go func() { removeDone <- fixture.app.RemovePlugin("review-helper") }()
7786 select {
7787 case <-removeEntered:
7788 case <-time.After(5 * time.Second):
7789 t.Fatal("RemovePlugin did not reach the MCP lifecycle lock")
7790 }
7791 // While RemovePlugin waits for the lock, background work starts on the
7792 // active tab — exactly the window the pre-lock check cannot see.
7793 busy := newBackgroundJobController(t, "remove-plugin-active-work")
7794 fixture.app.mu.Lock()
7795 fixture.app.tabs["active"].Ctrl = busy
7796 fixture.app.mu.Unlock()
7797 close(releaseConnection)
7798 if err := <-authorizeDone; err != nil {
7799 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
7800 }
7801 err := <-removeDone
7802 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
7803 t.Fatalf("RemovePlugin during background work error = %v, want active-work guard", err)
7804 }
7805 if !installedPluginNamed(t, "review-helper") {
7806 t.Fatal("active-work guard fired only after the plugin was already uninstalled")
7807 }
7808 }
7809
7810 // A global uninstall disconnects every runtime, so the busy guard must cover
7811 // every runtime too: a background job on a sibling tab must fail the removal
7812 // before anything is deleted, not silently lose its plugin MCP mid-run.
7813 func TestRemovePluginRejectsBusySiblingRuntime(t *testing.T) {
7814 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7815 installGatedTestPluginPackage(t, "h")
7816 busy := newBackgroundJobController(t, "sibling-busy")
7817 fixture.app.mu.Lock()
7818 fixture.app.tabs["sibling"].Ctrl = busy
7819 fixture.app.mu.Unlock()
7820
7821 err := fixture.app.RemovePlugin("review-helper")
7822 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
7823 t.Fatalf("RemovePlugin with busy sibling error = %v, want active-work guard", err)
7824 }
7825 if !installedPluginNamed(t, "review-helper") {
7826 t.Fatal("plugin was uninstalled despite a busy sibling runtime")
7827 }
7828 if !fixture.sharedHost.HasClient("h") {
7829 t.Fatal("busy-sibling guard still disconnected the shared MCP client")
7830 }
7831 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7832 t.Fatal("busy-sibling guard still removed active registry tools")
7833 }
7834 }
7835
7836 // Detached runtimes keep running after their tab is closed; the uninstall busy
7837 // guard must see them through the same gate sweep as visible tabs.
7838 func TestRemovePluginRejectsBusyDetachedRuntime(t *testing.T) {
7839 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7840 installGatedTestPluginPackage(t, "h")
7841 busy := newBackgroundJobController(t, "detached-busy")
7842 fixture.app.mu.Lock()
7843 fixture.app.detachedSessions = map[string]*WorkspaceTab{
7844 "detached": {
7845 ID: "detached", Scope: "global", Ready: true,
7846 Ctrl: busy, disabledMCP: map[string]ServerView{},
7847 },
7848 }
7849 fixture.app.mu.Unlock()
7850
7851 err := fixture.app.RemovePlugin("review-helper")
7852 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
7853 t.Fatalf("RemovePlugin with busy detached runtime error = %v, want active-work guard", err)
7854 }
7855 if !installedPluginNamed(t, "review-helper") {
7856 t.Fatal("plugin was uninstalled despite a busy detached runtime")
7857 }
7858 }
7859
7860 // A turn start holds its tab's turn gate before the controller reports active
7861 // work, so an idle check done without the gate can go stale immediately. The
7862 // authorization must wait on the sibling's gate — never disconnect first — and must
7863 // fail once the gated re-check sees the started work.
7864 func TestAuthorizeAndConnectMCPServerWaitsForSiblingTurnGate(t *testing.T) {
7865 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
7866 _, _ = conn.Write([]byte{1})
7867 })
7868 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
7869 waitForDesktopMCPStartAttempt(t, attempts, 1) // drain the fixture's initial connect
7870 sibling := fixture.app.tabs["sibling"]
7871
7872 // Simulate the racing turn: it takes the gate first, and only transitions
7873 // its controller to busy while holding it.
7874 sibling.turnStartMu.Lock()
7875 authorizeDone := make(chan error, 1)
7876 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
7877 select {
7878 case got := <-attempts:
7879 t.Fatalf("trust connection launched (attempt %d) while a sibling turn gate was held", got)
7880 case err := <-authorizeDone:
7881 t.Fatalf("AuthorizeAndConnectMCPServer returned %v without waiting for the sibling turn gate", err)
7882 case <-time.After(700 * time.Millisecond):
7883 }
7884 if !fixture.sharedHost.HasClient("h") {
7885 t.Fatal("authorization disconnected the shared client while a sibling turn gate was held")
7886 }
7887 busy := newBackgroundJobController(t, "sibling-turn")
7888 fixture.app.mu.Lock()
7889 sibling.Ctrl = busy
7890 fixture.app.mu.Unlock()
7891 sibling.turnStartMu.Unlock()
7892
7893 err := <-authorizeDone
7894 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
7895 t.Fatalf("AuthorizeAndConnectMCPServer after sibling turn start error = %v, want active-work guard", err)
7896 }
7897 if !fixture.sharedHost.HasClient("h") {
7898 t.Fatal("failed authorization left the shared client disconnected")
7899 }
7900 if _, found := fixture.siblingRegistry.Get("mcp__h__greet"); !found {
7901 t.Fatal("authorization stripped the busy sibling of its MCP tools")
7902 }
7903 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7904 t.Fatal("authorization stripped the active tab of its MCP tools")
7905 }
7906 }
7907
7908 // waitForRuntimeAdmissionBarrier polls until the work-admission write lock is
7909 // held, marking the point where a lifecycle mutation froze new admissions.
7910 func waitForRuntimeAdmissionBarrier(t *testing.T, app *App) {
7911 t.Helper()
7912 deadline := time.Now().Add(5 * time.Second)
7913 for time.Now().Before(deadline) {
7914 if app.runtimeAdmissionMu.TryRLock() {
7915 app.runtimeAdmissionMu.RUnlock()
7916 time.Sleep(2 * time.Millisecond)
7917 continue
7918 }
7919 return
7920 }
7921 t.Fatal("lifecycle mutation never acquired the work-admission barrier")
7922 }
7923
7924 func TestBridgeDriveReleasesRuntimeAdmissionWhenTakeoverWasReclaimed(t *testing.T) {
7925 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7926 fixture.app.tabs["active"].sink = &tabEventSink{tabID: "active", app: fixture.app}
7927 fixture.app.botBridge = &botBridgeHub{
7928 takeovers: make(map[string]bot.DesktopWatchRoute),
7929 takeoverTabs: make(map[string]string),
7930 }
7931
7932 err := fixture.app.bridgeDrive("active", "hello", bot.DesktopWatchRoute{})
7933 if err == nil || !strings.Contains(err.Error(), "接管已解除") {
7934 t.Fatalf("bridgeDrive error = %v, want lost-takeover error", err)
7935 }
7936 if !fixture.app.runtimeAdmissionMu.TryLock() {
7937 t.Fatal("bridgeDrive leaked the runtime-admission read lock")
7938 }
7939 fixture.app.runtimeAdmissionMu.Unlock()
7940 }
7941
7942 func TestBeginTabTurnWorkspaceRepairStaysOutsideLifecycleAdmission(t *testing.T) {
7943 fixture := newStaleWorkspaceBindingFixture(t, "admission_writer")
7944 fixture.tab.reconcileMu.Lock()
7945
7946 turnDone := make(chan error, 1)
7947 go func() {
7948 admission, _, err := fixture.app.beginTabTurn(fixture.tab.ID, false)
7949 if admission != nil {
7950 admission.abort()
7951 }
7952 turnDone <- err
7953 }()
7954 writerRebuildLocked := make(chan struct{})
7955 writerAdmissionLocked := make(chan struct{})
7956 writerDone := make(chan struct{})
7957 go func() {
7958 fixture.app.runtimeRebuildMu.Lock()
7959 close(writerRebuildLocked)
7960 fixture.app.runtimeAdmissionMu.Lock()
7961 close(writerAdmissionLocked)
7962 fixture.app.runtimeAdmissionMu.Unlock()
7963 fixture.app.runtimeRebuildMu.Unlock()
7964 close(writerDone)
7965 }()
7966 <-writerRebuildLocked
7967 select {
7968 case <-writerAdmissionLocked:
7969 // The repair is still blocked on reconcileMu; acquiring the lifecycle
7970 // writer here proves no slow repair/build I/O owns the read side.
7971 case <-t.Context().Done():
7972 fixture.tab.reconcileMu.Unlock()
7973 t.Fatal("workspace repair held runtimeAdmissionMu while waiting")
7974 }
7975 fixture.tab.reconcileMu.Unlock()
7976
7977 select {
7978 case err := <-turnDone:
7979 if err != nil {
7980 t.Fatalf("beginTabTurn after workspace repair: %v", err)
7981 }
7982 case <-t.Context().Done():
7983 t.Fatal("workspace repair did not complete after lifecycle writer released")
7984 }
7985 select {
7986 case <-writerDone:
7987 case <-t.Context().Done():
7988 t.Fatal("lifecycle writer did not complete after repaired turn admission")
7989 }
7990 }
7991
7992 func TestAuthorizeAndConnectMCPServerSerializesCloseOfCapturedRuntime(t *testing.T) {
7993 releaseConnection := make(chan struct{})
7994 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
7995 if attempt == 2 {
7996 <-releaseConnection
7997 }
7998 _, _ = conn.Write([]byte{1})
7999 })
8000 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
8001 waitForDesktopMCPStartAttempt(t, attempts, 1)
8002
8003 dir := fixture.app.tabs["active"].WorkspaceRoot
8004 otherRoot := robustTempDir(t)
8005 otherHost := plugin.NewHost()
8006 t.Cleanup(otherHost.Close)
8007 otherCtrl := control.New(control.Options{Host: otherHost, WorkspaceRoot: otherRoot})
8008 t.Cleanup(otherCtrl.Close)
8009 fixture.app.tabs = map[string]*WorkspaceTab{
8010 "active": fixture.app.tabs["active"],
8011 "other": {
8012 ID: "other", Scope: "project", WorkspaceRoot: otherRoot, Ready: true,
8013 Ctrl: otherCtrl, disabledMCP: map[string]ServerView{},
8014 },
8015 }
8016 fixture.app.tabOrder = []string{"active", "other"}
8017 fixture.app.activeTabID = "active"
8018 fixture.app.sharedHosts = map[string]*sharedPluginHost{
8019 dir: {host: fixture.sharedHost, refs: 1},
8020 }
8021
8022 authorizeDone := make(chan error, 1)
8023 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8024 waitForDesktopMCPStartAttempt(t, attempts, 2)
8025 closeDone := make(chan error, 1)
8026 go func() { closeDone <- fixture.app.CloseTab("active") }()
8027 select {
8028 case err := <-closeDone:
8029 t.Fatalf("CloseTab bypassed the MCP lifecycle barrier: %v", err)
8030 case <-time.After(300 * time.Millisecond):
8031 }
8032
8033 close(releaseConnection)
8034 if err := <-authorizeDone; err != nil {
8035 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
8036 }
8037 if err := <-closeDone; err != nil {
8038 t.Fatalf("CloseTab(active) after trust: %v", err)
8039 }
8040 }
8041
8042 func TestCloseTabWaitsForPendingTurnAdmission(t *testing.T) {
8043 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8044 admission, _, err := fixture.app.beginTabTurn("active", false)
8045 if err != nil {
8046 t.Fatalf("beginTabTurn(active): %v", err)
8047 }
8048 released := false
8049 defer func() {
8050 if !released {
8051 admission.abort()
8052 }
8053 }()
8054
8055 closeDone := make(chan error, 1)
8056 go func() { closeDone <- fixture.app.CloseTab("active") }()
8057 select {
8058 case err := <-closeDone:
8059 admission.abort()
8060 released = true
8061 t.Fatalf("CloseTab bypassed a pending turn admission and closed its controller: %v", err)
8062 case <-time.After(300 * time.Millisecond):
8063 }
8064
8065 admission.abort()
8066 released = true
8067 select {
8068 case err := <-closeDone:
8069 if err != nil {
8070 t.Fatalf("CloseTab(active) after turn admission release: %v", err)
8071 }
8072 case <-time.After(5 * time.Second):
8073 t.Fatal("CloseTab did not resume after the pending turn admission was released")
8074 }
8075 }
8076
8077 func TestCloseTabRemainsVisibleToPendingMCPHostGateSnapshot(t *testing.T) {
8078 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8079 active := fixture.app.tabs["active"]
8080 active.turnStartMu.Lock()
8081
8082 authorizeDone := make(chan error, 1)
8083 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8084 waitForRuntimeAdmissionBarrier(t, fixture.app)
8085
8086 closeDone := make(chan error, 1)
8087 go func() { closeDone <- fixture.app.CloseTab("active") }()
8088 time.Sleep(300 * time.Millisecond)
8089 fixture.app.mu.RLock()
8090 stillVisible := fixture.app.tabs["active"] == active
8091 fixture.app.mu.RUnlock()
8092 if !stillVisible {
8093 active.turnStartMu.Unlock()
8094 t.Fatal("CloseTab unlinked the runtime before the pending MCP Host gate snapshot")
8095 }
8096 select {
8097 case err := <-closeDone:
8098 active.turnStartMu.Unlock()
8099 t.Fatalf("CloseTab bypassed the lifecycle barrier: %v", err)
8100 default:
8101 }
8102
8103 active.turnStartMu.Unlock()
8104 if err := <-authorizeDone; err != nil {
8105 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
8106 }
8107 if err := <-closeDone; err != nil {
8108 t.Fatalf("CloseTab(active): %v", err)
8109 }
8110 }
8111
8112 func TestAuthorizeAndConnectMCPServerKeepsInvokingWorkspaceWhenActiveTabChanges(t *testing.T) {
8113 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8114 otherRoot := robustTempDir(t)
8115 otherCtrl := control.New(control.Options{Host: plugin.NewHost(), WorkspaceRoot: otherRoot})
8116 t.Cleanup(otherCtrl.Close)
8117 fixture.app.tabs["other"] = &WorkspaceTab{
8118 ID: "other", Scope: "project", WorkspaceRoot: otherRoot, Ready: true,
8119 Ctrl: otherCtrl, disabledMCP: map[string]ServerView{},
8120 }
8121 fixture.app.tabOrder = []string{"active", "sibling", "disabled", "other"}
8122
8123 sibling := fixture.app.tabs["sibling"]
8124 sibling.turnStartMu.Lock()
8125 authorizeDone := make(chan error, 1)
8126 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8127 waitForRuntimeAdmissionBarrier(t, fixture.app)
8128 if err := fixture.app.SetActiveTab("other"); err != nil {
8129 sibling.turnStartMu.Unlock()
8130 t.Fatalf("SetActiveTab(other): %v", err)
8131 }
8132 sibling.turnStartMu.Unlock()
8133
8134 if err := <-authorizeDone; err != nil {
8135 t.Fatalf("authorization operation drifted from its invoking workspace: %v", err)
8136 }
8137 }
8138
8139 // Work admission — not tab-set stability — is the gate invariant: a runtime
8140 // added after the gate snapshot must not be able to start a turn while the
8141 // uninstall holds the barrier. The late turn goes through the real
8142 // beginTabTurn admission path and must only be admitted after the uninstall.
8143 func TestRemovePluginBlocksLateTurnAdmission(t *testing.T) {
8144 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8145 installGatedTestPluginPackage(t, "h")
8146 dir := fixture.app.tabs["active"].WorkspaceRoot
8147
8148 // Hold an existing tab's gate so the uninstall blocks mid-acquisition
8149 // with the admission barrier already held.
8150 sibling := fixture.app.tabs["sibling"]
8151 sibling.turnStartMu.Lock()
8152 removeDone := make(chan error, 1)
8153 go func() { removeDone <- fixture.app.RemovePlugin("review-helper") }()
8154 waitForRuntimeAdmissionBarrier(t, fixture.app)
8155
8156 lateCtrl := control.New(control.Options{Host: plugin.NewHost(), WorkspaceRoot: dir})
8157 t.Cleanup(lateCtrl.Close)
8158 fixture.app.mu.Lock()
8159 fixture.app.tabs["late"] = &WorkspaceTab{
8160 ID: "late", Scope: "global", WorkspaceRoot: dir, Ready: true,
8161 Ctrl: lateCtrl, disabledMCP: map[string]ServerView{},
8162 }
8163 fixture.app.mu.Unlock()
8164 type admission struct {
8165 turn *tabTurnAdmission
8166 ctrl control.SessionAPI
8167 err error
8168 }
8169 admitted := make(chan admission, 1)
8170 go func() {
8171 turn, ctrl, err := fixture.app.beginTabTurn("late", false)
8172 admitted <- admission{turn: turn, ctrl: ctrl, err: err}
8173 }()
8174 select {
8175 case got := <-admitted:
8176 t.Fatalf("late turn was admitted (err=%v) while the uninstall held the admission barrier", got.err)
8177 case <-time.After(300 * time.Millisecond):
8178 }
8179
8180 sibling.turnStartMu.Unlock()
8181 if err := <-removeDone; err != nil {
8182 t.Fatalf("RemovePlugin(review-helper): %v", err)
8183 }
8184 got := <-admitted
8185 if got.err != nil {
8186 t.Fatalf("late turn admission after uninstall: %v", got.err)
8187 }
8188 got.turn.abort()
8189 if installedPluginNamed(t, "review-helper") {
8190 t.Fatal("uninstall did not complete before the late turn was admitted")
8191 }
8192 }
8193
8194 // A tab created during the 30s trust connection must not complete its async
8195 // controller build — attaching to the shared Host mid-connection can relaunch a
8196 // single-instance server or leave a registry the authorization never saw. The build
8197 // goes through the real startTabControllerBuild path and must only run after
8198 // the authorization releases the barrier.
8199 func TestAuthorizeAndConnectMCPServerBlocksLateControllerBuild(t *testing.T) {
8200 releaseConnection := make(chan struct{})
8201 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
8202 if attempt == 2 {
8203 <-releaseConnection
8204 }
8205 _, _ = conn.Write([]byte{1})
8206 })
8207 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
8208 waitForDesktopMCPStartAttempt(t, attempts, 1) // drain the fixture's initial connect
8209 dir := fixture.app.tabs["active"].WorkspaceRoot
8210
8211 authorizeDone := make(chan error, 1)
8212 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8213 waitForDesktopMCPStartAttempt(t, attempts, 2) // connection launched: gates held
8214
8215 late := &WorkspaceTab{
8216 ID: "late", Scope: "global", WorkspaceRoot: dir,
8217 disabledMCP: map[string]ServerView{},
8218 }
8219 late.sink = &tabEventSink{tabID: "late", app: fixture.app}
8220 fixture.app.mu.Lock()
8221 fixture.app.tabs["late"] = late
8222 fixture.app.mu.Unlock()
8223 buildDone := make(chan struct{})
8224 go func() {
8225 // a.ctx is nil in this fixture, so the build runs synchronously on
8226 // this goroutine — through the real buildTabControllerWithContext.
8227 fixture.app.startTabControllerBuild(late)
8228 close(buildDone)
8229 }()
8230 select {
8231 case <-buildDone:
8232 t.Fatal("late controller build completed while the trust connection held the admission barrier")
8233 case <-time.After(400 * time.Millisecond):
8234 }
8235
8236 close(releaseConnection)
8237 if err := <-authorizeDone; err != nil {
8238 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
8239 }
8240 select {
8241 case <-buildDone:
8242 case <-time.After(10 * time.Second):
8243 t.Fatal("late controller build never ran after the authorization released the barrier")
8244 }
8245 if !fixture.sharedHost.HasClient("h") {
8246 t.Fatal("authorization did not leave the shared client reconnected")
8247 }
8248 }
8249
8250 func TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {
8251 isolateDesktopUserDirs(t)
8252
8253 app := NewApp()
8254 app.setTestCtrl(newBackgroundJobController(t, "mcp-enabled-job"), "")
8255
8256 err := app.SetMCPServerEnabled("time", false)
8257 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
8258 t.Fatalf("SetMCPServerEnabled with background job error = %v, want active-work guard", err)
8259 }
8260 if tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {
8261 t.Fatalf("disabled MCP state changed after rejected toggle: %+v", tab)
8262 }
8263 }
8264
8265 func TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {
8266 isolateDesktopUserDirs(t)
8267 dir := robustTempDir(t)
8268 t.Chdir(dir)
8269 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
8270 [[plugins]]
8271 name = "time"
8272 command = "custom-time"
8273 args = ["serve"]
8274 `), 0o644); err != nil {
8275 t.Fatal(err)
8276 }
8277 approveWorkspace(t, dir)
8278
8279 app := NewApp()
8280 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8281 defer app.activeCtrl().Close()
8282 app.activeTab().disabledMCP["time"] = ServerView{Name: "time", Status: "disabled", Enabled: false}
8283
8284 if err := app.UpdateMCPServer("time", MCPServerInput{
8285 Name: "time",
8286 Transport: "stdio",
8287 Command: "updated-time",
8288 Args: []string{"run"},
8289 }); err != nil {
8290 t.Fatalf("UpdateMCPServer(time): %v", err)
8291 }
8292 cfg, err := config.LoadForRoot(dir)
8293 if err != nil {
8294 t.Fatal(err)
8295 }
8296 updated, ok := findPluginEntry(cfg.Plugins, "time")
8297 if !ok || updated.Command != "updated-time" || !reflect.DeepEqual(updated.Args, []string{"run"}) {
8298 t.Fatalf("updated time plugin = %+v, found=%v", updated, ok)
8299 }
8300
8301 if err := app.RemoveMCPServer("time"); err != nil {
8302 t.Fatalf("RemoveMCPServer(time): %v", err)
8303 }
8304 cfg, err = config.LoadForRoot(dir)
8305 if err != nil {
8306 t.Fatal(err)
8307 }
8308 if _, ok := findPluginEntry(cfg.Plugins, "time"); ok {
8309 t.Fatalf("time plugin still configured after remove: %+v", cfg.Plugins)
8310 }
8311 }
8312
8313 func TestRemoveProjectMCPRevealsAndRegistersGlobalFallback(t *testing.T) {
8314 isolateDesktopUserDirs(t)
8315 dir := robustTempDir(t)
8316 t.Chdir(dir)
8317 userCfg := config.LoadForEdit(config.UserConfigPath())
8318 userCfg.Plugins = []config.PluginEntry{{Name: "docs", Command: "global-docs"}}
8319 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
8320 t.Fatal(err)
8321 }
8322 projectPath := filepath.Join(dir, "reasonix.toml")
8323 if err := os.WriteFile(projectPath, []byte(`
8324 [[plugins]]
8325 name = "docs"
8326 command = "project-docs"
8327 `), 0o644); err != nil {
8328 t.Fatal(err)
8329 }
8330
8331 reg := tool.NewRegistry()
8332 var configured []plugin.Spec
8333 ctrl := control.New(control.Options{
8334 Host: plugin.NewHost(),
8335 Registry: reg,
8336 WorkspaceRoot: dir,
8337 MCPConfigureSpec: func(spec *plugin.Spec) {
8338 configured = append(configured, *spec)
8339 },
8340 })
8341 defer ctrl.Close()
8342 projectEntry, found, err := desktopEffectiveMCPServer(dir, "docs")
8343 if err != nil || !found {
8344 t.Fatalf("load project docs: entry=%+v found=%v err=%v", projectEntry, found, err)
8345 }
8346 if _, err := ctrl.RegisterMCPServerOnDemand(projectEntry); err != nil {
8347 t.Fatalf("register project docs: %v", err)
8348 }
8349
8350 app := NewApp()
8351 app.setTestCtrl(ctrl, "")
8352 app.activeTab().WorkspaceRoot = dir
8353 if err := app.RemoveMCPServer("docs"); err != nil {
8354 t.Fatalf("RemoveMCPServer(docs): %v", err)
8355 }
8356
8357 projectCfg := config.LoadForEdit(projectPath)
8358 if _, found := findPluginEntry(projectCfg.Plugins, "docs"); found {
8359 t.Fatalf("project docs still configured after removal: %+v", projectCfg.Plugins)
8360 }
8361 globalCfg := config.LoadForEdit(config.UserConfigPath())
8362 globalEntry, found := findPluginEntry(globalCfg.Plugins, "docs")
8363 if !found || globalEntry.Command != "global-docs" {
8364 t.Fatalf("global docs fallback = %+v, found=%v", globalEntry, found)
8365 }
8366 effective, found, err := desktopEffectiveMCPServer(dir, "docs")
8367 if err != nil || !found || effective.Source != config.MCPSourceUserConfig || effective.Command != "global-docs" {
8368 t.Fatalf("effective docs fallback = %+v, found=%v err=%v", effective, found, err)
8369 }
8370 if len(configured) < 2 || configured[len(configured)-1].Command != "global-docs" {
8371 t.Fatalf("registered specs = %+v, want global fallback registered last", configured)
8372 }
8373 if _, found := reg.Get("mcp__docs__connect"); !found {
8374 t.Fatalf("global fallback connect surface missing; names=%v", reg.Names())
8375 }
8376 }
8377
8378 func TestRemoveMCPServerClearsRecordedStartupFailure(t *testing.T) {
8379 isolateDesktopUserDirs(t)
8380 dir := robustTempDir(t)
8381 t.Chdir(dir)
8382 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
8383 [[plugins]]
8384 name = "broken"
8385 command = "reasonix-missing-mcp-binary"
8386 `), 0o644); err != nil {
8387 t.Fatal(err)
8388 }
8389 approveWorkspace(t, dir)
8390 enableProjectMCPForWorkspace(t, dir, "")
8391
8392 app := NewApp()
8393 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8394 defer app.activeCtrl().Close()
8395 recordMCPFailure(app.activeCtrl(), config.PluginEntry{
8396 Name: "broken",
8397 Command: "reasonix-missing-mcp-binary",
8398 }, errors.New("connect: missing binary"))
8399
8400 view := app.Capabilities()
8401 if len(view.Servers) != 1 || view.Servers[0].Name != "broken" || view.Servers[0].Status != "failed" {
8402 t.Fatalf("Capabilities before remove = %+v, want broken failed", view.Servers)
8403 }
8404
8405 if err := app.RemoveMCPServer("broken"); err != nil {
8406 t.Fatalf("RemoveMCPServer(broken): %v", err)
8407 }
8408 if mcpFailed(app.activeCtrl(), "broken") {
8409 t.Fatalf("Host.Failures() still contains broken after remove: %+v", app.activeCtrl().Host().Failures())
8410 }
8411 view = app.Capabilities()
8412 for _, s := range view.Servers {
8413 if s.Name == "broken" {
8414 t.Fatalf("Capabilities after remove still contains broken: %+v", view.Servers)
8415 }
8416 }
8417 }
8418
8419 func TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {
8420 isolateDesktopUserDirs(t)
8421 dir := robustTempDir(t)
8422 t.Chdir(dir)
8423 if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{
8424 "mcpServers": {
8425 "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] },
8426 "keep": { "command": "keep-mcp" }
8427 }
8428 }`), 0o644); err != nil {
8429 t.Fatal(err)
8430 }
8431
8432 app := NewApp()
8433 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8434 defer app.activeCtrl().Close()
8435
8436 if err := app.RemoveMCPServer("codegraph"); err != nil {
8437 t.Fatalf("RemoveMCPServer(.mcp.json codegraph): %v", err)
8438 }
8439 cfg, err := config.LoadForRoot(dir)
8440 if err != nil {
8441 t.Fatal(err)
8442 }
8443 if _, ok := findPluginEntry(cfg.Plugins, "codegraph"); ok {
8444 t.Fatalf("codegraph still merged after remove: %+v", cfg.Plugins)
8445 }
8446 if _, ok := findPluginEntry(cfg.Plugins, "keep"); !ok {
8447 t.Fatalf("unrelated .mcp.json server should be preserved: %+v", cfg.Plugins)
8448 }
8449 }
8450
8451 func TestRemoveMCPServerRejectsPluginManagedServerWithoutDisconnecting(t *testing.T) {
8452 isolateDesktopUserDirs(t)
8453 dir := robustTempDir(t)
8454 t.Chdir(dir)
8455
8456 srv := desktopMCPHTTPServer(t)
8457 defer srv.Close()
8458 reasonixHome := config.ReasonixHomeDir()
8459 root := filepath.Join(reasonixHome, "plugins", "superpowers")
8460 if err := os.MkdirAll(root, 0o755); err != nil {
8461 t.Fatal(err)
8462 }
8463 if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), fmt.Appendf(nil, `{"apiVersion": "reasonix.io/plugin/v2",
8464 "name": "superpowers",
8465 "version": "1.0.0",
8466 "mcpServers": {
8467 "helper": { "type": "http", "url": %q }
8468 }
8469 }`, srv.URL), 0o644); err != nil {
8470 t.Fatal(err)
8471 }
8472 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
8473 Name: "superpowers",
8474 Root: "plugins/superpowers",
8475 Version: "1.0.0",
8476 ManifestKind: "reasonix",
8477 Enabled: true,
8478 }); err != nil {
8479 t.Fatal(err)
8480 }
8481
8482 cfg, err := config.LoadForRoot(dir)
8483 if err != nil {
8484 t.Fatal(err)
8485 }
8486 entry, ok := findPluginEntry(cfg.Plugins, "helper")
8487 if !ok {
8488 t.Fatalf("plugin-managed MCP missing from config: %+v", cfg.Plugins)
8489 }
8490 ctrl := control.New(control.Options{Host: plugin.NewHost()})
8491 defer ctrl.Close()
8492 if _, err := ctrl.ConnectMCPServer(entry); err != nil {
8493 t.Fatalf("connect plugin-managed MCP: %v", err)
8494 }
8495
8496 app := NewApp()
8497 app.setTestCtrl(ctrl, "")
8498 app.activeTab().WorkspaceRoot = dir
8499 err = app.RemoveMCPServer("helper")
8500 if err == nil || !strings.Contains(err.Error(), "managed by plugin") || !strings.Contains(err.Error(), "superpowers") {
8501 t.Fatalf("RemoveMCPServer(plugin-managed) error = %v", err)
8502 }
8503 if !mcpConnected(ctrl, "helper") {
8504 t.Fatal("plugin-managed MCP was disconnected despite rejected removal")
8505 }
8506 for action, actionErr := range map[string]error{
8507 "clear auth": app.ClearMCPServerAuthentication("helper"),
8508 "update": app.UpdateMCPServer("helper", MCPServerInput{Name: "helper", Transport: "http", URL: srv.URL}),
8509 } {
8510 if actionErr == nil || !strings.Contains(actionErr.Error(), "managed by plugin") {
8511 t.Fatalf("%s plugin-managed MCP error = %v", action, actionErr)
8512 }
8513 }
8514 if _, found := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, "helper"); found {
8515 t.Fatal("plugin-managed MCP mutation created a user-config shadow")
8516 }
8517 servers := app.MCPServers()
8518 if len(servers) != 1 || servers[0].Name != "helper" || servers[0].ManagedByPlugin != "superpowers" {
8519 t.Fatalf("MCPServers() = %+v, want helper managed by superpowers", servers)
8520 }
8521 }
8522
8523 func TestRemoveMCPServerRejectsRuntimeOnlyServerWithoutDisconnecting(t *testing.T) {
8524 isolateDesktopUserDirs(t)
8525 dir := robustTempDir(t)
8526 t.Chdir(dir)
8527
8528 srv := desktopMCPHTTPServer(t)
8529 defer srv.Close()
8530 ctrl := control.New(control.Options{Host: plugin.NewHost()})
8531 defer ctrl.Close()
8532 if _, err := ctrl.ConnectMCPServer(config.PluginEntry{Name: "runtime-only", Type: "http", URL: srv.URL}); err != nil {
8533 t.Fatalf("connect runtime-only MCP: %v", err)
8534 }
8535
8536 app := NewApp()
8537 app.setTestCtrl(ctrl, "")
8538 app.activeTab().WorkspaceRoot = dir
8539 err := app.RemoveMCPServer("runtime-only")
8540 if err == nil || !strings.Contains(err.Error(), "no removable MCP server") {
8541 t.Fatalf("RemoveMCPServer(runtime-only) error = %v", err)
8542 }
8543 if !mcpConnected(ctrl, "runtime-only") {
8544 t.Fatal("runtime-only MCP was disconnected despite failed persistence removal")
8545 }
8546 }
8547
8548 func TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {
8549 isolateDesktopUserDirs(t)
8550 dir := robustTempDir(t)
8551 t.Chdir(dir)
8552 if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{
8553 "mcpServers": {
8554 "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] }
8555 }
8556 }`), 0o644); err != nil {
8557 t.Fatal(err)
8558 }
8559
8560 app := NewApp()
8561 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8562 defer app.activeCtrl().Close()
8563 entry, ok, err := desktopEffectiveMCPServer(dir, "codegraph")
8564 if err != nil || !ok {
8565 t.Fatalf("load codegraph entry: found=%v err=%v", ok, err)
8566 }
8567 if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
8568 t.Fatal(err)
8569 }
8570 app.activeTab().disabledMCP["codegraph"] = ServerView{}
8571
8572 if err := app.UpdateMCPServer("codegraph", MCPServerInput{
8573 Name: "codegraph",
8574 Transport: "stdio",
8575 Command: "reasonix-missing-mcp-binary",
8576 Args: []string{"serve", "--mcp"},
8577 Env: map[string]string{"CODEGRAPH_LOG": "debug"},
8578 }); err != nil {
8579 t.Fatalf("UpdateMCPServer(.mcp.json codegraph): %v", err)
8580 }
8581
8582 raw, err := os.ReadFile(filepath.Join(dir, ".mcp.json"))
8583 if err != nil {
8584 t.Fatal(err)
8585 }
8586 var doc struct {
8587 MCPServers map[string]struct {
8588 Command string `json:"command"`
8589 Args []string `json:"args"`
8590 Env map[string]string `json:"env"`
8591 } `json:"mcpServers"`
8592 }
8593 if err := json.Unmarshal(raw, &doc); err != nil {
8594 t.Fatal(err)
8595 }
8596 got := doc.MCPServers["codegraph"]
8597 if got.Command != "reasonix-missing-mcp-binary" || !reflect.DeepEqual(got.Args, []string{"serve", "--mcp"}) || got.Env["CODEGRAPH_LOG"] != "debug" {
8598 t.Fatalf(".mcp.json codegraph = %+v, want updated command/args/env", got)
8599 }
8600 if _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, "codegraph"); ok {
8601 t.Fatalf(".mcp.json update should not create a user config shadow entry")
8602 }
8603 }
8604
8605 func TestUpdateMCPServerPreservesProjectTOMLSourceAndGlobalShadow(t *testing.T) {
8606 isolateDesktopUserDirs(t)
8607 dir := robustTempDir(t)
8608 t.Chdir(dir)
8609 userCfg := config.LoadForEdit(config.UserConfigPath())
8610 userCfg.Plugins = []config.PluginEntry{{Name: "docs", Command: "global-docs"}}
8611 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
8612 t.Fatal(err)
8613 }
8614 projectPath := filepath.Join(dir, "reasonix.toml")
8615 if err := os.WriteFile(projectPath, []byte(`
8616 [[plugins]]
8617 name = "docs"
8618 command = "project-docs"
8619 `), 0o644); err != nil {
8620 t.Fatal(err)
8621 }
8622
8623 app := NewApp()
8624 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), WorkspaceRoot: dir}), "")
8625 defer app.activeCtrl().Close()
8626 app.activeTab().WorkspaceRoot = dir
8627 entry, ok, err := desktopEffectiveMCPServer(dir, "docs")
8628 if err != nil || !ok || entry.Source != config.MCPSourceProjectConfig {
8629 t.Fatalf("load project docs entry: entry=%+v found=%v err=%v", entry, ok, err)
8630 }
8631 if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
8632 t.Fatal(err)
8633 }
8634 app.activeTab().disabledMCP["docs"] = ServerView{}
8635
8636 if err := app.UpdateMCPServer("docs", MCPServerInput{
8637 Name: "docs", Transport: "stdio", Command: "project-docs-updated",
8638 }); err != nil {
8639 t.Fatalf("UpdateMCPServer(project reasonix.toml docs): %v", err)
8640 }
8641
8642 projectCfg := config.LoadForEdit(projectPath)
8643 projectEntry, found := findPluginEntry(projectCfg.Plugins, "docs")
8644 if !found || projectEntry.Command != "project-docs-updated" {
8645 t.Fatalf("project docs entry = %+v, found=%v", projectEntry, found)
8646 }
8647 globalCfg := config.LoadForEdit(config.UserConfigPath())
8648 globalEntry, found := findPluginEntry(globalCfg.Plugins, "docs")
8649 if !found || globalEntry.Command != "global-docs" {
8650 t.Fatalf("global shadow changed while editing project entry: %+v, found=%v", globalEntry, found)
8651 }
8652 effective, found, err := desktopEffectiveMCPServer(dir, "docs")
8653 if err != nil || !found || effective.Source != config.MCPSourceProjectConfig || effective.Command != "project-docs-updated" {
8654 t.Fatalf("effective docs after edit = %+v, found=%v err=%v", effective, found, err)
8655 }
8656 }
8657
8658 func TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {
8659 isolateDesktopUserDirs(t)
8660 dir := robustTempDir(t)
8661 t.Chdir(dir)
8662 t.Setenv("STRIPE_TOKEN", "stripe-test-token")
8663 srv := desktopMCPHTTPServer(t)
8664 defer srv.Close()
8665
8666 app := NewApp()
8667 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8668 defer app.activeCtrl().Close()
8669
8670 tools, err := app.AddMCPServer(MCPServerInput{
8671 Name: "stripe",
8672 Transport: "http",
8673 URL: srv.URL,
8674 Headers: map[string]string{
8675 "Authorization": "Bearer ${STRIPE_TOKEN}",
8676 "X-Org": "team",
8677 },
8678 })
8679 if err != nil {
8680 t.Fatalf("AddMCPServer(stripe): %v", err)
8681 }
8682 if tools != 1 {
8683 t.Fatalf("tools = %d, want 1", tools)
8684 }
8685
8686 cfg, err := config.LoadForRoot(dir)
8687 if err != nil {
8688 t.Fatal(err)
8689 }
8690 p, ok := findPluginEntry(cfg.Plugins, "stripe")
8691 if !ok {
8692 t.Fatalf("stripe plugin missing from config: %+v", cfg.Plugins)
8693 }
8694 if p.Type != "http" || p.URL != srv.URL {
8695 t.Fatalf("stripe plugin transport = %q url = %q", p.Type, p.URL)
8696 }
8697 if p.Headers["Authorization"] != "Bearer ${STRIPE_TOKEN}" || p.Headers["X-Org"] != "team" {
8698 t.Fatalf("stripe headers = %+v", p.Headers)
8699 }
8700
8701 view := app.MCPServers()
8702 for _, s := range view {
8703 if s.Name == "stripe" {
8704 if !reflect.DeepEqual(s.HeaderKeys, []string{"Authorization", "X-Org"}) {
8705 t.Fatalf("stripe header keys = %+v", s.HeaderKeys)
8706 }
8707 return
8708 }
8709 }
8710 t.Fatalf("stripe MCP missing from view: %+v", view)
8711 }
8712
8713 func TestInstallMCPServerHandshakeFailureDoesNotPersist(t *testing.T) {
8714 isolateDesktopUserDirs(t)
8715 dir := robustTempDir(t)
8716 t.Chdir(dir)
8717
8718 app := NewApp()
8719 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8720 defer app.activeCtrl().Close()
8721
8722 result, err := app.InstallMCPServer(MCPServerInput{
8723 Name: "broken", Transport: "stdio", Command: "reasonix-missing-mcp-binary",
8724 })
8725 if err != nil {
8726 t.Fatalf("InstallMCPServer returned transport error instead of structured issue: %v", err)
8727 }
8728 if result.State != "issue" || result.Action != "retry" {
8729 t.Fatalf("install result = %+v, want retryable issue", result)
8730 }
8731 cfg, err := config.LoadForRoot(dir)
8732 if err != nil {
8733 t.Fatal(err)
8734 }
8735 if _, ok := findPluginEntry(cfg.Plugins, "broken"); ok {
8736 t.Fatalf("failed candidate was persisted: %+v", cfg.Plugins)
8737 }
8738 for _, server := range app.MCPServers() {
8739 if server.Name == "broken" {
8740 t.Fatalf("failed candidate leaked into the installed server list: %+v", server)
8741 }
8742 }
8743 }
8744
8745 func TestInstallMCPServerAuthenticationRequiredPersistsForResume(t *testing.T) {
8746 isolateDesktopUserDirs(t)
8747 dir := robustTempDir(t)
8748 t.Chdir(dir)
8749 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
8750 http.Error(w, "unauthorized", http.StatusUnauthorized)
8751 }))
8752 defer srv.Close()
8753
8754 app := NewApp()
8755 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8756 defer app.activeCtrl().Close()
8757
8758 result, err := app.InstallMCPServer(MCPServerInput{Name: "oauth", Transport: "http", URL: srv.URL})
8759 if err != nil {
8760 t.Fatalf("InstallMCPServer auth result: %v", err)
8761 }
8762 if result.State != "action_required" || result.Action != "authenticate" {
8763 t.Fatalf("install result = %+v, want authentication action", result)
8764 }
8765 cfg, err := config.LoadForRoot(dir)
8766 if err != nil {
8767 t.Fatal(err)
8768 }
8769 if _, ok := findPluginEntry(cfg.Plugins, "oauth"); !ok {
8770 t.Fatalf("auth-pending candidate must persist for resume: %+v", cfg.Plugins)
8771 }
8772 }
8773
8774 func TestAddMCPServerPersistsConnectionConfiguration(t *testing.T) {
8775 isolateDesktopUserDirs(t)
8776 dir := robustTempDir(t)
8777 t.Chdir(dir)
8778 srv := desktopMCPHTTPServer(t)
8779 defer srv.Close()
8780
8781 app := NewApp()
8782 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8783 defer app.activeCtrl().Close()
8784 autoStart := false
8785 callTimeout := 45
8786 _, err := app.AddMCPServer(MCPServerInput{
8787 Name: "admin",
8788 Transport: "streamable-http",
8789 URL: srv.URL,
8790 AutoStart: &autoStart,
8791 CallTimeoutSeconds: &callTimeout,
8792 ToolTimeoutSeconds: map[string]int{
8793 "wipe": 120,
8794 },
8795 })
8796 if err != nil {
8797 t.Fatal(err)
8798 }
8799
8800 cfg, err := config.LoadForRoot(dir)
8801 if err != nil {
8802 t.Fatal(err)
8803 }
8804 entry, ok := findPluginEntry(cfg.Plugins, "admin")
8805 if !ok || entry.Type != "http" || entry.AutoStart == nil || *entry.AutoStart ||
8806 entry.CallTimeoutSeconds != 45 || entry.ToolTimeoutSeconds["wipe"] != 120 {
8807 t.Fatalf("persisted advanced MCP entry = %+v, found=%v", entry, ok)
8808 }
8809
8810 views := app.MCPServers()
8811 if len(views) != 1 || views[0].Transport != "http" || views[0].AutoStart ||
8812 views[0].CallTimeoutSeconds != 45 || views[0].ToolTimeoutSeconds["wipe"] != 120 {
8813 t.Fatalf("advanced MCP ServerView = %+v", views)
8814 }
8815 }
8816
8817 func TestUpdateMCPServerPreservesAbsentFieldsAndClearsExplicitOnes(t *testing.T) {
8818 isolateDesktopUserDirs(t)
8819 dir := robustTempDir(t)
8820 t.Chdir(dir)
8821 srv := desktopMCPHTTPServer(t)
8822 defer srv.Close()
8823
8824 app := NewApp()
8825 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8826 defer app.activeCtrl().Close()
8827 callTimeout := 45
8828 if _, err := app.AddMCPServer(MCPServerInput{
8829 Name: "admin",
8830 Transport: "http",
8831 URL: srv.URL,
8832 CallTimeoutSeconds: &callTimeout,
8833 ToolTimeoutSeconds: map[string]int{"wipe": 120},
8834 }); err != nil {
8835 t.Fatal(err)
8836 }
8837
8838 // An old frontend (or a partial payload) omits optional timeout fields.
8839 if err := app.UpdateMCPServer("admin", MCPServerInput{Name: "admin", Transport: "http", URL: srv.URL}); err != nil {
8840 t.Fatal(err)
8841 }
8842 cfg, err := config.LoadForRoot(dir)
8843 if err != nil {
8844 t.Fatal(err)
8845 }
8846 entry, ok := findPluginEntry(cfg.Plugins, "admin")
8847 if !ok || entry.CallTimeoutSeconds != 45 || entry.ToolTimeoutSeconds["wipe"] != 120 {
8848 t.Fatalf("absent input fields must preserve persisted values, entry = %+v, found=%v", entry, ok)
8849 }
8850
8851 // Explicit zero values are the editor's clear semantics.
8852 cleared := 0
8853 if err := app.UpdateMCPServer("admin", MCPServerInput{
8854 Name: "admin",
8855 Transport: "http",
8856 URL: srv.URL,
8857 CallTimeoutSeconds: &cleared,
8858 ToolTimeoutSeconds: map[string]int{},
8859 }); err != nil {
8860 t.Fatal(err)
8861 }
8862 cfg, err = config.LoadForRoot(dir)
8863 if err != nil {
8864 t.Fatal(err)
8865 }
8866 entry, ok = findPluginEntry(cfg.Plugins, "admin")
8867 if !ok || entry.CallTimeoutSeconds != 0 || len(entry.ToolTimeoutSeconds) != 0 {
8868 t.Fatalf("explicit empty fields must clear persisted values, entry = %+v, found=%v", entry, ok)
8869 }
8870 }
8871
8872 func TestUpdateMCPServerFailedCandidateRollsBackConfigAndConnection(t *testing.T) {
8873 isolateDesktopUserDirs(t)
8874 dir := robustTempDir(t)
8875 t.Chdir(dir)
8876 srv := desktopMCPHTTPServer(t)
8877 defer srv.Close()
8878
8879 app := NewApp()
8880 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8881 defer app.activeCtrl().Close()
8882 if _, err := app.AddMCPServer(MCPServerInput{Name: "stable", Transport: "http", URL: srv.URL}); err != nil {
8883 t.Fatal(err)
8884 }
8885
8886 err := app.UpdateMCPServer("stable", MCPServerInput{
8887 Name: "stable", Transport: "stdio", Command: "reasonix-missing-mcp-binary",
8888 })
8889 if err == nil {
8890 t.Fatal("broken update candidate should fail")
8891 }
8892 cfg, err := config.LoadForRoot(dir)
8893 if err != nil {
8894 t.Fatal(err)
8895 }
8896 entry, ok := findPluginEntry(cfg.Plugins, "stable")
8897 if !ok || entry.Type != "http" || entry.URL != srv.URL {
8898 t.Fatalf("failed update changed durable config: %+v, found=%v", entry, ok)
8899 }
8900 if !app.activeCtrl().Host().HasClient("stable") {
8901 t.Fatal("previous MCP connection was not restored after failed update")
8902 }
8903 }
8904
8905 func TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {
8906 isolateDesktopUserDirs(t)
8907 dir := robustTempDir(t)
8908 t.Chdir(dir)
8909 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
8910 [[plugins]]
8911 name = "dida"
8912 type = "http"
8913 url = "https://mcp.dida365.com"
8914 tier = "lazy"
8915 `), 0o644); err != nil {
8916 t.Fatal(err)
8917 }
8918 approveWorkspace(t, dir)
8919 enableProjectMCPForWorkspace(t, dir, "")
8920
8921 app := NewApp()
8922 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8923 defer app.activeCtrl().Close()
8924
8925 view := app.Capabilities()
8926 for _, s := range view.Servers {
8927 if s.Name == "dida" {
8928 if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" || s.AuthStatus != "possible" || s.AuthURL != "https://mcp.dida365.com" {
8929 t.Fatalf("dida auth diagnosis = %+v", s)
8930 }
8931 return
8932 }
8933 }
8934 t.Fatalf("dida MCP missing from Capabilities: %+v", view.Servers)
8935 }
8936
8937 func TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {
8938 isolateDesktopUserDirs(t)
8939 dir := robustTempDir(t)
8940 t.Chdir(dir)
8941 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
8942 [[plugins]]
8943 name = "stripe"
8944 type = "http"
8945 url = "https://mcp.stripe.com"
8946 headers = { Authorization = "Bearer ${STRIPE_TOKEN}" }
8947 tier = "lazy"
8948 `), 0o644); err != nil {
8949 t.Fatal(err)
8950 }
8951 approveWorkspace(t, dir)
8952
8953 app := NewApp()
8954 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8955 defer app.activeCtrl().Close()
8956
8957 view := app.Capabilities()
8958 for _, s := range view.Servers {
8959 if s.Name == "stripe" {
8960 if s.AuthStatus != "none" {
8961 t.Fatalf("stripe auth status = %q, want none; server = %+v", s.AuthStatus, s)
8962 }
8963 return
8964 }
8965 }
8966 t.Fatalf("stripe MCP missing from Capabilities: %+v", view.Servers)
8967 }
8968
8969 func TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {
8970 isolateDesktopUserDirs(t)
8971 dir := robustTempDir(t)
8972 t.Chdir(dir)
8973 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
8974 [[plugins]]
8975 name = "figma"
8976 type = "http"
8977 url = "https://mcp.figma.com/mcp"
8978 tier = "lazy"
8979 `), 0o644); err != nil {
8980 t.Fatal(err)
8981 }
8982 approveWorkspace(t, dir)
8983 enableProjectMCPForWorkspace(t, dir, "")
8984
8985 host := plugin.NewHost()
8986 host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
8987 app := NewApp()
8988 app.setTestCtrl(control.New(control.Options{Host: host}), "")
8989 defer app.activeCtrl().Close()
8990
8991 view := app.Capabilities()
8992 for _, s := range view.Servers {
8993 if s.Name == "figma" {
8994 if s.Status != "failed" || s.AuthStatus != "required" || s.AuthURL != "https://mcp.figma.com/mcp" {
8995 t.Fatalf("figma auth diagnosis = %+v", s)
8996 }
8997 return
8998 }
8999 }
9000 t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
9001 }
9002
9003 func TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {
9004 isolateDesktopUserDirs(t)
9005 dir := robustTempDir(t)
9006 t.Chdir(dir)
9007 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9008 [[plugins]]
9009 name = "figma"
9010 type = "http"
9011 url = "https://mcp.figma.com/mcp?access_token=abc&workspace=main"
9012 headers = { Authorization = "Bearer ${FIGMA_TOKEN}", "X-Org" = "team" }
9013 env = { FIGMA_TOKEN = "${FIGMA_TOKEN}", DEBUG = "1" }
9014 tier = "lazy"
9015 `), 0o644); err != nil {
9016 t.Fatal(err)
9017 }
9018 approveWorkspace(t, dir)
9019 enableProjectMCPForWorkspace(t, dir, "")
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 if err := app.ClearMCPServerAuthentication("figma"); err != nil {
9028 t.Fatalf("ClearMCPServerAuthentication: %v", err)
9029 }
9030 if failures := host.Failures(); len(failures) != 0 {
9031 t.Fatalf("failure should be cleared: %+v", failures)
9032 }
9033 cfg, err := config.Load()
9034 if err != nil {
9035 t.Fatal(err)
9036 }
9037 p := cfg.Plugins[0]
9038 if p.URL != "https://mcp.figma.com/mcp?workspace=main" {
9039 t.Fatalf("url = %q", p.URL)
9040 }
9041 if _, ok := p.Headers["Authorization"]; ok {
9042 t.Fatalf("auth header should be removed: %v", p.Headers)
9043 }
9044 if p.Headers["X-Org"] != "team" {
9045 t.Fatalf("ordinary header should be preserved: %v", p.Headers)
9046 }
9047 if _, ok := p.Env["FIGMA_TOKEN"]; ok {
9048 t.Fatalf("auth env should be removed: %v", p.Env)
9049 }
9050 if p.Env["DEBUG"] != "1" {
9051 t.Fatalf("ordinary env should be preserved: %v", p.Env)
9052 }
9053 view := app.Capabilities()
9054 for _, s := range view.Servers {
9055 if s.Name == "figma" {
9056 if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" || s.AuthStatus != "possible" {
9057 t.Fatalf("figma should return to background possible auth: %+v", s)
9058 }
9059 return
9060 }
9061 }
9062 t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
9063 }
9064
9065 func TestUpdateMCPServerMigratesLegacyTierInProjectSource(t *testing.T) {
9066 isolateDesktopUserDirs(t)
9067 dir := robustTempDir(t)
9068 t.Chdir(dir)
9069 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9070 [[plugins]]
9071 name = "playwright"
9072 command = "npx"
9073 args = ["-y", "@playwright/mcp"]
9074 env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
9075 tier = "lazy"
9076 `), 0o644); err != nil {
9077 t.Fatal(err)
9078 }
9079 approveWorkspace(t, dir)
9080
9081 app := NewApp()
9082 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9083 defer func() {
9084 if c := app.activeCtrl(); c != nil {
9085 c.Close()
9086 }
9087 }()
9088 entry, ok, err := desktopEffectiveMCPServer(dir, "playwright")
9089 if err != nil || !ok {
9090 t.Fatalf("load playwright entry: found=%v err=%v", ok, err)
9091 }
9092 if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
9093 t.Fatal(err)
9094 }
9095 app.activeTab().disabledMCP["playwright"] = ServerView{Name: "playwright", Status: "disabled", Enabled: false}
9096
9097 if err := app.UpdateMCPServer("playwright", MCPServerInput{
9098 Name: "playwright",
9099 Transport: "stdio",
9100 Command: "node",
9101 Args: []string{"server.js"},
9102 }); err != nil {
9103 t.Fatalf("UpdateMCPServer: %v", err)
9104 }
9105 cfg, err := config.Load()
9106 if err != nil {
9107 t.Fatal(err)
9108 }
9109 if got := cfg.Plugins[0].Command; got != "node" {
9110 t.Fatalf("updated command = %q, want node", got)
9111 }
9112 if got := cfg.Plugins[0].Env["TOKEN"]; got != "${PLAYWRIGHT_TOKEN}" {
9113 t.Fatalf("env TOKEN = %q, want preserved env", got)
9114 }
9115 userCfg := config.LoadForEdit(config.UserConfigPath())
9116 if _, ok := findPluginEntry(userCfg.Plugins, "playwright"); ok {
9117 t.Fatalf("project plugin should not be copied to user config: %+v", userCfg.Plugins)
9118 }
9119 projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
9120 projectPlugin, ok := findPluginEntry(projectCfg.Plugins, "playwright")
9121 if !ok {
9122 t.Fatalf("playwright should remain in project config: %+v", projectCfg.Plugins)
9123 }
9124 if projectPlugin.Command != "node" || projectPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
9125 t.Fatalf("project plugin after update = %+v", projectPlugin)
9126 }
9127 if projectPlugin.Tier != "" {
9128 t.Fatalf("project plugin tier = %q, want migrated empty", projectPlugin.Tier)
9129 }
9130 view := app.Capabilities()
9131 for _, s := range view.Servers {
9132 if s.Name == "playwright" {
9133 if s.Status != "disabled" {
9134 t.Fatalf("updated MCP status = %q, want disabled without a readiness probe; server = %+v", s.Status, s)
9135 }
9136 if s.Command != "node" || len(s.Args) != 1 || s.Args[0] != "server.js" {
9137 t.Fatalf("server command not refreshed: %+v", s)
9138 }
9139 return
9140 }
9141 }
9142 t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
9143 }
9144
9145 func TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {
9146 isolateDesktopUserDirs(t)
9147 dir := t.TempDir()
9148 t.Chdir(dir)
9149 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9150 [[plugins]]
9151 name = "playwright"
9152 command = "npx"
9153 args = ["-y", "@playwright/mcp"]
9154 `), 0o644); err != nil {
9155 t.Fatal(err)
9156 }
9157 approveWorkspace(t, dir)
9158
9159 app := NewApp()
9160 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9161 defer app.activeCtrl().Close()
9162 app.activeTab().disabledMCP["playwright"] = ServerView{}
9163
9164 if err := app.UpdateMCPServer("playwright", MCPServerInput{
9165 Name: "playwright",
9166 Transport: "stdio",
9167 Command: "npx -y @modelcontextprotocol/server-filesystem .",
9168 }); err != nil {
9169 t.Fatalf("UpdateMCPServer: %v", err)
9170 }
9171 cfg, err := config.Load()
9172 if err != nil {
9173 t.Fatal(err)
9174 }
9175 p := cfg.Plugins[0]
9176 if p.Command != "npx" {
9177 t.Fatalf("command = %q, want npx", p.Command)
9178 }
9179 if got := strings.Join(p.Args, "\x00"); got != strings.Join([]string{"-y", "@modelcontextprotocol/server-filesystem", "."}, "\x00") {
9180 t.Fatalf("args = %v", p.Args)
9181 }
9182 }
9183
9184 func TestUpdateMCPServerRejectsReconnectFailureWithoutPersisting(t *testing.T) {
9185 isolateDesktopUserDirs(t)
9186 dir := robustTempDir(t)
9187 t.Chdir(dir)
9188 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9189 [[plugins]]
9190 name = "broken"
9191 command = "reasonix-old-missing-mcp-binary"
9192 tier = "background"
9193 `), 0o644); err != nil {
9194 t.Fatal(err)
9195 }
9196 approveWorkspace(t, dir)
9197 enableProjectMCPForWorkspace(t, dir, "")
9198
9199 app := NewApp()
9200 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9201 defer app.activeCtrl().Close()
9202
9203 if err := app.UpdateMCPServer("broken", MCPServerInput{
9204 Name: "broken",
9205 Transport: "stdio",
9206 Command: "reasonix-missing-mcp-binary",
9207 }); err == nil {
9208 t.Fatal("UpdateMCPServer should reject an unusable candidate")
9209 }
9210 cfg, err := config.Load()
9211 if err != nil {
9212 t.Fatal(err)
9213 }
9214 if got := cfg.Plugins[0].Command; got != "reasonix-old-missing-mcp-binary" {
9215 t.Fatalf("failed update command = %q, want original command", got)
9216 }
9217 if got := cfg.Plugins[0].Tier; got != "" {
9218 t.Fatalf("loaded legacy tier = %q, want normalized empty", got)
9219 }
9220 if !mcpFailed(app.activeCtrl(), "broken") {
9221 t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
9222 }
9223 view := app.Capabilities()
9224 for _, s := range view.Servers {
9225 if s.Name == "broken" {
9226 if s.Status != "failed" {
9227 t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
9228 }
9229 if s.Command != "reasonix-old-missing-mcp-binary" || s.Tier != "background" {
9230 t.Fatalf("failed candidate leaked into server config: %+v", s)
9231 }
9232 return
9233 }
9234 }
9235 t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
9236 }
9237
9238 func TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {
9239 isolateDesktopUserDirs(t)
9240 dir := robustTempDir(t)
9241 t.Chdir(dir)
9242 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9243 [[plugins]]
9244 name = "codegraph"
9245 `), 0o644); err != nil {
9246 t.Fatal(err)
9247 }
9248 approveWorkspace(t, dir)
9249 enableProjectMCPForWorkspace(t, dir, "")
9250
9251 reg := tool.NewRegistry()
9252 reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
9253 app := NewApp()
9254 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), "")
9255 defer app.activeCtrl().Close()
9256
9257 view := app.Capabilities()
9258 foundIdle := false
9259 for _, s := range view.Servers {
9260 if s.Name == "codegraph" {
9261 foundIdle = true
9262 if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
9263 t.Fatalf("initial codegraph server = %+v, want automatic idle background state", s)
9264 }
9265 }
9266 }
9267 if !foundIdle {
9268 t.Fatalf("codegraph missing before reconnect: %+v", view.Servers)
9269 }
9270 if _, ok := reg.Get("mcp__codegraph__connect"); !ok {
9271 t.Fatal("test setup expected stale codegraph connect placeholder")
9272 }
9273
9274 if err := app.ReconnectMCPServer("codegraph"); err == nil || !strings.Contains(err.Error(), "command is required") {
9275 t.Fatalf("ReconnectMCPServer error = %v, want missing command", err)
9276 }
9277 if _, ok := reg.Get("mcp__codegraph__connect"); ok {
9278 t.Fatalf("stale codegraph placeholder still registered after reconnect failure; names=%v", reg.Names())
9279 }
9280 if !mcpFailed(app.activeCtrl(), "codegraph") {
9281 t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded", app.activeCtrl().Host().Failures())
9282 }
9283
9284 view = app.Capabilities()
9285 for _, s := range view.Servers {
9286 if s.Name == "codegraph" {
9287 if s.Status != "failed" || s.Error == "" {
9288 t.Fatalf("codegraph after failed reconnect = %+v, want failed with error", s)
9289 }
9290 return
9291 }
9292 }
9293 t.Fatalf("codegraph missing after reconnect: %+v", view.Servers)
9294 }
9295
9296 func TestSetMCPServerTierPreservesProjectSourceAndRecordsConnectFailure(t *testing.T) {
9297 isolateDesktopUserDirs(t)
9298 dir := robustTempDir(t)
9299 t.Chdir(dir)
9300 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9301 [[plugins]]
9302 name = "broken"
9303 command = "reasonix-missing-mcp-binary"
9304 tier = "lazy"
9305 `), 0o644); err != nil {
9306 t.Fatal(err)
9307 }
9308 approveWorkspace(t, dir)
9309 enableProjectMCPForWorkspace(t, dir, "")
9310
9311 app := NewApp()
9312 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9313 defer func() {
9314 if c := app.activeCtrl(); c != nil {
9315 c.Close()
9316 }
9317 }()
9318
9319 if err := app.SetMCPServerTier("broken", "background"); err != nil {
9320 t.Fatalf("SetMCPServerTier legacy binding: %v", err)
9321 }
9322 cfg, err := config.Load()
9323 if err != nil {
9324 t.Fatal(err)
9325 }
9326 if got := cfg.Plugins[0].Tier; got != "" {
9327 t.Fatalf("saved tier = %q, want migrated empty", got)
9328 }
9329 userCfg := config.LoadForEdit(config.UserConfigPath())
9330 if _, ok := findPluginEntry(userCfg.Plugins, "broken"); ok {
9331 t.Fatalf("project plugin should not be copied to user config: %+v", userCfg.Plugins)
9332 }
9333 projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
9334 projectPlugin, ok := findPluginEntry(projectCfg.Plugins, "broken")
9335 if !ok {
9336 t.Fatalf("broken should remain in project config: %+v", projectCfg.Plugins)
9337 }
9338 if projectPlugin.Tier != "" {
9339 t.Fatalf("project plugin tier = %q, want migrated empty", projectPlugin.Tier)
9340 }
9341 if !mcpFailed(app.activeCtrl(), "broken") {
9342 t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
9343 }
9344 view := app.Capabilities()
9345 for _, s := range view.Servers {
9346 if s.Name == "broken" {
9347 if s.Status != "failed" {
9348 t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
9349 }
9350 if s.Tier != "background" {
9351 t.Fatalf("server tier = %q, want background so radio selection does not jump back", s.Tier)
9352 }
9353 return
9354 }
9355 }
9356 t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
9357 }
9358
9359 func TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {
9360 isolateDesktopUserDirs(t)
9361 dir := robustTempDir(t)
9362 t.Chdir(dir)
9363 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
9364 t.Fatalf("mkdir config dir: %v", err)
9365 }
9366 if err := os.WriteFile(config.UserConfigPath(), []byte(`
9367 [[plugins]]
9368 name = "broken"
9369 command = "reasonix-missing-mcp-binary"
9370 tier = "lazy"
9371 `), 0o644); err != nil {
9372 t.Fatal(err)
9373 }
9374
9375 app := NewApp()
9376 app.setTestCtrl(newBackgroundJobController(t, "mcp-tier-job"), "")
9377
9378 err := app.SetMCPServerTier("broken", "background")
9379 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
9380 t.Fatalf("SetMCPServerTier with background job error = %v, want active-work guard", err)
9381 }
9382 data, readErr := os.ReadFile(config.UserConfigPath())
9383 if readErr != nil {
9384 t.Fatalf("read config: %v", readErr)
9385 }
9386 if !strings.Contains(string(data), `tier = "lazy"`) {
9387 t.Fatalf("plugin config changed after rejected tier update:\n%s", data)
9388 }
9389 }
9390
9391 func TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {
9392 isolateDesktopUserDirs(t)
9393 dir := robustTempDir(t)
9394 t.Chdir(dir)
9395 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9396 [[plugins]]
9397 name = "broken"
9398 command = "reasonix-missing-mcp-binary"
9399 tier = "eager"
9400 `), 0o644); err != nil {
9401 t.Fatal(err)
9402 }
9403 approveWorkspace(t, dir)
9404 enableProjectMCPForWorkspace(t, dir, "")
9405
9406 app := NewApp()
9407 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9408 defer app.activeCtrl().Close()
9409 recordMCPFailure(app.activeCtrl(), config.PluginEntry{
9410 Name: "broken",
9411 Command: "reasonix-missing-mcp-binary",
9412 Tier: "eager",
9413 }, errors.New("connect: missing binary"))
9414
9415 view := app.Capabilities()
9416 for _, s := range view.Servers {
9417 if s.Name == "broken" {
9418 if s.Status != "failed" {
9419 t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
9420 }
9421 if s.Tier != "background" {
9422 t.Fatalf("server tier = %q, want migrated background default", s.Tier)
9423 }
9424 if !s.Configured {
9425 t.Fatalf("server configured = false, want true; server = %+v", s)
9426 }
9427 return
9428 }
9429 }
9430 t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
9431 }
9432
9433 func TestRunShellForTabRoutesToRequestedTab(t *testing.T) {
9434 isolateDesktopUserDirs(t)
9435
9436 activeEvents := make(chan event.Event, 16)
9437 inactiveEvents := make(chan event.Event, 16)
9438 activeCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})
9439 inactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})
9440 defer activeCtrl.Close()
9441 defer inactiveCtrl.Close()
9442
9443 app := &App{
9444 tabs: map[string]*WorkspaceTab{
9445 "active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
9446 "inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
9447 },
9448 tabOrder: []string{"active", "inactive"},
9449 activeTabID: "active",
9450 }
9451
9452 app.RunShellForTab("inactive", "echo route-test")
9453
9454 sawDispatch := false
9455 deadline := time.After(3 * time.Second)
9456 for {
9457 select {
9458 case e := <-inactiveEvents:
9459 if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, "route-test") {
9460 sawDispatch = true
9461 }
9462 if e.Kind == event.TurnDone {
9463 if !sawDispatch {
9464 t.Fatal("inactive tab finished without receiving shell dispatch")
9465 }
9466 select {
9467 case active := <-activeEvents:
9468 t.Fatalf("active tab received event for inactive shell: %+v", active)
9469 default:
9470 }
9471 return
9472 }
9473 case <-deadline:
9474 t.Fatal("timed out waiting for inactive shell turn")
9475 }
9476 }
9477 }
9478
9479 func TestRunShellForTabStaysBoundDuringRapidProjectTabSwitching(t *testing.T) {
9480 if testing.Short() {
9481 t.Skip("skipping shell cancellation integration test in short mode")
9482 }
9483
9484 isolateDesktopUserDirs(t)
9485
9486 projectA := t.TempDir()
9487 projectB := t.TempDir()
9488 globalRoot := t.TempDir()
9489 shellEvents := make(chan event.Event, 64)
9490 projectEvents := make(chan event.Event, 64)
9491 globalEvents := make(chan event.Event, 64)
9492 shellCtrl := control.New(control.Options{
9493 Sink: event.FuncSink(func(e event.Event) { shellEvents <- e }),
9494 WorkspaceRoot: projectA,
9495 })
9496 projectCtrl := control.New(control.Options{
9497 Sink: event.FuncSink(func(e event.Event) { projectEvents <- e }),
9498 WorkspaceRoot: projectB,
9499 })
9500 globalCtrl := control.New(control.Options{
9501 Sink: event.FuncSink(func(e event.Event) { globalEvents <- e }),
9502 WorkspaceRoot: globalRoot,
9503 })
9504 defer shellCtrl.Close()
9505 defer projectCtrl.Close()
9506 defer globalCtrl.Close()
9507
9508 app := &App{
9509 tabs: map[string]*WorkspaceTab{
9510 "shell": {ID: "shell", Scope: "project", WorkspaceRoot: projectA, Ctrl: shellCtrl, Ready: true},
9511 "project-b": {ID: "project-b", Scope: "project", WorkspaceRoot: projectB, Ctrl: projectCtrl, Ready: true},
9512 "global": {ID: "global", Scope: "global", WorkspaceRoot: globalRoot, Ctrl: globalCtrl, Ready: true},
9513 },
9514 tabOrder: []string{"shell", "project-b", "global"},
9515 activeTabID: "shell",
9516 }
9517
9518 marker := "shell-route-marker.txt"
9519 if err := app.RunShellForTab("shell", longRunningMarkerCommand(marker)); err != nil {
9520 t.Fatalf("RunShellForTab: %v", err)
9521 }
9522 waitForShellDispatch(t, shellEvents, marker)
9523 waitForFile(t, filepath.Join(projectA, marker), "shell")
9524
9525 for range 8 {
9526 if err := app.SetActiveTab("project-b"); err != nil {
9527 t.Fatalf("SetActiveTab(project-b): %v", err)
9528 }
9529 if err := app.SetActiveTab("global"); err != nil {
9530 t.Fatalf("SetActiveTab(global): %v", err)
9531 }
9532 if err := app.SetActiveTab("shell"); err != nil {
9533 t.Fatalf("SetActiveTab(shell): %v", err)
9534 }
9535 }
9536 if err := app.SetActiveTab("project-b"); err != nil {
9537 t.Fatalf("SetActiveTab(project-b final): %v", err)
9538 }
9539 app.CancelTab("shell")
9540
9541 cancelled := false
9542 deadline := time.After(15 * time.Second)
9543 for {
9544 select {
9545 case e := <-shellEvents:
9546 if e.Kind == event.ToolResult && e.Tool.Name == "bash" {
9547 cancelled = e.Tool.Err != ""
9548 }
9549 if e.Kind == event.TurnDone {
9550 if !cancelled {
9551 t.Fatal("shell tab finished without a cancelled shell result")
9552 }
9553 if _, err := os.Stat(filepath.Join(projectB, marker)); !errors.Is(err, os.ErrNotExist) {
9554 t.Fatalf("shell marker appeared in project-b workspace: %v", err)
9555 }
9556 if got := activeTabIDForTest(app); got != "project-b" {
9557 t.Fatalf("active tab = %q, want project-b after background shell cancel", got)
9558 }
9559 assertNoEvents(t, projectEvents, "project-b")
9560 assertNoEvents(t, globalEvents, "global")
9561 return
9562 }
9563 case <-deadline:
9564 t.Fatal("timed out waiting for shell tab cancellation")
9565 }
9566 }
9567 }
9568
9569 func longRunningMarkerCommand(marker string) string {
9570 if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell {
9571 return fmt.Sprintf("Set-Content -LiteralPath %s -Value shell; Start-Sleep -Seconds 30", marker)
9572 }
9573 return fmt.Sprintf("printf shell > %s; sleep 30", marker)
9574 }
9575
9576 func waitForShellDispatch(t *testing.T, ch <-chan event.Event, marker string) {
9577 t.Helper()
9578 deadline := time.After(5 * time.Second)
9579 for {
9580 select {
9581 case e := <-ch:
9582 if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, marker) {
9583 return
9584 }
9585 case <-deadline:
9586 t.Fatal("timed out waiting for shell dispatch")
9587 }
9588 }
9589 }
9590
9591 func activeTabIDForTest(app *App) string {
9592 app.mu.RLock()
9593 defer app.mu.RUnlock()
9594 return app.activeTabID
9595 }
9596
9597 func assertNoEvents(t *testing.T, ch <-chan event.Event, name string) {
9598 t.Helper()
9599 select {
9600 case e := <-ch:
9601 t.Fatalf("%s received event while shell ran in another tab: %+v", name, e)
9602 default:
9603 }
9604 }
9605
9606 type blockingRunner struct {
9607 started chan struct{}
9608 release chan struct{}
9609 }
9610
9611 func (r *blockingRunner) Run(ctx context.Context, _ string) error {
9612 close(r.started)
9613 select {
9614 case <-ctx.Done():
9615 return ctx.Err()
9616 case <-r.release:
9617 return nil
9618 }
9619 }
9620
9621 func startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {
9622 t.Helper()
9623 started := make(chan struct{})
9624 release := make(chan struct{})
9625 jm.StartForSession(agent.BranchID(sessionPath), "bash", "stuck job", func(ctx context.Context, _ io.Writer) (string, error) {
9626 close(started)
9627 <-ctx.Done()
9628 <-release
9629 return "", ctx.Err()
9630 })
9631 select {
9632 case <-started:
9633 case <-time.After(2 * time.Second):
9634 t.Fatal("background job never started")
9635 }
9636 released := false
9637 return func() {
9638 if released {
9639 return
9640 }
9641 released = true
9642 close(release)
9643 }
9644 }
9645
9646 func newBackgroundJobController(t *testing.T, label string) *control.Controller {
9647 t.Helper()
9648 dir := config.SessionDir()
9649 if err := os.MkdirAll(dir, 0o755); err != nil {
9650 t.Fatalf("mkdir session dir: %v", err)
9651 }
9652 path := filepath.Join(dir, label+".jsonl")
9653 jm := jobs.NewManager(event.Discard)
9654 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
9655 t.Cleanup(ctrl.Close)
9656 jm.StartForSession(agent.BranchID(path), "bash", label, func(ctx context.Context, _ io.Writer) (string, error) {
9657 <-ctx.Done()
9658 return "", ctx.Err()
9659 })
9660 return ctrl
9661 }
9662
9663 func hasLevel(levels []string, want string) bool {
9664 return slices.Contains(levels, want)
9665 }
9666
9667 func hasCommand(cmds []CommandInfo, name string) bool {
9668 for _, cmd := range cmds {
9669 if cmd.Name == name {
9670 return true
9671 }
9672 }
9673 return false
9674 }
9675
9676 func hasDirEntry(entries []DirEntry, name string) bool {
9677 for _, entry := range entries {
9678 if entry.Name == name {
9679 return true
9680 }
9681 }
9682 return false
9683 }
9684
9685 func TestSessionActionsWithoutControllerReturnError(t *testing.T) {
9686 app := &App{tabs: map[string]*WorkspaceTab{}}
9687 if err := app.NewSession(); err == nil {
9688 t.Error("NewSession with no controller must surface an error, not silently no-op")
9689 }
9690 if _, err := app.ClearSession(); err == nil {
9691 t.Error("ClearSession with no controller must surface an error")
9692 }
9693
9694 app = &App{
9695 tabs: map[string]*WorkspaceTab{"t1": {ID: "t1", StartupErr: "boot exploded"}},
9696 activeTabID: "t1",
9697 }
9698 err := app.NewSession()
9699 if err == nil || !strings.Contains(err.Error(), "boot exploded") {
9700 t.Errorf("error should carry the tab's startup failure, got %v", err)
9701 }
9702 }
9703
9704 // Prompt history scanning tests
9705
9706 func identityPromptDisplay(text string) string { return text }
9707
9708 // TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format
9709 // {"kind":"user.message","text":"..."} is correctly extracted.
9710 func TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {
9711 dir := t.TempDir()
9712 path := filepath.Join(dir, "session.jsonl")
9713 if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"hello world"}
9714 {"kind":"user.message","text":"second prompt"}
9715 {"kind":"model.final","content":"response"}
9716 `), 0o644); err != nil {
9717 t.Fatal(err)
9718 }
9719 info, err := os.Stat(path)
9720 if err != nil {
9721 t.Fatal(err)
9722 }
9723 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9724 if err != nil {
9725 t.Fatal(err)
9726 }
9727 if len(entries) != 2 {
9728 t.Fatalf("expected 2 entries, got %d", len(entries))
9729 }
9730 if entries[0].Text != "hello world" {
9731 t.Errorf("expected 'hello world', got %q", entries[0].Text)
9732 }
9733 if entries[1].Text != "second prompt" {
9734 t.Errorf("expected 'second prompt', got %q", entries[1].Text)
9735 }
9736 if entries[0].Turn != 0 || entries[1].Turn != 1 {
9737 t.Errorf("expected turns 0,1; got %d,%d", entries[0].Turn, entries[1].Turn)
9738 }
9739 if entries[0].SessionPath != path {
9740 t.Errorf("expected session path %q, got %q", path, entries[0].SessionPath)
9741 }
9742 }
9743
9744 // TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event
9745 // format {"type":"user.message","text":"..."} is correctly extracted.
9746 func TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {
9747 dir := t.TempDir()
9748 path := filepath.Join(dir, "session.jsonl")
9749 if err := os.WriteFile(path, []byte(`{"type":"user.message","text":"v0 prompt"}
9750 {"type":"model.final","content":"response"}
9751 `), 0o644); err != nil {
9752 t.Fatal(err)
9753 }
9754 info, err := os.Stat(path)
9755 if err != nil {
9756 t.Fatal(err)
9757 }
9758 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9759 if err != nil {
9760 t.Fatal(err)
9761 }
9762 if len(entries) != 1 {
9763 t.Fatalf("expected 1 entry, got %d", len(entries))
9764 }
9765 if entries[0].Text != "v0 prompt" {
9766 t.Errorf("expected 'v0 prompt', got %q", entries[0].Text)
9767 }
9768 }
9769
9770 // TestCollectPromptHistoryEntriesProviderMessage verifies that the current
9771 // provider.Message format {"role":"user","content":"..."} is correctly extracted.
9772 func TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {
9773 dir := t.TempDir()
9774 path := filepath.Join(dir, "session.jsonl")
9775 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello from provider"}
9776 {"role":"assistant","content":"response"}
9777 {"role":"user","content":"another prompt"}
9778 `), 0o644); err != nil {
9779 t.Fatal(err)
9780 }
9781 info, err := os.Stat(path)
9782 if err != nil {
9783 t.Fatal(err)
9784 }
9785 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9786 if err != nil {
9787 t.Fatal(err)
9788 }
9789 if len(entries) != 2 {
9790 t.Fatalf("expected 2 entries, got %d", len(entries))
9791 }
9792 if entries[0].Text != "hello from provider" {
9793 t.Errorf("expected 'hello from provider', got %q", entries[0].Text)
9794 }
9795 if entries[1].Text != "another prompt" {
9796 t.Errorf("expected 'another prompt', got %q", entries[1].Text)
9797 }
9798 }
9799
9800 // TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the
9801 // same file are extracted.
9802 func TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {
9803 dir := t.TempDir()
9804 path := filepath.Join(dir, "session.jsonl")
9805 if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy prompt"}
9806 {"role":"user","content":"modern prompt"}
9807 `), 0o644); err != nil {
9808 t.Fatal(err)
9809 }
9810 info, err := os.Stat(path)
9811 if err != nil {
9812 t.Fatal(err)
9813 }
9814 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9815 if err != nil {
9816 t.Fatal(err)
9817 }
9818 if len(entries) != 2 {
9819 t.Fatalf("expected 2 entries, got %d", len(entries))
9820 }
9821 if entries[0].Text != "legacy prompt" {
9822 t.Errorf("expected 'legacy prompt', got %q", entries[0].Text)
9823 }
9824 if entries[1].Text != "modern prompt" {
9825 t.Errorf("expected 'modern prompt', got %q", entries[1].Text)
9826 }
9827 }
9828
9829 func TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {
9830 dir := t.TempDir()
9831 path := filepath.Join(dir, "session.jsonl")
9832 rfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)
9833 if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy timed","time":1800000000123}
9834 {"role":"user","content":"modern timed","createdAt":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}
9835 `), 0o644); err != nil {
9836 t.Fatal(err)
9837 }
9838 info, err := os.Stat(path)
9839 if err != nil {
9840 t.Fatal(err)
9841 }
9842 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9843 if err != nil {
9844 t.Fatal(err)
9845 }
9846 if len(entries) != 2 {
9847 t.Fatalf("expected 2 entries, got %d", len(entries))
9848 }
9849 if entries[0].At != 1800000000123 {
9850 t.Errorf("numeric event time = %d, want 1800000000123", entries[0].At)
9851 }
9852 if entries[1].At != rfcTime.UnixMilli() {
9853 t.Errorf("RFC3339 event time = %d, want %d", entries[1].At, rfcTime.UnixMilli())
9854 }
9855 }
9856
9857 // TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses
9858 // the user-visible prompt text, not the controller-expanded model input.
9859 func TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {
9860 dir := t.TempDir()
9861 path := filepath.Join(dir, "session.jsonl")
9862 expanded := "<memory-update>\nSaved memory\n</memory-update>\n\nvisible prompt"
9863 if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(expanded)+`}`+"\n"), 0o644); err != nil {
9864 t.Fatal(err)
9865 }
9866 if err := recordSessionDisplay(dir, path, expanded, "visible prompt"); err != nil {
9867 t.Fatal(err)
9868 }
9869 info, err := os.Stat(path)
9870 if err != nil {
9871 t.Fatal(err)
9872 }
9873 entries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))
9874 if err != nil {
9875 t.Fatal(err)
9876 }
9877 if len(entries) != 1 {
9878 t.Fatalf("expected 1 entry, got %d", len(entries))
9879 }
9880 if entries[0].Text != "visible prompt" {
9881 t.Errorf("expected visible prompt, got %q", entries[0].Text)
9882 }
9883 }
9884
9885 func TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {
9886 dir := t.TempDir()
9887 path := filepath.Join(dir, "session.jsonl")
9888 if err := os.WriteFile(path, []byte(`{"role":"user","content":"Plan approved — plan mode is off"}
9889 {"role":"user","content":"real prompt"}
9890 `), 0o644); 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, identityPromptDisplay)
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 != "real prompt" {
9905 t.Errorf("expected real prompt, got %q", entries[0].Text)
9906 }
9907 }
9908
9909 // TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only
9910 // assistant/tool messages returns no entries.
9911 func TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {
9912 dir := t.TempDir()
9913 path := filepath.Join(dir, "session.jsonl")
9914 if err := os.WriteFile(path, []byte(`{"kind":"model.final","content":"response"}
9915 {"kind":"tool.result","output":"done"}
9916 `), 0o644); err != nil {
9917 t.Fatal(err)
9918 }
9919 info, err := os.Stat(path)
9920 if err != nil {
9921 t.Fatal(err)
9922 }
9923 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9924 if err != nil {
9925 t.Fatal(err)
9926 }
9927 if len(entries) != 0 {
9928 t.Errorf("expected 0 entries, got %d", len(entries))
9929 }
9930 }
9931
9932 // TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file
9933 // returns no entries without error.
9934 func TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {
9935 dir := t.TempDir()
9936 path := filepath.Join(dir, "empty.jsonl")
9937 if err := os.WriteFile(path, nil, 0o644); err != nil {
9938 t.Fatal(err)
9939 }
9940 info, err := os.Stat(path)
9941 if err != nil {
9942 t.Fatal(err)
9943 }
9944 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
9945 if err != nil {
9946 t.Fatal(err)
9947 }
9948 if len(entries) != 0 {
9949 t.Errorf("expected 0 entries, got %d", len(entries))
9950 }
9951 }
9952
9953 // TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans
9954 // multiple JSONL files and returns prompts newest-first.
9955 func TestScanPromptHistoryFromDir(t *testing.T) {
9956 app := &App{tabs: map[string]*WorkspaceTab{"t1": {ID: "t1", Ctrl: nil, WorkspaceRoot: ""}}}
9957 _ = app
9958
9959 dir := t.TempDir()
9960 // Write two session files with different mtimes (sleep to ensure ordering).
9961 if err := os.WriteFile(filepath.Join(dir, "a.jsonl"), []byte(`{"role":"user","content":"older prompt"}
9962 `), 0o644); err != nil {
9963 t.Fatal(err)
9964 }
9965 time.Sleep(10 * time.Millisecond)
9966 if err := os.WriteFile(filepath.Join(dir, "b.jsonl"), []byte(`{"role":"user","content":"newer prompt"}
9967 `), 0o644); err != nil {
9968 t.Fatal(err)
9969 }
9970
9971 entries, err := app.scanPromptHistoryFromDir(dir)
9972 if err != nil {
9973 t.Fatal(err)
9974 }
9975 if len(entries) != 2 {
9976 t.Fatalf("expected 2 entries, got %d", len(entries))
9977 }
9978 // Newest-first: "newer prompt" should be first.
9979 if entries[0].Text != "newer prompt" {
9980 t.Errorf("expected 'newer prompt' first, got %q", entries[0].Text)
9981 }
9982 if entries[1].Text != "older prompt" {
9983 t.Errorf("expected 'older prompt' second, got %q", entries[1].Text)
9984 }
9985 }
9986
9987 func TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {
9988 app := &App{}
9989 dir := t.TempDir()
9990 base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
9991 early := filepath.Join(dir, "early.jsonl")
9992 late := filepath.Join(dir, "late.jsonl")
9993
9994 if err := os.WriteFile(early, fmt.Appendf(nil, `{"role":"user","content":"early first","time":%d}
9995 {"role":"assistant","content":"ok"}
9996 {"role":"user","content":"early second","time":%d}
9997 `, base.UnixMilli(), base.Add(time.Minute).UnixMilli()), 0o644); err != nil {
9998 t.Fatal(err)
9999 }
10000 if err := os.WriteFile(late, fmt.Appendf(nil, `{"role":"user","content":"late newest","time":%d}
10001 `, base.Add(2*time.Minute).UnixMilli()), 0o644); err != nil {
10002 t.Fatal(err)
10003 }
10004 // Invert file mtimes: session activity should keep each session grouped
10005 // before event timestamps are considered within that session.
10006 if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {
10007 t.Fatal(err)
10008 }
10009 if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {
10010 t.Fatal(err)
10011 }
10012
10013 entries, err := app.scanPromptHistoryFromDir(dir)
10014 if err != nil {
10015 t.Fatal(err)
10016 }
10017 if len(entries) != 3 {
10018 t.Fatalf("expected 3 entries, got %d", len(entries))
10019 }
10020 want := []string{"early second", "early first", "late newest"}
10021 for i, w := range want {
10022 if entries[i].Text != w {
10023 t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries)
10024 }
10025 }
10026 }
10027
10028 func TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {
10029 app := &App{}
10030 dir := t.TempDir()
10031 base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10032 early := filepath.Join(dir, "early.jsonl")
10033 late := filepath.Join(dir, "late.jsonl")
10034
10035 if err := os.WriteFile(early, []byte(`{"role":"user","content":"early first"}
10036 {"role":"assistant","content":"ok"}
10037 {"role":"user","content":"early second"}
10038 `), 0o644); err != nil {
10039 t.Fatal(err)
10040 }
10041 if err := os.WriteFile(late, []byte(`{"role":"user","content":"late newest"}
10042 `), 0o644); err != nil {
10043 t.Fatal(err)
10044 }
10045 if err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{
10046 CreatedAt: base,
10047 UpdatedAt: base.Add(time.Minute),
10048 }); err != nil {
10049 t.Fatal(err)
10050 }
10051 if err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{
10052 CreatedAt: base.Add(time.Minute),
10053 UpdatedAt: base.Add(2 * time.Minute),
10054 }); err != nil {
10055 t.Fatal(err)
10056 }
10057 // Invert file mtimes: branch UpdatedAt should be the activity clock.
10058 if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {
10059 t.Fatal(err)
10060 }
10061 if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {
10062 t.Fatal(err)
10063 }
10064
10065 entries, err := app.scanPromptHistoryFromDir(dir)
10066 if err != nil {
10067 t.Fatal(err)
10068 }
10069 if len(entries) != 3 {
10070 t.Fatalf("expected 3 entries, got %d", len(entries))
10071 }
10072 want := []string{"late newest", "early second", "early first"}
10073 for i, w := range want {
10074 if entries[i].Text != w {
10075 t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries)
10076 }
10077 }
10078 }
10079
10080 func TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {
10081 app := &App{}
10082 dir := t.TempDir()
10083 base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10084 empty := filepath.Join(dir, "empty.jsonl")
10085 real := filepath.Join(dir, "real.jsonl")
10086
10087 if err := os.WriteFile(empty, nil, 0o644); err != nil {
10088 t.Fatal(err)
10089 }
10090 if err := os.WriteFile(real, []byte(`{"role":"user","content":"real prompt"}
10091 `), 0o644); err != nil {
10092 t.Fatal(err)
10093 }
10094 if err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{
10095 CreatedAt: base,
10096 UpdatedAt: base.Add(time.Hour),
10097 }); err != nil {
10098 t.Fatal(err)
10099 }
10100 if err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{
10101 CreatedAt: base,
10102 UpdatedAt: base,
10103 }); err != nil {
10104 t.Fatal(err)
10105 }
10106
10107 entries, err := app.scanPromptHistoryFromDir(dir)
10108 if err != nil {
10109 t.Fatal(err)
10110 }
10111 if len(entries) != 1 || entries[0].Text != "real prompt" {
10112 t.Fatalf("entries = %+v, want only real prompt after skipping empty session", entries)
10113 }
10114 }
10115
10116 func TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {
10117 dir := t.TempDir()
10118 current := filepath.Join(dir, "current.jsonl")
10119 other := filepath.Join(dir, "other.jsonl")
10120 if err := os.WriteFile(current, []byte(`{"role":"user","content":"current first"}
10121 {"role":"assistant","content":"ok"}
10122 {"role":"user","content":"current second"}
10123 `), 0o644); err != nil {
10124 t.Fatal(err)
10125 }
10126 if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"}
10127 `), 0o644); err != nil {
10128 t.Fatal(err)
10129 }
10130 now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10131 if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{
10132 CreatedAt: now,
10133 UpdatedAt: now,
10134 }); err != nil {
10135 t.Fatal(err)
10136 }
10137 if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{
10138 CreatedAt: now.Add(time.Minute),
10139 UpdatedAt: now.Add(time.Minute),
10140 }); err != nil {
10141 t.Fatal(err)
10142 }
10143
10144 app := NewApp()
10145 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"})
10146 defer ctrl.Close()
10147 app.setTestCtrl(ctrl, "")
10148
10149 result, err := app.ScanPromptHistory("")
10150 if err != nil {
10151 t.Fatal(err)
10152 }
10153 if len(result.Entries) != 3 {
10154 t.Fatalf("expected current-session entries followed by cross-session fallback, got %d: %+v", len(result.Entries), result.Entries)
10155 }
10156 want := []string{"current second", "current first", "other newest"}
10157 for i, w := range want {
10158 if result.Entries[i].Text != w {
10159 t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, result.Entries[i].Text, w, result.Entries)
10160 }
10161 }
10162 }
10163
10164 func TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {
10165 dir := t.TempDir()
10166 current := filepath.Join(dir, "current.jsonl")
10167 other := filepath.Join(dir, "other.jsonl")
10168 var lines []byte
10169 for i := range 55 {
10170 lines = append(lines, fmt.Appendf(nil, `{"role":"user","content":"current %d"}
10171 `, i)...)
10172 }
10173 if err := os.WriteFile(current, lines, 0o644); err != nil {
10174 t.Fatal(err)
10175 }
10176 if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"}
10177 `), 0o644); err != nil {
10178 t.Fatal(err)
10179 }
10180 now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10181 if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{
10182 CreatedAt: now,
10183 UpdatedAt: now,
10184 }); err != nil {
10185 t.Fatal(err)
10186 }
10187 if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{
10188 CreatedAt: now.Add(time.Minute),
10189 UpdatedAt: now.Add(time.Minute),
10190 }); err != nil {
10191 t.Fatal(err)
10192 }
10193
10194 app := NewApp()
10195 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"})
10196 defer ctrl.Close()
10197 app.setTestCtrl(ctrl, "")
10198
10199 result, err := app.ScanPromptHistory("")
10200 if err != nil {
10201 t.Fatal(err)
10202 }
10203 if len(result.Entries) != promptHistoryPageLimit {
10204 t.Fatalf("expected %d entries, got %d", promptHistoryPageLimit, len(result.Entries))
10205 }
10206 if result.Entries[0].Text != "current 54" {
10207 t.Fatalf("first entry = %q, want current 54", result.Entries[0].Text)
10208 }
10209 if result.Entries[len(result.Entries)-1].Text != "current 5" {
10210 t.Fatalf("last first-page entry = %q, want current 5", result.Entries[len(result.Entries)-1].Text)
10211 }
10212 if !result.HasOlder || result.OlderCursor == "" {
10213 t.Fatalf("first page should expose an older cursor: %+v", result)
10214 }
10215 for _, entry := range result.Entries {
10216 if entry.Text == "other newest" {
10217 t.Fatalf("cross-session entry appeared before current-session page was exhausted: %+v", result.Entries)
10218 }
10219 }
10220
10221 nextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})
10222 if err != nil {
10223 t.Fatal(err)
10224 }
10225 next, err := app.ScanPromptHistory(string(nextRequest))
10226 if err != nil {
10227 t.Fatal(err)
10228 }
10229 want := []string{"current 4", "current 3", "current 2", "current 1", "current 0", "other newest"}
10230 if len(next.Entries) != len(want) {
10231 t.Fatalf("second page entries = %+v, want %d entries", next.Entries, len(want))
10232 }
10233 for i, w := range want {
10234 if next.Entries[i].Text != w {
10235 t.Fatalf("second page entries[%d] = %q, want %q; all=%+v", i, next.Entries[i].Text, w, next.Entries)
10236 }
10237 }
10238 }
10239
10240 func TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {
10241 app := &App{}
10242 dir := t.TempDir()
10243 var lines []byte
10244 for i := range 250 {
10245 lines = append(lines, fmt.Appendf(nil, `{"role":"user","content":"prompt %d"}
10246 `, i)...)
10247 }
10248 if err := os.WriteFile(filepath.Join(dir, "many.jsonl"), lines, 0o644); err != nil {
10249 t.Fatal(err)
10250 }
10251 entries, err := app.scanPromptHistoryFromDir(dir)
10252 if err != nil {
10253 t.Fatal(err)
10254 }
10255 if len(entries) != 250 {
10256 t.Fatalf("expected 250 entries, got %d", len(entries))
10257 }
10258 if entries[0].Text != "prompt 249" {
10259 t.Errorf("expected newest 'prompt 249' first, got %q", entries[0].Text)
10260 }
10261 }
10262
10263 // TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.
10264 func TestScanPromptHistoryFromDirEmpty(t *testing.T) {
10265 app := &App{}
10266 dir := t.TempDir()
10267 entries, err := app.scanPromptHistoryFromDir(dir)
10268 if err != nil {
10269 t.Fatal(err)
10270 }
10271 if len(entries) != 0 {
10272 t.Errorf("expected 0 entries, got %d", len(entries))
10273 }
10274 }
10275
10276 // TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil
10277 // on cache hit (nonce matches).
10278 func TestScanPromptHistoryCacheHit(t *testing.T) {
10279 app := &App{tabs: map[string]*WorkspaceTab{}}
10280 result, err := app.ScanPromptHistory("")
10281 if err != nil {
10282 t.Fatal(err)
10283 }
10284 nonce := result.Nonce
10285 if nonce == "" {
10286 t.Error("expected a non-empty nonce on first call")
10287 }
10288
10289 // Second call with the same nonce should be a cache hit (nil entries).
10290 result2, err := app.ScanPromptHistory(nonce)
10291 if err != nil {
10292 t.Fatal(err)
10293 }
10294 if result2.Entries != nil {
10295 t.Error("expected nil entries on cache hit")
10296 }
10297 if result2.Nonce != nonce {
10298 t.Errorf("expected nonce %q unchanged, got %q", nonce, result2.Nonce)
10299 }
10300 }
10301
10302 func TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {
10303 dirA := t.TempDir()
10304 dirB := t.TempDir()
10305 pathA := filepath.Join(dirA, "a.jsonl")
10306 pathB := filepath.Join(dirB, "b.jsonl")
10307 if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"}
10308 `), 0o644); err != nil {
10309 t.Fatal(err)
10310 }
10311 if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"}
10312 `), 0o644); err != nil {
10313 t.Fatal(err)
10314 }
10315
10316 app := NewApp()
10317 ctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"})
10318 ctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: "test"})
10319 defer ctrlA.Close()
10320 defer ctrlB.Close()
10321
10322 app.setTestCtrl(ctrlA, "")
10323 first, err := app.ScanPromptHistory("")
10324 if err != nil {
10325 t.Fatal(err)
10326 }
10327 if len(first.Entries) != 1 || first.Entries[0].Text != "workspace A" {
10328 t.Fatalf("first entries = %+v, want workspace A", first.Entries)
10329 }
10330
10331 app.setTestCtrl(ctrlB, "")
10332 second, err := app.ScanPromptHistory(first.Nonce)
10333 if err != nil {
10334 t.Fatal(err)
10335 }
10336 if second.Entries == nil {
10337 t.Fatal("expected rescan after session dir changes, got cache hit")
10338 }
10339 if len(second.Entries) != 1 || second.Entries[0].Text != "workspace B" {
10340 t.Fatalf("second entries = %+v, want workspace B", second.Entries)
10341 }
10342 }
10343
10344 func TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {
10345 dir := t.TempDir()
10346 pathA := filepath.Join(dir, "a.jsonl")
10347 pathB := filepath.Join(dir, "b.jsonl")
10348 if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"session A"}
10349 `), 0o644); err != nil {
10350 t.Fatal(err)
10351 }
10352 if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"session B"}
10353 `), 0o644); err != nil {
10354 t.Fatal(err)
10355 }
10356
10357 app := NewApp()
10358 ctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: "test"})
10359 ctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: "test"})
10360 defer ctrlA.Close()
10361 defer ctrlB.Close()
10362
10363 app.setTestCtrl(ctrlA, "")
10364 first, err := app.ScanPromptHistory("")
10365 if err != nil {
10366 t.Fatal(err)
10367 }
10368 if len(first.Entries) != 2 || first.Entries[0].Text != "session A" || first.Entries[1].Text != "session B" {
10369 t.Fatalf("first entries = %+v, want session A followed by session B", first.Entries)
10370 }
10371
10372 app.setTestCtrl(ctrlB, "")
10373 second, err := app.ScanPromptHistory(first.Nonce)
10374 if err != nil {
10375 t.Fatal(err)
10376 }
10377 if second.Entries == nil {
10378 t.Fatal("expected rescan after session path changes, got cache hit")
10379 }
10380 if len(second.Entries) != 2 || second.Entries[0].Text != "session B" || second.Entries[1].Text != "session A" {
10381 t.Fatalf("second entries = %+v, want session B followed by session A", second.Entries)
10382 }
10383 }
10384
10384 lines GO