From d60c8e7bb462fc7e2b854bae3608189a3cdb5c3b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:05:01 -0700 Subject: [PATCH] fix(notifications): retain paired client turn boundary --- ...abs-agent-completion-notifications.test.ts | 51 +++++++++++++++ .../src/runtime/web-session-tabs-sync.ts | 64 ++++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts index f8dacb97365..124625efd17 100644 --- a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts @@ -16,6 +16,7 @@ import { createAgentCompletionCoordinator, resetAgentCompletionCoordinatorIdentitiesForTest } from '@/components/terminal-pane/agent-completion-coordinator' +import { dispatchTerminalNotification } from '@/components/terminal-pane/use-notification-dispatch' import { markRendererOwnedAgentStatusWrite, registerRendererOwnedAgentStatusPane, @@ -97,6 +98,7 @@ describe('paired session-tab agent completion notifications', () => { useAppStore.setState(initialState, true) resetWebSessionTabsSnapshotFreshnessForTests() resetRendererOwnedAgentStatusPanesForTests() + vi.unstubAllGlobals() vi.useRealTimers() }) @@ -206,6 +208,55 @@ describe('paired session-tab agent completion notifications', () => { ) }) + it('keeps a delayed host stamp bound to the prior client turn', () => { + const paneKey = makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID) + registerRendererOwnedAgentStatusPane(paneKey, ENVIRONMENT_ID) + markRendererOwnedAgentStatusWrite(paneKey) + const setClientTurn = (stateStartedAt: number, prompt: string): void => { + useAppStore.setState({ + agentStatusByPaneKey: { + [paneKey]: { + state: 'working', + prompt, + updatedAt: NOW, + stateStartedAt, + agentType: 'claude', + paneKey, + tabId: toWebTerminalSurfaceTabId(HOST_TAB_ID), + worktreeId: WORKTREE_ID, + stateHistory: [] + } + } + }) + } + + setClientTurn(5_000, 'first turn') + applySnapshot(makeAgentSnapshot(1, NOW - 2_000), true) + setClientTurn(6_000, 'second turn') + applySnapshot(makeAgentSnapshot(2, NOW - 1_500), true) + const turnCompletedAt = NOW + 20_000 + applySnapshot(makeAgentSnapshot(3, NOW - 1_000, turnCompletedAt), true) + + const delayedPayload = + mocks.observeAgentHookCompletionForNotification.mock.calls.at(-1)?.[0]?.payload + expect(delayedPayload).toMatchObject({ + localStateStartedAt: 5_000, + turnCompletedAt + }) + + const notificationDispatch = vi.fn() + vi.stubGlobal('window', { api: { notifications: { dispatch: notificationDispatch } } }) + dispatchTerminalNotification(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: 'Claude working', + paneKey, + agentCompletionSource: 'hook', + agentStatusSnapshot: delayedPayload + }) + + expect(notificationDispatch).not.toHaveBeenCalled() + }) + it('suppresses a stamped reconnect tail and its live all-clear', () => { const dispatchCompletion = vi.fn() const coordinator = createAgentCompletionCoordinator({ diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 929aba5891f..22adc4c8c9e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -157,6 +157,17 @@ const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< string, Map> >() +// Why: a delayed host stamp belongs to the client boundary seen with its preceding ordered frame. +const hostWorkingClientBoundaryByPaneKey = new Map< + string, + { + hostStateStartedAt: number + hostPrompt: string + clientStateStartedAt: number + stamped: boolean + } +>() +const HOST_WORKING_CLIENT_BOUNDARY_LIMIT = 512 let receivedSessionTabsFrameSequence = 0 type TerminalSurface = RuntimeMobileSessionTerminalClientTab @@ -643,6 +654,7 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void { lastHostTerminalTabCountByWorktree.clear() hostSessionTabIdByLocalKey.clear() hostSessionTabMappingKeysByEnvironmentAndWorktree.clear() + hostWorkingClientBoundaryByPaneKey.clear() resetWebSessionBrowserPlacementsForTests() } @@ -3450,6 +3462,9 @@ export function applyWebSessionTabsStorePatch( const turnCompletedAt = remapped ? normalizeTurnCompletedAtField(surface.turnCompletedAt, remapped.state) : undefined + if (remapped?.state === 'done' && turnCompletedAt === undefined) { + hostWorkingClientBoundaryByPaneKey.delete(remapped.paneKey) + } // Why: client OSC owns display state, but only the host hook stream carries background-turn stamps. const clientOwnedNotification = Boolean( remapped && @@ -3469,6 +3484,52 @@ export function applyWebSessionTabsStorePatch( if (!notificationStatus) { continue } + const currentClientStateStartedAt = clientOwnedNotification + ? state.agentStatusByPaneKey[notificationStatus.paneKey]?.stateStartedAt + : undefined + let localStateStartedAt = currentClientStateStartedAt + if ( + clientOwnedNotification && + notificationStatus.state === 'working' && + currentClientStateStartedAt !== undefined + ) { + if (turnCompletedAt === undefined) { + const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( + notificationStatus.paneKey + ) + if ( + !retainedBoundary || + retainedBoundary.hostStateStartedAt !== notificationStatus.stateStartedAt || + retainedBoundary.hostPrompt !== notificationStatus.prompt || + retainedBoundary.stamped + ) { + hostWorkingClientBoundaryByPaneKey.delete(notificationStatus.paneKey) + hostWorkingClientBoundaryByPaneKey.set(notificationStatus.paneKey, { + hostStateStartedAt: notificationStatus.stateStartedAt, + hostPrompt: notificationStatus.prompt, + clientStateStartedAt: currentClientStateStartedAt, + stamped: false + }) + if (hostWorkingClientBoundaryByPaneKey.size > HOST_WORKING_CLIENT_BOUNDARY_LIMIT) { + const oldestPaneKey = hostWorkingClientBoundaryByPaneKey.keys().next().value + if (oldestPaneKey !== undefined) { + hostWorkingClientBoundaryByPaneKey.delete(oldestPaneKey) + } + } + } + } else { + const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( + notificationStatus.paneKey + ) + if ( + retainedBoundary?.hostStateStartedAt === notificationStatus.stateStartedAt && + retainedBoundary.hostPrompt === notificationStatus.prompt + ) { + localStateStartedAt = retainedBoundary.clientStateStartedAt + retainedBoundary.stamped = true + } + } + } if (!allowCompletionNotification && notificationStatus.state !== 'working') { continue } @@ -3484,8 +3545,7 @@ export function applyWebSessionTabsStorePatch( stateStartedAt: notificationStatus.stateStartedAt, ...(clientOwnedNotification ? { - localStateStartedAt: - state.agentStatusByPaneKey[notificationStatus.paneKey]?.stateStartedAt + localStateStartedAt } : {}) }