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.
This commit is contained in:
Josh Mini
2026-08-09 11:23:07 +08:00
parent 1e142aace9
commit bb5bfb2c04
2 changed files with 78 additions and 1 deletions
@@ -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;
@@ -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();