Preserve stamped tails across fallback signals

This commit is contained in:
Brennan Benson
2026-08-14 14:49:04 -07:00
parent 1003fff7ca
commit d063e3db69
4 changed files with 86 additions and 16 deletions
+30
View File
@@ -8920,6 +8920,36 @@ describe('AgentHookServer ingestRemote', () => {
})
describe('AgentHookServer ingestTerminalStatus', () => {
it('preserves a hook turn stamp across an equivalent OSC repaint', () => {
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{
paneKey: PANE,
payload: {
state: 'working',
prompt: 'review the PR',
agentType: 'claude',
turnCompletedAt: 1_700_000_005_000
}
},
'conn-1'
)
server.ingestTerminalStatus({
paneKey: PANE,
connectionId: 'conn-1',
payload: { state: 'working', prompt: 'review the PR', agentType: 'claude' }
})
expect(listener).toHaveBeenCalledOnce()
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
turnCompletedAt: 1_700_000_005_000
})
})
// Why: the OSC 9999 payload cannot carry a provider session, so letting it overwrite the row
// erased the session id from persisted rows and from headless `orca serve` — which serves these
// rows straight to mobile — leaving Chat UI with no transcript to subscribe to (#10630).
+2 -1
View File
@@ -430,7 +430,8 @@ function equivalentParsedAgentStatusPayload(
// Why: a session-boundary done must never be deduped against a cached real done —
// the flag has to reach receivers deterministically (STA-3386).
a.sessionBoundary === b.sessionBoundary &&
a.turnCompletedAt === b.turnCompletedAt
// Why: OSC cannot carry the hook-only turn stamp; an otherwise equivalent repaint must preserve it, while an incoming stamp still advances the row.
(b.turnCompletedAt === undefined || a.turnCompletedAt === b.turnCompletedAt)
)
}
@@ -808,6 +808,17 @@ describe('agent completion coordinator', () => {
await flushAsyncTicks()
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
result = processResult('codex')
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
result = processResult('zsh', false)
vi.advanceTimersByTime(750)
await flushAsyncTicks()
vi.advanceTimersByTime(750)
await flushAsyncTicks()
expect(dispatchCompletion).toHaveBeenCalledTimes(2)
})
it('keeps duplicate done-only hooks inside replay guard suppressed after process inspection', async () => {
@@ -1509,26 +1520,21 @@ describe('agent completion coordinator', () => {
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
hookCoordinator.observeHookStatus({
state: 'working',
prompt: 'continue the review',
agentType: 'claude',
stateStartedAt: 1_700_000_010_000
})
hookCoordinator.observeHookStatus({
state: 'done',
prompt: 'continue the review',
prompt: 'review the PR',
agentType: 'claude',
stateStartedAt: 1_700_000_020_000
stateStartedAt: 1_700_000_055_000,
turnCompletedAt: 1_700_000_005_000
})
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
expect(dispatchCompletion).toHaveBeenCalledTimes(2)
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
paneCoordinator.observeTitleWorking()
paneCoordinator.observeClassifiedTitleCompletion('Claude done')
expect(dispatchCompletion).toHaveBeenCalledTimes(3)
expect(dispatchCompletion).toHaveBeenCalledTimes(2)
})
it('does not let a vetoed gated Stop swallow the later all-clear', () => {
@@ -39,6 +39,8 @@ type LastCompletionIdentity = {
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>()
// Why: title fallback stays blocked only until the stamped background tail reaches its all-clear.
const pendingStampedTailByPaneKey = new Map<string, number>()
const IDLE_POLL_INTERVAL_MS = 2_000
const ACTIVE_POLL_INTERVAL_MS = 750
@@ -329,7 +331,7 @@ export function createAgentCompletionCoordinator(
optionsOverride.completionIdentity.lastTurnCompletedAtNotified
)
} else {
handledTurnCompletedAtByPaneKey.delete(options.paneKey)
pendingStampedTailByPaneKey.delete(options.paneKey)
}
}
if (
@@ -495,6 +497,17 @@ export function createAgentCompletionCoordinator(
function handleRecognizedProcess(process: RecognizedAgentProcess): void {
pendingProcessExitAgent = null
const replayIdentity = lastCompletionIdentityByPaneKey.get(options.paneKey)
if (
!lastForegroundAgent &&
processSession > 0 &&
!pendingStampedTailByPaneKey.has(options.paneKey) &&
replayIdentity?.source === 'hook' &&
replayIdentity.agentIdentity === process.agent
) {
// Why: the prior observed process exited and this is a new same-agent process session, so its fallback completion cannot replay the old hook turn.
lastCompletionIdentityByPaneKey.delete(options.paneKey)
}
if (lastForegroundAgent?.agent !== process.agent) {
if (lastForegroundAgent && hasAgentRunEvidence) {
if (
@@ -555,7 +568,8 @@ export function createAgentCompletionCoordinator(
const exited = lastForegroundAgent
pendingProcessExitAgent = null
if (options.shouldSuppressConfirmedProcessExitCompletion?.(exited) !== true) {
dispatchCompletion('process-exit', exited.processName, {
const replayIdentityBeforeExit = lastCompletionIdentityByPaneKey.get(options.paneKey)
const committed = dispatchCompletion('process-exit', exited.processName, {
terminalIdleConfirmed: true,
completionIdentity: {
source: 'process-exit',
@@ -563,6 +577,15 @@ export function createAgentCompletionCoordinator(
agentIdentity: exited.agent
}
})
if (
!committed &&
!pendingStampedTailByPaneKey.has(options.paneKey) &&
replayIdentityBeforeExit?.source === 'hook' &&
replayIdentityBeforeExit.agentIdentity === exited.agent
) {
// Why: this confirmed exit consumed the hook turn's process backstop; a later process instance must be allowed to complete independently.
lastCompletionIdentityByPaneKey.delete(options.paneKey)
}
}
lastForegroundAgent = null
clearAgentRunEvidence()
@@ -743,7 +766,7 @@ export function createAgentCompletionCoordinator(
clearPendingCodexAttention()
workingStatusObserved = true
requiresFreshWorking = false
if (!handledTurnCompletedAtByPaneKey.has(options.paneKey)) {
if (!pendingStampedTailByPaneKey.has(options.paneKey)) {
lastCompletionIdentityByPaneKey.delete(options.paneKey)
}
currentTurn += 1
@@ -844,10 +867,14 @@ export function createAgentCompletionCoordinator(
? payload.turnCompletedAt
: undefined
if (turnCompletedAt !== undefined) {
const alreadyHandled = turnCompletedAtAlreadyHandled(turnCompletedAt)
if (!alreadyHandled) {
pendingStampedTailByPaneKey.set(options.paneKey, turnCompletedAt)
}
// 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 &&
!turnCompletedAtAlreadyHandled(turnCompletedAt) &&
!alreadyHandled &&
lastCompletionIdentity?.lastTurnCompletedAtNotified !== turnCompletedAt
) {
const completionSnapshot: AgentCompletionStatusSnapshot = {
@@ -918,6 +945,9 @@ export function createAgentCompletionCoordinator(
lastCompletedTurn === currentTurn)
) {
// Why: this `done` is the background all-clear of a turn already announced (or completed from another source). Keep pane lifecycle; do not raise a second banner.
if (pendingStampedTailByPaneKey.get(options.paneKey) === turnCompletedAt) {
pendingStampedTailByPaneKey.delete(options.paneKey)
}
options.dispatchHookLifecycle?.(payload)
return
}
@@ -1012,6 +1042,7 @@ export function createAgentCompletionCoordinator(
if (!options.isLive()) {
lastCompletionIdentityByPaneKey.delete(options.paneKey)
handledTurnCompletedAtByPaneKey.delete(options.paneKey)
pendingStampedTailByPaneKey.delete(options.paneKey)
}
}
@@ -1031,11 +1062,13 @@ export function createAgentCompletionCoordinator(
export function resetAgentCompletionCoordinatorIdentitiesForTest(): void {
lastCompletionIdentityByPaneKey.clear()
handledTurnCompletedAtByPaneKey.clear()
pendingStampedTailByPaneKey.clear()
}
export function getAgentCompletionCoordinatorIdentityCountForTest(): number {
return new Set([
...lastCompletionIdentityByPaneKey.keys(),
...handledTurnCompletedAtByPaneKey.keys()
...handledTurnCompletedAtByPaneKey.keys(),
...pendingStampedTailByPaneKey.keys()
]).size
}