diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 3b27a143417..313971bbc4d 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -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', diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index c2fcbba475a..e66a59bd3f6 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts index 1b522fff6e8..1b3b36182e5 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -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() const dispatchCompletion = vi.fn() diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 5f5dfbf098f..c5d4fafcfdf 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -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') diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 61de3a3bda8..cfc691644ce 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -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