From bb5bfb2c04ce6404274d88df76d60d5f36be3cd3 Mon Sep 17 00:00:00 2001 From: Josh Mini Date: Sun, 9 Aug 2026 11:23:07 +0800 Subject: [PATCH] fix(vibe): only override the vibe snapshot when teardown lost the live toolset The persisted pre-vibe snapshot was applied on every reconciliation that re-entered vibe mode, including cold resumes and switches in from a non-vibe session. Those paths build their toolset from the current CLI flags and settings, so replacing it with a historical snapshot silently drops tools the session was started with: resuming a session that entered vibe under --tools read with --tools read,bash restored only read on exit. Gate the override on the one case the snapshot exists for: the vibe -> vibe switch, where #clearTransientModeState kept the already-reduced live set. --- .../src/modes/interactive-mode.ts | 12 +++- .../test/interactive-mode-vibe-toggle.test.ts | 67 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index f0c52bd32..37ee14a29 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -2513,6 +2513,14 @@ export class InteractiveMode implements InteractiveModeContext { this.#vibeModeOwnerScope?.ownerId === targetVibeScope.ownerId && this.#vibeModeOwnerScope.parentSessionId === targetVibeScope.parentSessionId && this.#vibeModeOwnerScope.parentSessionFile === targetVibeScope.parentSessionFile; + // #clearTransientModeState below keeps the live active set instead of + // applying a snapshot, so for a vibe -> vibe switch the live toolset is + // already the reduced vibe set and cannot serve as the pre-vibe snapshot. + // That is the only case the persisted snapshot is for: a cold resume or a + // switch in from a non-vibe session built its toolset from the current CLI + // flags and settings, and that set — not a historical one — is what exiting + // vibe must restore. + const vibeToolsetLostToTeardown = this.vibeModeEnabled && !preserveVibe; await this.#clearTransientModeState({ preserveVibe, vibeScopeAlreadySuspended }); await VibeSessionRegistry.global().rehydrate(vibeSession); const goalEnabled = this.session.settings.get("goal.enabled"); @@ -2552,7 +2560,9 @@ export class InteractiveMode implements InteractiveModeContext { if (!preserveVibe) { await this.#enterVibeMode({ persistModeChange: false, - previousTools: readPersistedToolNames(sessionContext.modeData?.previousTools), + previousTools: vibeToolsetLostToTeardown + ? readPersistedToolNames(sessionContext.modeData?.previousTools) + : undefined, }); } return; diff --git a/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts b/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts index 480047845..d9f6448c8 100644 --- a/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts +++ b/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts @@ -322,6 +322,73 @@ describe("InteractiveMode vibe mode toggle", () => { } }); + it("keeps the freshly built toolset when resuming a vibe session from outside vibe mode", async () => { + const model = session.model; + if (!model) throw new Error("Expected active model"); + const openFixture = (toolNames: string[]) => { + const opened = new AgentSession({ + agent: new Agent({ + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + }), + sessionManager: SessionManager.create(tempDir.path(), tempDir.path()), + settings: Settings.isolated({}), + modelRegistry, + toolRegistry: new Map(toolNames.map(name => [name, stubTool(name)])), + builtInToolNames: toolNames, + createVibeTools: () => VIBE_TOOL_NAMES.map(stubTool), + }); + return { + session: opened, + mode: new InteractiveMode(opened, "test", undefined, undefined, undefined, undefined, new EventBus()), + }; + }; + + // Target session entered vibe when only `read` and `todo` existed, so its + // persisted snapshot predates `bash`. + const { session: targetSession, mode: targetMode } = openFixture(["read", "todo"]); + let targetFile: string; + try { + await targetMode.init({ suppressWelcomeIntro: true }); + await targetSession.setActiveToolsByName(["read", "todo"]); + await targetMode.handleVibeModeCommand(); + await targetSession.sessionManager.ensureOnDisk(); + const file = targetSession.sessionFile; + if (!file) throw new Error("Expected persisted session file"); + targetFile = file; + } finally { + targetMode.stop(); + await targetSession.dispose(); + } + + // The resuming process is not in vibe mode, so the teardown path never + // runs and its live toolset — built from the current CLI flags and + // settings, here including `bash` — is the real pre-vibe set. The stale + // persisted snapshot must not override it, or `bash` would be dropped for + // the rest of the session. + const { session: resumed, mode: resumedMode } = openFixture(["read", "todo", "bash"]); + try { + await resumedMode.init({ suppressWelcomeIntro: true }); + await resumed.setActiveToolsByName(["read", "todo", "bash"]); + expect(resumedMode.vibeModeEnabled).toBe(false); + + expect(await resumed.switchSession(targetFile)).toBe(true); + expect(resumedMode.vibeModeEnabled).toBe(true); + expect(resumed.getActiveToolNames()).not.toContain("bash"); + + await resumedMode.handleVibeModeCommand(); + expect(resumedMode.vibeModeEnabled).toBe(false); + expect(resumed.getActiveToolNames().toSorted()).toEqual(["bash", "read", "todo"]); + } finally { + resumedMode.stop(); + await resumed.dispose(); + } + }); + it("passes the session's active model into vibe rehydration on resume", async () => { await mode.init({ suppressWelcomeIntro: true }); await mode.handleVibeModeCommand();