diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts index 14235654d37..5204c6b0b0b 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts @@ -43,6 +43,22 @@ describe('createHookStatusSessionTabsInvalidator', () => { expect(changed(working({}, payload))).toBe(true) }) + it('invalidates when the completion stamp is added, changed, or removed', () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working()) + + expect(changed(working({}, { turnCompletedAt: 100 }))).toBe(true) + expect(changed(working({}, { turnCompletedAt: 200 }))).toBe(true) + expect(changed(working())).toBe(true) + }) + + it('invalidates when the assistant body changes', () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working({}, { lastAssistantMessage: 'First answer' })) + + expect(changed(working({}, { lastAssistantMessage: 'Corrected answer' }))).toBe(true) + }) + it('ignores resume-identity rows, which the provider-session path owns', () => { const changed = createHookStatusSessionTabsInvalidator() diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts index 59b259fa849..3aa90b530f4 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -28,7 +28,9 @@ export function createHookStatusSessionTabsInvalidator(): { (previous.agentType ?? null) !== (next.agentType ?? null) || (previous.toolName ?? null) !== (next.toolName ?? null) || (previous.interactivePrompt ?? null) !== (next.interactivePrompt ?? null) || - (previous.interrupted ?? false) !== (next.interrupted ?? false) + (previous.interrupted ?? false) !== (next.interrupted ?? false) || + (previous.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) || + (previous.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null) ) } // Why: a cleared pane must re-arm, else the memo swallows the first event of the diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index bbf5f83186b..472e61eb24d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10928,7 +10928,9 @@ export class OrcaRuntimeService { (previous.payload.agentType ?? null) !== (payload.agentType ?? null) || (previous.payload.toolName ?? null) !== (payload.toolName ?? null) || (previous.payload.interactivePrompt ?? null) !== (payload.interactivePrompt ?? null) || - (previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) + (previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) || + (previous.payload.turnCompletedAt ?? null) !== (payload.turnCompletedAt ?? null) || + (previous.payload.lastAssistantMessage ?? null) !== (payload.lastAssistantMessage ?? null) ) } diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts index 2e36c229a76..ad1aab0ddcf 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts @@ -102,6 +102,29 @@ describe('agent completion coordinator identity map stays bounded (leak regressi expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(1) }) + it('clears per-coordinator working boundaries on reset and dispose', () => { + const live = { value: true } + const coordinator = createAgentCompletionCoordinator( + makeOptions('tab-1:leaf-working-boundary', live) + ) + const working = { + state: 'working' as const, + prompt: '', + agentType: 'claude' as const, + stateStartedAt: 1_000 + } + + coordinator.observeHookStatus(working) + expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(1) + coordinator.resetCompletionState() + expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(0) + + coordinator.observeHookStatus(working) + expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(1) + coordinator.dispose() + expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(0) + }) + it('retains stamped replay state when a mounted sibling disposes before remount', () => { const paneKey = 'tab-1:leaf-production-remount' const dispatchCompletion = vi.fn() 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 7799ac82d7b..ea81876440d 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 @@ -1392,6 +1392,63 @@ describe('agent completion coordinator', () => { } ) + it('does not consume a fresh turn as the all-clear for an earlier stamped tail', () => { + 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) + + localCoordinator.observeHookStatus({ + state: 'working', + prompt: 'second turn', + agentType: 'claude', + stateStartedAt: 6_000 + }) + localCoordinator.observeHookStatus({ + state: 'done', + prompt: 'second turn', + agentType: 'claude', + stateStartedAt: 6_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 1122dc1c4c0..dbb57b26855 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -40,8 +40,17 @@ const lastCompletionIdentityByPaneKey = new Map( // Why: a late-pair stamped tail must suppress its replayed all-clear even though no banner was eligible. const handledTurnCompletedAtByPaneKey = new Map() // Why: title fallback stays blocked only until the stamped background tail reaches its all-clear. -const pendingStampedTailByPaneKey = new Map() +const pendingStampedTailByPaneKey = new Map< + string, + { + turnCompletedAt: number + eligibleWorkingBoundaryByCoordinator: Map + } +>() +// Why: a sibling all-clear belongs to the stamped tail only while its exact working interval is still current. +const workingBoundaryByPaneKey = new Map>() const coordinatorCountByPaneKey = new Map() +let nextCoordinatorId = 1 const IDLE_POLL_INTERVAL_MS = 2_000 const ACTIVE_POLL_INTERVAL_MS = 750 @@ -83,6 +92,7 @@ function isAttentionHookState(state: ParsedAgentStatusPayload['state']): boolean export function createAgentCompletionCoordinator( options: AgentCompletionCoordinatorOptions ): AgentCompletionCoordinator { + const coordinatorId = nextCoordinatorId++ coordinatorCountByPaneKey.set( options.paneKey, (coordinatorCountByPaneKey.get(options.paneKey) ?? 0) + 1 @@ -211,14 +221,42 @@ export function createAgentCompletionCoordinator( ) } + function recordWorkingBoundary(stateStartedAt: number | undefined): void { + let boundaries = workingBoundaryByPaneKey.get(options.paneKey) + if (!isFiniteTurnCompletedAt(stateStartedAt)) { + boundaries?.delete(coordinatorId) + if (boundaries?.size === 0) { + workingBoundaryByPaneKey.delete(options.paneKey) + } + return + } + if (!boundaries) { + boundaries = new Map() + workingBoundaryByPaneKey.set(options.paneKey, boundaries) + } + boundaries.set(coordinatorId, stateStartedAt) + } + + function clearWorkingBoundary(): void { + const boundaries = workingBoundaryByPaneKey.get(options.paneKey) + boundaries?.delete(coordinatorId) + if (boundaries?.size === 0) { + workingBoundaryByPaneKey.delete(options.paneKey) + } + } + function consumePendingStampedTailForAgent(agentIdentity: string | null): boolean { const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey) const stampedCompletion = lastCompletionIdentityByPaneKey.get(options.paneKey) + const currentWorkingBoundary = workingBoundaryByPaneKey.get(options.paneKey)?.get(coordinatorId) if ( pendingStampedTail === undefined || - stampedCompletion?.lastTurnCompletedAtNotified !== pendingStampedTail || + stampedCompletion?.lastTurnCompletedAtNotified !== pendingStampedTail.turnCompletedAt || agentIdentity === null || - stampedCompletion.agentIdentity !== agentIdentity + stampedCompletion.agentIdentity !== agentIdentity || + currentWorkingBoundary === undefined || + pendingStampedTail.eligibleWorkingBoundaryByCoordinator.get(coordinatorId) !== + currentWorkingBoundary ) { return false } @@ -893,7 +931,12 @@ export function createAgentCompletionCoordinator( if (turnCompletedAt !== undefined) { const alreadyHandled = turnCompletedAtAlreadyHandled(turnCompletedAt) if (!alreadyHandled) { - pendingStampedTailByPaneKey.set(options.paneKey, turnCompletedAt) + pendingStampedTailByPaneKey.set(options.paneKey, { + turnCompletedAt, + eligibleWorkingBoundaryByCoordinator: new Map( + workingBoundaryByPaneKey.get(options.paneKey) + ) + }) } // Why: Claude's lead Stop already ended the turn; the pane stays `working` only for background inventory. Announce now, keep lifecycle on the reported working row. if ( @@ -927,6 +970,7 @@ export function createAgentCompletionCoordinator( options.dispatchHookLifecycle?.(payload) return } + recordWorkingBoundary(payload.stateStartedAt) clearPendingHookDone() // Why: resumed work cancels the debounced attention so a self-resolving pause never notifies. clearPendingCodexAttention() @@ -977,7 +1021,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) { + if (pendingStampedTailByPaneKey.get(options.paneKey)?.turnCompletedAt === turnCompletedAt) { pendingStampedTailByPaneKey.delete(options.paneKey) } options.dispatchHookLifecycle?.(payload) @@ -1062,6 +1106,7 @@ export function createAgentCompletionCoordinator( lastForegroundAgent = null requiresFreshWorking = options.requireFreshWorking ?? false inspectionGeneration += 1 + clearWorkingBoundary() } function dispose(): void { @@ -1073,6 +1118,7 @@ export function createAgentCompletionCoordinator( clearPendingHookDone() clearPendingCodexAttention() dropPendingTitle() + clearWorkingBoundary() const remainingCoordinators = (coordinatorCountByPaneKey.get(options.paneKey) ?? 1) - 1 if (remainingCoordinators > 0) { coordinatorCountByPaneKey.set(options.paneKey, remainingCoordinators) @@ -1084,6 +1130,7 @@ export function createAgentCompletionCoordinator( lastCompletionIdentityByPaneKey.delete(options.paneKey) handledTurnCompletedAtByPaneKey.delete(options.paneKey) pendingStampedTailByPaneKey.delete(options.paneKey) + workingBoundaryByPaneKey.delete(options.paneKey) } } @@ -1104,13 +1151,16 @@ export function resetAgentCompletionCoordinatorIdentitiesForTest(): void { lastCompletionIdentityByPaneKey.clear() handledTurnCompletedAtByPaneKey.clear() pendingStampedTailByPaneKey.clear() + workingBoundaryByPaneKey.clear() coordinatorCountByPaneKey.clear() + nextCoordinatorId = 1 } export function getAgentCompletionCoordinatorIdentityCountForTest(): number { return new Set([ ...lastCompletionIdentityByPaneKey.keys(), ...handledTurnCompletedAtByPaneKey.keys(), - ...pendingStampedTailByPaneKey.keys() + ...pendingStampedTailByPaneKey.keys(), + ...workingBoundaryByPaneKey.keys() ]).size }