Fix late-paired completion notification arming

This commit is contained in:
Brennan Benson
2026-08-14 14:09:57 -07:00
parent d59043b0eb
commit 94e7f30fda
4 changed files with 96 additions and 46 deletions
@@ -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
@@ -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()
})
})
@@ -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
@@ -3419,10 +3419,11 @@ function applyWebSessionTabsSnapshotOperations(
export function applyWebSessionTabsStorePatch(
buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial<WebSessionTabsSyncState>,
liveSnapshot?: RuntimeMobileSessionTabsResult
agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[],
allowCompletionNotification = false
): void {
let mirroredAgentStatusChanged = false
const acceptedLiveStatuses: {
const acceptedNotificationStatuses: {
paneKey: string
worktreeId: string
payload: ReturnType<typeof pickParsedAgentStatusPayload> & { 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)
}