From c6571453bcbf9ad506fda4f500bc188a2762467e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:41:50 -0400 Subject: [PATCH] refactor(agent-status): drop two superseded Codex attention workarounds Codex fires its PermissionRequest hook as decider #1, before its own auto-reviewer and before the user, so the event never meant "a human is blocked". #21389 fixed that at the source: the execution host reads the turn's approvals_reviewer from the rollout at write time and keeps a reviewer-owned approval in `working`. Two older reader-side workarounds for the same bug are now redundant. The launch-argument suppressor guessed auto-approve mode by string-matching the launch args, then dropped the status row in the reader. It only matched Codex's bypass flag, and under that flag Codex's approval policy is `Never`, which takes the Skip path and fires no PermissionRequest at all. When the user turns on "Approve for me" inside a live session the args never change, so it never fired for the actually-reported case either. The Codex-only 1.5s notification quiet window could not do its job: measured auto-reviews take 3-20s and a human can answer in under a second, so no fixed constant separates them. Its deferred callback also re-checked liveness and returned without notifying, so a genuine prompt whose pane went non-live inside the window was dropped rather than delayed. Codex now notifies synchronously like every other agent. Also types the coordinator's completion state from the controller's exported CompletionState instead of asserting each field, which the changed-lines casting gate required once those lines moved. --- src/main/startup/main-window-agent-status.ts | 21 +- ...in-window-structured-status-filter.test.ts | 1 - src/main/startup/synthetic-title-runtime.ts | 24 -- ...ion-coordinator-attention-dispatch.test.ts | 297 +++--------------- .../agent-completion-coordinator-types.ts | 1 - .../agent-completion-coordinator.ts | 36 +-- .../agent-completion-hook-observer.ts | 11 - .../agent-completion-inspection-result.ts | 4 +- .../agent-completion-lifecycle.ts | 4 - ...gent-completion-notification-controller.ts | 43 +-- .../agent-completion-process-monitor.ts | 2 - .../agent-completion-process-types.ts | 1 - ...-completion-stale-evidence-backoff.test.ts | 1 - ...-approval-notification-suppression.test.ts | 259 --------------- ...-auto-approval-notification-suppression.ts | 87 ----- ...-connection-task-complete-dispatch.test.ts | 131 +------- .../pty-connection/direct-ssh-retry-status.ts | 10 - .../pty-connection/terminal-keydown-fit.ts | 10 +- .../pty-connection/title-spawn-bell.ts | 10 - ...gent-hook-completion-notifications.test.ts | 31 +- .../agent-hook-completion-notifications.ts | 4 +- .../agent-status-event-applicator.ts | 13 - ...cEvents-agent-status-pane-teardown.test.ts | 12 +- ...Events-agent-status-queue-ordering.test.ts | 86 +---- 24 files changed, 80 insertions(+), 1019 deletions(-) delete mode 100644 src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts delete mode 100644 src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts 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()