返回 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 "strconv"
17 "strings"
18 "sync"
19 "sync/atomic"
20 "testing"
21 "time"
22
23 "reasonix/internal/agent"
24 "reasonix/internal/billing"
25 "reasonix/internal/boot"
26 "reasonix/internal/bot"
27 "reasonix/internal/command"
28 "reasonix/internal/config"
29 "reasonix/internal/control"
30 "reasonix/internal/event"
31 "reasonix/internal/evidence"
32 "reasonix/internal/instruction"
33 "reasonix/internal/jobs"
34 "reasonix/internal/mcplaunch"
35 "reasonix/internal/memory"
36 "reasonix/internal/plugin"
37 "reasonix/internal/pluginpkg"
38 "reasonix/internal/provider"
39 "reasonix/internal/sandbox"
40 "reasonix/internal/skill"
41 "reasonix/internal/store"
42 "reasonix/internal/tool"
43 )
44
45 type todoMetaController struct {
46 control.SessionAPI
47 todos []evidence.TodoItem
48 }
49
50 func (c *todoMetaController) Todos() []evidence.TodoItem {
51 return append([]evidence.TodoItem(nil), c.todos...)
52 }
53
54 func TestCanonicalTodosMetaWireContract(t *testing.T) {
55 if got := ctrlTodos(nil); got != nil {
56 t.Fatalf("nil controller todos = %+v, want unavailable", *got)
57 }
58
59 empty := Meta{CanonicalTodos: ctrlTodos(&todoMetaController{})}
60 raw, err := json.Marshal(empty)
61 if err != nil {
62 t.Fatalf("marshal empty canonical todos: %v", err)
63 }
64 if !strings.Contains(string(raw), `"canonicalTodos":[]`) {
65 t.Fatalf("empty canonical todos must encode as an authoritative empty array: %s", raw)
66 }
67
68 ctrl := &todoMetaController{todos: []evidence.TodoItem{{Content: "Ship", Status: "completed"}}}
69 got := ctrlTodos(ctrl)
70 if got == nil || len(*got) != 1 || (*got)[0].Status != "completed" {
71 t.Fatalf("canonical todos = %+v, want completed task", got)
72 }
73
74 unavailable, err := json.Marshal(Meta{CanonicalTodos: ctrlTodos(nil)})
75 if err != nil {
76 t.Fatalf("marshal unavailable canonical todos: %v", err)
77 }
78 if strings.Contains(string(unavailable), "canonicalTodos") {
79 t.Fatalf("unavailable canonical todos should preserve the legacy fallback contract: %s", unavailable)
80 }
81 }
82
83 func TestPluginToolsToViewPreservesSchemaError(t *testing.T) {
84 got := pluginToolsToView([]plugin.ToolInfo{{
85 Name: "generate_yso_bytes", Description: "Generate payload", ReadOnlyHint: true,
86 SchemaError: "invalid input schema: bad nested type",
87 }})
88 if len(got) != 1 || got[0].SchemaError != "invalid input schema: bad nested type" {
89 t.Fatalf("tool views = %+v", got)
90 }
91 }
92
93 func desktopMCPHTTPServer(t *testing.T) *httptest.Server {
94 return desktopMCPHTTPServerWithTool(t, "h", "greet")
95 }
96
97 func desktopMCPHTTPServerWithTool(t *testing.T, serverName, toolName string) *httptest.Server {
98 t.Helper()
99 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100 var req struct {
101 ID *int `json:"id"`
102 Method string `json:"method"`
103 Params json.RawMessage `json:"params"`
104 }
105 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
106 http.Error(w, "bad body", http.StatusBadRequest)
107 return
108 }
109 if req.ID == nil {
110 w.WriteHeader(http.StatusAccepted)
111 return
112 }
113 var result any
114 switch req.Method {
115 case "initialize":
116 result = map[string]any{
117 "protocolVersion": "2024-11-05",
118 "serverInfo": map[string]any{"name": serverName, "version": "0"},
119 }
120 case "tools/list":
121 result = map[string]any{"tools": []map[string]any{{
122 "name": toolName,
123 "description": "Greet someone.",
124 "inputSchema": map[string]any{"type": "object"},
125 }}}
126 default:
127 result = map[string]any{}
128 }
129 resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
130 w.Header().Set("Content-Type", "application/json")
131 _ = json.NewEncoder(w).Encode(resp)
132 }))
133 }
134
135 func TestDesktopMCPHelperProcess(t *testing.T) {
136 if os.Getenv("GO_WANT_DESKTOP_MCP_HELPER") != "1" {
137 return
138 }
139 if addr := os.Getenv("DESKTOP_MCP_START_GATE_ADDR"); addr != "" {
140 conn, err := net.Dial("tcp", addr)
141 if err != nil {
142 _, _ = fmt.Fprintf(os.Stderr, "connect MCP start gate %s: %v\n", addr, err)
143 os.Exit(24)
144 }
145 var release [1]byte
146 if _, err := io.ReadFull(conn, release[:]); err != nil {
147 _ = conn.Close()
148 _, _ = fmt.Fprintf(os.Stderr, "wait for MCP start gate %s: %v\n", addr, err)
149 os.Exit(25)
150 }
151 _ = conn.Close()
152 }
153 var instanceListener net.Listener
154 if addr := os.Getenv("DESKTOP_MCP_SINGLE_INSTANCE_ADDR"); addr != "" {
155 var err error
156 instanceListener, err = net.Listen("tcp", addr)
157 if err != nil {
158 _, _ = fmt.Fprintf(os.Stderr, "another MCP instance is already using %s: %v\n", addr, err)
159 os.Exit(23)
160 }
161 defer instanceListener.Close()
162 }
163 dec := json.NewDecoder(os.Stdin)
164 enc := json.NewEncoder(os.Stdout)
165 for {
166 var req struct {
167 ID *int `json:"id"`
168 Method string `json:"method"`
169 }
170 if err := dec.Decode(&req); err != nil {
171 if errors.Is(err, io.EOF) {
172 return
173 }
174 t.Fatalf("decode helper request: %v", err)
175 }
176 if req.ID == nil {
177 continue
178 }
179 var result any
180 switch req.Method {
181 case "initialize":
182 result = map[string]any{
183 "protocolVersion": "2024-11-05",
184 "serverInfo": map[string]any{"name": "desktop-helper", "version": "0"},
185 }
186 case "tools/list":
187 result = map[string]any{"tools": []map[string]any{{
188 "name": "greet", "description": "Greet someone.",
189 "inputSchema": map[string]any{"type": "object"},
190 }}}
191 default:
192 result = map[string]any{}
193 }
194 if err := enc.Encode(map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}); err != nil {
195 t.Fatalf("encode helper response: %v", err)
196 }
197 }
198 }
199
200 // setTestCtrl creates a minimal workspace tab (if needed) and sets its
201 // controller, so tests don't depend on the old App.ctrl field.
202 func (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {
203 if len(a.tabs) == 0 {
204 tab := &WorkspaceTab{
205 ID: "test",
206 Scope: "global",
207 Ready: true,
208 disabledMCP: map[string]ServerView{},
209 }
210 a.tabs = map[string]*WorkspaceTab{"test": tab}
211 a.activeTabID = "test"
212 }
213 tab := a.tabs["test"]
214 tab.Ctrl = ctrl
215 a.bindControllerDisplayRecorder(ctrl)
216 tab.model = model
217 }
218
219 func isolateDesktopUserDirs(t *testing.T) string {
220 t.Helper()
221 home := robustTempDir(t)
222 xdg := filepath.Join(home, ".config")
223 appData := filepath.Join(home, "AppData")
224 for _, dir := range []string{xdg, appData} {
225 if err := os.MkdirAll(dir, 0o755); err != nil {
226 t.Fatal(err)
227 }
228 }
229 t.Setenv("HOME", home)
230 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
231 t.Setenv("USERPROFILE", home)
232 t.Setenv("XDG_CONFIG_HOME", xdg)
233 t.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
234 t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
235 t.Setenv("AppData", appData)
236 return home
237 }
238
239 func primarySessionFiles(paths []string) []string {
240 out := make([]string, 0, len(paths))
241 for _, path := range paths {
242 if store.IsSessionTranscriptName(filepath.Base(path)) {
243 out = append(out, path)
244 }
245 }
246 return out
247 }
248
249 func readConflictLogLines(t *testing.T, path string) []string {
250 t.Helper()
251 data, err := os.ReadFile(path)
252 if err != nil {
253 t.Fatalf("read conflict log: %v", err)
254 }
255 text := strings.TrimSpace(string(data))
256 if text == "" {
257 return nil
258 }
259 return strings.Split(text, "\n")
260 }
261
262 func setDesktopTestCredential(t *testing.T, key, value string) {
263 t.Helper()
264 if _, err := config.SetCredential(key, value); err != nil {
265 t.Fatalf("SetCredential(%s): %v", key, err)
266 }
267 }
268
269 func TestNeedsOnboardingIgnoresInheritedEnv(t *testing.T) {
270 isolateDesktopUserDirs(t)
271 t.Setenv(onboardingKeyEnv, "inherited-key")
272
273 app := NewApp()
274 if !app.NeedsOnboarding() {
275 t.Fatal("NeedsOnboarding should require a key saved in Reasonix global .env")
276 }
277 setDesktopTestCredential(t, onboardingKeyEnv, "saved-key")
278 if app.NeedsOnboarding() {
279 t.Fatal("NeedsOnboarding should be false after saving the global credential")
280 }
281 }
282
283 func TestNeedsOnboardingTreatsBlankSavedKeyAsMissing(t *testing.T) {
284 isolateDesktopUserDirs(t)
285 if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
286 t.Fatal(err)
287 }
288 if err := os.WriteFile(config.UserCredentialsPath(), []byte(onboardingKeyEnv+"=\n"), 0o600); err != nil {
289 t.Fatal(err)
290 }
291
292 app := NewApp()
293 if !app.NeedsOnboarding() {
294 t.Fatal("NeedsOnboarding should require a non-empty saved credential")
295 }
296 }
297
298 func TestNeedsOnboardingAcceptsConfiguredCustomProvider(t *testing.T) {
299 isolateDesktopUserDirs(t)
300 cfg := config.Default()
301 cfg.DefaultModel = "custom/custom-model"
302 cfg.Desktop.ProviderAccess = []string{"custom"}
303 cfg.Providers = []config.ProviderEntry{{
304 Name: "custom", Kind: "openai", BaseURL: "https://models.example.invalid/v1",
305 Model: "custom-model", APIKeyEnv: "CUSTOM_API_KEY",
306 }}
307 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
308 t.Fatalf("save custom provider config: %v", err)
309 }
310 setDesktopTestCredential(t, "CUSTOM_API_KEY", "saved-custom-key")
311
312 if NewApp().NeedsOnboarding() {
313 t.Fatal("NeedsOnboarding should be false when a custom provider is configured")
314 }
315 }
316
317 func TestNeedsOnboardingAcceptsNoAuthLocalProvider(t *testing.T) {
318 isolateDesktopUserDirs(t)
319 cfg := config.Default()
320 cfg.DefaultModel = "local/local-model"
321 cfg.Desktop.ProviderAccess = []string{"local"}
322 cfg.Providers = []config.ProviderEntry{{
323 Name: "local", Kind: "openai", BaseURL: "http://127.0.0.1:11434/v1",
324 Model: "local-model",
325 }}
326 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
327 t.Fatalf("save local provider config: %v", err)
328 }
329
330 if NewApp().NeedsOnboarding() {
331 t.Fatal("NeedsOnboarding should be false for a no-auth local provider")
332 }
333 }
334
335 func providerNamesFromView(providers []ProviderView) []string {
336 out := make([]string, 0, len(providers))
337 for _, p := range providers {
338 out = append(out, p.Name)
339 }
340 return out
341 }
342
343 func modelRefsFromView(models []ModelInfo) map[string]bool {
344 out := map[string]bool{}
345 for _, m := range models {
346 out[m.Ref] = true
347 }
348 return out
349 }
350
351 type desktopFakeTool struct {
352 name string
353 }
354
355 func (t desktopFakeTool) Name() string { return t.name }
356
357 func (desktopFakeTool) Description() string { return "fake desktop tool" }
358
359 func (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
360
361 func (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return "", nil }
362
363 func (desktopFakeTool) ReadOnly() bool { return true }
364
365 type desktopAskRuntimeRunner struct {
366 ask func(context.Context) error
367 }
368
369 func (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {
370 if r.ask == nil {
371 return nil
372 }
373 return r.ask(ctx)
374 }
375
376 func TestCommandsIncludesDocsAndEffortNotThinking(t *testing.T) {
377 app := NewApp()
378 cmds := app.Commands()
379 if !hasCommand(cmds, "docs") {
380 t.Fatalf("Commands() should include docs: %+v", cmds)
381 }
382 if !hasCommand(cmds, "effort") {
383 t.Fatalf("Commands() should include effort: %+v", cmds)
384 }
385 if hasCommand(cmds, "thinking") {
386 t.Fatalf("Commands() should not include thinking: %+v", cmds)
387 }
388 }
389
390 func TestCommandsDocsShowsOnlyRuntimeWinner(t *testing.T) {
391 tests := []struct {
392 name string
393 commands []command.Command
394 skills []skill.Skill
395 wantKind string
396 }{
397 {
398 name: "custom command shadows builtin",
399 commands: []command.Command{{Name: "docs", Description: "custom docs"}},
400 wantKind: "custom",
401 },
402 {
403 name: "skill shadows builtin",
404 skills: []skill.Skill{{Name: "docs", Description: "docs skill"}},
405 wantKind: "skill",
406 },
407 {
408 name: "custom command shadows skill and builtin",
409 commands: []command.Command{{Name: "docs", Description: "custom docs"}},
410 skills: []skill.Skill{{Name: "docs", Description: "docs skill"}},
411 wantKind: "custom",
412 },
413 }
414 for _, tt := range tests {
415 t.Run(tt.name, func(t *testing.T) {
416 ctrl := control.New(control.Options{Commands: tt.commands, Skills: tt.skills})
417 defer ctrl.Close()
418 app := NewApp()
419 app.setTestCtrl(ctrl, "")
420
421 var docs []CommandInfo
422 for _, cmd := range app.Commands() {
423 if cmd.Name == "docs" {
424 docs = append(docs, cmd)
425 }
426 }
427 if len(docs) != 1 || docs[0].Kind != tt.wantKind {
428 t.Fatalf("docs commands = %+v, want one %s entry", docs, tt.wantKind)
429 }
430 if fallback, ok := commandInfoByName(app.Commands(), control.ReasonixDocsSlashName); !ok || fallback.Kind != "builtin" {
431 t.Fatalf("qualified docs fallback = %+v, %v; want built-in", fallback, ok)
432 }
433 })
434 }
435 }
436
437 func commandInfoByName(commands []CommandInfo, name string) (CommandInfo, bool) {
438 for _, command := range commands {
439 if command.Name == name {
440 return command, true
441 }
442 }
443 return CommandInfo{}, false
444 }
445
446 func TestCommandsDocsAccountsForHiddenCompatibilityAliases(t *testing.T) {
447 tests := []struct {
448 name string
449 commands []command.Command
450 skills []skill.Skill
451 wantCanonical string
452 }{
453 {
454 name: "hidden plugin command alias",
455 commands: []command.Command{
456 {Name: "docs", Plugin: "manuals", Hidden: true},
457 {Name: "manuals:docs", Plugin: "manuals"},
458 },
459 wantCanonical: "manuals:docs",
460 },
461 {
462 name: "compatible plugin skill alias",
463 skills: []skill.Skill{{Name: "docs", Plugin: "manuals"}},
464 wantCanonical: "manuals:docs",
465 },
466 }
467
468 for _, tt := range tests {
469 t.Run(tt.name, func(t *testing.T) {
470 ctrl := control.New(control.Options{Commands: tt.commands, Skills: tt.skills})
471 defer ctrl.Close()
472 app := NewApp()
473 app.setTestCtrl(ctrl, "")
474 commands := app.Commands()
475 if _, ok := commandInfoByName(commands, "docs"); ok {
476 t.Fatalf("hidden runtime owner left a misleading docs entry: %+v", commands)
477 }
478 for _, want := range []string{control.ReasonixDocsSlashName, tt.wantCanonical} {
479 if _, ok := commandInfoByName(commands, want); !ok {
480 t.Fatalf("commands missing %q: %+v", want, commands)
481 }
482 }
483 })
484 }
485 }
486
487 func TestCommandsDocsDoesNotDisplaceQualifiedCustomCommands(t *testing.T) {
488 ctrl := control.New(control.Options{Commands: []command.Command{
489 {Name: "docs", Description: "custom docs"},
490 {Name: "reasonix:docs", Description: "qualified custom docs"},
491 {Name: "reasonix:builtin:docs", Description: "second qualified custom docs"},
492 }})
493 defer ctrl.Close()
494 app := NewApp()
495 app.setTestCtrl(ctrl, "")
496 commands := app.Commands()
497 for _, want := range []struct {
498 name string
499 kind string
500 }{
501 {name: "docs", kind: "custom"},
502 {name: "reasonix:docs", kind: "custom"},
503 {name: "reasonix:builtin:docs", kind: "custom"},
504 {name: "reasonix:builtin:docs:2", kind: "builtin"},
505 } {
506 if command, ok := commandInfoByName(commands, want.name); !ok || command.Kind != want.kind {
507 t.Fatalf("command %q = %+v, %v; want kind %q", want.name, command, ok, want.kind)
508 }
509 }
510 }
511
512 func TestCommandsClassifiesSubagentSkills(t *testing.T) {
513 ctrl := control.New(control.Options{Skills: []skill.Skill{
514 {Name: "init", Description: "inline skill", RunAs: skill.RunInline},
515 {Name: "explore", Description: "isolated skill", RunAs: skill.RunSubagent, Color: "amber"},
516 }})
517 defer ctrl.Close()
518 app := NewApp()
519 app.setTestCtrl(ctrl, "")
520
521 kinds := map[string]string{}
522 groups := map[string]string{}
523 colors := map[string]string{}
524 for _, cmd := range app.Commands() {
525 kinds[cmd.Name] = cmd.Kind
526 groups[cmd.Name] = cmd.Group
527 colors[cmd.Name] = cmd.Color
528 }
529 if kinds["init"] != "skill" {
530 t.Fatalf("inline skill kind = %q, want skill", kinds["init"])
531 }
532 if kinds["explore"] != "subagent" {
533 t.Fatalf("subagent skill kind = %q, want subagent", kinds["explore"])
534 }
535 if colors["explore"] != "amber" {
536 t.Fatalf("subagent skill color = %q, want amber", colors["explore"])
537 }
538 if groups["new"] != "actions" {
539 t.Fatalf("new command group = %q, want actions", groups["new"])
540 }
541 if groups["mcp"] != "integrations" || groups["plugins"] != "integrations" {
542 t.Fatalf("integration command groups = mcp:%q plugins:%q", groups["mcp"], groups["plugins"])
543 }
544 if groups["skill"] != "skills" {
545 t.Fatalf("skill command group = %q, want skills", groups["skill"])
546 }
547 }
548
549 func TestMetaForTabIncludesWorkspaceContext(t *testing.T) {
550 if _, err := exec.LookPath("git"); err != nil {
551 t.Skip("git not installed")
552 }
553 isolateDesktopUserDirs(t)
554 resetWorkspaceGitBranchMetaCacheForTest(t)
555
556 repo := t.TempDir()
557 configuredSandboxRoot := filepath.Join(t.TempDir(), "sandbox")
558 cfg := config.LoadForEdit(config.UserConfigPath())
559 cfg.Sandbox.WorkspaceRoot = configuredSandboxRoot
560 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
561 t.Fatal(err)
562 }
563
564 orig, err := os.Getwd()
565 if err != nil {
566 t.Fatal(err)
567 }
568 defer func() {
569 if err := os.Chdir(orig); err != nil {
570 t.Fatal(err)
571 }
572 }()
573 if err := os.Chdir(repo); err != nil {
574 t.Fatal(err)
575 }
576 runGit(t, "init")
577 runGit(t, "checkout", "-b", "feature/meta")
578
579 app := NewApp()
580 app.tabs = map[string]*WorkspaceTab{"tab-1": {
581 ID: "tab-1",
582 Scope: "project",
583 WorkspaceRoot: repo,
584 Ready: true,
585 disabledMCP: map[string]ServerView{},
586 }}
587 app.activeTabID = "tab-1"
588
589 got := app.MetaForTab("tab-1")
590 if got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {
591 t.Fatalf("workspace fields = cwd:%q root:%q path:%q, want %q", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)
592 }
593 if got.WorkspaceName != filepath.Base(repo) {
594 t.Fatalf("workspaceName = %q, want %q", got.WorkspaceName, filepath.Base(repo))
595 }
596 raw, err := json.Marshal(got)
597 if err != nil {
598 t.Fatalf("marshal meta: %v", err)
599 }
600 if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
601 t.Fatalf("meta should not expose configured sandbox root as sandboxPath: %s", raw)
602 }
603 // The first git process launch can be noticeably slower on Windows runners
604 // while MetaForTab intentionally keeps the caller path non-blocking.
605 deadline := time.Now().Add(5 * time.Second)
606 for {
607 if got = app.MetaForTab("tab-1"); got.GitBranch == "feature/meta" {
608 break
609 }
610 if time.Now().After(deadline) {
611 t.Fatalf("gitBranch = %q, want feature/meta after async refresh", got.GitBranch)
612 }
613 time.Sleep(10 * time.Millisecond)
614 }
615 }
616
617 func TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {
618 isolateDesktopUserDirs(t)
619 workspace := t.TempDir()
620 configuredSandboxRoot := filepath.Join(t.TempDir(), "sandbox")
621 cfg := config.LoadForEdit(config.UserConfigPath())
622 cfg.Sandbox.WorkspaceRoot = configuredSandboxRoot
623 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
624 t.Fatal(err)
625 }
626
627 app := NewApp()
628 app.tabs = map[string]*WorkspaceTab{"tab-1": {
629 ID: "tab-1",
630 Scope: "project",
631 WorkspaceRoot: workspace,
632 Ready: true,
633 disabledMCP: map[string]ServerView{},
634 }}
635 app.activeTabID = "tab-1"
636 app.tabOrder = []string{"tab-1"}
637
638 raw, err := json.Marshal(app.ListTabs())
639 if err != nil {
640 t.Fatalf("marshal tabs: %v", err)
641 }
642 if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
643 t.Fatalf("tab metadata should not expose configured sandbox root as sandboxPath: %s", raw)
644 }
645 }
646
647 func TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {
648 asks := make(chan event.Ask, 1)
649 done := make(chan event.Event, 1)
650 runner := &desktopAskRuntimeRunner{}
651 ctrl := control.New(control.Options{
652 Runner: runner,
653 Sink: event.FuncSink(func(e event.Event) {
654 switch e.Kind {
655 case event.AskRequest:
656 asks <- e.Ask
657 case event.TurnDone:
658 done <- e
659 }
660 }),
661 })
662 runner.ask = func(ctx context.Context) error {
663 _, err := ctrl.Ask(ctx, []event.AskQuestion{{
664 ID: "choice",
665 Prompt: "Pick one",
666 Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
667 }})
668 return err
669 }
670
671 app := NewApp()
672 app.setTestCtrl(ctrl, "prov/model")
673 app.tabOrder = []string{"test"}
674 ctrl.Send("ask user")
675 select {
676 case <-asks:
677 case <-time.After(2 * time.Second):
678 t.Fatal("timed out waiting for ask request")
679 }
680
681 tabs := app.ListTabs()
682 if len(tabs) != 1 {
683 t.Fatalf("tabs = %d, want 1", len(tabs))
684 }
685 if !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {
686 t.Fatalf("tab runtime = running:%v pending:%v cancellable:%v cancel:%v", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)
687 }
688
689 app.CancelTab("test")
690 select {
691 case <-done:
692 case <-time.After(2 * time.Second):
693 t.Fatal("timed out waiting for turn_done")
694 }
695 }
696
697 func TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {
698 isolateDesktopUserDirs(t)
699 workspace := t.TempDir()
700 app := NewApp()
701 app.tabs = map[string]*WorkspaceTab{"tab-1": {
702 ID: "tab-1",
703 Scope: "project",
704 WorkspaceRoot: workspace,
705 Ready: true,
706 disabledMCP: map[string]ServerView{},
707 }}
708 app.activeTabID = "tab-1"
709
710 if got := app.MetaForTab("tab-1"); got.GitBranch != "" {
711 t.Fatalf("gitBranch = %q, want empty", got.GitBranch)
712 }
713 }
714
715 func TestEffortDefaultsBeforeStartup(t *testing.T) {
716 isolateDesktopUserDirs(t)
717
718 got := NewApp().Effort()
719 if !got.Supported || got.Current != "auto" || got.Default != "high" || !hasLevel(got.Levels, "auto") {
720 t.Fatalf("pre-startup Effort() = %+v, want auto with DeepSeek default high", got)
721 }
722 }
723
724 func TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {
725 isolateDesktopUserDirs(t)
726
727 view := NewApp().Memory()
728 if view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil || view.InstructionDiagnostics == nil || view.Conflicts == nil || view.LastRecall.Hits == nil {
729 t.Fatalf("Memory() arrays must be non-nil before startup: %+v", view)
730 }
731 raw, err := json.Marshal(view)
732 if err != nil {
733 t.Fatalf("marshal Memory(): %v", err)
734 }
735 for _, bad := range []string{`"docs":null`, `"facts":null`, `"archives":null`, `"scopes":null`, `"instructionDiagnostics":null`, `"conflicts":null`, `"hits":null`} {
736 if strings.Contains(string(raw), bad) {
737 t.Fatalf("Memory() JSON contains %s; frontend expects []: %s", bad, raw)
738 }
739 }
740 if revisions := NewApp().MemoryRevisions("missing"); revisions == nil {
741 t.Fatal("MemoryRevisions must return [] before startup, not nil")
742 }
743 }
744
745 func TestMemoryViewIncludesRecallFreshnessAndOverrides(t *testing.T) {
746 isolateDesktopUserDirs(t)
747 root := t.TempDir()
748 store := memory.Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")}
749 if _, err := (memory.Store{Dir: store.GlobalDir}).Save(memory.Memory{
750 Name: "deploy-target", Title: "Deploy target", Description: "legacy deployment target", Scope: memory.FactScopeGlobal, Type: memory.TypeProject, Body: "Deploy payments to the legacy cluster.",
751 }); err != nil {
752 t.Fatal(err)
753 }
754 if _, err := (memory.Store{Dir: store.Dir}).Save(memory.Memory{
755 Name: "deploy-target", Title: "Deploy target", Description: "current deployment target", Scope: memory.FactScopeProject, Type: memory.TypeProject, Body: "Deploy payments to the green cluster.",
756 }); err != nil {
757 t.Fatal(err)
758 }
759 ctrl := control.New(control.Options{Memory: &memory.Set{Store: store}})
760 ctrl.Compose("deploy payments target cluster")
761 app := NewApp()
762 app.setTestCtrl(ctrl, "test-model")
763
764 view := app.Memory()
765 if len(view.Facts) != 2 || view.Facts[0].Freshness == "" || view.Facts[1].Freshness == "" {
766 t.Fatalf("facts with freshness = %+v", view.Facts)
767 }
768 if len(view.Conflicts) != 1 || view.Conflicts[0].Resolution != "project_over_global" {
769 t.Fatalf("conflicts = %+v", view.Conflicts)
770 }
771 if view.LastRecall.Query != "deploy payments target cluster" || len(view.LastRecall.Hits) != 1 || view.LastRecall.Hits[0].Scope != "project" {
772 t.Fatalf("last recall = %+v", view.LastRecall)
773 }
774 }
775
776 func TestMemoryRevisionAPIRestoresSelectedRevision(t *testing.T) {
777 isolateDesktopUserDirs(t)
778 store := memory.Store{Dir: t.TempDir()}
779 first, err := store.SaveWithOptions(memory.Memory{Name: "fact", Description: "one", Body: "v1"}, memory.SaveOptions{})
780 if err != nil {
781 t.Fatal(err)
782 }
783 if _, err := store.SaveWithOptions(memory.Memory{ID: first.Memory.ID, Name: "fact", Description: "two", Body: "v2"}, memory.SaveOptions{}); err != nil {
784 t.Fatal(err)
785 }
786 app := NewApp()
787 app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{Store: store}}), "test-model")
788
789 revisions := app.MemoryRevisions(first.Memory.ID)
790 if len(revisions) != 1 || revisions[0].Revision != 1 {
791 t.Fatalf("revisions = %+v", revisions)
792 }
793 restored, err := app.RestoreMemoryRevision(first.Memory.ID, 1)
794 if err != nil {
795 t.Fatal(err)
796 }
797 if restored.Revision != 3 || restored.Body != "v1" {
798 t.Fatalf("restored = %+v", restored)
799 }
800 }
801
802 func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
803 isolateDesktopUserDirs(t)
804 userDir := t.TempDir()
805 cwd := t.TempDir()
806 store := memory.Store{Dir: filepath.Join(userDir, "projects", "test", "memory")}
807 if _, err := store.Save(memory.Memory{
808 Name: "active-fact",
809 Title: "Active fact",
810 Description: "Still applies",
811 Type: memory.TypeProject,
812 Body: "Active body",
813 }); err != nil {
814 t.Fatal(err)
815 }
816 if _, err := store.Save(memory.Memory{
817 Name: "archived-fact",
818 Description: "No longer applies",
819 Type: memory.TypeFeedback,
820 Body: "Archived body",
821 }); err != nil {
822 t.Fatal(err)
823 }
824 if _, err := store.Archive("archived-fact"); err != nil {
825 t.Fatalf("Archive: %v", err)
826 }
827
828 app := NewApp()
829 app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{
830 Docs: []memory.Source{{
831 Path: filepath.Join(cwd, "AGENTS.md"), Scope: memory.ScopeProject, Directory: cwd,
832 Body: "Project instructions", Imports: []instruction.Import{{Path: filepath.Join(cwd, "shared.md"), SourcePath: filepath.Join(cwd, "AGENTS.md")}},
833 }},
834 InstructionDiagnostics: []instruction.Diagnostic{{Code: "import_cycle", Path: "shared.md", SourcePath: filepath.Join(cwd, "AGENTS.md"), Line: 3, Message: "cycle"}},
835 Store: store, CWD: cwd, UserDir: userDir,
836 }}), "test-model")
837
838 view := app.Memory()
839 if !view.Available || view.StoreDir != store.Dir {
840 t.Fatalf("Memory() availability/store = %v/%q, want true/%q", view.Available, view.StoreDir, store.Dir)
841 }
842 if len(view.Docs) != 1 || view.Docs[0].Scope != "project" || !strings.Contains(view.Docs[0].Body, "Project instructions") {
843 t.Fatalf("Memory() docs = %+v", view.Docs)
844 }
845 if view.Docs[0].Directory != cwd || len(view.Docs[0].Imports) != 1 || len(view.InstructionDiagnostics) != 1 || view.InstructionDiagnostics[0].Code != "import_cycle" {
846 t.Fatalf("Memory() instruction provenance = docs %+v diagnostics %+v", view.Docs, view.InstructionDiagnostics)
847 }
848 if len(view.Facts) != 1 || view.Facts[0].Name != "active-fact" || view.Facts[0].Type != "project" || view.Facts[0].Scope != "project" {
849 t.Fatalf("Memory() active facts = %+v", view.Facts)
850 }
851 if view.Facts[0].ID == "" || view.Facts[0].Revision != 1 || view.Facts[0].CreatedAt == "" || view.Facts[0].UpdatedAt == "" {
852 t.Fatalf("Memory() active fact metadata = %+v", view.Facts[0])
853 }
854 if len(view.Archives) != 1 || view.Archives[0].Name != "archived-fact" || view.Archives[0].Type != "feedback" || view.Archives[0].Scope != "project" ||
855 view.Archives[0].Path == "" || view.Archives[0].ArchivedAt == "" {
856 t.Fatalf("Memory() archived facts = %+v", view.Archives)
857 }
858 if view.Archives[0].ID == "" || view.Archives[0].Revision != 1 || view.Archives[0].CreatedAt == "" || view.Archives[0].UpdatedAt == "" {
859 t.Fatalf("Memory() archived fact metadata = %+v", view.Archives[0])
860 }
861 if len(view.Scopes) != 3 {
862 t.Fatalf("Memory() scopes = %+v, want user/project/local", view.Scopes)
863 }
864 }
865
866 func TestRestoreArchivedMemoryRecoversFactForCurrentSession(t *testing.T) {
867 isolateDesktopUserDirs(t)
868 userDir := t.TempDir()
869 cwd := t.TempDir()
870 store := memory.StoreFor(userDir, cwd)
871 first, err := store.SaveWithOptions(memory.Memory{
872 Name: "restorable-fact", Description: "recover me", Body: "Recovered guidance.",
873 }, memory.SaveOptions{})
874 if err != nil {
875 t.Fatal(err)
876 }
877 archivePath, err := store.Archive(first.Memory.ID)
878 if err != nil {
879 t.Fatal(err)
880 }
881
882 app := NewApp()
883 app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir}}), "test-model")
884 if _, err := app.RestoreArchivedMemory(archivePath); err != nil {
885 t.Fatal(err)
886 }
887 view := app.Memory()
888 if len(view.Facts) != 1 || view.Facts[0].ID != first.Memory.ID || view.Facts[0].Revision != 2 {
889 t.Fatalf("restored memory view = %+v", view)
890 }
891 if len(view.Archives) != 0 {
892 t.Fatalf("restored archive remained visible: %+v", view.Archives)
893 }
894 }
895
896 func TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {
897 isolateDesktopUserDirs(t)
898 consumeSystemQuitRequested()
899 t.Cleanup(func() { consumeSystemQuitRequested() })
900
901 userCfg := config.LoadForEdit(config.UserConfigPath())
902 if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
903 t.Fatal(err)
904 }
905 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
906 t.Fatal(err)
907 }
908
909 markSystemQuitRequested()
910 if prevent := NewApp().beforeClose(context.Background()); prevent {
911 t.Fatal("system quit should bypass background close-to-tray behavior")
912 }
913 if consumeSystemQuitRequested() {
914 t.Fatal("system quit marker should be consumed by beforeClose")
915 }
916 }
917
918 func TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {
919 tests := []struct {
920 goos string
921 want bool
922 }{
923 {goos: "darwin", want: true},
924 {goos: "windows", want: false},
925 {goos: "linux", want: false},
926 {goos: "freebsd", want: false},
927 }
928 for _, tt := range tests {
929 if got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {
930 t.Fatalf("backgroundCloseUsesApplicationHide(%q) = %v, want %v", tt.goos, got, tt.want)
931 }
932 }
933 }
934
935 func TestBackgroundCloseRequiresRestorePath(t *testing.T) {
936 tests := []struct {
937 name string
938 goos string
939 trayStarted bool
940 trayReady bool
941 want bool
942 }{
943 {name: "macOS restores from Dock", goos: "darwin", trayStarted: false, trayReady: false, want: true},
944 {name: "Windows tray ready", goos: "windows", trayStarted: true, trayReady: true, want: true},
945 {name: "Windows tray started but not ready", goos: "windows", trayStarted: true, trayReady: false, want: false},
946 {name: "Linux tray ready", goos: "linux", trayStarted: true, trayReady: true, want: true},
947 {name: "Linux tray started but not ready", goos: "linux", trayStarted: true, trayReady: false, want: false},
948 {name: "Linux no tray", goos: "linux", trayStarted: false, trayReady: false, want: false},
949 {name: "other Unix no tray", goos: "freebsd", trayStarted: false, trayReady: false, want: false},
950 }
951 for _, tt := range tests {
952 t.Run(tt.name, func(t *testing.T) {
953 if got := backgroundCloseHasRestorePathFor(tt.goos, tt.trayStarted, tt.trayReady); got != tt.want {
954 t.Fatalf("backgroundCloseHasRestorePathFor(%q, %v, %v) = %v, want %v", tt.goos, tt.trayStarted, tt.trayReady, got, tt.want)
955 }
956 })
957 }
958 }
959
960 func TestBackgroundCloseReadySignalRequiresCurrentReadyState(t *testing.T) {
961 app := NewApp()
962 tray := newDesktopTray()
963 app.mu.Lock()
964 app.tray = tray
965 app.mu.Unlock()
966
967 if app.waitForTrayReady(0) {
968 t.Fatal("tray should not be ready before its ready signal")
969 }
970
971 tray.markReady()
972 if app.waitForTrayReady(0) {
973 t.Fatal("closed ready signal should not count without the current ready state")
974 }
975
976 app.mu.Lock()
977 app.trayReady = true
978 app.mu.Unlock()
979 if !app.waitForTrayReady(0) {
980 t.Fatal("ready state should be accepted after the tray is marked ready")
981 }
982
983 app.mu.Lock()
984 app.trayReady = false
985 app.mu.Unlock()
986 if app.waitForTrayReady(0) {
987 t.Fatal("stale ready signal should not count after the tray exits")
988 }
989 }
990
991 func TestBackgroundCloseWaitsForTrayReadySignal(t *testing.T) {
992 app := NewApp()
993 tray := newDesktopTray()
994 app.mu.Lock()
995 app.tray = tray
996 app.mu.Unlock()
997
998 go func() {
999 time.Sleep(10 * time.Millisecond)
1000 app.mu.Lock()
1001 app.trayReady = true
1002 app.mu.Unlock()
1003 tray.markReady()
1004 }()
1005
1006 if !app.waitForTrayReady(200 * time.Millisecond) {
1007 t.Fatal("waitForTrayReady should observe the tray becoming ready")
1008 }
1009 }
1010
1011 func TestBackgroundRestoreMaximiseStrategy(t *testing.T) {
1012 tests := []struct {
1013 goos string
1014 maximised bool
1015 want bool
1016 }{
1017 {goos: "windows", maximised: true, want: true},
1018 {goos: "linux", maximised: true, want: true},
1019 {goos: "darwin", maximised: true, want: false},
1020 {goos: "windows", maximised: false, want: false},
1021 }
1022 for _, tt := range tests {
1023 if got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {
1024 t.Fatalf("backgroundRestoreShouldMaximise(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want)
1025 }
1026 }
1027 }
1028
1029 func TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {
1030 tests := []struct {
1031 name string
1032 goos string
1033 maximised bool
1034 want backgroundRestorePlan
1035 }{
1036 {
1037 name: "maximised Windows window",
1038 goos: "windows",
1039 maximised: true,
1040 want: backgroundRestorePlan{maximiseBeforeShow: true},
1041 },
1042 {
1043 name: "normal Windows window",
1044 goos: "windows",
1045 maximised: false,
1046 want: backgroundRestorePlan{unminimiseAfterShow: true},
1047 },
1048 }
1049 for _, tt := range tests {
1050 t.Run(tt.name, func(t *testing.T) {
1051 got := backgroundRestorePlanFor(tt.goos, tt.maximised)
1052 if !reflect.DeepEqual(got, tt.want) {
1053 t.Fatalf("backgroundRestorePlanFor(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want)
1054 }
1055 })
1056 }
1057 }
1058
1059 func TestEmitReadyInvokesReadyHook(t *testing.T) {
1060 app := NewApp()
1061 var calls int32
1062 app.readyHook = func() {
1063 atomic.AddInt32(&calls, 1)
1064 }
1065
1066 app.emitReady(context.TODO())
1067
1068 if got := atomic.LoadInt32(&calls); got != 1 {
1069 t.Fatalf("ready hook calls = %d, want 1", got)
1070 }
1071 }
1072
1073 func TestSetEffortPersistsAndAutoClears(t *testing.T) {
1074 isolateDesktopUserDirs(t)
1075
1076 app := NewApp()
1077 if err := app.SetEffort("max"); err != nil {
1078 t.Fatalf("SetEffort(max): %v", err)
1079 }
1080 if got := app.Effort().Current; got != "max" {
1081 t.Fatalf("Effort current = %q, want max", got)
1082 }
1083 if err := app.SetEffort("auto"); err != nil {
1084 t.Fatalf("SetEffort(auto): %v", err)
1085 }
1086 if got := app.Effort().Current; got != "auto" {
1087 t.Fatalf("Effort current = %q, want auto", got)
1088 }
1089 body, err := os.ReadFile(config.UserConfigPath())
1090 if err != nil {
1091 t.Fatalf("read saved config: %v", err)
1092 }
1093 if strings.Contains(string(body), `effort = "max"`) {
1094 t.Fatalf("auto should clear explicit max effort:\n%s", body)
1095 }
1096 }
1097
1098 func TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {
1099 isolateDesktopUserDirs(t)
1100
1101 project := robustTempDir(t)
1102 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
1103 [desktop]
1104 language = "zh"
1105 layout_style = "workbench"
1106 theme = "light"
1107 theme_style = "glacier"
1108 close_behavior = "quit"
1109 status_bar_style = "icon"
1110 status_bar_items = ["cost", "balance"]
1111 `), 0o644); err != nil {
1112 t.Fatalf("write project config: %v", err)
1113 }
1114
1115 userCfg := config.LoadForEdit(config.UserConfigPath())
1116 if err := userCfg.SetDesktopLanguage("en"); err != nil {
1117 t.Fatalf("set desktop language: %v", err)
1118 }
1119 if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil {
1120 t.Fatalf("set desktop layout style: %v", err)
1121 }
1122 if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
1123 t.Fatalf("set desktop appearance: %v", err)
1124 }
1125 if err := userCfg.SetDesktopTerminalTheme("light"); err != nil {
1126 t.Fatalf("set desktop terminal theme: %v", err)
1127 }
1128 if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
1129 t.Fatalf("set desktop close behavior: %v", err)
1130 }
1131 if err := userCfg.SetDesktopStatusBarStyle("text"); err != nil {
1132 t.Fatalf("set desktop status bar style: %v", err)
1133 }
1134 if err := userCfg.SetDesktopStatusBarItems([]string{"model", "balance", "cache"}); err != nil {
1135 t.Fatalf("set desktop status bar items: %v", err)
1136 }
1137 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
1138 t.Fatalf("save user config: %v", err)
1139 }
1140
1141 orig, _ := os.Getwd()
1142 defer func() { _ = os.Chdir(orig) }()
1143 if err := os.Chdir(project); err != nil {
1144 t.Fatalf("chdir project: %v", err)
1145 }
1146
1147 got := NewApp().Settings()
1148 if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DesktopTerminalTheme != "light" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" {
1149 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)
1150 }
1151 if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.StatusBarItems, want) {
1152 t.Fatalf("desktop status bar items = %v, want user-level %v", got.StatusBarItems, want)
1153 }
1154 }
1155
1156 func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {
1157 isolateDesktopUserDirs(t)
1158
1159 userCfg := config.LoadForEdit(config.UserConfigPath())
1160 if err := userCfg.SetDesktopLanguage("en"); err != nil {
1161 t.Fatalf("set desktop language: %v", err)
1162 }
1163 if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil {
1164 t.Fatalf("set desktop layout style: %v", err)
1165 }
1166 if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
1167 t.Fatalf("set desktop appearance: %v", err)
1168 }
1169 if err := userCfg.SetDesktopTerminalTheme("light"); err != nil {
1170 t.Fatalf("set desktop terminal theme: %v", err)
1171 }
1172 if err := userCfg.SetDesktopStatusBarStyle("icon"); err != nil {
1173 t.Fatalf("set desktop status bar style: %v", err)
1174 }
1175 if err := userCfg.SetDesktopStatusBarItems([]string{"workspace", "git_branch", "model"}); err != nil {
1176 t.Fatalf("set desktop status bar items: %v", err)
1177 }
1178 if err := userCfg.SetDesktopCheckUpdates(false); err != nil {
1179 t.Fatalf("set desktop check updates: %v", err)
1180 }
1181 if err := userCfg.SetDesktopUpdateChannel("preview"); err != nil {
1182 t.Fatalf("set desktop update channel: %v", err)
1183 }
1184 userCfg.Bot.Enabled = true
1185 userCfg.Bot.Allowlist.Enabled = true
1186 userCfg.Bot.Allowlist.QQUsers = []string{"alice"}
1187 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
1188 t.Fatalf("save user config: %v", err)
1189 }
1190
1191 got := NewApp().DesktopStartupSettings()
1192 if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DesktopTerminalTheme != "light" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates || got.UpdateChannel != "stable" {
1193 t.Fatalf("DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs", got)
1194 }
1195 if want := []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got.StatusBarItems, want) {
1196 t.Fatalf("DesktopStartupSettings status bar items = %v, want %v", got.StatusBarItems, want)
1197 }
1198 if !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{"alice"}) {
1199 t.Fatalf("DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot", got.Bot)
1200 }
1201
1202 raw, err := json.Marshal(got)
1203 if err != nil {
1204 t.Fatalf("marshal DesktopStartupSettings: %v", err)
1205 }
1206 if strings.Contains(string(raw), "providers") || strings.Contains(string(raw), "officialProviders") || strings.Contains(string(raw), "providerKinds") {
1207 t.Fatalf("DesktopStartupSettings must not include full Settings provider payload: %s", raw)
1208 }
1209 }
1210
1211 func BenchmarkDesktopSettingsPayloads(b *testing.B) {
1212 home := b.TempDir()
1213 xdg := filepath.Join(home, ".config")
1214 appData := filepath.Join(home, "AppData")
1215 for _, dir := range []string{xdg, appData} {
1216 if err := os.MkdirAll(dir, 0o755); err != nil {
1217 b.Fatal(err)
1218 }
1219 }
1220 b.Setenv("HOME", home)
1221 b.Setenv("REASONIX_CREDENTIALS_STORE", "file")
1222 b.Setenv("USERPROFILE", home)
1223 b.Setenv("XDG_CONFIG_HOME", xdg)
1224 b.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
1225 b.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
1226 b.Setenv("AppData", appData)
1227 b.Setenv("SHARED_PROVIDER_KEY", "sk-test")
1228
1229 cfg := config.LoadForEdit(config.UserConfigPath())
1230 for i := 0; i < 40; i++ {
1231 cfg.Providers = append(cfg.Providers, config.ProviderEntry{
1232 Name: fmt.Sprintf("custom-%02d", i),
1233 Kind: "openai",
1234 BaseURL: "https://example.invalid/v1",
1235 APIKeyEnv: "SHARED_PROVIDER_KEY",
1236 Models: []string{"model-a", "model-b"},
1237 Default: "model-a",
1238 })
1239 }
1240 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1241 b.Fatalf("save config: %v", err)
1242 }
1243 app := NewApp()
1244
1245 b.Run("Settings", func(b *testing.B) {
1246 for i := 0; i < b.N; i++ {
1247 _ = app.Settings()
1248 }
1249 })
1250 b.Run("DesktopStartupSettings", func(b *testing.B) {
1251 for i := 0; i < b.N; i++ {
1252 _ = app.DesktopStartupSettings()
1253 }
1254 })
1255 }
1256
1257 func TestSettingsIgnoresActiveWorkspaceDotEnvCredentialsWithUserConfig(t *testing.T) {
1258 isolateDesktopUserDirs(t)
1259
1260 project := robustTempDir(t)
1261 launch := robustTempDir(t)
1262 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("WORKSPACE_ONLY_KEY=from-project\n"), 0o600); err != nil {
1263 t.Fatalf("write project env: %v", err)
1264 }
1265 userCfg := config.LoadForEdit(config.UserConfigPath())
1266 if err := userCfg.UpsertProvider(config.ProviderEntry{
1267 Name: "workspace-provider",
1268 Kind: "openai",
1269 BaseURL: "https://workspace.example/v1",
1270 Model: "workspace-model",
1271 APIKeyEnv: "WORKSPACE_ONLY_KEY",
1272 }); err != nil {
1273 t.Fatalf("upsert provider: %v", err)
1274 }
1275 userCfg.Desktop.ProviderAccess = []string{"workspace-provider"}
1276 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
1277 t.Fatalf("save user config: %v", err)
1278 }
1279 t.Setenv("WORKSPACE_ONLY_KEY", "")
1280 os.Unsetenv("WORKSPACE_ONLY_KEY")
1281 orig, _ := os.Getwd()
1282 defer func() { _ = os.Chdir(orig) }()
1283 if err := os.Chdir(launch); err != nil {
1284 t.Fatalf("chdir launch: %v", err)
1285 }
1286
1287 app := NewApp()
1288 app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}}
1289 app.activeTabID = "project"
1290 got := app.Settings()
1291 for _, p := range got.Providers {
1292 if p.Name == "workspace-provider" {
1293 if p.KeySet {
1294 t.Fatalf("workspace provider keySet = true, want false because workspace .env is ignored: %+v", p)
1295 }
1296 if p.Configured {
1297 t.Fatalf("workspace provider configured = true, want false because workspace .env is ignored: %+v", p)
1298 }
1299 return
1300 }
1301 }
1302 t.Fatalf("workspace provider missing from settings: %+v", got.Providers)
1303 }
1304
1305 func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {
1306 isolateDesktopUserDirs(t)
1307
1308 project := robustTempDir(t)
1309 launch := robustTempDir(t)
1310 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("SHARED_SETTINGS_KEY=from-project\n"), 0o600); err != nil {
1311 t.Fatalf("write project env: %v", err)
1312 }
1313 if _, err := config.SetCredential("SHARED_SETTINGS_KEY", "from-credentials"); err != nil {
1314 t.Fatalf("SetCredential: %v", err)
1315 }
1316 userCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
1317 if err := userCfg.UpsertProvider(config.ProviderEntry{
1318 Name: "settings-provider",
1319 Kind: "openai",
1320 BaseURL: "https://settings.example/v1",
1321 Model: "settings-model",
1322 APIKeyEnv: "SHARED_SETTINGS_KEY",
1323 }); err != nil {
1324 t.Fatalf("upsert provider: %v", err)
1325 }
1326 userCfg.Desktop.ProviderAccess = []string{"settings-provider"}
1327 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
1328 t.Fatalf("save user config: %v", err)
1329 }
1330 t.Setenv("SHARED_SETTINGS_KEY", "from-project")
1331 orig, _ := os.Getwd()
1332 defer func() { _ = os.Chdir(orig) }()
1333 if err := os.Chdir(launch); err != nil {
1334 t.Fatalf("chdir launch: %v", err)
1335 }
1336
1337 app := NewApp()
1338 app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}}
1339 app.activeTabID = "project"
1340 got := app.Settings()
1341 for _, p := range got.Providers {
1342 if p.Name != "settings-provider" {
1343 continue
1344 }
1345 if !p.KeySet || !strings.Contains(p.KeySource, "Reasonix credentials") {
1346 t.Fatalf("settings-provider key = set:%v source:%q, want Reasonix credentials: %+v", p.KeySet, p.KeySource, p)
1347 }
1348 if env := os.Getenv("SHARED_SETTINGS_KEY"); env != "from-project" {
1349 t.Fatalf("Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env", env)
1350 }
1351 return
1352 }
1353 t.Fatalf("settings provider missing from settings: %+v", got.Providers)
1354 }
1355
1356 func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
1357 isolateDesktopUserDirs(t)
1358
1359 project := robustTempDir(t)
1360 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
1361 default_model = "legacy-provider/legacy-model"
1362
1363 [desktop]
1364 language = "zh"
1365 layout_style = "workbench"
1366 theme = "light"
1367 theme_style = "glacier"
1368 close_behavior = "quit"
1369 status_bar_style = "text"
1370 status_bar_items = ["model", "cache", "balance"]
1371 `), 0o644); err != nil {
1372 t.Fatalf("write project config: %v", err)
1373 }
1374
1375 orig, _ := os.Getwd()
1376 defer func() { _ = os.Chdir(orig) }()
1377 if err := os.Chdir(project); err != nil {
1378 t.Fatalf("chdir project: %v", err)
1379 }
1380
1381 app := NewApp()
1382 got := app.Settings()
1383 if got.ConfigPath != config.UserConfigPath() {
1384 t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
1385 }
1386 if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "text" {
1387 t.Fatalf("Settings did not seed from legacy project config: %+v", got)
1388 }
1389 if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) {
1390 t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want)
1391 }
1392 if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
1393 t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
1394 }
1395 if err := app.SetDesktopLanguage("en"); err != nil {
1396 t.Fatalf("SetDesktopLanguage: %v", err)
1397 }
1398 userCfg := config.LoadForEdit(config.UserConfigPath())
1399 if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "text" {
1400 t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())
1401 }
1402 if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {
1403 t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want)
1404 }
1405 }
1406
1407 func TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {
1408 isolateDesktopUserDirs(t)
1409 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1410 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1411 t.Fatalf("mkdir config dir: %v", err)
1412 }
1413 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1414 default_model = "deepseek/deepseek-v4-flash"
1415
1416 [[providers]]
1417 name = "deepseek"
1418 kind = "openai"
1419 base_url = "https://api.deepseek.com"
1420 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
1421 default = "deepseek-v4-flash"
1422 api_key_env = "DEEPSEEK_API_KEY"
1423 `), 0o644); err != nil {
1424 t.Fatalf("write config: %v", err)
1425 }
1426
1427 app := NewApp()
1428 if got := app.Settings().Agent.MaxSubagentDepth; got != agent.DefaultMaxSubagentDepth {
1429 t.Fatalf("default max subagent depth = %d, want %d", got, agent.DefaultMaxSubagentDepth)
1430 }
1431 if err := app.SetSubagentModel("deepseek/deepseek-v4-pro"); err != nil {
1432 t.Fatalf("SetSubagentModel: %v", err)
1433 }
1434 if err := app.SetSubagentEffort("max"); err != nil {
1435 t.Fatalf("SetSubagentEffort: %v", err)
1436 }
1437 if err := app.SetMaxSubagentDepth(1); err != nil {
1438 t.Fatalf("SetMaxSubagentDepth(1): %v", err)
1439 }
1440 if err := app.SetMaxSubagentDepth(2); err != nil {
1441 t.Fatalf("SetMaxSubagentDepth(2): %v", err)
1442 }
1443
1444 got := app.Settings()
1445 if got.SubagentModel != "deepseek/deepseek-v4-pro" || got.SubagentEffort != "max" {
1446 t.Fatalf("subagent settings = model:%q effort:%q", got.SubagentModel, got.SubagentEffort)
1447 }
1448 if got.Agent.MaxSubagentDepth != 2 {
1449 t.Fatalf("max subagent depth = %d, want 2", got.Agent.MaxSubagentDepth)
1450 }
1451 cfg := config.LoadForEdit(config.UserConfigPath())
1452 if cfg.Agent.SubagentModel != "deepseek/deepseek-v4-pro" || cfg.Agent.SubagentEffort != "max" {
1453 t.Fatalf("saved config = model:%q effort:%q", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)
1454 }
1455 if cfg.Agent.MaxSubagentDepth != 2 {
1456 t.Fatalf("saved max_subagent_depth = %d, want 2", cfg.Agent.MaxSubagentDepth)
1457 }
1458 }
1459
1460 func TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {
1461 isolateDesktopUserDirs(t)
1462
1463 got := NewApp().Settings()
1464 providers := providerAccessSet(providerNamesFromView(got.Providers))
1465 official := providerAccessSet(providerNamesFromView(got.OfficialProviders))
1466 if providers["mimo-api"] {
1467 t.Fatalf("mimo-api should not be mixed into configured providers: %+v", got.Providers)
1468 }
1469 if !official["deepseek"] || official["mimo-api"] || official["mimo-token-plan"] {
1470 t.Fatalf("official providers = %+v, want only deepseek", got.OfficialProviders)
1471 }
1472 }
1473
1474 func TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {
1475 isolateDesktopUserDirs(t)
1476 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1477 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1478 t.Fatalf("mkdir config dir: %v", err)
1479 }
1480 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1481 default_model = "deepseek-flash"
1482
1483 [[providers]]
1484 name = "deepseek-flash"
1485 kind = "openai"
1486 base_url = "https://api.deepseek.com"
1487 api_key_env = "DEEPSEEK_API_KEY"
1488 `), 0o644); err != nil {
1489 t.Fatalf("write config: %v", err)
1490 }
1491
1492 got := NewApp().Settings()
1493 for _, p := range got.Providers {
1494 if p.Name != "deepseek" {
1495 continue
1496 }
1497 if !p.BuiltIn {
1498 t.Fatalf("deepseek provider should be marked built-in for official endpoint: %+v", p)
1499 }
1500 if !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" {
1501 t.Fatalf("deepseek provider = %+v, want added repaired official model list", p)
1502 }
1503 if got.DefaultModel != "deepseek/deepseek-v4-flash" {
1504 t.Fatalf("default_model = %q, want deepseek/deepseek-v4-flash", got.DefaultModel)
1505 }
1506 return
1507 }
1508 t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
1509 }
1510
1511 func TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {
1512 isolateDesktopUserDirs(t)
1513 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1514 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1515 t.Fatalf("mkdir config dir: %v", err)
1516 }
1517 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1518 default_model = "deepseek/deepseek-v4-Flash"
1519
1520 [desktop]
1521 provider_access = ["deepseek"]
1522
1523 [[providers]]
1524 name = "deepseek"
1525 kind = "openai"
1526 base_url = "https://opencode.ai/zen/go/v1"
1527 models = ["deepseek-v4-Flash", "deepseek-v4-pro", "glm-5"]
1528 default = "deepseek-v4-Flash"
1529 api_key_env = "DEEPSEEK_API_KEY"
1530 `), 0o644); err != nil {
1531 t.Fatalf("write config: %v", err)
1532 }
1533
1534 got := NewApp().Settings()
1535 var custom *ProviderView
1536 for i := range got.Providers {
1537 if got.Providers[i].Name == "deepseek" {
1538 custom = &got.Providers[i]
1539 break
1540 }
1541 }
1542 if custom == nil {
1543 t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
1544 }
1545 if custom.BuiltIn {
1546 t.Fatalf("external deepseek endpoint should be custom, got built-in provider: %+v", *custom)
1547 }
1548 if !custom.Added || !custom.KeySet || custom.BaseURL != "https://opencode.ai/zen/go/v1" {
1549 t.Fatalf("external deepseek provider = %+v, want added key-set custom opencode endpoint", *custom)
1550 }
1551 for _, p := range got.OfficialProviders {
1552 if p.Name == "deepseek" && p.Added {
1553 t.Fatalf("official DeepSeek template should not be marked added by external endpoint: %+v", p)
1554 }
1555 }
1556 }
1557
1558 func TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {
1559 isolateDesktopUserDirs(t)
1560 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1561 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
1562 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1563 t.Fatalf("mkdir config dir: %v", err)
1564 }
1565 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1566 default_model = "deepseek-flash/deepseek-v4-pro"
1567
1568 [[providers]]
1569 name = "deepseek-flash"
1570 kind = "openai"
1571 base_url = "https://api.deepseek.com"
1572 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
1573 default = "deepseek-v4-flash"
1574 api_key_env = "DEEPSEEK_API_KEY"
1575
1576 [[providers]]
1577 name = "mimo-pro"
1578 kind = "openai"
1579 base_url = "https://token-plan-cn.xiaomimimo.com/v1"
1580 model = "mimo-v2.5-pro"
1581 api_key_env = "MIMO_API_KEY"
1582 `), 0o644); err != nil {
1583 t.Fatalf("write config: %v", err)
1584 }
1585
1586 got := NewApp().Settings()
1587 providers := map[string]ProviderView{}
1588 for _, p := range got.Providers {
1589 providers[p.Name] = p
1590 }
1591 if !providers["deepseek"].Added || !providers["deepseek"].KeySet {
1592 t.Fatalf("deepseek provider = %+v, want inferred added key-set provider", providers["deepseek"])
1593 }
1594 if !providers["mimo-pro"].Added || !providers["mimo-pro"].KeySet || providers["mimo-pro"].BuiltIn {
1595 t.Fatalf("mimo-pro provider = %+v, want inferred custom key-set provider", providers["mimo-pro"])
1596 }
1597 if got.DefaultModel != "deepseek/deepseek-v4-pro" {
1598 t.Fatalf("default_model = %q, want deepseek/deepseek-v4-pro", got.DefaultModel)
1599 }
1600 }
1601
1602 func TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {
1603 isolateDesktopUserDirs(t)
1604 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1605 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1606 t.Fatalf("mkdir config dir: %v", err)
1607 }
1608 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1609 default_model = "deepseek-flash/deepseek-v4-flash"
1610
1611 [desktop]
1612 provider_access = []
1613
1614 [[providers]]
1615 name = "deepseek-flash"
1616 kind = "openai"
1617 base_url = "https://api.deepseek.com"
1618 models = ["deepseek-v4-flash"]
1619 default = "deepseek-v4-flash"
1620 api_key_env = "DEEPSEEK_API_KEY"
1621 `), 0o644); err != nil {
1622 t.Fatalf("write config: %v", err)
1623 }
1624
1625 got := NewApp().Settings()
1626 for _, p := range got.Providers {
1627 if p.Added {
1628 t.Fatalf("provider %+v should not be inferred as added when provider_access is explicit empty", p)
1629 }
1630 }
1631 }
1632
1633 func TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {
1634 isolateDesktopUserDirs(t)
1635 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
1636 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
1637
1638 got := NewApp().Settings()
1639 providers := map[string]ProviderView{}
1640 for _, p := range got.Providers {
1641 providers[p.Name] = p
1642 }
1643 if !providers["deepseek"].Added || !providers["deepseek"].KeySet {
1644 t.Fatalf("deepseek provider = %+v, want inferred added provider from configured key", providers["deepseek"])
1645 }
1646 if _, ok := providers["mimo-token-plan"]; ok {
1647 t.Fatalf("mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v", providers["mimo-token-plan"])
1648 }
1649 }
1650
1651 func TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {
1652 isolateDesktopUserDirs(t)
1653 t.Setenv("DEEPSEEK_API_KEY", "")
1654 t.Setenv("MIMO_API_KEY", "")
1655
1656 got := NewApp().Settings()
1657 for _, p := range got.Providers {
1658 if p.Added {
1659 t.Fatalf("provider %+v should not be inferred as added without a configured key", p)
1660 }
1661 }
1662 }
1663
1664 func TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {
1665 isolateDesktopUserDirs(t)
1666 t.Setenv("DEEPSEEK_API_KEY", "")
1667 os.Unsetenv("DEEPSEEK_API_KEY")
1668 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1669 t.Fatalf("mkdir config dir: %v", err)
1670 }
1671 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1672 default_model = "deepseek-flash"
1673
1674 [[providers]]
1675 name = "deepseek-flash"
1676 kind = "openai"
1677 base_url = "https://api.deepseek.com"
1678 api_key_env = "DEEPSEEK_API_KEY"
1679 `), 0o644); err != nil {
1680 t.Fatalf("write config: %v", err)
1681 }
1682
1683 if _, err := NewApp().AddOfficialProviderAccess("deepseek", "test-key"); err != nil {
1684 t.Fatalf("AddOfficialProviderAccess: %v", err)
1685 }
1686 cfg := config.LoadForEdit(config.UserConfigPath())
1687 p, ok := cfg.Provider("deepseek")
1688 if !ok {
1689 t.Fatal("deepseek provider not saved")
1690 }
1691 if len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" {
1692 t.Fatalf("deepseek provider after add = %+v, want official model list", p)
1693 }
1694 if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
1695 t.Fatalf("provider_access missing deepseek: %+v", cfg.Desktop.ProviderAccess)
1696 }
1697 if cfg.DefaultModel != "deepseek/deepseek-v4-flash" {
1698 t.Fatalf("default_model = %q, want deepseek/deepseek-v4-flash", cfg.DefaultModel)
1699 }
1700 }
1701
1702 func TestSettingsSurfacesCuratedProviderPresets(t *testing.T) {
1703 isolateDesktopUserDirs(t)
1704
1705 view := NewApp().Settings()
1706 if len(view.ProviderPresets) < 18 {
1707 t.Fatalf("Settings().ProviderPresets length = %d, want curated custom presets", len(view.ProviderPresets))
1708 }
1709 got := map[string]ProviderPresetView{}
1710 for _, preset := range view.ProviderPresets {
1711 got[preset.ID] = preset
1712 }
1713 for _, curated := range config.CuratedProviderPresets() {
1714 id := curated.ID
1715 preset, ok := got[id]
1716 if !ok {
1717 t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
1718 }
1719 if preset.KeyEnv == "" || len(preset.ProviderNames) == 0 || len(preset.Models) == 0 {
1720 t.Fatalf("preset %q view has missing fields: %+v", id, preset)
1721 }
1722 }
1723 }
1724
1725 func providerPresetViewByID(t *testing.T, view SettingsView, id string) ProviderPresetView {
1726 t.Helper()
1727 for _, preset := range view.ProviderPresets {
1728 if preset.ID == id {
1729 return preset
1730 }
1731 }
1732 t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
1733 return ProviderPresetView{}
1734 }
1735
1736 func TestSettingsMarksPresetAddedWhenSameNameProviderExistsWithoutAccess(t *testing.T) {
1737 isolateDesktopUserDirs(t)
1738 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
1739 t.Fatalf("mkdir config dir: %v", err)
1740 }
1741 if err := os.WriteFile(config.UserConfigPath(), []byte(`
1742 [desktop]
1743 provider_access = []
1744
1745 [[providers]]
1746 name = "mimo-api"
1747 kind = "openai"
1748 base_url = "https://custom.example/v1"
1749 models = ["custom-model"]
1750 default = "custom-model"
1751 api_key_env = "MIMO_API_KEY"
1752 `), 0o644); err != nil {
1753 t.Fatalf("write config: %v", err)
1754 }
1755
1756 view := NewApp().Settings()
1757 presetView := providerPresetViewByID(t, view, "mimo-api")
1758 if !presetView.Added || presetView.Status != providerPresetStatusNameConflict || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
1759 t.Fatalf("mimo-api preset view = %+v, want name-conflict because a different same-name provider exists", presetView)
1760 }
1761
1762 var providerView *ProviderView
1763 for i := range view.Providers {
1764 if view.Providers[i].Name == "mimo-api" {
1765 providerView = &view.Providers[i]
1766 break
1767 }
1768 }
1769 if providerView == nil {
1770 t.Fatal("mimo-api provider view missing")
1771 }
1772 if providerView.Added {
1773 t.Fatalf("mimo-api provider Added = true, want false until provider_access explicitly enables it")
1774 }
1775 }
1776
1777 func TestSettingsMarksLegacyEquivalentPresetAsInstalled(t *testing.T) {
1778 isolateDesktopUserDirs(t)
1779 preset, ok := config.CuratedProviderPreset("mimo-api")
1780 if !ok || len(preset.Entries) == 0 {
1781 t.Fatal("missing mimo-api preset")
1782 }
1783 legacy := preset.Entries[0]
1784 legacy.PresetID = ""
1785 legacy.PresetVersion = 0
1786 cfg := config.Default()
1787 if err := cfg.UpsertProvider(legacy); err != nil {
1788 t.Fatalf("upsert legacy provider: %v", err)
1789 }
1790 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1791 t.Fatalf("save config: %v", err)
1792 }
1793
1794 view := NewApp().Settings()
1795 presetView := providerPresetViewByID(t, view, "mimo-api")
1796 if !presetView.Added || presetView.Status != providerPresetStatusInstalled || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
1797 t.Fatalf("mimo-api preset view = %+v, want installed for legacy equivalent config", presetView)
1798 }
1799 }
1800
1801 func TestSettingsMarksPresetWithChangedCoreConfigAsModified(t *testing.T) {
1802 isolateDesktopUserDirs(t)
1803 preset, ok := config.CuratedProviderPreset("mimo-api")
1804 if !ok || len(preset.Entries) == 0 {
1805 t.Fatal("missing mimo-api preset")
1806 }
1807 modified := preset.Entries[0]
1808 modified.BaseURL = "https://custom.example/v1"
1809 cfg := config.Default()
1810 if err := cfg.UpsertProvider(modified); err != nil {
1811 t.Fatalf("upsert modified provider: %v", err)
1812 }
1813 cfg.Desktop.ProviderAccess = []string{"mimo-api"}
1814 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1815 t.Fatalf("save config: %v", err)
1816 }
1817
1818 view := NewApp().Settings()
1819 presetView := providerPresetViewByID(t, view, "mimo-api")
1820 if !presetView.Added || presetView.Status != providerPresetStatusInstalledModified || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
1821 t.Fatalf("mimo-api preset view = %+v, want installed-modified for edited preset provider", presetView)
1822 }
1823 }
1824
1825 func TestSettingsPreservesStepFunRegionalPresetBaseURLs(t *testing.T) {
1826 isolateDesktopUserDirs(t)
1827
1828 cfg := config.Default()
1829 stepfun, ok := config.CuratedProviderPreset("stepfun")
1830 if !ok || len(stepfun.Entries) != 1 {
1831 t.Fatal("missing stepfun preset")
1832 }
1833 stepfunEntry := stepfun.Entries[0]
1834 stepfunEntry.BaseURL = "https://api.stepfun.ai/step_plan/v1"
1835 stepfunAnthropic, ok := config.CuratedProviderPreset("stepfun-anthropic")
1836 if !ok || len(stepfunAnthropic.Entries) != 1 {
1837 t.Fatal("missing stepfun-anthropic preset")
1838 }
1839 stepfunAnthropicEntry := stepfunAnthropic.Entries[0]
1840 stepfunAnthropicEntry.BaseURL = "https://api.stepfun.ai/step_plan"
1841 cfg.Providers = append(cfg.Providers, stepfunEntry, stepfunAnthropicEntry)
1842 cfg.Desktop.ProviderAccess = []string{"stepfun", "stepfun-anthropic"}
1843 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1844 t.Fatalf("save config: %v", err)
1845 }
1846
1847 view := NewApp().Settings()
1848 for _, id := range []string{"stepfun", "stepfun-anthropic"} {
1849 presetView := providerPresetViewByID(t, view, id)
1850 if !presetView.Added || presetView.Status != providerPresetStatusInstalledModified {
1851 t.Fatalf("%s preset view = %+v, want installed-modified for a preserved regional endpoint", id, presetView)
1852 }
1853 }
1854
1855 loaded := config.LoadForEdit(config.UserConfigPath())
1856 stepfunEntryView, ok := loaded.Provider("stepfun")
1857 if !ok {
1858 t.Fatal("stepfun provider missing after load")
1859 }
1860 if got := stepfunEntryView.BaseURL; got != "https://api.stepfun.ai/step_plan/v1" {
1861 t.Fatalf("stepfun base_url = %q, want preserved regional URL", got)
1862 }
1863 stepfunAnthropicEntryView, ok := loaded.Provider("stepfun-anthropic")
1864 if !ok {
1865 t.Fatal("stepfun-anthropic provider missing after load")
1866 }
1867 if got := stepfunAnthropicEntryView.BaseURL; got != "https://api.stepfun.ai/step_plan" {
1868 t.Fatalf("stepfun-anthropic base_url = %q, want preserved regional URL", got)
1869 }
1870 }
1871
1872 func TestSettingsMarksSimilarProviderPresetWithoutBlockingAdd(t *testing.T) {
1873 isolateDesktopUserDirs(t)
1874 preset, ok := config.CuratedProviderPreset("mimo-api")
1875 if !ok || len(preset.Entries) == 0 {
1876 t.Fatal("missing mimo-api preset")
1877 }
1878 similar := preset.Entries[0]
1879 similar.Name = "my-mimo"
1880 similar.PresetID = ""
1881 similar.PresetVersion = 0
1882 cfg := config.Default()
1883 if err := cfg.UpsertProvider(similar); err != nil {
1884 t.Fatalf("upsert similar provider: %v", err)
1885 }
1886 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1887 t.Fatalf("save config: %v", err)
1888 }
1889
1890 view := NewApp().Settings()
1891 presetView := providerPresetViewByID(t, view, "mimo-api")
1892 if presetView.Added || presetView.Status != providerPresetStatusSimilarExisting || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"my-mimo"}) {
1893 t.Fatalf("mimo-api preset view = %+v, want non-blocking similar-existing status", presetView)
1894 }
1895 }
1896
1897 func TestAddProviderPresetAccessSavesEditableProviderAndKey(t *testing.T) {
1898 isolateDesktopUserDirs(t)
1899 t.Setenv("MIMO_API_KEY", "")
1900 os.Unsetenv("MIMO_API_KEY")
1901
1902 if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-mimo"); err != nil {
1903 t.Fatalf("AddProviderPresetAccess: %v", err)
1904 } else if warning != "" {
1905 t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
1906 }
1907
1908 cfg := config.LoadForEdit(config.UserConfigPath())
1909 p, ok := cfg.Provider("mimo-api")
1910 if !ok {
1911 t.Fatal("mimo-api provider not saved")
1912 }
1913 if p.Kind != "openai" || p.BaseURL != "https://api.xiaomimimo.com/v1" || p.Default != "mimo-v2.5-pro" {
1914 t.Fatalf("mimo-api provider after preset add = %+v", p)
1915 }
1916 if p.PresetID != "mimo-api" || p.PresetVersion != config.ProviderPresetVersion {
1917 t.Fatalf("mimo-api preset metadata = %q/%d, want mimo-api/%d", p.PresetID, p.PresetVersion, config.ProviderPresetVersion)
1918 }
1919 if !p.NoProxy {
1920 t.Fatal("mimo-api preset should save no_proxy = true")
1921 }
1922 if !p.HasVisionModel("mimo-v2.5") || p.HasVisionModel("mimo-v2.5-pro") {
1923 t.Fatalf("mimo vision_models = %+v, want only vision-capable MiMo models", p.VisionModels)
1924 }
1925 if price := p.PriceForModel("mimo-v2.5-pro"); price == nil || price.Currency != "¥" {
1926 t.Fatalf("mimo-v2.5-pro price = %+v, want RMB pricing", price)
1927 }
1928 if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
1929 t.Fatalf("provider_access missing mimo-api: %+v", cfg.Desktop.ProviderAccess)
1930 }
1931 data, err := os.ReadFile(config.UserCredentialsPath())
1932 if err != nil {
1933 t.Fatalf("read saved credentials: %v", err)
1934 }
1935 if !strings.Contains(string(data), "MIMO_API_KEY=sk-mimo") {
1936 t.Fatalf("saved credentials missing MiMo key:\n%s", data)
1937 }
1938
1939 view := NewApp().Settings()
1940 var presetView *ProviderPresetView
1941 var providerView *ProviderView
1942 for i := range view.ProviderPresets {
1943 if view.ProviderPresets[i].ID == "mimo-api" {
1944 presetView = &view.ProviderPresets[i]
1945 }
1946 }
1947 for i := range view.Providers {
1948 if view.Providers[i].Name == "mimo-api" {
1949 providerView = &view.Providers[i]
1950 }
1951 }
1952 if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet {
1953 t.Fatalf("mimo-api preset view = %+v, want installed/key-set", presetView)
1954 }
1955 if providerView == nil || providerView.BuiltIn || !providerView.Added || !providerView.KeySet {
1956 t.Fatalf("mimo provider view = %+v, want editable added custom provider with key", providerView)
1957 }
1958 }
1959
1960 func TestAddProviderPresetAccessDoesNotOverwriteExistingProvider(t *testing.T) {
1961 isolateDesktopUserDirs(t)
1962 t.Setenv("MIMO_API_KEY", "")
1963 os.Unsetenv("MIMO_API_KEY")
1964 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
1965
1966 cfg := config.Default()
1967 custom := config.ProviderEntry{
1968 Name: "mimo-api",
1969 Kind: "openai",
1970 BaseURL: "https://custom.example/v1",
1971 Models: []string{"custom-model"},
1972 Default: "custom-model",
1973 APIKeyEnv: "MIMO_API_KEY",
1974 Headers: map[string]string{"X-Custom": "keep-me"},
1975 }
1976 if err := cfg.UpsertProvider(custom); err != nil {
1977 t.Fatalf("upsert custom provider: %v", err)
1978 }
1979 cfg.Desktop.ProviderAccess = []string{"mimo-api"}
1980 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
1981 t.Fatalf("save config: %v", err)
1982 }
1983
1984 if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-new"); err == nil {
1985 t.Fatal("AddProviderPresetAccess unexpectedly overwrote an existing provider")
1986 } else if !strings.Contains(err.Error(), "provider name(s) already exist") {
1987 t.Fatalf("AddProviderPresetAccess error = %v, want name-exists guard", err)
1988 } else if warning != "" {
1989 t.Fatalf("AddProviderPresetAccess warning = %q, want none on rejected add", warning)
1990 }
1991
1992 cfg = config.LoadForEdit(config.UserConfigPath())
1993 got, ok := cfg.Provider("mimo-api")
1994 if !ok {
1995 t.Fatal("mimo-api provider missing after rejected add")
1996 }
1997 if got.BaseURL != custom.BaseURL || got.DefaultModel() != custom.DefaultModel() || !reflect.DeepEqual(got.ModelList(), custom.ModelList()) || !reflect.DeepEqual(got.Headers, custom.Headers) {
1998 t.Fatalf("mimo-api provider was overwritten: %+v, want custom %+v", got, custom)
1999 }
2000 data, err := os.ReadFile(config.UserCredentialsPath())
2001 if err != nil {
2002 t.Fatalf("read saved credentials: %v", err)
2003 }
2004 if strings.Contains(string(data), "sk-new") || !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
2005 t.Fatalf("credentials changed after rejected add:\n%s", data)
2006 }
2007 }
2008
2009 func TestResetProviderPresetAccessOverwritesSameNameProvider(t *testing.T) {
2010 isolateDesktopUserDirs(t)
2011 t.Setenv("MIMO_API_KEY", "")
2012 os.Unsetenv("MIMO_API_KEY")
2013 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
2014
2015 cfg := config.Default()
2016 custom := config.ProviderEntry{
2017 Name: "mimo-api",
2018 Kind: "openai",
2019 BaseURL: "https://custom.example/v1",
2020 Models: []string{"custom-model"},
2021 Default: "custom-model",
2022 APIKeyEnv: "MIMO_API_KEY",
2023 Headers: map[string]string{"X-Custom": "remove-me"},
2024 }
2025 if err := cfg.UpsertProvider(custom); err != nil {
2026 t.Fatalf("upsert custom provider: %v", err)
2027 }
2028 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2029 t.Fatalf("save config: %v", err)
2030 }
2031
2032 if err := NewApp().ResetProviderPresetAccess("mimo-api"); err != nil {
2033 t.Fatalf("ResetProviderPresetAccess: %v", err)
2034 }
2035
2036 cfg = config.LoadForEdit(config.UserConfigPath())
2037 got, ok := cfg.Provider("mimo-api")
2038 if !ok {
2039 t.Fatal("mimo-api provider missing after reset")
2040 }
2041 if got.BaseURL != "https://api.xiaomimimo.com/v1" || got.DefaultModel() != "mimo-v2.5-pro" || got.PresetID != "mimo-api" || got.PresetVersion != config.ProviderPresetVersion {
2042 t.Fatalf("mimo-api provider after reset = %+v, want preset template", got)
2043 }
2044 if len(got.Headers) != 0 {
2045 t.Fatalf("mimo-api headers after reset = %+v, want preset headers", got.Headers)
2046 }
2047 if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
2048 t.Fatalf("provider_access missing mimo-api after reset: %+v", cfg.Desktop.ProviderAccess)
2049 }
2050 data, err := os.ReadFile(config.UserCredentialsPath())
2051 if err != nil {
2052 t.Fatalf("read saved credentials: %v", err)
2053 }
2054 if !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
2055 t.Fatalf("credentials changed after reset:\n%s", data)
2056 }
2057
2058 presetView := providerPresetViewByID(t, NewApp().Settings(), "mimo-api")
2059 if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
2060 t.Fatalf("mimo-api preset view = %+v, want installed after reset", presetView)
2061 }
2062 }
2063
2064 func TestResetProviderPresetAccessRejectsMissingSameNameProvider(t *testing.T) {
2065 isolateDesktopUserDirs(t)
2066
2067 if err := NewApp().ResetProviderPresetAccess("mimo-api"); err == nil {
2068 t.Fatal("ResetProviderPresetAccess unexpectedly reset a missing provider")
2069 } else if !strings.Contains(err.Error(), "no same-name provider exists") {
2070 t.Fatalf("ResetProviderPresetAccess error = %v, want missing same-name provider guard", err)
2071 }
2072 }
2073
2074 func TestAddEveryProviderPresetAccessInstallsTemplate(t *testing.T) {
2075 for _, preset := range config.CuratedProviderPresets() {
2076 preset := preset
2077 t.Run(preset.ID, func(t *testing.T) {
2078 isolateDesktopUserDirs(t)
2079
2080 if warning, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-test"); err != nil {
2081 t.Fatalf("AddProviderPresetAccess(%q): %v", preset.ID, err)
2082 } else if warning != "" {
2083 t.Fatalf("AddProviderPresetAccess(%q) warning = %q, want none", preset.ID, warning)
2084 }
2085
2086 cfg := config.LoadForEdit(config.UserConfigPath())
2087 access := providerAccessSet(cfg.Desktop.ProviderAccess)
2088 for _, entry := range preset.Entries {
2089 got, ok := cfg.Provider(entry.Name)
2090 if !ok {
2091 t.Fatalf("provider %q from preset %q was not saved", entry.Name, preset.ID)
2092 }
2093 if !access[entry.Name] {
2094 t.Fatalf("provider_access for preset %q missing %q: %+v", preset.ID, entry.Name, cfg.Desktop.ProviderAccess)
2095 }
2096 if got.Kind != entry.Kind || got.BaseURL != entry.BaseURL || got.DefaultModel() != entry.DefaultModel() || got.APIKeyEnv != entry.APIKeyEnv || got.AuthHeader != entry.AuthHeader || got.NoProxy != entry.NoProxy {
2097 t.Fatalf("provider %q core fields = %+v, want template %+v", entry.Name, got, entry)
2098 }
2099 if got.PresetID != preset.ID || got.PresetVersion != config.ProviderPresetVersion {
2100 t.Fatalf("provider %q preset metadata = %q/%d, want %q/%d", entry.Name, got.PresetID, got.PresetVersion, preset.ID, config.ProviderPresetVersion)
2101 }
2102 if got.ContextWindow != entry.ContextWindow || got.Thinking != entry.Thinking || got.DefaultEffort != entry.DefaultEffort || got.ReasoningProtocol != entry.ReasoningProtocol {
2103 t.Fatalf("provider %q capability fields = %+v, want template %+v", entry.Name, got, entry)
2104 }
2105 if !reflect.DeepEqual(got.ModelList(), entry.ModelList()) || !reflect.DeepEqual(got.VisionModels, entry.VisionModels) || !reflect.DeepEqual(got.SupportedEfforts, entry.SupportedEfforts) {
2106 t.Fatalf("provider %q models/capabilities = %+v, want template %+v", entry.Name, got, entry)
2107 }
2108 if !reflect.DeepEqual(got.Headers, entry.Headers) || !reflect.DeepEqual(got.ExtraBody, entry.ExtraBody) {
2109 t.Fatalf("provider %q request extras = %+v, want template %+v", entry.Name, got, entry)
2110 }
2111 }
2112
2113 view := NewApp().Settings()
2114 var presetView *ProviderPresetView
2115 for i := range view.ProviderPresets {
2116 if view.ProviderPresets[i].ID == preset.ID {
2117 presetView = &view.ProviderPresets[i]
2118 break
2119 }
2120 }
2121 if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet || !presetView.Configured {
2122 t.Fatalf("preset view for %q = %+v, want installed/key-set/configured", preset.ID, presetView)
2123 }
2124 })
2125 }
2126 }
2127
2128 func TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {
2129 isolateDesktopUserDirs(t)
2130 t.Setenv("DEEPSEEK_API_KEY", "")
2131 os.Unsetenv("DEEPSEEK_API_KEY")
2132
2133 app := NewApp()
2134 app.readyHook = func() {}
2135 app.setTestCtrl(newBackgroundJobController(t, "provider-access-job"), "deepseek-flash/deepseek-v4-flash")
2136
2137 _, err := app.AddOfficialProviderAccess("deepseek", "sk-test")
2138 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
2139 t.Fatalf("AddOfficialProviderAccess with background job error = %v, want active-work guard", err)
2140 }
2141 if data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
2142 t.Fatalf("provider key should not be saved after rejected add access:\n%s", data)
2143 }
2144 }
2145
2146 func TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {
2147 isolateDesktopUserDirs(t)
2148 t.Setenv("DEEPSEEK_API_KEY", "")
2149 os.Unsetenv("DEEPSEEK_API_KEY")
2150 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
2151 t.Fatalf("mkdir config dir: %v", err)
2152 }
2153 if err := os.WriteFile(config.UserConfigPath(), []byte(`
2154 default_model = "deepseek/deepseek-v4-flash"
2155
2156 [desktop]
2157 provider_access = []
2158
2159 [[providers]]
2160 name = "deepseek"
2161 kind = "openai"
2162 base_url = "https://api.deepseek.com"
2163 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
2164 default = "deepseek-v4-flash"
2165 api_key_env = "DEEPSEEK_API_KEY"
2166 `), 0o644); err != nil {
2167 t.Fatalf("write config: %v", err)
2168 }
2169
2170 if _, err := NewApp().SetProviderKey("DEEPSEEK_API_KEY", "sk-test"); err != nil {
2171 t.Fatalf("SetProviderKey: %v", err)
2172 }
2173 cfg := config.LoadForEdit(config.UserConfigPath())
2174 if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
2175 t.Fatalf("provider_access = %+v, want deepseek restored", cfg.Desktop.ProviderAccess)
2176 }
2177 got := NewApp().Settings()
2178 for _, p := range got.Providers {
2179 if p.Name == "deepseek" {
2180 if !p.Added || !p.KeySet {
2181 t.Fatalf("deepseek settings = %+v, want added and key-set", p)
2182 }
2183 return
2184 }
2185 }
2186 t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
2187 }
2188
2189 func TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {
2190 isolateDesktopUserDirs(t)
2191 t.Setenv("PROXY_DEEPSEEK_KEY", "")
2192 os.Unsetenv("PROXY_DEEPSEEK_KEY")
2193 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
2194 t.Fatalf("mkdir config dir: %v", err)
2195 }
2196 if err := os.WriteFile(config.UserConfigPath(), []byte(`
2197 [desktop]
2198 provider_access = []
2199
2200 [[providers]]
2201 name = "deepseek-flash"
2202 kind = "openai"
2203 base_url = "https://proxy.example/v1"
2204 model = "deepseek-v4-flash"
2205 api_key_env = "PROXY_DEEPSEEK_KEY"
2206 `), 0o644); err != nil {
2207 t.Fatalf("write config: %v", err)
2208 }
2209
2210 if _, err := NewApp().SetProviderKey("PROXY_DEEPSEEK_KEY", "sk-test"); err != nil {
2211 t.Fatalf("SetProviderKey: %v", err)
2212 }
2213 cfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
2214 access := providerAccessSet(cfg.Desktop.ProviderAccess)
2215 if !access["deepseek-flash"] {
2216 t.Fatalf("provider_access = %+v, want custom alias deepseek-flash", cfg.Desktop.ProviderAccess)
2217 }
2218 if access["deepseek"] {
2219 t.Fatalf("provider_access = %+v, should not canonicalize custom proxy to deepseek", cfg.Desktop.ProviderAccess)
2220 }
2221 }
2222
2223 func TestSetProviderKeyLeaseHeldKeepsCurrentController(t *testing.T) {
2224 isolateDesktopUserDirs(t)
2225 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2226
2227 cfg := config.Default()
2228 cfg.DefaultModel = "old/old-model"
2229 cfg.Desktop.ProviderAccess = []string{"old"}
2230 cfg.Providers = []config.ProviderEntry{
2231 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
2232 {Name: "longcat", Kind: "openai", BaseURL: "https://longcat.example/v1", Model: "longcat-chat", APIKeyEnv: "LONGCAT_API_KEY"},
2233 }
2234 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2235 t.Fatalf("save config: %v", err)
2236 }
2237
2238 dir := config.SessionDir()
2239 if err := os.MkdirAll(dir, 0o755); err != nil {
2240 t.Fatalf("mkdir session dir: %v", err)
2241 }
2242 sessionPath := filepath.Join(dir, "externally-leased-provider-key.jsonl")
2243 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2244 t.Fatalf("write placeholder session: %v", err)
2245 }
2246 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2247 if err != nil {
2248 t.Fatalf("TryAcquireSessionLease: %v", err)
2249 }
2250 defer externalLease.Release()
2251
2252 oldSession := agent.NewSession("old system prompt")
2253 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
2254 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
2255 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2256 defer oldCtrl.Close()
2257
2258 app := NewApp()
2259 app.ctx = context.Background()
2260 tab := &WorkspaceTab{
2261 ID: "tab_provider",
2262 Scope: "global",
2263 SessionPath: sessionPath,
2264 Ready: true,
2265 model: "old/old-model",
2266 Ctrl: oldCtrl,
2267 sink: &tabEventSink{tabID: "tab_provider", app: app},
2268 disabledMCP: map[string]ServerView{},
2269 }
2270 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2271 app.tabOrder = []string{tab.ID}
2272 app.activeTabID = tab.ID
2273
2274 warning, err := app.SetProviderKey("LONGCAT_API_KEY", "sk-longcat")
2275 if err != nil {
2276 t.Fatalf("SetProviderKey: %v", err)
2277 }
2278 if !strings.Contains(warning, "current session could not refresh yet") || !strings.Contains(warning, "another Reasonix window") {
2279 t.Fatalf("SetProviderKey warning = %q, want deferred rebuild warning", warning)
2280 }
2281 if strings.Contains(warning, sessionPath) || strings.Contains(warning, "held by") {
2282 t.Fatalf("SetProviderKey surfaced raw lease details: %v", warning)
2283 }
2284 if tab.Ctrl != oldCtrl {
2285 t.Fatalf("tab controller changed after failed provider-key rebuild")
2286 }
2287 if tab.StartupErr != "" {
2288 t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
2289 }
2290 if got := tab.Ctrl.History(); len(got) < 2 || got[1].Content != "hello" {
2291 t.Fatalf("history after failed provider-key rebuild = %+v", got)
2292 }
2293 if access := providerAccessSet(config.LoadForEditWithoutCredentials(config.UserConfigPath()).Desktop.ProviderAccess); !access["longcat"] {
2294 t.Fatalf("provider_access should still persist longcat after key save")
2295 }
2296 }
2297
2298 func TestSetProviderKeyRebuildSupersedesInFlightStartupBuild(t *testing.T) {
2299 isolateDesktopUserDirs(t)
2300
2301 cfg := config.Default()
2302 cfg.DefaultModel = "old/old-model"
2303 cfg.Desktop.ProviderAccess = []string{"old"}
2304 cfg.Providers = []config.ProviderEntry{{
2305 Name: "old",
2306 Kind: "openai",
2307 BaseURL: "https://example.invalid/v1",
2308 Model: "old-model",
2309 APIKeyEnv: "OLD_MODEL_KEY",
2310 }}
2311 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2312 t.Fatalf("save config: %v", err)
2313 }
2314
2315 dir := config.SessionDir()
2316 if err := os.MkdirAll(dir, 0o755); err != nil {
2317 t.Fatalf("mkdir session dir: %v", err)
2318 }
2319 sessionPath := filepath.Join(dir, "startup-build-in-flight.jsonl")
2320 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2321 t.Fatalf("write placeholder session: %v", err)
2322 }
2323
2324 app := NewApp()
2325 app.ctx = context.Background()
2326 app.readyHook = func() {}
2327 // Model the async startup build still being in flight: no controller yet,
2328 // a live build generation, and a cancellable build context.
2329 buildCtx, buildCancel := context.WithCancel(context.Background())
2330 const startupGeneration = 1
2331 tab := &WorkspaceTab{
2332 ID: "tab_key_rebuild",
2333 Scope: "global",
2334 SessionPath: sessionPath,
2335 model: "old/old-model",
2336 buildGeneration: startupGeneration,
2337 buildCancel: buildCancel,
2338 disabledMCP: map[string]ServerView{},
2339 }
2340 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
2341 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2342 app.tabOrder = []string{tab.ID}
2343 app.activeTabID = tab.ID
2344 t.Cleanup(tab.releaseSessionLease)
2345
2346 if _, err := app.SetProviderKey("OLD_MODEL_KEY", "sk-new"); err != nil {
2347 t.Fatalf("SetProviderKey: %v", err)
2348 }
2349 if tab.Ctrl == nil {
2350 t.Fatal("provider-key rebuild did not install a controller")
2351 }
2352 defer tab.Ctrl.Close()
2353
2354 assertTabBuildSuperseded(t, app, tab, startupGeneration, buildCtx)
2355 }
2356
2357 func TestSaveProviderWithKeyLeaseHeldPersistsCustomProvider(t *testing.T) {
2358 isolateDesktopUserDirs(t)
2359 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2360
2361 cfg := config.Default()
2362 cfg.DefaultModel = "old/old-model"
2363 cfg.Desktop.ProviderAccess = []string{"old"}
2364 cfg.Providers = []config.ProviderEntry{{
2365 Name: "old",
2366 Kind: "openai",
2367 BaseURL: "https://example.invalid/v1",
2368 Model: "old-model",
2369 APIKeyEnv: "OLD_MODEL_KEY",
2370 }}
2371 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2372 t.Fatalf("save config: %v", err)
2373 }
2374
2375 dir := config.SessionDir()
2376 if err := os.MkdirAll(dir, 0o755); err != nil {
2377 t.Fatalf("mkdir session dir: %v", err)
2378 }
2379 sessionPath := filepath.Join(dir, "externally-leased-custom-provider.jsonl")
2380 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2381 t.Fatalf("write placeholder session: %v", err)
2382 }
2383 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2384 if err != nil {
2385 t.Fatalf("TryAcquireSessionLease: %v", err)
2386 }
2387 defer externalLease.Release()
2388
2389 oldSession := agent.NewSession("old system prompt")
2390 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
2391 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
2392 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2393 defer oldCtrl.Close()
2394
2395 app := NewApp()
2396 app.ctx = context.Background()
2397 tab := &WorkspaceTab{
2398 ID: "tab_custom_provider",
2399 Scope: "global",
2400 SessionPath: sessionPath,
2401 Ready: true,
2402 model: "old/old-model",
2403 Ctrl: oldCtrl,
2404 sink: &tabEventSink{tabID: "tab_custom_provider", app: app},
2405 disabledMCP: map[string]ServerView{},
2406 }
2407 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2408 app.tabOrder = []string{tab.ID}
2409 app.activeTabID = tab.ID
2410
2411 warning, err := app.SaveProviderWithKey(ProviderView{
2412 Name: "proxy",
2413 Kind: "openai",
2414 BaseURL: "https://proxy.example/v1",
2415 Models: []string{"model-a", "model-b"},
2416 Default: "model-a",
2417 APIKeyEnv: "PROXY_API_KEY",
2418 }, "sk-proxy")
2419 if err != nil {
2420 t.Fatalf("SaveProviderWithKey: %v", err)
2421 }
2422 if !strings.Contains(warning, "current session could not refresh yet") || !strings.Contains(warning, "another Reasonix window") {
2423 t.Fatalf("SaveProviderWithKey warning = %q, want deferred rebuild warning", warning)
2424 }
2425 if strings.Contains(warning, sessionPath) || strings.Contains(warning, "held by") {
2426 t.Fatalf("SaveProviderWithKey surfaced raw lease details: %v", warning)
2427 }
2428 if tab.Ctrl != oldCtrl {
2429 t.Fatalf("tab controller changed after failed provider rebuild")
2430 }
2431 gotCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
2432 got, ok := gotCfg.Provider("proxy")
2433 if !ok {
2434 t.Fatal("custom provider was not saved")
2435 }
2436 if want := []string{"model-a", "model-b"}; !reflect.DeepEqual(got.ModelList(), want) {
2437 t.Fatalf("custom provider models = %v, want %v", got.ModelList(), want)
2438 }
2439 if !providerAccessSet(gotCfg.Desktop.ProviderAccess)["proxy"] {
2440 t.Fatalf("provider_access = %+v, want proxy", gotCfg.Desktop.ProviderAccess)
2441 }
2442 data, err := os.ReadFile(config.UserCredentialsPath())
2443 if err != nil {
2444 t.Fatalf("read credentials: %v", err)
2445 }
2446 if !strings.Contains(string(data), "PROXY_API_KEY=sk-proxy") {
2447 t.Fatalf("provider key was not saved:\n%s", data)
2448 }
2449 }
2450
2451 func TestConfigChangeLeaseHeldPersistsAndDefersRefresh(t *testing.T) {
2452 isolateDesktopUserDirs(t)
2453 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2454
2455 cfg := config.Default()
2456 cfg.DefaultModel = "old/old-model"
2457 cfg.Desktop.ProviderAccess = []string{"old"}
2458 cfg.Providers = []config.ProviderEntry{{
2459 Name: "old",
2460 Kind: "openai",
2461 BaseURL: "https://example.invalid/v1",
2462 Model: "old-model",
2463 APIKeyEnv: "OLD_MODEL_KEY",
2464 }}
2465 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2466 t.Fatalf("save config: %v", err)
2467 }
2468
2469 dir := config.SessionDir()
2470 if err := os.MkdirAll(dir, 0o755); err != nil {
2471 t.Fatalf("mkdir session dir: %v", err)
2472 }
2473 sessionPath := filepath.Join(dir, "externally-leased-settings.jsonl")
2474 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2475 t.Fatalf("write placeholder session: %v", err)
2476 }
2477 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2478 if err != nil {
2479 t.Fatalf("TryAcquireSessionLease: %v", err)
2480 }
2481 defer externalLease.Release()
2482
2483 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
2484 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2485 defer oldCtrl.Close()
2486
2487 app := NewApp()
2488 app.ctx = context.Background()
2489 tab := &WorkspaceTab{
2490 ID: "tab_settings",
2491 Scope: "global",
2492 SessionPath: sessionPath,
2493 Ready: true,
2494 model: "old/old-model",
2495 Ctrl: oldCtrl,
2496 sink: &tabEventSink{tabID: "tab_settings", app: app},
2497 disabledMCP: map[string]ServerView{},
2498 }
2499 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2500 app.tabOrder = []string{tab.ID}
2501 app.activeTabID = tab.ID
2502
2503 if err := app.SetMaxSubagentDepth(1); err != nil {
2504 t.Fatalf("SetMaxSubagentDepth should defer lease-held refresh instead of failing: %v", err)
2505 }
2506 if tab.Ctrl != oldCtrl {
2507 t.Fatalf("tab controller changed after deferred settings rebuild")
2508 }
2509 got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
2510 if got.Agent.MaxSubagentDepth != 1 {
2511 t.Fatalf("saved max_subagent_depth = %d, want 1", got.Agent.MaxSubagentDepth)
2512 }
2513 }
2514
2515 func TestDeferredRebuildRetryAppliesAfterLeaseRelease(t *testing.T) {
2516 isolateDesktopUserDirs(t)
2517 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2518
2519 prevInterval := deferredRebuildRetryInterval
2520 deferredRebuildRetryInterval = 20 * time.Millisecond
2521 t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval })
2522
2523 cfg := config.Default()
2524 cfg.DefaultModel = "old/old-model"
2525 cfg.Desktop.ProviderAccess = []string{"old"}
2526 cfg.Providers = []config.ProviderEntry{{
2527 Name: "old",
2528 Kind: "openai",
2529 BaseURL: "https://example.invalid/v1",
2530 Model: "old-model",
2531 APIKeyEnv: "OLD_MODEL_KEY",
2532 }}
2533 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2534 t.Fatalf("save config: %v", err)
2535 }
2536
2537 dir := config.SessionDir()
2538 if err := os.MkdirAll(dir, 0o755); err != nil {
2539 t.Fatalf("mkdir session dir: %v", err)
2540 }
2541 sessionPath := filepath.Join(dir, "deferred-rebuild-retry.jsonl")
2542 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2543 t.Fatalf("write placeholder session: %v", err)
2544 }
2545 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2546 if err != nil {
2547 t.Fatalf("TryAcquireSessionLease: %v", err)
2548 }
2549 released := false
2550 defer func() {
2551 if !released {
2552 externalLease.Release()
2553 }
2554 }()
2555
2556 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
2557 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2558 defer oldCtrl.Close()
2559
2560 app := NewApp()
2561 app.ctx = context.Background()
2562 app.readyHook = func() {}
2563 app.enableDeferredRebuildRetry()
2564 t.Cleanup(app.stopDeferredRebuildRetry)
2565 tab := &WorkspaceTab{
2566 ID: "tab_deferred_retry",
2567 Scope: "global",
2568 SessionPath: sessionPath,
2569 Ready: true,
2570 model: "old/old-model",
2571 Ctrl: oldCtrl,
2572 sink: &tabEventSink{tabID: "tab_deferred_retry", app: app},
2573 disabledMCP: map[string]ServerView{},
2574 }
2575 installNoopRuntimeEvents(app, tab.sink)
2576 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2577 app.tabOrder = []string{tab.ID}
2578 app.activeTabID = tab.ID
2579 t.Cleanup(func() {
2580 if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
2581 c.Close()
2582 }
2583 tab.releaseSessionLease()
2584 })
2585
2586 if err := app.SetMaxSubagentDepth(1); err != nil {
2587 t.Fatalf("SetMaxSubagentDepth: %v", err)
2588 }
2589 if !app.deferredRebuildPending(tab.ID) {
2590 t.Fatal("deferred rebuild was not scheduled while the lease is held")
2591 }
2592 if app.controllerForTab(tab) != oldCtrl {
2593 t.Fatal("controller changed while the lease is still held")
2594 }
2595
2596 externalLease.Release()
2597 released = true
2598
2599 deadline := time.Now().Add(10 * time.Second)
2600 for time.Now().Before(deadline) {
2601 if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != oldCtrl {
2602 break
2603 }
2604 time.Sleep(10 * time.Millisecond)
2605 }
2606 if app.deferredRebuildPending(tab.ID) {
2607 t.Fatal("deferred rebuild is still pending after the lease was released")
2608 }
2609 if c := app.controllerForTab(tab); c == nil || c == oldCtrl {
2610 t.Fatalf("controller was not rebuilt after the lease release: got %p", c)
2611 }
2612 }
2613
2614 func TestDeferredRebuildScheduleAfterStopIsNoop(t *testing.T) {
2615 app := NewApp()
2616 app.stopDeferredRebuildRetry()
2617 app.scheduleDeferredRebuild("tab_x", "settings")
2618 if app.deferredRebuildPending("tab_x") {
2619 t.Fatal("schedule after stop should not register pending work")
2620 }
2621 }
2622
2623 func TestDeferredRebuildWaitsForTabToBecomeActive(t *testing.T) {
2624 isolateDesktopUserDirs(t)
2625 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2626
2627 prevInterval := deferredRebuildRetryInterval
2628 deferredRebuildRetryInterval = 20 * time.Millisecond
2629 t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval })
2630
2631 cfg := config.Default()
2632 cfg.DefaultModel = "old/old-model"
2633 cfg.Desktop.ProviderAccess = []string{"old"}
2634 cfg.Providers = []config.ProviderEntry{{
2635 Name: "old",
2636 Kind: "openai",
2637 BaseURL: "https://example.invalid/v1",
2638 Model: "old-model",
2639 APIKeyEnv: "OLD_MODEL_KEY",
2640 }}
2641 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2642 t.Fatalf("save config: %v", err)
2643 }
2644
2645 dir := config.SessionDir()
2646 if err := os.MkdirAll(dir, 0o755); err != nil {
2647 t.Fatalf("mkdir session dir: %v", err)
2648 }
2649 sessionPath := filepath.Join(dir, "deferred-rebuild-inactive.jsonl")
2650 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2651 t.Fatalf("write placeholder session: %v", err)
2652 }
2653 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2654 if err != nil {
2655 t.Fatalf("TryAcquireSessionLease: %v", err)
2656 }
2657 released := false
2658 defer func() {
2659 if !released {
2660 externalLease.Release()
2661 }
2662 }()
2663
2664 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
2665 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2666 defer oldCtrl.Close()
2667
2668 otherCtrl := control.New(control.Options{Label: "other"})
2669 defer otherCtrl.Close()
2670
2671 app := NewApp()
2672 app.ctx = context.Background()
2673 app.readyHook = func() {}
2674 app.enableDeferredRebuildRetry()
2675 t.Cleanup(app.stopDeferredRebuildRetry)
2676 tab := &WorkspaceTab{
2677 ID: "tab_pending",
2678 Scope: "global",
2679 SessionPath: sessionPath,
2680 Ready: true,
2681 model: "old/old-model",
2682 Ctrl: oldCtrl,
2683 sink: &tabEventSink{tabID: "tab_pending", app: app},
2684 disabledMCP: map[string]ServerView{},
2685 }
2686 installNoopRuntimeEvents(app, tab.sink)
2687 other := &WorkspaceTab{
2688 ID: "tab_other",
2689 Scope: "global",
2690 Ready: true,
2691 model: "old/old-model",
2692 Ctrl: otherCtrl,
2693 sink: &tabEventSink{tabID: "tab_other", app: app},
2694 disabledMCP: map[string]ServerView{},
2695 }
2696 app.tabs = map[string]*WorkspaceTab{tab.ID: tab, other.ID: other}
2697 app.tabOrder = []string{tab.ID, other.ID}
2698 app.activeTabID = tab.ID
2699 t.Cleanup(func() {
2700 if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
2701 c.Close()
2702 }
2703 tab.releaseSessionLease()
2704 })
2705
2706 if err := app.SetMaxSubagentDepth(1); err != nil {
2707 t.Fatalf("SetMaxSubagentDepth: %v", err)
2708 }
2709 if !app.deferredRebuildPending(tab.ID) {
2710 t.Fatal("deferred rebuild was not scheduled while the lease is held")
2711 }
2712
2713 // Focus another tab, then release the lease: the retry must not rebuild
2714 // while the pending tab is inactive (rebuildSettingLocked acts on the
2715 // active tab), and must not touch the focused tab's runtime either.
2716 app.mu.Lock()
2717 app.activeTabID = other.ID
2718 app.mu.Unlock()
2719 externalLease.Release()
2720 released = true
2721
2722 time.Sleep(150 * time.Millisecond)
2723 if !app.deferredRebuildPending(tab.ID) {
2724 t.Fatal("pending entry was consumed while its tab was inactive")
2725 }
2726 if app.controllerForTab(tab) != oldCtrl {
2727 t.Fatal("inactive pending tab was rebuilt")
2728 }
2729 if app.controllerForTab(other) != otherCtrl {
2730 t.Fatal("focused tab was rebuilt by another tab's deferred retry")
2731 }
2732
2733 // Switch back: the retry should now refresh the pending tab.
2734 app.mu.Lock()
2735 app.activeTabID = tab.ID
2736 app.mu.Unlock()
2737
2738 deadline := time.Now().Add(10 * time.Second)
2739 for time.Now().Before(deadline) {
2740 if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != oldCtrl {
2741 break
2742 }
2743 time.Sleep(10 * time.Millisecond)
2744 }
2745 if app.deferredRebuildPending(tab.ID) {
2746 t.Fatal("deferred rebuild still pending after its tab became active again")
2747 }
2748 if c := app.controllerForTab(tab); c == nil || c == oldCtrl {
2749 t.Fatalf("controller was not rebuilt after tab reactivation: got %p", c)
2750 }
2751 }
2752
2753 func TestSetEffortForTabLeaseHeldKeepsOldControllerAlive(t *testing.T) {
2754 isolateDesktopUserDirs(t)
2755 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2756
2757 cfg := config.Default()
2758 cfg.DefaultModel = "old/old-model"
2759 cfg.Desktop.ProviderAccess = []string{"old"}
2760 cfg.Providers = []config.ProviderEntry{{
2761 Name: "old",
2762 Kind: "openai",
2763 BaseURL: "https://example.invalid/v1",
2764 Model: "old-model",
2765 APIKeyEnv: "OLD_MODEL_KEY",
2766 SupportedEfforts: []string{"low", "max"},
2767 }}
2768 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2769 t.Fatalf("save config: %v", err)
2770 }
2771
2772 dir := config.SessionDir()
2773 if err := os.MkdirAll(dir, 0o755); err != nil {
2774 t.Fatalf("mkdir session dir: %v", err)
2775 }
2776 sessionPath := filepath.Join(dir, "externally-leased-effort-switch.jsonl")
2777 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
2778 t.Fatalf("write placeholder session: %v", err)
2779 }
2780 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
2781 if err != nil {
2782 t.Fatalf("TryAcquireSessionLease: %v", err)
2783 }
2784 released := false
2785 defer func() {
2786 if !released {
2787 externalLease.Release()
2788 }
2789 }()
2790
2791 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
2792 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
2793 defer oldCtrl.Close()
2794
2795 app := NewApp()
2796 app.ctx = context.Background()
2797 tab := &WorkspaceTab{
2798 ID: "tab_effort",
2799 Scope: "global",
2800 SessionPath: sessionPath,
2801 Ready: true,
2802 model: "old/old-model",
2803 Ctrl: oldCtrl,
2804 sink: &tabEventSink{tabID: "tab_effort", app: app},
2805 disabledMCP: map[string]ServerView{},
2806 }
2807 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2808 app.tabOrder = []string{tab.ID}
2809 app.activeTabID = tab.ID
2810 t.Cleanup(func() {
2811 if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
2812 c.Close()
2813 }
2814 tab.releaseSessionLease()
2815 })
2816
2817 err = app.SetEffortForTab(tab.ID, "max")
2818 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
2819 t.Fatalf("SetEffortForTab err = %v, want ErrSessionLeaseHeld", err)
2820 }
2821 if strings.Contains(err.Error(), sessionPath) || strings.Contains(err.Error(), "held by") {
2822 t.Fatalf("SetEffortForTab surfaced raw lease details: %v", err)
2823 }
2824 if tab.Ctrl != oldCtrl {
2825 t.Fatal("tab controller changed after failed effort switch")
2826 }
2827
2828 // The failed switch must leave the old runtime alive: after the other
2829 // window releases the lease, retrying from the same tab has to succeed.
2830 // (The old code closed the old controller before acquiring the lease, so
2831 // this retry died on a snapshot of a closed session.)
2832 externalLease.Release()
2833 released = true
2834 if err := app.SetEffortForTab(tab.ID, "max"); err != nil {
2835 t.Fatalf("SetEffortForTab retry after lease release: %v", err)
2836 }
2837 if tab.Ctrl == oldCtrl {
2838 t.Fatal("retry did not rebuild the controller")
2839 }
2840 }
2841
2842 func TestSetEffortForTabReanchorsDepthCapRecoveryBranch(t *testing.T) {
2843 isolateDesktopUserDirs(t)
2844 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
2845
2846 cfg := config.Default()
2847 cfg.DefaultModel = "old/old-model"
2848 cfg.Desktop.ProviderAccess = []string{"old"}
2849 cfg.Providers = []config.ProviderEntry{{
2850 Name: "old",
2851 Kind: "openai",
2852 BaseURL: "https://example.invalid/v1",
2853 Model: "old-model",
2854 APIKeyEnv: "OLD_MODEL_KEY",
2855 SupportedEfforts: []string{"low", "max"},
2856 }}
2857 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
2858 t.Fatalf("save config: %v", err)
2859 }
2860
2861 dir := config.SessionDir()
2862 if err := os.MkdirAll(dir, 0o755); err != nil {
2863 t.Fatalf("mkdir session dir: %v", err)
2864 }
2865 recoveryPath := filepath.Join(dir, "effort-switch-conflict-recovery-deadbeef.jsonl")
2866 disk := agent.NewSession("old system prompt")
2867 disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2868 disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
2869 disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
2870 if err := disk.Save(recoveryPath); err != nil {
2871 t.Fatalf("save recovery branch: %v", err)
2872 }
2873 meta, ok, err := agent.LoadBranchMeta(recoveryPath)
2874 if err != nil || !ok {
2875 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
2876 }
2877 meta.Recovered = true
2878 meta.ParentID = "effort-switch-conflict"
2879 meta.RecoveryReason = "snapshot conflict"
2880 meta.RecoveryDepth = agent.SessionRecoveryMaxDepth
2881 if err := agent.SaveBranchMeta(recoveryPath, meta); err != nil {
2882 t.Fatalf("SaveBranchMeta: %v", err)
2883 }
2884
2885 stale := agent.NewSession("old system prompt")
2886 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2887 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
2888 stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
2889 oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
2890
2891 app := NewApp()
2892 app.ctx = context.Background()
2893 app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
2894 tab := &WorkspaceTab{
2895 ID: "tab_depth_cap_effort",
2896 Scope: "global",
2897 SessionPath: recoveryPath,
2898 Ready: true,
2899 model: "old/old-model",
2900 disabledMCP: map[string]ServerView{},
2901 }
2902 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
2903 oldCtrl := control.New(control.Options{
2904 Executor: oldExec,
2905 SessionDir: dir,
2906 SessionPath: recoveryPath,
2907 Label: "old",
2908 Sink: tab.sink,
2909 SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
2910 OnSessionRecovered: app.handleTabSessionRecovered(tab),
2911 })
2912 tab.Ctrl = oldCtrl
2913 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
2914 app.tabOrder = []string{tab.ID}
2915 app.activeTabID = tab.ID
2916 t.Cleanup(func() {
2917 if tab.Ctrl != nil {
2918 tab.Ctrl.Close()
2919 }
2920 tab.releaseSessionLease()
2921 })
2922 stale.IncrementRewrite()
2923
2924 if err := app.SetEffortForTab(tab.ID, "max"); err != nil {
2925 t.Fatalf("SetEffortForTab: %v", err)
2926 }
2927 if got := tab.Ctrl.SessionPath(); got != recoveryPath {
2928 t.Fatalf("session path after effort switch = %q, want current recovery branch %q", got, recoveryPath)
2929 }
2930 if got := tab.currentSessionPath(); got != recoveryPath {
2931 t.Fatalf("tab current session path = %q, want %q", got, recoveryPath)
2932 }
2933 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) {
2934 t.Fatalf("tab lease path = %q, want %q", tab.sessionLeaseRuntimeKey(), recoveryPath)
2935 }
2936 matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
2937 if err != nil {
2938 t.Fatalf("glob recovery branches: %v", err)
2939 }
2940 matches = primarySessionFiles(matches)
2941 if len(matches) != 1 || matches[0] != recoveryPath {
2942 t.Fatalf("recovery branches after effort switch = %v, want only %q", matches, recoveryPath)
2943 }
2944
2945 lines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
2946 if len(lines) != 1 {
2947 t.Fatalf("conflict log lines = %v, want one depth-cap diagnostic", lines)
2948 }
2949 if !strings.Contains(lines[0], `"outcome":"recovery_depth_cap_force_saved"`) {
2950 t.Fatalf("conflict diagnostic = %s, want depth-cap outcome", lines[0])
2951 }
2952 if strings.Contains(lines[0], dir) || strings.Contains(lines[0], recoveryPath) {
2953 t.Fatalf("conflict diagnostic leaked local path: %s", lines[0])
2954 }
2955
2956 if err := tab.Ctrl.Snapshot(); err != nil {
2957 t.Fatalf("Snapshot after effort switch recovery: %v", err)
2958 }
2959 afterLines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
2960 if len(afterLines) != len(lines) {
2961 t.Fatalf("follow-up snapshot appended conflict diagnostics: before=%v after=%v", lines, afterLines)
2962 }
2963 matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
2964 if err != nil {
2965 t.Fatalf("glob recovery branches after snapshot: %v", err)
2966 }
2967 matches = primarySessionFiles(matches)
2968 if len(matches) != 1 || matches[0] != recoveryPath {
2969 t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
2970 }
2971 }
2972
2973 func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {
2974 isolateDesktopUserDirs(t)
2975 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
2976 t.Fatalf("mkdir config dir: %v", err)
2977 }
2978 if err := os.WriteFile(config.UserConfigPath(), []byte(`
2979 [desktop]
2980 language = "zh"
2981 `), 0o644); err != nil {
2982 t.Fatalf("write config: %v", err)
2983 }
2984
2985 if _, err := NewApp().AddOfficialProviderAccess("deepseek", ""); err != nil {
2986 t.Fatalf("AddOfficialProviderAccess: %v", err)
2987 }
2988 cfg := config.LoadForEdit(config.UserConfigPath())
2989 p, ok := cfg.Provider("deepseek")
2990 if !ok {
2991 t.Fatal("deepseek provider not saved")
2992 }
2993 flash := p.Prices["deepseek-v4-flash"]
2994 pro := p.Prices["deepseek-v4-pro"]
2995 if flash == nil || flash.Output != 2 || flash.Currency != "¥" {
2996 t.Fatalf("flash price = %+v, want CNY preset", flash)
2997 }
2998 if pro == nil || pro.Output != 6 || pro.Currency != "¥" {
2999 t.Fatalf("pro price = %+v, want CNY preset", pro)
3000 }
3001 }
3002
3003 func TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {
3004 isolateDesktopUserDirs(t)
3005 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
3006 t.Fatalf("mkdir config dir: %v", err)
3007 }
3008 if err := os.WriteFile(config.UserConfigPath(), []byte(`
3009 default_model = "deepseek-flash/deepseek-v4-pro"
3010
3011 [desktop]
3012 provider_access = ["deepseek-flash", "mimo-pro"]
3013
3014 [[providers]]
3015 name = "deepseek-flash"
3016 kind = "openai"
3017 base_url = "https://api.deepseek.com"
3018 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
3019 default = "deepseek-v4-flash"
3020 api_key_env = "DEEPSEEK_API_KEY"
3021
3022 [[providers]]
3023 name = "mimo-pro"
3024 kind = "openai"
3025 base_url = "https://token-plan-cn.xiaomimimo.com/v1"
3026 model = "mimo-v2.5-pro"
3027 api_key_env = "MIMO_API_KEY"
3028 `), 0o644); err != nil {
3029 t.Fatalf("write config: %v", err)
3030 }
3031
3032 if err := NewApp().RemoveProviderAccess("deepseek"); err != nil {
3033 t.Fatalf("RemoveProviderAccess: %v", err)
3034 }
3035 cfg := config.LoadForEdit(config.UserConfigPath())
3036 access := providerAccessSet(cfg.Desktop.ProviderAccess)
3037 if access["deepseek"] || !access["mimo-pro"] {
3038 t.Fatalf("provider_access = %+v, want only mimo-pro", cfg.Desktop.ProviderAccess)
3039 }
3040 if cfg.DefaultModel != "mimo-pro/mimo-v2.5-pro" {
3041 t.Fatalf("default_model = %q, want mimo-pro/mimo-v2.5-pro", cfg.DefaultModel)
3042 }
3043 }
3044
3045 func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
3046 isolateDesktopUserDirs(t)
3047 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
3048 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
3049
3050 cfg := config.Default()
3051 cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash"
3052 cfg.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"}
3053 deepseek, _ := cfg.Provider("deepseek-flash")
3054 deepseek.Model = ""
3055 deepseek.Models = []string{"deepseek-v4-flash", "deepseek-v4-pro"}
3056 deepseek.Default = "deepseek-v4-flash"
3057 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3058 t.Fatalf("save config: %v", err)
3059 }
3060
3061 models := NewApp().Models()
3062 refs := modelRefsFromView(models)
3063 for _, want := range []string{
3064 "deepseek/deepseek-v4-flash",
3065 "deepseek/deepseek-v4-pro",
3066 "mimo-pro/mimo-v2.5-pro",
3067 "mimo-pro/mimo-v2.5",
3068 } {
3069 if !refs[want] {
3070 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3071 }
3072 }
3073 for _, hidden := range []string{
3074 "deepseek-pro/deepseek-v4-pro",
3075 "mimo-flash/mimo-v2.5",
3076 } {
3077 if refs[hidden] {
3078 t.Fatalf("Models() refs = %+v, should not include hidden provider %s", models, hidden)
3079 }
3080 }
3081 if len(models) != 4 {
3082 t.Fatalf("Models() len = %d, want 4: %+v", len(models), models)
3083 }
3084 }
3085
3086 func TestModelsForTabListsNothingWhenProviderAccessExplicitlyEmpty(t *testing.T) {
3087 isolateDesktopUserDirs(t)
3088 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
3089
3090 cfg := config.Default()
3091 cfg.Desktop.ProviderAccess = []string{}
3092 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3093 t.Fatalf("save config: %v", err)
3094 }
3095
3096 if models := NewApp().Models(); len(models) != 0 {
3097 t.Fatalf("Models() = %+v, want no models when provider access is explicitly empty", models)
3098 }
3099 }
3100
3101 func TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {
3102 isolateDesktopUserDirs(t)
3103 setDesktopTestCredential(t, "LOCAL_API_KEY", "sk-test")
3104 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
3105 t.Fatalf("mkdir config dir: %v", err)
3106 }
3107 if err := os.WriteFile(config.UserConfigPath(), []byte(`
3108 default_model = "local/model-a"
3109
3110 [desktop]
3111 provider_access = ["local"]
3112
3113 [[providers]]
3114 name = "local"
3115 kind = "openai"
3116 base_url = "http://127.0.0.1:23333/v1"
3117 models = ["model-a", "model-b"]
3118 default = "model-a"
3119 api_key_env = "LOCAL_API_KEY"
3120 `), 0o644); err != nil {
3121 t.Fatalf("write config: %v", err)
3122 }
3123
3124 models := NewApp().Models()
3125 refs := modelRefsFromView(models)
3126 for _, want := range []string{"local/model-a", "local/model-b"} {
3127 if !refs[want] {
3128 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3129 }
3130 }
3131 if len(models) != 2 {
3132 t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
3133 }
3134 }
3135
3136 func TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {
3137 isolateDesktopUserDirs(t)
3138 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
3139 t.Fatalf("mkdir config dir: %v", err)
3140 }
3141 if err := os.WriteFile(config.UserConfigPath(), []byte(`
3142 default_model = "local/model-a"
3143
3144 [desktop]
3145 provider_access = ["local"]
3146
3147 [[providers]]
3148 name = "local"
3149 kind = "openai"
3150 base_url = "http://127.0.0.1:23333/v1"
3151 models = ["model-a", "model-b"]
3152 default = "model-a"
3153 `), 0o644); err != nil {
3154 t.Fatalf("write config: %v", err)
3155 }
3156
3157 models := NewApp().Models()
3158 refs := modelRefsFromView(models)
3159 for _, want := range []string{"local/model-a", "local/model-b"} {
3160 if !refs[want] {
3161 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3162 }
3163 }
3164 }
3165
3166 func TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {
3167 isolateDesktopUserDirs(t)
3168 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
3169 t.Fatalf("mkdir config dir: %v", err)
3170 }
3171 if err := os.WriteFile(config.UserConfigPath(), []byte(`
3172 default_model = "local/model-a"
3173
3174 [desktop]
3175 provider_access = ["local"]
3176
3177 [[providers]]
3178 name = "local"
3179 kind = "openai"
3180 base_url = "http://127.0.0.1:23333/v1"
3181 models = ["model-a", "model-b"]
3182 default = "model-a"
3183 api_key_env = "LOCAL_API_KEY"
3184 `), 0o644); err != nil {
3185 t.Fatalf("write config: %v", err)
3186 }
3187
3188 models := NewApp().Models()
3189 refs := modelRefsFromView(models)
3190 for _, want := range []string{"local/model-a", "local/model-b"} {
3191 if !refs[want] {
3192 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3193 }
3194 }
3195 }
3196
3197 func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
3198 isolateDesktopUserDirs(t)
3199 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
3200
3201 cfg := config.Default()
3202 preset, ok := config.CuratedProviderPreset("mimo-api")
3203 if !ok || len(preset.Entries) == 0 {
3204 t.Fatal("mimo-api preset missing")
3205 }
3206 if err := cfg.UpsertProvider(preset.Entries[0]); err != nil {
3207 t.Fatalf("upsert mimo-api preset: %v", err)
3208 }
3209 cfg.DefaultModel = "mimo-api/mimo-v2.5-pro"
3210 cfg.Desktop.ProviderAccess = []string{"mimo-api"}
3211 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3212 t.Fatalf("save config: %v", err)
3213 }
3214
3215 models := NewApp().Models()
3216 refs := modelRefsFromView(models)
3217 for _, want := range []string{
3218 "mimo-api/mimo-v2.5-pro",
3219 "mimo-api/mimo-v2.5",
3220 } {
3221 if !refs[want] {
3222 t.Fatalf("Models() refs = %+v, missing %s", models, want)
3223 }
3224 }
3225 if len(models) != 2 {
3226 t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
3227 }
3228 }
3229
3230 func TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {
3231 isolateDesktopUserDirs(t)
3232 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
3233 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
3234
3235 userCfg := config.Default()
3236 userCfg.DefaultModel = "mimo-pro/mimo-v2.5-pro"
3237 userCfg.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"}
3238 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
3239 t.Fatalf("save user config: %v", err)
3240 }
3241
3242 projectRoot := t.TempDir()
3243 projectConfig := `default_model = "deepseek-flash/deepseek-v4-flash"
3244
3245 [desktop]
3246 provider_access = ["deepseek-flash"]
3247
3248 [[providers]]
3249 name = "deepseek-flash"
3250 kind = "openai"
3251 base_url = "https://api.deepseek.com"
3252 model = "deepseek-v4-flash"
3253 api_key_env = "DEEPSEEK_API_KEY"
3254 `
3255 if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil {
3256 t.Fatalf("write project config: %v", err)
3257 }
3258
3259 app := NewApp()
3260 tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true}
3261 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3262 app.activeTabID = tab.ID
3263
3264 models := app.ModelsForTab(tab.ID)
3265 refs := modelRefsFromView(models)
3266 for _, want := range []string{
3267 "deepseek/deepseek-v4-flash",
3268 "mimo-pro/mimo-v2.5-pro",
3269 } {
3270 if !refs[want] {
3271 t.Fatalf("ModelsForTab refs = %+v, missing %s", models, want)
3272 }
3273 }
3274 }
3275
3276 func TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {
3277 isolateDesktopUserDirs(t)
3278 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
3279 setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
3280
3281 cfg := config.Default()
3282 cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash"
3283 cfg.Desktop.ProviderAccess = []string{"deepseek-flash"}
3284 cfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: "other", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "other-model"})
3285 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3286 t.Fatalf("save config: %v", err)
3287 }
3288
3289 app := NewApp()
3290 app.ctx = context.Background()
3291 tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ready: true, model: "deepseek-flash/deepseek-v4-flash"}
3292 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3293 app.tabOrder = []string{tab.ID}
3294 app.activeTabID = tab.ID
3295
3296 err := app.SetModelForTab(tab.ID, "other/other-model")
3297 if err == nil || !strings.Contains(err.Error(), "not available") {
3298 t.Fatalf("SetModelForTab hidden provider error = %v, want not available", err)
3299 }
3300 }
3301
3302 func TestSetModelForTabRefreshesCarriedSystemPromptWithoutChangingDefaults(t *testing.T) {
3303 isolateDesktopUserDirs(t)
3304 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3305 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3306
3307 cfg := config.Default()
3308 cfg.DefaultModel = "old/old-model"
3309 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3310 cfg.Providers = []config.ProviderEntry{
3311 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3312 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3313 }
3314 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3315 t.Fatalf("save config: %v", err)
3316 }
3317 if err := os.MkdirAll(config.MemoryUserDir(), 0o755); err != nil {
3318 t.Fatalf("mkdir memory dir: %v", err)
3319 }
3320 const freshRule = "Fresh global AGENTS rule for model switch"
3321 if err := os.WriteFile(filepath.Join(config.MemoryUserDir(), "AGENTS.md"), []byte(freshRule), 0o644); err != nil {
3322 t.Fatalf("write global AGENTS.md: %v", err)
3323 }
3324
3325 dir := config.SessionDir()
3326 if err := os.MkdirAll(dir, 0o755); err != nil {
3327 t.Fatalf("mkdir session dir: %v", err)
3328 }
3329 oldSession := agent.NewSession("old system prompt without memory")
3330 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
3331 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3332 oldPath := filepath.Join(dir, "old.jsonl")
3333 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3334
3335 app := NewApp()
3336 app.ctx = context.Background()
3337 tab := &WorkspaceTab{
3338 ID: "tab_a",
3339 Scope: "global",
3340 Ready: true,
3341 model: "old/old-model",
3342 Ctrl: oldCtrl,
3343 sink: &tabEventSink{tabID: "tab_a", app: app},
3344 disabledMCP: map[string]ServerView{},
3345 }
3346 sibling := &WorkspaceTab{
3347 ID: "tab_b",
3348 Scope: "global",
3349 Ready: true,
3350 model: "old/old-model",
3351 disabledMCP: map[string]ServerView{},
3352 }
3353 app.tabs = map[string]*WorkspaceTab{tab.ID: tab, sibling.ID: sibling}
3354 app.tabOrder = []string{tab.ID, sibling.ID}
3355 app.activeTabID = tab.ID
3356 var switchTiming modelSwitchTiming
3357 app.modelSwitchTimingHook = func(timing modelSwitchTiming) { switchTiming = timing }
3358 t.Cleanup(func() {
3359 if tab.Ctrl != nil {
3360 tab.Ctrl.Close()
3361 }
3362 })
3363
3364 if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
3365 t.Fatalf("SetModelForTab: %v", err)
3366 }
3367 history := tab.Ctrl.History()
3368 if len(history) < 2 {
3369 t.Fatalf("history length = %d, want system + user", len(history))
3370 }
3371 if history[0].Role != provider.RoleSystem {
3372 t.Fatalf("first message role = %s, want system", history[0].Role)
3373 }
3374 if !strings.Contains(history[0].Content, freshRule) {
3375 t.Fatalf("refreshed system prompt missing global AGENTS rule:\n%s", history[0].Content)
3376 }
3377 if history[1].Role != provider.RoleUser || history[1].Content != "hello" {
3378 t.Fatalf("carried user message changed: %+v", history[1])
3379 }
3380 if got := config.LoadForEdit(config.UserConfigPath()).DefaultModel; got != "old/old-model" {
3381 t.Fatalf("default model after session switch = %q, want old/old-model", got)
3382 }
3383 if sibling.model != "old/old-model" {
3384 t.Fatalf("sibling tab model after session switch = %q, want old/old-model", sibling.model)
3385 }
3386 if switchTiming.Outcome != "ok" || switchTiming.Total <= 0 {
3387 t.Fatalf("model switch timing = %+v, want successful non-zero observation", switchTiming)
3388 }
3389 if switchTiming.Build <= 0 || switchTiming.LeaseAndResume <= 0 || switchTiming.SwapAndPersist <= 0 {
3390 t.Fatalf("model switch stage timing incomplete: %+v", switchTiming)
3391 }
3392 }
3393
3394 // TestSetModelForTabRestoresSessionAuthorizations pins the fix for a model
3395 // switch dropping same-session "Allow for this session" tool grants and
3396 // Plan-mode read-only command trust, forcing the user to re-approve
3397 // something already granted this session after every model/effort/token-mode
3398 // switch.
3399 func TestSetModelForTabRestoresSessionAuthorizations(t *testing.T) {
3400 isolateDesktopUserDirs(t)
3401 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3402 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3403
3404 cfg := config.Default()
3405 cfg.DefaultModel = "old/old-model"
3406 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3407 cfg.Providers = []config.ProviderEntry{
3408 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3409 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3410 }
3411 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3412 t.Fatalf("save config: %v", err)
3413 }
3414
3415 dir := config.SessionDir()
3416 if err := os.MkdirAll(dir, 0o755); err != nil {
3417 t.Fatalf("mkdir session dir: %v", err)
3418 }
3419 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
3420 oldPath := filepath.Join(dir, "old.jsonl")
3421 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3422 oldCtrl.RestoreSessionAuthorizations(control.SessionAuthorizations{
3423 Grants: []string{"bash|go test ./..."},
3424 PlanModeReadOnlyCommands: []string{"go test ./..."},
3425 })
3426
3427 app := NewApp()
3428 app.ctx = context.Background()
3429 tab := &WorkspaceTab{
3430 ID: "tab_a",
3431 Scope: "global",
3432 Ready: true,
3433 model: "old/old-model",
3434 Ctrl: oldCtrl,
3435 sink: &tabEventSink{tabID: "tab_a", app: app},
3436 disabledMCP: map[string]ServerView{},
3437 }
3438 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3439 app.tabOrder = []string{tab.ID}
3440 app.activeTabID = tab.ID
3441 t.Cleanup(func() {
3442 if tab.Ctrl != nil {
3443 tab.Ctrl.Close()
3444 }
3445 })
3446
3447 if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
3448 t.Fatalf("SetModelForTab: %v", err)
3449 }
3450
3451 newCtrl, ok := tab.Ctrl.(*control.Controller)
3452 if !ok {
3453 t.Fatalf("tab.Ctrl = %T, want *control.Controller", tab.Ctrl)
3454 }
3455 got := newCtrl.SessionAuthorizations()
3456 if len(got.Grants) != 1 || got.Grants[0] != "bash|go test ./..." {
3457 t.Fatalf("restored grants = %+v, want [\"bash|go test ./...\"]", got.Grants)
3458 }
3459 if len(got.PlanModeReadOnlyCommands) != 1 || got.PlanModeReadOnlyCommands[0] != "go test ./..." {
3460 t.Fatalf("restored plan-mode read-only commands = %+v, want [\"go test ./...\"]", got.PlanModeReadOnlyCommands)
3461 }
3462 }
3463
3464 // TestRebuildSettingLockedRestoresSessionAuthorizations covers the same
3465 // dropped-session-authorization bug for the settings-change rebuild path
3466 // (also used by the deferred-rebuild retry loop), independent from
3467 // SetModelForTab's own rebuild.
3468 func TestRebuildSettingLockedRestoresSessionAuthorizations(t *testing.T) {
3469 isolateDesktopUserDirs(t)
3470 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3471
3472 cfg := config.Default()
3473 cfg.DefaultModel = "old/old-model"
3474 cfg.Desktop.ProviderAccess = []string{"old"}
3475 cfg.Providers = []config.ProviderEntry{
3476 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3477 }
3478 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3479 t.Fatalf("save config: %v", err)
3480 }
3481
3482 dir := config.SessionDir()
3483 if err := os.MkdirAll(dir, 0o755); err != nil {
3484 t.Fatalf("mkdir session dir: %v", err)
3485 }
3486 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
3487 oldPath := filepath.Join(dir, "old.jsonl")
3488 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3489 oldCtrl.RestoreSessionAuthorizations(control.SessionAuthorizations{
3490 Grants: []string{"bash|go test ./..."},
3491 PlanModeReadOnlyCommands: []string{"go test ./..."},
3492 })
3493
3494 app := NewApp()
3495 app.ctx = context.Background()
3496 tab := &WorkspaceTab{
3497 ID: "tab_a",
3498 Scope: "global",
3499 Ready: true,
3500 model: "old/old-model",
3501 Ctrl: oldCtrl,
3502 sink: &tabEventSink{tabID: "tab_a", app: app},
3503 disabledMCP: map[string]ServerView{},
3504 }
3505 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3506 app.tabOrder = []string{tab.ID}
3507 app.activeTabID = tab.ID
3508 app.readyHook = func() {}
3509 t.Cleanup(func() {
3510 if tab.Ctrl != nil {
3511 tab.Ctrl.Close()
3512 }
3513 })
3514
3515 if err := app.rebuildSetting("settings"); err != nil {
3516 t.Fatalf("rebuildSetting: %v", err)
3517 }
3518
3519 newCtrl, ok := tab.Ctrl.(*control.Controller)
3520 if !ok {
3521 t.Fatalf("tab.Ctrl = %T, want *control.Controller", tab.Ctrl)
3522 }
3523 got := newCtrl.SessionAuthorizations()
3524 if len(got.Grants) != 1 || got.Grants[0] != "bash|go test ./..." {
3525 t.Fatalf("restored grants = %+v, want [\"bash|go test ./...\"]", got.Grants)
3526 }
3527 if len(got.PlanModeReadOnlyCommands) != 1 || got.PlanModeReadOnlyCommands[0] != "go test ./..." {
3528 t.Fatalf("restored plan-mode read-only commands = %+v, want [\"go test ./...\"]", got.PlanModeReadOnlyCommands)
3529 }
3530 }
3531
3532 func TestSetModelForTabContinuesRecoveryPathAfterSnapshotConflict(t *testing.T) {
3533 isolateDesktopUserDirs(t)
3534 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3535 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3536
3537 cfg := config.Default()
3538 cfg.DefaultModel = "old/old-model"
3539 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3540 cfg.Providers = []config.ProviderEntry{
3541 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3542 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3543 }
3544 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3545 t.Fatalf("save config: %v", err)
3546 }
3547
3548 dir := config.SessionDir()
3549 if err := os.MkdirAll(dir, 0o755); err != nil {
3550 t.Fatalf("mkdir session dir: %v", err)
3551 }
3552 originalPath := filepath.Join(dir, "model-switch-conflict.jsonl")
3553 current := agent.NewSession("old system prompt")
3554 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
3555 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
3556 current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
3557 if err := current.Save(originalPath); err != nil {
3558 t.Fatalf("save current session: %v", err)
3559 }
3560
3561 stale := agent.NewSession("old system prompt")
3562 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
3563 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
3564 stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
3565 oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
3566
3567 app := NewApp()
3568 app.ctx = context.Background()
3569 app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
3570 tab := &WorkspaceTab{
3571 ID: "tab_recovery_model",
3572 Scope: "global",
3573 SessionPath: originalPath,
3574 Ready: true,
3575 model: "old/old-model",
3576 disabledMCP: map[string]ServerView{},
3577 }
3578 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
3579 oldCtrl := control.New(control.Options{
3580 Executor: oldExec,
3581 SessionDir: dir,
3582 SessionPath: originalPath,
3583 Label: "old",
3584 Sink: tab.sink,
3585 SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
3586 OnSessionRecovered: app.handleTabSessionRecovered(tab),
3587 })
3588 tab.Ctrl = oldCtrl
3589 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3590 app.tabOrder = []string{tab.ID}
3591 app.activeTabID = tab.ID
3592 t.Cleanup(func() {
3593 if tab.Ctrl != nil {
3594 tab.Ctrl.Close()
3595 }
3596 tab.releaseSessionLease()
3597 })
3598
3599 if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
3600 t.Fatalf("SetModelForTab: %v", err)
3601 }
3602 recoveryPath := tab.Ctrl.SessionPath()
3603 if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
3604 t.Fatalf("model switch session path = %q, want recovery path distinct from %q", recoveryPath, originalPath)
3605 }
3606 if got := tab.currentSessionPath(); got != recoveryPath {
3607 t.Fatalf("tab current session path = %q, want recovery path %q", got, recoveryPath)
3608 }
3609 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) {
3610 t.Fatalf("tab lease path = %q, want recovery path %q", tab.sessionLeaseRuntimeKey(), recoveryPath)
3611 }
3612
3613 matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
3614 if err != nil {
3615 t.Fatalf("glob recovery branches: %v", err)
3616 }
3617 matches = primarySessionFiles(matches)
3618 if len(matches) != 1 || matches[0] != recoveryPath {
3619 t.Fatalf("recovery branches after model switch = %v, want only %q", matches, recoveryPath)
3620 }
3621 if err := tab.Ctrl.Snapshot(); err != nil {
3622 t.Fatalf("Snapshot after model switch recovery: %v", err)
3623 }
3624 matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
3625 if err != nil {
3626 t.Fatalf("glob recovery branches after snapshot: %v", err)
3627 }
3628 matches = primarySessionFiles(matches)
3629 if len(matches) != 1 || matches[0] != recoveryPath {
3630 t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
3631 }
3632 }
3633
3634 func TestSetModelForTabReusesCurrentSessionLease(t *testing.T) {
3635 isolateDesktopUserDirs(t)
3636 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3637 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3638
3639 cfg := config.Default()
3640 cfg.DefaultModel = "old/old-model"
3641 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3642 cfg.Providers = []config.ProviderEntry{
3643 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3644 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3645 }
3646 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3647 t.Fatalf("save config: %v", err)
3648 }
3649
3650 dir := config.SessionDir()
3651 if err := os.MkdirAll(dir, 0o755); err != nil {
3652 t.Fatalf("mkdir session dir: %v", err)
3653 }
3654 oldSession := agent.NewSession("old system prompt")
3655 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
3656 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3657 oldPath := filepath.Join(dir, "leased-model-switch.jsonl")
3658 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3659
3660 app := NewApp()
3661 app.ctx = context.Background()
3662 tab := &WorkspaceTab{
3663 ID: "tab_a",
3664 Scope: "global",
3665 Ready: true,
3666 model: "old/old-model",
3667 Ctrl: oldCtrl,
3668 sink: &tabEventSink{tabID: "tab_a", app: app},
3669 disabledMCP: map[string]ServerView{},
3670 }
3671 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3672 app.tabOrder = []string{tab.ID}
3673 app.activeTabID = tab.ID
3674 t.Cleanup(func() {
3675 if tab.Ctrl != nil {
3676 tab.Ctrl.Close()
3677 }
3678 tab.releaseSessionLease()
3679 })
3680
3681 if err := tab.ensureSessionLease(oldPath); err != nil {
3682 t.Fatalf("ensureSessionLease: %v", err)
3683 }
3684 if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
3685 t.Fatalf("SetModelForTab: %v", err)
3686 }
3687 if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
3688 t.Fatalf("tab controller was not rebuilt")
3689 }
3690 if got := tab.model; got != "new/new-model" {
3691 t.Fatalf("tab model = %q, want new/new-model", got)
3692 }
3693 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(oldPath) {
3694 t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), oldPath)
3695 }
3696 history := tab.Ctrl.History()
3697 if len(history) < 2 || history[1].Role != provider.RoleUser || history[1].Content != "hello" {
3698 t.Fatalf("carried history = %+v, want original user message", history)
3699 }
3700 }
3701
3702 func TestSetModelForTabWaitsForConcurrentBlankSessionLease(t *testing.T) {
3703 isolateDesktopUserDirs(t)
3704 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3705 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3706
3707 cfg := config.Default()
3708 cfg.DefaultModel = "old/old-model"
3709 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3710 cfg.Providers = []config.ProviderEntry{
3711 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3712 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3713 }
3714 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3715 t.Fatalf("save config: %v", err)
3716 }
3717
3718 dir := desktopSessionDir(globalTabWorkspaceRoot())
3719 if err := os.MkdirAll(dir, 0o755); err != nil {
3720 t.Fatalf("mkdir sessions: %v", err)
3721 }
3722 path := filepath.Join(dir, "blank-model-switch-race.jsonl")
3723 if err := os.WriteFile(path, nil, 0o644); err != nil {
3724 t.Fatalf("write blank session: %v", err)
3725 }
3726
3727 app := NewApp()
3728 app.ctx = context.Background()
3729 tab := &WorkspaceTab{
3730 ID: "tab_blank_race",
3731 Scope: "global",
3732 WorkspaceRoot: globalTabWorkspaceRoot(),
3733 SessionPath: path,
3734 Ready: true,
3735 model: "old/old-model",
3736 sink: &tabEventSink{tabID: "tab_blank_race", app: app},
3737 disabledMCP: map[string]ServerView{},
3738 }
3739 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3740 app.tabOrder = []string{tab.ID}
3741 app.activeTabID = tab.ID
3742 t.Cleanup(func() {
3743 if tab.Ctrl != nil {
3744 tab.Ctrl.Close()
3745 }
3746 tab.releaseSessionLease()
3747 })
3748
3749 acquired := make(chan struct{})
3750 releaseHook := make(chan struct{})
3751 var once sync.Once
3752 sessionLeaseAcquireHookForTest = func() {
3753 once.Do(func() {
3754 close(acquired)
3755 <-releaseHook
3756 })
3757 }
3758 t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
3759
3760 buildErr := make(chan error, 1)
3761 go func() {
3762 buildErr <- tab.ensureSessionLease(path)
3763 }()
3764
3765 select {
3766 case <-acquired:
3767 case err := <-buildErr:
3768 t.Fatalf("background lease acquire returned before hook: %v", err)
3769 case <-time.After(2 * time.Second):
3770 t.Fatal("background lease acquire did not start")
3771 }
3772
3773 switchErr := make(chan error, 1)
3774 go func() {
3775 switchErr <- app.SetModelForTab(tab.ID, "new/new-model")
3776 }()
3777
3778 select {
3779 case err := <-switchErr:
3780 t.Fatalf("SetModelForTab returned before concurrent lease was bound: %v", err)
3781 case <-time.After(50 * time.Millisecond):
3782 }
3783
3784 close(releaseHook)
3785 if err := <-buildErr; err != nil {
3786 t.Fatalf("background ensureSessionLease: %v", err)
3787 }
3788 if err := <-switchErr; err != nil {
3789 t.Fatalf("SetModelForTab: %v", err)
3790 }
3791 if tab.Ctrl == nil {
3792 t.Fatal("model switch did not build a controller")
3793 }
3794 if got := tab.model; got != "new/new-model" {
3795 t.Fatalf("tab model = %q, want new/new-model", got)
3796 }
3797 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) {
3798 t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
3799 }
3800 }
3801
3802 func TestSetModelForTabLeaseHeldKeepsCurrentController(t *testing.T) {
3803 isolateDesktopUserDirs(t)
3804 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3805 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3806
3807 cfg := config.Default()
3808 cfg.DefaultModel = "old/old-model"
3809 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3810 cfg.Providers = []config.ProviderEntry{
3811 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3812 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3813 }
3814 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3815 t.Fatalf("save config: %v", err)
3816 }
3817
3818 dir := config.SessionDir()
3819 if err := os.MkdirAll(dir, 0o755); err != nil {
3820 t.Fatalf("mkdir session dir: %v", err)
3821 }
3822 oldPath := filepath.Join(dir, "externally-leased-model-switch.jsonl")
3823 if err := os.WriteFile(oldPath, nil, 0o644); err != nil {
3824 t.Fatalf("write placeholder session: %v", err)
3825 }
3826 externalLease, err := agent.TryAcquireSessionLease(oldPath)
3827 if err != nil {
3828 t.Fatalf("TryAcquireSessionLease: %v", err)
3829 }
3830 defer externalLease.Release()
3831
3832 oldSession := agent.NewSession("old system prompt")
3833 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
3834 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3835 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
3836 defer oldCtrl.Close()
3837
3838 app := NewApp()
3839 app.ctx = context.Background()
3840 tab := &WorkspaceTab{
3841 ID: "tab_a",
3842 Scope: "global",
3843 Ready: true,
3844 model: "old/old-model",
3845 Ctrl: oldCtrl,
3846 sink: &tabEventSink{tabID: "tab_a", app: app},
3847 disabledMCP: map[string]ServerView{},
3848 }
3849 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3850 app.tabOrder = []string{tab.ID}
3851 app.activeTabID = tab.ID
3852
3853 err = app.SetModelForTab(tab.ID, "new/new-model")
3854 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
3855 t.Fatalf("SetModelForTab err = %v, want ErrSessionLeaseHeld", err)
3856 }
3857 if strings.Contains(err.Error(), oldPath) || strings.Contains(err.Error(), "held by") {
3858 t.Fatalf("SetModelForTab surfaced raw lease details: %v", err)
3859 }
3860 if tab.Ctrl != oldCtrl {
3861 t.Fatalf("tab controller changed after failed switch")
3862 }
3863 if got := tab.model; got != "old/old-model" {
3864 t.Fatalf("tab model = %q, want old/old-model", got)
3865 }
3866 info, err := os.Stat(oldPath)
3867 if err != nil {
3868 t.Fatalf("stat session: %v", err)
3869 }
3870 if info.Size() != 0 {
3871 t.Fatalf("session file size = %d, want unchanged empty file", info.Size())
3872 }
3873 }
3874
3875 func TestSetModelForTabReattachesDetachedRuntime(t *testing.T) {
3876 isolateDesktopUserDirs(t)
3877 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
3878 setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
3879
3880 cfg := config.Default()
3881 cfg.DefaultModel = "old/old-model"
3882 cfg.Desktop.ProviderAccess = []string{"old", "new"}
3883 cfg.Providers = []config.ProviderEntry{
3884 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
3885 {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
3886 }
3887 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
3888 t.Fatalf("save config: %v", err)
3889 }
3890
3891 dir := desktopSessionDir(globalTabWorkspaceRoot())
3892 if err := os.MkdirAll(dir, 0o755); err != nil {
3893 t.Fatalf("mkdir session dir: %v", err)
3894 }
3895 path := filepath.Join(dir, "detached-model-switch.jsonl")
3896 oldSession := agent.NewSession("old system prompt")
3897 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello from detached"})
3898 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
3899 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
3900 lease, err := agent.TryAcquireSessionLease(path)
3901 if err != nil {
3902 t.Fatalf("TryAcquireSessionLease: %v", err)
3903 }
3904
3905 app := NewApp()
3906 app.ctx = context.Background()
3907 key := sessionRuntimeKey(path)
3908 detached := &WorkspaceTab{
3909 ID: detachedRuntimeTabID(key),
3910 Scope: "global",
3911 SessionPath: path,
3912 Ctrl: oldCtrl,
3913 Ready: true,
3914 model: "old/old-model",
3915 disabledMCP: map[string]ServerView{},
3916 SharedHostKey: "detached-host",
3917 ActivityStatus: "",
3918 }
3919 detached.adoptSessionLease(lease)
3920 tab := &WorkspaceTab{
3921 ID: "tab_a",
3922 Scope: "global",
3923 SessionPath: path,
3924 Ready: true,
3925 model: "old/old-model",
3926 sink: &tabEventSink{tabID: "tab_a", app: app},
3927 disabledMCP: map[string]ServerView{},
3928 }
3929 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
3930 app.detachedSessions = map[string]*WorkspaceTab{key: detached}
3931 app.tabOrder = []string{tab.ID}
3932 app.activeTabID = tab.ID
3933 t.Cleanup(func() {
3934 if tab.Ctrl != nil {
3935 tab.Ctrl.Close()
3936 }
3937 tab.releaseSessionLease()
3938 if detached.sessionLease != nil {
3939 detached.releaseSessionLease()
3940 }
3941 })
3942
3943 if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
3944 t.Fatalf("SetModelForTab: %v", err)
3945 }
3946 if _, ok := app.detachedSessions[key]; ok {
3947 t.Fatal("detached runtime was not consumed")
3948 }
3949 if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
3950 t.Fatalf("tab controller was not rebuilt from detached runtime")
3951 }
3952 if got := tab.model; got != "new/new-model" {
3953 t.Fatalf("tab model = %q, want new/new-model", got)
3954 }
3955 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != key {
3956 t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
3957 }
3958 history := tab.Ctrl.History()
3959 if len(history) < 2 || history[1].Content != "hello from detached" {
3960 t.Fatalf("carried history = %+v, want detached user message", history)
3961 }
3962 }
3963
3964 type staleWorkspaceBindingFixture struct {
3965 app *App
3966 tab *WorkspaceTab
3967 oldCtrl control.SessionAPI
3968 projectA string
3969 sessionDirA string
3970 sessionPathA string
3971 }
3972
3973 func newStaleWorkspaceBindingFixture(t *testing.T, suffix string) staleWorkspaceBindingFixture {
3974 return newStaleWorkspaceBindingFixtureWithLayout(t, suffix, "")
3975 }
3976
3977 func newStaleWorkspaceBindingFixtureWithLayout(t *testing.T, suffix, layoutStyle string) staleWorkspaceBindingFixture {
3978 t.Helper()
3979 isolateDesktopUserDirs(t)
3980 setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
3981
3982 // Submitted turns use a real provider, so the fixture must complete them
3983 // instantly instead of pointing at an unreachable host.
3984 providerStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
3985 w.Header().Set("Content-Type", "text/event-stream")
3986 w.WriteHeader(http.StatusOK)
3987 _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
3988 }))
3989 t.Cleanup(providerStub.Close)
3990
3991 cfg := config.Default()
3992 cfg.DefaultModel = "test/test-model"
3993 cfg.Desktop.ProviderAccess = []string{"test"}
3994 cfg.Providers = []config.ProviderEntry{
3995 {Name: "test", Kind: "openai", BaseURL: providerStub.URL, Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
3996 }
3997 if strings.TrimSpace(layoutStyle) != "" {
3998 if err := cfg.SetDesktopLayoutStyle(layoutStyle); err != nil {
3999 t.Fatalf("set desktop layout style: %v", err)
4000 }
4001 }
4002 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4003 t.Fatalf("save config: %v", err)
4004 }
4005
4006 projectA := t.TempDir()
4007 projectB := t.TempDir()
4008 if err := addProject(projectA, "Project A"); err != nil {
4009 t.Fatalf("add project A: %v", err)
4010 }
4011 if err := addProject(projectB, "Project B"); err != nil {
4012 t.Fatalf("add project B: %v", err)
4013 }
4014
4015 topicID := "topic_" + suffix
4016 topicTitle := "Rebuild workspace " + suffix
4017 sessionDirA := desktopSessionDir(projectA)
4018 sessionDirB := desktopSessionDir(projectB)
4019 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
4020 t.Fatalf("mkdir project A sessions: %v", err)
4021 }
4022 if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
4023 t.Fatalf("mkdir project B sessions: %v", err)
4024 }
4025 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
4026 sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
4027
4028 oldSession := agent.NewSession("old system prompt")
4029 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "carry me"})
4030 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
4031 oldCtrl := control.New(control.Options{
4032 Executor: oldExec,
4033 SessionDir: sessionDirB,
4034 SessionPath: sessionPathB,
4035 Label: "test/test-model",
4036 ModelRef: "test/test-model",
4037 WorkspaceRoot: projectB,
4038 Sink: event.Discard,
4039 })
4040
4041 app := NewApp()
4042 app.readyHook = func() {}
4043 tab := &WorkspaceTab{
4044 ID: "tab_stale_workspace_" + suffix,
4045 Scope: "project",
4046 WorkspaceRoot: projectB,
4047 TopicID: topicID,
4048 TopicTitle: topicTitle,
4049 SessionPath: sessionPathA,
4050 Ready: true,
4051 model: "test/test-model",
4052 Ctrl: oldCtrl,
4053 sink: &tabEventSink{tabID: "tab_stale_workspace_" + suffix, app: app},
4054 disabledMCP: map[string]ServerView{},
4055 }
4056 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4057 app.tabOrder = []string{tab.ID}
4058 app.activeTabID = tab.ID
4059 t.Cleanup(func() {
4060 if tab.Ctrl != nil {
4061 tab.Ctrl.Close()
4062 }
4063 })
4064
4065 return staleWorkspaceBindingFixture{
4066 app: app,
4067 tab: tab,
4068 oldCtrl: oldCtrl,
4069 projectA: projectA,
4070 sessionDirA: sessionDirA,
4071 sessionPathA: sessionPathA,
4072 }
4073 }
4074
4075 func assertTabRebuiltToPinnedWorkspace(t *testing.T, f staleWorkspaceBindingFixture) {
4076 t.Helper()
4077 if f.tab.Ctrl == nil {
4078 t.Fatal("controller was not rebuilt")
4079 }
4080 if f.tab.Ctrl == f.oldCtrl {
4081 t.Fatal("stale controller was reused")
4082 }
4083 if got := normalizeProjectRoot(f.tab.WorkspaceRoot); got != normalizeProjectRoot(f.projectA) {
4084 t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(f.projectA))
4085 }
4086 if got := normalizeProjectRoot(f.tab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(f.projectA) {
4087 t.Fatalf("controller workspace root = %q, want project A %q", got, normalizeProjectRoot(f.projectA))
4088 }
4089 if !sameDesktopPath(f.tab.Ctrl.SessionDir(), f.sessionDirA) {
4090 t.Fatalf("controller session dir = %q, want %q", f.tab.Ctrl.SessionDir(), f.sessionDirA)
4091 }
4092 if !sameDesktopPath(f.tab.Ctrl.SessionPath(), f.sessionPathA) {
4093 t.Fatalf("controller session path = %q, want %q", f.tab.Ctrl.SessionPath(), f.sessionPathA)
4094 }
4095 }
4096
4097 type blockingSnapshotCtrl struct {
4098 control.SessionAPI
4099
4100 firstSnapshotStarted chan struct{}
4101 secondSnapshotStarted chan struct{}
4102 releaseSnapshot chan struct{}
4103 firstOnce sync.Once
4104 secondOnce sync.Once
4105 snapshotCount atomic.Int32
4106 closeCount atomic.Int32
4107 }
4108
4109 func newBlockingSnapshotCtrl(ctrl control.SessionAPI) *blockingSnapshotCtrl {
4110 return &blockingSnapshotCtrl{
4111 SessionAPI: ctrl,
4112 firstSnapshotStarted: make(chan struct{}),
4113 secondSnapshotStarted: make(chan struct{}),
4114 releaseSnapshot: make(chan struct{}),
4115 }
4116 }
4117
4118 func (c *blockingSnapshotCtrl) Snapshot() error {
4119 count := c.snapshotCount.Add(1)
4120 switch count {
4121 case 1:
4122 c.firstOnce.Do(func() { close(c.firstSnapshotStarted) })
4123 case 2:
4124 c.secondOnce.Do(func() { close(c.secondSnapshotStarted) })
4125 }
4126 <-c.releaseSnapshot
4127 if c.SessionAPI == nil {
4128 return nil
4129 }
4130 return c.SessionAPI.Snapshot()
4131 }
4132
4133 func (c *blockingSnapshotCtrl) Close() {
4134 c.closeCount.Add(1)
4135 if c.SessionAPI != nil {
4136 c.SessionAPI.Close()
4137 }
4138 }
4139
4140 func (f *staleWorkspaceBindingFixture) installBlockingSnapshotController() *blockingSnapshotCtrl {
4141 ctrl := newBlockingSnapshotCtrl(f.tab.Ctrl)
4142 f.tab.Ctrl = ctrl
4143 f.oldCtrl = ctrl
4144 return ctrl
4145 }
4146
4147 func TestEnsureTabControllerWorkspaceRebuildsStaleWorkspace(t *testing.T) {
4148 f := newStaleWorkspaceBindingFixture(t, "rebuild_workspace")
4149
4150 if err := f.app.ensureTabControllerWorkspace(f.tab); err != nil {
4151 t.Fatalf("ensureTabControllerWorkspace: %v", err)
4152 }
4153 assertTabRebuiltToPinnedWorkspace(t, f)
4154 }
4155
4156 func TestEnsureTabControllerWorkspaceWarnsWhenPinnedSessionSwitchesWorkspace(t *testing.T) {
4157 f := newStaleWorkspaceBindingFixture(t, "warn_workspace_switch")
4158 events := make(chan event.Event, 8)
4159 f.tab.sink.SetBotSink(event.FuncSink(func(e event.Event) {
4160 events <- e
4161 }))
4162
4163 if err := f.app.ensureTabControllerWorkspace(f.tab); err != nil {
4164 t.Fatalf("ensureTabControllerWorkspace: %v", err)
4165 }
4166 assertTabRebuiltToPinnedWorkspace(t, f)
4167
4168 deadline := time.After(2 * time.Second)
4169 for {
4170 select {
4171 case e := <-events:
4172 if e.Kind == event.Notice &&
4173 e.Level == event.LevelWarn &&
4174 strings.Contains(strings.ToLower(e.Text), strings.ToLower(f.projectA)) &&
4175 strings.Contains(e.Text, "switched tab") {
4176 return
4177 }
4178 case <-deadline:
4179 t.Fatal("did not receive workspace switch warning notice")
4180 }
4181 }
4182 }
4183
4184 func TestDescribeSessionBindingWorkspaceKeepsWindowsPathReadable(t *testing.T) {
4185 path := `C:\Users\Jane Doe\Reasonix`
4186 want := `project workspace "C:\Users\Jane Doe\Reasonix"`
4187 if got := describeSessionBindingWorkspace("project", path); got != want {
4188 t.Fatalf("describeSessionBindingWorkspace = %q, want %q", got, want)
4189 }
4190 }
4191
4192 func TestSteerForTabReconcilesStaleWorkspaceBeforeRejectingIdleGuidance(t *testing.T) {
4193 f := newStaleWorkspaceBindingFixture(t, "steer_idle_fallback")
4194
4195 err := f.app.SteerForTab(f.tab.ID, "steer guidance")
4196 if err == nil || !strings.Contains(err.Error(), "remain queued") {
4197 t.Fatalf("SteerForTab error = %v, want explicit rejected-guidance result", err)
4198 }
4199 assertTabRebuiltToPinnedWorkspace(t, f)
4200 }
4201
4202 func TestCompactReconcilesStaleWorkspaceBeforeCompaction(t *testing.T) {
4203 f := newStaleWorkspaceBindingFixture(t, "compact")
4204
4205 if err := f.app.Compact(); err != nil {
4206 t.Fatalf("Compact: %v", err)
4207 }
4208 assertTabRebuiltToPinnedWorkspace(t, f)
4209 }
4210
4211 func TestEffortCommandUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
4212 isolateDesktopUserDirs(t)
4213 setDesktopTestCredential(t, "OWNER_MODEL_KEY", "sk-test")
4214 setDesktopTestCredential(t, "STALE_MODEL_KEY", "sk-test")
4215
4216 projectA := t.TempDir()
4217 projectB := t.TempDir()
4218 if err := addProject(projectA, "Project A"); err != nil {
4219 t.Fatalf("add project A: %v", err)
4220 }
4221 if err := addProject(projectB, "Project B"); err != nil {
4222 t.Fatalf("add project B: %v", err)
4223 }
4224 ownerConfig := `default_model = "owner/owner-model"
4225 [[providers]]
4226 name = "owner"
4227 kind = "openai"
4228 base_url = "https://owner.example.invalid/v1"
4229 model = "owner-model"
4230 api_key_env = "OWNER_MODEL_KEY"
4231 supported_efforts = ["max"]
4232 default_effort = "max"
4233 `
4234 if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil {
4235 t.Fatal(err)
4236 }
4237 staleConfig := `default_model = "stale/stale-model"
4238 [[providers]]
4239 name = "stale"
4240 kind = "openai"
4241 base_url = "https://stale.example.invalid/v1"
4242 model = "stale-model"
4243 api_key_env = "STALE_MODEL_KEY"
4244 reasoning_protocol = "none"
4245 `
4246 if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil {
4247 t.Fatal(err)
4248 }
4249
4250 topicID := "topic_effort_owner"
4251 topicTitle := "Effort owner"
4252 sessionDirA := desktopSessionDir(projectA)
4253 sessionDirB := desktopSessionDir(projectB)
4254 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
4255 t.Fatalf("mkdir project A sessions: %v", err)
4256 }
4257 if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
4258 t.Fatalf("mkdir project B sessions: %v", err)
4259 }
4260 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
4261 oldCtrl := control.New(control.Options{
4262 SessionDir: sessionDirB,
4263 SessionPath: filepath.Join(sessionDirB, "wrong.jsonl"),
4264 WorkspaceRoot: projectB,
4265 Sink: event.Discard,
4266 })
4267
4268 app := NewApp()
4269 app.readyHook = func() {}
4270 tab := &WorkspaceTab{
4271 ID: "tab_stale_effort",
4272 Scope: "project",
4273 WorkspaceRoot: projectB,
4274 TopicID: topicID,
4275 TopicTitle: topicTitle,
4276 SessionPath: sessionPathA,
4277 Ready: true,
4278 Ctrl: oldCtrl,
4279 sink: &tabEventSink{tabID: "tab_stale_effort", app: app},
4280 disabledMCP: map[string]ServerView{},
4281 }
4282 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4283 app.tabOrder = []string{tab.ID}
4284 app.activeTabID = tab.ID
4285 t.Cleanup(func() {
4286 if tab.Ctrl != nil {
4287 tab.Ctrl.Close()
4288 }
4289 })
4290
4291 if err := app.SubmitToTab(tab.ID, "/effort max"); err != nil {
4292 t.Fatalf("SubmitToTab(/effort max): %v", err)
4293 }
4294 waitNotRunning(t, tab.Ctrl)
4295 if tab.effort == nil || *tab.effort != "max" {
4296 t.Fatalf("tab effort = %#v, want max from pinned project A provider", tab.effort)
4297 }
4298 if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
4299 t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
4300 }
4301 if got := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
4302 t.Fatalf("controller workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
4303 }
4304 }
4305
4306 func TestClassicLayoutQuickClicksSerializeWorkspaceRebuild(t *testing.T) {
4307 runQuickClickWorkspaceReconcileTest(t, "classic")
4308 }
4309
4310 func TestWorkbenchLayoutQuickClicksSerializeWorkspaceRebuild(t *testing.T) {
4311 runQuickClickWorkspaceReconcileTest(t, "workbench")
4312 }
4313
4314 func TestCreationLayoutQuickClicksSerializeWorkspaceRebuild(t *testing.T) {
4315 runQuickClickWorkspaceReconcileTest(t, "creation")
4316 }
4317
4318 func runQuickClickWorkspaceReconcileTest(t *testing.T, layoutStyle string) {
4319 t.Helper()
4320 f := newStaleWorkspaceBindingFixtureWithLayout(t, "quick_click_"+layoutStyle, layoutStyle)
4321 if got, want := f.app.singleSurfaceLayoutEnabled(), singleSurfaceLayoutStyle(layoutStyle); got != want {
4322 t.Fatalf("singleSurfaceLayoutEnabled(%q) = %v, want %v", layoutStyle, got, want)
4323 }
4324 blockingCtrl := f.installBlockingSnapshotController()
4325
4326 type quickAction struct {
4327 name string
4328 run func() error
4329 }
4330 actions := []quickAction{
4331 {name: "submit", run: func() error { return f.app.SubmitToTab(f.tab.ID, "/unknown-command") }},
4332 {name: "steer", run: func() error { return f.app.SteerForTab(f.tab.ID, "steer guidance") }},
4333 {name: "compact", run: func() error { return f.app.Compact() }},
4334 {name: "submit-display", run: func() error { return f.app.SubmitDisplayToTab(f.tab.ID, "/unknown display", "/unknown-command") }},
4335 }
4336
4337 start := make(chan struct{})
4338 ready := make(chan struct{}, len(actions))
4339 errs := make(chan error, len(actions))
4340 var wg sync.WaitGroup
4341 for _, action := range actions {
4342 action := action
4343 wg.Add(1)
4344 go func() {
4345 defer wg.Done()
4346 ready <- struct{}{}
4347 <-start
4348 if err := action.run(); err != nil {
4349 errs <- fmt.Errorf("%s: %w", action.name, err)
4350 }
4351 }()
4352 }
4353 for range actions {
4354 <-ready
4355 }
4356 close(start)
4357
4358 select {
4359 case <-blockingCtrl.firstSnapshotStarted:
4360 case <-time.After(time.Second):
4361 t.Fatal("timed out waiting for first stale controller snapshot")
4362 }
4363 select {
4364 case <-blockingCtrl.secondSnapshotStarted:
4365 t.Fatal("workspace rebuild was not serialized: second stale snapshot started before the first rebuild finished")
4366 case <-time.After(75 * time.Millisecond):
4367 }
4368 close(blockingCtrl.releaseSnapshot)
4369 wg.Wait()
4370 close(errs)
4371 for err := range errs {
4372 // Racing quick clicks may legitimately observe a busy controller or an
4373 // already-ended steer target. This test asserts workspace-rebuild
4374 // serialization, not that every concurrent action wins admission.
4375 if strings.Contains(err.Error(), "turn already running") ||
4376 strings.Contains(err.Error(), "cannot compact while a turn is running") ||
4377 strings.Contains(err.Error(), "remain queued") {
4378 continue
4379 }
4380 t.Error(err)
4381 }
4382 if t.Failed() {
4383 return
4384 }
4385 if got := blockingCtrl.snapshotCount.Load(); got != 1 {
4386 t.Fatalf("stale snapshot count = %d, want 1", got)
4387 }
4388 if got := blockingCtrl.closeCount.Load(); got != 1 {
4389 t.Fatalf("stale close count = %d, want 1", got)
4390 }
4391 waitNotRunning(t, f.tab.Ctrl)
4392 assertTabRebuiltToPinnedWorkspace(t, f)
4393 }
4394
4395 func TestListSessionsUsesPinnedSessionOwnerBeforeStaleRuntimeDir(t *testing.T) {
4396 isolateDesktopUserDirs(t)
4397
4398 projectA := t.TempDir()
4399 projectB := t.TempDir()
4400 if err := addProject(projectA, "Project A"); err != nil {
4401 t.Fatalf("add project A: %v", err)
4402 }
4403 if err := addProject(projectB, "Project B"); err != nil {
4404 t.Fatalf("add project B: %v", err)
4405 }
4406 sessionDirA := desktopSessionDir(projectA)
4407 sessionDirB := desktopSessionDir(projectB)
4408 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
4409 t.Fatalf("mkdir project A sessions: %v", err)
4410 }
4411 if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
4412 t.Fatalf("mkdir project B sessions: %v", err)
4413 }
4414 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_project_a", "Project A topic", projectA, "project A prompt", time.Now())
4415 sessionPathB := writeTopicSessionWithPrompt(t, sessionDirB, "project-b.jsonl", "topic_project_b", "Project B topic", projectB, "project B prompt", time.Now().Add(time.Minute))
4416
4417 app := NewApp()
4418 oldCtrl := control.New(control.Options{
4419 SessionDir: sessionDirB,
4420 SessionPath: sessionPathB,
4421 WorkspaceRoot: projectB,
4422 Sink: event.Discard,
4423 })
4424 tab := &WorkspaceTab{
4425 ID: "tab_stale_runtime_dir",
4426 Scope: "project",
4427 WorkspaceRoot: projectB,
4428 TopicID: "topic_project_a",
4429 TopicTitle: "Project A topic",
4430 SessionPath: sessionPathA,
4431 Ready: true,
4432 Ctrl: oldCtrl,
4433 disabledMCP: map[string]ServerView{},
4434 }
4435 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4436 app.tabOrder = []string{tab.ID}
4437 app.activeTabID = tab.ID
4438 t.Cleanup(oldCtrl.Close)
4439
4440 sessions := app.ListSessions()
4441 if len(sessions) == 0 {
4442 t.Fatal("ListSessions() returned no sessions")
4443 }
4444 if filepath.Clean(sessions[0].Path) != filepath.Clean(sessionPathA) {
4445 t.Fatalf("ListSessions()[0].Path = %q, want pinned project A session %q", sessions[0].Path, sessionPathA)
4446 }
4447 for _, item := range sessions {
4448 if filepath.Clean(item.Path) == filepath.Clean(sessionPathB) {
4449 t.Fatalf("ListSessions() included stale project B runtime session: %+v", sessions)
4450 }
4451 }
4452 if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
4453 t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
4454 }
4455 }
4456
4457 func TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {
4458 isolateDesktopUserDirs(t)
4459 t.Setenv("MIMO_API_KEY", "")
4460
4461 cfg := config.Default()
4462 cfg.Desktop.ProviderAccess = []string{"mimo-api"}
4463 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4464 t.Fatalf("save config: %v", err)
4465 }
4466
4467 app := NewApp()
4468 tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ready: true, model: "deepseek-flash/deepseek-v4-flash"}
4469 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4470 app.tabOrder = []string{tab.ID}
4471 app.activeTabID = tab.ID
4472
4473 err := app.SetDefaultModel("mimo-api/mimo-v2.5-pro")
4474 if err == nil || !strings.Contains(err.Error(), "has no key") {
4475 t.Fatalf("SetDefaultModel no-key error = %v, want has no key", err)
4476 }
4477 if tab.model != "deepseek-flash/deepseek-v4-flash" {
4478 t.Fatalf("tab model after failed default change = %q, want previous", tab.model)
4479 }
4480 }
4481
4482 func TestSaveProviderPersistsReasoningProtocol(t *testing.T) {
4483 isolateDesktopUserDirs(t)
4484
4485 app := NewApp()
4486 if err := app.SaveProvider(ProviderView{
4487 Name: "deepseek-proxy",
4488 Kind: "openai",
4489 BaseURL: "https://proxy.example.com/v1",
4490 Models: []string{"deepseek-v4-flash"},
4491 Default: "deepseek-v4-flash",
4492 APIKeyEnv: "DEEPSEEK_PROXY_KEY",
4493 ReasoningProtocol: "none",
4494 SupportedEfforts: []string{"high", "max"},
4495 DefaultEffort: "max",
4496 }); err != nil {
4497 t.Fatalf("SaveProvider: %v", err)
4498 }
4499
4500 cfg := config.LoadForEdit(config.UserConfigPath())
4501 got, ok := cfg.Provider("deepseek-proxy")
4502 if !ok {
4503 t.Fatal("saved provider not found")
4504 }
4505 if got.ReasoningProtocol != "none" || got.DefaultEffort != "max" {
4506 t.Fatalf("saved provider = %+v, want reasoning_protocol none and default_effort max", got)
4507 }
4508
4509 view := app.Settings()
4510 for _, p := range view.Providers {
4511 if p.Name == "deepseek-proxy" {
4512 if p.ReasoningProtocol != "none" {
4513 t.Fatalf("settings reasoningProtocol = %q, want none", p.ReasoningProtocol)
4514 }
4515 return
4516 }
4517 }
4518 t.Fatalf("Settings() missing saved provider: %+v", view.Providers)
4519 }
4520
4521 func TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {
4522 isolateDesktopUserDirs(t)
4523 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4524
4525 cfg := config.Default()
4526 cfg.DefaultModel = "prov-a/model-a2"
4527 cfg.Providers = []config.ProviderEntry{
4528 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", Models: []string{"model-a1", "model-a2"}, APIKeyEnv: "REASONIX_TEST_KEY"},
4529 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4530 }
4531 cfg.Agent.PlannerModel = "prov-a"
4532 cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
4533 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4534 t.Fatalf("save config: %v", err)
4535 }
4536
4537 ctrl := control.New(control.Options{Label: "old"})
4538 defer ctrl.Close()
4539 app := NewApp()
4540 tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ctrl: ctrl, Label: "prov-a/model-a1", Ready: true, model: "prov-a/model-a1"}
4541 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4542 app.tabOrder = []string{tab.ID}
4543 app.activeTabID = tab.ID
4544
4545 if err := app.DeleteProvider("prov-a"); err != nil {
4546 t.Fatalf("DeleteProvider: %v", err)
4547 }
4548
4549 got := config.LoadForEdit(config.UserConfigPath())
4550 if _, ok := got.Provider("prov-a"); ok {
4551 t.Fatal("prov-a should be removed")
4552 }
4553 if got.DefaultModel != "prov-b" || got.Agent.PlannerModel != "prov-b" {
4554 t.Fatalf("model refs after delete = default:%q planner:%q, want prov-b", got.DefaultModel, got.Agent.PlannerModel)
4555 }
4556 if providerAccessSet(got.Desktop.ProviderAccess)["prov-a"] {
4557 t.Fatalf("provider access still contains prov-a: %+v", got.Desktop.ProviderAccess)
4558 }
4559 if tab.model != "prov-b/model-b1" || tab.Label != "prov-b/model-b1" {
4560 t.Fatalf("tab model after delete = model:%q label:%q, want prov-b/model-b1", tab.model, tab.Label)
4561 }
4562 if tab.Ctrl != nil {
4563 t.Fatal("tab controller should be closed and cleared when retargeted without a running app context")
4564 }
4565 }
4566
4567 // assertTabBuildSuperseded checks that the startup build registered before the
4568 // mutation (generation) can no longer install its controller and that its
4569 // build context was cancelled.
4570 func assertTabBuildSuperseded(t *testing.T, app *App, tab *WorkspaceTab, generation uint64, buildCtx context.Context) {
4571 t.Helper()
4572 app.mu.Lock()
4573 superseded := app.tabBuildSupersededLocked(tab, generation)
4574 app.mu.Unlock()
4575 if !superseded {
4576 t.Fatal("in-flight startup build was not superseded; finishing it would reinstall a stale controller")
4577 }
4578 select {
4579 case <-buildCtx.Done():
4580 default:
4581 t.Fatal("in-flight startup build context was not cancelled")
4582 }
4583 if tab.buildCancel != nil {
4584 t.Fatal("build cancel was not cleared")
4585 }
4586 }
4587
4588 func TestDeleteProviderSupersedesInFlightStartupBuild(t *testing.T) {
4589 isolateDesktopUserDirs(t)
4590 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4591
4592 cfg := config.Default()
4593 cfg.DefaultModel = "prov-b/model-b1"
4594 cfg.Providers = []config.ProviderEntry{
4595 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4596 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4597 }
4598 cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
4599 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4600 t.Fatalf("save config: %v", err)
4601 }
4602
4603 app := NewApp()
4604 // Model the async startup build still being in flight for the affected
4605 // tab: no controller yet, a live generation, a cancellable build context.
4606 buildCtx, buildCancel := context.WithCancel(context.Background())
4607 tab := &WorkspaceTab{
4608 ID: "tab_a",
4609 Scope: "global",
4610 model: "prov-a/model-a1",
4611 buildGeneration: 1,
4612 buildCancel: buildCancel,
4613 }
4614 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4615 app.tabOrder = []string{tab.ID}
4616 app.activeTabID = tab.ID
4617
4618 if err := app.DeleteProvider("prov-a"); err != nil {
4619 t.Fatalf("DeleteProvider: %v", err)
4620 }
4621 assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
4622 if tab.model != "prov-b/model-b1" {
4623 t.Fatalf("tab model after delete = %q, want prov-b/model-b1", tab.model)
4624 }
4625 }
4626
4627 func TestRemoveBuiltInProviderAccessSupersedesInFlightStartupBuild(t *testing.T) {
4628 isolateDesktopUserDirs(t)
4629 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4630
4631 cfg := config.Default()
4632 cfg.DefaultModel = "prov-b/model-b1"
4633 cfg.Providers = []config.ProviderEntry{
4634 {Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-chat", APIKeyEnv: "REASONIX_TEST_KEY"},
4635 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4636 }
4637 cfg.Desktop.ProviderAccess = []string{"deepseek", "prov-b"}
4638 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4639 t.Fatalf("save config: %v", err)
4640 }
4641
4642 app := NewApp()
4643 buildCtx, buildCancel := context.WithCancel(context.Background())
4644 tab := &WorkspaceTab{
4645 ID: "tab_ds",
4646 Scope: "global",
4647 model: "deepseek/deepseek-chat",
4648 buildGeneration: 1,
4649 buildCancel: buildCancel,
4650 }
4651 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4652 app.tabOrder = []string{tab.ID}
4653 app.activeTabID = tab.ID
4654
4655 if err := app.RemoveProviderAccess("deepseek"); err != nil {
4656 t.Fatalf("RemoveProviderAccess: %v", err)
4657 }
4658 assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
4659 if tab.model != "prov-b/model-b1" {
4660 t.Fatalf("tab model after access removal = %q, want prov-b/model-b1", tab.model)
4661 }
4662 }
4663
4664 func TestClearActiveSessionRuntimeSupersedesInFlightStartupBuild(t *testing.T) {
4665 isolateDesktopUserDirs(t)
4666 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
4667
4668 cfg := config.Default()
4669 cfg.DefaultModel = "old/old-model"
4670 cfg.Desktop.ProviderAccess = []string{"old"}
4671 cfg.Providers = []config.ProviderEntry{{
4672 Name: "old",
4673 Kind: "openai",
4674 BaseURL: "https://example.invalid/v1",
4675 Model: "old-model",
4676 APIKeyEnv: "OLD_MODEL_KEY",
4677 }}
4678 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4679 t.Fatalf("save config: %v", err)
4680 }
4681
4682 dir := config.SessionDir()
4683 if err := os.MkdirAll(dir, 0o755); err != nil {
4684 t.Fatalf("mkdir session dir: %v", err)
4685 }
4686 sessionPath := filepath.Join(dir, "clear-runtime-in-flight.jsonl")
4687 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
4688 t.Fatalf("write placeholder session: %v", err)
4689 }
4690
4691 oldSession := agent.NewSession("old system prompt")
4692 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
4693 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
4694
4695 app := NewApp()
4696 // A runtime is attached while an older async build is still in flight
4697 // (e.g. attached via topic activation); destroying the session must
4698 // invalidate that build so it cannot resurrect the destroyed session.
4699 buildCtx, buildCancel := context.WithCancel(context.Background())
4700 tab := &WorkspaceTab{
4701 ID: "tab_clear",
4702 Scope: "global",
4703 SessionPath: sessionPath,
4704 model: "old/old-model",
4705 Ready: true,
4706 Ctrl: oldCtrl,
4707 buildGeneration: 1,
4708 buildCancel: buildCancel,
4709 disabledMCP: map[string]ServerView{},
4710 }
4711 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
4712 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4713 app.tabOrder = []string{tab.ID}
4714 app.activeTabID = tab.ID
4715 t.Cleanup(tab.releaseSessionLease)
4716
4717 if err := app.clearActiveSessionRuntime(tab, oldCtrl); err != nil {
4718 t.Fatalf("clearActiveSessionRuntime: %v", err)
4719 }
4720 if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
4721 t.Fatalf("clear did not install a fresh controller (ctrl=%v)", tab.Ctrl)
4722 }
4723 defer tab.Ctrl.Close()
4724 assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
4725 }
4726
4727 func TestClearActiveSessionRuntimeReleasesResourcesWhenTabReplaced(t *testing.T) {
4728 isolateDesktopUserDirs(t)
4729 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
4730
4731 cfg := config.Default()
4732 cfg.DefaultModel = "old/old-model"
4733 cfg.Desktop.ProviderAccess = []string{"old"}
4734 cfg.Providers = []config.ProviderEntry{{
4735 Name: "old",
4736 Kind: "openai",
4737 BaseURL: "https://example.invalid/v1",
4738 Model: "old-model",
4739 APIKeyEnv: "OLD_MODEL_KEY",
4740 }}
4741 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4742 t.Fatalf("save config: %v", err)
4743 }
4744
4745 dir := config.SessionDir()
4746 if err := os.MkdirAll(dir, 0o755); err != nil {
4747 t.Fatalf("mkdir session dir: %v", err)
4748 }
4749 sessionPath := filepath.Join(dir, "clear-runtime-replaced-tab.jsonl")
4750 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
4751 t.Fatalf("write placeholder session: %v", err)
4752 }
4753
4754 oldSession := agent.NewSession("old system prompt")
4755 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
4756 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
4757
4758 app := NewApp()
4759 tab := &WorkspaceTab{
4760 ID: "tab_replaced",
4761 Scope: "global",
4762 SessionPath: sessionPath,
4763 model: "old/old-model",
4764 Ready: true,
4765 Ctrl: oldCtrl,
4766 disabledMCP: map[string]ServerView{},
4767 }
4768 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
4769 // The tab entry now points at a replacement struct (the tab was closed and
4770 // reopened while the clear ran off-lock), so the swap must not apply.
4771 replacement := &WorkspaceTab{ID: tab.ID, Scope: "global"}
4772 app.tabs = map[string]*WorkspaceTab{tab.ID: replacement}
4773 app.tabOrder = []string{tab.ID}
4774 app.activeTabID = tab.ID
4775 t.Cleanup(tab.releaseSessionLease)
4776
4777 err := app.clearActiveSessionRuntime(tab, oldCtrl)
4778 if err == nil || !strings.Contains(err.Error(), "changed while clearing") {
4779 t.Fatalf("clearActiveSessionRuntime error = %v, want tab-changed error", err)
4780 }
4781 if replacement.Ctrl != nil {
4782 t.Fatalf("replacement tab controller = %v, want untouched nil", replacement.Ctrl)
4783 }
4784 if tab.Ctrl != oldCtrl {
4785 t.Fatalf("replaced tab controller = %v, want left on the destroyed runtime", tab.Ctrl)
4786 }
4787 if key := tab.sessionLeaseRuntimeKey(); key != "" {
4788 t.Fatalf("replaced tab still holds a session lease for %q; the fresh lease leaked", key)
4789 }
4790 if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
4791 t.Fatalf("old session artifacts were not destroyed (stat err=%v)", err)
4792 }
4793 }
4794
4795 func TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {
4796 isolateDesktopUserDirs(t)
4797 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4798
4799 cfg := config.Default()
4800 cfg.DefaultModel = "prov-a/model-a1"
4801 cfg.Providers = []config.ProviderEntry{
4802 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4803 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4804 }
4805 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4806 t.Fatalf("save config: %v", err)
4807 }
4808
4809 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
4810 app := NewApp()
4811 app.setTestCtrl(control.New(control.Options{Runner: runner}), "prov-a/model-a1")
4812 ctrl := app.activeCtrl()
4813 ctrl.Submit("work")
4814 <-runner.started
4815
4816 err := app.DeleteProvider("prov-a")
4817 if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
4818 t.Fatalf("DeleteProvider while running error = %v, want finish/cancel guard", err)
4819 }
4820 if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok {
4821 t.Fatal("provider should remain after rejected deletion")
4822 }
4823
4824 close(runner.release)
4825 waitNotRunning(t, ctrl)
4826 ctrl.Close()
4827 }
4828
4829 func TestDeleteProviderRechecksWorkAfterWaitingForRuntimeMutation(t *testing.T) {
4830 isolateDesktopUserDirs(t)
4831 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4832 cfg := config.Default()
4833 cfg.DefaultModel = "prov-a/model-a1"
4834 cfg.Providers = []config.ProviderEntry{
4835 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4836 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4837 }
4838 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4839 t.Fatalf("save config: %v", err)
4840 }
4841
4842 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
4843 app := NewApp()
4844 app.setTestCtrl(control.New(control.Options{Runner: runner}), "prov-a/model-a1")
4845 ctrl := app.activeCtrl()
4846 app.runtimeRebuildMu.Lock()
4847 rebuildHeld := true
4848 defer func() {
4849 if rebuildHeld {
4850 app.runtimeRebuildMu.Unlock()
4851 }
4852 }()
4853 deleteEntered := make(chan struct{})
4854 var enteredOnce sync.Once
4855 app.runtimeMutationBeforeLockHook = func(operation string) {
4856 if operation == "delete-provider" {
4857 enteredOnce.Do(func() { close(deleteEntered) })
4858 }
4859 }
4860 deleteDone := make(chan error, 1)
4861 go func() { deleteDone <- app.DeleteProvider("prov-a") }()
4862 select {
4863 case <-deleteEntered:
4864 case <-time.After(5 * time.Second):
4865 t.Fatal("provider deletion did not reach the runtime lifecycle lock")
4866 }
4867
4868 ctrl.Submit("work")
4869 select {
4870 case <-runner.started:
4871 case <-time.After(5 * time.Second):
4872 t.Fatal("turn did not start while provider deletion waited for the lifecycle lock")
4873 }
4874 app.runtimeRebuildMu.Unlock()
4875 rebuildHeld = false
4876 select {
4877 case err := <-deleteDone:
4878 if err == nil || !strings.Contains(err.Error(), "active work") {
4879 t.Fatalf("DeleteProvider after late turn error = %v, want active-work guard", err)
4880 }
4881 case <-time.After(5 * time.Second):
4882 t.Fatal("provider deletion did not re-check runtime work after acquiring the lock")
4883 }
4884 if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok {
4885 t.Fatal("provider was deleted after a turn started while deletion waited")
4886 }
4887
4888 close(runner.release)
4889 waitNotRunning(t, ctrl)
4890 ctrl.Close()
4891 }
4892
4893 func TestDeleteProviderReleasesAffectedTabSharedHostReference(t *testing.T) {
4894 isolateDesktopUserDirs(t)
4895 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4896 cfg := config.Default()
4897 cfg.DefaultModel = "prov-a/model-a1"
4898 cfg.Providers = []config.ProviderEntry{
4899 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4900 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4901 }
4902 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4903 t.Fatalf("save config: %v", err)
4904 }
4905
4906 app := NewApp()
4907 hostKey := "provider-shared-host"
4908 host := app.acquireSharedHost(hostKey)
4909 ctrl := control.New(control.Options{Host: host})
4910 tab := &WorkspaceTab{
4911 ID: "affected", Scope: "global", Ready: true, Ctrl: ctrl,
4912 model: "prov-a/model-a1", SharedHostKey: hostKey, disabledMCP: map[string]ServerView{},
4913 }
4914 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4915 app.tabOrder = []string{tab.ID}
4916 app.activeTabID = tab.ID
4917
4918 if err := app.DeleteProvider("prov-a"); err != nil {
4919 t.Fatalf("DeleteProvider: %v", err)
4920 }
4921 if tab.SharedHostKey != "" {
4922 t.Fatalf("affected tab retained shared host key %q", tab.SharedHostKey)
4923 }
4924 app.sharedHostsMu.Lock()
4925 _, retained := app.sharedHosts[hostKey]
4926 app.sharedHostsMu.Unlock()
4927 if retained {
4928 t.Fatal("provider deletion leaked the affected tab's shared host reference")
4929 }
4930 }
4931
4932 func TestRemoveBuiltInProviderAccessReleasesAffectedTabSharedHostReference(t *testing.T) {
4933 isolateDesktopUserDirs(t)
4934 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4935 cfg := config.Default()
4936 cfg.DefaultModel = "deepseek/deepseek-chat"
4937 cfg.Providers = []config.ProviderEntry{
4938 {Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-chat", APIKeyEnv: "REASONIX_TEST_KEY"},
4939 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4940 }
4941 cfg.Desktop.ProviderAccess = []string{"deepseek", "prov-b"}
4942 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4943 t.Fatalf("save config: %v", err)
4944 }
4945
4946 app := NewApp()
4947 hostKey := "provider-access-shared-host"
4948 host := app.acquireSharedHost(hostKey)
4949 ctrl := control.New(control.Options{Host: host})
4950 tab := &WorkspaceTab{
4951 ID: "affected", Scope: "global", Ready: true, Ctrl: ctrl,
4952 model: "deepseek/deepseek-chat", SharedHostKey: hostKey, disabledMCP: map[string]ServerView{},
4953 }
4954 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
4955 app.tabOrder = []string{tab.ID}
4956 app.activeTabID = tab.ID
4957
4958 if err := app.RemoveProviderAccess("deepseek"); err != nil {
4959 t.Fatalf("RemoveProviderAccess: %v", err)
4960 }
4961 if tab.SharedHostKey != "" {
4962 t.Fatalf("affected tab retained shared host key %q", tab.SharedHostKey)
4963 }
4964 app.sharedHostsMu.Lock()
4965 _, retained := app.sharedHosts[hostKey]
4966 app.sharedHostsMu.Unlock()
4967 if retained {
4968 t.Fatal("provider access removal leaked the affected tab's shared host reference")
4969 }
4970 }
4971
4972 func TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {
4973 isolateDesktopUserDirs(t)
4974 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
4975
4976 cfg := config.Default()
4977 cfg.DefaultModel = "prov-a/model-a1"
4978 cfg.Providers = []config.ProviderEntry{
4979 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
4980 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
4981 }
4982 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
4983 t.Fatalf("save config: %v", err)
4984 }
4985
4986 dir := config.SessionDir()
4987 if err := os.MkdirAll(dir, 0o755); err != nil {
4988 t.Fatalf("mkdir session dir: %v", err)
4989 }
4990 path := filepath.Join(dir, "provider-job.jsonl")
4991 jm := jobs.NewManager(event.Discard)
4992 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
4993 defer ctrl.Close()
4994 app := NewApp()
4995 app.setTestCtrl(ctrl, "prov-a/model-a1")
4996 jm.StartForSession(agent.BranchID(path), "bash", "provider job", func(ctx context.Context, _ io.Writer) (string, error) {
4997 <-ctx.Done()
4998 return "", ctx.Err()
4999 })
5000
5001 err := app.DeleteProvider("prov-a")
5002 if err == nil || !strings.Contains(err.Error(), "active work") {
5003 t.Fatalf("DeleteProvider with background job error = %v, want active-work guard", err)
5004 }
5005 if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok {
5006 t.Fatal("provider should remain after rejected deletion")
5007 }
5008 }
5009
5010 func TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {
5011 isolateDesktopUserDirs(t)
5012 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
5013
5014 cfg := config.Default()
5015 cfg.DefaultModel = "prov-b/model-b1"
5016 cfg.Providers = []config.ProviderEntry{
5017 {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
5018 {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
5019 }
5020 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5021 t.Fatalf("save config: %v", err)
5022 }
5023
5024 app := NewApp()
5025 app.ctx = context.Background()
5026 app.setTestCtrl(newBackgroundJobController(t, "provider-unaffected-job"), "prov-b/model-b1")
5027
5028 err := app.DeleteProvider("prov-a")
5029 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
5030 t.Fatalf("DeleteProvider with unaffected background job error = %v, want active-work guard", err)
5031 }
5032 if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok {
5033 t.Fatal("unaffected provider should remain after rejected deletion")
5034 }
5035 }
5036
5037 func TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {
5038 isolateDesktopUserDirs(t)
5039 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
5040 t.Fatalf("mkdir config dir: %v", err)
5041 }
5042 if err := os.WriteFile(config.UserConfigPath(), []byte(`
5043 default_model = "mimo-pro/mimo-v2.5-pro"
5044
5045 [desktop]
5046 provider_access = ["deepseek-flash", "mimo-pro"]
5047
5048 [[providers]]
5049 name = "deepseek-flash"
5050 kind = "openai"
5051 base_url = "https://api.deepseek.com"
5052 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
5053 default = "deepseek-v4-flash"
5054 api_key_env = "DEEPSEEK_API_KEY"
5055
5056 [[providers]]
5057 name = "mimo-pro"
5058 kind = "openai"
5059 base_url = "https://token-plan-cn.xiaomimimo.com/v1"
5060 model = "mimo-v2.5-pro"
5061 api_key_env = "MIMO_API_KEY"
5062 `), 0o644); err != nil {
5063 t.Fatalf("write config: %v", err)
5064 }
5065
5066 app := NewApp()
5067 app.ctx = context.Background()
5068 app.setTestCtrl(newBackgroundJobController(t, "provider-access-unaffected-job"), "mimo-token-plan/mimo-v2.5-pro")
5069
5070 err := app.RemoveProviderAccess("deepseek")
5071 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
5072 t.Fatalf("RemoveProviderAccess with background job error = %v, want active-work guard", err)
5073 }
5074 cfg := config.LoadForEdit(config.UserConfigPath())
5075 access := providerAccessSet(cfg.Desktop.ProviderAccess)
5076 if !access["deepseek"] && !access["deepseek-flash"] {
5077 t.Fatalf("provider_access should still contain deepseek after rejected removal: %+v", cfg.Desktop.ProviderAccess)
5078 }
5079 }
5080
5081 func TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {
5082 isolateDesktopUserDirs(t)
5083 t.Setenv("DEEPSEEK_API_KEY", "")
5084 os.Unsetenv("DEEPSEEK_API_KEY")
5085
5086 app := NewApp()
5087 app.ctx = context.Background()
5088 app.setTestCtrl(newBackgroundJobController(t, "connect-key-job"), "deepseek-flash/deepseek-v4-flash")
5089
5090 _, err := app.ConnectKey("sk-test")
5091 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
5092 t.Fatalf("ConnectKey with background job error = %v, want active-work guard", err)
5093 }
5094 if data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
5095 t.Fatalf("onboarding key should not be saved after rejected connect:\n%s", data)
5096 }
5097 }
5098
5099 func TestConnectKeyRestoresDeepSeekProviderAccess(t *testing.T) {
5100 isolateDesktopUserDirs(t)
5101 cfg := config.Default()
5102 cfg.DefaultModel = "custom/custom-model"
5103 cfg.Desktop.ProviderAccess = []string{"custom"}
5104 cfg.Providers = []config.ProviderEntry{{
5105 Name: "custom", Kind: "openai", BaseURL: "https://models.example.invalid/v1",
5106 Model: "custom-model", APIKeyEnv: "CUSTOM_API_KEY",
5107 }}
5108 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5109 t.Fatalf("save custom provider config: %v", err)
5110 }
5111
5112 oldFetch := connectKeyBalanceFetch
5113 connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
5114 return &billing.Balance{Available: true}, nil
5115 }
5116 t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
5117
5118 app := NewApp()
5119 app.ctx = context.Background()
5120 app.readyHook = func() {}
5121 app.setTestCtrl(control.New(control.Options{Label: "custom"}), "custom/custom-model")
5122 defer func() {
5123 if ctrl := app.activeCtrl(); ctrl != nil {
5124 ctrl.Close()
5125 }
5126 }()
5127 if _, err := app.ConnectKey("sk-test"); err != nil {
5128 t.Fatalf("ConnectKey: %v", err)
5129 }
5130
5131 got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
5132 if !providerAccessSet(got.Desktop.ProviderAccess)["deepseek"] {
5133 t.Fatalf("provider_access = %v, want DeepSeek restored", got.Desktop.ProviderAccess)
5134 }
5135 if _, ok := got.Provider("deepseek"); !ok {
5136 t.Fatal("DeepSeek provider template should be restored")
5137 }
5138 if app.NeedsOnboarding() {
5139 t.Fatal("restored DeepSeek access and saved key should satisfy onboarding")
5140 }
5141 }
5142
5143 func TestBalanceForTabUsesDesktopPricingCurrency(t *testing.T) {
5144 isolateDesktopUserDirs(t)
5145 cfg := config.Default()
5146 cfg.Desktop.Currency = "USD"
5147 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5148 t.Fatalf("save USD desktop currency: %v", err)
5149 }
5150
5151 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
5152 w.Header().Set("Content-Type", "application/json")
5153 _, _ = io.WriteString(w, `{"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"70.16"},{"currency":"USD","total_balance":"9.82"}]}`)
5154 }))
5155 defer srv.Close()
5156
5157 app := NewApp()
5158 app.ctx = context.Background()
5159 ctrl := control.New(control.Options{BalanceURL: srv.URL, BalanceClient: srv.Client()})
5160 t.Cleanup(ctrl.Close)
5161 app.setTestCtrl(ctrl, "deepseek/deepseek-v4-flash")
5162
5163 got := app.BalanceForTab("test")
5164 if !got.Available || got.Display != "$9.82" || got.Err != "" {
5165 t.Fatalf("USD desktop balance = %+v, want available $9.82", got)
5166 }
5167 }
5168
5169 func TestConnectKeyRebuildLeaseHeldKeepsCurrentController(t *testing.T) {
5170 isolateDesktopUserDirs(t)
5171 t.Setenv(onboardingKeyEnv, "")
5172 os.Unsetenv(onboardingKeyEnv)
5173 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
5174
5175 oldFetch := connectKeyBalanceFetch
5176 connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
5177 return &billing.Balance{Available: true}, nil
5178 }
5179 t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
5180
5181 cfg := config.Default()
5182 cfg.DefaultModel = "old/old-model"
5183 cfg.Desktop.ProviderAccess = []string{"old"}
5184 cfg.Providers = []config.ProviderEntry{
5185 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
5186 }
5187 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5188 t.Fatalf("save config: %v", err)
5189 }
5190
5191 dir := config.SessionDir()
5192 if err := os.MkdirAll(dir, 0o755); err != nil {
5193 t.Fatalf("mkdir session dir: %v", err)
5194 }
5195 sessionPath := filepath.Join(dir, "externally-leased-connect-key.jsonl")
5196 if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
5197 t.Fatalf("write placeholder session: %v", err)
5198 }
5199 externalLease, err := agent.TryAcquireSessionLease(sessionPath)
5200 if err != nil {
5201 t.Fatalf("TryAcquireSessionLease: %v", err)
5202 }
5203 defer externalLease.Release()
5204
5205 oldSession := agent.NewSession("old system prompt")
5206 oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
5207 oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
5208 oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
5209 defer oldCtrl.Close()
5210
5211 app := NewApp()
5212 app.ctx = context.Background()
5213 tab := &WorkspaceTab{
5214 ID: "tab_connect",
5215 Scope: "global",
5216 SessionPath: sessionPath,
5217 Ready: true,
5218 model: "old/old-model",
5219 Ctrl: oldCtrl,
5220 sink: &tabEventSink{tabID: "tab_connect", app: app},
5221 disabledMCP: map[string]ServerView{},
5222 }
5223 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
5224 app.tabOrder = []string{tab.ID}
5225 app.activeTabID = tab.ID
5226
5227 warning, err := app.ConnectKey("sk-test")
5228 if err != nil {
5229 t.Fatalf("ConnectKey: %v", err)
5230 }
5231 if !strings.Contains(warning, "another Reasonix window") {
5232 t.Fatalf("ConnectKey warning = %q, want user-facing lease warning", warning)
5233 }
5234 if tab.Ctrl != oldCtrl {
5235 t.Fatalf("tab controller changed after failed connect-key rebuild")
5236 }
5237 if tab.StartupErr != "" {
5238 t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
5239 }
5240 if !config.CredentialStored(onboardingKeyEnv) {
5241 t.Fatal("onboarding key should be persisted even when hot rebuild is deferred")
5242 }
5243 }
5244
5245 func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {
5246 isolateDesktopUserDirs(t)
5247
5248 userCfg := config.LoadForEdit(config.UserConfigPath())
5249 if err := userCfg.SetDesktopLanguage("en"); err != nil {
5250 t.Fatalf("set desktop language: %v", err)
5251 }
5252 if err := userCfg.SetDesktopLayoutStyle("workbench"); err != nil {
5253 t.Fatalf("set desktop layout style: %v", err)
5254 }
5255 if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
5256 t.Fatalf("set desktop appearance: %v", err)
5257 }
5258 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
5259 t.Fatalf("save user config: %v", err)
5260 }
5261
5262 if err := NewApp().MigrateDesktopPreferences("zh", "light", "glacier"); err != nil {
5263 t.Fatalf("migrate desktop preferences: %v", err)
5264 }
5265
5266 got := config.LoadForEdit(config.UserConfigPath())
5267 if got.DesktopLanguage() != "en" || got.DesktopLayoutStyle() != "workbench" || got.DesktopTheme() != "dark" || got.DesktopThemeStyle() != "graphite" {
5268 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())
5269 }
5270 }
5271
5272 func TestSetEffortRebuildsController(t *testing.T) {
5273 isolateDesktopUserDirs(t)
5274
5275 app := NewApp()
5276 app.ctx = context.Background()
5277 app.readyHook = func() {}
5278 old := control.New(control.Options{Label: "old-controller"})
5279 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
5280 defer func() {
5281 if c := app.activeCtrl(); c != nil {
5282 c.Close()
5283 }
5284 }()
5285
5286 if err := app.SetEffort("max"); err != nil {
5287 t.Fatalf("SetEffort(max): %v", err)
5288 }
5289 if c := app.activeCtrl(); c == nil {
5290 t.Fatal("SetEffort should leave a rebuilt controller")
5291 }
5292 if c := app.activeCtrl(); c == old {
5293 t.Fatal("SetEffort should rebuild the active controller so the provider sees the new effort")
5294 }
5295 if got := app.Effort().Current; got != "max" {
5296 t.Fatalf("Effort current = %q, want max", got)
5297 }
5298 }
5299
5300 func TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
5301 isolateDesktopUserDirs(t)
5302 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
5303
5304 cfg := config.Default()
5305 cfg.DefaultModel = "deepseek/deepseek-v4-flash"
5306 cfg.Desktop.ProviderAccess = []string{"deepseek"}
5307 cfg.Providers = []config.ProviderEntry{{
5308 Name: "deepseek",
5309 Kind: "openai",
5310 BaseURL: "https://api.deepseek.com",
5311 Model: "glm-5",
5312 APIKeyEnv: "DEEPSEEK_API_KEY",
5313 }}
5314 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5315 t.Fatalf("save config: %v", err)
5316 }
5317
5318 app := NewApp()
5319 app.ctx = context.Background()
5320 app.readyHook = func() {}
5321 old := control.New(control.Options{Label: "old-controller"})
5322 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
5323 defer func() {
5324 if c := app.activeCtrl(); c != nil {
5325 c.Close()
5326 }
5327 }()
5328
5329 if err := app.SetEffort("max"); err != nil {
5330 t.Fatalf("SetEffort(max): %v", err)
5331 }
5332 tab := app.activeTab()
5333 if tab == nil {
5334 t.Fatal("active tab missing")
5335 }
5336 if tab.model != "deepseek/deepseek-v4-flash" {
5337 t.Fatalf("tab model = %q, want migrated official ref", tab.model)
5338 }
5339 }
5340
5341 func TestSetTokenModeRebuildsController(t *testing.T) {
5342 isolateDesktopUserDirs(t)
5343
5344 app := NewApp()
5345 app.ctx = context.Background()
5346 app.readyHook = func() {}
5347 old := control.New(control.Options{Label: "old-controller"})
5348 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
5349 defer func() {
5350 if c := app.activeCtrl(); c != nil {
5351 c.Close()
5352 }
5353 }()
5354
5355 if err := app.SetTokenMode("economy"); err != nil {
5356 t.Fatalf("SetTokenMode(economy): %v", err)
5357 }
5358 if c := app.activeCtrl(); c == nil {
5359 t.Fatal("SetTokenMode should leave a rebuilt controller")
5360 }
5361 if c := app.activeCtrl(); c == old {
5362 t.Fatal("SetTokenMode should rebuild the active controller so the provider sees the new tool profile")
5363 }
5364 tab := app.activeTab()
5365 if tab == nil {
5366 t.Fatal("active tab missing")
5367 }
5368 if got := currentTabTokenMode(tab); got != "economy" {
5369 t.Fatalf("token mode = %q, want economy", got)
5370 }
5371 if got := app.Meta().TokenMode; got != "economy" {
5372 t.Fatalf("Meta token mode = %q, want economy", got)
5373 }
5374 saved := loadTabsFile()
5375 if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != "economy" {
5376 t.Fatalf("saved tabs = %+v, want economy token mode", saved.Tabs)
5377 }
5378 }
5379
5380 func TestSetTokenModeDeliveryRebuildsAndPersistsProfile(t *testing.T) {
5381 isolateDesktopUserDirs(t)
5382
5383 app := NewApp()
5384 app.ctx = context.Background()
5385 app.readyHook = func() {}
5386 old := control.New(control.Options{Label: "old-controller"})
5387 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
5388 defer func() {
5389 if c := app.activeCtrl(); c != nil {
5390 c.Close()
5391 }
5392 }()
5393
5394 if err := app.SetTokenMode(boot.TokenModeDelivery); err != nil {
5395 t.Fatalf("SetTokenMode(delivery): %v", err)
5396 }
5397 if c := app.activeCtrl(); c == nil || c == old {
5398 t.Fatal("delivery profile should rebuild the active controller")
5399 }
5400 tab := app.activeTab()
5401 if got := currentTabTokenMode(tab); got != boot.TokenModeDelivery {
5402 t.Fatalf("token mode = %q, want delivery", got)
5403 }
5404 if got := app.Meta().TokenMode; got != boot.TokenModeDelivery {
5405 t.Fatalf("Meta token mode = %q, want delivery", got)
5406 }
5407 saved := loadTabsFile()
5408 if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != boot.TokenModeDelivery {
5409 t.Fatalf("saved tabs = %+v, want delivery profile", saved.Tabs)
5410 }
5411
5412 // Leaving delivery must clear the persisted tokenMode so a restart does not
5413 // re-arm final-readiness gates (#6582).
5414 if err := app.SetTokenMode(boot.TokenModeFull); err != nil {
5415 t.Fatalf("SetTokenMode(full): %v", err)
5416 }
5417 if got := currentTabTokenMode(app.activeTab()); got != boot.TokenModeFull {
5418 t.Fatalf("token mode after full = %q, want full", got)
5419 }
5420 if got := app.Meta().TokenMode; got != boot.TokenModeFull {
5421 t.Fatalf("Meta token mode after full = %q, want full", got)
5422 }
5423 saved = loadTabsFile()
5424 if len(saved.Tabs) != 1 {
5425 t.Fatalf("saved tabs = %+v", saved.Tabs)
5426 }
5427 if saved.Tabs[0].TokenMode != "" {
5428 t.Fatalf("saved tokenMode = %q, want omitted/empty for full", saved.Tabs[0].TokenMode)
5429 }
5430 }
5431
5432 func TestSetTokenModeReusesCurrentSessionLease(t *testing.T) {
5433 isolateDesktopUserDirs(t)
5434 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
5435
5436 cfg := config.Default()
5437 cfg.DefaultModel = "old/old-model"
5438 cfg.Desktop.ProviderAccess = []string{"old"}
5439 cfg.Providers = []config.ProviderEntry{
5440 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
5441 }
5442 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5443 t.Fatalf("save config: %v", err)
5444 }
5445
5446 dir := config.SessionDir()
5447 if err := os.MkdirAll(dir, 0o755); err != nil {
5448 t.Fatalf("mkdir session dir: %v", err)
5449 }
5450 session := agent.NewSession("old system prompt")
5451 session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
5452 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
5453 path := filepath.Join(dir, "leased-token-mode-switch.jsonl")
5454 oldCtrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
5455
5456 app := NewApp()
5457 app.ctx = context.Background()
5458 tab := &WorkspaceTab{
5459 ID: "tab_a",
5460 Scope: "global",
5461 Ready: true,
5462 model: "old/old-model",
5463 Ctrl: oldCtrl,
5464 sink: &tabEventSink{tabID: "tab_a", app: app},
5465 disabledMCP: map[string]ServerView{},
5466 }
5467 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
5468 app.tabOrder = []string{tab.ID}
5469 app.activeTabID = tab.ID
5470 t.Cleanup(func() {
5471 if tab.Ctrl != nil {
5472 tab.Ctrl.Close()
5473 }
5474 tab.releaseSessionLease()
5475 })
5476
5477 if err := tab.ensureSessionLease(path); err != nil {
5478 t.Fatalf("ensureSessionLease: %v", err)
5479 }
5480 if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
5481 t.Fatalf("SetTokenModeForTab: %v", err)
5482 }
5483 if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
5484 t.Fatalf("tab controller was not rebuilt")
5485 }
5486 if got := currentTabTokenMode(tab); got != "economy" {
5487 t.Fatalf("token mode = %q, want economy", got)
5488 }
5489 if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) {
5490 t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
5491 }
5492 history := tab.Ctrl.History()
5493 if len(history) < 2 || history[1].Role != provider.RoleUser || history[1].Content != "hello" {
5494 t.Fatalf("carried history = %+v, want original user message", history)
5495 }
5496 }
5497
5498 func TestSetTokenModeLeaseHeldKeepsCurrentController(t *testing.T) {
5499 isolateDesktopUserDirs(t)
5500 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
5501
5502 cfg := config.Default()
5503 cfg.DefaultModel = "old/old-model"
5504 cfg.Desktop.ProviderAccess = []string{"old"}
5505 cfg.Providers = []config.ProviderEntry{
5506 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
5507 }
5508 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5509 t.Fatalf("save config: %v", err)
5510 }
5511
5512 dir := config.SessionDir()
5513 if err := os.MkdirAll(dir, 0o755); err != nil {
5514 t.Fatalf("mkdir session dir: %v", err)
5515 }
5516 path := filepath.Join(dir, "externally-leased-token-mode-switch.jsonl")
5517 if err := os.WriteFile(path, nil, 0o644); err != nil {
5518 t.Fatalf("write placeholder session: %v", err)
5519 }
5520 externalLease, err := agent.TryAcquireSessionLease(path)
5521 if err != nil {
5522 t.Fatalf("TryAcquireSessionLease: %v", err)
5523 }
5524 defer externalLease.Release()
5525
5526 session := agent.NewSession("old system prompt")
5527 session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
5528 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
5529 oldCtrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
5530 defer oldCtrl.Close()
5531
5532 app := NewApp()
5533 app.ctx = context.Background()
5534 tab := &WorkspaceTab{
5535 ID: "tab_a",
5536 Scope: "global",
5537 Ready: true,
5538 model: "old/old-model",
5539 Ctrl: oldCtrl,
5540 sink: &tabEventSink{tabID: "tab_a", app: app},
5541 disabledMCP: map[string]ServerView{},
5542 }
5543 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
5544 app.tabOrder = []string{tab.ID}
5545 app.activeTabID = tab.ID
5546
5547 err = app.SetTokenModeForTab(tab.ID, "economy")
5548 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
5549 t.Fatalf("SetTokenModeForTab err = %v, want ErrSessionLeaseHeld", err)
5550 }
5551 if strings.Contains(err.Error(), path) || strings.Contains(err.Error(), "held by") {
5552 t.Fatalf("SetTokenModeForTab surfaced raw lease details: %v", err)
5553 }
5554 if tab.Ctrl != oldCtrl {
5555 t.Fatalf("tab controller changed after failed switch")
5556 }
5557 if got := currentTabTokenMode(tab); got != "full" {
5558 t.Fatalf("token mode = %q, want full", got)
5559 }
5560 meta := app.MetaForTab(tab.ID)
5561 if !meta.Ready || meta.Runtime.Phase != sessionRuntimeReady {
5562 t.Fatalf("failed switch disabled current runtime: ready=%v phase=%q", meta.Ready, meta.Runtime.Phase)
5563 }
5564 }
5565
5566 func TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
5567 isolateDesktopUserDirs(t)
5568 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
5569
5570 cfg := config.Default()
5571 cfg.DefaultModel = "deepseek/deepseek-v4-flash"
5572 cfg.Desktop.ProviderAccess = []string{"deepseek"}
5573 cfg.Providers = []config.ProviderEntry{{
5574 Name: "deepseek",
5575 Kind: "openai",
5576 BaseURL: "https://api.deepseek.com",
5577 Model: "glm-5",
5578 APIKeyEnv: "DEEPSEEK_API_KEY",
5579 }}
5580 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5581 t.Fatalf("save config: %v", err)
5582 }
5583
5584 app := NewApp()
5585 app.ctx = context.Background()
5586 app.readyHook = func() {}
5587 old := control.New(control.Options{Label: "old-controller"})
5588 app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
5589 defer func() {
5590 if c := app.activeCtrl(); c != nil {
5591 c.Close()
5592 }
5593 }()
5594
5595 if err := app.SetTokenMode("economy"); err != nil {
5596 t.Fatalf("SetTokenMode(economy): %v", err)
5597 }
5598 tab := app.activeTab()
5599 if tab == nil {
5600 t.Fatal("active tab missing")
5601 }
5602 if tab.model != "deepseek/deepseek-v4-flash" {
5603 t.Fatalf("tab model = %q, want migrated official ref", tab.model)
5604 }
5605 if got := currentTabTokenMode(tab); got != "economy" {
5606 t.Fatalf("token mode = %q, want economy", got)
5607 }
5608 }
5609
5610 func TestMetaForTabReportsImageInputCapability(t *testing.T) {
5611 isolateDesktopUserDirs(t)
5612 setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test")
5613
5614 cfg := config.Default()
5615 cfg.DefaultModel = "custom/text-only"
5616 cfg.Desktop.ProviderAccess = []string{"custom"}
5617 cfg.Providers = []config.ProviderEntry{{
5618 Name: "custom",
5619 Kind: "openai",
5620 BaseURL: "https://example.invalid/v1",
5621 APIKeyEnv: "CUSTOM_KEY",
5622 Models: []string{"text-only", "vision-pro"},
5623 VisionModels: []string{"vision-pro"},
5624 }}
5625 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5626 t.Fatalf("save config: %v", err)
5627 }
5628
5629 app := NewApp()
5630 app.ctx = context.Background()
5631 app.readyHook = func() {}
5632 app.setTestCtrl(control.New(control.Options{Label: "custom/text-only"}), "custom/text-only")
5633 defer func() {
5634 if c := app.activeCtrl(); c != nil {
5635 c.Close()
5636 }
5637 }()
5638
5639 if got := app.Meta().ImageInputEnabled; got {
5640 t.Fatal("text-only meta should disable image input")
5641 }
5642 if err := app.SetModel("custom/vision-pro"); err != nil {
5643 t.Fatalf("SetModel(custom/vision-pro): %v", err)
5644 }
5645 if got := app.Meta().ImageInputEnabled; !got {
5646 t.Fatal("vision model meta should enable image input")
5647 }
5648 }
5649
5650 func TestMetaForTabImageInputCapabilityUsesCurrentRef(t *testing.T) {
5651 isolateDesktopUserDirs(t)
5652 setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test")
5653
5654 cfg := config.Default()
5655 cfg.DefaultModel = "custom/vision-pro"
5656 cfg.Desktop.ProviderAccess = []string{"custom"}
5657 cfg.Providers = []config.ProviderEntry{{
5658 Name: "custom",
5659 Kind: "openai",
5660 BaseURL: "https://example.invalid/v1",
5661 APIKeyEnv: "CUSTOM_KEY",
5662 Models: []string{"text-only", "vision-pro"},
5663 VisionModels: []string{"vision-pro"},
5664 }}
5665 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5666 t.Fatalf("save config: %v", err)
5667 }
5668
5669 app := NewApp()
5670 app.ctx = context.Background()
5671 app.readyHook = func() {}
5672 app.setTestCtrl(control.New(control.Options{Label: "deleted/model"}), "deleted/model")
5673 defer func() {
5674 if c := app.activeCtrl(); c != nil {
5675 c.Close()
5676 }
5677 }()
5678
5679 if got := app.Meta().ImageInputEnabled; got {
5680 t.Fatal("unknown model ref should not inherit image input from the default fallback model")
5681 }
5682 }
5683
5684 func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {
5685 isolateDesktopUserDirs(t)
5686 t.Setenv("DEEPSEEK_API_KEY", "")
5687 t.Setenv("MIMO_API_KEY", "")
5688
5689 app := NewApp()
5690 app.ctx = context.Background()
5691 app.readyHook = func() {}
5692 old := control.New(control.Options{Label: "old-controller"})
5693 app.setTestCtrl(old, "missing-token-mode-model")
5694 defer func() {
5695 if c := app.activeCtrl(); c != nil {
5696 c.Close()
5697 }
5698 }()
5699
5700 err := app.SetTokenMode("economy")
5701 if err == nil {
5702 t.Fatal("SetTokenMode(economy) with an unknown model should fail")
5703 }
5704 if c := app.activeCtrl(); c != old {
5705 t.Fatalf("SetTokenMode failure replaced controller: got %p want %p", c, old)
5706 }
5707 tab := app.activeTab()
5708 if tab == nil {
5709 t.Fatal("active tab missing")
5710 }
5711 if got := currentTabTokenMode(tab); got != "full" {
5712 t.Fatalf("token mode after failed rebuild = %q, want full", got)
5713 }
5714 if got := app.Meta().TokenMode; got != "full" {
5715 t.Fatalf("Meta token mode after failed rebuild = %q, want full", got)
5716 }
5717 }
5718
5719 func TestSetEffortRejectsRunningTurn(t *testing.T) {
5720 isolateDesktopUserDirs(t)
5721
5722 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
5723 app := NewApp()
5724 app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
5725 app.activeCtrl().Submit("work")
5726 <-runner.started
5727
5728 err := app.SetEffort("max")
5729 if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
5730 t.Fatalf("SetEffort while running error = %v, want finish/cancel guard", err)
5731 }
5732
5733 close(runner.release)
5734 waitNotRunning(t, app.activeCtrl())
5735 }
5736
5737 func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
5738 isolateDesktopUserDirs(t)
5739
5740 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
5741 app := NewApp()
5742 app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
5743 app.activeCtrl().Submit("work")
5744 <-runner.started
5745
5746 err := app.SetTokenMode("economy")
5747 if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
5748 t.Fatalf("SetTokenMode while running error = %v, want finish/cancel guard", err)
5749 }
5750
5751 close(runner.release)
5752 waitNotRunning(t, app.activeCtrl())
5753 }
5754
5755 func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
5756 isolateDesktopUserDirs(t)
5757 setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
5758
5759 cfg := config.Default()
5760 cfg.DefaultModel = "old/old-model"
5761 cfg.Desktop.ProviderAccess = []string{"old"}
5762 cfg.Providers = []config.ProviderEntry{
5763 {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
5764 }
5765 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
5766 t.Fatalf("save config: %v", err)
5767 }
5768
5769 dir := config.SessionDir()
5770 if err := os.MkdirAll(dir, 0o755); err != nil {
5771 t.Fatalf("mkdir session dir: %v", err)
5772 }
5773 path := filepath.Join(dir, "jobs.jsonl")
5774 jm := jobs.NewManager(event.Discard)
5775 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
5776 app := NewApp()
5777 app.ctx = context.Background()
5778 app.setTestCtrl(ctrl, "old/old-model")
5779 t.Cleanup(func() {
5780 if current := app.activeCtrl(); current != nil {
5781 current.Close()
5782 }
5783 })
5784
5785 release := make(chan struct{})
5786 job := jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
5787 select {
5788 case <-ctx.Done():
5789 return "", ctx.Err()
5790 case <-release:
5791 return "", nil
5792 }
5793 })
5794 t.Cleanup(func() { close(release) })
5795
5796 err := app.SetTokenMode("economy")
5797 if err == nil || !strings.Contains(err.Error(), "background_jobs=1") {
5798 t.Fatalf("SetTokenMode with background job error = %v, want exact background-job guard", err)
5799 }
5800 cancelled, err := app.CancelJobForTab("", job.ID)
5801 if err != nil || !cancelled {
5802 t.Fatalf("CancelJobForTab = %v, %v, want true, nil", cancelled, err)
5803 }
5804 if result := jm.WaitForSession(context.Background(), agent.BranchID(path), []string{job.ID}, 5); len(result) != 1 || result[0].Status != jobs.Killed {
5805 t.Fatalf("stopped background job = %+v, want one killed result", result)
5806 }
5807 if err := app.SetTokenMode("economy"); err != nil {
5808 t.Fatalf("SetTokenMode after stopping background job: %v", err)
5809 }
5810 }
5811
5812 func TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {
5813 isolateDesktopUserDirs(t)
5814
5815 dir := config.SessionDir()
5816 if err := os.MkdirAll(dir, 0o755); err != nil {
5817 t.Fatalf("mkdir session dir: %v", err)
5818 }
5819 path := filepath.Join(dir, "settings-job.jsonl")
5820 jm := jobs.NewManager(event.Discard)
5821 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
5822 defer ctrl.Close()
5823 app := NewApp()
5824 app.ctx = context.Background()
5825 app.setTestCtrl(ctrl, "deepseek-flash/deepseek-v4-flash")
5826
5827 jm.StartForSession(agent.BranchID(path), "bash", "settings job", func(ctx context.Context, _ io.Writer) (string, error) {
5828 <-ctx.Done()
5829 return "", ctx.Err()
5830 })
5831
5832 err := app.SetSandbox("enforce", true, "", nil, "")
5833 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
5834 t.Fatalf("SetSandbox with background job error = %v, want background-job guard", err)
5835 }
5836 }
5837
5838 func TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {
5839 isolateDesktopUserDirs(t)
5840
5841 dir := config.SessionDir()
5842 if err := os.MkdirAll(dir, 0o755); err != nil {
5843 t.Fatalf("mkdir session dir: %v", err)
5844 }
5845 path := filepath.Join(dir, "clear-running.jsonl")
5846 if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
5847 t.Fatalf("write session: %v", err)
5848 }
5849 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
5850 oldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"})
5851 app := NewApp()
5852 app.projectTreeChangedHook = func() {}
5853 app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash")
5854 app.tabs["test"].TopicID = "topic_clear"
5855 app.tabs["test"].TopicTitle = "Clear topic"
5856 defer func() {
5857 if c := app.activeCtrl(); c != nil {
5858 c.Close()
5859 }
5860 }()
5861
5862 oldCtrl.Submit("work")
5863 <-runner.started
5864 if err := app.ClearSession(); err != nil {
5865 t.Fatalf("ClearSession: %v", err)
5866 }
5867 waitNotRunning(t, oldCtrl)
5868 tab := app.activeTab()
5869 if tab == nil || tab.Ctrl == nil {
5870 t.Fatalf("active tab/controller missing after clear")
5871 }
5872 if tab.Ctrl == oldCtrl {
5873 t.Fatalf("clear should replace the active controller after cancelling old work")
5874 }
5875 if tab.TopicID != "topic_clear" || tab.TopicTitle != "Clear topic" {
5876 t.Fatalf("clear changed topic identity: %+v", tab)
5877 }
5878 if _, err := os.Stat(path); !os.IsNotExist(err) {
5879 t.Fatalf("old cleared session artifacts should be removed, stat err = %v", err)
5880 }
5881 if got := tab.currentSessionPath(); got == "" || got == path {
5882 t.Fatalf("new session path = %q, want fresh path", got)
5883 }
5884 }
5885
5886 func TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {
5887 isolateDesktopUserDirs(t)
5888
5889 dir := config.SessionDir()
5890 if err := os.MkdirAll(dir, 0o755); err != nil {
5891 t.Fatalf("mkdir session dir: %v", err)
5892 }
5893 path := filepath.Join(dir, "clear-running-job.jsonl")
5894 if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
5895 t.Fatalf("write session: %v", err)
5896 }
5897 jm := jobs.NewManager(event.Discard)
5898 oldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
5899 app := NewApp()
5900 app.projectTreeChangedHook = func() {}
5901 app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash")
5902 defer func() {
5903 if c := app.activeCtrl(); c != nil {
5904 c.Close()
5905 }
5906 }()
5907
5908 started := make(chan struct{})
5909 jm.StartForSession(agent.BranchID(path), "bash", "clear artifact", func(ctx context.Context, _ io.Writer) (string, error) {
5910 close(started)
5911 <-ctx.Done()
5912 return "", ctx.Err()
5913 })
5914 <-started
5915 jobsDir := jobs.ArtifactDir(path)
5916 if _, err := os.Stat(jobsDir); err != nil {
5917 t.Fatalf("job sidecar should exist before clear: %v", err)
5918 }
5919
5920 if err := app.ClearSession(); err != nil {
5921 t.Fatalf("ClearSession: %v", err)
5922 }
5923 if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
5924 t.Fatalf("old job sidecar should be removed after clear, stat err = %v", err)
5925 }
5926 }
5927
5928 func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
5929 orig, _ := os.Getwd()
5930 defer os.Chdir(orig)
5931
5932 dir := robustTempDir(t)
5933 if err := os.MkdirAll(filepath.Join(dir, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
5934 t.Fatal(err)
5935 }
5936 if err := os.WriteFile(filepath.Join(dir, "frontend", "wailsjs", "runtime", "runtime.js"), []byte("x"), 0o644); err != nil {
5937 t.Fatal(err)
5938 }
5939 if err := os.WriteFile(filepath.Join(dir, "frontend", "Thumbs.db"), []byte("noise"), 0o644); err != nil {
5940 t.Fatal(err)
5941 }
5942 if err := os.WriteFile(filepath.Join(dir, "frontend", ".DS_Store"), []byte("noise"), 0o644); err != nil {
5943 t.Fatal(err)
5944 }
5945 if err := os.MkdirAll(filepath.Join(dir, "node_modules", "pkg"), 0o755); err != nil {
5946 t.Fatal(err)
5947 }
5948 if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "runtime.js"), []byte("noise"), 0o644); err != nil {
5949 t.Fatal(err)
5950 }
5951 for _, noise := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
5952 if err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {
5953 t.Fatal(err)
5954 }
5955 if err := os.WriteFile(filepath.Join(dir, noise, "runtime.js"), []byte("noise"), 0o644); err != nil {
5956 t.Fatal(err)
5957 }
5958 }
5959 if err := os.MkdirAll(filepath.Join(dir, "desktop", "frontend", "wailsjs"), 0o755); err != nil {
5960 t.Fatal(err)
5961 }
5962 if err := os.WriteFile(filepath.Join(dir, "desktop", "frontend", "wailsjs", "runtime.js"), []byte("generated"), 0o644); err != nil {
5963 t.Fatal(err)
5964 }
5965 if err := os.MkdirAll(filepath.Join(dir, "product", "bin"), 0o755); err != nil {
5966 t.Fatal(err)
5967 }
5968 if err := os.WriteFile(filepath.Join(dir, "product", "bin", "runtime.js"), []byte("real"), 0o644); err != nil {
5969 t.Fatal(err)
5970 }
5971 if err := os.Chdir(dir); err != nil {
5972 t.Fatal(err)
5973 }
5974
5975 app := &App{}
5976 listed := app.ListDir("")
5977 for _, hidden := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
5978 if hasDirEntry(listed, hidden) {
5979 t.Fatalf("ListDir should hide local noise %q, got %+v", hidden, listed)
5980 }
5981 }
5982 desktopFrontend := app.ListDir("desktop/frontend")
5983 if hasDirEntry(desktopFrontend, "wailsjs") {
5984 t.Fatalf("ListDir should hide generated Wails bindings, got %+v", desktopFrontend)
5985 }
5986 frontendEntries := app.ListDir("frontend")
5987 for _, hidden := range []string{".DS_Store", "Thumbs.db"} {
5988 if hasDirEntry(frontendEntries, hidden) {
5989 t.Fatalf("ListDir should hide local noise file %q, got %+v", hidden, frontendEntries)
5990 }
5991 }
5992
5993 got := app.SearchFileRefs("runtime.js")
5994 if !hasDirEntry(got, "frontend/wailsjs/runtime/runtime.js") {
5995 t.Fatalf("SearchFileRefs(runtime.js) should find nested workspace file, got %+v", got)
5996 }
5997 if !hasDirEntry(got, "product/bin/runtime.js") {
5998 t.Fatalf("SearchFileRefs should keep non-root bin directories searchable, got %+v", got)
5999 }
6000 if hasDirEntry(got, "node_modules/pkg/runtime.js") {
6001 t.Fatalf("SearchFileRefs should skip node_modules noise, got %+v", got)
6002 }
6003 for _, hidden := range []string{
6004 ".codex/runtime.js",
6005 ".npm/runtime.js",
6006 ".pnpm-store/runtime.js",
6007 "bin/runtime.js",
6008 "desktop/frontend/wailsjs/runtime.js",
6009 "dist/runtime.js",
6010 "stage/runtime.js",
6011 "tmp/runtime.js",
6012 } {
6013 if hasDirEntry(got, hidden) {
6014 t.Fatalf("SearchFileRefs should skip local noise %q, got %+v", hidden, got)
6015 }
6016 }
6017 if noise := app.SearchFileRefs("Thumbs"); hasDirEntry(noise, "frontend/Thumbs.db") {
6018 t.Fatalf("SearchFileRefs should skip Thumbs.db noise, got %+v", noise)
6019 }
6020 if noise := app.SearchFileRefs(".DS"); hasDirEntry(noise, "frontend/.DS_Store") {
6021 t.Fatalf("SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v", noise)
6022 }
6023 }
6024
6025 func TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {
6026 orig, _ := os.Getwd()
6027 defer os.Chdir(orig)
6028
6029 launchRoot := robustTempDir(t)
6030 projectRoot := robustTempDir(t)
6031 if err := os.WriteFile(filepath.Join(launchRoot, "launch-only.txt"), []byte("wrong"), 0o644); err != nil {
6032 t.Fatal(err)
6033 }
6034 if err := os.MkdirAll(filepath.Join(projectRoot, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
6035 t.Fatal(err)
6036 }
6037 projectFile := filepath.Join(projectRoot, "frontend", "wailsjs", "runtime", "runtime.js")
6038 if err := os.WriteFile(projectFile, []byte("right workspace"), 0o644); err != nil {
6039 t.Fatal(err)
6040 }
6041 if err := os.Chdir(launchRoot); err != nil {
6042 t.Fatal(err)
6043 }
6044
6045 app := NewApp()
6046 tab := &WorkspaceTab{ID: "project", Scope: "project", WorkspaceRoot: projectRoot}
6047 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
6048 app.activeTabID = tab.ID
6049
6050 listed := app.ListDir("")
6051 if !hasDirEntry(listed, "frontend") {
6052 t.Fatalf("ListDir should list active project root, got %+v", listed)
6053 }
6054 if hasDirEntry(listed, "launch-only.txt") {
6055 t.Fatalf("ListDir leaked launch cwd entries, got %+v", listed)
6056 }
6057
6058 found := app.SearchFileRefs("runtime.js")
6059 if !hasDirEntry(found, "frontend/wailsjs/runtime/runtime.js") {
6060 t.Fatalf("SearchFileRefs should search active project root, got %+v", found)
6061 }
6062 preview := app.ReadFile("frontend/wailsjs/runtime/runtime.js")
6063 if preview.Err != "" || preview.Body != "right workspace" {
6064 t.Fatalf("ReadFile active project preview = %+v, want project file", preview)
6065 }
6066 }
6067
6068 func TestFileRefsForTabIgnoreActiveParentWorkspace(t *testing.T) {
6069 parentRoot := robustTempDir(t)
6070 childRoot := filepath.Join(parentRoot, "child")
6071 if err := os.MkdirAll(childRoot, 0o755); err != nil {
6072 t.Fatal(err)
6073 }
6074 if err := os.WriteFile(filepath.Join(parentRoot, "parent-only.txt"), []byte("parent"), 0o644); err != nil {
6075 t.Fatal(err)
6076 }
6077 if err := os.WriteFile(filepath.Join(parentRoot, "shared.txt"), []byte("parent shared"), 0o644); err != nil {
6078 t.Fatal(err)
6079 }
6080 if err := os.WriteFile(filepath.Join(childRoot, "child-only.txt"), []byte("child"), 0o644); err != nil {
6081 t.Fatal(err)
6082 }
6083 if err := os.WriteFile(filepath.Join(childRoot, "shared.txt"), []byte("child shared"), 0o644); err != nil {
6084 t.Fatal(err)
6085 }
6086
6087 app := &App{
6088 tabs: map[string]*WorkspaceTab{
6089 "parent": {ID: "parent", Scope: "project", WorkspaceRoot: parentRoot},
6090 "child": {ID: "child", Scope: "project", WorkspaceRoot: childRoot},
6091 },
6092 activeTabID: "parent",
6093 }
6094
6095 listed := app.ListDirForTab("child", "")
6096 if !hasDirEntry(listed, "child-only.txt") || hasDirEntry(listed, "parent-only.txt") {
6097 t.Fatalf("ListDirForTab(child) = %+v, want only child workspace entries", listed)
6098 }
6099 found := app.SearchFileRefsForTab("child", "child-only")
6100 if !hasDirEntry(found, "child-only.txt") {
6101 t.Fatalf("SearchFileRefsForTab(child) = %+v, want child-only.txt", found)
6102 }
6103 preview := app.ReadFileForTab("child", "shared.txt")
6104 if preview.Err != "" || preview.Body != "child shared" {
6105 t.Fatalf("ReadFileForTab(child) = %+v, want child workspace file", preview)
6106 }
6107 path, ok, err := app.workspaceOrExternalPathForTab("child", "shared.txt")
6108 if err != nil || !ok || path != filepath.Join(childRoot, "shared.txt") {
6109 t.Fatalf("workspaceOrExternalPathForTab(child) = (%q, %v, %v)", path, ok, err)
6110 }
6111
6112 legacy := app.ReadFile("shared.txt")
6113 if legacy.Err != "" || legacy.Body != "parent shared" {
6114 t.Fatalf("ReadFile legacy active-tab behavior = %+v, want parent workspace file", legacy)
6115 }
6116 }
6117
6118 func TestFileRefsIncludeRegisteredExternalFolderChildren(t *testing.T) {
6119 workspace := robustTempDir(t)
6120 external := filepath.Join(robustTempDir(t), "Folder With Spaces")
6121 if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil {
6122 t.Fatal(err)
6123 }
6124 if err := os.WriteFile(filepath.Join(external, "src", "outside.txt"), []byte("outside"), 0o644); err != nil {
6125 t.Fatal(err)
6126 }
6127 expectedExternal := external
6128 if resolved, err := filepath.EvalSymlinks(external); err == nil {
6129 expectedExternal = resolved
6130 }
6131 expectedDisplayPath := filepath.ToSlash(expectedExternal)
6132
6133 ctrl := &control.Controller{}
6134 token, _, err := ctrl.RegisterExternalFolderRef(external)
6135 if err != nil {
6136 t.Fatalf("RegisterExternalFolderRef: %v", err)
6137 }
6138 app := &App{
6139 tabs: map[string]*WorkspaceTab{
6140 "project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
6141 "other": {ID: "other", WorkspaceRoot: robustTempDir(t)},
6142 },
6143 activeTabID: "other",
6144 }
6145
6146 listed := app.ListDirForTab("project", token+"/src/")
6147 if len(listed) != 1 ||
6148 listed[0].Name != "outside.txt" ||
6149 listed[0].Path != token+"/src/outside.txt" ||
6150 listed[0].DisplayPath != expectedDisplayPath+"/src/outside.txt" {
6151 t.Fatalf("ListDir external src = %+v, want outside token/display path", listed)
6152 }
6153
6154 found := app.SearchFileRefsForTab("project", "outside")
6155 var externalHit *DirEntry
6156 for i := range found {
6157 if found[i].Path == token+"/src/outside.txt" {
6158 externalHit = &found[i]
6159 break
6160 }
6161 }
6162 if externalHit == nil || externalHit.DisplayName != "Folder With Spaces/src/outside.txt" || externalHit.DisplayPath != expectedDisplayPath+"/src/outside.txt" {
6163 t.Fatalf("SearchFileRefs external hit = %+v, all results %+v", externalHit, found)
6164 }
6165
6166 preview := app.ReadFileForTab("project", token+"/src/outside.txt")
6167 if preview.Err != "" || preview.Body != "outside" {
6168 t.Fatalf("ReadFile external token preview = %+v, want outside file body", preview)
6169 }
6170 }
6171
6172 func TestDeleteSessionCancelsActiveRuntime(t *testing.T) {
6173 isolateDesktopUserDirs(t)
6174
6175 dir := config.SessionDir()
6176 if err := os.MkdirAll(dir, 0o755); err != nil {
6177 t.Fatalf("mkdir session dir: %v", err)
6178 }
6179 path := filepath.Join(dir, "active.jsonl")
6180 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6181 t.Fatalf("write session: %v", err)
6182 }
6183
6184 app := NewApp()
6185 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"})
6186 keepPath := filepath.Join(dir, "keep.jsonl")
6187 if err := os.WriteFile(keepPath, []byte(`{"role":"user","content":"keep"}`+"\n"), 0o644); err != nil {
6188 t.Fatalf("write keep session: %v", err)
6189 }
6190 keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"})
6191 defer keepCtrl.Close()
6192 app.setTestCtrl(activeCtrl, "")
6193 app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true}
6194 app.tabOrder = []string{"test", "keep"}
6195
6196 if err := app.DeleteSession(filepath.Base(path)); err != nil {
6197 t.Fatalf("DeleteSession(active basename): %v", err)
6198 }
6199 if _, ok := app.tabs["test"]; ok {
6200 t.Fatalf("deleted active session runtime should be removed")
6201 }
6202 if got := app.activeTabID; got != "keep" {
6203 t.Fatalf("active tab after delete = %q, want keep", got)
6204 }
6205 if _, err := os.Stat(path); !os.IsNotExist(err) {
6206 t.Fatalf("active session should be moved out of active history, stat err = %v", err)
6207 }
6208 trashPath := filepath.Join(dir, sessionTrashDir, "active.jsonl", "active.jsonl")
6209 if _, err := os.Stat(trashPath); err != nil {
6210 t.Fatalf("active session should be moved to trash: %v", err)
6211 }
6212 }
6213
6214 func TestDeleteSessionCancelsPreReadyBlankBuild(t *testing.T) {
6215 isolateDesktopUserDirs(t)
6216
6217 globalRoot := globalTabWorkspaceRoot()
6218 dir := desktopSessionDir(globalRoot)
6219 if err := os.MkdirAll(dir, 0o755); err != nil {
6220 t.Fatalf("mkdir session dir: %v", err)
6221 }
6222 path := filepath.Join(dir, "pre-ready-blank.jsonl")
6223 if err := os.WriteFile(path, nil, 0o644); err != nil {
6224 t.Fatalf("write blank session: %v", err)
6225 }
6226 cancelled := false
6227 blank := &WorkspaceTab{
6228 ID: "blank",
6229 Scope: "global",
6230 WorkspaceRoot: globalRoot,
6231 SessionPath: path,
6232 buildCancel: func() { cancelled = true },
6233 disabledMCP: map[string]ServerView{},
6234 }
6235 keep := &WorkspaceTab{
6236 ID: "keep",
6237 Scope: "global",
6238 WorkspaceRoot: globalRoot,
6239 Ready: true,
6240 disabledMCP: map[string]ServerView{},
6241 }
6242 app := &App{
6243 tabs: map[string]*WorkspaceTab{"blank": blank, "keep": keep},
6244 tabOrder: []string{"blank", "keep"},
6245 activeTabID: "blank",
6246 }
6247
6248 if err := app.DeleteSession(filepath.Base(path)); err != nil {
6249 t.Fatalf("DeleteSession(pre-ready blank): %v", err)
6250 }
6251 if !cancelled {
6252 t.Fatal("pre-ready blank build was not cancelled")
6253 }
6254 if !blank.removed {
6255 t.Fatal("pre-ready blank tab was not marked removed")
6256 }
6257 if _, ok := app.tabs["blank"]; ok {
6258 t.Fatal("pre-ready blank tab should be removed")
6259 }
6260 if _, err := os.Stat(path); !os.IsNotExist(err) {
6261 t.Fatalf("blank session should be moved out of active history, stat err = %v", err)
6262 }
6263 }
6264
6265 func TestDeleteLastTopicSessionFallbackDoesNotReuseDeletedTopic(t *testing.T) {
6266 isolateDesktopUserDirs(t)
6267
6268 projectRoot := t.TempDir()
6269 topicID := "topic_delete_last"
6270 if err := addProject(projectRoot, ""); err != nil {
6271 t.Fatalf("add project: %v", err)
6272 }
6273 if err := setTopicTitle(projectRoot, topicID, "Delete last"); err != nil {
6274 t.Fatalf("set topic title: %v", err)
6275 }
6276 dir := config.SessionDir()
6277 if err := os.MkdirAll(dir, 0o755); err != nil {
6278 t.Fatalf("mkdir session dir: %v", err)
6279 }
6280 path := writeTopicSession(t, dir, "delete-last.jsonl", topicID, "Delete last", projectRoot)
6281 ctrl := controllerWithContent(t, path)
6282 app := &App{
6283 tabs: map[string]*WorkspaceTab{
6284 "only": {
6285 ID: "only",
6286 Scope: "project",
6287 WorkspaceRoot: projectRoot,
6288 TopicID: topicID,
6289 TopicTitle: "Delete last",
6290 Ctrl: ctrl,
6291 Ready: true,
6292 disabledMCP: map[string]ServerView{},
6293 },
6294 },
6295 tabOrder: []string{"only"},
6296 activeTabID: "only",
6297 }
6298
6299 if err := app.DeleteSession(path); err != nil {
6300 t.Fatalf("DeleteSession(last topic session): %v", err)
6301 }
6302
6303 if _, ok := app.tabs["only"]; ok {
6304 t.Fatalf("deleted topic session tab should be removed")
6305 }
6306 for id, tab := range app.tabs {
6307 if tab.TopicID == topicID {
6308 t.Fatalf("fallback tab %q reused deleted topic %q", id, topicID)
6309 }
6310 if strings.TrimSpace(tab.TopicID) != "" {
6311 t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
6312 }
6313 }
6314 trashPath := filepath.Join(dir, sessionTrashDir, "delete-last.jsonl", "delete-last.jsonl")
6315 if _, err := os.Stat(trashPath); err != nil {
6316 t.Fatalf("deleted session should be moved to trash: %v", err)
6317 }
6318 }
6319
6320 func TestDeleteSessionFallbackKeepsTopicWithRemainingHistory(t *testing.T) {
6321 isolateDesktopUserDirs(t)
6322
6323 projectRoot := t.TempDir()
6324 topicID := "topic_delete_keep_history"
6325 if err := addProject(projectRoot, ""); err != nil {
6326 t.Fatalf("add project: %v", err)
6327 }
6328 if err := setTopicTitle(projectRoot, topicID, "Keep history"); err != nil {
6329 t.Fatalf("set topic title: %v", err)
6330 }
6331 dir := config.SessionDir()
6332 if err := os.MkdirAll(dir, 0o755); err != nil {
6333 t.Fatalf("mkdir session dir: %v", err)
6334 }
6335 path := writeTopicSession(t, dir, "delete-one.jsonl", topicID, "Keep history", projectRoot)
6336 remainingPath := writeTopicSessionWithPrompt(t, dir, "remaining.jsonl", topicID, "Keep history", projectRoot, "remaining turn", time.Now().Add(-time.Minute))
6337 ctrl := controllerWithContent(t, path)
6338 app := &App{
6339 tabs: map[string]*WorkspaceTab{
6340 "only": {
6341 ID: "only",
6342 Scope: "project",
6343 WorkspaceRoot: projectRoot,
6344 TopicID: topicID,
6345 TopicTitle: "Keep history",
6346 Ctrl: ctrl,
6347 Ready: true,
6348 disabledMCP: map[string]ServerView{},
6349 },
6350 },
6351 tabOrder: []string{"only"},
6352 activeTabID: "only",
6353 }
6354
6355 if err := app.DeleteSession(path); err != nil {
6356 t.Fatalf("DeleteSession(topic with remaining history): %v", err)
6357 }
6358
6359 found := false
6360 for _, tab := range app.tabs {
6361 if tab.TopicID == topicID {
6362 found = true
6363 if got := filepath.Clean(tab.currentSessionPath()); got != filepath.Clean(remainingPath) {
6364 t.Fatalf("fallback session path = %q, want remaining history %q", got, remainingPath)
6365 }
6366 }
6367 }
6368 if !found {
6369 t.Fatalf("fallback should keep topic %q when another session remains", topicID)
6370 }
6371 }
6372
6373 func TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {
6374 isolateDesktopUserDirs(t)
6375
6376 dir := config.SessionDir()
6377 if err := os.MkdirAll(dir, 0o755); err != nil {
6378 t.Fatalf("mkdir session dir: %v", err)
6379 }
6380 path := filepath.Join(dir, "stuck-delete.jsonl")
6381 keepPath := filepath.Join(dir, "keep.jsonl")
6382 for _, p := range []string{path, keepPath} {
6383 if err := os.WriteFile(p, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6384 t.Fatalf("write session %s: %v", p, err)
6385 }
6386 }
6387
6388 grace := 500 * time.Millisecond
6389 teardownNotices := make(chan event.Event, 2)
6390 jm := jobs.NewManager(teardownNoticeSink(teardownNotices), jobs.WithTeardownGrace(grace))
6391 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
6392 keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"})
6393 releaseJob := startNonCooperativeSessionJob(t, jm, path)
6394 defer func() {
6395 releaseJob()
6396 ctrl.Close()
6397 keepCtrl.Close()
6398 }()
6399
6400 app := NewApp()
6401 app.setTestCtrl(ctrl, "")
6402 app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true}
6403 app.tabOrder = []string{"test", "keep"}
6404
6405 start := time.Now()
6406 if err := app.DeleteSession(filepath.Base(path)); err != nil {
6407 t.Fatalf("DeleteSession(stuck job): %v", err)
6408 }
6409 elapsed := time.Since(start)
6410 if elapsed > grace+2*time.Second {
6411 t.Fatalf("DeleteSession took %s, want one teardown grace plus bounded metadata I/O", elapsed)
6412 }
6413 assertSingleTeardownTimeoutNotice(t, teardownNotices, grace)
6414 if !agent.IsCleanupPending(path) {
6415 t.Fatalf("stuck delete should mark cleanup pending")
6416 }
6417 if _, err := os.Stat(path); err != nil {
6418 t.Fatalf("stuck session file should remain until delayed cleanup: %v", err)
6419 }
6420 }
6421
6422 func TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {
6423 isolateDesktopUserDirs(t)
6424
6425 dir := config.SessionDir()
6426 if err := os.MkdirAll(dir, 0o755); err != nil {
6427 t.Fatalf("mkdir session dir: %v", err)
6428 }
6429 path := filepath.Join(dir, "active-conflict.jsonl")
6430 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6431 t.Fatalf("write session: %v", err)
6432 }
6433 if err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {
6434 t.Fatalf("create trash conflict: %v", err)
6435 }
6436
6437 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
6438 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"})
6439 app := NewApp()
6440 app.setTestCtrl(ctrl, "")
6441 defer ctrl.Close()
6442 ctrl.Submit("work")
6443 <-runner.started
6444
6445 err := app.DeleteSession(filepath.Base(path))
6446 if err != nil {
6447 t.Fatalf("DeleteSession should succeed after cleaning empty trash dir: %v", err)
6448 }
6449 if _, ok := app.tabs["test"]; ok {
6450 t.Fatalf("deleted session runtime should be removed from tabs")
6451 }
6452 if _, err := os.Stat(path); !os.IsNotExist(err) {
6453 t.Fatalf("session file should be moved out of active history, stat err = %v", err)
6454 }
6455 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6456 if _, err := os.Stat(trashPath); err != nil {
6457 t.Fatalf("session should be moved to trash: %v", err)
6458 }
6459
6460 close(runner.release)
6461 waitNotRunning(t, ctrl)
6462 }
6463
6464 func TestDeleteSessionValidTrashRemovesEmptyLiveStub(t *testing.T) {
6465 isolateDesktopUserDirs(t)
6466
6467 dir := config.SessionDir()
6468 if err := os.MkdirAll(dir, 0o755); err != nil {
6469 t.Fatalf("mkdir session dir: %v", err)
6470 }
6471 path := filepath.Join(dir, "stale-live.jsonl")
6472 if err := os.WriteFile(path, nil, 0o644); err != nil {
6473 t.Fatalf("write live stub: %v", err)
6474 }
6475 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6476 if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
6477 t.Fatalf("create trash dir: %v", err)
6478 }
6479 if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
6480 t.Fatalf("write trash session: %v", err)
6481 }
6482
6483 activePath := filepath.Join(dir, "active.jsonl")
6484 if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
6485 t.Fatalf("write active session: %v", err)
6486 }
6487 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
6488 defer activeCtrl.Close()
6489 app := &App{
6490 tabs: map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
6491 activeTabID: "active",
6492 tabOrder: []string{"active"},
6493 }
6494
6495 if err := app.DeleteSession(filepath.Base(path)); err != nil {
6496 t.Fatalf("DeleteSession should remove stale live stub: %v", err)
6497 }
6498 if _, err := os.Stat(path); !os.IsNotExist(err) {
6499 t.Fatalf("live stub should be removed, stat err = %v", err)
6500 }
6501 if _, err := os.Stat(trashPath); err != nil {
6502 t.Fatalf("existing trash should remain authoritative: %v", err)
6503 }
6504 }
6505
6506 func TestDeleteSessionValidTrashRemovesDuplicateLiveSession(t *testing.T) {
6507 isolateDesktopUserDirs(t)
6508
6509 dir := config.SessionDir()
6510 if err := os.MkdirAll(dir, 0o755); err != nil {
6511 t.Fatalf("mkdir session dir: %v", err)
6512 }
6513 path := filepath.Join(dir, "duplicate-recovery.jsonl")
6514 content := []byte(`{"role":"user","content":"same recovery"}` + "\n")
6515 if err := os.WriteFile(path, content, 0o644); err != nil {
6516 t.Fatalf("write live session: %v", err)
6517 }
6518 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6519 if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
6520 t.Fatalf("create trash dir: %v", err)
6521 }
6522 if err := os.WriteFile(trashPath, content, 0o644); err != nil {
6523 t.Fatalf("write trash session: %v", err)
6524 }
6525
6526 activePath := filepath.Join(dir, "active.jsonl")
6527 if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
6528 t.Fatalf("write active session: %v", err)
6529 }
6530 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
6531 defer activeCtrl.Close()
6532 app := &App{
6533 tabs: map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
6534 activeTabID: "active",
6535 tabOrder: []string{"active"},
6536 }
6537
6538 if err := app.DeleteSession(filepath.Base(path)); err != nil {
6539 t.Fatalf("DeleteSession should remove duplicate live session: %v", err)
6540 }
6541 if _, err := os.Stat(path); !os.IsNotExist(err) {
6542 t.Fatalf("duplicate live session should be removed, stat err = %v", err)
6543 }
6544 if got, err := os.ReadFile(trashPath); err != nil || string(got) != string(content) {
6545 t.Fatalf("existing trash should remain authoritative, got %q err=%v", string(got), err)
6546 }
6547 }
6548
6549 func TestRestoreSessionRejectsOpenEmptyLiveStub(t *testing.T) {
6550 isolateDesktopUserDirs(t)
6551
6552 dir := config.SessionDir()
6553 if err := os.MkdirAll(dir, 0o755); err != nil {
6554 t.Fatalf("mkdir session dir: %v", err)
6555 }
6556 path := filepath.Join(dir, "restore-open.jsonl")
6557 if err := os.WriteFile(path, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
6558 t.Fatalf("write trash source: %v", err)
6559 }
6560 if err := deleteSessionFile(dir, path); err != nil {
6561 t.Fatalf("trash source: %v", err)
6562 }
6563 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6564 if err := os.WriteFile(path, nil, 0o644); err != nil {
6565 t.Fatalf("write live stub: %v", err)
6566 }
6567 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "open"})
6568 defer ctrl.Close()
6569 app := &App{
6570 tabs: map[string]*WorkspaceTab{"open": {ID: "open", Scope: "global", Ctrl: ctrl, Ready: true}},
6571 tabOrder: []string{"open"},
6572 activeTabID: "open",
6573 }
6574
6575 err := app.RestoreSession(trashPath)
6576 if err == nil || !strings.Contains(err.Error(), "session is open") {
6577 t.Fatalf("RestoreSession error = %v, want open-session rejection", err)
6578 }
6579 if info, statErr := os.Stat(path); statErr != nil || info.Size() != 0 {
6580 t.Fatalf("open live stub should remain empty, info=%v err=%v", info, statErr)
6581 }
6582 if _, err := os.Stat(trashPath); err != nil {
6583 t.Fatalf("trash session should remain after rejected restore: %v", err)
6584 }
6585 }
6586
6587 func TestDeleteSessionValidTrashRenamesDifferentLiveConflict(t *testing.T) {
6588 isolateDesktopUserDirs(t)
6589
6590 dir := config.SessionDir()
6591 if err := os.MkdirAll(dir, 0o755); err != nil {
6592 t.Fatalf("mkdir session dir: %v", err)
6593 }
6594 path := filepath.Join(dir, "real-live.jsonl")
6595 if err := os.WriteFile(path, []byte(`{"role":"user","content":"new work"}`+"\n"), 0o644); err != nil {
6596 t.Fatalf("write live session: %v", err)
6597 }
6598 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
6599 if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
6600 t.Fatalf("create trash dir: %v", err)
6601 }
6602 if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
6603 t.Fatalf("write trash session: %v", err)
6604 }
6605
6606 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "active"})
6607 defer activeCtrl.Close()
6608 app := NewApp()
6609 app.setTestCtrl(activeCtrl, "")
6610
6611 if err := app.DeleteSession(filepath.Base(path)); err != nil {
6612 t.Fatalf("DeleteSession should move different live session to a unique trash item: %v", err)
6613 }
6614 if _, err := os.Stat(path); !os.IsNotExist(err) {
6615 t.Fatalf("live session should be moved out of active history, stat err = %v", err)
6616 }
6617 if got, err := os.ReadFile(trashPath); err != nil || !strings.Contains(string(got), "trashed") {
6618 t.Fatalf("original trash session should remain, got %q err=%v", string(got), err)
6619 }
6620 trashed, err := listTrashedSessionFiles(dir)
6621 if err != nil {
6622 t.Fatalf("list trash: %v", err)
6623 }
6624 var renamedPath string
6625 for _, candidate := range trashed {
6626 if candidate != trashPath && filepath.Base(candidate) == filepath.Base(path) {
6627 renamedPath = candidate
6628 break
6629 }
6630 }
6631 if renamedPath == "" {
6632 t.Fatalf("renamed trash copy not found in %#v", trashed)
6633 }
6634 if filepath.Base(filepath.Dir(renamedPath)) == filepath.Base(path) {
6635 t.Fatalf("renamed trash copy reused fixed trash item dir: %s", renamedPath)
6636 }
6637 if got, err := os.ReadFile(renamedPath); err != nil || !strings.Contains(string(got), "new work") {
6638 t.Fatalf("renamed trash session = %q err=%v, want live content", string(got), err)
6639 }
6640 }
6641
6642 func TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {
6643 isolateDesktopUserDirs(t)
6644
6645 dir := config.SessionDir()
6646 if err := os.MkdirAll(dir, 0o755); err != nil {
6647 t.Fatalf("mkdir session dir: %v", err)
6648 }
6649 activePath := filepath.Join(dir, "active.jsonl")
6650 inactivePath := filepath.Join(dir, "inactive.jsonl")
6651 otherPath := filepath.Join(dir, "other.jsonl")
6652 for _, path := range []string{activePath, inactivePath, otherPath} {
6653 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6654 t.Fatalf("write session %s: %v", path, err)
6655 }
6656 }
6657
6658 activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
6659 inactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: "inactive"})
6660 defer activeCtrl.Close()
6661 defer inactiveCtrl.Close()
6662
6663 app := &App{
6664 tabs: map[string]*WorkspaceTab{
6665 "active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
6666 "inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
6667 },
6668 tabOrder: []string{"active", "inactive"},
6669 activeTabID: "active",
6670 }
6671
6672 if err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {
6673 t.Fatalf("DeleteSession(inactive open basename): %v", err)
6674 }
6675 if _, ok := app.tabs["inactive"]; ok {
6676 t.Fatalf("deleted inactive session runtime should be removed")
6677 }
6678 if _, err := os.Stat(inactivePath); !os.IsNotExist(err) {
6679 t.Fatalf("inactive open session should be moved out of active history, stat err = %v", err)
6680 }
6681 trashPath := filepath.Join(dir, sessionTrashDir, "inactive.jsonl", "inactive.jsonl")
6682 if _, err := os.Stat(trashPath); err != nil {
6683 t.Fatalf("inactive open session should be moved to trash: %v", err)
6684 }
6685
6686 sessions := app.ListSessions()
6687 current := map[string]bool{}
6688 open := map[string]bool{}
6689 for _, s := range sessions {
6690 current[filepath.Base(s.Path)] = s.Current
6691 open[filepath.Base(s.Path)] = s.Open
6692 }
6693 if !current[filepath.Base(activePath)] {
6694 t.Fatalf("ListSessions should mark active session current, got %#v", current)
6695 }
6696 if current[filepath.Base(otherPath)] {
6697 t.Fatalf("ListSessions marked unopened session current, got %#v", current)
6698 }
6699 if !open[filepath.Base(activePath)] {
6700 t.Fatalf("ListSessions should mark active and inactive open sessions open, got %#v", open)
6701 }
6702 if open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {
6703 t.Fatalf("ListSessions marked unopened session open, got %#v", open)
6704 }
6705 }
6706
6707 func TestTrashTopicRejectsBackgroundJob(t *testing.T) {
6708 isolateDesktopUserDirs(t)
6709
6710 projectRoot := t.TempDir()
6711 topicID := "topic_stuck_trash"
6712 if err := addProject(projectRoot, ""); err != nil {
6713 t.Fatalf("add project: %v", err)
6714 }
6715 if err := setTopicTitle(projectRoot, topicID, "Stuck trash"); err != nil {
6716 t.Fatalf("set topic title: %v", err)
6717 }
6718 dir := config.SessionDir()
6719 if err := os.MkdirAll(dir, 0o755); err != nil {
6720 t.Fatalf("mkdir sessions: %v", err)
6721 }
6722 sessionPath := writeTopicSession(t, dir, "stuck-topic.jsonl", topicID, "Stuck trash", projectRoot)
6723
6724 jm := jobs.NewManager(event.Discard)
6725 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", Jobs: jm, WorkspaceRoot: projectRoot})
6726 releaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)
6727 defer func() {
6728 releaseJob()
6729 ctrl.Close()
6730 }()
6731
6732 app := &App{
6733 tabs: map[string]*WorkspaceTab{
6734 "stuck": {
6735 ID: "stuck",
6736 Scope: "project",
6737 WorkspaceRoot: projectRoot,
6738 TopicID: topicID,
6739 TopicTitle: "Stuck trash",
6740 Ctrl: ctrl,
6741 Ready: true,
6742 disabledMCP: map[string]ServerView{},
6743 },
6744 "keep": {
6745 ID: "keep",
6746 Scope: "project",
6747 WorkspaceRoot: projectRoot,
6748 TopicID: "topic_keep",
6749 TopicTitle: "Keep",
6750 Ready: true,
6751 disabledMCP: map[string]ServerView{},
6752 },
6753 },
6754 tabOrder: []string{"stuck", "keep"},
6755 activeTabID: "stuck",
6756 }
6757
6758 if err := app.TrashTopic(topicID); !errors.Is(err, errTopicHasActiveWork) {
6759 t.Fatalf("TrashTopic(background job) error = %v, want %v", err, errTopicHasActiveWork)
6760 }
6761 if _, ok := app.tabs["stuck"]; !ok {
6762 t.Fatal("rejected archive should keep the background-job topic tab")
6763 }
6764 if agent.IsCleanupPending(sessionPath) {
6765 t.Fatal("rejected archive should not mark session cleanup pending")
6766 }
6767 if _, err := os.Stat(sessionPath); err != nil {
6768 t.Fatalf("rejected archive should preserve the live session: %v", err)
6769 }
6770 trashPath := filepath.Join(dir, sessionTrashDir, "stuck-topic.jsonl", "stuck-topic.jsonl")
6771 if _, err := os.Stat(trashPath); !os.IsNotExist(err) {
6772 t.Fatalf("rejected archive created a trash entry, stat err = %v", err)
6773 }
6774 if got := loadTopicTitle(projectRoot, topicID); got != "Stuck trash" {
6775 t.Fatalf("rejected archive topic title = %q, want Stuck trash", got)
6776 }
6777 }
6778
6779 func teardownNoticeSink(out chan<- event.Event) event.Sink {
6780 return event.FuncSink(func(e event.Event) {
6781 if e.Kind == event.Notice && strings.Contains(e.Detail, "background job teardown timed out") {
6782 out <- e
6783 }
6784 })
6785 }
6786
6787 func assertSingleTeardownTimeoutNotice(t *testing.T, notices <-chan event.Event, grace time.Duration) {
6788 t.Helper()
6789 var notice event.Event
6790 select {
6791 case notice = <-notices:
6792 default:
6793 t.Fatal("missing background-job teardown timeout notice")
6794 }
6795 var waited time.Duration
6796 for _, field := range strings.Fields(notice.Detail) {
6797 if !strings.HasPrefix(field, "waited=") {
6798 continue
6799 }
6800 parsed, err := time.ParseDuration(strings.TrimSuffix(strings.TrimPrefix(field, "waited="), ";"))
6801 if err != nil {
6802 t.Fatalf("parse teardown waited field %q: %v", field, err)
6803 }
6804 waited = parsed
6805 break
6806 }
6807 if waited < grace-10*time.Millisecond || waited > grace+250*time.Millisecond {
6808 t.Fatalf("teardown notice waited %s, want one %s grace; detail: %s", waited, grace, notice.Detail)
6809 }
6810 select {
6811 case extra := <-notices:
6812 t.Fatalf("duplicate teardown timeout notice: %+v", extra)
6813 default:
6814 }
6815 }
6816
6817 func TestWaitDestroyHandlesWaitsConcurrently(t *testing.T) {
6818 started := make(chan int, 2)
6819 release := make(chan struct{})
6820 var releaseOnce sync.Once
6821 releaseAll := func() { releaseOnce.Do(func() { close(release) }) }
6822 defer releaseAll()
6823
6824 handle := func(id int) control.SessionDestroyHandle {
6825 return control.SessionDestroyHandle{Wait: func() jobs.TeardownResult {
6826 started <- id
6827 <-release
6828 return jobs.TeardownResult{TimedOut: []jobs.TeardownJob{{ID: strconv.Itoa(id)}}}
6829 }}
6830 }
6831 done := make(chan bool, 1)
6832 go func() { done <- waitDestroyHandles([]control.SessionDestroyHandle{handle(1), handle(2)}) }()
6833
6834 seen := map[int]bool{}
6835 for len(seen) < 2 {
6836 select {
6837 case id := <-started:
6838 seen[id] = true
6839 case <-time.After(2 * time.Second):
6840 t.Fatalf("destroy waits did not start concurrently; started=%v", seen)
6841 }
6842 }
6843 releaseAll()
6844 select {
6845 case timedOut := <-done:
6846 if !timedOut {
6847 t.Fatal("waitDestroyHandles lost timed-out result")
6848 }
6849 case <-time.After(2 * time.Second):
6850 t.Fatal("waitDestroyHandles did not return after all waits completed")
6851 }
6852 }
6853
6854 func TestRestoreSessionRejectsDestroyingSession(t *testing.T) {
6855 isolateDesktopUserDirs(t)
6856
6857 dir := config.SessionDir()
6858 if err := os.MkdirAll(dir, 0o755); err != nil {
6859 t.Fatalf("mkdir session dir: %v", err)
6860 }
6861 sessionPath := filepath.Join(dir, "trash-me.jsonl")
6862 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
6863 t.Fatalf("write session: %v", err)
6864 }
6865 if err := deleteSessionFile(dir, sessionPath); err != nil {
6866 t.Fatalf("deleteSessionFile: %v", err)
6867 }
6868 trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
6869
6870 jm := jobs.NewManager(event.Discard)
6871 defer jm.Close()
6872 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "active", Jobs: jm})
6873 defer ctrl.Close()
6874 destroy := ctrl.BeginDestroySession(sessionPath)
6875 defer destroy.Finish()
6876
6877 app := NewApp()
6878 app.setTestCtrl(ctrl, "")
6879 if err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), "cleanup is still in progress") {
6880 t.Fatalf("RestoreSession while destroying error = %v, want cleanup-in-progress", err)
6881 }
6882 if _, err := os.Stat(trashPath); err != nil {
6883 t.Fatalf("trashed session should remain after rejected restore: %v", err)
6884 }
6885
6886 destroy.Finish()
6887 if err := app.RestoreSession(trashPath); err != nil {
6888 t.Fatalf("RestoreSession after finish: %v", err)
6889 }
6890 if _, err := os.Stat(sessionPath); err != nil {
6891 t.Fatalf("session should be restored: %v", err)
6892 }
6893 }
6894
6895 func TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {
6896 isolateDesktopUserDirs(t)
6897
6898 dirA := filepath.Join(t.TempDir(), "workspace-a-sessions")
6899 dirB := filepath.Join(t.TempDir(), "workspace-b-sessions")
6900 if err := os.MkdirAll(dirA, 0o755); err != nil {
6901 t.Fatalf("mkdir dirA: %v", err)
6902 }
6903 if err := os.MkdirAll(dirB, 0o755); err != nil {
6904 t.Fatalf("mkdir dirB: %v", err)
6905 }
6906 pathA := filepath.Join(dirA, "a.jsonl")
6907 pathB := filepath.Join(dirB, "b.jsonl")
6908 if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"}`+"\n"), 0o644); err != nil {
6909 t.Fatalf("write pathA: %v", err)
6910 }
6911 if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"}`+"\n"), 0o644); err != nil {
6912 t.Fatalf("write pathB: %v", err)
6913 }
6914
6915 app := NewApp()
6916 app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"}), "")
6917 defer app.activeCtrl().Close()
6918
6919 sessions := app.ListSessions()
6920 if len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != "workspace A" {
6921 t.Fatalf("ListSessions should read the active controller session dir only, got %+v", sessions)
6922 }
6923 if err := app.RenameSession(pathA, "A title"); err != nil {
6924 t.Fatalf("RenameSession in active session dir: %v", err)
6925 }
6926 meta, ok, err := agent.LoadBranchMeta(pathA)
6927 if err != nil || !ok {
6928 t.Fatalf("LoadBranchMeta after RenameSession ok=%v err=%v", ok, err)
6929 }
6930 if meta.CustomTitle != "A title" {
6931 t.Fatalf("custom title should be written to branch meta, got %q", meta.CustomTitle)
6932 }
6933 sessions = app.ListSessions()
6934 if len(sessions) != 1 || sessions[0].Title != "A title" {
6935 t.Fatalf("ListSessions should return custom title from branch meta, got %+v", sessions)
6936 }
6937 if titles := loadSessionTitles(dirA); titles["a.jsonl"] != "A title" {
6938 t.Fatalf("title should be written beside the active session, got %+v", titles)
6939 }
6940 if titles := loadSessionTitles(dirB); len(titles) != 0 {
6941 t.Fatalf("inactive workspace title sidecar should remain untouched, got %+v", titles)
6942 }
6943 }
6944
6945 func TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {
6946 isolateDesktopUserDirs(t)
6947
6948 dir := config.SessionDir()
6949 if err := os.MkdirAll(dir, 0o755); err != nil {
6950 t.Fatalf("mkdir session dir: %v", err)
6951 }
6952 path := filepath.Join(dir, "bot-channel.jsonl")
6953 if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
6954 t.Fatalf("write session: %v", err)
6955 }
6956 cfg := config.Default()
6957 cfg.Bot.Connections = []config.BotConnectionConfig{{
6958 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
6959 SessionMappings: []config.BotConnectionSessionMapping{{
6960 RemoteID: "wx-chat-1", SessionID: "path:" + path, SessionSource: "auto",
6961 }},
6962 }}
6963 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
6964 t.Fatalf("save config: %v", err)
6965 }
6966
6967 app := NewApp()
6968 app.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"}), "")
6969 defer app.activeCtrl().Close()
6970
6971 sessions := app.ListSessions()
6972 if len(sessions) != 1 {
6973 t.Fatalf("ListSessions len = %d, want 1: %+v", len(sessions), sessions)
6974 }
6975 got := sessions[0]
6976 if got.Kind != "channel" || got.Channel != "weixin" || got.ChannelLabel != "微信" || got.RemoteID != "wx-chat-1" || got.SessionSource != "auto" {
6977 t.Fatalf("channel session meta = %+v", got)
6978 }
6979 }
6980
6981 func TestDeleteSessionClearsAutoBotSessionMapping(t *testing.T) {
6982 isolateDesktopUserDirs(t)
6983
6984 dir := config.SessionDir()
6985 if err := os.MkdirAll(dir, 0o755); err != nil {
6986 t.Fatalf("mkdir session dir: %v", err)
6987 }
6988 path := filepath.Join(dir, "bot-channel.jsonl")
6989 if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
6990 t.Fatalf("write session: %v", err)
6991 }
6992 other := filepath.Join(dir, "other-channel.jsonl")
6993 cfg := config.Default()
6994 cfg.Bot.Connections = []config.BotConnectionConfig{{
6995 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
6996 SessionMappings: []config.BotConnectionSessionMapping{
6997 {RemoteID: "remove-auto", SessionID: "path:" + path, SessionSource: "auto"},
6998 {RemoteID: "keep-explicit", SessionID: "path:" + path},
6999 {RemoteID: "keep-other-auto", SessionID: "path:" + other, SessionSource: "auto"},
7000 },
7001 }}
7002 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
7003 t.Fatalf("save config: %v", err)
7004 }
7005
7006 app := NewApp()
7007 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
7008 app.setTestCtrl(ctrl, "")
7009 defer app.activeCtrl().Close()
7010
7011 if err := app.DeleteSession(path); err != nil {
7012 t.Fatalf("DeleteSession: %v", err)
7013 }
7014
7015 got := config.LoadForEdit(config.UserConfigPath())
7016 mappings := got.Bot.Connections[0].SessionMappings
7017 if len(mappings) != 2 {
7018 t.Fatalf("session mappings = %+v, want explicit and other auto mappings preserved", mappings)
7019 }
7020 for _, mapping := range mappings {
7021 if mapping.RemoteID == "remove-auto" {
7022 t.Fatalf("deleted session auto mapping was preserved: %+v", mappings)
7023 }
7024 }
7025 }
7026
7027 func TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {
7028 isolateDesktopUserDirs(t)
7029
7030 dir := config.SessionDir()
7031 if err := os.MkdirAll(dir, 0o755); err != nil {
7032 t.Fatalf("mkdir session dir: %v", err)
7033 }
7034 path := filepath.Join(dir, "bot-channel.jsonl")
7035 if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
7036 t.Fatalf("write session: %v", err)
7037 }
7038
7039 app := NewApp()
7040 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
7041 app.setTestCtrl(ctrl, "")
7042 defer app.activeCtrl().Close()
7043
7044 if _, err := app.OpenChannelSessionForTab("test", path); err != nil {
7045 t.Fatalf("OpenChannelSessionForTab: %v", err)
7046 }
7047 if meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {
7048 t.Fatalf("channel tab should be read-only: %+v", meta)
7049 }
7050 before, err := os.ReadFile(path)
7051 if err != nil {
7052 t.Fatalf("read before: %v", err)
7053 }
7054 app.SubmitToTab("test", "must not append")
7055 app.RunShellForTab("test", "echo must-not-run")
7056 after, err := os.ReadFile(path)
7057 if err != nil {
7058 t.Fatalf("read after: %v", err)
7059 }
7060 if string(after) != string(before) {
7061 t.Fatalf("read-only channel transcript changed:\nbefore=%s\nafter=%s", before, after)
7062 }
7063
7064 f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
7065 if err != nil {
7066 t.Fatalf("open append: %v", err)
7067 }
7068 if _, err := f.WriteString(`{"role":"user","content":"external follow-up"}` + "\n"); err != nil {
7069 f.Close()
7070 t.Fatalf("append external message: %v", err)
7071 }
7072 if err := f.Close(); err != nil {
7073 t.Fatalf("close append: %v", err)
7074 }
7075 app.snapshotAllTabs()
7076 afterSnapshot, err := os.ReadFile(path)
7077 if err != nil {
7078 t.Fatalf("read after snapshot: %v", err)
7079 }
7080 if !strings.Contains(string(afterSnapshot), "external follow-up") {
7081 t.Fatalf("read-only channel snapshot overwrote external append:\n%s", afterSnapshot)
7082 }
7083 }
7084
7085 func TestUserTriggeredCommandsReturnErrorsWhenUnavailable(t *testing.T) {
7086 tests := []struct {
7087 name string
7088 app *App
7089 call func(*App) error
7090 want string
7091 }{
7092 {
7093 name: "submit read-only",
7094 app: &App{
7095 tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global", ReadOnly: true}},
7096 activeTabID: "test",
7097 },
7098 call: func(app *App) error { return app.SubmitToTab("test", "hello") },
7099 want: "read-only",
7100 },
7101 {
7102 name: "submit workspace unavailable",
7103 app: &App{
7104 tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global", StartupErr: "boom"}},
7105 activeTabID: "test",
7106 },
7107 call: func(app *App) error { return app.SubmitToTab("test", "hello") },
7108 want: "workspace failed to start: boom",
7109 },
7110 {
7111 name: "run shell workspace unavailable",
7112 app: &App{
7113 tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global"}},
7114 activeTabID: "test",
7115 },
7116 call: func(app *App) error { return app.RunShellForTab("test", "echo hi") },
7117 want: "workspace is still starting",
7118 },
7119 {
7120 name: "steer workspace unavailable",
7121 app: &App{
7122 tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global"}},
7123 activeTabID: "test",
7124 },
7125 call: func(app *App) error { return app.SteerForTab("test", "please continue") },
7126 want: "workspace is still starting",
7127 },
7128 }
7129
7130 for _, tt := range tests {
7131 t.Run(tt.name, func(t *testing.T) {
7132 err := tt.call(tt.app)
7133 if err == nil {
7134 t.Fatalf("expected error containing %q", tt.want)
7135 }
7136 if !strings.Contains(err.Error(), tt.want) {
7137 t.Fatalf("error = %q, want to contain %q", err, tt.want)
7138 }
7139 })
7140 }
7141 }
7142
7143 func TestSubmitEntryPointsRejectEmptyProviderInput(t *testing.T) {
7144 app := NewApp()
7145 for _, tt := range []struct {
7146 name string
7147 call func() error
7148 }{
7149 {name: "plain", call: func() error { return app.SubmitToTab("missing", " \n\t ") }},
7150 {name: "display", call: func() error { return app.SubmitDisplayToTab("missing", "visible prompt", " ") }},
7151 {name: "delivery recovery", call: func() error {
7152 return app.SubmitDeliveryRecoveryToTab("missing", "visible prompt", "")
7153 }},
7154 {name: "invocations", call: func() error {
7155 return app.SubmitInvocationsToTab("missing", "/skill visible", "", nil)
7156 }},
7157 {name: "edited display", call: func() error {
7158 return app.SubmitEditedDisplayToTab("missing", "visible prompt", "\n", "original prompt")
7159 }},
7160 {name: "initial goal", call: func() error {
7161 _, err := app.SubmitInitialGoalToTab("missing", "goal", "visible prompt", "", nil, "normal", "auto")
7162 return err
7163 }},
7164 } {
7165 t.Run(tt.name, func(t *testing.T) {
7166 if err := tt.call(); !errors.Is(err, errEmptyTurnInput) {
7167 t.Fatalf("error = %v, want errEmptyTurnInput", err)
7168 }
7169 })
7170 }
7171 }
7172
7173 func TestInvocationEntryPointsAllowEmptyExplicitTaskForSkillOnlyTurn(t *testing.T) {
7174 invocations := []InvocationRequest{{Name: "skill", Kind: "skill"}}
7175 if err := validateInvocationTurnInput("", invocations); err != nil {
7176 t.Fatalf("skill-only invocation input rejected: %v", err)
7177 }
7178 if err := validateInvocationTurnInput("", nil); !errors.Is(err, errEmptyTurnInput) {
7179 t.Fatalf("empty input without invocations = %v, want errEmptyTurnInput", err)
7180 }
7181 }
7182
7183 func TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {
7184 isolateDesktopUserDirs(t)
7185
7186 dir := config.SessionDir()
7187 if err := os.MkdirAll(dir, 0o755); err != nil {
7188 t.Fatalf("mkdir session dir: %v", err)
7189 }
7190 path := filepath.Join(dir, "bot-channel.jsonl")
7191 if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
7192 t.Fatalf("write session: %v", err)
7193 }
7194
7195 app := NewApp()
7196 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
7197 app.setTestCtrl(ctrl, "")
7198 defer ctrl.Close()
7199
7200 if _, err := app.OpenChannelSessionForTab("test", path); err != nil {
7201 t.Fatalf("OpenChannelSessionForTab: %v", err)
7202 }
7203 app.mu.Lock()
7204 app.tabs["survivor"] = &WorkspaceTab{ID: "survivor", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
7205 app.tabOrder = []string{"test", "survivor"}
7206 app.activeTabID = "test"
7207 app.mu.Unlock()
7208
7209 f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
7210 if err != nil {
7211 t.Fatalf("open append: %v", err)
7212 }
7213 if _, err := f.WriteString(`{"role":"user","content":"external close follow-up"}` + "\n"); err != nil {
7214 f.Close()
7215 t.Fatalf("append external message: %v", err)
7216 }
7217 if err := f.Close(); err != nil {
7218 t.Fatalf("close append: %v", err)
7219 }
7220
7221 if err := app.CloseTab("test"); err != nil {
7222 t.Fatalf("CloseTab: %v", err)
7223 }
7224 afterClose, err := os.ReadFile(path)
7225 if err != nil {
7226 t.Fatalf("read after close: %v", err)
7227 }
7228 if !strings.Contains(string(afterClose), "external close follow-up") {
7229 t.Fatalf("closing read-only channel tab overwrote external append:\n%s", afterClose)
7230 }
7231 }
7232
7233 func TestResumeSessionRejectsCleanupPending(t *testing.T) {
7234 isolateDesktopUserDirs(t)
7235
7236 dir := config.SessionDir()
7237 if err := os.MkdirAll(dir, 0o755); err != nil {
7238 t.Fatalf("mkdir session dir: %v", err)
7239 }
7240 activePath := filepath.Join(dir, "active.jsonl")
7241 pendingPath := filepath.Join(dir, "pending.jsonl")
7242 for _, path := range []string{activePath, pendingPath} {
7243 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
7244 t.Fatalf("write %s: %v", path, err)
7245 }
7246 }
7247 if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil {
7248 t.Fatal(err)
7249 }
7250
7251 app := NewApp()
7252 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "test"})
7253 app.setTestCtrl(ctrl, "")
7254 defer app.activeCtrl().Close()
7255
7256 if _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), "pending cleanup") {
7257 t.Fatalf("ResumeSession cleanup-pending error = %v, want pending cleanup", err)
7258 }
7259 if got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {
7260 t.Fatalf("active session path after rejected resume = %q, want %q", got, activePath)
7261 }
7262 if _, err := app.OpenChannelSessionForTab("test", pendingPath); err == nil || !strings.Contains(err.Error(), "pending cleanup") {
7263 t.Fatalf("OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup", err)
7264 }
7265 if meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {
7266 t.Fatalf("rejected channel open should not make tab read-only: %+v", meta)
7267 }
7268 }
7269
7270 func TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {
7271 dirA := t.TempDir()
7272 dirB := t.TempDir()
7273 activePath := filepath.Join(dirA, "active.jsonl")
7274 outsidePath := filepath.Join(dirB, "outside.jsonl")
7275 for _, path := range []string{activePath, outsidePath} {
7276 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
7277 t.Fatalf("write %s: %v", path, err)
7278 }
7279 }
7280
7281 app := NewApp()
7282 app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: "test"}), "")
7283 defer app.activeCtrl().Close()
7284
7285 if _, err := app.ResumeSession(outsidePath); err == nil {
7286 t.Fatal("ResumeSession should reject a transcript outside the active session dir")
7287 }
7288 if _, err := app.PreviewSession(outsidePath); err == nil {
7289 t.Fatal("PreviewSession should reject a transcript outside the active session dir")
7290 }
7291 }
7292
7293 func BenchmarkDesktopListSessionsScoped(b *testing.B) {
7294 dirA := filepath.Join(b.TempDir(), "workspace-a-sessions")
7295 dirB := filepath.Join(b.TempDir(), "workspace-b-sessions")
7296 for _, dir := range []string{dirA, dirB} {
7297 if err := os.MkdirAll(dir, 0o755); err != nil {
7298 b.Fatalf("mkdir %s: %v", dir, err)
7299 }
7300 for i := 0; i < 120; i++ {
7301 path := filepath.Join(dir, fmt.Sprintf("session-%03d.jsonl", i))
7302 body := fmt.Sprintf(`{"role":"user","content":"session %03d"}`+"\n", i)
7303 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
7304 b.Fatalf("write session: %v", err)
7305 }
7306 }
7307 }
7308
7309 app := NewApp()
7310 app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, "session-000.jsonl"), Label: "test"}), "")
7311 defer app.activeCtrl().Close()
7312
7313 b.ReportAllocs()
7314 b.ResetTimer()
7315 for i := 0; i < b.N; i++ {
7316 sessions := app.ListSessions()
7317 if len(sessions) != 120 {
7318 b.Fatalf("ListSessions len = %d, want 120", len(sessions))
7319 }
7320 }
7321 }
7322
7323 type appendingDesktopRunner struct {
7324 session *agent.Session
7325 started chan string
7326 }
7327
7328 func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
7329 r.started <- input
7330 r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
7331 r.session.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
7332 return nil
7333 }
7334
7335 func TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {
7336 isolateDesktopUserDirs(t)
7337 dir := config.SessionDir()
7338 if err := os.MkdirAll(dir, 0o755); err != nil {
7339 t.Fatal(err)
7340 }
7341
7342 path := filepath.Join(dir, "memory-display.jsonl")
7343 sess := agent.NewSession("sys")
7344 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
7345 runner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}
7346 ctrl := control.New(control.Options{
7347 Runner: runner,
7348 Executor: exec,
7349 Sink: event.Discard,
7350 SessionDir: dir,
7351 SessionPath: path,
7352 Label: "test",
7353 })
7354 defer ctrl.Close()
7355
7356 app := NewApp()
7357 app.setTestCtrl(ctrl, "deepseek/test")
7358 ctrl.QueueMemory(`Saved memory "reasonix-contributions": contribution count updated`)
7359
7360 const prompt = "不要,删了"
7361 app.SubmitToTab("test", prompt)
7362 composed := <-runner.started
7363 waitNotRunning(t, ctrl)
7364
7365 if !strings.Contains(composed, "<memory-update>") || !strings.HasSuffix(composed, prompt) {
7366 t.Fatalf("model input should include memory update followed by prompt, got %q", composed)
7367 }
7368 got := app.HistoryForTab("test")
7369 if len(got) < 2 {
7370 t.Fatalf("history length = %d, want user + assistant", len(got))
7371 }
7372 if got[0].Role != "system" || got[1].Role != "user" {
7373 t.Fatalf("history roles = %+v, want system then user", got[:min(len(got), 2)])
7374 }
7375 if got[1].Content != prompt {
7376 t.Fatalf("displayed user content = %q, want %q", got[1].Content, prompt)
7377 }
7378 if strings.Contains(got[1].Content, "<memory-update>") {
7379 t.Fatalf("displayed user content leaked memory update: %q", got[1].Content)
7380 }
7381 }
7382
7383 func TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {
7384 isolateDesktopUserDirs(t)
7385
7386 workspace := robustTempDir(t)
7387 if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(""), 0o644); err != nil {
7388 t.Fatalf("write workspace config: %v", err)
7389 }
7390 dir := config.SessionDir()
7391 if err := os.MkdirAll(dir, 0o755); err != nil {
7392 t.Fatalf("mkdir session dir: %v", err)
7393 }
7394 path := agent.NewSessionPath(dir, "test")
7395 sess := agent.NewSession("sys")
7396 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
7397 runner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}
7398 ctrl := control.New(control.Options{
7399 Runner: runner,
7400 Executor: exec,
7401 Sink: event.Discard,
7402 SessionDir: dir,
7403 SessionPath: path,
7404 Label: "test",
7405 WorkspaceRoot: workspace,
7406 })
7407 app := NewApp()
7408 app.setTestCtrl(ctrl, "deepseek/test")
7409 app.tabs["test"].Scope = "project"
7410 app.tabs["test"].WorkspaceRoot = workspace
7411 app.tabs["test"].TopicID = "topic_source"
7412 app.tabs["test"].TopicTitle = "Source topic"
7413 defer ctrl.Close()
7414
7415 ctrl.Submit("first")
7416 <-runner.started
7417 waitNotRunning(t, ctrl)
7418 ctrl.Submit("second")
7419 <-runner.started
7420 waitNotRunning(t, ctrl)
7421 if got := len(ctrl.History()); got != 5 {
7422 t.Fatalf("source history len before fork = %d, want 5", got)
7423 }
7424
7425 meta, err := app.Fork(1)
7426 if err != nil {
7427 t.Fatalf("Fork: %v", err)
7428 }
7429 if !meta.Active || meta.ID == "" || meta.ID == "test" {
7430 t.Fatalf("fork meta = %+v, want a new active tab", meta)
7431 }
7432 if got := app.activeTabID; got != meta.ID {
7433 t.Fatalf("active tab = %q, want fork tab %q", got, meta.ID)
7434 }
7435 if got := ctrl.SessionPath(); got != path {
7436 t.Fatalf("source controller session path = %q, want %q", got, path)
7437 }
7438 if got := len(ctrl.History()); got != 5 {
7439 t.Fatalf("source history len after fork = %d, want 5", got)
7440 }
7441 if got, want := meta.TopicTitle, "Source topic · 分叉"; got != want {
7442 t.Fatalf("fork topic title = %q, want %q", got, want)
7443 }
7444
7445 var forkPath string
7446 entries, err := os.ReadDir(dir)
7447 if err != nil {
7448 t.Fatalf("read session dir: %v", err)
7449 }
7450 for _, entry := range entries {
7451 if entry.IsDir() {
7452 continue
7453 }
7454 candidate := filepath.Join(dir, entry.Name())
7455 if candidate == path {
7456 continue
7457 }
7458 m, ok, err := agent.LoadBranchMeta(candidate)
7459 if err != nil {
7460 t.Fatalf("load fork meta: %v", err)
7461 }
7462 if ok && m.TopicID == meta.TopicID {
7463 forkPath = candidate
7464 if m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {
7465 t.Fatalf("fork branch meta = %+v, want parent %q turn 1 index 3", m, agent.BranchID(path))
7466 }
7467 if m.Scope != "project" || m.WorkspaceRoot != workspace || m.TopicTitle != "Source topic · 分叉" {
7468 t.Fatalf("fork topic meta = %+v", m)
7469 }
7470 }
7471 }
7472 if forkPath == "" {
7473 t.Fatalf("fork session with topic %q not found in %s", meta.TopicID, dir)
7474 }
7475 }
7476
7477 func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
7478 isolateDesktopUserDirs(t)
7479 dir := robustTempDir(t)
7480 t.Chdir(dir)
7481 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
7482 [[plugins]]
7483 name = "playwright"
7484 command = "npx"
7485 args = ["-y", "@playwright/mcp"]
7486 `), 0o644); err != nil {
7487 t.Fatal(err)
7488 }
7489
7490 app := NewApp()
7491 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
7492 defer func() {
7493 if c := app.activeCtrl(); c != nil {
7494 c.Close()
7495 }
7496 }()
7497
7498 view := app.Capabilities()
7499 for _, s := range view.Servers {
7500 if s.Name == "playwright" {
7501 if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
7502 t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
7503 }
7504 return
7505 }
7506 }
7507 t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
7508 }
7509
7510 func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
7511 isolateDesktopUserDirs(t)
7512 reasonixHome := config.ReasonixHomeDir()
7513 root := filepath.Join(reasonixHome, "plugins", "superpowers")
7514 if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
7515 t.Fatal(err)
7516 }
7517 if err := os.MkdirAll(filepath.Join(root, "skills", "plan"), 0o755); err != nil {
7518 t.Fatal(err)
7519 }
7520 if err := os.WriteFile(filepath.Join(root, "skills", "plan", "SKILL.md"), []byte("---\ndescription: Plan work\n---\nbody"), 0o644); err != nil {
7521 t.Fatal(err)
7522 }
7523 if err := os.MkdirAll(filepath.Join(root, ".codex-plugin"), 0o755); err != nil {
7524 t.Fatal(err)
7525 }
7526 if err := os.WriteFile(filepath.Join(root, ".codex-plugin", "plugin.json"), []byte(`{
7527 "name": "superpowers",
7528 "version": "6.1.0",
7529 "description": "Planning workflows",
7530 "skills": "./skills/"
7531 }`), 0o644); err != nil {
7532 t.Fatal(err)
7533 }
7534 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
7535 Name: "superpowers",
7536 Root: "plugins/superpowers",
7537 Version: "6.1.0",
7538 Description: "Planning workflows",
7539 ManifestKind: "codex",
7540 Enabled: true,
7541 }); err != nil {
7542 t.Fatal(err)
7543 }
7544
7545 app := NewApp()
7546 plugins := app.Capabilities().Plugins
7547 if len(plugins) != 1 || plugins[0].Name != "superpowers" || plugins[0].Skills != 1 {
7548 t.Fatalf("Capabilities().Plugins = %+v", plugins)
7549 }
7550 if len(plugins[0].SkillDetails) != 1 || plugins[0].SkillDetails[0].Invocation != "/superpowers:plan" {
7551 t.Fatalf("Capabilities().Plugins skill details = %+v", plugins[0].SkillDetails)
7552 }
7553 }
7554
7555 func TestDesktopSharedHostProjectMCPConnectsWithoutLaunchApproval(t *testing.T) {
7556 if testing.Short() {
7557 t.Skip("skipping background MCP boot integration test in short mode")
7558 }
7559
7560 isolateDesktopUserDirs(t)
7561 dir := robustTempDir(t)
7562 t.Chdir(dir)
7563
7564 srv := desktopMCPHTTPServer(t)
7565 defer srv.Close()
7566 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(`
7567 [[plugins]]
7568 name = "h"
7569 type = "http"
7570 url = %q
7571 `, srv.URL)), 0o644); err != nil {
7572 t.Fatal(err)
7573 }
7574
7575 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
7576 defer cancel()
7577 sharedHost := plugin.NewHost()
7578 defer sharedHost.Close()
7579 ctrl, err := boot.Build(ctx, boot.Options{
7580 WorkspaceRoot: dir,
7581 SessionDir: filepath.Join(dir, "sessions"),
7582 SharedHost: sharedHost,
7583 Stderr: io.Discard,
7584 })
7585 if err != nil {
7586 t.Fatalf("boot.Build: %v", err)
7587 }
7588 defer ctrl.Close()
7589
7590 deadline := time.Now().Add(3 * time.Second)
7591 for !sharedHost.HasClient("h") && time.Now().Before(deadline) {
7592 time.Sleep(25 * time.Millisecond)
7593 }
7594 if !sharedHost.HasClient("h") {
7595 t.Fatalf("project MCP did not connect automatically; failures=%+v", sharedHost.Failures())
7596 }
7597 for _, failure := range sharedHost.Failures() {
7598 if failure.Name == "h" && failure.RequiresLaunchApproval {
7599 t.Fatalf("project MCP unexpectedly requested launch approval: %+v", failure)
7600 }
7601 }
7602
7603 app := NewApp()
7604 app.tabs = map[string]*WorkspaceTab{
7605 "test": {
7606 ID: "test",
7607 Scope: "global",
7608 WorkspaceRoot: dir,
7609 Ready: true,
7610 Ctrl: ctrl,
7611 SharedHostKey: dir,
7612 disabledMCP: map[string]ServerView{},
7613 },
7614 }
7615 app.activeTabID = "test"
7616
7617 view := app.MCPServers()
7618 if len(view) != 1 || view[0].Name != "h" || view[0].Status != "connected" || view[0].RuntimeState != "ready" || view[0].RequiresLaunchApproval {
7619 t.Fatalf("MCPServers() = %+v, want trusted connected project h", view)
7620 }
7621 }
7622
7623 func TestProjectMCPViewIsTrustedAndKeepsProjectSource(t *testing.T) {
7624 entry := config.PluginEntry{Name: "project", Source: config.MCPSourceProjectConfig}
7625 connected := withPluginConfig(ServerView{Name: entry.Name, Status: "connected"}, entry)
7626 if connected.RequiresLaunchApproval {
7627 t.Fatalf("connected project MCP still requires launch approval: %+v", connected)
7628 }
7629 blocked := withPluginConfig(ServerView{
7630 Name: entry.Name, Status: "failed", RequiresLaunchApproval: true,
7631 }, entry)
7632 if blocked.RequiresLaunchApproval {
7633 t.Fatalf("project MCP exposed obsolete launch approval action: %+v", blocked)
7634 }
7635 if blocked.Source != "project" || blocked.ConfigSource != "reasonix.toml" {
7636 t.Fatalf("blocked project MCP source = %q/%q, want project/reasonix.toml", blocked.Source, blocked.ConfigSource)
7637 }
7638
7639 user := withPluginConfig(ServerView{Name: "user", Status: "connected"},
7640 config.PluginEntry{Name: "user", Source: config.MCPSourceUserConfig})
7641 if user.RequiresLaunchApproval {
7642 t.Fatalf("user-config MCP must not be launch-gate governed: %+v", user)
7643 }
7644 if user.Source != "user" || user.ConfigSource != "config.toml" {
7645 t.Fatalf("user MCP source = %q/%q, want user/config.toml", user.Source, user.ConfigSource)
7646 }
7647 }
7648
7649 func TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {
7650 isolateDesktopUserDirs(t)
7651 dir := robustTempDir(t)
7652 t.Chdir(dir)
7653 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
7654 [[plugins]]
7655 name = "playwright"
7656 command = "npx"
7657 args = ["-y", "@playwright/mcp"]
7658 `), 0o644); err != nil {
7659 t.Fatal(err)
7660 }
7661
7662 app := NewApp()
7663 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
7664 defer app.activeCtrl().Close()
7665
7666 if got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {
7667 t.Fatalf("MCPServers() = %+v, want Capabilities().Servers %+v", got, want)
7668 }
7669 }
7670
7671 func TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {
7672 isolateDesktopUserDirs(t)
7673 dir := robustTempDir(t)
7674 t.Chdir(dir)
7675 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
7676 [[plugins]]
7677 name = "time"
7678 command = "custom-time"
7679 args = ["serve"]
7680 tier = "lazy"
7681 `), 0o644); err != nil {
7682 t.Fatal(err)
7683 }
7684
7685 app := NewApp()
7686 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
7687 defer app.activeCtrl().Close()
7688
7689 view := app.Capabilities()
7690 found := false
7691 for _, s := range view.Servers {
7692 if s.Name != "time" {
7693 continue
7694 }
7695 found = true
7696 if s.BuiltIn || !s.Configured || s.Command != "custom-time" || !reflect.DeepEqual(s.Args, []string{"serve"}) {
7697 t.Fatalf("configured time view = %+v, want ordinary user MCP config", s)
7698 }
7699 }
7700 if !found {
7701 t.Fatalf("configured time server missing from Capabilities: %+v", view.Servers)
7702 }
7703
7704 if err := app.SetMCPServerEnabled("time", false); err != nil {
7705 t.Fatalf("SetMCPServerEnabled(time,false): %v", err)
7706 }
7707 view = app.Capabilities()
7708 for _, s := range view.Servers {
7709 if s.Name == "time" {
7710 if s.Status != "disabled" || s.BuiltIn || s.Command != "custom-time" {
7711 t.Fatalf("disabled configured time view = %+v, want disabled external config", s)
7712 }
7713 return
7714 }
7715 }
7716 t.Fatalf("time missing after disable: %+v", view.Servers)
7717 }
7718
7719 func TestSetMCPServerEnabledRestoresOnDemandWithoutConnecting(t *testing.T) {
7720 isolateDesktopUserDirs(t)
7721 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
7722 dir := robustTempDir(t)
7723 t.Chdir(dir)
7724 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
7725 [[plugins]]
7726 name = "offline"
7727 type = "http"
7728 url = "http://127.0.0.1:1/mcp"
7729 `), 0o644); err != nil {
7730 t.Fatal(err)
7731 }
7732
7733 host := plugin.NewHost()
7734 defer host.Close()
7735 reg := tool.NewRegistry()
7736 ctrl := control.New(control.Options{Host: host, Registry: reg, PluginCtx: context.Background(), WorkspaceRoot: dir})
7737 app := NewApp()
7738 app.setTestCtrl(ctrl, "")
7739 app.tabs["test"].WorkspaceRoot = dir
7740
7741 if err := app.SetMCPServerEnabled("offline", false); err != nil {
7742 t.Fatalf("SetMCPServerEnabled(false): %v", err)
7743 }
7744 if err := app.SetMCPServerEnabled("offline", true); err != nil {
7745 t.Fatalf("SetMCPServerEnabled(true) forced an unavailable connection: %v", err)
7746 }
7747 if host.HasClient("offline") {
7748 t.Fatal("durable enable started the disconnected MCP server")
7749 }
7750 if _, ok := reg.Get("mcp__offline__connect"); !ok {
7751 t.Fatalf("on-demand connect stub missing after enable; names=%v", reg.Names())
7752 }
7753 }
7754
7755 func TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {
7756 isolateDesktopUserDirs(t)
7757 dir := robustTempDir(t)
7758 t.Chdir(dir)
7759
7760 srv := desktopMCPHTTPServer(t)
7761 defer srv.Close()
7762 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(`
7763 [[plugins]]
7764 name = "h"
7765 type = "http"
7766 url = %q
7767 `, srv.URL)), 0o644); err != nil {
7768 t.Fatal(err)
7769 }
7770
7771 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
7772 defer cancel()
7773 sharedHost := plugin.NewHost()
7774 defer sharedHost.Close()
7775 tools, err := sharedHost.Add(ctx, plugin.Spec{Name: "h", Type: "http", URL: srv.URL})
7776 if err != nil {
7777 t.Fatalf("sharedHost.Add: %v", err)
7778 }
7779
7780 activeRegistry := tool.NewRegistry()
7781 siblingRegistry := tool.NewRegistry()
7782 for _, mt := range tools {
7783 activeRegistry.Add(mt)
7784 siblingRegistry.Add(mt)
7785 }
7786 activeCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})
7787 siblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})
7788 app := NewApp()
7789 app.tabs = map[string]*WorkspaceTab{
7790 "active": {
7791 ID: "active",
7792 Scope: "global",
7793 WorkspaceRoot: dir,
7794 Ready: true,
7795 Ctrl: activeCtrl,
7796 SharedHostKey: dir,
7797 disabledMCP: map[string]ServerView{},
7798 },
7799 "sibling": {
7800 ID: "sibling",
7801 Scope: "global",
7802 WorkspaceRoot: dir,
7803 Ready: true,
7804 Ctrl: siblingCtrl,
7805 SharedHostKey: dir,
7806 disabledMCP: map[string]ServerView{},
7807 },
7808 }
7809 app.activeTabID = "active"
7810
7811 if err := app.SetMCPServerEnabled("h", false); err != nil {
7812 t.Fatalf("SetMCPServerEnabled(h,false): %v", err)
7813 }
7814 if _, found := activeRegistry.Get("mcp__h__greet"); found {
7815 t.Fatal("active tab still has h tools after disabling the shared server")
7816 }
7817 if _, found := siblingRegistry.Get("mcp__h__greet"); !found {
7818 t.Fatal("sibling tab lost h tools when active tab disabled the shared server")
7819 }
7820 if !sharedHost.HasClient("h") {
7821 t.Fatal("shared host client was removed by a per-tab disable")
7822 }
7823 view := app.Capabilities()
7824 if len(view.Servers) != 1 || view.Servers[0].Name != "h" || view.Servers[0].Status != "disabled" {
7825 t.Fatalf("Capabilities after disable = %+v, want h disabled for the active tab", view.Servers)
7826 }
7827
7828 if err := app.SetMCPServerEnabled("h", true); err != nil {
7829 t.Fatalf("SetMCPServerEnabled(h,true): %v", err)
7830 }
7831 if _, found := activeRegistry.Get("mcp__h__greet"); !found {
7832 t.Fatal("active tab did not re-register h tools from the existing shared client")
7833 }
7834 view = app.Capabilities()
7835 if len(view.Servers) != 1 || view.Servers[0].Name != "h" || view.Servers[0].Status != "connected" {
7836 t.Fatalf("Capabilities after re-enable = %+v, want h connected for the active tab", view.Servers)
7837 }
7838 }
7839
7840 func TestAuthorizeAndConnectMCPServerStartsProjectOnlyOnce(t *testing.T) {
7841 gateAddr, attempts := newDesktopMCPStartGate(t, func(_ int, conn net.Conn) {
7842 _, _ = conn.Write([]byte{1})
7843 })
7844 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
7845 waitForDesktopMCPStartAttempt(t, attempts, 1)
7846 oldSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7847 if !found {
7848 t.Fatal("sibling registry missing initial h tool")
7849 }
7850
7851 if err := fixture.app.AuthorizeAndConnectMCPServer("h"); err != nil {
7852 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
7853 }
7854 waitForDesktopMCPStartAttempt(t, attempts, 2)
7855 select {
7856 case attempt := <-attempts:
7857 t.Fatalf("project authorization started a temporary connection process (unexpected attempt %d)", attempt)
7858 case <-time.After(250 * time.Millisecond):
7859 }
7860 if !fixture.sharedHost.HasClient("h") {
7861 t.Fatal("project authorization did not leave h connected")
7862 }
7863 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7864 t.Fatal("active registry was not refreshed after project authorization")
7865 }
7866 newSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7867 if !found || newSiblingTool == oldSiblingTool {
7868 t.Fatal("sibling registry did not receive the single new project connection")
7869 }
7870 if _, found := fixture.disabledRegistry.Get("mcp__h__greet"); found {
7871 t.Fatal("project authorization re-enabled h in a disabled sibling tab")
7872 }
7873 }
7874
7875 func TestReconnectMCPServerRefreshesEverySharedHostRegistry(t *testing.T) {
7876 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7877 oldSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7878 if !found {
7879 t.Fatal("sibling registry missing initial h tool")
7880 }
7881 if err := fixture.app.ReconnectMCPServer("h"); err != nil {
7882 t.Fatalf("ReconnectMCPServer(h): %v", err)
7883 }
7884 if !fixture.sharedHost.HasClient("h") {
7885 t.Fatal("shared host did not reconnect h")
7886 }
7887 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7888 t.Fatal("active registry was not refreshed")
7889 }
7890 newSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7891 if !found || newSiblingTool == oldSiblingTool {
7892 t.Fatal("sibling registry retained the tool backed by the disconnected client")
7893 }
7894 if _, found := fixture.disabledRegistry.Get("mcp__h__greet"); found {
7895 t.Fatal("reconnect re-enabled a tab where the server was disabled")
7896 }
7897 }
7898
7899 func TestReconnectMCPServerUsesEffectiveProjectConfigWhenUserNameIsShadowed(t *testing.T) {
7900 isolateDesktopUserDirs(t)
7901 dir := robustTempDir(t)
7902 userServer := desktopMCPHTTPServerWithTool(t, "user-shadow", "user_tool")
7903 defer userServer.Close()
7904 projectServer := desktopMCPHTTPServerWithTool(t, "project-effective", "project_tool")
7905 defer projectServer.Close()
7906
7907 userCfg := config.LoadForEdit(config.UserConfigPath())
7908 userCfg.Plugins = []config.PluginEntry{{Name: "h", Type: "http", URL: userServer.URL}}
7909 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
7910 t.Fatal(err)
7911 }
7912 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(`
7913 [[plugins]]
7914 name = "h"
7915 type = "http"
7916 url = %q
7917 `, projectServer.URL)), 0o644); err != nil {
7918 t.Fatal(err)
7919 }
7920
7921 host := plugin.NewHost()
7922 t.Cleanup(host.Close)
7923 registry := tool.NewRegistry()
7924 ctrl := control.New(control.Options{
7925 Host: host, Registry: registry, PluginCtx: context.Background(), WorkspaceRoot: dir,
7926 MCPConfigureSpec: func(spec *plugin.Spec) {
7927 // This test isolates effective-source selection from the project launch
7928 // approval flow, which has its own end-to-end coverage.
7929 spec.RequireLaunchApproval = false
7930 spec.Authorized = true
7931 },
7932 })
7933 app := NewApp()
7934 app.tabs = map[string]*WorkspaceTab{
7935 "active": {
7936 ID: "active", Scope: "global", WorkspaceRoot: dir, Ready: true,
7937 Ctrl: ctrl, disabledMCP: map[string]ServerView{},
7938 },
7939 }
7940 app.activeTabID = "active"
7941
7942 if err := app.ReconnectMCPServer("h"); err != nil {
7943 t.Fatalf("ReconnectMCPServer(h): %v", err)
7944 }
7945 if _, found := registry.Get("mcp__h__project_tool"); !found {
7946 t.Fatal("reconnect did not use the effective project MCP configuration")
7947 }
7948 if _, found := registry.Get("mcp__h__user_tool"); found {
7949 t.Fatal("reconnect used the shadowed user MCP configuration")
7950 }
7951 }
7952
7953 func TestUpdateMCPServerRefreshesEverySharedHostRegistry(t *testing.T) {
7954 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7955 oldSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7956 if !found {
7957 t.Fatal("sibling registry missing initial h tool")
7958 }
7959 root := fixture.app.tabs["active"].WorkspaceRoot
7960 cfg, err := config.LoadForRoot(root)
7961 if err != nil {
7962 t.Fatal(err)
7963 }
7964 entry, found := findPluginEntry(cfg.Plugins, "h")
7965 if !found {
7966 t.Fatal("fixture config missing h")
7967 }
7968 if err := fixture.app.UpdateMCPServer("h", MCPServerInput{
7969 Name: "h", Transport: entry.Type, Command: entry.Command, Args: entry.Args,
7970 }); err != nil {
7971 t.Fatalf("UpdateMCPServer(h): %v", err)
7972 }
7973 if !fixture.sharedHost.HasClient("h") {
7974 t.Fatal("shared host did not reconnect h")
7975 }
7976 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
7977 t.Fatal("active registry was not refreshed")
7978 }
7979 newSiblingTool, found := fixture.siblingRegistry.Get("mcp__h__greet")
7980 if !found || newSiblingTool == oldSiblingTool {
7981 t.Fatal("sibling registry retained the tool backed by the disconnected client")
7982 }
7983 if _, found := fixture.disabledRegistry.Get("mcp__h__greet"); found {
7984 t.Fatal("update re-enabled a tab where the server was disabled")
7985 }
7986 }
7987
7988 func TestClearMCPServerAuthenticationClearsEverySharedHostRegistry(t *testing.T) {
7989 fixture := newGatedDesktopMCPLaunchFixture(t, "")
7990 if err := fixture.app.ClearMCPServerAuthentication("h"); err != nil {
7991 t.Fatalf("ClearMCPServerAuthentication(h): %v", err)
7992 }
7993 if fixture.sharedHost.HasClient("h") {
7994 t.Fatal("shared host retained h after clearing authentication")
7995 }
7996 for label, registry := range map[string]*tool.Registry{
7997 "active": fixture.activeRegistry, "sibling": fixture.siblingRegistry, "disabled": fixture.disabledRegistry,
7998 } {
7999 if _, found := registry.Get("mcp__h__greet"); found {
8000 t.Fatalf("%s registry retained h after clearing authentication", label)
8001 }
8002 }
8003 }
8004
8005 func TestRemoveMCPServerClearsEverySharedHostRegistry(t *testing.T) {
8006 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8007 if err := fixture.app.RemoveMCPServer("h"); err != nil {
8008 t.Fatalf("RemoveMCPServer(h): %v", err)
8009 }
8010 if fixture.sharedHost.HasClient("h") {
8011 t.Fatal("shared host retained the removed server")
8012 }
8013 for label, registry := range map[string]*tool.Registry{
8014 "active": fixture.activeRegistry, "sibling": fixture.siblingRegistry, "disabled": fixture.disabledRegistry,
8015 } {
8016 if _, found := registry.Get("mcp__h__greet"); found {
8017 t.Fatalf("%s registry retained the removed server tool", label)
8018 }
8019 }
8020 for id, tab := range fixture.app.tabs {
8021 if _, disabled := tab.disabledMCP["h"]; disabled {
8022 t.Fatalf("tab %s retained removed-server disabled state", id)
8023 }
8024 }
8025 }
8026
8027 type gatedDesktopMCPLaunchFixture struct {
8028 app *App
8029 sharedHost *plugin.Host
8030 activeRegistry *tool.Registry
8031 siblingRegistry *tool.Registry
8032 disabledRegistry *tool.Registry
8033 }
8034
8035 func newGatedDesktopMCPLaunchFixture(t *testing.T, startGateAddr string) gatedDesktopMCPLaunchFixture {
8036 t.Helper()
8037 isolateDesktopUserDirs(t)
8038 dir := robustTempDir(t)
8039 t.Chdir(dir)
8040
8041 exe, err := os.Executable()
8042 if err != nil {
8043 t.Fatal(err)
8044 }
8045 listener, err := net.Listen("tcp", "127.0.0.1:0")
8046 if err != nil {
8047 t.Fatal(err)
8048 }
8049 singleInstanceAddr := listener.Addr().String()
8050 if err := listener.Close(); err != nil {
8051 t.Fatal(err)
8052 }
8053 gateConfig := ""
8054 if startGateAddr != "" {
8055 gateConfig = fmt.Sprintf("DESKTOP_MCP_START_GATE_ADDR = %q\n", startGateAddr)
8056 }
8057 helperArgs := []string{"-test.run=TestDesktopMCPHelperProcess", "--"}
8058 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(`
8059 [[plugins]]
8060 name = "h"
8061 command = %q
8062 args = ["-test.run=TestDesktopMCPHelperProcess", "--"]
8063
8064 [plugins.env]
8065 GO_WANT_DESKTOP_MCP_HELPER = "1"
8066 DESKTOP_MCP_SINGLE_INSTANCE_ADDR = %q
8067 %s
8068 [sandbox]
8069 network = true
8070 `, exe, singleInstanceAddr, gateConfig)), 0o644); err != nil {
8071 t.Fatal(err)
8072 }
8073
8074 entry := config.PluginEntry{
8075 Name: "h", Command: exe, Args: helperArgs,
8076 Env: map[string]string{
8077 "GO_WANT_DESKTOP_MCP_HELPER": "1",
8078 "DESKTOP_MCP_SINGLE_INSTANCE_ADDR": singleInstanceAddr,
8079 },
8080 }
8081 if startGateAddr != "" {
8082 entry.Env["DESKTOP_MCP_START_GATE_ADDR"] = startGateAddr
8083 }
8084 cfg, err := config.LoadForRoot(dir)
8085 if err != nil {
8086 t.Fatal(err)
8087 }
8088 runtimeSpecs := boot.PluginSpecsForRootWithOptions([]config.PluginEntry{entry}, dir, boot.PluginSpecOptions{
8089 DefaultCallTimeout: time.Duration(cfg.MCPCallTimeoutSeconds()) * time.Second,
8090 LaunchManager: mcplaunch.ForWorkspace(config.ReasonixHomeDir(), dir),
8091 ConfigSource: "workspace_config",
8092 StateHome: config.ReasonixHomeDir(),
8093 WriterRoots: cfg.WriteRootsForRoot(dir),
8094 ForbidReadRoots: cfg.ForbidReadRootsForRoot(dir),
8095 Network: cfg.Sandbox.Network,
8096 })
8097 if len(runtimeSpecs) != 1 {
8098 t.Fatalf("runtime specs = %d, want 1", len(runtimeSpecs))
8099 }
8100 runtimeSpec := runtimeSpecs[0]
8101 configure := func(spec *plugin.Spec) { *spec = runtimeSpec }
8102 lifeCtx, lifeCancel := context.WithCancel(context.Background())
8103 t.Cleanup(lifeCancel)
8104 callCtx, callCancel := context.WithTimeout(context.Background(), 10*time.Second)
8105 defer callCancel()
8106 sharedHost := plugin.NewHost()
8107 t.Cleanup(sharedHost.Close)
8108 tools, err := sharedHost.AddWithLifecycle(lifeCtx, callCtx, runtimeSpec)
8109 if err != nil {
8110 t.Fatalf("sharedHost.Add: %v", err)
8111 }
8112
8113 activeRegistry := tool.NewRegistry()
8114 siblingRegistry := tool.NewRegistry()
8115 disabledRegistry := tool.NewRegistry()
8116 for _, mt := range tools {
8117 activeRegistry.Add(mt)
8118 siblingRegistry.Add(mt)
8119 disabledRegistry.Add(mt)
8120 }
8121 activeCtrl := control.New(control.Options{
8122 Host: sharedHost, Registry: activeRegistry, PluginCtx: lifeCtx,
8123 MCPConfigureSpec: configure, WorkspaceRoot: dir,
8124 })
8125 siblingCtrl := control.New(control.Options{
8126 Host: sharedHost, Registry: siblingRegistry, PluginCtx: lifeCtx,
8127 MCPConfigureSpec: configure, WorkspaceRoot: dir,
8128 })
8129 disabledCtrl := control.New(control.Options{
8130 Host: sharedHost, Registry: disabledRegistry, PluginCtx: lifeCtx,
8131 MCPConfigureSpec: configure, WorkspaceRoot: dir,
8132 })
8133 disabledCtrl.UnregisterMCPServerTools("h")
8134 app := NewApp()
8135 app.tabs = map[string]*WorkspaceTab{
8136 "active": {
8137 ID: "active", Scope: "global", WorkspaceRoot: dir, Ready: true,
8138 Ctrl: activeCtrl, SharedHostKey: dir, disabledMCP: map[string]ServerView{},
8139 },
8140 "sibling": {
8141 ID: "sibling", Scope: "global", WorkspaceRoot: dir, Ready: true,
8142 Ctrl: siblingCtrl, SharedHostKey: dir, disabledMCP: map[string]ServerView{},
8143 },
8144 "disabled": {
8145 ID: "disabled", Scope: "global", WorkspaceRoot: dir, Ready: true,
8146 Ctrl: disabledCtrl, SharedHostKey: dir,
8147 disabledMCP: map[string]ServerView{"h": {Name: "h", Status: "disabled"}},
8148 },
8149 }
8150 app.activeTabID = "active"
8151 return gatedDesktopMCPLaunchFixture{
8152 app: app, sharedHost: sharedHost,
8153 activeRegistry: activeRegistry, siblingRegistry: siblingRegistry, disabledRegistry: disabledRegistry,
8154 }
8155 }
8156
8157 func newDesktopMCPStartGate(t *testing.T, handle func(attempt int, conn net.Conn)) (string, <-chan int) {
8158 t.Helper()
8159 listener, err := net.Listen("tcp", "127.0.0.1:0")
8160 if err != nil {
8161 t.Fatal(err)
8162 }
8163 t.Cleanup(func() { _ = listener.Close() })
8164 attempts := make(chan int, 8)
8165 go func() {
8166 for attempt := 1; ; attempt++ {
8167 conn, err := listener.Accept()
8168 if err != nil {
8169 return
8170 }
8171 attempts <- attempt
8172 handle(attempt, conn)
8173 _ = conn.Close()
8174 }
8175 }()
8176 return listener.Addr().String(), attempts
8177 }
8178
8179 func waitForDesktopMCPStartAttempt(t *testing.T, attempts <-chan int, want int) {
8180 t.Helper()
8181 deadline := time.NewTimer(5 * time.Second)
8182 defer deadline.Stop()
8183 for {
8184 select {
8185 case got := <-attempts:
8186 if got == want {
8187 return
8188 }
8189 case <-deadline.C:
8190 t.Fatalf("timed out waiting for MCP start attempt %d", want)
8191 }
8192 }
8193 }
8194
8195 func TestAuthorizeAndConnectMCPServerSerializesConcurrentDisable(t *testing.T) {
8196 releaseConnection := make(chan struct{})
8197 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
8198 if attempt == 2 {
8199 <-releaseConnection
8200 }
8201 _, _ = conn.Write([]byte{1})
8202 })
8203 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
8204
8205 disableEntered := make(chan struct{})
8206 var disableOnce sync.Once
8207 fixture.app.runtimeMutationBeforeLockHook = func(operation string) {
8208 if operation == "set-enabled" {
8209 disableOnce.Do(func() { close(disableEntered) })
8210 }
8211 }
8212 authorizeDone := make(chan error, 1)
8213 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8214 waitForDesktopMCPStartAttempt(t, attempts, 2)
8215 disableDone := make(chan error, 1)
8216 go func() { disableDone <- fixture.app.SetMCPServerEnabled("h", false) }()
8217 select {
8218 case <-disableEntered:
8219 case <-time.After(5 * time.Second):
8220 t.Fatal("concurrent disable did not reach the MCP lifecycle lock")
8221 }
8222 select {
8223 case err := <-disableDone:
8224 t.Fatalf("concurrent disable bypassed authorization serialization: %v", err)
8225 case <-time.After(200 * time.Millisecond):
8226 }
8227 close(releaseConnection)
8228 if err := <-authorizeDone; err != nil {
8229 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
8230 }
8231 if err := <-disableDone; err != nil {
8232 t.Fatalf("SetMCPServerEnabled(h,false): %v", err)
8233 }
8234 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); found {
8235 t.Fatal("authorization reconnect overrode the later per-tab disable")
8236 }
8237 if _, disabled := fixture.app.tabs["active"].disabledMCP["h"]; !disabled {
8238 t.Fatal("active tab did not retain the later disable decision")
8239 }
8240 if _, found := fixture.siblingRegistry.Get("mcp__h__greet"); !found {
8241 t.Fatal("active-tab disable removed the shared MCP from its enabled sibling")
8242 }
8243 }
8244
8245 // RemovePlugin disconnects the uninstalled plugin's MCP servers, so it must
8246 // serialize on the MCP lifecycle lock: an unlocked disconnect interleaving
8247 // with authorization lets the reconnect relaunch the just-removed
8248 // server from its stale snapshot. The plugin does not need to exist — the
8249 // lock is taken before the uninstall runs, which is the contract under test.
8250 func TestRemovePluginSerializesWithMCPAuthorization(t *testing.T) {
8251 releaseConnection := make(chan struct{})
8252 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
8253 if attempt == 2 {
8254 <-releaseConnection
8255 }
8256 _, _ = conn.Write([]byte{1})
8257 })
8258 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
8259
8260 removeEntered := make(chan struct{})
8261 var removeOnce sync.Once
8262 fixture.app.runtimeMutationBeforeLockHook = func(operation string) {
8263 if operation == "remove-plugin" {
8264 removeOnce.Do(func() { close(removeEntered) })
8265 }
8266 }
8267 authorizeDone := make(chan error, 1)
8268 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8269 waitForDesktopMCPStartAttempt(t, attempts, 2)
8270 removeDone := make(chan error, 1)
8271 go func() { removeDone <- fixture.app.RemovePlugin("not-an-installed-plugin") }()
8272 select {
8273 case <-removeEntered:
8274 case <-time.After(5 * time.Second):
8275 t.Fatal("RemovePlugin did not reach the MCP lifecycle lock")
8276 }
8277 select {
8278 case err := <-removeDone:
8279 t.Fatalf("RemovePlugin bypassed authorization serialization: %v", err)
8280 case <-time.After(200 * time.Millisecond):
8281 }
8282 close(releaseConnection)
8283 if err := <-authorizeDone; err != nil {
8284 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
8285 }
8286 // The uninstall itself is expected to fail (the plugin is not installed);
8287 // only the ordering matters. It must complete once the lock is free.
8288 select {
8289 case <-removeDone:
8290 case <-time.After(5 * time.Second):
8291 t.Fatal("RemovePlugin did not complete after the trust connection released the lock")
8292 }
8293 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
8294 t.Fatal("authorization reconnect result was lost after the serialized RemovePlugin")
8295 }
8296 }
8297
8298 // installGatedTestPluginPackage registers an installed plugin package whose
8299 // manifest declares the gated fixture's MCP server, so RemovePlugin exercises
8300 // the real uninstall and MCP disconnect flow. Returns the plugin root.
8301 func installGatedTestPluginPackage(t *testing.T, mcpServerName string) string {
8302 t.Helper()
8303 reasonixHome := config.ReasonixHomeDir()
8304 root := filepath.Join(reasonixHome, "plugins", "review-helper")
8305 if err := os.MkdirAll(root, 0o755); err != nil {
8306 t.Fatal(err)
8307 }
8308 if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(fmt.Sprintf(`{
8309 "name": "review-helper",
8310 "version": "1.0.0",
8311 "mcpServers": {
8312 %q: { "type": "stdio", "command": "helper" }
8313 }
8314 }`, mcpServerName)), 0o644); err != nil {
8315 t.Fatal(err)
8316 }
8317 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
8318 Name: "review-helper",
8319 Root: "plugins/review-helper",
8320 Version: "1.0.0",
8321 ManifestKind: "reasonix",
8322 Enabled: true,
8323 }); err != nil {
8324 t.Fatal(err)
8325 }
8326 return root
8327 }
8328
8329 func installedPluginNamed(t *testing.T, name string) bool {
8330 t.Helper()
8331 st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
8332 if err != nil {
8333 t.Fatal(err)
8334 }
8335 for _, p := range st.Plugins {
8336 if p.Name == name {
8337 return true
8338 }
8339 }
8340 return false
8341 }
8342
8343 // A global plugin uninstall must clean every runtime, not only the active tab:
8344 // sibling registries on the shared Host would otherwise keep provider-visible
8345 // tools backed by the closed client, and other workspaces would keep running
8346 // the uninstalled server.
8347 func TestRemovePluginDisconnectsEveryRuntime(t *testing.T) {
8348 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8349 pluginRoot := installGatedTestPluginPackage(t, "h")
8350
8351 if err := fixture.app.RemovePlugin("review-helper"); err != nil {
8352 t.Fatalf("RemovePlugin(review-helper): %v", err)
8353 }
8354 if fixture.sharedHost.HasClient("h") {
8355 t.Fatal("uninstall left the shared MCP client connected")
8356 }
8357 for name, reg := range map[string]*tool.Registry{
8358 "active": fixture.activeRegistry,
8359 "sibling": fixture.siblingRegistry,
8360 "disabled": fixture.disabledRegistry,
8361 } {
8362 if _, found := reg.Get("mcp__h__greet"); found {
8363 t.Fatalf("%s registry still exposes the uninstalled MCP tool", name)
8364 }
8365 }
8366 if _, err := os.Stat(pluginRoot); !os.IsNotExist(err) {
8367 t.Fatalf("plugin root still present after uninstall (err=%v)", err)
8368 }
8369 if installedPluginNamed(t, "review-helper") {
8370 t.Fatal("plugin state still lists the uninstalled plugin")
8371 }
8372 }
8373
8374 // The pre-lock active-work check can go stale during the lifecycle-lock wait.
8375 // Work that starts mid-wait must fail the removal before anything is deleted;
8376 // the old order deleted the plugin first and only then reported the failure.
8377 func TestRemovePluginRechecksActiveWorkUnderLock(t *testing.T) {
8378 releaseConnection := make(chan struct{})
8379 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
8380 if attempt == 2 {
8381 <-releaseConnection
8382 }
8383 _, _ = conn.Write([]byte{1})
8384 })
8385 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
8386 installGatedTestPluginPackage(t, "h")
8387
8388 removeEntered := make(chan struct{})
8389 var removeOnce sync.Once
8390 fixture.app.runtimeMutationBeforeLockHook = func(operation string) {
8391 if operation == "remove-plugin" {
8392 removeOnce.Do(func() { close(removeEntered) })
8393 }
8394 }
8395 authorizeDone := make(chan error, 1)
8396 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8397 waitForDesktopMCPStartAttempt(t, attempts, 2)
8398 removeDone := make(chan error, 1)
8399 go func() { removeDone <- fixture.app.RemovePlugin("review-helper") }()
8400 select {
8401 case <-removeEntered:
8402 case <-time.After(5 * time.Second):
8403 t.Fatal("RemovePlugin did not reach the MCP lifecycle lock")
8404 }
8405 // While RemovePlugin waits for the lock, background work starts on the
8406 // active tab — exactly the window the pre-lock check cannot see.
8407 busy := newBackgroundJobController(t, "remove-plugin-active-work")
8408 fixture.app.mu.Lock()
8409 fixture.app.tabs["active"].Ctrl = busy
8410 fixture.app.mu.Unlock()
8411 close(releaseConnection)
8412 if err := <-authorizeDone; err != nil {
8413 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
8414 }
8415 err := <-removeDone
8416 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
8417 t.Fatalf("RemovePlugin during background work error = %v, want active-work guard", err)
8418 }
8419 if !installedPluginNamed(t, "review-helper") {
8420 t.Fatal("active-work guard fired only after the plugin was already uninstalled")
8421 }
8422 }
8423
8424 // A global uninstall disconnects every runtime, so the busy guard must cover
8425 // every runtime too: a background job on a sibling tab must fail the removal
8426 // before anything is deleted, not silently lose its plugin MCP mid-run.
8427 func TestRemovePluginRejectsBusySiblingRuntime(t *testing.T) {
8428 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8429 installGatedTestPluginPackage(t, "h")
8430 busy := newBackgroundJobController(t, "sibling-busy")
8431 fixture.app.mu.Lock()
8432 fixture.app.tabs["sibling"].Ctrl = busy
8433 fixture.app.mu.Unlock()
8434
8435 err := fixture.app.RemovePlugin("review-helper")
8436 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
8437 t.Fatalf("RemovePlugin with busy sibling error = %v, want active-work guard", err)
8438 }
8439 if !installedPluginNamed(t, "review-helper") {
8440 t.Fatal("plugin was uninstalled despite a busy sibling runtime")
8441 }
8442 if !fixture.sharedHost.HasClient("h") {
8443 t.Fatal("busy-sibling guard still disconnected the shared MCP client")
8444 }
8445 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
8446 t.Fatal("busy-sibling guard still removed active registry tools")
8447 }
8448 }
8449
8450 // Detached runtimes keep running after their tab is closed; the uninstall busy
8451 // guard must see them through the same gate sweep as visible tabs.
8452 func TestRemovePluginRejectsBusyDetachedRuntime(t *testing.T) {
8453 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8454 installGatedTestPluginPackage(t, "h")
8455 busy := newBackgroundJobController(t, "detached-busy")
8456 fixture.app.mu.Lock()
8457 fixture.app.detachedSessions = map[string]*WorkspaceTab{
8458 "detached": {
8459 ID: "detached", Scope: "global", Ready: true,
8460 Ctrl: busy, disabledMCP: map[string]ServerView{},
8461 },
8462 }
8463 fixture.app.mu.Unlock()
8464
8465 err := fixture.app.RemovePlugin("review-helper")
8466 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
8467 t.Fatalf("RemovePlugin with busy detached runtime error = %v, want active-work guard", err)
8468 }
8469 if !installedPluginNamed(t, "review-helper") {
8470 t.Fatal("plugin was uninstalled despite a busy detached runtime")
8471 }
8472 }
8473
8474 // A turn start holds its tab's turn gate before the controller reports active
8475 // work, so an idle check done without the gate can go stale immediately. The
8476 // authorization must wait on the sibling's gate — never disconnect first — and must
8477 // fail once the gated re-check sees the started work.
8478 func TestAuthorizeAndConnectMCPServerWaitsForSiblingTurnGate(t *testing.T) {
8479 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
8480 _, _ = conn.Write([]byte{1})
8481 })
8482 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
8483 waitForDesktopMCPStartAttempt(t, attempts, 1) // drain the fixture's initial connect
8484 sibling := fixture.app.tabs["sibling"]
8485
8486 // Simulate the racing turn: it takes the gate first, and only transitions
8487 // its controller to busy while holding it.
8488 sibling.turnStartMu.Lock()
8489 authorizeDone := make(chan error, 1)
8490 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8491 select {
8492 case got := <-attempts:
8493 t.Fatalf("trust connection launched (attempt %d) while a sibling turn gate was held", got)
8494 case err := <-authorizeDone:
8495 t.Fatalf("AuthorizeAndConnectMCPServer returned %v without waiting for the sibling turn gate", err)
8496 case <-time.After(700 * time.Millisecond):
8497 }
8498 if !fixture.sharedHost.HasClient("h") {
8499 t.Fatal("authorization disconnected the shared client while a sibling turn gate was held")
8500 }
8501 busy := newBackgroundJobController(t, "sibling-turn")
8502 fixture.app.mu.Lock()
8503 sibling.Ctrl = busy
8504 fixture.app.mu.Unlock()
8505 sibling.turnStartMu.Unlock()
8506
8507 err := <-authorizeDone
8508 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
8509 t.Fatalf("AuthorizeAndConnectMCPServer after sibling turn start error = %v, want active-work guard", err)
8510 }
8511 if !fixture.sharedHost.HasClient("h") {
8512 t.Fatal("failed authorization left the shared client disconnected")
8513 }
8514 if _, found := fixture.siblingRegistry.Get("mcp__h__greet"); !found {
8515 t.Fatal("authorization stripped the busy sibling of its MCP tools")
8516 }
8517 if _, found := fixture.activeRegistry.Get("mcp__h__greet"); !found {
8518 t.Fatal("authorization stripped the active tab of its MCP tools")
8519 }
8520 }
8521
8522 // waitForRuntimeAdmissionBarrier polls until the work-admission write lock is
8523 // held, marking the point where a lifecycle mutation froze new admissions.
8524 func waitForRuntimeAdmissionBarrier(t *testing.T, app *App) {
8525 t.Helper()
8526 deadline := time.Now().Add(5 * time.Second)
8527 for time.Now().Before(deadline) {
8528 if app.runtimeAdmissionMu.TryRLock() {
8529 app.runtimeAdmissionMu.RUnlock()
8530 time.Sleep(2 * time.Millisecond)
8531 continue
8532 }
8533 return
8534 }
8535 t.Fatal("lifecycle mutation never acquired the work-admission barrier")
8536 }
8537
8538 func TestBridgeDriveReleasesRuntimeAdmissionWhenTakeoverWasReclaimed(t *testing.T) {
8539 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8540 fixture.app.tabs["active"].sink = &tabEventSink{tabID: "active", app: fixture.app}
8541 fixture.app.botBridge = &botBridgeHub{
8542 takeovers: make(map[string]bot.DesktopWatchRoute),
8543 takeoverTabs: make(map[string]string),
8544 }
8545
8546 err := fixture.app.bridgeDrive("active", "hello", bot.DesktopWatchRoute{})
8547 if err == nil || !strings.Contains(err.Error(), "接管已解除") {
8548 t.Fatalf("bridgeDrive error = %v, want lost-takeover error", err)
8549 }
8550 if !fixture.app.runtimeAdmissionMu.TryLock() {
8551 t.Fatal("bridgeDrive leaked the runtime-admission read lock")
8552 }
8553 fixture.app.runtimeAdmissionMu.Unlock()
8554 }
8555
8556 func TestBeginTabTurnWorkspaceRepairDoesNotRecursivelyLockAdmission(t *testing.T) {
8557 fixture := newStaleWorkspaceBindingFixture(t, "admission_writer")
8558 fixture.tab.reconcileMu.Lock()
8559
8560 turnDone := make(chan error, 1)
8561 go func() {
8562 admission, _, err := fixture.app.beginTabTurn(fixture.tab.ID, false)
8563 if admission != nil {
8564 admission.abort()
8565 }
8566 turnDone <- err
8567 }()
8568 deadline := time.Now().Add(5 * time.Second)
8569 for fixture.app.runtimeAdmissionMu.TryLock() {
8570 fixture.app.runtimeAdmissionMu.Unlock()
8571 if time.Now().After(deadline) {
8572 fixture.tab.reconcileMu.Unlock()
8573 t.Fatal("beginTabTurn never acquired the admission read lock")
8574 }
8575 time.Sleep(time.Millisecond)
8576 }
8577
8578 writerRebuildLocked := make(chan struct{})
8579 writerAdmissionLocked := make(chan struct{})
8580 writerDone := make(chan struct{})
8581 go func() {
8582 fixture.app.runtimeRebuildMu.Lock()
8583 close(writerRebuildLocked)
8584 fixture.app.runtimeAdmissionMu.Lock()
8585 close(writerAdmissionLocked)
8586 fixture.app.runtimeAdmissionMu.Unlock()
8587 fixture.app.runtimeRebuildMu.Unlock()
8588 close(writerDone)
8589 }()
8590 <-writerRebuildLocked
8591 for fixture.app.runtimeAdmissionMu.TryRLock() {
8592 fixture.app.runtimeAdmissionMu.RUnlock()
8593 time.Sleep(time.Millisecond)
8594 }
8595 fixture.tab.reconcileMu.Unlock()
8596
8597 select {
8598 case err := <-turnDone:
8599 if err != nil {
8600 t.Fatalf("beginTabTurn after workspace repair: %v", err)
8601 }
8602 case <-time.After(10 * time.Second):
8603 t.Fatal("workspace repair recursively waited on runtimeAdmissionMu with a writer pending")
8604 }
8605 select {
8606 case <-writerAdmissionLocked:
8607 case <-time.After(5 * time.Second):
8608 t.Fatal("lifecycle writer never acquired runtimeAdmissionMu after repaired turn admission")
8609 }
8610 select {
8611 case <-writerDone:
8612 case <-time.After(5 * time.Second):
8613 t.Fatal("lifecycle writer did not complete after repaired turn admission")
8614 }
8615 }
8616
8617 func TestAuthorizeAndConnectMCPServerSerializesCloseOfCapturedRuntime(t *testing.T) {
8618 releaseConnection := make(chan struct{})
8619 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
8620 if attempt == 2 {
8621 <-releaseConnection
8622 }
8623 _, _ = conn.Write([]byte{1})
8624 })
8625 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
8626 waitForDesktopMCPStartAttempt(t, attempts, 1)
8627
8628 dir := fixture.app.tabs["active"].WorkspaceRoot
8629 otherRoot := robustTempDir(t)
8630 otherHost := plugin.NewHost()
8631 t.Cleanup(otherHost.Close)
8632 otherCtrl := control.New(control.Options{Host: otherHost, WorkspaceRoot: otherRoot})
8633 t.Cleanup(otherCtrl.Close)
8634 fixture.app.tabs = map[string]*WorkspaceTab{
8635 "active": fixture.app.tabs["active"],
8636 "other": {
8637 ID: "other", Scope: "project", WorkspaceRoot: otherRoot, Ready: true,
8638 Ctrl: otherCtrl, disabledMCP: map[string]ServerView{},
8639 },
8640 }
8641 fixture.app.tabOrder = []string{"active", "other"}
8642 fixture.app.activeTabID = "active"
8643 fixture.app.sharedHosts = map[string]*sharedPluginHost{
8644 dir: {host: fixture.sharedHost, refs: 1},
8645 }
8646
8647 authorizeDone := make(chan error, 1)
8648 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8649 waitForDesktopMCPStartAttempt(t, attempts, 2)
8650 closeDone := make(chan error, 1)
8651 go func() { closeDone <- fixture.app.CloseTab("active") }()
8652 select {
8653 case err := <-closeDone:
8654 t.Fatalf("CloseTab bypassed the MCP lifecycle barrier: %v", err)
8655 case <-time.After(300 * time.Millisecond):
8656 }
8657
8658 close(releaseConnection)
8659 if err := <-authorizeDone; err != nil {
8660 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
8661 }
8662 if err := <-closeDone; err != nil {
8663 t.Fatalf("CloseTab(active) after trust: %v", err)
8664 }
8665 }
8666
8667 func TestCloseTabWaitsForPendingTurnAdmission(t *testing.T) {
8668 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8669 admission, _, err := fixture.app.beginTabTurn("active", false)
8670 if err != nil {
8671 t.Fatalf("beginTabTurn(active): %v", err)
8672 }
8673 released := false
8674 defer func() {
8675 if !released {
8676 admission.abort()
8677 }
8678 }()
8679
8680 closeDone := make(chan error, 1)
8681 go func() { closeDone <- fixture.app.CloseTab("active") }()
8682 select {
8683 case err := <-closeDone:
8684 admission.abort()
8685 released = true
8686 t.Fatalf("CloseTab bypassed a pending turn admission and closed its controller: %v", err)
8687 case <-time.After(300 * time.Millisecond):
8688 }
8689
8690 admission.abort()
8691 released = true
8692 select {
8693 case err := <-closeDone:
8694 if err != nil {
8695 t.Fatalf("CloseTab(active) after turn admission release: %v", err)
8696 }
8697 case <-time.After(5 * time.Second):
8698 t.Fatal("CloseTab did not resume after the pending turn admission was released")
8699 }
8700 }
8701
8702 func TestCloseTabRemainsVisibleToPendingMCPHostGateSnapshot(t *testing.T) {
8703 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8704 active := fixture.app.tabs["active"]
8705 active.turnStartMu.Lock()
8706
8707 authorizeDone := make(chan error, 1)
8708 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8709 waitForRuntimeAdmissionBarrier(t, fixture.app)
8710
8711 closeDone := make(chan error, 1)
8712 go func() { closeDone <- fixture.app.CloseTab("active") }()
8713 time.Sleep(300 * time.Millisecond)
8714 fixture.app.mu.RLock()
8715 stillVisible := fixture.app.tabs["active"] == active
8716 fixture.app.mu.RUnlock()
8717 if !stillVisible {
8718 active.turnStartMu.Unlock()
8719 t.Fatal("CloseTab unlinked the runtime before the pending MCP Host gate snapshot")
8720 }
8721 select {
8722 case err := <-closeDone:
8723 active.turnStartMu.Unlock()
8724 t.Fatalf("CloseTab bypassed the lifecycle barrier: %v", err)
8725 default:
8726 }
8727
8728 active.turnStartMu.Unlock()
8729 if err := <-authorizeDone; err != nil {
8730 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
8731 }
8732 if err := <-closeDone; err != nil {
8733 t.Fatalf("CloseTab(active): %v", err)
8734 }
8735 }
8736
8737 func TestAuthorizeAndConnectMCPServerKeepsInvokingWorkspaceWhenActiveTabChanges(t *testing.T) {
8738 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8739 otherRoot := robustTempDir(t)
8740 otherCtrl := control.New(control.Options{Host: plugin.NewHost(), WorkspaceRoot: otherRoot})
8741 t.Cleanup(otherCtrl.Close)
8742 fixture.app.tabs["other"] = &WorkspaceTab{
8743 ID: "other", Scope: "project", WorkspaceRoot: otherRoot, Ready: true,
8744 Ctrl: otherCtrl, disabledMCP: map[string]ServerView{},
8745 }
8746 fixture.app.tabOrder = []string{"active", "sibling", "disabled", "other"}
8747
8748 sibling := fixture.app.tabs["sibling"]
8749 sibling.turnStartMu.Lock()
8750 authorizeDone := make(chan error, 1)
8751 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8752 waitForRuntimeAdmissionBarrier(t, fixture.app)
8753 if err := fixture.app.SetActiveTab("other"); err != nil {
8754 sibling.turnStartMu.Unlock()
8755 t.Fatalf("SetActiveTab(other): %v", err)
8756 }
8757 sibling.turnStartMu.Unlock()
8758
8759 if err := <-authorizeDone; err != nil {
8760 t.Fatalf("authorization operation drifted from its invoking workspace: %v", err)
8761 }
8762 }
8763
8764 // Work admission — not tab-set stability — is the gate invariant: a runtime
8765 // added after the gate snapshot must not be able to start a turn while the
8766 // uninstall holds the barrier. The late turn goes through the real
8767 // beginTabTurn admission path and must only be admitted after the uninstall.
8768 func TestRemovePluginBlocksLateTurnAdmission(t *testing.T) {
8769 fixture := newGatedDesktopMCPLaunchFixture(t, "")
8770 installGatedTestPluginPackage(t, "h")
8771 dir := fixture.app.tabs["active"].WorkspaceRoot
8772
8773 // Hold an existing tab's gate so the uninstall blocks mid-acquisition
8774 // with the admission barrier already held.
8775 sibling := fixture.app.tabs["sibling"]
8776 sibling.turnStartMu.Lock()
8777 removeDone := make(chan error, 1)
8778 go func() { removeDone <- fixture.app.RemovePlugin("review-helper") }()
8779 waitForRuntimeAdmissionBarrier(t, fixture.app)
8780
8781 lateCtrl := control.New(control.Options{Host: plugin.NewHost(), WorkspaceRoot: dir})
8782 t.Cleanup(lateCtrl.Close)
8783 fixture.app.mu.Lock()
8784 fixture.app.tabs["late"] = &WorkspaceTab{
8785 ID: "late", Scope: "global", WorkspaceRoot: dir, Ready: true,
8786 Ctrl: lateCtrl, disabledMCP: map[string]ServerView{},
8787 }
8788 fixture.app.mu.Unlock()
8789 type admission struct {
8790 turn *tabTurnAdmission
8791 ctrl control.SessionAPI
8792 err error
8793 }
8794 admitted := make(chan admission, 1)
8795 go func() {
8796 turn, ctrl, err := fixture.app.beginTabTurn("late", false)
8797 admitted <- admission{turn: turn, ctrl: ctrl, err: err}
8798 }()
8799 select {
8800 case got := <-admitted:
8801 t.Fatalf("late turn was admitted (err=%v) while the uninstall held the admission barrier", got.err)
8802 case <-time.After(300 * time.Millisecond):
8803 }
8804
8805 sibling.turnStartMu.Unlock()
8806 if err := <-removeDone; err != nil {
8807 t.Fatalf("RemovePlugin(review-helper): %v", err)
8808 }
8809 got := <-admitted
8810 if got.err != nil {
8811 t.Fatalf("late turn admission after uninstall: %v", got.err)
8812 }
8813 got.turn.abort()
8814 if installedPluginNamed(t, "review-helper") {
8815 t.Fatal("uninstall did not complete before the late turn was admitted")
8816 }
8817 }
8818
8819 // A tab created during the 30s trust connection must not complete its async
8820 // controller build — attaching to the shared Host mid-connection can relaunch a
8821 // single-instance server or leave a registry the authorization never saw. The build
8822 // goes through the real startTabControllerBuild path and must only run after
8823 // the authorization releases the barrier.
8824 func TestAuthorizeAndConnectMCPServerBlocksLateControllerBuild(t *testing.T) {
8825 releaseConnection := make(chan struct{})
8826 gateAddr, attempts := newDesktopMCPStartGate(t, func(attempt int, conn net.Conn) {
8827 if attempt == 2 {
8828 <-releaseConnection
8829 }
8830 _, _ = conn.Write([]byte{1})
8831 })
8832 fixture := newGatedDesktopMCPLaunchFixture(t, gateAddr)
8833 waitForDesktopMCPStartAttempt(t, attempts, 1) // drain the fixture's initial connect
8834 dir := fixture.app.tabs["active"].WorkspaceRoot
8835
8836 authorizeDone := make(chan error, 1)
8837 go func() { authorizeDone <- fixture.app.AuthorizeAndConnectMCPServer("h") }()
8838 waitForDesktopMCPStartAttempt(t, attempts, 2) // connection launched: gates held
8839
8840 late := &WorkspaceTab{
8841 ID: "late", Scope: "global", WorkspaceRoot: dir,
8842 disabledMCP: map[string]ServerView{},
8843 }
8844 late.sink = &tabEventSink{tabID: "late", app: fixture.app}
8845 fixture.app.mu.Lock()
8846 fixture.app.tabs["late"] = late
8847 fixture.app.mu.Unlock()
8848 buildDone := make(chan struct{})
8849 go func() {
8850 // a.ctx is nil in this fixture, so the build runs synchronously on
8851 // this goroutine — through the real buildTabControllerWithContext.
8852 fixture.app.startTabControllerBuild(late)
8853 close(buildDone)
8854 }()
8855 select {
8856 case <-buildDone:
8857 t.Fatal("late controller build completed while the trust connection held the admission barrier")
8858 case <-time.After(400 * time.Millisecond):
8859 }
8860
8861 close(releaseConnection)
8862 if err := <-authorizeDone; err != nil {
8863 t.Fatalf("AuthorizeAndConnectMCPServer(h): %v", err)
8864 }
8865 select {
8866 case <-buildDone:
8867 case <-time.After(10 * time.Second):
8868 t.Fatal("late controller build never ran after the authorization released the barrier")
8869 }
8870 if !fixture.sharedHost.HasClient("h") {
8871 t.Fatal("authorization did not leave the shared client reconnected")
8872 }
8873 }
8874
8875 func TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {
8876 isolateDesktopUserDirs(t)
8877
8878 app := NewApp()
8879 app.setTestCtrl(newBackgroundJobController(t, "mcp-enabled-job"), "")
8880
8881 err := app.SetMCPServerEnabled("time", false)
8882 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
8883 t.Fatalf("SetMCPServerEnabled with background job error = %v, want active-work guard", err)
8884 }
8885 if tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {
8886 t.Fatalf("disabled MCP state changed after rejected toggle: %+v", tab)
8887 }
8888 }
8889
8890 func TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {
8891 isolateDesktopUserDirs(t)
8892 dir := robustTempDir(t)
8893 t.Chdir(dir)
8894 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
8895 [[plugins]]
8896 name = "time"
8897 command = "custom-time"
8898 args = ["serve"]
8899 `), 0o644); err != nil {
8900 t.Fatal(err)
8901 }
8902
8903 app := NewApp()
8904 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
8905 defer app.activeCtrl().Close()
8906 app.activeTab().disabledMCP["time"] = ServerView{Name: "time", Status: "disabled", Enabled: false}
8907
8908 if err := app.UpdateMCPServer("time", MCPServerInput{
8909 Name: "time",
8910 Transport: "stdio",
8911 Command: "updated-time",
8912 Args: []string{"run"},
8913 }); err != nil {
8914 t.Fatalf("UpdateMCPServer(time): %v", err)
8915 }
8916 cfg, err := config.LoadForRoot(dir)
8917 if err != nil {
8918 t.Fatal(err)
8919 }
8920 updated, ok := findPluginEntry(cfg.Plugins, "time")
8921 if !ok || updated.Command != "updated-time" || !reflect.DeepEqual(updated.Args, []string{"run"}) {
8922 t.Fatalf("updated time plugin = %+v, found=%v", updated, ok)
8923 }
8924
8925 if err := app.RemoveMCPServer("time"); err != nil {
8926 t.Fatalf("RemoveMCPServer(time): %v", err)
8927 }
8928 cfg, err = config.LoadForRoot(dir)
8929 if err != nil {
8930 t.Fatal(err)
8931 }
8932 if _, ok := findPluginEntry(cfg.Plugins, "time"); ok {
8933 t.Fatalf("time plugin still configured after remove: %+v", cfg.Plugins)
8934 }
8935 }
8936
8937 func TestRemoveProjectMCPRevealsAndRegistersGlobalFallback(t *testing.T) {
8938 isolateDesktopUserDirs(t)
8939 dir := robustTempDir(t)
8940 t.Chdir(dir)
8941 userCfg := config.LoadForEdit(config.UserConfigPath())
8942 userCfg.Plugins = []config.PluginEntry{{Name: "docs", Command: "global-docs"}}
8943 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
8944 t.Fatal(err)
8945 }
8946 projectPath := filepath.Join(dir, "reasonix.toml")
8947 if err := os.WriteFile(projectPath, []byte(`
8948 [[plugins]]
8949 name = "docs"
8950 command = "project-docs"
8951 `), 0o644); err != nil {
8952 t.Fatal(err)
8953 }
8954
8955 reg := tool.NewRegistry()
8956 var configured []plugin.Spec
8957 ctrl := control.New(control.Options{
8958 Host: plugin.NewHost(),
8959 Registry: reg,
8960 WorkspaceRoot: dir,
8961 MCPConfigureSpec: func(spec *plugin.Spec) {
8962 configured = append(configured, *spec)
8963 },
8964 })
8965 defer ctrl.Close()
8966 projectEntry, found, err := desktopEffectiveMCPServer(dir, "docs")
8967 if err != nil || !found {
8968 t.Fatalf("load project docs: entry=%+v found=%v err=%v", projectEntry, found, err)
8969 }
8970 if _, err := ctrl.RegisterMCPServerOnDemand(projectEntry); err != nil {
8971 t.Fatalf("register project docs: %v", err)
8972 }
8973
8974 app := NewApp()
8975 app.setTestCtrl(ctrl, "")
8976 app.activeTab().WorkspaceRoot = dir
8977 if err := app.RemoveMCPServer("docs"); err != nil {
8978 t.Fatalf("RemoveMCPServer(docs): %v", err)
8979 }
8980
8981 projectCfg := config.LoadForEdit(projectPath)
8982 if _, found := findPluginEntry(projectCfg.Plugins, "docs"); found {
8983 t.Fatalf("project docs still configured after removal: %+v", projectCfg.Plugins)
8984 }
8985 globalCfg := config.LoadForEdit(config.UserConfigPath())
8986 globalEntry, found := findPluginEntry(globalCfg.Plugins, "docs")
8987 if !found || globalEntry.Command != "global-docs" {
8988 t.Fatalf("global docs fallback = %+v, found=%v", globalEntry, found)
8989 }
8990 effective, found, err := desktopEffectiveMCPServer(dir, "docs")
8991 if err != nil || !found || effective.Source != config.MCPSourceUserConfig || effective.Command != "global-docs" {
8992 t.Fatalf("effective docs fallback = %+v, found=%v err=%v", effective, found, err)
8993 }
8994 if len(configured) < 2 || configured[len(configured)-1].Command != "global-docs" {
8995 t.Fatalf("registered specs = %+v, want global fallback registered last", configured)
8996 }
8997 if _, found := reg.Get("mcp__docs__connect"); !found {
8998 t.Fatalf("global fallback connect surface missing; names=%v", reg.Names())
8999 }
9000 }
9001
9002 func TestRemoveMCPServerClearsRecordedStartupFailure(t *testing.T) {
9003 isolateDesktopUserDirs(t)
9004 dir := robustTempDir(t)
9005 t.Chdir(dir)
9006 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9007 [[plugins]]
9008 name = "broken"
9009 command = "reasonix-missing-mcp-binary"
9010 `), 0o644); err != nil {
9011 t.Fatal(err)
9012 }
9013
9014 app := NewApp()
9015 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9016 defer app.activeCtrl().Close()
9017 recordMCPFailure(app.activeCtrl(), config.PluginEntry{
9018 Name: "broken",
9019 Command: "reasonix-missing-mcp-binary",
9020 }, errors.New("connect: missing binary"))
9021
9022 view := app.Capabilities()
9023 if len(view.Servers) != 1 || view.Servers[0].Name != "broken" || view.Servers[0].Status != "failed" {
9024 t.Fatalf("Capabilities before remove = %+v, want broken failed", view.Servers)
9025 }
9026
9027 if err := app.RemoveMCPServer("broken"); err != nil {
9028 t.Fatalf("RemoveMCPServer(broken): %v", err)
9029 }
9030 if mcpFailed(app.activeCtrl(), "broken") {
9031 t.Fatalf("Host.Failures() still contains broken after remove: %+v", app.activeCtrl().Host().Failures())
9032 }
9033 view = app.Capabilities()
9034 for _, s := range view.Servers {
9035 if s.Name == "broken" {
9036 t.Fatalf("Capabilities after remove still contains broken: %+v", view.Servers)
9037 }
9038 }
9039 }
9040
9041 func TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {
9042 isolateDesktopUserDirs(t)
9043 dir := robustTempDir(t)
9044 t.Chdir(dir)
9045 if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{
9046 "mcpServers": {
9047 "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] },
9048 "keep": { "command": "keep-mcp" }
9049 }
9050 }`), 0o644); err != nil {
9051 t.Fatal(err)
9052 }
9053
9054 app := NewApp()
9055 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9056 defer app.activeCtrl().Close()
9057
9058 if err := app.RemoveMCPServer("codegraph"); err != nil {
9059 t.Fatalf("RemoveMCPServer(.mcp.json codegraph): %v", err)
9060 }
9061 cfg, err := config.LoadForRoot(dir)
9062 if err != nil {
9063 t.Fatal(err)
9064 }
9065 if _, ok := findPluginEntry(cfg.Plugins, "codegraph"); ok {
9066 t.Fatalf("codegraph still merged after remove: %+v", cfg.Plugins)
9067 }
9068 if _, ok := findPluginEntry(cfg.Plugins, "keep"); !ok {
9069 t.Fatalf("unrelated .mcp.json server should be preserved: %+v", cfg.Plugins)
9070 }
9071 }
9072
9073 func TestRemoveMCPServerRejectsPluginManagedServerWithoutDisconnecting(t *testing.T) {
9074 isolateDesktopUserDirs(t)
9075 dir := robustTempDir(t)
9076 t.Chdir(dir)
9077
9078 srv := desktopMCPHTTPServer(t)
9079 defer srv.Close()
9080 reasonixHome := config.ReasonixHomeDir()
9081 root := filepath.Join(reasonixHome, "plugins", "superpowers")
9082 if err := os.MkdirAll(root, 0o755); err != nil {
9083 t.Fatal(err)
9084 }
9085 if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(fmt.Sprintf(`{
9086 "name": "superpowers",
9087 "version": "1.0.0",
9088 "mcpServers": {
9089 "helper": { "type": "http", "url": %q }
9090 }
9091 }`, srv.URL)), 0o644); err != nil {
9092 t.Fatal(err)
9093 }
9094 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
9095 Name: "superpowers",
9096 Root: "plugins/superpowers",
9097 Version: "1.0.0",
9098 ManifestKind: "reasonix",
9099 Enabled: true,
9100 }); err != nil {
9101 t.Fatal(err)
9102 }
9103
9104 cfg, err := config.LoadForRoot(dir)
9105 if err != nil {
9106 t.Fatal(err)
9107 }
9108 entry, ok := findPluginEntry(cfg.Plugins, "helper")
9109 if !ok {
9110 t.Fatalf("plugin-managed MCP missing from config: %+v", cfg.Plugins)
9111 }
9112 ctrl := control.New(control.Options{Host: plugin.NewHost()})
9113 defer ctrl.Close()
9114 if _, err := ctrl.ConnectMCPServer(entry); err != nil {
9115 t.Fatalf("connect plugin-managed MCP: %v", err)
9116 }
9117
9118 app := NewApp()
9119 app.setTestCtrl(ctrl, "")
9120 app.activeTab().WorkspaceRoot = dir
9121 err = app.RemoveMCPServer("helper")
9122 if err == nil || !strings.Contains(err.Error(), "managed by plugin") || !strings.Contains(err.Error(), "superpowers") {
9123 t.Fatalf("RemoveMCPServer(plugin-managed) error = %v", err)
9124 }
9125 if !mcpConnected(ctrl, "helper") {
9126 t.Fatal("plugin-managed MCP was disconnected despite rejected removal")
9127 }
9128 for action, actionErr := range map[string]error{
9129 "clear auth": app.ClearMCPServerAuthentication("helper"),
9130 "update": app.UpdateMCPServer("helper", MCPServerInput{Name: "helper", Transport: "http", URL: srv.URL}),
9131 } {
9132 if actionErr == nil || !strings.Contains(actionErr.Error(), "managed by plugin") {
9133 t.Fatalf("%s plugin-managed MCP error = %v", action, actionErr)
9134 }
9135 }
9136 if _, found := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, "helper"); found {
9137 t.Fatal("plugin-managed MCP mutation created a user-config shadow")
9138 }
9139 servers := app.MCPServers()
9140 if len(servers) != 1 || servers[0].Name != "helper" || servers[0].ManagedByPlugin != "superpowers" {
9141 t.Fatalf("MCPServers() = %+v, want helper managed by superpowers", servers)
9142 }
9143 }
9144
9145 func TestRemoveMCPServerRejectsRuntimeOnlyServerWithoutDisconnecting(t *testing.T) {
9146 isolateDesktopUserDirs(t)
9147 dir := robustTempDir(t)
9148 t.Chdir(dir)
9149
9150 srv := desktopMCPHTTPServer(t)
9151 defer srv.Close()
9152 ctrl := control.New(control.Options{Host: plugin.NewHost()})
9153 defer ctrl.Close()
9154 if _, err := ctrl.ConnectMCPServer(config.PluginEntry{Name: "runtime-only", Type: "http", URL: srv.URL}); err != nil {
9155 t.Fatalf("connect runtime-only MCP: %v", err)
9156 }
9157
9158 app := NewApp()
9159 app.setTestCtrl(ctrl, "")
9160 app.activeTab().WorkspaceRoot = dir
9161 err := app.RemoveMCPServer("runtime-only")
9162 if err == nil || !strings.Contains(err.Error(), "no removable MCP server") {
9163 t.Fatalf("RemoveMCPServer(runtime-only) error = %v", err)
9164 }
9165 if !mcpConnected(ctrl, "runtime-only") {
9166 t.Fatal("runtime-only MCP was disconnected despite failed persistence removal")
9167 }
9168 }
9169
9170 func TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {
9171 isolateDesktopUserDirs(t)
9172 dir := robustTempDir(t)
9173 t.Chdir(dir)
9174 if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{
9175 "mcpServers": {
9176 "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] }
9177 }
9178 }`), 0o644); err != nil {
9179 t.Fatal(err)
9180 }
9181
9182 app := NewApp()
9183 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9184 defer app.activeCtrl().Close()
9185 entry, ok, err := desktopEffectiveMCPServer(dir, "codegraph")
9186 if err != nil || !ok {
9187 t.Fatalf("load codegraph entry: found=%v err=%v", ok, err)
9188 }
9189 if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
9190 t.Fatal(err)
9191 }
9192 app.activeTab().disabledMCP["codegraph"] = ServerView{}
9193
9194 if err := app.UpdateMCPServer("codegraph", MCPServerInput{
9195 Name: "codegraph",
9196 Transport: "stdio",
9197 Command: "reasonix-missing-mcp-binary",
9198 Args: []string{"serve", "--mcp"},
9199 Env: map[string]string{"CODEGRAPH_LOG": "debug"},
9200 }); err != nil {
9201 t.Fatalf("UpdateMCPServer(.mcp.json codegraph): %v", err)
9202 }
9203
9204 raw, err := os.ReadFile(filepath.Join(dir, ".mcp.json"))
9205 if err != nil {
9206 t.Fatal(err)
9207 }
9208 var doc struct {
9209 MCPServers map[string]struct {
9210 Command string `json:"command"`
9211 Args []string `json:"args"`
9212 Env map[string]string `json:"env"`
9213 } `json:"mcpServers"`
9214 }
9215 if err := json.Unmarshal(raw, &doc); err != nil {
9216 t.Fatal(err)
9217 }
9218 got := doc.MCPServers["codegraph"]
9219 if got.Command != "reasonix-missing-mcp-binary" || !reflect.DeepEqual(got.Args, []string{"serve", "--mcp"}) || got.Env["CODEGRAPH_LOG"] != "debug" {
9220 t.Fatalf(".mcp.json codegraph = %+v, want updated command/args/env", got)
9221 }
9222 if _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, "codegraph"); ok {
9223 t.Fatalf(".mcp.json update should not create a user config shadow entry")
9224 }
9225 }
9226
9227 func TestUpdateMCPServerPreservesProjectTOMLSourceAndGlobalShadow(t *testing.T) {
9228 isolateDesktopUserDirs(t)
9229 dir := robustTempDir(t)
9230 t.Chdir(dir)
9231 userCfg := config.LoadForEdit(config.UserConfigPath())
9232 userCfg.Plugins = []config.PluginEntry{{Name: "docs", Command: "global-docs"}}
9233 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
9234 t.Fatal(err)
9235 }
9236 projectPath := filepath.Join(dir, "reasonix.toml")
9237 if err := os.WriteFile(projectPath, []byte(`
9238 [[plugins]]
9239 name = "docs"
9240 command = "project-docs"
9241 `), 0o644); err != nil {
9242 t.Fatal(err)
9243 }
9244
9245 app := NewApp()
9246 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), WorkspaceRoot: dir}), "")
9247 defer app.activeCtrl().Close()
9248 app.activeTab().WorkspaceRoot = dir
9249 entry, ok, err := desktopEffectiveMCPServer(dir, "docs")
9250 if err != nil || !ok || entry.Source != config.MCPSourceProjectConfig {
9251 t.Fatalf("load project docs entry: entry=%+v found=%v err=%v", entry, ok, err)
9252 }
9253 if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
9254 t.Fatal(err)
9255 }
9256 app.activeTab().disabledMCP["docs"] = ServerView{}
9257
9258 if err := app.UpdateMCPServer("docs", MCPServerInput{
9259 Name: "docs", Transport: "stdio", Command: "project-docs-updated",
9260 }); err != nil {
9261 t.Fatalf("UpdateMCPServer(project reasonix.toml docs): %v", err)
9262 }
9263
9264 projectCfg := config.LoadForEdit(projectPath)
9265 projectEntry, found := findPluginEntry(projectCfg.Plugins, "docs")
9266 if !found || projectEntry.Command != "project-docs-updated" {
9267 t.Fatalf("project docs entry = %+v, found=%v", projectEntry, found)
9268 }
9269 globalCfg := config.LoadForEdit(config.UserConfigPath())
9270 globalEntry, found := findPluginEntry(globalCfg.Plugins, "docs")
9271 if !found || globalEntry.Command != "global-docs" {
9272 t.Fatalf("global shadow changed while editing project entry: %+v, found=%v", globalEntry, found)
9273 }
9274 effective, found, err := desktopEffectiveMCPServer(dir, "docs")
9275 if err != nil || !found || effective.Source != config.MCPSourceProjectConfig || effective.Command != "project-docs-updated" {
9276 t.Fatalf("effective docs after edit = %+v, found=%v err=%v", effective, found, err)
9277 }
9278 }
9279
9280 func TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {
9281 isolateDesktopUserDirs(t)
9282 dir := robustTempDir(t)
9283 t.Chdir(dir)
9284 t.Setenv("STRIPE_TOKEN", "stripe-test-token")
9285 srv := desktopMCPHTTPServer(t)
9286 defer srv.Close()
9287
9288 app := NewApp()
9289 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9290 defer app.activeCtrl().Close()
9291
9292 tools, err := app.AddMCPServer(MCPServerInput{
9293 Name: "stripe",
9294 Transport: "http",
9295 URL: srv.URL,
9296 Headers: map[string]string{
9297 "Authorization": "Bearer ${STRIPE_TOKEN}",
9298 "X-Org": "team",
9299 },
9300 })
9301 if err != nil {
9302 t.Fatalf("AddMCPServer(stripe): %v", err)
9303 }
9304 if tools != 1 {
9305 t.Fatalf("tools = %d, want 1", tools)
9306 }
9307
9308 cfg, err := config.LoadForRoot(dir)
9309 if err != nil {
9310 t.Fatal(err)
9311 }
9312 p, ok := findPluginEntry(cfg.Plugins, "stripe")
9313 if !ok {
9314 t.Fatalf("stripe plugin missing from config: %+v", cfg.Plugins)
9315 }
9316 if p.Type != "http" || p.URL != srv.URL {
9317 t.Fatalf("stripe plugin transport = %q url = %q", p.Type, p.URL)
9318 }
9319 if p.Headers["Authorization"] != "Bearer ${STRIPE_TOKEN}" || p.Headers["X-Org"] != "team" {
9320 t.Fatalf("stripe headers = %+v", p.Headers)
9321 }
9322
9323 view := app.MCPServers()
9324 for _, s := range view {
9325 if s.Name == "stripe" {
9326 if !reflect.DeepEqual(s.HeaderKeys, []string{"Authorization", "X-Org"}) {
9327 t.Fatalf("stripe header keys = %+v", s.HeaderKeys)
9328 }
9329 return
9330 }
9331 }
9332 t.Fatalf("stripe MCP missing from view: %+v", view)
9333 }
9334
9335 func TestInstallMCPServerHandshakeFailureDoesNotPersist(t *testing.T) {
9336 isolateDesktopUserDirs(t)
9337 dir := robustTempDir(t)
9338 t.Chdir(dir)
9339
9340 app := NewApp()
9341 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9342 defer app.activeCtrl().Close()
9343
9344 result, err := app.InstallMCPServer(MCPServerInput{
9345 Name: "broken", Transport: "stdio", Command: "reasonix-missing-mcp-binary",
9346 })
9347 if err != nil {
9348 t.Fatalf("InstallMCPServer returned transport error instead of structured issue: %v", err)
9349 }
9350 if result.State != "issue" || result.Action != "retry" {
9351 t.Fatalf("install result = %+v, want retryable issue", result)
9352 }
9353 cfg, err := config.LoadForRoot(dir)
9354 if err != nil {
9355 t.Fatal(err)
9356 }
9357 if _, ok := findPluginEntry(cfg.Plugins, "broken"); ok {
9358 t.Fatalf("failed candidate was persisted: %+v", cfg.Plugins)
9359 }
9360 for _, server := range app.MCPServers() {
9361 if server.Name == "broken" {
9362 t.Fatalf("failed candidate leaked into the installed server list: %+v", server)
9363 }
9364 }
9365 }
9366
9367 func TestInstallMCPServerAuthenticationRequiredPersistsForResume(t *testing.T) {
9368 isolateDesktopUserDirs(t)
9369 dir := robustTempDir(t)
9370 t.Chdir(dir)
9371 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
9372 http.Error(w, "unauthorized", http.StatusUnauthorized)
9373 }))
9374 defer srv.Close()
9375
9376 app := NewApp()
9377 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9378 defer app.activeCtrl().Close()
9379
9380 result, err := app.InstallMCPServer(MCPServerInput{Name: "oauth", Transport: "http", URL: srv.URL})
9381 if err != nil {
9382 t.Fatalf("InstallMCPServer auth result: %v", err)
9383 }
9384 if result.State != "action_required" || result.Action != "authenticate" {
9385 t.Fatalf("install result = %+v, want authentication action", result)
9386 }
9387 cfg, err := config.LoadForRoot(dir)
9388 if err != nil {
9389 t.Fatal(err)
9390 }
9391 if _, ok := findPluginEntry(cfg.Plugins, "oauth"); !ok {
9392 t.Fatalf("auth-pending candidate must persist for resume: %+v", cfg.Plugins)
9393 }
9394 }
9395
9396 func TestAddMCPServerPersistsConnectionConfiguration(t *testing.T) {
9397 isolateDesktopUserDirs(t)
9398 dir := robustTempDir(t)
9399 t.Chdir(dir)
9400 srv := desktopMCPHTTPServer(t)
9401 defer srv.Close()
9402
9403 app := NewApp()
9404 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9405 defer app.activeCtrl().Close()
9406 autoStart := false
9407 callTimeout := 45
9408 _, err := app.AddMCPServer(MCPServerInput{
9409 Name: "admin",
9410 Transport: "streamable-http",
9411 URL: srv.URL,
9412 AutoStart: &autoStart,
9413 CallTimeoutSeconds: &callTimeout,
9414 ToolTimeoutSeconds: map[string]int{
9415 "wipe": 120,
9416 },
9417 })
9418 if err != nil {
9419 t.Fatal(err)
9420 }
9421
9422 cfg, err := config.LoadForRoot(dir)
9423 if err != nil {
9424 t.Fatal(err)
9425 }
9426 entry, ok := findPluginEntry(cfg.Plugins, "admin")
9427 if !ok || entry.Type != "http" || entry.AutoStart == nil || *entry.AutoStart ||
9428 entry.CallTimeoutSeconds != 45 || entry.ToolTimeoutSeconds["wipe"] != 120 {
9429 t.Fatalf("persisted advanced MCP entry = %+v, found=%v", entry, ok)
9430 }
9431
9432 views := app.MCPServers()
9433 if len(views) != 1 || views[0].Transport != "http" || views[0].AutoStart ||
9434 views[0].CallTimeoutSeconds != 45 || views[0].ToolTimeoutSeconds["wipe"] != 120 {
9435 t.Fatalf("advanced MCP ServerView = %+v", views)
9436 }
9437 }
9438
9439 func TestUpdateMCPServerPreservesAbsentFieldsAndClearsExplicitOnes(t *testing.T) {
9440 isolateDesktopUserDirs(t)
9441 dir := robustTempDir(t)
9442 t.Chdir(dir)
9443 srv := desktopMCPHTTPServer(t)
9444 defer srv.Close()
9445
9446 app := NewApp()
9447 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9448 defer app.activeCtrl().Close()
9449 callTimeout := 45
9450 if _, err := app.AddMCPServer(MCPServerInput{
9451 Name: "admin",
9452 Transport: "http",
9453 URL: srv.URL,
9454 CallTimeoutSeconds: &callTimeout,
9455 ToolTimeoutSeconds: map[string]int{"wipe": 120},
9456 }); err != nil {
9457 t.Fatal(err)
9458 }
9459
9460 // An old frontend (or a partial payload) omits optional timeout fields.
9461 if err := app.UpdateMCPServer("admin", MCPServerInput{Name: "admin", Transport: "http", URL: srv.URL}); err != nil {
9462 t.Fatal(err)
9463 }
9464 cfg, err := config.LoadForRoot(dir)
9465 if err != nil {
9466 t.Fatal(err)
9467 }
9468 entry, ok := findPluginEntry(cfg.Plugins, "admin")
9469 if !ok || entry.CallTimeoutSeconds != 45 || entry.ToolTimeoutSeconds["wipe"] != 120 {
9470 t.Fatalf("absent input fields must preserve persisted values, entry = %+v, found=%v", entry, ok)
9471 }
9472
9473 // Explicit zero values are the editor's clear semantics.
9474 cleared := 0
9475 if err := app.UpdateMCPServer("admin", MCPServerInput{
9476 Name: "admin",
9477 Transport: "http",
9478 URL: srv.URL,
9479 CallTimeoutSeconds: &cleared,
9480 ToolTimeoutSeconds: map[string]int{},
9481 }); err != nil {
9482 t.Fatal(err)
9483 }
9484 cfg, err = config.LoadForRoot(dir)
9485 if err != nil {
9486 t.Fatal(err)
9487 }
9488 entry, ok = findPluginEntry(cfg.Plugins, "admin")
9489 if !ok || entry.CallTimeoutSeconds != 0 || len(entry.ToolTimeoutSeconds) != 0 {
9490 t.Fatalf("explicit empty fields must clear persisted values, entry = %+v, found=%v", entry, ok)
9491 }
9492 }
9493
9494 func TestUpdateMCPServerFailedCandidateRollsBackConfigAndConnection(t *testing.T) {
9495 isolateDesktopUserDirs(t)
9496 dir := robustTempDir(t)
9497 t.Chdir(dir)
9498 srv := desktopMCPHTTPServer(t)
9499 defer srv.Close()
9500
9501 app := NewApp()
9502 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9503 defer app.activeCtrl().Close()
9504 if _, err := app.AddMCPServer(MCPServerInput{Name: "stable", Transport: "http", URL: srv.URL}); err != nil {
9505 t.Fatal(err)
9506 }
9507
9508 err := app.UpdateMCPServer("stable", MCPServerInput{
9509 Name: "stable", Transport: "stdio", Command: "reasonix-missing-mcp-binary",
9510 })
9511 if err == nil {
9512 t.Fatal("broken update candidate should fail")
9513 }
9514 cfg, err := config.LoadForRoot(dir)
9515 if err != nil {
9516 t.Fatal(err)
9517 }
9518 entry, ok := findPluginEntry(cfg.Plugins, "stable")
9519 if !ok || entry.Type != "http" || entry.URL != srv.URL {
9520 t.Fatalf("failed update changed durable config: %+v, found=%v", entry, ok)
9521 }
9522 if !app.activeCtrl().Host().HasClient("stable") {
9523 t.Fatal("previous MCP connection was not restored after failed update")
9524 }
9525 }
9526
9527 func TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {
9528 isolateDesktopUserDirs(t)
9529 dir := robustTempDir(t)
9530 t.Chdir(dir)
9531 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9532 [[plugins]]
9533 name = "dida"
9534 type = "http"
9535 url = "https://mcp.dida365.com"
9536 tier = "lazy"
9537 `), 0o644); err != nil {
9538 t.Fatal(err)
9539 }
9540
9541 app := NewApp()
9542 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9543 defer app.activeCtrl().Close()
9544
9545 view := app.Capabilities()
9546 for _, s := range view.Servers {
9547 if s.Name == "dida" {
9548 if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" || s.AuthStatus != "possible" || s.AuthURL != "https://mcp.dida365.com" {
9549 t.Fatalf("dida auth diagnosis = %+v", s)
9550 }
9551 return
9552 }
9553 }
9554 t.Fatalf("dida MCP missing from Capabilities: %+v", view.Servers)
9555 }
9556
9557 func TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {
9558 isolateDesktopUserDirs(t)
9559 dir := robustTempDir(t)
9560 t.Chdir(dir)
9561 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9562 [[plugins]]
9563 name = "stripe"
9564 type = "http"
9565 url = "https://mcp.stripe.com"
9566 headers = { Authorization = "Bearer ${STRIPE_TOKEN}" }
9567 tier = "lazy"
9568 `), 0o644); err != nil {
9569 t.Fatal(err)
9570 }
9571
9572 app := NewApp()
9573 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9574 defer app.activeCtrl().Close()
9575
9576 view := app.Capabilities()
9577 for _, s := range view.Servers {
9578 if s.Name == "stripe" {
9579 if s.AuthStatus != "none" {
9580 t.Fatalf("stripe auth status = %q, want none; server = %+v", s.AuthStatus, s)
9581 }
9582 return
9583 }
9584 }
9585 t.Fatalf("stripe MCP missing from Capabilities: %+v", view.Servers)
9586 }
9587
9588 func TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {
9589 isolateDesktopUserDirs(t)
9590 dir := robustTempDir(t)
9591 t.Chdir(dir)
9592 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9593 [[plugins]]
9594 name = "figma"
9595 type = "http"
9596 url = "https://mcp.figma.com/mcp"
9597 tier = "lazy"
9598 `), 0o644); err != nil {
9599 t.Fatal(err)
9600 }
9601
9602 host := plugin.NewHost()
9603 host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
9604 app := NewApp()
9605 app.setTestCtrl(control.New(control.Options{Host: host}), "")
9606 defer app.activeCtrl().Close()
9607
9608 view := app.Capabilities()
9609 for _, s := range view.Servers {
9610 if s.Name == "figma" {
9611 if s.Status != "failed" || s.AuthStatus != "required" || s.AuthURL != "https://mcp.figma.com/mcp" {
9612 t.Fatalf("figma auth diagnosis = %+v", s)
9613 }
9614 return
9615 }
9616 }
9617 t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
9618 }
9619
9620 func TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {
9621 isolateDesktopUserDirs(t)
9622 dir := robustTempDir(t)
9623 t.Chdir(dir)
9624 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9625 [[plugins]]
9626 name = "figma"
9627 type = "http"
9628 url = "https://mcp.figma.com/mcp?access_token=abc&workspace=main"
9629 headers = { Authorization = "Bearer ${FIGMA_TOKEN}", "X-Org" = "team" }
9630 env = { FIGMA_TOKEN = "${FIGMA_TOKEN}", DEBUG = "1" }
9631 tier = "lazy"
9632 `), 0o644); err != nil {
9633 t.Fatal(err)
9634 }
9635
9636 host := plugin.NewHost()
9637 host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
9638 app := NewApp()
9639 app.setTestCtrl(control.New(control.Options{Host: host}), "")
9640 defer app.activeCtrl().Close()
9641
9642 if err := app.ClearMCPServerAuthentication("figma"); err != nil {
9643 t.Fatalf("ClearMCPServerAuthentication: %v", err)
9644 }
9645 if failures := host.Failures(); len(failures) != 0 {
9646 t.Fatalf("failure should be cleared: %+v", failures)
9647 }
9648 cfg, err := config.Load()
9649 if err != nil {
9650 t.Fatal(err)
9651 }
9652 p := cfg.Plugins[0]
9653 if p.URL != "https://mcp.figma.com/mcp?workspace=main" {
9654 t.Fatalf("url = %q", p.URL)
9655 }
9656 if _, ok := p.Headers["Authorization"]; ok {
9657 t.Fatalf("auth header should be removed: %v", p.Headers)
9658 }
9659 if p.Headers["X-Org"] != "team" {
9660 t.Fatalf("ordinary header should be preserved: %v", p.Headers)
9661 }
9662 if _, ok := p.Env["FIGMA_TOKEN"]; ok {
9663 t.Fatalf("auth env should be removed: %v", p.Env)
9664 }
9665 if p.Env["DEBUG"] != "1" {
9666 t.Fatalf("ordinary env should be preserved: %v", p.Env)
9667 }
9668 view := app.Capabilities()
9669 for _, s := range view.Servers {
9670 if s.Name == "figma" {
9671 if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" || s.AuthStatus != "possible" {
9672 t.Fatalf("figma should return to background possible auth: %+v", s)
9673 }
9674 return
9675 }
9676 }
9677 t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
9678 }
9679
9680 func TestUpdateMCPServerMigratesLegacyTierInProjectSource(t *testing.T) {
9681 isolateDesktopUserDirs(t)
9682 dir := robustTempDir(t)
9683 t.Chdir(dir)
9684 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9685 [[plugins]]
9686 name = "playwright"
9687 command = "npx"
9688 args = ["-y", "@playwright/mcp"]
9689 env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
9690 tier = "lazy"
9691 `), 0o644); err != nil {
9692 t.Fatal(err)
9693 }
9694
9695 app := NewApp()
9696 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9697 defer func() {
9698 if c := app.activeCtrl(); c != nil {
9699 c.Close()
9700 }
9701 }()
9702 entry, ok, err := desktopEffectiveMCPServer(dir, "playwright")
9703 if err != nil || !ok {
9704 t.Fatalf("load playwright entry: found=%v err=%v", ok, err)
9705 }
9706 if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
9707 t.Fatal(err)
9708 }
9709 app.activeTab().disabledMCP["playwright"] = ServerView{Name: "playwright", Status: "disabled", Enabled: false}
9710
9711 if err := app.UpdateMCPServer("playwright", MCPServerInput{
9712 Name: "playwright",
9713 Transport: "stdio",
9714 Command: "node",
9715 Args: []string{"server.js"},
9716 }); err != nil {
9717 t.Fatalf("UpdateMCPServer: %v", err)
9718 }
9719 cfg, err := config.Load()
9720 if err != nil {
9721 t.Fatal(err)
9722 }
9723 if got := cfg.Plugins[0].Command; got != "node" {
9724 t.Fatalf("updated command = %q, want node", got)
9725 }
9726 if got := cfg.Plugins[0].Env["TOKEN"]; got != "${PLAYWRIGHT_TOKEN}" {
9727 t.Fatalf("env TOKEN = %q, want preserved env", got)
9728 }
9729 userCfg := config.LoadForEdit(config.UserConfigPath())
9730 if _, ok := findPluginEntry(userCfg.Plugins, "playwright"); ok {
9731 t.Fatalf("project plugin should not be copied to user config: %+v", userCfg.Plugins)
9732 }
9733 projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
9734 projectPlugin, ok := findPluginEntry(projectCfg.Plugins, "playwright")
9735 if !ok {
9736 t.Fatalf("playwright should remain in project config: %+v", projectCfg.Plugins)
9737 }
9738 if projectPlugin.Command != "node" || projectPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
9739 t.Fatalf("project plugin after update = %+v", projectPlugin)
9740 }
9741 if projectPlugin.Tier != "" {
9742 t.Fatalf("project plugin tier = %q, want migrated empty", projectPlugin.Tier)
9743 }
9744 view := app.Capabilities()
9745 for _, s := range view.Servers {
9746 if s.Name == "playwright" {
9747 if s.Status != "disabled" {
9748 t.Fatalf("updated MCP status = %q, want disabled without a readiness probe; server = %+v", s.Status, s)
9749 }
9750 if s.Command != "node" || len(s.Args) != 1 || s.Args[0] != "server.js" {
9751 t.Fatalf("server command not refreshed: %+v", s)
9752 }
9753 return
9754 }
9755 }
9756 t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
9757 }
9758
9759 func TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {
9760 isolateDesktopUserDirs(t)
9761 dir := t.TempDir()
9762 t.Chdir(dir)
9763 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9764 [[plugins]]
9765 name = "playwright"
9766 command = "npx"
9767 args = ["-y", "@playwright/mcp"]
9768 `), 0o644); err != nil {
9769 t.Fatal(err)
9770 }
9771
9772 app := NewApp()
9773 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9774 defer app.activeCtrl().Close()
9775 app.activeTab().disabledMCP["playwright"] = ServerView{}
9776
9777 if err := app.UpdateMCPServer("playwright", MCPServerInput{
9778 Name: "playwright",
9779 Transport: "stdio",
9780 Command: "npx -y @modelcontextprotocol/server-filesystem .",
9781 }); err != nil {
9782 t.Fatalf("UpdateMCPServer: %v", err)
9783 }
9784 cfg, err := config.Load()
9785 if err != nil {
9786 t.Fatal(err)
9787 }
9788 p := cfg.Plugins[0]
9789 if p.Command != "npx" {
9790 t.Fatalf("command = %q, want npx", p.Command)
9791 }
9792 if got := strings.Join(p.Args, "\x00"); got != strings.Join([]string{"-y", "@modelcontextprotocol/server-filesystem", "."}, "\x00") {
9793 t.Fatalf("args = %v", p.Args)
9794 }
9795 }
9796
9797 func TestUpdateMCPServerRejectsReconnectFailureWithoutPersisting(t *testing.T) {
9798 isolateDesktopUserDirs(t)
9799 dir := robustTempDir(t)
9800 t.Chdir(dir)
9801 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9802 [[plugins]]
9803 name = "broken"
9804 command = "reasonix-old-missing-mcp-binary"
9805 tier = "background"
9806 `), 0o644); err != nil {
9807 t.Fatal(err)
9808 }
9809
9810 app := NewApp()
9811 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9812 defer app.activeCtrl().Close()
9813
9814 if err := app.UpdateMCPServer("broken", MCPServerInput{
9815 Name: "broken",
9816 Transport: "stdio",
9817 Command: "reasonix-missing-mcp-binary",
9818 }); err == nil {
9819 t.Fatal("UpdateMCPServer should reject an unusable candidate")
9820 }
9821 cfg, err := config.Load()
9822 if err != nil {
9823 t.Fatal(err)
9824 }
9825 if got := cfg.Plugins[0].Command; got != "reasonix-old-missing-mcp-binary" {
9826 t.Fatalf("failed update command = %q, want original command", got)
9827 }
9828 if got := cfg.Plugins[0].Tier; got != "" {
9829 t.Fatalf("loaded legacy tier = %q, want normalized empty", got)
9830 }
9831 if !mcpFailed(app.activeCtrl(), "broken") {
9832 t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
9833 }
9834 view := app.Capabilities()
9835 for _, s := range view.Servers {
9836 if s.Name == "broken" {
9837 if s.Status != "failed" {
9838 t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
9839 }
9840 if s.Command != "reasonix-old-missing-mcp-binary" || s.Tier != "background" {
9841 t.Fatalf("failed candidate leaked into server config: %+v", s)
9842 }
9843 return
9844 }
9845 }
9846 t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
9847 }
9848
9849 func TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {
9850 isolateDesktopUserDirs(t)
9851 dir := robustTempDir(t)
9852 t.Chdir(dir)
9853 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9854 [[plugins]]
9855 name = "codegraph"
9856 `), 0o644); err != nil {
9857 t.Fatal(err)
9858 }
9859
9860 reg := tool.NewRegistry()
9861 reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
9862 app := NewApp()
9863 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), "")
9864 defer app.activeCtrl().Close()
9865
9866 view := app.Capabilities()
9867 foundIdle := false
9868 for _, s := range view.Servers {
9869 if s.Name == "codegraph" {
9870 foundIdle = true
9871 if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
9872 t.Fatalf("initial codegraph server = %+v, want automatic idle background state", s)
9873 }
9874 }
9875 }
9876 if !foundIdle {
9877 t.Fatalf("codegraph missing before reconnect: %+v", view.Servers)
9878 }
9879 if _, ok := reg.Get("mcp__codegraph__connect"); !ok {
9880 t.Fatal("test setup expected stale codegraph connect placeholder")
9881 }
9882
9883 if err := app.ReconnectMCPServer("codegraph"); err == nil || !strings.Contains(err.Error(), "command is required") {
9884 t.Fatalf("ReconnectMCPServer error = %v, want missing command", err)
9885 }
9886 if _, ok := reg.Get("mcp__codegraph__connect"); ok {
9887 t.Fatalf("stale codegraph placeholder still registered after reconnect failure; names=%v", reg.Names())
9888 }
9889 if !mcpFailed(app.activeCtrl(), "codegraph") {
9890 t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded", app.activeCtrl().Host().Failures())
9891 }
9892
9893 view = app.Capabilities()
9894 for _, s := range view.Servers {
9895 if s.Name == "codegraph" {
9896 if s.Status != "failed" || s.Error == "" {
9897 t.Fatalf("codegraph after failed reconnect = %+v, want failed with error", s)
9898 }
9899 return
9900 }
9901 }
9902 t.Fatalf("codegraph missing after reconnect: %+v", view.Servers)
9903 }
9904
9905 func TestSetMCPServerTierPreservesProjectSourceAndRecordsConnectFailure(t *testing.T) {
9906 isolateDesktopUserDirs(t)
9907 dir := robustTempDir(t)
9908 t.Chdir(dir)
9909 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
9910 [[plugins]]
9911 name = "broken"
9912 command = "reasonix-missing-mcp-binary"
9913 tier = "lazy"
9914 `), 0o644); err != nil {
9915 t.Fatal(err)
9916 }
9917
9918 app := NewApp()
9919 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
9920 defer func() {
9921 if c := app.activeCtrl(); c != nil {
9922 c.Close()
9923 }
9924 }()
9925
9926 if err := app.SetMCPServerTier("broken", "background"); err != nil {
9927 t.Fatalf("SetMCPServerTier legacy binding: %v", err)
9928 }
9929 cfg, err := config.Load()
9930 if err != nil {
9931 t.Fatal(err)
9932 }
9933 if got := cfg.Plugins[0].Tier; got != "" {
9934 t.Fatalf("saved tier = %q, want migrated empty", got)
9935 }
9936 userCfg := config.LoadForEdit(config.UserConfigPath())
9937 if _, ok := findPluginEntry(userCfg.Plugins, "broken"); ok {
9938 t.Fatalf("project plugin should not be copied to user config: %+v", userCfg.Plugins)
9939 }
9940 projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
9941 projectPlugin, ok := findPluginEntry(projectCfg.Plugins, "broken")
9942 if !ok {
9943 t.Fatalf("broken should remain in project config: %+v", projectCfg.Plugins)
9944 }
9945 if projectPlugin.Tier != "" {
9946 t.Fatalf("project plugin tier = %q, want migrated empty", projectPlugin.Tier)
9947 }
9948 if !mcpFailed(app.activeCtrl(), "broken") {
9949 t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
9950 }
9951 view := app.Capabilities()
9952 for _, s := range view.Servers {
9953 if s.Name == "broken" {
9954 if s.Status != "failed" {
9955 t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
9956 }
9957 if s.Tier != "background" {
9958 t.Fatalf("server tier = %q, want background so radio selection does not jump back", s.Tier)
9959 }
9960 return
9961 }
9962 }
9963 t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
9964 }
9965
9966 func TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {
9967 isolateDesktopUserDirs(t)
9968 dir := robustTempDir(t)
9969 t.Chdir(dir)
9970 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
9971 t.Fatalf("mkdir config dir: %v", err)
9972 }
9973 if err := os.WriteFile(config.UserConfigPath(), []byte(`
9974 [[plugins]]
9975 name = "broken"
9976 command = "reasonix-missing-mcp-binary"
9977 tier = "lazy"
9978 `), 0o644); err != nil {
9979 t.Fatal(err)
9980 }
9981
9982 app := NewApp()
9983 app.setTestCtrl(newBackgroundJobController(t, "mcp-tier-job"), "")
9984
9985 err := app.SetMCPServerTier("broken", "background")
9986 if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
9987 t.Fatalf("SetMCPServerTier with background job error = %v, want active-work guard", err)
9988 }
9989 data, readErr := os.ReadFile(config.UserConfigPath())
9990 if readErr != nil {
9991 t.Fatalf("read config: %v", readErr)
9992 }
9993 if !strings.Contains(string(data), `tier = "lazy"`) {
9994 t.Fatalf("plugin config changed after rejected tier update:\n%s", data)
9995 }
9996 }
9997
9998 func TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {
9999 isolateDesktopUserDirs(t)
10000 dir := robustTempDir(t)
10001 t.Chdir(dir)
10002 if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
10003 [[plugins]]
10004 name = "broken"
10005 command = "reasonix-missing-mcp-binary"
10006 tier = "eager"
10007 `), 0o644); err != nil {
10008 t.Fatal(err)
10009 }
10010
10011 app := NewApp()
10012 app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
10013 defer app.activeCtrl().Close()
10014 recordMCPFailure(app.activeCtrl(), config.PluginEntry{
10015 Name: "broken",
10016 Command: "reasonix-missing-mcp-binary",
10017 Tier: "eager",
10018 }, errors.New("connect: missing binary"))
10019
10020 view := app.Capabilities()
10021 for _, s := range view.Servers {
10022 if s.Name == "broken" {
10023 if s.Status != "failed" {
10024 t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
10025 }
10026 if s.Tier != "background" {
10027 t.Fatalf("server tier = %q, want migrated background default", s.Tier)
10028 }
10029 if !s.Configured {
10030 t.Fatalf("server configured = false, want true; server = %+v", s)
10031 }
10032 return
10033 }
10034 }
10035 t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
10036 }
10037
10038 func TestRunShellForTabRoutesToRequestedTab(t *testing.T) {
10039 isolateDesktopUserDirs(t)
10040
10041 activeEvents := make(chan event.Event, 16)
10042 inactiveEvents := make(chan event.Event, 16)
10043 activeCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})
10044 inactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})
10045 defer activeCtrl.Close()
10046 defer inactiveCtrl.Close()
10047
10048 app := &App{
10049 tabs: map[string]*WorkspaceTab{
10050 "active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
10051 "inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
10052 },
10053 tabOrder: []string{"active", "inactive"},
10054 activeTabID: "active",
10055 }
10056
10057 app.RunShellForTab("inactive", "echo route-test")
10058
10059 sawDispatch := false
10060 deadline := time.After(3 * time.Second)
10061 for {
10062 select {
10063 case e := <-inactiveEvents:
10064 if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, "route-test") {
10065 sawDispatch = true
10066 }
10067 if e.Kind == event.TurnDone {
10068 if !sawDispatch {
10069 t.Fatal("inactive tab finished without receiving shell dispatch")
10070 }
10071 select {
10072 case active := <-activeEvents:
10073 t.Fatalf("active tab received event for inactive shell: %+v", active)
10074 default:
10075 }
10076 return
10077 }
10078 case <-deadline:
10079 t.Fatal("timed out waiting for inactive shell turn")
10080 }
10081 }
10082 }
10083
10084 func TestRunShellForTabStaysBoundDuringRapidProjectTabSwitching(t *testing.T) {
10085 if testing.Short() {
10086 t.Skip("skipping shell cancellation integration test in short mode")
10087 }
10088
10089 isolateDesktopUserDirs(t)
10090
10091 projectA := t.TempDir()
10092 projectB := t.TempDir()
10093 globalRoot := t.TempDir()
10094 shellEvents := make(chan event.Event, 64)
10095 projectEvents := make(chan event.Event, 64)
10096 globalEvents := make(chan event.Event, 64)
10097 shellCtrl := control.New(control.Options{
10098 Sink: event.FuncSink(func(e event.Event) { shellEvents <- e }),
10099 WorkspaceRoot: projectA,
10100 })
10101 projectCtrl := control.New(control.Options{
10102 Sink: event.FuncSink(func(e event.Event) { projectEvents <- e }),
10103 WorkspaceRoot: projectB,
10104 })
10105 globalCtrl := control.New(control.Options{
10106 Sink: event.FuncSink(func(e event.Event) { globalEvents <- e }),
10107 WorkspaceRoot: globalRoot,
10108 })
10109 defer shellCtrl.Close()
10110 defer projectCtrl.Close()
10111 defer globalCtrl.Close()
10112
10113 app := &App{
10114 tabs: map[string]*WorkspaceTab{
10115 "shell": {ID: "shell", Scope: "project", WorkspaceRoot: projectA, Ctrl: shellCtrl, Ready: true},
10116 "project-b": {ID: "project-b", Scope: "project", WorkspaceRoot: projectB, Ctrl: projectCtrl, Ready: true},
10117 "global": {ID: "global", Scope: "global", WorkspaceRoot: globalRoot, Ctrl: globalCtrl, Ready: true},
10118 },
10119 tabOrder: []string{"shell", "project-b", "global"},
10120 activeTabID: "shell",
10121 }
10122
10123 marker := "shell-route-marker.txt"
10124 if err := app.RunShellForTab("shell", longRunningMarkerCommand(marker)); err != nil {
10125 t.Fatalf("RunShellForTab: %v", err)
10126 }
10127 waitForShellDispatch(t, shellEvents, marker)
10128 waitForFile(t, filepath.Join(projectA, marker), "shell")
10129
10130 for i := 0; i < 8; i++ {
10131 if err := app.SetActiveTab("project-b"); err != nil {
10132 t.Fatalf("SetActiveTab(project-b): %v", err)
10133 }
10134 if err := app.SetActiveTab("global"); err != nil {
10135 t.Fatalf("SetActiveTab(global): %v", err)
10136 }
10137 if err := app.SetActiveTab("shell"); err != nil {
10138 t.Fatalf("SetActiveTab(shell): %v", err)
10139 }
10140 }
10141 if err := app.SetActiveTab("project-b"); err != nil {
10142 t.Fatalf("SetActiveTab(project-b final): %v", err)
10143 }
10144 app.CancelTab("shell")
10145
10146 cancelled := false
10147 deadline := time.After(15 * time.Second)
10148 for {
10149 select {
10150 case e := <-shellEvents:
10151 if e.Kind == event.ToolResult && e.Tool.Name == "bash" {
10152 cancelled = e.Tool.Err != ""
10153 }
10154 if e.Kind == event.TurnDone {
10155 if !cancelled {
10156 t.Fatal("shell tab finished without a cancelled shell result")
10157 }
10158 if _, err := os.Stat(filepath.Join(projectB, marker)); !errors.Is(err, os.ErrNotExist) {
10159 t.Fatalf("shell marker appeared in project-b workspace: %v", err)
10160 }
10161 if got := activeTabIDForTest(app); got != "project-b" {
10162 t.Fatalf("active tab = %q, want project-b after background shell cancel", got)
10163 }
10164 assertNoEvents(t, projectEvents, "project-b")
10165 assertNoEvents(t, globalEvents, "global")
10166 return
10167 }
10168 case <-deadline:
10169 t.Fatal("timed out waiting for shell tab cancellation")
10170 }
10171 }
10172 }
10173
10174 func longRunningMarkerCommand(marker string) string {
10175 if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell {
10176 return fmt.Sprintf("Set-Content -LiteralPath %s -Value shell; Start-Sleep -Seconds 30", marker)
10177 }
10178 return fmt.Sprintf("printf shell > %s; sleep 30", marker)
10179 }
10180
10181 func waitForShellDispatch(t *testing.T, ch <-chan event.Event, marker string) {
10182 t.Helper()
10183 deadline := time.After(5 * time.Second)
10184 for {
10185 select {
10186 case e := <-ch:
10187 if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, marker) {
10188 return
10189 }
10190 case <-deadline:
10191 t.Fatal("timed out waiting for shell dispatch")
10192 }
10193 }
10194 }
10195
10196 func activeTabIDForTest(app *App) string {
10197 app.mu.RLock()
10198 defer app.mu.RUnlock()
10199 return app.activeTabID
10200 }
10201
10202 func assertNoEvents(t *testing.T, ch <-chan event.Event, name string) {
10203 t.Helper()
10204 select {
10205 case e := <-ch:
10206 t.Fatalf("%s received event while shell ran in another tab: %+v", name, e)
10207 default:
10208 }
10209 }
10210
10211 type blockingRunner struct {
10212 started chan struct{}
10213 release chan struct{}
10214 }
10215
10216 func (r *blockingRunner) Run(ctx context.Context, _ string) error {
10217 close(r.started)
10218 select {
10219 case <-ctx.Done():
10220 return ctx.Err()
10221 case <-r.release:
10222 return nil
10223 }
10224 }
10225
10226 func startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {
10227 t.Helper()
10228 started := make(chan struct{})
10229 release := make(chan struct{})
10230 jm.StartForSession(agent.BranchID(sessionPath), "bash", "stuck job", func(ctx context.Context, _ io.Writer) (string, error) {
10231 close(started)
10232 <-ctx.Done()
10233 <-release
10234 return "", ctx.Err()
10235 })
10236 select {
10237 case <-started:
10238 case <-time.After(2 * time.Second):
10239 t.Fatal("background job never started")
10240 }
10241 released := false
10242 return func() {
10243 if released {
10244 return
10245 }
10246 released = true
10247 close(release)
10248 }
10249 }
10250
10251 func waitNotRunning(t *testing.T, ctrl control.SessionAPI) {
10252 t.Helper()
10253 // Windows release runners can take more than one second to schedule the
10254 // controller's asynchronous completion while the full desktop suite is
10255 // active. Keep a bounded responsiveness check without treating scheduler
10256 // delay as a leaked controller.
10257 deadline := time.Now().Add(5 * time.Second)
10258 for ctrl.Running() {
10259 if time.Now().After(deadline) {
10260 t.Fatal("controller still running")
10261 }
10262 time.Sleep(10 * time.Millisecond)
10263 }
10264 }
10265
10266 func newBackgroundJobController(t *testing.T, label string) *control.Controller {
10267 t.Helper()
10268 dir := config.SessionDir()
10269 if err := os.MkdirAll(dir, 0o755); err != nil {
10270 t.Fatalf("mkdir session dir: %v", err)
10271 }
10272 path := filepath.Join(dir, label+".jsonl")
10273 jm := jobs.NewManager(event.Discard)
10274 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
10275 t.Cleanup(ctrl.Close)
10276 jm.StartForSession(agent.BranchID(path), "bash", label, func(ctx context.Context, _ io.Writer) (string, error) {
10277 <-ctx.Done()
10278 return "", ctx.Err()
10279 })
10280 return ctrl
10281 }
10282
10283 func hasLevel(levels []string, want string) bool {
10284 for _, level := range levels {
10285 if level == want {
10286 return true
10287 }
10288 }
10289 return false
10290 }
10291
10292 func hasCommand(cmds []CommandInfo, name string) bool {
10293 for _, cmd := range cmds {
10294 if cmd.Name == name {
10295 return true
10296 }
10297 }
10298 return false
10299 }
10300
10301 func hasDirEntry(entries []DirEntry, name string) bool {
10302 for _, entry := range entries {
10303 if entry.Name == name {
10304 return true
10305 }
10306 }
10307 return false
10308 }
10309
10310 func TestSessionActionsWithoutControllerReturnError(t *testing.T) {
10311 app := &App{tabs: map[string]*WorkspaceTab{}}
10312 if err := app.NewSession(); err == nil {
10313 t.Error("NewSession with no controller must surface an error, not silently no-op")
10314 }
10315 if err := app.ClearSession(); err == nil {
10316 t.Error("ClearSession with no controller must surface an error")
10317 }
10318
10319 app = &App{
10320 tabs: map[string]*WorkspaceTab{"t1": {ID: "t1", StartupErr: "boot exploded"}},
10321 activeTabID: "t1",
10322 }
10323 err := app.NewSession()
10324 if err == nil || !strings.Contains(err.Error(), "boot exploded") {
10325 t.Errorf("error should carry the tab's startup failure, got %v", err)
10326 }
10327 }
10328
10329 // --- Prompt history scanning tests ------------------------------------------
10330
10331 func identityPromptDisplay(text string) string { return text }
10332
10333 // TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format
10334 // {"kind":"user.message","text":"..."} is correctly extracted.
10335 func TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {
10336 dir := t.TempDir()
10337 path := filepath.Join(dir, "session.jsonl")
10338 if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"hello world"}
10339 {"kind":"user.message","text":"second prompt"}
10340 {"kind":"model.final","content":"response"}
10341 `), 0o644); err != nil {
10342 t.Fatal(err)
10343 }
10344 info, err := os.Stat(path)
10345 if err != nil {
10346 t.Fatal(err)
10347 }
10348 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
10349 if err != nil {
10350 t.Fatal(err)
10351 }
10352 if len(entries) != 2 {
10353 t.Fatalf("expected 2 entries, got %d", len(entries))
10354 }
10355 if entries[0].Text != "hello world" {
10356 t.Errorf("expected 'hello world', got %q", entries[0].Text)
10357 }
10358 if entries[1].Text != "second prompt" {
10359 t.Errorf("expected 'second prompt', got %q", entries[1].Text)
10360 }
10361 if entries[0].Turn != 0 || entries[1].Turn != 1 {
10362 t.Errorf("expected turns 0,1; got %d,%d", entries[0].Turn, entries[1].Turn)
10363 }
10364 if entries[0].SessionPath != path {
10365 t.Errorf("expected session path %q, got %q", path, entries[0].SessionPath)
10366 }
10367 }
10368
10369 // TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event
10370 // format {"type":"user.message","text":"..."} is correctly extracted.
10371 func TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {
10372 dir := t.TempDir()
10373 path := filepath.Join(dir, "session.jsonl")
10374 if err := os.WriteFile(path, []byte(`{"type":"user.message","text":"v0 prompt"}
10375 {"type":"model.final","content":"response"}
10376 `), 0o644); err != nil {
10377 t.Fatal(err)
10378 }
10379 info, err := os.Stat(path)
10380 if err != nil {
10381 t.Fatal(err)
10382 }
10383 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
10384 if err != nil {
10385 t.Fatal(err)
10386 }
10387 if len(entries) != 1 {
10388 t.Fatalf("expected 1 entry, got %d", len(entries))
10389 }
10390 if entries[0].Text != "v0 prompt" {
10391 t.Errorf("expected 'v0 prompt', got %q", entries[0].Text)
10392 }
10393 }
10394
10395 // TestCollectPromptHistoryEntriesProviderMessage verifies that the current
10396 // provider.Message format {"role":"user","content":"..."} is correctly extracted.
10397 func TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {
10398 dir := t.TempDir()
10399 path := filepath.Join(dir, "session.jsonl")
10400 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello from provider"}
10401 {"role":"assistant","content":"response"}
10402 {"role":"user","content":"another prompt"}
10403 `), 0o644); err != nil {
10404 t.Fatal(err)
10405 }
10406 info, err := os.Stat(path)
10407 if err != nil {
10408 t.Fatal(err)
10409 }
10410 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
10411 if err != nil {
10412 t.Fatal(err)
10413 }
10414 if len(entries) != 2 {
10415 t.Fatalf("expected 2 entries, got %d", len(entries))
10416 }
10417 if entries[0].Text != "hello from provider" {
10418 t.Errorf("expected 'hello from provider', got %q", entries[0].Text)
10419 }
10420 if entries[1].Text != "another prompt" {
10421 t.Errorf("expected 'another prompt', got %q", entries[1].Text)
10422 }
10423 }
10424
10425 // TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the
10426 // same file are extracted.
10427 func TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {
10428 dir := t.TempDir()
10429 path := filepath.Join(dir, "session.jsonl")
10430 if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy prompt"}
10431 {"role":"user","content":"modern prompt"}
10432 `), 0o644); err != nil {
10433 t.Fatal(err)
10434 }
10435 info, err := os.Stat(path)
10436 if err != nil {
10437 t.Fatal(err)
10438 }
10439 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
10440 if err != nil {
10441 t.Fatal(err)
10442 }
10443 if len(entries) != 2 {
10444 t.Fatalf("expected 2 entries, got %d", len(entries))
10445 }
10446 if entries[0].Text != "legacy prompt" {
10447 t.Errorf("expected 'legacy prompt', got %q", entries[0].Text)
10448 }
10449 if entries[1].Text != "modern prompt" {
10450 t.Errorf("expected 'modern prompt', got %q", entries[1].Text)
10451 }
10452 }
10453
10454 func TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {
10455 dir := t.TempDir()
10456 path := filepath.Join(dir, "session.jsonl")
10457 rfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)
10458 if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy timed","time":1800000000123}
10459 {"role":"user","content":"modern timed","createdAt":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}
10460 `), 0o644); err != nil {
10461 t.Fatal(err)
10462 }
10463 info, err := os.Stat(path)
10464 if err != nil {
10465 t.Fatal(err)
10466 }
10467 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
10468 if err != nil {
10469 t.Fatal(err)
10470 }
10471 if len(entries) != 2 {
10472 t.Fatalf("expected 2 entries, got %d", len(entries))
10473 }
10474 if entries[0].At != 1800000000123 {
10475 t.Errorf("numeric event time = %d, want 1800000000123", entries[0].At)
10476 }
10477 if entries[1].At != rfcTime.UnixMilli() {
10478 t.Errorf("RFC3339 event time = %d, want %d", entries[1].At, rfcTime.UnixMilli())
10479 }
10480 }
10481
10482 // TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses
10483 // the user-visible prompt text, not the controller-expanded model input.
10484 func TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {
10485 dir := t.TempDir()
10486 path := filepath.Join(dir, "session.jsonl")
10487 expanded := "<memory-update>\nSaved memory\n</memory-update>\n\nvisible prompt"
10488 if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(expanded)+`}`+"\n"), 0o644); err != nil {
10489 t.Fatal(err)
10490 }
10491 if err := recordSessionDisplay(dir, path, expanded, "visible prompt"); err != nil {
10492 t.Fatal(err)
10493 }
10494 info, err := os.Stat(path)
10495 if err != nil {
10496 t.Fatal(err)
10497 }
10498 entries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))
10499 if err != nil {
10500 t.Fatal(err)
10501 }
10502 if len(entries) != 1 {
10503 t.Fatalf("expected 1 entry, got %d", len(entries))
10504 }
10505 if entries[0].Text != "visible prompt" {
10506 t.Errorf("expected visible prompt, got %q", entries[0].Text)
10507 }
10508 }
10509
10510 func TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {
10511 dir := t.TempDir()
10512 path := filepath.Join(dir, "session.jsonl")
10513 if err := os.WriteFile(path, []byte(`{"role":"user","content":"Plan approved — plan mode is off"}
10514 {"role":"user","content":"real prompt"}
10515 `), 0o644); err != nil {
10516 t.Fatal(err)
10517 }
10518 info, err := os.Stat(path)
10519 if err != nil {
10520 t.Fatal(err)
10521 }
10522 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
10523 if err != nil {
10524 t.Fatal(err)
10525 }
10526 if len(entries) != 1 {
10527 t.Fatalf("expected 1 entry, got %d", len(entries))
10528 }
10529 if entries[0].Text != "real prompt" {
10530 t.Errorf("expected real prompt, got %q", entries[0].Text)
10531 }
10532 }
10533
10534 // TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only
10535 // assistant/tool messages returns no entries.
10536 func TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {
10537 dir := t.TempDir()
10538 path := filepath.Join(dir, "session.jsonl")
10539 if err := os.WriteFile(path, []byte(`{"kind":"model.final","content":"response"}
10540 {"kind":"tool.result","output":"done"}
10541 `), 0o644); err != nil {
10542 t.Fatal(err)
10543 }
10544 info, err := os.Stat(path)
10545 if err != nil {
10546 t.Fatal(err)
10547 }
10548 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
10549 if err != nil {
10550 t.Fatal(err)
10551 }
10552 if len(entries) != 0 {
10553 t.Errorf("expected 0 entries, got %d", len(entries))
10554 }
10555 }
10556
10557 // TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file
10558 // returns no entries without error.
10559 func TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {
10560 dir := t.TempDir()
10561 path := filepath.Join(dir, "empty.jsonl")
10562 if err := os.WriteFile(path, nil, 0o644); err != nil {
10563 t.Fatal(err)
10564 }
10565 info, err := os.Stat(path)
10566 if err != nil {
10567 t.Fatal(err)
10568 }
10569 entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
10570 if err != nil {
10571 t.Fatal(err)
10572 }
10573 if len(entries) != 0 {
10574 t.Errorf("expected 0 entries, got %d", len(entries))
10575 }
10576 }
10577
10578 // TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans
10579 // multiple JSONL files and returns prompts newest-first.
10580 func TestScanPromptHistoryFromDir(t *testing.T) {
10581 app := &App{tabs: map[string]*WorkspaceTab{"t1": {ID: "t1", Ctrl: nil, WorkspaceRoot: ""}}}
10582 _ = app
10583
10584 dir := t.TempDir()
10585 // Write two session files with different mtimes (sleep to ensure ordering).
10586 if err := os.WriteFile(filepath.Join(dir, "a.jsonl"), []byte(`{"role":"user","content":"older prompt"}
10587 `), 0o644); err != nil {
10588 t.Fatal(err)
10589 }
10590 time.Sleep(10 * time.Millisecond)
10591 if err := os.WriteFile(filepath.Join(dir, "b.jsonl"), []byte(`{"role":"user","content":"newer prompt"}
10592 `), 0o644); err != nil {
10593 t.Fatal(err)
10594 }
10595
10596 entries, err := app.scanPromptHistoryFromDir(dir)
10597 if err != nil {
10598 t.Fatal(err)
10599 }
10600 if len(entries) != 2 {
10601 t.Fatalf("expected 2 entries, got %d", len(entries))
10602 }
10603 // Newest-first: "newer prompt" should be first.
10604 if entries[0].Text != "newer prompt" {
10605 t.Errorf("expected 'newer prompt' first, got %q", entries[0].Text)
10606 }
10607 if entries[1].Text != "older prompt" {
10608 t.Errorf("expected 'older prompt' second, got %q", entries[1].Text)
10609 }
10610 }
10611
10612 func TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {
10613 app := &App{}
10614 dir := t.TempDir()
10615 base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10616 early := filepath.Join(dir, "early.jsonl")
10617 late := filepath.Join(dir, "late.jsonl")
10618
10619 if err := os.WriteFile(early, []byte(fmt.Sprintf(`{"role":"user","content":"early first","time":%d}
10620 {"role":"assistant","content":"ok"}
10621 {"role":"user","content":"early second","time":%d}
10622 `, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {
10623 t.Fatal(err)
10624 }
10625 if err := os.WriteFile(late, []byte(fmt.Sprintf(`{"role":"user","content":"late newest","time":%d}
10626 `, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {
10627 t.Fatal(err)
10628 }
10629 // Invert file mtimes: session activity should keep each session grouped
10630 // before event timestamps are considered within that session.
10631 if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {
10632 t.Fatal(err)
10633 }
10634 if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {
10635 t.Fatal(err)
10636 }
10637
10638 entries, err := app.scanPromptHistoryFromDir(dir)
10639 if err != nil {
10640 t.Fatal(err)
10641 }
10642 if len(entries) != 3 {
10643 t.Fatalf("expected 3 entries, got %d", len(entries))
10644 }
10645 want := []string{"early second", "early first", "late newest"}
10646 for i, w := range want {
10647 if entries[i].Text != w {
10648 t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries)
10649 }
10650 }
10651 }
10652
10653 func TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {
10654 app := &App{}
10655 dir := t.TempDir()
10656 base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10657 early := filepath.Join(dir, "early.jsonl")
10658 late := filepath.Join(dir, "late.jsonl")
10659
10660 if err := os.WriteFile(early, []byte(`{"role":"user","content":"early first"}
10661 {"role":"assistant","content":"ok"}
10662 {"role":"user","content":"early second"}
10663 `), 0o644); err != nil {
10664 t.Fatal(err)
10665 }
10666 if err := os.WriteFile(late, []byte(`{"role":"user","content":"late newest"}
10667 `), 0o644); err != nil {
10668 t.Fatal(err)
10669 }
10670 if err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{
10671 CreatedAt: base,
10672 UpdatedAt: base.Add(time.Minute),
10673 }); err != nil {
10674 t.Fatal(err)
10675 }
10676 if err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{
10677 CreatedAt: base.Add(time.Minute),
10678 UpdatedAt: base.Add(2 * time.Minute),
10679 }); err != nil {
10680 t.Fatal(err)
10681 }
10682 // Invert file mtimes: branch UpdatedAt should be the activity clock.
10683 if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {
10684 t.Fatal(err)
10685 }
10686 if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {
10687 t.Fatal(err)
10688 }
10689
10690 entries, err := app.scanPromptHistoryFromDir(dir)
10691 if err != nil {
10692 t.Fatal(err)
10693 }
10694 if len(entries) != 3 {
10695 t.Fatalf("expected 3 entries, got %d", len(entries))
10696 }
10697 want := []string{"late newest", "early second", "early first"}
10698 for i, w := range want {
10699 if entries[i].Text != w {
10700 t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries)
10701 }
10702 }
10703 }
10704
10705 func TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {
10706 app := &App{}
10707 dir := t.TempDir()
10708 base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10709 empty := filepath.Join(dir, "empty.jsonl")
10710 real := filepath.Join(dir, "real.jsonl")
10711
10712 if err := os.WriteFile(empty, nil, 0o644); err != nil {
10713 t.Fatal(err)
10714 }
10715 if err := os.WriteFile(real, []byte(`{"role":"user","content":"real prompt"}
10716 `), 0o644); err != nil {
10717 t.Fatal(err)
10718 }
10719 if err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{
10720 CreatedAt: base,
10721 UpdatedAt: base.Add(time.Hour),
10722 }); err != nil {
10723 t.Fatal(err)
10724 }
10725 if err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{
10726 CreatedAt: base,
10727 UpdatedAt: base,
10728 }); err != nil {
10729 t.Fatal(err)
10730 }
10731
10732 entries, err := app.scanPromptHistoryFromDir(dir)
10733 if err != nil {
10734 t.Fatal(err)
10735 }
10736 if len(entries) != 1 || entries[0].Text != "real prompt" {
10737 t.Fatalf("entries = %+v, want only real prompt after skipping empty session", entries)
10738 }
10739 }
10740
10741 func TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {
10742 dir := t.TempDir()
10743 current := filepath.Join(dir, "current.jsonl")
10744 other := filepath.Join(dir, "other.jsonl")
10745 if err := os.WriteFile(current, []byte(`{"role":"user","content":"current first"}
10746 {"role":"assistant","content":"ok"}
10747 {"role":"user","content":"current second"}
10748 `), 0o644); err != nil {
10749 t.Fatal(err)
10750 }
10751 if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"}
10752 `), 0o644); err != nil {
10753 t.Fatal(err)
10754 }
10755 now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10756 if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{
10757 CreatedAt: now,
10758 UpdatedAt: now,
10759 }); err != nil {
10760 t.Fatal(err)
10761 }
10762 if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{
10763 CreatedAt: now.Add(time.Minute),
10764 UpdatedAt: now.Add(time.Minute),
10765 }); err != nil {
10766 t.Fatal(err)
10767 }
10768
10769 app := NewApp()
10770 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"})
10771 defer ctrl.Close()
10772 app.setTestCtrl(ctrl, "")
10773
10774 result, err := app.ScanPromptHistory("")
10775 if err != nil {
10776 t.Fatal(err)
10777 }
10778 if len(result.Entries) != 3 {
10779 t.Fatalf("expected current-session entries followed by cross-session fallback, got %d: %+v", len(result.Entries), result.Entries)
10780 }
10781 want := []string{"current second", "current first", "other newest"}
10782 for i, w := range want {
10783 if result.Entries[i].Text != w {
10784 t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, result.Entries[i].Text, w, result.Entries)
10785 }
10786 }
10787 }
10788
10789 func TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {
10790 dir := t.TempDir()
10791 current := filepath.Join(dir, "current.jsonl")
10792 other := filepath.Join(dir, "other.jsonl")
10793 var lines []byte
10794 for i := range 55 {
10795 lines = append(lines, []byte(fmt.Sprintf(`{"role":"user","content":"current %d"}
10796 `, i))...)
10797 }
10798 if err := os.WriteFile(current, lines, 0o644); err != nil {
10799 t.Fatal(err)
10800 }
10801 if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"}
10802 `), 0o644); err != nil {
10803 t.Fatal(err)
10804 }
10805 now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
10806 if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{
10807 CreatedAt: now,
10808 UpdatedAt: now,
10809 }); err != nil {
10810 t.Fatal(err)
10811 }
10812 if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{
10813 CreatedAt: now.Add(time.Minute),
10814 UpdatedAt: now.Add(time.Minute),
10815 }); err != nil {
10816 t.Fatal(err)
10817 }
10818
10819 app := NewApp()
10820 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"})
10821 defer ctrl.Close()
10822 app.setTestCtrl(ctrl, "")
10823
10824 result, err := app.ScanPromptHistory("")
10825 if err != nil {
10826 t.Fatal(err)
10827 }
10828 if len(result.Entries) != promptHistoryPageLimit {
10829 t.Fatalf("expected %d entries, got %d", promptHistoryPageLimit, len(result.Entries))
10830 }
10831 if result.Entries[0].Text != "current 54" {
10832 t.Fatalf("first entry = %q, want current 54", result.Entries[0].Text)
10833 }
10834 if result.Entries[len(result.Entries)-1].Text != "current 5" {
10835 t.Fatalf("last first-page entry = %q, want current 5", result.Entries[len(result.Entries)-1].Text)
10836 }
10837 if !result.HasOlder || result.OlderCursor == "" {
10838 t.Fatalf("first page should expose an older cursor: %+v", result)
10839 }
10840 for _, entry := range result.Entries {
10841 if entry.Text == "other newest" {
10842 t.Fatalf("cross-session entry appeared before current-session page was exhausted: %+v", result.Entries)
10843 }
10844 }
10845
10846 nextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})
10847 if err != nil {
10848 t.Fatal(err)
10849 }
10850 next, err := app.ScanPromptHistory(string(nextRequest))
10851 if err != nil {
10852 t.Fatal(err)
10853 }
10854 want := []string{"current 4", "current 3", "current 2", "current 1", "current 0", "other newest"}
10855 if len(next.Entries) != len(want) {
10856 t.Fatalf("second page entries = %+v, want %d entries", next.Entries, len(want))
10857 }
10858 for i, w := range want {
10859 if next.Entries[i].Text != w {
10860 t.Fatalf("second page entries[%d] = %q, want %q; all=%+v", i, next.Entries[i].Text, w, next.Entries)
10861 }
10862 }
10863 }
10864
10865 func TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {
10866 app := &App{}
10867 dir := t.TempDir()
10868 var lines []byte
10869 for i := range 250 {
10870 lines = append(lines, []byte(fmt.Sprintf(`{"role":"user","content":"prompt %d"}
10871 `, i))...)
10872 }
10873 if err := os.WriteFile(filepath.Join(dir, "many.jsonl"), lines, 0o644); err != nil {
10874 t.Fatal(err)
10875 }
10876 entries, err := app.scanPromptHistoryFromDir(dir)
10877 if err != nil {
10878 t.Fatal(err)
10879 }
10880 if len(entries) != 250 {
10881 t.Fatalf("expected 250 entries, got %d", len(entries))
10882 }
10883 if entries[0].Text != "prompt 249" {
10884 t.Errorf("expected newest 'prompt 249' first, got %q", entries[0].Text)
10885 }
10886 }
10887
10888 // TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.
10889 func TestScanPromptHistoryFromDirEmpty(t *testing.T) {
10890 app := &App{}
10891 dir := t.TempDir()
10892 entries, err := app.scanPromptHistoryFromDir(dir)
10893 if err != nil {
10894 t.Fatal(err)
10895 }
10896 if len(entries) != 0 {
10897 t.Errorf("expected 0 entries, got %d", len(entries))
10898 }
10899 }
10900
10901 // TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil
10902 // on cache hit (nonce matches).
10903 func TestScanPromptHistoryCacheHit(t *testing.T) {
10904 app := &App{tabs: map[string]*WorkspaceTab{}}
10905 result, err := app.ScanPromptHistory("")
10906 if err != nil {
10907 t.Fatal(err)
10908 }
10909 nonce := result.Nonce
10910 if nonce == "" {
10911 t.Error("expected a non-empty nonce on first call")
10912 }
10913
10914 // Second call with the same nonce should be a cache hit (nil entries).
10915 result2, err := app.ScanPromptHistory(nonce)
10916 if err != nil {
10917 t.Fatal(err)
10918 }
10919 if result2.Entries != nil {
10920 t.Error("expected nil entries on cache hit")
10921 }
10922 if result2.Nonce != nonce {
10923 t.Errorf("expected nonce %q unchanged, got %q", nonce, result2.Nonce)
10924 }
10925 }
10926
10927 func TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {
10928 dirA := t.TempDir()
10929 dirB := t.TempDir()
10930 pathA := filepath.Join(dirA, "a.jsonl")
10931 pathB := filepath.Join(dirB, "b.jsonl")
10932 if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"}
10933 `), 0o644); err != nil {
10934 t.Fatal(err)
10935 }
10936 if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"}
10937 `), 0o644); err != nil {
10938 t.Fatal(err)
10939 }
10940
10941 app := NewApp()
10942 ctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"})
10943 ctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: "test"})
10944 defer ctrlA.Close()
10945 defer ctrlB.Close()
10946
10947 app.setTestCtrl(ctrlA, "")
10948 first, err := app.ScanPromptHistory("")
10949 if err != nil {
10950 t.Fatal(err)
10951 }
10952 if len(first.Entries) != 1 || first.Entries[0].Text != "workspace A" {
10953 t.Fatalf("first entries = %+v, want workspace A", first.Entries)
10954 }
10955
10956 app.setTestCtrl(ctrlB, "")
10957 second, err := app.ScanPromptHistory(first.Nonce)
10958 if err != nil {
10959 t.Fatal(err)
10960 }
10961 if second.Entries == nil {
10962 t.Fatal("expected rescan after session dir changes, got cache hit")
10963 }
10964 if len(second.Entries) != 1 || second.Entries[0].Text != "workspace B" {
10965 t.Fatalf("second entries = %+v, want workspace B", second.Entries)
10966 }
10967 }
10968
10969 func TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {
10970 dir := t.TempDir()
10971 pathA := filepath.Join(dir, "a.jsonl")
10972 pathB := filepath.Join(dir, "b.jsonl")
10973 if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"session A"}
10974 `), 0o644); err != nil {
10975 t.Fatal(err)
10976 }
10977 if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"session B"}
10978 `), 0o644); err != nil {
10979 t.Fatal(err)
10980 }
10981
10982 app := NewApp()
10983 ctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: "test"})
10984 ctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: "test"})
10985 defer ctrlA.Close()
10986 defer ctrlB.Close()
10987
10988 app.setTestCtrl(ctrlA, "")
10989 first, err := app.ScanPromptHistory("")
10990 if err != nil {
10991 t.Fatal(err)
10992 }
10993 if len(first.Entries) != 2 || first.Entries[0].Text != "session A" || first.Entries[1].Text != "session B" {
10994 t.Fatalf("first entries = %+v, want session A followed by session B", first.Entries)
10995 }
10996
10997 app.setTestCtrl(ctrlB, "")
10998 second, err := app.ScanPromptHistory(first.Nonce)
10999 if err != nil {
11000 t.Fatal(err)
11001 }
11002 if second.Entries == nil {
11003 t.Fatal("expected rescan after session path changes, got cache hit")
11004 }
11005 if len(second.Entries) != 2 || second.Entries[0].Text != "session B" || second.Entries[1].Text != "session A" {
11006 t.Fatalf("second entries = %+v, want session B followed by session A", second.Entries)
11007 }
11008 }
11009
11009 lines GO