From 403ac726aa9fa7a2327cc06c4d1df7ade8b6bf4c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 31 May 2026 06:57:12 -0700 Subject: [PATCH] fix: cancel hook completion on title working (#4175) --- .../agent-completion-coordinator.test.ts | 30 ++++++++++ .../agent-completion-coordinator.ts | 3 + .../terminal-pane/pty-connection.test.ts | 57 +++++++++++++++++++ 3 files changed, 90 insertions(+) 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 9b263067ef6..6adc607ab32 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 @@ -646,6 +646,36 @@ describe('agent completion coordinator', () => { }) }) + it('cancels a hook completion when title tracking observes resumed work before quiet', () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + isLive: () => true + }) + + coordinator.observeHookStatus({ + state: 'working', + prompt: 'run the goal', + agentType: 'codex' + }) + coordinator.observeHookStatus({ + state: 'done', + prompt: 'run the goal', + agentType: 'codex' + }) + expect(coordinator.hasPendingHookDoneCompletion()).toBe(true) + + coordinator.observeTitleWorking() + expect(coordinator.hasPendingHookDoneCompletion()).toBe(false) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchCompletion).not.toHaveBeenCalled() + }) + it.each([ 'claude', 'codex', diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index f96cbbce90f..d762a306ac8 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -361,6 +361,9 @@ export function createAgentCompletionCoordinator( } function recordTitleWorking(): boolean { + // Why: hooks can report `done` before title tracking notices the next + // milestone. The title working signal must cancel that provisional done. + clearPendingHookDone() if ( lastCompletionSource === 'hook' && Date.now() - lastCompletionAt < COMPLETION_REPLAY_GUARD_MS diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 390635ddfe8..02572a98ebd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4565,6 +4565,63 @@ describe('connectPanePty', () => { ) }) + it('restores a suppressed terminal bell when a delayed hook completion resumes via title tracking', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + + vi.useFakeTimers() + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { + state: 'working' | 'done' + prompt: string + agentType: 'codex' + lastAssistantMessage?: string + }) => void) + | undefined + const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined + const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as + | (() => void) + | undefined + if (!statusHandler || !bellHandler || !workingHandler) { + throw new Error('Expected hook status, bell, and working handlers to be registered') + } + + statusHandler({ + state: 'working', + prompt: 'finish the implementation', + agentType: 'codex' + }) + statusHandler({ + state: 'done', + prompt: 'finish the implementation', + agentType: 'codex', + lastAssistantMessage: 'Milestone complete.' + }) + bellHandler() + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + + workingHandler() + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'terminal-bell' }) + ) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + it('restores a suppressed terminal bell when the pending agent completion is canceled', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport()