From 94e7f30fda631edeab8da2850f2a0c8bd7fc1de7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:09:57 -0700 Subject: [PATCH] Fix late-paired completion notification arming --- .../src/runtime/web-runtime-session.ts | 2 +- ...abs-agent-completion-notifications.test.ts | 38 ++++++- .../src/runtime/web-session-tabs-sync.test.ts | 4 + .../src/runtime/web-session-tabs-sync.ts | 98 +++++++++++-------- 4 files changed, 96 insertions(+), 46 deletions(-) diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 0f4bae03f6a..f7c65b84c29 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -853,7 +853,7 @@ export async function refreshWebRuntimeSessionTabsSnapshot( // Why: eager refreshes can resolve after the user switched worktrees; update tabs without stealing focus. const patch = applyFreshWebSessionTabsSnapshot(state, snapshot, environmentId) return patch === state ? state : patch - }) + }, snapshot) } catch (error) { if (options.errorMode === 'throw') { throw error 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 490ffb222d3..417e61a1d31 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 @@ -12,6 +12,7 @@ vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ })) import { useAppStore } from '@/store' +import { createAgentCompletionCoordinator } from '@/components/terminal-pane/agent-completion-coordinator' import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch, @@ -67,7 +68,8 @@ function makeAgentSnapshot( function applySnapshot(snapshot: RuntimeMobileSessionTabsResult, live: boolean): void { applyWebSessionTabsStorePatch( (state) => applyWebSessionTabsSnapshot(state, snapshot, ENVIRONMENT_ID, NOW), - live ? snapshot : undefined + snapshot, + live ) } @@ -85,12 +87,12 @@ describe('paired session-tab agent completion notifications', () => { vi.useRealTimers() }) - it('forwards accepted live status and excludes snapshot or reconnect replays', () => { + it('seeds accepted working snapshots and excludes completed replays', () => { applySnapshot(makeAgentSnapshot(1, NOW), false) - expect(mocks.observeAgentHookCompletionForNotification).not.toHaveBeenCalled() + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1) applySnapshot(makeAgentSnapshot(2, NOW + 1_000), true) - expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(2) const turnCompletedAt = NOW + 2_000 applySnapshot(makeAgentSnapshot(3, NOW + 2_000, turnCompletedAt), true) @@ -105,6 +107,32 @@ describe('paired session-tab agent completion notifications', () => { }) applySnapshot(makeAgentSnapshot(4, NOW + 3_000, turnCompletedAt + 1_000), false) - expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(2) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(3) + }) + + it('announces one live stamped completion after a late-pair working seed', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID), + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + mocks.observeAgentHookCompletionForNotification.mockImplementation(({ payload }) => + coordinator.observeHookStatus(payload) + ) + + applySnapshot(makeAgentSnapshot(1, NOW), false) + applySnapshot(makeAgentSnapshot(2, NOW + 1_000, NOW + 1_000), true) + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('does not seed a stamped reconnect replay', () => { + applySnapshot(makeAgentSnapshot(1, NOW, NOW), false) + + expect(mocks.observeAgentHookCompletionForNotification).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 07e95685c23..f70d993686c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -57,6 +57,10 @@ vi.mock('../store', () => ({ } })) +vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: vi.fn() +})) + const WT = 'repo::/worktree' const ENV = 'web-env-1' const NOW = 1_700_000_000_000 diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 87d488fed5e..fcb81344140 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -3419,10 +3419,11 @@ function applyWebSessionTabsSnapshotOperations( export function applyWebSessionTabsStorePatch( buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial, - liveSnapshot?: RuntimeMobileSessionTabsResult + agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[], + allowCompletionNotification = false ): void { let mirroredAgentStatusChanged = false - const acceptedLiveStatuses: { + const acceptedNotificationStatuses: { paneKey: string worktreeId: string payload: ReturnType & { stateStartedAt: number } @@ -3430,37 +3431,48 @@ export function applyWebSessionTabsStorePatch( useAppStore.setState((state) => { const patch = buildPatch(state) mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey') - if (mirroredAgentStatusChanged && liveSnapshot) { + if (mirroredAgentStatusChanged && agentStatusSnapshots) { const nextAgentStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey - for (const surface of liveSnapshot.tabs) { - if (surface.type !== 'terminal') { - continue - } - const remapped = remapHostAgentStatus(surface) - const accepted = remapped ? nextAgentStatuses[remapped.paneKey] : undefined - if ( - !remapped || - !accepted || - accepted === state.agentStatusByPaneKey[remapped.paneKey] || - !agentStatusEntryEqual(accepted, remapped) - ) { - continue - } - const turnCompletedAt = normalizeTurnCompletedAtField( - surface.turnCompletedAt, - accepted.state - ) - acceptedLiveStatuses.push({ - paneKey: accepted.paneKey, - worktreeId: accepted.worktreeId ?? liveSnapshot.worktree, - payload: { - ...pickParsedAgentStatusPayload({ - ...accepted, - ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) - }), - stateStartedAt: accepted.stateStartedAt + const snapshots = Array.isArray(agentStatusSnapshots) + ? agentStatusSnapshots + : [agentStatusSnapshots] + for (const snapshot of snapshots) { + for (const surface of snapshot.tabs) { + if (surface.type !== 'terminal') { + continue } - }) + const remapped = remapHostAgentStatus(surface) + const accepted = remapped ? nextAgentStatuses[remapped.paneKey] : undefined + if ( + !remapped || + !accepted || + accepted === state.agentStatusByPaneKey[remapped.paneKey] || + !agentStatusEntryEqual(accepted, remapped) + ) { + continue + } + const turnCompletedAt = normalizeTurnCompletedAtField( + surface.turnCompletedAt, + accepted.state + ) + if ( + !allowCompletionNotification && + (accepted.state !== 'working' || turnCompletedAt !== undefined) + ) { + continue + } + acceptedNotificationStatuses.push({ + paneKey: accepted.paneKey, + worktreeId: accepted.worktreeId ?? snapshot.worktree, + payload: { + ...pickParsedAgentStatusPayload({ + ...accepted, + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) + }), + stateStartedAt: accepted.stateStartedAt + } + }) + } } } return patch @@ -3469,7 +3481,7 @@ export function applyWebSessionTabsStorePatch( if (mirroredAgentStatusChanged) { useAppStore.getState().scheduleAgentStatusFreshness() } - for (const status of acceptedLiveStatuses) { + for (const status of acceptedNotificationStatuses) { observeAgentHookCompletionForNotification(status) } } @@ -3538,8 +3550,9 @@ function loadInitialWebSessionTabs( receivedFrames[index]! ) ) - applyWebSessionTabsStorePatch((state) => - applyFreshWebSessionTabsSnapshots(state, applicable, environmentId) + applyWebSessionTabsStorePatch( + (state) => applyFreshWebSessionTabsSnapshots(state, applicable, environmentId), + applicable ) } finally { for (const finishRecovery of finishRecoveries) { @@ -3846,8 +3859,9 @@ export function useWebSessionTabsSync(): void { batch.deferredRepairWorktrees.delete(worktreeId) } if (operations.length > 0) { - applyWebSessionTabsStorePatch((state) => - applyWebSessionTabsSnapshotOperations(state, operations) + applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshotOperations(state, operations), + operations.map(({ snapshot }) => snapshot) ) } finishVisibilityResumeBatchIfIdle(batch) @@ -4156,8 +4170,10 @@ export function useWebSessionTabsSync(): void { : [] ) if (freshSnapshots.length > 0) { - applyWebSessionTabsStorePatch((state) => - applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId) + applyWebSessionTabsStorePatch( + (state) => + applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), + freshSnapshots ) } const freshSnapshotSet = new Set(freshSnapshots) @@ -4228,7 +4244,8 @@ export function useWebSessionTabsSync(): void { if (shouldApplyWebSessionTabsSnapshot(recovered, environmentId)) { applyWebSessionTabsStorePatch( (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), - event.type === 'updated' && !replayed ? recovered : undefined + recovered, + event.type === 'updated' && !replayed ) recordVisibilityResumeSnapshot(environmentId, recovered, receivedFrame) } @@ -4353,7 +4370,8 @@ export function useWebSessionTabsSync(): void { const replayed = isRuntimeSubscriptionReplayResponse(response) applyWebSessionTabsStorePatch( (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), - event.type === 'updated' && !replayed ? recovered : undefined + recovered, + event.type === 'updated' && !replayed ) recordVisibilityResumeSnapshotRef.current(environmentId, recovered, receivedFrame) }