fix(agent-completion): stop mid-turn permission/elicitation hooks from firing false completion notifications

isCompletionHookState() treated 'waiting' (PermissionRequest) and 'blocked'
(Copilot elicitation dialog) as turn-completion states, so observeHookStatus()
called dispatchCompletion() on those intermediate, mid-turn hook events. That
fired false agent-task-complete desktop notifications during permission prompts
and tool calls instead of only at terminal turn end, spamming the user.

Narrow isCompletionHookState to 'done' only, add isAttentionHookState covering
'waiting'/'blocked', and early-return those states in observeHookStatus after
clearing any pending hook-done quiet-window timer. This prevents a permission
prompt arriving within the 1.5s quiet window after a 'done' from firing a
delayed false completion. The sidebar smart-attention indicator still surfaces
'waiting'/'blocked' inline, so the attention signal is not lost.

Supersedes community PR #6316.

Fixes #4375

Co-authored-by: Rod Boev <rodboev@users.noreply.github.com>
This commit is contained in:
Neil
2026-06-27 23:21:33 -07:00
co-authored by Rod Boev
parent b45cb545c5
commit c5ee5d280c
2 changed files with 70 additions and 6 deletions
@@ -1054,7 +1054,7 @@ describe('agent completion coordinator', () => {
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
})
it('would spam Cursor notifications if shell hooks still mapped to waiting', () => {
it('does not dispatch completion when waiting states arrive mid-turn', () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
@@ -1090,7 +1090,64 @@ describe('agent completion coordinator', () => {
toolInput: 'git status'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(2)
expect(dispatchCompletion).toHaveBeenCalledTimes(0)
})
it('does not dispatch completion when blocked state arrives mid-turn', () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
const turn = {
prompt: 'fix the bug',
agentType: 'copilot' as const
}
coordinator.observeHookStatus({ state: 'working', ...turn })
coordinator.observeHookStatus({
state: 'blocked',
...turn,
toolName: 'Shell',
toolInput: 'npm install'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(0)
})
it('cancels a pending done timer when a waiting state arrives before the quiet window', () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
const turn = {
prompt: 'fix the bug',
agentType: 'cursor' as const
}
coordinator.observeHookStatus({ state: 'working', ...turn })
coordinator.observeHookStatus({ state: 'done', ...turn, lastAssistantMessage: 'Done.' })
// waiting arrives before the 1.5s quiet window expires; must cancel the timer
coordinator.observeHookStatus({
state: 'waiting',
...turn,
toolName: 'Shell',
toolInput: 'pnpm test'
})
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
expect(dispatchCompletion).toHaveBeenCalledTimes(0)
})
it('keeps a generic title completion pending long enough for the first remote inspection', async () => {
@@ -43,7 +43,13 @@ const COMPLETION_REPLAY_GUARD_MS = 1_000
const HOOK_DONE_QUIET_MS = 1_500
function isCompletionHookState(state: ParsedAgentStatusPayload['state']): boolean {
return state === 'done' || state === 'waiting' || state === 'blocked'
return state === 'done'
}
function isAttentionHookState(state: ParsedAgentStatusPayload['state']): boolean {
// 'waiting' = PermissionRequest; 'blocked' = Copilot elicitation dialog.
// Agent is alive but paused; not a completion signal.
return state === 'waiting' || state === 'blocked'
}
export function createAgentCompletionCoordinator(
@@ -622,10 +628,11 @@ export function createAgentCompletionCoordinator(
dropPendingTitle()
return
}
if (isAttentionHookState(payload.state)) {
clearPendingHookDone()
return
}
if (isCompletionHookState(payload.state)) {
if (payload.state !== 'done') {
clearPendingHookDone()
}
if (isRecognizedAgentType(payload.agentType)) {
establishAgentEvidence()
}