diff --git a/docs/AGENTS.md b/docs/AGENTS.md index e11ee05c..57dcfa3b 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -314,8 +314,12 @@ main agent's, its `AskUserQuestion` parked the workspace idle while background s were still working (the park then swallowing their busy signals until the next prompt), and any tool it tried after an idle `Stop` would trip the `PreToolUse`-while-idle rule with no `Stop` to follow. Only the main agent's `PreToolUse(AskUserQuestion)` parks, and only its -`PostToolUse`/`PostToolUseFailure(AskUserQuestion)` unparks; sub-agent tool activity still -reaches the status through `PostToolUse`. Pinned against a real TUI by the `suggestionfork` +`PostToolUse`/`PostToolUseFailure(AskUserQuestion)` or its turn-ending `Stop` unparks; +sub-agent tool activity still reaches the status through `PostToolUse`. The `Stop` covers a +question Claude drops without a `PostToolUse` (a message arriving mid-tool can start a second +turn branch that asks it, and the first branch then ends the turn): no question can outlive +the turn, and a `Stop` that lifts a park while background tasks keep the workspace busy goes +busy instead of leaving it parked idle. Pinned against a real TUI by the `suggestionfork` scenario in `claude/server-manager.boundary.test.ts`. ### Busy→Idle Edge for Untracked Turns (Claude Code) diff --git a/src/modules/agent-module/claude/server-manager.integration.test.ts b/src/modules/agent-module/claude/server-manager.integration.test.ts index 22eeccb2..7a180abb 100644 --- a/src/modules/agent-module/claude/server-manager.integration.test.ts +++ b/src/modules/agent-module/claude/server-manager.integration.test.ts @@ -2405,6 +2405,37 @@ describe("ClaudeCodeServerManager integration", () => { expect(statusChanges).toEqual(["idle", "busy", "idle", "busy"]); }); + it("the main agent's Stop lifts a dropped AskUserQuestion park while sub-agents run", async () => { + // Real trace: a message arriving mid-tool started a second turn branch that + // asked a question; the first branch then ended the turn and the question + // vanished with no PostToolUse. The Stop must unpark, and the still-running + // sub-agents keep the workspace busy instead of their activity being + // suppressed to idle. + const { port, statusChanges } = await start(); + await sendHook(port, "PreToolUse", { workspacePath: WS, tool_name: "AskUserQuestion" }); + expect(lastStatus(statusChanges)).toBe("idle"); + + await sendHook(port, "Stop", stopWith([subagentTask()])); + expect(lastStatus(statusChanges)).toBe("busy"); + + // The idle_prompt echo stays suppressed; the sub-agents' end still goes idle. + await sendHook(port, "Notification", { workspacePath: WS, notification_type: "idle_prompt" }); + await sendHook(port, "UserPromptSubmit", { workspacePath: WS }); + await sendHook(port, "Stop", stopWith([])); + expect(statusChanges).toEqual(["idle", "busy", "idle", "busy", "idle"]); + }); + + it("the main agent's Stop lifts a dropped park with nothing running (stays idle)", async () => { + const { port, statusChanges } = await start(); + await sendHook(port, "PreToolUse", { workspacePath: WS, tool_name: "AskUserQuestion" }); + await sendHook(port, "Stop", stopWith([])); + expect(lastStatus(statusChanges)).toBe("idle"); + + // No park is left behind: a later sub-agent-free tool call is not suppressed. + await sendHook(port, "PreToolUse", { workspacePath: WS, tool_name: "Bash" }); + expect(lastStatus(statusChanges)).toBe("busy"); + }); + it("Stop without sub-agents still transitions to idle normally", async () => { const port = await serverManager.startServer(testPath("/workspace/feature-a").toNative(), { workspaceRef: refOf(testPath("/workspace/feature-a").toNative()), diff --git a/src/modules/agent-module/claude/server-manager.ts b/src/modules/agent-module/claude/server-manager.ts index 32487d0c..791acce3 100644 --- a/src/modules/agent-module/claude/server-manager.ts +++ b/src/modules/agent-module/claude/server-manager.ts @@ -1124,12 +1124,22 @@ export class ClaudeCodeServerManager implements AgentServerManager { // auth, max-tokens — and the payload omits the field), so it always goes idle // to surface the stuck main agent regardless of background work; clear the // stash there. + // + // The main agent's Stop also ends any AskUserQuestion park: its turn is over, + // so no question of its own can still be open. Claude can drop a question + // without a PostToolUse (seen when a message arriving mid-tool started a + // second turn branch that asked it, and the first branch then ended the + // turn), and a park left behind would suppress every busy signal from the + // still-running background tasks. So a Stop that lifts a park while tasks + // keep the workspace busy goes busy rather than staying parked idle. if (hookName === "Stop") { const tasks = Array.isArray(payload.background_tasks) ? payload.background_tasks : []; const busyTasks = tasks.filter((task) => taskKeepsBusy(task)); + const wasParked = state.awaitingUserInputResolution === true; + state.awaitingUserInputResolution = false; state.busyForBackgroundTasks = busyTasks.length > 0; if (busyTasks.length > 0) { - newStatus = null; + newStatus = wasParked ? "busy" : null; this.logger.scoped({ path: normalizedPath }).debug("Idle suppressed for background tasks", { tasks: busyTasks.map((task) => task.command ?? task.agent_type ?? task.type).join(", "), });