diff --git a/src/main/events.ts b/src/main/events.ts index 1dee34b2c..3877802a8 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -24,7 +24,6 @@ export const CONFIG_EVENTS = { AUTO_SCROLL_CHANGED: 'config:auto-scroll-changed', NOTIFICATIONS_CHANGED: 'config:notifications-changed', CONTENT_PROTECTION_CHANGED: 'config:content-protection-changed', - SOUND_ENABLED_CHANGED: 'config:sound-enabled-changed', // 新增:声音开关变更事件 COPY_WITH_COT_CHANGED: 'config:copy-with-cot-enabled-changed', TRACE_DEBUG_CHANGED: 'config:trace-debug-changed', // Trace 调试功能开关变更事件 PROXY_RESOLVED: 'config:proxy-resolved', diff --git a/src/main/presenter/agentPresenter/acp/chatSettingsTools.ts b/src/main/presenter/agentPresenter/acp/chatSettingsTools.ts index ec81a3fa8..ee856bb96 100644 --- a/src/main/presenter/agentPresenter/acp/chatSettingsTools.ts +++ b/src/main/presenter/agentPresenter/acp/chatSettingsTools.ts @@ -45,7 +45,7 @@ const FONT_SIZE_LEVELS = [0, 1, 2, 3, 4] as const const toggleSchema = z .object({ - setting: z.enum(['soundEnabled', 'copyWithCotEnabled']).describe('Toggle setting id.'), + setting: z.enum(['copyWithCotEnabled']).describe('Toggle setting id.'), enabled: z.boolean().describe('Enable or disable the setting.') }) .strict() @@ -192,8 +192,6 @@ export class ChatSettingsToolHandler { private getCurrentValue(key: string): ChatSettingValue | undefined { const configPresenter = this.options.configPresenter switch (key) { - case 'soundEnabled': - return configPresenter.getSoundEnabled() case 'copyWithCotEnabled': return configPresenter.getCopyWithCotEnabled() case 'language': @@ -224,9 +222,6 @@ export class ChatSettingsToolHandler { try { switch (setting) { - case 'soundEnabled': - configPresenter.setSoundEnabled(enabled) - break case 'copyWithCotEnabled': configPresenter.setCopyWithCotEnabled(enabled) break @@ -409,7 +404,7 @@ export const buildChatSettingsToolDefinitions = (allowedTools: string[]): MCPToo type: 'function', function: { name: CHAT_SETTINGS_TOOL_NAMES.toggle, - description: 'Toggle a DeepChat setting (sound or copy COT).', + description: 'Toggle a DeepChat setting.', parameters: zodToJsonSchema(toggleSchema) as { type: string properties: Record diff --git a/src/main/presenter/agentPresenter/index.ts b/src/main/presenter/agentPresenter/index.ts index cf027cbc5..73632d82c 100644 --- a/src/main/presenter/agentPresenter/index.ts +++ b/src/main/presenter/agentPresenter/index.ts @@ -137,19 +137,6 @@ export class AgentPresenter implements IAgentPresenter { false, this.buildMessageMetadata(conversation) ) - try { - const promptPreview = this.extractUserMessageText(content) - presenter.hooksNotifications.dispatchEvent('UserPromptSubmit', { - conversationId: agentId, - messageId: userMessage.id, - promptPreview, - providerId: conversation.settings.providerId, - modelId: conversation.settings.modelId - }) - } catch (error) { - console.warn('[AgentPresenter] Failed to dispatch UserPromptSubmit hook:', error) - } - try { await this.resolvePendingQuestionIfNeeded(agentId, userMessage.id, content) } catch (error) { diff --git a/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts b/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts index 97a561ee7..9f3f533ae 100644 --- a/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts +++ b/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts @@ -11,7 +11,6 @@ import path from 'path' import { isNonRetryableError } from './errorClassification' import { resolveToolOffloadPath } from '../../sessionPresenter/sessionPaths' import { parseQuestionToolArgs, QUESTION_TOOL_NAME } from '../tools/questionTool' -import { presenter } from '@/presenter' interface ToolCallProcessorOptions { getAllToolDefinitions: (context: ToolCallExecutionContext) => Promise @@ -104,8 +103,6 @@ export class ToolCallProcessor { ): AsyncGenerator { let toolCallCount = context.currentToolCallCount let needContinueConversation = context.toolCalls.length > 0 - const shouldDispatchToolHooks = context.providerId === 'acp' - let toolDefinitions = await this.options.getAllToolDefinitions(context) // Step 1: Pre-check all tool permissions in batch @@ -311,21 +308,6 @@ export class ToolCallProcessor { } try { - if (shouldDispatchToolHooks) { - try { - presenter.hooksNotifications.dispatchEvent('PreToolUse', { - conversationId: context.conversationId, - tool: { - callId: toolCall.id, - name: toolCall.name, - params: toolCall.arguments - } - }) - } catch (error) { - console.warn('[ToolCallProcessor] Failed to dispatch PreToolUse hook:', error) - } - } - const toolResponse = await this.options.callTool(mcpToolInput) const requiresPermission = Boolean(toolResponse.rawData?.requiresPermission) @@ -369,22 +351,6 @@ export class ToolCallProcessor { toolCall.name ) - if (shouldDispatchToolHooks) { - try { - presenter.hooksNotifications.dispatchEvent('PostToolUse', { - conversationId: context.conversationId, - tool: { - callId: toolCall.id, - name: toolCall.name, - params: toolCall.arguments, - response: toolContent - } - }) - } catch (error) { - console.warn('[ToolCallProcessor] Failed to dispatch PostToolUse hook:', error) - } - } - if (supportsFunctionCall) { this.appendNativeFunctionCallMessages(context.conversationMessages, toolCall, { content: toolContentForModel @@ -436,22 +402,6 @@ export class ToolCallProcessor { ) const errorMessage = toolError instanceof Error ? toolError.message : String(toolError) - if (shouldDispatchToolHooks) { - try { - presenter.hooksNotifications.dispatchEvent('PostToolUseFailure', { - conversationId: context.conversationId, - tool: { - callId: toolCall.id, - name: toolCall.name, - params: toolCall.arguments, - error: errorMessage - } - }) - } catch (error) { - console.warn('[ToolCallProcessor] Failed to dispatch PostToolUseFailure hook:', error) - } - } - // Check if error is non-retryable (should stop the loop) const errorForClassification: Error | string = toolError instanceof Error ? toolError : String(toolError) diff --git a/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts b/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts index 780823d70..60e67125a 100644 --- a/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts +++ b/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts @@ -151,51 +151,7 @@ export class LLMEventHandler { if (tool_call && !shouldSkipToolCall) { if (isAcpProvider) { - try { - if (tool_call === 'start') { - presenter.hooksNotifications.dispatchEvent('PreToolUse', { - conversationId: state.conversationId, - messageId: eventId, - providerId: state.message.model_provider, - modelId: state.message.model_id, - tool: { - callId: tool_call_id, - name: tool_call_name, - params: tool_call_params - } - }) - } - if (tool_call === 'end') { - presenter.hooksNotifications.dispatchEvent('PostToolUse', { - conversationId: state.conversationId, - messageId: eventId, - providerId: state.message.model_provider, - modelId: state.message.model_id, - tool: { - callId: tool_call_id, - name: tool_call_name, - params: tool_call_params, - response: msg.tool_call_response ? String(msg.tool_call_response) : undefined - } - }) - } - if (tool_call === 'error') { - presenter.hooksNotifications.dispatchEvent('PostToolUseFailure', { - conversationId: state.conversationId, - messageId: eventId, - providerId: state.message.model_provider, - modelId: state.message.model_id, - tool: { - callId: tool_call_id, - name: tool_call_name, - params: tool_call_params, - error: msg.tool_call_response ? String(msg.tool_call_response) : undefined - } - }) - } - } catch (error) { - console.warn('[LLMEventHandler] Failed to dispatch ACP tool hooks:', error) - } + // Legacy hook dispatch disabled. New session hooks are emitted by the new agent pipeline. } switch (tool_call) { @@ -217,22 +173,6 @@ export class LLMEventHandler { payload: msg.permission_request ?? {} }) presenter.sessionManager.setStatus(state.conversationId, 'waiting_permission') - try { - presenter.hooksNotifications.dispatchEvent('PermissionRequest', { - conversationId: state.conversationId, - messageId: eventId, - providerId: state.message.model_provider, - modelId: state.message.model_id, - tool: { - callId: tool_call_id, - name: tool_call_name, - params: tool_call_params - }, - permission: msg.permission_request ?? null - }) - } catch (error) { - console.warn('[LLMEventHandler] Failed to dispatch PermissionRequest hook:', error) - } await this.toolCallHandler.processToolCallPermission(state, msg, currentTime) break case 'question-required': @@ -445,8 +385,6 @@ export class LLMEventHandler { async handleLLMAgentEnd(msg: LLMAgentEventData): Promise { const { eventId, userStop } = msg const state = this.generatingMessages.get(eventId) - const errorSnapshot = this.errorByEventId.get(eventId) - if (state) { if (state.adaptiveBuffer) { await this.contentBufferHandler.flushAdaptiveBuffer(eventId) @@ -512,51 +450,8 @@ export class LLMEventHandler { presenter.sessionManager.clearPendingQuestion(state.conversationId) } - const stopReason = errorSnapshot ? 'error' : userStop ? 'user_stop' : 'complete' - const stopPayload = { - reason: stopReason, - userStop: Boolean(userStop) - } - const usage = state?.totalUsage ?? errorSnapshot?.usage ?? null - const errorInfo = errorSnapshot?.error ?? null - let conversationId = state?.conversationId ?? errorSnapshot?.conversationId - let providerId = state?.message.model_provider ?? errorSnapshot?.providerId - let modelId = state?.message.model_id ?? errorSnapshot?.modelId - - if (!conversationId) { - try { - const message = await this.messageManager.getMessage(eventId) - conversationId = message.conversationId - providerId = providerId ?? message.model_provider - modelId = modelId ?? message.model_id - } catch { - // ignore - } - } - try { - try { - presenter.hooksNotifications.dispatchEvent('Stop', { - conversationId, - providerId, - modelId, - stop: stopPayload - }) - } catch (error) { - console.warn('[LLMEventHandler] Failed to dispatch Stop hook:', error) - } - try { - presenter.hooksNotifications.dispatchEvent('SessionEnd', { - conversationId, - providerId, - modelId, - stop: stopPayload, - usage, - error: errorInfo - }) - } catch (error) { - console.warn('[LLMEventHandler] Failed to dispatch SessionEnd hook:', error) - } + // Legacy hook dispatch disabled. New session hooks are emitted by the new agent pipeline. } finally { this.errorByEventId.delete(eventId) } diff --git a/src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts b/src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts index d1c93b3d0..6dbf443d4 100644 --- a/src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts +++ b/src/main/presenter/agentPresenter/streaming/streamGenerationHandler.ts @@ -118,19 +118,6 @@ export class StreamGenerationHandler extends BaseHandler { verbosity: currentVerbosity } = currentConversation.settings - try { - presenter.hooksNotifications.dispatchEvent('SessionStart', { - conversationId, - messageId: userMessage.id, - promptPreview: userContent, - providerId: currentProviderId, - modelId: currentModelId, - workdir: agentWorkspacePath ?? null - }) - } catch (error) { - console.warn('[StreamGenerationHandler] Failed to dispatch SessionStart hook:', error) - } - const stream = this.ctx.llmProviderPresenter.startStreamCompletion( currentProviderId, finalContent, @@ -258,18 +245,6 @@ export class StreamGenerationHandler extends BaseHandler { await this.updateGenerationState(state, promptTokens) - try { - presenter.hooksNotifications.dispatchEvent('SessionStart', { - conversationId, - messageId: userMessage.id, - promptPreview: 'continue', - providerId, - modelId - }) - } catch (error) { - console.warn('[StreamGenerationHandler] Failed to dispatch SessionStart hook:', error) - } - if (toolCallResponse && toolCall) { eventBus.sendToRenderer(STREAM_EVENTS.RESPONSE, SendTarget.ALL_WINDOWS, { eventId: state.message.id, diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index fa989c534..3c123abae 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -83,7 +83,6 @@ interface IAppSettings { syncFolderPath?: string // Sync folder path lastSyncTime?: number // Last sync time customSearchEngines?: string // Custom search engines JSON string - soundEnabled?: boolean // Whether sound effects are enabled copyWithCotEnabled?: boolean loggingEnabled?: boolean // Whether logging is enabled floatingButtonEnabled?: boolean // Whether floating button is enabled @@ -153,7 +152,6 @@ export class ConfigPresenter implements IConfigPresenter { syncEnabled: false, syncFolderPath: path.join(this.userDataPath, 'sync'), lastSyncTime: 0, - soundEnabled: false, copyWithCotEnabled: true, loggingEnabled: false, floatingButtonEnabled: false, @@ -962,18 +960,6 @@ export class ConfigPresenter implements IConfigPresenter { }, 1000) } - // Get sound effects switch status - getSoundEnabled(): boolean { - const value = this.getSetting('soundEnabled') ?? false - return value === undefined || value === null ? false : value - } - - // Set sound effects switch status - setSoundEnabled(enabled: boolean): void { - this.setSetting('soundEnabled', enabled) - eventBus.sendToRenderer(CONFIG_EVENTS.SOUND_ENABLED_CHANGED, SendTarget.ALL_WINDOWS, enabled) - } - getCopyWithCotEnabled(): boolean { return this.uiSettingsHelper.getCopyWithCotEnabled() } diff --git a/src/main/presenter/deepchatAgentPresenter/dispatch.ts b/src/main/presenter/deepchatAgentPresenter/dispatch.ts index 2b5ffe5ba..79db30b22 100644 --- a/src/main/presenter/deepchatAgentPresenter/dispatch.ts +++ b/src/main/presenter/deepchatAgentPresenter/dispatch.ts @@ -6,7 +6,7 @@ import type { MCPToolCall, MCPContentItem, MCPResourceContent } from '@shared/ty import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' import type { AssistantMessageBlock, PermissionMode } from '@shared/types/agent-interface' import { parseQuestionToolArgs, QUESTION_TOOL_NAME } from '../agentPresenter/tools/questionTool' -import type { IoParams, PendingToolInteraction, StreamState } from './types' +import type { IoParams, PendingToolInteraction, ProcessHooks, StreamState } from './types' import type { ChatMessage } from '@shared/types/core/chat-message' import { nanoid } from 'nanoid' import type { ToolOutputGuard } from './toolOutputGuard' @@ -380,7 +380,8 @@ export async function executeTools( permissionMode: PermissionMode, toolOutputGuard: ToolOutputGuard, contextLength: number, - maxTokens: number + maxTokens: number, + hooks?: ProcessHooks ): Promise<{ executed: number pendingInteractions: PendingToolInteraction[] @@ -487,6 +488,11 @@ export async function executeTools( if (permissionMode === 'full_access') { await autoGrantPermission(io.sessionId, preCheckedPermission) } else { + hooks?.onPermissionRequest?.(preCheckedPermission, { + callId: tc.id, + name: tc.name, + params: tc.arguments + }) const interaction = appendPermissionActionBlock( state, io, @@ -499,6 +505,12 @@ export async function executeTools( } } + hooks?.onPreToolUse?.({ + callId: tc.id, + name: tc.name, + params: tc.arguments + }) + const toolCallResult = await toolPresenter.callTool(toolCall) let toolRawData = toolCallResult.rawData @@ -518,6 +530,11 @@ export async function executeTools( const retryCallResult = await toolPresenter.callTool(toolCall) toolRawData = retryCallResult.rawData } else { + hooks?.onPermissionRequest?.(pendingPermission, { + callId: tc.id, + name: tc.name, + params: tc.arguments + }) const interaction = appendPermissionActionBlock( state, io, @@ -563,6 +580,12 @@ export async function executeTools( if (guardedResult.kind === 'terminal_error') { updateToolCallBlock(state.blocks, tc.id, guardedResult.message, true) + hooks?.onPostToolUseFailure?.({ + callId: tc.id, + name: tc.name, + params: tc.arguments, + error: guardedResult.message + }) state.dirty = true executed += 1 flushBlocksToRenderer(io, state.blocks) @@ -583,6 +606,21 @@ export async function executeTools( content: toolMessageContent }) updateToolCallBlock(state.blocks, tc.id, toolMessageContent, isToolError) + if (isToolError) { + hooks?.onPostToolUseFailure?.({ + callId: tc.id, + name: tc.name, + params: tc.arguments, + error: toolMessageContent + }) + } else { + hooks?.onPostToolUse?.({ + callId: tc.id, + name: tc.name, + params: tc.arguments, + response: toolMessageContent + }) + } } catch (err) { const errorText = err instanceof Error ? err.message : String(err) conversation.push({ @@ -591,6 +629,12 @@ export async function executeTools( content: `Error: ${errorText}` }) updateToolCallBlock(state.blocks, tc.id, `Error: ${errorText}`, true) + hooks?.onPostToolUseFailure?.({ + callId: tc.id, + name: tc.name, + params: tc.arguments, + error: `Error: ${errorText}` + }) } state.dirty = true diff --git a/src/main/presenter/deepchatAgentPresenter/index.ts b/src/main/presenter/deepchatAgentPresenter/index.ts index 487dd9e68..417e74dcc 100644 --- a/src/main/presenter/deepchatAgentPresenter/index.ts +++ b/src/main/presenter/deepchatAgentPresenter/index.ts @@ -39,6 +39,7 @@ import { DeepChatSessionStore, type SessionSummaryState } from './sessionStore' import type { PendingToolInteraction, ProcessResult } from './types' import { ToolOutputGuard } from './toolOutputGuard' import type { ProviderRequestTracePayload } from '../llmProviderPresenter/requestTrace' +import type { NewSessionHooksBridge } from '../hooksNotifications/newSessionBridge' type PendingInteractionEntry = { interaction: PendingToolInteraction @@ -92,12 +93,14 @@ const isVerbosity = (value: unknown): value is SessionGenerationSettings['verbos export class DeepChatAgentPresenter implements IAgentImplementation { private readonly llmProviderPresenter: ILlmProviderPresenter private readonly configPresenter: IConfigPresenter + private readonly sqlitePresenter: SQLitePresenter private readonly toolPresenter: IToolPresenter | null private readonly sessionStore: DeepChatSessionStore private readonly messageStore: DeepChatMessageStore private readonly runtimeState: Map = new Map() private readonly sessionGenerationSettings: Map = new Map() private readonly abortControllers: Map = new Map() + private readonly sessionAgentIds: Map = new Map() private readonly sessionProjectDirs: Map = new Map() private readonly systemPromptCache: Map = new Map() private readonly sessionCompactionStates: Map = new Map() @@ -105,15 +108,18 @@ export class DeepChatAgentPresenter implements IAgentImplementation { private readonly resumingMessages: Set = new Set() private readonly compactionService: CompactionService private readonly toolOutputGuard: ToolOutputGuard + private readonly hooksBridge?: NewSessionHooksBridge constructor( llmProviderPresenter: ILlmProviderPresenter, configPresenter: IConfigPresenter, sqlitePresenter: SQLitePresenter, - toolPresenter?: IToolPresenter + toolPresenter?: IToolPresenter, + hooksBridge?: NewSessionHooksBridge ) { this.llmProviderPresenter = llmProviderPresenter this.configPresenter = configPresenter + this.sqlitePresenter = sqlitePresenter this.toolPresenter = toolPresenter ?? null this.sessionStore = new DeepChatSessionStore(sqlitePresenter) this.messageStore = new DeepChatMessageStore(sqlitePresenter) @@ -124,6 +130,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { this.configPresenter ) this.toolOutputGuard = new ToolOutputGuard() + this.hooksBridge = hooksBridge const recovered = this.messageStore.recoverPendingMessages() if (recovered > 0) { @@ -134,6 +141,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { async initSession( sessionId: string, config: { + agentId?: string providerId: string modelId: string projectDir?: string | null @@ -159,6 +167,10 @@ export class DeepChatAgentPresenter implements IAgentImplementation { permissionMode, generationSettings ) + this.sessionAgentIds.set( + sessionId, + config.agentId?.trim() || this.getSessionAgentId(sessionId) || 'deepchat' + ) this.sessionProjectDirs.set(sessionId, projectDir) this.sessionGenerationSettings.set(sessionId, generationSettings) this.runtimeState.set(sessionId, { @@ -181,6 +193,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { this.messageStore.deleteBySession(sessionId) this.sessionStore.delete(sessionId) this.runtimeState.delete(sessionId) + this.sessionAgentIds.delete(sessionId) this.sessionGenerationSettings.delete(sessionId) this.sessionProjectDirs.delete(sessionId) this.systemPromptCache.delete(sessionId) @@ -190,6 +203,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { async getSessionState(sessionId: string): Promise { const state = this.runtimeState.get(sessionId) if (state) { + this.getSessionAgentId(sessionId) if (this.hasPendingInteractions(sessionId)) { state.status = 'generating' } @@ -200,6 +214,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { const dbSession = this.sessionStore.get(sessionId) if (!dbSession) return null + this.getSessionAgentId(sessionId) const rebuilt: DeepChatSessionState = { status: this.hasPendingInteractions(sessionId) ? 'generating' : 'idle', providerId: dbSession.provider_id, @@ -293,6 +308,15 @@ export class DeepChatAgentPresenter implements IAgentImplementation { ) } + this.dispatchHook('UserPromptSubmit', { + sessionId, + messageId: userMessageId, + promptPreview: normalizedInput.text, + providerId: state.providerId, + modelId: state.modelId, + projectDir + }) + const systemPrompt = appendSummarySection(baseSystemPrompt, summaryState.summaryText) const messages = buildContext( sessionId, @@ -322,11 +346,27 @@ export class DeepChatAgentPresenter implements IAgentImplementation { sessionId, messageId: assistantMessageId, messages, - projectDir + projectDir, + promptPreview: normalizedInput.text }) this.applyProcessResultStatus(sessionId, result) } catch (err) { console.error('[DeepChatAgent] processMessage error:', err) + const errorMessage = err instanceof Error ? err.message : String(err) + this.dispatchHook('Stop', { + sessionId, + providerId: state.providerId, + modelId: state.modelId, + projectDir, + stop: { reason: 'error', userStop: false } + }) + this.dispatchHook('SessionEnd', { + sessionId, + providerId: state.providerId, + modelId: state.modelId, + projectDir, + error: { message: errorMessage } + }) this.setSessionStatus(sessionId, 'error') } } @@ -365,6 +405,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { let waitingForUserMessage = false let resumeBudgetToolCall: ResumeBudgetToolCall | null = null + let emitResolvedToolHook: (() => void) | null = null const actionBlock = blocks[currentEntry.blockIndex] const toolCall = actionBlock.tool_call if (!toolCall?.id) { @@ -397,12 +438,40 @@ export class DeepChatAgentPresenter implements IAgentImplementation { } const permissionPayload = this.parsePermissionPayload(actionBlock) const permissionType = permissionPayload?.permissionType ?? 'write' + const state = this.runtimeState.get(sessionId) + const projectDir = this.resolveProjectDir(sessionId) + let shouldDispatchResolvedToolHook = false if (response.granted) { this.markPermissionResolved(actionBlock, true, permissionType) await this.grantPermissionForPayload(sessionId, permissionPayload, toolCall) + this.dispatchHook('PreToolUse', { + sessionId, + messageId, + providerId: state?.providerId, + modelId: state?.modelId, + projectDir, + tool: { + callId: toolCall.id, + name: toolCall.name, + params: toolCall.params + } + }) const execution = await this.executeDeferredToolCall(sessionId, toolCall) if (execution.terminalError) { + this.dispatchHook('PostToolUseFailure', { + sessionId, + messageId, + providerId: state?.providerId, + modelId: state?.modelId, + projectDir, + tool: { + callId: toolCall.id, + name: toolCall.name, + params: toolCall.params, + error: execution.terminalError + } + }) this.updateToolCallResponse(blocks, toolCall.id, execution.terminalError, true) this.messageStore.setMessageError(messageId, blocks) this.emitMessageRefresh(sessionId, messageId) @@ -412,6 +481,22 @@ export class DeepChatAgentPresenter implements IAgentImplementation { messageId, error: execution.terminalError }) + this.dispatchHook('Stop', { + sessionId, + messageId, + providerId: state?.providerId, + modelId: state?.modelId, + projectDir, + stop: { reason: 'error', userStop: false } + }) + this.dispatchHook('SessionEnd', { + sessionId, + messageId, + providerId: state?.providerId, + modelId: state?.modelId, + projectDir, + error: { message: execution.terminalError } + }) this.setSessionStatus(sessionId, 'error') return { resumed: false } } @@ -428,6 +513,19 @@ export class DeepChatAgentPresenter implements IAgentImplementation { } if (execution.requiresPermission && execution.permissionRequest) { + this.dispatchHook('PermissionRequest', { + sessionId, + messageId, + providerId: state?.providerId, + modelId: state?.modelId, + projectDir, + permission: execution.permissionRequest, + tool: { + callId: toolCall.id, + name: toolCall.name, + params: toolCall.params + } + }) actionBlock.status = 'pending' actionBlock.content = execution.permissionRequest.description actionBlock.extra = { @@ -436,11 +534,28 @@ export class DeepChatAgentPresenter implements IAgentImplementation { permissionType: execution.permissionRequest.permissionType, permissionRequest: JSON.stringify(execution.permissionRequest) } + } else { + shouldDispatchResolvedToolHook = true } } else { this.markPermissionResolved(actionBlock, false, permissionType) this.updateToolCallResponse(blocks, toolCall.id, 'User denied the request.', true) + shouldDispatchResolvedToolHook = true } + + emitResolvedToolHook = shouldDispatchResolvedToolHook + ? () => { + this.dispatchResolvedToolHook({ + sessionId, + messageId, + providerId: state?.providerId, + modelId: state?.modelId, + projectDir, + blocks, + toolCall + }) + } + : null } else { throw new Error(`Unsupported action type: ${actionBlock.action_type}`) } @@ -450,12 +565,14 @@ export class DeepChatAgentPresenter implements IAgentImplementation { this.emitMessageRefresh(sessionId, messageId) if (remainingPending.length > 0) { + emitResolvedToolHook?.() this.messageStore.updateMessageStatus(messageId, 'pending') this.setSessionStatus(sessionId, 'generating') return { resumed: false } } if (waitingForUserMessage) { + emitResolvedToolHook?.() this.messageStore.updateMessageStatus(messageId, 'sent') this.setSessionStatus(sessionId, 'idle') return { resumed: false, waitingForUserMessage: true } @@ -467,6 +584,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { blocks, resumeBudgetToolCall ) + emitResolvedToolHook?.() return { resumed } } finally { this.interactionLocks.delete(lockKey) @@ -578,6 +696,144 @@ export class DeepChatAgentPresenter implements IAgentImplementation { this.setSessionStatus(sessionId, 'idle') } + private dispatchTerminalHooks( + sessionId: string, + state: DeepChatSessionState | undefined, + result: ProcessResult + ): void { + if (!state || result.status === 'paused') { + return + } + + this.dispatchHook('Stop', { + sessionId, + providerId: state.providerId, + modelId: state.modelId, + projectDir: this.resolveProjectDir(sessionId), + stop: { + reason: + result.stopReason ?? + (result.status === 'completed' + ? 'complete' + : result.status === 'aborted' + ? 'user_stop' + : 'error'), + userStop: result.status === 'aborted' + } + }) + this.dispatchHook('SessionEnd', { + sessionId, + providerId: state.providerId, + modelId: state.modelId, + projectDir: this.resolveProjectDir(sessionId), + usage: result.usage ?? null, + error: + result.errorMessage || result.terminalError + ? { + message: result.errorMessage ?? result.terminalError + } + : null + }) + } + + private dispatchHook( + event: + | 'UserPromptSubmit' + | 'SessionStart' + | 'PreToolUse' + | 'PostToolUse' + | 'PostToolUseFailure' + | 'PermissionRequest' + | 'Stop' + | 'SessionEnd', + context: { + sessionId: string + messageId?: string + promptPreview?: string + providerId?: string + modelId?: string + projectDir?: string | null + tool?: { + callId?: string + name?: string + params?: string + response?: string + error?: string + } + permission?: Record | null + stop?: { + reason?: string + userStop?: boolean + } | null + usage?: Record | null + error?: { + message?: string + stack?: string + } | null + } + ): void { + try { + this.hooksBridge?.dispatch(event, { + ...context, + agentId: this.getSessionAgentId(context.sessionId) ?? 'deepchat' + }) + } catch (error) { + console.warn(`[DeepChatAgent] Failed to dispatch ${event} hook:`, error) + } + } + + private getSessionAgentId(sessionId: string): string | undefined { + const cached = this.sessionAgentIds.get(sessionId)?.trim() + if (cached) { + return cached + } + + const persisted = this.sqlitePresenter.newSessionsTable?.get(sessionId)?.agent_id?.trim() + if (persisted) { + this.sessionAgentIds.set(sessionId, persisted) + return persisted + } + + return undefined + } + + private dispatchResolvedToolHook(params: { + sessionId: string + messageId: string + providerId?: string + modelId?: string + projectDir?: string | null + blocks: AssistantMessageBlock[] + toolCall: NonNullable + }): void { + const resolvedBlock = params.blocks.find( + (block) => block.type === 'tool_call' && block.tool_call?.id === params.toolCall.id + ) + const responseText = resolvedBlock?.tool_call?.response ?? '' + const isError = resolvedBlock?.status === 'error' + + this.dispatchHook(isError ? 'PostToolUseFailure' : 'PostToolUse', { + sessionId: params.sessionId, + messageId: params.messageId, + providerId: params.providerId, + modelId: params.modelId, + projectDir: params.projectDir, + tool: isError + ? { + callId: params.toolCall.id, + name: params.toolCall.name, + params: params.toolCall.params, + error: responseText + } + : { + callId: params.toolCall.id, + name: params.toolCall.name, + params: params.toolCall.params, + response: responseText + } + }) + } + async getMessages(sessionId: string): Promise { return this.messageStore.getMessages(sessionId) } @@ -740,8 +996,17 @@ export class DeepChatAgentPresenter implements IAgentImplementation { projectDir: string | null tools?: MCPToolDefinition[] initialBlocks?: AssistantMessageBlock[] + promptPreview?: string }): Promise { - const { sessionId, messageId, messages, projectDir, tools: providedTools, initialBlocks } = args + const { + sessionId, + messageId, + messages, + projectDir, + tools: providedTools, + initialBlocks, + promptPreview + } = args const state = this.runtimeState.get(sessionId) if (!state) { throw new Error(`Session ${sessionId} not found`) @@ -805,6 +1070,15 @@ export class DeepChatAgentPresenter implements IAgentImplementation { this.abortControllers.set(sessionId, abortController) try { + this.dispatchHook('SessionStart', { + sessionId, + messageId, + promptPreview, + providerId: state.providerId, + modelId: state.modelId, + projectDir + }) + return await processStream({ messages, tools, @@ -818,6 +1092,49 @@ export class DeepChatAgentPresenter implements IAgentImplementation { permissionMode: state.permissionMode, toolOutputGuard: this.toolOutputGuard, initialBlocks, + hooks: { + onPreToolUse: (tool) => { + this.dispatchHook('PreToolUse', { + sessionId, + messageId, + providerId: state.providerId, + modelId: state.modelId, + projectDir, + tool + }) + }, + onPostToolUse: (tool) => { + this.dispatchHook('PostToolUse', { + sessionId, + messageId, + providerId: state.providerId, + modelId: state.modelId, + projectDir, + tool + }) + }, + onPostToolUseFailure: (tool) => { + this.dispatchHook('PostToolUseFailure', { + sessionId, + messageId, + providerId: state.providerId, + modelId: state.modelId, + projectDir, + tool + }) + }, + onPermissionRequest: (permission, tool) => { + this.dispatchHook('PermissionRequest', { + sessionId, + messageId, + providerId: state.providerId, + modelId: state.modelId, + projectDir, + permission, + tool + }) + } + }, io: { sessionId, messageId, @@ -837,11 +1154,13 @@ export class DeepChatAgentPresenter implements IAgentImplementation { sessionId: string, result: ProcessResult | null | undefined ): void { + const state = this.runtimeState.get(sessionId) if (!result || !result.status) { this.setSessionStatus(sessionId, 'idle') return } if (result.status === 'completed') { + this.dispatchTerminalHooks(sessionId, state, result) this.setSessionStatus(sessionId, 'idle') return } @@ -850,9 +1169,11 @@ export class DeepChatAgentPresenter implements IAgentImplementation { return } if (result.status === 'aborted') { + this.dispatchTerminalHooks(sessionId, state, result) this.setSessionStatus(sessionId, 'idle') return } + this.dispatchTerminalHooks(sessionId, state, result) this.setSessionStatus(sessionId, 'error') } diff --git a/src/main/presenter/deepchatAgentPresenter/process.ts b/src/main/presenter/deepchatAgentPresenter/process.ts index 5c059029c..1b12b7ada 100644 --- a/src/main/presenter/deepchatAgentPresenter/process.ts +++ b/src/main/presenter/deepchatAgentPresenter/process.ts @@ -56,6 +56,7 @@ export async function processStream(params: ProcessParams): Promise 0 ? modelConfig.contextLength : UNKNOWN_CONTEXT_LIMIT, - maxTokens + maxTokens, + hooks ) toolCallCount += executed.executed echo.flush() @@ -155,7 +160,10 @@ export async function processStream(params: ProcessParams): Promise { + const usage: Record = {} + if (typeof state.metadata.totalTokens === 'number') { + usage.totalTokens = state.metadata.totalTokens + } + if (typeof state.metadata.inputTokens === 'number') { + usage.inputTokens = state.metadata.inputTokens + } + if (typeof state.metadata.outputTokens === 'number') { + usage.outputTokens = state.metadata.outputTokens + } + return usage +} diff --git a/src/main/presenter/deepchatAgentPresenter/types.ts b/src/main/presenter/deepchatAgentPresenter/types.ts index b4ca8561c..a3da6dac3 100644 --- a/src/main/presenter/deepchatAgentPresenter/types.ts +++ b/src/main/presenter/deepchatAgentPresenter/types.ts @@ -38,6 +38,30 @@ export interface IoParams { abortSignal: AbortSignal } +export interface ProcessHooks { + onPreToolUse?: (tool: { callId?: string; name?: string; params?: string }) => void + onPostToolUse?: (tool: { + callId?: string + name?: string + params?: string + response?: string + }) => void + onPostToolUseFailure?: (tool: { + callId?: string + name?: string + params?: string + error?: string + }) => void + onPermissionRequest?: ( + permission: Record, + tool: { + callId?: string + name?: string + params?: string + } + ) => void +} + export interface PendingToolInteraction { type: 'question' | 'permission' messageId: string @@ -79,6 +103,9 @@ export interface ProcessResult { status: 'completed' | 'paused' | 'aborted' | 'error' pendingInteractions?: PendingToolInteraction[] terminalError?: string + stopReason?: string + usage?: Record + errorMessage?: string } export interface ProcessParams { @@ -101,6 +128,7 @@ export interface ProcessParams { permissionMode: PermissionMode toolOutputGuard: ToolOutputGuard initialBlocks?: AssistantMessageBlock[] + hooks?: ProcessHooks io: IoParams } diff --git a/src/main/presenter/hooksNotifications/index.ts b/src/main/presenter/hooksNotifications/index.ts index 7df94037c..8c7061688 100644 --- a/src/main/presenter/hooksNotifications/index.ts +++ b/src/main/presenter/hooksNotifications/index.ts @@ -3,7 +3,7 @@ import log from 'electron-log' import { spawn } from 'child_process' import fs from 'fs' import path from 'path' -import type { IConfigPresenter, ISessionPresenter } from '@shared/presenter' +import type { IConfigPresenter } from '@shared/presenter' import { RuntimeHelper } from '@/lib/runtimeHelper' import { HookCommandResult, @@ -21,7 +21,7 @@ const TRUNCATION_SUFFIX = ' ...(truncated)' const MAX_RETRIES = 2 const CONFIRMO_HOOK_RELATIVE_PATH = path.join('.confirmo', 'hooks', 'confirmo-hook.js') -type HookDispatchContext = { +export type HookDispatchContext = { conversationId?: string messageId?: string promptPreview?: string @@ -49,6 +49,26 @@ type HookDispatchContext = { isTest?: boolean } +type HookConversationLookup = { + settings: { + providerId?: string + modelId?: string + } +} + +type HookMessageLookup = { + content: unknown +} + +type HooksNotificationsDeps = { + getConversation?: (conversationId: string) => Promise + getMessage?: (messageId: string) => Promise + resolveWorkspaceContext?: ( + conversationId?: string, + modelId?: string + ) => Promise<{ agentWorkspacePath: string | null }> +} + class SerialQueue { private chain: Promise = Promise.resolve() @@ -129,13 +149,7 @@ export class HooksNotificationsService { constructor( private readonly configPresenter: IConfigPresenter, - private readonly deps: { - sessionPresenter: ISessionPresenter - resolveWorkspaceContext: ( - conversationId?: string, - modelId?: string - ) => Promise<{ agentWorkspacePath: string | null }> - } + private readonly deps: HooksNotificationsDeps ) {} getConfigSnapshot(): HooksNotificationsSettings { @@ -251,12 +265,15 @@ export class HooksNotificationsService { let workdir = context.workdir if (conversationId && (!providerId || !modelId)) { + const getConversation = this.deps.getConversation try { - const conversation = await this.deps.sessionPresenter.getConversation(conversationId) - providerId = providerId ?? conversation.settings.providerId - modelId = modelId ?? conversation.settings.modelId - if (!agentId && conversation.settings.providerId === 'acp') { - agentId = conversation.settings.modelId + if (getConversation) { + const conversation = await getConversation(conversationId) + providerId = providerId ?? conversation.settings.providerId + modelId = modelId ?? conversation.settings.modelId + if (!agentId && conversation.settings.providerId === 'acp') { + agentId = conversation.settings.modelId + } } } catch (error) { log.warn('[HooksNotifications] Failed to load conversation info:', error) @@ -264,9 +281,12 @@ export class HooksNotificationsService { } if (conversationId && !workdir) { + const resolveWorkspaceContext = this.deps.resolveWorkspaceContext try { - const resolved = await this.deps.resolveWorkspaceContext(conversationId, modelId) - workdir = resolved.agentWorkspacePath ?? null + if (resolveWorkspaceContext) { + const resolved = await resolveWorkspaceContext(conversationId, modelId) + workdir = resolved.agentWorkspacePath ?? null + } } catch (error) { log.warn('[HooksNotifications] Failed to resolve workdir:', error) } @@ -274,9 +294,12 @@ export class HooksNotificationsService { let promptPreview = context.promptPreview if (!promptPreview && context.messageId) { + const getMessage = this.deps.getMessage try { - const message = await this.deps.sessionPresenter.getMessage(context.messageId) - promptPreview = extractPromptPreview(message.content) + if (getMessage) { + const message = await getMessage(context.messageId) + promptPreview = extractPromptPreview(message.content) + } } catch (error) { log.warn('[HooksNotifications] Failed to read message for preview:', error) } diff --git a/src/main/presenter/hooksNotifications/newSessionBridge.ts b/src/main/presenter/hooksNotifications/newSessionBridge.ts new file mode 100644 index 000000000..f37aeb926 --- /dev/null +++ b/src/main/presenter/hooksNotifications/newSessionBridge.ts @@ -0,0 +1,42 @@ +import type { HookEventName } from '@shared/hooksNotifications' +import type { HookDispatchContext } from './index' + +type HookDispatcher = { + dispatchEvent(event: HookEventName, context: HookDispatchContext): void +} + +export type NewSessionHookContext = { + sessionId: string + agentId?: string | null + projectDir?: string | null + messageId?: string + promptPreview?: string + providerId?: string + modelId?: string + tool?: HookDispatchContext['tool'] + permission?: HookDispatchContext['permission'] + stop?: HookDispatchContext['stop'] + usage?: HookDispatchContext['usage'] + error?: HookDispatchContext['error'] +} + +export class NewSessionHooksBridge { + constructor(private readonly dispatcher: HookDispatcher) {} + + dispatch(event: HookEventName, context: NewSessionHookContext): void { + this.dispatcher.dispatchEvent(event, { + conversationId: context.sessionId, + messageId: context.messageId, + promptPreview: context.promptPreview, + providerId: context.providerId, + modelId: context.modelId, + agentId: context.agentId ?? null, + workdir: context.projectDir ?? null, + tool: context.tool, + permission: context.permission ?? null, + stop: context.stop ?? null, + usage: context.usage ?? null, + error: context.error ?? null + }) + } +} diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 364931671..2600202a7 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -64,6 +64,7 @@ import { ConversationExporterService } from './exporter' import { SkillPresenter } from './skillPresenter' import { SkillSyncPresenter } from './skillSyncPresenter' import { HooksNotificationsService } from './hooksNotifications' +import { NewSessionHooksBridge } from './hooksNotifications/newSessionBridge' import { NewAgentPresenter } from './newAgentPresenter' import { DeepChatAgentPresenter } from './deepchatAgentPresenter' import { ProjectPresenter } from './projectPresenter' @@ -202,12 +203,21 @@ export class Presenter implements IPresenter { // Initialize Skill Sync presenter this.skillSyncPresenter = new SkillSyncPresenter(this.skillPresenter, this.configPresenter) + // Initialize Hooks & Notifications service + this.hooksNotifications = new HooksNotificationsService(this.configPresenter, { + getConversation: this.sessionPresenter.getConversation.bind(this.sessionPresenter), + getMessage: this.sessionPresenter.getMessage.bind(this.sessionPresenter), + resolveWorkspaceContext: this.sessionManager.resolveWorkspaceContext.bind(this.sessionManager) + }) + const newSessionHooksBridge = new NewSessionHooksBridge(this.hooksNotifications) + // Initialize new agent architecture presenters const deepchatAgentPresenter = new DeepChatAgentPresenter( this.llmproviderPresenter as unknown as ILlmProviderPresenter, this.configPresenter, this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter, - this.toolPresenter + this.toolPresenter, + newSessionHooksBridge ) this.newAgentPresenter = new NewAgentPresenter( deepchatAgentPresenter, @@ -221,12 +231,6 @@ export class Presenter implements IPresenter { this.devicePresenter ) - // Initialize Hooks & Notifications service - this.hooksNotifications = new HooksNotificationsService(this.configPresenter, { - sessionPresenter: this.sessionPresenter, - resolveWorkspaceContext: this.sessionManager.resolveWorkspaceContext.bind(this.sessionManager) - }) - this.setupEventBus() // 设置事件总线监听 } diff --git a/src/main/presenter/newAgentPresenter/index.ts b/src/main/presenter/newAgentPresenter/index.ts index 6cfc18504..f2acfba9b 100644 --- a/src/main/presenter/newAgentPresenter/index.ts +++ b/src/main/presenter/newAgentPresenter/index.ts @@ -103,12 +103,14 @@ export class NewAgentPresenter { // Initialize agent-side session const initConfig: { + agentId?: string providerId: string modelId: string projectDir: string | null permissionMode: PermissionMode generationSettings?: Partial } = { + agentId, providerId, modelId, projectDir, @@ -182,6 +184,7 @@ export class NewAgentPresenter { isDraft: true }) await this.ensureSessionRuntimeInitialized(agent, sessionId, { + agentId, providerId: 'acp', modelId: agentId, projectDir, @@ -193,6 +196,7 @@ export class NewAgentPresenter { } } else { await this.ensureSessionRuntimeInitialized(agent, record.id, { + agentId, providerId: 'acp', modelId: agentId, projectDir, @@ -314,6 +318,7 @@ export class NewAgentPresenter { try { await agent.initSession(targetSessionId, { + agentId: sourceSession.agentId, providerId: sourceState.providerId, modelId: sourceState.modelId, projectDir: sourceSession.projectDir ?? null, @@ -941,6 +946,7 @@ export class NewAgentPresenter { agent: IAgentImplementation, sessionId: string, config: { + agentId?: string providerId: string modelId: string projectDir: string diff --git a/src/renderer/public/sounds/sfx-fc.mp3 b/src/renderer/public/sounds/sfx-fc.mp3 deleted file mode 100644 index 63fe8002f..000000000 Binary files a/src/renderer/public/sounds/sfx-fc.mp3 and /dev/null differ diff --git a/src/renderer/public/sounds/sfx-typing.mp3 b/src/renderer/public/sounds/sfx-typing.mp3 deleted file mode 100644 index bfe3833a4..000000000 Binary files a/src/renderer/public/sounds/sfx-typing.mp3 and /dev/null differ diff --git a/src/renderer/settings/components/CommonSettings.vue b/src/renderer/settings/components/CommonSettings.vue index ad0ed5a1d..bd2f64773 100644 --- a/src/renderer/settings/components/CommonSettings.vue +++ b/src/renderer/settings/components/CommonSettings.vue @@ -11,13 +11,6 @@ :model-value="autoScrollEnabled" @update:model-value="handleAutoScrollChange" /> - uiSettingsStore.autoScrollEnabled) -const soundEnabled = computed(() => soundStore.soundEnabled) const copyWithCotEnabled = computed(() => uiSettingsStore.copyWithCotEnabled) const traceDebugEnabled = computed(() => uiSettingsStore.traceDebugEnabled) @@ -62,10 +52,6 @@ const handleAutoScrollChange = (value: boolean) => { uiSettingsStore.setAutoScrollEnabled(value) } -const handleSoundChange = (value: boolean) => { - soundStore.setSoundEnabled(value) -} - const handleCopyWithCotChange = (value: boolean) => { uiSettingsStore.setCopyWithCotEnabled(value) } diff --git a/src/renderer/src/components/chat/ChatStatusBar.vue b/src/renderer/src/components/chat/ChatStatusBar.vue index 6261514f4..9eaaa42d0 100644 --- a/src/renderer/src/components/chat/ChatStatusBar.vue +++ b/src/renderer/src/components/chat/ChatStatusBar.vue @@ -1,5 +1,5 @@ - + diff --git a/src/renderer/src/stores/sound.ts b/src/renderer/src/stores/sound.ts deleted file mode 100644 index 8ba0750b8..000000000 --- a/src/renderer/src/stores/sound.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { usePresenter } from '@/composables/usePresenter' -import { CONFIG_EVENTS } from '@/events' -import { defineStore } from 'pinia' -import { ref, onMounted } from 'vue' - -export const useSoundStore = defineStore('sound', () => { - const soundEnabled = ref(false) // 声音是否启用,默认禁用 - const configPresenter = usePresenter('configPresenter') - - // 初始化设置 - const initSound = async () => { - try { - soundEnabled.value = await configPresenter.getSoundEnabled() - setupSoundEnabledListener() - } catch (error) { - console.error('初始化音效失败:', error) - } - } - - // 设置音效开关状态 - const setSoundEnabled = async (enabled: boolean) => { - // 更新本地状态 - soundEnabled.value = Boolean(enabled) - - // 调用ConfigPresenter设置值 - await configPresenter.setSoundEnabled(enabled) - } - - // 获取音效开关状态 - const getSoundEnabled = async (): Promise => { - return await configPresenter.getSoundEnabled() - } - - // 设置音效开关监听器 - const setupSoundEnabledListener = () => { - // 监听音效开关变更事件 - window.electron.ipcRenderer.on( - CONFIG_EVENTS.SOUND_ENABLED_CHANGED, - (_event, enabled: boolean) => { - soundEnabled.value = enabled - } - ) - } - - // 在 store 创建时初始化 - onMounted(async () => { - await initSound() - }) - - return { - soundEnabled, - initSound, - setSoundEnabled, - getSoundEnabled, - setupSoundEnabledListener - } -}) diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index 82f3b28ac..8a7768016 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -37,6 +37,7 @@ export interface IAgentImplementation { initSession( sessionId: string, config: { + agentId?: string providerId: string modelId: string projectDir?: string | null diff --git a/src/shared/types/chatSettings.ts b/src/shared/types/chatSettings.ts index 16c3c5a63..c1a596270 100644 --- a/src/shared/types/chatSettings.ts +++ b/src/shared/types/chatSettings.ts @@ -15,17 +15,12 @@ export type ChatLanguage = export type ChatTheme = 'dark' | 'light' | 'system' -export type ChatSettingId = - | 'soundEnabled' - | 'copyWithCotEnabled' - | 'language' - | 'theme' - | 'fontSizeLevel' +export type ChatSettingId = 'copyWithCotEnabled' | 'language' | 'theme' | 'fontSizeLevel' export type ChatSettingValue = boolean | number | ChatLanguage | ChatTheme export type ToggleChatSettingRequest = { - setting: 'soundEnabled' | 'copyWithCotEnabled' + setting: 'copyWithCotEnabled' enabled: boolean } diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index 0f7f7f7e8..b190b84a8 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -546,9 +546,6 @@ export interface IConfigPresenter { getEnabledProviders(): LLM_PROVIDER[] getModelDefaultConfig(modelId: string, providerId?: string): ModelConfig getAllEnabledModels(): Promise<{ providerId: string; models: RENDERER_MODEL_META[] }[]> - // Sound effect settings - getSoundEnabled(): boolean - setSoundEnabled(enabled: boolean): void // Chain of Thought copy settings getCopyWithCotEnabled(): boolean setCopyWithCotEnabled(enabled: boolean): void diff --git a/src/types/i18n.d.ts b/src/types/i18n.d.ts index 1bc01efc9..5e04eb93b 100644 --- a/src/types/i18n.d.ts +++ b/src/types/i18n.d.ts @@ -1117,7 +1117,6 @@ declare module 'vue-i18n' { contentProtectionEnableDesc: string contentProtectionDisableDesc: string contentProtectionRestartNotice: string - soundEnabled: string copyWithCotEnabled: string traceDebugEnabled: string loggingEnabled: string diff --git a/test/main/presenter/agentPresenter/chatSettingsTools.test.ts b/test/main/presenter/agentPresenter/chatSettingsTools.test.ts index 8c2994433..de4fdb3ce 100644 --- a/test/main/presenter/agentPresenter/chatSettingsTools.test.ts +++ b/test/main/presenter/agentPresenter/chatSettingsTools.test.ts @@ -9,8 +9,6 @@ import { describe('ChatSettingsToolHandler', () => { const configPresenter = { - getSoundEnabled: vi.fn(), - setSoundEnabled: vi.fn(), getCopyWithCotEnabled: vi.fn(), setCopyWithCotEnabled: vi.fn(), getSetting: vi.fn(), @@ -43,7 +41,6 @@ describe('ChatSettingsToolHandler', () => { beforeEach(() => { vi.clearAllMocks() - configPresenter.getSoundEnabled.mockReturnValue(false) configPresenter.getCopyWithCotEnabled.mockReturnValue(true) configPresenter.getSetting.mockReturnValue('chat') configPresenter.setTheme.mockResolvedValue(false) @@ -57,34 +54,34 @@ describe('ChatSettingsToolHandler', () => { it('rejects toggle when skill is inactive', async () => { skillPresenter.getActiveSkills.mockResolvedValue([]) const handler = buildHandler() - const result = await handler.toggle({ setting: 'soundEnabled', enabled: true }, 'conv-1') + const result = await handler.toggle({ setting: 'copyWithCotEnabled', enabled: true }, 'conv-1') expect(result.ok).toBe(false) if (!result.ok) { expect(result.errorCode).toBe('skill_inactive') } - expect(configPresenter.setSoundEnabled).not.toHaveBeenCalled() + expect(configPresenter.setCopyWithCotEnabled).not.toHaveBeenCalled() }) it('rejects invalid toggle payloads', async () => { const handler = buildHandler() - const result = await handler.toggle({ setting: 'soundEnabled', enabled: 'true' }, 'conv-1') + const result = await handler.toggle({ setting: 'unknownSetting', enabled: 'true' }, 'conv-1') expect(result.ok).toBe(false) if (!result.ok) { expect(result.errorCode).toBe('invalid_request') } - expect(configPresenter.setSoundEnabled).not.toHaveBeenCalled() + expect(configPresenter.setCopyWithCotEnabled).not.toHaveBeenCalled() }) - it('applies soundEnabled toggle', async () => { + it('applies copyWithCotEnabled toggle', async () => { const handler = buildHandler() - const result = await handler.toggle({ setting: 'soundEnabled', enabled: true }, 'conv-1') + const result = await handler.toggle({ setting: 'copyWithCotEnabled', enabled: false }, 'conv-1') - expect(configPresenter.setSoundEnabled).toHaveBeenCalledWith(true) + expect(configPresenter.setCopyWithCotEnabled).toHaveBeenCalledWith(false) expect(result.ok).toBe(true) if (result.ok) { - expect(result.previousValue).toBe(false) + expect(result.previousValue).toBe(true) } }) diff --git a/test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts b/test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts index aa654021b..f92460f33 100644 --- a/test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts +++ b/test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts @@ -4,6 +4,7 @@ import os from 'os' import path from 'path' import { app } from 'electron' import { DeepChatAgentPresenter } from '@/presenter/deepchatAgentPresenter/index' +import { NewSessionHooksBridge } from '@/presenter/hooksNotifications/newSessionBridge' vi.mock('nanoid', () => ({ nanoid: vi.fn(() => 'mock-msg-id') })) @@ -76,6 +77,9 @@ function createMockSqlitePresenter() { summary_updated_at: null } return { + newSessionsTable: { + get: vi.fn() + }, deepchatSessionsTable: { create: vi.fn(), get: vi.fn(), @@ -199,6 +203,7 @@ describe('DeepChatAgentPresenter', () => { let configPresenter: ReturnType let toolPresenter: ReturnType let agent: DeepChatAgentPresenter + let hookDispatcher: { dispatchEvent: ReturnType } let tempHome: string | null = null let getPathSpy: ReturnType | null = null @@ -216,7 +221,14 @@ describe('DeepChatAgentPresenter', () => { llmProvider = createMockLlmProviderPresenter() configPresenter = createMockConfigPresenter() toolPresenter = createMockToolPresenter() - agent = new DeepChatAgentPresenter(llmProvider, configPresenter, sqlitePresenter, toolPresenter) + hookDispatcher = { dispatchEvent: vi.fn() } + agent = new DeepChatAgentPresenter( + llmProvider, + configPresenter, + sqlitePresenter, + toolPresenter, + new NewSessionHooksBridge(hookDispatcher) + ) }) afterEach(async () => { @@ -383,6 +395,139 @@ describe('DeepChatAgentPresenter', () => { ) }) + it('dispatches lifecycle hooks through new session bridge', async () => { + ;(processStream as ReturnType).mockImplementationOnce(async () => ({ + status: 'completed', + stopReason: 'complete', + usage: { totalTokens: 3 } + })) + + await agent.initSession('s1', { + agentId: 'deepchat', + providerId: 'openai', + modelId: 'gpt-4', + projectDir: '/tmp/project' + }) + await agent.processMessage('s1', 'Hello bridge') + + expect(hookDispatcher.dispatchEvent).toHaveBeenCalledWith( + 'UserPromptSubmit', + expect.objectContaining({ + conversationId: 's1', + agentId: 'deepchat', + workdir: '/tmp/project', + promptPreview: 'Hello bridge' + }) + ) + expect(hookDispatcher.dispatchEvent).toHaveBeenCalledWith( + 'SessionStart', + expect.objectContaining({ + conversationId: 's1', + agentId: 'deepchat', + workdir: '/tmp/project' + }) + ) + expect(hookDispatcher.dispatchEvent).toHaveBeenCalledWith( + 'Stop', + expect.objectContaining({ + conversationId: 's1', + stop: expect.objectContaining({ reason: 'complete', userStop: false }) + }) + ) + expect(hookDispatcher.dispatchEvent).toHaveBeenCalledWith( + 'SessionEnd', + expect.objectContaining({ + conversationId: 's1' + }) + ) + }) + + it('rehydrates agentId from persisted new session rows before dispatching hooks', async () => { + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'coder' + }) + sqlitePresenter.deepchatSessionsTable.get.mockReturnValue({ + id: 's1', + provider_id: 'acp', + model_id: 'coder', + permission_mode: 'full_access' + }) + ;(processStream as ReturnType).mockImplementationOnce(async () => ({ + status: 'completed', + stopReason: 'complete' + })) + + await agent.getSessionState('s1') + await agent.processMessage('s1', 'Reopened session', { projectDir: '/tmp/project' }) + + expect(hookDispatcher.dispatchEvent).toHaveBeenCalledWith( + 'UserPromptSubmit', + expect.objectContaining({ + conversationId: 's1', + agentId: 'coder' + }) + ) + }) + + it('dispatches tool and permission hooks through process callbacks', async () => { + ;(processStream as ReturnType).mockImplementationOnce(async (params) => { + params.hooks?.onPreToolUse?.({ + callId: 'tool-1', + name: 'write_file', + params: '{"path":"a.txt"}' + }) + params.hooks?.onPermissionRequest?.( + { + permissionType: 'write', + description: 'Need permission' + }, + { + callId: 'tool-1', + name: 'write_file', + params: '{"path":"a.txt"}' + } + ) + params.hooks?.onPostToolUseFailure?.({ + callId: 'tool-1', + name: 'write_file', + params: '{"path":"a.txt"}', + error: 'permission denied' + }) + return { + status: 'error', + stopReason: 'error', + errorMessage: 'permission denied' + } + }) + + await agent.initSession('s1', { agentId: 'coder', providerId: 'acp', modelId: 'coder' }) + await agent.processMessage('s1', 'Run tool') + + expect(hookDispatcher.dispatchEvent).toHaveBeenCalledWith( + 'PreToolUse', + expect.objectContaining({ + conversationId: 's1', + agentId: 'coder', + tool: expect.objectContaining({ callId: 'tool-1', name: 'write_file' }) + }) + ) + expect(hookDispatcher.dispatchEvent).toHaveBeenCalledWith( + 'PermissionRequest', + expect.objectContaining({ + conversationId: 's1', + permission: expect.objectContaining({ permissionType: 'write' }) + }) + ) + expect(hookDispatcher.dispatchEvent).toHaveBeenCalledWith( + 'PostToolUseFailure', + expect.objectContaining({ + conversationId: 's1', + tool: expect.objectContaining({ error: 'permission denied' }) + }) + ) + }) + it('includes conversation history in LLM call', async () => { // Set up: first user message already in DB as sent const existingMessages = [ @@ -1722,6 +1867,23 @@ describe('DeepChatAgentPresenter', () => { 'remaining context window is insufficient' ) expect(updatedBlocks[0].tool_call.response).not.toContain('[Tool output offloaded]') + const postToolUseCalls = hookDispatcher.dispatchEvent.mock.calls.filter( + ([event]) => event === 'PostToolUse' + ) + const postToolUseFailureCalls = hookDispatcher.dispatchEvent.mock.calls.filter( + ([event]) => event === 'PostToolUseFailure' + ) + expect(postToolUseCalls).toHaveLength(0) + expect(postToolUseFailureCalls).toHaveLength(1) + expect(postToolUseFailureCalls[0][1]).toEqual( + expect.objectContaining({ + conversationId: 's1', + tool: expect.objectContaining({ + callId: 'tc1', + error: expect.stringContaining('remaining context window is insufficient') + }) + }) + ) await expect( fs.access(path.join(tempHome, '.deepchat', 'sessions', 's1', 'tool_tc1.offload')) ).rejects.toThrow() @@ -1765,6 +1927,23 @@ describe('DeepChatAgentPresenter', () => { expect(updatedBlocks[0].tool_call.response).toBe('User denied the request.') expect(updatedBlocks[0].status).toBe('error') expect(updatedBlocks[1].status).toBe('denied') + const postToolUseCalls = hookDispatcher.dispatchEvent.mock.calls.filter( + ([event]) => event === 'PostToolUse' + ) + const postToolUseFailureCalls = hookDispatcher.dispatchEvent.mock.calls.filter( + ([event]) => event === 'PostToolUseFailure' + ) + expect(postToolUseCalls).toHaveLength(0) + expect(postToolUseFailureCalls).toHaveLength(1) + expect(postToolUseFailureCalls[0][1]).toEqual( + expect.objectContaining({ + conversationId: 's1', + tool: expect.objectContaining({ + callId: 'tc1', + error: 'User denied the request.' + }) + }) + ) expect(processStream).toHaveBeenCalledTimes(1) }) }) diff --git a/test/main/presenter/deepchatAgentPresenter/dispatch.test.ts b/test/main/presenter/deepchatAgentPresenter/dispatch.test.ts index 33d335d51..17a90cacc 100644 --- a/test/main/presenter/deepchatAgentPresenter/dispatch.test.ts +++ b/test/main/presenter/deepchatAgentPresenter/dispatch.test.ts @@ -8,6 +8,7 @@ import { createState } from '@/presenter/deepchatAgentPresenter/types' import type { MCPToolDefinition } from '@shared/presenter' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' import { ToolOutputGuard } from '@/presenter/deepchatAgentPresenter/toolOutputGuard' +import { QUESTION_TOOL_NAME } from '@/presenter/agentPresenter/tools/questionTool' vi.mock('@/eventbus', () => ({ eventBus: { sendToRenderer: vi.fn() }, @@ -163,6 +164,99 @@ describe('dispatch', () => { expect(toolBlock!.status).toBe('success') }) + it('does not emit PreToolUse for question interactions that pause execution', async () => { + const hooks = { + onPreToolUse: vi.fn(), + onPermissionRequest: vi.fn(), + onPostToolUse: vi.fn(), + onPostToolUseFailure: vi.fn() + } + const toolPresenter = createMockToolPresenter() + + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { id: 'tc1', name: QUESTION_TOOL_NAME, params: '', response: '' } + }) + state.completedToolCalls = [ + { + id: 'tc1', + name: QUESTION_TOOL_NAME, + arguments: JSON.stringify({ + question: 'Continue?', + options: [{ label: 'Yes' }] + }) + } + ] + + const result = await executeTools( + state, + [], + 0, + [makeTool(QUESTION_TOOL_NAME)], + toolPresenter, + 'gpt-4', + io, + 'full_access', + new ToolOutputGuard(), + 32000, + 1024, + hooks + ) + + expect(result.pendingInteractions).toHaveLength(1) + expect(hooks.onPreToolUse).not.toHaveBeenCalled() + expect(toolPresenter.callTool).not.toHaveBeenCalled() + }) + + it('does not emit PreToolUse before a pre-checked permission pause', async () => { + const hooks = { + onPreToolUse: vi.fn(), + onPermissionRequest: vi.fn(), + onPostToolUse: vi.fn(), + onPostToolUseFailure: vi.fn() + } + const toolPresenter = createMockToolPresenter() as IToolPresenter & { + preCheckToolPermission: ReturnType + } + toolPresenter.preCheckToolPermission = vi.fn().mockResolvedValue({ + needsPermission: true, + permissionType: 'write', + description: 'Need permission' + }) + + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { id: 'tc1', name: 'write_file', params: '{"path":"a.txt"}', response: '' } + }) + state.completedToolCalls = [{ id: 'tc1', name: 'write_file', arguments: '{"path":"a.txt"}' }] + + const result = await executeTools( + state, + [], + 0, + [makeTool('write_file')], + toolPresenter, + 'gpt-4', + io, + 'default', + new ToolOutputGuard(), + 32000, + 1024, + hooks + ) + + expect(result.pendingInteractions).toHaveLength(1) + expect(hooks.onPreToolUse).not.toHaveBeenCalled() + expect(hooks.onPermissionRequest).toHaveBeenCalledTimes(1) + expect(toolPresenter.callTool).not.toHaveBeenCalled() + }) + it('enriches tool_call blocks with server info', async () => { const tools = [makeTool('get_weather')] const toolPresenter = createMockToolPresenter({ get_weather: 'Sunny' }) @@ -606,6 +700,12 @@ describe('dispatch', () => { const longScreenshot = JSON.stringify({ data: 'x'.repeat(7000) }) const toolPresenter = createMockToolPresenter({ yo_browser_cdp_send: longScreenshot }) const conversation: any[] = [] + const hooks = { + onPreToolUse: vi.fn(), + onPermissionRequest: vi.fn(), + onPostToolUse: vi.fn(), + onPostToolUseFailure: vi.fn() + } state.blocks.push({ type: 'tool_call', @@ -638,12 +738,19 @@ describe('dispatch', () => { 'full_access', new ToolOutputGuard(), 1, - 1 + 1, + hooks ) expect(executed.terminalError).toContain('remaining context window is too small') expect(conversation.find((message: any) => message.role === 'tool')).toBeUndefined() expect(state.blocks[0].status).toBe('error') + expect(hooks.onPostToolUseFailure).toHaveBeenCalledWith({ + callId: 'tc1', + name: 'yo_browser_cdp_send', + params: '{"method":"Page.captureScreenshot"}', + error: expect.stringContaining('remaining context window is too small') + }) await expect( fs.access(path.join(tempHome, '.deepchat', 'sessions', 's1', 'tool_tc1.offload')) ).rejects.toThrow() diff --git a/test/main/presenter/newAgentPresenter/integration.test.ts b/test/main/presenter/newAgentPresenter/integration.test.ts index 1af8db12e..55824b279 100644 --- a/test/main/presenter/newAgentPresenter/integration.test.ts +++ b/test/main/presenter/newAgentPresenter/integration.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { NewAgentPresenter } from '@/presenter/newAgentPresenter/index' import { DeepChatAgentPresenter } from '@/presenter/deepchatAgentPresenter/index' +import { NewSessionHooksBridge } from '@/presenter/hooksNotifications/newSessionBridge' vi.mock('nanoid', () => { let counter = 0 @@ -541,6 +542,88 @@ describe('Integration: createSession end-to-end', () => { }) }) +describe('Integration: ACP hooks bridge', () => { + let sqlitePresenter: ReturnType + let llmProvider: ReturnType + let configPresenter: ReturnType + let agentPresenter: NewAgentPresenter + let hookDispatcher: { dispatchEvent: ReturnType } + + beforeEach(() => { + vi.clearAllMocks() + sqlitePresenter = createMockSqlitePresenter() + llmProvider = createMockLlmProviderPresenter() + configPresenter = createMockConfigPresenter() + configPresenter.getAcpAgents.mockResolvedValue([{ id: 'coder', name: 'Coder' }]) + hookDispatcher = { dispatchEvent: vi.fn() } + + const deepchatAgent = new DeepChatAgentPresenter( + llmProvider, + configPresenter, + sqlitePresenter, + createMockToolPresenter(), + new NewSessionHooksBridge(hookDispatcher) + ) + agentPresenter = new NewAgentPresenter( + deepchatAgent as any, + llmProvider, + configPresenter, + sqlitePresenter + ) + }) + + it('dispatches lifecycle hooks for ACP sessions through the new bridge', async () => { + const session = await agentPresenter.createSession( + { + agentId: 'coder', + providerId: 'acp', + modelId: 'coder', + message: 'Inspect workspace', + projectDir: '/tmp/acp-project' + }, + 1 + ) + + await new Promise((r) => setTimeout(r, 50)) + + expect(session.agentId).toBe('coder') + expect(hookDispatcher.dispatchEvent).toHaveBeenCalledWith( + 'UserPromptSubmit', + expect.objectContaining({ + conversationId: session.id, + agentId: 'coder', + workdir: '/tmp/acp-project', + providerId: 'acp', + modelId: 'coder', + promptPreview: 'Inspect workspace' + }) + ) + expect(hookDispatcher.dispatchEvent).toHaveBeenCalledWith( + 'SessionStart', + expect.objectContaining({ + conversationId: session.id, + agentId: 'coder', + workdir: '/tmp/acp-project', + providerId: 'acp', + modelId: 'coder' + }) + ) + expect(hookDispatcher.dispatchEvent).toHaveBeenCalledWith( + 'Stop', + expect.objectContaining({ + conversationId: session.id, + stop: expect.objectContaining({ userStop: false }) + }) + ) + expect(hookDispatcher.dispatchEvent).toHaveBeenCalledWith( + 'SessionEnd', + expect.objectContaining({ + conversationId: session.id + }) + ) + }) +}) + describe('Integration: multi-turn context', () => { let sqlitePresenter: ReturnType let llmProvider: ReturnType