mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Suppress Codex yolo permission attention
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
+41
-1
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+188
-41
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+53
-11
@@ -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 () => {
|
||||
|
||||
@@ -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 } = {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user