diff --git a/desktop/app.go b/desktop/app.go index 48e903a49..12712ac24 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -1768,7 +1768,6 @@ func (a *App) SetModel(name string) error { prevPath = tab.Ctrl.SessionPath() _ = tab.Ctrl.Snapshot() carried = tab.Ctrl.History() - tab.Ctrl.Close() } newCtrl, err := boot.Build(a.ctx, boot.Options{ @@ -1776,15 +1775,20 @@ func (a *App) SetModel(name string) error { RequireKey: false, Sink: tab.sink, WorkspaceRoot: tab.WorkspaceRoot, + Stderr: io.Discard, }) if err != nil { return err } + old := tab.Ctrl a.mu.Lock() tab.Ctrl = newCtrl tab.model = name tab.Label = newCtrl.Label() a.mu.Unlock() + if old != nil { + old.Close() + } newCtrl.EnableInteractiveApproval() path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label()) diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index f3ba12145..7a596d75f 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -458,7 +458,15 @@ export function useController() { const compact = useCallback(() => { app.Compact().catch(() => {}); }, []); const setModel = useCallback(async (name: string) => { - await app.SetModel(name).catch(() => {}); + try { + await app.SetModel(name); + } catch (e) { + if (activeTabId) { + const text = `Model switch failed: ${e instanceof Error ? e.message : String(e)}`; + dispatchTo(activeTabId, { type: "local_notice", level: "warn", text }); + } + return; + } if (!activeTabId) return; try { dispatchTo(activeTabId, { type: "meta", meta: await app.Meta() }); diff --git a/desktop/setmodel_test.go b/desktop/setmodel_test.go new file mode 100644 index 000000000..081b669ed --- /dev/null +++ b/desktop/setmodel_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestSetModelFailureLeavesTabStateIntact(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(` +default_model = "test-model" + +[codegraph] +enabled = false + +[[providers]] +name = "test-model" +kind = "openai" +base_url = "https://example.invalid" +model = "x" +api_key_env = "REASONIX_TEST_KEY_UNSET" +`), 0o644); err != nil { + t.Fatal(err) + } + + app := NewApp() + app.ctx = context.Background() + app.readyHook = func() {} + tab := app.createTabEntryWithID("project", workspace, "", "tab1") + app.tabs[tab.ID] = tab + app.activeTabID = tab.ID + app.buildTabController(tab) + if tab.Ctrl == nil { + t.Fatalf("tab controller failed to build: %s", tab.StartupErr) + } + defer tab.Ctrl.Close() + + oldCtrl := tab.Ctrl + oldModel := tab.model + + if err := app.SetModel("no-such-provider/no-such-model"); err == nil { + t.Fatal("SetModel with an unknown model should return an error") + } + if tab.Ctrl != oldCtrl { + t.Fatal("a failed model switch must keep the existing controller, not replace/close it") + } + if tab.model != oldModel { + t.Fatalf("tab.model changed to %q on a failed switch, want %q", tab.model, oldModel) + } +} diff --git a/desktop/settings_app.go b/desktop/settings_app.go index 2365070bc..c8a9ceda2 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io" "os" "strings" @@ -204,7 +205,6 @@ func (a *App) rebuild() error { prevPath = tab.Ctrl.SessionPath() _ = tab.Ctrl.Snapshot() carried = tab.Ctrl.History() - tab.Ctrl.Close() } model := tab.model if cfg, err := config.Load(); err == nil { @@ -219,6 +219,7 @@ func (a *App) rebuild() error { Model: model, RequireKey: false, Sink: tab.sink, WorkspaceRoot: tab.WorkspaceRoot, + Stderr: io.Discard, }) if err != nil { a.mu.Lock() @@ -226,12 +227,16 @@ func (a *App) rebuild() error { a.mu.Unlock() return err } + old := tab.Ctrl a.mu.Lock() tab.Ctrl = ctrl tab.model = model tab.Label = ctrl.Label() tab.StartupErr = "" a.mu.Unlock() + if old != nil { + old.Close() + } ctrl.EnableInteractiveApproval() path := agent.ContinueSessionPath(prevPath, ctrl.SessionDir(), ctrl.Label()) if len(carried) > 0 { diff --git a/desktop/tabs.go b/desktop/tabs.go index 170e0edf3..3364bb7aa 100644 --- a/desktop/tabs.go +++ b/desktop/tabs.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "os" "path/filepath" "sort" @@ -416,6 +417,7 @@ func (a *App) buildTabController(tab *WorkspaceTab) { RequireKey: false, Sink: tab.sink, WorkspaceRoot: root, + Stderr: io.Discard, }) if err != nil { a.mu.Lock()