Files
2026-07-13 13:00:08 +08:00

8719 lines
285 KiB
Go

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, "<memory-update>") || !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, "<memory-update>") {
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 := "<memory-update>\nSaved memory\n</memory-update>\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)
}
}