diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b257328d25c8..df2319ad060f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28954,10 +28954,8 @@ struct CMUXCLI { let hasPendingBackgroundWork = hasActiveClaudeBackgroundWork(parsedInput) // Claude sets stop_hook_active on a re-entry after a Stop hook // blocked once. That flag describes hook recursion, not pending - // work: it must not mark the turn pending, but it does keep the - // sidebar pill in Running. Only authoritative background-work - // signals mark the turn pending and show Waiting. - let isReentrantStop = parsedInput.rawObject?["stop_hook_active"] as? Bool == true + // work: it must not mark the turn pending. Only authoritative + // background-work signals keep the turn out of Idle. let hasUnsettledWork = stopFailure == nil && hasPendingBackgroundWork // Update session with transcript summary and send completion notification. @@ -29026,8 +29024,8 @@ struct CMUXCLI { // // A background task or cron is a deterministic wakeup the pane // is parked on, which reads as Waiting. A re-entrant Stop - // (`stop_hook_active`) keeps the pill on Running separately - // because it does not represent pending background work. + // (`stop_hook_active`) does not change this decision because + // it is not pending background work. try? setClaudeStatus( client: client, workspaceId: workspaceId, @@ -29037,16 +29035,6 @@ struct CMUXCLI { color: "#8E8E93", workState: .waiting ) - } else if isReentrantStop { - try? setClaudeStatus( - client: client, - workspaceId: workspaceId, - surfaceId: surfaceId, - value: String(localized: "agent.generic.status.running", defaultValue: "Running"), - icon: "bolt.fill", - color: "#4C8DFF", - workState: .running - ) } else { try? setClaudeStatus( client: client, diff --git a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift index b07b61512541..852e05441111 100644 --- a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift +++ b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift @@ -13,7 +13,7 @@ struct ClaudeBackgroundWorkNotifyTests { (snapshot.compactMap(AgentHookTestNotificationPipeline.candidatePresentation) + snapshot).first { $0.hasPrefix("notify_target_async ") && $0.contains(needle) } } - @Test func stopHookContinuationDoesNotPoisonTheLaterIdleSignal() throws { + @Test func reentrantStopWithoutBackgroundWorkSettlesIdle() throws { let result = try runStopHook(name: "stop-continuation", sessionId: "continued-session", stdin: """ {"session_id":"continued-session","hook_event_name":"Stop","stop_hook_active":true,"last_assistant_message":"Intermediate response","background_tasks":[],"session_crons":[]} """) @@ -22,9 +22,11 @@ struct ClaudeBackgroundWorkNotifyTests { // must not mark the completion as pending or poison the later idle signal. #expect(notifyLine(result.snapshot, containing: "c=turn-complete;p=0") != nil) #expect(journalEvent(result.snapshot, kind: "agent.turn.completed", pendingWork: false) != nil) - // A re-entrant Stop is the agent itself still going, not a pane parked - // on a deterministic wakeup, so it stays Running. - #expect(statusLine(result.snapshot, value: "Running") != nil) + // Hook recursion is not evidence of live work. The final re-entrant + // Stop must settle the pane so it cannot remain stuck on Running until + // another user prompt arrives. + #expect(statusLine(result.snapshot, value: "Idle") != nil) + #expect(statusLine(result.snapshot, value: "Running") == nil) #expect(statusLine(result.snapshot, value: "Waiting") == nil) } diff --git a/tests/test_claude_hook_stop_last_assistant.py b/tests/test_claude_hook_stop_last_assistant.py index 1ce54a135eef..b2f736581889 100644 --- a/tests/test_claude_hook_stop_last_assistant.py +++ b/tests/test_claude_hook_stop_last_assistant.py @@ -264,13 +264,15 @@ def main() -> int: print(f"FAIL: incorrect semantic completion: {notifications[0]!r}") return 1 if not any( - command.startswith("set_status claude_code Running ") + command.startswith("set_status claude_code Idle ") and f"--tab={workspace_id}" in command and f"--panel={surface_id}" in command - and "--work=running" in command for command in server.commands ): - print(f"FAIL: re-entrant Stop with stop_hook_active=true did not keep Running: {server.commands!r}") + print(f"FAIL: re-entrant Stop with stop_hook_active=true did not settle Idle: {server.commands!r}") + return 1 + if any(command.startswith("set_status claude_code Running ") for command in server.commands): + print(f"FAIL: re-entrant Stop with stop_hook_active=true set Running: {server.commands!r}") return 1 print("PASS: Claude cron guard denies durable jobs and Stop notification uses final assistant text")