Fix cursor-agent notification spam on shell tool hooks (#4835)

This commit is contained in:
Jinwoo Hong
2026-06-07 15:24:15 -07:00
committed by GitHub
parent 3b41d45c90
commit da98a93089
5 changed files with 165 additions and 9 deletions
+35 -4
View File
@@ -3894,24 +3894,24 @@ describe('Cursor hook normalization', () => {
expect(result?.payload.interrupted).toBe(true)
})
it('beforeShellExecution maps to waiting with the pending command as toolInput', () => {
it('beforeShellExecution maps to working with the pending command as toolInput', () => {
const result = _internals.normalizeHookPayload(
'cursor',
buildBody({ hook_event_name: 'beforeShellExecution', command: 'rm -rf /tmp/foo' }),
'production'
)
expect(result?.payload.state).toBe('waiting')
expect(result?.payload.state).toBe('working')
expect(result?.payload.toolName).toBe('Shell')
expect(result?.payload.toolInput).toBe('rm -rf /tmp/foo')
})
it('beforeMCPExecution maps to waiting', () => {
it('beforeMCPExecution maps to working', () => {
const result = _internals.normalizeHookPayload(
'cursor',
buildBody({ hook_event_name: 'beforeMCPExecution', tool_name: 'fetch', url: 'https://x' }),
'production'
)
expect(result?.payload.state).toBe('waiting')
expect(result?.payload.state).toBe('working')
expect(result?.payload.toolName).toBe('fetch')
})
@@ -4010,6 +4010,37 @@ describe('Cursor hook normalization', () => {
])
})
it('tool-heavy turn keeps working across shell and generic tool hooks until stop', () => {
_internals.normalizeHookPayload(
'cursor',
buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'run checks' }),
'production'
)
const shell = _internals.normalizeHookPayload(
'cursor',
buildBody({ hook_event_name: 'beforeShellExecution', command: 'pnpm test' }),
'production'
)
expect(shell?.payload.state).toBe('working')
const tool = _internals.normalizeHookPayload(
'cursor',
buildBody({
hook_event_name: 'preToolUse',
tool_name: 'Read',
tool_input: { file_path: '/repo/src/app.ts' }
}),
'production'
)
expect(tool?.payload.state).toBe('working')
const stop = _internals.normalizeHookPayload(
'cursor',
buildBody({ hook_event_name: 'stop', status: 'completed' }),
'production'
)
expect(stop?.payload.state).toBe('done')
expect(stop?.payload.prompt).toBe('run checks')
})
it('beforeSubmitPrompt clears the cached tool state from a prior turn', () => {
_internals.normalizeHookPayload(
'cursor',
+1 -1
View File
@@ -28,7 +28,7 @@ import {
// - preToolUse/postToolUse/postToolUseFailure: in-flight tool preview
// between submit and stop — without these the pane appears idle for the
// entire duration of a long tool-heavy turn
// - beforeShellExecution / beforeMCPExecution: approval prompts (→ waiting)
// - beforeShellExecution / beforeMCPExecution: shell/MCP tool preview (→ working)
// - afterAgentResponse: carries the final composed reply text so the
// dashboard can surface it on done
// sessionStart / sessionEnd are intentionally NOT subscribed — cursor-agent
@@ -723,6 +723,89 @@ describe('agent completion coordinator', () => {
expect(dispatchCompletion).toHaveBeenCalledWith(agentType)
})
it('notifies once after a Cursor tool-heavy turn, not on each shell hook', () => {
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: 'working',
...turn,
toolName: 'Shell',
toolInput: 'pnpm test'
})
coordinator.observeHookStatus({
state: 'working',
...turn,
toolName: 'Read',
toolInput: '/repo/src/app.ts'
})
coordinator.observeHookStatus({
state: 'working',
...turn,
toolName: 'Shell',
toolInput: 'git status'
})
expect(dispatchCompletion).not.toHaveBeenCalled()
coordinator.observeHookStatus({ state: 'done', ...turn, lastAssistantMessage: 'Fixed.' })
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
})
it('would spam Cursor notifications if shell hooks still mapped to waiting', () => {
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: 'waiting',
...turn,
toolName: 'Shell',
toolInput: 'pnpm test'
})
coordinator.observeHookStatus({
state: 'working',
...turn,
toolName: 'Read',
toolInput: '/repo/src/app.ts'
})
coordinator.observeHookStatus({
state: 'waiting',
...turn,
toolName: 'Shell',
toolInput: 'git status'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(2)
})
it('keeps a generic title completion pending long enough for the first remote inspection', async () => {
const inspection = createDeferred<RuntimeTerminalProcessInspection>()
const dispatchCompletion = vi.fn()
@@ -318,6 +318,45 @@ describe('agent hook completion notifications', () => {
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
})
it('does not notify on each Cursor shell tool hook during a working turn', async () => {
const { observeAgentHookCompletionForNotification } =
await import('./agent-hook-completion-notifications')
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'fix the bug',
agentType: 'cursor'
}
})
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'fix the bug',
agentType: 'cursor',
toolName: 'Shell',
toolInput: 'pnpm test'
}
})
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'fix the bug',
agentType: 'cursor',
toolName: 'Read',
toolInput: '/repo/src/app.ts'
}
})
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
})
it('suppresses an internal milestone completion when hook work resumes before quiet', async () => {
const { observeAgentHookCompletionForNotification } =
await import('./agent-hook-completion-notifications')
+7 -4
View File
@@ -2343,7 +2343,12 @@ function normalizeCursorEvent(
eventName === 'sessionStart' ||
eventName === 'preToolUse' ||
eventName === 'postToolUse' ||
eventName === 'postToolUseFailure'
eventName === 'postToolUseFailure' ||
// Why: these fire for every shell/MCP invocation as pre-execution gates,
// not only when the user is blocked on approval. Treat them like PreToolUse
// so a tool-heavy turn does not spam waiting-state notifications.
eventName === 'beforeShellExecution' ||
eventName === 'beforeMCPExecution'
? 'working'
: eventName === 'afterAgentResponse'
? previousStatus?.state === 'done' && previousStatus.agentType === 'cursor'
@@ -2351,9 +2356,7 @@ function normalizeCursorEvent(
: 'working'
: eventName === 'stop' || eventName === 'sessionEnd'
? 'done'
: eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution'
? 'waiting'
: null
: null
if (!stateName) {
return null