Suppress stamped completion tail replays

This commit is contained in:
Brennan Benson
2026-08-14 14:24:31 -07:00
parent 337e21ef71
commit c72a864e06
4 changed files with 135 additions and 16 deletions
@@ -1215,6 +1215,46 @@ describe('agent completion coordinator', () => {
expect(dispatchCompletion).not.toHaveBeenCalled()
})
it('does not announce the all-clear after first seeing a stamped turn tail', () => {
const dispatchCompletion = vi.fn()
const firstCoordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
firstCoordinator.observeHookStatus({
state: 'working',
prompt: 'review the PR',
agentType: 'claude',
stateStartedAt: 1_700_000_000_000,
turnCompletedAt: 1_700_000_005_000
})
firstCoordinator.dispose()
const remounted = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
remounted.observeHookStatus({
state: 'done',
prompt: 'review the PR',
agentType: 'claude',
stateStartedAt: 1_700_000_055_000,
turnCompletedAt: 1_700_000_005_000
})
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
expect(dispatchCompletion).not.toHaveBeenCalled()
})
it('announces a gated Stop immediately and treats the later all-clear as the same turn', () => {
const dispatchCompletion = vi.fn()
const dispatchHookLifecycle = vi.fn()
@@ -1379,6 +1419,56 @@ describe('agent completion coordinator', () => {
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
})
it('keeps stamped replay suppression through a working title and live remount', () => {
const dispatchCompletion = vi.fn()
const firstCoordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
firstCoordinator.observeHookStatus({
state: 'working',
prompt: 'review the PR',
agentType: 'claude',
stateStartedAt: 1_700_000_000_000
})
firstCoordinator.observeHookStatus({
state: 'working',
prompt: 'review the PR',
agentType: 'claude',
stateStartedAt: 1_700_000_000_000,
turnCompletedAt: 1_700_000_005_000
})
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(2_000)
firstCoordinator.observeTitleWorking()
firstCoordinator.dispose()
const remounted = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
remounted.observeHookStatus({
state: 'done',
prompt: 'review the PR',
agentType: 'claude',
stateStartedAt: 1_700_000_055_000,
turnCompletedAt: 1_700_000_005_000
})
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
})
it('does not let a vetoed gated Stop swallow the later all-clear', () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
@@ -37,6 +37,8 @@ type LastCompletionIdentity = {
// Why: worktree switches remount a pane while the PTY/hook stream stays live, so stale completion replays must outlive one coordinator.
const lastCompletionIdentityByPaneKey = new Map<string, LastCompletionIdentity>()
// Why: a late-pair stamped tail must suppress its replayed all-clear even though no banner was eligible.
const handledTurnCompletedAtByPaneKey = new Map<string, number>()
const IDLE_POLL_INTERVAL_MS = 2_000
const ACTIVE_POLL_INTERVAL_MS = 750
@@ -194,10 +196,11 @@ export function createAgentCompletionCoordinator(
return completionIdentityFor(payload.state, payload.agentType, timestamp)
}
function turnCompletedAtAlreadyNotified(turnCompletedAt: number): boolean {
function turnCompletedAtAlreadyHandled(turnCompletedAt: number): boolean {
return (
handledTurnCompletedAtByPaneKey.get(options.paneKey) === turnCompletedAt ||
lastCompletionIdentityByPaneKey.get(options.paneKey)?.lastTurnCompletedAtNotified ===
turnCompletedAt
turnCompletedAt
)
}
@@ -320,6 +323,12 @@ export function createAgentCompletionCoordinator(
clearPendingCodexAttention()
if (optionsOverride.completionIdentity) {
lastCompletionIdentityByPaneKey.set(options.paneKey, optionsOverride.completionIdentity)
if (optionsOverride.completionIdentity.lastTurnCompletedAtNotified !== undefined) {
handledTurnCompletedAtByPaneKey.set(
options.paneKey,
optionsOverride.completionIdentity.lastTurnCompletedAtNotified
)
}
}
if (
source === 'hook' &&
@@ -834,7 +843,7 @@ export function createAgentCompletionCoordinator(
// 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 (
workingStatusObserved &&
!turnCompletedAtAlreadyNotified(turnCompletedAt) &&
!turnCompletedAtAlreadyHandled(turnCompletedAt) &&
lastCompletionIdentity?.lastTurnCompletedAtNotified !== turnCompletedAt
) {
const completionSnapshot: AgentCompletionStatusSnapshot = {
@@ -857,6 +866,8 @@ export function createAgentCompletionCoordinator(
if (committed) {
lastCompletionIdentity = completionIdentity
}
} else if (!workingStatusObserved) {
handledTurnCompletedAtByPaneKey.set(options.paneKey, turnCompletedAt)
}
options.dispatchHookLifecycle?.(payload)
return
@@ -898,7 +909,7 @@ export function createAgentCompletionCoordinator(
: undefined
if (
turnCompletedAt !== undefined &&
(turnCompletedAtAlreadyNotified(turnCompletedAt) ||
(turnCompletedAtAlreadyHandled(turnCompletedAt) ||
lastCompletionIdentity?.lastTurnCompletedAtNotified === turnCompletedAt ||
lastCompletedTurn === currentTurn)
) {
@@ -996,6 +1007,7 @@ export function createAgentCompletionCoordinator(
// Why: module-scoped identity survives a live-stream remount; evict only on genuine teardown (isLive() false) so it can't leak per closed pane.
if (!options.isLive()) {
lastCompletionIdentityByPaneKey.delete(options.paneKey)
handledTurnCompletedAtByPaneKey.delete(options.paneKey)
}
}
@@ -1014,8 +1026,12 @@ export function createAgentCompletionCoordinator(
export function resetAgentCompletionCoordinatorIdentitiesForTest(): void {
lastCompletionIdentityByPaneKey.clear()
handledTurnCompletedAtByPaneKey.clear()
}
export function getAgentCompletionCoordinatorIdentityCountForTest(): number {
return lastCompletionIdentityByPaneKey.size
return new Set([
...lastCompletionIdentityByPaneKey.keys(),
...handledTurnCompletedAtByPaneKey.keys()
]).size
}
@@ -29,7 +29,8 @@ const initialState = useAppStore.getInitialState()
function makeAgentSnapshot(
snapshotVersion: number,
updatedAt: number,
turnCompletedAt?: number
turnCompletedAt?: number,
state: 'working' | 'done' = 'working'
): RuntimeMobileSessionTabsResult {
return {
worktree: WORKTREE_ID,
@@ -50,7 +51,7 @@ function makeAgentSnapshot(
terminal: 'terminal-1',
...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}),
agentStatus: {
state: 'working',
state,
prompt: 'review the PR',
updatedAt,
stateStartedAt: NOW,
@@ -87,7 +88,7 @@ describe('paired session-tab agent completion notifications', () => {
vi.useRealTimers()
})
it('seeds accepted working snapshots and excludes completed replays', () => {
it('forwards accepted working snapshots and live statuses', () => {
applySnapshot(makeAgentSnapshot(1, NOW), false)
expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1)
@@ -107,7 +108,7 @@ describe('paired session-tab agent completion notifications', () => {
})
applySnapshot(makeAgentSnapshot(4, NOW + 3_000, turnCompletedAt + 1_000), false)
expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(3)
expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(4)
})
it('announces one live stamped completion after a late-pair working seed', () => {
@@ -130,9 +131,24 @@ describe('paired session-tab agent completion notifications', () => {
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
})
it('does not seed a stamped reconnect replay', () => {
applySnapshot(makeAgentSnapshot(1, NOW, NOW), false)
it('suppresses a stamped reconnect tail and its live all-clear', () => {
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)
)
expect(mocks.observeAgentHookCompletionForNotification).not.toHaveBeenCalled()
applySnapshot(makeAgentSnapshot(1, NOW, NOW), false)
applySnapshot(makeAgentSnapshot(2, NOW + 1_000, NOW, 'done'), true)
expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(2)
expect(dispatchCompletion).not.toHaveBeenCalled()
})
})
@@ -3455,10 +3455,7 @@ export function applyWebSessionTabsStorePatch(
surface.turnCompletedAt,
accepted.state
)
if (
!allowCompletionNotification &&
(accepted.state !== 'working' || turnCompletedAt !== undefined)
) {
if (!allowCompletionNotification && accepted.state !== 'working') {
continue
}
acceptedNotificationStatuses.push({