From ebfb1c5abf19c45d2179783e00849f349c7e129f Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Sat, 22 Nov 2025 21:45:42 +0800 Subject: [PATCH] fix: add missing execution state for approved tool permissions (#11394) --- .../agents/services/claudecode/index.ts | 75 ++++++++++++++++++- .../services/claudecode/tool-permissions.ts | 13 +++- src/renderer/src/hooks/useAppInit.ts | 36 ++++++++- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + src/renderer/src/i18n/translate/de-de.json | 1 + src/renderer/src/i18n/translate/el-gr.json | 1 + src/renderer/src/i18n/translate/es-es.json | 1 + src/renderer/src/i18n/translate/fr-fr.json | 1 + src/renderer/src/i18n/translate/ja-jp.json | 1 + src/renderer/src/i18n/translate/pt-pt.json | 1 + src/renderer/src/i18n/translate/ru-ru.json | 1 + .../Tools/ToolPermissionRequestCard.tsx | 50 ++++++++++++- .../messageStreaming/callbacks/index.ts | 3 +- .../callbacks/toolCallbacks.ts | 8 +- src/renderer/src/store/toolPermissions.ts | 32 ++++++-- 17 files changed, 209 insertions(+), 18 deletions(-) diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 83d3e49311..53b318c5b2 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -2,7 +2,14 @@ import { EventEmitter } from 'node:events' import { createRequire } from 'node:module' -import type { CanUseTool, McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk' +import type { + CanUseTool, + HookCallback, + McpHttpServerConfig, + Options, + PreToolUseHookInput, + SDKMessage +} from '@anthropic-ai/claude-agent-sdk' import { query } from '@anthropic-ai/claude-agent-sdk' import { loggerService } from '@logger' import { config as apiConfigService } from '@main/apiServer/config' @@ -157,6 +164,63 @@ class ClaudeCodeService implements AgentServiceInterface { }) } + const preToolUseHook: HookCallback = async (input, toolUseID, options) => { + // Type guard to ensure we're handling PreToolUse event + if (input.hook_event_name !== 'PreToolUse') { + return {} + } + + const hookInput = input as PreToolUseHookInput + const toolName = hookInput.tool_name + + logger.debug('PreToolUse hook triggered', { + session_id: hookInput.session_id, + tool_name: hookInput.tool_name, + tool_use_id: toolUseID, + tool_input: hookInput.tool_input, + cwd: hookInput.cwd, + permission_mode: hookInput.permission_mode, + autoAllowTools: autoAllowTools + }) + + if (options?.signal?.aborted) { + logger.debug('PreToolUse hook signal already aborted; skipping tool use', { + tool_name: hookInput.tool_name + }) + return {} + } + + // handle auto approved tools since it never triggers canUseTool + const normalizedToolName = normalizeToolName(toolName) + if (toolUseID) { + const bypassAll = input.permission_mode === 'bypassPermissions' + const autoAllowed = autoAllowTools.has(toolName) || autoAllowTools.has(normalizedToolName) + if (bypassAll || autoAllowed) { + const namespacedToolCallId = buildNamespacedToolCallId(session.id, toolUseID) + logger.debug('handling auto approved tools', { + toolName, + normalizedToolName, + namespacedToolCallId, + permission_mode: input.permission_mode, + autoAllowTools + }) + const isRecord = (v: unknown): v is Record => { + return !!v && typeof v === 'object' && !Array.isArray(v) + } + const toolInput = isRecord(input.tool_input) ? input.tool_input : {} + + await promptForToolApproval(toolName, toolInput, { + ...options, + toolCallId: namespacedToolCallId, + autoApprove: true + }) + } + } + + // Return to proceed without modification + return {} + } + // Build SDK options from parameters const options: Options = { abortController, @@ -180,7 +244,14 @@ class ClaudeCodeService implements AgentServiceInterface { permissionMode: session.configuration?.permission_mode, maxTurns: session.configuration?.max_turns, allowedTools: session.allowed_tools, - canUseTool + canUseTool, + hooks: { + PreToolUse: [ + { + hooks: [preToolUseHook] + } + ] + } } if (session.accessible_paths.length > 1) { diff --git a/src/main/services/agents/services/claudecode/tool-permissions.ts b/src/main/services/agents/services/claudecode/tool-permissions.ts index 5b50f4567e..bbca3bd40e 100644 --- a/src/main/services/agents/services/claudecode/tool-permissions.ts +++ b/src/main/services/agents/services/claudecode/tool-permissions.ts @@ -31,6 +31,7 @@ type PendingPermissionRequest = { abortListener?: () => void originalInput: Record toolName: string + toolCallId?: string } type RendererPermissionRequestPayload = { @@ -45,6 +46,7 @@ type RendererPermissionRequestPayload = { createdAt: number expiresAt: number suggestions: PermissionUpdate[] + autoApprove?: boolean } type RendererPermissionResultPayload = { @@ -52,6 +54,7 @@ type RendererPermissionResultPayload = { behavior: ToolPermissionBehavior message?: string reason: 'response' | 'timeout' | 'aborted' | 'no-window' + toolCallId?: string } const pendingRequests = new Map() @@ -145,7 +148,8 @@ const finalizeRequest = ( requestId, behavior: update.behavior, message: update.behavior === 'deny' ? update.message : undefined, - reason + reason, + toolCallId: pending.toolCallId } const dispatched = broadcastToRenderer(IpcChannel.AgentToolPermission_Result, resultPayload) @@ -210,6 +214,7 @@ const ensureIpcHandlersRegistered = () => { type PromptForToolApprovalOptions = { signal: AbortSignal suggestions?: PermissionUpdate[] + autoApprove?: boolean // NOTICE: This ID is namespaced with session ID, not the raw SDK tool call ID. // Format: `${sessionId}:${rawToolCallId}`, e.g., `session_123:WebFetch_0` @@ -270,7 +275,8 @@ export async function promptForToolApproval( inputPreview, createdAt, expiresAt, - suggestions: sanitizedSuggestions + suggestions: sanitizedSuggestions, + autoApprove: options.autoApprove } const defaultDenyUpdate: PermissionResult = { behavior: 'deny', message: 'Tool request aborted before user decision' } @@ -299,7 +305,8 @@ export async function promptForToolApproval( timeout, originalInput: sanitizedInput, toolName, - signal: options?.signal + signal: options?.signal, + toolCallId: options.toolCallId } if (options?.signal) { diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 0ca30a7f04..3ee9392ce5 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -175,14 +175,46 @@ export function useAppInit() { useEffect(() => { if (!window.electron?.ipcRenderer) return - const requestListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionRequestPayload) => { + const requestListener = async (_event: Electron.IpcRendererEvent, payload: ToolPermissionRequestPayload) => { logger.debug('Renderer received tool permission request', { requestId: payload.requestId, toolName: payload.toolName, expiresAt: payload.expiresAt, - suggestionCount: payload.suggestions.length + suggestionCount: payload.suggestions.length, + autoApprove: payload.autoApprove }) dispatch(toolPermissionsActions.requestReceived(payload)) + + // Auto-approve if requested + if (payload.autoApprove) { + logger.debug('Auto-approving tool permission request', { + requestId: payload.requestId, + toolName: payload.toolName + }) + + dispatch(toolPermissionsActions.submissionSent({ requestId: payload.requestId, behavior: 'allow' })) + + try { + const response = await window.api.agentTools.respondToPermission({ + requestId: payload.requestId, + behavior: 'allow', + updatedInput: payload.input, + updatedPermissions: payload.suggestions + }) + + if (!response?.success) { + throw new Error('Auto-approval response rejected by main process') + } + + logger.debug('Auto-approval acknowledged by main process', { + requestId: payload.requestId, + toolName: payload.toolName + }) + } catch (error) { + logger.error('Failed to send auto-approval response', error as Error) + dispatch(toolPermissionsActions.submissionFailed({ requestId: payload.requestId })) + } + } } const resultListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionResultPayload) => { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 5b1de2a257..329ec7879b 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -266,6 +266,7 @@ "error": { "sendFailed": "Failed to send your decision. Please try again." }, + "executing": "Executing...", "expired": "Expired", "inputPreview": "Tool input preview", "pending": "Pending ({{seconds}}s)", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8d7073fcfd..0d44039a16 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -266,6 +266,7 @@ "error": { "sendFailed": "发送您的决定失败,请重试。" }, + "executing": "正在执行...", "expired": "已过期", "inputPreview": "工具输入预览", "pending": "等待中 ({{seconds}}秒)", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 72eb71ea97..5736da530b 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -266,6 +266,7 @@ "error": { "sendFailed": "傳送您的決定失敗,請重試。" }, + "executing": "[to be translated]:Executing...", "expired": "已過期", "inputPreview": "工具輸入預覽", "pending": "等待中 ({{seconds}}秒)", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 0dd6d6d41c..b02a2895e5 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -266,6 +266,7 @@ "error": { "sendFailed": "Ihre Entscheidung konnte nicht gesendet werden. Bitte versuchen Sie es erneut." }, + "executing": "[to be translated]:Executing...", "expired": "Abgelaufen", "inputPreview": "Vorschau der Werkzeugeingabe", "pending": "Ausstehend ({{seconds}}s)", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index c043dfc174..7d16a1af5b 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -266,6 +266,7 @@ "error": { "sendFailed": "Αποτυχία αποστολής της απόφασής σας. Προσπαθήστε ξανά." }, + "executing": "[to be translated]:Executing...", "expired": "Ληγμένο", "inputPreview": "Προεπισκόπηση εισόδου εργαλείου", "pending": "Εκκρεμεί ({{seconds}}δ)", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 6451a43686..c68f3dc321 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -266,6 +266,7 @@ "error": { "sendFailed": "No se pudo enviar tu decisión. Por favor, inténtalo de nuevo." }, + "executing": "[to be translated]:Executing...", "expired": "Caducado", "inputPreview": "Vista previa de entrada de herramienta", "pending": "Pendiente ({{seconds}}s)", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index ce452256c5..f318dc51cb 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -266,6 +266,7 @@ "error": { "sendFailed": "Échec de l'envoi de votre décision. Veuillez réessayer." }, + "executing": "[to be translated]:Executing...", "expired": "Expiré", "inputPreview": "Aperçu de l'entrée de l'outil", "pending": "En attente ({{seconds}}s)", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 53814f528f..5d19239c87 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -266,6 +266,7 @@ "error": { "sendFailed": "決定の送信に失敗しました。もう一度お試しください。" }, + "executing": "[to be translated]:Executing...", "expired": "期限切れ", "inputPreview": "ツール入力プレビュー", "pending": "保留中({{seconds}}秒)", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 1ca373f394..d17e9749a9 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -266,6 +266,7 @@ "error": { "sendFailed": "Falha ao enviar sua decisão. Por favor, tente novamente." }, + "executing": "[to be translated]:Executing...", "expired": "Expirado", "inputPreview": "Pré-visualização da entrada da ferramenta", "pending": "Pendente ({{seconds}}s)", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 9658cb620b..f623423d18 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -266,6 +266,7 @@ "error": { "sendFailed": "Не удалось отправить ваше решение. Попробуйте ещё раз." }, + "executing": "[to be translated]:Executing...", "expired": "Истёк", "inputPreview": "Предварительный просмотр ввода инструмента", "pending": "Ожидание ({{seconds}}с)", diff --git a/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx b/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx index 1fd2023b38..0e0ba211f6 100644 --- a/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/ToolPermissionRequestCard.tsx @@ -3,7 +3,7 @@ import { loggerService } from '@logger' import { useAppDispatch, useAppSelector } from '@renderer/store' import { selectPendingPermission, toolPermissionsActions } from '@renderer/store/toolPermissions' import type { NormalToolResponse } from '@renderer/types' -import { Button } from 'antd' +import { Button, Spin } from 'antd' import { ChevronDown, CirclePlay, CircleX } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -52,6 +52,7 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) { const isSubmittingAllow = request?.status === 'submitting-allow' const isSubmittingDeny = request?.status === 'submitting-deny' const isSubmitting = isSubmittingAllow || isSubmittingDeny + const isInvoking = request?.status === 'invoking' const handleDecision = useCallback( async ( @@ -113,6 +114,53 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) { ) } + if (isInvoking) { + return ( +
+
+
+
+ +
+
{request.toolName}
+
{t('agent.toolPermission.executing')}
+
+
+ {request.inputPreview && ( +
+
+ )} +
+ + {showDetails && request.inputPreview && ( +
+
+

+ {t('agent.toolPermission.inputPreview')} +

+
+
{request.inputPreview}
+
+
+
+ )} +
+
+ ) + } + return (
diff --git a/src/renderer/src/services/messageStreaming/callbacks/index.ts b/src/renderer/src/services/messageStreaming/callbacks/index.ts index f6f2096405..2bb1d158bb 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/index.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/index.ts @@ -42,7 +42,8 @@ export const createCallbacks = (deps: CallbacksDependencies) => { const toolCallbacks = createToolCallbacks({ blockManager, - assistantMsgId + assistantMsgId, + dispatch }) const imageCallbacks = createImageCallbacks({ diff --git a/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts index ce64ea90a6..74d854d665 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts @@ -1,4 +1,6 @@ import { loggerService } from '@logger' +import type { AppDispatch } from '@renderer/store' +import { toolPermissionsActions } from '@renderer/store/toolPermissions' import type { MCPToolResponse } from '@renderer/types' import { WebSearchSource } from '@renderer/types' import type { ToolMessageBlock } from '@renderer/types/newMessage' @@ -12,10 +14,11 @@ const logger = loggerService.withContext('ToolCallbacks') interface ToolCallbacksDependencies { blockManager: BlockManager assistantMsgId: string + dispatch: AppDispatch } export const createToolCallbacks = (deps: ToolCallbacksDependencies) => { - const { blockManager, assistantMsgId } = deps + const { blockManager, assistantMsgId, dispatch } = deps // 内部维护的状态 const toolCallIdToBlockIdMap = new Map() @@ -53,6 +56,9 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => { }, onToolCallComplete: (toolResponse: MCPToolResponse) => { + if (toolResponse?.id) { + dispatch(toolPermissionsActions.removeByToolCallId({ toolCallId: toolResponse.id })) + } const existingBlockId = toolCallIdToBlockIdMap.get(toolResponse.id) toolCallIdToBlockIdMap.delete(toolResponse.id) diff --git a/src/renderer/src/store/toolPermissions.ts b/src/renderer/src/store/toolPermissions.ts index 59ff971329..cd31b16af8 100644 --- a/src/renderer/src/store/toolPermissions.ts +++ b/src/renderer/src/store/toolPermissions.ts @@ -14,6 +14,7 @@ export type ToolPermissionRequestPayload = { createdAt: number expiresAt: number suggestions: PermissionUpdate[] + autoApprove?: boolean } export type ToolPermissionResultPayload = { @@ -21,9 +22,10 @@ export type ToolPermissionResultPayload = { behavior: 'allow' | 'deny' message?: string reason: 'response' | 'timeout' | 'aborted' | 'no-window' + toolCallId?: string } -export type ToolPermissionStatus = 'pending' | 'submitting-allow' | 'submitting-deny' +export type ToolPermissionStatus = 'pending' | 'submitting-allow' | 'submitting-deny' | 'invoking' export type ToolPermissionEntry = ToolPermissionRequestPayload & { status: ToolPermissionStatus @@ -61,8 +63,24 @@ const toolPermissionsSlice = createSlice({ entry.status = 'pending' }, requestResolved: (state, action: PayloadAction) => { - const { requestId } = action.payload - delete state.requests[requestId] + const { requestId, behavior } = action.payload + const entry = state.requests[requestId] + + if (!entry) return + + if (behavior === 'allow') { + entry.status = 'invoking' + } else { + delete state.requests[requestId] + } + }, + removeByToolCallId: (state, action: PayloadAction<{ toolCallId: string }>) => { + const { toolCallId } = action.payload + + const entryId = Object.keys(state.requests).find((key) => state.requests[key]?.toolCallId === toolCallId) + if (entryId) { + delete state.requests[entryId] + } }, clearAll: (state) => { state.requests = {} @@ -73,8 +91,8 @@ const toolPermissionsSlice = createSlice({ export const toolPermissionsActions = toolPermissionsSlice.actions export const selectActiveToolPermission = (state: ToolPermissionsState): ToolPermissionEntry | null => { - const activeEntries = Object.values(state.requests).filter( - (entry) => entry.status === 'pending' || entry.status === 'submitting-allow' || entry.status === 'submitting-deny' + const activeEntries = Object.values(state.requests).filter((entry) => + ['pending', 'submitting-allow', 'submitting-deny', 'invoking'].includes(entry.status) ) if (activeEntries.length === 0) return null @@ -89,9 +107,7 @@ export const selectPendingPermission = ( ): ToolPermissionEntry | undefined => { const activeEntries = Object.values(state.requests) .filter((entry) => entry.toolCallId === toolCallId) - .filter( - (entry) => entry.status === 'pending' || entry.status === 'submitting-allow' || entry.status === 'submitting-deny' - ) + .filter((entry) => ['pending', 'submitting-allow', 'submitting-deny', 'invoking'].includes(entry.status)) if (activeEntries.length === 0) return undefined