fix(notifications): gate remote Codex lead Stops too

SSH/relay panes never reach normalizeHookPayload's codex branch — the main
process re-derives their roster in reconcileRemoteCodexState, which does its own
reap. So the previous commit covered local Codex only, and remote leads kept
spamming. Same pre-reap capture, applied on that path.
This commit is contained in:
Neil
2026-07-26 19:07:23 -07:00
parent 84880f2711
commit 5fd00b9f57
2 changed files with 35 additions and 1 deletions
@@ -7,6 +7,7 @@ import { dispatchTerminalNotification } from './use-notification-dispatch'
import {
createHookListenerState,
normalizeHookPayload,
reconcileRemoteCodexState,
type HookListenerState
} from '../../../../shared/agent-hook-listener'
import type { AgentHookSource } from '../../../../shared/agent-hook-relay'
@@ -279,6 +280,33 @@ describe('issue #4375 — non-turn-end notification spam', () => {
expect(childStop?.payload.leadStopWithLiveSubagents).toBeUndefined()
})
it('gates a lead Stop arriving over SSH/relay, which reconciles on a separate path', () => {
// Why: remote panes never reach normalizeHookPayload's codex branch — the main process
// re-derives their roster in reconcileRemoteCodexState, which does its own reap. Without the
// same pre-reap capture there, the fix would cover local Codex only.
const listenerState: HookListenerState = createHookListenerState()
const remote = (
ev: string,
agentId: string | undefined,
payload: Parameters<typeof reconcileRemoteCodexState>[4],
previous?: Parameters<typeof reconcileRemoteCodexState>[5]
): ReturnType<typeof reconcileRemoteCodexState> =>
reconcileRemoteCodexState(listenerState, PANE_KEY, ev, agentId, payload, previous)
const working = { state: 'working', prompt: 'go', agentType: 'codex' } as const
remote('UserPromptSubmit', undefined, { ...working })
// The relay reports the live child roster alongside the lead's own state.
remote('SubagentStart', 'child-1', {
...working,
subagents: [{ id: 'child-1', state: 'working', agentType: 'reviewer', startedAt: Date.now() }]
})
const leadStop = remote('Stop', undefined, { state: 'done', prompt: 'go', agentType: 'codex' })
expect(leadStop.state).toBe('done')
expect(leadStop.subagents).toBeUndefined()
expect(leadStop.leadStopWithLiveSubagents).toBe(true)
})
// KNOWN-OPEN residual, deliberately out of scope for the roster fix. A bare mid-turn
// Stop (no subagent) that resumes AFTER the 1.5s quiet window still notifies. There is
// no principled bound to widen the window to, and widening it delays every real Codex
+7 -1
View File
@@ -3133,12 +3133,17 @@ export function reconcileRemoteCodexState(
if (payload.subagents) {
seedCodexSubagentRoster(roster, payload.subagents)
}
let leadStopWithLiveSubagents = false
if (agentId) {
if (eventName === 'SubagentStop') {
finishCodexSubagent(roster, agentId)
}
} else {
const leadState = codexLeadStateForHookEvent(eventName)
// Why: same pre-reap capture as the local path — SSH/relay panes reach the notification
// coordinator through here, so without it remote Codex leads keep spamming (#4375).
leadStopWithLiveSubagents =
eventName === 'Stop' && codexRosterEffectiveState(roster, 'done') !== 'done'
if (eventName === 'SessionStart' || eventName === 'Stop') {
roster.clear()
}
@@ -3159,7 +3164,8 @@ export function reconcileRemoteCodexState(
...payload,
state: codexRosterEffectiveState(roster, lead.state),
model: lead.model ?? payload.model,
subagents: codexRosterToSnapshots(roster)
subagents: codexRosterToSnapshots(roster),
...(leadStopWithLiveSubagents ? { leadStopWithLiveSubagents: true } : {})
}
}