diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 79a148b7159..6602a2de218 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -3383,16 +3383,30 @@ describe('shared agent-hook-listener', () => { it('routes a padded child tool event to the canonical roster row', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) claudeEvent({ hook_event_name: 'Stop', background_tasks: [] }) - const childTool = claudeEvent({ + claudeEvent({ hook_event_name: 'PreToolUse', agent_id: ' a4 ', agent_type: 'general-purpose', tool_name: 'Bash', tool_input: { command: 'true' } }) - expect(childTool?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'a4', state: 'working' }) - ]) + // Why: a clean-id stop must drain the padded tool event's row — under a raw + // key it would survive and pin the lead's next Stop on working. + claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a4' }) + const leadStop = claudeEvent({ hook_event_name: 'Stop' }) + expect(leadStop?.payload.state).toBe('done') + expect(leadStop?.payload.subagents).toBeUndefined() + }) + + it('ignores child events whose agent_id is whitespace-only', () => { + // Why: agent_id present means child-origin; an unattributable id treated + // as the lead would mint a waiting no lead Stop ever clears. + const waiting = claudeEvent({ + hook_event_name: 'PermissionRequest', + agent_id: ' ', + tool_name: 'Bash' + }) + expect(waiting).toBeNull() }) it('tracks whitespace-padded inventory ids canonically so their stops can drain them', () => { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 7587962e798..8e32d83bf04 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2734,7 +2734,14 @@ function normalizeClaudeEvent( // Why: canonicalize like the inventory parser and lifecycle handler — a padded id must hit // the same roster key and wait ownership as its trimmed twin. - const eventAgentId = readString(hookPayload, 'agent_id')?.trim() || undefined + const rawAgentId = readString(hookPayload, 'agent_id') + const eventAgentId = rawAgentId?.trim() || undefined + // Why: agent_id present means child-origin by contract; a whitespace-only id cannot be + // attributed, and treating the event as the LEAD's would mint lead waiting/working that no + // lead Stop ever clears. + if (rawAgentId !== undefined && eventAgentId === undefined) { + return null + } // Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish). // Two exceptions take the full path below: waiting-inducing events (a child needs human attention on this pane) and the blocked child's own next tool event (approval granted — clear the wait as for the lead). const isWaitingInducing = stateName === 'waiting'