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.
This commit is contained in:
Brennan Benson
2026-09-20 17:41:50 -04:00
parent 68b11282a5
commit c6571453bc
24 changed files with 80 additions and 1019 deletions
+2 -19
View File
@@ -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)
}
}
@@ -30,7 +30,6 @@ vi.mock('../window/dashboard-popout-window', () => ({
}))
vi.mock('./synthetic-title-runtime', () => ({
driveSyntheticTitleFromHook: vi.fn(),
shouldSuppressCodexAutoApprovalSyntheticTitleFromHook: () => false,
stopAllSyntheticTitleSpinners: vi.fn()
}))
@@ -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<string, string> | 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))
@@ -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)
})
})
@@ -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 = {
@@ -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<typeof setTimeout> | null,
pendingHookDoneTitle: null as string | null,
pendingHookDonePayload: null as AgentCompletionStatusSnapshot | null,
pendingCodexAttentionTimer: null as ReturnType<typeof setTimeout> | 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(),
@@ -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<string, unknown>) => 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
@@ -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
}
@@ -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())
@@ -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<typeof setTimeout> | null
pendingHookDoneTitle: string | null
pendingHookDonePayload: AgentCompletionStatusSnapshot | null
pendingCodexAttentionTimer: ReturnType<typeof setTimeout> | 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
}
}
@@ -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,
@@ -35,6 +35,5 @@ export type ProcessMonitorOptions = {
establishAgentEvidence: () => void
clearAgentRunEvidence: () => void
hasPendingHookDone: () => boolean
hasPendingCodexAttention: () => boolean
dispatchCompletion: CompletionDispatch
}
@@ -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(),
@@ -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<typeof createTestStore>
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<AppState>)
}
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)
})
})
@@ -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<CodexAutoApprovalStatusContext, 'paneKey'>
): (payload: AgentCompletionStatusSnapshot) => boolean {
return (payload) =>
shouldSuppressCodexAutoApprovalStatus(payload, {
paneKey,
...getContext?.()
})
}
@@ -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)
@@ -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) {
@@ -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 } : {})
})
)
}
})
}
@@ -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
@@ -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<void> {
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)
@@ -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)
})
}
@@ -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 = {
@@ -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
@@ -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()