diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index fa07472cd47..e1494c10c14 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -3132,6 +3132,30 @@ describe('shared agent-hook-listener', () => { expect(state.claudeSubagentRosterByPaneKey.has(PANE_KEY)).toBe(false) }) + it('resolves a known-child drain to done instead of sticky working when no lead ever spoke', () => { + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'a1', + agent_type: 'general-purpose' + }) + // Why: the child's own identity-matched stop drains the roster; the old no-lead + // 'working' default left this pane spinning until the stale sweeper. + const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + expect(drained?.payload.state).toBe('done') + expect(drained?.payload.subagents).toBeUndefined() + }) + + it('suppresses a duplicate stale stop after a no-lead drain', () => { + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'a1', + agent_type: 'general-purpose' + }) + claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + const duplicate = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + expect(duplicate).toBeNull() + }) + it('re-emits the lead done state on a start-less SubagentStop after a completed turn', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) claudeEvent({ hook_event_name: 'Stop', background_tasks: [] }) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 0924655910e..6da538aca35 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2573,14 +2573,16 @@ function buildClaudeChildDrivenStatusPayload( // Why: no lead record, no live child, and an unchanged roster mean the event proves nothing is // running. Compact's start-less SubagentStop is exactly that; minting the default below left a // sticky 'working' no Stop ever clears on freshly resumed panes (STA-2915). - if (!lead && !rosterChanged && !claudeRosterHasWorkingSubagent(roster)) { + const rosterHasWorking = claudeRosterHasWorkingSubagent(roster) + if (!lead && !rosterChanged && !rosterHasWorking) { return null } - // Why: default 'working' — a spawn proves activity even before the lead's first state-bearing event (e.g. Orca restarted mid-session). - const leadState = lead?.state ?? 'working' + // Why: with no lead record the pane state is inferred from tracked children only — a live child + // shows working, while an identity-matched drain/park resolves to done. The old unconditional + // 'working' default stuck until the stale sweeper once the last child left (STA-2915 review). + const leadState = lead?.state ?? (rosterHasWorking ? 'working' : 'done') return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, { - stateName: - leadState === 'done' && claudeRosterHasWorkingSubagent(roster) ? 'working' : leadState, + stateName: leadState === 'done' && rosterHasWorking ? 'working' : leadState, updateToolSnapshot: false, interrupted: lead?.interrupted })