fix(notifications): retain paired client turn boundary

This commit is contained in:
Brennan Benson
2026-08-14 18:05:01 -07:00
parent 0e1bd05b7c
commit d60c8e7bb4
2 changed files with 113 additions and 2 deletions
@@ -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({
@@ -157,6 +157,17 @@ const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map<
string,
Map<string, Set<string>>
>()
// 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
}
: {})
}