fix(agent-status): drop child events with whitespace-only agent ids

Review findings round 17: a whitespace-only agent_id canonicalized to
undefined and reclassified the child event as the LEAD's, minting a lead
waiting no lead Stop ever clears; agent_id-present events with an
unattributable id are now ignored. The padded-child-tool regression now
drains via a clean-id stop so it fails if canonicalization reverts.
This commit is contained in:
Brennan Benson
2026-08-01 10:43:39 -07:00
parent 8a73393802
commit 24a5ff0ce5
2 changed files with 26 additions and 5 deletions
+18 -4
View File
@@ -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', () => {
+8 -1
View File
@@ -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'