diff --git a/src/renderer/src/components/terminal-pane/codex-lead-stop-subagent-gate.test.ts b/src/renderer/src/components/terminal-pane/codex-lead-stop-subagent-gate.test.ts index dea820f72f9..3bdc6686b4b 100644 --- a/src/renderer/src/components/terminal-pane/codex-lead-stop-subagent-gate.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-lead-stop-subagent-gate.test.ts @@ -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[4], + previous?: Parameters[5] + ): ReturnType => + 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 diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 1d2630b5a9b..e4e6c2e67f7 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -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 } : {}) } }