Suppress Codex yolo permission attention

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-26 21:09:47 -07:00
co-authored by Orca
parent 8c40d5bc91
commit 779a0a8a77
12 changed files with 930 additions and 87 deletions
+41 -1
View File
@@ -145,6 +145,7 @@ import {
type SyntheticAgentTitleProfile
} from '../shared/synthetic-agent-title'
import type { AgentStatusState } from '../shared/agent-status-types'
import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions'
import { KeybindingService } from './keybindings/keybinding-service'
import { applyElectronProxySettings } from './network/proxy-settings'
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
@@ -841,7 +842,20 @@ function openMainWindow(): BrowserWindow {
// Why: some native OSC titles miss terminal idle/permission frames.
// Inject hook-derived frames so the renderer title tracker updates too.
const profile = getSyntheticAgentTitleProfile(payload.agentType)
if (profile && shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state)) {
const suppressSyntheticCodexAutoApprovalTitle =
payload.agentType === 'codex' &&
(payload.state === 'waiting' || payload.state === 'blocked')
? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({
agentType: payload.agentType,
state: payload.state,
launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken })
})
: false
if (
profile &&
shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) &&
!suppressSyntheticCodexAutoApprovalTitle
) {
driveSyntheticTitleFromHook(paneKey, payload.state, profile)
}
}
@@ -1261,6 +1275,32 @@ function driveSyntheticTitleFromHook(
})
}
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'
)
}
app.whenReady().then(async () => {
logStartupMilestone('app-ready')
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
+31
View File
@@ -17459,6 +17459,20 @@ export class OrcaRuntimeService {
return this.getTerminalHandleForPaneKey(paneKey) ?? undefined
}
getAgentStatusLaunchConfigForPaneKey(
paneKey: string,
args?: { launchToken?: string }
): SleepingAgentLaunchConfig | undefined {
const pty = this.getPtyRecordForPaneKey(paneKey)
if (!pty?.launchConfig) {
return undefined
}
if (pty.launchToken === null || pty.launchToken !== args?.launchToken) {
return undefined
}
return copySleepingAgentLaunchConfig(pty.launchConfig)
}
private buildAgentOrchestrationByPaneKey():
| Record<string, AgentStatusOrchestrationContext>
| undefined {
@@ -17569,6 +17583,23 @@ export class OrcaRuntimeService {
return null
}
private getPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null {
const parsed = parsePaneKey(paneKey)
if (parsed) {
const leaf = this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId))
const pty = leaf?.ptyId ? this.ptysById.get(leaf.ptyId) : undefined
if (pty) {
return pty
}
}
for (const pty of this.ptysById.values()) {
if (pty.paneKey === paneKey) {
return pty
}
}
return null
}
private getPaneKeyForTerminalHandle(handle: string): string | null {
const livePty = this.getLivePtyForHandle(handle)
if (livePty?.pty.paneKey) {
@@ -2,6 +2,11 @@ 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>
@@ -11,64 +16,206 @@ vi.mock('@/store', () => ({
}
}))
import { isAutoApprovedCodexPermissionStatus } from './codex-auto-approval-notification-suppression'
const paneKey = 'tab-1:leaf-1'
const launchToken = 'launch-token-1'
const providerSession = { key: 'session_id' as const, id: 'codex-session-1' }
describe('Codex auto-approval notification suppression', () => {
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('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 }
)
it('suppresses the first auto-approved Codex waiting status with matching launch token', () => {
registerCodexLaunchConfig({
agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '',
launchToken
})
expect(
isAutoApprovedCodexPermissionStatus(
shouldSuppressCodexAutoApprovalStatus(
{ state: 'waiting', prompt: 'implement notifications', agentType: 'codex' },
paneKey
{ paneKey, tabId: 'tab-1', launchToken }
)
).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'
it('suppresses auto-approved Codex blocked statuses', () => {
registerCodexLaunchConfig({
agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '',
launchToken
})
expect(
isAutoApprovedCodexPermissionStatus(
shouldSuppressCodexAutoApprovalStatus(
{ state: 'blocked', prompt: 'implement notifications', agentType: 'codex' },
{ paneKey, tabId: 'tab-1', launchToken }
)
).toBe(true)
})
it('preserves manual Codex permission attention', () => {
registerCodexLaunchConfig({ agentArgs: '', launchToken })
expect(
shouldSuppressCodexAutoApprovalStatus(
{ state: 'waiting', prompt: 'implement notifications', agentType: 'codex' },
paneKey
{ 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('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,40 +1,82 @@
import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions'
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
const CODEX_AUTO_APPROVED_ARGS = (YOLO_TUI_AGENT_ARGS.codex ?? '').trim()
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 state === 'waiting' || state === 'blocked'
return CODEX_AUTO_APPROVED_PERMISSION_STATES.some((permissionState) => permissionState === state)
}
export function isAutoApprovedCodexPermissionStatus(
export function shouldSuppressCodexAutoApprovalStatus(
payload: AgentCompletionStatusSnapshot,
paneKey: string
context: CodexAutoApprovalStatusContext
): boolean {
if (payload.agentType !== 'codex' || !isCodexAutoApprovedPermissionState(payload.state)) {
return false
}
const state = useAppStore.getState()
const statusEntry = state.agentStatusByPaneKey[paneKey]
if (!statusEntry) {
if (typeof state.getAgentLaunchConfigForStatusMetadata !== 'function') {
return false
}
const launchConfig = state.getAgentLaunchConfigForStatusEntry(statusEntry)
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 launchConfig.agentArgs.trim() === CODEX_AUTO_APPROVED_ARGS
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
paneKey: string,
getContext?: () => Omit<CodexAutoApprovalStatusContext, 'paneKey'>
): (payload: AgentCompletionStatusSnapshot) => boolean {
return (payload) => isAutoApprovedCodexPermissionStatus(payload, paneKey)
return (payload) =>
shouldSuppressCodexAutoApprovalStatus(payload, {
paneKey,
...getContext?.()
})
}
@@ -11,6 +11,7 @@ import type * as UseNotificationDispatchModule from './use-notification-dispatch
import { getEagerPtyBufferHandle } from './pty-dispatcher'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions'
// Repro command:
// pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -t "OpenTUI-style small ANSI redraw"
@@ -114,6 +115,7 @@ type StoreState = {
sleepingAgentSessionsByPaneKey: Record<string, unknown>
agentLaunchConfigByPaneKey: Record<string, { launchConfig: unknown }>
getAgentLaunchConfigForStatusEntry: ReturnType<typeof vi.fn>
getAgentLaunchConfigForStatusMetadata: ReturnType<typeof vi.fn>
clearSleepingAgentSession: ReturnType<typeof vi.fn>
registerAgentLaunchConfig: ReturnType<typeof vi.fn>
clearAgentLaunchConfig: ReturnType<typeof vi.fn>
@@ -519,6 +521,13 @@ describe('connectPanePty', () => {
getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => {
return mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig
}),
getAgentLaunchConfigForStatusMetadata: vi.fn(
(metadata: { paneKey: string; launchToken?: string }) => {
return metadata.launchToken
? mockStoreState.agentLaunchConfigByPaneKey[metadata.paneKey]?.launchConfig
: undefined
}
),
clearSleepingAgentSession: vi.fn((paneKey: string) => {
delete mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]
}),
@@ -4228,6 +4237,7 @@ describe('connectPanePty', () => {
}
},
getAgentLaunchConfigForStatusEntry: vi.fn(() => undefined),
getAgentLaunchConfigForStatusMetadata: vi.fn(() => undefined),
sleepingAgentSessionsByPaneKey: {
[paneKey]: {
paneKey,
@@ -7903,6 +7913,177 @@ 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 () => {
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: '',
agentEnv: {}
}
}
const pane = createPane(1)
const manager = createManager(1)
manager.getActivePane.mockReturnValue({ id: 1 })
const deps = createDeps({
startup: {
command: 'codex',
launchConfig: {
agentCommand: 'codex',
agentArgs: '',
agentEnv: {}
},
launchToken: 'launch-manual',
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')
}
titleHandler('Codex - action required', 'Codex - action required')
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex - action required')
expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Codex - action required')
})
it('resolves synthetic terminal titles for remote hook status updates', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-devin')
@@ -76,7 +76,11 @@ import {
type AgentInterruptInputIntent
} from '../../../../shared/agent-interrupt-intent'
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
import { createCodexAutoApprovalHookCompletionSuppressor } from './codex-auto-approval-notification-suppression'
import {
createCodexAutoApprovalHookCompletionSuppressor,
shouldSuppressCodexAutoApprovalSyntheticTitle,
shouldSuppressCodexAutoApprovalStatus
} from './codex-auto-approval-notification-suppression'
import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types'
import {
markTerminalBracketedPasteInterrupted,
@@ -1284,7 +1288,10 @@ export function connectPanePty(
}
return (useAppStore.getState().ptyIdsByTabId[deps.tabId] ?? []).length > 0
},
shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(cacheKey)
shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(cacheKey, () => ({
tabId: deps.tabId,
...(launchToken ? { launchToken } : {})
}))
})
const onExit = (ptyId: string): void => {
@@ -1340,6 +1347,15 @@ export function connectPanePty(
let allowInitialIdleCacheSeed = false
const onTitleChange = (title: string, rawTitle: string): void => {
if (
shouldSuppressCodexAutoApprovalSyntheticTitle(title, {
paneKey: cacheKey,
tabId: deps.tabId,
...(launchToken ? { launchToken } : {})
})
) {
return
}
manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle))
deps.setRuntimePaneTitle(deps.tabId, pane.id, title)
if (syncAgentTaskCompleteTrackingEnabled()) {
@@ -1832,6 +1848,15 @@ export function connectPanePty(
...(shouldOwnAgentStatusInRenderer
? {
onAgentStatus: (payload) => {
if (
shouldSuppressCodexAutoApprovalStatus(payload, {
paneKey: cacheKey,
tabId: deps.tabId,
...(launchToken ? { launchToken } : {})
})
) {
return
}
// Why: capture the store snapshot once so the title lookup and the
// setAgentStatus call observe the same state. Re-reading getState()
// between the two lines opens a brief window where the title could
@@ -46,6 +46,10 @@ type MockStoreState = {
getAgentLaunchConfigForStatusEntry: (entry: {
paneKey: string
}) => { agentArgs: string; agentEnv: Record<string, string> } | undefined
getAgentLaunchConfigForStatusMetadata: (metadata: {
paneKey: string
launchToken?: string
}) => { agentArgs: string; agentEnv: Record<string, string> } | undefined
}
let mockStoreState: MockStoreState
@@ -114,7 +118,11 @@ describe('agent hook completion notifications', () => {
agentLaunchConfigByPaneKey: {},
agentStatusByPaneKey: {},
getAgentLaunchConfigForStatusEntry: (entry) =>
mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig
mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig,
getAgentLaunchConfigForStatusMetadata: (metadata) =>
metadata.launchToken
? mockStoreState.agentLaunchConfigByPaneKey[metadata.paneKey]?.launchConfig
: undefined
}
})
@@ -458,7 +466,7 @@ describe('agent hook completion notifications', () => {
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
})
it('does not notify for Codex auto-approved permission requests', async () => {
it('fails open for Codex auto-approved permission requests without launch proof', async () => {
seedCodexPaneLaunchConfig(paneKey, YOLO_TUI_AGENT_ARGS.codex ?? '')
const { observeAgentHookCompletionForNotification } =
await import('./agent-hook-completion-notifications')
@@ -480,7 +488,7 @@ describe('agent hook completion notifications', () => {
}
})
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
})
it('still notifies for manual Codex permission requests', async () => {
@@ -516,7 +524,7 @@ describe('agent hook completion notifications', () => {
)
})
it('does not notify for Codex auto-approved blocked permission requests', async () => {
it('fails open for Codex auto-approved blocked permission requests without launch proof', async () => {
seedCodexPaneLaunchConfig(paneKey, YOLO_TUI_AGENT_ARGS.codex ?? '')
const { observeAgentHookCompletionForNotification } =
await import('./agent-hook-completion-notifications')
@@ -538,7 +546,7 @@ describe('agent hook completion notifications', () => {
}
})
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
})
it('suppresses an internal milestone completion when hook work resumes before quiet', async () => {
+251
View File
@@ -13,6 +13,7 @@ import {
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
import type { TuiAgent } from '../../../shared/types'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { YOLO_TUI_AGENT_ARGS } from '../../../shared/tui-agent-permissions'
const { closeTerminalTabMock } = vi.hoisted(() => ({
closeTerminalTabMock: vi.fn()
@@ -3563,6 +3564,8 @@ describe('useIpcEvents agent status snapshot integration', () => {
lastAssistantMessage?: string
interrupted?: boolean
terminalHandle?: string
launchToken?: string
providerSession?: { key: 'session_id'; id: string }
orchestration?: {
taskId?: string
dispatchId?: string
@@ -3630,6 +3633,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
runtimePaneTitlesByTabId: {},
terminalLayoutsByTabId: {},
agentStatusByPaneKey: {},
getAgentLaunchConfigForStatusMetadata: vi.fn(() => undefined),
recentlyClosedAgentStatusTabIds: {},
repos: [],
worktreesByRepo: {},
@@ -4170,6 +4174,253 @@ 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(),
syncAgentHookCompletionNotificationSettings: 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 () => {
const setAgentStatus = vi.fn()
const updateTabTitle = vi.fn()
const observeAgentHookCompletionForNotification = vi.fn()
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
current: null
}
const storeState: StoreLike = buildStoreState({
setAgentStatus,
updateTabTitle,
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(),
syncAgentHookCompletionNotificationSettings: 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: 'manual permission',
agentType: 'codex',
receivedAt: 1_700_000_000_400,
stateStartedAt: 1_699_999_999_400
})
expect(setAgentStatus).toHaveBeenCalledTimes(1)
expect(setAgentStatus).toHaveBeenCalledWith(
FUTURE_PANE_KEY,
expect.objectContaining({
state: 'waiting',
prompt: 'manual permission',
agentType: 'codex'
}),
'Codex - action required',
{ updatedAt: 1_700_000_000_400, stateStartedAt: 1_699_999_999_400 },
expectWorktreeRouting('wt-1'),
undefined
)
expect(updateTabTitle).toHaveBeenCalledWith('tab-future', 'Codex - action required')
expect(observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1)
})
it('keeps auto-approved 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: {
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Codex' }]
},
terminalLayoutsByTabId: {}
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => storeState
}
}))
vi.doMock('./agent-hook-completion-notifications', () => ({
observeAgentHookCompletionForNotification,
resetAgentHookCompletionNotificationCoordinators: vi.fn(),
syncAgentHookCompletionNotificationSettings: 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: 'done',
prompt: 'auto-approved task',
agentType: 'codex',
launchToken: 'launch-yolo',
lastAssistantMessage: 'Done.',
receivedAt: 1_700_000_000_500,
stateStartedAt: 1_699_999_999_500
})
expect(setAgentStatus).toHaveBeenCalledTimes(1)
expect(observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1)
expect(observeAgentHookCompletionForNotification).toHaveBeenCalledWith(
expect.objectContaining({
paneKey: FUTURE_PANE_KEY,
worktreeId: 'wt-1',
payload: expect.objectContaining({ state: 'done', agentType: 'codex' })
})
)
})
it('drops late push events for a recently closed terminal tab', async () => {
const setAgentStatus = vi.fn()
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
+15
View File
@@ -112,6 +112,7 @@ import {
resetAgentHookCompletionNotificationCoordinators,
syncAgentHookCompletionNotificationSettings
} from './agent-hook-completion-notifications'
import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pane/codex-auto-approval-notification-suppression'
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title'
import { titleHasAgentName } from '../../../shared/agent-detection'
@@ -2880,6 +2881,20 @@ export function useIpcEvents(): void {
// Keep the defensive store guard and completion notification path in sync.
return 'dropped'
}
if (
shouldSuppressCodexAutoApprovalStatus(statusPayload, {
paneKey: data.paneKey,
tabId: data.tabId,
terminalHandle: data.terminalHandle,
launchToken: data.launchToken,
providerSession: data.providerSession,
existingProviderSession: existingStatus?.providerSession
})
) {
// Why: Codex yolo permission hooks are not user-actionable, and must
// not drive status, synthetic titles, unread badges, or notifications.
return 'dropped'
}
const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title)
const statusWorktreeId = data.worktreeId ?? owningWorktreeId
store.setAgentStatus(
@@ -66,6 +66,17 @@ type AgentLaunchConfigRegistrationMetadata = {
providerSession?: AgentProviderSessionMetadata
}
type AgentLaunchConfigStatusMetadata = {
paneKey: string
agentType?: AgentType
tabId?: string
terminalHandle?: string
launchToken?: string
providerSession?: AgentProviderSessionMetadata
existingProviderSession?: AgentProviderSessionMetadata
providerSessionChanged?: boolean
}
type AgentLaunchConfigRegistryEntry = {
launchConfig: SleepingAgentLaunchConfig
registeredAt: number
@@ -130,6 +141,9 @@ export type AgentStatusSlice = {
getAgentLaunchConfigForStatusEntry: (
entry: AgentStatusEntry
) => SleepingAgentLaunchConfig | undefined
getAgentLaunchConfigForStatusMetadata: (
metadata: AgentLaunchConfigStatusMetadata
) => SleepingAgentLaunchConfig | undefined
clearAgentLaunchConfig: (paneKey: string) => void
setRuntimeAgentOrchestrationByPaneKey: (
@@ -640,6 +654,15 @@ function registryEntryMatchesStatus(args: {
) {
return false
}
if (
identity.launchToken !== undefined &&
args.launchToken !== undefined &&
identity.launchToken !== args.launchToken
) {
// Why: an explicit mismatched launch token is stale launch proof even if a
// provider session id was reused by a later manual/mixed Codex run.
return false
}
if (identity.providerSession !== undefined) {
return providerSessionsEqual(identity.providerSession, args.providerSession)
}
@@ -691,6 +714,26 @@ function getLaunchConfigForEntry(
: undefined
}
function getLaunchConfigForStatusMetadata(
state: AppState,
metadata: AgentLaunchConfigStatusMetadata
): SleepingAgentLaunchConfig | undefined {
const registryEntry = state.agentLaunchConfigByPaneKey[metadata.paneKey]
return registryEntryMatchesStatus({
entry: registryEntry,
paneKey: metadata.paneKey,
agentType: metadata.agentType,
tabId: metadata.tabId ?? getTabIdFromPaneKey(metadata.paneKey) ?? undefined,
terminalHandle: metadata.terminalHandle,
launchToken: metadata.launchToken,
providerSession: metadata.providerSession,
existingProviderSession: metadata.existingProviderSession,
providerSessionChanged: metadata.providerSessionChanged ?? false
})
? registryEntry?.launchConfig
: undefined
}
function pruneMigrationUnsupportedEntries(
entries: Record<string, MigrationUnsupportedPtyEntry>,
predicate: (entry: MigrationUnsupportedPtyEntry) => boolean
@@ -916,6 +959,8 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
})
},
getAgentLaunchConfigForStatusEntry: (entry) => getLaunchConfigForEntry(get(), entry),
getAgentLaunchConfigForStatusMetadata: (metadata) =>
getLaunchConfigForStatusMetadata(get(), metadata),
clearAgentLaunchConfig: (paneKey) => {
set((s) => {
+37
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
applyAgentPermissionMode,
resolveAgentPermissionModeSummary,
resolveTuiAgentPermissionMode,
YOLO_TUI_AGENT_ARGS,
YOLO_TUI_AGENT_ENV
} from './tui-agent-permissions'
@@ -48,4 +49,40 @@ describe('tui agent permissions', () => {
})
).toBe('mixed')
})
it('resolves one Codex yolo launch as yolo', () => {
expect(
resolveTuiAgentPermissionMode({
agent: 'codex',
agentArgs: YOLO_TUI_AGENT_ARGS.codex,
agentEnv: {}
})
).toBe('yolo')
})
it('resolves one empty Codex launch as manual', () => {
expect(resolveTuiAgentPermissionMode({ agent: 'codex', agentArgs: '', agentEnv: {} })).toBe(
'manual'
)
})
it('resolves custom Codex permission arguments as mixed', () => {
expect(
resolveTuiAgentPermissionMode({
agent: 'codex',
agentArgs: '--ask-for-approval on-request',
agentEnv: {}
})
).toBe('mixed')
})
it('resolves env-driven yolo launches', () => {
expect(
resolveTuiAgentPermissionMode({
agent: 'goose',
agentArgs: '',
agentEnv: YOLO_TUI_AGENT_ENV.goose
})
).toBe('yolo')
})
})
+48 -27
View File
@@ -71,37 +71,18 @@ function resolveAgentEnvPermissionMode(
return sameEnv(env, yoloEnv) ? 'yolo' : 'mixed'
}
export function resolveAgentPermissionModeSummary(args: {
agentDefaultArgs?: Partial<Record<TuiAgent, string>> | null
agentDefaultEnv?: Partial<Record<TuiAgent, Record<string, string>>> | null
}): AgentPermissionMode {
function combinePermissionModes(modes: AgentPermissionMode[]): AgentPermissionMode {
let sawYolo = false
let sawManual = false
let sawMixed = false
for (const agent of PERMISSION_AGENT_IDS) {
const modes: AgentPermissionMode[] = []
if (agent in YOLO_TUI_AGENT_ARGS) {
modes.push(
resolveAgentPermissionMode(
normalizeArgs(args.agentDefaultArgs?.[agent]),
YOLO_TUI_AGENT_ARGS[agent] ?? ''
)
)
}
if (agent in YOLO_TUI_AGENT_ENV) {
modes.push(
resolveAgentEnvPermissionMode(args.agentDefaultEnv?.[agent], YOLO_TUI_AGENT_ENV[agent])
)
}
for (const mode of modes) {
if (mode === 'yolo') {
sawYolo = true
} else if (mode === 'manual') {
sawManual = true
} else {
sawMixed = true
}
for (const mode of modes) {
if (mode === 'yolo') {
sawYolo = true
} else if (mode === 'manual') {
sawManual = true
} else {
sawMixed = true
}
}
@@ -111,6 +92,46 @@ export function resolveAgentPermissionModeSummary(args: {
return sawYolo ? 'yolo' : 'manual'
}
export function resolveTuiAgentPermissionMode(args: {
agent: TuiAgent
agentArgs?: string | null
agentEnv?: Record<string, string> | null
}): AgentPermissionMode {
const modes: AgentPermissionMode[] = []
if (args.agent in YOLO_TUI_AGENT_ARGS) {
modes.push(
resolveAgentPermissionMode(
normalizeArgs(args.agentArgs),
YOLO_TUI_AGENT_ARGS[args.agent] ?? ''
)
)
}
if (args.agent in YOLO_TUI_AGENT_ENV) {
modes.push(resolveAgentEnvPermissionMode(args.agentEnv, YOLO_TUI_AGENT_ENV[args.agent]))
}
return combinePermissionModes(modes)
}
export function resolveAgentPermissionModeSummary(args: {
agentDefaultArgs?: Partial<Record<TuiAgent, string>> | null
agentDefaultEnv?: Partial<Record<TuiAgent, Record<string, string>>> | null
}): AgentPermissionMode {
const modes: AgentPermissionMode[] = []
for (const agent of PERMISSION_AGENT_IDS) {
modes.push(
resolveTuiAgentPermissionMode({
agent,
agentArgs: args.agentDefaultArgs?.[agent],
agentEnv: args.agentDefaultEnv?.[agent]
})
)
}
return combinePermissionModes(modes)
}
export function applyAgentPermissionMode(args: {
mode: Exclude<AgentPermissionMode, 'mixed'>
agentDefaultArgs?: Partial<Record<TuiAgent, string>> | null