From 7f7e742db6640a5ce38c44a7ed9769dab7d2b0fb Mon Sep 17 00:00:00 2001 From: Hesham Salman Date: Mon, 27 Jul 2026 13:51:13 -0400 Subject: [PATCH] fix(coding-agent): preserve parent todos in vibe mode --- docs/vibe-mode.md | 15 +-- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive-mode.ts | 15 ++- .../src/prompts/system/vibe-mode-active.md | 5 +- .../coding-agent/src/session/agent-session.ts | 4 +- .../test/interactive-mode-vibe-toggle.test.ts | 102 +++++++++++++++--- .../test/sdk-tool-activation.test.ts | 68 +++++++++++- 7 files changed, 179 insertions(+), 31 deletions(-) diff --git a/docs/vibe-mode.md b/docs/vibe-mode.md index 4f3a67d7b..93765eb81 100644 --- a/docs/vibe-mode.md +++ b/docs/vibe-mode.md @@ -2,9 +2,10 @@ Vibe mode turns the session into a **director** that drives persistent background worker sessions instead of editing code itself. In vibe mode your own toolset is -stripped down to `read` plus five worker-control tools; the workers do the -grepping, editing, running, and building, and you verify their work by reading -the files they touch. +stripped down to `read`, optional parent-owned `todo`, plus five worker-control +tools; the workers do the grepping, editing, running, and building, and you +verify their work by reading the files they touch. When available, `todo` is +only the director's parent-session bookkeeping tool; workers do not own it. ## Enabling and disabling @@ -16,8 +17,9 @@ Toggle it with the `/vibe` slash command: /vibe # run again to exit ``` -- Entering installs the vibe tools, reduces the active toolset to `read` + the - vibe tools, and injects the director instructions for the turn. +- Entering installs the vibe tools, reduces the active toolset to `read`, + optional parent-owned `todo`, and the vibe tools, and injects the director + instructions for the turn. - An inline prompt (`/vibe `) enters the mode and submits that prompt as the first directive. - Exiting restores the previous toolset and **kills every worker session** — a @@ -62,7 +64,8 @@ concurrently is the normal shape. 3. Keep directing other workers while turns are in flight; `vibe_wait` only when blocked. 4. When a turn result arrives, `read` the touched files to verify claims before - building on them, then `vibe_send` the next step. + building on them. When the optional parent-owned `todo` tool is available, + reconcile verified work through it, then `vibe_send` the next step. 5. Route by difficulty: draft with `fast`, escalate to `good` when `fast` stalls or the problem needs judgment. 6. `vibe_kill` finished or stuck workers; `vibe_list` to recover the roster. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 280be8c1e..4c9890333 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -82,6 +82,7 @@ - Fixed `@czottmann/pi-automode` failing legacy extension validation because the pi-ai compatibility shim omitted `clampThinkingLevel`, then failing every classified tool call because `ctx.modelRegistry` omitted `getApiKeyAndHeaders`. ([#6648](https://github.com/can1357/oh-my-pi/issues/6648)) - Fixed hide-secrets placeholders conflicting with hashline edit headers by replacing hash-delimited tokens with the unambiguous `$$HASH$$` format ([#6631](https://github.com/can1357/oh-my-pi/issues/6631)). - Fixed the advisor silently swallowing its own quarantined turns: when an advisor called an ungranted tool (e.g. `bash`) its whole turn was discarded before dispatch, so its advice never reached the primary agent and the failure surfaced only in advisor diagnostics — every other non-recovering failure branch notifies the host UI, but quarantine re-primed silently with no bound. A persistently-quarantining advisor now surfaces a `notifyFailure` warning in the main UI (deduped, cleared on the next successful turn) and stops the unbounded silent re-prime loop ([#6661](https://github.com/can1357/oh-my-pi/issues/6661)). +- Fixed `/vibe` mode preventing the director from completing parent todos after verifying worker results: a registered built-in `todo` tool now remains active alongside `read` and Vibe controls, while normal worker sessions still do not receive it. - Fixed the Docker `natives-builder` stage failing to build releases ≥ 17.1.1: the native audio stack added bindgen (miniaudio needs libclang) and a bundled-opus CMake build (needs cmake + make), none of which were installed in the slim builder image. - Fixed a configured `modelRoles.default` naming an extension-registered model (listed in `enabledModels`) silently running on a different in-scope provider's model. The startup model scope is resolved before extensions call `registerProvider()`, so the default role dropped out of scope and `buildSessionOptions` pinned `options.model` to the first scoped model — which marked the model "explicit" and suppressed the post-extension default-role re-resolution. A configured default that can't be found in the startup scope is now deferred so it re-resolves against the fully registered, still `enabledModels`-scoped catalog once extensions load ([#6694](https://github.com/can1357/oh-my-pi/issues/6694)). - Fixed Parakeet speech-to-text failing to load `sherpa-onnx-node` from Windows source workspaces when Bun installed the wrapper under `packages/coding-agent/node_modules` but hoisted its native platform package to the repository root ([#6690](https://github.com/can1357/oh-my-pi/issues/6690)). diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 9a444a4f3..490d156a9 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -3277,9 +3277,10 @@ export class InteractiveMode implements InteractiveModeContext { /** * `/vibe` toggle. Entering installs the ephemeral vibe tools, strips the - * active toolset down to `read` plus those tools, and injects the director - * context. Exiting unregisters them, restores the previous toolset, and kills - * every worker session so workers cannot outlive the mode that directs them. + * active toolset down to `read`, optional parent-owned `todo`, plus those + * tools, and injects the director context. Exiting unregisters them, restores + * the previous toolset, and kills every worker session so workers cannot + * outlive the mode that directs them. */ async handleVibeModeCommand(initialPrompt?: string): Promise { if (this.vibeModeEnabled) { @@ -3317,7 +3318,9 @@ export class InteractiveMode implements InteractiveModeContext { const ownerScope = vibeRegistry.ownerScope(this.#vibeParentSession()); vibeRegistry.activateScope(ownerScope); const previousTools = this.session.getEnabledToolNames(); - await this.session.activateVibeTools(["read"]); + const vibeBaseTools = ["read"]; + if (this.session.hasBuiltInTool("todo")) vibeBaseTools.push("todo"); + await this.session.activateVibeTools(vibeBaseTools); this.#vibeModePreviousTools = previousTools; this.#vibeModeOwnerScope = ownerScope; this.vibeModeEnabled = true; @@ -3330,7 +3333,9 @@ export class InteractiveMode implements InteractiveModeContext { } this.#updateVibeModeStatus(); if (options?.persistModeChange !== false) this.sessionManager.appendModeChange("vibe"); - this.showStatus("Vibe mode enabled. You direct fast/good worker sessions; toolset is read + vibe tools."); + this.showStatus( + "Vibe mode enabled. You direct fast/good worker sessions; toolset is read + optional parent Todo + vibe tools.", + ); } async #exitVibeMode(): Promise { diff --git a/packages/coding-agent/src/prompts/system/vibe-mode-active.md b/packages/coding-agent/src/prompts/system/vibe-mode-active.md index 6d09f7b0d..a54ddccad 100644 --- a/packages/coding-agent/src/prompts/system/vibe-mode-active.md +++ b/packages/coding-agent/src/prompts/system/vibe-mode-active.md @@ -1,7 +1,7 @@ Vibe mode is ON. You are the DIRECTOR. You do not edit, run, grep, or build anything yourself — your hands are off the keyboard. You drive two kinds of worker CLIs, each a full coding agent with every normal tool, and you verify their work by reading files. -Your entire toolset: `read`, `vibe_spawn`, `vibe_send`, `vibe_wait`, `vibe_kill`, `vibe_list`. +Your entire toolset: `read`{{#if todoAvailable}}, `todo`{{/if}}, `vibe_spawn`, `vibe_send`, `vibe_wait`, `vibe_kill`, `vibe_list`. # The two CLIs you drive @@ -16,6 +16,9 @@ Sessions are persistent conversations, like terminals you keep open. A session r 2. `vibe_spawn` with a complete, self-contained brief: files, constraints, acceptance criteria. Workers start blank — they never see this conversation. 3. Sends and spawns return immediately; results arrive on their own when a worker finishes its turn. Keep directing other sessions meanwhile; call `vibe_wait` only when you cannot proceed without a result. 4. When a turn result arrives, judge it: `read` the touched files to verify claims before building on them. Follow up with `vibe_send` — corrections, next step, or a review request. +{{#if todoAvailable}} +After reading and verifying a worker result, use `todo` to maintain the parent session's list. Workers do not own this bookkeeping. +{{/if}} 5. Route by difficulty: draft with `fast`, escalate to `good` when `fast` stalls or the problem needs judgment; have `good` design and `fast` execute the mechanical parts. 6. `vibe_kill` a session that is stuck or whose workstream is done; `vibe_list` when you lose track of the roster. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index b86958944..0213ef2a5 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -4477,7 +4477,9 @@ export class AgentSession { return { role: "custom", customType: "vibe-mode-context", - content: prompt.render(vibeModeActivePrompt), + content: prompt.render(vibeModeActivePrompt, { + todoAvailable: this.getActiveToolNames().includes("todo"), + }), display: false, attribution: "agent", timestamp: Date.now(), 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 5b13b205b..31395b5bd 100644 --- a/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts +++ b/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts @@ -2,7 +2,8 @@ * Contracts: /vibe mode toggle on InteractiveMode. * * 1. Vibe tools do not exist in the session registry before the mode is entered. - * 2. Entering registers and activates exactly `read` plus the vibe tools. + * 2. Entering registers and activates exactly `read`, parent-owned `todo`, plus + * the vibe tools. * 3. Exiting unregisters the vibe tools and restores the pre-vibe active toolset * exactly, including the legitimate empty set. */ @@ -15,6 +16,7 @@ import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mod import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { normalizeCustomMessagePayload } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { FileSessionStorage, type WriteTextAtomicOptions } from "@oh-my-pi/pi-coding-agent/session/session-storage"; import { VIBE_TOOL_NAMES } from "@oh-my-pi/pi-coding-agent/tools/vibe"; @@ -85,6 +87,7 @@ describe("InteractiveMode vibe mode toggle", () => { let authStorage: AuthStorage; let session: AgentSession; let mode: InteractiveMode; + let modelRegistry: ModelRegistry; let storage: ExitFaultStorage; beforeAll(async () => { @@ -97,12 +100,11 @@ describe("InteractiveMode vibe mode toggle", () => { tempDir = TempDir.createSync("@pi-vibe-toggle-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - const modelRegistry = new ModelRegistry(authStorage); + modelRegistry = new ModelRegistry(authStorage); const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 to exist in registry"); - const registryTools = [stubTool("read")]; - + const registryTools = [stubTool("read"), stubTool("todo")]; storage = new ExitFaultStorage(); session = new AgentSession({ agent: new Agent({ @@ -117,6 +119,7 @@ describe("InteractiveMode vibe mode toggle", () => { settings: Settings.isolated({}), modelRegistry, toolRegistry: new Map(registryTools.map(tool => [tool.name, tool])), + builtInToolNames: registryTools.map(tool => tool.name), createVibeTools: () => VIBE_TOOL_NAMES.map(stubTool), }); mode = new InteractiveMode(session, "test", undefined, undefined, undefined, undefined, new EventBus()); @@ -132,29 +135,88 @@ describe("InteractiveMode vibe mode toggle", () => { resetSettingsForTest(); }); - it("restores the exact pre-vibe toolset on exit, including an empty one", async () => { - expect(session.getAllToolNames()).toEqual(["read"]); + it("preserves the parent Todo tool and restores the exact pre-vibe toolset on exit", async () => { + expect(session.getAllToolNames().toSorted()).toEqual(["read", "todo"]); expect(session.getActiveToolNames()).toEqual([]); await mode.handleVibeModeCommand(); expect(mode.vibeModeEnabled).toBe(true); const inMode = session.getActiveToolNames(); expect(inMode).toContain("read"); + expect(inMode).toContain("todo"); for (const name of VIBE_TOOL_NAMES) { expect(inMode).toContain(name); } - expect(inMode.toSorted()).toEqual(["read", ...VIBE_TOOL_NAMES].toSorted()); - expect(session.getAllToolNames().toSorted()).toEqual(["read", ...VIBE_TOOL_NAMES].toSorted()); + expect(inMode.toSorted()).toEqual(["read", "todo", ...VIBE_TOOL_NAMES].toSorted()); + expect(session.getAllToolNames().toSorted()).toEqual(["read", "todo", ...VIBE_TOOL_NAMES].toSorted()); - // Toggle off: the empty previous toolset must come back — vibe tools - // must not leak past the mode. + const sendCustomMessage = vi.spyOn(session, "sendCustomMessage"); + await session.sendVibeModeContext({ deliverAs: "steer" }); + const message = normalizeCustomMessagePayload(sendCustomMessage.mock.calls[0]?.[0]); + const content = typeof message.content === "string" ? message.content : ""; + expect(message.customType).toBe("vibe-mode-context"); + expect(content).toContain("`todo`"); + expect(content).toContain("parent session's list"); + expect(content).toContain("Workers do not own this bookkeeping."); + + // Toggle off: the empty previous toolset must come back — only the + // ephemeral vibe tools must leave the registry. await mode.handleVibeModeCommand(); expect(mode.vibeModeEnabled).toBe(false); expect(session.getActiveToolNames()).toEqual([]); - expect(session.getAllToolNames()).toEqual(["read"]); + expect(session.getAllToolNames().toSorted()).toEqual(["read", "todo"]); }); - it("preserves workers and mode metadata on a same-session reload", async () => { + it("keeps a same-named non-built-in Todo tool unavailable in Vibe mode", async () => { + const model = session.model; + if (!model) throw new Error("Expected active model"); + const foreignTodoSession = 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(["read", "todo"].map(name => [name, stubTool(name)])), + builtInToolNames: ["read"], + createVibeTools: () => VIBE_TOOL_NAMES.map(stubTool), + }); + const foreignTodoMode = new InteractiveMode( + foreignTodoSession, + "test", + undefined, + undefined, + undefined, + undefined, + new EventBus(), + ); + + try { + await foreignTodoMode.handleVibeModeCommand(); + expect(foreignTodoSession.getActiveToolNames().toSorted()).toEqual(["read", ...VIBE_TOOL_NAMES].toSorted()); + + const sendCustomMessage = vi.spyOn(foreignTodoSession, "sendCustomMessage"); + await foreignTodoSession.sendVibeModeContext({ deliverAs: "steer" }); + const message = normalizeCustomMessagePayload(sendCustomMessage.mock.calls[0]?.[0]); + const content = typeof message.content === "string" ? message.content : ""; + expect(content).not.toContain("`todo`"); + expect(content).not.toContain("parent session's list"); + + await foreignTodoMode.handleVibeModeCommand(); + expect(foreignTodoSession.getActiveToolNames()).toEqual([]); + expect(foreignTodoSession.getAllToolNames().toSorted()).toEqual(["read", "todo"]); + } finally { + foreignTodoMode.stop(); + await foreignTodoSession.dispose(); + } + }); + + it("preserves workers, Todo access, and mode metadata on a same-session reload", async () => { await mode.init({ suppressWelcomeIntro: true }); await mode.handleVibeModeCommand(); await session.sessionManager.ensureOnDisk(); @@ -173,6 +235,13 @@ describe("InteractiveMode vibe mode toggle", () => { expect(await switching).toBe(true); expect(mode.vibeModeEnabled).toBe(true); + expect(session.getActiveToolNames()).toEqual(expect.arrayContaining(["read", "todo", ...VIBE_TOOL_NAMES])); + const sendCustomMessage = vi.spyOn(session, "sendCustomMessage"); + await session.sendVibeModeContext({ deliverAs: "steer" }); + const message = normalizeCustomMessagePayload(sendCustomMessage.mock.calls[0]?.[0]); + const content = typeof message.content === "string" ? message.content : ""; + expect(content).toContain("`todo`"); + expect(content).toContain("parent session's list"); expect(suspend).toHaveBeenCalledTimes(1); expect(terminate).not.toHaveBeenCalled(); expect(vibeModeEntryCount(session.sessionManager)).toBe(1); @@ -230,7 +299,7 @@ describe("InteractiveMode vibe mode toggle", () => { it("does not clobber the target's active tools with the source snapshot when switching out of vibe", async () => { await mode.init({ suppressWelcomeIntro: true }); // Pre-vibe snapshot on the source session is empty; entering vibe activates - // read + the vibe tools. + // read, parent-owned todo, and the vibe tools. await mode.handleVibeModeCommand(); expect(mode.vibeModeEnabled).toBe(true); expect(session.getActiveToolNames()).toContain("read"); @@ -246,9 +315,10 @@ describe("InteractiveMode vibe mode toggle", () => { expect(await session.switchSession(targetFile)).toBe(true); expect(mode.vibeModeEnabled).toBe(false); - // The transient vibe tools are gone, but the genuinely-active `read` tool - // must survive — the source's empty pre-vibe snapshot must not wipe it. - expect(session.getActiveToolNames()).toEqual(["read"]); + // The transient vibe tools are gone, but the genuinely-active `read` and + // parent-owned `todo` tools must survive — the source's empty pre-vibe + // snapshot must not wipe them. + expect(session.getActiveToolNames()).toEqual(["read", "todo"]); for (const name of VIBE_TOOL_NAMES) { expect(session.getActiveToolNames()).not.toContain(name); } diff --git a/packages/coding-agent/test/sdk-tool-activation.test.ts b/packages/coding-agent/test/sdk-tool-activation.test.ts index 6d583a805..2720d3c0c 100644 --- a/packages/coding-agent/test/sdk-tool-activation.test.ts +++ b/packages/coding-agent/test/sdk-tool-activation.test.ts @@ -305,7 +305,7 @@ describe("createAgentSession defaultInactive tool activation", () => { await session.dispose(); } }); - it("registers vibe tools only during explicit vibe activation", async () => { + it("registers vibe tools only during explicit vibe activation and exposes parent Todo bookkeeping", async () => { const tempDir = makeTempDir(); const { session } = await createAgentSession(baseOptions(tempDir)); const previousActiveToolNames = session.getActiveToolNames(); @@ -315,12 +315,27 @@ describe("createAgentSession defaultInactive tool activation", () => { expect(session.getToolByName(name)).toBeUndefined(); } - await session.activateVibeTools(["read"]); + await session.activateVibeTools(["read", "todo"]); + const todo = session.getToolByName("todo"); + if (!todo) throw new Error("Expected real Todo tool"); + expect(session.getActiveToolNames()).toContain("todo"); for (const name of VIBE_TOOL_NAMES) { expect(session.getToolByName(name)).toBeDefined(); expect(session.getActiveToolNames()).toContain(name); } + await todo.execute("vibe-todo-init", { + op: "init", + list: [{ phase: "Work", items: ["Worker change"] }], + }); + await todo.execute("vibe-todo-done", { op: "done", task: "Worker change" }); + expect(session.getTodoPhases()).toMatchObject([ + { + name: "Work", + tasks: [{ content: "Worker change", status: "completed" }], + }, + ]); + await session.deactivateVibeTools(previousActiveToolNames); for (const name of VIBE_TOOL_NAMES) { expect(session.getToolByName(name)).toBeUndefined(); @@ -331,6 +346,55 @@ describe("createAgentSession defaultInactive tool activation", () => { } }); + it("rehydrates completed parent Todo work from persisted session history", async () => { + const tempDir = makeTempDir(); + const sessionManager = SessionManager.create(tempDir, tempDir); + const { session } = await createAgentSession({ + ...baseOptions(tempDir), + sessionManager, + }); + + try { + await session.activateVibeTools(["read", "todo"]); + const todo = session.getToolByName("todo"); + if (!todo) throw new Error("Expected real Todo tool"); + const init = await todo.execute("vibe-todo-init", { + op: "init", + list: [{ phase: "Worker flow", items: ["Reconcile worker result"] }], + }); + const done = await todo.execute("vibe-todo-done", { op: "done", task: "Reconcile worker result" }); + for (const [toolCallId, result] of [ + ["vibe-todo-init", init], + ["vibe-todo-done", done], + ] as const) { + sessionManager.appendMessage({ + role: "toolResult", + toolCallId, + toolName: "todo", + content: result.content, + details: result.details, + isError: result.isError === true, + timestamp: Date.now(), + }); + } + await sessionManager.ensureOnDisk(); + const sessionFile = session.sessionFile; + if (!sessionFile) throw new Error("Expected persisted session file"); + + session.setTodoPhases([]); + expect(session.getTodoPhases()).toEqual([]); + expect(await session.switchSession(sessionFile)).toBe(true); + expect(session.getTodoPhases()).toMatchObject([ + { + name: "Worker flow", + tasks: [{ content: "Reconcile worker result", status: "completed" }], + }, + ]); + } finally { + await session.dispose(); + } + }); + it("does not register the xAI TTS tool unless enabled", async () => { const tempDir = makeTempDir();