mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
Suppress stamped completion tail replays
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user