diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 9d8b9246d7d..5899b2fe6bd 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -2,14 +2,12 @@ import type { BrowserWindow } from 'electron' import { agentHookServer } from '../agent-hooks/server' import { setMigrationUnsupportedPtyListener } from '../agent-hooks/migration-unsupported-pty-state' import { getDashboardPopoutWindow } from '../window/dashboard-popout-window' -import { isAskUserQuestionTool } from '../../shared/agent-question-answered-intent' import { getSyntheticAgentTitleProfile, shouldDriveSyntheticAgentTitleFromHook } from '../../shared/synthetic-agent-title' import { driveSyntheticTitleFromHook, - shouldSuppressCodexAutoApprovalSyntheticTitleFromHook, stopAllSyntheticTitleSpinners } from './synthetic-title-runtime' import { mainProcessState as state } from './main-process-state' @@ -79,15 +77,6 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt const runtime = state.runtime const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) - const suppressSyntheticCodexAutoApprovalTitle = - payload.agentType === 'codex' && - (payload.state === 'waiting' || payload.state === 'blocked') - ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ - agentType: payload.agentType, - state: payload.state, - launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) - }) - : false const statusEvent = { ...(authorityRestartId && isReplay !== true ? { authorityRestartId } : {}), ...payload, @@ -107,17 +96,11 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt ...(orchestration ? { orchestration } : {}) } state.mainWindow?.webContents.send('agentStatus:set', statusEvent) - if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) { - getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) - } + getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) options.onRecordAgentState(payload.agentType ?? 'unknown', payload.state) // Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync. const profile = getSyntheticAgentTitleProfile(payload.agentType) - if ( - profile && - shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && - !suppressSyntheticCodexAutoApprovalTitle - ) { + if (profile && shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state)) { driveSyntheticTitleFromHook(paneKey, payload.state, profile) } } diff --git a/src/main/startup/main-window-structured-status-filter.test.ts b/src/main/startup/main-window-structured-status-filter.test.ts index 689eb4e85e6..c7fe55da626 100644 --- a/src/main/startup/main-window-structured-status-filter.test.ts +++ b/src/main/startup/main-window-structured-status-filter.test.ts @@ -30,7 +30,6 @@ vi.mock('../window/dashboard-popout-window', () => ({ })) vi.mock('./synthetic-title-runtime', () => ({ driveSyntheticTitleFromHook: vi.fn(), - shouldSuppressCodexAutoApprovalSyntheticTitleFromHook: () => false, stopAllSyntheticTitleSpinners: vi.fn() })) diff --git a/src/main/startup/synthetic-title-runtime.ts b/src/main/startup/synthetic-title-runtime.ts index f8b02860bef..e28b992edd4 100644 --- a/src/main/startup/synthetic-title-runtime.ts +++ b/src/main/startup/synthetic-title-runtime.ts @@ -13,7 +13,6 @@ import { } from '../synthetic-title-spinner' import { shouldSendSyntheticTitleFrame } from '../synthetic-title-visibility' import { shouldCopySyntheticTitleFrameToPtyData } from '../synthetic-title-frame-routing' -import { resolveTuiAgentPermissionMode } from '../../shared/tui-agent-permissions' import { mainProcessState as state } from './main-process-state' // Why: cursor-agent re-emits its own OSC title on every redraw, overwriting a one-shot frame — so re-assert a working frame on an interval. @@ -150,29 +149,6 @@ export function driveSyntheticTitleFromHook( sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, { force: true }) } -export function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { - agentType: string | null | undefined - state: AgentStatusState - launchConfig: - | { agentArgs?: string | null; agentEnv?: Record | null } - | null - | undefined -}): boolean { - if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) { - return false - } - if (!args.launchConfig) { - return false - } - return ( - resolveTuiAgentPermissionMode({ - agent: 'codex', - agentArgs: args.launchConfig.agentArgs, - agentEnv: args.launchConfig.agentEnv - }) === 'yolo' - ) -} - export function initializeSyntheticTitleRuntime(): void { // Why: on PTY teardown drop the spinner entry explicitly, else the shared timer keeps ticking with sendSyntheticTitle no-oping forever. registerPaneKeyTeardownListener((paneKey) => stopSyntheticTitleSpinner(paneKey)) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts index deff744fbd8..c7dd2b7a08f 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts @@ -7,8 +7,6 @@ import { useAgentCompletionCoordinatorLifecycle } from './agent-completion-coordinator-test-harness' -const CODEX_ATTENTION_QUIET_MS = 1_500 - describe('agent completion coordinator', () => { useAgentCompletionCoordinatorLifecycle() @@ -67,47 +65,6 @@ describe('agent completion coordinator', () => { ) }) - it('suppresses the attention dispatch when shouldSuppressHookCompletion matches', () => { - // Why: guards the merge seam where the suppressor must short-circuit before - // the attention path, so auto-approved Codex pauses never notify. - const dispatchCompletion = vi.fn() - const dispatchAttention = vi.fn() - const coordinator = createAgentCompletionCoordinator({ - paneKey: 'tab-1:leaf-1', - getPtyId: () => 'pty-1', - getSettings: () => null, - inspectProcess: vi.fn(), - dispatchCompletion, - dispatchAttention, - isLive: () => true, - shouldSuppressHookCompletion: (payload) => - payload.state === 'waiting' || payload.state === 'blocked' - }) - - const turn = { - prompt: 'implement notifications', - agentType: 'codex' as const - } - - coordinator.observeHookStatus({ state: 'working', ...turn }) - coordinator.observeHookStatus({ - state: 'waiting', - ...turn, - toolName: 'exec_command', - toolInput: 'git status' - }) - coordinator.observeHookStatus({ - state: 'blocked', - ...turn, - toolName: 'exec_command', - toolInput: 'rm file' - }) - vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) - - expect(dispatchAttention).not.toHaveBeenCalled() - expect(dispatchCompletion).not.toHaveBeenCalled() - }) - it('does not dispatch completion when a blocked state arrives mid-turn', () => { const dispatchCompletion = vi.fn() const dispatchAttention = vi.fn() @@ -197,45 +154,6 @@ describe('agent completion coordinator', () => { ) }) - it('cancels a pending done timer when a suppressed attention state arrives before the quiet window', () => { - // Why: a suppressed Codex auto-approval pause must still cancel a provisional - // 'done' so the quiet-window timer never fires a false completion notification. - const dispatchCompletion = vi.fn() - const dispatchAttention = vi.fn() - const coordinator = createAgentCompletionCoordinator({ - paneKey: 'tab-1:leaf-1', - getPtyId: () => 'pty-1', - getSettings: () => null, - inspectProcess: vi.fn(), - dispatchCompletion, - dispatchAttention, - isLive: () => true, - shouldSuppressHookCompletion: (payload) => - payload.state === 'waiting' || payload.state === 'blocked' - }) - - const turn = { - prompt: 'implement notifications', - agentType: 'codex' as const - } - - coordinator.observeHookStatus({ state: 'working', ...turn }) - coordinator.observeHookStatus({ state: 'done', ...turn, lastAssistantMessage: 'Done.' }) - expect(coordinator.hasPendingHookDoneCompletion()).toBe(true) - - coordinator.observeHookStatus({ - state: 'waiting', - ...turn, - toolName: 'exec_command', - toolInput: 'git status' - }) - expect(coordinator.hasPendingHookDoneCompletion()).toBe(false) - vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) - - expect(dispatchCompletion).not.toHaveBeenCalled() - expect(dispatchAttention).not.toHaveBeenCalled() - }) - it('still dispatches completion on done after an intervening waiting state in the same turn', () => { const dispatchCompletion = vi.fn() const dispatchAttention = vi.fn() @@ -273,11 +191,11 @@ describe('agent completion coordinator', () => { expect(dispatchCompletion).toHaveBeenCalledTimes(1) }) - it('cancels the debounced Codex attention notification when work resumes in the quiet window', () => { - // Why: Codex fires PermissionRequest at the human-input boundary *before* the - // approval decision. Under "Approve for me" the review agent approves and - // Codex resumes within the quiet window, so the OS notification must be - // debounced and canceled — no false "approval required" banner (issue #8387). + it('dispatches a Codex attention notification immediately, like every other agent', () => { + // Why: Codex used to debounce this behind a 1.5s window to hide auto-approved + // pauses. The hook listener now classifies a reviewer-owned approval as + // `working` at write time, so a Codex `waiting` that reaches here is a real + // prompt and must notify at once (#21389). const dispatchAttention = vi.fn() const dispatchHookLifecycle = vi.fn() const coordinator = createAgentCompletionCoordinator({ @@ -297,50 +215,12 @@ describe('agent completion coordinator', () => { state: 'waiting', ...turn, toolName: 'exec_command', - toolInput: 'git status' + toolInput: 'apply patch' }) - // Visual status still updates immediately even though the notification waits. expect(dispatchHookLifecycle).toHaveBeenCalledWith( expect.objectContaining({ state: 'waiting', agentType: 'codex' }) ) - expect(dispatchAttention).not.toHaveBeenCalled() - - coordinator.observeHookStatus({ - state: 'working', - ...turn, - toolName: 'exec_command', - toolInput: 'git status' - }) - vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) - - expect(dispatchAttention).not.toHaveBeenCalled() - }) - - it('dispatches the debounced Codex attention notification after the quiet window elapses', () => { - const dispatchAttention = vi.fn() - const coordinator = createAgentCompletionCoordinator({ - paneKey: 'tab-1:leaf-1', - getPtyId: () => 'pty-1', - getSettings: () => null, - inspectProcess: vi.fn(), - dispatchCompletion: vi.fn(), - dispatchAttention, - isLive: () => true - }) - - const turn = { prompt: 'fix the bug', agentType: 'codex' as const } - coordinator.observeHookStatus({ state: 'working', ...turn }) - coordinator.observeHookStatus({ - state: 'waiting', - ...turn, - toolName: 'exec_command', - toolInput: 'apply patch' - }) - expect(dispatchAttention).not.toHaveBeenCalled() - - vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) - expect(dispatchAttention).toHaveBeenCalledTimes(1) expect(dispatchAttention).toHaveBeenCalledWith( 'codex', @@ -353,9 +233,11 @@ describe('agent completion coordinator', () => { }) }) ) + // No Codex-only timer is armed any more. + expect(vi.getTimerCount()).toBe(0) }) - it('dispatches a non-Codex attention notification immediately without debounce', () => { + it('dispatches a non-Codex attention notification immediately', () => { const dispatchAttention = vi.fn() const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', @@ -377,11 +259,10 @@ describe('agent completion coordinator', () => { }) expect(dispatchAttention).toHaveBeenCalledTimes(1) - // Non-Codex attention must not arm the debounce timer at all. expect(vi.getTimerCount()).toBe(0) }) - it('debounces a blocked Codex pause like waiting and fires after the quiet window', () => { + it('dispatches a blocked Codex pause immediately, like waiting', () => { const dispatchAttention = vi.fn() const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', @@ -396,13 +277,12 @@ describe('agent completion coordinator', () => { const turn = { prompt: 'fix the bug', agentType: 'codex' as const } coordinator.observeHookStatus({ state: 'working', ...turn }) coordinator.observeHookStatus({ state: 'blocked', ...turn, toolName: 'exec_command' }) - expect(dispatchAttention).not.toHaveBeenCalled() - vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) expect(dispatchAttention).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) }) - it('cancels the debounced Codex attention when a completion lands in the window (no double notify)', () => { + it('notifies the Codex pause and the completion that ends the same turn', () => { const dispatchAttention = vi.fn() const dispatchCompletion = vi.fn() const coordinator = createAgentCompletionCoordinator({ @@ -418,41 +298,17 @@ describe('agent completion coordinator', () => { const turn = { prompt: 'fix the bug', agentType: 'codex' as const } coordinator.observeHookStatus({ state: 'working', ...turn }) coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'exec_command' }) - // A 'done' completing the turn inside the window must cancel the pending - // attention so the pause never co-fires with the completion notification. + expect(dispatchAttention).toHaveBeenCalledTimes(1) + coordinator.observeHookStatus({ state: 'done', ...turn }) - vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) - expect(dispatchAttention).not.toHaveBeenCalled() expect(dispatchCompletion).toHaveBeenCalledTimes(1) + expect(dispatchAttention).toHaveBeenCalledTimes(1) expect(vi.getTimerCount()).toBe(0) }) - it('clears the pending Codex attention timer on dispose (no leak, no late fire)', () => { - const dispatchAttention = vi.fn() - const coordinator = createAgentCompletionCoordinator({ - paneKey: 'tab-1:leaf-1', - getPtyId: () => 'pty-1', - getSettings: () => null, - inspectProcess: vi.fn(), - dispatchCompletion: vi.fn(), - dispatchAttention, - isLive: () => true - }) - - const turn = { prompt: 'fix the bug', agentType: 'codex' as const } - coordinator.observeHookStatus({ state: 'working', ...turn }) - coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'exec_command' }) - expect(vi.getTimerCount()).toBe(1) - - coordinator.dispose() - expect(vi.getTimerCount()).toBe(0) - - vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) - expect(dispatchAttention).not.toHaveBeenCalled() - }) - - it('re-arms and fires a second distinct Codex pause after work resumed', () => { + it('notifies a second distinct Codex pause after work resumed', () => { const dispatchAttention = vi.fn() const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', @@ -472,80 +328,44 @@ describe('agent completion coordinator', () => { toolName: 'exec_command', toolInput: 'ls' }) - // First pause auto-resolves before the window elapses. + expect(dispatchAttention).toHaveBeenCalledTimes(1) + coordinator.observeHookStatus({ state: 'working', ...turn, toolName: 'exec_command', toolInput: 'ls' }) - vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) - expect(dispatchAttention).not.toHaveBeenCalled() - - // A later, genuinely-distinct pause must re-arm the debounce and fire. coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'apply_patch', toolInput: 'diff' }) - expect(dispatchAttention).not.toHaveBeenCalled() - vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) - expect(dispatchAttention).toHaveBeenCalledTimes(1) + + expect(dispatchAttention).toHaveBeenCalledTimes(2) }) - it('cancels the debounced Codex attention when a working-spinner title resumes', () => { - // Why: a Codex resume can surface as a working title before the resume - // 'working' hook lands; that title must also cancel the pending attention - // so the self-resolving pause never fires a false banner (issue #8387). - const dispatchAttention = vi.fn() - const coordinator = createAgentCompletionCoordinator({ - paneKey: 'tab-1:leaf-1', - getPtyId: () => 'pty-1', - getSettings: () => null, - inspectProcess: vi.fn(), - dispatchCompletion: vi.fn(), - dispatchAttention, - isLive: () => true - }) - - const turn = { prompt: 'fix the bug', agentType: 'codex' as const } - coordinator.observeHookStatus({ state: 'working', ...turn }) - coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'exec_command' }) - expect(vi.getTimerCount()).toBe(1) - - coordinator.observeTitleWorking() - expect(vi.getTimerCount()).toBe(0) - - vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) - expect(dispatchAttention).not.toHaveBeenCalled() - }) - - it('does not let a null-foreground inspection blip drop the debounced Codex attention', async () => { - // Why: guard for #8387 fail-open. In the pty-connection coordinator (real - // process polling), a transient null/shell foreground blip — or a remote - // inspection that cannot resolve the foreground — must not convert a genuine - // Codex pause into a process-exit completion while the attention debounce is - // still pending. Mirrors the pendingHookDoneTimer evidence-teardown guard. + it('delivers a Codex pause notification before a null-foreground inspection blip', async () => { + // Why: the notification used to be deferred 1.5s, and the deferred callback + // re-checked liveness — so a pause whose pane blipped inside the window was + // dropped outright rather than delayed. Delivery is synchronous now. let foreground: string | null = 'codex' const dispatchAttention = vi.fn() - const dispatchCompletion = vi.fn() const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', getSettings: () => null, inspectProcess: vi.fn(async () => processResult(foreground)), - dispatchCompletion, + dispatchCompletion: vi.fn(), dispatchAttention, isLive: () => true }) coordinator.startProcessTracking() - // First cadence poll recognizes Codex as the foreground agent (active tier). await vi.advanceTimersByTimeAsync(2_000) await flushAsyncTicks() - // Codex pauses for a permission decision: the OS attention is debounced. const turn = { prompt: 'apply patch', agentType: 'codex' as const } coordinator.observeHookStatus({ state: 'waiting', @@ -553,69 +373,38 @@ describe('agent completion coordinator', () => { toolName: 'exec_command', toolInput: 'rm -rf build' }) - expect(dispatchAttention).not.toHaveBeenCalled() + expect(dispatchAttention).toHaveBeenCalledTimes(1) - // Foreground reads null for the whole window; without the guard this would - // land a false process-exit completion racing/duplicating the pause banner. foreground = null - await vi.advanceTimersByTimeAsync(CODEX_ATTENTION_QUIET_MS + 100) + await vi.advanceTimersByTimeAsync(2_000) await flushAsyncTicks() - expect(dispatchCompletion).not.toHaveBeenCalled() expect(dispatchAttention).toHaveBeenCalledTimes(1) }) - it('does not mutate completion state when hook completion is suppressed', () => { - const dispatchCompletion = vi.fn() - const shouldSuppressHookCompletion = vi.fn( - (payload: { state: string }) => payload.state === 'waiting' || payload.state === 'blocked' - ) + it('notifies a Codex pause even when the pane stops being live right after it', () => { + // Why: the removed quiet window re-checked isLive() when it expired, so a + // genuine prompt on a pane that went non-live inside the window was dropped. + const dispatchAttention = vi.fn() + let live = true const coordinator = createAgentCompletionCoordinator({ paneKey: 'tab-1:leaf-1', getPtyId: () => 'pty-1', getSettings: () => null, inspectProcess: vi.fn(), - dispatchCompletion, - isLive: () => true, - shouldSuppressHookCompletion + dispatchCompletion: vi.fn(), + dispatchAttention, + isLive: () => live }) - coordinator.observeHookStatus({ - state: 'working', - prompt: 'implement notifications', - agentType: 'codex' - }) - coordinator.observeHookStatus({ - state: 'waiting', - prompt: 'implement notifications', - agentType: 'codex', - toolName: 'exec_command', - toolInput: 'git status' - }) + const turn = { prompt: 'fix the bug', agentType: 'codex' as const } + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'exec_command' }) + expect(dispatchAttention).toHaveBeenCalledTimes(1) - expect(dispatchCompletion).not.toHaveBeenCalled() - expect(shouldSuppressHookCompletion).toHaveBeenCalled() + live = false + vi.advanceTimersByTime(5_000) - coordinator.observeHookStatus({ - state: 'done', - prompt: 'implement notifications', - agentType: 'codex', - stateStartedAt: 1_700_000_010_000, - lastAssistantMessage: 'Done.' - }) - vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) - - expect(dispatchCompletion).toHaveBeenCalledTimes(1) - expect(dispatchCompletion).toHaveBeenCalledWith( - 'codex', - expect.objectContaining({ - source: 'hook', - quietedHookDone: true, - agentStatus: expect.objectContaining({ - state: 'done', - agentType: 'codex' - }) - }) - ) + expect(dispatchAttention).toHaveBeenCalledTimes(1) }) }) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index 7bf7e90f7f9..4e7e07f5c7c 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -55,7 +55,6 @@ export type AgentCompletionCoordinatorOptions = { // panes without agent evidence relax to a slow cadence and re-arm from // output/title/hook activity. See agent-process-inspection-cost.ts. isProcessInspectionCostly?: () => boolean - shouldSuppressHookCompletion?: (payload: AgentCompletionStatusSnapshot) => boolean } export type AgentCompletionCoordinator = { 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 ca416b2c2ef..960204d7c24 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -8,12 +8,14 @@ import type { import { createAgentCompletionIdentityScope, getAgentCompletionCoordinatorIdentityCountForTest, - resetAgentCompletionCoordinatorIdentitiesForTest, - type LastCompletionIdentity + resetAgentCompletionCoordinatorIdentitiesForTest } from './agent-completion-identity-store' import { createAgentCompletionProcessMonitor } from './agent-completion-process-monitor' import { createPendingTitleController } from './agent-completion-pending-title' -import { createAgentCompletionNotificationController } from './agent-completion-notification-controller' +import { + createAgentCompletionNotificationController, + type CompletionState +} from './agent-completion-notification-controller' import { createAgentCompletionTitleObserver } from './agent-completion-title-observer' import { createAgentCompletionHookObserver } from './agent-completion-hook-observer' import { createAgentCompletionLifecycle } from './agent-completion-lifecycle' @@ -29,20 +31,19 @@ export function createAgentCompletionCoordinator( let agentIdentityEstablished = false let hasAgentRunEvidence = false let lastTitleStatus: AgentStatus | null = null - const completionState = { + const completionState: CompletionState = { currentTurn: 0, workingStatusObserved: false, requiresFreshWorking: false, - lastCompletionToken: null as string | null, + lastCompletionToken: null, lastCompletionAt: 0, - lastCompletedTurn: null as number | null, - lastCompletionSource: null as CompletionSource | null, - lastCompletionIdentity: null as LastCompletionIdentity | null, - lastAttentionToken: null as string | null, - pendingHookDoneTimer: null as ReturnType | null, - pendingHookDoneTitle: null as string | null, - pendingHookDonePayload: null as AgentCompletionStatusSnapshot | null, - pendingCodexAttentionTimer: null as ReturnType | null + lastCompletedTurn: null, + lastCompletionSource: null, + lastCompletionIdentity: null, + lastAttentionToken: null, + pendingHookDoneTimer: null, + pendingHookDoneTitle: null, + pendingHookDonePayload: null } // Why: output/title activity can arrive before async PTY bind; only re-arm cadence after bind starts process tracking. const processState = { @@ -86,10 +87,6 @@ export function createAgentCompletionCoordinator( notification.clearPendingHookDone() } - function clearPendingCodexAttention(): void { - notification.clearPendingCodexAttention() - } - function dispatchCompletion( source: CompletionSource, title: string, @@ -168,7 +165,6 @@ export function createAgentCompletionCoordinator( establishAgentEvidence, clearAgentRunEvidence, hasPendingHookDone: () => completionState.pendingHookDoneTimer !== null, - hasPendingCodexAttention: () => completionState.pendingCodexAttentionTimer !== null, dispatchCompletion }) @@ -242,8 +238,6 @@ export function createAgentCompletionCoordinator( ) { return false } - // Why: cancel debounced attention when a Codex resume surfaces as a working title (else false banner #8387); placed after the replay guard so a stale post-completion replay can't drop it. - clearPendingCodexAttention() completionState.workingStatusObserved = true completionState.requiresFreshWorking = false if (!hasUnconsumedStampedTail()) { @@ -272,7 +266,6 @@ export function createAgentCompletionCoordinator( establishAgentEvidence, recordPaneActivity, clearPendingHookDone, - clearPendingCodexAttention, dispatchAttention, dispatchCompletion: (source, title, override) => dispatchCompletion( @@ -300,7 +293,6 @@ export function createAgentCompletionCoordinator( processState, identityScope, clearPendingHookDone, - clearPendingCodexAttention, dropPendingTitle, clearWorkingBoundary, incrementGeneration: () => processMonitor.incrementGeneration(), diff --git a/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts b/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts index 1a6fff7d1c1..e6e64ade5a8 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts @@ -23,7 +23,6 @@ type HookObserverOptions = { establishAgentEvidence: () => void recordPaneActivity: () => void clearPendingHookDone: () => void - clearPendingCodexAttention: () => void dispatchAttention: (payload: AgentCompletionStatusSnapshot) => void dispatchCompletion: (source: 'hook', title: string, override?: Record) => boolean scheduleHookDoneCompletion: (title: string, payload: AgentCompletionStatusSnapshot) => void @@ -55,7 +54,6 @@ export function createAgentCompletionHookObserver({ establishAgentEvidence, recordPaneActivity, clearPendingHookDone, - clearPendingCodexAttention, dispatchAttention, dispatchCompletion, scheduleHookDoneCompletion, @@ -74,13 +72,6 @@ export function createAgentCompletionHookObserver({ }: HookObserverOptions) { function observeHookStatus(payload: AgentCompletionStatusSnapshot): void { recordPaneActivity() - if (options.shouldSuppressHookCompletion?.(payload)) { - if (isAttentionHookState(payload.state)) { - clearPendingHookDone() - clearPendingCodexAttention() - } - return - } if (isRecognizedAgentType(payload.agentType)) { establishAgentEvidence() } @@ -125,7 +116,6 @@ export function createAgentCompletionHookObserver({ clearOriginStampedTail() recordWorkingBoundary(payload.stateStartedAt) clearPendingHookDone() - clearPendingCodexAttention() state.workingStatusObserved = true state.requiresFreshWorking = false state.lastCompletionIdentity = null @@ -146,7 +136,6 @@ export function createAgentCompletionHookObserver({ if (payload.state !== 'done') { return } - clearPendingCodexAttention() const identity = hookCompletionIdentity(payload) const turnCompletedAt = isFiniteTurnCompletedAt(payload.turnCompletedAt) ? payload.turnCompletedAt diff --git a/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts b/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts index b1a74972311..7a9df259d73 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts @@ -33,7 +33,6 @@ export function handleAgentCompletionInspectionResult(args: { identityScope: AgentCompletionIdentityScope clearAgentRunEvidence: () => void hasPendingHookDone: () => boolean - hasPendingCodexAttention: () => boolean scheduleNextPoll: () => void handleRecognizedProcess: (process: RecognizedAgentProcess) => void dispatchCompletion: CompletionDispatch @@ -47,7 +46,6 @@ export function handleAgentCompletionInspectionResult(args: { identityScope, clearAgentRunEvidence, hasPendingHookDone, - hasPendingCodexAttention, scheduleNextPoll, handleRecognizedProcess, dispatchCompletion, @@ -136,7 +134,7 @@ export function handleAgentCompletionInspectionResult(args: { handleRecognizedProcess(recognized) return true } - if (hasPendingHookDone() || hasPendingCodexAttention()) { + if (hasPendingHookDone()) { scheduleNextPoll() return false } diff --git a/src/renderer/src/components/terminal-pane/agent-completion-lifecycle.ts b/src/renderer/src/components/terminal-pane/agent-completion-lifecycle.ts index 1885d08e49a..c562f37e446 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-lifecycle.ts @@ -17,7 +17,6 @@ type LifecycleOptions = { processState: { disposed: boolean; lastForegroundAgent: unknown; hasAgentRunEvidence: boolean } identityScope: AgentCompletionIdentityScope clearPendingHookDone: () => void - clearPendingCodexAttention: () => void dropPendingTitle: () => void clearWorkingBoundary: () => void incrementGeneration: () => void @@ -32,7 +31,6 @@ export function createAgentCompletionLifecycle({ processState, identityScope, clearPendingHookDone, - clearPendingCodexAttention, dropPendingTitle, clearWorkingBoundary, incrementGeneration, @@ -43,7 +41,6 @@ export function createAgentCompletionLifecycle({ }: LifecycleOptions) { function resetCompletionState(options: { requireFreshWorking?: boolean } = {}): void { clearPendingHookDone() - clearPendingCodexAttention() dropPendingTitle() clearEvidence() clearTitleStatus() @@ -68,7 +65,6 @@ export function createAgentCompletionLifecycle({ processState.disposed = true clearPollTimer() clearPendingHookDone() - clearPendingCodexAttention() dropPendingTitle() clearWorkingBoundary() identityScope.dispose(isLive()) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-notification-controller.ts b/src/renderer/src/components/terminal-pane/agent-completion-notification-controller.ts index 5b3e505af41..0130a990e64 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-notification-controller.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-notification-controller.ts @@ -13,9 +13,8 @@ type CompletionSource = 'hook' | 'title' | 'process-exit' const COMPLETION_REPLAY_GUARD_MS = 1_000 const HOOK_DONE_QUIET_MS = 1_500 -const CODEX_ATTENTION_QUIET_MS = 1_500 -type CompletionState = { +export type CompletionState = { currentTurn: number workingStatusObserved: boolean requiresFreshWorking: boolean @@ -28,7 +27,6 @@ type CompletionState = { pendingHookDoneTimer: ReturnType | null pendingHookDoneTitle: string | null pendingHookDonePayload: AgentCompletionStatusSnapshot | null - pendingCodexAttentionTimer: ReturnType | null } type ProcessState = { @@ -180,8 +178,6 @@ export function createAgentCompletionNotificationController({ state.lastCompletedTurn = state.currentTurn state.lastCompletionSource = source state.workingStatusObserved = false - // Why: any committed completion ends the turn, so a debounced Codex attention from an earlier pause must not fire after it. - clearPendingCodexAttention() if (optionsOverride.completionIdentity) { identityScope.setLast(optionsOverride.completionIdentity) if (optionsOverride.completionIdentity.lastTurnCompletedAtNotified !== undefined) { @@ -220,13 +216,6 @@ export function createAgentCompletionNotificationController({ return true } - function dispatchAttentionNotification(payload: AgentCompletionStatusSnapshot): void { - options.dispatchAttention?.(payload.agentType ?? options.paneKey, { - source: 'hook', - agentStatus: payload - }) - } - function dispatchAttention(payload: AgentCompletionStatusSnapshot): void { if (!options.dispatchAttention || !options.isLive() || !processState.hasAgentRunEvidence) { return @@ -236,21 +225,12 @@ export function createAgentCompletionNotificationController({ return } state.lastAttentionToken = token - // Why: the visual "needs input" status updates immediately; only the OS attention notification is debounced (Codex, below). + // Why: the visual "needs input" row is driven by the lifecycle hook, the OS banner by the dispatch below. options.dispatchHookLifecycle?.(payload) - if (payload.agentType === 'codex') { - // Why: an auto-resolved Codex "Approve for me" cancels this pending notification via a later hook; scoped to Codex so other agents notify at once. - clearPendingCodexAttention() - state.pendingCodexAttentionTimer = setTimeout(() => { - state.pendingCodexAttentionTimer = null - if (!options.isLive() || !processState.hasAgentRunEvidence) { - return - } - dispatchAttentionNotification(payload) - }, CODEX_ATTENTION_QUIET_MS) - return - } - dispatchAttentionNotification(payload) + options.dispatchAttention(payload.agentType ?? options.paneKey, { + source: 'hook', + agentStatus: payload + }) } function scheduleHookDoneCompletion(title: string, payload: AgentCompletionStatusSnapshot): void { @@ -294,24 +274,15 @@ export function createAgentCompletionNotificationController({ state.pendingHookDonePayload = null } - function clearPendingCodexAttention(): void { - if (state.pendingCodexAttentionTimer !== null) { - clearTimeout(state.pendingCodexAttentionTimer) - state.pendingCodexAttentionTimer = null - } - } - return { completionIdentityFor, hookCompletionIdentity, hookCompletionAgentIdentity, doneShouldUseQuietWindow, clearPendingHookDone, - clearPendingCodexAttention, dispatchCompletion, dispatchAttention, scheduleHookDoneCompletion, - hasPendingHookDone: () => state.pendingHookDoneTimer !== null, - hasPendingCodexAttention: () => state.pendingCodexAttentionTimer !== null + hasPendingHookDone: () => state.pendingHookDoneTimer !== null } } diff --git a/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts b/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts index ca2456aedbb..5971e1a87c6 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts @@ -18,7 +18,6 @@ export function createAgentCompletionProcessMonitor({ establishAgentEvidence, clearAgentRunEvidence, hasPendingHookDone, - hasPendingCodexAttention, dispatchCompletion }: ProcessMonitorOptions) { const remoteInspection: RemoteInspectionState = { @@ -139,7 +138,6 @@ export function createAgentCompletionProcessMonitor({ identityScope, clearAgentRunEvidence, hasPendingHookDone, - hasPendingCodexAttention, scheduleNextPoll, handleRecognizedProcess, dispatchCompletion, diff --git a/src/renderer/src/components/terminal-pane/agent-completion-process-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-process-types.ts index 9227c7f917e..ac2cae22128 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-process-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-process-types.ts @@ -35,6 +35,5 @@ export type ProcessMonitorOptions = { establishAgentEvidence: () => void clearAgentRunEvidence: () => void hasPendingHookDone: () => boolean - hasPendingCodexAttention: () => boolean dispatchCompletion: CompletionDispatch } diff --git a/src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts index 82714edce6e..7772306421a 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts @@ -99,7 +99,6 @@ function inspect(result: RuntimeTerminalProcessInspection, roundTripMs = 20): Pr identityScope: {} as never, clearAgentRunEvidence: vi.fn(), hasPendingHookDone: () => false, - hasPendingCodexAttention: () => false, scheduleNextPoll: vi.fn(), handleRecognizedProcess: vi.fn(), dispatchCompletion: vi.fn(), diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts deleted file mode 100644 index 28a9cca5bf7..00000000000 --- a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' -import { createTestStore, makeTab } from '../../store/slices/store-test-helpers' -import type { AppState } from '../../store/types' -import { - createCodexAutoApprovalHookCompletionSuppressor, - shouldSuppressCodexAutoApprovalSyntheticTitle, - shouldSuppressCodexAutoApprovalStatus -} from './codex-auto-approval-notification-suppression' - -let testStore: ReturnType - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => testStore.getState() - } -})) - -const paneKey = 'tab-1:leaf-1' -const launchToken = 'launch-token-1' -const providerSession = { key: 'session_id' as const, id: 'codex-session-1' } - -function seedTab(): void { - testStore.setState({ - tabsByWorktree: { - 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] - } - } as Partial) -} - -function registerCodexLaunchConfig(args: { - agentArgs: string - launchToken?: string - providerSession?: typeof providerSession -}): void { - testStore.getState().registerAgentLaunchConfig( - paneKey, - { - agentArgs: args.agentArgs, - agentEnv: {} - }, - { - agentType: 'codex', - tabId: 'tab-1', - leafId: 'leaf-1', - ...(args.launchToken ? { launchToken: args.launchToken } : {}), - ...(args.providerSession ? { providerSession: args.providerSession } : {}) - } - ) -} - -describe('Codex auto-approval status suppression', () => { - beforeEach(() => { - testStore = createTestStore() - seedTab() - }) - - it('suppresses the first auto-approved Codex waiting status with matching launch token', () => { - registerCodexLaunchConfig({ - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - launchToken - }) - - expect( - shouldSuppressCodexAutoApprovalStatus( - { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, - { paneKey, tabId: 'tab-1', launchToken } - ) - ).toBe(true) - }) - - it('suppresses auto-approved Codex blocked statuses', () => { - registerCodexLaunchConfig({ - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - launchToken - }) - - expect( - shouldSuppressCodexAutoApprovalStatus( - { state: 'blocked', prompt: 'implement notifications', agentType: 'codex' }, - { paneKey, tabId: 'tab-1', launchToken } - ) - ).toBe(true) - }) - - it('preserves request_user_input question waits even under yolo attribution', () => { - registerCodexLaunchConfig({ - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - launchToken - }) - - expect( - shouldSuppressCodexAutoApprovalStatus( - { - state: 'waiting', - prompt: 'pick a color', - agentType: 'codex', - toolName: 'request_user_input' - }, - { paneKey, tabId: 'tab-1', launchToken } - ) - ).toBe(false) - }) - - it('preserves manual Codex permission attention', () => { - registerCodexLaunchConfig({ agentArgs: '', launchToken }) - - expect( - shouldSuppressCodexAutoApprovalStatus( - { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, - { paneKey, tabId: 'tab-1', launchToken } - ) - ).toBe(false) - }) - - it('preserves mixed Codex permission attention', () => { - registerCodexLaunchConfig({ agentArgs: '--ask-for-approval on-request', launchToken }) - - expect( - shouldSuppressCodexAutoApprovalStatus( - { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, - { paneKey, tabId: 'tab-1', launchToken } - ) - ).toBe(false) - }) - - it('preserves missing-attribution Codex permission attention', () => { - expect( - shouldSuppressCodexAutoApprovalStatus( - { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, - { paneKey, tabId: 'tab-1', launchToken } - ) - ).toBe(false) - }) - - it('fails open when a stale yolo launch token does not match', () => { - registerCodexLaunchConfig({ - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - launchToken - }) - - expect( - shouldSuppressCodexAutoApprovalStatus( - { state: 'waiting', prompt: 'manual prompt', agentType: 'codex' }, - { paneKey, tabId: 'tab-1', launchToken: 'manual-launch' } - ) - ).toBe(false) - }) - - it('fails open when a stale launch token conflicts with matching provider session', () => { - registerCodexLaunchConfig({ - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - launchToken, - providerSession - }) - - expect( - shouldSuppressCodexAutoApprovalStatus( - { state: 'waiting', prompt: 'manual prompt', agentType: 'codex' }, - { - paneKey, - tabId: 'tab-1', - launchToken: 'manual-launch', - providerSession - } - ) - ).toBe(false) - }) - - it('fails open when launch token is missing from a token-registered launch', () => { - registerCodexLaunchConfig({ - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - launchToken, - providerSession - }) - - expect( - shouldSuppressCodexAutoApprovalStatus( - { state: 'waiting', prompt: 'manual prompt', agentType: 'codex' }, - { - paneKey, - tabId: 'tab-1', - providerSession - } - ) - ).toBe(false) - }) - - it('matches provider session attribution when launch token is absent', () => { - registerCodexLaunchConfig({ - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - providerSession - }) - - expect( - shouldSuppressCodexAutoApprovalStatus( - { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, - { paneKey, tabId: 'tab-1', providerSession } - ) - ).toBe(true) - }) - - it('does not suppress non-Codex or done statuses', () => { - registerCodexLaunchConfig({ - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - launchToken - }) - - expect( - shouldSuppressCodexAutoApprovalStatus( - { state: 'waiting', prompt: 'implement notifications', agentType: 'claude' }, - { paneKey, tabId: 'tab-1', launchToken } - ) - ).toBe(false) - expect( - shouldSuppressCodexAutoApprovalStatus( - { state: 'done', prompt: 'implement notifications', agentType: 'codex' }, - { paneKey, tabId: 'tab-1', launchToken } - ) - ).toBe(false) - }) - - it('uses the same predicate for hook-completion fallback suppression', () => { - registerCodexLaunchConfig({ - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - launchToken - }) - const suppressor = createCodexAutoApprovalHookCompletionSuppressor(paneKey, () => ({ - tabId: 'tab-1', - launchToken - })) - - expect( - suppressor({ state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }) - ).toBe(true) - }) - - it('suppresses synthetic Codex permission titles only when launch attribution is yolo', () => { - registerCodexLaunchConfig({ - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - launchToken - }) - - expect( - shouldSuppressCodexAutoApprovalSyntheticTitle('Codex - action required', { - paneKey, - tabId: 'tab-1', - launchToken - }) - ).toBe(true) - expect( - shouldSuppressCodexAutoApprovalSyntheticTitle('Codex ready', { - paneKey, - tabId: 'tab-1', - launchToken - }) - ).toBe(false) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts deleted file mode 100644 index ffd2ee6ee18..00000000000 --- a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { isAskUserQuestionTool } from '../../../../shared/agent-question-answered-intent' -import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' -import { getSyntheticAgentTitleProfile } from '../../../../shared/synthetic-agent-title' -import { resolveTuiAgentPermissionMode } from '../../../../shared/tui-agent-permissions' -import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types' -import { useAppStore } from '@/store' - -const CODEX_AUTO_APPROVED_PERMISSION_STATES = ['waiting', 'blocked'] as const - -export type CodexAutoApprovalStatusContext = { - paneKey: string - tabId?: string - terminalHandle?: string - launchToken?: string - providerSession?: AgentProviderSessionMetadata - existingProviderSession?: AgentProviderSessionMetadata -} - -function isCodexAutoApprovedPermissionState( - state: AgentCompletionStatusSnapshot['state'] -): state is (typeof CODEX_AUTO_APPROVED_PERMISSION_STATES)[number] { - return CODEX_AUTO_APPROVED_PERMISSION_STATES.some((permissionState) => permissionState === state) -} - -export function shouldSuppressCodexAutoApprovalStatus( - payload: AgentCompletionStatusSnapshot, - context: CodexAutoApprovalStatusContext -): boolean { - if (payload.agentType !== 'codex' || !isCodexAutoApprovedPermissionState(payload.state)) { - return false - } - // Why: request_user_input waits are real questions the user must answer — yolo auto-approval never resolves them, so they must keep driving status. - if (isAskUserQuestionTool(payload.toolName)) { - return false - } - - const state = useAppStore.getState() - if (typeof state.getAgentLaunchConfigForStatusMetadata !== 'function') { - return false - } - - const launchConfig = state.getAgentLaunchConfigForStatusMetadata({ - paneKey: context.paneKey, - agentType: 'codex', - tabId: context.tabId, - terminalHandle: context.terminalHandle, - launchToken: context.launchToken, - providerSession: context.providerSession, - existingProviderSession: context.existingProviderSession - }) - if (!launchConfig) { - return false - } - - return ( - resolveTuiAgentPermissionMode({ - agent: 'codex', - agentArgs: launchConfig.agentArgs, - agentEnv: launchConfig.agentEnv - }) === 'yolo' - ) -} - -export function shouldSuppressCodexAutoApprovalSyntheticTitle( - title: string, - context: CodexAutoApprovalStatusContext -): boolean { - if (title !== getSyntheticAgentTitleProfile('codex')?.permissionLabel) { - return false - } - - return shouldSuppressCodexAutoApprovalStatus( - { state: 'waiting', prompt: '', agentType: 'codex' }, - context - ) -} - -export function createCodexAutoApprovalHookCompletionSuppressor( - paneKey: string, - getContext?: () => Omit -): (payload: AgentCompletionStatusSnapshot) => boolean { - return (payload) => - shouldSuppressCodexAutoApprovalStatus(payload, { - paneKey, - ...getContext?.() - }) -} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts index 410c252adc8..4767de83382 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts @@ -2,7 +2,6 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' import { makePaneKey } from '../../../../shared/stable-pane-id' -import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' import { flushAsyncTicks } from './pty-connection-test-async' import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './pty-connection-test-constants' import { @@ -281,135 +280,7 @@ describe('connectPanePty', () => { ) }) - it('suppresses PTY-owned Codex auto-approved permission statuses before status or notification work', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-hook') - transportFactoryQueue.push(transport) - enableActiveRuntimeEnvironment() - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - agentEnv: {} - } - } - - const launchConfig = { - agentCommand: 'codex', - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - agentEnv: {} - } - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - startup: { - command: 'codex', - launchConfig, - launchToken: 'launch-yolo', - launchAgent: 'codex' - } - }) - - connectPanePty(pane as never, manager as never, deps as never) - - const statusHandler = createdTransportOptions[0]?.onAgentStatus as - | ((payload: { - state: 'waiting' - prompt: string - agentType: 'codex' - toolName: string - toolInput: string - }) => void) - | undefined - if (!statusHandler) { - throw new Error('Expected onAgentStatus to be registered') - } - - statusHandler({ - state: 'waiting', - prompt: 'auto-approved permission', - agentType: 'codex', - toolName: 'exec_command', - toolInput: 'git status' - }) - - expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() - expect(deps.dispatchNotification).not.toHaveBeenCalled() - }) - - it('suppresses synthetic Codex auto-approved permission titles before title work', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-hook') - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - agentEnv: {} - } - } - - const pane = createPane(1) - const manager = createManager(1) - manager.getActivePane.mockReturnValue({ id: 1 }) - const deps = createDeps({ - startup: { - command: 'codex', - launchConfig: { - agentCommand: 'codex', - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - agentEnv: {} - }, - launchToken: 'launch-yolo', - launchAgent: 'codex' - } - }) - - connectPanePty(pane as never, manager as never, deps as never) - - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - mockStoreState.getAgentLaunchConfigForStatusMetadata.mockClear() - - titleHandler('Codex - action required', 'Codex - action required') - - expect(mockStoreState.getAgentLaunchConfigForStatusMetadata).toHaveBeenCalledTimes(1) - expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() - expect(deps.updateTabTitle).not.toHaveBeenCalled() - expect(manager.setPaneGpuRendering).not.toHaveBeenCalled() - }) - - it('does not resolve launch config for ordinary title changes', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-hook') - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() - - connectPanePty(pane as never, manager as never, deps as never) - - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - if (!titleHandler) { - throw new Error('Expected onTitleChange to be registered') - } - mockStoreState.getAgentLaunchConfigForStatusMetadata.mockClear() - - for (let index = 0; index < 100; index += 1) { - titleHandler(`build output ${index}`, `build output ${index}`) - } - - expect(mockStoreState.getAgentLaunchConfigForStatusMetadata).not.toHaveBeenCalled() - }) - - it('preserves synthetic Codex manual permission titles', async () => { + it('preserves synthetic Codex permission titles', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-hook') transportFactoryQueue.push(transport) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts index 5f229819c70..67fd09bcc59 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts @@ -15,7 +15,6 @@ import { isLocalNativeWindowsConpty, resolveWindowsShellOverride } from '@/lib/pane-manager/windows-pty-compatibility' -import { shouldSuppressCodexAutoApprovalStatus } from '../codex-auto-approval-notification-suppression' import { createCommandCodeOutputStatusDetector } from '../../../../../shared/command-code-output-status' import { readInFlightCommandCodeTurn } from '../parked-terminal-command-status' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -153,15 +152,6 @@ export function installDirectSshRetryStatus(session: ConnectPanePtySession): voi ? registerRendererOwnedAgentStatusPane(session.cacheKey, session.runtimeEnvironmentId) : null session.handleRendererOwnedAgentStatus = (payload): void => { - if ( - shouldSuppressCodexAutoApprovalStatus(payload, { - paneKey: session.cacheKey, - tabId: session.deps.tabId, - ...(session.launchToken ? { launchToken: session.launchToken } : {}) - }) - ) { - return - } const currentState = useAppStore.getState() const routing = session.resolveCurrentAgentStatusRouting() if (!routing) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts index 7fcf51e18ac..f454b247e4f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts @@ -6,7 +6,6 @@ import { isFreshNonDoneAgentStatus } from '../../../../../shared/agent-status-ty import { isCtrlCKeyEvent, isPlainEscapeKeyEvent } from '../agent-interrupt-inference' import { createAgentCompletionCoordinator } from '../agent-completion-coordinator' import { dispatchAgentHookTerminalLifecycle } from '../agent-hook-terminal-lifecycle' -import { createCodexAutoApprovalHookCompletionSuppressor } from '../codex-auto-approval-notification-suppression' import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner' import { registerTerminalSideEffectFactConsumer } from '../terminal-side-effect-facts-handler' @@ -252,13 +251,6 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void return true } return (useAppStore.getState().ptyIdsByTabId[session.deps.tabId] ?? []).length > 0 - }, - shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor( - session.cacheKey, - () => ({ - tabId: session.deps.tabId, - ...(session.launchToken ? { launchToken: session.launchToken } : {}) - }) - ) + } }) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts index d035a2366a9..7a0700d1c32 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts @@ -1,7 +1,6 @@ import { resolvePaneTitleDecision } from '../terminal-title-evidence' import { useAppStore } from '@/store' import { shouldSeedCacheTimerOnInitialTitle } from '../cache-timer-seeding' -import { shouldSuppressCodexAutoApprovalSyntheticTitle } from '../codex-auto-approval-notification-suppression' import { cancelCommandCodeDoneSettle, openCommandCodeDoneSettle, @@ -32,15 +31,6 @@ export function installTitleSpawnBell(session: ConnectPanePtySession): void { userGpuMode: useAppStore.getState().settings?.terminalGpuAcceleration ?? 'auto' }) const paneTitle = decision.displayTitle - if ( - shouldSuppressCodexAutoApprovalSyntheticTitle(paneTitle, { - paneKey: session.cacheKey, - tabId: session.deps.tabId, - ...(session.launchToken ? { launchToken: session.launchToken } : {}) - }) - ) { - return - } session.manager.setPaneGpuRendering(session.pane.id, decision.rendererPolicy.gpuEnabled) session.deps.setRuntimePaneTitle(session.deps.tabId, session.pane.id, paneTitle) // Why: a stale-derived cleared title comes from main's unthrottled 3s 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 2b39a07a4fb..c60a95ea43f 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' -import { YOLO_TUI_AGENT_ARGS } from '../../../shared/tui-agent-permissions' import { createHookListenerState } from '../../../shared/agent-hook-listener/listener-state' import { normalizeHookPayload } from '../../../shared/agent-hook-listener' @@ -57,9 +56,6 @@ type MockStoreState = { let mockStoreState: MockStoreState const HOOK_DONE_QUIET_MS = 1_500 -// Why: Codex attention notifications are debounced (issue #8387), so a genuine -// permission pause only notifies once this quiet window elapses without resuming. -const CODEX_ATTENTION_QUIET_MS = 1_500 vi.mock('@/store', () => ({ useAppStore: { @@ -84,14 +80,10 @@ function hookStatus(state: ParsedAgentStatusPayload['state']): ParsedAgentStatus } } -function seedCodexPaneLaunchConfig( - paneKey: string, - agentArgs: string, - launchToken = 'launch-token-1' -): void { +function seedCodexPane(paneKey: string, launchToken = 'launch-token-1'): void { mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { launchConfig: { - agentArgs, + agentArgs: '', agentEnv: {} }, launchToken @@ -110,8 +102,7 @@ function seedCodexPaneLaunchConfig( describe('agent hook completion notifications', () => { const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' - // Why: the Codex permission-pause tests share a working→pause→quiet-window - // sequence; centralizing it keeps the debounce advance (issue #8387) in one spot. + // Why: the Codex permission-pause tests share a working→pause sequence. async function observeCodexPermissionPause(state: 'waiting' | 'blocked'): Promise { const { observeAgentHookCompletionForNotification } = await import('./agent-hook-completion-notifications') @@ -131,7 +122,6 @@ describe('agent hook completion notifications', () => { toolInput: 'git status' } }) - vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) } beforeEach(() => { @@ -622,15 +612,8 @@ describe('agent hook completion notifications', () => { ) }) - it('fails open for Codex auto-approved permission requests without launch proof', async () => { - seedCodexPaneLaunchConfig(paneKey, YOLO_TUI_AGENT_ARGS.codex ?? '') - await observeCodexPermissionPause('waiting') - - expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) - }) - - it('still notifies for manual Codex permission requests', async () => { - seedCodexPaneLaunchConfig(paneKey, '') + it('notifies for a Codex permission request', async () => { + seedCodexPane(paneKey) await observeCodexPermissionPause('waiting') expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) @@ -644,8 +627,8 @@ describe('agent hook completion notifications', () => { ) }) - it('fails open for Codex auto-approved blocked permission requests without launch proof', async () => { - seedCodexPaneLaunchConfig(paneKey, YOLO_TUI_AGENT_ARGS.codex ?? '') + it('notifies for a blocked Codex permission request', async () => { + seedCodexPane(paneKey) await observeCodexPermissionPause('blocked') expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index dbf8ca45f6d..8b43478818c 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -8,7 +8,6 @@ import type { import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' import { dispatchTerminalNotification } from '@/components/terminal-pane/use-notification-dispatch' import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' -import { createCodexAutoApprovalHookCompletionSuppressor } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' import { dispatchAgentHookTerminalLifecycle } from '@/components/terminal-pane/agent-hook-terminal-lifecycle' import { isAgentHookCompletionTrackingEnabled, @@ -267,8 +266,7 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion agentStatusSnapshot: meta.agentStatus }) }, - isLive: () => paneCanReceiveHookCompletion(paneKey), - shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(paneKey) + isLive: () => paneCanReceiveHookCompletion(paneKey) }) } diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index 7a9117e3360..47aa5521a26 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -6,7 +6,6 @@ import { } from '../../../../shared/agent-status-identity' import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' import { parsePaneKey } from '../../../../shared/stable-pane-id' -import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { track } from '@/lib/telemetry' import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' @@ -217,18 +216,6 @@ export function createAgentStatusEventApplicator(args: { ) { return 'dropped' } - if ( - shouldSuppressCodexAutoApprovalStatus(statusPayload, { - paneKey, - tabId: ownerTabId, - terminalHandle: data.terminalHandle, - launchToken: data.launchToken, - providerSession: data.providerSession, - existingProviderSession: existingStatus?.providerSession - }) - ) { - return 'dropped' - } const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title) const statusWorktreeId = data.worktreeId ?? owningWorktreeId const update: AgentStatusUpdate = { diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts index 059d051def9..f54ac9ade4b 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts @@ -1,7 +1,6 @@ import type * as ReactModule from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types' -import { YOLO_TUI_AGENT_ARGS } from '../../../shared/tui-agent-permissions' import { buildStoreState, expectWorktreeRouting, @@ -94,21 +93,15 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(observeAgentHookCompletionForNotification).not.toHaveBeenCalled() }) - it('keeps auto-approved Codex done statuses on the completion path', async () => { + it('keeps Codex done statuses on the completion path', async () => { const setAgentStatus = vi.fn() const observeAgentHookCompletionForNotification = vi.fn() - const getAgentLaunchConfigForStatusMetadata = vi.fn((metadata: { launchToken?: string }) => - metadata.launchToken === 'launch-yolo' - ? { agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', agentEnv: {} } - : undefined - ) const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { current: null } const storeState: StoreLike = buildStoreState({ setAgentStatus, - getAgentLaunchConfigForStatusMetadata, workspaceSessionReady: true, settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } }, tabsByWorktree: { @@ -154,9 +147,8 @@ describe('useIpcEvents agent status snapshot integration', () => { tabId: 'tab-future', worktreeId: 'wt-1', state: 'done', - prompt: 'auto-approved task', + prompt: 'codex task', agentType: 'codex', - launchToken: 'launch-yolo', lastAssistantMessage: 'Done.', receivedAt: 1_700_000_000_500, stateStartedAt: 1_699_999_999_500 diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts index eb613f21e70..536a8d88fc2 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusUpdate } from '../store/slices/agent-status' -import { YOLO_TUI_AGENT_ARGS } from '../../../shared/tui-agent-permissions' import { buildStoreState, expectWorktreeRouting, @@ -420,90 +419,7 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) - it('suppresses auto-approved Codex permission attention before status and title mutation', async () => { - const setAgentStatus = vi.fn() - const updateTabTitle = vi.fn() - const observeAgentHookCompletionForNotification = vi.fn() - const getAgentLaunchConfigForStatusMetadata = vi.fn((metadata: { launchToken?: string }) => - metadata.launchToken === 'launch-yolo' - ? { agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', agentEnv: {} } - : undefined - ) - const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { - current: null - } - - const storeState: StoreLike = buildStoreState({ - setAgentStatus, - updateTabTitle, - getAgentLaunchConfigForStatusMetadata, - workspaceSessionReady: true, - settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } }, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Codex' }] - }, - terminalLayoutsByTabId: { - 'tab-future': { - root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, - activeLeafId: FUTURE_LEAF_ID, - expandedLeafId: null - } - } - }) - - stubReactSyncEffect() - vi.doMock('../store', () => ({ - useAppStore: { - subscribe: vi.fn(() => () => {}), - getState: () => storeState - } - })) - vi.doMock('./agent-hook-completion-notifications', () => ({ - observeAgentHookCompletionForNotification, - resetAgentHookCompletionNotificationCoordinators: vi.fn(), - syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() - })) - stubAuxiliaryModules() - vi.stubGlobal( - 'window', - buildWindowApi({ - onSet: (cb) => { - onSetListenerRef.current = cb - return () => {} - } - }) - ) - - const { useIpcEvents } = await import('./useIpcEvents') - - useIpcEvents() - await Promise.resolve() - - if (typeof onSetListenerRef.current !== 'function') { - throw new Error('Expected agentStatus.onSet listener to be registered') - } - - onSetListenerRef.current({ - paneKey: FUTURE_PANE_KEY, - tabId: 'tab-future', - worktreeId: 'wt-1', - state: 'waiting', - prompt: 'auto-approved permission', - agentType: 'codex', - launchToken: 'launch-yolo', - receivedAt: 1_700_000_000_300, - stateStartedAt: 1_699_999_999_300 - }) - - expect(getAgentLaunchConfigForStatusMetadata).toHaveBeenCalledWith( - expect.objectContaining({ paneKey: FUTURE_PANE_KEY, launchToken: 'launch-yolo' }) - ) - expect(setAgentStatus).not.toHaveBeenCalled() - expect(updateTabTitle).not.toHaveBeenCalled() - expect(observeAgentHookCompletionForNotification).not.toHaveBeenCalled() - }) - - it('keeps manual or missing-attribution Codex permission attention actionable', async () => { + it('keeps a Codex permission attention row actionable', async () => { const setAgentStatus = vi.fn() const updateTabTitle = vi.fn() const observeAgentHookCompletionForNotification = vi.fn()