package main import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "reflect" "strconv" "strings" "sync" "sync/atomic" "testing" "time" "reasonix/internal/agent" "reasonix/internal/billing" "reasonix/internal/boot" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/event" "reasonix/internal/jobs" "reasonix/internal/memory" "reasonix/internal/plugin" "reasonix/internal/pluginpkg" "reasonix/internal/provider" "reasonix/internal/sandbox" "reasonix/internal/skill" "reasonix/internal/store" "reasonix/internal/tool" ) func desktopMCPHTTPServer(t *testing.T) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { ID *int `json:"id"` Method string `json:"method"` Params json.RawMessage `json:"params"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad body", http.StatusBadRequest) return } if req.ID == nil { w.WriteHeader(http.StatusAccepted) return } var result any switch req.Method { case "initialize": result = map[string]any{ "protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "h", "version": "0"}, } case "tools/list": result = map[string]any{"tools": []map[string]any{{ "name": "greet", "description": "Greet someone.", "inputSchema": map[string]any{"type": "object"}, }}} default: result = map[string]any{} } resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result} w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) } // setTestCtrl creates a minimal workspace tab (if needed) and sets its // controller, so tests don't depend on the old App.ctrl field. func (a *App) setTestCtrl(ctrl control.SessionAPI, model string) { if len(a.tabs) == 0 { tab := &WorkspaceTab{ ID: "test", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}, } a.tabs = map[string]*WorkspaceTab{"test": tab} a.activeTabID = "test" } tab := a.tabs["test"] tab.Ctrl = ctrl a.bindControllerDisplayRecorder(ctrl) tab.model = model } func isolateDesktopUserDirs(t *testing.T) string { t.Helper() home := robustTempDir(t) xdg := filepath.Join(home, ".config") appData := filepath.Join(home, "AppData") for _, dir := range []string{xdg, appData} { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } } t.Setenv("HOME", home) t.Setenv("REASONIX_CREDENTIALS_STORE", "file") t.Setenv("USERPROFILE", home) t.Setenv("XDG_CONFIG_HOME", xdg) t.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state")) t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache")) t.Setenv("AppData", appData) return home } func primarySessionFiles(paths []string) []string { out := make([]string, 0, len(paths)) for _, path := range paths { if store.IsSessionTranscriptName(filepath.Base(path)) { out = append(out, path) } } return out } func readConflictLogLines(t *testing.T, path string) []string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read conflict log: %v", err) } text := strings.TrimSpace(string(data)) if text == "" { return nil } return strings.Split(text, "\n") } func setDesktopTestCredential(t *testing.T, key, value string) { t.Helper() if _, err := config.SetCredential(key, value); err != nil { t.Fatalf("SetCredential(%s): %v", key, err) } } func TestNeedsOnboardingIgnoresInheritedEnv(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv(onboardingKeyEnv, "inherited-key") app := NewApp() if !app.NeedsOnboarding() { t.Fatal("NeedsOnboarding should require a key saved in Reasonix global .env") } setDesktopTestCredential(t, onboardingKeyEnv, "saved-key") if app.NeedsOnboarding() { t.Fatal("NeedsOnboarding should be false after saving the global credential") } } func TestNeedsOnboardingTreatsBlankSavedKeyAsMissing(t *testing.T) { isolateDesktopUserDirs(t) if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(config.UserCredentialsPath(), []byte(onboardingKeyEnv+"=\n"), 0o600); err != nil { t.Fatal(err) } app := NewApp() if !app.NeedsOnboarding() { t.Fatal("NeedsOnboarding should require a non-empty saved credential") } } func providerNamesFromView(providers []ProviderView) []string { out := make([]string, 0, len(providers)) for _, p := range providers { out = append(out, p.Name) } return out } func modelRefsFromView(models []ModelInfo) map[string]bool { out := map[string]bool{} for _, m := range models { out[m.Ref] = true } return out } type desktopFakeTool struct { name string } func (t desktopFakeTool) Name() string { return t.name } func (desktopFakeTool) Description() string { return "fake desktop tool" } func (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } func (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return "", nil } func (desktopFakeTool) ReadOnly() bool { return true } type desktopAskRuntimeRunner struct { ask func(context.Context) error } func (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error { if r.ask == nil { return nil } return r.ask(ctx) } func TestCommandsIncludesEffortNotThinking(t *testing.T) { app := NewApp() cmds := app.Commands() if !hasCommand(cmds, "effort") { t.Fatalf("Commands() should include effort: %+v", cmds) } if hasCommand(cmds, "thinking") { t.Fatalf("Commands() should not include thinking: %+v", cmds) } } func TestCommandsClassifiesSubagentSkills(t *testing.T) { ctrl := control.New(control.Options{Skills: []skill.Skill{ {Name: "init", Description: "inline skill", RunAs: skill.RunInline}, {Name: "explore", Description: "isolated skill", RunAs: skill.RunSubagent, Color: "amber"}, }}) defer ctrl.Close() app := NewApp() app.setTestCtrl(ctrl, "") kinds := map[string]string{} groups := map[string]string{} colors := map[string]string{} for _, cmd := range app.Commands() { kinds[cmd.Name] = cmd.Kind groups[cmd.Name] = cmd.Group colors[cmd.Name] = cmd.Color } if kinds["init"] != "skill" { t.Fatalf("inline skill kind = %q, want skill", kinds["init"]) } if kinds["explore"] != "subagent" { t.Fatalf("subagent skill kind = %q, want subagent", kinds["explore"]) } if colors["explore"] != "amber" { t.Fatalf("subagent skill color = %q, want amber", colors["explore"]) } if groups["new"] != "actions" { t.Fatalf("new command group = %q, want actions", groups["new"]) } if groups["mcp"] != "integrations" || groups["plugins"] != "integrations" { t.Fatalf("integration command groups = mcp:%q plugins:%q", groups["mcp"], groups["plugins"]) } if groups["skill"] != "skills" { t.Fatalf("skill command group = %q, want skills", groups["skill"]) } } func TestMetaForTabIncludesWorkspaceContext(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not installed") } isolateDesktopUserDirs(t) resetWorkspaceGitBranchMetaCacheForTest(t) repo := t.TempDir() configuredSandboxRoot := filepath.Join(t.TempDir(), "sandbox") cfg := config.LoadForEdit(config.UserConfigPath()) cfg.Sandbox.WorkspaceRoot = configuredSandboxRoot if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatal(err) } orig, err := os.Getwd() if err != nil { t.Fatal(err) } defer func() { if err := os.Chdir(orig); err != nil { t.Fatal(err) } }() if err := os.Chdir(repo); err != nil { t.Fatal(err) } runGit(t, "init") runGit(t, "checkout", "-b", "feature/meta") app := NewApp() app.tabs = map[string]*WorkspaceTab{"tab-1": { ID: "tab-1", Scope: "project", WorkspaceRoot: repo, Ready: true, disabledMCP: map[string]ServerView{}, }} app.activeTabID = "tab-1" got := app.MetaForTab("tab-1") if got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo { t.Fatalf("workspace fields = cwd:%q root:%q path:%q, want %q", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo) } if got.WorkspaceName != filepath.Base(repo) { t.Fatalf("workspaceName = %q, want %q", got.WorkspaceName, filepath.Base(repo)) } raw, err := json.Marshal(got) if err != nil { t.Fatalf("marshal meta: %v", err) } if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) { t.Fatalf("meta should not expose configured sandbox root as sandboxPath: %s", raw) } deadline := time.Now().Add(time.Second) for { if got = app.MetaForTab("tab-1"); got.GitBranch == "feature/meta" { break } if time.Now().After(deadline) { t.Fatalf("gitBranch = %q, want feature/meta after async refresh", got.GitBranch) } time.Sleep(10 * time.Millisecond) } } func TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) { isolateDesktopUserDirs(t) workspace := t.TempDir() configuredSandboxRoot := filepath.Join(t.TempDir(), "sandbox") cfg := config.LoadForEdit(config.UserConfigPath()) cfg.Sandbox.WorkspaceRoot = configuredSandboxRoot if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatal(err) } app := NewApp() app.tabs = map[string]*WorkspaceTab{"tab-1": { ID: "tab-1", Scope: "project", WorkspaceRoot: workspace, Ready: true, disabledMCP: map[string]ServerView{}, }} app.activeTabID = "tab-1" app.tabOrder = []string{"tab-1"} raw, err := json.Marshal(app.ListTabs()) if err != nil { t.Fatalf("marshal tabs: %v", err) } if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) { t.Fatalf("tab metadata should not expose configured sandbox root as sandboxPath: %s", raw) } } func TestListTabsExposesStructuredRuntimeStatus(t *testing.T) { asks := make(chan event.Ask, 1) done := make(chan event.Event, 1) runner := &desktopAskRuntimeRunner{} ctrl := control.New(control.Options{ Runner: runner, Sink: event.FuncSink(func(e event.Event) { switch e.Kind { case event.AskRequest: asks <- e.Ask case event.TurnDone: done <- e } }), }) runner.ask = func(ctx context.Context) error { _, err := ctrl.Ask(ctx, []event.AskQuestion{{ ID: "choice", Prompt: "Pick one", Options: []event.AskOption{{Label: "A"}, {Label: "B"}}, }}) return err } app := NewApp() app.setTestCtrl(ctrl, "prov/model") app.tabOrder = []string{"test"} ctrl.Send("ask user") select { case <-asks: case <-time.After(2 * time.Second): t.Fatal("timed out waiting for ask request") } tabs := app.ListTabs() if len(tabs) != 1 { t.Fatalf("tabs = %d, want 1", len(tabs)) } if !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested { t.Fatalf("tab runtime = running:%v pending:%v cancellable:%v cancel:%v", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested) } app.CancelTab("test") select { case <-done: case <-time.After(2 * time.Second): t.Fatal("timed out waiting for turn_done") } } func TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) { isolateDesktopUserDirs(t) workspace := t.TempDir() app := NewApp() app.tabs = map[string]*WorkspaceTab{"tab-1": { ID: "tab-1", Scope: "project", WorkspaceRoot: workspace, Ready: true, disabledMCP: map[string]ServerView{}, }} app.activeTabID = "tab-1" if got := app.MetaForTab("tab-1"); got.GitBranch != "" { t.Fatalf("gitBranch = %q, want empty", got.GitBranch) } } func TestEffortDefaultsBeforeStartup(t *testing.T) { isolateDesktopUserDirs(t) got := NewApp().Effort() if !got.Supported || got.Current != "auto" || got.Default != "high" || !hasLevel(got.Levels, "auto") { t.Fatalf("pre-startup Effort() = %+v, want auto with DeepSeek default high", got) } } func TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) { isolateDesktopUserDirs(t) view := NewApp().Memory() if view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil { t.Fatalf("Memory() arrays must be non-nil before startup: %+v", view) } raw, err := json.Marshal(view) if err != nil { t.Fatalf("marshal Memory(): %v", err) } for _, bad := range []string{`"docs":null`, `"facts":null`, `"archives":null`, `"scopes":null`} { if strings.Contains(string(raw), bad) { t.Fatalf("Memory() JSON contains %s; frontend expects []: %s", bad, raw) } } } func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) { isolateDesktopUserDirs(t) userDir := t.TempDir() cwd := t.TempDir() store := memory.Store{Dir: filepath.Join(userDir, "projects", "test", "memory")} if _, err := store.Save(memory.Memory{ Name: "active-fact", Title: "Active fact", Description: "Still applies", Type: memory.TypeProject, Body: "Active body", }); err != nil { t.Fatal(err) } if _, err := store.Save(memory.Memory{ Name: "archived-fact", Description: "No longer applies", Type: memory.TypeFeedback, Body: "Archived body", }); err != nil { t.Fatal(err) } if _, err := store.Archive("archived-fact"); err != nil { t.Fatalf("Archive: %v", err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{ Docs: []memory.Source{{Path: filepath.Join(cwd, "AGENTS.md"), Scope: memory.ScopeProject, Body: "Project instructions"}}, Store: store, CWD: cwd, UserDir: userDir, }}), "test-model") view := app.Memory() if !view.Available || view.StoreDir != store.Dir { t.Fatalf("Memory() availability/store = %v/%q, want true/%q", view.Available, view.StoreDir, store.Dir) } if len(view.Docs) != 1 || view.Docs[0].Scope != "project" || !strings.Contains(view.Docs[0].Body, "Project instructions") { t.Fatalf("Memory() docs = %+v", view.Docs) } if len(view.Facts) != 1 || view.Facts[0].Name != "active-fact" || view.Facts[0].Type != "project" { t.Fatalf("Memory() active facts = %+v", view.Facts) } if len(view.Archives) != 1 || view.Archives[0].Name != "archived-fact" || view.Archives[0].Type != "feedback" || view.Archives[0].Path == "" || view.Archives[0].ArchivedAt == "" { t.Fatalf("Memory() archived facts = %+v", view.Archives) } if len(view.Scopes) != 3 { t.Fatalf("Memory() scopes = %+v, want user/project/local", view.Scopes) } } func TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) { isolateDesktopUserDirs(t) consumeSystemQuitRequested() t.Cleanup(func() { consumeSystemQuitRequested() }) userCfg := config.LoadForEdit(config.UserConfigPath()) if err := userCfg.SetDesktopCloseBehavior("background"); err != nil { t.Fatal(err) } if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatal(err) } markSystemQuitRequested() if prevent := NewApp().beforeClose(context.Background()); prevent { t.Fatal("system quit should bypass background close-to-tray behavior") } if consumeSystemQuitRequested() { t.Fatal("system quit marker should be consumed by beforeClose") } } func TestBackgroundCloseHideStrategyByPlatform(t *testing.T) { tests := []struct { goos string want bool }{ {goos: "darwin", want: true}, {goos: "windows", want: false}, {goos: "linux", want: false}, {goos: "freebsd", want: false}, } for _, tt := range tests { if got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want { t.Fatalf("backgroundCloseUsesApplicationHide(%q) = %v, want %v", tt.goos, got, tt.want) } } } func TestBackgroundCloseRequiresRestorePath(t *testing.T) { tests := []struct { name string goos string trayStarted bool trayReady bool want bool }{ {name: "macOS restores from Dock", goos: "darwin", trayStarted: false, trayReady: false, want: true}, {name: "Windows tray ready", goos: "windows", trayStarted: true, trayReady: true, want: true}, {name: "Windows tray started but not ready", goos: "windows", trayStarted: true, trayReady: false, want: false}, {name: "Linux tray ready", goos: "linux", trayStarted: true, trayReady: true, want: true}, {name: "Linux tray started but not ready", goos: "linux", trayStarted: true, trayReady: false, want: false}, {name: "Linux no tray", goos: "linux", trayStarted: false, trayReady: false, want: false}, {name: "other Unix no tray", goos: "freebsd", trayStarted: false, trayReady: false, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := backgroundCloseHasRestorePathFor(tt.goos, tt.trayStarted, tt.trayReady); got != tt.want { t.Fatalf("backgroundCloseHasRestorePathFor(%q, %v, %v) = %v, want %v", tt.goos, tt.trayStarted, tt.trayReady, got, tt.want) } }) } } func TestBackgroundCloseReadySignalRequiresCurrentReadyState(t *testing.T) { app := NewApp() tray := newDesktopTray() app.mu.Lock() app.tray = tray app.mu.Unlock() if app.waitForTrayReady(0) { t.Fatal("tray should not be ready before its ready signal") } tray.markReady() if app.waitForTrayReady(0) { t.Fatal("closed ready signal should not count without the current ready state") } app.mu.Lock() app.trayReady = true app.mu.Unlock() if !app.waitForTrayReady(0) { t.Fatal("ready state should be accepted after the tray is marked ready") } app.mu.Lock() app.trayReady = false app.mu.Unlock() if app.waitForTrayReady(0) { t.Fatal("stale ready signal should not count after the tray exits") } } func TestBackgroundCloseWaitsForTrayReadySignal(t *testing.T) { app := NewApp() tray := newDesktopTray() app.mu.Lock() app.tray = tray app.mu.Unlock() go func() { time.Sleep(10 * time.Millisecond) app.mu.Lock() app.trayReady = true app.mu.Unlock() tray.markReady() }() if !app.waitForTrayReady(200 * time.Millisecond) { t.Fatal("waitForTrayReady should observe the tray becoming ready") } } func TestBackgroundRestoreMaximiseStrategy(t *testing.T) { tests := []struct { goos string maximised bool want bool }{ {goos: "windows", maximised: true, want: true}, {goos: "linux", maximised: true, want: true}, {goos: "darwin", maximised: true, want: false}, {goos: "windows", maximised: false, want: false}, } for _, tt := range tests { if got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want { t.Fatalf("backgroundRestoreShouldMaximise(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want) } } } func TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) { tests := []struct { name string goos string maximised bool want backgroundRestorePlan }{ { name: "maximised Windows window", goos: "windows", maximised: true, want: backgroundRestorePlan{maximiseBeforeShow: true}, }, { name: "normal Windows window", goos: "windows", maximised: false, want: backgroundRestorePlan{unminimiseAfterShow: true}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := backgroundRestorePlanFor(tt.goos, tt.maximised) if !reflect.DeepEqual(got, tt.want) { t.Fatalf("backgroundRestorePlanFor(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want) } }) } } func TestEmitReadyInvokesReadyHook(t *testing.T) { app := NewApp() var calls int32 app.readyHook = func() { atomic.AddInt32(&calls, 1) } app.emitReady(context.TODO()) if got := atomic.LoadInt32(&calls); got != 1 { t.Fatalf("ready hook calls = %d, want 1", got) } } func TestSetEffortPersistsAndAutoClears(t *testing.T) { isolateDesktopUserDirs(t) app := NewApp() if err := app.SetEffort("max"); err != nil { t.Fatalf("SetEffort(max): %v", err) } if got := app.Effort().Current; got != "max" { t.Fatalf("Effort current = %q, want max", got) } if err := app.SetEffort("auto"); err != nil { t.Fatalf("SetEffort(auto): %v", err) } if got := app.Effort().Current; got != "auto" { t.Fatalf("Effort current = %q, want auto", got) } body, err := os.ReadFile(config.UserConfigPath()) if err != nil { t.Fatalf("read saved config: %v", err) } if strings.Contains(string(body), `effort = "max"`) { t.Fatalf("auto should clear explicit max effort:\n%s", body) } } func TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) { isolateDesktopUserDirs(t) project := robustTempDir(t) if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(` [desktop] language = "zh" layout_style = "workbench" theme = "light" theme_style = "glacier" close_behavior = "quit" status_bar_style = "icon" status_bar_items = ["cost", "balance"] `), 0o644); err != nil { t.Fatalf("write project config: %v", err) } userCfg := config.LoadForEdit(config.UserConfigPath()) if err := userCfg.SetDesktopLanguage("en"); err != nil { t.Fatalf("set desktop language: %v", err) } if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil { t.Fatalf("set desktop layout style: %v", err) } if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil { t.Fatalf("set desktop appearance: %v", err) } if err := userCfg.SetDesktopCloseBehavior("background"); err != nil { t.Fatalf("set desktop close behavior: %v", err) } if err := userCfg.SetDesktopStatusBarStyle("text"); err != nil { t.Fatalf("set desktop status bar style: %v", err) } if err := userCfg.SetDesktopStatusBarItems([]string{"model", "balance", "cache"}); err != nil { t.Fatalf("set desktop status bar items: %v", err) } if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save user config: %v", err) } orig, _ := os.Getwd() defer func() { _ = os.Chdir(orig) }() if err := os.Chdir(project); err != nil { t.Fatalf("chdir project: %v", err) } got := NewApp().Settings() if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" { 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) } if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.StatusBarItems, want) { t.Fatalf("desktop status bar items = %v, want user-level %v", got.StatusBarItems, want) } } func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) { isolateDesktopUserDirs(t) userCfg := config.LoadForEdit(config.UserConfigPath()) if err := userCfg.SetDesktopLanguage("en"); err != nil { t.Fatalf("set desktop language: %v", err) } if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil { t.Fatalf("set desktop layout style: %v", err) } if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil { t.Fatalf("set desktop appearance: %v", err) } if err := userCfg.SetDesktopStatusBarStyle("icon"); err != nil { t.Fatalf("set desktop status bar style: %v", err) } if err := userCfg.SetDesktopStatusBarItems([]string{"workspace", "git_branch", "model"}); err != nil { t.Fatalf("set desktop status bar items: %v", err) } if err := userCfg.SetDesktopCheckUpdates(false); err != nil { t.Fatalf("set desktop check updates: %v", err) } userCfg.Bot.Enabled = true userCfg.Bot.Allowlist.Enabled = true userCfg.Bot.Allowlist.QQUsers = []string{"alice"} if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save user config: %v", err) } got := NewApp().DesktopStartupSettings() if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates { t.Fatalf("DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs", got) } if want := []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got.StatusBarItems, want) { t.Fatalf("DesktopStartupSettings status bar items = %v, want %v", got.StatusBarItems, want) } if !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{"alice"}) { t.Fatalf("DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot", got.Bot) } raw, err := json.Marshal(got) if err != nil { t.Fatalf("marshal DesktopStartupSettings: %v", err) } if strings.Contains(string(raw), "providers") || strings.Contains(string(raw), "officialProviders") || strings.Contains(string(raw), "providerKinds") { t.Fatalf("DesktopStartupSettings must not include full Settings provider payload: %s", raw) } } func BenchmarkDesktopSettingsPayloads(b *testing.B) { home := b.TempDir() xdg := filepath.Join(home, ".config") appData := filepath.Join(home, "AppData") for _, dir := range []string{xdg, appData} { if err := os.MkdirAll(dir, 0o755); err != nil { b.Fatal(err) } } b.Setenv("HOME", home) b.Setenv("REASONIX_CREDENTIALS_STORE", "file") b.Setenv("USERPROFILE", home) b.Setenv("XDG_CONFIG_HOME", xdg) b.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state")) b.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache")) b.Setenv("AppData", appData) b.Setenv("SHARED_PROVIDER_KEY", "sk-test") cfg := config.LoadForEdit(config.UserConfigPath()) for i := 0; i < 40; i++ { cfg.Providers = append(cfg.Providers, config.ProviderEntry{ Name: fmt.Sprintf("custom-%02d", i), Kind: "openai", BaseURL: "https://example.invalid/v1", APIKeyEnv: "SHARED_PROVIDER_KEY", Models: []string{"model-a", "model-b"}, Default: "model-a", }) } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { b.Fatalf("save config: %v", err) } app := NewApp() b.Run("Settings", func(b *testing.B) { for i := 0; i < b.N; i++ { _ = app.Settings() } }) b.Run("DesktopStartupSettings", func(b *testing.B) { for i := 0; i < b.N; i++ { _ = app.DesktopStartupSettings() } }) } func TestSettingsIgnoresActiveWorkspaceDotEnvCredentialsWithUserConfig(t *testing.T) { isolateDesktopUserDirs(t) project := robustTempDir(t) launch := robustTempDir(t) if err := os.WriteFile(filepath.Join(project, ".env"), []byte("WORKSPACE_ONLY_KEY=from-project\n"), 0o600); err != nil { t.Fatalf("write project env: %v", err) } userCfg := config.LoadForEdit(config.UserConfigPath()) if err := userCfg.UpsertProvider(config.ProviderEntry{ Name: "workspace-provider", Kind: "openai", BaseURL: "https://workspace.example/v1", Model: "workspace-model", APIKeyEnv: "WORKSPACE_ONLY_KEY", }); err != nil { t.Fatalf("upsert provider: %v", err) } userCfg.Desktop.ProviderAccess = []string{"workspace-provider"} if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save user config: %v", err) } t.Setenv("WORKSPACE_ONLY_KEY", "") os.Unsetenv("WORKSPACE_ONLY_KEY") orig, _ := os.Getwd() defer func() { _ = os.Chdir(orig) }() if err := os.Chdir(launch); err != nil { t.Fatalf("chdir launch: %v", err) } app := NewApp() app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}} app.activeTabID = "project" got := app.Settings() for _, p := range got.Providers { if p.Name == "workspace-provider" { if p.KeySet { t.Fatalf("workspace provider keySet = true, want false because workspace .env is ignored: %+v", p) } if p.Configured { t.Fatalf("workspace provider configured = true, want false because workspace .env is ignored: %+v", p) } return } } t.Fatalf("workspace provider missing from settings: %+v", got.Providers) } func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) { isolateDesktopUserDirs(t) project := robustTempDir(t) launch := robustTempDir(t) if err := os.WriteFile(filepath.Join(project, ".env"), []byte("SHARED_SETTINGS_KEY=from-project\n"), 0o600); err != nil { t.Fatalf("write project env: %v", err) } if _, err := config.SetCredential("SHARED_SETTINGS_KEY", "from-credentials"); err != nil { t.Fatalf("SetCredential: %v", err) } userCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath()) if err := userCfg.UpsertProvider(config.ProviderEntry{ Name: "settings-provider", Kind: "openai", BaseURL: "https://settings.example/v1", Model: "settings-model", APIKeyEnv: "SHARED_SETTINGS_KEY", }); err != nil { t.Fatalf("upsert provider: %v", err) } userCfg.Desktop.ProviderAccess = []string{"settings-provider"} if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save user config: %v", err) } t.Setenv("SHARED_SETTINGS_KEY", "from-project") orig, _ := os.Getwd() defer func() { _ = os.Chdir(orig) }() if err := os.Chdir(launch); err != nil { t.Fatalf("chdir launch: %v", err) } app := NewApp() app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}} app.activeTabID = "project" got := app.Settings() for _, p := range got.Providers { if p.Name != "settings-provider" { continue } if !p.KeySet || !strings.Contains(p.KeySource, "Reasonix credentials") { t.Fatalf("settings-provider key = set:%v source:%q, want Reasonix credentials: %+v", p.KeySet, p.KeySource, p) } if env := os.Getenv("SHARED_SETTINGS_KEY"); env != "from-project" { t.Fatalf("Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env", env) } return } t.Fatalf("settings provider missing from settings: %+v", got.Providers) } func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) { isolateDesktopUserDirs(t) project := robustTempDir(t) if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(` default_model = "legacy-provider/legacy-model" [desktop] language = "zh" layout_style = "workbench" theme = "light" theme_style = "glacier" close_behavior = "quit" status_bar_style = "text" status_bar_items = ["model", "cache", "balance"] `), 0o644); err != nil { t.Fatalf("write project config: %v", err) } orig, _ := os.Getwd() defer func() { _ = os.Chdir(orig) }() if err := os.Chdir(project); err != nil { t.Fatalf("chdir project: %v", err) } app := NewApp() got := app.Settings() if got.ConfigPath != config.UserConfigPath() { t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath()) } 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" { t.Fatalf("Settings did not seed from legacy project config: %+v", got) } if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) { t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want) } if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) { t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err) } if err := app.SetDesktopLanguage("en"); err != nil { t.Fatalf("SetDesktopLanguage: %v", err) } userCfg := config.LoadForEdit(config.UserConfigPath()) if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "text" { 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()) } if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) { t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want) } } func TestSettingsSubagentDefaultsRoundTrip(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` default_model = "deepseek/deepseek-v4-flash" [[providers]] name = "deepseek" kind = "openai" base_url = "https://api.deepseek.com" models = ["deepseek-v4-flash", "deepseek-v4-pro"] default = "deepseek-v4-flash" api_key_env = "DEEPSEEK_API_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } app := NewApp() if got := app.Settings().Agent.MaxSubagentDepth; got != agent.DefaultMaxSubagentDepth { t.Fatalf("default max subagent depth = %d, want %d", got, agent.DefaultMaxSubagentDepth) } if err := app.SetSubagentModel("deepseek/deepseek-v4-pro"); err != nil { t.Fatalf("SetSubagentModel: %v", err) } if err := app.SetSubagentEffort("max"); err != nil { t.Fatalf("SetSubagentEffort: %v", err) } if err := app.SetMaxSubagentDepth(1); err != nil { t.Fatalf("SetMaxSubagentDepth(1): %v", err) } if err := app.SetMaxSubagentDepth(2); err != nil { t.Fatalf("SetMaxSubagentDepth(2): %v", err) } got := app.Settings() if got.SubagentModel != "deepseek/deepseek-v4-pro" || got.SubagentEffort != "max" { t.Fatalf("subagent settings = model:%q effort:%q", got.SubagentModel, got.SubagentEffort) } if got.Agent.MaxSubagentDepth != 2 { t.Fatalf("max subagent depth = %d, want 2", got.Agent.MaxSubagentDepth) } cfg := config.LoadForEdit(config.UserConfigPath()) if cfg.Agent.SubagentModel != "deepseek/deepseek-v4-pro" || cfg.Agent.SubagentEffort != "max" { t.Fatalf("saved config = model:%q effort:%q", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort) } if cfg.Agent.MaxSubagentDepth != 2 { t.Fatalf("saved max_subagent_depth = %d, want 2", cfg.Agent.MaxSubagentDepth) } } func TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) { isolateDesktopUserDirs(t) got := NewApp().Settings() providers := providerAccessSet(providerNamesFromView(got.Providers)) official := providerAccessSet(providerNamesFromView(got.OfficialProviders)) if providers["mimo-api"] { t.Fatalf("mimo-api should not be mixed into configured providers: %+v", got.Providers) } if !official["deepseek"] || official["mimo-api"] || official["mimo-token-plan"] { t.Fatalf("official providers = %+v, want only deepseek", got.OfficialProviders) } } func TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` default_model = "deepseek-flash" [[providers]] name = "deepseek-flash" kind = "openai" base_url = "https://api.deepseek.com" api_key_env = "DEEPSEEK_API_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } got := NewApp().Settings() for _, p := range got.Providers { if p.Name != "deepseek" { continue } if !p.BuiltIn { t.Fatalf("deepseek provider should be marked built-in for official endpoint: %+v", p) } 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" { t.Fatalf("deepseek provider = %+v, want added repaired official model list", p) } if got.DefaultModel != "deepseek/deepseek-v4-flash" { t.Fatalf("default_model = %q, want deepseek/deepseek-v4-flash", got.DefaultModel) } return } t.Fatalf("settings providers missing deepseek: %+v", got.Providers) } func TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` default_model = "deepseek/deepseek-v4-Flash" [desktop] provider_access = ["deepseek"] [[providers]] name = "deepseek" kind = "openai" base_url = "https://opencode.ai/zen/go/v1" models = ["deepseek-v4-Flash", "deepseek-v4-pro", "glm-5"] default = "deepseek-v4-Flash" api_key_env = "DEEPSEEK_API_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } got := NewApp().Settings() var custom *ProviderView for i := range got.Providers { if got.Providers[i].Name == "deepseek" { custom = &got.Providers[i] break } } if custom == nil { t.Fatalf("settings providers missing deepseek: %+v", got.Providers) } if custom.BuiltIn { t.Fatalf("external deepseek endpoint should be custom, got built-in provider: %+v", *custom) } if !custom.Added || !custom.KeySet || custom.BaseURL != "https://opencode.ai/zen/go/v1" { t.Fatalf("external deepseek provider = %+v, want added key-set custom opencode endpoint", *custom) } for _, p := range got.OfficialProviders { if p.Name == "deepseek" && p.Added { t.Fatalf("official DeepSeek template should not be marked added by external endpoint: %+v", p) } } } func TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test") if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` default_model = "deepseek-flash/deepseek-v4-pro" [[providers]] name = "deepseek-flash" kind = "openai" base_url = "https://api.deepseek.com" models = ["deepseek-v4-flash", "deepseek-v4-pro"] default = "deepseek-v4-flash" api_key_env = "DEEPSEEK_API_KEY" [[providers]] name = "mimo-pro" kind = "openai" base_url = "https://token-plan-cn.xiaomimimo.com/v1" model = "mimo-v2.5-pro" api_key_env = "MIMO_API_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } got := NewApp().Settings() providers := map[string]ProviderView{} for _, p := range got.Providers { providers[p.Name] = p } if !providers["deepseek"].Added || !providers["deepseek"].KeySet { t.Fatalf("deepseek provider = %+v, want inferred added key-set provider", providers["deepseek"]) } if !providers["mimo-pro"].Added || !providers["mimo-pro"].KeySet || providers["mimo-pro"].BuiltIn { t.Fatalf("mimo-pro provider = %+v, want inferred custom key-set provider", providers["mimo-pro"]) } if got.DefaultModel != "deepseek/deepseek-v4-pro" { t.Fatalf("default_model = %q, want deepseek/deepseek-v4-pro", got.DefaultModel) } } func TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` default_model = "deepseek-flash/deepseek-v4-flash" [desktop] provider_access = [] [[providers]] name = "deepseek-flash" kind = "openai" base_url = "https://api.deepseek.com" models = ["deepseek-v4-flash"] default = "deepseek-v4-flash" api_key_env = "DEEPSEEK_API_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } got := NewApp().Settings() for _, p := range got.Providers { if p.Added { t.Fatalf("provider %+v should not be inferred as added when provider_access is explicit empty", p) } } } func TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test") got := NewApp().Settings() providers := map[string]ProviderView{} for _, p := range got.Providers { providers[p.Name] = p } if !providers["deepseek"].Added || !providers["deepseek"].KeySet { t.Fatalf("deepseek provider = %+v, want inferred added provider from configured key", providers["deepseek"]) } if _, ok := providers["mimo-token-plan"]; ok { t.Fatalf("mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v", providers["mimo-token-plan"]) } } func TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv("DEEPSEEK_API_KEY", "") t.Setenv("MIMO_API_KEY", "") got := NewApp().Settings() for _, p := range got.Providers { if p.Added { t.Fatalf("provider %+v should not be inferred as added without a configured key", p) } } } func TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv("DEEPSEEK_API_KEY", "") os.Unsetenv("DEEPSEEK_API_KEY") if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` default_model = "deepseek-flash" [[providers]] name = "deepseek-flash" kind = "openai" base_url = "https://api.deepseek.com" api_key_env = "DEEPSEEK_API_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } if _, err := NewApp().AddOfficialProviderAccess("deepseek", "test-key"); err != nil { t.Fatalf("AddOfficialProviderAccess: %v", err) } cfg := config.LoadForEdit(config.UserConfigPath()) p, ok := cfg.Provider("deepseek") if !ok { t.Fatal("deepseek provider not saved") } if len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" { t.Fatalf("deepseek provider after add = %+v, want official model list", p) } if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] { t.Fatalf("provider_access missing deepseek: %+v", cfg.Desktop.ProviderAccess) } if cfg.DefaultModel != "deepseek/deepseek-v4-flash" { t.Fatalf("default_model = %q, want deepseek/deepseek-v4-flash", cfg.DefaultModel) } } func TestSettingsSurfacesCuratedProviderPresets(t *testing.T) { isolateDesktopUserDirs(t) view := NewApp().Settings() if len(view.ProviderPresets) < 18 { t.Fatalf("Settings().ProviderPresets length = %d, want curated custom presets", len(view.ProviderPresets)) } got := map[string]ProviderPresetView{} for _, preset := range view.ProviderPresets { got[preset.ID] = preset } for _, curated := range config.CuratedProviderPresets() { id := curated.ID preset, ok := got[id] if !ok { t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets) } if preset.KeyEnv == "" || len(preset.ProviderNames) == 0 || len(preset.Models) == 0 { t.Fatalf("preset %q view has missing fields: %+v", id, preset) } } } func providerPresetViewByID(t *testing.T, view SettingsView, id string) ProviderPresetView { t.Helper() for _, preset := range view.ProviderPresets { if preset.ID == id { return preset } } t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets) return ProviderPresetView{} } func TestSettingsMarksPresetAddedWhenSameNameProviderExistsWithoutAccess(t *testing.T) { isolateDesktopUserDirs(t) if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` [desktop] provider_access = [] [[providers]] name = "mimo-api" kind = "openai" base_url = "https://custom.example/v1" models = ["custom-model"] default = "custom-model" api_key_env = "MIMO_API_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } view := NewApp().Settings() presetView := providerPresetViewByID(t, view, "mimo-api") if !presetView.Added || presetView.Status != providerPresetStatusNameConflict || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) { t.Fatalf("mimo-api preset view = %+v, want name-conflict because a different same-name provider exists", presetView) } var providerView *ProviderView for i := range view.Providers { if view.Providers[i].Name == "mimo-api" { providerView = &view.Providers[i] break } } if providerView == nil { t.Fatal("mimo-api provider view missing") } if providerView.Added { t.Fatalf("mimo-api provider Added = true, want false until provider_access explicitly enables it") } } func TestSettingsMarksLegacyEquivalentPresetAsInstalled(t *testing.T) { isolateDesktopUserDirs(t) preset, ok := config.CuratedProviderPreset("mimo-api") if !ok || len(preset.Entries) == 0 { t.Fatal("missing mimo-api preset") } legacy := preset.Entries[0] legacy.PresetID = "" legacy.PresetVersion = 0 cfg := config.Default() if err := cfg.UpsertProvider(legacy); err != nil { t.Fatalf("upsert legacy provider: %v", err) } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } view := NewApp().Settings() presetView := providerPresetViewByID(t, view, "mimo-api") if !presetView.Added || presetView.Status != providerPresetStatusInstalled || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) { t.Fatalf("mimo-api preset view = %+v, want installed for legacy equivalent config", presetView) } } func TestSettingsMarksPresetWithChangedCoreConfigAsModified(t *testing.T) { isolateDesktopUserDirs(t) preset, ok := config.CuratedProviderPreset("mimo-api") if !ok || len(preset.Entries) == 0 { t.Fatal("missing mimo-api preset") } modified := preset.Entries[0] modified.BaseURL = "https://custom.example/v1" cfg := config.Default() if err := cfg.UpsertProvider(modified); err != nil { t.Fatalf("upsert modified provider: %v", err) } cfg.Desktop.ProviderAccess = []string{"mimo-api"} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } view := NewApp().Settings() presetView := providerPresetViewByID(t, view, "mimo-api") if !presetView.Added || presetView.Status != providerPresetStatusInstalledModified || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) { t.Fatalf("mimo-api preset view = %+v, want installed-modified for edited preset provider", presetView) } } func TestSettingsMigratesLegacyStepFunPresetBaseURLs(t *testing.T) { isolateDesktopUserDirs(t) cfg := config.Default() stepfun, ok := config.CuratedProviderPreset("stepfun") if !ok || len(stepfun.Entries) != 1 { t.Fatal("missing stepfun preset") } stepfunEntry := stepfun.Entries[0] stepfunEntry.BaseURL = "https://api.stepfun.ai/step_plan/v1" stepfunAnthropic, ok := config.CuratedProviderPreset("stepfun-anthropic") if !ok || len(stepfunAnthropic.Entries) != 1 { t.Fatal("missing stepfun-anthropic preset") } stepfunAnthropicEntry := stepfunAnthropic.Entries[0] stepfunAnthropicEntry.BaseURL = "https://api.stepfun.ai/step_plan" cfg.Providers = append(cfg.Providers, stepfunEntry, stepfunAnthropicEntry) cfg.Desktop.ProviderAccess = []string{"stepfun", "stepfun-anthropic"} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } view := NewApp().Settings() for _, id := range []string{"stepfun", "stepfun-anthropic"} { presetView := providerPresetViewByID(t, view, id) if !presetView.Added || presetView.Status != providerPresetStatusInstalled { t.Fatalf("%s preset view = %+v, want installed after migration", id, presetView) } } migrated := config.LoadForEdit(config.UserConfigPath()) stepfunEntryView, ok := migrated.Provider("stepfun") if !ok { t.Fatal("stepfun provider missing after migration") } if got := stepfunEntryView.BaseURL; got != "https://api.stepfun.com/step_plan/v1" { t.Fatalf("stepfun base_url = %q, want official URL", got) } stepfunAnthropicEntryView, ok := migrated.Provider("stepfun-anthropic") if !ok { t.Fatal("stepfun-anthropic provider missing after migration") } if got := stepfunAnthropicEntryView.BaseURL; got != "https://api.stepfun.com/step_plan" { t.Fatalf("stepfun-anthropic base_url = %q, want official URL", got) } } func TestSettingsMarksSimilarProviderPresetWithoutBlockingAdd(t *testing.T) { isolateDesktopUserDirs(t) preset, ok := config.CuratedProviderPreset("mimo-api") if !ok || len(preset.Entries) == 0 { t.Fatal("missing mimo-api preset") } similar := preset.Entries[0] similar.Name = "my-mimo" similar.PresetID = "" similar.PresetVersion = 0 cfg := config.Default() if err := cfg.UpsertProvider(similar); err != nil { t.Fatalf("upsert similar provider: %v", err) } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } view := NewApp().Settings() presetView := providerPresetViewByID(t, view, "mimo-api") if presetView.Added || presetView.Status != providerPresetStatusSimilarExisting || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"my-mimo"}) { t.Fatalf("mimo-api preset view = %+v, want non-blocking similar-existing status", presetView) } } func TestAddProviderPresetAccessSavesEditableProviderAndKey(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv("MIMO_API_KEY", "") os.Unsetenv("MIMO_API_KEY") if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-mimo"); err != nil { t.Fatalf("AddProviderPresetAccess: %v", err) } else if warning != "" { t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning) } cfg := config.LoadForEdit(config.UserConfigPath()) p, ok := cfg.Provider("mimo-api") if !ok { t.Fatal("mimo-api provider not saved") } if p.Kind != "openai" || p.BaseURL != "https://api.xiaomimimo.com/v1" || p.Default != "mimo-v2.5-pro" { t.Fatalf("mimo-api provider after preset add = %+v", p) } if p.PresetID != "mimo-api" || p.PresetVersion != config.ProviderPresetVersion { t.Fatalf("mimo-api preset metadata = %q/%d, want mimo-api/%d", p.PresetID, p.PresetVersion, config.ProviderPresetVersion) } if !p.NoProxy { t.Fatal("mimo-api preset should save no_proxy = true") } if !p.HasVisionModel("mimo-v2.5") || p.HasVisionModel("mimo-v2.5-pro") { t.Fatalf("mimo vision_models = %+v, want only vision-capable MiMo models", p.VisionModels) } if price := p.PriceForModel("mimo-v2.5-pro"); price == nil || price.Currency != "¥" { t.Fatalf("mimo-v2.5-pro price = %+v, want RMB pricing", price) } if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] { t.Fatalf("provider_access missing mimo-api: %+v", cfg.Desktop.ProviderAccess) } data, err := os.ReadFile(config.UserCredentialsPath()) if err != nil { t.Fatalf("read saved credentials: %v", err) } if !strings.Contains(string(data), "MIMO_API_KEY=sk-mimo") { t.Fatalf("saved credentials missing MiMo key:\n%s", data) } view := NewApp().Settings() var presetView *ProviderPresetView var providerView *ProviderView for i := range view.ProviderPresets { if view.ProviderPresets[i].ID == "mimo-api" { presetView = &view.ProviderPresets[i] } } for i := range view.Providers { if view.Providers[i].Name == "mimo-api" { providerView = &view.Providers[i] } } if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet { t.Fatalf("mimo-api preset view = %+v, want installed/key-set", presetView) } if providerView == nil || providerView.BuiltIn || !providerView.Added || !providerView.KeySet { t.Fatalf("mimo provider view = %+v, want editable added custom provider with key", providerView) } } func TestAddProviderPresetAccessDoesNotOverwriteExistingProvider(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv("MIMO_API_KEY", "") os.Unsetenv("MIMO_API_KEY") setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original") cfg := config.Default() custom := config.ProviderEntry{ Name: "mimo-api", Kind: "openai", BaseURL: "https://custom.example/v1", Models: []string{"custom-model"}, Default: "custom-model", APIKeyEnv: "MIMO_API_KEY", Headers: map[string]string{"X-Custom": "keep-me"}, } if err := cfg.UpsertProvider(custom); err != nil { t.Fatalf("upsert custom provider: %v", err) } cfg.Desktop.ProviderAccess = []string{"mimo-api"} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-new"); err == nil { t.Fatal("AddProviderPresetAccess unexpectedly overwrote an existing provider") } else if !strings.Contains(err.Error(), "provider name(s) already exist") { t.Fatalf("AddProviderPresetAccess error = %v, want name-exists guard", err) } else if warning != "" { t.Fatalf("AddProviderPresetAccess warning = %q, want none on rejected add", warning) } cfg = config.LoadForEdit(config.UserConfigPath()) got, ok := cfg.Provider("mimo-api") if !ok { t.Fatal("mimo-api provider missing after rejected add") } if got.BaseURL != custom.BaseURL || got.DefaultModel() != custom.DefaultModel() || !reflect.DeepEqual(got.ModelList(), custom.ModelList()) || !reflect.DeepEqual(got.Headers, custom.Headers) { t.Fatalf("mimo-api provider was overwritten: %+v, want custom %+v", got, custom) } data, err := os.ReadFile(config.UserCredentialsPath()) if err != nil { t.Fatalf("read saved credentials: %v", err) } if strings.Contains(string(data), "sk-new") || !strings.Contains(string(data), "MIMO_API_KEY=sk-original") { t.Fatalf("credentials changed after rejected add:\n%s", data) } } func TestResetProviderPresetAccessOverwritesSameNameProvider(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv("MIMO_API_KEY", "") os.Unsetenv("MIMO_API_KEY") setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original") cfg := config.Default() custom := config.ProviderEntry{ Name: "mimo-api", Kind: "openai", BaseURL: "https://custom.example/v1", Models: []string{"custom-model"}, Default: "custom-model", APIKeyEnv: "MIMO_API_KEY", Headers: map[string]string{"X-Custom": "remove-me"}, } if err := cfg.UpsertProvider(custom); err != nil { t.Fatalf("upsert custom provider: %v", err) } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } if err := NewApp().ResetProviderPresetAccess("mimo-api"); err != nil { t.Fatalf("ResetProviderPresetAccess: %v", err) } cfg = config.LoadForEdit(config.UserConfigPath()) got, ok := cfg.Provider("mimo-api") if !ok { t.Fatal("mimo-api provider missing after reset") } if got.BaseURL != "https://api.xiaomimimo.com/v1" || got.DefaultModel() != "mimo-v2.5-pro" || got.PresetID != "mimo-api" || got.PresetVersion != config.ProviderPresetVersion { t.Fatalf("mimo-api provider after reset = %+v, want preset template", got) } if len(got.Headers) != 0 { t.Fatalf("mimo-api headers after reset = %+v, want preset headers", got.Headers) } if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] { t.Fatalf("provider_access missing mimo-api after reset: %+v", cfg.Desktop.ProviderAccess) } data, err := os.ReadFile(config.UserCredentialsPath()) if err != nil { t.Fatalf("read saved credentials: %v", err) } if !strings.Contains(string(data), "MIMO_API_KEY=sk-original") { t.Fatalf("credentials changed after reset:\n%s", data) } presetView := providerPresetViewByID(t, NewApp().Settings(), "mimo-api") if !presetView.Added || presetView.Status != providerPresetStatusInstalled { t.Fatalf("mimo-api preset view = %+v, want installed after reset", presetView) } } func TestResetProviderPresetAccessRejectsMissingSameNameProvider(t *testing.T) { isolateDesktopUserDirs(t) if err := NewApp().ResetProviderPresetAccess("mimo-api"); err == nil { t.Fatal("ResetProviderPresetAccess unexpectedly reset a missing provider") } else if !strings.Contains(err.Error(), "no same-name provider exists") { t.Fatalf("ResetProviderPresetAccess error = %v, want missing same-name provider guard", err) } } func TestAddEveryProviderPresetAccessInstallsTemplate(t *testing.T) { for _, preset := range config.CuratedProviderPresets() { preset := preset t.Run(preset.ID, func(t *testing.T) { isolateDesktopUserDirs(t) if warning, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-test"); err != nil { t.Fatalf("AddProviderPresetAccess(%q): %v", preset.ID, err) } else if warning != "" { t.Fatalf("AddProviderPresetAccess(%q) warning = %q, want none", preset.ID, warning) } cfg := config.LoadForEdit(config.UserConfigPath()) access := providerAccessSet(cfg.Desktop.ProviderAccess) for _, entry := range preset.Entries { got, ok := cfg.Provider(entry.Name) if !ok { t.Fatalf("provider %q from preset %q was not saved", entry.Name, preset.ID) } if !access[entry.Name] { t.Fatalf("provider_access for preset %q missing %q: %+v", preset.ID, entry.Name, cfg.Desktop.ProviderAccess) } 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 { t.Fatalf("provider %q core fields = %+v, want template %+v", entry.Name, got, entry) } if got.PresetID != preset.ID || got.PresetVersion != config.ProviderPresetVersion { t.Fatalf("provider %q preset metadata = %q/%d, want %q/%d", entry.Name, got.PresetID, got.PresetVersion, preset.ID, config.ProviderPresetVersion) } if got.ContextWindow != entry.ContextWindow || got.Thinking != entry.Thinking || got.DefaultEffort != entry.DefaultEffort || got.ReasoningProtocol != entry.ReasoningProtocol { t.Fatalf("provider %q capability fields = %+v, want template %+v", entry.Name, got, entry) } if !reflect.DeepEqual(got.ModelList(), entry.ModelList()) || !reflect.DeepEqual(got.VisionModels, entry.VisionModels) || !reflect.DeepEqual(got.SupportedEfforts, entry.SupportedEfforts) { t.Fatalf("provider %q models/capabilities = %+v, want template %+v", entry.Name, got, entry) } if !reflect.DeepEqual(got.Headers, entry.Headers) || !reflect.DeepEqual(got.ExtraBody, entry.ExtraBody) { t.Fatalf("provider %q request extras = %+v, want template %+v", entry.Name, got, entry) } } view := NewApp().Settings() var presetView *ProviderPresetView for i := range view.ProviderPresets { if view.ProviderPresets[i].ID == preset.ID { presetView = &view.ProviderPresets[i] break } } if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet || !presetView.Configured { t.Fatalf("preset view for %q = %+v, want installed/key-set/configured", preset.ID, presetView) } }) } } func TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv("DEEPSEEK_API_KEY", "") os.Unsetenv("DEEPSEEK_API_KEY") app := NewApp() app.readyHook = func() {} app.setTestCtrl(newBackgroundJobController(t, "provider-access-job"), "deepseek-flash/deepseek-v4-flash") _, err := app.AddOfficialProviderAccess("deepseek", "sk-test") if err == nil || !strings.Contains(err.Error(), "stop background jobs") { t.Fatalf("AddOfficialProviderAccess with background job error = %v, want active-work guard", err) } if data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") { t.Fatalf("provider key should not be saved after rejected add access:\n%s", data) } } func TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv("DEEPSEEK_API_KEY", "") os.Unsetenv("DEEPSEEK_API_KEY") if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` default_model = "deepseek/deepseek-v4-flash" [desktop] provider_access = [] [[providers]] name = "deepseek" kind = "openai" base_url = "https://api.deepseek.com" models = ["deepseek-v4-flash", "deepseek-v4-pro"] default = "deepseek-v4-flash" api_key_env = "DEEPSEEK_API_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } if _, err := NewApp().SetProviderKey("DEEPSEEK_API_KEY", "sk-test"); err != nil { t.Fatalf("SetProviderKey: %v", err) } cfg := config.LoadForEdit(config.UserConfigPath()) if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] { t.Fatalf("provider_access = %+v, want deepseek restored", cfg.Desktop.ProviderAccess) } got := NewApp().Settings() for _, p := range got.Providers { if p.Name == "deepseek" { if !p.Added || !p.KeySet { t.Fatalf("deepseek settings = %+v, want added and key-set", p) } return } } t.Fatalf("settings providers missing deepseek: %+v", got.Providers) } func TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv("PROXY_DEEPSEEK_KEY", "") os.Unsetenv("PROXY_DEEPSEEK_KEY") if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` [desktop] provider_access = [] [[providers]] name = "deepseek-flash" kind = "openai" base_url = "https://proxy.example/v1" model = "deepseek-v4-flash" api_key_env = "PROXY_DEEPSEEK_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } if _, err := NewApp().SetProviderKey("PROXY_DEEPSEEK_KEY", "sk-test"); err != nil { t.Fatalf("SetProviderKey: %v", err) } cfg := config.LoadForEditWithoutCredentials(config.UserConfigPath()) access := providerAccessSet(cfg.Desktop.ProviderAccess) if !access["deepseek-flash"] { t.Fatalf("provider_access = %+v, want custom alias deepseek-flash", cfg.Desktop.ProviderAccess) } if access["deepseek"] { t.Fatalf("provider_access = %+v, should not canonicalize custom proxy to deepseek", cfg.Desktop.ProviderAccess) } } func TestSetProviderKeyLeaseHeldKeepsCurrentController(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{ {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"}, {Name: "longcat", Kind: "openai", BaseURL: "https://longcat.example/v1", Model: "longcat-chat", APIKeyEnv: "LONGCAT_API_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "externally-leased-provider-key.jsonl") if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { t.Fatalf("write placeholder session: %v", err) } externalLease, err := agent.TryAcquireSessionLease(sessionPath) if err != nil { t.Fatalf("TryAcquireSessionLease: %v", err) } defer externalLease.Release() oldSession := agent.NewSession("old system prompt") oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard}) defer oldCtrl.Close() app := NewApp() app.ctx = context.Background() tab := &WorkspaceTab{ ID: "tab_provider", Scope: "global", SessionPath: sessionPath, Ready: true, model: "old/old-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_provider", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID warning, err := app.SetProviderKey("LONGCAT_API_KEY", "sk-longcat") if err != nil { t.Fatalf("SetProviderKey: %v", err) } if !strings.Contains(warning, "current session could not refresh yet") || !strings.Contains(warning, "another Reasonix window") { t.Fatalf("SetProviderKey warning = %q, want deferred rebuild warning", warning) } if strings.Contains(warning, sessionPath) || strings.Contains(warning, "held by") { t.Fatalf("SetProviderKey surfaced raw lease details: %v", warning) } if tab.Ctrl != oldCtrl { t.Fatalf("tab controller changed after failed provider-key rebuild") } if tab.StartupErr != "" { t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr) } if got := tab.Ctrl.History(); len(got) < 2 || got[1].Content != "hello" { t.Fatalf("history after failed provider-key rebuild = %+v", got) } if access := providerAccessSet(config.LoadForEditWithoutCredentials(config.UserConfigPath()).Desktop.ProviderAccess); !access["longcat"] { t.Fatalf("provider_access should still persist longcat after key save") } } func TestSetProviderKeyRebuildSupersedesInFlightStartupBuild(t *testing.T) { isolateDesktopUserDirs(t) cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{{ Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY", }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "startup-build-in-flight.jsonl") if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { t.Fatalf("write placeholder session: %v", err) } app := NewApp() app.ctx = context.Background() app.readyHook = func() {} // Model the async startup build still being in flight: no controller yet, // a live build generation, and a cancellable build context. buildCtx, buildCancel := context.WithCancel(context.Background()) const startupGeneration = 1 tab := &WorkspaceTab{ ID: "tab_key_rebuild", Scope: "global", SessionPath: sessionPath, model: "old/old-model", buildGeneration: startupGeneration, buildCancel: buildCancel, disabledMCP: map[string]ServerView{}, } tab.sink = &tabEventSink{tabID: tab.ID, app: app} app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(tab.releaseSessionLease) if _, err := app.SetProviderKey("OLD_MODEL_KEY", "sk-new"); err != nil { t.Fatalf("SetProviderKey: %v", err) } if tab.Ctrl == nil { t.Fatal("provider-key rebuild did not install a controller") } defer tab.Ctrl.Close() assertTabBuildSuperseded(t, app, tab, startupGeneration, buildCtx) } func TestSaveProviderWithKeyLeaseHeldPersistsCustomProvider(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{{ Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY", }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "externally-leased-custom-provider.jsonl") if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { t.Fatalf("write placeholder session: %v", err) } externalLease, err := agent.TryAcquireSessionLease(sessionPath) if err != nil { t.Fatalf("TryAcquireSessionLease: %v", err) } defer externalLease.Release() oldSession := agent.NewSession("old system prompt") oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard}) defer oldCtrl.Close() app := NewApp() app.ctx = context.Background() tab := &WorkspaceTab{ ID: "tab_custom_provider", Scope: "global", SessionPath: sessionPath, Ready: true, model: "old/old-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_custom_provider", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID warning, err := app.SaveProviderWithKey(ProviderView{ Name: "proxy", Kind: "openai", BaseURL: "https://proxy.example/v1", Models: []string{"model-a", "model-b"}, Default: "model-a", APIKeyEnv: "PROXY_API_KEY", }, "sk-proxy") if err != nil { t.Fatalf("SaveProviderWithKey: %v", err) } if !strings.Contains(warning, "current session could not refresh yet") || !strings.Contains(warning, "another Reasonix window") { t.Fatalf("SaveProviderWithKey warning = %q, want deferred rebuild warning", warning) } if strings.Contains(warning, sessionPath) || strings.Contains(warning, "held by") { t.Fatalf("SaveProviderWithKey surfaced raw lease details: %v", warning) } if tab.Ctrl != oldCtrl { t.Fatalf("tab controller changed after failed provider rebuild") } gotCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath()) got, ok := gotCfg.Provider("proxy") if !ok { t.Fatal("custom provider was not saved") } if want := []string{"model-a", "model-b"}; !reflect.DeepEqual(got.ModelList(), want) { t.Fatalf("custom provider models = %v, want %v", got.ModelList(), want) } if !providerAccessSet(gotCfg.Desktop.ProviderAccess)["proxy"] { t.Fatalf("provider_access = %+v, want proxy", gotCfg.Desktop.ProviderAccess) } data, err := os.ReadFile(config.UserCredentialsPath()) if err != nil { t.Fatalf("read credentials: %v", err) } if !strings.Contains(string(data), "PROXY_API_KEY=sk-proxy") { t.Fatalf("provider key was not saved:\n%s", data) } } func TestConfigChangeLeaseHeldPersistsAndDefersRefresh(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{{ Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY", }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "externally-leased-settings.jsonl") if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { t.Fatalf("write placeholder session: %v", err) } externalLease, err := agent.TryAcquireSessionLease(sessionPath) if err != nil { t.Fatalf("TryAcquireSessionLease: %v", err) } defer externalLease.Release() oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard}) defer oldCtrl.Close() app := NewApp() app.ctx = context.Background() tab := &WorkspaceTab{ ID: "tab_settings", Scope: "global", SessionPath: sessionPath, Ready: true, model: "old/old-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_settings", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID if err := app.SetMaxSubagentDepth(1); err != nil { t.Fatalf("SetMaxSubagentDepth should defer lease-held refresh instead of failing: %v", err) } if tab.Ctrl != oldCtrl { t.Fatalf("tab controller changed after deferred settings rebuild") } got := config.LoadForEditWithoutCredentials(config.UserConfigPath()) if got.Agent.MaxSubagentDepth != 1 { t.Fatalf("saved max_subagent_depth = %d, want 1", got.Agent.MaxSubagentDepth) } } func TestDeferredRebuildRetryAppliesAfterLeaseRelease(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") prevInterval := deferredRebuildRetryInterval deferredRebuildRetryInterval = 20 * time.Millisecond t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval }) cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{{ Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY", }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "deferred-rebuild-retry.jsonl") if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { t.Fatalf("write placeholder session: %v", err) } externalLease, err := agent.TryAcquireSessionLease(sessionPath) if err != nil { t.Fatalf("TryAcquireSessionLease: %v", err) } released := false defer func() { if !released { externalLease.Release() } }() oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard}) defer oldCtrl.Close() app := NewApp() app.ctx = context.Background() app.readyHook = func() {} app.enableDeferredRebuildRetry() t.Cleanup(app.stopDeferredRebuildRetry) tab := &WorkspaceTab{ ID: "tab_deferred_retry", Scope: "global", SessionPath: sessionPath, Ready: true, model: "old/old-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_deferred_retry", app: app}, disabledMCP: map[string]ServerView{}, } installNoopRuntimeEvents(app, tab.sink) app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(func() { if c := app.controllerForTab(tab); c != nil && c != oldCtrl { c.Close() } tab.releaseSessionLease() }) if err := app.SetMaxSubagentDepth(1); err != nil { t.Fatalf("SetMaxSubagentDepth: %v", err) } if !app.deferredRebuildPending(tab.ID) { t.Fatal("deferred rebuild was not scheduled while the lease is held") } if app.controllerForTab(tab) != oldCtrl { t.Fatal("controller changed while the lease is still held") } externalLease.Release() released = true deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != oldCtrl { break } time.Sleep(10 * time.Millisecond) } if app.deferredRebuildPending(tab.ID) { t.Fatal("deferred rebuild is still pending after the lease was released") } if c := app.controllerForTab(tab); c == nil || c == oldCtrl { t.Fatalf("controller was not rebuilt after the lease release: got %p", c) } } func TestDeferredRebuildScheduleAfterStopIsNoop(t *testing.T) { app := NewApp() app.stopDeferredRebuildRetry() app.scheduleDeferredRebuild("tab_x", "settings") if app.deferredRebuildPending("tab_x") { t.Fatal("schedule after stop should not register pending work") } } func TestDeferredRebuildWaitsForTabToBecomeActive(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") prevInterval := deferredRebuildRetryInterval deferredRebuildRetryInterval = 20 * time.Millisecond t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval }) cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{{ Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY", }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "deferred-rebuild-inactive.jsonl") if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { t.Fatalf("write placeholder session: %v", err) } externalLease, err := agent.TryAcquireSessionLease(sessionPath) if err != nil { t.Fatalf("TryAcquireSessionLease: %v", err) } released := false defer func() { if !released { externalLease.Release() } }() oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard}) defer oldCtrl.Close() otherCtrl := control.New(control.Options{Label: "other"}) defer otherCtrl.Close() app := NewApp() app.ctx = context.Background() app.readyHook = func() {} app.enableDeferredRebuildRetry() t.Cleanup(app.stopDeferredRebuildRetry) tab := &WorkspaceTab{ ID: "tab_pending", Scope: "global", SessionPath: sessionPath, Ready: true, model: "old/old-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_pending", app: app}, disabledMCP: map[string]ServerView{}, } installNoopRuntimeEvents(app, tab.sink) other := &WorkspaceTab{ ID: "tab_other", Scope: "global", Ready: true, model: "old/old-model", Ctrl: otherCtrl, sink: &tabEventSink{tabID: "tab_other", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab, other.ID: other} app.tabOrder = []string{tab.ID, other.ID} app.activeTabID = tab.ID t.Cleanup(func() { if c := app.controllerForTab(tab); c != nil && c != oldCtrl { c.Close() } tab.releaseSessionLease() }) if err := app.SetMaxSubagentDepth(1); err != nil { t.Fatalf("SetMaxSubagentDepth: %v", err) } if !app.deferredRebuildPending(tab.ID) { t.Fatal("deferred rebuild was not scheduled while the lease is held") } // Focus another tab, then release the lease: the retry must not rebuild // while the pending tab is inactive (rebuildSettingLocked acts on the // active tab), and must not touch the focused tab's runtime either. app.mu.Lock() app.activeTabID = other.ID app.mu.Unlock() externalLease.Release() released = true time.Sleep(150 * time.Millisecond) if !app.deferredRebuildPending(tab.ID) { t.Fatal("pending entry was consumed while its tab was inactive") } if app.controllerForTab(tab) != oldCtrl { t.Fatal("inactive pending tab was rebuilt") } if app.controllerForTab(other) != otherCtrl { t.Fatal("focused tab was rebuilt by another tab's deferred retry") } // Switch back: the retry should now refresh the pending tab. app.mu.Lock() app.activeTabID = tab.ID app.mu.Unlock() deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != oldCtrl { break } time.Sleep(10 * time.Millisecond) } if app.deferredRebuildPending(tab.ID) { t.Fatal("deferred rebuild still pending after its tab became active again") } if c := app.controllerForTab(tab); c == nil || c == oldCtrl { t.Fatalf("controller was not rebuilt after tab reactivation: got %p", c) } } func TestSetEffortForTabLeaseHeldKeepsOldControllerAlive(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{{ Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY", SupportedEfforts: []string{"low", "max"}, }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "externally-leased-effort-switch.jsonl") if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { t.Fatalf("write placeholder session: %v", err) } externalLease, err := agent.TryAcquireSessionLease(sessionPath) if err != nil { t.Fatalf("TryAcquireSessionLease: %v", err) } released := false defer func() { if !released { externalLease.Release() } }() oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard}) defer oldCtrl.Close() app := NewApp() app.ctx = context.Background() tab := &WorkspaceTab{ ID: "tab_effort", Scope: "global", SessionPath: sessionPath, Ready: true, model: "old/old-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_effort", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(func() { if c := app.controllerForTab(tab); c != nil && c != oldCtrl { c.Close() } tab.releaseSessionLease() }) err = app.SetEffortForTab(tab.ID, "max") if !errors.Is(err, agent.ErrSessionLeaseHeld) { t.Fatalf("SetEffortForTab err = %v, want ErrSessionLeaseHeld", err) } if strings.Contains(err.Error(), sessionPath) || strings.Contains(err.Error(), "held by") { t.Fatalf("SetEffortForTab surfaced raw lease details: %v", err) } if tab.Ctrl != oldCtrl { t.Fatal("tab controller changed after failed effort switch") } // The failed switch must leave the old runtime alive: after the other // window releases the lease, retrying from the same tab has to succeed. // (The old code closed the old controller before acquiring the lease, so // this retry died on a snapshot of a closed session.) externalLease.Release() released = true if err := app.SetEffortForTab(tab.ID, "max"); err != nil { t.Fatalf("SetEffortForTab retry after lease release: %v", err) } if tab.Ctrl == oldCtrl { t.Fatal("retry did not rebuild the controller") } } func TestSetEffortForTabReanchorsDepthCapRecoveryBranch(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{{ Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY", SupportedEfforts: []string{"low", "max"}, }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } recoveryPath := filepath.Join(dir, "effort-switch-conflict-recovery-deadbeef.jsonl") disk := agent.NewSession("old system prompt") disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"}) disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"}) disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"}) if err := disk.Save(recoveryPath); err != nil { t.Fatalf("save recovery branch: %v", err) } meta, ok, err := agent.LoadBranchMeta(recoveryPath) if err != nil || !ok { t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err) } meta.Recovered = true meta.ParentID = "effort-switch-conflict" meta.RecoveryReason = "snapshot conflict" meta.RecoveryDepth = agent.SessionRecoveryMaxDepth if err := agent.SaveBranchMeta(recoveryPath, meta); err != nil { t.Fatalf("SaveBranchMeta: %v", err) } stale := agent.NewSession("old system prompt") stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"}) stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"}) stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"}) oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard) app := NewApp() app.ctx = context.Background() app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {} tab := &WorkspaceTab{ ID: "tab_depth_cap_effort", Scope: "global", SessionPath: recoveryPath, Ready: true, model: "old/old-model", disabledMCP: map[string]ServerView{}, } tab.sink = &tabEventSink{tabID: tab.ID, app: app} oldCtrl := control.New(control.Options{ Executor: oldExec, SessionDir: dir, SessionPath: recoveryPath, Label: "old", Sink: tab.sink, SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab), OnSessionRecovered: app.handleTabSessionRecovered(tab), }) tab.Ctrl = oldCtrl app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(func() { if tab.Ctrl != nil { tab.Ctrl.Close() } tab.releaseSessionLease() }) stale.IncrementRewrite() if err := app.SetEffortForTab(tab.ID, "max"); err != nil { t.Fatalf("SetEffortForTab: %v", err) } if got := tab.Ctrl.SessionPath(); got != recoveryPath { t.Fatalf("session path after effort switch = %q, want current recovery branch %q", got, recoveryPath) } if got := tab.currentSessionPath(); got != recoveryPath { t.Fatalf("tab current session path = %q, want %q", got, recoveryPath) } if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) { t.Fatalf("tab lease path = %q, want %q", tab.sessionLeaseRuntimeKey(), recoveryPath) } matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")) if err != nil { t.Fatalf("glob recovery branches: %v", err) } matches = primarySessionFiles(matches) if len(matches) != 1 || matches[0] != recoveryPath { t.Fatalf("recovery branches after effort switch = %v, want only %q", matches, recoveryPath) } lines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath)) if len(lines) != 1 { t.Fatalf("conflict log lines = %v, want one depth-cap diagnostic", lines) } if !strings.Contains(lines[0], `"outcome":"recovery_depth_cap_force_saved"`) { t.Fatalf("conflict diagnostic = %s, want depth-cap outcome", lines[0]) } if strings.Contains(lines[0], dir) || strings.Contains(lines[0], recoveryPath) { t.Fatalf("conflict diagnostic leaked local path: %s", lines[0]) } if err := tab.Ctrl.Snapshot(); err != nil { t.Fatalf("Snapshot after effort switch recovery: %v", err) } afterLines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath)) if len(afterLines) != len(lines) { t.Fatalf("follow-up snapshot appended conflict diagnostics: before=%v after=%v", lines, afterLines) } matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")) if err != nil { t.Fatalf("glob recovery branches after snapshot: %v", err) } matches = primarySessionFiles(matches) if len(matches) != 1 || matches[0] != recoveryPath { t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath) } } func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) { isolateDesktopUserDirs(t) if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` [desktop] language = "zh" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } if _, err := NewApp().AddOfficialProviderAccess("deepseek", ""); err != nil { t.Fatalf("AddOfficialProviderAccess: %v", err) } cfg := config.LoadForEdit(config.UserConfigPath()) p, ok := cfg.Provider("deepseek") if !ok { t.Fatal("deepseek provider not saved") } flash := p.Prices["deepseek-v4-flash"] pro := p.Prices["deepseek-v4-pro"] if flash == nil || flash.Output != 2 || flash.Currency != "¥" { t.Fatalf("flash price = %+v, want CNY preset", flash) } if pro == nil || pro.Output != 6 || pro.Currency != "¥" { t.Fatalf("pro price = %+v, want CNY preset", pro) } } func TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) { isolateDesktopUserDirs(t) if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` default_model = "deepseek-flash/deepseek-v4-pro" [desktop] provider_access = ["deepseek-flash", "mimo-pro"] [[providers]] name = "deepseek-flash" kind = "openai" base_url = "https://api.deepseek.com" models = ["deepseek-v4-flash", "deepseek-v4-pro"] default = "deepseek-v4-flash" api_key_env = "DEEPSEEK_API_KEY" [[providers]] name = "mimo-pro" kind = "openai" base_url = "https://token-plan-cn.xiaomimimo.com/v1" model = "mimo-v2.5-pro" api_key_env = "MIMO_API_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } if err := NewApp().RemoveProviderAccess("deepseek"); err != nil { t.Fatalf("RemoveProviderAccess: %v", err) } cfg := config.LoadForEdit(config.UserConfigPath()) access := providerAccessSet(cfg.Desktop.ProviderAccess) if access["deepseek"] || !access["mimo-pro"] { t.Fatalf("provider_access = %+v, want only mimo-pro", cfg.Desktop.ProviderAccess) } if cfg.DefaultModel != "mimo-pro/mimo-v2.5-pro" { t.Fatalf("default_model = %q, want mimo-pro/mimo-v2.5-pro", cfg.DefaultModel) } } func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash" cfg.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"} deepseek, _ := cfg.Provider("deepseek-flash") deepseek.Model = "" deepseek.Models = []string{"deepseek-v4-flash", "deepseek-v4-pro"} deepseek.Default = "deepseek-v4-flash" if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } models := NewApp().Models() refs := modelRefsFromView(models) for _, want := range []string{ "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro", "mimo-pro/mimo-v2.5-pro", "mimo-pro/mimo-v2.5", } { if !refs[want] { t.Fatalf("Models() refs = %+v, missing %s", models, want) } } for _, hidden := range []string{ "deepseek-pro/deepseek-v4-pro", "mimo-flash/mimo-v2.5", } { if refs[hidden] { t.Fatalf("Models() refs = %+v, should not include hidden provider %s", models, hidden) } } if len(models) != 4 { t.Fatalf("Models() len = %d, want 4: %+v", len(models), models) } } func TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "LOCAL_API_KEY", "sk-test") if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` default_model = "local/model-a" [desktop] provider_access = ["local"] [[providers]] name = "local" kind = "openai" base_url = "http://127.0.0.1:23333/v1" models = ["model-a", "model-b"] default = "model-a" api_key_env = "LOCAL_API_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } models := NewApp().Models() refs := modelRefsFromView(models) for _, want := range []string{"local/model-a", "local/model-b"} { if !refs[want] { t.Fatalf("Models() refs = %+v, missing %s", models, want) } } if len(models) != 2 { t.Fatalf("Models() len = %d, want 2: %+v", len(models), models) } } func TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) { isolateDesktopUserDirs(t) if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` default_model = "local/model-a" [desktop] provider_access = ["local"] [[providers]] name = "local" kind = "openai" base_url = "http://127.0.0.1:23333/v1" models = ["model-a", "model-b"] default = "model-a" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } models := NewApp().Models() refs := modelRefsFromView(models) for _, want := range []string{"local/model-a", "local/model-b"} { if !refs[want] { t.Fatalf("Models() refs = %+v, missing %s", models, want) } } } func TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) { isolateDesktopUserDirs(t) if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` default_model = "local/model-a" [desktop] provider_access = ["local"] [[providers]] name = "local" kind = "openai" base_url = "http://127.0.0.1:23333/v1" models = ["model-a", "model-b"] default = "model-a" api_key_env = "LOCAL_API_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } models := NewApp().Models() refs := modelRefsFromView(models) for _, want := range []string{"local/model-a", "local/model-b"} { if !refs[want] { t.Fatalf("Models() refs = %+v, missing %s", models, want) } } } func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test") cfg := config.Default() preset, ok := config.CuratedProviderPreset("mimo-api") if !ok || len(preset.Entries) == 0 { t.Fatal("mimo-api preset missing") } if err := cfg.UpsertProvider(preset.Entries[0]); err != nil { t.Fatalf("upsert mimo-api preset: %v", err) } cfg.DefaultModel = "mimo-api/mimo-v2.5-pro" cfg.Desktop.ProviderAccess = []string{"mimo-api"} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } models := NewApp().Models() refs := modelRefsFromView(models) for _, want := range []string{ "mimo-api/mimo-v2.5-pro", "mimo-api/mimo-v2.5", } { if !refs[want] { t.Fatalf("Models() refs = %+v, missing %s", models, want) } } if len(models) != 2 { t.Fatalf("Models() len = %d, want 2: %+v", len(models), models) } } func TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test") userCfg := config.Default() userCfg.DefaultModel = "mimo-pro/mimo-v2.5-pro" userCfg.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"} if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save user config: %v", err) } projectRoot := t.TempDir() projectConfig := `default_model = "deepseek-flash/deepseek-v4-flash" [desktop] provider_access = ["deepseek-flash"] [[providers]] name = "deepseek-flash" kind = "openai" base_url = "https://api.deepseek.com" model = "deepseek-v4-flash" api_key_env = "DEEPSEEK_API_KEY" ` if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil { t.Fatalf("write project config: %v", err) } app := NewApp() tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true} app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.activeTabID = tab.ID models := app.ModelsForTab(tab.ID) refs := modelRefsFromView(models) for _, want := range []string{ "deepseek/deepseek-v4-flash", "mimo-pro/mimo-v2.5-pro", } { if !refs[want] { t.Fatalf("ModelsForTab refs = %+v, missing %s", models, want) } } } func TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash" cfg.Desktop.ProviderAccess = []string{"deepseek-flash"} cfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: "other", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "other-model"}) if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } app := NewApp() app.ctx = context.Background() tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ready: true, model: "deepseek-flash/deepseek-v4-flash"} app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID err := app.SetModelForTab(tab.ID, "other/other-model") if err == nil || !strings.Contains(err.Error(), "not available") { t.Fatalf("SetModelForTab hidden provider error = %v, want not available", err) } } func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old", "new"} cfg.Providers = []config.ProviderEntry{ {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"}, {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } if err := os.MkdirAll(config.MemoryUserDir(), 0o755); err != nil { t.Fatalf("mkdir memory dir: %v", err) } const freshRule = "Fresh global AGENTS rule for model switch" if err := os.WriteFile(filepath.Join(config.MemoryUserDir(), "AGENTS.md"), []byte(freshRule), 0o644); err != nil { t.Fatalf("write global AGENTS.md: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } oldSession := agent.NewSession("old system prompt without memory") oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard) oldPath := filepath.Join(dir, "old.jsonl") oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard}) app := NewApp() app.ctx = context.Background() tab := &WorkspaceTab{ ID: "tab_a", Scope: "global", Ready: true, model: "old/old-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_a", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(func() { if tab.Ctrl != nil { tab.Ctrl.Close() } }) if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil { t.Fatalf("SetModelForTab: %v", err) } history := tab.Ctrl.History() if len(history) < 2 { t.Fatalf("history length = %d, want system + user", len(history)) } if history[0].Role != provider.RoleSystem { t.Fatalf("first message role = %s, want system", history[0].Role) } if !strings.Contains(history[0].Content, freshRule) { t.Fatalf("refreshed system prompt missing global AGENTS rule:\n%s", history[0].Content) } if history[1].Role != provider.RoleUser || history[1].Content != "hello" { t.Fatalf("carried user message changed: %+v", history[1]) } } func TestSetModelForTabContinuesRecoveryPathAfterSnapshotConflict(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old", "new"} cfg.Providers = []config.ProviderEntry{ {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"}, {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } originalPath := filepath.Join(dir, "model-switch-conflict.jsonl") current := agent.NewSession("old system prompt") current.Add(provider.Message{Role: provider.RoleUser, Content: "first"}) current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"}) current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"}) if err := current.Save(originalPath); err != nil { t.Fatalf("save current session: %v", err) } stale := agent.NewSession("old system prompt") stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"}) stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"}) stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"}) oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard) app := NewApp() app.ctx = context.Background() app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {} tab := &WorkspaceTab{ ID: "tab_recovery_model", Scope: "global", SessionPath: originalPath, Ready: true, model: "old/old-model", disabledMCP: map[string]ServerView{}, } tab.sink = &tabEventSink{tabID: tab.ID, app: app} oldCtrl := control.New(control.Options{ Executor: oldExec, SessionDir: dir, SessionPath: originalPath, Label: "old", Sink: tab.sink, SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab), OnSessionRecovered: app.handleTabSessionRecovered(tab), }) tab.Ctrl = oldCtrl app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(func() { if tab.Ctrl != nil { tab.Ctrl.Close() } tab.releaseSessionLease() }) if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil { t.Fatalf("SetModelForTab: %v", err) } recoveryPath := tab.Ctrl.SessionPath() if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") { t.Fatalf("model switch session path = %q, want recovery path distinct from %q", recoveryPath, originalPath) } if got := tab.currentSessionPath(); got != recoveryPath { t.Fatalf("tab current session path = %q, want recovery path %q", got, recoveryPath) } if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) { t.Fatalf("tab lease path = %q, want recovery path %q", tab.sessionLeaseRuntimeKey(), recoveryPath) } matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")) if err != nil { t.Fatalf("glob recovery branches: %v", err) } matches = primarySessionFiles(matches) if len(matches) != 1 || matches[0] != recoveryPath { t.Fatalf("recovery branches after model switch = %v, want only %q", matches, recoveryPath) } if err := tab.Ctrl.Snapshot(); err != nil { t.Fatalf("Snapshot after model switch recovery: %v", err) } matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")) if err != nil { t.Fatalf("glob recovery branches after snapshot: %v", err) } matches = primarySessionFiles(matches) if len(matches) != 1 || matches[0] != recoveryPath { t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath) } } func TestSetModelForTabReusesCurrentSessionLease(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old", "new"} cfg.Providers = []config.ProviderEntry{ {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"}, {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } oldSession := agent.NewSession("old system prompt") oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard) oldPath := filepath.Join(dir, "leased-model-switch.jsonl") oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard}) app := NewApp() app.ctx = context.Background() tab := &WorkspaceTab{ ID: "tab_a", Scope: "global", Ready: true, model: "old/old-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_a", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(func() { if tab.Ctrl != nil { tab.Ctrl.Close() } tab.releaseSessionLease() }) if err := tab.ensureSessionLease(oldPath); err != nil { t.Fatalf("ensureSessionLease: %v", err) } if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil { t.Fatalf("SetModelForTab: %v", err) } if tab.Ctrl == nil || tab.Ctrl == oldCtrl { t.Fatalf("tab controller was not rebuilt") } if got := tab.model; got != "new/new-model" { t.Fatalf("tab model = %q, want new/new-model", got) } if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(oldPath) { t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), oldPath) } history := tab.Ctrl.History() if len(history) < 2 || history[1].Role != provider.RoleUser || history[1].Content != "hello" { t.Fatalf("carried history = %+v, want original user message", history) } } func TestSetModelForTabWaitsForConcurrentBlankSessionLease(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old", "new"} cfg.Providers = []config.ProviderEntry{ {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"}, {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := desktopSessionDir(globalTabWorkspaceRoot()) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir sessions: %v", err) } path := filepath.Join(dir, "blank-model-switch-race.jsonl") if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatalf("write blank session: %v", err) } app := NewApp() app.ctx = context.Background() tab := &WorkspaceTab{ ID: "tab_blank_race", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), SessionPath: path, Ready: true, model: "old/old-model", sink: &tabEventSink{tabID: "tab_blank_race", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(func() { if tab.Ctrl != nil { tab.Ctrl.Close() } tab.releaseSessionLease() }) acquired := make(chan struct{}) releaseHook := make(chan struct{}) var once sync.Once sessionLeaseAcquireHookForTest = func() { once.Do(func() { close(acquired) <-releaseHook }) } t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil }) buildErr := make(chan error, 1) go func() { buildErr <- tab.ensureSessionLease(path) }() select { case <-acquired: case err := <-buildErr: t.Fatalf("background lease acquire returned before hook: %v", err) case <-time.After(2 * time.Second): t.Fatal("background lease acquire did not start") } switchErr := make(chan error, 1) go func() { switchErr <- app.SetModelForTab(tab.ID, "new/new-model") }() select { case err := <-switchErr: t.Fatalf("SetModelForTab returned before concurrent lease was bound: %v", err) case <-time.After(50 * time.Millisecond): } close(releaseHook) if err := <-buildErr; err != nil { t.Fatalf("background ensureSessionLease: %v", err) } if err := <-switchErr; err != nil { t.Fatalf("SetModelForTab: %v", err) } if tab.Ctrl == nil { t.Fatal("model switch did not build a controller") } if got := tab.model; got != "new/new-model" { t.Fatalf("tab model = %q, want new/new-model", got) } if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) { t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path) } } func TestSetModelForTabLeaseHeldKeepsCurrentController(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old", "new"} cfg.Providers = []config.ProviderEntry{ {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"}, {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } oldPath := filepath.Join(dir, "externally-leased-model-switch.jsonl") if err := os.WriteFile(oldPath, nil, 0o644); err != nil { t.Fatalf("write placeholder session: %v", err) } externalLease, err := agent.TryAcquireSessionLease(oldPath) if err != nil { t.Fatalf("TryAcquireSessionLease: %v", err) } defer externalLease.Release() oldSession := agent.NewSession("old system prompt") oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard}) defer oldCtrl.Close() app := NewApp() app.ctx = context.Background() tab := &WorkspaceTab{ ID: "tab_a", Scope: "global", Ready: true, model: "old/old-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_a", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID err = app.SetModelForTab(tab.ID, "new/new-model") if !errors.Is(err, agent.ErrSessionLeaseHeld) { t.Fatalf("SetModelForTab err = %v, want ErrSessionLeaseHeld", err) } if strings.Contains(err.Error(), oldPath) || strings.Contains(err.Error(), "held by") { t.Fatalf("SetModelForTab surfaced raw lease details: %v", err) } if tab.Ctrl != oldCtrl { t.Fatalf("tab controller changed after failed switch") } if got := tab.model; got != "old/old-model" { t.Fatalf("tab model = %q, want old/old-model", got) } info, err := os.Stat(oldPath) if err != nil { t.Fatalf("stat session: %v", err) } if info.Size() != 0 { t.Fatalf("session file size = %d, want unchanged empty file", info.Size()) } } func TestSetModelForTabReattachesDetachedRuntime(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old", "new"} cfg.Providers = []config.ProviderEntry{ {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"}, {Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := desktopSessionDir(globalTabWorkspaceRoot()) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "detached-model-switch.jsonl") oldSession := agent.NewSession("old system prompt") oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello from detached"}) oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard}) lease, err := agent.TryAcquireSessionLease(path) if err != nil { t.Fatalf("TryAcquireSessionLease: %v", err) } app := NewApp() app.ctx = context.Background() key := sessionRuntimeKey(path) detached := &WorkspaceTab{ ID: detachedRuntimeTabID(key), Scope: "global", SessionPath: path, Ctrl: oldCtrl, Ready: true, model: "old/old-model", sessionLease: lease, disabledMCP: map[string]ServerView{}, SharedHostKey: "detached-host", ActivityStatus: "", } tab := &WorkspaceTab{ ID: "tab_a", Scope: "global", SessionPath: path, Ready: true, model: "old/old-model", sink: &tabEventSink{tabID: "tab_a", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.detachedSessions = map[string]*WorkspaceTab{key: detached} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(func() { if tab.Ctrl != nil { tab.Ctrl.Close() } tab.releaseSessionLease() if detached.sessionLease != nil { detached.releaseSessionLease() } }) if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil { t.Fatalf("SetModelForTab: %v", err) } if _, ok := app.detachedSessions[key]; ok { t.Fatal("detached runtime was not consumed") } if tab.Ctrl == nil || tab.Ctrl == oldCtrl { t.Fatalf("tab controller was not rebuilt from detached runtime") } if got := tab.model; got != "new/new-model" { t.Fatalf("tab model = %q, want new/new-model", got) } if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != key { t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path) } history := tab.Ctrl.History() if len(history) < 2 || history[1].Content != "hello from detached" { t.Fatalf("carried history = %+v, want detached user message", history) } } type staleWorkspaceBindingFixture struct { app *App tab *WorkspaceTab oldCtrl control.SessionAPI projectA string sessionDirA string sessionPathA string } func newStaleWorkspaceBindingFixture(t *testing.T, suffix string) staleWorkspaceBindingFixture { return newStaleWorkspaceBindingFixtureWithLayout(t, suffix, "") } func newStaleWorkspaceBindingFixtureWithLayout(t *testing.T, suffix, layoutStyle string) staleWorkspaceBindingFixture { t.Helper() isolateDesktopUserDirs(t) setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test") // Steers and idle-steer fallbacks start real provider turns (they are no // longer command-interpreted), so the fixture's provider must complete // instantly instead of pointing at an unreachable host — otherwise every // steer leaves the controller running for the rest of the test. providerStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n") })) t.Cleanup(providerStub.Close) cfg := config.Default() cfg.DefaultModel = "test/test-model" cfg.Desktop.ProviderAccess = []string{"test"} cfg.Providers = []config.ProviderEntry{ {Name: "test", Kind: "openai", BaseURL: providerStub.URL, Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"}, } if strings.TrimSpace(layoutStyle) != "" { if err := cfg.SetDesktopLayoutStyle(layoutStyle); err != nil { t.Fatalf("set desktop layout style: %v", err) } } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } projectA := t.TempDir() projectB := t.TempDir() if err := addProject(projectA, "Project A"); err != nil { t.Fatalf("add project A: %v", err) } if err := addProject(projectB, "Project B"); err != nil { t.Fatalf("add project B: %v", err) } topicID := "topic_" + suffix topicTitle := "Rebuild workspace " + suffix sessionDirA := desktopSessionDir(projectA) sessionDirB := desktopSessionDir(projectB) if err := os.MkdirAll(sessionDirA, 0o755); err != nil { t.Fatalf("mkdir project A sessions: %v", err) } if err := os.MkdirAll(sessionDirB, 0o755); err != nil { t.Fatalf("mkdir project B sessions: %v", err) } sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now()) sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl") oldSession := agent.NewSession("old system prompt") oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "carry me"}) oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{ Executor: oldExec, SessionDir: sessionDirB, SessionPath: sessionPathB, Label: "test/test-model", ModelRef: "test/test-model", WorkspaceRoot: projectB, Sink: event.Discard, }) app := NewApp() app.readyHook = func() {} tab := &WorkspaceTab{ ID: "tab_stale_workspace_" + suffix, Scope: "project", WorkspaceRoot: projectB, TopicID: topicID, TopicTitle: topicTitle, SessionPath: sessionPathA, Ready: true, model: "test/test-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_stale_workspace_" + suffix, app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(func() { if tab.Ctrl != nil { tab.Ctrl.Close() } }) return staleWorkspaceBindingFixture{ app: app, tab: tab, oldCtrl: oldCtrl, projectA: projectA, sessionDirA: sessionDirA, sessionPathA: sessionPathA, } } func assertTabRebuiltToPinnedWorkspace(t *testing.T, f staleWorkspaceBindingFixture) { t.Helper() if f.tab.Ctrl == nil { t.Fatal("controller was not rebuilt") } if f.tab.Ctrl == f.oldCtrl { t.Fatal("stale controller was reused") } if got := normalizeProjectRoot(f.tab.WorkspaceRoot); got != normalizeProjectRoot(f.projectA) { t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(f.projectA)) } if got := normalizeProjectRoot(f.tab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(f.projectA) { t.Fatalf("controller workspace root = %q, want project A %q", got, normalizeProjectRoot(f.projectA)) } if !sameDesktopPath(f.tab.Ctrl.SessionDir(), f.sessionDirA) { t.Fatalf("controller session dir = %q, want %q", f.tab.Ctrl.SessionDir(), f.sessionDirA) } if !sameDesktopPath(f.tab.Ctrl.SessionPath(), f.sessionPathA) { t.Fatalf("controller session path = %q, want %q", f.tab.Ctrl.SessionPath(), f.sessionPathA) } } type blockingSnapshotCtrl struct { control.SessionAPI firstSnapshotStarted chan struct{} secondSnapshotStarted chan struct{} releaseSnapshot chan struct{} firstOnce sync.Once secondOnce sync.Once snapshotCount atomic.Int32 closeCount atomic.Int32 } func newBlockingSnapshotCtrl(ctrl control.SessionAPI) *blockingSnapshotCtrl { return &blockingSnapshotCtrl{ SessionAPI: ctrl, firstSnapshotStarted: make(chan struct{}), secondSnapshotStarted: make(chan struct{}), releaseSnapshot: make(chan struct{}), } } func (c *blockingSnapshotCtrl) Snapshot() error { count := c.snapshotCount.Add(1) switch count { case 1: c.firstOnce.Do(func() { close(c.firstSnapshotStarted) }) case 2: c.secondOnce.Do(func() { close(c.secondSnapshotStarted) }) } <-c.releaseSnapshot if c.SessionAPI == nil { return nil } return c.SessionAPI.Snapshot() } func (c *blockingSnapshotCtrl) Close() { c.closeCount.Add(1) if c.SessionAPI != nil { c.SessionAPI.Close() } } func (f *staleWorkspaceBindingFixture) installBlockingSnapshotController() *blockingSnapshotCtrl { ctrl := newBlockingSnapshotCtrl(f.tab.Ctrl) f.tab.Ctrl = ctrl f.oldCtrl = ctrl return ctrl } func TestEnsureTabControllerWorkspaceRebuildsStaleWorkspace(t *testing.T) { f := newStaleWorkspaceBindingFixture(t, "rebuild_workspace") if err := f.app.ensureTabControllerWorkspace(f.tab); err != nil { t.Fatalf("ensureTabControllerWorkspace: %v", err) } assertTabRebuiltToPinnedWorkspace(t, f) } func TestEnsureTabControllerWorkspaceWarnsWhenPinnedSessionSwitchesWorkspace(t *testing.T) { f := newStaleWorkspaceBindingFixture(t, "warn_workspace_switch") events := make(chan event.Event, 8) f.tab.sink.SetBotSink(event.FuncSink(func(e event.Event) { events <- e })) if err := f.app.ensureTabControllerWorkspace(f.tab); err != nil { t.Fatalf("ensureTabControllerWorkspace: %v", err) } assertTabRebuiltToPinnedWorkspace(t, f) deadline := time.After(2 * time.Second) for { select { case e := <-events: if e.Kind == event.Notice && e.Level == event.LevelWarn && strings.Contains(e.Text, f.projectA) && strings.Contains(e.Text, "switched tab") { return } case <-deadline: t.Fatal("did not receive workspace switch warning notice") } } } func TestSteerForTabReconcilesStaleWorkspaceBeforeIdleFallback(t *testing.T) { f := newStaleWorkspaceBindingFixture(t, "steer_idle_fallback") // The idle fallback submits the steer text verbatim as a provider turn // (steers are never command-interpreted); the fixture's provider stub // completes it instantly. if err := f.app.SteerForTab(f.tab.ID, "steer guidance"); err != nil { t.Fatalf("SteerForTab: %v", err) } waitNotRunning(t, f.tab.Ctrl) assertTabRebuiltToPinnedWorkspace(t, f) } func TestCompactReconcilesStaleWorkspaceBeforeCompaction(t *testing.T) { f := newStaleWorkspaceBindingFixture(t, "compact") if err := f.app.Compact(); err != nil { t.Fatalf("Compact: %v", err) } assertTabRebuiltToPinnedWorkspace(t, f) } func TestEffortCommandUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OWNER_MODEL_KEY", "sk-test") setDesktopTestCredential(t, "STALE_MODEL_KEY", "sk-test") projectA := t.TempDir() projectB := t.TempDir() if err := addProject(projectA, "Project A"); err != nil { t.Fatalf("add project A: %v", err) } if err := addProject(projectB, "Project B"); err != nil { t.Fatalf("add project B: %v", err) } ownerConfig := `default_model = "owner/owner-model" [[providers]] name = "owner" kind = "openai" base_url = "https://owner.example.invalid/v1" model = "owner-model" api_key_env = "OWNER_MODEL_KEY" supported_efforts = ["max"] default_effort = "max" ` if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil { t.Fatal(err) } staleConfig := `default_model = "stale/stale-model" [[providers]] name = "stale" kind = "openai" base_url = "https://stale.example.invalid/v1" model = "stale-model" api_key_env = "STALE_MODEL_KEY" reasoning_protocol = "none" ` if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil { t.Fatal(err) } topicID := "topic_effort_owner" topicTitle := "Effort owner" sessionDirA := desktopSessionDir(projectA) sessionDirB := desktopSessionDir(projectB) if err := os.MkdirAll(sessionDirA, 0o755); err != nil { t.Fatalf("mkdir project A sessions: %v", err) } if err := os.MkdirAll(sessionDirB, 0o755); err != nil { t.Fatalf("mkdir project B sessions: %v", err) } sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now()) oldCtrl := control.New(control.Options{ SessionDir: sessionDirB, SessionPath: filepath.Join(sessionDirB, "wrong.jsonl"), WorkspaceRoot: projectB, Sink: event.Discard, }) app := NewApp() app.readyHook = func() {} tab := &WorkspaceTab{ ID: "tab_stale_effort", Scope: "project", WorkspaceRoot: projectB, TopicID: topicID, TopicTitle: topicTitle, SessionPath: sessionPathA, Ready: true, Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_stale_effort", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(func() { if tab.Ctrl != nil { tab.Ctrl.Close() } }) if err := app.SubmitToTab(tab.ID, "/effort max"); err != nil { t.Fatalf("SubmitToTab(/effort max): %v", err) } waitNotRunning(t, tab.Ctrl) if tab.effort == nil || *tab.effort != "max" { t.Fatalf("tab effort = %#v, want max from pinned project A provider", tab.effort) } if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) { t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA)) } if got := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) { t.Fatalf("controller workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA)) } } func TestClassicLayoutQuickClicksSerializeWorkspaceRebuild(t *testing.T) { runQuickClickWorkspaceReconcileTest(t, "classic") } func TestWorkbenchLayoutQuickClicksSerializeWorkspaceRebuild(t *testing.T) { runQuickClickWorkspaceReconcileTest(t, "workbench") } func TestCreationLayoutQuickClicksSerializeWorkspaceRebuild(t *testing.T) { runQuickClickWorkspaceReconcileTest(t, "creation") } func runQuickClickWorkspaceReconcileTest(t *testing.T, layoutStyle string) { t.Helper() f := newStaleWorkspaceBindingFixtureWithLayout(t, "quick_click_"+layoutStyle, layoutStyle) if got, want := f.app.singleSurfaceLayoutEnabled(), singleSurfaceLayoutStyle(layoutStyle); got != want { t.Fatalf("singleSurfaceLayoutEnabled(%q) = %v, want %v", layoutStyle, got, want) } blockingCtrl := f.installBlockingSnapshotController() type quickAction struct { name string run func() error } actions := []quickAction{ {name: "submit", run: func() error { return f.app.SubmitToTab(f.tab.ID, "/unknown-command") }}, {name: "steer", run: func() error { return f.app.SteerForTab(f.tab.ID, "steer guidance") }}, {name: "compact", run: func() error { return f.app.Compact() }}, {name: "submit-display", run: func() error { return f.app.SubmitDisplayToTab(f.tab.ID, "/unknown display", "/unknown-command") }}, } start := make(chan struct{}) ready := make(chan struct{}, len(actions)) errs := make(chan error, len(actions)) var wg sync.WaitGroup for _, action := range actions { action := action wg.Add(1) go func() { defer wg.Done() ready <- struct{}{} <-start if err := action.run(); err != nil { errs <- fmt.Errorf("%s: %w", action.name, err) } }() } for range actions { <-ready } close(start) select { case <-blockingCtrl.firstSnapshotStarted: case <-time.After(time.Second): t.Fatal("timed out waiting for first stale controller snapshot") } select { case <-blockingCtrl.secondSnapshotStarted: t.Fatal("workspace rebuild was not serialized: second stale snapshot started before the first rebuild finished") case <-time.After(75 * time.Millisecond): } close(blockingCtrl.releaseSnapshot) wg.Wait() close(errs) for err := range errs { // The steer action holds a real provider turn now, so racing quick // clicks may legitimately observe a busy controller. This test // asserts workspace-rebuild serialization, not that every // concurrent action wins the turn. if strings.Contains(err.Error(), "turn already running") || strings.Contains(err.Error(), "cannot compact while a turn is running") { continue } t.Error(err) } if t.Failed() { return } if got := blockingCtrl.snapshotCount.Load(); got != 1 { t.Fatalf("stale snapshot count = %d, want 1", got) } if got := blockingCtrl.closeCount.Load(); got != 1 { t.Fatalf("stale close count = %d, want 1", got) } waitNotRunning(t, f.tab.Ctrl) assertTabRebuiltToPinnedWorkspace(t, f) } func TestListSessionsUsesPinnedSessionOwnerBeforeStaleRuntimeDir(t *testing.T) { isolateDesktopUserDirs(t) projectA := t.TempDir() projectB := t.TempDir() if err := addProject(projectA, "Project A"); err != nil { t.Fatalf("add project A: %v", err) } if err := addProject(projectB, "Project B"); err != nil { t.Fatalf("add project B: %v", err) } sessionDirA := desktopSessionDir(projectA) sessionDirB := desktopSessionDir(projectB) if err := os.MkdirAll(sessionDirA, 0o755); err != nil { t.Fatalf("mkdir project A sessions: %v", err) } if err := os.MkdirAll(sessionDirB, 0o755); err != nil { t.Fatalf("mkdir project B sessions: %v", err) } sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_project_a", "Project A topic", projectA, "project A prompt", time.Now()) sessionPathB := writeTopicSessionWithPrompt(t, sessionDirB, "project-b.jsonl", "topic_project_b", "Project B topic", projectB, "project B prompt", time.Now().Add(time.Minute)) app := NewApp() oldCtrl := control.New(control.Options{ SessionDir: sessionDirB, SessionPath: sessionPathB, WorkspaceRoot: projectB, Sink: event.Discard, }) tab := &WorkspaceTab{ ID: "tab_stale_runtime_dir", Scope: "project", WorkspaceRoot: projectB, TopicID: "topic_project_a", TopicTitle: "Project A topic", SessionPath: sessionPathA, Ready: true, Ctrl: oldCtrl, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(oldCtrl.Close) sessions := app.ListSessions() if len(sessions) == 0 { t.Fatal("ListSessions() returned no sessions") } if filepath.Clean(sessions[0].Path) != filepath.Clean(sessionPathA) { t.Fatalf("ListSessions()[0].Path = %q, want pinned project A session %q", sessions[0].Path, sessionPathA) } for _, item := range sessions { if filepath.Clean(item.Path) == filepath.Clean(sessionPathB) { t.Fatalf("ListSessions() included stale project B runtime session: %+v", sessions) } } if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) { t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA)) } } func TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv("MIMO_API_KEY", "") cfg := config.Default() cfg.Desktop.ProviderAccess = []string{"mimo-api"} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } app := NewApp() tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ready: true, model: "deepseek-flash/deepseek-v4-flash"} app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID err := app.SetDefaultModel("mimo-api/mimo-v2.5-pro") if err == nil || !strings.Contains(err.Error(), "has no key") { t.Fatalf("SetDefaultModel no-key error = %v, want has no key", err) } if tab.model != "deepseek-flash/deepseek-v4-flash" { t.Fatalf("tab model after failed default change = %q, want previous", tab.model) } } func TestSaveProviderPersistsReasoningProtocol(t *testing.T) { isolateDesktopUserDirs(t) app := NewApp() if err := app.SaveProvider(ProviderView{ Name: "deepseek-proxy", Kind: "openai", BaseURL: "https://proxy.example.com/v1", Models: []string{"deepseek-v4-flash"}, Default: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_PROXY_KEY", ReasoningProtocol: "none", SupportedEfforts: []string{"high", "max"}, DefaultEffort: "max", }); err != nil { t.Fatalf("SaveProvider: %v", err) } cfg := config.LoadForEdit(config.UserConfigPath()) got, ok := cfg.Provider("deepseek-proxy") if !ok { t.Fatal("saved provider not found") } if got.ReasoningProtocol != "none" || got.DefaultEffort != "max" { t.Fatalf("saved provider = %+v, want reasoning_protocol none and default_effort max", got) } view := app.Settings() for _, p := range view.Providers { if p.Name == "deepseek-proxy" { if p.ReasoningProtocol != "none" { t.Fatalf("settings reasoningProtocol = %q, want none", p.ReasoningProtocol) } return } } t.Fatalf("Settings() missing saved provider: %+v", view.Providers) } func TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "prov-a/model-a2" cfg.Providers = []config.ProviderEntry{ {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", Models: []string{"model-a1", "model-a2"}, APIKeyEnv: "REASONIX_TEST_KEY"}, {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"}, } cfg.Agent.PlannerModel = "prov-a" cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } ctrl := control.New(control.Options{Label: "old"}) defer ctrl.Close() app := NewApp() tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ctrl: ctrl, Label: "prov-a/model-a1", Ready: true, model: "prov-a/model-a1"} app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID if err := app.DeleteProvider("prov-a"); err != nil { t.Fatalf("DeleteProvider: %v", err) } got := config.LoadForEdit(config.UserConfigPath()) if _, ok := got.Provider("prov-a"); ok { t.Fatal("prov-a should be removed") } if got.DefaultModel != "prov-b" || got.Agent.PlannerModel != "prov-b" { t.Fatalf("model refs after delete = default:%q planner:%q, want prov-b", got.DefaultModel, got.Agent.PlannerModel) } if providerAccessSet(got.Desktop.ProviderAccess)["prov-a"] { t.Fatalf("provider access still contains prov-a: %+v", got.Desktop.ProviderAccess) } if tab.model != "prov-b/model-b1" || tab.Label != "prov-b/model-b1" { t.Fatalf("tab model after delete = model:%q label:%q, want prov-b/model-b1", tab.model, tab.Label) } if tab.Ctrl != nil { t.Fatal("tab controller should be closed and cleared when retargeted without a running app context") } } // assertTabBuildSuperseded checks that the startup build registered before the // mutation (generation) can no longer install its controller and that its // build context was cancelled. func assertTabBuildSuperseded(t *testing.T, app *App, tab *WorkspaceTab, generation uint64, buildCtx context.Context) { t.Helper() app.mu.Lock() superseded := app.tabBuildSupersededLocked(tab, generation) app.mu.Unlock() if !superseded { t.Fatal("in-flight startup build was not superseded; finishing it would reinstall a stale controller") } select { case <-buildCtx.Done(): default: t.Fatal("in-flight startup build context was not cancelled") } if tab.buildCancel != nil { t.Fatal("build cancel was not cleared") } } func TestDeleteProviderSupersedesInFlightStartupBuild(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "prov-b/model-b1" cfg.Providers = []config.ProviderEntry{ {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"}, {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"}, } cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } app := NewApp() // Model the async startup build still being in flight for the affected // tab: no controller yet, a live generation, a cancellable build context. buildCtx, buildCancel := context.WithCancel(context.Background()) tab := &WorkspaceTab{ ID: "tab_a", Scope: "global", model: "prov-a/model-a1", buildGeneration: 1, buildCancel: buildCancel, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID if err := app.DeleteProvider("prov-a"); err != nil { t.Fatalf("DeleteProvider: %v", err) } assertTabBuildSuperseded(t, app, tab, 1, buildCtx) if tab.model != "prov-b/model-b1" { t.Fatalf("tab model after delete = %q, want prov-b/model-b1", tab.model) } } func TestRemoveBuiltInProviderAccessSupersedesInFlightStartupBuild(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "prov-b/model-b1" cfg.Providers = []config.ProviderEntry{ {Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-chat", APIKeyEnv: "REASONIX_TEST_KEY"}, {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"}, } cfg.Desktop.ProviderAccess = []string{"deepseek", "prov-b"} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } app := NewApp() buildCtx, buildCancel := context.WithCancel(context.Background()) tab := &WorkspaceTab{ ID: "tab_ds", Scope: "global", model: "deepseek/deepseek-chat", buildGeneration: 1, buildCancel: buildCancel, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID if err := app.RemoveProviderAccess("deepseek"); err != nil { t.Fatalf("RemoveProviderAccess: %v", err) } assertTabBuildSuperseded(t, app, tab, 1, buildCtx) if tab.model != "prov-b/model-b1" { t.Fatalf("tab model after access removal = %q, want prov-b/model-b1", tab.model) } } func TestClearActiveSessionRuntimeSupersedesInFlightStartupBuild(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{{ Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY", }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "clear-runtime-in-flight.jsonl") if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { t.Fatalf("write placeholder session: %v", err) } oldSession := agent.NewSession("old system prompt") oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard}) app := NewApp() // A runtime is attached while an older async build is still in flight // (e.g. attached via topic activation); destroying the session must // invalidate that build so it cannot resurrect the destroyed session. buildCtx, buildCancel := context.WithCancel(context.Background()) tab := &WorkspaceTab{ ID: "tab_clear", Scope: "global", SessionPath: sessionPath, model: "old/old-model", Ready: true, Ctrl: oldCtrl, buildGeneration: 1, buildCancel: buildCancel, disabledMCP: map[string]ServerView{}, } tab.sink = &tabEventSink{tabID: tab.ID, app: app} app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(tab.releaseSessionLease) if err := app.clearActiveSessionRuntime(tab, oldCtrl); err != nil { t.Fatalf("clearActiveSessionRuntime: %v", err) } if tab.Ctrl == nil || tab.Ctrl == oldCtrl { t.Fatalf("clear did not install a fresh controller (ctrl=%v)", tab.Ctrl) } defer tab.Ctrl.Close() assertTabBuildSuperseded(t, app, tab, 1, buildCtx) } func TestClearActiveSessionRuntimeReleasesResourcesWhenTabReplaced(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{{ Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY", }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "clear-runtime-replaced-tab.jsonl") if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { t.Fatalf("write placeholder session: %v", err) } oldSession := agent.NewSession("old system prompt") oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard}) app := NewApp() tab := &WorkspaceTab{ ID: "tab_replaced", Scope: "global", SessionPath: sessionPath, model: "old/old-model", Ready: true, Ctrl: oldCtrl, disabledMCP: map[string]ServerView{}, } tab.sink = &tabEventSink{tabID: tab.ID, app: app} // The tab entry now points at a replacement struct (the tab was closed and // reopened while the clear ran off-lock), so the swap must not apply. replacement := &WorkspaceTab{ID: tab.ID, Scope: "global"} app.tabs = map[string]*WorkspaceTab{tab.ID: replacement} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(tab.releaseSessionLease) err := app.clearActiveSessionRuntime(tab, oldCtrl) if err == nil || !strings.Contains(err.Error(), "changed while clearing") { t.Fatalf("clearActiveSessionRuntime error = %v, want tab-changed error", err) } if replacement.Ctrl != nil { t.Fatalf("replacement tab controller = %v, want untouched nil", replacement.Ctrl) } if tab.Ctrl != oldCtrl { t.Fatalf("replaced tab controller = %v, want left on the destroyed runtime", tab.Ctrl) } if key := tab.sessionLeaseRuntimeKey(); key != "" { t.Fatalf("replaced tab still holds a session lease for %q; the fresh lease leaked", key) } if _, err := os.Stat(sessionPath); !os.IsNotExist(err) { t.Fatalf("old session artifacts were not destroyed (stat err=%v)", err) } } func TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "prov-a/model-a1" cfg.Providers = []config.ProviderEntry{ {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"}, {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})} app := NewApp() app.setTestCtrl(control.New(control.Options{Runner: runner}), "prov-a/model-a1") ctrl := app.activeCtrl() ctrl.Submit("work") <-runner.started err := app.DeleteProvider("prov-a") if err == nil || !strings.Contains(err.Error(), "finish or cancel") { t.Fatalf("DeleteProvider while running error = %v, want finish/cancel guard", err) } if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok { t.Fatal("provider should remain after rejected deletion") } close(runner.release) waitNotRunning(t, ctrl) ctrl.Close() } func TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "prov-a/model-a1" cfg.Providers = []config.ProviderEntry{ {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"}, {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "provider-job.jsonl") jm := jobs.NewManager(event.Discard) ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm}) defer ctrl.Close() app := NewApp() app.setTestCtrl(ctrl, "prov-a/model-a1") jm.StartForSession(agent.BranchID(path), "bash", "provider job", func(ctx context.Context, _ io.Writer) (string, error) { <-ctx.Done() return "", ctx.Err() }) err := app.DeleteProvider("prov-a") if err == nil || !strings.Contains(err.Error(), "active work") { t.Fatalf("DeleteProvider with background job error = %v, want active-work guard", err) } if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok { t.Fatal("provider should remain after rejected deletion") } } func TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "prov-b/model-b1" cfg.Providers = []config.ProviderEntry{ {Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"}, {Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } app := NewApp() app.ctx = context.Background() app.setTestCtrl(newBackgroundJobController(t, "provider-unaffected-job"), "prov-b/model-b1") err := app.DeleteProvider("prov-a") if err == nil || !strings.Contains(err.Error(), "stop background jobs") { t.Fatalf("DeleteProvider with unaffected background job error = %v, want active-work guard", err) } if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok { t.Fatal("unaffected provider should remain after rejected deletion") } } func TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) { isolateDesktopUserDirs(t) if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` default_model = "mimo-pro/mimo-v2.5-pro" [desktop] provider_access = ["deepseek-flash", "mimo-pro"] [[providers]] name = "deepseek-flash" kind = "openai" base_url = "https://api.deepseek.com" models = ["deepseek-v4-flash", "deepseek-v4-pro"] default = "deepseek-v4-flash" api_key_env = "DEEPSEEK_API_KEY" [[providers]] name = "mimo-pro" kind = "openai" base_url = "https://token-plan-cn.xiaomimimo.com/v1" model = "mimo-v2.5-pro" api_key_env = "MIMO_API_KEY" `), 0o644); err != nil { t.Fatalf("write config: %v", err) } app := NewApp() app.ctx = context.Background() app.setTestCtrl(newBackgroundJobController(t, "provider-access-unaffected-job"), "mimo-token-plan/mimo-v2.5-pro") err := app.RemoveProviderAccess("deepseek") if err == nil || !strings.Contains(err.Error(), "stop background jobs") { t.Fatalf("RemoveProviderAccess with background job error = %v, want active-work guard", err) } cfg := config.LoadForEdit(config.UserConfigPath()) access := providerAccessSet(cfg.Desktop.ProviderAccess) if !access["deepseek"] && !access["deepseek-flash"] { t.Fatalf("provider_access should still contain deepseek after rejected removal: %+v", cfg.Desktop.ProviderAccess) } } func TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv("DEEPSEEK_API_KEY", "") os.Unsetenv("DEEPSEEK_API_KEY") app := NewApp() app.ctx = context.Background() app.setTestCtrl(newBackgroundJobController(t, "connect-key-job"), "deepseek-flash/deepseek-v4-flash") _, err := app.ConnectKey("sk-test") if err == nil || !strings.Contains(err.Error(), "stop background jobs") { t.Fatalf("ConnectKey with background job error = %v, want active-work guard", err) } if data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") { t.Fatalf("onboarding key should not be saved after rejected connect:\n%s", data) } } func TestConnectKeyRebuildLeaseHeldKeepsCurrentController(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv(onboardingKeyEnv, "") os.Unsetenv(onboardingKeyEnv) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") oldFetch := connectKeyBalanceFetch connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) { return &billing.Balance{Available: true}, nil } t.Cleanup(func() { connectKeyBalanceFetch = oldFetch }) cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{ {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "externally-leased-connect-key.jsonl") if err := os.WriteFile(sessionPath, nil, 0o644); err != nil { t.Fatalf("write placeholder session: %v", err) } externalLease, err := agent.TryAcquireSessionLease(sessionPath) if err != nil { t.Fatalf("TryAcquireSessionLease: %v", err) } defer externalLease.Release() oldSession := agent.NewSession("old system prompt") oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard}) defer oldCtrl.Close() app := NewApp() app.ctx = context.Background() tab := &WorkspaceTab{ ID: "tab_connect", Scope: "global", SessionPath: sessionPath, Ready: true, model: "old/old-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_connect", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID warning, err := app.ConnectKey("sk-test") if err != nil { t.Fatalf("ConnectKey: %v", err) } if !strings.Contains(warning, "another Reasonix window") { t.Fatalf("ConnectKey warning = %q, want user-facing lease warning", warning) } if tab.Ctrl != oldCtrl { t.Fatalf("tab controller changed after failed connect-key rebuild") } if tab.StartupErr != "" { t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr) } if !config.CredentialStored(onboardingKeyEnv) { t.Fatal("onboarding key should be persisted even when hot rebuild is deferred") } } func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) { isolateDesktopUserDirs(t) userCfg := config.LoadForEdit(config.UserConfigPath()) if err := userCfg.SetDesktopLanguage("en"); err != nil { t.Fatalf("set desktop language: %v", err) } if err := userCfg.SetDesktopLayoutStyle("workbench"); err != nil { t.Fatalf("set desktop layout style: %v", err) } if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil { t.Fatalf("set desktop appearance: %v", err) } if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save user config: %v", err) } if err := NewApp().MigrateDesktopPreferences("zh", "light", "glacier"); err != nil { t.Fatalf("migrate desktop preferences: %v", err) } got := config.LoadForEdit(config.UserConfigPath()) if got.DesktopLanguage() != "en" || got.DesktopLayoutStyle() != "workbench" || got.DesktopTheme() != "dark" || got.DesktopThemeStyle() != "graphite" { 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()) } } func TestSetEffortRebuildsController(t *testing.T) { isolateDesktopUserDirs(t) app := NewApp() app.ctx = context.Background() app.readyHook = func() {} old := control.New(control.Options{Label: "old-controller"}) app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash") defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() if err := app.SetEffort("max"); err != nil { t.Fatalf("SetEffort(max): %v", err) } if c := app.activeCtrl(); c == nil { t.Fatal("SetEffort should leave a rebuilt controller") } if c := app.activeCtrl(); c == old { t.Fatal("SetEffort should rebuild the active controller so the provider sees the new effort") } if got := app.Effort().Current; got != "max" { t.Fatalf("Effort current = %q, want max", got) } } func TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "deepseek/deepseek-v4-flash" cfg.Desktop.ProviderAccess = []string{"deepseek"} cfg.Providers = []config.ProviderEntry{{ Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "glm-5", APIKeyEnv: "DEEPSEEK_API_KEY", }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } app := NewApp() app.ctx = context.Background() app.readyHook = func() {} old := control.New(control.Options{Label: "old-controller"}) app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash") defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() if err := app.SetEffort("max"); err != nil { t.Fatalf("SetEffort(max): %v", err) } tab := app.activeTab() if tab == nil { t.Fatal("active tab missing") } if tab.model != "deepseek/deepseek-v4-flash" { t.Fatalf("tab model = %q, want migrated official ref", tab.model) } } func TestSetTokenModeRebuildsController(t *testing.T) { isolateDesktopUserDirs(t) app := NewApp() app.ctx = context.Background() app.readyHook = func() {} old := control.New(control.Options{Label: "old-controller"}) app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash") defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() if err := app.SetTokenMode("economy"); err != nil { t.Fatalf("SetTokenMode(economy): %v", err) } if c := app.activeCtrl(); c == nil { t.Fatal("SetTokenMode should leave a rebuilt controller") } if c := app.activeCtrl(); c == old { t.Fatal("SetTokenMode should rebuild the active controller so the provider sees the new tool profile") } tab := app.activeTab() if tab == nil { t.Fatal("active tab missing") } if got := currentTabTokenMode(tab); got != "economy" { t.Fatalf("token mode = %q, want economy", got) } if got := app.Meta().TokenMode; got != "economy" { t.Fatalf("Meta token mode = %q, want economy", got) } saved := loadTabsFile() if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != "economy" { t.Fatalf("saved tabs = %+v, want economy token mode", saved.Tabs) } } func TestSetTokenModeDeliveryRebuildsAndPersistsProfile(t *testing.T) { isolateDesktopUserDirs(t) app := NewApp() app.ctx = context.Background() app.readyHook = func() {} old := control.New(control.Options{Label: "old-controller"}) app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash") defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() if err := app.SetTokenMode(boot.TokenModeDelivery); err != nil { t.Fatalf("SetTokenMode(delivery): %v", err) } if c := app.activeCtrl(); c == nil || c == old { t.Fatal("delivery profile should rebuild the active controller") } tab := app.activeTab() if got := currentTabTokenMode(tab); got != boot.TokenModeDelivery { t.Fatalf("token mode = %q, want delivery", got) } if got := app.Meta().TokenMode; got != boot.TokenModeDelivery { t.Fatalf("Meta token mode = %q, want delivery", got) } saved := loadTabsFile() if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != boot.TokenModeDelivery { t.Fatalf("saved tabs = %+v, want delivery profile", saved.Tabs) } } func TestSetTokenModeReusesCurrentSessionLease(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{ {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } session := agent.NewSession("old system prompt") session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) exec := agent.New(nil, nil, session, agent.Options{}, event.Discard) path := filepath.Join(dir, "leased-token-mode-switch.jsonl") oldCtrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard}) app := NewApp() app.ctx = context.Background() tab := &WorkspaceTab{ ID: "tab_a", Scope: "global", Ready: true, model: "old/old-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_a", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID t.Cleanup(func() { if tab.Ctrl != nil { tab.Ctrl.Close() } tab.releaseSessionLease() }) if err := tab.ensureSessionLease(path); err != nil { t.Fatalf("ensureSessionLease: %v", err) } if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil { t.Fatalf("SetTokenModeForTab: %v", err) } if tab.Ctrl == nil || tab.Ctrl == oldCtrl { t.Fatalf("tab controller was not rebuilt") } if got := currentTabTokenMode(tab); got != "economy" { t.Fatalf("token mode = %q, want economy", got) } if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) { t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path) } history := tab.Ctrl.History() if len(history) < 2 || history[1].Role != provider.RoleUser || history[1].Content != "hello" { t.Fatalf("carried history = %+v, want original user message", history) } } func TestSetTokenModeLeaseHeldKeepsCurrentController(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "old/old-model" cfg.Desktop.ProviderAccess = []string{"old"} cfg.Providers = []config.ProviderEntry{ {Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"}, } if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "externally-leased-token-mode-switch.jsonl") if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatalf("write placeholder session: %v", err) } externalLease, err := agent.TryAcquireSessionLease(path) if err != nil { t.Fatalf("TryAcquireSessionLease: %v", err) } defer externalLease.Release() session := agent.NewSession("old system prompt") session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"}) exec := agent.New(nil, nil, session, agent.Options{}, event.Discard) oldCtrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard}) defer oldCtrl.Close() app := NewApp() app.ctx = context.Background() tab := &WorkspaceTab{ ID: "tab_a", Scope: "global", Ready: true, model: "old/old-model", Ctrl: oldCtrl, sink: &tabEventSink{tabID: "tab_a", app: app}, disabledMCP: map[string]ServerView{}, } app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.tabOrder = []string{tab.ID} app.activeTabID = tab.ID err = app.SetTokenModeForTab(tab.ID, "economy") if !errors.Is(err, agent.ErrSessionLeaseHeld) { t.Fatalf("SetTokenModeForTab err = %v, want ErrSessionLeaseHeld", err) } if strings.Contains(err.Error(), path) || strings.Contains(err.Error(), "held by") { t.Fatalf("SetTokenModeForTab surfaced raw lease details: %v", err) } if tab.Ctrl != oldCtrl { t.Fatalf("tab controller changed after failed switch") } if got := currentTabTokenMode(tab); got != "full" { t.Fatalf("token mode = %q, want full", got) } } func TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "deepseek/deepseek-v4-flash" cfg.Desktop.ProviderAccess = []string{"deepseek"} cfg.Providers = []config.ProviderEntry{{ Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "glm-5", APIKeyEnv: "DEEPSEEK_API_KEY", }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } app := NewApp() app.ctx = context.Background() app.readyHook = func() {} old := control.New(control.Options{Label: "old-controller"}) app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash") defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() if err := app.SetTokenMode("economy"); err != nil { t.Fatalf("SetTokenMode(economy): %v", err) } tab := app.activeTab() if tab == nil { t.Fatal("active tab missing") } if tab.model != "deepseek/deepseek-v4-flash" { t.Fatalf("tab model = %q, want migrated official ref", tab.model) } if got := currentTabTokenMode(tab); got != "economy" { t.Fatalf("token mode = %q, want economy", got) } } func TestMetaForTabReportsImageInputCapability(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "custom/text-only" cfg.Desktop.ProviderAccess = []string{"custom"} cfg.Providers = []config.ProviderEntry{{ Name: "custom", Kind: "openai", BaseURL: "https://example.invalid/v1", APIKeyEnv: "CUSTOM_KEY", Models: []string{"text-only", "vision-pro"}, VisionModels: []string{"vision-pro"}, }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } app := NewApp() app.ctx = context.Background() app.readyHook = func() {} app.setTestCtrl(control.New(control.Options{Label: "custom/text-only"}), "custom/text-only") defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() if got := app.Meta().ImageInputEnabled; got { t.Fatal("text-only meta should disable image input") } if err := app.SetModel("custom/vision-pro"); err != nil { t.Fatalf("SetModel(custom/vision-pro): %v", err) } if got := app.Meta().ImageInputEnabled; !got { t.Fatal("vision model meta should enable image input") } } func TestMetaForTabImageInputCapabilityUsesCurrentRef(t *testing.T) { isolateDesktopUserDirs(t) setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test") cfg := config.Default() cfg.DefaultModel = "custom/vision-pro" cfg.Desktop.ProviderAccess = []string{"custom"} cfg.Providers = []config.ProviderEntry{{ Name: "custom", Kind: "openai", BaseURL: "https://example.invalid/v1", APIKeyEnv: "CUSTOM_KEY", Models: []string{"text-only", "vision-pro"}, VisionModels: []string{"vision-pro"}, }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } app := NewApp() app.ctx = context.Background() app.readyHook = func() {} app.setTestCtrl(control.New(control.Options{Label: "deleted/model"}), "deleted/model") defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() if got := app.Meta().ImageInputEnabled; got { t.Fatal("unknown model ref should not inherit image input from the default fallback model") } } func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) { isolateDesktopUserDirs(t) t.Setenv("DEEPSEEK_API_KEY", "") t.Setenv("MIMO_API_KEY", "") app := NewApp() app.ctx = context.Background() app.readyHook = func() {} old := control.New(control.Options{Label: "old-controller"}) app.setTestCtrl(old, "missing-token-mode-model") defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() err := app.SetTokenMode("economy") if err == nil { t.Fatal("SetTokenMode(economy) with an unknown model should fail") } if c := app.activeCtrl(); c != old { t.Fatalf("SetTokenMode failure replaced controller: got %p want %p", c, old) } tab := app.activeTab() if tab == nil { t.Fatal("active tab missing") } if got := currentTabTokenMode(tab); got != "full" { t.Fatalf("token mode after failed rebuild = %q, want full", got) } if got := app.Meta().TokenMode; got != "full" { t.Fatalf("Meta token mode after failed rebuild = %q, want full", got) } } func TestSetEffortRejectsRunningTurn(t *testing.T) { isolateDesktopUserDirs(t) runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})} app := NewApp() app.setTestCtrl(control.New(control.Options{Runner: runner}), "") app.activeCtrl().Submit("work") <-runner.started err := app.SetEffort("max") if err == nil || !strings.Contains(err.Error(), "finish or cancel") { t.Fatalf("SetEffort while running error = %v, want finish/cancel guard", err) } close(runner.release) waitNotRunning(t, app.activeCtrl()) } func TestSetTokenModeRejectsRunningTurn(t *testing.T) { isolateDesktopUserDirs(t) runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})} app := NewApp() app.setTestCtrl(control.New(control.Options{Runner: runner}), "") app.activeCtrl().Submit("work") <-runner.started err := app.SetTokenMode("economy") if err == nil || !strings.Contains(err.Error(), "finish or cancel") { t.Fatalf("SetTokenMode while running error = %v, want finish/cancel guard", err) } close(runner.release) waitNotRunning(t, app.activeCtrl()) } func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "jobs.jsonl") jm := jobs.NewManager(event.Discard) ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm}) defer ctrl.Close() app := NewApp() app.setTestCtrl(ctrl, "") release := make(chan struct{}) jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) { select { case <-ctx.Done(): return "", ctx.Err() case <-release: return "", nil } }) defer close(release) err := app.SetTokenMode("economy") if err == nil || !strings.Contains(err.Error(), "stop background jobs") { t.Fatalf("SetTokenMode with background job error = %v, want background-job guard", err) } } func TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "settings-job.jsonl") jm := jobs.NewManager(event.Discard) ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm}) defer ctrl.Close() app := NewApp() app.ctx = context.Background() app.setTestCtrl(ctrl, "deepseek-flash/deepseek-v4-flash") jm.StartForSession(agent.BranchID(path), "bash", "settings job", func(ctx context.Context, _ io.Writer) (string, error) { <-ctx.Done() return "", ctx.Err() }) err := app.SetSandbox("enforce", true, "", nil, "") if err == nil || !strings.Contains(err.Error(), "stop background jobs") { t.Fatalf("SetSandbox with background job error = %v, want background-job guard", err) } } func TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "clear-running.jsonl") if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil { t.Fatalf("write session: %v", err) } runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})} oldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"}) app := NewApp() app.projectTreeChangedHook = func() {} app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash") app.tabs["test"].TopicID = "topic_clear" app.tabs["test"].TopicTitle = "Clear topic" defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() oldCtrl.Submit("work") <-runner.started if err := app.ClearSession(); err != nil { t.Fatalf("ClearSession: %v", err) } waitNotRunning(t, oldCtrl) tab := app.activeTab() if tab == nil || tab.Ctrl == nil { t.Fatalf("active tab/controller missing after clear") } if tab.Ctrl == oldCtrl { t.Fatalf("clear should replace the active controller after cancelling old work") } if tab.TopicID != "topic_clear" || tab.TopicTitle != "Clear topic" { t.Fatalf("clear changed topic identity: %+v", tab) } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("old cleared session artifacts should be removed, stat err = %v", err) } if got := tab.currentSessionPath(); got == "" || got == path { t.Fatalf("new session path = %q, want fresh path", got) } } func TestClearSessionRemovesRunningJobArtifacts(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "clear-running-job.jsonl") if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil { t.Fatalf("write session: %v", err) } jm := jobs.NewManager(event.Discard) oldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm}) app := NewApp() app.projectTreeChangedHook = func() {} app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash") defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() started := make(chan struct{}) jm.StartForSession(agent.BranchID(path), "bash", "clear artifact", func(ctx context.Context, _ io.Writer) (string, error) { close(started) <-ctx.Done() return "", ctx.Err() }) <-started jobsDir := jobs.ArtifactDir(path) if _, err := os.Stat(jobsDir); err != nil { t.Fatalf("job sidecar should exist before clear: %v", err) } if err := app.ClearSession(); err != nil { t.Fatalf("ClearSession: %v", err) } if _, err := os.Stat(jobsDir); !os.IsNotExist(err) { t.Fatalf("old job sidecar should be removed after clear, stat err = %v", err) } } func TestSearchFileRefsFindsNestedBasename(t *testing.T) { orig, _ := os.Getwd() defer os.Chdir(orig) dir := robustTempDir(t) if err := os.MkdirAll(filepath.Join(dir, "frontend", "wailsjs", "runtime"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "frontend", "wailsjs", "runtime", "runtime.js"), []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "frontend", "Thumbs.db"), []byte("noise"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "frontend", ".DS_Store"), []byte("noise"), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(dir, "node_modules", "pkg"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "runtime.js"), []byte("noise"), 0o644); err != nil { t.Fatal(err) } for _, noise := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} { if err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, noise, "runtime.js"), []byte("noise"), 0o644); err != nil { t.Fatal(err) } } if err := os.MkdirAll(filepath.Join(dir, "desktop", "frontend", "wailsjs"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "desktop", "frontend", "wailsjs", "runtime.js"), []byte("generated"), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(dir, "product", "bin"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "product", "bin", "runtime.js"), []byte("real"), 0o644); err != nil { t.Fatal(err) } if err := os.Chdir(dir); err != nil { t.Fatal(err) } app := &App{} listed := app.ListDir("") for _, hidden := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} { if hasDirEntry(listed, hidden) { t.Fatalf("ListDir should hide local noise %q, got %+v", hidden, listed) } } desktopFrontend := app.ListDir("desktop/frontend") if hasDirEntry(desktopFrontend, "wailsjs") { t.Fatalf("ListDir should hide generated Wails bindings, got %+v", desktopFrontend) } frontendEntries := app.ListDir("frontend") for _, hidden := range []string{".DS_Store", "Thumbs.db"} { if hasDirEntry(frontendEntries, hidden) { t.Fatalf("ListDir should hide local noise file %q, got %+v", hidden, frontendEntries) } } got := app.SearchFileRefs("runtime.js") if !hasDirEntry(got, "frontend/wailsjs/runtime/runtime.js") { t.Fatalf("SearchFileRefs(runtime.js) should find nested workspace file, got %+v", got) } if !hasDirEntry(got, "product/bin/runtime.js") { t.Fatalf("SearchFileRefs should keep non-root bin directories searchable, got %+v", got) } if hasDirEntry(got, "node_modules/pkg/runtime.js") { t.Fatalf("SearchFileRefs should skip node_modules noise, got %+v", got) } for _, hidden := range []string{ ".codex/runtime.js", ".npm/runtime.js", ".pnpm-store/runtime.js", "bin/runtime.js", "desktop/frontend/wailsjs/runtime.js", "dist/runtime.js", "stage/runtime.js", "tmp/runtime.js", } { if hasDirEntry(got, hidden) { t.Fatalf("SearchFileRefs should skip local noise %q, got %+v", hidden, got) } } if noise := app.SearchFileRefs("Thumbs"); hasDirEntry(noise, "frontend/Thumbs.db") { t.Fatalf("SearchFileRefs should skip Thumbs.db noise, got %+v", noise) } if noise := app.SearchFileRefs(".DS"); hasDirEntry(noise, "frontend/.DS_Store") { t.Fatalf("SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v", noise) } } func TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) { orig, _ := os.Getwd() defer os.Chdir(orig) launchRoot := robustTempDir(t) projectRoot := robustTempDir(t) if err := os.WriteFile(filepath.Join(launchRoot, "launch-only.txt"), []byte("wrong"), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(projectRoot, "frontend", "wailsjs", "runtime"), 0o755); err != nil { t.Fatal(err) } projectFile := filepath.Join(projectRoot, "frontend", "wailsjs", "runtime", "runtime.js") if err := os.WriteFile(projectFile, []byte("right workspace"), 0o644); err != nil { t.Fatal(err) } if err := os.Chdir(launchRoot); err != nil { t.Fatal(err) } app := NewApp() tab := &WorkspaceTab{ID: "project", Scope: "project", WorkspaceRoot: projectRoot} app.tabs = map[string]*WorkspaceTab{tab.ID: tab} app.activeTabID = tab.ID listed := app.ListDir("") if !hasDirEntry(listed, "frontend") { t.Fatalf("ListDir should list active project root, got %+v", listed) } if hasDirEntry(listed, "launch-only.txt") { t.Fatalf("ListDir leaked launch cwd entries, got %+v", listed) } found := app.SearchFileRefs("runtime.js") if !hasDirEntry(found, "frontend/wailsjs/runtime/runtime.js") { t.Fatalf("SearchFileRefs should search active project root, got %+v", found) } preview := app.ReadFile("frontend/wailsjs/runtime/runtime.js") if preview.Err != "" || preview.Body != "right workspace" { t.Fatalf("ReadFile active project preview = %+v, want project file", preview) } } func TestFileRefsForTabIgnoreActiveParentWorkspace(t *testing.T) { parentRoot := robustTempDir(t) childRoot := filepath.Join(parentRoot, "child") if err := os.MkdirAll(childRoot, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(parentRoot, "parent-only.txt"), []byte("parent"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(parentRoot, "shared.txt"), []byte("parent shared"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(childRoot, "child-only.txt"), []byte("child"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(childRoot, "shared.txt"), []byte("child shared"), 0o644); err != nil { t.Fatal(err) } app := &App{ tabs: map[string]*WorkspaceTab{ "parent": {ID: "parent", Scope: "project", WorkspaceRoot: parentRoot}, "child": {ID: "child", Scope: "project", WorkspaceRoot: childRoot}, }, activeTabID: "parent", } listed := app.ListDirForTab("child", "") if !hasDirEntry(listed, "child-only.txt") || hasDirEntry(listed, "parent-only.txt") { t.Fatalf("ListDirForTab(child) = %+v, want only child workspace entries", listed) } found := app.SearchFileRefsForTab("child", "child-only") if !hasDirEntry(found, "child-only.txt") { t.Fatalf("SearchFileRefsForTab(child) = %+v, want child-only.txt", found) } preview := app.ReadFileForTab("child", "shared.txt") if preview.Err != "" || preview.Body != "child shared" { t.Fatalf("ReadFileForTab(child) = %+v, want child workspace file", preview) } path, ok, err := app.workspaceOrExternalPathForTab("child", "shared.txt") if err != nil || !ok || path != filepath.Join(childRoot, "shared.txt") { t.Fatalf("workspaceOrExternalPathForTab(child) = (%q, %v, %v)", path, ok, err) } legacy := app.ReadFile("shared.txt") if legacy.Err != "" || legacy.Body != "parent shared" { t.Fatalf("ReadFile legacy active-tab behavior = %+v, want parent workspace file", legacy) } } func TestFileRefsIncludeRegisteredExternalFolderChildren(t *testing.T) { workspace := robustTempDir(t) external := filepath.Join(robustTempDir(t), "Folder With Spaces") if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(external, "src", "outside.txt"), []byte("outside"), 0o644); err != nil { t.Fatal(err) } expectedExternal := external if resolved, err := filepath.EvalSymlinks(external); err == nil { expectedExternal = resolved } expectedDisplayPath := filepath.ToSlash(expectedExternal) ctrl := &control.Controller{} token, _, err := ctrl.RegisterExternalFolderRef(external) if err != nil { t.Fatalf("RegisterExternalFolderRef: %v", err) } app := &App{ tabs: map[string]*WorkspaceTab{ "project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl}, "other": {ID: "other", WorkspaceRoot: robustTempDir(t)}, }, activeTabID: "other", } listed := app.ListDirForTab("project", token+"/src/") if len(listed) != 1 || listed[0].Name != "outside.txt" || listed[0].Path != token+"/src/outside.txt" || listed[0].DisplayPath != expectedDisplayPath+"/src/outside.txt" { t.Fatalf("ListDir external src = %+v, want outside token/display path", listed) } found := app.SearchFileRefsForTab("project", "outside") var externalHit *DirEntry for i := range found { if found[i].Path == token+"/src/outside.txt" { externalHit = &found[i] break } } if externalHit == nil || externalHit.DisplayName != "Folder With Spaces/src/outside.txt" || externalHit.DisplayPath != expectedDisplayPath+"/src/outside.txt" { t.Fatalf("SearchFileRefs external hit = %+v, all results %+v", externalHit, found) } preview := app.ReadFileForTab("project", token+"/src/outside.txt") if preview.Err != "" || preview.Body != "outside" { t.Fatalf("ReadFile external token preview = %+v, want outside file body", preview) } } func TestDeleteSessionCancelsActiveRuntime(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "active.jsonl") if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil { t.Fatalf("write session: %v", err) } app := NewApp() activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"}) keepPath := filepath.Join(dir, "keep.jsonl") if err := os.WriteFile(keepPath, []byte(`{"role":"user","content":"keep"}`+"\n"), 0o644); err != nil { t.Fatalf("write keep session: %v", err) } keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"}) defer keepCtrl.Close() app.setTestCtrl(activeCtrl, "") app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true} app.tabOrder = []string{"test", "keep"} if err := app.DeleteSession(filepath.Base(path)); err != nil { t.Fatalf("DeleteSession(active basename): %v", err) } if _, ok := app.tabs["test"]; ok { t.Fatalf("deleted active session runtime should be removed") } if got := app.activeTabID; got != "keep" { t.Fatalf("active tab after delete = %q, want keep", got) } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("active session should be moved out of active history, stat err = %v", err) } trashPath := filepath.Join(dir, sessionTrashDir, "active.jsonl", "active.jsonl") if _, err := os.Stat(trashPath); err != nil { t.Fatalf("active session should be moved to trash: %v", err) } } func TestDeleteSessionCancelsPreReadyBlankBuild(t *testing.T) { isolateDesktopUserDirs(t) globalRoot := globalTabWorkspaceRoot() dir := desktopSessionDir(globalRoot) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "pre-ready-blank.jsonl") if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatalf("write blank session: %v", err) } cancelled := false blank := &WorkspaceTab{ ID: "blank", Scope: "global", WorkspaceRoot: globalRoot, SessionPath: path, buildCancel: func() { cancelled = true }, disabledMCP: map[string]ServerView{}, } keep := &WorkspaceTab{ ID: "keep", Scope: "global", WorkspaceRoot: globalRoot, Ready: true, disabledMCP: map[string]ServerView{}, } app := &App{ tabs: map[string]*WorkspaceTab{"blank": blank, "keep": keep}, tabOrder: []string{"blank", "keep"}, activeTabID: "blank", } if err := app.DeleteSession(filepath.Base(path)); err != nil { t.Fatalf("DeleteSession(pre-ready blank): %v", err) } if !cancelled { t.Fatal("pre-ready blank build was not cancelled") } if !blank.removed { t.Fatal("pre-ready blank tab was not marked removed") } if _, ok := app.tabs["blank"]; ok { t.Fatal("pre-ready blank tab should be removed") } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("blank session should be moved out of active history, stat err = %v", err) } } func TestDeleteLastTopicSessionFallbackDoesNotReuseDeletedTopic(t *testing.T) { isolateDesktopUserDirs(t) projectRoot := t.TempDir() topicID := "topic_delete_last" if err := addProject(projectRoot, ""); err != nil { t.Fatalf("add project: %v", err) } if err := setTopicTitle(projectRoot, topicID, "Delete last"); err != nil { t.Fatalf("set topic title: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := writeTopicSession(t, dir, "delete-last.jsonl", topicID, "Delete last", projectRoot) ctrl := controllerWithContent(t, path) app := &App{ tabs: map[string]*WorkspaceTab{ "only": { ID: "only", Scope: "project", WorkspaceRoot: projectRoot, TopicID: topicID, TopicTitle: "Delete last", Ctrl: ctrl, Ready: true, disabledMCP: map[string]ServerView{}, }, }, tabOrder: []string{"only"}, activeTabID: "only", } if err := app.DeleteSession(path); err != nil { t.Fatalf("DeleteSession(last topic session): %v", err) } if _, ok := app.tabs["only"]; ok { t.Fatalf("deleted topic session tab should be removed") } for id, tab := range app.tabs { if tab.TopicID == topicID { t.Fatalf("fallback tab %q reused deleted topic %q", id, topicID) } if strings.TrimSpace(tab.TopicID) != "" { t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID) } } trashPath := filepath.Join(dir, sessionTrashDir, "delete-last.jsonl", "delete-last.jsonl") if _, err := os.Stat(trashPath); err != nil { t.Fatalf("deleted session should be moved to trash: %v", err) } } func TestDeleteSessionFallbackKeepsTopicWithRemainingHistory(t *testing.T) { isolateDesktopUserDirs(t) projectRoot := t.TempDir() topicID := "topic_delete_keep_history" if err := addProject(projectRoot, ""); err != nil { t.Fatalf("add project: %v", err) } if err := setTopicTitle(projectRoot, topicID, "Keep history"); err != nil { t.Fatalf("set topic title: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := writeTopicSession(t, dir, "delete-one.jsonl", topicID, "Keep history", projectRoot) remainingPath := writeTopicSessionWithPrompt(t, dir, "remaining.jsonl", topicID, "Keep history", projectRoot, "remaining turn", time.Now().Add(-time.Minute)) ctrl := controllerWithContent(t, path) app := &App{ tabs: map[string]*WorkspaceTab{ "only": { ID: "only", Scope: "project", WorkspaceRoot: projectRoot, TopicID: topicID, TopicTitle: "Keep history", Ctrl: ctrl, Ready: true, disabledMCP: map[string]ServerView{}, }, }, tabOrder: []string{"only"}, activeTabID: "only", } if err := app.DeleteSession(path); err != nil { t.Fatalf("DeleteSession(topic with remaining history): %v", err) } found := false for _, tab := range app.tabs { if tab.TopicID == topicID { found = true if got := filepath.Clean(tab.currentSessionPath()); got != filepath.Clean(remainingPath) { t.Fatalf("fallback session path = %q, want remaining history %q", got, remainingPath) } } } if !found { t.Fatalf("fallback should keep topic %q when another session remains", topicID) } } func TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "stuck-delete.jsonl") keepPath := filepath.Join(dir, "keep.jsonl") for _, p := range []string{path, keepPath} { if err := os.WriteFile(p, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil { t.Fatalf("write session %s: %v", p, err) } } grace := 500 * time.Millisecond slack := 300 * time.Millisecond jm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace)) ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm}) keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"}) releaseJob := startNonCooperativeSessionJob(t, jm, path) defer func() { releaseJob() ctrl.Close() keepCtrl.Close() }() app := NewApp() app.setTestCtrl(ctrl, "") app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true} app.tabOrder = []string{"test", "keep"} start := time.Now() if err := app.DeleteSession(filepath.Base(path)); err != nil { t.Fatalf("DeleteSession(stuck job): %v", err) } elapsed := time.Since(start) if elapsed > grace+slack { t.Fatalf("DeleteSession took %s, want one teardown grace plus scheduling slack", elapsed) } if !agent.IsCleanupPending(path) { t.Fatalf("stuck delete should mark cleanup pending") } if _, err := os.Stat(path); err != nil { t.Fatalf("stuck session file should remain until delayed cleanup: %v", err) } } func TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "active-conflict.jsonl") if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil { t.Fatalf("write session: %v", err) } if err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil { t.Fatalf("create trash conflict: %v", err) } runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})} ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"}) app := NewApp() app.setTestCtrl(ctrl, "") defer ctrl.Close() ctrl.Submit("work") <-runner.started err := app.DeleteSession(filepath.Base(path)) if err != nil { t.Fatalf("DeleteSession should succeed after cleaning empty trash dir: %v", err) } if _, ok := app.tabs["test"]; ok { t.Fatalf("deleted session runtime should be removed from tabs") } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("session file should be moved out of active history, stat err = %v", err) } trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path)) if _, err := os.Stat(trashPath); err != nil { t.Fatalf("session should be moved to trash: %v", err) } close(runner.release) waitNotRunning(t, ctrl) } func TestDeleteSessionValidTrashRemovesEmptyLiveStub(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "stale-live.jsonl") if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatalf("write live stub: %v", err) } trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path)) if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil { t.Fatalf("create trash dir: %v", err) } if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil { t.Fatalf("write trash session: %v", err) } activePath := filepath.Join(dir, "active.jsonl") if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil { t.Fatalf("write active session: %v", err) } activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"}) defer activeCtrl.Close() app := &App{ tabs: map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}}, activeTabID: "active", tabOrder: []string{"active"}, } if err := app.DeleteSession(filepath.Base(path)); err != nil { t.Fatalf("DeleteSession should remove stale live stub: %v", err) } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("live stub should be removed, stat err = %v", err) } if _, err := os.Stat(trashPath); err != nil { t.Fatalf("existing trash should remain authoritative: %v", err) } } func TestDeleteSessionValidTrashRemovesDuplicateLiveSession(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "duplicate-recovery.jsonl") content := []byte(`{"role":"user","content":"same recovery"}` + "\n") if err := os.WriteFile(path, content, 0o644); err != nil { t.Fatalf("write live session: %v", err) } trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path)) if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil { t.Fatalf("create trash dir: %v", err) } if err := os.WriteFile(trashPath, content, 0o644); err != nil { t.Fatalf("write trash session: %v", err) } activePath := filepath.Join(dir, "active.jsonl") if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil { t.Fatalf("write active session: %v", err) } activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"}) defer activeCtrl.Close() app := &App{ tabs: map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}}, activeTabID: "active", tabOrder: []string{"active"}, } if err := app.DeleteSession(filepath.Base(path)); err != nil { t.Fatalf("DeleteSession should remove duplicate live session: %v", err) } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("duplicate live session should be removed, stat err = %v", err) } if got, err := os.ReadFile(trashPath); err != nil || string(got) != string(content) { t.Fatalf("existing trash should remain authoritative, got %q err=%v", string(got), err) } } func TestRestoreSessionRejectsOpenEmptyLiveStub(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "restore-open.jsonl") if err := os.WriteFile(path, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil { t.Fatalf("write trash source: %v", err) } if err := deleteSessionFile(dir, path); err != nil { t.Fatalf("trash source: %v", err) } trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path)) if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatalf("write live stub: %v", err) } ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "open"}) defer ctrl.Close() app := &App{ tabs: map[string]*WorkspaceTab{"open": {ID: "open", Scope: "global", Ctrl: ctrl, Ready: true}}, tabOrder: []string{"open"}, activeTabID: "open", } err := app.RestoreSession(trashPath) if err == nil || !strings.Contains(err.Error(), "session is open") { t.Fatalf("RestoreSession error = %v, want open-session rejection", err) } if info, statErr := os.Stat(path); statErr != nil || info.Size() != 0 { t.Fatalf("open live stub should remain empty, info=%v err=%v", info, statErr) } if _, err := os.Stat(trashPath); err != nil { t.Fatalf("trash session should remain after rejected restore: %v", err) } } func TestDeleteSessionValidTrashRenamesDifferentLiveConflict(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "real-live.jsonl") if err := os.WriteFile(path, []byte(`{"role":"user","content":"new work"}`+"\n"), 0o644); err != nil { t.Fatalf("write live session: %v", err) } trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path)) if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil { t.Fatalf("create trash dir: %v", err) } if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil { t.Fatalf("write trash session: %v", err) } activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "active"}) defer activeCtrl.Close() app := NewApp() app.setTestCtrl(activeCtrl, "") if err := app.DeleteSession(filepath.Base(path)); err != nil { t.Fatalf("DeleteSession should move different live session to a unique trash item: %v", err) } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("live session should be moved out of active history, stat err = %v", err) } if got, err := os.ReadFile(trashPath); err != nil || !strings.Contains(string(got), "trashed") { t.Fatalf("original trash session should remain, got %q err=%v", string(got), err) } trashed, err := listTrashedSessionFiles(dir) if err != nil { t.Fatalf("list trash: %v", err) } var renamedPath string for _, candidate := range trashed { if candidate != trashPath && filepath.Base(candidate) == filepath.Base(path) { renamedPath = candidate break } } if renamedPath == "" { t.Fatalf("renamed trash copy not found in %#v", trashed) } if filepath.Base(filepath.Dir(renamedPath)) == filepath.Base(path) { t.Fatalf("renamed trash copy reused fixed trash item dir: %s", renamedPath) } if got, err := os.ReadFile(renamedPath); err != nil || !strings.Contains(string(got), "new work") { t.Fatalf("renamed trash session = %q err=%v, want live content", string(got), err) } } func TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } activePath := filepath.Join(dir, "active.jsonl") inactivePath := filepath.Join(dir, "inactive.jsonl") otherPath := filepath.Join(dir, "other.jsonl") for _, path := range []string{activePath, inactivePath, otherPath} { if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil { t.Fatalf("write session %s: %v", path, err) } } activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"}) inactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: "inactive"}) defer activeCtrl.Close() defer inactiveCtrl.Close() app := &App{ tabs: map[string]*WorkspaceTab{ "active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}, "inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true}, }, tabOrder: []string{"active", "inactive"}, activeTabID: "active", } if err := app.DeleteSession(filepath.Base(inactivePath)); err != nil { t.Fatalf("DeleteSession(inactive open basename): %v", err) } if _, ok := app.tabs["inactive"]; ok { t.Fatalf("deleted inactive session runtime should be removed") } if _, err := os.Stat(inactivePath); !os.IsNotExist(err) { t.Fatalf("inactive open session should be moved out of active history, stat err = %v", err) } trashPath := filepath.Join(dir, sessionTrashDir, "inactive.jsonl", "inactive.jsonl") if _, err := os.Stat(trashPath); err != nil { t.Fatalf("inactive open session should be moved to trash: %v", err) } sessions := app.ListSessions() current := map[string]bool{} open := map[string]bool{} for _, s := range sessions { current[filepath.Base(s.Path)] = s.Current open[filepath.Base(s.Path)] = s.Open } if !current[filepath.Base(activePath)] { t.Fatalf("ListSessions should mark active session current, got %#v", current) } if current[filepath.Base(otherPath)] { t.Fatalf("ListSessions marked unopened session current, got %#v", current) } if !open[filepath.Base(activePath)] { t.Fatalf("ListSessions should mark active and inactive open sessions open, got %#v", open) } if open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] { t.Fatalf("ListSessions marked unopened session open, got %#v", open) } } func TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) { isolateDesktopUserDirs(t) projectRoot := t.TempDir() topicID := "topic_stuck_trash" if err := addProject(projectRoot, ""); err != nil { t.Fatalf("add project: %v", err) } if err := setTopicTitle(projectRoot, topicID, "Stuck trash"); err != nil { t.Fatalf("set topic title: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir sessions: %v", err) } sessionPath := writeTopicSession(t, dir, "stuck-topic.jsonl", topicID, "Stuck trash", projectRoot) grace := 500 * time.Millisecond slack := 300 * time.Millisecond jm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace)) ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", Jobs: jm, WorkspaceRoot: projectRoot}) releaseJob := startNonCooperativeSessionJob(t, jm, sessionPath) defer func() { releaseJob() ctrl.Close() }() app := &App{ tabs: map[string]*WorkspaceTab{ "stuck": { ID: "stuck", Scope: "project", WorkspaceRoot: projectRoot, TopicID: topicID, TopicTitle: "Stuck trash", Ctrl: ctrl, Ready: true, disabledMCP: map[string]ServerView{}, }, "keep": { ID: "keep", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic_keep", TopicTitle: "Keep", Ready: true, disabledMCP: map[string]ServerView{}, }, }, tabOrder: []string{"stuck", "keep"}, activeTabID: "stuck", } start := time.Now() if err := app.TrashTopic(topicID); err != nil { t.Fatalf("TrashTopic(stuck job): %v", err) } elapsed := time.Since(start) if elapsed > grace+slack { t.Fatalf("TrashTopic took %s, want one teardown grace plus scheduling slack", elapsed) } if !agent.IsCleanupPending(sessionPath) { t.Fatalf("stuck topic trash should mark cleanup pending") } if _, err := os.Stat(sessionPath); err != nil { t.Fatalf("stuck topic session should remain until delayed trash: %v", err) } } func TestRestoreSessionRejectsDestroyingSession(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } sessionPath := filepath.Join(dir, "trash-me.jsonl") if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil { t.Fatalf("write session: %v", err) } if err := deleteSessionFile(dir, sessionPath); err != nil { t.Fatalf("deleteSessionFile: %v", err) } trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath)) jm := jobs.NewManager(event.Discard) defer jm.Close() ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "active", Jobs: jm}) defer ctrl.Close() destroy := ctrl.BeginDestroySession(sessionPath) defer destroy.Finish() app := NewApp() app.setTestCtrl(ctrl, "") if err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), "cleanup is still in progress") { t.Fatalf("RestoreSession while destroying error = %v, want cleanup-in-progress", err) } if _, err := os.Stat(trashPath); err != nil { t.Fatalf("trashed session should remain after rejected restore: %v", err) } destroy.Finish() if err := app.RestoreSession(trashPath); err != nil { t.Fatalf("RestoreSession after finish: %v", err) } if _, err := os.Stat(sessionPath); err != nil { t.Fatalf("session should be restored: %v", err) } } func TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) { isolateDesktopUserDirs(t) dirA := filepath.Join(t.TempDir(), "workspace-a-sessions") dirB := filepath.Join(t.TempDir(), "workspace-b-sessions") if err := os.MkdirAll(dirA, 0o755); err != nil { t.Fatalf("mkdir dirA: %v", err) } if err := os.MkdirAll(dirB, 0o755); err != nil { t.Fatalf("mkdir dirB: %v", err) } pathA := filepath.Join(dirA, "a.jsonl") pathB := filepath.Join(dirB, "b.jsonl") if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"}`+"\n"), 0o644); err != nil { t.Fatalf("write pathA: %v", err) } if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"}`+"\n"), 0o644); err != nil { t.Fatalf("write pathB: %v", err) } app := NewApp() app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"}), "") defer app.activeCtrl().Close() sessions := app.ListSessions() if len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != "workspace A" { t.Fatalf("ListSessions should read the active controller session dir only, got %+v", sessions) } if err := app.RenameSession(pathA, "A title"); err != nil { t.Fatalf("RenameSession in active session dir: %v", err) } meta, ok, err := agent.LoadBranchMeta(pathA) if err != nil || !ok { t.Fatalf("LoadBranchMeta after RenameSession ok=%v err=%v", ok, err) } if meta.CustomTitle != "A title" { t.Fatalf("custom title should be written to branch meta, got %q", meta.CustomTitle) } sessions = app.ListSessions() if len(sessions) != 1 || sessions[0].Title != "A title" { t.Fatalf("ListSessions should return custom title from branch meta, got %+v", sessions) } if titles := loadSessionTitles(dirA); titles["a.jsonl"] != "A title" { t.Fatalf("title should be written beside the active session, got %+v", titles) } if titles := loadSessionTitles(dirB); len(titles) != 0 { t.Fatalf("inactive workspace title sidecar should remain untouched, got %+v", titles) } } func TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "bot-channel.jsonl") if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil { t.Fatalf("write session: %v", err) } cfg := config.Default() cfg.Bot.Connections = []config.BotConnectionConfig{{ ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected", SessionMappings: []config.BotConnectionSessionMapping{{ RemoteID: "wx-chat-1", SessionID: "path:" + path, SessionSource: "auto", }}, }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } app := NewApp() app.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"}), "") defer app.activeCtrl().Close() sessions := app.ListSessions() if len(sessions) != 1 { t.Fatalf("ListSessions len = %d, want 1: %+v", len(sessions), sessions) } got := sessions[0] if got.Kind != "channel" || got.Channel != "weixin" || got.ChannelLabel != "微信" || got.RemoteID != "wx-chat-1" || got.SessionSource != "auto" { t.Fatalf("channel session meta = %+v", got) } } func TestDeleteSessionClearsAutoBotSessionMapping(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "bot-channel.jsonl") if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil { t.Fatalf("write session: %v", err) } other := filepath.Join(dir, "other-channel.jsonl") cfg := config.Default() cfg.Bot.Connections = []config.BotConnectionConfig{{ ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected", SessionMappings: []config.BotConnectionSessionMapping{ {RemoteID: "remove-auto", SessionID: "path:" + path, SessionSource: "auto"}, {RemoteID: "keep-explicit", SessionID: "path:" + path}, {RemoteID: "keep-other-auto", SessionID: "path:" + other, SessionSource: "auto"}, }, }} if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } app := NewApp() ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"}) app.setTestCtrl(ctrl, "") defer app.activeCtrl().Close() if err := app.DeleteSession(path); err != nil { t.Fatalf("DeleteSession: %v", err) } got := config.LoadForEdit(config.UserConfigPath()) mappings := got.Bot.Connections[0].SessionMappings if len(mappings) != 2 { t.Fatalf("session mappings = %+v, want explicit and other auto mappings preserved", mappings) } for _, mapping := range mappings { if mapping.RemoteID == "remove-auto" { t.Fatalf("deleted session auto mapping was preserved: %+v", mappings) } } } func TestOpenChannelSessionForTabIsReadOnly(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "bot-channel.jsonl") if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil { t.Fatalf("write session: %v", err) } app := NewApp() ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"}) app.setTestCtrl(ctrl, "") defer app.activeCtrl().Close() if _, err := app.OpenChannelSessionForTab("test", path); err != nil { t.Fatalf("OpenChannelSessionForTab: %v", err) } if meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly { t.Fatalf("channel tab should be read-only: %+v", meta) } before, err := os.ReadFile(path) if err != nil { t.Fatalf("read before: %v", err) } app.SubmitToTab("test", "must not append") app.RunShellForTab("test", "echo must-not-run") after, err := os.ReadFile(path) if err != nil { t.Fatalf("read after: %v", err) } if string(after) != string(before) { t.Fatalf("read-only channel transcript changed:\nbefore=%s\nafter=%s", before, after) } f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) if err != nil { t.Fatalf("open append: %v", err) } if _, err := f.WriteString(`{"role":"user","content":"external follow-up"}` + "\n"); err != nil { f.Close() t.Fatalf("append external message: %v", err) } if err := f.Close(); err != nil { t.Fatalf("close append: %v", err) } app.snapshotAllTabs() afterSnapshot, err := os.ReadFile(path) if err != nil { t.Fatalf("read after snapshot: %v", err) } if !strings.Contains(string(afterSnapshot), "external follow-up") { t.Fatalf("read-only channel snapshot overwrote external append:\n%s", afterSnapshot) } } func TestUserTriggeredCommandsReturnErrorsWhenUnavailable(t *testing.T) { tests := []struct { name string app *App call func(*App) error want string }{ { name: "submit read-only", app: &App{ tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global", ReadOnly: true}}, activeTabID: "test", }, call: func(app *App) error { return app.SubmitToTab("test", "hello") }, want: "read-only", }, { name: "submit workspace unavailable", app: &App{ tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global", StartupErr: "boom"}}, activeTabID: "test", }, call: func(app *App) error { return app.SubmitToTab("test", "hello") }, want: "workspace failed to start: boom", }, { name: "run shell workspace unavailable", app: &App{ tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global"}}, activeTabID: "test", }, call: func(app *App) error { return app.RunShellForTab("test", "echo hi") }, want: "workspace is still starting", }, { name: "steer workspace unavailable", app: &App{ tabs: map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global"}}, activeTabID: "test", }, call: func(app *App) error { return app.SteerForTab("test", "please continue") }, want: "workspace is still starting", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.call(tt.app) if err == nil { t.Fatalf("expected error containing %q", tt.want) } if !strings.Contains(err.Error(), tt.want) { t.Fatalf("error = %q, want to contain %q", err, tt.want) } }) } } func TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, "bot-channel.jsonl") if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil { t.Fatalf("write session: %v", err) } app := NewApp() ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"}) app.setTestCtrl(ctrl, "") defer ctrl.Close() if _, err := app.OpenChannelSessionForTab("test", path); err != nil { t.Fatalf("OpenChannelSessionForTab: %v", err) } app.mu.Lock() app.tabs["survivor"] = &WorkspaceTab{ID: "survivor", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}} app.tabOrder = []string{"test", "survivor"} app.activeTabID = "test" app.mu.Unlock() f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) if err != nil { t.Fatalf("open append: %v", err) } if _, err := f.WriteString(`{"role":"user","content":"external close follow-up"}` + "\n"); err != nil { f.Close() t.Fatalf("append external message: %v", err) } if err := f.Close(); err != nil { t.Fatalf("close append: %v", err) } if err := app.CloseTab("test"); err != nil { t.Fatalf("CloseTab: %v", err) } afterClose, err := os.ReadFile(path) if err != nil { t.Fatalf("read after close: %v", err) } if !strings.Contains(string(afterClose), "external close follow-up") { t.Fatalf("closing read-only channel tab overwrote external append:\n%s", afterClose) } } func TestResumeSessionRejectsCleanupPending(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } activePath := filepath.Join(dir, "active.jsonl") pendingPath := filepath.Join(dir, "pending.jsonl") for _, path := range []string{activePath, pendingPath} { if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil { t.Fatal(err) } app := NewApp() ctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "test"}) app.setTestCtrl(ctrl, "") defer app.activeCtrl().Close() if _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), "pending cleanup") { t.Fatalf("ResumeSession cleanup-pending error = %v, want pending cleanup", err) } if got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) { t.Fatalf("active session path after rejected resume = %q, want %q", got, activePath) } if _, err := app.OpenChannelSessionForTab("test", pendingPath); err == nil || !strings.Contains(err.Error(), "pending cleanup") { t.Fatalf("OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup", err) } if meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly { t.Fatalf("rejected channel open should not make tab read-only: %+v", meta) } } func TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) { dirA := t.TempDir() dirB := t.TempDir() activePath := filepath.Join(dirA, "active.jsonl") outsidePath := filepath.Join(dirB, "outside.jsonl") for _, path := range []string{activePath, outsidePath} { if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } app := NewApp() app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: "test"}), "") defer app.activeCtrl().Close() if _, err := app.ResumeSession(outsidePath); err == nil { t.Fatal("ResumeSession should reject a transcript outside the active session dir") } if _, err := app.PreviewSession(outsidePath); err == nil { t.Fatal("PreviewSession should reject a transcript outside the active session dir") } } func BenchmarkDesktopListSessionsScoped(b *testing.B) { dirA := filepath.Join(b.TempDir(), "workspace-a-sessions") dirB := filepath.Join(b.TempDir(), "workspace-b-sessions") for _, dir := range []string{dirA, dirB} { if err := os.MkdirAll(dir, 0o755); err != nil { b.Fatalf("mkdir %s: %v", dir, err) } for i := 0; i < 120; i++ { path := filepath.Join(dir, fmt.Sprintf("session-%03d.jsonl", i)) body := fmt.Sprintf(`{"role":"user","content":"session %03d"}`+"\n", i) if err := os.WriteFile(path, []byte(body), 0o644); err != nil { b.Fatalf("write session: %v", err) } } } app := NewApp() app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, "session-000.jsonl"), Label: "test"}), "") defer app.activeCtrl().Close() b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { sessions := app.ListSessions() if len(sessions) != 120 { b.Fatalf("ListSessions len = %d, want 120", len(sessions)) } } } type appendingDesktopRunner struct { session *agent.Session started chan string } func (r *appendingDesktopRunner) Run(_ context.Context, input string) error { r.started <- input r.session.Add(provider.Message{Role: provider.RoleUser, Content: input}) r.session.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"}) return nil } func TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } path := filepath.Join(dir, "memory-display.jsonl") sess := agent.NewSession("sys") exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) runner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)} ctrl := control.New(control.Options{ Runner: runner, Executor: exec, Sink: event.Discard, SessionDir: dir, SessionPath: path, Label: "test", }) defer ctrl.Close() app := NewApp() app.setTestCtrl(ctrl, "deepseek/test") ctrl.QueueMemory(`Saved memory "reasonix-contributions": contribution count updated`) const prompt = "不要,删了" app.SubmitToTab("test", prompt) composed := <-runner.started waitNotRunning(t, ctrl) if !strings.Contains(composed, "") || !strings.HasSuffix(composed, prompt) { t.Fatalf("model input should include memory update followed by prompt, got %q", composed) } got := app.HistoryForTab("test") if len(got) < 2 { t.Fatalf("history length = %d, want user + assistant", len(got)) } if got[0].Role != "system" || got[1].Role != "user" { t.Fatalf("history roles = %+v, want system then user", got[:min(len(got), 2)]) } if got[1].Content != prompt { t.Fatalf("displayed user content = %q, want %q", got[1].Content, prompt) } if strings.Contains(got[1].Content, "") { t.Fatalf("displayed user content leaked memory update: %q", got[1].Content) } } func TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) { isolateDesktopUserDirs(t) workspace := robustTempDir(t) if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(""), 0o644); err != nil { t.Fatalf("write workspace config: %v", err) } dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := agent.NewSessionPath(dir, "test") sess := agent.NewSession("sys") exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) runner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)} ctrl := control.New(control.Options{ Runner: runner, Executor: exec, Sink: event.Discard, SessionDir: dir, SessionPath: path, Label: "test", WorkspaceRoot: workspace, }) app := NewApp() app.setTestCtrl(ctrl, "deepseek/test") app.tabs["test"].Scope = "project" app.tabs["test"].WorkspaceRoot = workspace app.tabs["test"].TopicID = "topic_source" app.tabs["test"].TopicTitle = "Source topic" defer ctrl.Close() ctrl.Submit("first") <-runner.started waitNotRunning(t, ctrl) ctrl.Submit("second") <-runner.started waitNotRunning(t, ctrl) if got := len(ctrl.History()); got != 5 { t.Fatalf("source history len before fork = %d, want 5", got) } meta, err := app.Fork(1) if err != nil { t.Fatalf("Fork: %v", err) } if !meta.Active || meta.ID == "" || meta.ID == "test" { t.Fatalf("fork meta = %+v, want a new active tab", meta) } if got := app.activeTabID; got != meta.ID { t.Fatalf("active tab = %q, want fork tab %q", got, meta.ID) } if got := ctrl.SessionPath(); got != path { t.Fatalf("source controller session path = %q, want %q", got, path) } if got := len(ctrl.History()); got != 5 { t.Fatalf("source history len after fork = %d, want 5", got) } if got, want := meta.TopicTitle, "Source topic · 分叉"; got != want { t.Fatalf("fork topic title = %q, want %q", got, want) } var forkPath string entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("read session dir: %v", err) } for _, entry := range entries { if entry.IsDir() { continue } candidate := filepath.Join(dir, entry.Name()) if candidate == path { continue } m, ok, err := agent.LoadBranchMeta(candidate) if err != nil { t.Fatalf("load fork meta: %v", err) } if ok && m.TopicID == meta.TopicID { forkPath = candidate if m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 { t.Fatalf("fork branch meta = %+v, want parent %q turn 1 index 3", m, agent.BranchID(path)) } if m.Scope != "project" || m.WorkspaceRoot != workspace || m.TopicTitle != "Source topic · 分叉" { t.Fatalf("fork topic meta = %+v", m) } } } if forkPath == "" { t.Fatalf("fork session with topic %q not found in %s", meta.TopicID, dir) } } func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "playwright" command = "npx" args = ["-y", "@playwright/mcp"] `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() view := app.Capabilities() for _, s := range view.Servers { if s.Name == "playwright" { if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" { t.Fatalf("default MCP view = %+v, want deferred automatic idle", s) } return } } t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers) } func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) { home := isolateDesktopUserDirs(t) reasonixHome := filepath.Join(home, ".reasonix") root := filepath.Join(reasonixHome, "plugins", "superpowers") if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(root, "skills", "plan"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "skills", "plan", "SKILL.md"), []byte("---\ndescription: Plan work\n---\nbody"), 0o644); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(root, ".codex-plugin"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".codex-plugin", "plugin.json"), []byte(`{ "name": "superpowers", "version": "6.1.0", "description": "Planning workflows", "skills": "./skills/" }`), 0o644); err != nil { t.Fatal(err) } if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{ Name: "superpowers", Root: "plugins/superpowers", Version: "6.1.0", Description: "Planning workflows", ManifestKind: "codex", Enabled: true, }); err != nil { t.Fatal(err) } app := NewApp() plugins := app.Capabilities().Plugins if len(plugins) != 1 || plugins[0].Name != "superpowers" || plugins[0].Skills != 1 { t.Fatalf("Capabilities().Plugins = %+v", plugins) } if len(plugins[0].SkillDetails) != 1 || plugins[0].SkillDetails[0].Invocation != "/superpowers:plan" { t.Fatalf("Capabilities().Plugins skill details = %+v", plugins[0].SkillDetails) } } func TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) { if testing.Short() { t.Skip("skipping background MCP boot integration test in short mode") } isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) srv := desktopMCPHTTPServer(t) defer srv.Close() if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(` [[plugins]] name = "h" type = "http" url = %q `, srv.URL)), 0o644); err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() sharedHost := plugin.NewHost() defer sharedHost.Close() ctrl, err := boot.Build(ctx, boot.Options{ WorkspaceRoot: dir, SessionDir: filepath.Join(dir, "sessions"), SharedHost: sharedHost, Stderr: io.Discard, }) if err != nil { t.Fatalf("boot.Build: %v", err) } defer ctrl.Close() deadline := time.Now().Add(3 * time.Second) for !sharedHost.HasClient("h") && time.Now().Before(deadline) { time.Sleep(25 * time.Millisecond) } if !sharedHost.HasClient("h") { t.Fatalf("background MCP did not auto-connect; connecting=%v failures=%+v", sharedHost.ConnectingServers(), sharedHost.Failures()) } app := NewApp() app.tabs = map[string]*WorkspaceTab{ "test": { ID: "test", Scope: "global", WorkspaceRoot: dir, Ready: true, Ctrl: ctrl, SharedHostKey: dir, disabledMCP: map[string]ServerView{}, }, } app.activeTabID = "test" view := app.MCPServers() if len(view) != 1 || view[0].Name != "h" || view[0].Status != "connected" || view[0].StartIntent != "automatic" || view[0].RuntimeState != "ready" || view[0].Tools != 1 { t.Fatalf("MCPServers() = %+v, want h connected automatic ready with one tool", view) } } func TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "playwright" command = "npx" args = ["-y", "@playwright/mcp"] `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() if got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) { t.Fatalf("MCPServers() = %+v, want Capabilities().Servers %+v", got, want) } } func TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "time" command = "custom-time" args = ["serve"] tier = "lazy" `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() view := app.Capabilities() found := false for _, s := range view.Servers { if s.Name != "time" { continue } found = true if s.BuiltIn || !s.Configured || s.Command != "custom-time" || !reflect.DeepEqual(s.Args, []string{"serve"}) { t.Fatalf("configured time view = %+v, want ordinary user MCP config", s) } } if !found { t.Fatalf("configured time server missing from Capabilities: %+v", view.Servers) } if err := app.SetMCPServerEnabled("time", false); err != nil { t.Fatalf("SetMCPServerEnabled(time,false): %v", err) } view = app.Capabilities() for _, s := range view.Servers { if s.Name == "time" { if s.Status != "disabled" || s.BuiltIn || s.Command != "custom-time" { t.Fatalf("disabled configured time view = %+v, want disabled external config", s) } return } } t.Fatalf("time missing after disable: %+v", view.Servers) } func TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) srv := desktopMCPHTTPServer(t) defer srv.Close() if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(` [[plugins]] name = "h" type = "http" url = %q `, srv.URL)), 0o644); err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() sharedHost := plugin.NewHost() defer sharedHost.Close() tools, err := sharedHost.Add(ctx, plugin.Spec{Name: "h", Type: "http", URL: srv.URL}) if err != nil { t.Fatalf("sharedHost.Add: %v", err) } activeRegistry := tool.NewRegistry() siblingRegistry := tool.NewRegistry() for _, mt := range tools { activeRegistry.Add(mt) siblingRegistry.Add(mt) } activeCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()}) siblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()}) app := NewApp() app.tabs = map[string]*WorkspaceTab{ "active": { ID: "active", Scope: "global", WorkspaceRoot: dir, Ready: true, Ctrl: activeCtrl, SharedHostKey: dir, disabledMCP: map[string]ServerView{}, }, "sibling": { ID: "sibling", Scope: "global", WorkspaceRoot: dir, Ready: true, Ctrl: siblingCtrl, SharedHostKey: dir, disabledMCP: map[string]ServerView{}, }, } app.activeTabID = "active" if err := app.SetMCPServerEnabled("h", false); err != nil { t.Fatalf("SetMCPServerEnabled(h,false): %v", err) } if _, found := activeRegistry.Get("mcp__h__greet"); found { t.Fatal("active tab still has h tools after disabling the shared server") } if _, found := siblingRegistry.Get("mcp__h__greet"); !found { t.Fatal("sibling tab lost h tools when active tab disabled the shared server") } if !sharedHost.HasClient("h") { t.Fatal("shared host client was removed by a per-tab disable") } view := app.Capabilities() if len(view.Servers) != 1 || view.Servers[0].Name != "h" || view.Servers[0].Status != "disabled" { t.Fatalf("Capabilities after disable = %+v, want h disabled for the active tab", view.Servers) } if err := app.SetMCPServerEnabled("h", true); err != nil { t.Fatalf("SetMCPServerEnabled(h,true): %v", err) } if _, found := activeRegistry.Get("mcp__h__greet"); !found { t.Fatal("active tab did not re-register h tools from the existing shared client") } view = app.Capabilities() if len(view.Servers) != 1 || view.Servers[0].Name != "h" || view.Servers[0].Status != "connected" { t.Fatalf("Capabilities after re-enable = %+v, want h connected for the active tab", view.Servers) } } func TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) { isolateDesktopUserDirs(t) app := NewApp() app.setTestCtrl(newBackgroundJobController(t, "mcp-enabled-job"), "") err := app.SetMCPServerEnabled("time", false) if err == nil || !strings.Contains(err.Error(), "stop background jobs") { t.Fatalf("SetMCPServerEnabled with background job error = %v, want active-work guard", err) } if tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 { t.Fatalf("disabled MCP state changed after rejected toggle: %+v", tab) } } func TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "time" command = "custom-time" args = ["serve"] `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() if err := app.UpdateMCPServer("time", MCPServerInput{ Name: "time", Transport: "stdio", Command: "updated-time", Args: []string{"run"}, }); err != nil { t.Fatalf("UpdateMCPServer(time): %v", err) } cfg, err := config.LoadForRoot(dir) if err != nil { t.Fatal(err) } updated, ok := findPluginEntry(cfg.Plugins, "time") if !ok || updated.Command != "updated-time" || !reflect.DeepEqual(updated.Args, []string{"run"}) { t.Fatalf("updated time plugin = %+v, found=%v", updated, ok) } if err := app.RemoveMCPServer("time"); err != nil { t.Fatalf("RemoveMCPServer(time): %v", err) } cfg, err = config.LoadForRoot(dir) if err != nil { t.Fatal(err) } if _, ok := findPluginEntry(cfg.Plugins, "time"); ok { t.Fatalf("time plugin still configured after remove: %+v", cfg.Plugins) } } func TestRemoveMCPServerClearsRecordedStartupFailure(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "broken" command = "reasonix-missing-mcp-binary" `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() recordMCPFailure(app.activeCtrl(), config.PluginEntry{ Name: "broken", Command: "reasonix-missing-mcp-binary", }, errors.New("connect: missing binary")) view := app.Capabilities() if len(view.Servers) != 1 || view.Servers[0].Name != "broken" || view.Servers[0].Status != "failed" { t.Fatalf("Capabilities before remove = %+v, want broken failed", view.Servers) } if err := app.RemoveMCPServer("broken"); err != nil { t.Fatalf("RemoveMCPServer(broken): %v", err) } if mcpFailed(app.activeCtrl(), "broken") { t.Fatalf("Host.Failures() still contains broken after remove: %+v", app.activeCtrl().Host().Failures()) } view = app.Capabilities() for _, s := range view.Servers { if s.Name == "broken" { t.Fatalf("Capabilities after remove still contains broken: %+v", view.Servers) } } } func TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{ "mcpServers": { "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] }, "keep": { "command": "keep-mcp" } } }`), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() if err := app.RemoveMCPServer("codegraph"); err != nil { t.Fatalf("RemoveMCPServer(.mcp.json codegraph): %v", err) } cfg, err := config.LoadForRoot(dir) if err != nil { t.Fatal(err) } if _, ok := findPluginEntry(cfg.Plugins, "codegraph"); ok { t.Fatalf("codegraph still merged after remove: %+v", cfg.Plugins) } if _, ok := findPluginEntry(cfg.Plugins, "keep"); !ok { t.Fatalf("unrelated .mcp.json server should be preserved: %+v", cfg.Plugins) } } func TestRemoveMCPServerRejectsPluginManagedServerWithoutDisconnecting(t *testing.T) { home := isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) srv := desktopMCPHTTPServer(t) defer srv.Close() reasonixHome := filepath.Join(home, ".reasonix") root := filepath.Join(reasonixHome, "plugins", "superpowers") if err := os.MkdirAll(root, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(fmt.Sprintf(`{ "name": "superpowers", "version": "1.0.0", "mcpServers": { "helper": { "type": "http", "url": %q } } }`, srv.URL)), 0o644); err != nil { t.Fatal(err) } if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{ Name: "superpowers", Root: "plugins/superpowers", Version: "1.0.0", ManifestKind: "reasonix", Enabled: true, }); err != nil { t.Fatal(err) } cfg, err := config.LoadForRoot(dir) if err != nil { t.Fatal(err) } entry, ok := findPluginEntry(cfg.Plugins, "helper") if !ok { t.Fatalf("plugin-managed MCP missing from config: %+v", cfg.Plugins) } ctrl := control.New(control.Options{Host: plugin.NewHost()}) defer ctrl.Close() if _, err := ctrl.ConnectMCPServer(entry); err != nil { t.Fatalf("connect plugin-managed MCP: %v", err) } app := NewApp() app.setTestCtrl(ctrl, "") app.activeTab().WorkspaceRoot = dir err = app.RemoveMCPServer("helper") if err == nil || !strings.Contains(err.Error(), "managed by plugin") || !strings.Contains(err.Error(), "superpowers") { t.Fatalf("RemoveMCPServer(plugin-managed) error = %v", err) } if !mcpConnected(ctrl, "helper") { t.Fatal("plugin-managed MCP was disconnected despite rejected removal") } for action, actionErr := range map[string]error{ "trust tool": app.TrustMCPServerTool("helper", "echo"), "clear auth": app.ClearMCPServerAuthentication("helper"), "update": app.UpdateMCPServer("helper", MCPServerInput{Name: "helper", Transport: "http", URL: srv.URL}), } { if actionErr == nil || !strings.Contains(actionErr.Error(), "managed by plugin") { t.Fatalf("%s plugin-managed MCP error = %v", action, actionErr) } } if _, found := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, "helper"); found { t.Fatal("plugin-managed MCP mutation created a user-config shadow") } servers := app.MCPServers() if len(servers) != 1 || servers[0].Name != "helper" || servers[0].ManagedByPlugin != "superpowers" { t.Fatalf("MCPServers() = %+v, want helper managed by superpowers", servers) } } func TestRemoveMCPServerRejectsRuntimeOnlyServerWithoutDisconnecting(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) srv := desktopMCPHTTPServer(t) defer srv.Close() ctrl := control.New(control.Options{Host: plugin.NewHost()}) defer ctrl.Close() if _, err := ctrl.ConnectMCPServer(config.PluginEntry{Name: "runtime-only", Type: "http", URL: srv.URL}); err != nil { t.Fatalf("connect runtime-only MCP: %v", err) } app := NewApp() app.setTestCtrl(ctrl, "") app.activeTab().WorkspaceRoot = dir err := app.RemoveMCPServer("runtime-only") if err == nil || !strings.Contains(err.Error(), "no removable MCP server") { t.Fatalf("RemoveMCPServer(runtime-only) error = %v", err) } if !mcpConnected(ctrl, "runtime-only") { t.Fatal("runtime-only MCP was disconnected despite failed persistence removal") } } func TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{ "mcpServers": { "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] } } }`), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() if err := app.UpdateMCPServer("codegraph", MCPServerInput{ Name: "codegraph", Transport: "stdio", Command: "reasonix-missing-mcp-binary", Args: []string{"serve", "--mcp"}, Env: map[string]string{"CODEGRAPH_LOG": "debug"}, }); err != nil { t.Fatalf("UpdateMCPServer(.mcp.json codegraph): %v", err) } raw, err := os.ReadFile(filepath.Join(dir, ".mcp.json")) if err != nil { t.Fatal(err) } var doc struct { MCPServers map[string]struct { Command string `json:"command"` Args []string `json:"args"` Env map[string]string `json:"env"` } `json:"mcpServers"` } if err := json.Unmarshal(raw, &doc); err != nil { t.Fatal(err) } got := doc.MCPServers["codegraph"] if got.Command != "reasonix-missing-mcp-binary" || !reflect.DeepEqual(got.Args, []string{"serve", "--mcp"}) || got.Env["CODEGRAPH_LOG"] != "debug" { t.Fatalf(".mcp.json codegraph = %+v, want updated command/args/env", got) } if _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, "codegraph"); ok { t.Fatalf(".mcp.json update should not create a user config shadow entry") } } func TestTrustMCPServerToolPersistsTrustedReadOnlyTools(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "github" command = "npx" args = ["-y", "@modelcontextprotocol/server-github"] trusted_read_only_tools = ["pull_request_read"] `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() if err := app.TrustMCPServerTool("github", " issue_read "); err != nil { t.Fatalf("TrustMCPServerTool(github, issue_read): %v", err) } if err := app.TrustMCPServerTool("github", "issue_read"); err != nil { t.Fatalf("TrustMCPServerTool duplicate: %v", err) } if err := app.TrustMCPServerTools("github", []string{" search_issues ", "issue_read", ""}); err != nil { t.Fatalf("TrustMCPServerTools(github): %v", err) } if err := app.UntrustMCPServerTool("github", " pull_request_read "); err != nil { t.Fatalf("UntrustMCPServerTool(github, pull_request_read): %v", err) } cfg, err := config.LoadForRoot(dir) if err != nil { t.Fatal(err) } updated, ok := findPluginEntry(cfg.Plugins, "github") if !ok { t.Fatalf("github plugin missing: %+v", cfg.Plugins) } if !reflect.DeepEqual(updated.TrustedReadOnlyTools, []string{"issue_read", "search_issues"}) { t.Fatalf("trusted read-only tools = %+v", updated.TrustedReadOnlyTools) } for _, s := range app.MCPServers() { if s.Name == "github" { if !reflect.DeepEqual(s.TrustedReadOnlyTools, []string{"issue_read", "search_issues"}) { t.Fatalf("view trusted read-only tools = %+v", s.TrustedReadOnlyTools) } return } } t.Fatalf("github MCP missing from view") } func TestTrustMCPServerToolPersistsProjectMCPJSONEntry(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{ "mcpServers": { "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] } } }`), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() if err := app.TrustMCPServerTool("codegraph", "codegraph_context"); err != nil { t.Fatalf("TrustMCPServerTool(.mcp.json codegraph): %v", err) } raw, err := os.ReadFile(filepath.Join(dir, ".mcp.json")) if err != nil { t.Fatal(err) } var doc struct { MCPServers map[string]struct { TrustedReadOnlyTools []string `json:"trusted_read_only_tools"` } `json:"mcpServers"` } if err := json.Unmarshal(raw, &doc); err != nil { t.Fatal(err) } if !reflect.DeepEqual(doc.MCPServers["codegraph"].TrustedReadOnlyTools, []string{"codegraph_context"}) { t.Fatalf(".mcp.json trusted_read_only_tools = %+v", doc.MCPServers["codegraph"].TrustedReadOnlyTools) } cfg, err := config.LoadForRoot(dir) if err != nil { t.Fatal(err) } updated, ok := findPluginEntry(cfg.Plugins, "codegraph") if !ok || !reflect.DeepEqual(updated.TrustedReadOnlyTools, []string{"codegraph_context"}) { t.Fatalf("merged codegraph trusted_read_only_tools = %+v, found=%v", updated.TrustedReadOnlyTools, ok) } } func TestAddMCPServerPersistsRemoteHeaders(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) t.Setenv("STRIPE_TOKEN", "stripe-test-token") srv := desktopMCPHTTPServer(t) defer srv.Close() app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() tools, err := app.AddMCPServer(MCPServerInput{ Name: "stripe", Transport: "http", URL: srv.URL, Headers: map[string]string{ "Authorization": "Bearer ${STRIPE_TOKEN}", "X-Org": "team", }, }) if err != nil { t.Fatalf("AddMCPServer(stripe): %v", err) } if tools != 1 { t.Fatalf("tools = %d, want 1", tools) } cfg, err := config.LoadForRoot(dir) if err != nil { t.Fatal(err) } p, ok := findPluginEntry(cfg.Plugins, "stripe") if !ok { t.Fatalf("stripe plugin missing from config: %+v", cfg.Plugins) } if p.Type != "http" || p.URL != srv.URL { t.Fatalf("stripe plugin transport = %q url = %q", p.Type, p.URL) } if p.Headers["Authorization"] != "Bearer ${STRIPE_TOKEN}" || p.Headers["X-Org"] != "team" { t.Fatalf("stripe headers = %+v", p.Headers) } view := app.MCPServers() for _, s := range view { if s.Name == "stripe" { if !reflect.DeepEqual(s.HeaderKeys, []string{"Authorization", "X-Org"}) { t.Fatalf("stripe header keys = %+v", s.HeaderKeys) } return } } t.Fatalf("stripe MCP missing from view: %+v", view) } func TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "dida" type = "http" url = "https://mcp.dida365.com" tier = "lazy" `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() view := app.Capabilities() for _, s := range view.Servers { if s.Name == "dida" { if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" || s.AuthStatus != "possible" || s.AuthURL != "https://mcp.dida365.com" { t.Fatalf("dida auth diagnosis = %+v", s) } return } } t.Fatalf("dida MCP missing from Capabilities: %+v", view.Servers) } func TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "stripe" type = "http" url = "https://mcp.stripe.com" headers = { Authorization = "Bearer ${STRIPE_TOKEN}" } tier = "lazy" `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() view := app.Capabilities() for _, s := range view.Servers { if s.Name == "stripe" { if s.AuthStatus != "none" { t.Fatalf("stripe auth status = %q, want none; server = %+v", s.AuthStatus, s) } return } } t.Fatalf("stripe MCP missing from Capabilities: %+v", view.Servers) } func TestCapabilitiesMarksAuthFailureRequired(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "figma" type = "http" url = "https://mcp.figma.com/mcp" tier = "lazy" `), 0o644); err != nil { t.Fatal(err) } host := plugin.NewHost() host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized")) app := NewApp() app.setTestCtrl(control.New(control.Options{Host: host}), "") defer app.activeCtrl().Close() view := app.Capabilities() for _, s := range view.Servers { if s.Name == "figma" { if s.Status != "failed" || s.AuthStatus != "required" || s.AuthURL != "https://mcp.figma.com/mcp" { t.Fatalf("figma auth diagnosis = %+v", s) } return } } t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers) } func TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "figma" type = "http" url = "https://mcp.figma.com/mcp?access_token=abc&workspace=main" headers = { Authorization = "Bearer ${FIGMA_TOKEN}", "X-Org" = "team" } env = { FIGMA_TOKEN = "${FIGMA_TOKEN}", DEBUG = "1" } tier = "lazy" `), 0o644); err != nil { t.Fatal(err) } host := plugin.NewHost() host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized")) app := NewApp() app.setTestCtrl(control.New(control.Options{Host: host}), "") defer app.activeCtrl().Close() if err := app.ClearMCPServerAuthentication("figma"); err != nil { t.Fatalf("ClearMCPServerAuthentication: %v", err) } if failures := host.Failures(); len(failures) != 0 { t.Fatalf("failure should be cleared: %+v", failures) } cfg, err := config.Load() if err != nil { t.Fatal(err) } p := cfg.Plugins[0] if p.URL != "https://mcp.figma.com/mcp?workspace=main" { t.Fatalf("url = %q", p.URL) } if _, ok := p.Headers["Authorization"]; ok { t.Fatalf("auth header should be removed: %v", p.Headers) } if p.Headers["X-Org"] != "team" { t.Fatalf("ordinary header should be preserved: %v", p.Headers) } if _, ok := p.Env["FIGMA_TOKEN"]; ok { t.Fatalf("auth env should be removed: %v", p.Env) } if p.Env["DEBUG"] != "1" { t.Fatalf("ordinary env should be preserved: %v", p.Env) } view := app.Capabilities() for _, s := range view.Servers { if s.Name == "figma" { if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" || s.AuthStatus != "possible" { t.Fatalf("figma should return to background possible auth: %+v", s) } return } } t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers) } func TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "playwright" command = "npx" args = ["-y", "@playwright/mcp"] env = { TOKEN = "${PLAYWRIGHT_TOKEN}" } tier = "lazy" `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() if err := app.UpdateMCPServer("playwright", MCPServerInput{ Name: "playwright", Transport: "stdio", Command: "node", Args: []string{"server.js"}, }); err != nil { t.Fatalf("UpdateMCPServer: %v", err) } cfg, err := config.Load() if err != nil { t.Fatal(err) } if got := cfg.Plugins[0].Command; got != "node" { t.Fatalf("updated command = %q, want node", got) } if got := cfg.Plugins[0].Env["TOKEN"]; got != "${PLAYWRIGHT_TOKEN}" { t.Fatalf("env TOKEN = %q, want preserved env", got) } userCfg := config.LoadForEdit(config.UserConfigPath()) userPlugin, ok := findPluginEntry(userCfg.Plugins, "playwright") if !ok { t.Fatalf("playwright should be migrated to user config: %+v", userCfg.Plugins) } if userPlugin.Command != "node" || userPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" { t.Fatalf("user plugin after migration = %+v", userPlugin) } if userPlugin.Tier != "" { t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier) } projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml")) if _, ok := findPluginEntry(projectCfg.Plugins, "playwright"); ok { t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins) } view := app.Capabilities() for _, s := range view.Servers { if s.Name == "playwright" { if s.Status != "failed" { t.Fatalf("updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v", s.Status, s) } if s.Command != "node" || len(s.Args) != 1 || s.Args[0] != "server.js" { t.Fatalf("server command not refreshed: %+v", s) } return } } t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers) } func TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) { isolateDesktopUserDirs(t) dir := t.TempDir() t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "playwright" command = "npx" args = ["-y", "@playwright/mcp"] `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() app.activeTab().disabledMCP["playwright"] = ServerView{} if err := app.UpdateMCPServer("playwright", MCPServerInput{ Name: "playwright", Transport: "stdio", Command: "npx -y @modelcontextprotocol/server-filesystem .", }); err != nil { t.Fatalf("UpdateMCPServer: %v", err) } cfg, err := config.Load() if err != nil { t.Fatal(err) } p := cfg.Plugins[0] if p.Command != "npx" { t.Fatalf("command = %q, want npx", p.Command) } if got := strings.Join(p.Args, "\x00"); got != strings.Join([]string{"-y", "@modelcontextprotocol/server-filesystem", "."}, "\x00") { t.Fatalf("args = %v", p.Args) } } func TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "broken" command = "npx" tier = "background" `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() if err := app.UpdateMCPServer("broken", MCPServerInput{ Name: "broken", Transport: "stdio", Command: "reasonix-missing-mcp-binary", }); err != nil { t.Fatalf("UpdateMCPServer should persist config even when reconnect fails: %v", err) } cfg, err := config.Load() if err != nil { t.Fatal(err) } if got := cfg.Plugins[0].Command; got != "reasonix-missing-mcp-binary" { t.Fatalf("updated command = %q, want missing binary", got) } if got := cfg.Plugins[0].Tier; got != "" { t.Fatalf("updated tier = %q, want migrated empty", got) } if !mcpFailed(app.activeCtrl(), "broken") { t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures()) } view := app.Capabilities() for _, s := range view.Servers { if s.Name == "broken" { if s.Status != "failed" { t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s) } if s.Command != "reasonix-missing-mcp-binary" || s.Tier != "background" { t.Fatalf("server config not refreshed after failed reconnect: %+v", s) } return } } t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers) } func TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "codegraph" `), 0o644); err != nil { t.Fatal(err) } reg := tool.NewRegistry() reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"}) app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), "") defer app.activeCtrl().Close() view := app.Capabilities() foundIdle := false for _, s := range view.Servers { if s.Name == "codegraph" { foundIdle = true if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" { t.Fatalf("initial codegraph server = %+v, want automatic idle background state", s) } } } if !foundIdle { t.Fatalf("codegraph missing before reconnect: %+v", view.Servers) } if _, ok := reg.Get("mcp__codegraph__connect"); !ok { t.Fatal("test setup expected stale codegraph connect placeholder") } if err := app.ReconnectMCPServer("codegraph"); err == nil || !strings.Contains(err.Error(), "command is required") { t.Fatalf("ReconnectMCPServer error = %v, want missing command", err) } if _, ok := reg.Get("mcp__codegraph__connect"); ok { t.Fatalf("stale codegraph placeholder still registered after reconnect failure; names=%v", reg.Names()) } if !mcpFailed(app.activeCtrl(), "codegraph") { t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded", app.activeCtrl().Host().Failures()) } view = app.Capabilities() for _, s := range view.Servers { if s.Name == "codegraph" { if s.Status != "failed" || s.Error == "" { t.Fatalf("codegraph after failed reconnect = %+v, want failed with error", s) } return } } t.Fatalf("codegraph missing after reconnect: %+v", view.Servers) } func TestSetMCPServerTierRecordsConnectFailure(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "broken" command = "reasonix-missing-mcp-binary" tier = "lazy" `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer func() { if c := app.activeCtrl(); c != nil { c.Close() } }() if err := app.SetMCPServerTier("broken", "background"); err != nil { t.Fatalf("SetMCPServerTier legacy binding: %v", err) } cfg, err := config.Load() if err != nil { t.Fatal(err) } if got := cfg.Plugins[0].Tier; got != "" { t.Fatalf("saved tier = %q, want migrated empty", got) } userCfg := config.LoadForEdit(config.UserConfigPath()) userPlugin, ok := findPluginEntry(userCfg.Plugins, "broken") if !ok { t.Fatalf("broken should be migrated to user config: %+v", userCfg.Plugins) } if userPlugin.Tier != "" { t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier) } projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml")) if _, ok := findPluginEntry(projectCfg.Plugins, "broken"); ok { t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins) } if !mcpFailed(app.activeCtrl(), "broken") { t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures()) } view := app.Capabilities() for _, s := range view.Servers { if s.Name == "broken" { if s.Status != "failed" { t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s) } if s.Tier != "background" { t.Fatalf("server tier = %q, want background so radio selection does not jump back", s.Tier) } return } } t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers) } func TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { t.Fatalf("mkdir config dir: %v", err) } if err := os.WriteFile(config.UserConfigPath(), []byte(` [[plugins]] name = "broken" command = "reasonix-missing-mcp-binary" tier = "lazy" `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(newBackgroundJobController(t, "mcp-tier-job"), "") err := app.SetMCPServerTier("broken", "background") if err == nil || !strings.Contains(err.Error(), "stop background jobs") { t.Fatalf("SetMCPServerTier with background job error = %v, want active-work guard", err) } data, readErr := os.ReadFile(config.UserConfigPath()) if readErr != nil { t.Fatalf("read config: %v", readErr) } if !strings.Contains(string(data), `tier = "lazy"`) { t.Fatalf("plugin config changed after rejected tier update:\n%s", data) } } func TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) { isolateDesktopUserDirs(t) dir := robustTempDir(t) t.Chdir(dir) if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(` [[plugins]] name = "broken" command = "reasonix-missing-mcp-binary" tier = "eager" `), 0o644); err != nil { t.Fatal(err) } app := NewApp() app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "") defer app.activeCtrl().Close() recordMCPFailure(app.activeCtrl(), config.PluginEntry{ Name: "broken", Command: "reasonix-missing-mcp-binary", Tier: "eager", }, errors.New("connect: missing binary")) view := app.Capabilities() for _, s := range view.Servers { if s.Name == "broken" { if s.Status != "failed" { t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s) } if s.Tier != "background" { t.Fatalf("server tier = %q, want migrated background default", s.Tier) } if !s.Configured { t.Fatalf("server configured = false, want true; server = %+v", s) } return } } t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers) } func TestRunShellForTabRoutesToRequestedTab(t *testing.T) { isolateDesktopUserDirs(t) activeEvents := make(chan event.Event, 16) inactiveEvents := make(chan event.Event, 16) activeCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })}) inactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })}) defer activeCtrl.Close() defer inactiveCtrl.Close() app := &App{ tabs: map[string]*WorkspaceTab{ "active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}, "inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true}, }, tabOrder: []string{"active", "inactive"}, activeTabID: "active", } app.RunShellForTab("inactive", "echo route-test") sawDispatch := false deadline := time.After(3 * time.Second) for { select { case e := <-inactiveEvents: if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, "route-test") { sawDispatch = true } if e.Kind == event.TurnDone { if !sawDispatch { t.Fatal("inactive tab finished without receiving shell dispatch") } select { case active := <-activeEvents: t.Fatalf("active tab received event for inactive shell: %+v", active) default: } return } case <-deadline: t.Fatal("timed out waiting for inactive shell turn") } } } func TestRunShellForTabStaysBoundDuringRapidProjectTabSwitching(t *testing.T) { if testing.Short() { t.Skip("skipping shell cancellation integration test in short mode") } isolateDesktopUserDirs(t) projectA := t.TempDir() projectB := t.TempDir() globalRoot := t.TempDir() shellEvents := make(chan event.Event, 64) projectEvents := make(chan event.Event, 64) globalEvents := make(chan event.Event, 64) shellCtrl := control.New(control.Options{ Sink: event.FuncSink(func(e event.Event) { shellEvents <- e }), WorkspaceRoot: projectA, }) projectCtrl := control.New(control.Options{ Sink: event.FuncSink(func(e event.Event) { projectEvents <- e }), WorkspaceRoot: projectB, }) globalCtrl := control.New(control.Options{ Sink: event.FuncSink(func(e event.Event) { globalEvents <- e }), WorkspaceRoot: globalRoot, }) defer shellCtrl.Close() defer projectCtrl.Close() defer globalCtrl.Close() app := &App{ tabs: map[string]*WorkspaceTab{ "shell": {ID: "shell", Scope: "project", WorkspaceRoot: projectA, Ctrl: shellCtrl, Ready: true}, "project-b": {ID: "project-b", Scope: "project", WorkspaceRoot: projectB, Ctrl: projectCtrl, Ready: true}, "global": {ID: "global", Scope: "global", WorkspaceRoot: globalRoot, Ctrl: globalCtrl, Ready: true}, }, tabOrder: []string{"shell", "project-b", "global"}, activeTabID: "shell", } marker := "shell-route-marker.txt" if err := app.RunShellForTab("shell", longRunningMarkerCommand(marker)); err != nil { t.Fatalf("RunShellForTab: %v", err) } waitForShellDispatch(t, shellEvents, marker) waitForFile(t, filepath.Join(projectA, marker), "shell") for i := 0; i < 8; i++ { if err := app.SetActiveTab("project-b"); err != nil { t.Fatalf("SetActiveTab(project-b): %v", err) } if err := app.SetActiveTab("global"); err != nil { t.Fatalf("SetActiveTab(global): %v", err) } if err := app.SetActiveTab("shell"); err != nil { t.Fatalf("SetActiveTab(shell): %v", err) } } if err := app.SetActiveTab("project-b"); err != nil { t.Fatalf("SetActiveTab(project-b final): %v", err) } app.CancelTab("shell") cancelled := false deadline := time.After(15 * time.Second) for { select { case e := <-shellEvents: if e.Kind == event.ToolResult && e.Tool.Name == "bash" { cancelled = e.Tool.Err != "" } if e.Kind == event.TurnDone { if !cancelled { t.Fatal("shell tab finished without a cancelled shell result") } if _, err := os.Stat(filepath.Join(projectB, marker)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("shell marker appeared in project-b workspace: %v", err) } if got := activeTabIDForTest(app); got != "project-b" { t.Fatalf("active tab = %q, want project-b after background shell cancel", got) } assertNoEvents(t, projectEvents, "project-b") assertNoEvents(t, globalEvents, "global") return } case <-deadline: t.Fatal("timed out waiting for shell tab cancellation") } } } func longRunningMarkerCommand(marker string) string { if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell { return fmt.Sprintf("Set-Content -LiteralPath %s -Value shell; Start-Sleep -Seconds 30", marker) } return fmt.Sprintf("printf shell > %s; sleep 30", marker) } func waitForShellDispatch(t *testing.T, ch <-chan event.Event, marker string) { t.Helper() deadline := time.After(5 * time.Second) for { select { case e := <-ch: if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, marker) { return } case <-deadline: t.Fatal("timed out waiting for shell dispatch") } } } func activeTabIDForTest(app *App) string { app.mu.RLock() defer app.mu.RUnlock() return app.activeTabID } func assertNoEvents(t *testing.T, ch <-chan event.Event, name string) { t.Helper() select { case e := <-ch: t.Fatalf("%s received event while shell ran in another tab: %+v", name, e) default: } } type blockingRunner struct { started chan struct{} release chan struct{} } func (r *blockingRunner) Run(ctx context.Context, _ string) error { close(r.started) select { case <-ctx.Done(): return ctx.Err() case <-r.release: return nil } } func startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() { t.Helper() started := make(chan struct{}) release := make(chan struct{}) jm.StartForSession(agent.BranchID(sessionPath), "bash", "stuck job", func(ctx context.Context, _ io.Writer) (string, error) { close(started) <-ctx.Done() <-release return "", ctx.Err() }) select { case <-started: case <-time.After(2 * time.Second): t.Fatal("background job never started") } released := false return func() { if released { return } released = true close(release) } } func waitNotRunning(t *testing.T, ctrl control.SessionAPI) { t.Helper() deadline := time.Now().Add(time.Second) for ctrl.Running() { if time.Now().After(deadline) { t.Fatal("controller still running") } time.Sleep(10 * time.Millisecond) } } func newBackgroundJobController(t *testing.T, label string) *control.Controller { t.Helper() dir := config.SessionDir() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session dir: %v", err) } path := filepath.Join(dir, label+".jsonl") jm := jobs.NewManager(event.Discard) ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm}) t.Cleanup(ctrl.Close) jm.StartForSession(agent.BranchID(path), "bash", label, func(ctx context.Context, _ io.Writer) (string, error) { <-ctx.Done() return "", ctx.Err() }) return ctrl } func hasLevel(levels []string, want string) bool { for _, level := range levels { if level == want { return true } } return false } func hasCommand(cmds []CommandInfo, name string) bool { for _, cmd := range cmds { if cmd.Name == name { return true } } return false } func hasDirEntry(entries []DirEntry, name string) bool { for _, entry := range entries { if entry.Name == name { return true } } return false } func TestSessionActionsWithoutControllerReturnError(t *testing.T) { app := &App{tabs: map[string]*WorkspaceTab{}} if err := app.NewSession(); err == nil { t.Error("NewSession with no controller must surface an error, not silently no-op") } if err := app.ClearSession(); err == nil { t.Error("ClearSession with no controller must surface an error") } app = &App{ tabs: map[string]*WorkspaceTab{"t1": {ID: "t1", StartupErr: "boot exploded"}}, activeTabID: "t1", } err := app.NewSession() if err == nil || !strings.Contains(err.Error(), "boot exploded") { t.Errorf("error should carry the tab's startup failure, got %v", err) } } // --- Prompt history scanning tests ------------------------------------------ func identityPromptDisplay(text string) string { return text } // TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format // {"kind":"user.message","text":"..."} is correctly extracted. func TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"hello world"} {"kind":"user.message","text":"second prompt"} {"kind":"model.final","content":"response"} `), 0o644); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) if err != nil { t.Fatal(err) } if len(entries) != 2 { t.Fatalf("expected 2 entries, got %d", len(entries)) } if entries[0].Text != "hello world" { t.Errorf("expected 'hello world', got %q", entries[0].Text) } if entries[1].Text != "second prompt" { t.Errorf("expected 'second prompt', got %q", entries[1].Text) } if entries[0].Turn != 0 || entries[1].Turn != 1 { t.Errorf("expected turns 0,1; got %d,%d", entries[0].Turn, entries[1].Turn) } if entries[0].SessionPath != path { t.Errorf("expected session path %q, got %q", path, entries[0].SessionPath) } } // TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event // format {"type":"user.message","text":"..."} is correctly extracted. func TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") if err := os.WriteFile(path, []byte(`{"type":"user.message","text":"v0 prompt"} {"type":"model.final","content":"response"} `), 0o644); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) if err != nil { t.Fatal(err) } if len(entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(entries)) } if entries[0].Text != "v0 prompt" { t.Errorf("expected 'v0 prompt', got %q", entries[0].Text) } } // TestCollectPromptHistoryEntriesProviderMessage verifies that the current // provider.Message format {"role":"user","content":"..."} is correctly extracted. func TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello from provider"} {"role":"assistant","content":"response"} {"role":"user","content":"another prompt"} `), 0o644); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) if err != nil { t.Fatal(err) } if len(entries) != 2 { t.Fatalf("expected 2 entries, got %d", len(entries)) } if entries[0].Text != "hello from provider" { t.Errorf("expected 'hello from provider', got %q", entries[0].Text) } if entries[1].Text != "another prompt" { t.Errorf("expected 'another prompt', got %q", entries[1].Text) } } // TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the // same file are extracted. func TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy prompt"} {"role":"user","content":"modern prompt"} `), 0o644); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) if err != nil { t.Fatal(err) } if len(entries) != 2 { t.Fatalf("expected 2 entries, got %d", len(entries)) } if entries[0].Text != "legacy prompt" { t.Errorf("expected 'legacy prompt', got %q", entries[0].Text) } if entries[1].Text != "modern prompt" { t.Errorf("expected 'modern prompt', got %q", entries[1].Text) } } func TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") rfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC) if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy timed","time":1800000000123} {"role":"user","content":"modern timed","createdAt":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`} `), 0o644); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) if err != nil { t.Fatal(err) } if len(entries) != 2 { t.Fatalf("expected 2 entries, got %d", len(entries)) } if entries[0].At != 1800000000123 { t.Errorf("numeric event time = %d, want 1800000000123", entries[0].At) } if entries[1].At != rfcTime.UnixMilli() { t.Errorf("RFC3339 event time = %d, want %d", entries[1].At, rfcTime.UnixMilli()) } } // TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses // the user-visible prompt text, not the controller-expanded model input. func TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") expanded := "\nSaved memory\n\n\nvisible prompt" if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(expanded)+`}`+"\n"), 0o644); err != nil { t.Fatal(err) } if err := recordSessionDisplay(dir, path, expanded, "visible prompt"); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } entries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path)) if err != nil { t.Fatal(err) } if len(entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(entries)) } if entries[0].Text != "visible prompt" { t.Errorf("expected visible prompt, got %q", entries[0].Text) } } func TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") if err := os.WriteFile(path, []byte(`{"role":"user","content":"Plan approved — plan mode is off"} {"role":"user","content":"real prompt"} `), 0o644); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) if err != nil { t.Fatal(err) } if len(entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(entries)) } if entries[0].Text != "real prompt" { t.Errorf("expected real prompt, got %q", entries[0].Text) } } // TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only // assistant/tool messages returns no entries. func TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "session.jsonl") if err := os.WriteFile(path, []byte(`{"kind":"model.final","content":"response"} {"kind":"tool.result","output":"done"} `), 0o644); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Errorf("expected 0 entries, got %d", len(entries)) } } // TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file // returns no entries without error. func TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "empty.jsonl") if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatal(err) } info, err := os.Stat(path) if err != nil { t.Fatal(err) } entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Errorf("expected 0 entries, got %d", len(entries)) } } // TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans // multiple JSONL files and returns prompts newest-first. func TestScanPromptHistoryFromDir(t *testing.T) { app := &App{tabs: map[string]*WorkspaceTab{"t1": {ID: "t1", Ctrl: nil, WorkspaceRoot: ""}}} _ = app dir := t.TempDir() // Write two session files with different mtimes (sleep to ensure ordering). if err := os.WriteFile(filepath.Join(dir, "a.jsonl"), []byte(`{"role":"user","content":"older prompt"} `), 0o644); err != nil { t.Fatal(err) } time.Sleep(10 * time.Millisecond) if err := os.WriteFile(filepath.Join(dir, "b.jsonl"), []byte(`{"role":"user","content":"newer prompt"} `), 0o644); err != nil { t.Fatal(err) } entries, err := app.scanPromptHistoryFromDir(dir) if err != nil { t.Fatal(err) } if len(entries) != 2 { t.Fatalf("expected 2 entries, got %d", len(entries)) } // Newest-first: "newer prompt" should be first. if entries[0].Text != "newer prompt" { t.Errorf("expected 'newer prompt' first, got %q", entries[0].Text) } if entries[1].Text != "older prompt" { t.Errorf("expected 'older prompt' second, got %q", entries[1].Text) } } func TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) { app := &App{} dir := t.TempDir() base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC) early := filepath.Join(dir, "early.jsonl") late := filepath.Join(dir, "late.jsonl") if err := os.WriteFile(early, []byte(fmt.Sprintf(`{"role":"user","content":"early first","time":%d} {"role":"assistant","content":"ok"} {"role":"user","content":"early second","time":%d} `, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(late, []byte(fmt.Sprintf(`{"role":"user","content":"late newest","time":%d} `, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil { t.Fatal(err) } // Invert file mtimes: session activity should keep each session grouped // before event timestamps are considered within that session. if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil { t.Fatal(err) } if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil { t.Fatal(err) } entries, err := app.scanPromptHistoryFromDir(dir) if err != nil { t.Fatal(err) } if len(entries) != 3 { t.Fatalf("expected 3 entries, got %d", len(entries)) } want := []string{"early second", "early first", "late newest"} for i, w := range want { if entries[i].Text != w { t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries) } } } func TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) { app := &App{} dir := t.TempDir() base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC) early := filepath.Join(dir, "early.jsonl") late := filepath.Join(dir, "late.jsonl") if err := os.WriteFile(early, []byte(`{"role":"user","content":"early first"} {"role":"assistant","content":"ok"} {"role":"user","content":"early second"} `), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(late, []byte(`{"role":"user","content":"late newest"} `), 0o644); err != nil { t.Fatal(err) } if err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{ CreatedAt: base, UpdatedAt: base.Add(time.Minute), }); err != nil { t.Fatal(err) } if err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{ CreatedAt: base.Add(time.Minute), UpdatedAt: base.Add(2 * time.Minute), }); err != nil { t.Fatal(err) } // Invert file mtimes: branch UpdatedAt should be the activity clock. if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil { t.Fatal(err) } if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil { t.Fatal(err) } entries, err := app.scanPromptHistoryFromDir(dir) if err != nil { t.Fatal(err) } if len(entries) != 3 { t.Fatalf("expected 3 entries, got %d", len(entries)) } want := []string{"late newest", "early second", "early first"} for i, w := range want { if entries[i].Text != w { t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries) } } } func TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) { app := &App{} dir := t.TempDir() base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC) empty := filepath.Join(dir, "empty.jsonl") real := filepath.Join(dir, "real.jsonl") if err := os.WriteFile(empty, nil, 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(real, []byte(`{"role":"user","content":"real prompt"} `), 0o644); err != nil { t.Fatal(err) } if err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{ CreatedAt: base, UpdatedAt: base.Add(time.Hour), }); err != nil { t.Fatal(err) } if err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{ CreatedAt: base, UpdatedAt: base, }); err != nil { t.Fatal(err) } entries, err := app.scanPromptHistoryFromDir(dir) if err != nil { t.Fatal(err) } if len(entries) != 1 || entries[0].Text != "real prompt" { t.Fatalf("entries = %+v, want only real prompt after skipping empty session", entries) } } func TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) { dir := t.TempDir() current := filepath.Join(dir, "current.jsonl") other := filepath.Join(dir, "other.jsonl") if err := os.WriteFile(current, []byte(`{"role":"user","content":"current first"} {"role":"assistant","content":"ok"} {"role":"user","content":"current second"} `), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"} `), 0o644); err != nil { t.Fatal(err) } now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC) if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{ CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatal(err) } if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{ CreatedAt: now.Add(time.Minute), UpdatedAt: now.Add(time.Minute), }); err != nil { t.Fatal(err) } app := NewApp() ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"}) defer ctrl.Close() app.setTestCtrl(ctrl, "") result, err := app.ScanPromptHistory("") if err != nil { t.Fatal(err) } if len(result.Entries) != 3 { t.Fatalf("expected current-session entries followed by cross-session fallback, got %d: %+v", len(result.Entries), result.Entries) } want := []string{"current second", "current first", "other newest"} for i, w := range want { if result.Entries[i].Text != w { t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, result.Entries[i].Text, w, result.Entries) } } } func TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) { dir := t.TempDir() current := filepath.Join(dir, "current.jsonl") other := filepath.Join(dir, "other.jsonl") var lines []byte for i := range 55 { lines = append(lines, []byte(fmt.Sprintf(`{"role":"user","content":"current %d"} `, i))...) } if err := os.WriteFile(current, lines, 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"} `), 0o644); err != nil { t.Fatal(err) } now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC) if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{ CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatal(err) } if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{ CreatedAt: now.Add(time.Minute), UpdatedAt: now.Add(time.Minute), }); err != nil { t.Fatal(err) } app := NewApp() ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"}) defer ctrl.Close() app.setTestCtrl(ctrl, "") result, err := app.ScanPromptHistory("") if err != nil { t.Fatal(err) } if len(result.Entries) != promptHistoryPageLimit { t.Fatalf("expected %d entries, got %d", promptHistoryPageLimit, len(result.Entries)) } if result.Entries[0].Text != "current 54" { t.Fatalf("first entry = %q, want current 54", result.Entries[0].Text) } if result.Entries[len(result.Entries)-1].Text != "current 5" { t.Fatalf("last first-page entry = %q, want current 5", result.Entries[len(result.Entries)-1].Text) } if !result.HasOlder || result.OlderCursor == "" { t.Fatalf("first page should expose an older cursor: %+v", result) } for _, entry := range result.Entries { if entry.Text == "other newest" { t.Fatalf("cross-session entry appeared before current-session page was exhausted: %+v", result.Entries) } } nextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor}) if err != nil { t.Fatal(err) } next, err := app.ScanPromptHistory(string(nextRequest)) if err != nil { t.Fatal(err) } want := []string{"current 4", "current 3", "current 2", "current 1", "current 0", "other newest"} if len(next.Entries) != len(want) { t.Fatalf("second page entries = %+v, want %d entries", next.Entries, len(want)) } for i, w := range want { if next.Entries[i].Text != w { t.Fatalf("second page entries[%d] = %q, want %q; all=%+v", i, next.Entries[i].Text, w, next.Entries) } } } func TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) { app := &App{} dir := t.TempDir() var lines []byte for i := range 250 { lines = append(lines, []byte(fmt.Sprintf(`{"role":"user","content":"prompt %d"} `, i))...) } if err := os.WriteFile(filepath.Join(dir, "many.jsonl"), lines, 0o644); err != nil { t.Fatal(err) } entries, err := app.scanPromptHistoryFromDir(dir) if err != nil { t.Fatal(err) } if len(entries) != 250 { t.Fatalf("expected 250 entries, got %d", len(entries)) } if entries[0].Text != "prompt 249" { t.Errorf("expected newest 'prompt 249' first, got %q", entries[0].Text) } } // TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil. func TestScanPromptHistoryFromDirEmpty(t *testing.T) { app := &App{} dir := t.TempDir() entries, err := app.scanPromptHistoryFromDir(dir) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Errorf("expected 0 entries, got %d", len(entries)) } } // TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil // on cache hit (nonce matches). func TestScanPromptHistoryCacheHit(t *testing.T) { app := &App{tabs: map[string]*WorkspaceTab{}} result, err := app.ScanPromptHistory("") if err != nil { t.Fatal(err) } nonce := result.Nonce if nonce == "" { t.Error("expected a non-empty nonce on first call") } // Second call with the same nonce should be a cache hit (nil entries). result2, err := app.ScanPromptHistory(nonce) if err != nil { t.Fatal(err) } if result2.Entries != nil { t.Error("expected nil entries on cache hit") } if result2.Nonce != nonce { t.Errorf("expected nonce %q unchanged, got %q", nonce, result2.Nonce) } } func TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) { dirA := t.TempDir() dirB := t.TempDir() pathA := filepath.Join(dirA, "a.jsonl") pathB := filepath.Join(dirB, "b.jsonl") if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"} `), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"} `), 0o644); err != nil { t.Fatal(err) } app := NewApp() ctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"}) ctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: "test"}) defer ctrlA.Close() defer ctrlB.Close() app.setTestCtrl(ctrlA, "") first, err := app.ScanPromptHistory("") if err != nil { t.Fatal(err) } if len(first.Entries) != 1 || first.Entries[0].Text != "workspace A" { t.Fatalf("first entries = %+v, want workspace A", first.Entries) } app.setTestCtrl(ctrlB, "") second, err := app.ScanPromptHistory(first.Nonce) if err != nil { t.Fatal(err) } if second.Entries == nil { t.Fatal("expected rescan after session dir changes, got cache hit") } if len(second.Entries) != 1 || second.Entries[0].Text != "workspace B" { t.Fatalf("second entries = %+v, want workspace B", second.Entries) } } func TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) { dir := t.TempDir() pathA := filepath.Join(dir, "a.jsonl") pathB := filepath.Join(dir, "b.jsonl") if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"session A"} `), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"session B"} `), 0o644); err != nil { t.Fatal(err) } app := NewApp() ctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: "test"}) ctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: "test"}) defer ctrlA.Close() defer ctrlB.Close() app.setTestCtrl(ctrlA, "") first, err := app.ScanPromptHistory("") if err != nil { t.Fatal(err) } if len(first.Entries) != 2 || first.Entries[0].Text != "session A" || first.Entries[1].Text != "session B" { t.Fatalf("first entries = %+v, want session A followed by session B", first.Entries) } app.setTestCtrl(ctrlB, "") second, err := app.ScanPromptHistory(first.Nonce) if err != nil { t.Fatal(err) } if second.Entries == nil { t.Fatal("expected rescan after session path changes, got cache hit") } if len(second.Entries) != 2 || second.Entries[0].Text != "session B" || second.Entries[1].Text != "session A" { t.Fatalf("second entries = %+v, want session B followed by session A", second.Entries) } }