diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index 76ab77c0376..0b3e912749d 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -23,6 +23,7 @@ export type AgentCompletionCoordinatorOptions = { dispatchCompletion: (title: string, meta?: AgentCompletionDispatchMeta) => void isLive: () => boolean shouldPollProcessCadence?: () => boolean + shouldSuppressHookCompletion?: (payload: AgentCompletionStatusSnapshot) => boolean } export type AgentCompletionCoordinator = { diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts index 3d5acfb168e..4062795b560 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -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' + }) + }) + ) + }) }) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index 085c45c8cad..50ee06a85e6 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -610,6 +610,9 @@ export function createAgentCompletionCoordinator( } function observeHookStatus(payload: AgentCompletionStatusSnapshot): void { + if (options.shouldSuppressHookCompletion?.(payload)) { + return + } if (isRecognizedAgentType(payload.agentType)) { establishAgentEvidence() } diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts new file mode 100644 index 00000000000..c5d2e59f0e9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts @@ -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 + +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) + 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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts new file mode 100644 index 00000000000..11244bc20f0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts @@ -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) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 4133374fa2d..504d6b472bd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 => { diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index fdaee70cf89..f2080df3fac 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -1,6 +1,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 } > + agentLaunchConfigByPaneKey: Record< + string, + { + launchConfig: { agentArgs: string; agentEnv: Record } + } + > + 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 } | 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') diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index f5e1227bd91..6665d111320 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -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) }) } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b67d1db3bf5..dd2564ec309 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1102,7 +1102,8 @@ "importHostSetup": "导入", "notePasteTooLarge": "笔记字段的粘贴内容过大。", "reuseExistingBranch": "复用分支", - "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。" + "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。", + "createMultiple": "Create more" }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。"