diff --git a/src/main/lib/agentRuntime/systemEnvPromptBuilder.ts b/src/main/lib/agentRuntime/systemEnvPromptBuilder.ts index fdba156b3..8d27af6ce 100644 --- a/src/main/lib/agentRuntime/systemEnvPromptBuilder.ts +++ b/src/main/lib/agentRuntime/systemEnvPromptBuilder.ts @@ -12,6 +12,12 @@ export interface BuildSystemEnvPromptOptions { agentsFilePath?: string } +export interface RuntimeCapabilitiesPromptOptions { + hasYoBrowser?: boolean + hasExec?: boolean + hasProcess?: boolean +} + function resolveModelDisplayName(providerId: string, modelId: string): string | undefined { try { const models = presenter.configPresenter?.getProviderModels?.(providerId) || [] @@ -86,14 +92,33 @@ async function readAgentsInstructions(sourcePath: string): Promise { } } -export function buildRuntimeCapabilitiesPrompt(): string { - return [ - '## Runtime Capabilities', - '- YoBrowser tools are available for browser automation when needed.', - '- Use exec(background: true) to start long-running terminal commands.', - '- Use process(list|poll|log|write|kill|remove) to manage background terminal sessions.', - '- Before launching another long-running command, prefer process action "list" to inspect existing sessions.' - ].join('\n') +export function buildRuntimeCapabilitiesPrompt( + options: RuntimeCapabilitiesPromptOptions = { + hasYoBrowser: true, + hasExec: true, + hasProcess: true + } +): string { + const lines = ['## Runtime Capabilities'] + + if (options.hasYoBrowser) { + lines.push('- YoBrowser tools are available for browser automation when needed.') + } + if (options.hasExec) { + lines.push('- Use exec(background: true) to start long-running terminal commands.') + } + if (options.hasProcess) { + lines.push( + '- Use process(list|poll|log|write|kill|remove) to manage background terminal sessions.' + ) + } + if (options.hasExec && options.hasProcess) { + lines.push( + '- Before launching another long-running command, prefer process action "list" to inspect existing sessions.' + ) + } + + return lines.length > 1 ? lines.join('\n') : '' } export async function buildSystemEnvPrompt( diff --git a/src/main/presenter/agentPresenter/tool/toolCallCenter.ts b/src/main/presenter/agentPresenter/tool/toolCallCenter.ts index 6c06251af..86e09d92d 100644 --- a/src/main/presenter/agentPresenter/tool/toolCallCenter.ts +++ b/src/main/presenter/agentPresenter/tool/toolCallCenter.ts @@ -7,6 +7,7 @@ import type { export type ToolCallContext = { enabledMcpTools?: string[] + disabledAgentTools?: string[] chatMode?: 'agent' | 'acp agent' supportsVision?: boolean agentWorkspacePath?: string | null @@ -24,7 +25,10 @@ export class ToolCallCenter { return this.toolPresenter.callTool(request) } - buildToolSystemPrompt(context: { conversationId?: string }): string { + buildToolSystemPrompt(context: { + conversationId?: string + toolDefinitions?: MCPToolDefinition[] + }): string { return this.toolPresenter.buildToolSystemPrompt(context) } } diff --git a/src/main/presenter/deepchatAgentPresenter/index.ts b/src/main/presenter/deepchatAgentPresenter/index.ts index 0b3a3a4a2..008d0d55b 100644 --- a/src/main/presenter/deepchatAgentPresenter/index.ts +++ b/src/main/presenter/deepchatAgentPresenter/index.ts @@ -245,9 +245,11 @@ export class DeepChatAgentPresenter implements IAgentImplementation { try { const generationSettings = await this.getEffectiveSessionGenerationSettings(sessionId) const maxTokens = generationSettings.maxTokens + const tools = await this.loadToolDefinitionsForSession(sessionId, projectDir) const baseSystemPrompt = await this.buildSystemPromptWithSkills( sessionId, - generationSettings.systemPrompt + generationSettings.systemPrompt, + tools ) const historyRecords = this.messageStore .getMessages(sessionId) @@ -343,7 +345,8 @@ export class DeepChatAgentPresenter implements IAgentImplementation { messageId: assistantMessageId, messages, projectDir, - promptPreview: normalizedInput.text + promptPreview: normalizedInput.text, + tools }) this.applyProcessResultStatus(sessionId, result) } catch (err) { @@ -1190,9 +1193,12 @@ export class DeepChatAgentPresenter implements IAgentImplementation { this.setSessionStatus(sessionId, 'generating') const generationSettings = await this.getEffectiveSessionGenerationSettings(sessionId) const maxTokens = generationSettings.maxTokens + const projectDir = this.resolveProjectDir(sessionId) + const tools = await this.loadToolDefinitionsForSession(sessionId, projectDir) const baseSystemPrompt = await this.buildSystemPromptWithSkills( sessionId, - generationSettings.systemPrompt + generationSettings.systemPrompt, + tools ) const summaryState = await this.resolveCompactionStateForResumeTurn({ sessionId, @@ -1218,9 +1224,6 @@ export class DeepChatAgentPresenter implements IAgentImplementation { fallbackProtectedTurnCount: 1 } ) - const projectDir = this.resolveProjectDir(sessionId) - const tools = await this.loadToolDefinitionsForSession(sessionId, projectDir) - if (budgetToolCall?.id && budgetToolCall.name) { const resumeBudget = this.fitResumeBudgetForToolCall({ resumeContext, @@ -1278,7 +1281,8 @@ export class DeepChatAgentPresenter implements IAgentImplementation { private async buildSystemPromptWithSkills( sessionId: string, - basePrompt: string + basePrompt: string, + toolDefinitions: MCPToolDefinition[] ): Promise { const normalizedBase = basePrompt?.trim() ?? '' const state = this.runtimeState.get(sessionId) @@ -1331,6 +1335,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { const normalizedAvailableSkills = this.normalizeSkillNames(availableSkillNames) const normalizedActiveSkills = this.normalizeSkillNames(activeSkillNames) + const agentToolNames = this.getAgentToolNames(toolDefinitions) const fingerprint = this.buildSystemPromptFingerprint({ providerId, modelId, @@ -1338,7 +1343,8 @@ export class DeepChatAgentPresenter implements IAgentImplementation { basePrompt: normalizedBase, skillsEnabled, availableSkillNames: normalizedAvailableSkills, - activeSkillNames: normalizedActiveSkills + activeSkillNames: normalizedActiveSkills, + toolSignature: this.buildToolSignature(toolDefinitions) }) const cachedPrompt = this.systemPromptCache.get(sessionId) @@ -1350,9 +1356,19 @@ export class DeepChatAgentPresenter implements IAgentImplementation { return cachedPrompt.prompt } - const runtimePrompt = buildRuntimeCapabilitiesPrompt() + const runtimePrompt = buildRuntimeCapabilitiesPrompt({ + hasYoBrowser: toolDefinitions.some( + (tool) => tool.source === 'agent' && tool.server.name === 'yobrowser' + ), + hasExec: agentToolNames.has('exec'), + hasProcess: agentToolNames.has('process') + }) const skillsMetadataPrompt = skillsEnabled - ? this.buildSkillsMetadataPrompt(normalizedAvailableSkills) + ? this.buildSkillsMetadataPrompt(normalizedAvailableSkills, { + canListSkills: agentToolNames.has('skill_list'), + canControlSkills: agentToolNames.has('skill_control'), + canRunSkillScripts: agentToolNames.has('skill_run') + }) : '' let skillsPrompt = '' @@ -1390,7 +1406,10 @@ export class DeepChatAgentPresenter implements IAgentImplementation { let toolingPrompt = '' if (this.toolPresenter) { try { - toolingPrompt = this.toolPresenter.buildToolSystemPrompt({ conversationId: sessionId }) + toolingPrompt = this.toolPresenter.buildToolSystemPrompt({ + conversationId: sessionId, + toolDefinitions + }) } catch (error) { console.warn( `[DeepChatAgent] Failed to build tooling prompt for session ${sessionId}:`, @@ -1424,21 +1443,53 @@ export class DeepChatAgentPresenter implements IAgentImplementation { .join('\n\n') } - private buildSkillsMetadataPrompt(availableSkillNames: string[]): string { - const lines = [ - '## Skills', - 'If you may need specialized guidance, call `skill_list` first to inspect available skills and activation status.', - 'After identifying a matching skill, call `skill_control` to activate or deactivate it before proceeding.' - ] + private buildSkillsMetadataPrompt( + availableSkillNames: string[], + capabilities: { + canListSkills: boolean + canControlSkills: boolean + canRunSkillScripts: boolean + } + ): string { + if ( + !capabilities.canListSkills && + !capabilities.canControlSkills && + !capabilities.canRunSkillScripts + ) { + return '' + } + + const lines = ['## Skills'] + let hasContent = false + + if (capabilities.canListSkills) { + lines.push( + 'If you may need specialized guidance, call `skill_list` to inspect available skills and activation status.' + ) + hasContent = true + } + if (capabilities.canControlSkills) { + lines.push( + 'After identifying a matching skill, call `skill_control` to activate or deactivate it before proceeding.' + ) + hasContent = true + } + if (capabilities.canRunSkillScripts) { + lines.push( + 'Use `skill_run` to execute bundled helper scripts from active skills when a skill provides them.' + ) + hasContent = true + } if (availableSkillNames.length > 0) { lines.push('Installed skill names:') lines.push(...availableSkillNames.map((name) => `- ${name}`)) - } else { + hasContent = true + } else if (hasContent) { lines.push('Installed skill names: (none)') } - return lines.join('\n') + return hasContent ? lines.join('\n') : '' } private buildActiveSkillsPrompt(skillSections: string[]): string { @@ -1467,6 +1518,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { skillsEnabled: boolean availableSkillNames: string[] activeSkillNames: string[] + toolSignature: string[] }): string { return JSON.stringify({ providerId: params.providerId, @@ -1475,10 +1527,24 @@ export class DeepChatAgentPresenter implements IAgentImplementation { basePrompt: params.basePrompt, skillsEnabled: params.skillsEnabled, availableSkillNames: params.availableSkillNames, - activeSkillNames: params.activeSkillNames + activeSkillNames: params.activeSkillNames, + toolSignature: params.toolSignature }) } + private getAgentToolNames(toolDefinitions: MCPToolDefinition[]): Set { + return new Set( + toolDefinitions.filter((tool) => tool.source === 'agent').map((tool) => tool.function.name) + ) + } + + private buildToolSignature(toolDefinitions: MCPToolDefinition[]): string[] { + return toolDefinitions + .filter((tool) => tool.source === 'agent') + .map((tool) => `${tool.server.name}:${tool.function.name}`) + .sort((left, right) => left.localeCompare(right)) + } + private buildLocalDayKey(now: Date): string { const year = now.getFullYear() const month = String(now.getMonth() + 1).padStart(2, '0') @@ -1486,6 +1552,10 @@ export class DeepChatAgentPresenter implements IAgentImplementation { return `${year}-${month}-${day}` } + public invalidateSessionSystemPromptCache(sessionId: string): void { + this.invalidateSystemPromptCache(sessionId) + } + private invalidateSystemPromptCache(sessionId: string): void { this.systemPromptCache.delete(sessionId) } @@ -2191,6 +2261,21 @@ export class DeepChatAgentPresenter implements IAgentImplementation { return true }) + if (!toolDefinition) { + const disabledAgentTools = this.getDisabledAgentTools(sessionId) + if (disabledAgentTools.includes(toolName)) { + return { + responseText: `Tool '${toolName}' is disabled for the current session.`, + isError: true + } + } + + return { + responseText: `Tool '${toolName}' is no longer available in the current session.`, + isError: true + } + } + const request: MCPToolCall = { id: toolCall.id || '', type: 'function', @@ -2250,6 +2335,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { try { return await this.toolPresenter.getAllToolDefinitions({ + disabledAgentTools: this.getDisabledAgentTools(sessionId), chatMode: 'agent', conversationId: sessionId, agentWorkspacePath: projectDir @@ -2260,6 +2346,10 @@ export class DeepChatAgentPresenter implements IAgentImplementation { } } + private getDisabledAgentTools(sessionId: string): string[] { + return this.sqlitePresenter.newSessionsTable?.getDisabledAgentTools(sessionId) ?? [] + } + private fitResumeBudgetForToolCall(params: { resumeContext: ChatMessage[] toolDefinitions: MCPToolDefinition[] diff --git a/src/main/presenter/newAgentPresenter/index.ts b/src/main/presenter/newAgentPresenter/index.ts index d74af30aa..d7a4c175a 100644 --- a/src/main/presenter/newAgentPresenter/index.ts +++ b/src/main/presenter/newAgentPresenter/index.ts @@ -80,6 +80,8 @@ export class NewAgentPresenter { console.log(`[NewAgentPresenter] createSession agent=${agentId} webContentsId=${webContentsId}`) const projectDir = input.projectDir?.trim() ? input.projectDir.trim() : null const normalizedInput = this.normalizeCreateSessionInput(input) + const disabledAgentTools = + agentId === 'deepchat' ? this.normalizeDisabledAgentTools(input.disabledAgentTools) : [] const agent = await this.resolveAgentImplementation(agentId) @@ -98,7 +100,10 @@ export class NewAgentPresenter { // Create session record const title = normalizedInput.text.slice(0, 50) || 'New Chat' - const sessionId = this.sessionManager.create(agentId, title, projectDir, { isDraft: false }) + const sessionId = this.sessionManager.create(agentId, title, projectDir, { + isDraft: false, + disabledAgentTools + }) console.log(`[NewAgentPresenter] session created id=${sessionId} title="${title}"`) // Initialize agent-side session @@ -793,6 +798,38 @@ export class NewAgentPresenter { return await agent.getGenerationSettings(sessionId) } + async getSessionDisabledAgentTools(sessionId: string): Promise { + const session = this.sessionManager.get(sessionId) + if (!session) { + throw new Error(`Session not found: ${sessionId}`) + } + + return this.sessionManager.getDisabledAgentTools(sessionId) + } + + async updateSessionDisabledAgentTools( + sessionId: string, + disabledAgentTools: string[] + ): Promise { + const session = this.sessionManager.get(sessionId) + if (!session) { + throw new Error(`Session not found: ${sessionId}`) + } + + const normalized = this.normalizeDisabledAgentTools(disabledAgentTools) + this.sessionManager.updateDisabledAgentTools(sessionId, normalized) + + const agent = await this.resolveAgentImplementation(session.agentId) + if ( + 'invalidateSessionSystemPromptCache' in agent && + typeof agent.invalidateSessionSystemPromptCache === 'function' + ) { + agent.invalidateSessionSystemPromptCache(sessionId) + } + + return normalized + } + async updateSessionGenerationSettings( sessionId: string, settings: Partial @@ -1362,4 +1399,19 @@ export class NewAgentPresenter { : [] return { text, files } } + + private normalizeDisabledAgentTools(disabledAgentTools?: string[]): string[] { + if (!Array.isArray(disabledAgentTools)) { + return [] + } + + return Array.from( + new Set( + disabledAgentTools + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) + ) + ).sort((left, right) => left.localeCompare(right)) + } } diff --git a/src/main/presenter/newAgentPresenter/sessionManager.ts b/src/main/presenter/newAgentPresenter/sessionManager.ts index e4d23316b..e5de4230e 100644 --- a/src/main/presenter/newAgentPresenter/sessionManager.ts +++ b/src/main/presenter/newAgentPresenter/sessionManager.ts @@ -15,11 +15,12 @@ export class NewSessionManager { agentId: string, title: string, projectDir: string | null, - options?: { isDraft?: boolean } + options?: { isDraft?: boolean; disabledAgentTools?: string[] } ): string { const id = nanoid() this.sqlitePresenter.newSessionsTable.create(id, agentId, title, projectDir, { - isDraft: options?.isDraft + isDraft: options?.isDraft, + disabledAgentTools: options?.disabledAgentTools }) return id } @@ -74,6 +75,14 @@ export class NewSessionManager { this.sqlitePresenter.newSessionsTable.delete(id) } + getDisabledAgentTools(id: string): string[] { + return this.sqlitePresenter.newSessionsTable.getDisabledAgentTools(id) + } + + updateDisabledAgentTools(id: string, disabledAgentTools: string[]): void { + this.sqlitePresenter.newSessionsTable.updateDisabledAgentTools(id, disabledAgentTools) + } + // Window binding management bindWindow(webContentsId: number, sessionId: string): void { this.windowBindings.set(webContentsId, sessionId) diff --git a/src/main/presenter/sqlitePresenter/tables/newSessions.ts b/src/main/presenter/sqlitePresenter/tables/newSessions.ts index 3a1086394..014793937 100644 --- a/src/main/presenter/sqlitePresenter/tables/newSessions.ts +++ b/src/main/presenter/sqlitePresenter/tables/newSessions.ts @@ -9,6 +9,7 @@ export interface NewSessionRow { is_pinned: number is_draft: number active_skills: string + disabled_agent_tools: string created_at: number updated_at: number } @@ -26,6 +27,9 @@ export class NewSessionsTable extends BaseTable { title TEXT NOT NULL, project_dir TEXT, is_pinned INTEGER DEFAULT 0, + is_draft INTEGER NOT NULL DEFAULT 0, + active_skills TEXT NOT NULL DEFAULT '[]', + disabled_agent_tools TEXT NOT NULL DEFAULT '[]', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -41,11 +45,14 @@ export class NewSessionsTable extends BaseTable { if (version === 15) { return `ALTER TABLE new_sessions ADD COLUMN active_skills TEXT NOT NULL DEFAULT '[]';` } + if (version === 16) { + return `ALTER TABLE new_sessions ADD COLUMN disabled_agent_tools TEXT NOT NULL DEFAULT '[]';` + } return null } getLatestVersion(): number { - return 15 + return 16 } create( @@ -57,6 +64,7 @@ export class NewSessionsTable extends BaseTable { isDraft?: boolean isPinned?: boolean activeSkills?: string[] + disabledAgentTools?: string[] createdAt?: number updatedAt?: number } @@ -74,9 +82,10 @@ export class NewSessionsTable extends BaseTable { is_pinned, is_draft, active_skills, + disabled_agent_tools, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, @@ -86,6 +95,7 @@ export class NewSessionsTable extends BaseTable { options?.isPinned ? 1 : 0, options?.isDraft ? 1 : 0, JSON.stringify(options?.activeSkills ?? []), + JSON.stringify(options?.disabledAgentTools ?? []), createdAt, updatedAt ) @@ -122,7 +132,15 @@ export class NewSessionsTable extends BaseTable { update( id: string, fields: Partial< - Pick + Pick< + NewSessionRow, + | 'title' + | 'project_dir' + | 'is_pinned' + | 'is_draft' + | 'active_skills' + | 'disabled_agent_tools' + > > ): void { const setClauses: string[] = [] @@ -148,6 +166,10 @@ export class NewSessionsTable extends BaseTable { setClauses.push('active_skills = ?') params.push(fields.active_skills) } + if (fields.disabled_agent_tools !== undefined) { + setClauses.push('disabled_agent_tools = ?') + params.push(fields.disabled_agent_tools) + } if (setClauses.length === 0) return @@ -174,7 +196,23 @@ export class NewSessionsTable extends BaseTable { this.update(id, { active_skills: JSON.stringify(activeSkills) }) } + getDisabledAgentTools(id: string): string[] { + const row = this.db + .prepare('SELECT disabled_agent_tools FROM new_sessions WHERE id = ?') + .get(id) as { disabled_agent_tools?: string | null } | undefined + + return this.parseStringArray(row?.disabled_agent_tools) + } + + updateDisabledAgentTools(id: string, disabledAgentTools: string[]): void { + this.update(id, { disabled_agent_tools: JSON.stringify(disabledAgentTools) }) + } + private parseActiveSkills(raw: string | null | undefined): string[] { + return this.parseStringArray(raw) + } + + private parseStringArray(raw: string | null | undefined): string[] { if (!raw) { return [] } diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index a4afbb605..8ba0328a1 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -42,6 +42,7 @@ interface PreCheckedPermissionResult { export interface IToolPresenter { getAllToolDefinitions(context: { enabledMcpTools?: string[] + disabledAgentTools?: string[] chatMode?: 'agent' | 'acp agent' supportsVision?: boolean agentWorkspacePath?: string | null @@ -49,7 +50,10 @@ export interface IToolPresenter { }): Promise callTool(request: MCPToolCall): Promise<{ content: unknown; rawData: MCPToolResponse }> preCheckToolPermission?(request: MCPToolCall): Promise - buildToolSystemPrompt(context: { conversationId?: string }): string + buildToolSystemPrompt(context: { + conversationId?: string + toolDefinitions?: MCPToolDefinition[] + }): string } interface ToolPresenterOptions { @@ -59,6 +63,30 @@ interface ToolPresenterOptions { agentToolRuntime: AgentToolRuntimePort } +const FILESYSTEM_TOOL_ORDER = ['read', 'write', 'edit', 'find', 'grep', 'ls', 'exec', 'process'] +const OFFLOAD_TOOL_NAMES = new Set(['exec', 'ls', 'find', 'grep', 'yo_browser_cdp_send']) + +const withToolSource = (tools: MCPToolDefinition[], source: 'mcp' | 'agent'): MCPToolDefinition[] => + tools.map((tool) => ({ + ...tool, + source + })) + +const normalizeToolNames = (toolNames?: string[]): string[] => { + if (!Array.isArray(toolNames)) { + return [] + } + + return Array.from( + new Set( + toolNames + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) + ) + ) +} + /** * ToolPresenter - Unified tool routing presenter * Manages all tool sources (MCP, Agent) and provides unified interface @@ -79,6 +107,7 @@ export class ToolPresenter implements IToolPresenter { */ async getAllToolDefinitions(context: { enabledMcpTools?: string[] + disabledAgentTools?: string[] chatMode?: 'agent' | 'acp agent' supportsVision?: boolean agentWorkspacePath?: string | null @@ -92,7 +121,10 @@ export class ToolPresenter implements IToolPresenter { const agentWorkspacePath = context.agentWorkspacePath || null // 1. Get MCP tools - const mcpDefs = await this.options.mcpPresenter.getAllToolDefinitions(context.enabledMcpTools) + const mcpDefs = withToolSource( + await this.options.mcpPresenter.getAllToolDefinitions(context.enabledMcpTools), + 'mcp' + ) defs.push(...mcpDefs) this.mapper.registerTools(mcpDefs, 'mcp') @@ -108,19 +140,26 @@ export class ToolPresenter implements IToolPresenter { } try { - const agentDefs = await this.agentToolManager.getAllToolDefinitions({ - chatMode, - supportsVision, - agentWorkspacePath, - conversationId: context.conversationId - }) - const filteredAgentDefs = agentDefs.filter((tool) => { + const agentDefs = withToolSource( + await this.agentToolManager.getAllToolDefinitions({ + chatMode, + supportsVision, + agentWorkspacePath, + conversationId: context.conversationId + }), + 'agent' + ) + const disabledAgentToolSet = new Set(normalizeToolNames(context.disabledAgentTools)) + const dedupedAgentDefs = agentDefs.filter((tool) => { if (!this.mapper.hasTool(tool.function.name)) return true console.warn( `[ToolPresenter] Tool name conflict for '${tool.function.name}', preferring MCP tool.` ) return false }) + const filteredAgentDefs = dedupedAgentDefs.filter( + (tool) => !disabledAgentToolSet.has(tool.function.name) + ) defs.push(...filteredAgentDefs) this.mapper.registerTools(filteredAgentDefs, 'agent') } catch (error) { @@ -251,20 +290,179 @@ export class ToolPresenter implements IToolPresenter { return response } - buildToolSystemPrompt(context: { conversationId?: string }): string { + buildToolSystemPrompt(context: { + conversationId?: string + toolDefinitions?: MCPToolDefinition[] + }): string { const conversationId = context.conversationId || '' const offloadPath = resolveToolOffloadTemplatePath(conversationId) ?? '~/.deepchat/sessions//tool_.offload' + const toolDefinitions = + context.toolDefinitions?.filter((tool) => tool.source === 'agent') ?? this.getFallbackTools() + const toolNames = new Set(toolDefinitions.map((tool) => tool.function.name)) + const groupedTools = new Map() + for (const tool of toolDefinitions) { + const existing = groupedTools.get(tool.server.name) ?? [] + existing.push(tool) + groupedTools.set(tool.server.name, existing) + } + + const sections = [ + this.buildFilesystemPrompt(toolNames, offloadPath), + this.buildQuestionPrompt(toolNames), + this.buildSkillsPrompt(toolNames), + this.buildSettingsPrompt(groupedTools.get('deepchat-settings') ?? []), + this.buildYoBrowserPrompt(groupedTools.get('yobrowser') ?? []) + ] + + return sections.filter(Boolean).join('\n\n') + } + + private getFallbackTools(): MCPToolDefinition[] { + return FILESYSTEM_TOOL_ORDER.map((name) => ({ + type: 'function' as const, + source: 'agent' as const, + function: { + name, + description: '', + parameters: { type: 'object', properties: {} } + }, + server: { + name: 'agent-filesystem', + icons: '', + description: '' + } + })).concat([ + { + type: 'function' as const, + source: 'agent' as const, + function: { + name: QUESTION_TOOL_NAME, + description: '', + parameters: { type: 'object', properties: {} } + }, + server: { + name: 'agent-core', + icons: '', + description: '' + } + } + ]) + } + + private buildFilesystemPrompt(toolNames: Set, offloadPath: string): string { + const filesystemTools = FILESYSTEM_TOOL_ORDER.filter((toolName) => toolNames.has(toolName)) + if (filesystemTools.length === 0) { + return '' + } + + const lines = [ + '## File and Command Tools', + `Use canonical Agent tool names only: ${filesystemTools.join(', ')}.`, + 'Legacy or disabled Agent tool names are not available.' + ] + + const searchSteps = ['find', 'grep'].filter((toolName) => toolNames.has(toolName)) + const mutationSteps = ['edit', 'write'].filter((toolName) => toolNames.has(toolName)) + const flow: string[] = [] + if (searchSteps.length > 0) { + flow.push(searchSteps.join('/')) + } + if (toolNames.has('read')) { + flow.push('read') + } + if (mutationSteps.length > 0) { + flow.push(mutationSteps.join('/')) + } + if (flow.length >= 2) { + lines.push(`Recommended code task flow: ${flow.join(' -> ')}.`) + } + + const hasOffloadTools = Array.from(toolNames).some((toolName) => + OFFLOAD_TOOL_NAMES.has(toolName) + ) + if (hasOffloadTools) { + lines.push('Tool outputs may be offloaded when large.') + lines.push(`When you see an offload stub, the full output is stored at: ${offloadPath}`) + if (toolNames.has('read')) { + lines.push('Use `read` to inspect that path when you need the full output.') + } + } + + return lines.join('\n') + } + + private buildQuestionPrompt(toolNames: Set): string { + if (!toolNames.has(QUESTION_TOOL_NAME)) { + return '' + } + + return [ + '## User Interaction', + `If you need user confirmation or a structured choice, ask with the ${QUESTION_TOOL_NAME} tool.` + ].join('\n') + } + + private buildSkillsPrompt(toolNames: Set): string { + const lines = ['## Skill Tools'] + let hasContent = false + + if (toolNames.has('skill_list')) { + lines.push('- Use `skill_list` to inspect available skills and activation status.') + hasContent = true + } + if (toolNames.has('skill_control')) { + lines.push('- Use `skill_control` to activate or deactivate skills before continuing.') + hasContent = true + } + if (toolNames.has('skill_run')) { + lines.push('- Use `skill_run` to execute bundled scripts from active skills.') + hasContent = true + } + + return hasContent ? lines.join('\n') : '' + } + + private buildSettingsPrompt(tools: MCPToolDefinition[]): string { + if (tools.length === 0) { + return '' + } + + const names = tools.map((tool) => `\`${tool.function.name}\``).join(', ') return [ - 'Use canonical Agent tool names only: read, write, edit, find, grep, ls, exec, process.', - 'Legacy tool names are not available and will fail with Unknown Agent tool.', - 'Recommended sequence for code tasks: find/grep -> read -> edit/write.', - 'Tool outputs may be offloaded when large.', - `When you see an offload stub, read the full output from: ${offloadPath}`, - 'Use file tools to read that path. Access is limited to the current conversation session.', - `If you need user confirmation or choices, ask with the ${QUESTION_TOOL_NAME} tool.` + '## DeepChat Settings Tools', + `DeepChat settings tools are available in this session: ${names}.`, + 'Prefer these tools over describing manual settings steps when a direct change is possible.' ].join('\n') } + + private buildYoBrowserPrompt(tools: MCPToolDefinition[]): string { + if (tools.length === 0) { + return '' + } + + const toolNames = new Set(tools.map((tool) => tool.function.name)) + const lines = [ + '## YoBrowser Tools', + `Available YoBrowser tools: ${tools.map((tool) => `\`${tool.function.name}\``).join(', ')}.` + ] + + if (toolNames.has('yo_browser_window_list')) { + lines.push('- Use `yo_browser_window_list` to inspect current browser windows before acting.') + } + if (toolNames.has('yo_browser_window_open')) { + lines.push( + '- Use `yo_browser_window_open` when you need a browser window for web exploration.' + ) + } + if (toolNames.has('yo_browser_cdp_send')) { + lines.push( + '- Use `yo_browser_cdp_send` for DOM inspection, scripted interaction, and screenshots.' + ) + } + + return lines.join('\n') + } } diff --git a/src/renderer/src/components/chat-input/McpIndicator.vue b/src/renderer/src/components/chat-input/McpIndicator.vue index 3cbbc251d..2b3dcc30a 100644 --- a/src/renderer/src/components/chat-input/McpIndicator.vue +++ b/src/renderer/src/components/chat-input/McpIndicator.vue @@ -1,79 +1,322 @@ diff --git a/src/renderer/src/i18n/da-DK/chat.json b/src/renderer/src/i18n/da-DK/chat.json index 3853bac47..3267e30de 100644 --- a/src/renderer/src/i18n/da-DK/chat.json +++ b/src/renderer/src/i18n/da-DK/chat.json @@ -40,6 +40,21 @@ "title": "Aktiverede MCP'er", "empty": "Ingen aktiverede tjenester", "openSettings": "Åbn MCP-indstillinger" + }, + "tools": { + "badge": "Tools", + "title": "Tools", + "mcpSection": "MCP", + "builtinSection": "Built-in Tools", + "loading": "Loading tools...", + "builtinEmpty": "No built-in tools available", + "groups": { + "agentFilesystem": "Agent Filesystem", + "agentCore": "Agent Core", + "agentSkills": "Agent Skills", + "deepchatSettings": "DeepChat Settings", + "yobrowser": "YoBrowser" + } } }, "mcpUi": { diff --git a/src/renderer/src/i18n/en-US/chat.json b/src/renderer/src/i18n/en-US/chat.json index 71f6142d9..461b83981 100644 --- a/src/renderer/src/i18n/en-US/chat.json +++ b/src/renderer/src/i18n/en-US/chat.json @@ -33,6 +33,21 @@ "title": "Enabled MCP", "empty": "No enabled services", "openSettings": "Open MCP settings" + }, + "tools": { + "badge": "Tools", + "title": "Tools", + "mcpSection": "MCP", + "builtinSection": "Built-in Tools", + "loading": "Loading tools...", + "builtinEmpty": "No built-in tools available", + "groups": { + "agentFilesystem": "Agent Filesystem", + "agentCore": "Agent Core", + "agentSkills": "Agent Skills", + "deepchatSettings": "DeepChat Settings", + "yobrowser": "YoBrowser" + } } }, "features": { diff --git a/src/renderer/src/i18n/fa-IR/chat.json b/src/renderer/src/i18n/fa-IR/chat.json index 549ca5ddc..7b114f27c 100644 --- a/src/renderer/src/i18n/fa-IR/chat.json +++ b/src/renderer/src/i18n/fa-IR/chat.json @@ -33,6 +33,21 @@ "title": "MCPهای فعال", "empty": "سرویسی فعال نشده است", "openSettings": "باز کردن تنظیمات MCP" + }, + "tools": { + "badge": "Tools", + "title": "Tools", + "mcpSection": "MCP", + "builtinSection": "Built-in Tools", + "loading": "Loading tools...", + "builtinEmpty": "No built-in tools available", + "groups": { + "agentFilesystem": "Agent Filesystem", + "agentCore": "Agent Core", + "agentSkills": "Agent Skills", + "deepchatSettings": "DeepChat Settings", + "yobrowser": "YoBrowser" + } } }, "features": { diff --git a/src/renderer/src/i18n/fr-FR/chat.json b/src/renderer/src/i18n/fr-FR/chat.json index ac8cb30b7..23e379fde 100644 --- a/src/renderer/src/i18n/fr-FR/chat.json +++ b/src/renderer/src/i18n/fr-FR/chat.json @@ -33,6 +33,21 @@ "title": "MCP activés", "empty": "Aucun service activé", "openSettings": "Ouvrir les paramètres MCP" + }, + "tools": { + "badge": "Tools", + "title": "Tools", + "mcpSection": "MCP", + "builtinSection": "Built-in Tools", + "loading": "Loading tools...", + "builtinEmpty": "No built-in tools available", + "groups": { + "agentFilesystem": "Agent Filesystem", + "agentCore": "Agent Core", + "agentSkills": "Agent Skills", + "deepchatSettings": "DeepChat Settings", + "yobrowser": "YoBrowser" + } } }, "features": { diff --git a/src/renderer/src/i18n/he-IL/chat.json b/src/renderer/src/i18n/he-IL/chat.json index 2bff8f3c4..978a0ca34 100644 --- a/src/renderer/src/i18n/he-IL/chat.json +++ b/src/renderer/src/i18n/he-IL/chat.json @@ -33,6 +33,21 @@ "title": "MCP פעילים", "empty": "אין שירותים פעילים", "openSettings": "פתח הגדרות MCP" + }, + "tools": { + "badge": "Tools", + "title": "Tools", + "mcpSection": "MCP", + "builtinSection": "Built-in Tools", + "loading": "Loading tools...", + "builtinEmpty": "No built-in tools available", + "groups": { + "agentFilesystem": "Agent Filesystem", + "agentCore": "Agent Core", + "agentSkills": "Agent Skills", + "deepchatSettings": "DeepChat Settings", + "yobrowser": "YoBrowser" + } } }, "features": { diff --git a/src/renderer/src/i18n/ja-JP/chat.json b/src/renderer/src/i18n/ja-JP/chat.json index 947d2bda0..5c8acd3b0 100644 --- a/src/renderer/src/i18n/ja-JP/chat.json +++ b/src/renderer/src/i18n/ja-JP/chat.json @@ -33,6 +33,21 @@ "title": "有効な MCP", "empty": "有効なサービスはありません", "openSettings": "MCP 設定を開く" + }, + "tools": { + "badge": "Tools", + "title": "Tools", + "mcpSection": "MCP", + "builtinSection": "Built-in Tools", + "loading": "Loading tools...", + "builtinEmpty": "No built-in tools available", + "groups": { + "agentFilesystem": "Agent Filesystem", + "agentCore": "Agent Core", + "agentSkills": "Agent Skills", + "deepchatSettings": "DeepChat Settings", + "yobrowser": "YoBrowser" + } } }, "features": { diff --git a/src/renderer/src/i18n/ko-KR/chat.json b/src/renderer/src/i18n/ko-KR/chat.json index b7be6662a..826e9f05e 100644 --- a/src/renderer/src/i18n/ko-KR/chat.json +++ b/src/renderer/src/i18n/ko-KR/chat.json @@ -33,6 +33,21 @@ "title": "활성화된 MCP", "empty": "활성화된 서비스가 없습니다", "openSettings": "MCP 설정 열기" + }, + "tools": { + "badge": "Tools", + "title": "Tools", + "mcpSection": "MCP", + "builtinSection": "Built-in Tools", + "loading": "Loading tools...", + "builtinEmpty": "No built-in tools available", + "groups": { + "agentFilesystem": "Agent Filesystem", + "agentCore": "Agent Core", + "agentSkills": "Agent Skills", + "deepchatSettings": "DeepChat Settings", + "yobrowser": "YoBrowser" + } } }, "features": { diff --git a/src/renderer/src/i18n/pt-BR/chat.json b/src/renderer/src/i18n/pt-BR/chat.json index 52d37f91a..84a879855 100644 --- a/src/renderer/src/i18n/pt-BR/chat.json +++ b/src/renderer/src/i18n/pt-BR/chat.json @@ -33,6 +33,21 @@ "title": "MCPs habilitados", "empty": "Nenhum serviço habilitado", "openSettings": "Abrir configurações do MCP" + }, + "tools": { + "badge": "Tools", + "title": "Tools", + "mcpSection": "MCP", + "builtinSection": "Built-in Tools", + "loading": "Loading tools...", + "builtinEmpty": "No built-in tools available", + "groups": { + "agentFilesystem": "Agent Filesystem", + "agentCore": "Agent Core", + "agentSkills": "Agent Skills", + "deepchatSettings": "DeepChat Settings", + "yobrowser": "YoBrowser" + } } }, "features": { diff --git a/src/renderer/src/i18n/ru-RU/chat.json b/src/renderer/src/i18n/ru-RU/chat.json index 1f24a24e0..4ce1ecbd3 100644 --- a/src/renderer/src/i18n/ru-RU/chat.json +++ b/src/renderer/src/i18n/ru-RU/chat.json @@ -33,6 +33,21 @@ "title": "Включённые MCP", "empty": "Нет включённых сервисов", "openSettings": "Открыть настройки MCP" + }, + "tools": { + "badge": "Tools", + "title": "Tools", + "mcpSection": "MCP", + "builtinSection": "Built-in Tools", + "loading": "Loading tools...", + "builtinEmpty": "No built-in tools available", + "groups": { + "agentFilesystem": "Agent Filesystem", + "agentCore": "Agent Core", + "agentSkills": "Agent Skills", + "deepchatSettings": "DeepChat Settings", + "yobrowser": "YoBrowser" + } } }, "features": { diff --git a/src/renderer/src/i18n/zh-CN/chat.json b/src/renderer/src/i18n/zh-CN/chat.json index 924fdb59d..fa1ecc812 100644 --- a/src/renderer/src/i18n/zh-CN/chat.json +++ b/src/renderer/src/i18n/zh-CN/chat.json @@ -33,6 +33,21 @@ "title": "已启用 MCP", "empty": "暂无已启用服务", "openSettings": "打开 MCP 设置" + }, + "tools": { + "badge": "Tools", + "title": "Tools", + "mcpSection": "MCP", + "builtinSection": "内置工具", + "loading": "正在加载工具...", + "builtinEmpty": "暂无可用内置工具", + "groups": { + "agentFilesystem": "文件系统", + "agentCore": "核心工具", + "agentSkills": "技能工具", + "deepchatSettings": "DeepChat 设置", + "yobrowser": "YoBrowser" + } } }, "features": { diff --git a/src/renderer/src/i18n/zh-HK/chat.json b/src/renderer/src/i18n/zh-HK/chat.json index 31307487e..5a35d4466 100644 --- a/src/renderer/src/i18n/zh-HK/chat.json +++ b/src/renderer/src/i18n/zh-HK/chat.json @@ -33,6 +33,21 @@ "title": "已啟用 MCP", "empty": "暫無已啟用服務", "openSettings": "打開 MCP 設置" + }, + "tools": { + "badge": "Tools", + "title": "Tools", + "mcpSection": "MCP", + "builtinSection": "內置工具", + "loading": "正在載入工具...", + "builtinEmpty": "暫無可用內置工具", + "groups": { + "agentFilesystem": "檔案系統", + "agentCore": "核心工具", + "agentSkills": "技能工具", + "deepchatSettings": "DeepChat 設置", + "yobrowser": "YoBrowser" + } } }, "features": { diff --git a/src/renderer/src/i18n/zh-TW/chat.json b/src/renderer/src/i18n/zh-TW/chat.json index 23fc52cf9..c8b649f64 100644 --- a/src/renderer/src/i18n/zh-TW/chat.json +++ b/src/renderer/src/i18n/zh-TW/chat.json @@ -33,6 +33,21 @@ "title": "已啟用 MCP", "empty": "暫無已啟用服務", "openSettings": "打開 MCP 設置" + }, + "tools": { + "badge": "Tools", + "title": "Tools", + "mcpSection": "MCP", + "builtinSection": "內置工具", + "loading": "正在載入工具...", + "builtinEmpty": "暫無可用內置工具", + "groups": { + "agentFilesystem": "檔案系統", + "agentCore": "核心工具", + "agentSkills": "技能工具", + "deepchatSettings": "DeepChat 設定", + "yobrowser": "YoBrowser" + } } }, "features": { diff --git a/src/renderer/src/pages/NewThreadPage.vue b/src/renderer/src/pages/NewThreadPage.vue index 7c3dc825a..729f4b1df 100644 --- a/src/renderer/src/pages/NewThreadPage.vue +++ b/src/renderer/src/pages/NewThreadPage.vue @@ -239,6 +239,7 @@ async function submitText(text: string, files: MessageFile[]) { providerId, modelId, permissionMode: draftStore.permissionMode, + disabledAgentTools: agentId === 'deepchat' ? [...draftStore.disabledAgentTools] : undefined, generationSettings: draftStore.toGenerationSettings(), activeSkills: dedupedPendingSkills.length > 0 ? dedupedPendingSkills : undefined }) @@ -323,6 +324,7 @@ onMounted(() => { draftStore.providerId = undefined draftStore.modelId = undefined draftStore.permissionMode = 'full_access' + draftStore.disabledAgentTools = [] draftStore.resetGenerationSettings() }) diff --git a/src/renderer/src/stores/ui/draft.ts b/src/renderer/src/stores/ui/draft.ts index 73b4bdf37..e3b81ee5a 100644 --- a/src/renderer/src/stores/ui/draft.ts +++ b/src/renderer/src/stores/ui/draft.ts @@ -22,6 +22,7 @@ export const useDraftStore = defineStore('draft', () => { const reasoningEffort = ref(undefined) const verbosity = ref(undefined) const permissionMode = ref('full_access') + const disabledAgentTools = ref([]) // --- Actions --- @@ -47,6 +48,7 @@ export const useDraftStore = defineStore('draft', () => { providerId: providerId.value, modelId: modelId.value, permissionMode: permissionMode.value, + disabledAgentTools: [...disabledAgentTools.value], generationSettings: toGenerationSettings() } } @@ -91,6 +93,7 @@ export const useDraftStore = defineStore('draft', () => { projectDir.value = undefined agentId.value = 'deepchat' permissionMode.value = 'full_access' + disabledAgentTools.value = [] resetGenerationSettings() } @@ -107,6 +110,7 @@ export const useDraftStore = defineStore('draft', () => { reasoningEffort, verbosity, permissionMode, + disabledAgentTools, toGenerationSettings, toCreateInput, updateGenerationSettings, diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index 75cdc4fee..fbdde3549 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -329,6 +329,7 @@ export interface CreateSessionInput { modelId?: string permissionMode?: PermissionMode activeSkills?: string[] + disabledAgentTools?: string[] generationSettings?: Partial } diff --git a/src/shared/types/core/mcp.ts b/src/shared/types/core/mcp.ts index 51228b521..2f5d4d33e 100644 --- a/src/shared/types/core/mcp.ts +++ b/src/shared/types/core/mcp.ts @@ -2,6 +2,7 @@ export interface MCPToolDefinition { type: string + source?: 'mcp' | 'agent' function: { name: string description: string diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index 355f66b83..c852ec0ed 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -1532,6 +1532,7 @@ export interface MCPConfig { export interface MCPToolDefinition { type: string + source?: 'mcp' | 'agent' function: { name: string description: string diff --git a/src/shared/types/presenters/new-agent.presenter.d.ts b/src/shared/types/presenters/new-agent.presenter.d.ts index f39587847..23bf2635e 100644 --- a/src/shared/types/presenters/new-agent.presenter.d.ts +++ b/src/shared/types/presenters/new-agent.presenter.d.ts @@ -72,6 +72,11 @@ export interface INewAgentPresenter { setPermissionMode(sessionId: string, mode: PermissionMode): Promise setSessionModel(sessionId: string, providerId: string, modelId: string): Promise getSessionGenerationSettings(sessionId: string): Promise + getSessionDisabledAgentTools(sessionId: string): Promise + updateSessionDisabledAgentTools( + sessionId: string, + disabledAgentTools: string[] + ): Promise updateSessionGenerationSettings( sessionId: string, settings: Partial diff --git a/src/shared/types/presenters/tool.presenter.d.ts b/src/shared/types/presenters/tool.presenter.d.ts index c89b5bebe..4f6b39e34 100644 --- a/src/shared/types/presenters/tool.presenter.d.ts +++ b/src/shared/types/presenters/tool.presenter.d.ts @@ -16,6 +16,7 @@ export interface IToolPresenter { */ getAllToolDefinitions(context: { enabledMcpTools?: string[] + disabledAgentTools?: string[] chatMode?: 'agent' | 'acp agent' supportsVision?: boolean agentWorkspacePath?: string | null @@ -60,5 +61,8 @@ export interface IToolPresenter { /** * Build system prompt section for tool-related behavior. */ - buildToolSystemPrompt(context: { conversationId?: string }): string + buildToolSystemPrompt(context: { + conversationId?: string + toolDefinitions?: MCPToolDefinition[] + }): string } diff --git a/test/main/presenter/agentPresenter/message/systemEnvPromptBuilder.test.ts b/test/main/presenter/agentPresenter/message/systemEnvPromptBuilder.test.ts index fbab27a3f..d0f65d0c0 100644 --- a/test/main/presenter/agentPresenter/message/systemEnvPromptBuilder.test.ts +++ b/test/main/presenter/agentPresenter/message/systemEnvPromptBuilder.test.ts @@ -107,6 +107,28 @@ describe('systemEnvPromptBuilder', () => { expect(prompt).toContain('process(list|poll|log|write|kill|remove)') }) + it('omits runtime capability lines when related tools are unavailable', () => { + const prompt = buildRuntimeCapabilitiesPrompt({ + hasYoBrowser: false, + hasExec: false, + hasProcess: false + }) + + expect(prompt).toBe('') + }) + + it('builds a partial runtime capabilities prompt when only exec is enabled', () => { + const prompt = buildRuntimeCapabilitiesPrompt({ + hasYoBrowser: false, + hasExec: true, + hasProcess: false + }) + + expect(prompt).toContain('exec(background: true)') + expect(prompt).not.toContain('YoBrowser') + expect(prompt).not.toContain('process(list|poll|log|write|kill|remove)') + }) + it('falls back to unknown provider/model identity', async () => { const workdir = path.resolve(path.sep, 'workspace', 'deepchat') const prompt = await buildSystemEnvPrompt({ diff --git a/test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts b/test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts index 6cce07ec4..f3562f05c 100644 --- a/test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts +++ b/test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts @@ -69,7 +69,10 @@ vi.mock('@/presenter/deepchatAgentPresenter/process', () => ({ import { eventBus } from '@/eventbus' import { processStream } from '@/presenter/deepchatAgentPresenter/process' import { presenter } from '@/presenter' -import { buildSystemEnvPrompt } from '@/lib/agentRuntime/systemEnvPromptBuilder' +import { + buildRuntimeCapabilitiesPrompt, + buildSystemEnvPrompt +} from '@/lib/agentRuntime/systemEnvPromptBuilder' function createMockSqlitePresenter() { const summaryState = { @@ -79,7 +82,8 @@ function createMockSqlitePresenter() { } return { newSessionsTable: { - get: vi.fn() + get: vi.fn(), + getDisabledAgentTools: vi.fn().mockReturnValue([]) }, deepchatSessionsTable: { create: vi.fn(), @@ -853,6 +857,28 @@ describe('DeepChatAgentPresenter', () => { getActiveSkills: ReturnType loadSkillContent: ReturnType } + toolPresenter.getAllToolDefinitions.mockResolvedValueOnce([ + { + type: 'function', + source: 'agent', + function: { + name: 'skill_list', + description: 'skill list', + parameters: { type: 'object', properties: {} } + }, + server: { name: 'agent-skills', icons: '', description: '' } + }, + { + type: 'function', + source: 'agent', + function: { + name: 'skill_control', + description: 'skill control', + parameters: { type: 'object', properties: {} } + }, + server: { name: 'agent-skills', icons: '', description: '' } + } + ]) toolPresenter.buildToolSystemPrompt.mockReturnValue('TOOLING_BLOCK') skillPresenter.getMetadataList.mockResolvedValue([{ name: 'skill-a', description: 'desc-a' }]) skillPresenter.getActiveSkills.mockResolvedValue(['skill-a']) @@ -887,6 +913,81 @@ describe('DeepChatAgentPresenter', () => { expect(systemPrompt).not.toContain('desc-a') }) + it('derives runtime capabilities from the current enabled agent tools', async () => { + const runtimeBuilder = buildRuntimeCapabilitiesPrompt as ReturnType + toolPresenter.getAllToolDefinitions.mockResolvedValueOnce([ + { + type: 'function', + source: 'agent', + function: { + name: 'exec', + description: 'exec', + parameters: { type: 'object', properties: {} } + }, + server: { name: 'agent-filesystem', icons: '', description: '' } + }, + { + type: 'function', + source: 'agent', + function: { + name: 'skill_list', + description: 'skill list', + parameters: { type: 'object', properties: {} } + }, + server: { name: 'agent-skills', icons: '', description: '' } + } + ]) + + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + await agent.processMessage('s1', 'Inspect tools') + + expect(runtimeBuilder).toHaveBeenCalledWith({ + hasYoBrowser: false, + hasExec: true, + hasProcess: false + }) + expect(toolPresenter.buildToolSystemPrompt).toHaveBeenCalledWith({ + conversationId: 's1', + toolDefinitions: expect.arrayContaining([ + expect.objectContaining({ + function: expect.objectContaining({ name: 'exec' }) + }) + ]) + }) + }) + + it('omits skill metadata when skill management tools are unavailable', async () => { + const skillPresenter = presenter.skillPresenter as { + getMetadataList: ReturnType + getActiveSkills: ReturnType + loadSkillContent: ReturnType + } + + skillPresenter.getMetadataList.mockResolvedValue([{ name: 'skill-a' }]) + skillPresenter.getActiveSkills.mockResolvedValue([]) + toolPresenter.getAllToolDefinitions.mockResolvedValueOnce([ + { + type: 'function', + source: 'agent', + function: { + name: 'exec', + description: 'exec', + parameters: { type: 'object', properties: {} } + }, + server: { name: 'agent-filesystem', icons: '', description: '' } + } + ]) + + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + await agent.processMessage('s1', 'No skill tools') + + const callArgs = (processStream as ReturnType).mock.calls[0][0] + const systemPrompt = String(callArgs.messages[0].content) + + expect(systemPrompt).not.toContain('## Skills') + expect(systemPrompt).not.toContain('- skill-a') + }) + it('transitions status: idle → generating → idle', async () => { await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) await agent.processMessage('s1', 'Hello') @@ -1847,6 +1948,18 @@ describe('DeepChatAgentPresenter', () => { content: 'done', rawData: { content: 'done', isError: false } }) + toolPresenter.getAllToolDefinitions.mockResolvedValueOnce([ + { + type: 'function', + source: 'agent', + function: { + name: 'write_file', + description: 'write file', + parameters: { type: 'object', properties: {} } + }, + server: { name: 'agent-filesystem', icons: '', description: '' } + } + ]) const result = await agent.respondToolInteraction('s1', 'm1', 'tc1', { kind: 'permission', @@ -2120,4 +2233,26 @@ describe('DeepChatAgentPresenter', () => { expect(mode).toBe('default') }) }) + + describe('disabled tools', () => { + it('returns a disabled error when a deferred tool call is no longer enabled', async () => { + sqlitePresenter.newSessionsTable.getDisabledAgentTools.mockReturnValue(['exec']) + toolPresenter.getAllToolDefinitions.mockResolvedValueOnce([]) + + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + + const result = await (agent as any).executeDeferredToolCall('s1', { + id: 'tc1', + name: 'exec', + params: '{"command":"npm test"}' + }) + + expect(result).toEqual( + expect.objectContaining({ + isError: true, + responseText: "Tool 'exec' is disabled for the current session." + }) + ) + }) + }) }) diff --git a/test/main/presenter/newAgentPresenter/integration.test.ts b/test/main/presenter/newAgentPresenter/integration.test.ts index 31e370dd0..98f9bf935 100644 --- a/test/main/presenter/newAgentPresenter/integration.test.ts +++ b/test/main/presenter/newAgentPresenter/integration.test.ts @@ -81,6 +81,8 @@ function createMockSqlitePresenter() { ), get: vi.fn((id: string) => sessionsStore.get(id)), list: vi.fn(() => Array.from(sessionsStore.values())), + getDisabledAgentTools: vi.fn().mockReturnValue([]), + updateDisabledAgentTools: vi.fn(), update: vi.fn(), delete: vi.fn((id: string) => sessionsStore.delete(id)) }, diff --git a/test/main/presenter/newAgentPresenter/legacyImportService.test.ts b/test/main/presenter/newAgentPresenter/legacyImportService.test.ts index 53d21fbf0..d5f885824 100644 --- a/test/main/presenter/newAgentPresenter/legacyImportService.test.ts +++ b/test/main/presenter/newAgentPresenter/legacyImportService.test.ts @@ -57,11 +57,13 @@ function createMockSqlitePresenter() { : undefined }), getActiveSkills: vi.fn((id: string) => sessionStore.get(id)?.activeSkills ?? []), + getDisabledAgentTools: vi.fn().mockReturnValue([]), updateActiveSkills: vi.fn((id: string, activeSkills: string[]) => { const row = sessionStore.get(id) if (!row) return row.activeSkills = [...activeSkills] - }) + }), + updateDisabledAgentTools: vi.fn() }, deepchatSessionsTable: { get: vi.fn(() => undefined), diff --git a/test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts b/test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts index 9c0e89b68..1b35199d7 100644 --- a/test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts +++ b/test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts @@ -43,6 +43,7 @@ function createMockDeepChatAgent() { return { initSession: vi.fn().mockResolvedValue(undefined), destroySession: vi.fn().mockResolvedValue(undefined), + invalidateSessionSystemPromptCache: vi.fn(), getSessionState: vi.fn().mockResolvedValue({ status: 'idle', providerId: 'openai', @@ -119,6 +120,8 @@ function createMockSqlitePresenter() { create: vi.fn(), get: vi.fn(), list: vi.fn().mockReturnValue([]), + getDisabledAgentTools: vi.fn().mockReturnValue([]), + updateDisabledAgentTools: vi.fn(), update: vi.fn(), delete: vi.fn() }, @@ -351,6 +354,28 @@ describe('NewAgentPresenter', () => { ) }) + it('persists disabled agent tools for deepchat sessions', async () => { + await presenter.createSession( + { + agentId: 'deepchat', + message: 'Hi', + disabledAgentTools: ['exec', 'exec', 'yo_browser_cdp_send'] + }, + 1 + ) + + expect(sqlitePresenter.newSessionsTable.create).toHaveBeenCalledWith( + 'mock-session-id', + 'deepchat', + 'Hi', + null, + expect.objectContaining({ + isDraft: false, + disabledAgentTools: ['exec', 'yo_browser_cdp_send'] + }) + ) + }) + it('throws when no provider/model available', async () => { configPresenter.getDefaultModel.mockReturnValue(null) @@ -976,6 +1001,53 @@ describe('NewAgentPresenter', () => { }) }) + describe('disabled agent tools', () => { + it('reads disabled agent tools from session storage', async () => { + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + title: 'Test', + project_dir: null, + is_pinned: 0, + created_at: 1000, + updated_at: 1000 + }) + sqlitePresenter.newSessionsTable.getDisabledAgentTools.mockReturnValue([ + 'exec', + 'yo_browser_cdp_send' + ]) + + const disabledTools = await presenter.getSessionDisabledAgentTools('s1') + + expect(disabledTools).toEqual(['exec', 'yo_browser_cdp_send']) + }) + + it('updates disabled agent tools and invalidates the deepchat prompt cache', async () => { + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + title: 'Test', + project_dir: null, + is_pinned: 0, + created_at: 1000, + updated_at: 1000 + }) + + const disabledTools = await presenter.updateSessionDisabledAgentTools('s1', [ + 'yo_browser_cdp_send', + 'exec', + 'exec' + ]) + + expect(disabledTools).toEqual(['exec', 'yo_browser_cdp_send']) + expect(sqlitePresenter.newSessionsTable.updateDisabledAgentTools).toHaveBeenCalledWith('s1', [ + 'exec', + 'yo_browser_cdp_send' + ]) + expect(deepChatAgent.invalidateSessionSystemPromptCache).toHaveBeenCalledWith('s1') + }) + }) + describe('setSessionModel', () => { it('updates deepchat session model and emits LIST_UPDATED', async () => { sqlitePresenter.newSessionsTable.get.mockReturnValue({ diff --git a/test/main/presenter/newAgentPresenter/sessionManager.test.ts b/test/main/presenter/newAgentPresenter/sessionManager.test.ts index e809229b9..beb6e6d6b 100644 --- a/test/main/presenter/newAgentPresenter/sessionManager.test.ts +++ b/test/main/presenter/newAgentPresenter/sessionManager.test.ts @@ -9,6 +9,8 @@ function createMockSqlitePresenter() { create: vi.fn(), get: vi.fn(), list: vi.fn().mockReturnValue([]), + getDisabledAgentTools: vi.fn().mockReturnValue([]), + updateDisabledAgentTools: vi.fn(), update: vi.fn(), delete: vi.fn() } diff --git a/test/main/presenter/sqlitePresenter.test.ts b/test/main/presenter/sqlitePresenter.test.ts index b38462188..a6f0168e8 100644 --- a/test/main/presenter/sqlitePresenter.test.ts +++ b/test/main/presenter/sqlitePresenter.test.ts @@ -99,11 +99,32 @@ describe('SQLitePresenter legacy schema bootstrap', () => { }> const columnNames = new Set(newSessionColumns.map((column) => column.name)) expect(columnNames.has('active_skills')).toBe(true) + expect(columnNames.has('disabled_agent_tools')).toBe(true) const versions = checkDb .prepare('SELECT version FROM schema_versions ORDER BY version ASC') .all() as Array<{ version: number }> - expect(versions.map((row) => row.version)).toContain(15) + expect(versions.map((row) => row.version)).toContain(16) + checkDb.close() + }) + + it('creates fresh new_sessions tables with disabled_agent_tools column', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepchat-sqlite-presenter-')) + tempDirs.push(tempDir) + + const dbPath = path.join(tempDir, 'agent.db') + const presenter = new SQLitePresenter(dbPath) + presenter.close() + + const checkDb = new Database(dbPath) + const newSessionColumns = checkDb.prepare('PRAGMA table_info(new_sessions)').all() as Array<{ + name: string + }> + const columnNames = new Set(newSessionColumns.map((column) => column.name)) + + expect(columnNames.has('is_draft')).toBe(true) + expect(columnNames.has('active_skills')).toBe(true) + expect(columnNames.has('disabled_agent_tools')).toBe(true) checkDb.close() }) }) diff --git a/test/main/presenter/toolPresenter/toolPresenter.test.ts b/test/main/presenter/toolPresenter/toolPresenter.test.ts index 0f07f4c02..8369c3553 100644 --- a/test/main/presenter/toolPresenter/toolPresenter.test.ts +++ b/test/main/presenter/toolPresenter/toolPresenter.test.ts @@ -161,4 +161,215 @@ describe('ToolPresenter', () => { expect(callToolSpy).toHaveBeenCalledWith('read', { path: 'foo' }, 'conv-1') }) + + it('filters disabled agent tools while preserving MCP tools', async () => { + const mcpDefs = [buildToolDefinition('shared', 'mcp'), buildToolDefinition('mcp_only', 'mcp')] + const mcpPresenter = { + getAllToolDefinitions: vi.fn().mockResolvedValue(mcpDefs), + callTool: vi.fn() + } as any + const configPresenter = { + getSkillsEnabled: vi.fn().mockReturnValue(false), + getSkillsPath: vi.fn().mockReturnValue('C:\\\\skills'), + getDefaultVisionModel: vi.fn(), + getModelConfig: vi.fn() + } + const runtimePort = { + resolveConversationWorkdir: vi.fn().mockResolvedValue(null), + getSkillPresenter: () => + ({ + getActiveSkills: vi.fn().mockResolvedValue([]), + getActiveSkillsAllowedTools: vi.fn().mockResolvedValue([]), + listSkillScripts: vi.fn().mockResolvedValue([]), + getSkillExtension: vi.fn().mockResolvedValue({ + version: 1, + env: {}, + runtimePolicy: { python: 'auto', node: 'auto' }, + scriptOverrides: {} + }) + }) as any, + getYoBrowserToolHandler: () => ({ + getToolDefinitions: vi.fn().mockReturnValue([]), + callTool: vi.fn() + }), + getFilePresenter: () => ({ + getMimeType: vi.fn(), + prepareFileCompletely: vi.fn() + }), + getLlmProviderPresenter: () => ({ + generateCompletionStandalone: vi.fn() + }), + createSettingsWindow: vi.fn(), + sendToWindow: vi.fn().mockReturnValue(true), + getApprovedFilePaths: vi.fn().mockReturnValue([]), + consumeSettingsApproval: vi.fn().mockReturnValue(false) + } + + const toolPresenter = new ToolPresenter({ + mcpPresenter, + configPresenter: configPresenter as any, + commandPermissionHandler: new CommandPermissionService(), + agentToolRuntime: runtimePort as any + }) + + const defs = await toolPresenter.getAllToolDefinitions({ + disabledAgentTools: ['read', 'exec'], + chatMode: 'agent', + supportsVision: false, + agentWorkspacePath: 'C:\\\\workspace' + }) + + expect(defs.some((tool) => tool.function.name === 'mcp_only' && tool.source === 'mcp')).toBe( + true + ) + expect(defs.some((tool) => tool.function.name === 'read')).toBe(false) + expect(defs.some((tool) => tool.function.name === 'exec')).toBe(false) + }) + + it('omits YoBrowser prompt text when no yobrowser tools are enabled', () => { + const mcpPresenter = { + getAllToolDefinitions: vi.fn().mockResolvedValue([]), + callTool: vi.fn() + } as any + const configPresenter = { + getSkillsEnabled: vi.fn().mockReturnValue(false), + getSkillsPath: vi.fn().mockReturnValue('C:\\\\skills'), + getDefaultVisionModel: vi.fn(), + getModelConfig: vi.fn() + } + + const toolPresenter = new ToolPresenter({ + mcpPresenter, + configPresenter: configPresenter as any, + commandPermissionHandler: new CommandPermissionService(), + agentToolRuntime: { + resolveConversationWorkdir: vi.fn().mockResolvedValue(null), + getSkillPresenter: () => + ({ + getActiveSkills: vi.fn().mockResolvedValue([]), + getActiveSkillsAllowedTools: vi.fn().mockResolvedValue([]), + listSkillScripts: vi.fn().mockResolvedValue([]), + getSkillExtension: vi.fn().mockResolvedValue({ + version: 1, + env: {}, + runtimePolicy: { python: 'auto', node: 'auto' }, + scriptOverrides: {} + }) + }) as any, + getYoBrowserToolHandler: () => ({ + getToolDefinitions: vi.fn().mockReturnValue([]), + callTool: vi.fn() + }), + getFilePresenter: () => ({ + getMimeType: vi.fn(), + prepareFileCompletely: vi.fn() + }), + getLlmProviderPresenter: () => ({ + generateCompletionStandalone: vi.fn() + }), + createSettingsWindow: vi.fn(), + sendToWindow: vi.fn().mockReturnValue(true), + getApprovedFilePaths: vi.fn().mockReturnValue([]), + consumeSettingsApproval: vi.fn().mockReturnValue(false) + } as any + }) + + const withoutYoBrowser = toolPresenter.buildToolSystemPrompt({ + conversationId: 'conv-1', + toolDefinitions: [ + { + ...buildToolDefinition('read', 'agent-filesystem'), + source: 'agent' + } + ] + }) + const withYoBrowser = toolPresenter.buildToolSystemPrompt({ + conversationId: 'conv-1', + toolDefinitions: [ + { + ...buildToolDefinition('read', 'agent-filesystem'), + source: 'agent' + }, + { + ...buildToolDefinition('yo_browser_cdp_send', 'yobrowser'), + source: 'agent' + } + ] + }) + + expect(withoutYoBrowser).not.toContain('YoBrowser') + expect(withYoBrowser).toContain('YoBrowser') + expect(withYoBrowser).toContain('yo_browser_cdp_send') + }) + + it('includes question guidance only when deepchat_question is enabled', () => { + const mcpPresenter = { + getAllToolDefinitions: vi.fn().mockResolvedValue([]), + callTool: vi.fn() + } as any + const configPresenter = { + getSkillsEnabled: vi.fn().mockReturnValue(false), + getSkillsPath: vi.fn().mockReturnValue('C:\\\\skills'), + getDefaultVisionModel: vi.fn(), + getModelConfig: vi.fn() + } + + const toolPresenter = new ToolPresenter({ + mcpPresenter, + configPresenter: configPresenter as any, + commandPermissionHandler: new CommandPermissionService(), + agentToolRuntime: { + resolveConversationWorkdir: vi.fn().mockResolvedValue(null), + getSkillPresenter: () => + ({ + getActiveSkills: vi.fn().mockResolvedValue([]), + getActiveSkillsAllowedTools: vi.fn().mockResolvedValue([]), + listSkillScripts: vi.fn().mockResolvedValue([]), + getSkillExtension: vi.fn().mockResolvedValue({ + version: 1, + env: {}, + runtimePolicy: { python: 'auto', node: 'auto' }, + scriptOverrides: {} + }) + }) as any, + getYoBrowserToolHandler: () => ({ + getToolDefinitions: vi.fn().mockReturnValue([]), + callTool: vi.fn() + }), + getFilePresenter: () => ({ + getMimeType: vi.fn(), + prepareFileCompletely: vi.fn() + }), + getLlmProviderPresenter: () => ({ + generateCompletionStandalone: vi.fn() + }), + createSettingsWindow: vi.fn(), + sendToWindow: vi.fn().mockReturnValue(true), + getApprovedFilePaths: vi.fn().mockReturnValue([]), + consumeSettingsApproval: vi.fn().mockReturnValue(false) + } as any + }) + + const withoutQuestion = toolPresenter.buildToolSystemPrompt({ + conversationId: 'conv-1', + toolDefinitions: [ + { + ...buildToolDefinition('read', 'agent-filesystem'), + source: 'agent' + } + ] + }) + const withQuestion = toolPresenter.buildToolSystemPrompt({ + conversationId: 'conv-1', + toolDefinitions: [ + { + ...buildToolDefinition('deepchat_question', 'agent-core'), + source: 'agent' + } + ] + }) + + expect(withoutQuestion).not.toContain('deepchat_question') + expect(withQuestion).toContain('deepchat_question') + }) }) diff --git a/test/renderer/components/McpIndicator.test.ts b/test/renderer/components/McpIndicator.test.ts new file mode 100644 index 000000000..17a354be8 --- /dev/null +++ b/test/renderer/components/McpIndicator.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, reactive } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' + +const passthrough = (name: string) => + defineComponent({ + name, + template: '
' + }) + +const ButtonStub = defineComponent({ + name: 'Button', + props: { + disabled: { type: Boolean, default: false } + }, + emits: ['click'], + template: '' +}) + +const SwitchStub = defineComponent({ + name: 'Switch', + props: { + modelValue: { type: Boolean, default: false }, + disabled: { type: Boolean, default: false }, + ariaLabel: { type: String, default: '' } + }, + emits: ['update:modelValue'], + template: + '' +}) + +const buildTool = (name: string, serverName: string, source: 'mcp' | 'agent' = 'agent') => ({ + type: 'function', + source, + function: { + name, + description: `${name} description`, + parameters: { + type: 'object', + properties: {} + } + }, + server: { + name: serverName, + icons: '', + description: `${serverName} description` + } +}) + +const setup = async (options?: { + hasActiveSession?: boolean + activeAgentId?: string + selectedAgentId?: string + disabledAgentTools?: string[] +}) => { + vi.resetModules() + + const mcpStore = reactive({ + enabledServers: [{ name: 'demo-server', icons: 'D', enabled: true }], + enabledServerCount: 1, + tools: [buildTool('mcp_tool', 'demo-server', 'mcp')] + }) + + const sessionStore = reactive({ + hasActiveSession: options?.hasActiveSession ?? true, + activeSessionId: options?.hasActiveSession === false ? null : 's1', + activeSession: + options?.hasActiveSession === false + ? null + : { + id: 's1', + agentId: options?.activeAgentId ?? 'deepchat', + projectDir: '/tmp/workspace' + } + }) + + const draftStore = reactive({ + disabledAgentTools: [...(options?.disabledAgentTools ?? [])] + }) + + const agentStore = reactive({ + selectedAgentId: options?.selectedAgentId ?? 'deepchat' + }) + + const projectStore = reactive({ + selectedProject: { + path: '/tmp/workspace', + name: 'workspace' + } + }) + + const toolPresenter = { + getAllToolDefinitions: vi + .fn() + .mockResolvedValue([ + buildTool('read', 'agent-filesystem'), + buildTool('exec', 'agent-filesystem'), + buildTool('deepchat_question', 'agent-core'), + buildTool('yo_browser_cdp_send', 'yobrowser'), + buildTool('mcp_tool', 'demo-server', 'mcp') + ]) + } + + const newAgentPresenter = { + getSessionDisabledAgentTools: vi + .fn() + .mockResolvedValue([...(options?.disabledAgentTools ?? [])]), + updateSessionDisabledAgentTools: vi + .fn() + .mockImplementation(async (_id: string, tools: string[]) => tools) + } + + const windowPresenter = { + createSettingsWindow: vi.fn().mockResolvedValue(undefined), + getSettingsWindowId: vi.fn().mockReturnValue(1), + sendToWindow: vi.fn() + } + + vi.doMock('@/stores/mcp', () => ({ + useMcpStore: () => mcpStore + })) + vi.doMock('@/stores/ui/session', () => ({ + useSessionStore: () => sessionStore + })) + vi.doMock('@/stores/ui/draft', () => ({ + useDraftStore: () => draftStore + })) + vi.doMock('@/stores/ui/agent', () => ({ + useAgentStore: () => agentStore + })) + vi.doMock('@/stores/ui/project', () => ({ + useProjectStore: () => projectStore + })) + vi.doMock('@/composables/usePresenter', () => ({ + usePresenter: (name: string) => { + if (name === 'toolPresenter') return toolPresenter + if (name === 'newAgentPresenter') return newAgentPresenter + return windowPresenter + } + })) + vi.doMock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => { + if (key === 'chat.input.mcp.badge') { + return `MCP ${params?.count ?? 0}` + } + + const translations: Record = { + 'chat.input.mcp.title': 'Enabled MCP', + 'chat.input.mcp.empty': 'No enabled services', + 'chat.input.mcp.openSettings': 'Open MCP settings', + 'chat.input.tools.badge': 'Tools', + 'chat.input.tools.title': 'Tools', + 'chat.input.tools.mcpSection': 'MCP', + 'chat.input.tools.builtinSection': 'Built-in Tools', + 'chat.input.tools.loading': 'Loading tools...', + 'chat.input.tools.builtinEmpty': 'No built-in tools available', + 'chat.input.tools.groups.agentFilesystem': 'Agent Filesystem', + 'chat.input.tools.groups.agentCore': 'Agent Core', + 'chat.input.tools.groups.agentSkills': 'Agent Skills', + 'chat.input.tools.groups.deepchatSettings': 'DeepChat Settings', + 'chat.input.tools.groups.yobrowser': 'YoBrowser' + } + + return translations[key] ?? key + } + }) + })) + vi.doMock('@iconify/vue', () => ({ + Icon: defineComponent({ + name: 'Icon', + template: '' + }) + })) + + const McpIndicator = (await import('@/components/chat-input/McpIndicator.vue')).default + const wrapper = mount(McpIndicator, { + global: { + stubs: { + Button: ButtonStub, + Switch: SwitchStub, + Popover: passthrough('Popover'), + PopoverTrigger: passthrough('PopoverTrigger'), + PopoverContent: passthrough('PopoverContent'), + Icon: true + } + } + }) + + await flushPromises() + + return { + wrapper, + draftStore, + toolPresenter, + newAgentPresenter + } +} + +describe('McpIndicator', () => { + it('renders Tools badge for deepchat and toggles session-scoped built-in tools', async () => { + const { wrapper, newAgentPresenter } = await setup({ + hasActiveSession: true, + activeAgentId: 'deepchat' + }) + + const buttons = wrapper.findAll('button') + expect(buttons[0].text()).toContain('Tools') + expect(wrapper.text()).toContain('Built-in Tools') + expect(wrapper.text()).not.toContain('MCP 1') + expect(wrapper.text().indexOf('Built-in Tools')).toBeLessThan( + wrapper.text().indexOf('demo-server') + ) + + const execButton = buttons.find((button) => button.text() === 'exec') + expect(execButton).toBeTruthy() + + await execButton!.trigger('click') + await flushPromises() + + expect(newAgentPresenter.updateSessionDisabledAgentTools).toHaveBeenCalledWith('s1', ['exec']) + }) + + it('supports enabling and disabling a whole tool group', async () => { + const { wrapper, newAgentPresenter } = await setup({ + hasActiveSession: true, + activeAgentId: 'deepchat', + disabledAgentTools: ['exec'] + }) + + const groupSwitches = wrapper.findAll('[role="switch"]') + const filesystemSwitch = groupSwitches[0] + expect(filesystemSwitch).toBeTruthy() + expect(filesystemSwitch.attributes('aria-checked')).toBe('true') + + await filesystemSwitch.trigger('click') + await flushPromises() + + expect(newAgentPresenter.updateSessionDisabledAgentTools).toHaveBeenCalledWith('s1', [ + 'exec', + 'read' + ]) + }) + + it('resets a fully disabled tool group back to all enabled when switched on', async () => { + const { wrapper, newAgentPresenter } = await setup({ + hasActiveSession: true, + activeAgentId: 'deepchat', + disabledAgentTools: ['exec', 'read'] + }) + + const groupSwitches = wrapper.findAll('[role="switch"]') + const filesystemSwitch = groupSwitches[0] + expect(filesystemSwitch).toBeTruthy() + expect(filesystemSwitch.attributes('aria-checked')).toBe('false') + + await filesystemSwitch.trigger('click') + await flushPromises() + + expect(newAgentPresenter.updateSessionDisabledAgentTools).toHaveBeenCalledWith('s1', []) + }) + + it('renders MCP badge for ACP sessions and keeps built-in tools hidden', async () => { + const { wrapper, toolPresenter } = await setup({ + hasActiveSession: true, + activeAgentId: 'acp-coder' + }) + + const buttons = wrapper.findAll('button') + expect(buttons[0].text()).toContain('MCP 1') + expect(wrapper.text()).not.toContain('Built-in Tools') + expect(toolPresenter.getAllToolDefinitions).not.toHaveBeenCalled() + }) + + it('updates draft disabled tools for deepchat new thread mode', async () => { + const { wrapper, draftStore, newAgentPresenter } = await setup({ + hasActiveSession: false, + selectedAgentId: 'deepchat' + }) + + const execButton = wrapper.findAll('button').find((button) => button.text() === 'exec') + expect(execButton).toBeTruthy() + + await execButton!.trigger('click') + await flushPromises() + + expect(draftStore.disabledAgentTools).toEqual(['exec']) + expect(newAgentPresenter.updateSessionDisabledAgentTools).not.toHaveBeenCalled() + }) +}) diff --git a/test/renderer/components/NewThreadPage.test.ts b/test/renderer/components/NewThreadPage.test.ts index a5b353a66..75c6c8d77 100644 --- a/test/renderer/components/NewThreadPage.test.ts +++ b/test/renderer/components/NewThreadPage.test.ts @@ -76,6 +76,7 @@ const setup = async (options?: { providerId: undefined as string | undefined, modelId: undefined as string | undefined, permissionMode: 'full_access' as const, + disabledAgentTools: [] as string[], systemPrompt: undefined as string | undefined, temperature: undefined as number | undefined, contextLength: undefined as number | undefined, @@ -213,6 +214,7 @@ describe('NewThreadPage ACP draft session bootstrap', () => { ] draftStore.providerId = 'openai' draftStore.modelId = 'gpt-4' + draftStore.disabledAgentTools = ['exec', 'yo_browser_cdp_send'] ;(draftStore.toGenerationSettings as unknown as ReturnType).mockReturnValue({ systemPrompt: 'Preset prompt', temperature: 1.2, @@ -231,6 +233,7 @@ describe('NewThreadPage ACP draft session bootstrap', () => { message: 'hello deepchat', files: [{ name: 'plan.md', path: '/tmp/workspace/plan.md', mimeType: 'text/markdown' }], agentId: 'deepchat', + disabledAgentTools: ['exec', 'yo_browser_cdp_send'], generationSettings: { systemPrompt: 'Preset prompt', temperature: 1.2,