diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts index ea81876440d..5131171db2d 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -1449,6 +1449,131 @@ describe('agent completion coordinator', () => { expect(dispatchCompletion).toHaveBeenCalledTimes(2) }) + it('keeps sibling tail suppression after the host stamped all-clear arrives first', () => { + const dispatchCompletion = vi.fn() + const localCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const hostCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000, + turnCompletedAt: 2_500 + }) + hostCoordinator.observeHookStatus({ + state: 'done', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 3_000, + turnCompletedAt: 2_500 + }) + localCoordinator.observeHookStatus({ + state: 'done', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_500 + }) + localCoordinator.observeHookStatus({ + state: 'done', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_500 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('releases a stamped tail when its host reports a new same-state turn', () => { + const dispatchCompletion = vi.fn() + const localCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + const hostCoordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 5_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'first turn', + agentType: 'claude', + stateStartedAt: 2_000, + turnCompletedAt: 2_500 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + hostCoordinator.observeHookStatus({ + state: 'working', + prompt: 'second turn', + agentType: 'claude', + stateStartedAt: 2_000 + }) + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'second turn', + agentType: 'claude', + stateStartedAt: 5_000 + }) + localCoordinator.observeHookStatus({ + state: 'done', + prompt: 'second turn', + agentType: 'claude', + stateStartedAt: 5_500 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).toHaveBeenCalledTimes(2) + }) + it('does not replay a stamped turn or its all-clear after a working title blip', () => { const dispatchCompletion = vi.fn() const dispatchHookLifecycle = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index dbb57b26855..2502ce358de 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -44,6 +44,7 @@ const pendingStampedTailByPaneKey = new Map< string, { turnCompletedAt: number + originCoordinatorId: number eligibleWorkingBoundaryByCoordinator: Map } >() @@ -260,10 +261,24 @@ export function createAgentCompletionCoordinator( ) { return false } - pendingStampedTailByPaneKey.delete(options.paneKey) + pendingStampedTail.eligibleWorkingBoundaryByCoordinator.delete(coordinatorId) + if (pendingStampedTail.eligibleWorkingBoundaryByCoordinator.size === 0) { + pendingStampedTailByPaneKey.delete(options.paneKey) + } return true } + function consumeStampedTailForCurrentCoordinator(turnCompletedAt: number): void { + const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey) + if (pendingStampedTail?.turnCompletedAt !== turnCompletedAt) { + return + } + pendingStampedTail.eligibleWorkingBoundaryByCoordinator.delete(coordinatorId) + if (pendingStampedTail.eligibleWorkingBoundaryByCoordinator.size === 0) { + pendingStampedTailByPaneKey.delete(options.paneKey) + } + } + function hookCompletionAgentIdentity(payload: AgentCompletionStatusSnapshot): string | null { return payload.agentType?.trim().toLowerCase() || null } @@ -933,6 +948,7 @@ export function createAgentCompletionCoordinator( if (!alreadyHandled) { pendingStampedTailByPaneKey.set(options.paneKey, { turnCompletedAt, + originCoordinatorId: coordinatorId, eligibleWorkingBoundaryByCoordinator: new Map( workingBoundaryByPaneKey.get(options.paneKey) ) @@ -970,6 +986,10 @@ export function createAgentCompletionCoordinator( options.dispatchHookLifecycle?.(payload) return } + const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey) + if (pendingStampedTail?.originCoordinatorId === coordinatorId) { + pendingStampedTailByPaneKey.delete(options.paneKey) + } recordWorkingBoundary(payload.stateStartedAt) clearPendingHookDone() // Why: resumed work cancels the debounced attention so a self-resolving pause never notifies. @@ -1011,6 +1031,13 @@ export function createAgentCompletionCoordinator( consumePendingStampedTailForAgent(hookCompletionAgentIdentity(payload)) ) { // Why: paired host hooks carry the stamp while the sibling OSC coordinator sees only the matching all-clear. + lastCompletionIdentity = hookIdentity + ? { + source: 'hook', + identity: hookIdentity, + agentIdentity: hookCompletionAgentIdentity(payload) + } + : null options.dispatchHookLifecycle?.(payload) return } @@ -1021,9 +1048,7 @@ export function createAgentCompletionCoordinator( lastCompletedTurn === currentTurn) ) { // Why: this `done` is the background all-clear of a turn already announced (or completed from another source). Keep pane lifecycle; do not raise a second banner. - if (pendingStampedTailByPaneKey.get(options.paneKey)?.turnCompletedAt === turnCompletedAt) { - pendingStampedTailByPaneKey.delete(options.paneKey) - } + consumeStampedTailForCurrentCoordinator(turnCompletedAt) options.dispatchHookLifecycle?.(payload) return }