From a72e6d8ef3513bd92d57ab00354a24a672320b35 Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Sun, 30 Aug 2026 23:16:36 +0900 Subject: [PATCH 01/15] fix(tools): resolve effective request tool policy Build a canonical request-scoped tool set after mode restrictions, feature flags, user-disabled tools, model include/exclude rules, and MCP availability are applied. Keep lifecycle tools required for completion and orchestration available, and separate Gemini compatibility declarations from the logical names the model may invoke. Signed-off-by: JunyongParkDev --- .../prompts/tools/filter-tools-for-mode.ts | 32 +++- src/core/task/__tests__/build-tools.spec.ts | 167 ++++++++++++++++++ src/core/task/build-tools.ts | 22 ++- .../tools/__tests__/validateToolUse.spec.ts | 7 +- src/core/tools/validateToolUse.ts | 25 +-- src/shared/tools.ts | 11 ++ 6 files changed, 240 insertions(+), 24 deletions(-) create mode 100644 src/core/task/__tests__/build-tools.spec.ts diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 2b31714a4c..de95acac6e 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -1,7 +1,7 @@ import type OpenAI from "openai" import type { ModeConfig, ToolName, ToolGroup, ModelInfo } from "@roo-code/types" import { getModeBySlug, getToolsForMode } from "../../../shared/modes" -import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES } from "../../../shared/tools" +import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES, isRequiredToolForMode } from "../../../shared/tools" import { defaultModeSlug } from "../../../shared/modes" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" @@ -305,6 +305,14 @@ export function filterNativeToolsForMode( } } + // The task loop cannot safely ask for required input or finish without its lifecycle tools. + // Orchestrator likewise cannot perform its core workflow without new_task. + for (const toolName of allToolsForMode) { + if (isRequiredToolForMode(toolName, modeSlug)) { + allowedToolNames.add(resolveToolAlias(toolName)) + } + } + // Conditionally exclude access_mcp_resource if MCP is not enabled or there are no resources. // When the mode restricts MCP servers via allowedMcpServers, only resources from allowed // servers count — otherwise a restricted mode could still read resources from disallowed servers. @@ -449,6 +457,7 @@ export function getAvailableToolsInGroup( * @param mode - Current mode slug * @param customModes - Custom mode configurations * @param experiments - Experiment flags + * @param settings - Request-specific disabled tools and model exclusions * @returns Filtered array of MCP tools if use_mcp_tool is allowed, empty array otherwise */ export function filterMcpToolsForMode( @@ -456,6 +465,7 @@ export function filterMcpToolsForMode( mode: string | undefined, customModes: ModeConfig[] | undefined, experiments: Record | undefined, + settings?: { disabledTools?: string[]; modelInfo?: ModelInfo }, ): OpenAI.Chat.ChatCompletionTool[] { const modeSlug = mode ?? defaultModeSlug @@ -469,5 +479,23 @@ export function filterMcpToolsForMode( experiments ?? {}, ) - return isMcpAllowed ? mcpTools : [] + if (!isMcpAllowed) { + return [] + } + + const excludedToolNames = new Set( + [...(settings?.disabledTools ?? []), ...(settings?.modelInfo?.excludedTools ?? [])].map(resolveToolAlias), + ) + + if (excludedToolNames.has("use_mcp_tool")) { + return [] + } + + return mcpTools.filter((tool) => { + if (!("function" in tool) || !tool.function) { + return false + } + + return !excludedToolNames.has(resolveToolAlias(tool.function.name)) + }) } diff --git a/src/core/task/__tests__/build-tools.spec.ts b/src/core/task/__tests__/build-tools.spec.ts new file mode 100644 index 0000000000..2f2e75f12c --- /dev/null +++ b/src/core/task/__tests__/build-tools.spec.ts @@ -0,0 +1,167 @@ +import type * as vscode from "vscode" + +import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" + +import type { McpHub } from "../../../services/mcp/McpHub" +import type { ClineProvider } from "../../webview/ClineProvider" +import { buildNativeToolsArrayWithRestrictions } from "../build-tools" + +vi.mock("../../../services/code-index/manager", () => ({ + CodeIndexManager: { + getInstance: () => ({ + isFeatureEnabled: false, + isFeatureConfigured: false, + isInitialized: false, + }), + }, +})) + +const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.anthropic, +} + +function createMcpHub(withCapabilities: boolean): McpHub { + return { + getServers: () => + withCapabilities + ? [ + { + name: "test-server", + resources: [{ uri: "test://resource", name: "Test Resource" }], + tools: [ + { + name: "test-tool", + description: "Test tool", + inputSchema: { type: "object", properties: {} }, + enabledForPrompt: true, + }, + ], + }, + ] + : [], + // The builder only reads server metadata from this test double. + } as unknown as McpHub +} + +function createProvider(mcpHub: McpHub): Pick { + return { + context: {} as vscode.ExtensionContext, + getMcpHub: () => mcpHub, + } +} + +describe("buildNativeToolsArrayWithRestrictions", () => { + it("returns canonical names for the request's logical tool set", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(createMcpHub(false)), + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration, + disabledTools: ["execute_command"], + modelInfo: { + contextWindow: 200_000, + supportsPromptCache: true, + includedTools: ["search_and_replace"], + }, + }) + + expect(result.effectiveToolNames).not.toContain("execute_command") + expect(result.effectiveToolNames).toContain("edit") + expect(result.effectiveToolNames).not.toContain("search_and_replace") + }) + + it("removes model-excluded tools from logical availability", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(createMcpHub(false)), + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration, + modelInfo: { + contextWindow: 200_000, + supportsPromptCache: true, + excludedTools: ["execute_command"], + }, + }) + + expect(result.effectiveToolNames).not.toContain("execute_command") + expect(result.effectiveToolNames).toContain("read_file") + }) + + it("separates Gemini compatibility definitions from logical availability", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(createMcpHub(false)), + cwd: "/test/path", + mode: "architect", + customModes: undefined, + experiments: {}, + apiConfiguration, + includeAllToolsWithRestrictions: true, + }) + const sentToolNames = result.tools.flatMap((tool) => (tool.type === "function" ? [tool.function.name] : [])) + + expect(sentToolNames).toContain("execute_command") + expect(result.effectiveToolNames).not.toContain("execute_command") + expect(result.allowedFunctionNames).not.toContain("execute_command") + }) + + it("keeps lifecycle and active-mode essential tools available", async () => { + const provider = createProvider(createMcpHub(false)) + const codeResult = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration, + disabledTools: ["ask_followup_question", "attempt_completion"], + }) + const orchestratorResult = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: "/test/path", + mode: "orchestrator", + customModes: undefined, + experiments: {}, + apiConfiguration, + disabledTools: ["new_task"], + }) + + expect(codeResult.effectiveToolNames).toContain("ask_followup_question") + expect(codeResult.effectiveToolNames).toContain("attempt_completion") + expect(orchestratorResult.effectiveToolNames).toContain("new_task") + }) + + it("excludes MCP operations when MCP is globally disabled", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(createMcpHub(true)), + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration, + mcpEnabled: false, + }) + + expect(result.effectiveToolNames).not.toContain("access_mcp_resource") + expect(Array.from(result.effectiveToolNames).some((name) => name.startsWith("mcp--"))).toBe(false) + }) + + it("excludes dynamic MCP tools when use_mcp_tool is disabled", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(createMcpHub(true)), + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration, + disabledTools: ["use_mcp_tool"], + mcpEnabled: true, + }) + + expect(result.effectiveToolNames).toContain("access_mcp_resource") + expect(Array.from(result.effectiveToolNames).some((name) => name.startsWith("mcp--"))).toBe(false) + }) +}) diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index ebbdc050dc..020fce28b1 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -17,7 +17,7 @@ import { } from "../prompts/tools/filter-tools-for-mode" interface BuildToolsOptions { - provider: ClineProvider + provider: Pick cwd: string mode: string | undefined customModes: ModeConfig[] | undefined @@ -25,6 +25,8 @@ interface BuildToolsOptions { apiConfiguration: ProviderSettings | undefined disabledTools?: string[] modelInfo?: ModelInfo + /** Whether MCP tools and resources are enabled globally for this request. */ + mcpEnabled?: boolean /** * If true, returns all tools without mode filtering, but also includes * the list of allowed tool names for use with allowedFunctionNames. @@ -34,13 +36,15 @@ interface BuildToolsOptions { includeAllToolsWithRestrictions?: boolean } -interface BuildToolsResult { +export interface BuildToolsResult { /** * The tools to pass to the model. * If includeAllToolsWithRestrictions is true, this includes ALL tools. * Otherwise, it includes only mode-filtered tools. */ tools: OpenAI.Chat.ChatCompletionTool[] + /** Canonical names of tools that are logically available for this request. */ + effectiveToolNames: ReadonlySet /** * The names of tools that are allowed to be called based on mode restrictions. * Only populated when includeAllToolsWithRestrictions is true. @@ -90,10 +94,12 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO apiConfiguration, disabledTools, modelInfo, + mcpEnabled, includeAllToolsWithRestrictions, } = options const mcpHub = provider.getMcpHub() + const effectiveMcpHub = mcpEnabled === false ? undefined : mcpHub // Get CodeIndexManager for feature checking. const { CodeIndexManager } = await import("../../services/code-index/manager") @@ -128,13 +134,14 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO experiments, codeIndexManager, filterSettings, - mcpHub, + effectiveMcpHub, allowedMcpServers, ) // Filter MCP tools based on mode restrictions. - const mcpTools = getMcpServerTools(mcpHub, allowedMcpServers) - const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, customModes, experiments) + const allMcpTools = getMcpServerTools(mcpHub, allowedMcpServers) + const mcpTools = mcpEnabled === false ? [] : allMcpTools + const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, customModes, experiments, filterSettings) // Add custom tools if they are available and the experiment is enabled. let nativeCustomTools: OpenAI.Chat.ChatCompletionFunctionTool[] = [] @@ -151,12 +158,13 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO // Combine filtered tools (for backward compatibility and for allowedFunctionNames) const filteredTools = [...filteredNativeTools, ...filteredMcpTools, ...nativeCustomTools] + const effectiveToolNames = new Set(filteredTools.map((tool) => resolveToolAlias(getToolName(tool)))) // If includeAllToolsWithRestrictions is true, return ALL tools but provide // allowed names based on mode filtering if (includeAllToolsWithRestrictions) { // Combine ALL tools (unfiltered native + all MCP + custom) - const allTools = [...nativeTools, ...mcpTools, ...nativeCustomTools] + const allTools = [...nativeTools, ...allMcpTools, ...nativeCustomTools] // Extract names of tools that are allowed based on mode filtering. // Resolve any alias names to canonical names to ensure consistency with allTools @@ -167,11 +175,13 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO return { tools: allTools, allowedFunctionNames, + effectiveToolNames, } } // Default behavior: return only filtered tools return { tools: filteredTools, + effectiveToolNames, } } diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index 9e4a8bbd0c..78bb8a2b24 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -160,13 +160,12 @@ describe("mode-validator", () => { expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(false) }) - it("prioritizes requirements over ALWAYS_AVAILABLE_TOOLS", () => { - // Tools in ALWAYS_AVAILABLE_TOOLS (switch_mode, new_task, etc.) should still - // be blockable via toolRequirements / disabledTools + it("keeps lifecycle tools available while allowing optional control tools to be disabled", () => { const requirements = { switch_mode: false, new_task: false, attempt_completion: false } expect(isToolAllowedForMode("switch_mode", codeMode, [], requirements)).toBe(false) expect(isToolAllowedForMode("new_task", codeMode, [], requirements)).toBe(false) - expect(isToolAllowedForMode("attempt_completion", codeMode, [], requirements)).toBe(false) + expect(isToolAllowedForMode("attempt_completion", codeMode, [], requirements)).toBe(true) + expect(isToolAllowedForMode("new_task", "orchestrator", [], requirements)).toBe(true) }) }) }) diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index 243a170ed9..6817f044fe 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -4,7 +4,7 @@ import { customToolRegistry } from "@roo-code/core" import { type Mode, FileRestrictionError, getModeBySlug, getGroupName } from "../../shared/modes" import { EXPERIMENT_IDS } from "../../shared/experiments" -import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES } from "../../shared/tools" +import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES, isRequiredToolForMode } from "../../shared/tools" /** * Checks if a tool name is a valid, known tool. @@ -130,19 +130,20 @@ export function isToolAllowedForMode( const resolvedTool = TOOL_ALIASES[tool] ?? tool const resolvedIncludedTools = includedTools?.map((t) => TOOL_ALIASES[t] ?? t) - // Check tool requirements first — explicit disabling takes priority over everything, - // including ALWAYS_AVAILABLE_TOOLS. This ensures disabledTools works consistently - // at both the filtering layer and the execution-time validation layer. - if (toolRequirements && typeof toolRequirements === "object") { - if ( - (tool in toolRequirements && !toolRequirements[tool]) || - (resolvedTool in toolRequirements && !toolRequirements[resolvedTool]) - ) { + // Explicit disabling takes priority over ordinary always-available tools. Lifecycle + // and active-mode essentials remain available because the task loop has no fallback. + if (!isRequiredToolForMode(resolvedTool, modeSlug)) { + if (toolRequirements && typeof toolRequirements === "object") { + if ( + (tool in toolRequirements && !toolRequirements[tool]) || + (resolvedTool in toolRequirements && !toolRequirements[resolvedTool]) + ) { + return false + } + } else if (toolRequirements === false) { + // If toolRequirements is a boolean false, all non-required tools are disabled return false } - } else if (toolRequirements === false) { - // If toolRequirements is a boolean false, all tools are disabled - return false } // Always allow these tools (unless explicitly disabled above) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 1a1fb03200..282b4049d7 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -325,6 +325,17 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "skill", ] as const +/** Tools required by the task lifecycle or by a built-in mode's core workflow. */ +export function isRequiredToolForMode(toolName: string, mode: string): boolean { + const canonicalToolName = TOOL_ALIASES[toolName] ?? toolName + + return ( + canonicalToolName === "ask_followup_question" || + canonicalToolName === "attempt_completion" || + (mode === "orchestrator" && canonicalToolName === "new_task") + ) +} + /** * Central registry of tool aliases. * Maps alias name -> canonical tool name. From b90ca6e174957cbe15ee521c3196a595781eb4d9 Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Sun, 30 Aug 2026 23:34:12 +0900 Subject: [PATCH 02/15] fix(prompts): gate guidance by available tools Pass the effective tool set into system prompt sections so Zoo-owned guidance only references callable tools. Conditionally adapt Architect and Ask defaults, surface active edit restrictions, and leave user-authored mode and global instructions unchanged. Signed-off-by: JunyongParkDev --- src/core/prompts/__tests__/sections.spec.ts | 48 +++++++ .../prompts/__tests__/system-prompt.spec.ts | 75 ++++++++++ .../__tests__/mode-instructions.spec.ts | 49 +++++++ src/core/prompts/sections/capabilities.ts | 66 ++++++++- src/core/prompts/sections/index.ts | 1 + .../prompts/sections/markdown-formatting.ts | 11 +- .../prompts/sections/mode-instructions.ts | 72 ++++++++++ src/core/prompts/sections/objective.ts | 39 +++++- src/core/prompts/sections/rules.ts | 128 +++++++++++++++++- src/core/prompts/sections/system-info.ts | 23 +++- .../prompts/sections/tool-use-guidelines.ts | 16 ++- src/core/prompts/sections/tool-use.ts | 8 +- src/core/prompts/system.ts | 50 +++++-- src/core/prompts/types.ts | 27 ++++ 14 files changed, 585 insertions(+), 28 deletions(-) create mode 100644 src/core/prompts/sections/__tests__/mode-instructions.spec.ts create mode 100644 src/core/prompts/sections/mode-instructions.ts diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index 79d4fad4ca..e7c86da409 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -88,6 +88,40 @@ describe("getCapabilitiesSection", () => { expect(result).not.toContain("MCP servers") }) + + it("describes only capabilities backed by the effective tool set", () => { + const result = getCapabilitiesSection(cwd, undefined, undefined, { + availableToolNames: new Set(["read_file", "ask_followup_question", "attempt_completion"]), + }) + + expect(result).toContain("read files") + expect(result).toContain("ask follow-up questions") + expect(result).not.toContain("execute_command") + expect(result).not.toContain("list_files") + expect(result).not.toContain("write and edit files") + }) + + it("describes image generation separately from file editing", () => { + const result = getCapabilitiesSection(cwd, undefined, undefined, { + availableToolNames: new Set(["generate_image"]), + }) + + expect(result).toContain("generate images") + expect(result).not.toContain("write and edit files") + }) + + it("advertises MCP only when an MCP operation is in the effective tool set", () => { + const mockMcpHub = createMockMcpHub(["test-server"]) + const withoutMcpOperation = getCapabilitiesSection(cwd, mockMcpHub, undefined, { + availableToolNames: new Set(["read_file"]), + }) + const withMcpOperation = getCapabilitiesSection(cwd, mockMcpHub, undefined, { + availableToolNames: new Set(["read_file", "mcp--test-server--search"]), + }) + + expect(withoutMcpOperation).not.toContain("MCP servers") + expect(withMcpOperation).toContain("MCP servers") + }) }) describe("getRulesSection", () => { @@ -144,6 +178,20 @@ describe("getRulesSection", () => { expect(result).not.toContain("VENDOR CONFIDENTIALITY") expect(result).not.toContain("Never reveal the vendor or company") }) + + it("omits command guidance and includes the active edit restriction", () => { + const result = getRulesSection(cwd, undefined, { + availableToolNames: new Set(["write_to_file", "ask_followup_question", "attempt_completion"]), + editFileRestriction: { + fileRegex: "\\.md$", + description: "Markdown files only", + }, + }) + + expect(result).not.toContain("execute_command") + expect(result).not.toContain("Actively Running Terminals") + expect(result).toContain('The active mode can only edit files matching "\\.md$" (Markdown files only)') + }) }) describe("getCommandChainOperator", () => { diff --git a/src/core/prompts/__tests__/system-prompt.spec.ts b/src/core/prompts/__tests__/system-prompt.spec.ts index d8671b2027..7c5dd8b6b5 100644 --- a/src/core/prompts/__tests__/system-prompt.spec.ts +++ b/src/core/prompts/__tests__/system-prompt.spec.ts @@ -192,6 +192,32 @@ const createMockMcpHub = (withServers: boolean = false): McpHub => connections: [], }) as unknown as McpHub +const generatePromptWithTools = ( + toolNames: string[], + mode: Mode = defaultModeSlug, + customModePrompts?: Parameters[6], + customModes?: ModeConfig[], +) => + SYSTEM_PROMPT( + mockContext, + "/test/path", + false, + undefined, + undefined, + mode, + customModePrompts, + customModes, + undefined, + {}, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { availableToolNames: new Set(toolNames) }, + ) + describe("SYSTEM_PROMPT", () => { let mockMcpHub: McpHub let experiments: Record | undefined @@ -577,6 +603,55 @@ describe("SYSTEM_PROMPT", () => { expect(prompt).toContain("OBJECTIVE") }) + it("should omit system-owned guidance for unavailable tools", async () => { + const prompt = await generatePromptWithTools(["read_file", "ask_followup_question", "attempt_completion"]) + + expect(prompt).toContain("read files") + expect(prompt).not.toContain("execute_command") + expect(prompt).not.toContain("list_files") + expect(prompt).not.toContain("write and edit files") + }) + + it("should apply Architect tool availability and edit restrictions to built-in instructions", async () => { + const prompt = await generatePromptWithTools( + ["read_file", "write_to_file", "ask_followup_question", "attempt_completion"], + "architect", + ) + + expect(prompt).toContain("write the plan to a markdown file") + expect(prompt).toContain('The active mode can only edit files matching "\\.md$" (Markdown files only)') + expect(prompt).not.toContain("update_todo_list") + expect(prompt).not.toContain("switch_mode") + }) + + it("should preserve user-authored references to unavailable tools", async () => { + const userInstruction = "Document why execute_command is disabled for this workflow." + const prompt = await generatePromptWithTools( + ["read_file", "ask_followup_question", "attempt_completion"], + defaultModeSlug, + { + [defaultModeSlug]: { customInstructions: userInstruction }, + }, + ) + + expect(prompt).toContain(userInstruction) + }) + + it("should still gate stored built-in instructions when they equal the default", async () => { + const architectInstructions = modes.find((mode) => mode.slug === "architect")?.customInstructions + const prompt = await generatePromptWithTools( + ["read_file", "ask_followup_question", "attempt_completion"], + "architect", + { + architect: { customInstructions: architectInstructions }, + }, + ) + + expect(prompt).toContain("present the plan directly in your response") + expect(prompt).not.toContain("update_todo_list") + expect(prompt).not.toContain("switch_mode") + }) + describe("allowedMcpServers filtering in system prompt", () => { it("should exclude MCP capability text when allowedMcpServers is empty array", async () => { mockMcpHub = createMockMcpHub(true) diff --git a/src/core/prompts/sections/__tests__/mode-instructions.spec.ts b/src/core/prompts/sections/__tests__/mode-instructions.spec.ts new file mode 100644 index 0000000000..b68c66827e --- /dev/null +++ b/src/core/prompts/sections/__tests__/mode-instructions.spec.ts @@ -0,0 +1,49 @@ +import { modes } from "../../../../shared/modes" +import { getBuiltInModeInstructions } from "../mode-instructions" + +function getInstructions(mode: string): string { + return modes.find((candidate) => candidate.slug === mode)?.customInstructions ?? "" +} + +describe("getBuiltInModeInstructions", () => { + it("replaces unavailable Architect control tools with supported planning guidance", () => { + const result = getBuiltInModeInstructions("architect", getInstructions("architect"), { + availableToolNames: new Set(["read_file", "write_to_file", "ask_followup_question", "attempt_completion"]), + }) + + expect(result).not.toContain("update_todo_list") + expect(result).not.toContain("switch_mode") + expect(result).toContain("write the plan to a markdown file") + }) + + it("uses a response plan when Architect has no edit tool", () => { + const result = getBuiltInModeInstructions("architect", getInstructions("architect"), { + availableToolNames: new Set(["read_file", "ask_followup_question", "attempt_completion"]), + }) + + expect(result).toContain("present the plan directly in your response") + expect(result).not.toContain("./plans") + }) + + it("does not treat image generation or replacement-only tools as plan file creation", () => { + const result = getBuiltInModeInstructions("architect", getInstructions("architect"), { + availableToolNames: new Set([ + "generate_image", + "search_replace", + "ask_followup_question", + "attempt_completion", + ]), + }) + + expect(result).toContain("present the plan directly in your response") + expect(result).not.toContain("write the plan to a markdown file") + }) + + it("removes Ask's external-resource claim when no MCP operation is available", () => { + const result = getBuiltInModeInstructions("ask", getInstructions("ask"), { + availableToolNames: new Set(["read_file", "ask_followup_question", "attempt_completion"]), + }) + + expect(result).not.toContain("access external resources") + }) +}) diff --git a/src/core/prompts/sections/capabilities.ts b/src/core/prompts/sections/capabilities.ts index c493692401..89c806d32c 100644 --- a/src/core/prompts/sections/capabilities.ts +++ b/src/core/prompts/sections/capabilities.ts @@ -1,4 +1,6 @@ import { McpHub } from "../../../services/mcp/McpHub" +import { isMcpTool } from "../../../utils/mcp-name" +import { FILE_EDIT_TOOL_NAMES, hasAnyPromptTool, isPromptToolAvailable, type SystemPromptContext } from "../types" /** * Builds the CAPABILITIES section of the system prompt. @@ -16,7 +18,12 @@ import { McpHub } from "../../../services/mcp/McpHub" * @param allowedMcpServers Optional per-mode allowlist of MCP server names. When provided, * the hub's servers are filtered to this set before determining MCP availability. */ -export function getCapabilitiesSection(cwd: string, mcpHub?: McpHub, allowedMcpServers?: string[]): string { +export function getCapabilitiesSection( + cwd: string, + mcpHub?: McpHub, + allowedMcpServers?: string[], + context?: SystemPromptContext, +): string { // Determine whether any MCP server is actually available to the current mode. // Filtering the hub's servers by the allowlist (when provided) keeps the capability // text consistent with the tools that are exposed for the mode. @@ -30,14 +37,67 @@ export function getCapabilitiesSection(cwd: string, mcpHub?: McpHub, allowedMcpS hasMcpServers = servers.length > 0 } - return `==== + if (!context) { + return `==== CAPABILITIES - You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as writing code, making edits or improvements to existing files, understanding the current state of a project, performing system operations, and much more. - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('${cwd}') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.${ - hasMcpServers + hasMcpServers + ? ` +- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively. +` + : "" + }` + } + + const hasTool = (toolName: string) => isPromptToolAvailable(context, toolName) + const hasCommandTool = hasTool("execute_command") + const hasListTool = hasTool("list_files") + const hasReadTool = hasTool("read_file") + const hasSearchTool = hasTool("search_files") || hasTool("codebase_search") + const hasEditTool = hasAnyPromptTool(context, FILE_EDIT_TOOL_NAMES) + const hasImageTool = hasTool("generate_image") + const hasQuestionTool = hasTool("ask_followup_question") + const hasMcpOperations = context + ? context.availableToolNames.has("access_mcp_resource") || + Array.from(context.availableToolNames).some(isMcpTool) + : hasMcpServers + + const capabilities: string[] = [] + if (hasCommandTool) capabilities.push("execute CLI commands on the user's computer") + if (hasListTool) capabilities.push("list files") + if (hasReadTool) capabilities.push("read files") + if (hasSearchTool) capabilities.push("search source code") + if (hasEditTool) { + const restriction = context?.editFileRestriction + capabilities.push( + restriction + ? `edit files matching ${restriction.description ?? restriction.fileRegex}` + : "write and edit files", + ) + } + if (hasImageTool) capabilities.push("generate images") + if (hasQuestionTool) capabilities.push("ask follow-up questions") + + const capabilitySummary = capabilities.length + ? `- You have access to tools that let you ${capabilities.join(", ")}.\n` + : "" + const listFilesGuidance = hasListTool + ? " If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop." + : "" + const commandGuidance = hasCommandTool + ? `\n- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.` + : "" + + return `==== + +CAPABILITIES + +${capabilitySummary}- When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('${cwd}') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further.${listFilesGuidance}${commandGuidance}${ + hasMcpOperations ? ` - You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively. ` diff --git a/src/core/prompts/sections/index.ts b/src/core/prompts/sections/index.ts index 318cd47bc9..89dfd5d5cc 100644 --- a/src/core/prompts/sections/index.ts +++ b/src/core/prompts/sections/index.ts @@ -8,3 +8,4 @@ export { getCapabilitiesSection } from "./capabilities" export { getModesSection } from "./modes" export { markdownFormattingSection } from "./markdown-formatting" export { getSkillsSection } from "./skills" +export { getBuiltInModeInstructions } from "./mode-instructions" diff --git a/src/core/prompts/sections/markdown-formatting.ts b/src/core/prompts/sections/markdown-formatting.ts index 0e47385632..25479cfae5 100644 --- a/src/core/prompts/sections/markdown-formatting.ts +++ b/src/core/prompts/sections/markdown-formatting.ts @@ -1,7 +1,14 @@ -export function markdownFormattingSection(): string { +import type { SystemPromptContext } from "../types" +import { isPromptToolAvailable } from "../types" + +export function markdownFormattingSection(context?: SystemPromptContext): string { + const completionReference = isPromptToolAvailable(context, "attempt_completion") + ? " and ALSO those in attempt_completion" + : "" + return `==== MARKDOWN RULES -ALL responses MUST show ANY \`language construct\` OR filename reference as clickable, exactly as [\`filename OR language.declaration()\`](relative/file/path.ext:line); line is required for \`syntax\` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion` +ALL responses MUST show ANY \`language construct\` OR filename reference as clickable, exactly as [\`filename OR language.declaration()\`](relative/file/path.ext:line); line is required for \`syntax\` and optional for filename links. This applies to ALL markdown responses${completionReference}` } diff --git a/src/core/prompts/sections/mode-instructions.ts b/src/core/prompts/sections/mode-instructions.ts new file mode 100644 index 0000000000..173234c115 --- /dev/null +++ b/src/core/prompts/sections/mode-instructions.ts @@ -0,0 +1,72 @@ +import { isMcpTool } from "../../../utils/mcp-name" +import { hasAnyPromptTool, isPromptToolAvailable, type SystemPromptContext } from "../types" + +const ARCHITECT_TODO_STEP = /3\. Once you've gained more context[\s\S]*?(?=\n\n4\. As you gather)/ +const ARCHITECT_TODO_UPDATE_STEP = + /4\. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished\./ +const ARCHITECT_TODO_IMPORTANCE = + /\*\*IMPORTANT: Focus on creating clear, actionable todo lists rather than lengthy markdown documents\. Use the todo list as your primary planning tool to track and organize the work that needs to be done\.\*\*/ +const ARCHITECT_SWITCH_STEP = + /\n\n7\. Use the switch_mode tool to request that the user switch to another mode to implement the solution\./ +const ARCHITECT_PLAN_FILE = + /\n\nUnless told otherwise, if you want to save a plan file, put it in the \.\/plans directory \(a directory named "plans" relative to the workspace root, not the absolute filesystem path \/plans\)/ +const PLAN_FILE_TOOL_NAMES = ["write_to_file", "apply_patch"] as const + +/** Adjusts only Zoo-owned built-in mode instructions; user-authored instructions bypass this function. */ +export function getBuiltInModeInstructions(mode: string, instructions: string, context?: SystemPromptContext): string { + if (!context) { + return instructions + } + + if (mode === "ask") { + const hasMcpOperations = + context.availableToolNames.has("access_mcp_resource") || + Array.from(context.availableToolNames).some(isMcpTool) + return hasMcpOperations ? instructions : instructions.replace(", and access external resources", "") + } + + if (mode !== "architect") { + return instructions + } + + let result = instructions + let changed = false + const hasPlanFileTool = hasAnyPromptTool(context, PLAN_FILE_TOOL_NAMES) + + if (!isPromptToolAvailable(context, "update_todo_list")) { + const planDestination = hasPlanFileTool + ? "write the plan to a markdown file (e.g., `plan.md` or `todo.md`)" + : "present the plan directly in your response" + result = result.replace( + ARCHITECT_TODO_STEP, + `3. Once you've gained more context about the user's request, break down the task into clear, actionable steps and ${planDestination}. Each plan item should be:\n - Specific and actionable\n - Listed in logical execution order\n - Focused on a single, well-defined outcome\n - Clear enough that another mode could execute it independently`, + ) + result = result.replace( + ARCHITECT_TODO_UPDATE_STEP, + "4. As you gather more information or discover new requirements, update the plan to reflect the current understanding of what needs to be accomplished.", + ) + result = result.replace("refine the todo list", "refine the plan") + result = result.replace( + ARCHITECT_TODO_IMPORTANCE, + "**IMPORTANT: Focus on creating a clear, actionable plan rather than a lengthy markdown document.**", + ) + changed = true + } + + if (!isPromptToolAvailable(context, "switch_mode")) { + result = result.replace(ARCHITECT_SWITCH_STEP, "") + changed = true + } + + if (!hasPlanFileTool) { + result = result.replace(ARCHITECT_PLAN_FILE, "") + changed = true + } + + if (changed) { + let step = 0 + result = result.replace(/^\d+\. /gm, () => `${++step}. `) + } + + return result +} diff --git a/src/core/prompts/sections/objective.ts b/src/core/prompts/sections/objective.ts index 2ef32bc144..e0812b6230 100644 --- a/src/core/prompts/sections/objective.ts +++ b/src/core/prompts/sections/objective.ts @@ -1,5 +1,9 @@ -export function getObjectiveSection(): string { - return `==== +import type { SystemPromptContext } from "../types" +import { isPromptToolAvailable } from "../types" + +export function getObjectiveSection(context?: SystemPromptContext): string { + if (!context) { + return `==== OBJECTIVE @@ -10,4 +14,35 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. 4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. 5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.` + } + + const steps = [ + "Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order.", + ] + + if (context.availableToolNames.size > 0) { + const missingParameterGuidance = isPromptToolAvailable(context, "ask_followup_question") + ? " BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool." + : " BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool, including with filler values." + steps.push( + "Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go.", + `Remember, the available tools can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use.${missingParameterGuidance} DO NOT ask for more information on optional parameters if it is not provided.`, + ) + } + + if (isPromptToolAvailable(context, "attempt_completion")) { + steps.push("Once the task is complete, use attempt_completion to present the result to the user.") + } + + steps.push( + "The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.", + ) + + return `==== + +OBJECTIVE + +You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically. + +${steps.map((step, index) => `${index + 1}. ${step}`).join("\n")}` } diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts index 4f6e573fa7..71ba75f82d 100644 --- a/src/core/prompts/sections/rules.ts +++ b/src/core/prompts/sections/rules.ts @@ -1,4 +1,11 @@ -import type { SystemPromptSettings } from "../types" +import { isMcpTool } from "../../../utils/mcp-name" +import { + FILE_EDIT_TOOL_NAMES, + hasAnyPromptTool, + isPromptToolAvailable, + type SystemPromptContext, + type SystemPromptSettings, +} from "../types" import { getShell } from "../../../utils/shell" @@ -62,12 +69,13 @@ When asked about your creator, vendor, or company, respond with: - "I don't have information about specific vendors"` } -export function getRulesSection(cwd: string, settings?: SystemPromptSettings): string { +export function getRulesSection(cwd: string, settings?: SystemPromptSettings, context?: SystemPromptContext): string { // Get shell-appropriate command chaining operator const chainOp = getCommandChainOperator() const chainNote = getCommandChainNote() - return `==== + if (!context) { + return `==== RULES @@ -92,4 +100,118 @@ RULES - Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal. - MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations. - It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.${settings?.isStealthModel ? getVendorConfidentialitySection() : ""}` + } + + const hasCommandTool = context.availableToolNames.has("execute_command") + const hasListTool = context.availableToolNames.has("list_files") + const hasReadTool = context.availableToolNames.has("read_file") + const hasEditTool = hasAnyPromptTool(context, FILE_EDIT_TOOL_NAMES) + const hasQuestionTool = isPromptToolAvailable(context, "ask_followup_question") + const hasCompletionTool = isPromptToolAvailable(context, "attempt_completion") + const hasMcpOperations = + context.availableToolNames.has("access_mcp_resource") || Array.from(context.availableToolNames).some(isMcpTool) + const hasAnyTools = context.availableToolNames.size > 0 + const rules = [ + `- The project base directory is: ${cwd.toPosix()}`, + `- All file paths must be relative to this directory.${hasCommandTool ? " However, commands may change directories in terminals, so respect working directory specified by the response to execute_command." : ""}`, + `- You cannot \`cd\` into a different directory to complete a task. You are stuck operating from '${cwd.toPosix()}', so be sure to pass in the correct 'path' parameter when using tools that require a path.`, + "- Do not use the ~ character or $HOME to refer to the home directory.", + ] + + if (hasCommandTool) { + rules.push( + `- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory ${chainOp} then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) ${chainOp} (command, in this case npm install)\`.${chainNote ? ` ${chainNote}` : ""}`, + ) + } + + if (hasEditTool) { + const restriction = context.editFileRestriction + rules.push( + "- Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode.", + ) + if (restriction) { + rules.push( + ` * The active mode can only edit files matching "${restriction.fileRegex}"${restriction.description ? ` (${restriction.description})` : ""}.`, + ) + } + } + + rules.push( + "- Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write.", + "- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.", + ) + + const toolGuidance = hasAnyTools + ? " Use the tools provided to accomplish the user's request efficiently and effectively." + : "" + const completionGuidance = hasCompletionTool + ? " When you've completed your task, you must use the attempt_completion tool to present the result to the user." + : "" + rules.push( + `- Do not ask for more information than necessary.${toolGuidance}${completionGuidance} The user may provide feedback, which you can use to make improvements and try again.`, + ) + + if (hasQuestionTool) { + const listExample = hasListTool + ? " However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves." + : "" + rules.push( + `- You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. When you ask a question, provide the user with 2-4 suggested answers based on your question so they don't need to do so much typing. The suggestions should be specific, actionable, and directly related to the completed task. They should be ordered by priority or logical sequence.${listExample}`, + ) + } + + if (hasCommandTool) { + rules.push( + hasQuestionTool + ? "- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you." + : "- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly.", + ) + } + + if (hasReadTool) { + rules.push( + "- The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it.", + ) + } + + rules.push("- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.") + + if (hasCompletionTool) { + rules.push( + "- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.", + ) + } + + rules.push( + '- You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I\'ve updated the CSS" but instead something like "I\'ve updated the CSS". It is important you be clear and technical in your messages.', + "- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task.", + "- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.", + ) + + if (hasCommandTool) { + rules.push( + '- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn\'t need to start it again. If no active terminals are listed, proceed with command execution as normal.', + ) + } + + if (hasMcpOperations) { + rules.push( + "- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.", + ) + } + + if (hasAnyTools) { + const editExample = hasEditTool + ? " For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc." + : "" + rules.push( + `- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use.${editExample}`, + ) + } + + return `==== + +RULES + +${rules.join("\n")}${settings?.isStealthModel ? getVendorConfidentialitySection() : ""}` } diff --git a/src/core/prompts/sections/system-info.ts b/src/core/prompts/sections/system-info.ts index a4af3c6ac9..6fdfd9c226 100644 --- a/src/core/prompts/sections/system-info.ts +++ b/src/core/prompts/sections/system-info.ts @@ -2,8 +2,10 @@ import os from "os" import osName from "os-name" import { getShell } from "../../../utils/shell" +import type { SystemPromptContext } from "../types" +import { isPromptToolAvailable } from "../types" -export function getSystemInfoSection(cwd: string): string { +export function getSystemInfoSection(cwd: string, context?: SystemPromptContext): string { // Try to get detailed OS name, fall back to basic info if it fails let osInfo: string try { @@ -15,16 +17,29 @@ export function getSystemInfoSection(cwd: string): string { osInfo = `${platform} ${release}` } - const details = `==== + const header = `==== SYSTEM INFORMATION Operating System: ${osInfo} Default Shell: ${getShell()} Home Directory: ${os.homedir().toPosix()} -Current Workspace Directory: ${cwd.toPosix()} +Current Workspace Directory: ${cwd.toPosix()}` + + if (!context) { + return `${header} The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop.` + } + + const terminalGuidance = isPromptToolAvailable(context, "execute_command") + ? " New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory." + : "" + const listFilesGuidance = isPromptToolAvailable(context, "list_files") + ? " If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop." + : "" + + return `${header} - return details +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations.${terminalGuidance} When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further.${listFilesGuidance}` } diff --git a/src/core/prompts/sections/tool-use-guidelines.ts b/src/core/prompts/sections/tool-use-guidelines.ts index 78193372cc..29b4a46fc9 100644 --- a/src/core/prompts/sections/tool-use-guidelines.ts +++ b/src/core/prompts/sections/tool-use-guidelines.ts @@ -1,8 +1,20 @@ -export function getToolUseGuidelinesSection(): string { +import type { SystemPromptContext } from "../types" +import { isPromptToolAvailable } from "../types" + +export function getToolUseGuidelinesSection(context?: SystemPromptContext): string { + if (context && context.availableToolNames.size === 0) { + return "" + } + + const listFilesExample = + isPromptToolAvailable(context, "list_files") && isPromptToolAvailable(context, "execute_command") + ? " For example using the list_files tool is more effective than running a command like `ls` in the terminal." + : "" + return `# Tool Use Guidelines 1. Assess what information you already have and what information you need to proceed with the task. -2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like \`ls\` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task. +2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information.${listFilesExample} It's critical that you think about each available tool and use the one that best fits the current step in the task. 3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. By carefully considering the user's response after tool executions, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work.` diff --git a/src/core/prompts/sections/tool-use.ts b/src/core/prompts/sections/tool-use.ts index a3def86c07..ff23b23e2f 100644 --- a/src/core/prompts/sections/tool-use.ts +++ b/src/core/prompts/sections/tool-use.ts @@ -1,4 +1,10 @@ -export function getSharedToolUseSection(): string { +import type { SystemPromptContext } from "../types" + +export function getSharedToolUseSection(context?: SystemPromptContext): string { + if (context && context.availableToolNames.size === 0) { + return "" + } + return `==== TOOL USE diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 93f4a52846..046017350b 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -11,7 +11,7 @@ import { McpHub } from "../../services/mcp/McpHub" import { CodeIndexManager } from "../../services/code-index/manager" import { SkillsManager } from "../../services/skills/SkillsManager" -import type { SystemPromptSettings } from "./types" +import type { SystemPromptContext, SystemPromptSettings } from "./types" import { getRulesSection, getSystemInfoSection, @@ -23,6 +23,7 @@ import { addCustomInstructions, markdownFormattingSection, getSkillsSection, + getBuiltInModeInstructions, } from "./sections" // Helper function to get prompt component, filtering out empty objects @@ -55,14 +56,37 @@ async function generatePrompt( todoList?: TodoItem[], modelId?: string, skillsManager?: SkillsManager, + promptContext?: SystemPromptContext, ): Promise { if (!context) { throw new Error("Extension context is required for generating system prompt") } // Get the full mode config to ensure we have the role definition (used for groups, etc.) - const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0] + const builtInMode = modes.find((candidate) => candidate.slug === mode) + const modeConfig = getModeBySlug(mode, customModeConfigs) || builtInMode || modes[0] const { roleDefinition, baseInstructions } = getModeSelection(mode, promptComponent, customModeConfigs) + const editGroup = modeConfig.groups.find((groupEntry) => getGroupName(groupEntry) === "edit") + const editFileRestriction = + Array.isArray(editGroup) && editGroup[1].fileRegex + ? { + fileRegex: editGroup[1].fileRegex, + description: editGroup[1].description, + } + : undefined + const effectivePromptContext = promptContext + ? { + availableToolNames: promptContext.availableToolNames, + ...(editFileRestriction ? { editFileRestriction } : {}), + } + : undefined + const hasUserAuthoredModeInstructions = + customModeConfigs?.some((customMode) => customMode.slug === mode) === true || + (Boolean(promptComponent?.customInstructions) && + promptComponent?.customInstructions !== builtInMode?.customInstructions) + const effectiveBaseInstructions = hasUserAuthoredModeInstructions + ? baseInstructions + : getBuiltInModeInstructions(mode, baseInstructions, effectivePromptContext) // Check if MCP functionality should be included const hasMcpGroup = modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp") @@ -86,7 +110,9 @@ async function generatePrompt( const [modesSection, skillsSection] = await Promise.all([ getModesSection(context), - getSkillsSection(skillsManager, mode as string), + effectivePromptContext?.availableToolNames.has("skill") === false + ? Promise.resolve("") + : getSkillsSection(skillsManager, mode as string), ]) // Tools catalog is not included in the system prompt. @@ -94,11 +120,11 @@ async function generatePrompt( const basePrompt = `${roleDefinition} -${markdownFormattingSection()} +${markdownFormattingSection(effectivePromptContext)} -${getSharedToolUseSection()}${toolsCatalog} +${getSharedToolUseSection(effectivePromptContext)}${toolsCatalog} - ${getToolUseGuidelinesSection()} + ${getToolUseGuidelinesSection(effectivePromptContext)} ${ // Forward the hub only when the mode actually exposes the MCP group, and pass the per-mode @@ -107,18 +133,18 @@ ${ // the capability text consistent with the tools exposed in mixed cases (e.g. one allowed + // one disallowed server), preventing the section from advertising MCP based on a disallowed // server. `shouldIncludeMcp` is still used to short-circuit when no allowed server exists. - getCapabilitiesSection(cwd, hasMcpGroup ? mcpHub : undefined, allowedMcpServers) + getCapabilitiesSection(cwd, hasMcpGroup ? mcpHub : undefined, allowedMcpServers, effectivePromptContext) } ${modesSection} ${skillsSection ? `\n${skillsSection}` : ""} -${getRulesSection(cwd, settings)} +${getRulesSection(cwd, settings, effectivePromptContext)} -${getSystemInfoSection(cwd)} +${getSystemInfoSection(cwd, effectivePromptContext)} -${getObjectiveSection()} +${getObjectiveSection(effectivePromptContext)} -${await addCustomInstructions(baseInstructions, globalCustomInstructions || "", cwd, mode, { +${await addCustomInstructions(effectiveBaseInstructions, globalCustomInstructions || "", cwd, mode, { language: language ?? formatLanguage(vscode.env.language), rooIgnoreInstructions, settings, @@ -144,6 +170,7 @@ export const SYSTEM_PROMPT = async ( todoList?: TodoItem[], modelId?: string, skillsManager?: SkillsManager, + promptContext?: SystemPromptContext, ): Promise => { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -172,5 +199,6 @@ export const SYSTEM_PROMPT = async ( todoList, modelId, skillsManager, + promptContext, ) } diff --git a/src/core/prompts/types.ts b/src/core/prompts/types.ts index a4c17c3a6e..a16f43b6f1 100644 --- a/src/core/prompts/types.ts +++ b/src/core/prompts/types.ts @@ -10,3 +10,30 @@ export interface SystemPromptSettings { /** When true, model should hide vendor/company identity in responses */ isStealthModel?: boolean } + +/** Request-scoped inputs used to keep system-owned guidance aligned with available tools. */ +export interface SystemPromptContext { + availableToolNames: ReadonlySet + editFileRestriction?: { + fileRegex: string + description?: string + } +} + +export const FILE_EDIT_TOOL_NAMES = [ + "write_to_file", + "apply_diff", + "edit", + "search_replace", + "edit_file", + "apply_patch", +] as const + +/** Legacy section callers omit context and retain the existing all-tools wording. */ +export function isPromptToolAvailable(context: SystemPromptContext | undefined, toolName: string): boolean { + return context?.availableToolNames.has(toolName) ?? true +} + +export function hasAnyPromptTool(context: SystemPromptContext | undefined, toolNames: readonly string[]): boolean { + return toolNames.some((toolName) => isPromptToolAvailable(context, toolName)) +} From c5bc2120f5a2715805fbe11e9fb2d7ea919b866c Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Mon, 31 Aug 2026 00:48:42 +0900 Subject: [PATCH 03/15] fix(tools): share request policy across prompt and runtime Resolve tool availability once per request and reuse it for the system prompt, API metadata, runtime validation, and context condensation. Snapshot the mode and MCP policy used for the request, and build previews from the same effective policy to prevent prompt/runtime drift. Signed-off-by: JunyongParkDev --- ...tantMessage-tool-usage-attribution.spec.ts | 170 +++++++++- .../presentAssistantMessage.ts | 120 +++++-- src/core/task/Task.ts | 311 ++++++++---------- src/core/task/__tests__/Task.spec.ts | 81 ++++- .../__tests__/mcpServerRestriction.spec.ts | 21 +- src/core/tools/mcpServerRestriction.ts | 5 + .../webview/__tests__/ClineProvider.spec.ts | 78 +++++ src/core/webview/generateSystemPrompt.ts | 28 +- 8 files changed, 612 insertions(+), 202 deletions(-) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts index c75eb6ee18..7d03e8695e 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -4,8 +4,9 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { describe, it, expect, beforeEach, vi } from "vitest" import { presentAssistantMessage } from "../presentAssistantMessage" import { validateToolUse } from "../../tools/validateToolUse" +import { useMcpToolTool } from "../../tools/UseMcpToolTool" import { getModeBySlug } from "../../../shared/modes" -import type { Task } from "../../task/Task" +import type { CurrentRequestToolPolicy, Task } from "../../task/Task" vi.mock("../../task/Task") vi.mock("../../../shared/modes", async (importOriginal) => { @@ -67,12 +68,19 @@ interface MockTask { providerRef: { deref: () => { getState: ReturnType - getMcpHub?: () => { findServerNameBySanitizedName: (name: string) => string | undefined } + getMcpHub?: () => { findServerNameBySanitizedName: (name: string) => string | undefined } | undefined } } say: ReturnType ask: ReturnType pushToolResultToUserContent: ReturnType + getTaskMode?: () => Promise + getCurrentRequestToolPolicy?: () => CurrentRequestToolPolicy +} + +function presentMockTask(task: MockTask) { + // This focused structural mock implements only the Task members reached by these tests. + return presentAssistantMessage(task as unknown as Task) } describe("presentAssistantMessage - tool usage attribution", () => { @@ -110,6 +118,7 @@ describe("presentAssistantMessage - tool usage attribution", () => { mode: "code", customModes: [], }), + getMcpHub: () => undefined, }), }, say: vi.fn().mockResolvedValue(undefined), @@ -151,6 +160,109 @@ describe("presentAssistantMessage - tool usage attribution", () => { expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "read_file") }) + it("passes model-excluded tools to fallback execution validation", async () => { + mockTask.api.getModel = () => ({ + id: "test-model", + info: { excludedTools: ["read_file"] }, + }) + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_excluded", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(vi.mocked(validateToolUse).mock.calls[0][3]).toMatchObject({ read_file: false }) + }) + + it("uses the request policy without reading the live focused mode", async () => { + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set(["read_file", "attempt_completion"]), + mode: "architect", + customModes: [], + experiments: {}, + }) + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockRejectedValue(new Error("live state should not be read")), + }), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_request_policy", + name: "execute_command", + params: { command: "echo should-not-run" }, + nativeArgs: { command: "echo should-not-run" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + expect(mockTask.recordToolError).toHaveBeenCalledWith("execute_command", expect.any(String)) + expect(mockTask.userMessageContent).toHaveLength(1) + }) + + it("validates available tools with the request mode and effective included set", async () => { + const effectiveToolNames = new Set(["read_file", "apply_patch", "attempt_completion"]) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames, + mode: "architect", + customModes: [], + experiments: {}, + }) + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_allowed", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + const validationCall = vi.mocked(validateToolUse).mock.calls[0] + expect(validationCall[1]).toBe("architect") + expect(validationCall[6]).toEqual(Array.from(effectiveToolNames)) + }) + + it("passes global MCP disablement to fallback execution validation", async () => { + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [], mcpEnabled: false }), + getMcpHub: () => undefined, + }), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_mcp_disabled", + name: "access_mcp_resource", + params: { server_name: "test", uri: "resource://test" }, + nativeArgs: { server_name: "test", uri: "resource://test" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(vi.mocked(validateToolUse).mock.calls[0][3]).toMatchObject({ + use_mcp_tool: false, + access_mcp_resource: false, + }) + }) + it("records a valid dynamic mcp_ tool name as use_mcp_tool", async () => { mockTask.assistantMessageContent = [ { @@ -236,6 +348,60 @@ describe("presentAssistantMessage - tool usage attribution", () => { }) describe("native mcp_tool_use block", () => { + it("blocks an MCP tool absent from the request policy", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set(["mcp--allowed-server--allowed-tool"]), + mode: "code", + customModes: [], + experiments: {}, + }) + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_blocked", + name: "mcp--blocked-server--blocked-tool", + serverName: "blocked-server", + toolName: "blocked-tool", + arguments: {}, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(handleSpy).not.toHaveBeenCalled() + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + expect(mockTask.userMessageContent).toHaveLength(1) + handleSpy.mockRestore() + }) + + it("matches provider-normalized MCP names against the request policy", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set(["mcp--my-server--do-thing"]), + mode: "code", + customModes: [], + experiments: {}, + }) + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_normalized", + name: "mcp__my_server__do_thing", + serverName: "my_server", + toolName: "do_thing", + arguments: {}, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(handleSpy).toHaveBeenCalledTimes(1) + handleSpy.mockRestore() + }) + it("records exactly one attempt once the MCP tool's own validation passes", async () => { mockTask.providerRef = { deref: () => ({ diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7b25db4e66..2c67a91490 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -40,6 +40,7 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" +import { isMcpTool, toolNamesMatch } from "../../utils/mcp-name" /** * Maps a raw, potentially model-controlled tool name to a safe analytics key. @@ -289,6 +290,58 @@ export async function presentAssistantMessage(cline: Task) { }, } + if (!mcpBlock.partial) { + const requestPolicy = cline.getCurrentRequestToolPolicy?.() + const state = requestPolicy ? undefined : await cline.providerRef.deref()?.getState() + const mode = requestPolicy?.mode ?? (await cline.getTaskMode?.()) ?? state?.mode ?? defaultModeSlug + const customModes = requestPolicy?.customModes ?? state?.customModes + const experiments = requestPolicy?.experiments ?? state?.experiments + const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode") + const unavailableTools = requestPolicy + ? [] + : [ + ...(state?.disabledTools ?? []), + ...(cline.api.getModel().info.excludedTools ?? []), + ...(state?.mcpEnabled === false ? ["use_mcp_tool", "access_mcp_resource"] : []), + ] + const toolRequirements = unavailableTools.reduce((acc: Record, toolName: string) => { + acc[toolName] = false + acc[resolveToolAlias(toolName)] = false + return acc + }, {}) + + try { + if ( + requestPolicy && + !Array.from(requestPolicy.effectiveToolNames).some( + (toolName) => isMcpTool(toolName) && toolNamesMatch(toolName, mcpBlock.name), + ) + ) { + throw new Error(`Tool "${mcpBlock.name}" is not available for this request.`) + } + if ( + !requestPolicy && + unavailableTools.some((toolName) => toolNamesMatch(toolName, mcpBlock.name)) + ) { + throw new Error(`Tool "${mcpBlock.name}" is not available for this model.`) + } + validateToolUse( + "use_mcp_tool", + mode, + customModes ?? [], + toolRequirements, + syntheticToolUse.params, + experiments, + requestPolicy ? Array.from(requestPolicy.effectiveToolNames) : undefined, + ) + } catch (error) { + cline.consecutiveMistakeCount++ + cline.recordToolError("use_mcp_tool", error.message) + pushToolResult(formatResponse.toolError(error.message)) + break + } + } + await useMcpToolTool.handle(cline, syntheticToolUse, { askApproval, handleError, @@ -342,9 +395,13 @@ export async function presentAssistantMessage(cline: Task) { break } - // Fetch state early so it's available for toolDescription and validation - const state = await cline.providerRef.deref()?.getState() + // Prefer the request snapshot so validation does not depend on mutable focused state. + const requestPolicy = cline.getCurrentRequestToolPolicy?.() + const state = requestPolicy ? undefined : await cline.providerRef.deref()?.getState() const { mode, customModes, experiments: stateExperiments, disabledTools } = state ?? {} + const effectiveMode = requestPolicy?.mode ?? (await cline.getTaskMode?.()) ?? mode ?? defaultModeSlug + const effectiveCustomModes = requestPolicy?.customModes ?? customModes + const effectiveExperiments = requestPolicy?.experiments ?? stateExperiments const toolDescription = (): string => { switch (block.name) { @@ -396,7 +453,7 @@ export async function presentAssistantMessage(cline: Task) { case "new_task": { const mode = block.params.mode ?? defaultModeSlug const message = block.params.message ?? "(no message)" - const modeName = getModeBySlug(mode, customModes)?.name ?? mode + const modeName = getModeBySlug(mode, effectiveCustomModes)?.name ?? mode return `[${block.name} in ${modeName} mode: '${message}']` } case "run_slash_command": @@ -438,8 +495,8 @@ export async function presentAssistantMessage(cline: Task) { // This avoids executing an invalid tool_use block and prevents duplicate/fragmented // error reporting. if (!block.partial) { - const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined - const isKnownTool = isValidToolName(String(block.name), stateExperiments) + const customTool = effectiveExperiments?.customTools ? customToolRegistry.get(block.name) : undefined + const isKnownTool = isValidToolName(String(block.name), effectiveExperiments) if (isKnownTool && !block.nativeArgs && !customTool) { const errorMessage = `Invalid tool call for '${block.name}': missing nativeArgs. ` + @@ -447,7 +504,10 @@ export async function presentAssistantMessage(cline: Task) { cline.consecutiveMistakeCount++ try { - cline.recordToolError(toTelemetryToolName(block.name, false, stateExperiments), errorMessage) + cline.recordToolError( + toTelemetryToolName(block.name, false, effectiveExperiments), + errorMessage, + ) } catch { // Best-effort only } @@ -599,29 +659,37 @@ export async function presentAssistantMessage(cline: Task) { // e.g., "edit_file" should resolve to "apply_diff" const rawIncludedTools = modelInfo?.info?.includedTools const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode") - const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool)) + const includedTools = requestPolicy + ? Array.from(requestPolicy.effectiveToolNames) + : rawIncludedTools?.map((tool) => resolveToolAlias(tool)) - const isCustomTool = Boolean(stateExperiments?.customTools && customToolRegistry.has(block.name)) + const isCustomTool = Boolean(effectiveExperiments?.customTools && customToolRegistry.has(block.name)) try { - const toolRequirements = - disabledTools?.reduce( - (acc: Record, tool: string) => { - acc[tool] = false - const resolvedToolName = resolveToolAlias(tool) - acc[resolvedToolName] = false - return acc - }, - {} as Record, - ) ?? {} + const unavailableTools = requestPolicy + ? [] + : [ + ...(disabledTools ?? []), + ...(modelInfo?.info?.excludedTools ?? []), + ...(state?.mcpEnabled === false ? ["use_mcp_tool", "access_mcp_resource"] : []), + ] + const toolRequirements = unavailableTools.reduce((acc: Record, tool: string) => { + acc[tool] = false + acc[resolveToolAlias(tool)] = false + return acc + }, {}) + const canonicalToolName = resolveToolAlias(block.name) + if (requestPolicy && !requestPolicy.effectiveToolNames.has(canonicalToolName)) { + throw new Error(`Tool "${block.name}" is not available for this request.`) + } validateToolUse( block.name as ToolName, - mode ?? defaultModeSlug, - customModes ?? [], + effectiveMode, + effectiveCustomModes ?? [], toolRequirements, block.params, - stateExperiments, + effectiveExperiments, includedTools, ) } catch (error) { @@ -643,7 +711,7 @@ export async function presentAssistantMessage(cline: Task) { // Record a safe failure key. Never key telemetry on the raw, // model-controlled tool name. cline.recordToolError( - toTelemetryToolName(block.name, isCustomTool, stateExperiments), + toTelemetryToolName(block.name, isCustomTool, effectiveExperiments), error.message, ) @@ -653,7 +721,7 @@ export async function presentAssistantMessage(cline: Task) { // Validation passed: record exactly one attempt at this single // central point. Individual tool handlers must not also record // usage, or the attempt would be double-counted. - const recordName = toTelemetryToolName(block.name, isCustomTool, stateExperiments) + const recordName = toTelemetryToolName(block.name, isCustomTool, effectiveExperiments) cline.recordToolUsage(recordName) TelemetryService.instance.captureToolUsage(cline.taskId, recordName) @@ -904,7 +972,9 @@ export async function presentAssistantMessage(cline: Task) { break } - const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined + const customTool = effectiveExperiments?.customTools + ? customToolRegistry.get(block.name) + : undefined if (customTool) { try { @@ -924,7 +994,7 @@ export async function presentAssistantMessage(cline: Task) { } const result = await customTool.execute(customToolArgs, { - mode: mode ?? defaultModeSlug, + mode: effectiveMode, task: cline, }) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 37281a9010..c2750b95f7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -9,7 +9,6 @@ import { AskIgnoredError } from "./AskIgnoredError" import { RateLimitClock, createRateLimitClock } from "./RateLimitClock" import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" import debounce from "lodash.debounce" import delay from "delay" import pWaitFor from "p-wait-for" @@ -97,7 +96,7 @@ import { getTaskDirectoryPath } from "../../utils/storage" // prompts import { formatResponse } from "../prompts/responses" import { SYSTEM_PROMPT } from "../prompts/system" -import { buildNativeToolsArrayWithRestrictions } from "./build-tools" +import { buildNativeToolsArrayWithRestrictions, type BuildToolsResult } from "./build-tools" // core modules import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" @@ -194,6 +193,23 @@ export interface TaskOptions extends CreateTaskOptions { diffFuzzyThreshold?: number } +type TaskProviderState = Awaited> + +interface ResolvedPromptTools { + state: TaskProviderState + mode: string + mcpHub?: McpHub + toolsResult: BuildToolsResult +} + +export interface CurrentRequestToolPolicy { + effectiveToolNames: ReadonlySet + mode: string + customModes?: TaskProviderState["customModes"] + experiments?: TaskProviderState["experiments"] + allowedMcpServers?: string[] +} + export class Task extends EventEmitter implements TaskLike { readonly taskId: string readonly rootTaskId?: string @@ -205,6 +221,7 @@ export class Task extends EventEmitter implements TaskLike { readonly metadata: TaskMetadata todoList?: TodoItem[] + private currentRequestToolPolicy?: CurrentRequestToolPolicy readonly rootTask: Task | undefined = undefined readonly parentTask: Task | undefined = undefined @@ -792,6 +809,11 @@ export class Task extends EventEmitter implements TaskLike { return this._taskMode || defaultModeSlug } + /** Tool policy used to generate and validate the currently streaming request. */ + public getCurrentRequestToolPolicy(): CurrentRequestToolPolicy | undefined { + return this.currentRequestToolPolicy + } + /** * Get the task mode synchronously. This should only be used when you're certain * that the mode has already been initialized (e.g., after waitForModeInitialization). @@ -1708,35 +1730,16 @@ export class Task extends EventEmitter implements TaskLike { // to ensure tool_use/tool_result pairs are complete in history await this.flushPendingToolResultsToHistory() - const systemPrompt = await this.getSystemPrompt() - - // Get condensing configuration - const state = await this.providerRef.deref()?.getState() - const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE - // Use task-local values, not provider state, to prevent cross-task configuration leaks. - const mode = await this.getTaskMode() - const apiConfiguration = this.apiConfiguration + await this.safeEnsureModelFetched() + const resolvedPromptTools = await this.resolvePromptTools() + const systemPrompt = await this.getSystemPrompt(resolvedPromptTools) + const { state, mode, toolsResult } = resolvedPromptTools + const customCondensingPrompt = state.customSupportPrompts?.CONDENSE const { contextTokens: prevContextTokens } = this.getTokenUsage() - // Build tools for condensing metadata (same tools used for normal API calls) - const provider = this.providerRef.deref() - let allTools: import("openai").default.Chat.ChatCompletionTool[] = [] - if (provider) { - const modelInfo = this.api.getModel().info - const toolsResult = await buildNativeToolsArrayWithRestrictions({ - provider, - cwd: this.cwd, - mode, - customModes: state?.customModes, - experiments: state?.experiments, - apiConfiguration, - disabledTools: state?.disabledTools, - modelInfo, - includeAllToolsWithRestrictions: false, - }) - allTools = toolsResult.tools - } + // Reuse the exact policy that generated the condensing system prompt. + const allTools = toolsResult.tools // Build metadata with tools and taskId for the condensing API call const metadata: ApiHandlerCreateMessageMetadata = { @@ -4014,76 +4017,96 @@ export class Task extends EventEmitter implements TaskLike { return false } - private async getSystemPrompt(): Promise { - const { mcpEnabled } = (await this.providerRef.deref()?.getState()) ?? {} - let mcpHub: McpHub | undefined - if (mcpEnabled ?? true) { - const provider = this.providerRef.deref() + private async getMcpHubForPrompt(state: TaskProviderState): Promise { + if (!(state.mcpEnabled ?? true)) { + return undefined + } - if (!provider) { - throw new Error("Provider reference lost during view transition") - } + const provider = this.providerRef.deref() + if (!provider) { + throw new Error("Provider reference lost during view transition") + } - // Wait for MCP hub initialization through McpServerManager - mcpHub = await McpServerManager.getInstance(provider.context, provider) + const mcpHub = await McpServerManager.getInstance(provider.context, provider) + if (!mcpHub) { + throw new Error("Failed to get MCP hub from server manager") + } - if (!mcpHub) { - throw new Error("Failed to get MCP hub from server manager") - } + await pWaitFor(() => !mcpHub.isConnecting, { timeout: 10_000 }).catch(() => { + console.error("MCP servers failed to connect in time") + }) - // Wait for MCP servers to be connected before generating system prompt - await pWaitFor(() => !mcpHub!.isConnecting, { timeout: 10_000 }).catch(() => { - console.error("MCP servers failed to connect in time") - }) - } + return mcpHub + } - const rooIgnoreInstructions = this.rooIgnoreController?.getInstructions() + private async resolvePromptTools(options?: { + state?: TaskProviderState + mode?: string + includeAllToolsWithRestrictions?: boolean + }): Promise { + const provider = this.providerRef.deref() + if (!provider) { + throw new Error("Provider not available") + } - const state = await this.providerRef.deref()?.getState() + const state = options?.state ?? (await provider.getState()) + const mode = options?.mode ?? (await this.getTaskMode()) + const mcpHub = await this.getMcpHubForPrompt(state) + const toolsResult = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: this.cwd, + mode, + customModes: state.customModes, + experiments: state.experiments, + apiConfiguration: this.apiConfiguration, + disabledTools: state.disabledTools, + modelInfo: this.api.getModel().info, + mcpEnabled: state.mcpEnabled, + includeAllToolsWithRestrictions: options?.includeAllToolsWithRestrictions, + }) - const { customModes, customModePrompts, customInstructions, experiments, language, enableSubfolderRules } = - state ?? {} - // Use task-local values, not provider state, to prevent cross-task configuration leaks. - const mode = await this.getTaskMode() - const apiConfiguration = this.apiConfiguration + return { state, mode, mcpHub, toolsResult } + } - return await (async () => { - const provider = this.providerRef.deref() + private async getSystemPrompt(resolved?: ResolvedPromptTools): Promise { + const promptTools = resolved ?? (await this.resolvePromptTools()) + const { state, mode, mcpHub, toolsResult } = promptTools + const provider = this.providerRef.deref() + if (!provider) { + throw new Error("Provider not available") + } - if (!provider) { - throw new Error("Provider not available") - } + const { customModes, customModePrompts, customInstructions, experiments, language, enableSubfolderRules } = + state + const modelInfo = this.api.getModel().info - const modelInfo = this.api.getModel().info - - return SYSTEM_PROMPT( - provider.context, - this.cwd, - false, - mcpHub, - this.diffStrategy, - mode ?? defaultModeSlug, - customModePrompts, - customModes, - customInstructions, - experiments, - language, - rooIgnoreInstructions, - { - todoListEnabled: apiConfiguration?.todoListEnabled ?? true, - useAgentRules: - vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, - enableSubfolderRules: enableSubfolderRules ?? false, - newTaskRequireTodos: vscode.workspace - .getConfiguration(Package.name) - .get("newTaskRequireTodos", false), - isStealthModel: modelInfo?.isStealthModel, - }, - undefined, // todoList - this.api.getModel().id, - provider.getSkillsManager(), - ) - })() + return SYSTEM_PROMPT( + provider.context, + this.cwd, + false, + mcpHub, + this.diffStrategy, + mode ?? defaultModeSlug, + customModePrompts, + customModes, + customInstructions, + experiments, + language, + this.rooIgnoreController?.getInstructions(), + { + todoListEnabled: this.apiConfiguration?.todoListEnabled ?? true, + useAgentRules: vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, + enableSubfolderRules: enableSubfolderRules ?? false, + newTaskRequireTodos: vscode.workspace + .getConfiguration(Package.name) + .get("newTaskRequireTodos", false), + isStealthModel: modelInfo?.isStealthModel, + }, + undefined, // todoList + this.api.getModel().id, + provider.getSkillsManager(), + { availableToolNames: toolsResult.effectiveToolNames }, + ) } private getCurrentProfileId(state: any): string { @@ -4114,7 +4137,6 @@ export class Task extends EventEmitter implements TaskLike { const { profileThresholds = {} } = state ?? {} // Use task-local values, not provider state, to prevent cross-task configuration leaks. const mode = await this.getTaskMode() - const apiConfiguration = this.apiConfiguration const { contextTokens } = this.getTokenUsage() await this.safeEnsureModelFetched() @@ -4143,23 +4165,8 @@ export class Task extends EventEmitter implements TaskLike { // Send condenseTaskContextStarted to show in-progress indicator await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) - // Build tools for condensing metadata (same tools used for normal API calls) - const provider = this.providerRef.deref() - let allTools: import("openai").default.Chat.ChatCompletionTool[] = [] - if (provider) { - const toolsResult = await buildNativeToolsArrayWithRestrictions({ - provider, - cwd: this.cwd, - mode, - customModes: state?.customModes, - experiments: state?.experiments, - apiConfiguration, - disabledTools: state?.disabledTools, - modelInfo, - includeAllToolsWithRestrictions: false, - }) - allTools = toolsResult.tools - } + const resolvedPromptTools = await this.resolvePromptTools({ state, mode }) + const allTools = resolvedPromptTools.toolsResult.tools // Build metadata with tools and taskId for the condensing API call const metadata: ApiHandlerCreateMessageMetadata = { @@ -4192,7 +4199,7 @@ export class Task extends EventEmitter implements TaskLike { apiHandler: this.api, autoCondenseContext: true, autoCondenseContextPercent: FORCED_CONTEXT_REDUCTION_PERCENT, - systemPrompt: await this.getSystemPrompt(), + systemPrompt: await this.getSystemPrompt(resolvedPromptTools), taskId: this.taskId, profileThresholds, currentProfileId, @@ -4313,13 +4320,27 @@ export class Task extends EventEmitter implements TaskLike { // in the caller. this.rateLimitClock.recordRequest() - const systemPrompt = await this.getSystemPrompt() + await this.safeEnsureModelFetched() + const modelInfo = this.api.getModel().info + const supportsAllowedFunctionNames = apiConfiguration?.apiProvider === providerIdentifiers.gemini + const resolvedPromptTools = await this.resolvePromptTools({ + state, + mode, + includeAllToolsWithRestrictions: supportsAllowedFunctionNames, + }) + const resolvedState = resolvedPromptTools.state + const allowedMcpServers = getModeBySlug(mode, resolvedState.customModes)?.allowedMcpServers + this.currentRequestToolPolicy = { + effectiveToolNames: new Set(resolvedPromptTools.toolsResult.effectiveToolNames), + mode, + customModes: resolvedState.customModes, + experiments: resolvedState.experiments ? { ...resolvedState.experiments } : undefined, + allowedMcpServers: allowedMcpServers ? [...allowedMcpServers] : undefined, + } + const systemPrompt = await this.getSystemPrompt(resolvedPromptTools) const { contextTokens } = this.getTokenUsage() if (contextTokens) { - await this.safeEnsureModelFetched() - const modelInfo = this.api.getModel().info - const maxTokens = getModelMaxOutputTokens({ modelId: this.api.getModel().id, model: modelInfo, @@ -4366,26 +4387,10 @@ export class Task extends EventEmitter implements TaskLike { ?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) } - // Build tools for condensing metadata (same tools used for normal API calls) - // This ensures the condensing API call includes tool definitions for providers that need them - let contextMgmtTools: import("openai").default.Chat.ChatCompletionTool[] = [] - { - const provider = this.providerRef.deref() - if (provider) { - const toolsResult = await buildNativeToolsArrayWithRestrictions({ - provider, - cwd: this.cwd, - mode, - customModes: state?.customModes, - experiments: state?.experiments, - apiConfiguration, - disabledTools: state?.disabledTools, - modelInfo, - includeAllToolsWithRestrictions: false, - }) - contextMgmtTools = toolsResult.tools - } - } + // Reuse the main request's declarations and logical restrictions so the + // condensing prompt and metadata cannot drift while settings change. + const { tools: contextMgmtTools, allowedFunctionNames: contextMgmtAllowedFunctionNames } = + resolvedPromptTools.toolsResult // Build metadata with tools and taskId for the condensing API call const contextMgmtMetadata: ApiHandlerCreateMessageMetadata = { @@ -4401,6 +4406,9 @@ export class Task extends EventEmitter implements TaskLike { tools: contextMgmtTools, tool_choice: "auto", parallelToolCalls: true, + ...(contextMgmtAllowedFunctionNames + ? { allowedFunctionNames: contextMgmtAllowedFunctionNames } + : {}), } : {}), } @@ -4519,43 +4527,11 @@ export class Task extends EventEmitter implements TaskLike { throw new Error("Auto-approval limit reached and user did not approve continuation") } - // Whether we include tools is determined by whether we have any tools to send. - const modelInfo = this.api.getModel().info - // Build complete tools array: native tools + dynamic MCP tools // When includeAllToolsWithRestrictions is true, returns all tools but provides - // allowedFunctionNames for providers (like Gemini) that need to see all tool - // definitions in history while restricting callable tools for the current mode. - // Only Gemini currently supports this - other providers filter tools normally. - let allTools: OpenAI.Chat.ChatCompletionTool[] = [] - let allowedFunctionNames: string[] | undefined - - // Gemini requires all tool definitions to be present for history compatibility, - // but uses allowedFunctionNames to restrict which tools can be called. - // Other providers (Anthropic, OpenAI, etc.) don't support this feature yet, - // so they continue to receive only the filtered tools for the current mode. - const supportsAllowedFunctionNames = apiConfiguration?.apiProvider === providerIdentifiers.gemini - - { - const provider = this.providerRef.deref() - if (!provider) { - throw new Error("Provider reference lost during tool building") - } - - const toolsResult = await buildNativeToolsArrayWithRestrictions({ - provider, - cwd: this.cwd, - mode, - customModes: state?.customModes, - experiments: state?.experiments, - apiConfiguration, - disabledTools: state?.disabledTools, - modelInfo, - includeAllToolsWithRestrictions: supportsAllowedFunctionNames, - }) - allTools = toolsResult.tools - allowedFunctionNames = toolsResult.allowedFunctionNames - } + // allowedFunctionNames for providers (like Gemini) that need all definitions + // for history compatibility. Runtime validation enforces the same logical set. + const { tools: allTools, allowedFunctionNames } = resolvedPromptTools.toolsResult const shouldIncludeTools = allTools.length > 0 @@ -4574,8 +4550,7 @@ export class Task extends EventEmitter implements TaskLike { tools: allTools, tool_choice: "auto", parallelToolCalls: true, - // When mode restricts tools, provide allowedFunctionNames so providers - // like Gemini can see all tools in history but only call allowed ones + // Keep logical allowed names alongside compatibility declarations. ...(allowedFunctionNames ? { allowedFunctionNames } : {}), } : {}), diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 37e228f887..5f6e0a8ed8 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -578,9 +578,35 @@ describe("Cline", () => { await getTaskTestAccess(task).getSystemPrompt() const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) - const [, , , , , mode, , , , , , , settings] = systemPromptCall + const [, , , , , mode, , , , , , , settings, , , , promptContext] = systemPromptCall expect(mode).toBe("architect") expect(settings).toMatchObject({ todoListEnabled: true }) + expect(promptContext?.availableToolNames).not.toContain("execute_command") + }) + + it("passes disabled tools through the effective prompt context", async () => { + const providerState = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue({ + ...providerState, + mode: "code", + mcpEnabled: false, + disabledTools: ["execute_command"], + }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await getTaskTestAccess(task).getSystemPrompt() + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + const promptContext = systemPromptCall[16] + expect(promptContext?.availableToolNames).not.toContain("execute_command") + expect(promptContext?.availableToolNames).toContain("read_file") }) it("uses the task mode when manually condensing after focused state changes", async () => { @@ -599,12 +625,20 @@ describe("Cline", () => { mode: "code", mcpEnabled: false, } as unknown as ProviderState) - vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") await task.condenseContext() const [options] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + const promptToolNames = systemPromptCall[16]?.availableToolNames + const metadataToolNames = new Set( + (options.metadata?.tools ?? []).flatMap((tool) => + "function" in tool && tool.function ? [tool.function.name] : [], + ), + ) expect(options.metadata?.mode).toBe("architect") + expect(metadataToolNames).toEqual(promptToolNames) }) it("uses the task mode in request metadata when focused provider state differs", async () => { @@ -642,6 +676,49 @@ describe("Cline", () => { const metadata = requireDefined(createMessage.mock.calls[0])[2] expect(metadata?.mode).toBe("ask") }) + + it("shares one effective tool policy across prompt, API metadata, and runtime validation", async () => { + const providerState = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue({ + ...providerState, + mode: "code", + mcpEnabled: false, + autoApprovalEnabled: true, + requestDelaySeconds: 0, + disabledTools: ["execute_command"], + }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + const stream = (async function* () { + yield { type: "text", text: "response" } as ApiStreamChunk + })() + const createMessage = vi.spyOn(task.api, "createMessage").mockReturnValue(stream) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + + await task.attemptApiRequest().next() + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + const promptToolNames = systemPromptCall[16]?.availableToolNames + const metadata = requireDefined(createMessage.mock.calls[0])[2] + const apiToolNames = new Set( + (metadata?.tools ?? []).flatMap((tool) => + "function" in tool && tool.function ? [tool.function.name] : [], + ), + ) + const requestPolicy = requireDefined(task.getCurrentRequestToolPolicy()) + + expect(promptToolNames).toEqual(requestPolicy.effectiveToolNames) + expect(apiToolNames).toEqual(requestPolicy.effectiveToolNames) + expect(requestPolicy.effectiveToolNames).not.toContain("execute_command") + }) }) describe("sayAndCreateMissingParamError", () => { diff --git a/src/core/tools/__tests__/mcpServerRestriction.spec.ts b/src/core/tools/__tests__/mcpServerRestriction.spec.ts index 6455e1e4b3..a0d4d4731e 100644 --- a/src/core/tools/__tests__/mcpServerRestriction.spec.ts +++ b/src/core/tools/__tests__/mcpServerRestriction.spec.ts @@ -1,6 +1,6 @@ // npx vitest run core/tools/__tests__/mcpServerRestriction.spec.ts -import type { Task } from "../../task/Task" +import type { CurrentRequestToolPolicy, Task } from "../../task/Task" import { isMcpServerAllowed, getAllowedMcpServersForTask, ensureMcpServerAllowed } from "../mcpServerRestriction" vi.mock("../../../shared/modes", async (importOriginal) => { @@ -16,7 +16,7 @@ import { getModeBySlug } from "../../../shared/modes" const toolError = (error: string) => `ERR:${error}` -function makeTask(state: any): Task { +function makeTask(state: any, requestPolicy?: CurrentRequestToolPolicy): Task { return { providerRef: { deref: () => ({ @@ -26,6 +26,7 @@ function makeTask(state: any): Task { consecutiveMistakeCount: 0, didToolFailInCurrentTurn: false, recordToolError: vi.fn(), + ...(requestPolicy ? { getCurrentRequestToolPolicy: () => requestPolicy } : {}), } as unknown as Task } @@ -64,6 +65,22 @@ describe("getAllowedMcpServersForTask", () => { await expect(getAllowedMcpServersForTask(task)).resolves.toEqual(["srv-a"]) }) + it("prefers the request policy over the live focused mode", async () => { + const task = makeTask( + { mode: "code", customModes: [] }, + { + effectiveToolNames: new Set(["access_mcp_resource"]), + mode: "architect", + customModes: [], + experiments: {}, + allowedMcpServers: ["request-server"], + }, + ) + + await expect(getAllowedMcpServersForTask(task)).resolves.toEqual(["request-server"]) + expect(getModeBySlug).not.toHaveBeenCalled() + }) + it("returns undefined when the mode does not restrict servers", async () => { vi.mocked(getModeBySlug).mockReturnValue({ slug: "code", diff --git a/src/core/tools/mcpServerRestriction.ts b/src/core/tools/mcpServerRestriction.ts index aa88125733..e5c3118870 100644 --- a/src/core/tools/mcpServerRestriction.ts +++ b/src/core/tools/mcpServerRestriction.ts @@ -32,6 +32,11 @@ export function isMcpServerAllowed(serverName: string, allowedMcpServers?: strin * @returns The mode's `allowedMcpServers` allowlist, or `undefined` when unrestricted. */ export async function getAllowedMcpServersForTask(task: Task): Promise { + const requestPolicy = task.getCurrentRequestToolPolicy?.() + if (requestPolicy) { + return requestPolicy.allowedMcpServers + } + const provider = task.providerRef.deref() // Be defensive: provider may be gone, or `getState` may be unavailable (e.g. in tests). diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index ad6ea143a8..c9e1d95df0 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -33,6 +33,7 @@ import { webviewMessageHandler } from "../webviewMessageHandler" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" +import { buildNativeToolsArrayWithRestrictions } from "../../task/build-tools" // Mock setup must come before imports. vi.mock("../../prompts/sections/custom-instructions") @@ -356,10 +357,19 @@ vi.mock("../../prompts/system", () => ({ codeMode: "code", })) +vi.mock("../../task/build-tools", () => ({ + buildNativeToolsArrayWithRestrictions: vi.fn().mockResolvedValue({ + tools: [], + effectiveToolNames: new Set(["read_file", "execute_command"]), + }), +})) + vi.mock("../../../api", () => ({ buildApiHandler: vi.fn().mockReturnValue({ + ensureModelFetched: vi.fn().mockResolvedValue(undefined), getModel: vi.fn().mockReturnValue({ id: "claude-3-sonnet", + info: {}, }), }), })) @@ -2172,6 +2182,74 @@ describe("ClineProvider", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.get_system_prompt") }) + test("uses the effective disabled-tool policy for preview guidance", async () => { + const providerState = await provider.getState() + vi.spyOn(provider, "getState").mockResolvedValue({ + ...providerState, + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + }, + mcpEnabled: false, + mode: "code", + disabledTools: ["execute_command"], + experiments: experimentDefault, + }) + vi.mocked(buildNativeToolsArrayWithRestrictions).mockResolvedValueOnce({ + tools: [], + effectiveToolNames: new Set(["read_file"]), + }) + const { SYSTEM_PROMPT } = await import("../../prompts/system") + vi.mocked(SYSTEM_PROMPT).mockClear() + + const handler = getMessageHandler() + await handler({ type: "getSystemPrompt", mode: "code" }) + + expect(buildNativeToolsArrayWithRestrictions).toHaveBeenCalledWith( + expect.objectContaining({ + disabledTools: ["execute_command"], + mcpEnabled: false, + mode: "code", + }), + ) + const systemPromptCall = vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1) + const promptContext = systemPromptCall?.[16] + expect(promptContext?.availableToolNames).not.toContain("execute_command") + expect(promptContext?.availableToolNames).toContain("read_file") + }) + + test("uses cached model policy when full model metadata fetch fails", async () => { + const { buildApiHandler } = await import("../../../api") + const fallbackHandler = buildApiHandler({ apiProvider: providerIdentifiers.openrouter }) + fallbackHandler.ensureModelFetched = vi.fn().mockRejectedValue(new Error("fetch failed")) + vi.mocked(fallbackHandler.getModel).mockReturnValue({ + id: "cached-model", + info: { + contextWindow: 128_000, + supportsPromptCache: false, + excludedTools: ["execute_command"], + }, + }) + vi.mocked(buildApiHandler).mockReturnValueOnce(fallbackHandler) + + const providerState = await provider.getState() + vi.spyOn(provider, "getState").mockResolvedValue({ + ...providerState, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + mcpEnabled: false, + mode: "code", + experiments: experimentDefault, + }) + + const handler = getMessageHandler() + await handler({ type: "getSystemPrompt", mode: "code" }) + + expect(buildNativeToolsArrayWithRestrictions).toHaveBeenCalledWith( + expect.objectContaining({ + modelInfo: expect.objectContaining({ excludedTools: ["execute_command"] }), + }), + ) + }) + test("uses code mode custom instructions", async () => { await provider.resolveWebviewView(mockWebviewView) diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index 8af2f5ff5d..0dc304860e 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode" +import type { ModelInfo } from "@roo-code/types" import { WebviewMessage } from "../../shared/WebviewMessage" import { defaultModeSlug } from "../../shared/modes" import { buildApiHandler } from "../../api" @@ -6,6 +7,7 @@ import { buildApiHandler } from "../../api" import { SYSTEM_PROMPT } from "../prompts/system" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { Package } from "../../shared/package" +import { buildNativeToolsArrayWithRestrictions } from "../task/build-tools" import { ClineProvider } from "./ClineProvider" @@ -18,6 +20,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web experiments, language, enableSubfolderRules, + disabledTools, } = await provider.getState() const diffStrategy = new MultiSearchReplaceDiffStrategy() @@ -31,19 +34,37 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web // Create a temporary API handler to check model info for stealth mode. // This avoids relying on an active Cline instance which might not exist during preview. - let modelInfo: { isStealthModel?: boolean } | undefined + let modelInfo: ModelInfo | undefined try { const tempApiHandler = buildApiHandler(apiConfiguration) + try { + await tempApiHandler.ensureModelFetched?.() + } catch (error) { + console.error("Error fetching full model info for system prompt preview:", error) + } modelInfo = tempApiHandler.getModel().info } catch (error) { - console.error("Error fetching model info for system prompt preview:", error) + console.error("Error reading model info for system prompt preview:", error) } + const toolsResult = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd, + mode, + customModes, + experiments, + apiConfiguration, + disabledTools, + modelInfo, + mcpEnabled, + includeAllToolsWithRestrictions: false, + }) + const systemPrompt = await SYSTEM_PROMPT( provider.context, cwd, false, // supportsComputerUse — browser removed - mcpEnabled ? provider.getMcpHub() : undefined, + (mcpEnabled ?? true) ? provider.getMcpHub() : undefined, diffStrategy, mode, customModePrompts, @@ -64,6 +85,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web undefined, // todoList undefined, // modelId provider.getSkillsManager(), + { availableToolNames: toolsResult.effectiveToolNames }, ) return systemPrompt From 3022bb6a66a364d50ffaf423214aef41fadd0e8a Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Mon, 31 Aug 2026 01:37:36 +0900 Subject: [PATCH 04/15] fix(prompts): gate environment tool guidance Thread effective tool names through generated environment details and remove list_files and update_todo_list hints when those tools are unavailable. Reuse the prepared policy for normal requests, resumed tasks, and manual or automatic condensation, with focused regression coverage. Signed-off-by: JunyongParkDev --- .../__tests__/getEnvironmentDetails.spec.ts | 27 +++++++++ src/core/environment/getEnvironmentDetails.ts | 20 +++++-- .../__tests__/responses-rooignore.spec.ts | 18 ++++++ src/core/prompts/responses.ts | 8 ++- src/core/task/Task.ts | 58 +++++++++++++------ src/core/task/__tests__/Task.spec.ts | 6 +- 6 files changed, 112 insertions(+), 25 deletions(-) diff --git a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts index df47e83c21..19d1b54c1d 100644 --- a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts +++ b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts @@ -178,6 +178,8 @@ describe("getEnvironmentDetails", () => { false, mockCline.rooIgnoreController, false, + undefined, + true, ) }) @@ -187,6 +189,18 @@ describe("getEnvironmentDetails", () => { expect(formatResponse.formatFilesList).not.toHaveBeenCalled() }) + it("should not advertise list_files when it is unavailable", async () => { + mockProvider.getState.mockResolvedValue({ + ...mockState, + maxWorkspaceFiles: 0, + }) + + const result = await getEnvironmentDetails(mockCline as Task, true, new Set(["read_file"])) + + expect(result).toContain("Workspace files context disabled") + expect(result).not.toContain("list_files") + }) + it("should handle desktop directory specially", async () => { ;(arePathsEqual as Mock).mockReturnValue(true) const result = await getEnvironmentDetails(mockCline as Task, true) @@ -375,6 +389,19 @@ describe("getEnvironmentDetails", () => { expect(result).not.toContain("REMINDERS") }) + it("should not advertise update_todo_list when it is unavailable", async () => { + mockProvider.getState.mockResolvedValue({ + ...mockState, + apiConfiguration: { todoListEnabled: true }, + }) + const cline = { ...mockCline, todoList: [{ content: "test", status: "pending" }] } + + const result = await getEnvironmentDetails(cline as Task, false, new Set(["read_file"])) + + expect(result).not.toContain("REMINDERS") + expect(result).not.toContain("update_todo_list") + }) + it("should include REMINDERS section when todoListEnabled is undefined", async () => { mockProvider.getState.mockResolvedValue({ ...mockState, diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 0e7d18a57a..b1c32796e2 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -20,8 +20,14 @@ import { getGitStatus } from "../../utils/git" import { Task } from "../task/Task" import { formatReminderSection } from "./reminder" -export async function getEnvironmentDetails(cline: Task, includeFileDetails: boolean = false) { +export async function getEnvironmentDetails( + cline: Task, + includeFileDetails: boolean = false, + availableToolNames?: ReadonlySet, +) { let details = "" + const canListFiles = availableToolNames?.has("list_files") ?? true + const canUpdateTodoList = availableToolNames?.has("update_todo_list") ?? true const clineProvider = cline.providerRef.deref() const state = await clineProvider?.getState() @@ -233,13 +239,17 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo if (isDesktop) { // Don't want to immediately access desktop since it would show // permission popup. - details += "(Desktop files not shown automatically. Use list_files to explore if needed.)" + details += canListFiles + ? "(Desktop files not shown automatically. Use list_files to explore if needed.)" + : "(Desktop files not shown automatically.)" } else { const maxFiles = maxWorkspaceFiles ?? 200 // Early return for limit of 0 if (maxFiles === 0) { - details += "(Workspace files context disabled. Use list_files to explore if needed.)" + details += canListFiles + ? "(Workspace files context disabled. Use list_files to explore if needed.)" + : "(Workspace files context disabled.)" } else { try { const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles) @@ -251,6 +261,8 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo didHitLimit, cline.rooIgnoreController, showRooIgnoredFiles, + undefined, + canListFiles, ) details += result @@ -265,6 +277,6 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo state && typeof state.apiConfiguration?.todoListEnabled === "boolean" ? state.apiConfiguration.todoListEnabled : true - const reminderSection = todoListEnabled ? formatReminderSection(cline.todoList) : "" + const reminderSection = todoListEnabled && canUpdateTodoList ? formatReminderSection(cline.todoList) : "" return `\n${details.trim()}\n${reminderSection}\n` } diff --git a/src/core/prompts/__tests__/responses-rooignore.spec.ts b/src/core/prompts/__tests__/responses-rooignore.spec.ts index 03aae96776..17c6d83030 100644 --- a/src/core/prompts/__tests__/responses-rooignore.spec.ts +++ b/src/core/prompts/__tests__/responses-rooignore.spec.ts @@ -193,6 +193,24 @@ describe("RooIgnore Response Formatting", () => { expect(result).toMatch(/use list_files on specific subdirectories/i) }) + it("should omit the list_files hint when the tool is unavailable", async () => { + const controller = new RooIgnoreController(TEST_CWD) + await controller.initialize() + + const result = formatResponse.formatFilesList( + TEST_CWD, + ["file1.txt", "file2.txt"], + true, + controller, + true, + undefined, + false, + ) + + expect(result).toContain("File list truncated") + expect(result).not.toContain("list_files") + }) + /** * Tests formatFilesList handles empty results */ diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 60b5b4123a..7ef969c93c 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -121,6 +121,7 @@ Otherwise, if you have not completed the task and do not need additional informa rooIgnoreController: RooIgnoreController | undefined, showRooIgnoredFiles: boolean, rooProtectedController?: RooProtectedController, + includeListFilesHint: boolean = true, ): string => { const sorted = files .map((file) => { @@ -180,9 +181,10 @@ Otherwise, if you have not completed the task and do not need additional informa } } if (didHitLimit) { - return `${rooIgnoreParsed.join( - "\n", - )}\n\n(File list truncated. Use list_files on specific subdirectories if you need to explore further.)` + const hint = includeListFilesHint + ? " Use list_files on specific subdirectories if you need to explore further." + : "" + return `${rooIgnoreParsed.join("\n")}\n\n(File list truncated.${hint})` } else if (rooIgnoreParsed.length === 0 || (rooIgnoreParsed.length === 1 && rooIgnoreParsed[0] === "")) { return "No files found." } else { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index c2750b95f7..a072f3c15b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1759,7 +1759,7 @@ export class Task extends EventEmitter implements TaskLike { : {}), } // Generate environment details to include in the condensed summary - const environmentDetails = await getEnvironmentDetails(this, true) + const environmentDetails = await getEnvironmentDetails(this, true, toolsResult.effectiveToolNames) const filesReadByRoo = await this.getFilesReadByRooSafely("condenseContext") @@ -2658,7 +2658,13 @@ export class Task extends EventEmitter implements TaskLike { // Add environment details to the existing last user message (which contains the tool_result) // This avoids creating a new user message which would cause consecutive user messages - const environmentDetails = await getEnvironmentDetails(this, true) + await this.safeEnsureModelFetched() + const resolvedPromptTools = await this.resolvePromptTools() + const environmentDetails = await getEnvironmentDetails( + this, + true, + resolvedPromptTools.toolsResult.effectiveToolNames, + ) let lastUserMsgIndex = -1 for (let i = this.apiConversationHistory.length - 1; i >= 0; i--) { if (this.apiConversationHistory[i].role === "user") { @@ -2855,7 +2861,16 @@ export class Task extends EventEmitter implements TaskLike { } } - const environmentDetails = await getEnvironmentDetails(this, currentIncludeFileDetails) + await this.safeEnsureModelFetched() + const supportsAllowedFunctionNames = this.apiConfiguration?.apiProvider === providerIdentifiers.gemini + const resolvedPromptTools = await this.resolvePromptTools({ + includeAllToolsWithRestrictions: supportsAllowedFunctionNames, + }) + const environmentDetails = await getEnvironmentDetails( + this, + currentIncludeFileDetails, + resolvedPromptTools.toolsResult.effectiveToolNames, + ) // Remove any existing environment_details blocks before adding fresh ones. // This prevents duplicate environment details when resuming tasks, @@ -3008,8 +3023,6 @@ export class Task extends EventEmitter implements TaskLike { await this.diffViewProvider.reset() - await this.safeEnsureModelFetched() - // Cache model info once per API request to avoid repeated calls during streaming // This is especially important for tools and background usage collection this.cachedStreamingModel = this.api.getModel() @@ -3019,7 +3032,10 @@ export class Task extends EventEmitter implements TaskLike { // Yields only if the first chunk is successful, otherwise will // allow the user to retry the request (most likely due to rate // limit error, which gets thrown on the first chunk). - const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { skipProviderRateLimit: true }) + const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { + skipProviderRateLimit: true, + resolvedPromptTools, + }) let assistantMessage = "" let reasoningMessage = "" const pendingGroundingSources: GroundingSource[] = [] @@ -4188,7 +4204,11 @@ export class Task extends EventEmitter implements TaskLike { try { // Generate environment details to include in the condensed summary - const environmentDetails = await getEnvironmentDetails(this, true) + const environmentDetails = await getEnvironmentDetails( + this, + true, + resolvedPromptTools.toolsResult.effectiveToolNames, + ) // Force aggressive truncation by keeping only 75% of the conversation history const truncateResult = await manageContext({ @@ -4289,9 +4309,9 @@ export class Task extends EventEmitter implements TaskLike { public async *attemptApiRequest( retryAttempt: number = 0, - options: { skipProviderRateLimit?: boolean } = {}, + options: { skipProviderRateLimit?: boolean; resolvedPromptTools?: ResolvedPromptTools } = {}, ): ApiStream { - const state = await this.providerRef.deref()?.getState() + const state = options.resolvedPromptTools?.state ?? (await this.providerRef.deref()?.getState()) const { autoApprovalEnabled, @@ -4301,7 +4321,7 @@ export class Task extends EventEmitter implements TaskLike { profileThresholds = {}, } = state ?? {} // Use task-local values, not provider state, to prevent cross-task configuration leaks. - const mode = await this.getTaskMode() + const mode = options.resolvedPromptTools?.mode ?? (await this.getTaskMode()) const apiConfiguration = this.apiConfiguration // Get condensing configuration for automatic triggers. @@ -4320,14 +4340,18 @@ export class Task extends EventEmitter implements TaskLike { // in the caller. this.rateLimitClock.recordRequest() - await this.safeEnsureModelFetched() + if (!options.resolvedPromptTools) { + await this.safeEnsureModelFetched() + } const modelInfo = this.api.getModel().info const supportsAllowedFunctionNames = apiConfiguration?.apiProvider === providerIdentifiers.gemini - const resolvedPromptTools = await this.resolvePromptTools({ - state, - mode, - includeAllToolsWithRestrictions: supportsAllowedFunctionNames, - }) + const resolvedPromptTools = + options.resolvedPromptTools ?? + (await this.resolvePromptTools({ + state, + mode, + includeAllToolsWithRestrictions: supportsAllowedFunctionNames, + })) const resolvedState = resolvedPromptTools.state const allowedMcpServers = getModeBySlug(mode, resolvedState.customModes)?.allowedMcpServers this.currentRequestToolPolicy = { @@ -4417,7 +4441,7 @@ export class Task extends EventEmitter implements TaskLike { // getEnvironmentDetails(this, true) triggers a recursive workspace listing which // adds overhead - avoid this for the common case where context is below threshold. const contextMgmtEnvironmentDetails = contextManagementWillRun - ? await getEnvironmentDetails(this, true) + ? await getEnvironmentDetails(this, true, resolvedPromptTools.toolsResult.effectiveToolNames) : undefined // Get files read by Roo for code folding - only when context management will run diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 5f6e0a8ed8..89af2070e9 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -21,6 +21,7 @@ import { Task } from "../Task" import { SYSTEM_PROMPT } from "../../prompts/system" import { createRateLimitClock } from "../RateLimitClock" import { summarizeConversation } from "../../condense" +import { getEnvironmentDetails } from "../../environment/getEnvironmentDetails" import { ClineProvider } from "../../webview/ClineProvider" import { ApiStreamChunk } from "../../../api/transform/stream" import { ContextProxy } from "../../config/ContextProxy" @@ -639,6 +640,7 @@ describe("Cline", () => { ) expect(options.metadata?.mode).toBe("architect") expect(metadataToolNames).toEqual(promptToolNames) + expect(vi.mocked(getEnvironmentDetails)).toHaveBeenCalledWith(task, true, promptToolNames) }) it("uses the task mode in request metadata when focused provider state differs", async () => { @@ -3286,7 +3288,9 @@ describe("Cline", () => { mode: undefined, }) const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") - vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { + vi.spyOn(task, "attemptApiRequest").mockImplementation((_retryAttempt, options) => { + const environmentToolNames = vi.mocked(getEnvironmentDetails).mock.calls.at(-1)?.[2] + expect(environmentToolNames).toEqual(options?.resolvedPromptTools?.toolsResult.effectiveToolNames) throw new Error("stop after model metadata fetch") }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) From 16403c961bdd69e3e621925a01cc2f12f5ca07cc Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Mon, 31 Aug 2026 01:38:54 +0900 Subject: [PATCH 05/15] test(tools): cover built-in mode policy Add table-driven coverage for Code, Debug, Architect, Ask, and Orchestrator. Assert each built-in mode's command, read, list, and edit capabilities so future policy changes cannot silently reintroduce unavailable tool guidance. Signed-off-by: JunyongParkDev --- src/core/task/__tests__/build-tools.spec.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/core/task/__tests__/build-tools.spec.ts b/src/core/task/__tests__/build-tools.spec.ts index 2f2e75f12c..071fb87dbe 100644 --- a/src/core/task/__tests__/build-tools.spec.ts +++ b/src/core/task/__tests__/build-tools.spec.ts @@ -51,6 +51,28 @@ function createProvider(mcpHub: McpHub): Pick { + it.each([ + { mode: "code", hasCommand: true, hasRead: true, hasEdit: true }, + { mode: "debug", hasCommand: true, hasRead: true, hasEdit: true }, + { mode: "architect", hasCommand: false, hasRead: true, hasEdit: true }, + { mode: "ask", hasCommand: false, hasRead: true, hasEdit: false }, + { mode: "orchestrator", hasCommand: false, hasRead: false, hasEdit: false }, + ])("resolves the built-in $mode mode tool policy", async ({ mode, hasCommand, hasRead, hasEdit }) => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(createMcpHub(false)), + cwd: "/test/path", + mode, + customModes: undefined, + experiments: {}, + apiConfiguration, + }) + + expect(result.effectiveToolNames.has("execute_command")).toBe(hasCommand) + expect(result.effectiveToolNames.has("read_file")).toBe(hasRead) + expect(result.effectiveToolNames.has("list_files")).toBe(hasRead) + expect(result.effectiveToolNames.has("write_to_file")).toBe(hasEdit) + }) + it("returns canonical names for the request's logical tool set", async () => { const result = await buildNativeToolsArrayWithRestrictions({ provider: createProvider(createMcpHub(false)), From 293ed6c55897f5f5b4b15dbffa6d2fdd66cb94bd Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Tue, 1 Sep 2026 01:48:22 +0900 Subject: [PATCH 06/15] test(prompts): cover effective tool guidance branches Exercise rules with complete, command-only, restricted, and empty effective tool sets. Verify tool-use sections disappear when a request has no callable tools so patch coverage protects the prompt/runtime policy contract. Signed-off-by: JunyongParkDev --- src/core/prompts/__tests__/sections.spec.ts | 44 +++++++++++++++++++ .../__tests__/tool-use-guidelines.spec.ts | 6 +++ .../sections/__tests__/tool-use.spec.ts | 6 +++ 3 files changed, 56 insertions(+) diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index e7c86da409..38f7948639 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -192,6 +192,50 @@ describe("getRulesSection", () => { expect(result).not.toContain("Actively Running Terminals") expect(result).toContain('The active mode can only edit files matching "\\.md$" (Markdown files only)') }) + + it("varies tool guidance with the effective tool set", () => { + const fullResult = getRulesSection( + cwd, + { + todoListEnabled: true, + useAgentRules: true, + newTaskRequireTodos: false, + isStealthModel: true, + }, + { + availableToolNames: new Set([ + "execute_command", + "list_files", + "read_file", + "write_to_file", + "ask_followup_question", + "attempt_completion", + "access_mcp_resource", + ]), + }, + ) + const commandOnlyResult = getRulesSection(cwd, undefined, { + availableToolNames: new Set(["execute_command"]), + }) + const restrictedResult = getRulesSection(cwd, undefined, { + availableToolNames: new Set(["write_to_file"]), + editFileRestriction: { fileRegex: "\\.md$" }, + }) + const noToolsResult = getRulesSection(cwd, undefined, { + availableToolNames: new Set(), + }) + + expect(fullResult).toContain("Before using the execute_command tool") + expect(fullResult).toContain("list_files tool to list the files") + expect(fullResult).toContain("shouldn't use the read_file tool") + expect(fullResult).toContain("must use the attempt_completion tool") + expect(fullResult).toContain("MCP operations should be used one at a time") + expect(fullResult).toContain("VENDOR CONFIDENTIALITY") + expect(commandOnlyResult).not.toContain("ask_followup_question") + expect(restrictedResult).toContain('The active mode can only edit files matching "\\.md$".') + expect(noToolsResult).not.toContain("Use the tools provided") + expect(noToolsResult).not.toContain("wait for the user's response after each tool use") + }) }) describe("getCommandChainOperator", () => { diff --git a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts index 6d1f4b3fbf..64349bcb09 100644 --- a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts +++ b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts @@ -36,4 +36,10 @@ describe("getToolUseGuidelinesSection", () => { expect(guidelines).not.toContain("After each tool use, the user will respond with the result") }) + + it("omits tool guidance when no tools are available", () => { + const guidelines = getToolUseGuidelinesSection({ availableToolNames: new Set() }) + + expect(guidelines).toBe("") + }) }) diff --git a/src/core/prompts/sections/__tests__/tool-use.spec.ts b/src/core/prompts/sections/__tests__/tool-use.spec.ts index 878db81a1c..55db61ce72 100644 --- a/src/core/prompts/sections/__tests__/tool-use.spec.ts +++ b/src/core/prompts/sections/__tests__/tool-use.spec.ts @@ -28,4 +28,10 @@ describe("getSharedToolUseSection", () => { expect(section).not.toContain("") expect(section).not.toContain("") }) + + it("omits tool-use instructions when no tools are available", () => { + const section = getSharedToolUseSection({ availableToolNames: new Set() }) + + expect(section).toBe("") + }) }) From 8b34eb08da9c4fda51643e70b727c41f4ad53004 Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Tue, 1 Sep 2026 02:06:58 +0900 Subject: [PATCH 07/15] test(visual): update chat token count snapshot Refresh the extension-host chat baseline after effective tool guidance reduces the Ask mode system prompt token count from 4.2k to 3.2k. Signed-off-by: JunyongParkDev --- .../electron-chat-dark-sidebar.png | Bin 30867 -> 30937 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png index cb69da51e546a37537df7ec2fabfe60064a6f316..220bb103b946505db904b3ffde4a6db8865c050c 100644 GIT binary patch literal 30937 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 ONmP|I6^j(^68{$n%}A>N From 3a19e660746c6ee2d200808fae04ed471943c79d Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Tue, 1 Sep 2026 09:13:53 +0900 Subject: [PATCH 08/15] test(prompts): cover remaining effective tool policy branches Exercise reduced and restricted tool sets across prompt sections, environment details, MCP filtering, and system prompt previews. Remove unreachable prompt fallbacks and align the tool requirement type with its existing boolean-disable behavior so each policy path is directly testable. Signed-off-by: JunyongParkDev --- .../__tests__/getEnvironmentDetails.spec.ts | 10 ++++ src/core/prompts/__tests__/sections.spec.ts | 47 +++++++++++++++++++ .../prompts/__tests__/system-prompt.spec.ts | 7 +++ .../__tests__/markdown-formatting.spec.ts | 13 +++++ .../__tests__/mode-instructions.spec.ts | 32 +++++++++++++ .../sections/__tests__/objective.spec.ts | 23 +++++++++ .../sections/__tests__/system-info.spec.ts | 14 ++++++ src/core/prompts/sections/capabilities.ts | 6 +-- src/core/prompts/system.ts | 14 ++++-- .../__tests__/filter-tools-for-mode.spec.ts | 20 +++++++- .../tools/__tests__/validateToolUse.spec.ts | 5 ++ src/core/tools/validateToolUse.ts | 4 +- .../webview/__tests__/ClineProvider.spec.ts | 44 +++++++++++++---- 13 files changed, 219 insertions(+), 20 deletions(-) create mode 100644 src/core/prompts/sections/__tests__/markdown-formatting.spec.ts diff --git a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts index 19d1b54c1d..6b430b59b9 100644 --- a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts +++ b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts @@ -208,6 +208,16 @@ describe("getEnvironmentDetails", () => { expect(listFiles).not.toHaveBeenCalled() }) + it("should not advertise list_files for the desktop when it is unavailable", async () => { + ;(arePathsEqual as Mock).mockReturnValue(true) + + const result = await getEnvironmentDetails(mockCline as Task, true, new Set(["read_file"])) + + expect(result).toContain("(Desktop files not shown automatically.)") + expect(result).not.toContain("Use list_files") + expect(listFiles).not.toHaveBeenCalled() + }) + it("should skip file listing when maxWorkspaceFiles is 0", async () => { mockProvider.getState.mockResolvedValue({ ...mockState, diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index 38f7948639..fa6cb8a757 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -122,6 +122,41 @@ describe("getCapabilitiesSection", () => { expect(withoutMcpOperation).not.toContain("MCP servers") expect(withMcpOperation).toContain("MCP servers") }) + + it("describes command, listing, search, and unrestricted edit capabilities", () => { + const result = getCapabilitiesSection(cwd, undefined, undefined, { + availableToolNames: new Set(["execute_command", "list_files", "search_files", "write_to_file"]), + }) + + expect(result).toContain("execute CLI commands") + expect(result).toContain("list files") + expect(result).toContain("search source code") + expect(result).toContain("write and edit files") + expect(result).toContain("you can use the list_files tool") + expect(result).toContain("You can use the execute_command tool") + }) + + it("describes edit restrictions using their description or regex", () => { + const withDescription = getCapabilitiesSection(cwd, undefined, undefined, { + availableToolNames: new Set(["apply_patch"]), + editFileRestriction: { fileRegex: "\\.md$", description: "Markdown files only" }, + }) + const withRegexOnly = getCapabilitiesSection(cwd, undefined, undefined, { + availableToolNames: new Set(["apply_patch"]), + editFileRestriction: { fileRegex: "\\.ts$" }, + }) + + expect(withDescription).toContain("edit files matching Markdown files only") + expect(withRegexOnly).toContain("edit files matching \\.ts$") + }) + + it("omits the capability summary when no tools are available", () => { + const result = getCapabilitiesSection(cwd, undefined, undefined, { + availableToolNames: new Set(), + }) + + expect(result).not.toContain("- You have access to tools that let you") + }) }) describe("getRulesSection", () => { @@ -236,6 +271,18 @@ describe("getRulesSection", () => { expect(noToolsResult).not.toContain("Use the tools provided") expect(noToolsResult).not.toContain("wait for the user's response after each tool use") }) + + it("includes PowerShell chaining guidance when command execution is available", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ) + + const result = getRulesSection(cwd, undefined, { + availableToolNames: new Set(["execute_command"]), + }) + + expect(result).toContain("Note: Using `;` for PowerShell command chaining") + }) }) describe("getCommandChainOperator", () => { diff --git a/src/core/prompts/__tests__/system-prompt.spec.ts b/src/core/prompts/__tests__/system-prompt.spec.ts index 7c5dd8b6b5..8231f178b1 100644 --- a/src/core/prompts/__tests__/system-prompt.spec.ts +++ b/src/core/prompts/__tests__/system-prompt.spec.ts @@ -612,6 +612,13 @@ describe("SYSTEM_PROMPT", () => { expect(prompt).not.toContain("write and edit files") }) + it("should fall back to the default mode configuration for an unknown mode", async () => { + const prompt = await generatePromptWithTools(["read_file"], "unknown-mode") + + expect(prompt).toContain(modes[0].roleDefinition) + expect(prompt).toContain("read files") + }) + it("should apply Architect tool availability and edit restrictions to built-in instructions", async () => { const prompt = await generatePromptWithTools( ["read_file", "write_to_file", "ask_followup_question", "attempt_completion"], diff --git a/src/core/prompts/sections/__tests__/markdown-formatting.spec.ts b/src/core/prompts/sections/__tests__/markdown-formatting.spec.ts new file mode 100644 index 0000000000..e305069da7 --- /dev/null +++ b/src/core/prompts/sections/__tests__/markdown-formatting.spec.ts @@ -0,0 +1,13 @@ +import { markdownFormattingSection } from "../markdown-formatting" + +describe("markdownFormattingSection", () => { + it("references attempt_completion only when it is available", () => { + const withCompletion = markdownFormattingSection({ + availableToolNames: new Set(["attempt_completion"]), + }) + const withoutCompletion = markdownFormattingSection({ availableToolNames: new Set() }) + + expect(withCompletion).toContain("and ALSO those in attempt_completion") + expect(withoutCompletion).not.toContain("attempt_completion") + }) +}) diff --git a/src/core/prompts/sections/__tests__/mode-instructions.spec.ts b/src/core/prompts/sections/__tests__/mode-instructions.spec.ts index b68c66827e..97b5ed8318 100644 --- a/src/core/prompts/sections/__tests__/mode-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/mode-instructions.spec.ts @@ -46,4 +46,36 @@ describe("getBuiltInModeInstructions", () => { expect(result).not.toContain("access external resources") }) + + it("preserves Ask's external-resource claim when an MCP operation is available", () => { + const withResourceTool = getBuiltInModeInstructions("ask", getInstructions("ask"), { + availableToolNames: new Set(["access_mcp_resource"]), + }) + const withDynamicTool = getBuiltInModeInstructions("ask", getInstructions("ask"), { + availableToolNames: new Set(["mcp--test-server--search"]), + }) + + expect(withResourceTool).toContain("access external resources") + expect(withDynamicTool).toContain("access external resources") + }) + + it("leaves other built-in modes unchanged", () => { + const instructions = getInstructions("code") + + expect( + getBuiltInModeInstructions("code", instructions, { + availableToolNames: new Set(), + }), + ).toBe(instructions) + }) + + it("leaves Architect instructions unchanged when all referenced tools are available", () => { + const instructions = getInstructions("architect") + + expect( + getBuiltInModeInstructions("architect", instructions, { + availableToolNames: new Set(["update_todo_list", "switch_mode", "write_to_file"]), + }), + ).toBe(instructions) + }) }) diff --git a/src/core/prompts/sections/__tests__/objective.spec.ts b/src/core/prompts/sections/__tests__/objective.spec.ts index f776a326d2..a08a7e0cb2 100644 --- a/src/core/prompts/sections/__tests__/objective.spec.ts +++ b/src/core/prompts/sections/__tests__/objective.spec.ts @@ -44,4 +44,27 @@ describe("getObjectiveSection", () => { expect(objective).toContain("OBJECTIVE") expect(objective).toContain("You accomplish a given task iteratively") }) + + it("omits tool-specific steps when no tools are available", () => { + const objective = getObjectiveSection({ availableToolNames: new Set() }) + + expect(objective).not.toContain("utilizing available tools") + expect(objective).not.toContain("use attempt_completion") + }) + + it("uses non-interactive parameter guidance when follow-up questions are unavailable", () => { + const objective = getObjectiveSection({ availableToolNames: new Set(["read_file"]) }) + + expect(objective).toContain("DO NOT invoke the tool, including with filler values") + expect(objective).not.toContain("ask_followup_question tool") + }) + + it("includes question and completion guidance only when those tools are available", () => { + const objective = getObjectiveSection({ + availableToolNames: new Set(["ask_followup_question", "attempt_completion"]), + }) + + expect(objective).toContain("ask_followup_question tool") + expect(objective).toContain("use attempt_completion to present the result") + }) }) diff --git a/src/core/prompts/sections/__tests__/system-info.spec.ts b/src/core/prompts/sections/__tests__/system-info.spec.ts index 749b53a0fd..3a1f12d416 100644 --- a/src/core/prompts/sections/__tests__/system-info.spec.ts +++ b/src/core/prompts/sections/__tests__/system-info.spec.ts @@ -63,4 +63,18 @@ describe("getSystemInfoSection", () => { expect(result).toContain("Operating System: win32 10.0.19043") }) + + it("includes workspace guidance only for available tools", () => { + mockOsName.mockReturnValue("Ubuntu 22.04") + + const withTools = getSystemInfoSection(mockCwd, { + availableToolNames: new Set(["execute_command", "list_files"]), + }) + const withoutTools = getSystemInfoSection(mockCwd, { availableToolNames: new Set() }) + + expect(withTools).toContain("New terminals will be created") + expect(withTools).toContain("you can use the list_files tool") + expect(withoutTools).not.toContain("New terminals will be created") + expect(withoutTools).not.toContain("you can use the list_files tool") + }) }) diff --git a/src/core/prompts/sections/capabilities.ts b/src/core/prompts/sections/capabilities.ts index 89c806d32c..76897d13a7 100644 --- a/src/core/prompts/sections/capabilities.ts +++ b/src/core/prompts/sections/capabilities.ts @@ -61,10 +61,8 @@ CAPABILITIES const hasEditTool = hasAnyPromptTool(context, FILE_EDIT_TOOL_NAMES) const hasImageTool = hasTool("generate_image") const hasQuestionTool = hasTool("ask_followup_question") - const hasMcpOperations = context - ? context.availableToolNames.has("access_mcp_resource") || - Array.from(context.availableToolNames).some(isMcpTool) - : hasMcpServers + const hasMcpOperations = + context.availableToolNames.has("access_mcp_resource") || Array.from(context.availableToolNames).some(isMcpTool) const capabilities: string[] = [] if (hasCommandTool) capabilities.push("execute CLI commands on the user's computer") diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 046017350b..a92122afe1 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -2,7 +2,15 @@ import * as vscode from "vscode" import { type ModeConfig, type PromptComponent, type CustomModePrompts, type TodoItem } from "@roo-code/types" -import { Mode, modes, defaultModeSlug, getModeBySlug, getGroupName, getModeSelection } from "../../shared/modes" +import { + Mode, + modes, + defaultModeSlug, + getModeBySlug, + getModeConfig, + getGroupName, + getModeSelection, +} from "../../shared/modes" import { DiffStrategy } from "../../shared/tools" import { formatLanguage } from "../../shared/language" import { isEmpty } from "../../utils/object" @@ -64,7 +72,7 @@ async function generatePrompt( // Get the full mode config to ensure we have the role definition (used for groups, etc.) const builtInMode = modes.find((candidate) => candidate.slug === mode) - const modeConfig = getModeBySlug(mode, customModeConfigs) || builtInMode || modes[0] + const modeConfig = getModeConfig(mode, customModeConfigs) const { roleDefinition, baseInstructions } = getModeSelection(mode, promptComponent, customModeConfigs) const editGroup = modeConfig.groups.find((groupEntry) => getGroupName(groupEntry) === "edit") const editFileRestriction = @@ -77,7 +85,7 @@ async function generatePrompt( const effectivePromptContext = promptContext ? { availableToolNames: promptContext.availableToolNames, - ...(editFileRestriction ? { editFileRestriction } : {}), + editFileRestriction, } : undefined const hasUserAuthoredModeInstructions = diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index bc3cd0a360..88fa907f1a 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -2,7 +2,7 @@ import type OpenAI from "openai" -import { filterNativeToolsForMode } from "../filter-tools-for-mode" +import { filterMcpToolsForMode, filterNativeToolsForMode } from "../filter-tools-for-mode" function makeTool(name: string): OpenAI.Chat.ChatCompletionTool { return { @@ -227,3 +227,21 @@ describe("filterNativeToolsForMode - access_mcp_resource allowlist", () => { }) }) }) + +describe("filterMcpToolsForMode", () => { + it("drops malformed and explicitly disabled MCP tools", () => { + // Provider input is validated at runtime, so exercise the guard with a tool + // that is deliberately missing the statically required function payload. + const malformedTool = { type: "function" } as OpenAI.Chat.ChatCompletionTool + const result = filterMcpToolsForMode( + [malformedTool, makeTool("mcp--server--allowed"), makeTool("mcp--server--blocked")], + "code", + undefined, + undefined, + { disabledTools: ["mcp--server--blocked"] }, + ) + + expect(result).toHaveLength(1) + expect("function" in result[0] ? result[0].function?.name : undefined).toBe("mcp--server--allowed") + }) +}) diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index 78bb8a2b24..1802cb94d6 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -136,6 +136,11 @@ describe("mode-validator", () => { }) describe("tool requirements", () => { + it("disables every non-required tool when requirements are false", () => { + expect(isToolAllowedForMode("read_file", codeMode, [], false)).toBe(false) + expect(isToolAllowedForMode("attempt_completion", codeMode, [], false)).toBe(true) + }) + it("respects tool requirements when provided", () => { const requirements = { apply_diff: false } expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(false) diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index 6817f044fe..62f47c6569 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -33,7 +33,7 @@ export function validateToolUse( toolName: ToolName, mode: Mode, customModes?: ModeConfig[], - toolRequirements?: Record, + toolRequirements?: Record | false, toolParams?: Record, experiments?: Record, includedTools?: string[], @@ -121,7 +121,7 @@ export function isToolAllowedForMode( tool: string, modeSlug: string, customModes: ModeConfig[], - toolRequirements?: Record, + toolRequirements?: Record | false, toolParams?: Record, // All tool parameters experiments?: Record, includedTools?: string[], // Opt-in tools explicitly included (e.g., from modelInfo) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index c9e1d95df0..9ec4182a4b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2219,16 +2219,19 @@ describe("ClineProvider", () => { test("uses cached model policy when full model metadata fetch fails", async () => { const { buildApiHandler } = await import("../../../api") - const fallbackHandler = buildApiHandler({ apiProvider: providerIdentifiers.openrouter }) - fallbackHandler.ensureModelFetched = vi.fn().mockRejectedValue(new Error("fetch failed")) - vi.mocked(fallbackHandler.getModel).mockReturnValue({ - id: "cached-model", - info: { - contextWindow: 128_000, - supportsPromptCache: false, - excludedTools: ["execute_command"], - }, - }) + const fallbackHandler = { + ensureModelFetched: vi.fn().mockRejectedValue(new Error("fetch failed")), + createMessage: vi.fn(), + countTokens: vi.fn(), + getModel: vi.fn().mockReturnValue({ + id: "cached-model", + info: { + contextWindow: 128_000, + supportsPromptCache: false, + excludedTools: ["execute_command"], + }, + }), + } as ReturnType vi.mocked(buildApiHandler).mockReturnValueOnce(fallbackHandler) const providerState = await provider.getState() @@ -2250,6 +2253,27 @@ describe("ClineProvider", () => { ) }) + test("generates a preview when the temporary API handler cannot be created", async () => { + const { buildApiHandler } = await import("../../../api") + const error = new Error("handler creation failed") + vi.mocked(buildApiHandler).mockImplementationOnce(() => { + throw error + }) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + + const handler = getMessageHandler() + await handler({ type: "getSystemPrompt", mode: "code" }) + + expect(errorSpy).toHaveBeenCalledWith("Error reading model info for system prompt preview:", error) + expect(buildNativeToolsArrayWithRestrictions).toHaveBeenCalledWith( + expect.objectContaining({ modelInfo: undefined }), + ) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "systemPrompt", text: "mocked system prompt" }), + ) + errorSpy.mockRestore() + }) + test("uses code mode custom instructions", async () => { await provider.resolveWebviewView(mockWebviewView) From 495abf9375cc1d0b9292794ff6e9c3f2996c7646 Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Tue, 1 Sep 2026 09:14:21 +0900 Subject: [PATCH 09/15] test(task): cover request tool policy fallback paths Verify native and MCP tool validation against request snapshots, live-state fallbacks, mode resolution, model inclusions, and custom mode descriptions. Cover delegation resume, MCP hub failure handling, resolved policy reuse, allowlist snapshots, and Gemini context-management metadata. Signed-off-by: JunyongParkDev --- ...tantMessage-tool-usage-attribution.spec.ts | 203 +++++++++++++++- src/core/task/__tests__/Task.spec.ts | 228 +++++++++++++++++- 2 files changed, 429 insertions(+), 2 deletions(-) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts index 7d03e8695e..b66efd4fa4 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -5,7 +5,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest" import { presentAssistantMessage } from "../presentAssistantMessage" import { validateToolUse } from "../../tools/validateToolUse" import { useMcpToolTool } from "../../tools/UseMcpToolTool" -import { getModeBySlug } from "../../../shared/modes" +import { defaultModeSlug, getModeBySlug } from "../../../shared/modes" import type { CurrentRequestToolPolicy, Task } from "../../task/Task" vi.mock("../../task/Task") @@ -181,6 +181,58 @@ describe("presentAssistantMessage - tool usage attribution", () => { expect(vi.mocked(validateToolUse).mock.calls[0][3]).toMatchObject({ read_file: false }) }) + it("uses task mode before live state when no request policy exists", async () => { + mockTask.getTaskMode = vi.fn().mockResolvedValue("architect") + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }), + }), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_task_mode", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(vi.mocked(validateToolUse).mock.calls[0][1]).toBe("architect") + }) + + it("defaults fallback validation and resolves model-included tool aliases", async () => { + mockTask.api.getModel = () => ({ + id: "test-model", + info: { includedTools: ["search_and_replace"] }, + }) + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({}), + }), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_default_policy", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + const validationCall = vi.mocked(validateToolUse).mock.calls[0] + expect(validationCall[1]).toBe(defaultModeSlug) + expect(validationCall[2]).toEqual([]) + expect(validationCall[6]).toEqual(["edit"]) + }) + it("uses the request policy without reading the live focused mode", async () => { mockTask.getCurrentRequestToolPolicy = () => ({ effectiveToolNames: new Set(["read_file", "attempt_completion"]), @@ -347,7 +399,156 @@ describe("presentAssistantMessage - tool usage attribution", () => { expect(mockTask.recordToolUsage).not.toHaveBeenCalled() }) + it.each([ + ["custom-mode", "Custom Mode"], + ["missing-mode", "missing-mode"], + ])("describes skipped new_task calls using the resolved mode name for %s", async (mode, expectedName) => { + mockTask.didRejectTool = true + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [ + { + slug: "custom-mode", + name: "Custom Mode", + roleDefinition: "Custom role", + groups: ["read"], + }, + ], + }), + }), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: `call_new_task_${mode}`, + name: "new_task", + params: { mode, message: "Delegate work" }, + nativeArgs: { mode, message: "Delegate work" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(mockTask.pushToolResultToUserContent).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining(`[new_task in ${expectedName} mode`) }), + ) + }) + describe("native mcp_tool_use block", () => { + it("passes partial MCP blocks through without final validation", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_partial", + name: "mcp--test-server--search", + serverName: "test-server", + toolName: "search", + arguments: {}, + partial: true, + }, + ] + + await presentMockTask(mockTask) + + expect(handleSpy).toHaveBeenCalledTimes(1) + expect(validateToolUse).not.toHaveBeenCalled() + handleSpy.mockRestore() + }) + + it("applies fallback disablement before executing an MCP block", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.api.getModel = () => ({ + id: "test-model", + info: { excludedTools: ["mcp--blocked-server--blocked-tool"] }, + }) + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + disabledTools: ["read_file"], + mcpEnabled: false, + }), + getMcpHub: () => undefined, + }), + } + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_fallback_blocked", + name: "mcp--blocked-server--blocked-tool", + serverName: "blocked-server", + toolName: "blocked-tool", + arguments: {}, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(handleSpy).not.toHaveBeenCalled() + expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool", expect.any(String)) + handleSpy.mockRestore() + }) + + it("uses the task mode for fallback MCP validation", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.getTaskMode = vi.fn().mockResolvedValue("architect") + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({}), + getMcpHub: () => undefined, + }), + } + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_task_mode", + name: "mcp--test-server--search", + serverName: "test-server", + toolName: "search", + arguments: {}, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(vi.mocked(validateToolUse).mock.calls[0][1]).toBe("architect") + expect(vi.mocked(validateToolUse).mock.calls[0][2]).toEqual([]) + expect(handleSpy).toHaveBeenCalledTimes(1) + handleSpy.mockRestore() + }) + + it("uses the default mode for fallback MCP validation", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({}), + getMcpHub: () => undefined, + }), + } + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_default_mode", + name: "mcp--test-server--search", + serverName: "test-server", + toolName: "search", + arguments: {}, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(vi.mocked(validateToolUse).mock.calls[0][1]).toBe(defaultModeSlug) + expect(handleSpy).toHaveBeenCalledTimes(1) + handleSpy.mockRestore() + }) + it("blocks an MCP tool absent from the request policy", async () => { const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) mockTask.getCurrentRequestToolPolicy = () => ({ diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 89af2070e9..0af3e331af 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6,6 +6,7 @@ import * as path from "path" import * as vscode from "vscode" import { Anthropic } from "@anthropic-ai/sdk" import type { Mock } from "vitest" +import pWaitFor from "p-wait-for" import { providerIdentifiers, @@ -14,6 +15,7 @@ import { type ProviderSettings, type ModelInfo, type TaskLike, + type ModeConfig, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -28,9 +30,28 @@ import { ContextProxy } from "../../config/ContextProxy" import { processUserContentMentions } from "../../mentions/processUserContentMentions" import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" import type { ApiMessage } from "../../task-persistence" +import { McpServerManager } from "../../../services/mcp/McpServerManager" +import { defaultModeSlug } from "../../../shared/modes" + +type TestPromptTools = { + state: ProviderState + mode?: string + mcpHub: undefined + toolsResult: { + tools: unknown[] + effectiveToolNames: Set + allowedFunctionNames?: string[] + } +} type TaskTestAccess = { - getSystemPrompt: () => Promise + getSystemPrompt: (resolved?: TestPromptTools) => Promise + getMcpHubForPrompt: (state: ProviderState) => Promise + resolvePromptTools: (options?: { + state?: ProviderState + mode?: string + includeAllToolsWithRestrictions?: boolean + }) => Promise getEnabledMcpToolsCount: () => Promise<{ enabledToolCount: number; enabledServerCount: number }> initiateTaskLoop: (userContent: Anthropic.Messages.ContentBlockParam[]) => Promise startTask: (task?: string, images?: string[]) => Promise @@ -41,6 +62,7 @@ type TaskTestAccess = { saveClineMessages: () => Promise safeEnsureModelFetched: () => Promise addToApiConversationHistory: (message: unknown, reasoning?: string) => Promise + saveApiConversationHistory: () => Promise } type TaskAskResult = Awaited> @@ -723,6 +745,198 @@ describe("Cline", () => { }) }) + describe("effective prompt tool resolution", () => { + it("refreshes model and tool policy before resuming after delegation", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + task.apiConversationHistory = [ + { + role: "user", + content: [ + { type: "text", text: "delegation result" }, + { type: "text", text: "stale" }, + ], + ts: Date.now(), + }, + ] + const state = await mockProvider.getState() + const effectiveToolNames = new Set(["read_file"]) + const taskAccess = getTaskTestAccess(task) + const ensureSpy = vi.spyOn(taskAccess, "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(taskAccess, "resolvePromptTools").mockResolvedValue({ + state, + mode: "code", + mcpHub: undefined, + toolsResult: { tools: [], effectiveToolNames }, + }) + vi.mocked(getEnvironmentDetails).mockResolvedValueOnce("fresh") + const saveSpy = vi.spyOn(taskAccess, "saveApiConversationHistory").mockResolvedValue(true) + const loopSpy = vi.spyOn(taskAccess, "initiateTaskLoop").mockResolvedValue(undefined) + + await task.resumeAfterDelegation() + + expect(ensureSpy).toHaveBeenCalledOnce() + expect(getEnvironmentDetails).toHaveBeenCalledWith(task, true, effectiveToolNames) + expect(task.apiConversationHistory[0].content).toEqual([ + { type: "text", text: "delegation result" }, + { type: "text", text: "fresh" }, + ]) + expect(saveSpy).toHaveBeenCalledOnce() + expect(loopSpy).toHaveBeenCalledWith([]) + }) + + it("reports a lost provider while resolving tools", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + Object.defineProperty(task, "providerRef", { value: { deref: () => undefined } }) + + await expect(getTaskTestAccess(task).resolvePromptTools()).rejects.toThrow("Provider not available") + }) + + it("reports a lost provider while building the system prompt", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const state = await mockProvider.getState() + Object.defineProperty(task, "providerRef", { value: { deref: () => undefined } }) + + await expect( + getTaskTestAccess(task).getSystemPrompt({ + state, + mode: "code", + mcpHub: undefined, + toolsResult: { tools: [], effectiveToolNames: new Set() }, + }), + ).rejects.toThrow("Provider not available") + }) + + it("uses the default mode when a resolved prompt policy has no mode", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const state = await mockProvider.getState() + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await getTaskTestAccess(task).getSystemPrompt({ + state, + mode: undefined, + mcpHub: undefined, + toolsResult: { tools: [], effectiveToolNames: new Set() }, + }) + + expect(requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1))[5]).toBe(defaultModeSlug) + }) + + it("reports a lost provider before loading the MCP hub", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const state = { ...(await mockProvider.getState()), mcpEnabled: true } + Object.defineProperty(task, "providerRef", { value: { deref: () => undefined } }) + + await expect(getTaskTestAccess(task).getMcpHubForPrompt(state)).rejects.toThrow( + "Provider reference lost during view transition", + ) + }) + + it("reports a missing MCP hub from the server manager", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const state = { ...(await mockProvider.getState()), mcpEnabled: true } + const getInstanceSpy = vi.spyOn(McpServerManager, "getInstance").mockResolvedValueOnce(undefined as never) + + await expect(getTaskTestAccess(task).getMcpHubForPrompt(state)).rejects.toThrow( + "Failed to get MCP hub from server manager", + ) + getInstanceSpy.mockRestore() + }) + + it("returns the MCP hub after logging a connection timeout", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const state = { ...(await mockProvider.getState()), mcpEnabled: true } + const mcpHub = { isConnecting: true } + const getInstanceSpy = vi.spyOn(McpServerManager, "getInstance").mockResolvedValueOnce(mcpHub as never) + vi.mocked(pWaitFor).mockRejectedValueOnce(new Error("timeout")) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + + await expect(getTaskTestAccess(task).getMcpHubForPrompt(state)).resolves.toBe(mcpHub) + expect(errorSpy).toHaveBeenCalledWith("MCP servers failed to connect in time") + errorSpy.mockRestore() + getInstanceSpy.mockRestore() + }) + + it("reuses a resolved policy and snapshots its MCP allowlist for the request", async () => { + const mode = "restricted-mcp" + const customModes: ModeConfig[] = [ + { + slug: mode, + name: "Restricted MCP", + roleDefinition: "Restricted MCP role", + groups: ["read", "mcp"], + allowedMcpServers: ["allowed-server"], + }, + ] + const state = { + ...(await mockProvider.getState()), + mode, + mcpEnabled: false, + autoApprovalEnabled: true, + requestDelaySeconds: 0, + customModes, + } + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const resolvedPromptTools = await taskAccess.resolvePromptTools({ state, mode }) + const ensureSpy = vi.spyOn(taskAccess, "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(taskAccess, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(task.api, "createMessage").mockReturnValue( + (async function* () { + yield { type: "text", text: "response" } as ApiStreamChunk + })(), + ) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + + await task.attemptApiRequest(0, { resolvedPromptTools } as never).next() + + expect(ensureSpy).not.toHaveBeenCalled() + expect(task.getCurrentRequestToolPolicy()?.allowedMcpServers).toEqual(["allowed-server"]) + }) + }) + describe("sayAndCreateMissingParamError", () => { it("surfaces a localized error notice and returns the missing-parameter tool error for both relPath branches", async () => { const cline = new Task({ @@ -2581,11 +2795,19 @@ describe("Cline", () => { id: requireDefined(mockApiConfig.apiModelId), info: { contextWindow: 200000, maxTokens: 4096 } as ModelInfo, }) + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 190000, + }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration, autoApprovalEnabled: true, + autoCondenseContext: true, + autoCondenseContextPercent: 80, requestDelaySeconds: 0, }) const mockStream = (async function* () { @@ -2611,6 +2833,10 @@ describe("Cline", () => { expect(tools.length).toBeGreaterThan(0) expect(allowedFunctionNames.length).toBeGreaterThan(0) expect(allowedFunctionNames.every((name) => toolNames.includes(name))).toBe(true) + expect(summarizeConversation).toHaveBeenCalled() + const [contextOptions] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) + expect(contextOptions.metadata?.tools).toEqual(tools) + expect(contextOptions.metadata?.allowedFunctionNames).toEqual(allowedFunctionNames) }) it("should invoke abort on currentRequestAbortController during first-chunk wait", async () => { From ae16eefca8e291554e38162c9535748e652e809c Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Tue, 1 Sep 2026 09:41:19 +0900 Subject: [PATCH 10/15] test(policy): strengthen tool availability regressions Cover partial and unset prompt contexts, required lifecycle tools, and MCP filtering boundaries highlighted during review. Move the request-policy equality assertion outside the swallowed streaming error path so the test fails reliably when the values diverge. Signed-off-by: JunyongParkDev --- .../__tests__/mode-instructions.spec.ts | 6 +++++ .../sections/__tests__/system-info.spec.ts | 16 ++++++++++++ .../__tests__/tool-use-guidelines.spec.ts | 11 ++++++++ .../sections/__tests__/tool-use.spec.ts | 6 +++++ .../__tests__/filter-tools-for-mode.spec.ts | 26 +++++++++++++++++++ src/core/task/__tests__/Task.spec.ts | 8 ++++-- .../tools/__tests__/validateToolUse.spec.ts | 8 +++++- 7 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/core/prompts/sections/__tests__/mode-instructions.spec.ts b/src/core/prompts/sections/__tests__/mode-instructions.spec.ts index 97b5ed8318..5d554e57d4 100644 --- a/src/core/prompts/sections/__tests__/mode-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/mode-instructions.spec.ts @@ -78,4 +78,10 @@ describe("getBuiltInModeInstructions", () => { }), ).toBe(instructions) }) + + it("leaves built-in instructions unchanged when no prompt context is provided", () => { + const instructions = getInstructions("architect") + + expect(getBuiltInModeInstructions("architect", instructions)).toBe(instructions) + }) }) diff --git a/src/core/prompts/sections/__tests__/system-info.spec.ts b/src/core/prompts/sections/__tests__/system-info.spec.ts index 3a1f12d416..814083b6fe 100644 --- a/src/core/prompts/sections/__tests__/system-info.spec.ts +++ b/src/core/prompts/sections/__tests__/system-info.spec.ts @@ -77,4 +77,20 @@ describe("getSystemInfoSection", () => { expect(withoutTools).not.toContain("New terminals will be created") expect(withoutTools).not.toContain("you can use the list_files tool") }) + + it("gates terminal and directory guidance independently", () => { + mockOsName.mockReturnValue("Ubuntu 22.04") + + const withCommandOnly = getSystemInfoSection(mockCwd, { + availableToolNames: new Set(["execute_command"]), + }) + const withListOnly = getSystemInfoSection(mockCwd, { + availableToolNames: new Set(["list_files"]), + }) + + expect(withCommandOnly).toContain("New terminals will be created") + expect(withCommandOnly).not.toContain("you can use the list_files tool") + expect(withListOnly).not.toContain("New terminals will be created") + expect(withListOnly).toContain("you can use the list_files tool") + }) }) diff --git a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts index 64349bcb09..ab3085af65 100644 --- a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts +++ b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts @@ -42,4 +42,15 @@ describe("getToolUseGuidelinesSection", () => { expect(guidelines).toBe("") }) + + it.each(["list_files", "execute_command"])( + "omits the list-files comparison when only %s is available", + (availableToolName) => { + const guidelines = getToolUseGuidelinesSection({ + availableToolNames: new Set([availableToolName]), + }) + + expect(guidelines).not.toContain("running a command like `ls`") + }, + ) }) diff --git a/src/core/prompts/sections/__tests__/tool-use.spec.ts b/src/core/prompts/sections/__tests__/tool-use.spec.ts index 55db61ce72..a298996f89 100644 --- a/src/core/prompts/sections/__tests__/tool-use.spec.ts +++ b/src/core/prompts/sections/__tests__/tool-use.spec.ts @@ -34,4 +34,10 @@ describe("getSharedToolUseSection", () => { expect(section).toBe("") }) + + it("keeps tool-use instructions when a supplied context has an available tool", () => { + const section = getSharedToolUseSection({ availableToolNames: new Set(["read_file"]) }) + + expect(section).toContain("TOOL USE") + }) }) diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index 88fa907f1a..38bc33fd70 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -229,6 +229,32 @@ describe("filterNativeToolsForMode - access_mcp_resource allowlist", () => { }) describe("filterMcpToolsForMode", () => { + it("drops MCP tools when the mode does not allow MCP", () => { + const result = filterMcpToolsForMode([makeTool("mcp--server--search")], "orchestrator", undefined, undefined) + + expect(result).toEqual([]) + }) + + it("drops MCP tools excluded by the model", () => { + const result = filterMcpToolsForMode( + [makeTool("mcp--server--allowed"), makeTool("mcp--server--excluded")], + "code", + undefined, + undefined, + { + modelInfo: { + contextWindow: 200_000, + supportsPromptCache: false, + excludedTools: ["mcp--server--excluded"], + }, + }, + ) + + expect(result.map((tool) => ("function" in tool ? tool.function?.name : undefined))).toEqual([ + "mcp--server--allowed", + ]) + }) + it("drops malformed and explicitly disabled MCP tools", () => { // Provider input is validated at runtime, so exercise the guard with a tool // that is deliberately missing the statically required function payload. diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 0af3e331af..0866a998f1 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -3514,9 +3514,11 @@ describe("Cline", () => { mode: undefined, }) const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") + let observedEnvironmentToolNames: unknown + let observedPolicyToolNames: unknown vi.spyOn(task, "attemptApiRequest").mockImplementation((_retryAttempt, options) => { - const environmentToolNames = vi.mocked(getEnvironmentDetails).mock.calls.at(-1)?.[2] - expect(environmentToolNames).toEqual(options?.resolvedPromptTools?.toolsResult.effectiveToolNames) + observedEnvironmentToolNames = vi.mocked(getEnvironmentDetails).mock.calls.at(-1)?.[2] + observedPolicyToolNames = options?.resolvedPromptTools?.toolsResult.effectiveToolNames throw new Error("stop after model metadata fetch") }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) @@ -3546,6 +3548,8 @@ describe("Cline", () => { const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "hello" }], false) expect(result).toBe(true) + expect(observedPolicyToolNames).toBeDefined() + expect(observedEnvironmentToolNames).toEqual(observedPolicyToolNames) expect(safeSpy).toHaveBeenCalled() expect(ensureModelFetched).toHaveBeenCalled() expect(task.cachedStreamingModel?.id).toBe(mockApiConfig.apiModelId) diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index 1802cb94d6..816d90c846 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -166,10 +166,16 @@ describe("mode-validator", () => { }) it("keeps lifecycle tools available while allowing optional control tools to be disabled", () => { - const requirements = { switch_mode: false, new_task: false, attempt_completion: false } + const requirements = { + switch_mode: false, + new_task: false, + attempt_completion: false, + ask_followup_question: false, + } expect(isToolAllowedForMode("switch_mode", codeMode, [], requirements)).toBe(false) expect(isToolAllowedForMode("new_task", codeMode, [], requirements)).toBe(false) expect(isToolAllowedForMode("attempt_completion", codeMode, [], requirements)).toBe(true) + expect(isToolAllowedForMode("ask_followup_question", codeMode, [], requirements)).toBe(true) expect(isToolAllowedForMode("new_task", "orchestrator", [], requirements)).toBe(true) }) }) From 7658dcb83aebf5adc14185bced4b642bd522cab2 Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Wed, 2 Sep 2026 00:36:40 +0900 Subject: [PATCH 11/15] fix(task): finalize request policy preparation failures Complete api_req_started with zero cost and streaming failure metadata when model or tool policy preparation fails, preventing the chat loading state from remaining active. Address review coverage for global MCP exclusions, request-scoped custom tool execution, skill gating, Architect numbering, and Set-valued policy assertions. Signed-off-by: JunyongParkDev --- ...tantMessage-tool-usage-attribution.spec.ts | 58 ++++++++++++ .../prompts/__tests__/system-prompt.spec.ts | 36 ++++++- .../__tests__/mode-instructions.spec.ts | 3 + .../__tests__/filter-tools-for-mode.spec.ts | 29 ++++++ src/core/task/Task.ts | 50 ++++++++-- src/core/task/__tests__/Task.spec.ts | 94 ++++++++++++++++--- src/core/task/__tests__/build-tools.spec.ts | 22 ++--- .../webview/__tests__/ClineProvider.spec.ts | 4 +- 8 files changed, 259 insertions(+), 37 deletions(-) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts index b66efd4fa4..572f62de18 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -1,6 +1,7 @@ // npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts import type { Anthropic } from "@anthropic-ai/sdk" +import { parametersSchema as z } from "@roo-code/types" import { describe, it, expect, beforeEach, vi } from "vitest" import { presentAssistantMessage } from "../presentAssistantMessage" import { validateToolUse } from "../../tools/validateToolUse" @@ -45,6 +46,7 @@ vi.mock("@roo-code/telemetry", () => ({ }, })) +import { customToolRegistry } from "@roo-code/core" import { TelemetryService } from "@roo-code/telemetry" interface MockTask { @@ -263,6 +265,62 @@ describe("presentAssistantMessage - tool usage attribution", () => { expect(mockTask.userMessageContent).toHaveLength(1) }) + it("uses the request policy experiment and mode for custom tool execution", async () => { + const parameters = z.object({ value: z.string() }) + const parseSpy = vi.spyOn(parameters, "parse") + const execute = vi.fn().mockResolvedValue("request-scoped custom result") + const getState = vi.fn().mockRejectedValue(new Error("live state should not be read")) + + vi.mocked(customToolRegistry.has).mockImplementation((name) => name === "request_scoped_tool") + vi.mocked(customToolRegistry.get).mockImplementation((name) => + name === "request_scoped_tool" + ? { + name, + description: "A request-scoped custom tool", + parameters, + execute, + } + : undefined, + ) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set(["request_scoped_tool"]), + mode: "architect", + customModes: [], + experiments: { customTools: true }, + }) + mockTask.providerRef = { + deref: () => ({ + getState, + }), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_request_scoped_custom_tool", + name: "request_scoped_tool", + params: { value: "from-request" }, + nativeArgs: { value: "from-request" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(getState).not.toHaveBeenCalled() + expect(customToolRegistry.get).toHaveBeenCalledWith("request_scoped_tool") + expect(parseSpy).toHaveBeenCalledWith({ value: "from-request" }) + expect(execute).toHaveBeenCalledWith( + { value: "from-request" }, + expect.objectContaining({ mode: "architect", task: mockTask }), + ) + expect(mockTask.userMessageContent).toEqual([ + expect.objectContaining({ + tool_use_id: "call_request_scoped_custom_tool", + content: "request-scoped custom result", + }), + ]) + }) + it("validates available tools with the request mode and effective included set", async () => { const effectiveToolNames = new Set(["read_file", "apply_patch", "attempt_completion"]) mockTask.getCurrentRequestToolPolicy = () => ({ diff --git a/src/core/prompts/__tests__/system-prompt.spec.ts b/src/core/prompts/__tests__/system-prompt.spec.ts index 8231f178b1..42649329cc 100644 --- a/src/core/prompts/__tests__/system-prompt.spec.ts +++ b/src/core/prompts/__tests__/system-prompt.spec.ts @@ -45,6 +45,7 @@ import { ModeConfig } from "@roo-code/types" import { SYSTEM_PROMPT } from "../system" import { McpHub } from "../../../services/mcp/McpHub" +import { SkillsManager } from "../../../services/skills/SkillsManager" import { defaultModeSlug, modes, Mode } from "../../../shared/modes" import "../../../utils/path" import { addCustomInstructions } from "../sections/custom-instructions" @@ -197,6 +198,7 @@ const generatePromptWithTools = ( mode: Mode = defaultModeSlug, customModePrompts?: Parameters[6], customModes?: ModeConfig[], + skillsManager?: SkillsManager, ) => SYSTEM_PROMPT( mockContext, @@ -214,7 +216,7 @@ const generatePromptWithTools = ( undefined, undefined, undefined, - undefined, + skillsManager, { availableToolNames: new Set(toolNames) }, ) @@ -612,6 +614,38 @@ describe("SYSTEM_PROMPT", () => { expect(prompt).not.toContain("write and edit files") }) + it("should include available skills only when the skill tool is available", async () => { + const skillsManager = Object.create(SkillsManager.prototype) as SkillsManager + vi.spyOn(skillsManager, "getSkillsForMode").mockReturnValue([ + { + name: "test-skill", + description: "A test skill for prompt composition", + path: "/skills/test-skill/SKILL.md", + source: "global", + }, + ]) + + const withoutSkillTool = await generatePromptWithTools( + ["read_file", "attempt_completion"], + defaultModeSlug, + undefined, + undefined, + skillsManager, + ) + const withSkillTool = await generatePromptWithTools( + ["read_file", "skill", "attempt_completion"], + defaultModeSlug, + undefined, + undefined, + skillsManager, + ) + + expect(withoutSkillTool).not.toContain("AVAILABLE SKILLS") + expect(withoutSkillTool).not.toContain("test-skill") + expect(withSkillTool).toContain("AVAILABLE SKILLS") + expect(withSkillTool).toContain("test-skill") + }) + it("should fall back to the default mode configuration for an unknown mode", async () => { const prompt = await generatePromptWithTools(["read_file"], "unknown-mode") diff --git a/src/core/prompts/sections/__tests__/mode-instructions.spec.ts b/src/core/prompts/sections/__tests__/mode-instructions.spec.ts index 5d554e57d4..da56ba805f 100644 --- a/src/core/prompts/sections/__tests__/mode-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/mode-instructions.spec.ts @@ -14,6 +14,9 @@ describe("getBuiltInModeInstructions", () => { expect(result).not.toContain("update_todo_list") expect(result).not.toContain("switch_mode") expect(result).toContain("write the plan to a markdown file") + + const stepNumbers = Array.from(result.matchAll(/^(\d+)\. /gm), (match) => Number(match[1])) + expect(stepNumbers).toEqual([1, 2, 3, 4, 5, 6]) }) it("uses a response plan when Architect has no edit tool", () => { diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index 38bc33fd70..d32e0af919 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -270,4 +270,33 @@ describe("filterMcpToolsForMode", () => { expect(result).toHaveLength(1) expect("function" in result[0] ? result[0].function?.name : undefined).toBe("mcp--server--allowed") }) + + const globallyDisabledMcpCases: [string, NonNullable[4]>][] = [ + ["disabledTools", { disabledTools: ["use_mcp_tool"] }], + [ + "modelInfo.excludedTools", + { + modelInfo: { + contextWindow: 200_000, + supportsPromptCache: false, + excludedTools: ["use_mcp_tool"], + }, + }, + ], + ] + + it.each(globallyDisabledMcpCases)( + "drops all MCP tools when %s globally disables MCP tool use", + (_source, settings) => { + const result = filterMcpToolsForMode( + [makeTool("mcp--server--first"), makeTool("mcp--server--second")], + "code", + undefined, + undefined, + settings, + ) + + expect(result).toEqual([]) + }, + ) }) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index a072f3c15b..7b440e1933 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2861,16 +2861,46 @@ export class Task extends EventEmitter implements TaskLike { } } - await this.safeEnsureModelFetched() - const supportsAllowedFunctionNames = this.apiConfiguration?.apiProvider === providerIdentifiers.gemini - const resolvedPromptTools = await this.resolvePromptTools({ - includeAllToolsWithRestrictions: supportsAllowedFunctionNames, - }) - const environmentDetails = await getEnvironmentDetails( - this, - currentIncludeFileDetails, - resolvedPromptTools.toolsResult.effectiveToolNames, - ) + let resolvedPromptTools: ResolvedPromptTools + let environmentDetails: string + try { + await this.safeEnsureModelFetched() + const supportsAllowedFunctionNames = this.apiConfiguration?.apiProvider === providerIdentifiers.gemini + resolvedPromptTools = await this.resolvePromptTools({ + includeAllToolsWithRestrictions: supportsAllowedFunctionNames, + }) + environmentDetails = await getEnvironmentDetails( + this, + currentIncludeFileDetails, + resolvedPromptTools.toolsResult.effectiveToolNames, + ) + } catch (error) { + const lastApiReqIndex = findLastIndex( + this.clineMessages, + (message) => message.say === "api_req_started", + ) + const rawErrorMessage = error instanceof Error ? error.message : String(error) + const streamingFailedMessage = `${t("common:interruption.streamTerminatedByProvider")}: ${rawErrorMessage}` + + if (lastApiReqIndex >= 0 && this.clineMessages[lastApiReqIndex]) { + const existingData = JSON.parse(this.clineMessages[lastApiReqIndex].text || "{}") + this.clineMessages[lastApiReqIndex].text = JSON.stringify({ + ...existingData, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + cost: 0, + cancelReason: "streaming_failed", + streamingFailedMessage, + } satisfies ClineApiReqInfo) + await this.saveClineMessages() + await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() + } + + console.error(`[Task#${this.taskId}.${this.instanceId}] Failed to prepare API request:`, error) + return true + } // Remove any existing environment_details blocks before adding fresh ones. // This prevents duplicate environment details when resuming tasks, diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 0866a998f1..9d6280365c 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -31,21 +31,21 @@ import { processUserContentMentions } from "../../mentions/processUserContentMen import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" import type { ApiMessage } from "../../task-persistence" import { McpServerManager } from "../../../services/mcp/McpServerManager" +import type { McpHub } from "../../../services/mcp/McpHub" import { defaultModeSlug } from "../../../shared/modes" +import type { BuildToolsResult } from "../build-tools" type TestPromptTools = { state: ProviderState - mode?: string - mcpHub: undefined - toolsResult: { - tools: unknown[] - effectiveToolNames: Set - allowedFunctionNames?: string[] - } + mode: string + mcpHub?: McpHub + toolsResult: BuildToolsResult } +type TestSystemPromptTools = Omit & { mode?: string } + type TaskTestAccess = { - getSystemPrompt: (resolved?: TestPromptTools) => Promise + getSystemPrompt: (resolved?: TestSystemPromptTools) => Promise getMcpHubForPrompt: (state: ProviderState) => Promise resolvePromptTools: (options?: { state?: ProviderState @@ -604,7 +604,7 @@ describe("Cline", () => { const [, , , , , mode, , , , , , , settings, , , , promptContext] = systemPromptCall expect(mode).toBe("architect") expect(settings).toMatchObject({ todoListEnabled: true }) - expect(promptContext?.availableToolNames).not.toContain("execute_command") + expect(promptContext?.availableToolNames.has("execute_command")).toBe(false) }) it("passes disabled tools through the effective prompt context", async () => { @@ -628,8 +628,8 @@ describe("Cline", () => { const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) const promptContext = systemPromptCall[16] - expect(promptContext?.availableToolNames).not.toContain("execute_command") - expect(promptContext?.availableToolNames).toContain("read_file") + expect(promptContext?.availableToolNames.has("execute_command")).toBe(false) + expect(promptContext?.availableToolNames.has("read_file")).toBe(true) }) it("uses the task mode when manually condensing after focused state changes", async () => { @@ -741,7 +741,7 @@ describe("Cline", () => { expect(promptToolNames).toEqual(requestPolicy.effectiveToolNames) expect(apiToolNames).toEqual(requestPolicy.effectiveToolNames) - expect(requestPolicy.effectiveToolNames).not.toContain("execute_command") + expect(requestPolicy.effectiveToolNames.has("execute_command")).toBe(false) }) }) @@ -801,6 +801,74 @@ describe("Cline", () => { await expect(getTaskTestAccess(task).resolvePromptTools()).rejects.toThrow("Provider not available") }) + it("finalizes api_req_started when request tool resolution fails", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const state = { + ...(await mockProvider.getState()), + mcpEnabled: false, + autoApprovalEnabled: true, + requestDelaySeconds: 0, + } + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + vi.mocked(processUserContentMentions).mockResolvedValueOnce({ + content: [{ type: "text", text: "hello" }], + mode: undefined, + }) + vi.spyOn(taskAccess, "safeEnsureModelFetched").mockResolvedValue(undefined) + const resolutionError = new Error("MCP hub unavailable") + vi.spyOn(taskAccess, "resolvePromptTools").mockRejectedValueOnce(resolutionError) + const saveSpy = vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) + const requestSpy = vi.spyOn(task, "attemptApiRequest") + const postStateSpy = vi.mocked(mockProvider.postStateToWebviewWithoutTaskHistory) + postStateSpy.mockClear() + task.clineMessages = [] + vi.spyOn(task, "say").mockImplementation(async (type, text) => { + if (type === "api_req_started") { + task.clineMessages.push({ + ts: Date.now(), + type: "say", + say: "api_req_started", + text, + }) + } + return undefined + }) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + + const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "hello" }], false) + + const requestMessage = requireDefined( + task.clineMessages + .slice() + .reverse() + .find((message) => message.say === "api_req_started"), + ) + expect(JSON.parse(requireDefined(requestMessage.text))).toMatchObject({ + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + cost: 0, + cancelReason: "streaming_failed", + streamingFailedMessage: expect.stringContaining("MCP hub unavailable"), + }) + expect(result).toBe(true) + expect(saveSpy).toHaveBeenCalledOnce() + expect(postStateSpy).toHaveBeenCalledOnce() + expect(requestSpy).not.toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to prepare API request"), + resolutionError, + ) + errorSpy.mockRestore() + }) + it("reports a lost provider while building the system prompt", async () => { const task = new Task({ provider: mockProvider, @@ -930,7 +998,7 @@ describe("Cline", () => { { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] - await task.attemptApiRequest(0, { resolvedPromptTools } as never).next() + await task.attemptApiRequest(0, { resolvedPromptTools }).next() expect(ensureSpy).not.toHaveBeenCalled() expect(task.getCurrentRequestToolPolicy()?.allowedMcpServers).toEqual(["allowed-server"]) diff --git a/src/core/task/__tests__/build-tools.spec.ts b/src/core/task/__tests__/build-tools.spec.ts index 071fb87dbe..773014d842 100644 --- a/src/core/task/__tests__/build-tools.spec.ts +++ b/src/core/task/__tests__/build-tools.spec.ts @@ -89,9 +89,9 @@ describe("buildNativeToolsArrayWithRestrictions", () => { }, }) - expect(result.effectiveToolNames).not.toContain("execute_command") - expect(result.effectiveToolNames).toContain("edit") - expect(result.effectiveToolNames).not.toContain("search_and_replace") + expect(result.effectiveToolNames.has("execute_command")).toBe(false) + expect(result.effectiveToolNames.has("edit")).toBe(true) + expect(result.effectiveToolNames.has("search_and_replace")).toBe(false) }) it("removes model-excluded tools from logical availability", async () => { @@ -109,8 +109,8 @@ describe("buildNativeToolsArrayWithRestrictions", () => { }, }) - expect(result.effectiveToolNames).not.toContain("execute_command") - expect(result.effectiveToolNames).toContain("read_file") + expect(result.effectiveToolNames.has("execute_command")).toBe(false) + expect(result.effectiveToolNames.has("read_file")).toBe(true) }) it("separates Gemini compatibility definitions from logical availability", async () => { @@ -126,7 +126,7 @@ describe("buildNativeToolsArrayWithRestrictions", () => { const sentToolNames = result.tools.flatMap((tool) => (tool.type === "function" ? [tool.function.name] : [])) expect(sentToolNames).toContain("execute_command") - expect(result.effectiveToolNames).not.toContain("execute_command") + expect(result.effectiveToolNames.has("execute_command")).toBe(false) expect(result.allowedFunctionNames).not.toContain("execute_command") }) @@ -151,9 +151,9 @@ describe("buildNativeToolsArrayWithRestrictions", () => { disabledTools: ["new_task"], }) - expect(codeResult.effectiveToolNames).toContain("ask_followup_question") - expect(codeResult.effectiveToolNames).toContain("attempt_completion") - expect(orchestratorResult.effectiveToolNames).toContain("new_task") + expect(codeResult.effectiveToolNames.has("ask_followup_question")).toBe(true) + expect(codeResult.effectiveToolNames.has("attempt_completion")).toBe(true) + expect(orchestratorResult.effectiveToolNames.has("new_task")).toBe(true) }) it("excludes MCP operations when MCP is globally disabled", async () => { @@ -167,7 +167,7 @@ describe("buildNativeToolsArrayWithRestrictions", () => { mcpEnabled: false, }) - expect(result.effectiveToolNames).not.toContain("access_mcp_resource") + expect(result.effectiveToolNames.has("access_mcp_resource")).toBe(false) expect(Array.from(result.effectiveToolNames).some((name) => name.startsWith("mcp--"))).toBe(false) }) @@ -183,7 +183,7 @@ describe("buildNativeToolsArrayWithRestrictions", () => { mcpEnabled: true, }) - expect(result.effectiveToolNames).toContain("access_mcp_resource") + expect(result.effectiveToolNames.has("access_mcp_resource")).toBe(true) expect(Array.from(result.effectiveToolNames).some((name) => name.startsWith("mcp--"))).toBe(false) }) }) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9ec4182a4b..cf3167fef7 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2213,8 +2213,8 @@ describe("ClineProvider", () => { ) const systemPromptCall = vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1) const promptContext = systemPromptCall?.[16] - expect(promptContext?.availableToolNames).not.toContain("execute_command") - expect(promptContext?.availableToolNames).toContain("read_file") + expect(promptContext?.availableToolNames.has("execute_command")).toBe(false) + expect(promptContext?.availableToolNames.has("read_file")).toBe(true) }) test("uses cached model policy when full model metadata fetch fails", async () => { From cc6afa6c2bb9c6d0b71ba7720d977ad6703d3080 Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Wed, 2 Sep 2026 01:01:03 +0900 Subject: [PATCH 12/15] fix(mcp): preserve wrapper validation errors Allow the standard use_mcp_tool compatibility call through request policy validation when the snapshot contains dynamic MCP tools, so server- and tool-specific checks can report actionable errors. Keep the wrapper blocked when no MCP tools are available and cover both branches with focused runtime tests. Signed-off-by: JunyongParkDev --- ...tantMessage-tool-usage-attribution.spec.ts | 68 +++++++++++++++++++ .../presentAssistantMessage.ts | 6 +- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts index 572f62de18..2898868111 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -321,6 +321,74 @@ describe("presentAssistantMessage - tool usage attribution", () => { ]) }) + it("allows the standard MCP wrapper when the request policy exposes a dynamic MCP tool", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set(["mcp--filesystem--read_file", "attempt_completion"]), + mode: "code", + customModes: [], + experiments: {}, + }) + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_standard_mcp_wrapper", + name: "use_mcp_tool", + params: { + server_name: "nonexistent-server", + tool_name: "read_file", + arguments: { path: "test.txt" }, + }, + nativeArgs: { + server_name: "nonexistent-server", + tool_name: "read_file", + arguments: { path: "test.txt" }, + }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(handleSpy).toHaveBeenCalledOnce() + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool") + handleSpy.mockRestore() + }) + + it("blocks the standard MCP wrapper when the request policy exposes no MCP tools", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set(["read_file", "attempt_completion"]), + mode: "code", + customModes: [], + experiments: {}, + }) + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_unavailable_standard_mcp_wrapper", + name: "use_mcp_tool", + params: { + server_name: "filesystem", + tool_name: "read_file", + arguments: { path: "test.txt" }, + }, + nativeArgs: { + server_name: "filesystem", + tool_name: "read_file", + arguments: { path: "test.txt" }, + }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(handleSpy).not.toHaveBeenCalled() + expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool", expect.any(String)) + handleSpy.mockRestore() + }) + it("validates available tools with the request mode and effective included set", async () => { const effectiveToolNames = new Set(["read_file", "apply_patch", "attempt_completion"]) mockTask.getCurrentRequestToolPolicy = () => ({ diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 2c67a91490..4a4ad1442d 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -679,7 +679,11 @@ export async function presentAssistantMessage(cline: Task) { return acc }, {}) const canonicalToolName = resolveToolAlias(block.name) - if (requestPolicy && !requestPolicy.effectiveToolNames.has(canonicalToolName)) { + const isAvailableInRequestPolicy = + requestPolicy?.effectiveToolNames.has(canonicalToolName) || + (canonicalToolName === "use_mcp_tool" && + Array.from(requestPolicy?.effectiveToolNames ?? []).some(isMcpTool)) + if (requestPolicy && !isAvailableInRequestPolicy) { throw new Error(`Tool "${block.name}" is not available for this request.`) } From 0adaf9628d15fd99b168d82ea45833576d3fafb3 Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Wed, 2 Sep 2026 23:18:55 +0900 Subject: [PATCH 13/15] fix(mcp): enforce wrapper request policy Validate the resolved MCP server and tool against the request-scoped effective tool set before approval or execution, closing the compatibility-wrapper exclusion bypass. Preserve specific unknown-server errors and cover allowed fuzzy names plus excluded sibling tools. Signed-off-by: JunyongParkDev --- src/core/tools/UseMcpToolTool.ts | 19 ++- .../tools/__tests__/useMcpToolTool.spec.ts | 109 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts index cd7c3d469d..90639cf972 100644 --- a/src/core/tools/UseMcpToolTool.ts +++ b/src/core/tools/UseMcpToolTool.ts @@ -4,7 +4,7 @@ import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" import type { ToolUse } from "../../shared/tools" -import { toolNamesMatch } from "../../utils/mcp-name" +import { buildMcpToolName, isMcpTool, toolNamesMatch } from "../../utils/mcp-name" import { BaseTool, ToolCallbacks } from "./BaseTool" import { ensureMcpServerAllowed } from "./mcpServerRestriction" @@ -75,6 +75,23 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { // This handles cases where models mangle hyphens to underscores const resolvedToolName = toolValidation.resolvedToolName ?? toolName + // The compatibility wrapper is admitted by presentAssistantMessage when any dynamic MCP + // tool is available so that unknown servers/tools can still return specific errors. Once + // the target resolves, enforce the exact request snapshot before approval or execution. + const requestPolicy = task.getCurrentRequestToolPolicy?.() + const canonicalToolName = buildMcpToolName(serverName, resolvedToolName) + const isAvailableForRequest = Array.from(requestPolicy?.effectiveToolNames ?? []).some( + (name) => isMcpTool(name) && toolNamesMatch(name, canonicalToolName), + ) + if (requestPolicy && !isAvailableForRequest) { + const errorMessage = `Tool "${canonicalToolName}" is not available for this request.` + task.consecutiveMistakeCount++ + task.recordToolError("use_mcp_tool", errorMessage) + task.didToolFailInCurrentTurn = true + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + // Reset mistake count on successful validation task.consecutiveMistakeCount = 0 diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 6af93be0f4..9f54441ceb 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -347,6 +347,115 @@ describe("useMcpToolTool", () => { }) }) + describe("request tool policy", () => { + it("allows the resolved MCP tool when it is in the request snapshot", async () => { + const callTool = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "Allowed result" }], + isError: false, + }) + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + getAllServers: () => [ + { + name: "test-server", + tools: [{ name: "allowed-tool", description: "Allowed tool" }], + }, + ], + callTool, + }), + postMessageToWebview: vi.fn(), + }) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set(["mcp--test-server--allowed-tool"]), + mode: "code", + customModes: [], + experiments: {}, + }) + mockAskApproval.mockResolvedValue(true) + const block: ToolUse<"use_mcp_tool"> = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test-server", + tool_name: "allowed_tool", + arguments: "{}", + }, + nativeArgs: { + server_name: "test-server", + tool_name: "allowed_tool", + arguments: {}, + }, + partial: false, + } + + await useMcpToolTool.handle(mockTask as Task, block, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockAskApproval).toHaveBeenCalledOnce() + expect(callTool).toHaveBeenCalledWith("test-server", "allowed-tool", {}) + expect(mockTask.recordToolError).not.toHaveBeenCalled() + }) + + it("blocks an existing MCP tool excluded from the request snapshot", async () => { + const callTool = vi.fn() + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + getAllServers: () => [ + { + name: "test-server", + tools: [ + { name: "allowed-tool", description: "Allowed tool" }, + { name: "excluded-tool", description: "Excluded tool" }, + ], + }, + ], + callTool, + }), + postMessageToWebview: vi.fn(), + }) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set(["mcp--test-server--allowed-tool"]), + mode: "code", + customModes: [], + experiments: {}, + }) + const block: ToolUse<"use_mcp_tool"> = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test-server", + tool_name: "excluded-tool", + arguments: "{}", + }, + nativeArgs: { + server_name: "test-server", + tool_name: "excluded-tool", + arguments: {}, + }, + partial: false, + } + + await useMcpToolTool.handle(mockTask as Task, block, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.recordToolError).toHaveBeenCalledWith( + "use_mcp_tool", + 'Tool "mcp--test-server--excluded-tool" is not available for this request.', + ) + expect(mockPushToolResult).toHaveBeenCalledWith( + 'Tool error: Tool "mcp--test-server--excluded-tool" is not available for this request.', + ) + expect(mockAskApproval).not.toHaveBeenCalled() + expect(callTool).not.toHaveBeenCalled() + }) + }) + describe("error handling", () => { it("should handle unexpected errors", async () => { const block: ToolUse = { From 62430efcaaa4fa9a2d79233f1168daba8529f52e Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Wed, 2 Sep 2026 23:34:36 +0900 Subject: [PATCH 14/15] fix(mcp): retain full tool identities Keep provider-compatible 64-character aliases while snapshotting untruncated MCP identities for request authorization, preventing colliding long names from bypassing the wrapper policy. Cover both policy construction and execution-time rejection for alias collisions. Signed-off-by: JunyongParkDev --- src/core/prompts/tools/native-tools/index.ts | 2 +- .../prompts/tools/native-tools/mcp_server.ts | 27 +++++++- src/core/task/__tests__/build-tools.spec.ts | 32 ++++++++++ src/core/task/build-tools.ts | 12 +++- src/core/tools/UseMcpToolTool.ts | 4 +- .../tools/__tests__/useMcpToolTool.spec.ts | 63 +++++++++++++++++++ src/utils/mcp-name.ts | 26 +++++--- 7 files changed, 151 insertions(+), 15 deletions(-) diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d6..2d699ec671 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -21,7 +21,7 @@ import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" import writeToFile from "./write_to_file" -export { getMcpServerTools } from "./mcp_server" +export { getMcpServerTools, getMcpServerToolsWithIdentity } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" export type { ReadFileToolOptions } from "./read_file" diff --git a/src/core/prompts/tools/native-tools/mcp_server.ts b/src/core/prompts/tools/native-tools/mcp_server.ts index 11e3906934..9687b95999 100644 --- a/src/core/prompts/tools/native-tools/mcp_server.ts +++ b/src/core/prompts/tools/native-tools/mcp_server.ts @@ -1,8 +1,13 @@ import type OpenAI from "openai" import { McpHub } from "../../../../services/mcp/McpHub" -import { buildMcpToolName } from "../../../../utils/mcp-name" +import { buildMcpToolIdentity, buildMcpToolName } from "../../../../utils/mcp-name" import { normalizeToolSchema, type JsonSchema } from "../../../../utils/json-schema" +export interface McpServerToolDefinition { + tool: OpenAI.Chat.ChatCompletionTool + identity: string +} + /** * Dynamically generates native tool definitions for all enabled tools across connected MCP servers. * Tools are deduplicated by name to prevent API errors. When the same server exists in both @@ -12,6 +17,19 @@ import { normalizeToolSchema, type JsonSchema } from "../../../../utils/json-sch * @returns An array of OpenAI.Chat.ChatCompletionTool definitions. */ export function getMcpServerTools(mcpHub?: McpHub, allowedServers?: string[]): OpenAI.Chat.ChatCompletionTool[] { + return getMcpServerToolsWithIdentity(mcpHub, allowedServers).map(({ tool }) => tool) +} + +/** + * Generates provider tool definitions together with their untruncated identities. + * The identities are retained separately because provider aliases are limited to + * 64 characters and are therefore not safe authorization keys. + * + * @param mcpHub The McpHub instance containing connected servers. + * @param allowedServers Optional server allowlist. + * @returns Deduplicated provider definitions paired with full MCP identities. + */ +export function getMcpServerToolsWithIdentity(mcpHub?: McpHub, allowedServers?: string[]): McpServerToolDefinition[] { if (!mcpHub) { return [] } @@ -23,7 +41,7 @@ export function getMcpServerTools(mcpHub?: McpHub, allowedServers?: string[]): O const allowSet = new Set(allowedServers) servers = servers.filter((s) => allowSet.has(s.name)) } - const tools: OpenAI.Chat.ChatCompletionTool[] = [] + const tools: McpServerToolDefinition[] = [] // Track seen tool names to prevent duplicates (e.g., when same server exists in both global and project configs) const seenToolNames = new Set() @@ -67,7 +85,10 @@ export function getMcpServerTools(mcpHub?: McpHub, allowedServers?: string[]): O }, } - tools.push(toolDefinition) + tools.push({ + tool: toolDefinition, + identity: buildMcpToolIdentity(server.name, tool.name), + }) } } diff --git a/src/core/task/__tests__/build-tools.spec.ts b/src/core/task/__tests__/build-tools.spec.ts index 773014d842..5ead5e81d7 100644 --- a/src/core/task/__tests__/build-tools.spec.ts +++ b/src/core/task/__tests__/build-tools.spec.ts @@ -3,6 +3,7 @@ import type * as vscode from "vscode" import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" import type { McpHub } from "../../../services/mcp/McpHub" +import { buildMcpToolIdentity, buildMcpToolName } from "../../../utils/mcp-name" import type { ClineProvider } from "../../webview/ClineProvider" import { buildNativeToolsArrayWithRestrictions } from "../build-tools" @@ -186,4 +187,35 @@ describe("buildNativeToolsArrayWithRestrictions", () => { expect(result.effectiveToolNames.has("access_mcp_resource")).toBe(true) expect(Array.from(result.effectiveToolNames).some((name) => name.startsWith("mcp--"))).toBe(false) }) + + it("keeps untruncated identities distinct when provider aliases collide", async () => { + const sharedPrefix = "long-tool-prefix-".repeat(4) + const firstToolName = `${sharedPrefix}first` + const collidingToolName = `${sharedPrefix}second` + const mcpHub = { + getServers: () => [ + { + name: "test-server", + tools: [ + { name: firstToolName, description: "First", enabledForPrompt: true }, + { name: collidingToolName, description: "Second", enabledForPrompt: true }, + ], + }, + ], + } as unknown as McpHub + + expect(buildMcpToolName("test-server", firstToolName)).toBe(buildMcpToolName("test-server", collidingToolName)) + + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(mcpHub), + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration, + }) + + expect(result.effectiveToolNames.has(buildMcpToolIdentity("test-server", firstToolName))).toBe(true) + expect(result.effectiveToolNames.has(buildMcpToolIdentity("test-server", collidingToolName))).toBe(false) + }) }) diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index 020fce28b1..062c18a22f 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -9,7 +9,7 @@ import type { ClineProvider } from "../webview/ClineProvider" import { getRooDirectoriesForCwd } from "../../services/roo-config/index.js" import { getModeBySlug, defaultModeSlug } from "../../shared/modes" -import { getNativeTools, getMcpServerTools } from "../prompts/tools/native-tools" +import { getNativeTools, getMcpServerToolsWithIdentity } from "../prompts/tools/native-tools" import { filterNativeToolsForMode, filterMcpToolsForMode, @@ -139,7 +139,8 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO ) // Filter MCP tools based on mode restrictions. - const allMcpTools = getMcpServerTools(mcpHub, allowedMcpServers) + const allMcpToolDefinitions = getMcpServerToolsWithIdentity(mcpHub, allowedMcpServers) + const allMcpTools = allMcpToolDefinitions.map(({ tool }) => tool) const mcpTools = mcpEnabled === false ? [] : allMcpTools const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, customModes, experiments, filterSettings) @@ -159,6 +160,13 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO // Combine filtered tools (for backward compatibility and for allowedFunctionNames) const filteredTools = [...filteredNativeTools, ...filteredMcpTools, ...nativeCustomTools] const effectiveToolNames = new Set(filteredTools.map((tool) => resolveToolAlias(getToolName(tool)))) + const mcpIdentityByTool = new Map(allMcpToolDefinitions.map(({ tool, identity }) => [tool, identity])) + for (const tool of filteredMcpTools) { + const identity = mcpIdentityByTool.get(tool) + if (identity) { + effectiveToolNames.add(identity) + } + } // If includeAllToolsWithRestrictions is true, return ALL tools but provide // allowed names based on mode filtering diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts index 90639cf972..55eb7d9ae0 100644 --- a/src/core/tools/UseMcpToolTool.ts +++ b/src/core/tools/UseMcpToolTool.ts @@ -4,7 +4,7 @@ import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" import type { ToolUse } from "../../shared/tools" -import { buildMcpToolName, isMcpTool, toolNamesMatch } from "../../utils/mcp-name" +import { buildMcpToolIdentity, isMcpTool, toolNamesMatch } from "../../utils/mcp-name" import { BaseTool, ToolCallbacks } from "./BaseTool" import { ensureMcpServerAllowed } from "./mcpServerRestriction" @@ -79,7 +79,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { // tool is available so that unknown servers/tools can still return specific errors. Once // the target resolves, enforce the exact request snapshot before approval or execution. const requestPolicy = task.getCurrentRequestToolPolicy?.() - const canonicalToolName = buildMcpToolName(serverName, resolvedToolName) + const canonicalToolName = buildMcpToolIdentity(serverName, resolvedToolName) const isAvailableForRequest = Array.from(requestPolicy?.effectiveToolNames ?? []).some( (name) => isMcpTool(name) && toolNamesMatch(name, canonicalToolName), ) diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 9f54441ceb..759445eb0c 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -3,6 +3,7 @@ import { useMcpToolTool } from "../UseMcpToolTool" import { Task } from "../../task/Task" import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" +import { buildMcpToolIdentity, buildMcpToolName } from "../../../utils/mcp-name" // Mock dependencies vi.mock("../../prompts/responses", () => ({ @@ -454,6 +455,68 @@ describe("useMcpToolTool", () => { expect(mockAskApproval).not.toHaveBeenCalled() expect(callTool).not.toHaveBeenCalled() }) + + it("blocks a long MCP tool whose provider alias collides with an allowed tool", async () => { + const sharedPrefix = "long-tool-prefix-".repeat(4) + const allowedToolName = `${sharedPrefix}allowed` + const collidingToolName = `${sharedPrefix}excluded` + const callTool = vi.fn() + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + getAllServers: () => [ + { + name: "test-server", + tools: [ + { name: allowedToolName, description: "Allowed tool" }, + { name: collidingToolName, description: "Excluded tool" }, + ], + }, + ], + callTool, + }), + postMessageToWebview: vi.fn(), + }) + expect(buildMcpToolName("test-server", allowedToolName)).toBe( + buildMcpToolName("test-server", collidingToolName), + ) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set([ + buildMcpToolName("test-server", allowedToolName), + buildMcpToolIdentity("test-server", allowedToolName), + ]), + mode: "code", + customModes: [], + experiments: {}, + }) + const block: ToolUse<"use_mcp_tool"> = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test-server", + tool_name: collidingToolName, + arguments: "{}", + }, + nativeArgs: { + server_name: "test-server", + tool_name: collidingToolName, + arguments: {}, + }, + partial: false, + } + + await useMcpToolTool.handle(mockTask as Task, block, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.recordToolError).toHaveBeenCalledWith( + "use_mcp_tool", + expect.stringContaining(`Tool "${buildMcpToolIdentity("test-server", collidingToolName)}"`), + ) + expect(mockAskApproval).not.toHaveBeenCalled() + expect(callTool).not.toHaveBeenCalled() + }) }) describe("error handling", () => { diff --git a/src/utils/mcp-name.ts b/src/utils/mcp-name.ts index 5f75f49c64..f7ec0923d4 100644 --- a/src/utils/mcp-name.ts +++ b/src/utils/mcp-name.ts @@ -115,21 +115,33 @@ export function sanitizeMcpName(name: string): string { } /** - * Build a full MCP tool function name from server and tool names. + * Builds the normalized, untruncated identity for an MCP server/tool pair. + * Provider-facing aliases may be truncated, so authorization checks must use + * this full identity to distinguish names that share the same alias prefix. + * + * @param serverName - The MCP server name + * @param toolName - The tool name + * @returns A sanitized identity in the format mcp--serverName--toolName + */ +export function buildMcpToolIdentity(serverName: string, toolName: string): string { + const sanitizedServer = sanitizeMcpName(serverName) + const sanitizedTool = sanitizeMcpName(toolName) + + return `${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}${sanitizedServer}${MCP_TOOL_SEPARATOR}${sanitizedTool}` +} + +/** + * Build an MCP tool function name for provider APIs. * The format is: mcp--{sanitized_server_name}--{sanitized_tool_name} * * The total length is capped at 64 characters to conform to API limits. * * @param serverName - The MCP server name * @param toolName - The tool name - * @returns A sanitized function name in the format mcp--serverName--toolName + * @returns A provider-compatible function name in the format mcp--serverName--toolName */ export function buildMcpToolName(serverName: string, toolName: string): string { - const sanitizedServer = sanitizeMcpName(serverName) - const sanitizedTool = sanitizeMcpName(toolName) - - // Build the full name: mcp--{server}--{tool} - const fullName = `${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}${sanitizedServer}${MCP_TOOL_SEPARATOR}${sanitizedTool}` + const fullName = buildMcpToolIdentity(serverName, toolName) // Truncate if necessary (max 64 chars for Gemini) if (fullName.length > 64) { From 107d6646957e5abeb0827002f395166a54f7f4c9 Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Thu, 3 Sep 2026 00:10:12 +0900 Subject: [PATCH 15/15] fix(mcp): make tool identities collision-free --- src/core/task/__tests__/build-tools.spec.ts | 21 ++---- src/core/tools/UseMcpToolTool.ts | 6 +- .../tools/__tests__/useMcpToolTool.spec.ts | 74 ++++++++++++++++++- src/utils/__tests__/mcp-name.spec.ts | 12 +++ src/utils/mcp-name.ts | 17 ++--- 5 files changed, 99 insertions(+), 31 deletions(-) diff --git a/src/core/task/__tests__/build-tools.spec.ts b/src/core/task/__tests__/build-tools.spec.ts index 5ead5e81d7..ebbf169107 100644 --- a/src/core/task/__tests__/build-tools.spec.ts +++ b/src/core/task/__tests__/build-tools.spec.ts @@ -1,6 +1,6 @@ import type * as vscode from "vscode" -import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" +import { providerIdentifiers, type McpTool, type ProviderSettings } from "@roo-code/types" import type { McpHub } from "../../../services/mcp/McpHub" import { buildMcpToolIdentity, buildMcpToolName } from "../../../utils/mcp-name" @@ -21,7 +21,7 @@ const apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.anthropic, } -function createMcpHub(withCapabilities: boolean): McpHub { +function createMcpHub(withCapabilities: boolean, tools?: McpTool[]): McpHub { return { getServers: () => withCapabilities @@ -29,7 +29,7 @@ function createMcpHub(withCapabilities: boolean): McpHub { { name: "test-server", resources: [{ uri: "test://resource", name: "Test Resource" }], - tools: [ + tools: tools ?? [ { name: "test-tool", description: "Test tool", @@ -192,17 +192,10 @@ describe("buildNativeToolsArrayWithRestrictions", () => { const sharedPrefix = "long-tool-prefix-".repeat(4) const firstToolName = `${sharedPrefix}first` const collidingToolName = `${sharedPrefix}second` - const mcpHub = { - getServers: () => [ - { - name: "test-server", - tools: [ - { name: firstToolName, description: "First", enabledForPrompt: true }, - { name: collidingToolName, description: "Second", enabledForPrompt: true }, - ], - }, - ], - } as unknown as McpHub + const mcpHub = createMcpHub(true, [ + { name: firstToolName, description: "First", enabledForPrompt: true }, + { name: collidingToolName, description: "Second", enabledForPrompt: true }, + ]) expect(buildMcpToolName("test-server", firstToolName)).toBe(buildMcpToolName("test-server", collidingToolName)) diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts index 55eb7d9ae0..2132e9897a 100644 --- a/src/core/tools/UseMcpToolTool.ts +++ b/src/core/tools/UseMcpToolTool.ts @@ -4,7 +4,7 @@ import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" import type { ToolUse } from "../../shared/tools" -import { buildMcpToolIdentity, isMcpTool, toolNamesMatch } from "../../utils/mcp-name" +import { buildMcpToolIdentity, toolNamesMatch } from "../../utils/mcp-name" import { BaseTool, ToolCallbacks } from "./BaseTool" import { ensureMcpServerAllowed } from "./mcpServerRestriction" @@ -80,9 +80,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { // the target resolves, enforce the exact request snapshot before approval or execution. const requestPolicy = task.getCurrentRequestToolPolicy?.() const canonicalToolName = buildMcpToolIdentity(serverName, resolvedToolName) - const isAvailableForRequest = Array.from(requestPolicy?.effectiveToolNames ?? []).some( - (name) => isMcpTool(name) && toolNamesMatch(name, canonicalToolName), - ) + const isAvailableForRequest = requestPolicy?.effectiveToolNames.has(canonicalToolName) ?? false if (requestPolicy && !isAvailableForRequest) { const errorMessage = `Tool "${canonicalToolName}" is not available for this request.` task.consecutiveMistakeCount++ diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 759445eb0c..e606693d2e 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -367,7 +367,7 @@ describe("useMcpToolTool", () => { postMessageToWebview: vi.fn(), }) mockTask.getCurrentRequestToolPolicy = () => ({ - effectiveToolNames: new Set(["mcp--test-server--allowed-tool"]), + effectiveToolNames: new Set([buildMcpToolIdentity("test-server", "allowed-tool")]), mode: "code", customModes: [], experiments: {}, @@ -418,7 +418,7 @@ describe("useMcpToolTool", () => { postMessageToWebview: vi.fn(), }) mockTask.getCurrentRequestToolPolicy = () => ({ - effectiveToolNames: new Set(["mcp--test-server--allowed-tool"]), + effectiveToolNames: new Set([buildMcpToolIdentity("test-server", "allowed-tool")]), mode: "code", customModes: [], experiments: {}, @@ -447,10 +447,10 @@ describe("useMcpToolTool", () => { expect(mockTask.recordToolError).toHaveBeenCalledWith( "use_mcp_tool", - 'Tool "mcp--test-server--excluded-tool" is not available for this request.', + `Tool "${buildMcpToolIdentity("test-server", "excluded-tool")}" is not available for this request.`, ) expect(mockPushToolResult).toHaveBeenCalledWith( - 'Tool error: Tool "mcp--test-server--excluded-tool" is not available for this request.', + `Tool error: Tool "${buildMcpToolIdentity("test-server", "excluded-tool")}" is not available for this request.`, ) expect(mockAskApproval).not.toHaveBeenCalled() expect(callTool).not.toHaveBeenCalled() @@ -517,6 +517,72 @@ describe("useMcpToolTool", () => { expect(mockAskApproval).not.toHaveBeenCalled() expect(callTool).not.toHaveBeenCalled() }) + + it.each([ + { + name: "hyphen and underscore names", + allowedToolName: "read_file", + excludedToolName: "read-file", + serverToolNames: ["read-file", "read_file"], + }, + { + name: "sanitized dotted and undotted names", + allowedToolName: "read.file", + excludedToolName: "readfile", + serverToolNames: ["read.file", "readfile"], + }, + ])("blocks distinct $name when only one identity is allowed", async (testCase) => { + const callTool = vi.fn() + mockProviderRef.deref.mockReturnValue({ + getMcpHub: () => ({ + getAllServers: () => [ + { + name: "test-server", + tools: testCase.serverToolNames.map((name) => ({ name, description: name })), + }, + ], + callTool, + }), + postMessageToWebview: vi.fn(), + }) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set([ + buildMcpToolName("test-server", testCase.allowedToolName), + buildMcpToolIdentity("test-server", testCase.allowedToolName), + ]), + mode: "code", + customModes: [], + experiments: {}, + }) + const block: ToolUse<"use_mcp_tool"> = { + type: "tool_use", + name: "use_mcp_tool", + params: { + server_name: "test-server", + tool_name: testCase.excludedToolName, + arguments: "{}", + }, + nativeArgs: { + server_name: "test-server", + tool_name: testCase.excludedToolName, + arguments: {}, + }, + partial: false, + } + + await useMcpToolTool.handle(mockTask as Task, block, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.recordToolError).toHaveBeenCalledWith( + "use_mcp_tool", + `Tool "${buildMcpToolIdentity("test-server", testCase.excludedToolName)}" is not available for this request.`, + ) + expect(mockAskApproval).not.toHaveBeenCalled() + expect(callTool).not.toHaveBeenCalled() + }) }) describe("error handling", () => { diff --git a/src/utils/__tests__/mcp-name.spec.ts b/src/utils/__tests__/mcp-name.spec.ts index 3bdc88c790..0c2d2dae7c 100644 --- a/src/utils/__tests__/mcp-name.spec.ts +++ b/src/utils/__tests__/mcp-name.spec.ts @@ -1,5 +1,6 @@ import { sanitizeMcpName, + buildMcpToolIdentity, buildMcpToolName, parseMcpToolName, normalizeMcpToolName, @@ -193,6 +194,17 @@ describe("mcp-name utilities", () => { }) }) + describe("buildMcpToolIdentity", () => { + it("keeps names distinct when provider sanitization collides", () => { + expect(buildMcpToolName("server", "read.file")).toBe(buildMcpToolName("server", "readfile")) + expect(buildMcpToolIdentity("server", "read.file")).not.toBe(buildMcpToolIdentity("server", "readfile")) + }) + + it("keeps hyphens and underscores distinct", () => { + expect(buildMcpToolIdentity("server", "read-file")).not.toBe(buildMcpToolIdentity("server", "read_file")) + }) + }) + describe("parseMcpToolName", () => { it("should parse valid mcp tool names with hyphen separators", () => { expect(parseMcpToolName("mcp--server--tool")).toEqual({ diff --git a/src/utils/mcp-name.ts b/src/utils/mcp-name.ts index f7ec0923d4..e519012f80 100644 --- a/src/utils/mcp-name.ts +++ b/src/utils/mcp-name.ts @@ -115,19 +115,16 @@ export function sanitizeMcpName(name: string): string { } /** - * Builds the normalized, untruncated identity for an MCP server/tool pair. - * Provider-facing aliases may be truncated, so authorization checks must use - * this full identity to distinguish names that share the same alias prefix. + * Builds a lossless internal identity for an MCP server/tool pair. + * Provider-facing aliases are sanitized and truncated, so authorization checks + * use length-prefixed raw names to keep every distinct pair collision-free. * * @param serverName - The MCP server name * @param toolName - The tool name - * @returns A sanitized identity in the format mcp--serverName--toolName + * @returns A collision-free, internal-only MCP identity */ export function buildMcpToolIdentity(serverName: string, toolName: string): string { - const sanitizedServer = sanitizeMcpName(serverName) - const sanitizedTool = sanitizeMcpName(toolName) - - return `${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}${sanitizedServer}${MCP_TOOL_SEPARATOR}${sanitizedTool}` + return `${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}identity:${serverName.length}:${serverName}${toolName.length}:${toolName}` } /** @@ -141,7 +138,9 @@ export function buildMcpToolIdentity(serverName: string, toolName: string): stri * @returns A provider-compatible function name in the format mcp--serverName--toolName */ export function buildMcpToolName(serverName: string, toolName: string): string { - const fullName = buildMcpToolIdentity(serverName, toolName) + const sanitizedServer = sanitizeMcpName(serverName) + const sanitizedTool = sanitizeMcpName(toolName) + const fullName = `${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}${sanitizedServer}${MCP_TOOL_SEPARATOR}${sanitizedTool}` // Truncate if necessary (max 64 chars for Gemini) if (fullName.length > 64) {