Suppress Codex auto-approval notifications

This commit is contained in:
brennanb2025
2026-06-23 21:05:18 -07:00
parent e03a1cf769
commit 8c40d5bc91
9 changed files with 308 additions and 4 deletions
@@ -23,6 +23,7 @@ export type AgentCompletionCoordinatorOptions = {
dispatchCompletion: (title: string, meta?: AgentCompletionDispatchMeta) => void
isLive: () => boolean
shouldPollProcessCadence?: () => boolean
shouldSuppressHookCompletion?: (payload: AgentCompletionStatusSnapshot) => boolean
}
export type AgentCompletionCoordinator = {
@@ -1113,4 +1113,58 @@ describe('agent completion coordinator', () => {
expect(dispatchCompletion).toHaveBeenCalledWith('experimental-agent-observability')
})
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'
)
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true,
shouldSuppressHookCompletion
})
coordinator.observeHookStatus({
state: 'working',
prompt: 'implement notifications',
agentType: 'codex'
})
coordinator.observeHookStatus({
state: 'waiting',
prompt: 'implement notifications',
agentType: 'codex',
toolName: 'exec_command',
toolInput: 'git status'
})
expect(dispatchCompletion).not.toHaveBeenCalled()
expect(shouldSuppressHookCompletion).toHaveBeenCalled()
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'
})
})
)
})
})
@@ -610,6 +610,9 @@ export function createAgentCompletionCoordinator(
}
function observeHookStatus(payload: AgentCompletionStatusSnapshot): void {
if (options.shouldSuppressHookCompletion?.(payload)) {
return
}
if (isRecognizedAgentType(payload.agentType)) {
establishAgentEvidence()
}
@@ -0,0 +1,74 @@
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'
let testStore: ReturnType<typeof createTestStore>
vi.mock('@/store', () => ({
useAppStore: {
getState: () => testStore.getState()
}
}))
import { isAutoApprovedCodexPermissionStatus } from './codex-auto-approval-notification-suppression'
describe('Codex auto-approval notification suppression', () => {
beforeEach(() => {
testStore = createTestStore()
})
it('uses the production launch-config resolver for launch-token registered Codex panes', () => {
const paneKey = 'tab-1:leaf-1'
const launchToken = 'launch-token-1'
const providerSession = { key: 'session_id' as const, id: 'codex-session-1' }
testStore.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
testStore.getState().registerAgentLaunchConfig(
paneKey,
{
agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '',
agentEnv: {}
},
{ agentType: 'codex', launchToken, tabId: 'tab-1', leafId: 'leaf-1' }
)
testStore
.getState()
.setAgentStatus(
paneKey,
{ state: 'waiting', prompt: 'implement notifications', agentType: 'codex' },
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession, launchToken }
)
expect(
isAutoApprovedCodexPermissionStatus(
{ state: 'waiting', prompt: 'implement notifications', agentType: 'codex' },
paneKey
)
).toBe(true)
})
it('preserves notifications when the live status has no resolvable launch config', () => {
const paneKey = 'tab-1:leaf-1'
testStore.getState().setAgentStatus(paneKey, {
state: 'waiting',
prompt: 'implement notifications',
agentType: 'codex'
})
expect(
isAutoApprovedCodexPermissionStatus(
{ state: 'waiting', prompt: 'implement notifications', agentType: 'codex' },
paneKey
)
).toBe(false)
})
})
@@ -0,0 +1,40 @@
import { YOLO_TUI_AGENT_ARGS } 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
const CODEX_AUTO_APPROVED_ARGS = (YOLO_TUI_AGENT_ARGS.codex ?? '').trim()
function isCodexAutoApprovedPermissionState(
state: AgentCompletionStatusSnapshot['state']
): state is (typeof CODEX_AUTO_APPROVED_PERMISSION_STATES)[number] {
return state === 'waiting' || state === 'blocked'
}
export function isAutoApprovedCodexPermissionStatus(
payload: AgentCompletionStatusSnapshot,
paneKey: string
): boolean {
if (payload.agentType !== 'codex' || !isCodexAutoApprovedPermissionState(payload.state)) {
return false
}
const state = useAppStore.getState()
const statusEntry = state.agentStatusByPaneKey[paneKey]
if (!statusEntry) {
return false
}
const launchConfig = state.getAgentLaunchConfigForStatusEntry(statusEntry)
if (!launchConfig) {
return false
}
return launchConfig.agentArgs.trim() === CODEX_AUTO_APPROVED_ARGS
}
export function createCodexAutoApprovalHookCompletionSuppressor(
paneKey: string
): (payload: AgentCompletionStatusSnapshot) => boolean {
return (payload) => isAutoApprovedCodexPermissionStatus(payload, paneKey)
}
@@ -76,6 +76,7 @@ import {
type AgentInterruptInputIntent
} from '../../../../shared/agent-interrupt-intent'
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
import { createCodexAutoApprovalHookCompletionSuppressor } from './codex-auto-approval-notification-suppression'
import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types'
import {
markTerminalBracketedPasteInterrupted,
@@ -1282,7 +1283,8 @@ export function connectPanePty(
return true
}
return (useAppStore.getState().ptyIdsByTabId[deps.tabId] ?? []).length > 0
}
},
shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(cacheKey)
})
const onExit = (ptyId: string): void => {
@@ -1,6 +1,7 @@
/* eslint-disable max-lines -- Why: notification edge cases share one module-scoped coordinator, so keeping setup and regression cases together prevents brittle cross-file mock resets. */
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'
const dispatchTerminalNotification = vi.fn()
@@ -24,6 +25,27 @@ type MockStoreState = {
ptyIdsByLeafId?: Record<string, string>
}
>
agentLaunchConfigByPaneKey: Record<
string,
{
launchConfig: { agentArgs: string; agentEnv: Record<string, string> }
}
>
agentStatusByPaneKey: Record<
string,
{
state: ParsedAgentStatusPayload['state']
prompt: string
paneKey: string
updatedAt: number
stateStartedAt: number
agentType?: ParsedAgentStatusPayload['agentType']
stateHistory: []
}
>
getAgentLaunchConfigForStatusEntry: (entry: {
paneKey: string
}) => { agentArgs: string; agentEnv: Record<string, string> } | undefined
}
let mockStoreState: MockStoreState
@@ -48,6 +70,24 @@ function hookStatus(state: ParsedAgentStatusPayload['state']): ParsedAgentStatus
}
}
function seedCodexPaneLaunchConfig(paneKey: string, agentArgs: string): void {
mockStoreState.agentLaunchConfigByPaneKey[paneKey] = {
launchConfig: {
agentArgs,
agentEnv: {}
}
}
mockStoreState.agentStatusByPaneKey[paneKey] = {
state: 'working',
prompt: 'implement notifications',
paneKey,
updatedAt: Date.now(),
stateStartedAt: Date.now(),
agentType: 'codex',
stateHistory: []
}
}
describe('agent hook completion notifications', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
@@ -70,7 +110,11 @@ describe('agent hook completion notifications', () => {
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' }]
},
terminalLayoutsByTabId: {}
terminalLayoutsByTabId: {},
agentLaunchConfigByPaneKey: {},
agentStatusByPaneKey: {},
getAgentLaunchConfigForStatusEntry: (entry) =>
mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig
}
})
@@ -414,6 +458,89 @@ describe('agent hook completion notifications', () => {
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
})
it('does not notify for Codex auto-approved permission requests', async () => {
seedCodexPaneLaunchConfig(paneKey, YOLO_TUI_AGENT_ARGS.codex ?? '')
const { observeAgentHookCompletionForNotification } =
await import('./agent-hook-completion-notifications')
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
payload: hookStatus('working')
})
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
payload: {
state: 'waiting',
prompt: 'implement notifications',
agentType: 'codex',
toolName: 'exec_command',
toolInput: 'git status'
}
})
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
})
it('still notifies for manual Codex permission requests', async () => {
seedCodexPaneLaunchConfig(paneKey, '')
const { observeAgentHookCompletionForNotification } =
await import('./agent-hook-completion-notifications')
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
payload: hookStatus('working')
})
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
payload: {
state: 'waiting',
prompt: 'implement notifications',
agentType: 'codex',
toolName: 'exec_command',
toolInput: 'git status'
}
})
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(
'wt-1',
expect.objectContaining({
source: 'agent-task-complete',
paneKey,
terminalTitle: 'codex'
})
)
})
it('does not notify for Codex auto-approved blocked permission requests', async () => {
seedCodexPaneLaunchConfig(paneKey, YOLO_TUI_AGENT_ARGS.codex ?? '')
const { observeAgentHookCompletionForNotification } =
await import('./agent-hook-completion-notifications')
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
payload: hookStatus('working')
})
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
payload: {
state: 'blocked',
prompt: 'implement notifications',
agentType: 'codex',
toolName: 'exec_command',
toolInput: 'git status'
}
})
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
})
it('suppresses an internal milestone completion when hook work resumes before quiet', async () => {
const { observeAgentHookCompletionForNotification } =
await import('./agent-hook-completion-notifications')
@@ -8,6 +8,7 @@ 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'
type CoordinatorEntry = {
worktreeId: string
@@ -159,7 +160,8 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion
...(meta?.agentStatus ? { agentStatusSnapshot: meta.agentStatus } : {})
})
},
isLive: () => paneCanReceiveHookCompletion(paneKey)
isLive: () => paneCanReceiveHookCompletion(paneKey),
shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(paneKey)
})
}
+2 -1
View File
@@ -1102,7 +1102,8 @@
"importHostSetup": "导入",
"notePasteTooLarge": "笔记字段的粘贴内容过大。",
"reuseExistingBranch": "复用分支",
"reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。"
"reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。",
"createMultiple": "Create more"
},
"NewWorkspaceComposerModal": {
"fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。"