Bind paired completion tails to their turn

This commit is contained in:
Brennan Benson
2026-08-14 16:10:58 -07:00
parent ca2e71c0ae
commit caec58ab4f
6 changed files with 158 additions and 8 deletions
@@ -43,6 +43,22 @@ describe('createHookStatusSessionTabsInvalidator', () => {
expect(changed(working({}, payload))).toBe(true)
})
it('invalidates when the completion stamp is added, changed, or removed', () => {
const changed = createHookStatusSessionTabsInvalidator()
changed(working())
expect(changed(working({}, { turnCompletedAt: 100 }))).toBe(true)
expect(changed(working({}, { turnCompletedAt: 200 }))).toBe(true)
expect(changed(working())).toBe(true)
})
it('invalidates when the assistant body changes', () => {
const changed = createHookStatusSessionTabsInvalidator()
changed(working({}, { lastAssistantMessage: 'First answer' }))
expect(changed(working({}, { lastAssistantMessage: 'Corrected answer' }))).toBe(true)
})
it('ignores resume-identity rows, which the provider-session path owns', () => {
const changed = createHookStatusSessionTabsInvalidator()
@@ -28,7 +28,9 @@ export function createHookStatusSessionTabsInvalidator(): {
(previous.agentType ?? null) !== (next.agentType ?? null) ||
(previous.toolName ?? null) !== (next.toolName ?? null) ||
(previous.interactivePrompt ?? null) !== (next.interactivePrompt ?? null) ||
(previous.interrupted ?? false) !== (next.interrupted ?? false)
(previous.interrupted ?? false) !== (next.interrupted ?? false) ||
(previous.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) ||
(previous.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null)
)
}
// Why: a cleared pane must re-arm, else the memo swallows the first event of the
+3 -1
View File
@@ -10928,7 +10928,9 @@ export class OrcaRuntimeService {
(previous.payload.agentType ?? null) !== (payload.agentType ?? null) ||
(previous.payload.toolName ?? null) !== (payload.toolName ?? null) ||
(previous.payload.interactivePrompt ?? null) !== (payload.interactivePrompt ?? null) ||
(previous.payload.interrupted ?? false) !== (payload.interrupted ?? false)
(previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) ||
(previous.payload.turnCompletedAt ?? null) !== (payload.turnCompletedAt ?? null) ||
(previous.payload.lastAssistantMessage ?? null) !== (payload.lastAssistantMessage ?? null)
)
}
@@ -102,6 +102,29 @@ describe('agent completion coordinator identity map stays bounded (leak regressi
expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(1)
})
it('clears per-coordinator working boundaries on reset and dispose', () => {
const live = { value: true }
const coordinator = createAgentCompletionCoordinator(
makeOptions('tab-1:leaf-working-boundary', live)
)
const working = {
state: 'working' as const,
prompt: '',
agentType: 'claude' as const,
stateStartedAt: 1_000
}
coordinator.observeHookStatus(working)
expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(1)
coordinator.resetCompletionState()
expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(0)
coordinator.observeHookStatus(working)
expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(1)
coordinator.dispose()
expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(0)
})
it('retains stamped replay state when a mounted sibling disposes before remount', () => {
const paneKey = 'tab-1:leaf-production-remount'
const dispatchCompletion = vi.fn()
@@ -1392,6 +1392,63 @@ describe('agent completion coordinator', () => {
}
)
it('does not consume a fresh turn as the all-clear for an earlier stamped tail', () => {
const dispatchCompletion = vi.fn()
const localCoordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
const hostCoordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
localCoordinator.observeHookStatus({
state: 'working',
prompt: 'first turn',
agentType: 'claude',
stateStartedAt: 5_000
})
hostCoordinator.observeHookStatus({
state: 'working',
prompt: 'first turn',
agentType: 'claude',
stateStartedAt: 2_000
})
hostCoordinator.observeHookStatus({
state: 'working',
prompt: 'first turn',
agentType: 'claude',
stateStartedAt: 2_000,
turnCompletedAt: 2_500
})
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
localCoordinator.observeHookStatus({
state: 'working',
prompt: 'second turn',
agentType: 'claude',
stateStartedAt: 6_000
})
localCoordinator.observeHookStatus({
state: 'done',
prompt: 'second turn',
agentType: 'claude',
stateStartedAt: 6_500
})
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
expect(dispatchCompletion).toHaveBeenCalledTimes(2)
})
it('does not replay a stamped turn or its all-clear after a working title blip', () => {
const dispatchCompletion = vi.fn()
const dispatchHookLifecycle = vi.fn()
@@ -40,8 +40,17 @@ 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 pendingStampedTailByPaneKey = new Map<
string,
{
turnCompletedAt: number
eligibleWorkingBoundaryByCoordinator: Map<number, number>
}
>()
// Why: a sibling all-clear belongs to the stamped tail only while its exact working interval is still current.
const workingBoundaryByPaneKey = new Map<string, Map<number, number>>()
const coordinatorCountByPaneKey = new Map<string, number>()
let nextCoordinatorId = 1
const IDLE_POLL_INTERVAL_MS = 2_000
const ACTIVE_POLL_INTERVAL_MS = 750
@@ -83,6 +92,7 @@ function isAttentionHookState(state: ParsedAgentStatusPayload['state']): boolean
export function createAgentCompletionCoordinator(
options: AgentCompletionCoordinatorOptions
): AgentCompletionCoordinator {
const coordinatorId = nextCoordinatorId++
coordinatorCountByPaneKey.set(
options.paneKey,
(coordinatorCountByPaneKey.get(options.paneKey) ?? 0) + 1
@@ -211,14 +221,42 @@ export function createAgentCompletionCoordinator(
)
}
function recordWorkingBoundary(stateStartedAt: number | undefined): void {
let boundaries = workingBoundaryByPaneKey.get(options.paneKey)
if (!isFiniteTurnCompletedAt(stateStartedAt)) {
boundaries?.delete(coordinatorId)
if (boundaries?.size === 0) {
workingBoundaryByPaneKey.delete(options.paneKey)
}
return
}
if (!boundaries) {
boundaries = new Map()
workingBoundaryByPaneKey.set(options.paneKey, boundaries)
}
boundaries.set(coordinatorId, stateStartedAt)
}
function clearWorkingBoundary(): void {
const boundaries = workingBoundaryByPaneKey.get(options.paneKey)
boundaries?.delete(coordinatorId)
if (boundaries?.size === 0) {
workingBoundaryByPaneKey.delete(options.paneKey)
}
}
function consumePendingStampedTailForAgent(agentIdentity: string | null): boolean {
const pendingStampedTail = pendingStampedTailByPaneKey.get(options.paneKey)
const stampedCompletion = lastCompletionIdentityByPaneKey.get(options.paneKey)
const currentWorkingBoundary = workingBoundaryByPaneKey.get(options.paneKey)?.get(coordinatorId)
if (
pendingStampedTail === undefined ||
stampedCompletion?.lastTurnCompletedAtNotified !== pendingStampedTail ||
stampedCompletion?.lastTurnCompletedAtNotified !== pendingStampedTail.turnCompletedAt ||
agentIdentity === null ||
stampedCompletion.agentIdentity !== agentIdentity
stampedCompletion.agentIdentity !== agentIdentity ||
currentWorkingBoundary === undefined ||
pendingStampedTail.eligibleWorkingBoundaryByCoordinator.get(coordinatorId) !==
currentWorkingBoundary
) {
return false
}
@@ -893,7 +931,12 @@ export function createAgentCompletionCoordinator(
if (turnCompletedAt !== undefined) {
const alreadyHandled = turnCompletedAtAlreadyHandled(turnCompletedAt)
if (!alreadyHandled) {
pendingStampedTailByPaneKey.set(options.paneKey, turnCompletedAt)
pendingStampedTailByPaneKey.set(options.paneKey, {
turnCompletedAt,
eligibleWorkingBoundaryByCoordinator: new Map(
workingBoundaryByPaneKey.get(options.paneKey)
)
})
}
// 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 (
@@ -927,6 +970,7 @@ export function createAgentCompletionCoordinator(
options.dispatchHookLifecycle?.(payload)
return
}
recordWorkingBoundary(payload.stateStartedAt)
clearPendingHookDone()
// Why: resumed work cancels the debounced attention so a self-resolving pause never notifies.
clearPendingCodexAttention()
@@ -977,7 +1021,7 @@ 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) {
if (pendingStampedTailByPaneKey.get(options.paneKey)?.turnCompletedAt === turnCompletedAt) {
pendingStampedTailByPaneKey.delete(options.paneKey)
}
options.dispatchHookLifecycle?.(payload)
@@ -1062,6 +1106,7 @@ export function createAgentCompletionCoordinator(
lastForegroundAgent = null
requiresFreshWorking = options.requireFreshWorking ?? false
inspectionGeneration += 1
clearWorkingBoundary()
}
function dispose(): void {
@@ -1073,6 +1118,7 @@ export function createAgentCompletionCoordinator(
clearPendingHookDone()
clearPendingCodexAttention()
dropPendingTitle()
clearWorkingBoundary()
const remainingCoordinators = (coordinatorCountByPaneKey.get(options.paneKey) ?? 1) - 1
if (remainingCoordinators > 0) {
coordinatorCountByPaneKey.set(options.paneKey, remainingCoordinators)
@@ -1084,6 +1130,7 @@ export function createAgentCompletionCoordinator(
lastCompletionIdentityByPaneKey.delete(options.paneKey)
handledTurnCompletedAtByPaneKey.delete(options.paneKey)
pendingStampedTailByPaneKey.delete(options.paneKey)
workingBoundaryByPaneKey.delete(options.paneKey)
}
}
@@ -1104,13 +1151,16 @@ export function resetAgentCompletionCoordinatorIdentitiesForTest(): void {
lastCompletionIdentityByPaneKey.clear()
handledTurnCompletedAtByPaneKey.clear()
pendingStampedTailByPaneKey.clear()
workingBoundaryByPaneKey.clear()
coordinatorCountByPaneKey.clear()
nextCoordinatorId = 1
}
export function getAgentCompletionCoordinatorIdentityCountForTest(): number {
return new Set([
...lastCompletionIdentityByPaneKey.keys(),
...handledTurnCompletedAtByPaneKey.keys(),
...pendingStampedTailByPaneKey.keys()
...pendingStampedTailByPaneKey.keys(),
...workingBoundaryByPaneKey.keys()
]).size
}