From 1c2fad9330666b30afac3bf866a0839325aaa481 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 1 Sep 2026 16:18:10 +0200 Subject: [PATCH 01/39] initial fix for issues #1240 and #505 --- ...resentAssistantMessage-custom-tool.spec.ts | 89 ++++ .../presentAssistantMessage.ts | 15 +- .../architect-mode-prompt.snap | 17 +- .../ask-mode-prompt.snap | 17 +- .../no-mcp-servers.snap | 17 +- .../consistent-system-prompt.snap | 17 +- .../system-prompt/with-mcp-hub-provided.snap | 19 +- .../system-prompt/with-undefined-mcp-hub.snap | 17 +- src/core/prompts/__tests__/sections.spec.ts | 413 +++++++++++++++--- .../prompts/__tests__/system-prompt.spec.ts | 143 +++++- .../sections/__tests__/objective.spec.ts | 21 +- .../prompts/sections/__tests__/skills.spec.ts | 32 +- .../sections/__tests__/system-info.spec.ts | 22 +- .../__tests__/tool-use-guidelines.spec.ts | 27 +- src/core/prompts/sections/capabilities.ts | 103 +++-- src/core/prompts/sections/objective.ts | 26 +- src/core/prompts/sections/rules.ts | 151 ++++++- src/core/prompts/sections/skills.ts | 6 + src/core/prompts/sections/system-info.ts | 24 +- .../prompts/sections/tool-use-guidelines.ts | 18 +- src/core/prompts/system.ts | 70 +-- .../__tests__/effective-tool-policy.spec.ts | 285 ++++++++++++ .../prompts/tools/effective-tool-policy.ts | 282 ++++++++++++ .../prompts/tools/filter-tools-for-mode.ts | 244 +++-------- src/core/task/Task.ts | 2 + src/core/task/__tests__/build-tools.spec.ts | 141 ++++++ .../__tests__/generateSystemPrompt.spec.ts | 226 ++++++++++ src/core/webview/generateSystemPrompt.ts | 10 +- 28 files changed, 2005 insertions(+), 449 deletions(-) create mode 100644 src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts create mode 100644 src/core/prompts/tools/effective-tool-policy.ts create mode 100644 src/core/task/__tests__/build-tools.spec.ts create mode 100644 src/core/webview/__tests__/generateSystemPrompt.spec.ts diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts index 1ef25e852b..fdf28c4e2b 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -23,6 +23,15 @@ vi.mock("@roo-code/core", () => ({ }, })) +// Mock the tool handlers so the tests only exercise validation (toolRequirements) +// and never the real tool execution logic. +vi.mock("../../tools/AttemptCompletionTool", () => ({ + attemptCompletionTool: { handle: vi.fn().mockResolvedValue(undefined) }, +})) +vi.mock("../../tools/AskFollowupQuestionTool", () => ({ + askFollowupQuestionTool: { handle: vi.fn().mockResolvedValue(undefined) }, +})) + // presentAssistantMessage records tool usage through TelemetryService.instance. vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { @@ -333,6 +342,86 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { edit: false, }) }) + + it("never marks a protocol tool (attempt_completion) as blocked", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "tool_call_protocol_123", + name: "attempt_completion", + params: {}, + nativeArgs: {}, + partial: false, + }, + ] + + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + experiments: { + customTools: false, + }, + disabledTools: ["attempt_completion"], + }), + }), + } + + await presentAssistantMessage(mockTask) + + const validateToolUseMock = vi.mocked(validateToolUse) + expect(validateToolUseMock).toHaveBeenCalled() + const toolRequirements = validateToolUseMock.mock.calls[0][3] + // Protocol tools never enter toolRequirements, so the validator cannot + // block them even when disabledTools lists them. + expect(toolRequirements).not.toHaveProperty("attempt_completion") + + // With validateToolUse mocked to return normally, the block proceeds + // past validation: no validation-error tool_result is pushed. + const errorToolResults = mockTask.userMessageContent.filter((block: unknown) => { + const b = block as { type?: string; is_error?: boolean } + return b.type === "tool_result" && b.is_error + }) + expect(errorToolResults).toEqual([]) + }) + + it("still marks ordinary tools (ask_followup_question) as blocked", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "tool_call_ordinary_123", + name: "ask_followup_question", + params: { question: "Which option?" }, + nativeArgs: { question: "Which option?" }, + partial: false, + }, + ] + + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + experiments: { + customTools: false, + }, + disabledTools: ["ask_followup_question"], + }), + }), + } + + await presentAssistantMessage(mockTask) + + const validateToolUseMock = vi.mocked(validateToolUse) + expect(validateToolUseMock).toHaveBeenCalled() + const toolRequirements = validateToolUseMock.mock.calls[0][3] + // Control/ordinary tools remain blockable — the inverse of the + // protocol-tool guarantee. + expect(toolRequirements).toMatchObject({ + ask_followup_question: false, + }) + }) }) describe("Partial blocks", () => { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7b25db4e66..4155552728 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -36,6 +36,7 @@ import { skillTool } from "../tools/SkillTool" import { generateImageTool } from "../tools/GenerateImageTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" +import { buildToolRequirements } from "../prompts/tools/effective-tool-policy" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" @@ -604,16 +605,10 @@ export async function presentAssistantMessage(cline: Task) { const isCustomTool = Boolean(stateExperiments?.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, - ) ?? {} + // Use the exported resolver so `attempt_completion` (and its aliases) + // never enters `toolRequirements` — the runtime validator never blocks a + // protocol tool. See `buildToolRequirements` in effective-tool-policy.ts. + const toolRequirements = buildToolRequirements(disabledTools) validateToolUse( block.name as ToolName, diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap index d6fd17ba2f..1c2bca6291 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap @@ -24,9 +24,9 @@ By carefully considering the user's response after tool executions, you can reac 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 ('/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. -- 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. +You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) +- These tools help you effectively accomplish a wide range of tasks. +- 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. ==== @@ -39,25 +39,20 @@ MODES RULES - The project base directory is: /test/path -- All file paths must be relative to this directory. 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 '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. +- All file paths must be relative to this directory. +- You are stuck operating from '/test/path', 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. -- 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 '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - 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. - 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. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" - 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. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. - 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. 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. -- 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. - 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. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - 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. - 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. -- 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. ==== @@ -69,7 +64,7 @@ Default Shell: /bin/zsh Home Directory: /home/user Current Workspace Directory: /test/path -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. +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory 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. ==== diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap index 86d5b27f08..c2e76a594a 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap @@ -24,9 +24,9 @@ By carefully considering the user's response after tool executions, you can reac 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 ('/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. -- 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. +You have access to tools that let you list files, regex search, read files. +- These tools help you effectively accomplish a wide range of tasks. +- 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. ==== @@ -39,25 +39,20 @@ MODES RULES - The project base directory is: /test/path -- All file paths must be relative to this directory. 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 '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. +- All file paths must be relative to this directory. +- You are stuck operating from '/test/path', 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. -- 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 '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - 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. - 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. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" - 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. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. - 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. 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. -- 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. - 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. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - 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. - 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. -- 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. ==== @@ -69,7 +64,7 @@ Default Shell: /bin/zsh Home Directory: /home/user Current Workspace Directory: /test/path -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. +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory 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. ==== diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap index d6fd17ba2f..1c2bca6291 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap @@ -24,9 +24,9 @@ By carefully considering the user's response after tool executions, you can reac 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 ('/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. -- 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. +You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) +- These tools help you effectively accomplish a wide range of tasks. +- 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. ==== @@ -39,25 +39,20 @@ MODES RULES - The project base directory is: /test/path -- All file paths must be relative to this directory. 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 '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. +- All file paths must be relative to this directory. +- You are stuck operating from '/test/path', 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. -- 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 '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - 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. - 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. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" - 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. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. - 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. 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. -- 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. - 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. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - 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. - 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. -- 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. ==== @@ -69,7 +64,7 @@ Default Shell: /bin/zsh Home Directory: /home/user Current Workspace Directory: /test/path -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. +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory 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. ==== diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap index 2a1533bfef..78181206bd 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap @@ -24,9 +24,9 @@ By carefully considering the user's response after tool executions, you can reac 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 ('/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. -- 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. +You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) +- These tools help you effectively accomplish a wide range of tasks. +- 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. ==== @@ -39,25 +39,20 @@ MODES RULES - The project base directory is: /test/path -- All file paths must be relative to this directory. 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 '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. +- All file paths must be relative to this directory. +- You are stuck operating from '/test/path', 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. -- 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 '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - 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. - 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. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" - 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. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. - 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. 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. -- 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. - 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. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - 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. - 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. -- 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. ==== @@ -69,7 +64,7 @@ Default Shell: /bin/zsh Home Directory: /home/user Current Workspace Directory: /test/path -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. +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory 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. ==== diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap index 5660cd4def..4f3f69feed 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap @@ -24,11 +24,10 @@ By carefully considering the user's response after tool executions, you can reac 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 ('/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. -- 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. -- 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. - +You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) +- These tools help you effectively accomplish a wide range of tasks. +- 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 have access to MCP servers that may provide additional tools and/or resources actually available to this mode. Each server may provide different capabilities that you can use to accomplish tasks more effectively. ==== @@ -41,24 +40,20 @@ MODES RULES - The project base directory is: /test/path -- All file paths must be relative to this directory. 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 '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. +- All file paths must be relative to this directory. +- You are stuck operating from '/test/path', 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. -- 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 '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - 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. - 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. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" - 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. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. - 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. 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. -- 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. - 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. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - 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. - 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. -- 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. @@ -71,7 +66,7 @@ Default Shell: /bin/zsh Home Directory: /home/user Current Workspace Directory: /test/path -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. +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory 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. ==== diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap index 2a1533bfef..78181206bd 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap @@ -24,9 +24,9 @@ By carefully considering the user's response after tool executions, you can reac 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 ('/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. -- 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. +You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) +- These tools help you effectively accomplish a wide range of tasks. +- 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. ==== @@ -39,25 +39,20 @@ MODES RULES - The project base directory is: /test/path -- All file paths must be relative to this directory. 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 '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. +- All file paths must be relative to this directory. +- You are stuck operating from '/test/path', 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. -- 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 '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - 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. - 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. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" - 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. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. - 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. 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. -- 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. - 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. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - 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. - 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. -- 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. ==== @@ -69,7 +64,7 @@ Default Shell: /bin/zsh Home Directory: /home/user Current Workspace Directory: /test/path -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. +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory 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. ==== diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index 79d4fad4ca..c260de4698 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -1,9 +1,60 @@ import { addCustomInstructions } from "../sections/custom-instructions" import { getCapabilitiesSection } from "../sections/capabilities" import { getRulesSection, getCommandChainOperator } from "../sections/rules" +import { getSystemInfoSection } from "../sections/system-info" +import { getObjectiveSection } from "../sections/objective" +import { getToolUseGuidelinesSection } from "../sections/tool-use-guidelines" +import { getSkillsSection } from "../sections/skills" +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" +import { resolveEffectiveToolPolicy } from "../tools/effective-tool-policy" +import type { EffectiveToolPolicyInput } from "../tools/effective-tool-policy" +import type { GroupEntry, ModelInfo } from "@roo-code/types" import { McpHub } from "../../../services/mcp/McpHub" +import type { CodeIndexManager } from "../../../services/code-index/manager" +import type { SkillsManager } from "../../../services/skills/SkillsManager" import * as shellUtils from "../../../utils/shell" +/** + * Build an {@link EffectiveToolPolicy} for arbitrary mode groups. `mode` is the + * custom-mode slug so the resolver derives everything from `groups` (never from + * built-in names), which keeps assertions mode-neutral. + */ +function policyFor( + groups: GroupEntry[], + extra: Partial<{ + mcpHub: McpHub + disabledTools: string[] + modelInfo: ModelInfo + experiments: Record + todoListEnabled: boolean + codeIndexManager: CodeIndexManager + allowedMcpServers: string[] + }> = {}, +): EffectiveToolPolicy { + return resolveEffectiveToolPolicy({ + mode: "p", + customModes: [{ slug: "p", name: "Policy Under Test", roleDefinition: "", groups }], + ...extra, + }) +} + +/** Minimal McpHub stub. `tools`/`resources` mirror the McpServer shape the resolver reads. */ +function makeMcpHub(servers: Array<{ name: string; tools?: unknown[]; resources?: unknown[] }>): McpHub { + return { getServers: () => servers } as unknown as McpHub +} + +/** Minimal SkillsManager stub returning a fixed skill list. */ +function makeSkillsManager(n: number): SkillsManager { + return { + getSkillsForMode: () => + Array.from({ length: n }, (_, i) => ({ + name: `skill-${i}`, + description: `Skill ${i}`, + path: `./skills/${i}`, + })), + } as unknown as SkillsManager +} + describe("addCustomInstructions", () => { it("adds vscode language to custom instructions", async () => { const result = await addCustomInstructions( @@ -32,69 +83,145 @@ describe("addCustomInstructions", () => { }) describe("getCapabilitiesSection", () => { - const cwd = "/test/path" - - it("includes standard capabilities", () => { - const result = getCapabilitiesSection(cwd) + it("includes standard clauses for a full-tool mode", () => { + const result = getCapabilitiesSection(policyFor(["read", "edit", "command"])) expect(result).toContain("CAPABILITIES") - expect(result).toContain("execute CLI commands") + expect(result).toContain("execute CLI commands on the user's computer") expect(result).toContain("list files") - expect(result).toContain("read and write files") + expect(result).toContain("read files") + expect(result).toContain("write and edit files") + // the task tail is a plain sentence — no over-claiming enumeration (plan §5) + expect(result).not.toContain("such as writing code") }) - const createMockMcpHub = (serverNames: string[]): McpHub => - ({ - getServers: () => serverNames.map((name) => ({ name })), - }) as unknown as McpHub + it("uses the fallback sentence when zero per-tool clauses exist", () => { + // control-tools-only mode: only switch_mode/new_task remain (no read/edit/command clauses) + const result = getCapabilitiesSection(policyFor(["modes"])) - it("includes MCP reference when mcpHub exposes at least one server", () => { - const mockMcpHub = createMockMcpHub(["test-server"]) - const result = getCapabilitiesSection(cwd, mockMcpHub) + expect(result).toContain("You have access to a limited set of tools for this mode") + expect(result).not.toContain("You have access to tools that let you") + }) - expect(result).toContain("MCP servers") + it("emits the edit-restriction suffix when the mode declares a fileRegex", () => { + const result = getCapabilitiesSection( + policyFor(["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }]]), + ) + + expect(result).toContain("only files matching") + expect(result).toContain("\\.md$") + expect(result).toContain("Markdown files only") + // The suffix binds to the capability sentence, not the last emitted bullet. + expect(result).toContain( + "You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\\.md$' can be edited — Markdown files only)", + ) }) - it("excludes MCP reference when mcpHub is undefined", () => { - const result = getCapabilitiesSection(cwd, undefined) + it("keeps the edit-restriction suffix off the MCP bullet when MCP is active", () => { + // With the mcp group + an enabled MCP server the MCP bullet is the last + // bullet; the restriction suffix must stay on the capability sentence. + const result = getCapabilitiesSection( + policyFor(["read", ["edit", { fileRegex: "\\.md$" }], "mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", description: "d" }] }]), + }), + ) - expect(result).not.toContain("MCP servers") + expect(result).toContain("MCP servers") + expect(result).not.toContain("accomplish tasks more effectively. (in this mode") + expect(result).toContain("write and edit files. (in this mode only files matching") }) - it("excludes MCP reference when mcpHub exposes no servers", () => { - const mockMcpHub = createMockMcpHub([]) - const result = getCapabilitiesSection(cwd, mockMcpHub) + it("omits the edit-restriction suffix without a fileRegex", () => { + const result = getCapabilitiesSection(policyFor(["read", "edit"])) + expect(result).not.toContain("only files matching") + }) - expect(result).not.toContain("MCP servers") + it("lists files guidance only when list_files is available", () => { + const withListFiles = getCapabilitiesSection(policyFor(["read"])) + expect(withListFiles).toContain("you can use the list_files tool") + // the file-tree *fact* lives in SYSTEM INFORMATION, not CAPABILITIES + expect(withListFiles).not.toContain("a recursive list of all filepaths") + + const withoutListFiles = getCapabilitiesSection(policyFor(["command"])) + expect(withoutListFiles).not.toContain("you can use the list_files tool") }) - it("includes MCP reference when allowedMcpServers matches a connected server", () => { - const mockMcpHub = createMockMcpHub(["allowed-server", "other-server"]) - const result = getCapabilitiesSection(cwd, mockMcpHub, ["allowed-server"]) + it("only emits the execute_command paragraph when execute_command is available", () => { + const withCmd = getCapabilitiesSection(policyFor(["command"])) + expect(withCmd).toContain("You can use the execute_command tool") - expect(result).toContain("MCP servers") + const withoutCmd = getCapabilitiesSection(policyFor(["read"])) + expect(withoutCmd).not.toContain("You can use the execute_command tool") }) - it("excludes MCP reference when allowedMcpServers is an empty array", () => { - const mockMcpHub = createMockMcpHub(["test-server"]) - const result = getCapabilitiesSection(cwd, mockMcpHub, []) + it("emits the MCP bullet only when the mode has the mcp group AND effective MCP availability", () => { + // mcp group, server with a prompt-enabled tool -> present + const hasTools = getCapabilitiesSection( + policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", description: "d" }] }]) }), + ) + expect(hasTools).toContain("MCP servers") - expect(result).not.toContain("MCP servers") + // mcp group, server with no tools but a resource -> present via resources + const hasResources = getCapabilitiesSection( + policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s", resources: [{ uri: "x" }] }]) }), + ) + expect(hasResources).toContain("MCP servers") + + // mcp group, empty server (no tools, no resources) -> absent + const nothing = getCapabilitiesSection(policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s" }]) })) + expect(nothing).not.toContain("MCP servers") + + // no mcp group -> absent even with a working server + const noGroup = getCapabilitiesSection( + policyFor(["read"], { mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", description: "d" }] }]) }), + ) + expect(noGroup).not.toContain("MCP servers") }) - it("excludes MCP reference when allowedMcpServers matches no connected server", () => { - const mockMcpHub = createMockMcpHub(["test-server"]) - const result = getCapabilitiesSection(cwd, mockMcpHub, ["nonexistent-server"]) + it("omits the MCP bullet when every tool is enabledForPrompt:false and no resources exist", () => { + const result = getCapabilitiesSection( + policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", description: "d", enabledForPrompt: false }] }]), + }), + ) + expect(result).not.toContain("MCP servers") + }) + it("omits the MCP bullet when a disallowed server is the only one with tools/resources", () => { + const result = getCapabilitiesSection( + policyFor(["mcp"], { + mcpHub: makeMcpHub([ + { name: "allowed", tools: [] }, + { name: "blocked", tools: [{ name: "t", description: "d" }], resources: [{ uri: "x" }] }, + ]), + allowedMcpServers: [], + }), + ) expect(result).not.toContain("MCP servers") }) + + it("includes the MCP bullet for an allowed server under an allowlist", () => { + const result = getCapabilitiesSection( + policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "allowed", tools: [{ name: "t", description: "d" }] }]), + allowedMcpServers: ["allowed"], + }), + ) + expect(result).toContain("MCP servers") + }) }) describe("getRulesSection", () => { const cwd = "/test/path" + const settings = { + todoListEnabled: true, + useAgentRules: true, + newTaskRequireTodos: false, + } + it("includes standard rules", () => { - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, policyFor(["read", "edit", "command"])) expect(result).toContain("RULES") expect(result).toContain("project base directory") @@ -102,14 +229,8 @@ describe("getRulesSection", () => { }) it("includes vendor confidentiality section when isStealthModel is true", () => { - const settings = { - todoListEnabled: true, - useAgentRules: true, - newTaskRequireTodos: false, - isStealthModel: true, - } - - const result = getRulesSection(cwd, settings) + const stealthSettings = { ...settings, isStealthModel: true } + const result = getRulesSection(cwd, stealthSettings, policyFor(["read", "edit", "command"])) expect(result).toContain("VENDOR CONFIDENTIALITY") expect(result).toContain("Never reveal the vendor or company that created you") @@ -119,31 +240,194 @@ describe("getRulesSection", () => { }) it("excludes vendor confidentiality section when isStealthModel is false", () => { - const settings = { - todoListEnabled: true, - useAgentRules: true, - newTaskRequireTodos: false, - isStealthModel: false, - } - - const result = getRulesSection(cwd, settings) + const stealthSettings = { ...settings, isStealthModel: false } + const result = getRulesSection(cwd, stealthSettings, policyFor(["read", "edit", "command"])) expect(result).not.toContain("VENDOR CONFIDENTIALITY") expect(result).not.toContain("Never reveal the vendor or company") }) it("excludes vendor confidentiality section when isStealthModel is undefined", () => { - const settings = { - todoListEnabled: true, - useAgentRules: true, - newTaskRequireTodos: false, - } - - const result = getRulesSection(cwd, settings) + const result = getRulesSection(cwd, settings, policyFor(["read", "edit", "command"])) expect(result).not.toContain("VENDOR CONFIDENTIALITY") expect(result).not.toContain("Never reveal the vendor or company") }) + + it("omits the execute_command bullet when execute_command is absent", () => { + const result = getRulesSection(cwd, settings, policyFor(["read"])) + + expect(result).not.toContain("Before using the execute_command tool") + expect(result).not.toContain("Actively Running Terminals") + // the terminal-aware "working directory" clause is gone too + expect(result).not.toContain("commands may change directories in terminals") + // but the base path rule stays + expect(result).toContain("All file paths must be relative to this directory") + }) + + it("includes the execute_command bullet when execute_command is present", () => { + const result = getRulesSection(cwd, settings, policyFor(["command"])) + + expect(result).toContain("Before using the execute_command tool") + expect(result).toContain("Actively Running Terminals") + }) + + it("does not contain the removed hardcoded architect example line", () => { + const result = getRulesSection(cwd, settings, policyFor(["read", "edit", "command"])) + + expect(result).not.toContain("in architect mode") + expect(result).not.toContain("trying to edit app.js") + }) + + it("uses ask_followup_question when the tool is available", () => { + const result = getRulesSection(cwd, settings, policyFor(["read"])) + expect(result).toContain("ask the user questions using the ask_followup_question tool") + }) + + it("uses the replacement bullet when ask_followup_question is absent", () => { + // Both sub-cases — list_files present (previously the + // filesystem-enumeration branch) and list_files absent — now take the + // single best-effort replacement bullet (plan §5: emitted exactly when + // ask_followup_question is absent). + const withListFiles = getRulesSection( + cwd, + settings, + policyFor(["read"], { disabledTools: ["ask_followup_question"] }), + ) + expect(withListFiles).toContain("Provide your best-effort result and state your assumptions") + expect(withListFiles).not.toContain( + "You are only allowed to ask the user questions using the ask_followup_question tool", + ) + expect(withListFiles).not.toContain("enumerate the filesystem yourself") + + const withoutListFiles = getRulesSection( + cwd, + settings, + policyFor(["edit", "command"], { disabledTools: ["ask_followup_question", "list_files"] }), + ) + expect(withoutListFiles).toContain("Provide your best-effort result and state your assumptions") + expect(withoutListFiles).not.toContain( + "You are only allowed to ask the user questions using the ask_followup_question tool", + ) + expect(withoutListFiles).not.toContain("enumerate the filesystem yourself") + }) + + it("uses the fallback phrasing in the terminal-output rule when ask_followup_question is absent", () => { + // The execute_command bullet stays, but its tail must not reference a + // disabled tool (the disabled-tool-reference bug class this fix removes). + const withoutAsk = getRulesSection( + cwd, + settings, + policyFor(["command"], { disabledTools: ["ask_followup_question"] }), + ) + expect(withoutAsk).toContain("When executing commands") + expect(withoutAsk).not.toContain("ask_followup_question") + + const withAsk = getRulesSection(cwd, settings, policyFor(["command"])) + expect(withAsk).toContain( + "use the ask_followup_question tool to request the user to copy and paste it back to you", + ) + }) + + it("omits the read_file rule when read_file is absent", () => { + const result = getRulesSection(cwd, settings, policyFor(["command"])) + expect(result).not.toContain("The user may provide a file's contents directly") + }) + + it("includes the read_file rule when read_file is present", () => { + const result = getRulesSection(cwd, settings, policyFor(["read"])) + expect(result).toContain("The user may provide a file's contents directly") + }) + + it("includes vendor confidentiality section when isStealthModel is true (kept)", () => { + // duplicate guard: ensure the describe still asserts a stable baseline even if other tests change + const result = getRulesSection(cwd, settings, policyFor(["read", "edit", "command"])) + expect(result).toContain("RULES") + }) +}) + +describe("getSystemInfoSection", () => { + const cwd = "/some/real/path" + + it("keeps the header lines", () => { + const result = getSystemInfoSection(cwd, policyFor(["read", "edit", "command"])) + expect(result).toContain("SYSTEM INFORMATION") + expect(result).toContain("Operating System:") + expect(result).toContain("Default Shell:") + expect(result).toContain("Home Directory:") + expect(result).toContain(`Current Workspace Directory: ${cwd}`) + }) + + it("contains no /test/path literal", () => { + const result = getSystemInfoSection(cwd, policyFor(["read", "edit", "command"])) + expect(result).not.toContain("/test/path") + }) + + it("omits the terminal-cd sentence when execute_command is absent", () => { + const result = getSystemInfoSection(cwd, policyFor(["read"])) + expect(result).not.toContain("New terminals will be created") + expect(result).not.toContain("change directories in a terminal") + }) + + it("includes the terminal-cd sentence when execute_command is present", () => { + const result = getSystemInfoSection(cwd, policyFor(["command"])) + expect(result).toContain("New terminals will be created") + }) + + it("states the file-tree fact once and omits list_files guidance here", () => { + const result = getSystemInfoSection(cwd, policyFor(["read"])) + expect(result).toContain( + "a recursive list of all filepaths in the current workspace directory will be included in environment_details", + ) + // the list_files *guidance* belongs in CAPABILITIES, not SYSTEM INFORMATION + expect(result).not.toContain("you can use the list_files tool") + }) +}) + +describe("getObjectiveSection", () => { + it("names ask_followup_question when the tool is available", () => { + const result = getObjectiveSection(policyFor(["read"])) + expect(result).toContain("ask the user to provide the missing parameters using the ask_followup_question tool") + }) + + it("uses best-effort phrasing when ask_followup_question is absent", () => { + const result = getObjectiveSection( + policyFor(["read", "edit", "command"], { disabledTools: ["ask_followup_question"] }), + ) + expect(result).toContain("state your assumptions and proceed with the best available value") + expect(result).not.toContain("ask the user to provide the missing parameters") + }) +}) + +describe("getToolUseGuidelinesSection", () => { + it("includes the list_files example when list_files is available", () => { + const result = getToolUseGuidelinesSection(policyFor(["read"])) + expect(result).toContain( + "For example using the list_files tool is more effective than running a command like `ls` in the terminal.", + ) + }) + + it("omits the list_files example when list_files is absent", () => { + const result = getToolUseGuidelinesSection(policyFor(["command"])) + expect(result).not.toContain("using the list_files tool is more effective") + }) +}) + +describe("getSkillsSection", () => { + it("returns the skills XML when the skill tool is available", async () => { + const result = await getSkillsSection(makeSkillsManager(2), "code", policyFor(["read", "edit", "command"])) + expect(result).toContain("AVAILABLE SKILLS") + expect(result).toContain("skill-0") + }) + + it("returns an empty string when the skill tool is disabled", async () => { + const result = await getSkillsSection( + makeSkillsManager(2), + "code", + policyFor(["read", "edit", "command"], { disabledTools: ["skill"] }), + ) + expect(result).toBe("") + }) }) describe("getCommandChainOperator", () => { @@ -187,6 +471,9 @@ describe("getCommandChainOperator", () => { describe("getRulesSection shell-aware command chaining", () => { const cwd = "/test/path" + const settings = { todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false } + + const codePolicy = policyFor(["read", "edit", "command"]) afterEach(() => { vi.restoreAllMocks() @@ -194,7 +481,7 @@ describe("getRulesSection shell-aware command chaining", () => { it("uses && for Unix shells in command chaining example", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash") - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).toContain("cd (path to project) && (command") expect(result).not.toContain("cd (path to project) ; (command") @@ -205,7 +492,7 @@ describe("getRulesSection shell-aware command chaining", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue( "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ) - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).toContain("cd (path to project) ; (command") expect(result).toContain("Note: Using `;` for PowerShell command chaining") @@ -213,7 +500,7 @@ describe("getRulesSection shell-aware command chaining", () => { it("uses && for cmd.exe in command chaining example", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe") - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).toContain("cd (path to project) && (command") expect(result).toContain("Note: Using `&&` for cmd.exe command chaining") @@ -223,7 +510,7 @@ describe("getRulesSection shell-aware command chaining", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue( "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ) - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).toContain("IMPORTANT: When using PowerShell, avoid Unix-specific utilities") expect(result).toContain("`sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`") @@ -234,7 +521,7 @@ describe("getRulesSection shell-aware command chaining", () => { it("includes Unix utility guidance for cmd.exe", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe") - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).toContain("IMPORTANT: When using cmd.exe, avoid Unix-specific utilities") expect(result).toContain("`sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`") @@ -245,7 +532,7 @@ describe("getRulesSection shell-aware command chaining", () => { it("does not include Unix utility guidance for Unix shells", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash") - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).not.toContain("IMPORTANT: When using PowerShell") expect(result).not.toContain("IMPORTANT: When using cmd.exe") @@ -254,7 +541,7 @@ describe("getRulesSection shell-aware command chaining", () => { it("does not include note for Unix shells", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh") - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).not.toContain("Note: Using") }) diff --git a/src/core/prompts/__tests__/system-prompt.spec.ts b/src/core/prompts/__tests__/system-prompt.spec.ts index d8671b2027..6e04ec9937 100644 --- a/src/core/prompts/__tests__/system-prompt.spec.ts +++ b/src/core/prompts/__tests__/system-prompt.spec.ts @@ -41,11 +41,12 @@ vi.mock("fs/promises") import * as vscode from "vscode" -import { ModeConfig } from "@roo-code/types" +import { ModeConfig, ModelInfo } from "@roo-code/types" import { SYSTEM_PROMPT } from "../system" import { McpHub } from "../../../services/mcp/McpHub" import { defaultModeSlug, modes, Mode } from "../../../shared/modes" +import type { SystemPromptSettings } from "../types" import "../../../utils/path" import { addCustomInstructions } from "../sections/custom-instructions" import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" @@ -641,6 +642,146 @@ describe("SYSTEM_PROMPT", () => { }) }) + describe("effective tool policy reflected in the system prompt", () => { + // Section-scoped extraction: capture the text between two "====" headers so + // user-authored roleDefinition/customInstructions can't pollute the assertions. + function extractSection(prompt: string, header: string): string { + const marker = `\n\n${header}\n\n` + const idx = prompt.indexOf(marker) + expect(idx).toBeGreaterThan(-1) + const afterHeader = prompt.slice(idx + marker.length) + const nextMarker = afterHeader.indexOf("\n\n====") + return nextMarker === -1 ? afterHeader : afterHeader.slice(0, nextMarker) + } + + const fullToolSettings: SystemPromptSettings = { + todoListEnabled: true, + useAgentRules: true, + newTaskRequireTodos: false, + } + + function run( + mode: string, + extra: Partial<{ + customModes?: ModeConfig[] + mcpHub?: McpHub + settings?: SystemPromptSettings + disabledTools?: string[] + modelInfo?: ModelInfo + }> = {}, + ) { + return SYSTEM_PROMPT( + mockContext, + "/test/path", + false, + extra.mcpHub, + undefined, // diffStrategy + mode, + undefined, // customModePrompts + extra.customModes, + undefined, // globalCustomInstructions + experiments, + undefined, // language + undefined, // rooIgnoreInstructions + extra.settings ?? fullToolSettings, // settings + undefined, // todoList + undefined, // modelId + undefined, // skillsManager + extra.disabledTools, // disabledTools + extra.modelInfo, // modelInfo + ) + } + + it("Code & Debug expose execute_command guidance (CAPABILITIES + RULES)", async () => { + for (const mode of ["code", "debug"]) { + const prompt = await run(mode) + const capabilities = extractSection(prompt, "CAPABILITIES") + const rules = extractSection(prompt, "RULES") + + expect(capabilities).toContain("execute CLI commands on the user's computer") + expect(rules).toContain("Before using the execute_command tool") + expect(rules).toContain('check the "Actively Running Terminals" section') + } + }) + + it("Architect has no execute_command and advertises the \\ .md$ edit restriction", async () => { + const prompt = await run("architect") + const capabilities = extractSection(prompt, "CAPABILITIES") + const rules = extractSection(prompt, "RULES") + const systemInfo = extractSection(prompt, "SYSTEM INFORMATION") + + // No execute_command anywhere. + expect(capabilities).not.toContain("execute CLI commands") + expect(rules).not.toContain("Before using the execute_command tool") + expect(rules).not.toContain('check the "Actively Running Terminals" section') + expect(systemInfo).not.toContain("New terminals will be created") + // Architect-style edit restriction reflected in CAPABILITIES. + expect(capabilities).toContain("in this mode only files matching") + expect(capabilities).toContain("\\.md$") + expect(capabilities).toContain("Markdown files only") + }) + + it("Ask advertises no write clause and no execute_command", async () => { + const prompt = await run("ask") + const capabilities = extractSection(prompt, "CAPABILITIES") + + expect(capabilities).not.toContain("execute CLI commands") + expect(capabilities).not.toContain("write and edit files") + expect(capabilities).toContain("read files") + }) + + it("Orchestrator advertises no read/list/edit clauses", async () => { + const prompt = await run("orchestrator") + const capabilities = extractSection(prompt, "CAPABILITIES") + + expect(capabilities).not.toContain("read files") + expect(capabilities).not.toContain("execute CLI commands") + expect(capabilities).not.toContain("write and edit files") + }) + + it("empty groups -> fallback sentence, no per-tool clauses", async () => { + const customModes: ModeConfig[] = [ + { + slug: "empty-mode", + name: "Empty Mode", + roleDefinition: "An empty mode", + groups: [], + }, + ] + const prompt = await run("empty-mode", { customModes }) + const capabilities = extractSection(prompt, "CAPABILITIES") + + // No per-tool clauses remain -> the fallback sentence is emitted. + expect(capabilities).toContain("You have access to a limited set of tools for this mode") + expect(capabilities).not.toContain("You have access to tools that let you") + // A control-only set must never advertise tool-execution clauses. + expect(capabilities).not.toContain("execute CLI commands") + expect(capabilities).not.toContain("regex search") + expect(capabilities).not.toContain("The project base directory is:") + }) + + it("disabledTools: ['execute_command'] removes command guidance from the prompt", async () => { + const prompt = await run("code", { disabledTools: ["execute_command"] }) + const capabilities = extractSection(prompt, "CAPABILITIES") + const rules = extractSection(prompt, "RULES") + + expect(rules).not.toContain("Before using the execute_command tool") + expect(rules).not.toContain("Actively Running Terminals") + expect(capabilities).not.toContain("execute CLI commands") + }) + + it("modelInfo.excludedTools removes the matching capability clause", async () => { + const prompt = await run("code", { + modelInfo: { contextWindow: 100_000, supportsPromptCache: true, excludedTools: ["read_file"] }, + }) + const capabilities = extractSection(prompt, "CAPABILITIES") + + expect(capabilities).not.toContain("read files") + // other clauses survive, proving the exclusion is scoped to the one tool + expect(capabilities).toContain("execute CLI commands") + }) + }) + afterAll(() => { vi.restoreAllMocks() }) diff --git a/src/core/prompts/sections/__tests__/objective.spec.ts b/src/core/prompts/sections/__tests__/objective.spec.ts index f776a326d2..67435782b1 100644 --- a/src/core/prompts/sections/__tests__/objective.spec.ts +++ b/src/core/prompts/sections/__tests__/objective.spec.ts @@ -1,8 +1,19 @@ import { getObjectiveSection } from "../objective" +import type { EffectiveToolPolicy } from "../../tools/effective-tool-policy" + +/** Build a policy advertising `tools` as logically available. */ +function policyFor(tools: string[]): EffectiveToolPolicy { + return { + tools: new Set(tools), + hasMcpGroup: false, + hasMcpTools: false, + hasMcpResources: false, + } +} describe("getObjectiveSection", () => { it("should include proper numbered structure", () => { - const objective = getObjectiveSection() + const objective = getObjectiveSection(policyFor([])) // Check that all numbered items are present expect(objective).toContain("1. Analyze the user's task") @@ -13,7 +24,7 @@ describe("getObjectiveSection", () => { }) it("should include analysis guidance", () => { - const objective = getObjectiveSection() + const objective = getObjectiveSection(policyFor(["read_file"])) expect(objective).toContain("Before calling a tool, do some analysis") expect(objective).toContain("analyze the file structure provided in environment_details") @@ -21,7 +32,7 @@ describe("getObjectiveSection", () => { }) it("should include parameter inference guidance", () => { - const objective = getObjectiveSection() + const objective = getObjectiveSection(policyFor(["ask_followup_question"])) expect(objective).toContain("Go through each of the required parameters") expect(objective).toContain( @@ -32,14 +43,14 @@ describe("getObjectiveSection", () => { }) it("should include guidance about not engaging in back and forth conversations", () => { - const objective = getObjectiveSection() + const objective = getObjectiveSection(policyFor([])) expect(objective).toContain("DO NOT continue in pointless back and forth conversations") expect(objective).toContain("don't end your responses with questions or offers for further assistance") }) it("should include the OBJECTIVE header", () => { - const objective = getObjectiveSection() + const objective = getObjectiveSection(policyFor([])) expect(objective).toContain("OBJECTIVE") expect(objective).toContain("You accomplish a given task iteratively") diff --git a/src/core/prompts/sections/__tests__/skills.spec.ts b/src/core/prompts/sections/__tests__/skills.spec.ts index 707d151252..aa53d2e3c6 100644 --- a/src/core/prompts/sections/__tests__/skills.spec.ts +++ b/src/core/prompts/sections/__tests__/skills.spec.ts @@ -1,4 +1,15 @@ import { getSkillsSection } from "../skills" +import type { EffectiveToolPolicy } from "../../tools/effective-tool-policy" + +/** Build a policy advertising `tools` as logically available. */ +function policyFor(tools: string[]): EffectiveToolPolicy { + return { + tools: new Set(tools), + hasMcpGroup: false, + hasMcpTools: false, + hasMcpResources: false, + } +} describe("getSkillsSection", () => { it("should emit XML with name, description, and location", async () => { @@ -13,7 +24,7 @@ describe("getSkillsSection", () => { ]), } - const result = await getSkillsSection(mockSkillsManager, "code") + const result = await getSkillsSection(mockSkillsManager, "code", policyFor(["skill"])) expect(result).toContain("") expect(result).toContain("") @@ -26,7 +37,22 @@ describe("getSkillsSection", () => { }) it("should return empty string when skillsManager or currentMode is missing", async () => { - await expect(getSkillsSection(undefined, "code")).resolves.toBe("") - await expect(getSkillsSection({ getSkillsForMode: vi.fn() }, undefined)).resolves.toBe("") + await expect(getSkillsSection(undefined, "code", policyFor(["skill"]))).resolves.toBe("") + await expect(getSkillsSection({ getSkillsForMode: vi.fn() }, undefined, policyFor(["skill"]))).resolves.toBe("") + }) + + it("should return empty string when the skill tool is disabled", async () => { + const mockSkillsManager = { + getSkillsForMode: vi.fn().mockReturnValue([ + { + name: "pdf-processing", + description: "Extracts text & tables from PDFs", + path: "/abs/path/pdf-processing/SKILL.md", + source: "global" as const, + }, + ]), + } + + await expect(getSkillsSection(mockSkillsManager, "code", policyFor([]))).resolves.toBe("") }) }) diff --git a/src/core/prompts/sections/__tests__/system-info.spec.ts b/src/core/prompts/sections/__tests__/system-info.spec.ts index 749b53a0fd..5c43cb2a4d 100644 --- a/src/core/prompts/sections/__tests__/system-info.spec.ts +++ b/src/core/prompts/sections/__tests__/system-info.spec.ts @@ -24,6 +24,14 @@ describe("getSystemInfoSection", () => { vi.spyOn(os, "release").mockReturnValue("5.15.0") }) + /** Minimal policy with execute_command present (the default case these tests exercise). */ + const policyFor = (hasExecuteCommand: boolean = true) => ({ + tools: new Set(hasExecuteCommand ? ["execute_command"] : []), + hasMcpGroup: false, + hasMcpTools: false, + hasMcpResources: false, + }) + afterEach(() => { vi.clearAllMocks() }) @@ -31,7 +39,7 @@ describe("getSystemInfoSection", () => { it("should return system info with os-name when available", () => { mockOsName.mockReturnValue("Ubuntu 22.04") - const result = getSystemInfoSection(mockCwd) + const result = getSystemInfoSection(mockCwd, policyFor()) expect(result).toContain("Operating System: Ubuntu 22.04") expect(result).toContain("Default Shell: /bin/bash") @@ -44,7 +52,7 @@ describe("getSystemInfoSection", () => { throw new Error("Command failed with ENOENT: powershell") }) - const result = getSystemInfoSection(mockCwd) + const result = getSystemInfoSection(mockCwd, policyFor()) expect(result).toContain("Operating System: linux 5.15.0") expect(result).toContain("Default Shell: /bin/bash") @@ -59,8 +67,16 @@ describe("getSystemInfoSection", () => { vi.spyOn(os, "platform").mockReturnValue("win32" as any) vi.spyOn(os, "release").mockReturnValue("10.0.19043") - const result = getSystemInfoSection(mockCwd) + const result = getSystemInfoSection(mockCwd, policyFor()) expect(result).toContain("Operating System: win32 10.0.19043") }) + + it("omits the terminal sentence when execute_command is absent", () => { + mockOsName.mockReturnValue("Ubuntu 22.04") + + const result = getSystemInfoSection(mockCwd, policyFor(false)) + + expect(result).not.toContain("New terminals will be created") + }) }) 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..947c0b207e 100644 --- a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts +++ b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts @@ -1,8 +1,19 @@ import { getToolUseGuidelinesSection } from "../tool-use-guidelines" +import type { EffectiveToolPolicy } from "../../tools/effective-tool-policy" + +/** Build a policy advertising `tools` as logically available. */ +function policyFor(tools: string[]): EffectiveToolPolicy { + return { + tools: new Set(tools), + hasMcpGroup: false, + hasMcpTools: false, + hasMcpResources: false, + } +} describe("getToolUseGuidelinesSection", () => { it("should include proper numbered guidelines", () => { - const guidelines = getToolUseGuidelinesSection() + const guidelines = getToolUseGuidelinesSection(policyFor(["list_files"])) expect(guidelines).toContain("1. Assess what information") expect(guidelines).toContain("2. Choose the most appropriate tool") @@ -10,14 +21,14 @@ describe("getToolUseGuidelinesSection", () => { }) it("should include multiple-tools-per-message guidance", () => { - const guidelines = getToolUseGuidelinesSection() + const guidelines = getToolUseGuidelinesSection(policyFor(["list_files"])) expect(guidelines).toContain("you may use multiple tools in a single message") expect(guidelines).not.toContain("use one tool at a time per message") }) it("should use simplified footer without step-by-step language", () => { - const guidelines = getToolUseGuidelinesSection() + const guidelines = getToolUseGuidelinesSection(policyFor(["list_files"])) expect(guidelines).toContain("carefully considering the user's response after tool executions") expect(guidelines).not.toContain("It is crucial to proceed step-by-step") @@ -25,15 +36,21 @@ describe("getToolUseGuidelinesSection", () => { }) it("should include common guidance", () => { - const guidelines = getToolUseGuidelinesSection() + const guidelines = getToolUseGuidelinesSection(policyFor(["list_files"])) expect(guidelines).toContain("Assess what information you already have") expect(guidelines).toContain("Choose the most appropriate tool") expect(guidelines).not.toContain("") }) it("should not include per-tool confirmation guidelines", () => { - const guidelines = getToolUseGuidelinesSection() + const guidelines = getToolUseGuidelinesSection(policyFor(["list_files"])) expect(guidelines).not.toContain("After each tool use, the user will respond with the result") }) + + it("omits the list_files example when list_files is absent", () => { + const guidelines = getToolUseGuidelinesSection(policyFor([])) + + expect(guidelines).not.toContain("the list_files tool is more effective than running a command like `ls`") + }) }) diff --git a/src/core/prompts/sections/capabilities.ts b/src/core/prompts/sections/capabilities.ts index c493692401..3d74fe091f 100644 --- a/src/core/prompts/sections/capabilities.ts +++ b/src/core/prompts/sections/capabilities.ts @@ -1,46 +1,83 @@ -import { McpHub } from "../../../services/mcp/McpHub" +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" /** * Builds the CAPABILITIES section of the system prompt. * - * The MCP availability line is only emitted when at least one MCP server is actually - * exposed to the current mode. When `allowedMcpServers` is provided, the hub's server - * list is filtered by that allowlist BEFORE deciding whether to advertise MCP, so the - * capability text matches the per-mode tool exposure: - * - `undefined` allowlist → all connected servers count (backward compatible) - * - empty `[]` allowlist → no servers count ⇒ MCP line omitted - * - populated allowlist → only listed servers count + * Every capability claim is now a fragment emitted only when its tool is in the + * request's effective tool policy (the single source of truth shared by prompt + * generation, API tool construction, runtime validation, and preview). This + * keeps the prose consistent with what the model can actually call for the mode. * - * @param cwd Current working directory used in the prompt text. - * @param mcpHub Optional MCP hub. When omitted, the MCP line is never emitted. - * @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. + * The file-tree paragraph is stated once as a fact in SYSTEM INFORMATION; the + * `list_files` *guidance* lives here and is gated on the tool being present. + * + * @param policy The request's effective tool policy. */ -export function getCapabilitiesSection(cwd: string, mcpHub?: McpHub, allowedMcpServers?: string[]): 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. - let hasMcpServers = false - if (mcpHub) { - let servers = mcpHub.getServers() - if (allowedMcpServers) { - const allowSet = new Set(allowedMcpServers) - servers = servers.filter((server) => allowSet.has(server.name)) - } - hasMcpServers = servers.length > 0 +export function getCapabilitiesSection(policy: EffectiveToolPolicy): string { + const tools = policy.tools + + const clauses: string[] = [] + if (tools.has("execute_command")) { + clauses.push("execute CLI commands on the user's computer") + } + if (tools.has("list_files")) { + clauses.push("list files") + } + if (tools.has("codebase_search")) { + clauses.push("view source code definitions") + } + if (tools.has("search_files")) { + clauses.push("regex search") + } + if (tools.has("read_file")) { + clauses.push("read files") + } + if (tools.has("write_to_file") || tools.has("apply_diff")) { + clauses.push("write and edit files") + } + + // The catalog clause is the only always-present sentence; when there are no + // per-tool clauses (e.g. a control-tool-only mode) we fall back to a sentence + // that warns the model it may only call provided tools. + const capabilitySentence = + clauses.length > 0 + ? `You have access to tools that let you ${clauses.join(", ")}.` + : "You have access to a limited set of tools for this mode; only the tools you are provided may be called." + + // The edit-restriction suffix binds to the capability sentence (not the last + // emitted bullet) so its position is deterministic regardless of which + // optional bullets follow. + const editRestrictionSuffix = policy.editRestriction + ? ` (in this mode only files matching '${policy.editRestriction.fileRegex}' can be edited${ + policy.editRestriction.description ? ` — ${policy.editRestriction.description}` : "" + })` + : "" + + let body = `${capabilitySentence}${editRestrictionSuffix}\n` + + body += `- These tools help you effectively accomplish a wide range of tasks.\n` + + // `list_files` guidance only — the file-tree *fact* is stated once in + // SYSTEM INFORMATION (and carries the cwd there). + if (tools.has("list_files")) { + body += `- 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.\n` } + // execute_command paragraph. + if (tools.has("execute_command")) { + body += `- 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.\n` + } + + // MCP bullet — only when MCP is effectively available (group + enabled tools/resources). + if (policy.hasMcpGroup && (policy.hasMcpTools || policy.hasMcpResources)) { + body += `- You have access to MCP servers that may provide additional tools and/or resources actually available to this mode. Each server may provide different capabilities that you can use to accomplish tasks more effectively.\n` + } + + body = body.replace(/\n$/, "") + 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 - ? ` -- 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. -` - : "" - }` +${body}` } diff --git a/src/core/prompts/sections/objective.ts b/src/core/prompts/sections/objective.ts index 2ef32bc144..7987213487 100644 --- a/src/core/prompts/sections/objective.ts +++ b/src/core/prompts/sections/objective.ts @@ -1,4 +1,24 @@ -export function getObjectiveSection(): string { +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" + +/** + * Builds the OBJECTIVE section of the system prompt. + * + * Step 3's guidance to ask the user via ask_followup_question is replaced with + * best-effort phrasing when that tool is not in the request's effective policy. + * Step 4 names attempt_completion, which is a protocol guarantee and thus always + * present — but it is gated on the policy for symmetry. + * + * @param policy The request's effective tool policy. + */ +export function getObjectiveSection(policy: EffectiveToolPolicy): string { + const askStep = policy.tools.has("ask_followup_question") + ? "ask the user to provide the missing parameters using the ask_followup_question tool" + : "state your assumptions and proceed with the best available value" + + const completionStep = policy.tools.has("attempt_completion") + ? "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." + : "Once you've completed the user's task, present the result of the task to the user." + return `==== OBJECTIVE @@ -7,7 +27,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. 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. -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. +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, ${askStep}. DO NOT ask for more information on optional parameters if it is not provided. +4. ${completionStep} 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.` } diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts index 4f6e573fa7..f712054eff 100644 --- a/src/core/prompts/sections/rules.ts +++ b/src/core/prompts/sections/rules.ts @@ -2,6 +2,8 @@ import type { SystemPromptSettings } from "../types" import { getShell } from "../../../utils/shell" +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" + /** * Returns the appropriate command chaining operator based on the user's shell. * - Unix shells (bash, zsh, etc.): `&&` (run next command only if previous succeeds) @@ -62,34 +64,137 @@ 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 { - // Get shell-appropriate command chaining operator +/** + * Builds the RULES section of the system prompt. + * + * Fragments that describe tool-specific behavior are emitted only when that tool + * is in the request's effective tool policy. The hardcoded `architect` edit + * example (which was misplaced, mode-wrong for most built-ins, and redundant with + * the CAPABILITIES restriction clause and the FileRestrictionError message) is + * removed without replacement. + * + * @param cwd Current working directory used in the prompt text. + * @param settings System prompt settings (used for the stealth-model confidentiality section). + * @param policy The request's effective tool policy. + */ +export function getRulesSection( + cwd: string, + settings: SystemPromptSettings | undefined, + policy: EffectiveToolPolicy, +): string { const chainOp = getCommandChainOperator() const chainNote = getCommandChainNote() + const hasExecuteCommand = policy.tools.has("execute_command") + const hasAskFollowupQuestion = policy.tools.has("ask_followup_question") + const hasListFiles = policy.tools.has("list_files") + const hasAttemptCompletion = policy.tools.has("attempt_completion") + const hasReadFile = policy.tools.has("read_file") + + const rules: string[] = [] + + rules.push(`The project base directory is: ${cwd.toPosix()}`) + + rules.push( + hasExecuteCommand + ? `All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command.` + : "All file paths must be relative to this directory.", + ) + + rules.push( + `You are stuck operating from '${cwd.toPosix()}', so be sure to pass in the correct 'path' parameter when using tools that require a path.`, + ) + + rules.push("Do not use the ~ character or $HOME to refer to the home directory.") + + if (hasExecuteCommand) { + 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}` : ""}`, + ) + } + + 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.", + ) + + 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.", + ) + + rules.push( + "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.", + ) + + if (hasAttemptCompletion) { + rules.push( + "Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.", + ) + } else { + // attempt_completion is a protocol guarantee and is essentially always present; + // the fallback keeps the guidance coherent if it were ever excluded. + rules.push( + "Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, provide your best-effort result and state your assumptions; the user may respond with feedback after completion.", + ) + } + + if (hasAskFollowupQuestion) { + 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. However if you can use the available tools to avoid having to ask the user questions, you should do so.${ + hasListFiles + ? ` 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.` + : "" + }`, + ) + } else { + // ask_followup_question unavailable: best-effort guidance (plan §5 replacement bullet). + rules.push( + "Provide your best-effort result and state your assumptions; the user may respond with feedback after completion.", + ) + } + + if (hasExecuteCommand) { + rules.push( + `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, ${ + hasAskFollowupQuestion + ? "use the ask_followup_question tool to request the user to copy and paste it back to you" + : "note what you expected and proceed with the task, stating your assumptions" + }.`, + ) + } + + if (hasReadFile) { + 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.", + "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.", + '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 (hasExecuteCommand) { + 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 (policy.hasMcpGroup && (policy.hasMcpTools || policy.hasMcpResources)) { + 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.", + ) + } + + 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. 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.", + ) + return `==== RULES -- The project base directory is: ${cwd.toPosix()} -- All file paths must be relative to this directory. 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. -- 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}` : ""} -- 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. -- 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. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\\.md$" -- 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. -- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. -- 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. 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. -- 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. -- 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. -- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. -- 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. -- 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. -- 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() : ""}` +- ${rules.join("\n- ")}${settings?.isStealthModel ? getVendorConfidentialitySection() : ""}` } diff --git a/src/core/prompts/sections/skills.ts b/src/core/prompts/sections/skills.ts index 6cd3a71d75..86d9e59844 100644 --- a/src/core/prompts/sections/skills.ts +++ b/src/core/prompts/sections/skills.ts @@ -1,4 +1,5 @@ import type { SkillsManager } from "../../../services/skills/SkillsManager" +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" type SkillsManagerLike = Pick @@ -22,7 +23,12 @@ function escapeXml(value: string): string { export async function getSkillsSection( skillsManager: SkillsManagerLike | undefined, currentMode: string | undefined, + policy?: EffectiveToolPolicy, ): Promise { + // The protocol in this section mandates the `skill` tool; if it's not available + // the section would be unhelpful/unactionable, so emit nothing. + if (!policy?.tools.has("skill")) return "" + if (!skillsManager || !currentMode) return "" // Get skills filtered by current mode (with override resolution) diff --git a/src/core/prompts/sections/system-info.ts b/src/core/prompts/sections/system-info.ts index a4af3c6ac9..afdaff015f 100644 --- a/src/core/prompts/sections/system-info.ts +++ b/src/core/prompts/sections/system-info.ts @@ -3,7 +3,21 @@ import osName from "os-name" import { getShell } from "../../../utils/shell" -export function getSystemInfoSection(cwd: string): string { +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" + +/** + * Builds the SYSTEM INFORMATION section of the system prompt. + * + * The workspace-directory / file-tree facts are stated once here (deduplicated + * from CAPABILITIES). The `/test/path` literal that was previously baked into + * every prompt is removed; the file-tree fact is now cwd-independent. The + * terminal-cd sentence is gated on `execute_command`, since those semantics do + * not exist without it. + * + * @param cwd Current working directory used in the prompt text. + * @param policy The request's effective tool policy. + */ +export function getSystemInfoSection(cwd: string, policy: EffectiveToolPolicy): string { // Try to get detailed OS name, fall back to basic info if it fails let osInfo: string try { @@ -15,6 +29,12 @@ export function getSystemInfoSection(cwd: string): string { osInfo = `${platform} ${release}` } + const executeCommandAvailable = policy.tools.has("execute_command") + + const executeCommandSentence = executeCommandAvailable + ? " 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 details = `==== SYSTEM INFORMATION @@ -24,7 +44,7 @@ Default Shell: ${getShell()} Home Directory: ${os.homedir().toPosix()} Current Workspace Directory: ${cwd.toPosix()} -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.` +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations.${executeCommandSentence} When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory 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.` return details } diff --git a/src/core/prompts/sections/tool-use-guidelines.ts b/src/core/prompts/sections/tool-use-guidelines.ts index 78193372cc..2a34c89966 100644 --- a/src/core/prompts/sections/tool-use-guidelines.ts +++ b/src/core/prompts/sections/tool-use-guidelines.ts @@ -1,8 +1,22 @@ -export function getToolUseGuidelinesSection(): string { +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" + +/** + * Builds the TOOL USE GUIDELINES section of the system prompt. + * + * Guideline 2's example names `list_files` over `ls`; that example is only kept + * when `list_files` is in the request's effective tool policy. + * + * @param policy The request's effective tool policy. + */ +export function getToolUseGuidelinesSection(policy: EffectiveToolPolicy): string { + const listExample = policy.tools.has("list_files") + ? " 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.${listExample} 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/system.ts b/src/core/prompts/system.ts index 93f4a52846..847fc1ca87 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -1,8 +1,14 @@ 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 { + type ModeConfig, + type PromptComponent, + type CustomModePrompts, + type TodoItem, + type ModelInfo, +} from "@roo-code/types" + +import { Mode, modes, defaultModeSlug, getModeBySlug, getModeSelection } from "../../shared/modes" import { DiffStrategy } from "../../shared/tools" import { formatLanguage } from "../../shared/language" import { isEmpty } from "../../utils/object" @@ -12,6 +18,8 @@ import { CodeIndexManager } from "../../services/code-index/manager" import { SkillsManager } from "../../services/skills/SkillsManager" import type { SystemPromptSettings } from "./types" +import type { EffectiveToolPolicy } from "./tools/effective-tool-policy" +import { resolveEffectiveToolPolicy } from "./tools/effective-tool-policy" import { getRulesSection, getSystemInfoSection, @@ -55,6 +63,8 @@ async function generatePrompt( todoList?: TodoItem[], modelId?: string, skillsManager?: SkillsManager, + disabledTools?: string[], + modelInfo?: ModelInfo, ): Promise { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -64,29 +74,29 @@ async function generatePrompt( const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0] const { roleDefinition, baseInstructions } = getModeSelection(mode, promptComponent, customModeConfigs) - // Check if MCP functionality should be included - const hasMcpGroup = modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp") - const allowedMcpServers = modeConfig.allowedMcpServers - - // Hoist the allowlist Set once (matches the sibling call sites, e.g. mcp_server.ts) instead - // of constructing a new Set on every `.filter` iteration. - const allowSet = allowedMcpServers ? new Set(allowedMcpServers) : undefined - - let hasMcpServers = false - if (mcpHub) { - const servers = allowSet ? mcpHub.getServers().filter((s) => allowSet.has(s.name)) : mcpHub.getServers() - hasMcpServers = servers.length > 0 - } - const shouldIncludeMcp = hasMcpGroup && hasMcpServers - const codeIndexManager = CodeIndexManager.getInstance(context, cwd) + // Resolve the single, request-scoped effective tool policy ONCE, then have every + // prompt section and the MCP short-circuit derive from it. This is the one source of + // truth shared by prompt generation, API tool construction, runtime validation, and + // preview, so the prose never advertises a tool the model cannot actually call. + const policy = resolveEffectiveToolPolicy({ + mode, + customModes: customModeConfigs, + mcpHub, + disabledTools, + modelInfo, + experiments, + todoListEnabled: settings?.todoListEnabled, + codeIndexManager, + }) + // Tool calling is native-only. const effectiveProtocol = "native" const [modesSection, skillsSection] = await Promise.all([ getModesSection(context), - getSkillsSection(skillsManager, mode as string), + getSkillsSection(skillsManager, mode as string, policy), ]) // Tools catalog is not included in the system prompt. @@ -98,25 +108,17 @@ ${markdownFormattingSection()} ${getSharedToolUseSection()}${toolsCatalog} - ${getToolUseGuidelinesSection()} + ${getToolUseGuidelinesSection(policy)} -${ - // Forward the hub only when the mode actually exposes the MCP group, and pass the per-mode - // allowlist through so the capabilities section filters servers using the SAME convention as - // the tool-listing layer (a single source of truth for which servers are visible). This keeps - // 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(policy)} ${modesSection} ${skillsSection ? `\n${skillsSection}` : ""} -${getRulesSection(cwd, settings)} +${getRulesSection(cwd, settings, policy)} -${getSystemInfoSection(cwd)} +${getSystemInfoSection(cwd, policy)} -${getObjectiveSection()} +${getObjectiveSection(policy)} ${await addCustomInstructions(baseInstructions, globalCustomInstructions || "", cwd, mode, { language: language ?? formatLanguage(vscode.env.language), @@ -144,6 +146,8 @@ export const SYSTEM_PROMPT = async ( todoList?: TodoItem[], modelId?: string, skillsManager?: SkillsManager, + disabledTools?: string[], + modelInfo?: ModelInfo, ): Promise => { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -172,5 +176,7 @@ export const SYSTEM_PROMPT = async ( todoList, modelId, skillsManager, + disabledTools, + modelInfo, ) } diff --git a/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts b/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts new file mode 100644 index 0000000000..54fb220e3f --- /dev/null +++ b/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts @@ -0,0 +1,285 @@ +import type { ModeConfig, ModelInfo } from "@roo-code/types" + +import type { EffectiveToolPolicy } from "../effective-tool-policy" +import { PROTOCOL_TOOLS, resolveEffectiveToolPolicy, buildToolRequirements } from "../effective-tool-policy" +import { getModeBySlug, defaultModeSlug } from "../../../../shared/modes" +import type { McpHub } from "../../../../services/mcp/McpHub" +import type { CodeIndexManager } from "../../../../services/code-index/manager" + +/** Build a policy by giving the custom mode `groups` (derived from a real custom mode config). */ +function policyFor( + groups: ModeConfig["groups"], + extra: Partial<{ + mcpHub: McpHub + disabledTools: string[] + modelInfo: ModelInfo + experiments: Record + todoListEnabled: boolean + codeIndexManager: CodeIndexManager + allowedMcpServers: string[] + }> = {}, +): EffectiveToolPolicy { + const customMode: ModeConfig = { + slug: "policy-test", + name: "Policy Under Test", + roleDefinition: "", + groups, + } + return resolveEffectiveToolPolicy({ + mode: "policy-test", + customModes: [customMode], + ...extra, + }) +} + +/** Minimal McpHub stub. Mirrors the McpServer shape the resolver reads (getServers, resources). */ +function makeMcpHub(servers: Array<{ name: string; resources?: unknown[]; tools?: unknown[] }>): McpHub { + return { getServers: () => servers } as unknown as McpHub +} + +/** CodeIndexManager stub with all "ready" flags true. */ +function enabledCodeIndexManager(): CodeIndexManager { + return { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true } as CodeIndexManager +} + +/** Build a ModelInfo satisfying the required schema fields, merged with test-specific overrides. */ +function modelInfo(partial?: Partial): ModelInfo { + return { contextWindow: 100_000, supportsPromptCache: true, ...partial } +} + +describe("resolveEffectiveToolPolicy - groups", () => { + it("grants read-group tools for a read mode", () => { + const policy = policyFor(["read"]) + expect(policy.tools.has("read_file")).toBe(true) + expect(policy.tools.has("codebase_search")).toBe(false) // gated by code index, off by default + expect(policy.tools.has("list_files")).toBe(true) + expect(policy.tools.has("search_files")).toBe(true) + }) + + it("grants edit-group tools for an edit mode", () => { + const policy = policyFor(["edit"]) + expect(policy.tools.has("write_to_file")).toBe(true) + expect(policy.tools.has("apply_diff")).toBe(true) + }) + + it("grants command-group tools for a command mode", () => { + const policy = policyFor(["command"]) + expect(policy.tools.has("execute_command")).toBe(true) + expect(policy.tools.has("read_command_output")).toBe(true) + }) + + it("combines groups", () => { + const policy = policyFor(["read", "edit", "command"]) + expect(policy.tools.has("read_file")).toBe(true) + expect(policy.tools.has("write_to_file")).toBe(true) + expect(policy.tools.has("execute_command")).toBe(true) + }) + + it("keeps always-available tools regardless of groups", () => { + const policy = policyFor([]) + // switch_mode/new_task are in the "modes" group but also always-available + expect(policy.tools.has("ask_followup_question")).toBe(true) + expect(policy.tools.has("update_todo_list")).toBe(true) + expect(policy.tools.has("skill")).toBe(true) + // run_slash_command is always-available but gated by the runSlashCommand experiment (plan step 8) + expect(policy.tools.has("run_slash_command")).toBe(false) + }) + + it("sets hasMcpGroup only when the mode has the mcp group", () => { + expect(policyFor(["mcp"]).hasMcpGroup).toBe(true) + expect(policyFor(["read"]).hasMcpGroup).toBe(false) + }) + + it("extracts the first edit-restriction tuple with fileRegex", () => { + const policy = policyFor(["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }]]) + expect(policy.editRestriction).toEqual({ fileRegex: "\\.md$", description: "Markdown files only" }) + }) + + it("returns undefined editRestriction when no edit tuple has a fileRegex", () => { + expect(policyFor(["edit"]).editRestriction).toBeUndefined() + }) +}) + +describe("resolveEffectiveToolPolicy - disabledTools", () => { + it("removes tools listed in disabledTools (canonical)", () => { + const policy = policyFor(["read", "edit", "command"], { disabledTools: ["execute_command"] }) + expect(policy.tools.has("execute_command")).toBe(false) + expect(policy.tools.has("read_file")).toBe(true) + }) + + it("removes tools by alias (alias normalization)", () => { + const policy = policyFor(["edit"], { disabledTools: ["write_file"] }) + expect(policy.tools.has("write_to_file")).toBe(false) + }) + + it("does not remove the protocol guarantee", () => { + expect( + policyFor(["read", "edit", "command"], { disabledTools: [...PROTOCOL_TOOLS] }).tools.has( + "attempt_completion", + ), + ).toBe(true) + }) +}) + +describe("resolveEffectiveToolPolicy - model customization", () => { + it("removes tools in modelInfo.excludedTools", () => { + const policy = policyFor(["read", "edit", "command"], { + modelInfo: modelInfo({ excludedTools: ["read_file"] }), + }) + expect(policy.tools.has("read_file")).toBe(false) + }) + + it("removes tools by excludedTools alias", () => { + const policy = policyFor(["edit"], { modelInfo: modelInfo({ excludedTools: ["write_file"] }) }) + expect(policy.tools.has("write_to_file")).toBe(false) + }) + + it("re-adds excludedTools that are protocol tools", () => { + const policy = policyFor(["read", "edit", "command"], { + modelInfo: modelInfo({ excludedTools: ["attempt_completion"] }), + }) + expect(policy.tools.has("attempt_completion")).toBe(true) + }) + + it("adds includedTools only when their group is allowed", () => { + // read group is allowed; codebase_search is in read. + const policy = policyFor(["read"], { + modelInfo: modelInfo({ excludedTools: [], includedTools: ["codebase_search"] }), + codeIndexManager: enabledCodeIndexManager(), + }) + expect(policy.tools.has("codebase_search")).toBe(true) + }) + + it("ignores includedTools outside the allowed group", () => { + // command group only; codebase_search is in read -> not added even when requested. + const policy = policyFor(["command"], { modelInfo: modelInfo({ includedTools: ["read_file"] }) }) + expect(policy.tools.has("read_file")).toBe(false) + }) +}) + +describe("resolveEffectiveToolPolicy - conditional gates", () => { + it("drops codebase_search unless the code index is enabled/configured/initialized", () => { + const modeWithIndex = policyFor(["read"], { codeIndexManager: enabledCodeIndexManager() }) + expect(modeWithIndex.tools.has("codebase_search")).toBe(true) + + const modeWithoutIndex = policyFor(["read"]) + expect(modeWithoutIndex.tools.has("codebase_search")).toBe(false) + }) + + it("drops update_todo_list when todoListEnabled is false", () => { + expect(policyFor(["read", "edit", "command"], { todoListEnabled: false }).tools.has("update_todo_list")).toBe( + false, + ) + expect(policyFor(["read", "edit", "command"], { todoListEnabled: true }).tools.has("update_todo_list")).toBe( + true, + ) + }) + + it("drops generate_image unless the imageGeneration experiment is enabled", () => { + expect( + policyFor(["read", "edit", "command"], { experiments: { imageGeneration: true } }).tools.has( + "generate_image", + ), + ).toBe(true) + expect(policyFor(["read", "edit", "command"]).tools.has("generate_image")).toBe(false) + }) + + it("drops run_slash_command unless the runSlashCommand experiment is enabled", () => { + expect( + policyFor(["read", "edit", "command"], { experiments: { runSlashCommand: true } }).tools.has( + "run_slash_command", + ), + ).toBe(true) + expect(policyFor(["read", "edit", "command"]).tools.has("run_slash_command")).toBe(false) + }) +}) + +describe("resolveEffectiveToolPolicy - MCP resource gate", () => { + it("keeps access_mcp_resource iff an allowed server exposes resources", () => { + const hasResources = policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s", resources: [{ uri: "r" }] }]) }) + expect(hasResources.tools.has("access_mcp_resource")).toBe(true) + + const noResources = policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s" }]) }) + expect(noResources.tools.has("access_mcp_resource")).toBe(false) + }) + + it("respects an explicit allowlist over the mode-config allowlist", () => { + const allowed = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "allowed", resources: [{ uri: "r" }] }]), + allowedMcpServers: ["allowed"], + }) + expect(allowed.tools.has("access_mcp_resource")).toBe(true) + + const wrongAllow = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "allowed", resources: [{ uri: "r" }] }]), + allowedMcpServers: ["blocked"], + }) + expect(wrongAllow.tools.has("access_mcp_resource")).toBe(false) + }) + + it("falls back to the mode config allowlist when no explicit allowlist is provided", () => { + const customMode: ModeConfig = { + slug: "policy-test", + name: "Restricted Mode", + roleDefinition: "", + groups: ["mcp"], + allowedMcpServers: ["blocked"], + } + const policy = resolveEffectiveToolPolicy({ + mode: "policy-test", + customModes: [customMode], + mcpHub: makeMcpHub([{ name: "allowed", resources: [{ uri: "r" }] }]), + }) + expect(policy.tools.has("access_mcp_resource")).toBe(false) + }) + + it("computes hasMcpTools from effective enabled tools and hasMcpResources from resources", () => { + const hasToolsOnly = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", description: "d" }] }]), + }) + expect(hasToolsOnly.hasMcpTools).toBe(true) + expect(hasToolsOnly.hasMcpResources).toBe(false) + + const hasResourcesOnly = policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s", resources: [{ uri: "r" }] }]) }) + expect(hasResourcesOnly.hasMcpTools).toBe(false) + expect(hasResourcesOnly.hasMcpResources).toBe(true) + + const hasNeither = policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s" }]) }) + expect(hasNeither.hasMcpTools).toBe(false) + expect(hasNeither.hasMcpResources).toBe(false) + }) +}) + +describe("resolveEffectiveToolPolicy - worst case (control-tools-only mode)", () => { + it("only exposes always-available + protocol tools when groups is empty", () => { + const policy = policyFor([]) + expect(policy.tools.has("read_file")).toBe(false) + expect(policy.tools.has("write_to_file")).toBe(false) + expect(policy.tools.has("execute_command")).toBe(false) + expect(policy.tools.has("attempt_completion")).toBe(true) // protocol guarantee + expect(policy.tools.has("switch_mode")).toBe(true) // always-available + }) +}) + +describe("buildToolRequirements", () => { + it("returns an empty map when disabledTools is undefined or empty", () => { + expect(buildToolRequirements(undefined)).toEqual({}) + expect(buildToolRequirements([])).toEqual({}) + }) + + it("maps disabled tools to false (including alias + canonical)", () => { + const reqs = buildToolRequirements(["write_file"]) + expect(reqs).toEqual({ write_file: false, write_to_file: false }) + }) + + it("skips protocol tools and their aliases", () => { + const reqs = buildToolRequirements([...PROTOCOL_TOOLS, "ask_followup_question", "switch_mode"]) + expect(Object.keys(reqs)).not.toContain("attempt_completion") + expect(reqs).toEqual({ ask_followup_question: false, switch_mode: false }) + }) + + it("adds alias + canonical for real aliases", () => { + const reqs = buildToolRequirements(["write_file"]) + expect(Object.keys(reqs).sort()).toEqual(["write_file", "write_to_file"].sort()) + }) +}) diff --git a/src/core/prompts/tools/effective-tool-policy.ts b/src/core/prompts/tools/effective-tool-policy.ts new file mode 100644 index 0000000000..8af3f564f0 --- /dev/null +++ b/src/core/prompts/tools/effective-tool-policy.ts @@ -0,0 +1,282 @@ +import type { ModeConfig, ToolGroup, ModelInfo, GroupEntry } from "@roo-code/types" +import { getModeBySlug, defaultModeSlug, getGroupName, getToolsForMode } from "../../../shared/modes" +import { TOOL_ALIASES, TOOL_GROUPS } from "../../../shared/tools" +import type { CodeIndexManager } from "../../../services/code-index/manager" +import type { McpHub } from "../../../services/mcp/McpHub" +import { isToolAllowedForMode } from "../../../core/tools/validateToolUse" +import { getMcpServerTools } from "./native-tools" + +/** + * Canonical tool names that participate in the task-completion protocol and must + * remain logically available even when a profile disables them. + * + * The effective tool policy re-adds every one of these after `disabledTools` and + * model-specific exclusions have been applied, so the system prompt and the API's + * logical allowed set always agree that these tools can be called. + * + * See decision D2 in the bugfix plan: `attempt_completion` is the only tool with + * no coherent prompt state when absent (the task loop can only exit through it), so + * it is the sole protocol guarantee today. + */ +export const PROTOCOL_TOOLS: readonly string[] = ["attempt_completion"] + +/** + * Extract the first edit restriction declared by a mode's groups, if any. + * + * A group entry may be either a bare group name (string) or a tuple of + * `[groupName, options]`. Only a tuple entry with a `fileRegex` establishes a + * prompt-visible edit restriction. + * + * @param groups The mode's group entries. + * @returns The first `{ fileRegex, description }` found, or undefined when the + * mode declares no restricted edit group. + */ +function getEditRestriction(groups: readonly GroupEntry[]): + | { + fileRegex: string + description?: string + } + | undefined { + for (const group of groups) { + const groupName = getGroupName(group) + if (groupName !== "edit") { + continue + } + if (Array.isArray(group) && group[1]?.fileRegex) { + return { fileRegex: group[1].fileRegex, description: group[1].description } + } + } + return undefined +} + +/** + * Reverse lookup map - maps alias name to canonical tool name. + * Built once at module load from the central TOOL_ALIASES constant. + */ +const ALIAS_TO_CANONICAL: Map = new Map( + Object.entries(TOOL_ALIASES).map(([alias, canonical]) => [alias, canonical]), +) + +/** + * Resolves a tool name to its canonical name. + * If the tool name is an alias, returns the canonical tool name. + * If it's already a canonical name or unknown, returns as-is. + * + * @param toolName - The tool name to resolve (may be an alias) + * @returns The canonical tool name + */ +export function resolveToolAlias(toolName: string): string { + const canonical = ALIAS_TO_CANONICAL.get(toolName) + return canonical ?? toolName +} + +export interface EffectiveToolPolicyInput { + mode: string + customModes?: ModeConfig[] + mcpHub?: McpHub + disabledTools?: string[] + modelInfo?: ModelInfo + experiments?: Record + todoListEnabled?: boolean + codeIndexManager?: CodeIndexManager + /** + * Optional explicit per-mode MCP server allowlist. When provided it takes + * precedence; when omitted the resolver falls back to the mode config's own + * allowlist (defense in depth), so a restricted mode can never retain + * `access_mcp_resource` based on resources from disallowed servers. + */ + allowedMcpServers?: string[] +} + +export interface EffectiveToolPolicy { + /** Canonical tool names logically available for this request (after all filters, incl. protocol guarantee) */ + tools: ReadonlySet + hasMcpGroup: boolean // mode's groups include "mcp" + hasMcpTools: boolean // ≥1 dynamic MCP tool enabled for allowed servers + hasMcpResources: boolean // ≥1 accessible resource on allowed servers + editRestriction?: { fileRegex: string; description?: string } +} + +/** + * True when at least one dynamic MCP tool (e.g. `mcp_serverName_toolName`) is + * enabled for the allowed servers. Used to gate the MCP capability bullet in the + * prompt so servers whose every tool is `enabledForPrompt: false` do not count. + */ +function resolveHasMcpTools(mcpHub?: McpHub, allowedServers?: string[]): boolean { + if (!mcpHub) { + return false + } + return getMcpServerTools(mcpHub, allowedServers).length > 0 +} + +/** + * True when `mcpHub` exposes at least one accessible resource on the allowed servers. + * + * When `allowedServers` is provided, only servers whose name is in the allowlist + * are considered, keeping the `access_mcp_resource` availability check consistent + * with the mode's MCP server allowlist. + */ +export function hasAnyMcpResources(mcpHub: McpHub, allowedServers?: string[]): boolean { + let servers = mcpHub.getServers() + if (allowedServers) { + const allowSet = new Set(allowedServers) + servers = servers.filter((server) => allowSet.has(server.name)) + } + return servers.some((server) => server.resources && server.resources.length > 0) +} + +/** + * Computes the request-scoped effective tool policy: the set of tool names + * logically available for a single request, together with the MCP and edit + * metadata the system prompt needs. + * + * This is the single source of truth shared by prompt generation, API tool + * construction, runtime validation, and preview. It performs, in order, the same + * steps `filterNativeToolsForMode` used to compute inline (steps 1-10 of that + * function), plus a protocol guarantee that re-adds `PROTOCOL_TOOLS`. + * + * @param input Mode, custom modes, MCP hub, disabled tools, model customization, + * experiment flags, todo-list enablement, and the code index manager. + * @returns An {@link EffectiveToolPolicy} describing the effective tool set. + */ +export function resolveEffectiveToolPolicy(input: EffectiveToolPolicyInput): EffectiveToolPolicy { + const { + mode, + customModes, + mcpHub, + disabledTools, + modelInfo, + experiments, + todoListEnabled, + codeIndexManager, + allowedMcpServers, + } = input + + // 1. Resolve mode config with default-slug fallback (existing behavior). + const modeSlug = mode ?? defaultModeSlug + const modeConfig = getModeBySlug(modeSlug, customModes) || getModeBySlug(defaultModeSlug, customModes)! + + // 2. Start from all tools granted by the mode's groups (including always-available tools). + const allowedToolNames = new Set(getToolsForMode(modeConfig.groups)) + + // 3. Filter through per-mode permission checks (feature/experiment flags, custom-mode overrides). + for (const tool of Array.from(allowedToolNames)) { + if (!isToolAllowedForMode(tool, modeSlug, customModes ?? [], undefined, undefined, experiments ?? {})) { + allowedToolNames.delete(tool) + } + } + + // 4. Apply model-specific tool customization (excluded tools removed; included tools added only when their group is allowed). + if (modelInfo) { + // Exclusions. + if (modelInfo.excludedTools?.length) { + for (const excluded of modelInfo.excludedTools) { + allowedToolNames.delete(resolveToolAlias(excluded)) + } + } + // Inclusions: only tools belonging to an allowed group are added. + if (modelInfo.includedTools?.length) { + const toolToGroup = new Map() + for (const [groupName, groupConfig] of Object.entries(TOOL_GROUPS)) { + groupConfig.tools.forEach((tool) => toolToGroup.set(tool, groupName as ToolGroup)) + groupConfig.customTools?.forEach((tool) => toolToGroup.set(tool, groupName as ToolGroup)) + } + + const allowedGroups = new Set( + modeConfig.groups.map((groupEntry: GroupEntry) => + Array.isArray(groupEntry) ? groupEntry[0] : groupEntry, + ), + ) + + for (const included of modelInfo.includedTools) { + const resolvedTool = resolveToolAlias(included) + const toolGroup = toolToGroup.get(resolvedTool) + if (toolGroup && allowedGroups.has(toolGroup)) { + allowedToolNames.add(resolvedTool) + } + } + } + } + + // 5. Drop codebase_search unless the code index is enabled, configured, and initialized. + if ( + !codeIndexManager || + !(codeIndexManager.isFeatureEnabled && codeIndexManager.isFeatureConfigured && codeIndexManager.isInitialized) + ) { + allowedToolNames.delete("codebase_search") + } + + // 6. Drop update_todo_list when the todo list is disabled. + if (todoListEnabled === false) { + allowedToolNames.delete("update_todo_list") + } + + // 7. Drop generate_image unless the image-generation experiment is enabled. + if (experiments?.imageGeneration !== true) { + allowedToolNames.delete("generate_image") + } + + // 8. Drop run_slash_command unless the run-slash-command experiment is enabled. + if (experiments?.runSlashCommand !== true) { + allowedToolNames.delete("run_slash_command") + } + + // 9. Drop disabledTools entries (alias-resolved). + if (disabledTools?.length) { + for (const toolName of disabledTools) { + allowedToolNames.delete(resolveToolAlias(toolName)) + } + } + + // 10. Drop access_mcp_resource unless allowed servers expose accessible resources. + // Fall back to the mode config's own allowlist when the caller omits the + // parameter, so the restriction is enforced regardless of call site + // (defense in depth). + const effectiveAllowedMcpServers = allowedMcpServers ?? modeConfig.allowedMcpServers + if (!mcpHub || !hasAnyMcpResources(mcpHub, effectiveAllowedMcpServers)) { + allowedToolNames.delete("access_mcp_resource") + } + + // 11. Protocol guarantee: re-add every protocol tool so the logical set and + // the runtime validator both agree it is callable even when disabled. + for (const tool of PROTOCOL_TOOLS) { + allowedToolNames.add(resolveToolAlias(tool)) + } + + const hasMcpGroup = modeConfig.groups.some((groupEntry: GroupEntry) => getGroupName(groupEntry) === "mcp") + const hasMcpResources = !!mcpHub && hasAnyMcpResources(mcpHub, effectiveAllowedMcpServers) + const hasMcpTools = resolveHasMcpTools(mcpHub, effectiveAllowedMcpServers) + + return { + tools: allowedToolNames, + hasMcpGroup, + hasMcpTools, + hasMcpResources, + editRestriction: getEditRestriction(modeConfig.groups), + } +} + +/** + * Builds the runtime `toolRequirements` map (tool name → false) from a list of + * disabled tool names. Protocol tools and their aliases are intentionally + * skipped so that `attempt_completion` (and every call site that disables it) + * can never be marked un-callable at runtime. + * + * @param disabledTools The raw disabled-tools list (may contain aliases). + * @returns A map of disabled canonical/alias names to `false`. + */ +export function buildToolRequirements(disabledTools?: string[]): Record { + const requirements: Record = {} + if (!disabledTools?.length) { + return requirements + } + for (const toolName of disabledTools) { + const canonical = resolveToolAlias(toolName) + if (PROTOCOL_TOOLS.includes(canonical) || PROTOCOL_TOOLS.includes(toolName)) { + continue + } + requirements[toolName] = false + requirements[canonical] = false + } + return requirements +} diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 2b31714a4c..2b38fa1eb9 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -1,19 +1,16 @@ 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 { defaultModeSlug } from "../../../shared/modes" +import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES } from "../../../shared/tools" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" +import { resolveEffectiveToolPolicy, resolveToolAlias } from "./effective-tool-policy" import { isToolAllowedForMode } from "../../../core/tools/validateToolUse" -/** - * Reverse lookup map - maps alias name to canonical tool name. - * Built once at module load from the central TOOL_ALIASES constant. - */ -const ALIAS_TO_CANONICAL: Map = new Map( - Object.entries(TOOL_ALIASES).map(([alias, canonical]) => [alias, canonical]), -) +// Re-export the resolver's alias helper so existing importers of this module +// (NativeToolCallParser, presentAssistantMessage, build-tools) keep binding to the +// single canonical implementation in effective-tool-policy.ts. +export { resolveToolAlias } /** * Canonical to aliases map - maps canonical tool name to array of alias names. @@ -85,19 +82,6 @@ function getOrCreateRenamedTool( return renamedTool } -/** - * Resolves a tool name to its canonical name. - * If the tool name is an alias, returns the canonical tool name. - * If it's already a canonical name or unknown, returns as-is. - * - * @param toolName - The tool name to resolve (may be an alias) - * @returns The canonical tool name - */ -export function resolveToolAlias(toolName: string): string { - const canonical = ALIAS_TO_CANONICAL.get(toolName) - return canonical ?? toolName -} - /** * Applies tool alias resolution to a set of allowed tools. * Resolves any aliases to their canonical tool names. @@ -127,88 +111,6 @@ export function getToolAliasGroup(toolName: string): readonly string[] { return ALIAS_GROUPS.get(toolName) ?? [toolName] } -/** - * Apply model-specific tool customization to a set of allowed tools. - * - * This function filters tools based on model configuration: - * 1. Removes tools specified in modelInfo.excludedTools - * 2. Adds tools from modelInfo.includedTools (only if they belong to allowed groups) - * - * @param allowedTools - Set of tools already allowed by mode configuration - * @param modeConfig - Current mode configuration to check tool groups - * @param modelInfo - Model configuration with tool customization - * @returns Modified set of tools after applying model customization - */ -/** - * Result of applying model tool customization. - * Contains the set of allowed tools and any alias renames to apply. - */ -interface ModelToolCustomizationResult { - allowedTools: Set - /** Maps canonical tool name to alias name for tools that should be renamed */ - aliasRenames: Map -} - -export function applyModelToolCustomization( - allowedTools: Set, - modeConfig: ModeConfig, - modelInfo?: ModelInfo, -): ModelToolCustomizationResult { - if (!modelInfo) { - return { allowedTools, aliasRenames: new Map() } - } - - const result = new Set(allowedTools) - const aliasRenames = new Map() - - // Apply excluded tools (remove from allowed set) - if (modelInfo.excludedTools && modelInfo.excludedTools.length > 0) { - modelInfo.excludedTools.forEach((tool) => { - const resolvedTool = resolveToolAlias(tool) - result.delete(resolvedTool) - }) - } - - // Apply included tools (add to allowed set, but only if they belong to an allowed group) - if (modelInfo.includedTools && modelInfo.includedTools.length > 0) { - // Build a map of tool -> group for all tools in TOOL_GROUPS (including customTools) - const toolToGroup = new Map() - for (const [groupName, groupConfig] of Object.entries(TOOL_GROUPS)) { - // Add regular tools - groupConfig.tools.forEach((tool) => { - toolToGroup.set(tool, groupName as ToolGroup) - }) - // Add customTools (opt-in only tools) - if (groupConfig.customTools) { - groupConfig.customTools.forEach((tool) => { - toolToGroup.set(tool, groupName as ToolGroup) - }) - } - } - - // Get the list of allowed groups for this mode - const allowedGroups = new Set( - modeConfig.groups.map((groupEntry) => (Array.isArray(groupEntry) ? groupEntry[0] : groupEntry)), - ) - - // Add included tools only if they belong to an allowed group - // If the tool was specified as an alias, track the rename - modelInfo.includedTools.forEach((tool) => { - const resolvedTool = resolveToolAlias(tool) - const toolGroup = toolToGroup.get(resolvedTool) - if (toolGroup && allowedGroups.has(toolGroup)) { - result.add(resolvedTool) - // If the tool was specified as an alias, rename it in the API - if (tool !== resolvedTool) { - aliasRenames.set(resolvedTool, tool) - } - } - }) - } - - return { allowedTools: result, aliasRenames } -} - /** * Filters native tools based on mode restrictions and model customization. * This ensures native tools are filtered consistently with mode/tool permissions. @@ -235,94 +137,39 @@ export function filterNativeToolsForMode( mcpHub?: McpHub, allowedMcpServers?: string[], ): OpenAI.Chat.ChatCompletionTool[] { - // Get mode configuration and all tools for this mode - const modeSlug = mode ?? defaultModeSlug - let modeConfig = getModeBySlug(modeSlug, customModes) - - // Fallback to default mode if current mode config is not found - // This ensures the agent always has functional tools even if a custom mode is deleted - // or configuration becomes corrupted - if (!modeConfig) { - modeConfig = getModeBySlug(defaultModeSlug, customModes)! - } - - // Get all tools for this mode (including always-available tools) - const allToolsForMode = getToolsForMode(modeConfig.groups) - - // Filter to only tools that pass permission checks - let allowedToolNames = new Set( - allToolsForMode.filter((tool) => - isToolAllowedForMode( - tool as ToolName, - modeSlug, - customModes ?? [], - undefined, - undefined, - experiments ?? {}, - ), - ), - ) - - // Apply model-specific tool customization + // Resolve the single, request-scoped effective tool policy. The filter below + // consumes only its `tools` set (plus alias renames from model customization), + // so prompt generation and API tool construction agree on the logical allowed + // set. Behavior for all non-protocol tools is byte-identical to the previous + // inline computation; attempt_completion is always advertised (the protocol + // guarantee), even if it appears in disabledTools. const modelInfo = settings?.modelInfo as ModelInfo | undefined - const { allowedTools: customizedTools, aliasRenames } = applyModelToolCustomization( - allowedToolNames, - modeConfig, - modelInfo, - ) - allowedToolNames = customizedTools - - // Conditionally exclude codebase_search if feature is disabled or not configured - if ( - !codeIndexManager || - !(codeIndexManager.isFeatureEnabled && codeIndexManager.isFeatureConfigured && codeIndexManager.isInitialized) - ) { - allowedToolNames.delete("codebase_search") - } - - // Conditionally exclude update_todo_list if disabled in settings - if (settings?.todoListEnabled === false) { - allowedToolNames.delete("update_todo_list") - } - - // Conditionally exclude generate_image if experiment is not enabled - if (!experiments?.imageGeneration) { - allowedToolNames.delete("generate_image") - } - - // Conditionally exclude run_slash_command if experiment is not enabled - if (!experiments?.runSlashCommand) { - allowedToolNames.delete("run_slash_command") - } - - // Remove tools that are explicitly disabled via the disabledTools setting - if (settings?.disabledTools?.length) { - for (const toolName of settings.disabledTools) { - // Normalize aliases so disabling a legacy alias (e.g. "search_and_replace") - // also disables the canonical tool (e.g. "edit"). - const resolvedToolName = resolveToolAlias(toolName) - allowedToolNames.delete(resolvedToolName) - } - } - // 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. - // Fall back to the mode config's own allowlist when the caller omits the parameter, so the - // restriction is enforced regardless of call site (defense in depth). - const effectiveAllowedMcpServers = allowedMcpServers ?? modeConfig.allowedMcpServers - if (!mcpHub || !hasAnyMcpResources(mcpHub, effectiveAllowedMcpServers)) { - allowedToolNames.delete("access_mcp_resource") - } - - // Filter native tools based on allowed tool names and apply alias renames + const policy = resolveEffectiveToolPolicy({ + mode: mode ?? defaultModeSlug, + customModes, + mcpHub, + disabledTools: settings?.disabledTools, + modelInfo, + experiments, + todoListEnabled: settings?.todoListEnabled, + codeIndexManager, + allowedMcpServers, + }) + + // Apply model-specific alias renames (canonical -> alias) to the allowed set. + // Included-tools customization may rename a tool to the alias the caller asked + // for; excluded/always-available semantics are already resolved by the resolver. + const aliasRenames = resolveModelAliasRenames(modelInfo, policy.tools) + + // Filter native tools based on the allowed tool names and apply alias renames const filteredTools: OpenAI.Chat.ChatCompletionTool[] = [] for (const tool of nativeTools) { // Handle both ChatCompletionTool and ChatCompletionCustomTool if ("function" in tool && tool.function) { const toolName = tool.function.name - if (allowedToolNames.has(toolName)) { + if (policy.tools.has(resolveToolAlias(toolName))) { // Check if this tool should be renamed to an alias const aliasName = aliasRenames.get(toolName) if (aliasName) { @@ -339,19 +186,26 @@ export function filterNativeToolsForMode( } /** - * Helper function to check if any MCP server has resources available. - * - * When `allowedServers` is provided, only servers whose name is in the allowlist are considered. - * This keeps the `access_mcp_resource` availability check consistent with the mode's MCP server - * allowlist so a restricted mode cannot retain the tool based on resources from disallowed servers. + * Computes canonical -> alias renames from model-specific included-tools + * customization, but only for tools that remain in the effective policy's allowed + * set (exclusions are already applied by the resolver). Preserves the previous + * behavior where an alias listed in includedTools renames the canonical tool. */ -function hasAnyMcpResources(mcpHub: McpHub, allowedServers?: string[]): boolean { - let servers = mcpHub.getServers() - if (allowedServers) { - const allowSet = new Set(allowedServers) - servers = servers.filter((server) => allowSet.has(server.name)) +function resolveModelAliasRenames( + modelInfo: ModelInfo | undefined, + allowedTools: ReadonlySet, +): Map { + const aliasRenames = new Map() + if (!modelInfo?.includedTools?.length) { + return aliasRenames + } + for (const included of modelInfo.includedTools) { + const canonical = resolveToolAlias(included) + if (canonical !== included && allowedTools.has(canonical)) { + aliasRenames.set(canonical, included) + } } - return servers.some((server) => server.resources && server.resources.length > 0) + return aliasRenames } /** diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 37281a9010..206c825009 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4082,6 +4082,8 @@ export class Task extends EventEmitter implements TaskLike { undefined, // todoList this.api.getModel().id, provider.getSkillsManager(), + state?.disabledTools, + modelInfo, ) })() } 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..0edd925a92 --- /dev/null +++ b/src/core/task/__tests__/build-tools.spec.ts @@ -0,0 +1,141 @@ +// npx vitest src/core/task/__tests__/build-tools.spec.ts +// +// Gemini `includeAllToolsWithRestrictions` path: with the flag on, `tools` +// contains ALL declarations while `allowedFunctionNames` is derived from the +// resolver-filtered set — so a disabled `attempt_completion` is still allowed +// (protocol guarantee) and `disabledTools`-removed tools are excluded (plan §8 / D5). + +import type OpenAI from "openai" + +import type { ModeConfig, ModelInfo } from "@roo-code/types" + +import type { ClineProvider } from "../../webview/ClineProvider" +import type { McpHub } from "../../../services/mcp/McpHub" + +vi.mock("../../../services/code-index/manager", () => ({ + CodeIndexManager: { + getInstance: () => ({ isFeatureEnabled: false, isFeatureConfigured: false, isInitialized: false }), + }, +})) + +// Keeps the test independent of the bundled @roo-code/core package; the +// customTools experiment stays off in every case below. +vi.mock("@roo-code/core", () => ({ + customToolRegistry: { + loadFromDirectoriesIfStale: vi.fn(), + getAllSerialized: () => [], + }, + formatNative: vi.fn(), +})) + +import { buildNativeToolsArrayWithRestrictions } from "../build-tools" + +/** + * ClineProvider is a heavy class; build-tools only reads `context` and + * `getMcpHub()` from it, so a minimal object literal stands in. The single + * double assertion in this file. + */ +function makeProvider(): ClineProvider { + const provider = { + context: { extensionPath: "/mock", globalStoragePath: "/mock", storagePath: "/mock", logPath: "/mock" }, + getMcpHub: () => ({ getServers: () => [] }) as unknown as McpHub, + } + return provider as unknown as ClineProvider +} + +function toolNames(tools: OpenAI.Chat.ChatCompletionTool[]): string[] { + return tools + .filter((t): t is OpenAI.Chat.ChatCompletionFunctionTool => "function" in t && Boolean(t.function)) + .map((t) => t.function.name) +} + +describe("buildNativeToolsArrayWithRestrictions — Gemini includeAllToolsWithRestrictions", () => { + const provider = makeProvider() + + it("sends all declarations but restricts allowedFunctionNames (protocol tool stays allowed)", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration: undefined, + disabledTools: ["execute_command", "attempt_completion"], + includeAllToolsWithRestrictions: true, + }) + + // All tools are still advertised (declarations), including the two + // disabled ones. + expect(toolNames(result.tools)).toContain("execute_command") + expect(toolNames(result.tools)).toContain("attempt_completion") + + // But the logical set (allowedFunctionNames) honors the policy: + // attempt_completion is a protocol tool and stays allowed even though + // disabledTools lists it; execute_command is removed. + expect(result.allowedFunctionNames).toContain("attempt_completion") + expect(result.allowedFunctionNames).not.toContain("execute_command") + }) + + it("flows mode filtering through the resolver into allowedFunctionNames", async () => { + const customModes: ModeConfig[] = [ + { + slug: "arch", + name: "Architect-ish", + roleDefinition: "", + groups: ["read", ["edit", { fileRegex: "\\.md$" }]], + }, + ] + + const result = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: "/test/path", + mode: "arch", + customModes, + experiments: {}, + apiConfiguration: undefined, + includeAllToolsWithRestrictions: true, + }) + + // The mode's groups do not include "command", so execute_command is not + // in the logical set even though it is advertised in tools. + expect(toolNames(result.tools)).toContain("execute_command") + expect(result.allowedFunctionNames).not.toContain("execute_command") + }) + + it("default path (flag omitted) omits disabled tools from the sent declarations", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration: undefined, + disabledTools: ["execute_command"], + }) + + // Non-Gemini path unchanged: disabled tools are not sent at all. + expect(toolNames(result.tools)).not.toContain("execute_command") + expect(result.allowedFunctionNames).toBeUndefined() + }) + + it("excludes modelInfo.excludedTools from allowedFunctionNames", async () => { + const modelInfo: ModelInfo = { + contextWindow: 100_000, + supportsPromptCache: true, + excludedTools: ["read_file"], + } + + const result = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration: undefined, + modelInfo, + includeAllToolsWithRestrictions: true, + }) + + expect(result.allowedFunctionNames).not.toContain("read_file") + }) +}) diff --git a/src/core/webview/__tests__/generateSystemPrompt.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.spec.ts new file mode 100644 index 0000000000..417ad2c9f6 --- /dev/null +++ b/src/core/webview/__tests__/generateSystemPrompt.spec.ts @@ -0,0 +1,226 @@ +// npx vitest src/core/webview/__tests__/generateSystemPrompt.spec.ts +// +// Preview parity: generateSystemPrompt (the webview preview path) must produce +// the same CAPABILITIES / RULES / SYSTEM INFORMATION sections as a direct +// SYSTEM_PROMPT call built from the *same* inputs — including a full ModelInfo, +// so model-level excludedTools/includedTools are honored in the preview exactly +// like the runtime path (plan §6.3 / §8, and fix-plan issue 8: the old +// `{ isStealthModel }`-only typing silently allowed the preview to ignore them). + +vi.mock("os", () => ({ + default: { + homedir: () => "/home/user", + platform: () => "linux", + arch: () => "x64", + type: () => "Linux", + release: () => "5.4.0", + hostname: () => "test-host", + tmpdir: () => "/tmp", + endianness: () => "LE", + loadavg: () => [0, 0, 0], + totalmem: () => 8589934592, + freemem: () => 4294967296, + cpus: () => [], + networkInterfaces: () => ({}), + userInfo: () => ({ username: "test", uid: 1000, gid: 1000, shell: "/bin/bash", homedir: "/home/user" }), + }, + homedir: () => "/home/user", + platform: () => "linux", + arch: () => "x64", + type: () => "Linux", + release: () => "5.4.0", + hostname: () => "test-host", + tmpdir: () => "/tmp", + endianness: () => "LE", + loadavg: () => [0, 0, 0], + totalmem: () => 8589934592, + freemem: () => 4294967296, + cpus: () => [], + networkInterfaces: () => ({}), + userInfo: () => ({ username: "test", uid: 1000, gid: 1000, shell: "/bin/bash", homedir: "/home/user" }), +})) + +vi.mock("os-name", () => ({ + default: () => "Linux", +})) + +vi.mock("fs/promises") + +import * as vscode from "vscode" + +import type { ModelInfo } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { SYSTEM_PROMPT } from "../../prompts/system" +import { generateSystemPrompt } from "../generateSystemPrompt" +import type { ClineProvider } from "../ClineProvider" +import "../../../utils/path" + +// Mock vscode — generateSystemPrompt reads env.language and workspace config. +vi.mock("vscode", () => ({ + env: { + language: "en", + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/test/path" } }], + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + }), + getWorkspaceFolder: vi.fn().mockReturnValue({ uri: { fsPath: "/test/path" } }), + }, + window: { + activeTextEditor: undefined, + }, + EventEmitter: vi.fn().mockImplementation(function () { + return { + event: vi.fn(), + fire: vi.fn(), + dispose: vi.fn(), + } + }), +})) + +vi.mock("../../../utils/shell", () => ({ + getShell: () => "/bin/zsh", +})) + +// Mock the section builders that touch the filesystem / extension context so the +// parity comparison is stable and independent of workspace state. +vi.mock("../../prompts/sections/modes", () => ({ + getModesSection: vi.fn().mockImplementation(async () => `====\n\nMODES\n\n- Test modes section`), +})) + +vi.mock("../../prompts/sections/custom-instructions", () => ({ + addCustomInstructions: vi.fn().mockImplementation(async () => ""), +})) + +// The preview must consume a *complete* ModelInfo from the API handler. This +// locks in the issue-8 contract: if generateSystemPrompt ever narrows the local +// modelInfo back down, the excludedTools sub-assertion below fails. +const fullModelInfo: ModelInfo = { + contextWindow: 100_000, + supportsPromptCache: true, + excludedTools: ["read_file"], +} + +// Note: the module under test imports `../../api` from src/core/webview, which +// resolves to src/api — from this spec's directory (one level deeper) that is +// `../../../api`. +vi.mock("../../../api", () => ({ + buildApiHandler: () => ({ + getModel: () => ({ id: "m", info: fullModelInfo }), + }), +})) + +// Minimal mock ExtensionContext, mirroring the pattern in system-prompt.spec.ts. +const mockContext = { + extensionPath: "/mock/extension/path", + globalStoragePath: "/mock/storage/path", + storagePath: "/mock/storage/path", + logPath: "/mock/log/path", + subscriptions: [], + workspaceState: { + get: () => undefined, + update: () => Promise.resolve(), + }, + globalState: { + get: () => undefined, + update: () => Promise.resolve(), + setKeysForSync: () => {}, + }, + extensionUri: { fsPath: "/mock/extension/path" }, + globalStorageUri: { fsPath: "/mock/settings/path" }, + asAbsolutePath: (relativePath: string) => `/mock/extension/path/${relativePath}`, + extension: { + packageJSON: { + version: "1.0.0", + }, + }, +} as unknown as vscode.ExtensionContext + +const fullSettings = { + todoListEnabled: true, + useAgentRules: true, + newTaskRequireTodos: false, +} + +describe("generateSystemPrompt preview parity", () => { + // Section-scoped extraction: capture the text between two "====" headers so + // the comparison is limited to the sections the tool policy drives. + function extractSection(prompt: string, header: string): string { + const marker = `\n\n${header}\n\n` + const idx = prompt.indexOf(marker) + expect(idx).toBeGreaterThan(-1) + const afterHeader = prompt.slice(idx + marker.length) + const nextMarker = afterHeader.indexOf("\n\n====") + return nextMarker === -1 ? afterHeader : afterHeader.slice(0, nextMarker) + } + + /** + * ClineProvider is a heavy class; the preview only touches these members, so + * a minimal object literal stands in for it. This is the single double + * assertion in this spec. + */ + const fakeProvider = { + context: mockContext, + cwd: "/test/path", + getState: vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: providerIdentifiers.openai, modelId: "gpt-4o" }, + customModePrompts: undefined, + customInstructions: undefined, + mcpEnabled: false, + experiments: {}, + language: undefined, + enableSubfolderRules: false, + disabledTools: undefined, + }), + getMcpHub: vi.fn(), + getCurrentTask: vi.fn().mockReturnValue(undefined), + getSkillsManager: vi.fn().mockReturnValue(undefined), + customModesManager: { + getCustomModes: vi.fn().mockResolvedValue([]), + }, + } as unknown as ClineProvider + + it("produces identical CAPABILITIES, RULES, and SYSTEM INFORMATION sections for the same inputs", async () => { + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + // The direct SYSTEM_PROMPT call uses exactly the inputs the webview path + // builds: same disabledTools (undefined), same full modelInfo, same + // settings shape. + const direct = await SYSTEM_PROMPT( + mockContext, + "/test/path", + false, + undefined, // mcpHub + undefined, // diffStrategy + "code", + undefined, // customModePrompts + undefined, // customModes + undefined, // globalCustomInstructions + {}, // experiments + undefined, // language + undefined, // rooIgnoreInstructions + fullSettings, // settings + undefined, // todoList + undefined, // modelId + undefined, // skillsManager + undefined, // disabledTools + fullModelInfo, // modelInfo + ) + + for (const header of ["CAPABILITIES", "RULES", "SYSTEM INFORMATION"]) { + expect(extractSection(preview, header)).toEqual(extractSection(direct, header)) + } + }) + + it("honors the full modelInfo.excludedTools in the preview output", async () => { + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + const capabilities = extractSection(preview, "CAPABILITIES") + + // read_file is excluded by the model info: no "read files" clause. + expect(capabilities).not.toContain("read files") + // Other clauses survive, proving the exclusion is scoped to that tool. + expect(capabilities).toContain("execute CLI commands") + }) +}) diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index 8af2f5ff5d..b321b0fafe 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" @@ -18,6 +19,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web experiments, language, enableSubfolderRules, + disabledTools, } = await provider.getState() const diffStrategy = new MultiSearchReplaceDiffStrategy() @@ -29,9 +31,11 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web const rooIgnoreInstructions = provider.getCurrentTask()?.rooIgnoreController?.getInstructions() - // Create a temporary API handler to check model info for stealth mode. + // Create a temporary API handler to fetch the full model info for the preview. // This avoids relying on an active Cline instance which might not exist during preview. - let modelInfo: { isStealthModel?: boolean } | undefined + // The full ModelInfo flows into SYSTEM_PROMPT so the preview honors + // excludedTools/includedTools exactly like the runtime path (plan §6.3 parity). + let modelInfo: ModelInfo | undefined try { const tempApiHandler = buildApiHandler(apiConfiguration) modelInfo = tempApiHandler.getModel().info @@ -64,6 +68,8 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web undefined, // todoList undefined, // modelId provider.getSkillsManager(), + disabledTools, + modelInfo, ) return systemPrompt From 41a769880bde5765d5efcc3d36625baf2d95ccf4 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Wed, 2 Sep 2026 16:21:45 +0200 Subject: [PATCH 02/39] 1st round of fixes --- src/core/prompts/__tests__/sections.spec.ts | 18 +++ .../sections/__tests__/objective.spec.ts | 13 ++ src/core/prompts/sections/objective.ts | 10 +- src/core/prompts/sections/rules.ts | 15 +- .../effective-tool-policy-warn.spec.ts | 58 +++++++ .../__tests__/effective-tool-policy.spec.ts | 22 +++ .../prompts/tools/effective-tool-policy.ts | 60 ++++++- .../prompts/tools/filter-tools-for-mode.ts | 150 +----------------- src/eslint-suppressions.json | 2 +- 9 files changed, 177 insertions(+), 171 deletions(-) create mode 100644 src/core/prompts/tools/__tests__/effective-tool-policy-warn.spec.ts diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index c260de4698..8051a6fa13 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -344,6 +344,24 @@ describe("getRulesSection", () => { const result = getRulesSection(cwd, settings, policyFor(["read", "edit", "command"])) expect(result).toContain("RULES") }) + + it("states the attempt_completion protocol rule unconditionally", () => { + // F6: the completion sentence is protocol wording — emitted even when the policy + // does not advertise attempt_completion. A raw literal is required: the + // resolver-backed policyFor cannot express this (protocol guarantee re-adds the + // tool in resolveEffectiveToolPolicy step 11). + const rawPolicy: EffectiveToolPolicy = { + tools: new Set(["read_file"]), + hasMcpGroup: false, + hasMcpTools: false, + hasMcpResources: false, + } + + expect(rawPolicy.tools.has("attempt_completion")).toBe(false) + expect(getRulesSection(cwd, settings, rawPolicy)).toContain( + "you must use the attempt_completion tool to present the result to the user", + ) + }) }) describe("getSystemInfoSection", () => { diff --git a/src/core/prompts/sections/__tests__/objective.spec.ts b/src/core/prompts/sections/__tests__/objective.spec.ts index 67435782b1..2a3b4cbc36 100644 --- a/src/core/prompts/sections/__tests__/objective.spec.ts +++ b/src/core/prompts/sections/__tests__/objective.spec.ts @@ -55,4 +55,17 @@ describe("getObjectiveSection", () => { expect(objective).toContain("OBJECTIVE") expect(objective).toContain("You accomplish a given task iteratively") }) + + it("still names attempt_completion unconditionally when the tool is not advertised", () => { + // F6: step 4 names attempt_completion, a protocol tool, so the wording is emitted + // even when the policy's tools set does not include it. The local policyFor builds + // the policy object directly (no resolver), so policyFor([]) provably excludes + // attempt_completion. + const policy = policyFor([]) + + expect(policy.tools.has("attempt_completion")).toBe(false) + expect(getObjectiveSection(policy)).toContain( + "you must use the attempt_completion tool to present the result of the task to the user", + ) + }) }) diff --git a/src/core/prompts/sections/objective.ts b/src/core/prompts/sections/objective.ts index 7987213487..294686fcc6 100644 --- a/src/core/prompts/sections/objective.ts +++ b/src/core/prompts/sections/objective.ts @@ -5,8 +5,8 @@ import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" * * Step 3's guidance to ask the user via ask_followup_question is replaced with * best-effort phrasing when that tool is not in the request's effective policy. - * Step 4 names attempt_completion, which is a protocol guarantee and thus always - * present — but it is gated on the policy for symmetry. + * Step 4 names attempt_completion, a protocol tool that is always present in the + * effective tool policy, so it is emitted unconditionally. * * @param policy The request's effective tool policy. */ @@ -15,10 +15,6 @@ export function getObjectiveSection(policy: EffectiveToolPolicy): string { ? "ask the user to provide the missing parameters using the ask_followup_question tool" : "state your assumptions and proceed with the best available value" - const completionStep = policy.tools.has("attempt_completion") - ? "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." - : "Once you've completed the user's task, present the result of the task to the user." - return `==== OBJECTIVE @@ -28,6 +24,6 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. 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. 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, ${askStep}. DO NOT ask for more information on optional parameters if it is not provided. -4. ${completionStep} +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.` } diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts index f712054eff..fe217eacc5 100644 --- a/src/core/prompts/sections/rules.ts +++ b/src/core/prompts/sections/rules.ts @@ -88,7 +88,6 @@ export function getRulesSection( const hasExecuteCommand = policy.tools.has("execute_command") const hasAskFollowupQuestion = policy.tools.has("ask_followup_question") const hasListFiles = policy.tools.has("list_files") - const hasAttemptCompletion = policy.tools.has("attempt_completion") const hasReadFile = policy.tools.has("read_file") const rules: string[] = [] @@ -125,17 +124,9 @@ export function getRulesSection( "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.", ) - if (hasAttemptCompletion) { - rules.push( - "Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.", - ) - } else { - // attempt_completion is a protocol guarantee and is essentially always present; - // the fallback keeps the guidance coherent if it were ever excluded. - rules.push( - "Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, provide your best-effort result and state your assumptions; the user may respond with feedback after completion.", - ) - } + rules.push( + "Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.", + ) if (hasAskFollowupQuestion) { rules.push( diff --git a/src/core/prompts/tools/__tests__/effective-tool-policy-warn.spec.ts b/src/core/prompts/tools/__tests__/effective-tool-policy-warn.spec.ts new file mode 100644 index 0000000000..49dca3aa83 --- /dev/null +++ b/src/core/prompts/tools/__tests__/effective-tool-policy-warn.spec.ts @@ -0,0 +1,58 @@ +import { resolveEffectiveToolPolicy } from "../effective-tool-policy" + +/** + * F1: `resolveEffectiveToolPolicy` must warn (once per process, per protocol + * tool) when `disabledTools` tries to disable a protocol tool, since the + * protocol guarantee makes such a disable a no-op. + * + * These tests live in their own file (not in `effective-tool-policy.spec.ts`) + * because the warn-dedupe set is module-level state and that spec already + * resolves a policy with `disabledTools: [...PROTOCOL_TOOLS]`, which would + * prime the set and make the "warned exactly once" assertion silently fail. + * Vitest gives each test file a fresh module registry, so the dedupe state + * starts empty here. + * + * Note: the "warns once / dedupes" assertions are combined into a single test + * that keeps one spy active across two resolves, because the dedupe set is + * shared across `it` blocks within a file — a later test's fresh spy would see + * zero calls if an earlier test had already primed the set. + */ +describe("resolveEffectiveToolPolicy - protocol override warning", () => { + /** Build a resolver input for a mode with all standard tool groups. */ + function input(disabledTools?: string[]) { + return { mode: "code", disabledTools } + } + + it("warns exactly once for a disabled protocol tool, dedupes on repeat, and keeps the tool available", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + const policy = resolveEffectiveToolPolicy(input(["attempt_completion"])) + + // First resolve: warns exactly once and names the tool. + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy.mock.calls[0]?.[0]).toContain("attempt_completion") + // The protocol guarantee still keeps the tool available. + expect(policy.tools.has("attempt_completion")).toBe(true) + + // Second resolve with the same protocol tool: no additional warn (dedupe). + resolveEffectiveToolPolicy(input(["attempt_completion", "execute_command"])) + expect(warnSpy).toHaveBeenCalledTimes(1) + } finally { + warnSpy.mockRestore() + } + }) + + it("does not warn when disabledTools contains no protocol tools", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + const policy = resolveEffectiveToolPolicy(input(["execute_command", "read_file"])) + + expect(warnSpy).not.toHaveBeenCalled() + // Non-protocol disables still apply. + expect(policy.tools.has("execute_command")).toBe(false) + expect(policy.tools.has("read_file")).toBe(false) + } finally { + warnSpy.mockRestore() + } + }) +}) diff --git a/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts b/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts index 54fb220e3f..205adae278 100644 --- a/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts +++ b/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts @@ -248,6 +248,28 @@ describe("resolveEffectiveToolPolicy - MCP resource gate", () => { expect(hasNeither.hasMcpTools).toBe(false) expect(hasNeither.hasMcpResources).toBe(false) }) + + it("returns hasMcpTools false when the only tool has enabledForPrompt: false", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", enabledForPrompt: false }] }]), + }) + expect(policy.hasMcpTools).toBe(false) + }) + + it("returns hasMcpTools true when a tool has enabledForPrompt: true", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", enabledForPrompt: true }] }]), + }) + expect(policy.hasMcpTools).toBe(true) + }) + + it("returns hasMcpTools false for a server excluded by the allowlist", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "excluded", tools: [{ name: "t", enabledForPrompt: true }] }]), + allowedMcpServers: ["other"], + }) + expect(policy.hasMcpTools).toBe(false) + }) }) describe("resolveEffectiveToolPolicy - worst case (control-tools-only mode)", () => { diff --git a/src/core/prompts/tools/effective-tool-policy.ts b/src/core/prompts/tools/effective-tool-policy.ts index 8af3f564f0..2133cec3db 100644 --- a/src/core/prompts/tools/effective-tool-policy.ts +++ b/src/core/prompts/tools/effective-tool-policy.ts @@ -4,7 +4,6 @@ import { TOOL_ALIASES, TOOL_GROUPS } from "../../../shared/tools" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" import { isToolAllowedForMode } from "../../../core/tools/validateToolUse" -import { getMcpServerTools } from "./native-tools" /** * Canonical tool names that participate in the task-completion protocol and must @@ -27,6 +26,12 @@ export const PROTOCOL_TOOLS: readonly string[] = ["attempt_completion"] * `[groupName, options]`. Only a tuple entry with a `fileRegex` establishes a * prompt-visible edit restriction. * + * Returning only the first restriction is intentional: the mode schema rejects + * duplicate groups (the `rawGroupEntryArraySchema` refine in + * `packages/types/src/mode.ts`), so a mode can declare at most one `edit` group + * with a `fileRegex`; and the runtime validator (`validateToolUse.ts`) likewise + * returns at the first matching group, so the prompt and the validator agree. + * * @param groups The mode's group entries. * @returns The first `{ fileRegex, description }` found, or undefined when the * mode declares no restricted edit group. @@ -70,6 +75,36 @@ export function resolveToolAlias(toolName: string): string { return canonical ?? toolName } +/** + * Canonical protocol-tool names already warned about. Module-level so the + * protocol-override warning fires at most once per tool per process. + */ +const warnedProtocolOverrides = new Set() + +/** + * Warns once per process, per protocol tool, when `disabledTools` tries to + * disable a protocol tool — which the protocol guarantee step makes a no-op. + * + * Uses `console.warn` (not the shared `logger`, which is a no-op in production) + * so the no-op disable is visible to extension developers. + * + * @param disabledTools The raw disabled-tools list (may contain aliases). + */ +function warnProtocolToolOverrides(disabledTools?: string[]): void { + if (!disabledTools?.length) { + return + } + for (const toolName of disabledTools) { + const canonical = resolveToolAlias(toolName) + if (PROTOCOL_TOOLS.includes(canonical) && !warnedProtocolOverrides.has(canonical)) { + warnedProtocolOverrides.add(canonical) + console.warn( + `[effective-tool-policy] '${canonical}' is a protocol tool: disabling it via disabledTools is a no-op; it remains available.`, + ) + } + } +} + export interface EffectiveToolPolicyInput { mode: string customModes?: ModeConfig[] @@ -94,6 +129,12 @@ export interface EffectiveToolPolicy { hasMcpGroup: boolean // mode's groups include "mcp" hasMcpTools: boolean // ≥1 dynamic MCP tool enabled for allowed servers hasMcpResources: boolean // ≥1 accessible resource on allowed servers + /** + * The mode's first edit-group file restriction. First-only is intentional: + * the mode schema rejects duplicate groups, so at most one `edit` group can + * carry a `fileRegex`, and the runtime validator likewise stops at the first + * matching group — prompt and validator agree. + */ editRestriction?: { fileRegex: string; description?: string } } @@ -101,12 +142,21 @@ export interface EffectiveToolPolicy { * True when at least one dynamic MCP tool (e.g. `mcp_serverName_toolName`) is * enabled for the allowed servers. Used to gate the MCP capability bullet in the * prompt so servers whose every tool is `enabledForPrompt: false` do not count. + * + * Cheap existence check: it inspects the MCP server snapshot directly (allowlist + * + `enabledForPrompt !== false`, mirroring the `getMcpServerTools` filter) and + * never materializes or normalizes tool schemas. */ function resolveHasMcpTools(mcpHub?: McpHub, allowedServers?: string[]): boolean { if (!mcpHub) { return false } - return getMcpServerTools(mcpHub, allowedServers).length > 0 + let servers = mcpHub.getServers() + if (allowedServers) { + const allowSet = new Set(allowedServers) + servers = servers.filter((server) => allowSet.has(server.name)) + } + return servers.some((server) => server.tools?.some((tool) => tool.enabledForPrompt !== false)) } /** @@ -135,6 +185,11 @@ export function hasAnyMcpResources(mcpHub: McpHub, allowedServers?: string[]): b * steps `filterNativeToolsForMode` used to compute inline (steps 1-10 of that * function), plus a protocol guarantee that re-adds `PROTOCOL_TOOLS`. * + * The returned policy is deterministic for a given input. The only side effect + * is an intentional, process-deduplicated `console.warn` when a protocol tool is + * disabled via `disabledTools` (such a disable is a no-op); it fires at most once + * per tool per process, so repeated calls never re-warn and do not affect output. + * * @param input Mode, custom modes, MCP hub, disabled tools, model customization, * experiment flags, todo-list enablement, and the code index manager. * @returns An {@link EffectiveToolPolicy} describing the effective tool set. @@ -239,6 +294,7 @@ export function resolveEffectiveToolPolicy(input: EffectiveToolPolicyInput): Eff // 11. Protocol guarantee: re-add every protocol tool so the logical set and // the runtime validator both agree it is callable even when disabled. + warnProtocolToolOverrides(disabledTools) for (const tool of PROTOCOL_TOOLS) { allowedToolNames.add(resolveToolAlias(tool)) } diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 2b38fa1eb9..0c4498fe6f 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -1,7 +1,6 @@ import type OpenAI from "openai" -import type { ModeConfig, ToolName, ToolGroup, ModelInfo } from "@roo-code/types" +import type { ModeConfig, ModelInfo } from "@roo-code/types" import { defaultModeSlug } from "../../../shared/modes" -import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES } from "../../../shared/tools" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" import { resolveEffectiveToolPolicy, resolveToolAlias } from "./effective-tool-policy" @@ -12,36 +11,6 @@ import { isToolAllowedForMode } from "../../../core/tools/validateToolUse" // single canonical implementation in effective-tool-policy.ts. export { resolveToolAlias } -/** - * Canonical to aliases map - maps canonical tool name to array of alias names. - * Built once at module load from the central TOOL_ALIASES constant. - */ -const CANONICAL_TO_ALIASES: Map = new Map() - -// Build the reverse mapping (canonical -> aliases) -for (const [alias, canonical] of Object.entries(TOOL_ALIASES)) { - const existing = CANONICAL_TO_ALIASES.get(canonical) ?? [] - existing.push(alias) - CANONICAL_TO_ALIASES.set(canonical, existing) -} - -/** - * Pre-computed alias groups map - maps any tool name (canonical or alias) to its full group. - * Built once at module load for O(1) lookup. - */ -const ALIAS_GROUPS: Map = new Map() - -// Build alias groups for all tools -for (const [canonical, aliases] of CANONICAL_TO_ALIASES.entries()) { - const group = Object.freeze([canonical, ...aliases]) - // Map canonical to group - ALIAS_GROUPS.set(canonical, group) - // Map each alias to the same group - for (const alias of aliases) { - ALIAS_GROUPS.set(alias, group) - } -} - /** * Cache for renamed tool definitions. * Maps "canonicalName:aliasName" to the pre-built tool definition. @@ -82,35 +51,6 @@ function getOrCreateRenamedTool( return renamedTool } -/** - * Applies tool alias resolution to a set of allowed tools. - * Resolves any aliases to their canonical tool names. - * - * @param allowedTools - Set of tools that may contain aliases - * @returns Set with aliases resolved to canonical names - */ -export function applyToolAliases(allowedTools: Set): Set { - const result = new Set() - - for (const tool of allowedTools) { - // Resolve alias to canonical name - result.add(resolveToolAlias(tool)) - } - - return result -} - -/** - * Gets all tools in an alias group (including the canonical tool). - * Uses pre-computed ALIAS_GROUPS map for O(1) lookup. - * - * @param toolName - Any tool name in the alias group - * @returns Array of all tool names in the alias group, or just the tool if not aliased - */ -export function getToolAliasGroup(toolName: string): readonly string[] { - return ALIAS_GROUPS.get(toolName) ?? [toolName] -} - /** * Filters native tools based on mode restrictions and model customization. * This ensures native tools are filtered consistently with mode/tool permissions. @@ -208,94 +148,6 @@ function resolveModelAliasRenames( return aliasRenames } -/** - * Checks if a specific tool is allowed in the current mode. - * This is useful for dynamically filtering system prompt content. - * - * @param toolName - Name of the tool to check - * @param mode - Current mode slug - * @param customModes - Custom mode configurations - * @param experiments - Experiment flags - * @param codeIndexManager - Code index manager for codebase_search feature check - * @param settings - Additional settings for tool filtering - * @returns true if the tool is allowed in the mode, false otherwise - */ -export function isToolAllowedInMode( - toolName: ToolName, - mode: string | undefined, - customModes: ModeConfig[] | undefined, - experiments: Record | undefined, - codeIndexManager?: CodeIndexManager, - settings?: Record, -): boolean { - const modeSlug = mode ?? defaultModeSlug - - // Check if it's an always-available tool - if (ALWAYS_AVAILABLE_TOOLS.includes(toolName)) { - // But still check for conditional exclusions - if (toolName === "codebase_search") { - return !!( - codeIndexManager && - codeIndexManager.isFeatureEnabled && - codeIndexManager.isFeatureConfigured && - codeIndexManager.isInitialized - ) - } - if (toolName === "update_todo_list") { - return settings?.todoListEnabled !== false - } - if (toolName === "generate_image") { - return experiments?.imageGeneration === true - } - if (toolName === "run_slash_command") { - return experiments?.runSlashCommand === true - } - return true - } - - // Check if the tool is allowed by the mode's groups - // Resolve to canonical name and check that single value - const canonicalTool = resolveToolAlias(toolName) - return isToolAllowedForMode( - canonicalTool as ToolName, - modeSlug, - customModes ?? [], - undefined, - undefined, - experiments ?? {}, - ) -} - -/** - * Gets the list of available tools from a specific tool group for the current mode. - * This is useful for dynamically building system prompt content based on available tools. - * - * @param groupName - Name of the tool group to check - * @param mode - Current mode slug - * @param customModes - Custom mode configurations - * @param experiments - Experiment flags - * @param codeIndexManager - Code index manager for codebase_search feature check - * @param settings - Additional settings for tool filtering - * @returns Array of tool names that are available from the group - */ -export function getAvailableToolsInGroup( - groupName: ToolGroup, - mode: string | undefined, - customModes: ModeConfig[] | undefined, - experiments: Record | undefined, - codeIndexManager?: CodeIndexManager, - settings?: Record, -): ToolName[] { - const toolGroup = TOOL_GROUPS[groupName] - if (!toolGroup) { - return [] - } - - return toolGroup.tools.filter((tool) => - isToolAllowedInMode(tool as ToolName, mode, customModes, experiments, codeIndexManager, settings), - ) as ToolName[] -} - /** * Filters MCP tools based on whether use_mcp_tool is allowed in the current mode. * diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 0706dbe6fb..a6a805eac7 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -761,7 +761,7 @@ }, "core/prompts/tools/filter-tools-for-mode.ts": { "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 1 } }, "core/prompts/tools/native-tools/__tests__/converters.spec.ts": { From f27387b83031d9e633826172773c6cd14a029d71 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Wed, 2 Sep 2026 18:24:01 +0200 Subject: [PATCH 03/39] fixed comments --- src/core/prompts/__tests__/sections.spec.ts | 13 +++++-------- .../prompts/sections/__tests__/objective.spec.ts | 2 +- src/core/prompts/sections/capabilities.ts | 1 - src/core/prompts/sections/rules.ts | 7 ++----- src/core/prompts/sections/system-info.ts | 8 +++----- .../__tests__/effective-tool-policy-warn.spec.ts | 2 +- .../tools/__tests__/effective-tool-policy.spec.ts | 2 +- src/core/prompts/tools/effective-tool-policy.ts | 11 +++++------ src/core/prompts/tools/filter-tools-for-mode.ts | 9 ++++----- src/core/task/__tests__/build-tools.spec.ts | 4 ++-- .../webview/__tests__/generateSystemPrompt.spec.ts | 6 +++--- src/core/webview/generateSystemPrompt.ts | 2 +- 12 files changed, 28 insertions(+), 39 deletions(-) diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index 8051a6fa13..c94c5b901f 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -91,7 +91,7 @@ describe("getCapabilitiesSection", () => { expect(result).toContain("list files") expect(result).toContain("read files") expect(result).toContain("write and edit files") - // the task tail is a plain sentence — no over-claiming enumeration (plan §5) + // the task tail is a plain sentence — assert no over-claiming enumeration expect(result).not.toContain("such as writing code") }) @@ -285,10 +285,8 @@ describe("getRulesSection", () => { }) it("uses the replacement bullet when ask_followup_question is absent", () => { - // Both sub-cases — list_files present (previously the - // filesystem-enumeration branch) and list_files absent — now take the - // single best-effort replacement bullet (plan §5: emitted exactly when - // ask_followup_question is absent). + // Both sub-cases — list_files present and list_files absent — take the single + // best-effort replacement bullet, emitted exactly when ask_followup_question is absent. const withListFiles = getRulesSection( cwd, settings, @@ -313,8 +311,7 @@ describe("getRulesSection", () => { }) it("uses the fallback phrasing in the terminal-output rule when ask_followup_question is absent", () => { - // The execute_command bullet stays, but its tail must not reference a - // disabled tool (the disabled-tool-reference bug class this fix removes). + // The execute_command bullet is always present, but its tail must not reference a disabled tool. const withoutAsk = getRulesSection( cwd, settings, @@ -346,7 +343,7 @@ describe("getRulesSection", () => { }) it("states the attempt_completion protocol rule unconditionally", () => { - // F6: the completion sentence is protocol wording — emitted even when the policy + // The completion sentence is protocol wording — emitted even when the policy // does not advertise attempt_completion. A raw literal is required: the // resolver-backed policyFor cannot express this (protocol guarantee re-adds the // tool in resolveEffectiveToolPolicy step 11). diff --git a/src/core/prompts/sections/__tests__/objective.spec.ts b/src/core/prompts/sections/__tests__/objective.spec.ts index 2a3b4cbc36..e58eaf8ebb 100644 --- a/src/core/prompts/sections/__tests__/objective.spec.ts +++ b/src/core/prompts/sections/__tests__/objective.spec.ts @@ -57,7 +57,7 @@ describe("getObjectiveSection", () => { }) it("still names attempt_completion unconditionally when the tool is not advertised", () => { - // F6: step 4 names attempt_completion, a protocol tool, so the wording is emitted + // Step 4 names attempt_completion, a protocol tool, so the wording is emitted // even when the policy's tools set does not include it. The local policyFor builds // the policy object directly (no resolver), so policyFor([]) provably excludes // attempt_completion. diff --git a/src/core/prompts/sections/capabilities.ts b/src/core/prompts/sections/capabilities.ts index 3d74fe091f..8df9c3f9ff 100644 --- a/src/core/prompts/sections/capabilities.ts +++ b/src/core/prompts/sections/capabilities.ts @@ -63,7 +63,6 @@ export function getCapabilitiesSection(policy: EffectiveToolPolicy): string { body += `- 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.\n` } - // execute_command paragraph. if (tools.has("execute_command")) { body += `- 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.\n` } diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts index fe217eacc5..b71fa97823 100644 --- a/src/core/prompts/sections/rules.ts +++ b/src/core/prompts/sections/rules.ts @@ -68,10 +68,7 @@ When asked about your creator, vendor, or company, respond with: * Builds the RULES section of the system prompt. * * Fragments that describe tool-specific behavior are emitted only when that tool - * is in the request's effective tool policy. The hardcoded `architect` edit - * example (which was misplaced, mode-wrong for most built-ins, and redundant with - * the CAPABILITIES restriction clause and the FileRestrictionError message) is - * removed without replacement. + * is in the request's effective tool policy. * * @param cwd Current working directory used in the prompt text. * @param settings System prompt settings (used for the stealth-model confidentiality section). @@ -137,7 +134,7 @@ export function getRulesSection( }`, ) } else { - // ask_followup_question unavailable: best-effort guidance (plan §5 replacement bullet). + // ask_followup_question unavailable: fall back to best-effort guidance. rules.push( "Provide your best-effort result and state your assumptions; the user may respond with feedback after completion.", ) diff --git a/src/core/prompts/sections/system-info.ts b/src/core/prompts/sections/system-info.ts index afdaff015f..98112cd4ed 100644 --- a/src/core/prompts/sections/system-info.ts +++ b/src/core/prompts/sections/system-info.ts @@ -8,11 +8,9 @@ import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" /** * Builds the SYSTEM INFORMATION section of the system prompt. * - * The workspace-directory / file-tree facts are stated once here (deduplicated - * from CAPABILITIES). The `/test/path` literal that was previously baked into - * every prompt is removed; the file-tree fact is now cwd-independent. The - * terminal-cd sentence is gated on `execute_command`, since those semantics do - * not exist without it. + * The workspace-directory / file-tree facts are stated once here; the + * file-tree fact is cwd-independent. The terminal-cd sentence is gated on + * `execute_command`, since those semantics do not exist without it. * * @param cwd Current working directory used in the prompt text. * @param policy The request's effective tool policy. diff --git a/src/core/prompts/tools/__tests__/effective-tool-policy-warn.spec.ts b/src/core/prompts/tools/__tests__/effective-tool-policy-warn.spec.ts index 49dca3aa83..f2bf667277 100644 --- a/src/core/prompts/tools/__tests__/effective-tool-policy-warn.spec.ts +++ b/src/core/prompts/tools/__tests__/effective-tool-policy-warn.spec.ts @@ -1,7 +1,7 @@ import { resolveEffectiveToolPolicy } from "../effective-tool-policy" /** - * F1: `resolveEffectiveToolPolicy` must warn (once per process, per protocol + * `resolveEffectiveToolPolicy` must warn (once per process, per protocol * tool) when `disabledTools` tries to disable a protocol tool, since the * protocol guarantee makes such a disable a no-op. * diff --git a/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts b/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts index 205adae278..eb10ef8564 100644 --- a/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts +++ b/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts @@ -81,7 +81,7 @@ describe("resolveEffectiveToolPolicy - groups", () => { expect(policy.tools.has("ask_followup_question")).toBe(true) expect(policy.tools.has("update_todo_list")).toBe(true) expect(policy.tools.has("skill")).toBe(true) - // run_slash_command is always-available but gated by the runSlashCommand experiment (plan step 8) + // run_slash_command is always-available but gated by the runSlashCommand experiment expect(policy.tools.has("run_slash_command")).toBe(false) }) diff --git a/src/core/prompts/tools/effective-tool-policy.ts b/src/core/prompts/tools/effective-tool-policy.ts index 2133cec3db..7904afe6cb 100644 --- a/src/core/prompts/tools/effective-tool-policy.ts +++ b/src/core/prompts/tools/effective-tool-policy.ts @@ -13,9 +13,8 @@ import { isToolAllowedForMode } from "../../../core/tools/validateToolUse" * model-specific exclusions have been applied, so the system prompt and the API's * logical allowed set always agree that these tools can be called. * - * See decision D2 in the bugfix plan: `attempt_completion` is the only tool with - * no coherent prompt state when absent (the task loop can only exit through it), so - * it is the sole protocol guarantee today. + * `attempt_completion` is the only tool with no coherent prompt state when absent + * (the task loop can only exit through it), so it is the sole protocol guarantee. */ export const PROTOCOL_TOOLS: readonly string[] = ["attempt_completion"] @@ -181,9 +180,9 @@ export function hasAnyMcpResources(mcpHub: McpHub, allowedServers?: string[]): b * metadata the system prompt needs. * * This is the single source of truth shared by prompt generation, API tool - * construction, runtime validation, and preview. It performs, in order, the same - * steps `filterNativeToolsForMode` used to compute inline (steps 1-10 of that - * function), plus a protocol guarantee that re-adds `PROTOCOL_TOOLS`. + * construction, runtime validation, and preview. The numbered steps below (1-10) + * compute the allowed tool set; step 11 adds the protocol guarantee that re-adds + * `PROTOCOL_TOOLS`. * * The returned policy is deterministic for a given input. The only side effect * is an intentional, process-deduplicated `console.warn` when a protocol tool is diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 0c4498fe6f..02311c5d7f 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -80,9 +80,8 @@ export function filterNativeToolsForMode( // Resolve the single, request-scoped effective tool policy. The filter below // consumes only its `tools` set (plus alias renames from model customization), // so prompt generation and API tool construction agree on the logical allowed - // set. Behavior for all non-protocol tools is byte-identical to the previous - // inline computation; attempt_completion is always advertised (the protocol - // guarantee), even if it appears in disabledTools. + // set. attempt_completion is always advertised (the protocol guarantee), even + // if it appears in disabledTools. const modelInfo = settings?.modelInfo as ModelInfo | undefined const policy = resolveEffectiveToolPolicy({ @@ -128,8 +127,8 @@ export function filterNativeToolsForMode( /** * Computes canonical -> alias renames from model-specific included-tools * customization, but only for tools that remain in the effective policy's allowed - * set (exclusions are already applied by the resolver). Preserves the previous - * behavior where an alias listed in includedTools renames the canonical tool. + * set (exclusions are already applied by the resolver). An alias listed in + * includedTools renames the canonical tool to that alias. */ function resolveModelAliasRenames( modelInfo: ModelInfo | undefined, diff --git a/src/core/task/__tests__/build-tools.spec.ts b/src/core/task/__tests__/build-tools.spec.ts index 0edd925a92..cc369783ce 100644 --- a/src/core/task/__tests__/build-tools.spec.ts +++ b/src/core/task/__tests__/build-tools.spec.ts @@ -3,7 +3,7 @@ // Gemini `includeAllToolsWithRestrictions` path: with the flag on, `tools` // contains ALL declarations while `allowedFunctionNames` is derived from the // resolver-filtered set — so a disabled `attempt_completion` is still allowed -// (protocol guarantee) and `disabledTools`-removed tools are excluded (plan §8 / D5). +// (protocol guarantee) and `disabledTools`-removed tools are excluded. import type OpenAI from "openai" @@ -113,7 +113,7 @@ describe("buildNativeToolsArrayWithRestrictions — Gemini includeAllToolsWithRe disabledTools: ["execute_command"], }) - // Non-Gemini path unchanged: disabled tools are not sent at all. + // Non-Gemini path: disabled tools are not sent at all. expect(toolNames(result.tools)).not.toContain("execute_command") expect(result.allowedFunctionNames).toBeUndefined() }) diff --git a/src/core/webview/__tests__/generateSystemPrompt.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.spec.ts index 417ad2c9f6..5ea8e14887 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.spec.ts +++ b/src/core/webview/__tests__/generateSystemPrompt.spec.ts @@ -4,8 +4,8 @@ // the same CAPABILITIES / RULES / SYSTEM INFORMATION sections as a direct // SYSTEM_PROMPT call built from the *same* inputs — including a full ModelInfo, // so model-level excludedTools/includedTools are honored in the preview exactly -// like the runtime path (plan §6.3 / §8, and fix-plan issue 8: the old -// `{ isStealthModel }`-only typing silently allowed the preview to ignore them). +// like the runtime path. The old `{ isStealthModel }`-only typing silently +// allowed the preview to ignore them. vi.mock("os", () => ({ default: { @@ -95,7 +95,7 @@ vi.mock("../../prompts/sections/custom-instructions", () => ({ })) // The preview must consume a *complete* ModelInfo from the API handler. This -// locks in the issue-8 contract: if generateSystemPrompt ever narrows the local +// locks in that contract: if generateSystemPrompt ever narrows the local // modelInfo back down, the excludedTools sub-assertion below fails. const fullModelInfo: ModelInfo = { contextWindow: 100_000, diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index b321b0fafe..27457c1c7f 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -34,7 +34,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web // Create a temporary API handler to fetch the full model info for the preview. // This avoids relying on an active Cline instance which might not exist during preview. // The full ModelInfo flows into SYSTEM_PROMPT so the preview honors - // excludedTools/includedTools exactly like the runtime path (plan §6.3 parity). + // excludedTools/includedTools exactly like the runtime path. let modelInfo: ModelInfo | undefined try { const tempApiHandler = buildApiHandler(apiConfiguration) From bc6f8ff552d72f0217f57fd8cd813d72187a5486 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Thu, 3 Sep 2026 22:51:46 +0200 Subject: [PATCH 04/39] increase test coverage --- scripts/stryker-diff.mjs | 72 +++- src/core/prompts/__tests__/sections.spec.ts | 8 + .../sections/__tests__/objective.spec.ts | 8 + .../prompts/sections/__tests__/skills.spec.ts | 9 + .../sections/__tests__/system-info.spec.ts | 22 ++ .../__tests__/tool-use-guidelines.spec.ts | 16 + .../__tests__/effective-tool-policy.spec.ts | 357 +++++++++++++++++- .../__tests__/filter-tools-for-mode.spec.ts | 195 +++++++++- src/core/task/__tests__/Task.spec.ts | 27 ++ .../__tests__/generateSystemPrompt.spec.ts | 303 ++++++++++++++- 10 files changed, 1002 insertions(+), 15 deletions(-) diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index c0e8a6cd1a..1ce0cb24e6 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -313,23 +313,54 @@ export function resolveVitestBinary(repoRoot, packageEntry) { return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates.at(-1) } +// On Windows, pnpm creates .CMD shims (e.g. vitest.CMD) that spawnSync refuses to +// execute without the extension and shell:true; on Linux the extensionless shim works as-is. +function resolveWin32Cmd(bin) { + return process.platform === "win32" && fs.existsSync(`${bin}.CMD`) ? `${bin}.CMD` : bin +} + +// With shell:true Node concatenates command and args into a single cmd.exe line +// without escaping (DEP0190), so operands containing spaces must be quoted. +function win32ShellQuote(s) { + return `"${String(s).replaceAll('"', '\\"')}"` +} + export function discoverRelatedTestFiles(repoRoot, packageEntry, reportDirectory) { const packageRoot = path.join(repoRoot, packageEntry.root) const runRoot = path.join(repoRoot, packageEntry.runRoot ?? packageEntry.root) const outputFile = path.join(reportDirectory, "vitest-related.json") const configFile = path.relative(runRoot, path.join(packageRoot, packageEntry.vitestConfig)).replaceAll("\\", "/") const sourceFiles = [...new Set(packageEntry.selectors.map(selectorFile))] - const result = spawnSync( - resolveVitestBinary(repoRoot, packageEntry), - ["related", ...sourceFiles, "--run", "--config", configFile, "--reporter=json", `--outputFile=${outputFile}`], - { - cwd: runRoot, - encoding: "utf8", - timeout: 5 * 60 * 1_000, - maxBuffer: 50 * 1024 * 1024, - env: process.env, - }, - ) + const command = resolveWin32Cmd(resolveVitestBinary(repoRoot, packageEntry)) + // With shell:true a missing binary surfaces as a cmd.exe exit code instead of a + // spawn error, so mirror the ENOENT that spawnSync reports without a shell. + if (process.platform === "win32" && !fs.existsSync(command)) { + throw new Error(`${packageEntry.id} related-test discovery could not start: spawnSync ${command} ENOENT`) + } + const win32 = process.platform === "win32" + const spawnArgs = [ + "related", + ...sourceFiles, + "--run", + "--config", + configFile, + "--reporter=json", + `--outputFile=${outputFile}`, + ] + // Node warns (DEP0190) whenever spawnSync receives a non-empty args array with + // shell:true, even when every operand is already quoted, so on win32 join the + // quoted operands into one command line and pass an empty args array. + const [spawnCommand, spawnOperands] = win32 + ? [[command, ...spawnArgs].map((operand) => win32ShellQuote(operand)).join(" "), []] + : [command, spawnArgs] + const result = spawnSync(spawnCommand, spawnOperands, { + cwd: runRoot, + encoding: "utf8", + timeout: 5 * 60 * 1_000, + maxBuffer: 50 * 1024 * 1024, + env: process.env, + shell: win32, + }) if (result.error?.code === "ETIMEDOUT") { throw new Error(`${packageEntry.id} related-test discovery exceeded 5 minutes`) @@ -367,11 +398,28 @@ function runStryker(repoRoot, packageEntry, reportRoot, dryRunOnly) { ] if (dryRunOnly) args.push("--dryRunOnly", "--reporters", "clear-text", "--logLevel", "info") - const result = spawnSync(path.join(repoRoot, "node_modules/.bin/stryker"), args, { + const command = resolveWin32Cmd(path.join(repoRoot, "node_modules/.bin/stryker")) + // With shell:true a missing binary surfaces as a cmd.exe exit code instead of a + // spawn error, so mirror the ENOENT that spawnSync reports without a shell. + if (process.platform === "win32" && !fs.existsSync(command)) { + throw new Error( + `${packageEntry.id} Stryker ${dryRunOnly ? "preflight" : "run"} could not start: spawnSync ${command} ENOENT`, + ) + } + const win32 = process.platform === "win32" + // Node warns (DEP0190) whenever spawnSync receives a non-empty args array with + // shell:true, even when every operand is already quoted, so on win32 join the + // quoted operands into one command line and pass an empty args array. + const [spawnCommand, spawnOperands] = win32 + ? [[command, ...args].map((operand) => win32ShellQuote(operand)).join(" "), []] + : [command, args] + + const result = spawnSync(spawnCommand, spawnOperands, { cwd: runRoot, encoding: "utf8", timeout: 12 * 60 * 1_000, maxBuffer: 50 * 1024 * 1024, + shell: win32, env: { ...process.env, STRYKER_VITEST_CONFIG: path diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index c94c5b901f..570cc4c846 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -14,6 +14,14 @@ import type { CodeIndexManager } from "../../../services/code-index/manager" import type { SkillsManager } from "../../../services/skills/SkillsManager" import * as shellUtils from "../../../utils/shell" +// Mock os-name so getSystemInfoSection never spawns PowerShell on Windows (cold +// launches can exceed the CI test timeout). Matches the form used in +// sections/__tests__/system-info.spec.ts, but returns a constant since no test +// here asserts on the OS string itself. +vi.mock("os-name", () => ({ + default: vi.fn(() => "MockOS"), +})) + /** * Build an {@link EffectiveToolPolicy} for arbitrary mode groups. `mode` is the * custom-mode slug so the resolver derives everything from `groups` (never from diff --git a/src/core/prompts/sections/__tests__/objective.spec.ts b/src/core/prompts/sections/__tests__/objective.spec.ts index e58eaf8ebb..64b666d552 100644 --- a/src/core/prompts/sections/__tests__/objective.spec.ts +++ b/src/core/prompts/sections/__tests__/objective.spec.ts @@ -56,6 +56,14 @@ describe("getObjectiveSection", () => { expect(objective).toContain("You accomplish a given task iteratively") }) + it("replaces the ask step with best-effort phrasing when ask_followup_question is absent", () => { + const objective = getObjectiveSection(policyFor([])) + + // Exact substring of the false branch, which no other test asserts. + expect(objective).toContain("state your assumptions and proceed with the best available value") + expect(objective).not.toContain("ask_followup_question tool") + }) + it("still names attempt_completion unconditionally when the tool is not advertised", () => { // Step 4 names attempt_completion, a protocol tool, so the wording is emitted // even when the policy's tools set does not include it. The local policyFor builds diff --git a/src/core/prompts/sections/__tests__/skills.spec.ts b/src/core/prompts/sections/__tests__/skills.spec.ts index aa53d2e3c6..5cd3cb4ddc 100644 --- a/src/core/prompts/sections/__tests__/skills.spec.ts +++ b/src/core/prompts/sections/__tests__/skills.spec.ts @@ -41,6 +41,15 @@ describe("getSkillsSection", () => { await expect(getSkillsSection({ getSkillsForMode: vi.fn() }, undefined, policyFor(["skill"]))).resolves.toBe("") }) + it("should return empty string when the policy is missing", async () => { + const mockSkillsManager = { getSkillsForMode: vi.fn() } + + // The `policy?.` optional chain is the only guard against an undefined + // policy; removing it would make this call throw. + await expect(getSkillsSection(mockSkillsManager, "code", undefined)).resolves.toBe("") + expect(mockSkillsManager.getSkillsForMode).not.toHaveBeenCalled() + }) + it("should return empty string when the skill tool is disabled", async () => { const mockSkillsManager = { getSkillsForMode: vi.fn().mockReturnValue([ diff --git a/src/core/prompts/sections/__tests__/system-info.spec.ts b/src/core/prompts/sections/__tests__/system-info.spec.ts index 5c43cb2a4d..7c3b53c426 100644 --- a/src/core/prompts/sections/__tests__/system-info.spec.ts +++ b/src/core/prompts/sections/__tests__/system-info.spec.ts @@ -79,4 +79,26 @@ describe("getSystemInfoSection", () => { expect(result).not.toContain("New terminals will be created") }) + + it("includes the full terminal working-directory sentence when execute_command is present", () => { + mockOsName.mockReturnValue("Ubuntu 22.04") + + const result = getSystemInfoSection(mockCwd, policyFor(true)) + + // Exact substring of the execute_command-gated sentence; also proves the + // `execute_command` lookup itself is not mutated away. + expect(result).toContain( + "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.", + ) + }) + + it("joins the workspace sentence directly to the next sentence when execute_command is absent", () => { + mockOsName.mockReturnValue("Ubuntu 22.04") + + const result = getSystemInfoSection(mockCwd, policyFor(false)) + + // The false branch must stay empty: any injected filler (e.g. a mutated + // sentinel string) breaks this exact join. + expect(result).toContain("default directory for all tool operations. When the user initially gives you a task") + }) }) 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 947c0b207e..ee07bda004 100644 --- a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts +++ b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts @@ -53,4 +53,20 @@ describe("getToolUseGuidelinesSection", () => { expect(guidelines).not.toContain("the list_files tool is more effective than running a command like `ls`") }) + + it("includes the list_files example verbatim when list_files is present", () => { + const guidelines = getToolUseGuidelinesSection(policyFor(["list_files"])) + + // Exact substring of the gated example, and of the exact join around it. + expect(guidelines).toContain( + "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", + ) + }) + + it("keeps the false branch empty when the example is omitted", () => { + const guidelines = getToolUseGuidelinesSection(policyFor([])) + + // Any injected filler in the false branch breaks this exact join. + expect(guidelines).toContain("gathering this information. It's critical") + }) }) diff --git a/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts b/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts index eb10ef8564..b57fbdc8e7 100644 --- a/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts +++ b/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts @@ -1,7 +1,13 @@ +import { customToolRegistry } from "@roo-code/core" import type { ModeConfig, ModelInfo } from "@roo-code/types" import type { EffectiveToolPolicy } from "../effective-tool-policy" -import { PROTOCOL_TOOLS, resolveEffectiveToolPolicy, buildToolRequirements } from "../effective-tool-policy" +import { + PROTOCOL_TOOLS, + resolveEffectiveToolPolicy, + resolveToolAlias, + buildToolRequirements, +} from "../effective-tool-policy" import { getModeBySlug, defaultModeSlug } from "../../../../shared/modes" import type { McpHub } from "../../../../services/mcp/McpHub" import type { CodeIndexManager } from "../../../../services/code-index/manager" @@ -304,4 +310,353 @@ describe("buildToolRequirements", () => { const reqs = buildToolRequirements(["write_file"]) expect(Object.keys(reqs).sort()).toEqual(["write_file", "write_to_file"].sort()) }) + + it("skips protocol tools but keeps regular tools in a mixed list", () => { + // A protocol tool must be skipped while the regular tool in the same list + // still produces its alias + canonical entries. + expect(buildToolRequirements(["attempt_completion", "write_file"])).toEqual({ + write_file: false, + write_to_file: false, + }) + }) +}) + +describe("resolveToolAlias", () => { + it("resolves every registered alias to its canonical tool", () => { + // Exercises the module-load ALIAS_TO_CANONICAL map for both registered aliases. + expect(resolveToolAlias("write_file")).toBe("write_to_file") + expect(resolveToolAlias("search_and_replace")).toBe("edit") + }) + + it("returns canonical and unknown names unchanged", () => { + expect(resolveToolAlias("read_file")).toBe("read_file") + expect(resolveToolAlias("not_a_tool")).toBe("not_a_tool") + }) +}) + +describe("PROTOCOL_TOOLS", () => { + it("lists the single protocol tool by canonical name", () => { + expect([...PROTOCOL_TOOLS]).toEqual(["attempt_completion"]) + }) +}) + +describe("resolveEffectiveToolPolicy - edit restriction edge cases", () => { + it("skips non-edit group tuples even when they declare a fileRegex", () => { + // Only an actual `edit` tuple can establish the restriction: a `read` tuple + // carrying a fileRegex must be skipped, and an edit tuple without a fileRegex + // must not produce one either. + const policy = policyFor([["read", { fileRegex: "\\.ts$" }], ["edit", {}], "command"]) + expect(policy.editRestriction).toBeUndefined() + }) + + it("does not crash on a malformed edit tuple without options", () => { + // Runtime guard: the extraction uses `group[1]?.fileRegex`, so an options-less + // tuple must be skipped rather than throwing. + const groups = JSON.parse('[["edit"]]') as ModeConfig["groups"] + expect(policyFor(groups).editRestriction).toBeUndefined() + }) +}) + +describe("resolveEffectiveToolPolicy - step 3 validator removal", () => { + it("drops granted tools when the validator does not recognize the mode", () => { + const customMode: ModeConfig = { + slug: "policy-test", + name: "Policy Under Test", + roleDefinition: "", + groups: ["read", "edit", "command"], + } + // The requested slug matches no mode, so the fallback (architect) grants its + // read/edit/mcp tools but the per-tool validator rejects every non-always-available + // tool, and the step-3 removal loop drops them. + const policy = resolveEffectiveToolPolicy({ mode: "ghost-mode", customModes: [customMode] }) + expect(policy.tools.has("read_file")).toBe(false) + expect(policy.tools.has("write_to_file")).toBe(false) + expect(policy.tools.has("use_mcp_tool")).toBe(false) + expect(policy.tools.has("switch_mode")).toBe(true) + expect(policy.tools.has("attempt_completion")).toBe(true) + }) + + it("re-adds validator-removed group tools via includedTools (regular-tool mapping)", () => { + // The includedTools branch maps every regular group tool through + // TOOL_GROUPS; when a granted tool was dropped by the step-3 validator + // (unknown mode slug), including it re-adds it because its group is allowed + // by the fallback mode config. + const customMode: ModeConfig = { + slug: "policy-test", + name: "Policy Under Test", + roleDefinition: "", + groups: ["read", "edit", "command"], + } + const policy = resolveEffectiveToolPolicy({ + mode: "ghost-mode", + customModes: [customMode], + modelInfo: modelInfo({ includedTools: ["read_file"] }), + }) + expect(policy.tools.has("read_file")).toBe(true) + }) + + it("threads the experiments flags into the per-mode validator", () => { + // The resolver forwards `experiments ?? {}` to the validator; the customTools + // escape hatch in isToolAllowedForMode only fires when that flag actually + // arrives. A registered custom tool is therefore retained for an otherwise + // unknown mode when (and only when) the flag is passed through. + const customMode: ModeConfig = { + slug: "policy-test", + name: "Policy Under Test", + roleDefinition: "", + groups: ["read"], + } + customToolRegistry.register({ name: "shadow_read_tool", description: "test double", execute: async () => "ok" }) + try { + const withFlag = resolveEffectiveToolPolicy({ + mode: "ghost-mode", + customModes: [customMode], + experiments: { customTools: true }, + }) + // shadow_read_tool is not granted by any group, so the flag alone cannot + // re-add it; instead the flag must keep granted tools that the validator + // would otherwise reject for the unknown mode. + expect(withFlag.tools.has("read_file")).toBe(false) + + // Direct proof of flag threading: register under a granted tool's name. + customToolRegistry.register({ name: "read_file", description: "shadow", execute: async () => "ok" }) + const shadowed = resolveEffectiveToolPolicy({ + mode: "ghost-mode", + customModes: [customMode], + experiments: { customTools: true }, + }) + expect(shadowed.tools.has("read_file")).toBe(true) + + // Without the flag the same shadowed tool is still rejected. + const withoutFlag = resolveEffectiveToolPolicy({ mode: "ghost-mode", customModes: [customMode] }) + expect(withoutFlag.tools.has("read_file")).toBe(false) + } finally { + customToolRegistry.clear() + } + }) + + it("forwards an empty customModes default to the per-mode validator", async () => { + // The step-3 permission filter forwards `customModes ?? []` (and + // `experiments ?? {}`) to isToolAllowedForMode. A phantom default entry would + // behave identically downstream (a non-object never matches a mode slug), so + // the forwarded argument itself is the only observable. Wrap the real + // validator for one fresh module instance and assert what it receives. + const seen: unknown[][] = [] + vi.doMock("../../../../core/tools/validateToolUse", async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + isToolAllowedForMode: (...args: Parameters) => { + seen.push(args) + return original.isToolAllowedForMode(...args) + }, + } + }) + vi.resetModules() + const mod = await import("../effective-tool-policy") + try { + mod.resolveEffectiveToolPolicy({ mode: "code" }) + expect(seen.length).toBeGreaterThan(0) + for (const args of seen) { + expect(args[2]).toEqual([]) + } + + // Provided custom modes are forwarded by reference, unchanged. + const customModes: ModeConfig[] = [ + { slug: "passthrough-test", name: "PT", roleDefinition: "", groups: ["read"] }, + ] + seen.length = 0 + mod.resolveEffectiveToolPolicy({ mode: "code", customModes }) + expect(seen.some((args) => args[2] === customModes)).toBe(true) + } finally { + vi.doUnmock("../../../../core/tools/validateToolUse") + vi.resetModules() + } + }) +}) + +describe("resolveEffectiveToolPolicy - opt-in custom tools via includedTools", () => { + it("adds opt-in custom tools only when their group is allowed", () => { + // "edit" is an opt-in custom tool of the edit group: absent from the group grant, + // it is re-added only when model customization includes it AND the mode allows + // the owning group (the toolToGroup map includes customTools entries). + const withEditGroup = policyFor(["edit"], { modelInfo: modelInfo({ includedTools: ["edit"] }) }) + expect(withEditGroup.tools.has("edit")).toBe(true) + + const withoutEditGroup = policyFor(["read"], { modelInfo: modelInfo({ includedTools: ["edit"] }) }) + expect(withoutEditGroup.tools.has("edit")).toBe(false) + }) + + it("resolves aliased opt-in custom tools through the group's customTools", () => { + // "search_and_replace" is an alias of the opt-in custom tool "edit". + const policy = policyFor(["edit"], { modelInfo: modelInfo({ includedTools: ["search_and_replace"] }) }) + expect(policy.tools.has("edit")).toBe(true) + }) +}) + +describe("resolveEffectiveToolPolicy - code index readiness flags", () => { + it("drops codebase_search when the feature is disabled", () => { + const manager = { + isFeatureEnabled: false, + isFeatureConfigured: true, + isInitialized: true, + } as CodeIndexManager + expect(policyFor(["read"], { codeIndexManager: manager }).tools.has("codebase_search")).toBe(false) + }) + + it("drops codebase_search when the feature is not configured", () => { + const manager = { + isFeatureEnabled: true, + isFeatureConfigured: false, + isInitialized: true, + } as CodeIndexManager + expect(policyFor(["read"], { codeIndexManager: manager }).tools.has("codebase_search")).toBe(false) + }) + + it("drops codebase_search when the index is not initialized", () => { + const manager = { + isFeatureEnabled: true, + isFeatureConfigured: true, + isInitialized: false, + } as CodeIndexManager + expect(policyFor(["read"], { codeIndexManager: manager }).tools.has("codebase_search")).toBe(false) + }) +}) + +describe("resolveEffectiveToolPolicy - MCP capability flags", () => { + it("reports no MCP capabilities without an mcpHub", () => { + const policy = policyFor(["mcp"]) + expect(policy.hasMcpTools).toBe(false) + expect(policy.hasMcpResources).toBe(false) + }) + + it("keeps hasMcpGroup true when mcp is mixed with other groups", () => { + expect(policyFor(["read", "mcp", "command"]).hasMcpGroup).toBe(true) + }) + + it("returns hasMcpTools true for an allowlisted server even when other servers are dropped", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([ + { name: "other", tools: [{ name: "t", enabledForPrompt: true }] }, + { name: "listed", tools: [{ name: "t", enabledForPrompt: true }] }, + ]), + allowedMcpServers: ["listed"], + }) + expect(policy.hasMcpTools).toBe(true) + }) + + it("returns hasMcpTools true when at least one of several tools is prompt-enabled", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([ + { + name: "s", + tools: [ + { name: "off-a", enabledForPrompt: false }, + { name: "off-b", enabledForPrompt: false }, + { name: "live", enabledForPrompt: true }, + ], + }, + ]), + }) + expect(policy.hasMcpTools).toBe(true) + }) + + it("returns hasMcpTools false when every tool of the server is prompt-disabled", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([ + { + name: "s", + tools: [ + { name: "off-a", enabledForPrompt: false }, + { name: "off-b", enabledForPrompt: false }, + ], + }, + ]), + }) + expect(policy.hasMcpTools).toBe(false) + }) +}) + +describe("resolveEffectiveToolPolicy - protocol override warning (fresh module)", () => { + // The warn-dedupe set is module state and other tests in this file already resolve + // policies that disable protocol tools (priming the set), so each test reloads a + // fresh module instance whose dedupe set starts empty. + async function freshResolve() { + vi.resetModules() + const mod = await import("../effective-tool-policy") + return mod.resolveEffectiveToolPolicy + } + + it("skips an alias of a protocol tool when building tool requirements", async () => { + // buildToolRequirements skips a tool when its canonical name OR its raw name + // is a protocol tool. Register a temporary alias of attempt_completion so the + // two operands of that `||` differ: the alias must still be skipped. + vi.resetModules() + const toolsMod = await import("../../../../shared/tools") + toolsMod.TOOL_ALIASES.wp4_attempt_alias = "attempt_completion" + const mod = await import("../effective-tool-policy") + try { + expect(mod.buildToolRequirements(["wp4_attempt_alias"])).toEqual({}) + // Sanity: the injected alias actually resolves through the fresh module. + expect(mod.resolveToolAlias("wp4_attempt_alias")).toBe("attempt_completion") + } finally { + delete toolsMod.TOOL_ALIASES.wp4_attempt_alias + } + }) + + it("warns once per protocol tool, names the tool, and keeps it available", async () => { + vi.resetModules() + // Import shared/tools first and assert the alias map right after the fresh + // import, so a broken module-load alias map (ALIAS_TO_CANONICAL) fails here. + const mod = await import("../effective-tool-policy") + expect(mod.resolveToolAlias("write_file")).toBe("write_to_file") + expect(mod.resolveToolAlias("search_and_replace")).toBe("edit") + expect([...mod.PROTOCOL_TOOLS]).toEqual(["attempt_completion"]) + const resolve = mod.resolveEffectiveToolPolicy + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + const policy = resolve({ mode: "code", disabledTools: ["attempt_completion"] }) + expect(warnSpy).toHaveBeenCalledTimes(1) + const message = String(warnSpy.mock.calls[0]?.[0]) + expect(message).toContain("[effective-tool-policy]") + expect(message).toContain("'attempt_completion'") + expect(message).toContain("no-op") + expect(policy.tools.has("attempt_completion")).toBe(true) + + // Second resolve on the same module instance: deduped, no additional warn. + const policy2 = resolve({ mode: "code", disabledTools: ["attempt_completion", "execute_command"] }) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(policy2.tools.has("execute_command")).toBe(false) + } finally { + warnSpy.mockRestore() + } + }) + + it("does not warn or throw for empty or missing disabledTools", async () => { + const resolve = await freshResolve() + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + expect(() => resolve({ mode: "code", disabledTools: [] })).not.toThrow() + expect(() => resolve({ mode: "code" })).not.toThrow() + expect(warnSpy).not.toHaveBeenCalled() + } finally { + warnSpy.mockRestore() + } + }) + + it("warns again on a fresh module instance (per-process dedupe)", async () => { + const first = await freshResolve() + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + first({ mode: "code", disabledTools: ["attempt_completion"] }) + expect(warnSpy).toHaveBeenCalledTimes(1) + + // A different module instance has its own dedupe set and warns again. + const second = await freshResolve() + second({ mode: "code", disabledTools: ["attempt_completion"] }) + expect(warnSpy).toHaveBeenCalledTimes(2) + } finally { + warnSpy.mockRestore() + } + }) }) 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..4071836679 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 @@ -1,8 +1,9 @@ // npx vitest run core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts import type OpenAI from "openai" +import type { ModeConfig } from "@roo-code/types" -import { filterNativeToolsForMode } from "../filter-tools-for-mode" +import { filterMcpToolsForMode, filterNativeToolsForMode } from "../filter-tools-for-mode" function makeTool(name: string): OpenAI.Chat.ChatCompletionTool { return { @@ -90,6 +91,198 @@ describe("filterNativeToolsForMode - disabledTools", () => { }) }) +describe("filterNativeToolsForMode - settings round-trips", () => { + const nativeTools: OpenAI.Chat.ChatCompletionTool[] = [makeTool("read_file"), makeTool("update_todo_list")] + + function resultNames(result: OpenAI.Chat.ChatCompletionTool[]): string[] { + return result.map((t) => ("function" in t && t.function ? t.function.name : "")) + } + + it("works when the settings argument is omitted entirely", () => { + // settings?.disabledTools / settings?.todoListEnabled must tolerate an + // absent settings object rather than dereferencing it. + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined) + expect(resultNames(result)).toContain("read_file") + }) + + it("applies settings.todoListEnabled=false to the native tool set", () => { + const without = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, { + todoListEnabled: false, + }) + expect(resultNames(without)).not.toContain("update_todo_list") + expect(resultNames(without)).toContain("read_file") + + const enabled = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, { + todoListEnabled: true, + }) + expect(resultNames(enabled)).toContain("update_todo_list") + }) + + it("keeps todoListEnabled=undefined as enabled (default semantics)", () => { + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, {}) + expect(resultNames(result)).toContain("update_todo_list") + }) + + it("tolerates a modelInfo without an includedTools property", () => { + // resolveModelAliasRenames guards with `modelInfo?.includedTools?.length`; + // a present-but-incomplete modelInfo must take the early-return path rather + // than dereferencing the missing property. + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, { + modelInfo: {}, + }) + expect(resultNames(result)).toContain("read_file") + expect(resultNames(result)).toContain("update_todo_list") + }) +}) + +describe("filterNativeToolsForMode - alias renaming", () => { + function resultNames(result: OpenAI.Chat.ChatCompletionTool[]): string[] { + return result.map((t) => ("function" in t && t.function ? t.function.name : "")) + } + + it("renames an allowed canonical tool to its alias from includedTools", () => { + // "search_and_replace" is an alias of the opt-in custom tool "edit"; listing + // it in modelInfo.includedTools both enables "edit" and renames it, so the + // advertised definition must carry the alias name, not the canonical one. + const nativeTools = [makeTool("edit")] + const settings = { modelInfo: { includedTools: ["search_and_replace"] } } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + expect(resultNames(result)).toEqual(["search_and_replace"]) + }) + + it("keeps non-aliased tool definitions identical (no needless copies)", () => { + // A canonical name in includedTools is not an alias; the tool must be passed + // through as the exact same definition object rather than renamed/copied. + const readFileTool = makeTool("read_file") + const settings = { modelInfo: { includedTools: ["read_file"] } } + + const result = filterNativeToolsForMode([readFileTool], "code", undefined, undefined, undefined, settings) + + expect(result).toHaveLength(1) + expect(result[0]).toBe(readFileTool) + }) + + it("does not advertise an alias whose canonical tool is not allowed", () => { + // "edit" needs the edit group; a read-only mode must drop it even when the + // alias is requested through includedTools. + const nativeTools = [makeTool("edit"), makeTool("read_file")] + const settings = { modelInfo: { includedTools: ["search_and_replace"] } } + const readOnlyMode: ModeConfig = { + slug: "read-only", + name: "Read Only", + roleDefinition: "", + groups: ["read"], + } + + const result = filterNativeToolsForMode( + nativeTools, + "read-only", + [readOnlyMode], + undefined, + undefined, + settings, + ) + + const names = resultNames(result) + expect(names).not.toContain("search_and_replace") + expect(names).not.toContain("edit") + expect(names).toContain("read_file") + }) + + it("reuses the cached renamed definition for repeated calls", () => { + // Uses the write_file pair exclusively: the module-level rename cache is + // shared across tests in this file, so the first call below must be the one + // that stores the entry (dropping the cache write would return fresh objects). + const nativeTools = [makeTool("write_to_file")] + const settings = { modelInfo: { includedTools: ["write_file"] } } + + const first = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + const second = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + expect(resultNames(first)).toEqual(["write_file"]) + expect(second[0]).toBe(first[0]) + }) + + it("keeps separate cache entries per canonical/alias pair", () => { + // Two different renames must not collide in the rename cache: each advertised + // tool carries its own alias name. + const nativeTools = [makeTool("edit"), makeTool("write_to_file")] + const settings = { modelInfo: { includedTools: ["search_and_replace", "write_file"] } } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + expect(resultNames(result).sort()).toEqual(["search_and_replace", "write_file"]) + }) + + it("skips non-function (custom) tool definitions without throwing", () => { + // The filter loop only inspects definitions that carry a function schema; + // a custom tool definition must be dropped, not dereferenced. + const customTool: OpenAI.Chat.ChatCompletionTool = { type: "custom", custom: { name: "custom_tool" } } + const nativeTools = [makeTool("read_file"), customTool] + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, {}) + + expect(resultNames(result)).toEqual(["read_file"]) + }) + + it("skips a malformed function definition whose schema is missing", () => { + // Defensive branch: a definition that declares the "function" key but carries + // a nullish schema must be skipped by the loop guard rather than dereferenced. + // The double assertion is required because the SDK types forbid this shape. + const malformedTool = { + ...makeTool("broken_tool"), + function: undefined, + } as unknown as OpenAI.Chat.ChatCompletionTool + const nativeTools = [makeTool("read_file"), malformedTool] + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, {}) + + expect(resultNames(result)).toEqual(["read_file"]) + }) +}) + +describe("filterMcpToolsForMode", () => { + const mcpTools = [makeTool("mcp_server_tool")] + + it("returns the MCP tools for a mode whose groups include mcp", () => { + expect(filterMcpToolsForMode(mcpTools, "code", undefined, undefined)).toBe(mcpTools) + }) + + it("returns the MCP tools when the mode is undefined (default-mode fallback)", () => { + // `mode ?? defaultModeSlug` must fall back to the default mode (code), which + // allows use_mcp_tool. + expect(filterMcpToolsForMode(mcpTools, undefined, undefined, undefined)).toBe(mcpTools) + }) + + it("returns an empty array for a mode without the mcp group", () => { + const readOnlyMode: ModeConfig = { + slug: "read-only", + name: "Read Only", + roleDefinition: "", + groups: ["read"], + } + expect(filterMcpToolsForMode(mcpTools, "read-only", [readOnlyMode], undefined)).toEqual([]) + }) + + it("resolves a custom mode from the customModes argument", () => { + // The customModes array must be forwarded to the permission check: the mode + // slug only exists in the custom list. + const mcpCustomMode: ModeConfig = { + slug: "custom-mcp", + name: "Custom MCP", + roleDefinition: "", + groups: ["mcp"], + } + expect(filterMcpToolsForMode(mcpTools, "custom-mcp", [mcpCustomMode], undefined)).toBe(mcpTools) + }) + + it("accepts experiment flags without affecting the result", () => { + expect(filterMcpToolsForMode(mcpTools, "code", undefined, { imageGeneration: true })).toBe(mcpTools) + }) +}) + describe("filterNativeToolsForMode - access_mcp_resource allowlist", () => { const nativeTools: OpenAI.Chat.ChatCompletionTool[] = [makeTool("read_file"), makeTool("access_mcp_resource")] diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 37e228f887..4a36919a2c 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -583,6 +583,33 @@ describe("Cline", () => { expect(settings).toMatchObject({ todoListEnabled: true }) }) + it("passes undefined disabledTools when provider state becomes unavailable", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // First getState call: MCP disabled (avoids the MCP hub path). Later + // calls (including the state read feeding `state?.disabledTools`): + // undefined. Dropping the optional chain on that read makes + // getSystemPrompt reject with a TypeError instead of resolving. + vi.spyOn(mockProvider, "getState") + // ProviderState requires all declared fields; the test deliberately supplies a partial state to exercise the fallback path. + .mockResolvedValueOnce({ mcpEnabled: false } as unknown as ProviderState) + // ProviderState requires all declared fields; the test deliberately supplies an absent state to exercise the fallback path. + .mockResolvedValue(undefined as unknown as ProviderState) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await expect(getTaskTestAccess(task).getSystemPrompt()).resolves.toBe("mock system prompt") + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 16 is the disabledTools parameter fed by `state?.disabledTools`. + expect(systemPromptCall[16]).toBeUndefined() + }) + it("uses the task mode when manually condensing after focused state changes", async () => { vi.spyOn(mockProvider, "getState").mockResolvedValue({ mode: "architect", diff --git a/src/core/webview/__tests__/generateSystemPrompt.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.spec.ts index 5ea8e14887..777db78e02 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.spec.ts +++ b/src/core/webview/__tests__/generateSystemPrompt.spec.ts @@ -52,6 +52,9 @@ import type { ModelInfo } from "@roo-code/types" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { SYSTEM_PROMPT } from "../../prompts/system" +import { getCapabilitiesSection } from "../../prompts/sections/capabilities" +import { getRulesSection } from "../../prompts/sections/rules" +import type { EffectiveToolPolicy } from "../../prompts/tools/effective-tool-policy" import { generateSystemPrompt } from "../generateSystemPrompt" import type { ClineProvider } from "../ClineProvider" import "../../../utils/path" @@ -80,8 +83,13 @@ vi.mock("vscode", () => ({ }), })) +// Mutable shell mock: getShell feeds the command-chaining text in RULES, so the +// fragment-gating describe below can retarget the shell per test without +// re-registering the module mock (which would leak across the parity tests). +const shellMock = vi.hoisted(() => ({ shell: "/bin/zsh" })) + vi.mock("../../../utils/shell", () => ({ - getShell: () => "/bin/zsh", + getShell: () => shellMock.shell, })) // Mock the section builders that touch the filesystem / extension context so the @@ -223,4 +231,297 @@ describe("generateSystemPrompt preview parity", () => { // Other clauses survive, proving the exclusion is scoped to that tool. expect(capabilities).toContain("execute CLI commands") }) + + it("resolves when settings are omitted instead of dereferencing them", async () => { + // generatePrompt reads `settings?.todoListEnabled`; without the optional + // chain this call rejects with a TypeError on the undefined settings object. + const prompt = await SYSTEM_PROMPT( + mockContext, + "/test/path", + false, + undefined, // mcpHub + undefined, // diffStrategy + "code", + undefined, // customModePrompts + undefined, // customModes + undefined, // globalCustomInstructions + {}, // experiments + undefined, // language + undefined, // rooIgnoreInstructions + undefined, // settings -> exercises the `settings?.` optional chain + ) + + expect(prompt).toContain("OBJECTIVE") + }) +}) + +// --------------------------------------------------------------------------- +// Mutation coverage for the CAPABILITIES and RULES fragment builders. These +// sections have no name-matched spec file, so this spec — the gate's direct +// test file for the prompt pipeline — drives every fragment gate, fallback +// sentence, and MCP-availability branch directly against the section builders. +// --------------------------------------------------------------------------- +describe("getCapabilitiesSection / getRulesSection fragment gating", () => { + const cwd = "/test/path" + const settings = { ...fullSettings } + + /** + * Raw policy double: the section builders only read `tools` plus the MCP and + * edit-restriction fields, so a literal captures every branch the resolver + * could produce for these two sections. + */ + function sectionPolicy( + tools: string[], + extra: Partial< + Pick + > = {}, + ): EffectiveToolPolicy { + return { + tools: new Set(tools), + hasMcpGroup: false, + hasMcpTools: false, + hasMcpResources: false, + ...extra, + } + } + + afterEach(() => { + shellMock.shell = "/bin/zsh" + }) + + describe("getCapabilitiesSection", () => { + it("emits every clause and paragraph when all capability tools are advertised", () => { + const result = getCapabilitiesSection( + sectionPolicy( + [ + "execute_command", + "list_files", + "codebase_search", + "search_files", + "read_file", + "write_to_file", + "apply_diff", + ], + { hasMcpGroup: true, hasMcpTools: true }, + ), + ) + + expect(result).toContain("====\n\nCAPABILITIES\n\n") + expect(result).toContain( + "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 files, write and edit files.", + ) + expect(result).toContain("\n- These tools help you effectively accomplish a wide range of tasks.\n") + expect(result).toContain("you can use the list_files tool") + expect(result).toContain("You can use the execute_command tool to run commands on the user's computer") + expect(result).toContain( + "You have access to MCP servers that may provide additional tools and/or resources", + ) + expect(result).not.toContain("Stryker was here") + // The trailing newline is trimmed; the result must end with the last bullet. + expect(result.endsWith("accomplish tasks more effectively.")).toBe(true) + }) + + it("falls back to the limited-tools sentence and omits every fragment when no capability tools are advertised", () => { + const result = getCapabilitiesSection(sectionPolicy([])) + + expect(result).toContain( + "You have access to a limited set of tools for this mode; only the tools you are provided may be called.", + ) + expect(result).not.toContain("You have access to tools that let you") + expect(result).not.toContain("execute CLI commands") + expect(result).not.toContain("list files") + expect(result).not.toContain("view source code definitions") + expect(result).not.toContain("regex search") + expect(result).not.toContain("read files") + expect(result).not.toContain("write and edit files") + expect(result).not.toContain("you can use the list_files tool") + expect(result).not.toContain("You can use the execute_command tool") + expect(result).not.toContain("MCP servers") + }) + + it("gates each clause on exactly its advertised tool", () => { + expect(getCapabilitiesSection(sectionPolicy(["list_files"]))).toContain( + "You have access to tools that let you list files.", + ) + expect(getCapabilitiesSection(sectionPolicy(["codebase_search"]))).toContain( + "You have access to tools that let you view source code definitions.", + ) + expect(getCapabilitiesSection(sectionPolicy(["search_files"]))).toContain( + "You have access to tools that let you regex search.", + ) + expect(getCapabilitiesSection(sectionPolicy(["search_files"]))).not.toContain( + "view source code definitions", + ) + expect(getCapabilitiesSection(sectionPolicy(["read_file"]))).toContain( + "You have access to tools that let you read files.", + ) + expect(getCapabilitiesSection(sectionPolicy(["write_to_file"]))).toContain("write and edit files") + expect(getCapabilitiesSection(sectionPolicy(["apply_diff"]))).toContain("write and edit files") + expect(getCapabilitiesSection(sectionPolicy(["read_file"]))).not.toContain("write and edit files") + }) + + it("binds the edit-restriction suffix with and without a description", () => { + const withDescription = getCapabilitiesSection( + sectionPolicy(["read_file"], { + editRestriction: { fileRegex: "\\.md$", description: "Markdown files only" }, + }), + ) + expect(withDescription).toContain( + "(in this mode only files matching '\\.md$' can be edited — Markdown files only)", + ) + + const withoutDescription = getCapabilitiesSection( + sectionPolicy(["read_file"], { editRestriction: { fileRegex: "\\.md$" } }), + ) + // "Stryker was here" (no trailing !) covers both the StringLiteral and + // ArrayDeclaration sentinel replacements Stryker injects. + expect(withoutDescription).toContain("(in this mode only files matching '\\.md$' can be edited)") + expect(withoutDescription).not.toContain("Stryker was here") + + const unrestricted = getCapabilitiesSection(sectionPolicy(["read_file"])) + expect(unrestricted).not.toContain("(in this mode only files matching") + expect(unrestricted).not.toContain("Stryker was here") + }) + + it("emits the MCP bullet only when the mcp group is present and tools or resources are effective", () => { + const mcpBullet = "You have access to MCP servers that may provide additional tools" + + // group + effective tools, and group + effective resources -> present + expect(getCapabilitiesSection(sectionPolicy([], { hasMcpGroup: true, hasMcpTools: true }))).toContain( + mcpBullet, + ) + expect(getCapabilitiesSection(sectionPolicy([], { hasMcpGroup: true, hasMcpResources: true }))).toContain( + mcpBullet, + ) + // group but nothing effective -> absent + expect(getCapabilitiesSection(sectionPolicy([], { hasMcpGroup: true }))).not.toContain(mcpBullet) + // effective tools/resources but no group -> absent + expect( + getCapabilitiesSection(sectionPolicy([], { hasMcpTools: true, hasMcpResources: true })), + ).not.toContain(mcpBullet) + }) + }) + + describe("getRulesSection", () => { + it("includes every tool-gated fragment when all relevant tools are advertised", () => { + const result = getRulesSection( + cwd, + settings, + sectionPolicy(["execute_command", "ask_followup_question", "list_files", "read_file"]), + ) + + expect(result).toContain("====\n\nRULES\n\n- ") + expect(result).toContain("The project base directory is: /test/path") + expect(result).toContain( + "All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command.", + ) + expect(result).toContain("You are stuck operating from '/test/path'") + expect(result).toContain("Do not use the ~ character or $HOME to refer to the home directory.") + expect(result).toContain( + "Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context", + ) + expect(result).toContain("Some modes have restrictions on which files they can edit") + expect(result).toContain("Be sure to consider the type of project") + expect(result).toContain("When making changes to code, always consider the context") + expect(result).toContain("Do not ask for more information than necessary") + expect(result).toContain( + "You are only allowed to ask the user questions using the ask_followup_question tool", + ) + expect(result).toContain("you should use the list_files tool to list the files in the Desktop") + expect(result).not.toContain("Provide your best-effort result") + expect(result).toContain("When executing commands, if you don't see the expected output") + expect(result).toContain( + "use the ask_followup_question tool to request the user to copy and paste it back to you", + ) + expect(result).not.toContain("note what you expected and proceed with the task") + expect(result).toContain("The user may provide a file's contents directly") + expect(result).toContain( + "Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.", + ) + expect(result).toContain("NEVER end attempt_completion result with a question") + expect(result).toContain("STRICTLY FORBIDDEN from starting your messages") + expect(result).toContain("When presented with images, utilize your vision capabilities") + expect(result).toContain("you will automatically receive environment_details") + expect(result).toContain('"Actively Running Terminals"') + expect(result).toContain("It is critical you wait for the user's response after each tool use") + expect(result).not.toContain("MCP operations should be used one at a time") + expect(result).not.toContain("VENDOR CONFIDENTIALITY") + // join separator: rules are bulleted one per line, not concatenated + expect(result).toContain("/test/path\n- All file paths must be relative") + expect(result).not.toContain("Stryker was here") + }) + + it("uses the fallback fragments when execute_command, ask_followup_question, and read_file are absent", () => { + const result = getRulesSection(cwd, settings, sectionPolicy([])) + + expect(result).toContain("- All file paths must be relative to this directory.\n") + expect(result).not.toContain("However, commands may change directories in terminals") + expect(result).not.toContain("Before using the execute_command tool") + expect(result).toContain("Provide your best-effort result and state your assumptions") + expect(result).not.toContain("You are only allowed to ask the user questions") + expect(result).not.toContain("When executing commands") + expect(result).not.toContain("The user may provide a file's contents directly") + expect(result).not.toContain("Actively Running Terminals") + }) + + it("keeps the ask guidance but drops the list_files example when only ask_followup_question is advertised", () => { + const result = getRulesSection(cwd, settings, sectionPolicy(["ask_followup_question"])) + + expect(result).toContain( + "You are only allowed to ask the user questions using the ask_followup_question tool", + ) + expect(result).not.toContain("the list_files tool") + expect(result).not.toContain("Stryker was here!") + }) + + it("uses the fallback phrasing in the terminal-output rule when ask_followup_question is absent", () => { + const result = getRulesSection(cwd, settings, sectionPolicy(["execute_command"])) + + expect(result).toContain("When executing commands, if you don't see the expected output") + expect(result).toContain("note what you expected and proceed with the task, stating your assumptions") + expect(result).not.toContain("use the ask_followup_question tool to request") + }) + + it("emits the MCP usage rule only when the mcp group is present and tools or resources are effective", () => { + const mcpRule = "MCP operations should be used one at a time" + + expect( + getRulesSection(cwd, settings, sectionPolicy([], { hasMcpGroup: true, hasMcpTools: true })), + ).toContain(mcpRule) + expect( + getRulesSection(cwd, settings, sectionPolicy([], { hasMcpGroup: true, hasMcpResources: true })), + ).toContain(mcpRule) + expect(getRulesSection(cwd, settings, sectionPolicy([], { hasMcpGroup: true }))).not.toContain(mcpRule) + expect( + getRulesSection(cwd, settings, sectionPolicy([], { hasMcpTools: true, hasMcpResources: true })), + ).not.toContain(mcpRule) + }) + + it("tolerates undefined settings and emits vendor confidentiality only for stealth models", () => { + const full = sectionPolicy(["execute_command", "ask_followup_question", "list_files", "read_file"]) + + // The `settings?.isStealthModel` optional chain must survive an undefined settings + // object; dropping the chain throws a TypeError inside getRulesSection. + expect(() => getRulesSection(cwd, undefined, full)).not.toThrow() + expect(getRulesSection(cwd, undefined, full)).not.toContain("VENDOR CONFIDENTIALITY") + expect(getRulesSection(cwd, { ...settings, isStealthModel: true }, full)).toContain( + "VENDOR CONFIDENTIALITY", + ) + }) + + it("appends the PowerShell chain note and omits it for Unix shells", () => { + const full = sectionPolicy(["execute_command"]) + + shellMock.shell = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" + const powershell = getRulesSection(cwd, settings, full) + expect(powershell).toContain("cd (path to project) ; (command, in this case npm install)") + expect(powershell).toContain(" Note: Using `;` for PowerShell command chaining") + + shellMock.shell = "/bin/bash" + const unix = getRulesSection(cwd, settings, full) + expect(unix).toContain("cd (path to project) && (command, in this case npm install)") + expect(unix).not.toContain("Note: Using") + expect(unix).not.toContain("Stryker was here") + }) + }) }) From 6968a984f8fddcdc02a7087b93e18a1e922beba6 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Fri, 4 Sep 2026 09:02:59 +0200 Subject: [PATCH 05/39] revert: remove Windows shell invocation from stryker-diff --- scripts/stryker-diff.mjs | 72 +++++++--------------------------------- 1 file changed, 12 insertions(+), 60 deletions(-) diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index 1ce0cb24e6..c0e8a6cd1a 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -313,54 +313,23 @@ export function resolveVitestBinary(repoRoot, packageEntry) { return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates.at(-1) } -// On Windows, pnpm creates .CMD shims (e.g. vitest.CMD) that spawnSync refuses to -// execute without the extension and shell:true; on Linux the extensionless shim works as-is. -function resolveWin32Cmd(bin) { - return process.platform === "win32" && fs.existsSync(`${bin}.CMD`) ? `${bin}.CMD` : bin -} - -// With shell:true Node concatenates command and args into a single cmd.exe line -// without escaping (DEP0190), so operands containing spaces must be quoted. -function win32ShellQuote(s) { - return `"${String(s).replaceAll('"', '\\"')}"` -} - export function discoverRelatedTestFiles(repoRoot, packageEntry, reportDirectory) { const packageRoot = path.join(repoRoot, packageEntry.root) const runRoot = path.join(repoRoot, packageEntry.runRoot ?? packageEntry.root) const outputFile = path.join(reportDirectory, "vitest-related.json") const configFile = path.relative(runRoot, path.join(packageRoot, packageEntry.vitestConfig)).replaceAll("\\", "/") const sourceFiles = [...new Set(packageEntry.selectors.map(selectorFile))] - const command = resolveWin32Cmd(resolveVitestBinary(repoRoot, packageEntry)) - // With shell:true a missing binary surfaces as a cmd.exe exit code instead of a - // spawn error, so mirror the ENOENT that spawnSync reports without a shell. - if (process.platform === "win32" && !fs.existsSync(command)) { - throw new Error(`${packageEntry.id} related-test discovery could not start: spawnSync ${command} ENOENT`) - } - const win32 = process.platform === "win32" - const spawnArgs = [ - "related", - ...sourceFiles, - "--run", - "--config", - configFile, - "--reporter=json", - `--outputFile=${outputFile}`, - ] - // Node warns (DEP0190) whenever spawnSync receives a non-empty args array with - // shell:true, even when every operand is already quoted, so on win32 join the - // quoted operands into one command line and pass an empty args array. - const [spawnCommand, spawnOperands] = win32 - ? [[command, ...spawnArgs].map((operand) => win32ShellQuote(operand)).join(" "), []] - : [command, spawnArgs] - const result = spawnSync(spawnCommand, spawnOperands, { - cwd: runRoot, - encoding: "utf8", - timeout: 5 * 60 * 1_000, - maxBuffer: 50 * 1024 * 1024, - env: process.env, - shell: win32, - }) + const result = spawnSync( + resolveVitestBinary(repoRoot, packageEntry), + ["related", ...sourceFiles, "--run", "--config", configFile, "--reporter=json", `--outputFile=${outputFile}`], + { + cwd: runRoot, + encoding: "utf8", + timeout: 5 * 60 * 1_000, + maxBuffer: 50 * 1024 * 1024, + env: process.env, + }, + ) if (result.error?.code === "ETIMEDOUT") { throw new Error(`${packageEntry.id} related-test discovery exceeded 5 minutes`) @@ -398,28 +367,11 @@ function runStryker(repoRoot, packageEntry, reportRoot, dryRunOnly) { ] if (dryRunOnly) args.push("--dryRunOnly", "--reporters", "clear-text", "--logLevel", "info") - const command = resolveWin32Cmd(path.join(repoRoot, "node_modules/.bin/stryker")) - // With shell:true a missing binary surfaces as a cmd.exe exit code instead of a - // spawn error, so mirror the ENOENT that spawnSync reports without a shell. - if (process.platform === "win32" && !fs.existsSync(command)) { - throw new Error( - `${packageEntry.id} Stryker ${dryRunOnly ? "preflight" : "run"} could not start: spawnSync ${command} ENOENT`, - ) - } - const win32 = process.platform === "win32" - // Node warns (DEP0190) whenever spawnSync receives a non-empty args array with - // shell:true, even when every operand is already quoted, so on win32 join the - // quoted operands into one command line and pass an empty args array. - const [spawnCommand, spawnOperands] = win32 - ? [[command, ...args].map((operand) => win32ShellQuote(operand)).join(" "), []] - : [command, args] - - const result = spawnSync(spawnCommand, spawnOperands, { + const result = spawnSync(path.join(repoRoot, "node_modules/.bin/stryker"), args, { cwd: runRoot, encoding: "utf8", timeout: 12 * 60 * 1_000, maxBuffer: 50 * 1024 * 1024, - shell: win32, env: { ...process.env, STRYKER_VITEST_CONFIG: path From e00a2b17d42b1b2791d900486369e87fa25e3079 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Fri, 4 Sep 2026 13:27:47 +0200 Subject: [PATCH 06/39] fix: address CodeRabbit review on tool-policy prompt unification --- .../architect-mode-prompt.snap | 4 +- .../ask-mode-prompt.snap | 4 +- .../no-mcp-servers.snap | 4 +- .../consistent-system-prompt.snap | 4 +- .../system-prompt/with-mcp-hub-provided.snap | 4 +- .../system-prompt/with-undefined-mcp-hub.snap | 4 +- src/core/prompts/__tests__/sections.spec.ts | 2 +- .../sections/__tests__/objective.spec.ts | 11 +++++- src/core/prompts/sections/capabilities.ts | 2 +- src/core/prompts/sections/objective.ts | 2 +- .../prompts/tools/effective-tool-policy.ts | 14 ++++++- src/core/task/__tests__/Task.spec.ts | 37 +++++++++++++++++++ src/core/task/__tests__/build-tools.spec.ts | 3 ++ src/core/task/build-tools.ts | 3 ++ .../__tests__/generateSystemPrompt.spec.ts | 28 +++++++++++++- 15 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap index 1c2bca6291..8dacd14a25 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap @@ -25,7 +25,7 @@ By carefully considering the user's response after tool executions, you can reac CAPABILITIES You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) -- These tools help you effectively accomplish a wide range of tasks. +- These tools help you accomplish tasks. - 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. ==== @@ -74,7 +74,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. 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. -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. +3. Remember, use the tools provided to you 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. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap index c2e76a594a..51df496c74 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap @@ -25,7 +25,7 @@ By carefully considering the user's response after tool executions, you can reac CAPABILITIES You have access to tools that let you list files, regex search, read files. -- These tools help you effectively accomplish a wide range of tasks. +- These tools help you accomplish tasks. - 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. ==== @@ -74,7 +74,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. 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. -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. +3. Remember, use the tools provided to you 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. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap index 1c2bca6291..8dacd14a25 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap @@ -25,7 +25,7 @@ By carefully considering the user's response after tool executions, you can reac CAPABILITIES You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) -- These tools help you effectively accomplish a wide range of tasks. +- These tools help you accomplish tasks. - 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. ==== @@ -74,7 +74,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. 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. -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. +3. Remember, use the tools provided to you 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. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap index 78181206bd..8144b1fbd0 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap @@ -25,7 +25,7 @@ By carefully considering the user's response after tool executions, you can reac CAPABILITIES You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) -- These tools help you effectively accomplish a wide range of tasks. +- These tools help you accomplish tasks. - 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. ==== @@ -74,7 +74,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. 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. -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. +3. Remember, use the tools provided to you 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. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap index 4f3f69feed..f470918698 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap @@ -25,7 +25,7 @@ By carefully considering the user's response after tool executions, you can reac CAPABILITIES You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) -- These tools help you effectively accomplish a wide range of tasks. +- These tools help you accomplish tasks. - 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 have access to MCP servers that may provide additional tools and/or resources actually available to this mode. Each server may provide different capabilities that you can use to accomplish tasks more effectively. @@ -76,7 +76,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. 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. -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. +3. Remember, use the tools provided to you 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. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap index 78181206bd..8144b1fbd0 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap @@ -25,7 +25,7 @@ By carefully considering the user's response after tool executions, you can reac CAPABILITIES You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) -- These tools help you effectively accomplish a wide range of tasks. +- These tools help you accomplish tasks. - 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. ==== @@ -74,7 +74,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. 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. -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. +3. Remember, use the tools provided to you 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. diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index 570cc4c846..b3f99838e9 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -344,7 +344,7 @@ describe("getRulesSection", () => { expect(result).toContain("The user may provide a file's contents directly") }) - it("includes vendor confidentiality section when isStealthModel is true (kept)", () => { + it("keeps a stable RULES baseline", () => { // duplicate guard: ensure the describe still asserts a stable baseline even if other tests change const result = getRulesSection(cwd, settings, policyFor(["read", "edit", "command"])) expect(result).toContain("RULES") diff --git a/src/core/prompts/sections/__tests__/objective.spec.ts b/src/core/prompts/sections/__tests__/objective.spec.ts index 64b666d552..fd0fddd2c8 100644 --- a/src/core/prompts/sections/__tests__/objective.spec.ts +++ b/src/core/prompts/sections/__tests__/objective.spec.ts @@ -18,7 +18,7 @@ describe("getObjectiveSection", () => { // Check that all numbered items are present expect(objective).toContain("1. Analyze the user's task") expect(objective).toContain("2. Work through these goals sequentially") - expect(objective).toContain("3. Remember, you have extensive capabilities") + expect(objective).toContain("3. Remember, use the tools provided to you") expect(objective).toContain("4. Once you've completed the user's task") expect(objective).toContain("5. The user may provide feedback") }) @@ -56,6 +56,15 @@ describe("getObjectiveSection", () => { expect(objective).toContain("You accomplish a given task iteratively") }) + it("drops the broad-tool claim under a zero-clause policy", () => { + // Regression guard: step 3 must not claim "extensive capabilities" or a + // "wide range of tools" when the policy advertises no tool clauses at all. + const objective = getObjectiveSection(policyFor([])) + + expect(objective).not.toContain("extensive capabilities") + expect(objective).not.toContain("wide range of tools") + }) + it("replaces the ask step with best-effort phrasing when ask_followup_question is absent", () => { const objective = getObjectiveSection(policyFor([])) diff --git a/src/core/prompts/sections/capabilities.ts b/src/core/prompts/sections/capabilities.ts index 8df9c3f9ff..73e6e9ca20 100644 --- a/src/core/prompts/sections/capabilities.ts +++ b/src/core/prompts/sections/capabilities.ts @@ -55,7 +55,7 @@ export function getCapabilitiesSection(policy: EffectiveToolPolicy): string { let body = `${capabilitySentence}${editRestrictionSuffix}\n` - body += `- These tools help you effectively accomplish a wide range of tasks.\n` + body += `- These tools help you accomplish tasks.\n` // `list_files` guidance only — the file-tree *fact* is stated once in // SYSTEM INFORMATION (and carries the cwd there). diff --git a/src/core/prompts/sections/objective.ts b/src/core/prompts/sections/objective.ts index 294686fcc6..fbb2459668 100644 --- a/src/core/prompts/sections/objective.ts +++ b/src/core/prompts/sections/objective.ts @@ -23,7 +23,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. 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. -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, ${askStep}. DO NOT ask for more information on optional parameters if it is not provided. +3. Remember, use the tools provided to you 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, ${askStep}. 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.` } diff --git a/src/core/prompts/tools/effective-tool-policy.ts b/src/core/prompts/tools/effective-tool-policy.ts index 7904afe6cb..fb1a8de2c9 100644 --- a/src/core/prompts/tools/effective-tool-policy.ts +++ b/src/core/prompts/tools/effective-tool-policy.ts @@ -145,6 +145,11 @@ export interface EffectiveToolPolicy { * Cheap existence check: it inspects the MCP server snapshot directly (allowlist * + `enabledForPrompt !== false`, mirroring the `getMcpServerTools` filter) and * never materializes or normalizes tool schemas. + * + * @param mcpHub The MCP hub, or undefined when MCP is unavailable (always false). + * @param allowedServers Optional per-mode server allowlist; when provided only + * these servers are considered. + * @returns True when at least one allowed server exposes a prompt-enabled tool. */ function resolveHasMcpTools(mcpHub?: McpHub, allowedServers?: string[]): boolean { if (!mcpHub) { @@ -164,6 +169,11 @@ function resolveHasMcpTools(mcpHub?: McpHub, allowedServers?: string[]): boolean * When `allowedServers` is provided, only servers whose name is in the allowlist * are considered, keeping the `access_mcp_resource` availability check consistent * with the mode's MCP server allowlist. + * + * @param mcpHub The MCP hub whose server snapshot is inspected. + * @param allowedServers Optional per-mode server allowlist; when provided only + * these servers are considered. + * @returns True when at least one allowed server exposes one or more resources. */ export function hasAnyMcpResources(mcpHub: McpHub, allowedServers?: string[]): boolean { let servers = mcpHub.getServers() @@ -287,7 +297,8 @@ export function resolveEffectiveToolPolicy(input: EffectiveToolPolicyInput): Eff // parameter, so the restriction is enforced regardless of call site // (defense in depth). const effectiveAllowedMcpServers = allowedMcpServers ?? modeConfig.allowedMcpServers - if (!mcpHub || !hasAnyMcpResources(mcpHub, effectiveAllowedMcpServers)) { + const hasMcpResources = !!mcpHub && hasAnyMcpResources(mcpHub, effectiveAllowedMcpServers) + if (!hasMcpResources) { allowedToolNames.delete("access_mcp_resource") } @@ -299,7 +310,6 @@ export function resolveEffectiveToolPolicy(input: EffectiveToolPolicyInput): Eff } const hasMcpGroup = modeConfig.groups.some((groupEntry: GroupEntry) => getGroupName(groupEntry) === "mcp") - const hasMcpResources = !!mcpHub && hasAnyMcpResources(mcpHub, effectiveAllowedMcpServers) const hasMcpTools = resolveHasMcpTools(mcpHub, effectiveAllowedMcpServers) return { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 4a36919a2c..b90b61fd3f 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -610,6 +610,43 @@ describe("Cline", () => { expect(systemPromptCall[16]).toBeUndefined() }) + it("forwards non-empty disabledTools and modelInfo to the system prompt call", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // First getState call: MCP disabled (avoids the MCP hub path). Later + // calls supply the state that feeds `state?.disabledTools`. + vi.spyOn(mockProvider, "getState") + // ProviderState requires all declared fields; the test supplies a partial state. + .mockResolvedValueOnce({ mcpEnabled: false } as unknown as ProviderState) + // ProviderState requires all declared fields; the test supplies a partial state. + .mockResolvedValue({ + mcpEnabled: false, + disabledTools: ["execute_command"], + } as unknown as ProviderState) + + const modelInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: false, + maxTokens: 1234, + } + vi.spyOn(task.api, "getModel").mockReturnValue({ id: "distinctive-model-id", info: modelInfo }) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await expect(getTaskTestAccess(task).getSystemPrompt()).resolves.toBe("mock system prompt") + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 16 is the disabledTools parameter fed by `state?.disabledTools`; + // index 17 is the modelInfo from `this.api.getModel().info`. + expect(systemPromptCall[16]).toEqual(["execute_command"]) + expect(systemPromptCall[17]).toBe(modelInfo) + }) + it("uses the task mode when manually condensing after focused state changes", async () => { vi.spyOn(mockProvider, "getState").mockResolvedValue({ mode: "architect", diff --git a/src/core/task/__tests__/build-tools.spec.ts b/src/core/task/__tests__/build-tools.spec.ts index cc369783ce..f2cc1d8757 100644 --- a/src/core/task/__tests__/build-tools.spec.ts +++ b/src/core/task/__tests__/build-tools.spec.ts @@ -100,6 +100,8 @@ describe("buildNativeToolsArrayWithRestrictions — Gemini includeAllToolsWithRe // in the logical set even though it is advertised in tools. expect(toolNames(result.tools)).toContain("execute_command") expect(result.allowedFunctionNames).not.toContain("execute_command") + // Anchor: the mode's read group is still allowed, so the list is populated. + expect(result.allowedFunctionNames).toContain("read_file") }) it("default path (flag omitted) omits disabled tools from the sent declarations", async () => { @@ -137,5 +139,6 @@ describe("buildNativeToolsArrayWithRestrictions — Gemini includeAllToolsWithRe }) expect(result.allowedFunctionNames).not.toContain("read_file") + expect(result.allowedFunctionNames).toContain("attempt_completion") }) }) diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index ebbdc050dc..8e1015e360 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -51,6 +51,9 @@ interface BuildToolsResult { /** * Extracts the function name from a tool definition. + * + * @param tool A chat-completion tool definition (function tool in practice). + * @returns The tool's function name. */ function getToolName(tool: OpenAI.Chat.ChatCompletionTool): string { return (tool as OpenAI.Chat.ChatCompletionFunctionTool).function.name diff --git a/src/core/webview/__tests__/generateSystemPrompt.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.spec.ts index 777db78e02..6988783ec0 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.spec.ts +++ b/src/core/webview/__tests__/generateSystemPrompt.spec.ts @@ -232,6 +232,32 @@ describe("generateSystemPrompt preview parity", () => { expect(capabilities).toContain("execute CLI commands") }) + it("omits command guidance from the preview when execute_command is disabled", async () => { + // The preview must forward state.disabledTools to SYSTEM_PROMPT: with + // execute_command disabled, the CAPABILITIES section drops every + // command-related fragment. The once-value overrides the shared default + // without mutating it for other tests. + vi.mocked(fakeProvider.getState).mockResolvedValueOnce({ + apiConfiguration: { apiProvider: providerIdentifiers.openai, modelId: "gpt-4o" }, + customModePrompts: undefined, + customInstructions: undefined, + mcpEnabled: false, + experiments: {}, + language: undefined, + enableSubfolderRules: false, + disabledTools: ["execute_command"], + }) + + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + const capabilities = extractSection(preview, "CAPABILITIES") + + expect(capabilities).not.toContain("execute CLI commands") + expect(capabilities).not.toContain("You can use the execute_command tool") + // Anchor: the section is still populated, proving only execute_command + // guidance was removed. + expect(capabilities).toContain("list files") + }) + it("resolves when settings are omitted instead of dereferencing them", async () => { // generatePrompt reads `settings?.todoListEnabled`; without the optional // chain this call rejects with a TypeError on the undefined settings object. @@ -310,7 +336,7 @@ describe("getCapabilitiesSection / getRulesSection fragment gating", () => { expect(result).toContain( "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 files, write and edit files.", ) - expect(result).toContain("\n- These tools help you effectively accomplish a wide range of tasks.\n") + expect(result).toContain("\n- These tools help you accomplish tasks.\n") expect(result).toContain("you can use the list_files tool") expect(result).toContain("You can use the execute_command tool to run commands on the user's computer") expect(result).toContain( From 943a16ed2274b836ddaedcbfc873494e94070277 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Fri, 4 Sep 2026 13:39:38 +0200 Subject: [PATCH 07/39] fix(test): correct apiModelId in generateSystemPrompt state mock --- .../__tests__/generateSystemPrompt.spec.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/core/webview/__tests__/generateSystemPrompt.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.spec.ts index 6988783ec0..384e564f8c 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.spec.ts +++ b/src/core/webview/__tests__/generateSystemPrompt.spec.ts @@ -169,19 +169,24 @@ describe("generateSystemPrompt preview parity", () => { * a minimal object literal stands in for it. This is the single double * assertion in this spec. */ + // The preview only destructures a handful of getState() fields, so the mock + // returns that subset instead of a full ExtensionState; keeping the raw + // vi.fn() (rather than vi.mocked) avoids casting the partial doubles. + const getStateMock = vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: providerIdentifiers.openai, apiModelId: "gpt-4o" }, + customModePrompts: undefined, + customInstructions: undefined, + mcpEnabled: false, + experiments: {}, + language: undefined, + enableSubfolderRules: false, + disabledTools: undefined, + }) + const fakeProvider = { context: mockContext, cwd: "/test/path", - getState: vi.fn().mockResolvedValue({ - apiConfiguration: { apiProvider: providerIdentifiers.openai, modelId: "gpt-4o" }, - customModePrompts: undefined, - customInstructions: undefined, - mcpEnabled: false, - experiments: {}, - language: undefined, - enableSubfolderRules: false, - disabledTools: undefined, - }), + getState: getStateMock, getMcpHub: vi.fn(), getCurrentTask: vi.fn().mockReturnValue(undefined), getSkillsManager: vi.fn().mockReturnValue(undefined), @@ -237,8 +242,8 @@ describe("generateSystemPrompt preview parity", () => { // execute_command disabled, the CAPABILITIES section drops every // command-related fragment. The once-value overrides the shared default // without mutating it for other tests. - vi.mocked(fakeProvider.getState).mockResolvedValueOnce({ - apiConfiguration: { apiProvider: providerIdentifiers.openai, modelId: "gpt-4o" }, + getStateMock.mockResolvedValueOnce({ + apiConfiguration: { apiProvider: providerIdentifiers.openai, apiModelId: "gpt-4o" }, customModePrompts: undefined, customInstructions: undefined, mcpEnabled: false, From bd7894c2d0e0e82dcc7d84ec530d05c785b82313 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Fri, 4 Sep 2026 14:01:28 +0200 Subject: [PATCH 08/39] drop use_mcp_tool from policy when no MCP tool is permitted --- .../__tests__/effective-tool-policy.spec.ts | 44 +++++++++++++++++++ .../prompts/tools/effective-tool-policy.ts | 17 ++++--- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts b/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts index b57fbdc8e7..05fccb0ae8 100644 --- a/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts +++ b/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts @@ -275,6 +275,50 @@ describe("resolveEffectiveToolPolicy - MCP resource gate", () => { allowedMcpServers: ["other"], }) expect(policy.hasMcpTools).toBe(false) + expect(policy.tools.has("use_mcp_tool")).toBe(false) + }) + + it("keeps use_mcp_tool only when an allowed server exposes a prompt-enabled tool", () => { + const withTools = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", enabledForPrompt: true }] }]), + }) + expect(withTools.tools.has("use_mcp_tool")).toBe(true) + + const allDisabled = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", enabledForPrompt: false }] }]), + }) + expect(allDisabled.tools.has("use_mcp_tool")).toBe(false) + }) + + it("drops use_mcp_tool when mcpHub is undefined even though the mcp group is granted", () => { + const policy = policyFor(["mcp"]) + expect(policy.hasMcpGroup).toBe(true) + expect(policy.hasMcpTools).toBe(false) + expect(policy.tools.has("use_mcp_tool")).toBe(false) + expect(policy.tools.has("access_mcp_resource")).toBe(false) + }) + + it("drops use_mcp_tool when the allowedMcpServers list is empty", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([ + { name: "s", tools: [{ name: "t", enabledForPrompt: true }], resources: [{ uri: "r" }] }, + ]), + allowedMcpServers: [], + }) + // An empty allowlist permits no servers: both MCP group tools must go, + // even though the hub itself exposes a live tool and a resource. + expect(policy.hasMcpTools).toBe(false) + expect(policy.hasMcpResources).toBe(false) + expect(policy.tools.has("use_mcp_tool")).toBe(false) + expect(policy.tools.has("access_mcp_resource")).toBe(false) + }) + + it("keeps use_mcp_tool with resources-only hub and access_mcp_resource pruned", () => { + // The two group tools are gated independently: resources alone keep + // access_mcp_resource but must not resurrect use_mcp_tool. + const policy = policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s", resources: [{ uri: "r" }] }]) }) + expect(policy.tools.has("access_mcp_resource")).toBe(true) + expect(policy.tools.has("use_mcp_tool")).toBe(false) }) }) diff --git a/src/core/prompts/tools/effective-tool-policy.ts b/src/core/prompts/tools/effective-tool-policy.ts index fb1a8de2c9..5a800da7b6 100644 --- a/src/core/prompts/tools/effective-tool-policy.ts +++ b/src/core/prompts/tools/effective-tool-policy.ts @@ -139,8 +139,9 @@ export interface EffectiveToolPolicy { /** * True when at least one dynamic MCP tool (e.g. `mcp_serverName_toolName`) is - * enabled for the allowed servers. Used to gate the MCP capability bullet in the - * prompt so servers whose every tool is `enabledForPrompt: false` do not count. + * enabled for the allowed servers. Used both to gate the MCP capability bullet in + * the prompt and to prune `use_mcp_tool` from the policy's tool set, so servers + * whose every tool is `enabledForPrompt: false` do not count. * * Cheap existence check: it inspects the MCP server snapshot directly (allowlist * + `enabledForPrompt !== false`, mirroring the `getMcpServerTools` filter) and @@ -292,15 +293,22 @@ export function resolveEffectiveToolPolicy(input: EffectiveToolPolicyInput): Eff } } - // 10. Drop access_mcp_resource unless allowed servers expose accessible resources. + // 10. Drop the MCP group tools unless allowed servers actually expose them. // Fall back to the mode config's own allowlist when the caller omits the // parameter, so the restriction is enforced regardless of call site - // (defense in depth). + // (defense in depth). `getToolsForMode` grants both group tools together, so + // each is pruned independently: `access_mcp_resource` when no allowed server + // exposes resources, and `use_mcp_tool` when no allowed server exposes a + // prompt-enabled tool (mirrors `getMcpServerTools`, which would emit none). const effectiveAllowedMcpServers = allowedMcpServers ?? modeConfig.allowedMcpServers const hasMcpResources = !!mcpHub && hasAnyMcpResources(mcpHub, effectiveAllowedMcpServers) if (!hasMcpResources) { allowedToolNames.delete("access_mcp_resource") } + const hasMcpTools = resolveHasMcpTools(mcpHub, effectiveAllowedMcpServers) + if (!hasMcpTools) { + allowedToolNames.delete("use_mcp_tool") + } // 11. Protocol guarantee: re-add every protocol tool so the logical set and // the runtime validator both agree it is callable even when disabled. @@ -310,7 +318,6 @@ export function resolveEffectiveToolPolicy(input: EffectiveToolPolicyInput): Eff } const hasMcpGroup = modeConfig.groups.some((groupEntry: GroupEntry) => getGroupName(groupEntry) === "mcp") - const hasMcpTools = resolveHasMcpTools(mcpHub, effectiveAllowedMcpServers) return { tools: allowedToolNames, From 26a3a686532d69fb2d2af95d1a8580458693159b Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Mon, 7 Sep 2026 09:50:57 +0200 Subject: [PATCH 09/39] code hardening --- src/core/task/Task.ts | 49 +++- src/core/task/__tests__/Task.spec.ts | 212 +++++++++++++++++- .../__tests__/generateSystemPrompt.spec.ts | 112 ++++++++- src/core/webview/generateSystemPrompt.ts | 35 ++- 4 files changed, 398 insertions(+), 10 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index f32fc8a8a8..3a4e1b0060 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -171,6 +171,13 @@ function queuedResponseForAsk(type: ClineAsk, text?: string): QueuedAskResolutio const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors +/** + * Provider state snapshot threaded from request entry points (attemptApiRequest, + * condenseContext, handleContextWindowExceededError) into `getSystemPrompt` so the + * prompt and the runtime tool array resolve from one consistent set of values. + */ +type SystemPromptRequestState = Awaited> + export interface TaskOptions extends CreateTaskOptions { provider: ClineProvider apiConfiguration: ProviderSettings @@ -1711,10 +1718,13 @@ 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() + // Capture provider state once and thread it into getSystemPrompt so the + // prompt and the condensing tool array below resolve from one snapshot. + const state = await this.providerRef.deref()?.getState() + + const systemPrompt = await this.getSystemPrompt(state) // 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() @@ -4037,8 +4047,15 @@ export class Task extends EventEmitter implements TaskLike { return false } - private async getSystemPrompt(): Promise { - const { mcpEnabled } = (await this.providerRef.deref()?.getState()) ?? {} + /** + * Builds the SYSTEM_PROMPT. Callers that also construct runtime tools for the + * same request must pass their state snapshot as `requestState` so the prompt + * and the tool array are resolved from one consistent snapshot - otherwise a + * settings change during the MCP wait can make the prompt advertise a tool the + * runtime rejects, or hide a callable tool. + */ + private async getSystemPrompt(requestState?: SystemPromptRequestState): Promise { + const { mcpEnabled } = requestState ?? (await this.providerRef.deref()?.getState()) ?? {} let mcpHub: McpHub | undefined if (mcpEnabled ?? true) { const provider = this.providerRef.deref() @@ -4062,7 +4079,7 @@ export class Task extends EventEmitter implements TaskLike { const rooIgnoreInstructions = this.rooIgnoreController?.getInstructions() - const state = await this.providerRef.deref()?.getState() + const state = requestState ?? (await this.providerRef.deref()?.getState()) const { customModes, customModePrompts, customInstructions, experiments, language, enableSubfolderRules } = state ?? {} @@ -4077,6 +4094,10 @@ export class Task extends EventEmitter implements TaskLike { throw new Error("Provider not available") } + // Load dynamically discovered model metadata (router providers) before + // reading it, so the prompt's included/excluded tool guidance matches + // the runtime path, which fetches before tool construction. + await this.safeEnsureModelFetched() const modelInfo = this.api.getModel().info return SYSTEM_PROMPT( @@ -4122,6 +4143,17 @@ export class Task extends EventEmitter implements TaskLike { * Ensures router-provider model metadata is loaded before getModel() is used for * context management or streaming. Failures fall back to hardcoded defaults rather * than aborting the task. + * + * The prompt- and context-critical read sites of getModel() (streaming entry, + * getSystemPrompt, context-window handling) each await this immediately before + * reading; the condense/tool-array read sites are covered by the immediately + * preceding getSystemPrompt guard in the same request plus the router provider's + * success cache. That redundancy is deliberate: router-provider caches successes + * (repeat awaits are no-ops) but caches no failures, so a failing endpoint costs + * one retry per guard. The per-site invariant was chosen over fetch-ordering + * coupling between methods; negative caching in RouterProvider (recording failed + * fetches with a TTL) remains future work if the failure-path latency ever + * matters. */ private async safeEnsureModelFetched(): Promise { try { @@ -4217,7 +4249,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(state), taskId: this.taskId, profileThresholds, currentProfileId, @@ -4338,7 +4370,10 @@ export class Task extends EventEmitter implements TaskLike { // in the caller. this.rateLimitClock.recordRequest() - const systemPrompt = await this.getSystemPrompt() + // Thread the request state snapshot into prompt generation so the prompt + // and the runtime tools (built below from the same `state`) stay aligned + // even if settings change while this method waits on MCP or rate limits. + const systemPrompt = await this.getSystemPrompt(state) const { contextTokens } = this.getTokenUsage() if (contextTokens) { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 6213f5d376..1e48ed14ae 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -29,7 +29,8 @@ import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-sear import type { ApiMessage } from "../../task-persistence" type TaskTestAccess = { - getSystemPrompt: () => Promise + getSystemPrompt: (requestState?: unknown) => Promise + handleContextWindowExceededError: () => Promise getEnabledMcpToolsCount: () => Promise<{ enabledToolCount: number; enabledServerCount: number }> initiateTaskLoop: (userContent: Anthropic.Messages.ContentBlockParam[]) => Promise startTask: (task?: string, images?: string[]) => Promise @@ -647,6 +648,215 @@ describe("Cline", () => { expect(systemPromptCall[17]).toBe(modelInfo) }) + it("fetches dynamic model metadata before reading model info for the prompt", async () => { + // Router providers discover model metadata (including included/excluded + // tools) lazily. getSystemPrompt must await ensureModelFetched() before + // reading getModel().info, otherwise the prompt is built from fallback + // metadata with different tool guidance than the runtime path. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // ProviderState requires all declared fields; the test supplies a partial state (MCP disabled). + vi.spyOn(mockProvider, "getState").mockResolvedValue({ + mcpEnabled: false, + } as unknown as ProviderState) + + const fallbackInfo: ModelInfo = { + contextWindow: 32_000, + supportsPromptCache: false, + } + const fetchedInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: true, + excludedTools: ["execute_command"], + } + let currentInfo = fallbackInfo + const ensureModelFetched = vi.fn(async () => { + currentInfo = fetchedInfo + }) + Object.assign(task.api, { ensureModelFetched }) + vi.spyOn(task.api, "getModel").mockImplementation(() => ({ id: "router-model", info: currentInfo })) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await expect(getTaskTestAccess(task).getSystemPrompt()).resolves.toBe("mock system prompt") + + expect(ensureModelFetched).toHaveBeenCalledTimes(1) + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 17 is modelInfo: it must be the post-fetch metadata. + expect(systemPromptCall[17]).toBe(fetchedInfo) + }) + + it("threads the request state snapshot into the system prompt when provider state changes mid-request", async () => { + // attemptApiRequest captures provider state, then getSystemPrompt waits + // on MCP initialization. A settings change during that window must NOT + // leak into the prompt: the prompt and the runtime tool array (both fed + // from the request snapshot) have to stay aligned. The prompt must be + // built from that snapshot: if the prompt path re-read provider state, + // it would pick up the divergent disabledTools stubbed for later calls. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + const realState = await mockProvider.getState() + // First call: the snapshot captured by attemptApiRequest. + vi.spyOn(mockProvider, "getState") + .mockResolvedValueOnce({ + ...realState, + mcpEnabled: false, + autoApprovalEnabled: false, + disabledTools: ["execute_command"], + // ProviderState requires all declared fields; the test supplies the request-scoped subset the prompt consumes. + } as unknown as ProviderState) + // Any later getState() read returns different disabledTools, so a + // re-read along the prompt path would change the observed behavior. + .mockResolvedValue({ + ...realState, + mcpEnabled: false, + autoApprovalEnabled: false, + disabledTools: ["read_file"], + // ProviderState requires all declared fields; the test supplies the changed-state subset to detect re-reads. + } as unknown as ProviderState) + + vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 0, + }) + vi.spyOn(task.api, "createMessage").mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { type: "text", text: "ok" } + }, + async next() { + return { done: true, value: undefined } + }, + async return() { + return { done: true, value: undefined } + }, + async throw(error: unknown) { + throw error + }, + async [Symbol.asyncDispose]() {}, + } as AsyncGenerator) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + const iterator = task.attemptApiRequest(0) + await iterator.next() + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 16 is disabledTools: the snapshot value from the first + // getState call, not the changed value from later reads. + expect(systemPromptCall[16]).toEqual(["execute_command"]) + }) + + it("threads the captured state snapshot into the system prompt when manually condensing", async () => { + // condenseContext captures provider state once and threads it into + // getSystemPrompt so the prompt and the condensing tool array resolve + // from one snapshot. Without threading, getSystemPrompt would re-read + // provider state here and pick up the divergent second state below. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // ProviderState requires all declared fields; the test supplies the + // snapshot subset (MCP disabled, one disabled tool). + const snapshot = { + mcpEnabled: false, + disabledTools: ["execute_command"], + } as unknown as ProviderState + vi.spyOn(mockProvider, "getState") + // First call: the snapshot captured by condenseContext. + .mockResolvedValueOnce(snapshot) + // Any later getState() read returns different disabledTools, so a + // re-read along the prompt path would change the observed behavior. + // ProviderState requires all declared fields; the test supplies the changed-state subset to detect re-reads. + .mockResolvedValue({ + mcpEnabled: false, + disabledTools: ["read_file"], + } as unknown as ProviderState) + + const getSystemPromptSpy = vi + .spyOn(getTaskTestAccess(task), "getSystemPrompt") + .mockResolvedValue("mock system prompt") + + await task.condenseContext() + + // Reference equality: without threading, the argument would be + // undefined and getSystemPrompt would re-read the divergent state. + expect(getSystemPromptSpy).toHaveBeenCalledWith(snapshot) + }) + + it("threads the captured state snapshot into the system prompt when the context window is exceeded", async () => { + // handleContextWindowExceededError captures provider state up front + // and threads it into the getSystemPrompt call feeding manageContext; + // a settings change mid-handler must not leak into the condensing prompt. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // ProviderState requires all declared fields; the test supplies the + // snapshot subset (MCP disabled, one disabled tool). + const snapshot = { + mcpEnabled: false, + disabledTools: ["execute_command"], + } as unknown as ProviderState + vi.spyOn(mockProvider, "getState") + // First call: the snapshot captured at the top of + // handleContextWindowExceededError. + .mockResolvedValueOnce(snapshot) + // Any later getState() read returns different disabledTools, so a + // re-read along the prompt path would change the observed behavior. + // ProviderState requires all declared fields; the test supplies the changed-state subset to detect re-reads. + .mockResolvedValue({ + mcpEnabled: false, + disabledTools: ["read_file"], + } as unknown as ProviderState) + + // Overflow the 50k window so manageContext takes the condense branch + // (the module-mocked summarizeConversation returns a summary). + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 100_000, + }) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: "ctx-model", + info: { contextWindow: 50_000, maxTokens: 1024, supportsPromptCache: false } as ModelInfo, + }) + Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + task.apiConversationHistory = [{ role: "user", content: [{ type: "text", text: "x" }], ts: Date.now() }] + + const getSystemPromptSpy = vi + .spyOn(getTaskTestAccess(task), "getSystemPrompt") + .mockResolvedValue("mock system prompt") + + await getTaskTestAccess(task).handleContextWindowExceededError() + + // Reference equality: without threading, the argument would be + // undefined and getSystemPrompt would re-read the divergent state. + expect(getSystemPromptSpy).toHaveBeenCalledWith(snapshot) + }) + it("uses the task mode when manually condensing after focused state changes", async () => { vi.spyOn(mockProvider, "getState").mockResolvedValue({ mode: "architect", diff --git a/src/core/webview/__tests__/generateSystemPrompt.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.spec.ts index 384e564f8c..2388acbaf8 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.spec.ts +++ b/src/core/webview/__tests__/generateSystemPrompt.spec.ts @@ -111,12 +111,36 @@ const fullModelInfo: ModelInfo = { excludedTools: ["read_file"], } +// Fallback metadata a lazily loaded router model exposes BEFORE its network +// fetch resolves. Deliberately distinct from fullModelInfo on the OUTPUT axis: +// it excludes list_files (fullModelInfo excludes read_file), so the three +// states — fallback / fetched / undefined — render three different CAPABILITIES +// sections. The parity tests only pass if generateSystemPrompt awaits +// ensureModelFetched() before reading getModel().info, and the rejection test +// below only passes if a failed fetch degrades to THIS fixture (not undefined). +const fallbackModelInfo: ModelInfo = { + contextWindow: 32_000, + supportsPromptCache: false, + excludedTools: ["list_files"], +} + +const modelMock = vi.hoisted(() => { + const state = { fetched: false } + const ensureModelFetched = vi.fn(async () => { + state.fetched = true + }) + return { state, ensureModelFetched } +}) + // Note: the module under test imports `../../api` from src/core/webview, which // resolves to src/api — from this spec's directory (one level deeper) that is // `../../../api`. vi.mock("../../../api", () => ({ buildApiHandler: () => ({ - getModel: () => ({ id: "m", info: fullModelInfo }), + ensureModelFetched: modelMock.ensureModelFetched, + // The handler only knows its full metadata (incl. excludedTools) after + // ensureModelFetched() resolves, mirroring router providers. + getModel: () => ({ id: "m", info: modelMock.state.fetched ? fullModelInfo : fallbackModelInfo }), }), })) @@ -153,6 +177,27 @@ const fullSettings = { } describe("generateSystemPrompt preview parity", () => { + // Spy lifecycle owned by the describe (mirrors Task.spec.ts's consoleErrorSpy + // pattern): a failed assertion inside the rejection test must not leak a + // stubbed console.error into later tests. afterEach restores only this spy; + // the shared vi.fn() doubles (getStateMock, modelMock) are deliberately left + // untouched so their defaults persist for the other tests in this file + // (vi.resetAllMocks() would clobber them). + let errorSpy: ReturnType + + // The temp handler starts every test in the lazy (pre-fetch) state so the + // parity tests genuinely prove the fetch is awaited before getModel().info + // is read. + beforeEach(() => { + modelMock.state.fetched = false + modelMock.ensureModelFetched.mockClear() + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + afterEach(() => { + errorSpy.mockRestore() + }) + // Section-scoped extraction: capture the text between two "====" headers so // the comparison is limited to the sections the tool policy drives. function extractSection(prompt: string, header: string): string { @@ -237,6 +282,71 @@ describe("generateSystemPrompt preview parity", () => { expect(capabilities).toContain("execute CLI commands") }) + it("awaits ensureModelFetched before reading model info", async () => { + // A lazily loaded router model exposes only fallback metadata until the + // fetch resolves. The preview must await ensureModelFetched() first, or + // it would build tool guidance from the fallback metadata (which excludes + // list_files, not read_file) and diverge from the runtime path. + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + expect(modelMock.ensureModelFetched).toHaveBeenCalledTimes(1) + // "read files" only appears with the fallback metadata; the preview must + // reflect the fetched model info instead. + const capabilities = extractSection(preview, "CAPABILITIES") + expect(capabilities).not.toContain("read files") + expect(capabilities).toContain("execute CLI commands") + }) + + it("falls back to handler model info when ensureModelFetched rejects", async () => { + // A network failure must not drop model guidance entirely: the runtime + // path (Task.safeEnsureModelFetched) degrades to getModel().info + // fallback metadata, and the preview must do the same instead of + // passing modelInfo = undefined to SYSTEM_PROMPT. The fixtures make + // the three states distinguishable: fallbackModelInfo excludes + // list_files, fullModelInfo excludes read_file, and undefined excludes + // neither — so the assertion pair below pins the prompt to the + // fallback fixture, and fails if the inner try/catch is removed: the + // rejection would then skip getModel() and the prompt would be built + // with modelInfo === undefined. + modelMock.ensureModelFetched.mockRejectedValueOnce(new Error("network down")) + + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + const capabilities = extractSection(preview, "CAPABILITIES") + // Absent only when modelInfo === fallbackModelInfo (its exclusion). + expect(capabilities).not.toContain("list files") + // Present only when read_file was NOT excluded — rules out fullModelInfo. + expect(capabilities).toContain("read files") + expect(capabilities).toContain("execute CLI commands") + expect(errorSpy).toHaveBeenCalled() + }) + + it("degrades to fallback metadata when ensureModelFetched hangs past the preview timeout", async () => { + // A hung metadata endpoint (some fetchers issue unbounded GETs) must not + // block the user-triggered preview: after PREVIEW_MODEL_FETCH_TIMEOUT_MS + // (5s — kept in sync with the production constant) the race resolves and + // the prompt is built from the fallback metadata, identical to the + // rejected-fetch degradation. + vi.useFakeTimers() + try { + modelMock.ensureModelFetched.mockImplementationOnce(() => new Promise(() => {})) + + const previewPromise = generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + await vi.advanceTimersByTimeAsync(5_000) + const preview = await previewPromise + + const capabilities = extractSection(preview, "CAPABILITIES") + // Fallback fixture signature (see fallbackModelInfo): list_files + // excluded, read_file still advertised — proves fallback metadata, + // not undefined (which would advertise both) and not fullModelInfo + // (which would drop "read files"). + expect(capabilities).not.toContain("list files") + expect(capabilities).toContain("read files") + } finally { + vi.useRealTimers() + } + }) + it("omits command guidance from the preview when execute_command is disabled", async () => { // The preview must forward state.disabledTools to SYSTEM_PROMPT: with // execute_command disabled, the CAPABILITIES section drops every diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index 27457c1c7f..080df356cd 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -10,6 +10,14 @@ import { Package } from "../../shared/package" import { ClineProvider } from "./ClineProvider" +// Upper bound on the preview's wait for lazily loaded model metadata. The +// preview is a user-triggered UI action: some model-catalog fetchers (e.g. +// OpenRouter's bare axios GET) have no request timeout, so a hung endpoint +// must not block it indefinitely. On timeout we degrade to the handler's +// fallback metadata — the same degradation a rejected fetch produces — and +// the next preview re-attempts after the (persistent) cache refreshes. +const PREVIEW_MODEL_FETCH_TIMEOUT_MS = 5_000 + export const generateSystemPrompt = async (provider: ClineProvider, message: WebviewMessage) => { const { apiConfiguration, @@ -35,12 +43,37 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web // This avoids relying on an active Cline instance which might not exist during preview. // The full ModelInfo flows into SYSTEM_PROMPT so the preview honors // excludedTools/includedTools exactly like the runtime path. + // ensureModelFetched() must be awaited before reading getModel().info: + // router providers discover model metadata over the network, and reading the + // info beforehand would build the preview from fallback metadata with different + // tool guidance than the runtime path (which fetches before tool construction). let modelInfo: ModelInfo | undefined try { const tempApiHandler = buildApiHandler(apiConfiguration) + // A failed OR stalled metadata fetch degrades to the handler's fallback + // metadata (mirroring Task.safeEnsureModelFetched) rather than dropping + // model guidance entirely, so the preview keeps matching the runtime + // prompt's failure semantics. Promise.race attaches handlers to both + // inputs, so the abandoned fetch rejecting after the timeout is already + // considered handled — no extra .catch is needed here. + let timeoutId: ReturnType | undefined + try { + await Promise.race([ + tempApiHandler.ensureModelFetched?.(), + new Promise((resolve) => { + timeoutId = setTimeout(resolve, PREVIEW_MODEL_FETCH_TIMEOUT_MS) + }), + ]) + } catch (error) { + console.error("Error fetching model metadata for system prompt preview:", error) + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } + } 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 systemPrompt = await SYSTEM_PROMPT( From 198d86570f7432a3d7e71566c8042a093c7339f0 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Mon, 7 Sep 2026 14:15:44 +0200 Subject: [PATCH 10/39] initial implementation of blanket auto-deny commands --- .../src/__tests__/global-settings.test.ts | 18 + packages/types/src/global-settings.ts | 17 + packages/types/src/vscode-extension-host.ts | 1 + .../presentAssistantMessage-auto-deny.spec.ts | 332 ++++++++++++++++++ .../presentAssistantMessage.ts | 42 ++- .../__tests__/blanket-deny.spec.ts | 259 ++++++++++++++ .../auto-approval/__tests__/commands.spec.ts | 58 ++- src/core/auto-approval/__tests__/dcg.spec.ts | 93 +++++ src/core/auto-approval/__tests__/fixtures.ts | 1 + src/core/auto-approval/autoDenyReason.ts | 49 +++ src/core/auto-approval/commands.ts | 78 +++- src/core/auto-approval/index.ts | 97 ++++- src/core/prompts/responses.ts | 22 ++ src/core/task/Task.ts | 73 +++- src/core/task/__tests__/ask-auto-deny.spec.ts | 197 +++++++++++ src/core/tools/ExecuteCommandTool.ts | 49 ++- .../__tests__/executeCommandTool.spec.ts | 111 +++++- src/core/webview/ClineProvider.ts | 5 + .../webview/__tests__/ClineProvider.spec.ts | 31 ++ src/shared/tools.ts | 18 + .../settings/AutoApproveSettings.tsx | 22 ++ .../src/components/settings/SettingsView.tsx | 3 + .../__tests__/AutoApproveSettings.spec.tsx | 50 +++ .../SettingsView.change-detection.spec.tsx | 2 + .../settings/__tests__/SettingsView.spec.tsx | 1 + .../SettingsView.unsaved-changes.spec.tsx | 2 + webview-ui/src/i18n/locales/ca/settings.json | 4 + webview-ui/src/i18n/locales/de/settings.json | 4 + webview-ui/src/i18n/locales/en/settings.json | 4 + webview-ui/src/i18n/locales/es/settings.json | 4 + webview-ui/src/i18n/locales/fr/settings.json | 4 + webview-ui/src/i18n/locales/hi/settings.json | 4 + webview-ui/src/i18n/locales/id/settings.json | 4 + webview-ui/src/i18n/locales/it/settings.json | 4 + webview-ui/src/i18n/locales/ja/settings.json | 4 + webview-ui/src/i18n/locales/ko/settings.json | 4 + webview-ui/src/i18n/locales/nl/settings.json | 4 + webview-ui/src/i18n/locales/pl/settings.json | 4 + .../src/i18n/locales/pt-BR/settings.json | 4 + webview-ui/src/i18n/locales/ru/settings.json | 4 + webview-ui/src/i18n/locales/tr/settings.json | 4 + webview-ui/src/i18n/locales/vi/settings.json | 4 + .../src/i18n/locales/zh-CN/settings.json | 4 + .../src/i18n/locales/zh-TW/settings.json | 4 + 44 files changed, 1654 insertions(+), 49 deletions(-) create mode 100644 src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts create mode 100644 src/core/auto-approval/__tests__/blanket-deny.spec.ts create mode 100644 src/core/auto-approval/autoDenyReason.ts create mode 100644 src/core/task/__tests__/ask-auto-deny.spec.ts diff --git a/packages/types/src/__tests__/global-settings.test.ts b/packages/types/src/__tests__/global-settings.test.ts index 8107053333..c2040383e8 100644 --- a/packages/types/src/__tests__/global-settings.test.ts +++ b/packages/types/src/__tests__/global-settings.test.ts @@ -1,4 +1,5 @@ import { + DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, GLOBAL_SETTINGS_KEYS, globalSettingsSchema, @@ -20,3 +21,20 @@ describe("destructive command guard global setting", () => { expect(() => globalSettingsSchema.parse({ destructiveCommandGuardEnabled: "true" })).toThrow() }) }) + +describe("alwaysDenyUnapprovedCommands global setting", () => { + it("is opt-in by default", () => { + expect(DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS).toBe(false) + }) + + it("accepts and exposes the persisted setting", () => { + expect(globalSettingsSchema.parse({ alwaysDenyUnapprovedCommands: true })).toEqual({ + alwaysDenyUnapprovedCommands: true, + }) + expect(GLOBAL_SETTINGS_KEYS).toContain("alwaysDenyUnapprovedCommands") + }) + + it("rejects non-boolean setting values", () => { + expect(() => globalSettingsSchema.parse({ alwaysDenyUnapprovedCommands: "true" })).toThrow() + }) +}) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 95f246dbe7..0a8bdf139b 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -48,6 +48,15 @@ export const DEFAULT_DIFF_FUZZY_THRESHOLD = 1.0 export const DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED = false +/** + * Whether commands that are not explicitly auto-approved are automatically + * denied (with a structured reason sent to the model) instead of prompting the + * user. Opt-in: by default, unapproved commands still ask for confirmation. + * Only engages when command auto-approval (`autoApprovalEnabled` + + * `alwaysAllowExecute`) is on. + */ +export const DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS = false + /** * Terminal output preview size options for persisted command output. * @@ -153,6 +162,14 @@ export const globalSettingsSchema = z.object({ alwaysAllowSubtasks: z.boolean().optional(), alwaysAllowExecute: z.boolean().optional(), destructiveCommandGuardEnabled: z.boolean().optional(), + /** + * Blanket auto-deny for unapproved commands. When true (and command + * auto-approval is engaged), every command that is not explicitly + * auto-approved is automatically denied with a structured reason delivered + * to the model, instead of prompting the user. + * @default false + */ + alwaysDenyUnapprovedCommands: z.boolean().optional(), alwaysAllowFollowupQuestions: z.boolean().optional(), followupAutoApproveTimeoutMs: z.number().optional(), allowedCommands: z.array(z.string()).optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 5f6b579779..2f1222f8ee 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -283,6 +283,7 @@ export type ExtensionState = Pick< | "alwaysAllowFollowupQuestions" | "alwaysAllowExecute" | "destructiveCommandGuardEnabled" + | "alwaysDenyUnapprovedCommands" | "followupAutoApproveTimeoutMs" | "allowedCommands" | "deniedCommands" diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts new file mode 100644 index 0000000000..886719278f --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts @@ -0,0 +1,332 @@ +// npx vitest run core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts + +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 type { Task } from "../../task/Task" + +vi.mock("../../task/Task") +vi.mock("../../tools/validateToolUse", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + validateToolUse: vi.fn(), + } +}) + +vi.mock("@roo-code/core", () => ({ + customToolRegistry: { + has: vi.fn(() => false), + get: vi.fn(), + }, +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + captureException: vi.fn(), + captureEvent: vi.fn(), + }, + }, +})) + +// Mock the tool handlers so each test controls exactly what askApproval/pushToolResult +// callbacks do inside a tool execution, isolating the approval-flow behavior of +// presentAssistantMessage itself. +const { executeCommandHandle, listFilesHandle, useMcpToolHandle } = vi.hoisted(() => ({ + executeCommandHandle: vi.fn(), + listFilesHandle: vi.fn(), + useMcpToolHandle: vi.fn(), +})) + +vi.mock("../../tools/ExecuteCommandTool", () => ({ + executeCommandTool: { handle: executeCommandHandle }, +})) + +vi.mock("../../tools/ListFilesTool", () => ({ + listFilesTool: { handle: listFilesHandle }, +})) + +vi.mock("../../tools/UseMcpToolTool", () => ({ + useMcpToolTool: { handle: useMcpToolHandle }, +})) + +interface MockTask { + taskId: string + instanceId: string + abort: boolean + presentAssistantMessageLocked: boolean + presentAssistantMessageHasPendingUpdates: boolean + currentStreamingContentIndex: number + assistantMessageContent: unknown[] + userMessageContent: Anthropic.ToolResultBlockParam[] + didCompleteReadingStream: boolean + didRejectTool: boolean + didAlreadyUseTool: boolean + consecutiveMistakeCount: number + clineMessages: unknown[] + api: { getModel: () => { id: string; info: Record } } + apiConfiguration: { apiProvider: string } + recordToolUsage: ReturnType + recordToolError: ReturnType + toolRepetitionDetector: { check: ReturnType } + providerRef: { + deref: () => { + getState: ReturnType + getMcpHub?: () => { findServerNameBySanitizedName: (name: string) => string | undefined } + } + } + say: ReturnType + ask: ReturnType + pushToolResultToUserContent: ReturnType +} + +function buildMockTask(): MockTask { + const mockTask: MockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + userMessageContent: [], + didCompleteReadingStream: true, + didRejectTool: false, + didAlreadyUseTool: false, + consecutiveMistakeCount: 0, + clineMessages: [], + api: { + getModel: () => ({ id: "test-model", info: {} }), + }, + apiConfiguration: { apiProvider: "test" }, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + toolRepetitionDetector: { + check: vi.fn().mockReturnValue({ allowExecution: true }), + }, + providerRef: { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + pushToolResultToUserContent: vi.fn(), + } + + // Mirror the real Task: collect tool results into userMessageContent, one per + // tool_use_id, so assertions can inspect the exact payloads the model receives. + mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: Anthropic.ToolResultBlockParam) => { + const existingResult = mockTask.userMessageContent.find( + (block) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + return false + } + mockTask.userMessageContent.push(toolResult) + return true + }) + + return mockTask +} + +const executeCommandBlock = { + type: "tool_use", + id: "call_exec", + name: "execute_command", + params: { command: "rm x && npm test" }, + nativeArgs: { command: "rm x && npm test" }, + partial: false, +} + +const listFilesBlock = { + type: "tool_use", + id: "call_ls", + name: "list_files", + params: { path: "src" }, + nativeArgs: { path: "src" }, + partial: false, +} + +const NOT_ALLOWLISTED_DETAIL = { + kind: "not_allowlisted" as const, + command: "rm x && npm test", +} + +describe("presentAssistantMessage - automatic (policy) denials", () => { + let mockTask: MockTask + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(validateToolUse).mockImplementation(() => undefined) + mockTask = buildMockTask() + + // The mocked execute_command handler only runs the approval step: an + // automatic denial means the tool returns without executing anything. + executeCommandHandle.mockImplementation( + async ( + _task: unknown, + _block: unknown, + { askApproval }: { askApproval: (t: string, m?: string) => Promise }, + ) => { + await askApproval("command", "rm x && npm test") + }, + ) + + // The mocked list_files handler records whether it ever ran, and pushes a + // recognizable result when its own approval succeeds. + listFilesHandle.mockImplementation( + async ( + _task: unknown, + _block: unknown, + { + askApproval, + pushToolResult, + }: { askApproval: (t: string, m?: string) => Promise; pushToolResult: (c: string) => void }, + ) => { + const approved = await askApproval("tool", JSON.stringify({ tool: "listFilesTopLevel", path: "src" })) + if (approved) { + pushToolResult("second tool executed") + } + }, + ) + }) + + it("pushes a structured auto_deny tool_result, keeps didRejectTool false, and lets the next tool execute", async () => { + mockTask.assistantMessageContent = [executeCommandBlock, listFilesBlock] + + // First ask: automatic denial with structured detail. Second ask: approval. + mockTask.ask + .mockResolvedValueOnce({ response: "noButtonClicked", autoDenyDetail: NOT_ALLOWLISTED_DETAIL }) + .mockResolvedValueOnce({ response: "yesButtonClicked" }) + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.userMessageContent).toHaveLength(2) + + // The denied command gets the structured auto_deny payload naming the reason. + const denialResult = mockTask.userMessageContent[0] + expect(denialResult.type).toBe("tool_result") + expect(denialResult.tool_use_id).toBe("call_exec") + const denialPayload = JSON.parse(denialResult.content as string) + expect(denialPayload.status).toBe("denied") + expect(denialPayload.type).toBe("auto_deny") + expect(denialPayload.reason).toContain("not on the command allowlist") + expect(denialPayload.offending_command).toBe("rm x && npm test") + + // An automatic denial is scoped to its own tool call: it must NOT abort the + // turn the way a user rejection does. + expect(mockTask.didRejectTool).toBe(false) + expect(mockTask.didAlreadyUseTool).toBe(false) + + // The reason is system-generated: it must not surface as user feedback. + expect(mockTask.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) + expect(mockTask.say).not.toHaveBeenCalledWith("user_feedback", expect.anything()) + + // The second tool in the same turn still executes normally. + expect(listFilesHandle).toHaveBeenCalledTimes(1) + const secondResult = mockTask.userMessageContent[1] + expect(secondResult.tool_use_id).toBe("call_ls") + expect(secondResult.content).toBe("second tool executed") + expect(secondResult.is_error).toBeUndefined() + }) + + it("routes an auto-deny through the MCP askApproval copy without aborting the turn", async () => { + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_mcp", + name: "mcp_my_server_do_thing", + serverName: "my_server", + toolName: "do_thing", + arguments: {}, + partial: false, + }, + ] + + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }), + getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined }), + }), + } + + useMcpToolHandle.mockImplementation( + async ( + _task: unknown, + _block: unknown, + { askApproval }: { askApproval: (t: string, m?: string) => Promise }, + ) => { + await askApproval("use_mcp_server", "{}") + }, + ) + + mockTask.ask.mockResolvedValueOnce({ + response: "noButtonClicked", + autoDenyDetail: { kind: "denylist", command: "rm x", pattern: "rm" }, + }) + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.userMessageContent).toHaveLength(1) + const payload = JSON.parse(mockTask.userMessageContent[0].content as string) + expect(payload.type).toBe("auto_deny") + expect(payload.reason).toContain("matches denied prefix `rm`") + expect(payload.offending_command).toBe("rm x") + expect(mockTask.didRejectTool).toBe(false) + expect(mockTask.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) + }) + + it("keeps the legacy user-rejection behavior when the rejection carries no autoDenyDetail", async () => { + mockTask.assistantMessageContent = [executeCommandBlock, listFilesBlock] + + // Real user click: noButtonClicked with no structured detail. + mockTask.ask.mockResolvedValueOnce({ response: "noButtonClicked" }) + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.userMessageContent).toHaveLength(2) + + // Rejection wording stays the user-denial payload. + const denialResult = mockTask.userMessageContent[0] + expect(denialResult.tool_use_id).toBe("call_exec") + const denialPayload = JSON.parse(denialResult.content as string) + expect(denialPayload.message).toBe("The user denied this operation.") + expect(denialPayload.type).toBeUndefined() + + // A real user rejection still aborts the rest of the turn. + expect(mockTask.didRejectTool).toBe(true) + + // The remaining tool is skipped with the "user rejecting a previous tool" + // message and never executes. + expect(listFilesHandle).not.toHaveBeenCalled() + const skippedResult = mockTask.userMessageContent[1] + expect(skippedResult.tool_use_id).toBe("call_ls") + expect(skippedResult.is_error).toBe(true) + expect(skippedResult.content).toContain("due to user rejecting a previous tool") + expect(skippedResult.content).not.toContain("auto_deny") + }) + + it("persists user feedback as a user_feedback say row on a text-carrying rejection (no detail)", async () => { + mockTask.assistantMessageContent = [executeCommandBlock] + + mockTask.ask.mockResolvedValueOnce({ response: "noButtonClicked", text: "do not run that" }) + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "do not run that", undefined) + expect(mockTask.didRejectTool).toBe(true) + + const payload = JSON.parse(mockTask.userMessageContent[0].content as string) + expect(payload.status).toBe("denied") + expect(payload.feedback).toBe("do not run that") + expect(payload.type).toBeUndefined() + }) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 4155552728..6fa45999ea 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -9,7 +9,9 @@ import { customToolRegistry } from "@roo-code/core" import { t } from "../../i18n" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" -import type { ToolParamName, ToolResponse, ToolUse, McpToolUse } from "../../shared/tools" +import type { AutoApprovalContext, ToolParamName, ToolResponse, ToolUse, McpToolUse } from "../../shared/tools" + +import { buildAutoDenyReason } from "../auto-approval" import { AskIgnoredError } from "../task/AskIgnoredError" import { Task } from "../task/Task" @@ -214,16 +216,32 @@ export async function presentAssistantMessage(cline: Task) { partialMessage?: string, progressStatus?: ToolProgressStatus, isProtected?: boolean, + autoApprovalContext?: AutoApprovalContext, ) => { - const { response, text, images } = await cline.ask( + const { response, text, images, autoDenyDetail } = await cline.ask( type, partialMessage, false, progressStatus, isProtected || false, + autoApprovalContext, ) if (response !== "yesButtonClicked") { + // Automatic (policy) denial: scoped to this tool call, so no + // `didRejectTool` (remaining tool calls in the turn proceed) + // and no `user_feedback` say (the reason is system-generated). + if (autoDenyDetail) { + pushToolResult( + formatResponse.toolAutoDenied({ + reason: buildAutoDenyReason(autoDenyDetail), + offendingCommand: autoDenyDetail.command, + ruleId: autoDenyDetail.dcgRuleId, + }), + ) + return false + } + if (text) { await cline.say("user_feedback", text, images) pushToolResult(formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images)) @@ -519,16 +537,34 @@ export async function presentAssistantMessage(cline: Task) { partialMessage?: string, progressStatus?: ToolProgressStatus, isProtected?: boolean, + autoApprovalContext?: AutoApprovalContext, ) => { - const { response, text, images, queuedMessageId } = await cline.ask( + const { response, text, images, queuedMessageId, autoDenyDetail } = await cline.ask( type, partialMessage, false, progressStatus, isProtected || false, + autoApprovalContext, ) if (response !== "yesButtonClicked") { + // Automatic (policy) denial: scoped to this tool call, so no + // `didRejectTool` (remaining tool calls in the turn proceed) + // and no `user_feedback` say (the reason is system-generated). + // Automatic denials never carry queued feedback — a queued + // message forces a real ask. + if (autoDenyDetail) { + pushToolResult( + formatResponse.toolAutoDenied({ + reason: buildAutoDenyReason(autoDenyDetail), + offendingCommand: autoDenyDetail.command, + ruleId: autoDenyDetail.dcgRuleId, + }), + ) + return false + } + // Handle both messageResponse and noButtonClicked with text. if (queuedMessageId) { const persisted = await cline.persistQueuedFeedbackAndAcknowledge(queuedMessageId, text, images) diff --git a/src/core/auto-approval/__tests__/blanket-deny.spec.ts b/src/core/auto-approval/__tests__/blanket-deny.spec.ts new file mode 100644 index 0000000000..c374a1866a --- /dev/null +++ b/src/core/auto-approval/__tests__/blanket-deny.spec.ts @@ -0,0 +1,259 @@ +import { checkAutoApproval } from ".." + +// Matrix over the blanket auto-deny feature (`alwaysDenyUnapprovedCommands`): +// DCG on/off × blanket on/off × command shapes. The blanket setting only +// engages while command auto-approval (`autoApprovalEnabled` + +// `alwaysAllowExecute`) is on, so every case here keeps both gates on. +describe("blanket auto-deny for unapproved commands", () => { + const baseState = { + autoApprovalEnabled: true, + alwaysAllowReadOnly: false, + alwaysAllowReadOnlyOutsideWorkspace: false, + alwaysAllowWrite: false, + alwaysAllowWriteOutsideWorkspace: false, + alwaysAllowWriteProtected: false, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowFollowupQuestions: false, + alwaysAllowExecute: true, + allowedCommands: ["git"], + deniedCommands: ["rm"], + destructiveCommandGuardEnabled: false, + alwaysDenyUnapprovedCommands: false, + } + + const commandCase = (text: string, overrides: Partial = {}, extra: object = {}) => + checkAutoApproval({ state: { ...baseState, ...overrides }, ask: "command", text, ...extra }) + + describe("DCG disabled", () => { + it("auto-approves allowlist matches regardless of the blanket setting", async () => { + expect(await commandCase("git status")).toEqual({ decision: "approve" }) + expect(await commandCase("git status", { alwaysDenyUnapprovedCommands: true })).toEqual({ + decision: "approve", + }) + }) + + it("auto-denies denylist matches with structured detail, even with blanket off", async () => { + expect(await commandCase("rm file")).toEqual({ + decision: "deny", + autoDeny: { kind: "denylist", command: "rm file", pattern: "rm" }, + }) + expect(await commandCase("rm file", { alwaysDenyUnapprovedCommands: true })).toEqual({ + decision: "deny", + autoDeny: { kind: "denylist", command: "rm file", pattern: "rm" }, + }) + }) + + it("asks for unlisted commands with blanket off", async () => { + expect(await commandCase("some-unknown-command")).toEqual({ decision: "ask" }) + }) + + it("auto-denies unlisted commands with blanket on", async () => { + expect(await commandCase("some-unknown-command", { alwaysDenyUnapprovedCommands: true })).toEqual({ + decision: "deny", + autoDeny: { kind: "not_allowlisted", command: "some-unknown-command" }, + }) + }) + + it("names the first sub-command lacking an allowlist match in a chain", async () => { + const result = await commandCase("git status && unknown-tool", { + alwaysDenyUnapprovedCommands: true, + }) + + expect(result).toEqual({ + decision: "deny", + autoDeny: { kind: "not_allowlisted", command: "unknown-tool" }, + }) + }) + + it("asks for dangerous substitutions with blanket off", async () => { + expect(await commandCase('echo "${var@P}"', { allowedCommands: ["echo"] })).toEqual({ decision: "ask" }) + }) + + it("auto-denies dangerous substitutions with blanket on", async () => { + expect( + await commandCase('echo "${var@P}"', { + allowedCommands: ["echo"], + alwaysDenyUnapprovedCommands: true, + }), + ).toEqual({ + decision: "deny", + autoDeny: { kind: "dangerous_substitution", command: undefined }, + }) + }) + + it("asks for malformed commands with blanket off (normally blocked earlier by the tool)", async () => { + expect(await commandCase("sh -c 'echo a")).toEqual({ decision: "ask" }) + }) + + it("auto-denies malformed commands with blanket on (defense in depth)", async () => { + const result = await commandCase("sh -c 'echo a", { alwaysDenyUnapprovedCommands: true }) + + expect(result).toEqual({ + decision: "deny", + autoDeny: expect.objectContaining({ + kind: "malformed_command", + command: "sh -c 'echo a", + parseError: expect.stringContaining("unterminated"), + }), + }) + }) + + it("never returns ask for a command ask with all gates on and blanket on", async () => { + const commands = [ + "git status", // allowlisted + "rm file", // denylisted + "unknown-command", // unlisted + 'echo "${var@P}"', // dangerous substitution + "sh -c 'echo a", // malformed + "git status && rm x && npm test", // chain with a denied part + "git status && unknown-tool", // chain with an unlisted part + ] + + for (const command of commands) { + const result = await commandCase(command, { + alwaysDenyUnapprovedCommands: true, + allowedCommands: ["echo", "git", "npm", "sh"], + }) + expect(result.decision, `command: ${command}`).not.toBe("ask") + } + }) + + it("leaves protected asks prompting even with blanket on", async () => { + expect( + await checkAutoApproval({ + state: { ...baseState, alwaysDenyUnapprovedCommands: true }, + ask: "command", + text: "some-unknown-command", + isProtected: true, + }), + ).toEqual({ decision: "ask" }) + }) + }) + + describe("gate: blanket only engages while command auto-approval is on", () => { + it("asks when the master auto-approval switch is off", async () => { + expect( + await commandCase("unknown-command", { + alwaysDenyUnapprovedCommands: true, + autoApprovalEnabled: false, + }), + ).toEqual({ decision: "ask" }) + }) + + it("asks when execute auto-approval is off", async () => { + expect( + await commandCase("unknown-command", { + alwaysDenyUnapprovedCommands: true, + alwaysAllowExecute: false, + }), + ).toEqual({ decision: "ask" }) + }) + }) + + describe("DCG enabled", () => { + const dcgState = { destructiveCommandGuardEnabled: true } + + it("approves a DCG-allowed verdict in every blanket mode", async () => { + expect(await commandCase("rm file", dcgState, { dcgDecision: { decision: "allow" } })).toEqual({ + decision: "approve", + }) + expect( + await commandCase( + "rm file", + { ...dcgState, alwaysDenyUnapprovedCommands: true }, + { + dcgDecision: { decision: "allow" }, + }, + ), + ).toEqual({ decision: "approve" }) + }) + + it("auto-denies with the DCG reason when blanket is on", async () => { + expect( + await commandCase( + "rm -rf /", + { ...dcgState, alwaysDenyUnapprovedCommands: true }, + { + dcgDecision: { + decision: "deny", + reason: "matches a destructive pattern", + ruleId: "recursive-delete", + }, + }, + ), + ).toEqual({ + decision: "deny", + autoDeny: { + kind: "dcg", + command: "rm -rf /", + dcgReason: "matches a destructive pattern", + dcgRuleId: "recursive-delete", + }, + }) + }) + + it("prompts (protected ask) when blanket is off", async () => { + expect( + await commandCase( + "rm -rf /", + { ...dcgState, alwaysDenyUnapprovedCommands: false }, + { dcgDecision: { decision: "deny", reason: "matches a destructive pattern" } }, + ), + ).toEqual({ decision: "ask" }) + }) + + it("does not let an allowlist match rescue a DCG denial", async () => { + const result = await commandCase( + "rm file", + { ...dcgState, alwaysDenyUnapprovedCommands: true, allowedCommands: ["rm"] }, + { dcgDecision: { decision: "deny", reason: "matches a destructive pattern" } }, + ) + + expect(result).toEqual({ + decision: "deny", + autoDeny: { + kind: "dcg", + command: "rm file", + dcgReason: "matches a destructive pattern", + dcgRuleId: undefined, + }, + }) + }) + + it("never returns ask for a command ask with all gates on, blanket on, and a verdict", async () => { + for (const dcgDecision of [{ decision: "allow" }, { decision: "deny", reason: "nope" }] as const) { + const result = await commandCase( + "anything", + { ...dcgState, alwaysDenyUnapprovedCommands: true }, + { dcgDecision }, + ) + expect(result.decision, `verdict: ${dcgDecision.decision}`).not.toBe("ask") + } + }) + }) + + it("does not affect non-command asks", async () => { + const state = { ...baseState, alwaysDenyUnapprovedCommands: true, alwaysAllowWrite: true } + + expect( + await checkAutoApproval({ + state, + cwd: "/repo", + ask: "tool", + text: JSON.stringify({ tool: "editedExistingFile", path: "a.ts" }), + }), + ).toEqual({ decision: "approve" }) + + // A write that is not allowed still asks, exactly as before. + expect( + await checkAutoApproval({ + state: { ...state, alwaysAllowWrite: false }, + cwd: "/repo", + ask: "tool", + text: JSON.stringify({ tool: "editedExistingFile", path: "a.ts" }), + }), + ).toEqual({ decision: "ask" }) + }) +}) diff --git a/src/core/auto-approval/__tests__/commands.spec.ts b/src/core/auto-approval/__tests__/commands.spec.ts index fa5762cb56..ade53c2b20 100644 --- a/src/core/auto-approval/__tests__/commands.spec.ts +++ b/src/core/auto-approval/__tests__/commands.spec.ts @@ -1,4 +1,4 @@ -import { containsDangerousSubstitution, getCommandDecision } from "../commands" +import { containsDangerousSubstitution, getCommandDecision, getCommandDecisionDetailed } from "../commands" describe("containsDangerousSubstitution", () => { describe("zsh array assignments (should NOT be flagged)", () => { @@ -159,3 +159,59 @@ describe("getCommandDecision — multi-line script wrapped in a quoted argument" expect(getCommandDecision(malformed, [malformed])).toBe("malformed_command") }) }) + +describe("getCommandDecisionDetailed", () => { + it("names the offending sub-command and matched denied prefix on chained commands", () => { + const result = getCommandDecisionDetailed("git status && rm x && npm test", ["git", "npm"], ["rm"]) + + expect(result.decision).toBe("auto_deny") + expect(result.offendingCommand).toBe("rm x") + expect(result.matchedPattern).toBe("rm") + }) + + it("preserves the original casing of the matched denied prefix", () => { + const result = getCommandDecisionDetailed("RM -rf /tmp/x", [], ["RM -rf"]) + + expect(result.decision).toBe("auto_deny") + expect(result.matchedPattern).toBe("RM -rf") + }) + + it("names the first sub-command lacking an allowlist match for ask_user decisions", () => { + const result = getCommandDecisionDetailed("git status && unknown-tool", ["git"]) + + expect(result.decision).toBe("ask_user") + expect(result.offendingCommand).toBe("unknown-tool") + expect(result.matchedPattern).toBeUndefined() + }) + + it("reports the parse error message for malformed commands", () => { + const result = getCommandDecisionDetailed("sh -c 'echo a", ["sh"]) + + expect(result.decision).toBe("malformed_command") + expect(result.offendingCommand).toBe("sh -c 'echo a") + expect(result.parseError).toContain("unterminated") + }) + + it("returns plain approval details for auto-approved commands", () => { + expect(getCommandDecisionDetailed("git status", ["git"])).toEqual({ decision: "auto_approve" }) + expect(getCommandDecisionDetailed(" ", ["git"])).toEqual({ decision: "auto_approve" }) + }) + + it("returns the same decision as getCommandDecision across representative inputs", () => { + const cases: Array<[string, string[], string[] | undefined]> = [ + ["git status", ["git"], []], + ["git push origin", ["git"], ["git push"]], + ["git status && rm file", ["git"], ["rm"]], + ["unknown command", ["git"], ["rm"]], + ['echo "${var@P}"', ["echo"], []], + ["sh -c 'echo a", ["sh"], []], + ["", ["git"], []], + ] + + for (const [command, allowed, denied] of cases) { + expect(getCommandDecisionDetailed(command, allowed, denied).decision).toBe( + getCommandDecision(command, allowed, denied), + ) + } + }) +}) diff --git a/src/core/auto-approval/__tests__/dcg.spec.ts b/src/core/auto-approval/__tests__/dcg.spec.ts index fb4f0ac9a7..49e300875b 100644 --- a/src/core/auto-approval/__tests__/dcg.spec.ts +++ b/src/core/auto-approval/__tests__/dcg.spec.ts @@ -60,8 +60,11 @@ describe("Destructive Command Guard auto-approval precedence", () => { it("keeps ordinary denylist behavior when DCG is disabled", async () => { const state = { ...baseState, destructiveCommandGuardEnabled: false } + // Denylist denials are automatic denials and carry their structured + // detail even when the blanket setting is off. expect(await checkAutoApproval({ state, ask: "command", text: "rm file" })).toEqual({ decision: "deny", + autoDeny: { kind: "denylist", command: "rm file", pattern: "rm" }, }) }) @@ -72,4 +75,94 @@ describe("Destructive Command Guard auto-approval precedence", () => { decision: "ask", }) }) + + describe("with an explicit DCG verdict", () => { + it("auto-approves when the verdict allows the command, bypassing Zoo's deny list", async () => { + expect( + await checkAutoApproval({ + state: baseState, + ask: "command", + text: "rm file", + dcgDecision: { decision: "allow" }, + }), + ).toEqual({ decision: "approve" }) + }) + + it("falls back to the (protected) user prompt when DCG denies and blanket auto-deny is off", async () => { + expect( + await checkAutoApproval({ + state: baseState, + ask: "command", + text: "echo test", + isProtected: true, + dcgDecision: { + decision: "deny", + reason: "matches a destructive pattern", + ruleId: "recursive-delete", + }, + }), + ).toEqual({ decision: "ask" }) + }) + + it("auto-denies with the DCG reason and rule when blanket auto-deny is on", async () => { + const state = { ...baseState, alwaysDenyUnapprovedCommands: true } + + expect( + await checkAutoApproval({ + state, + ask: "command", + text: "rm -rf /", + dcgDecision: { + decision: "deny", + reason: "matches a destructive pattern", + ruleId: "recursive-delete", + }, + }), + ).toEqual({ + decision: "deny", + autoDeny: { + kind: "dcg", + command: "rm -rf /", + dcgReason: "matches a destructive pattern", + dcgRuleId: "recursive-delete", + }, + }) + }) + + it("does not let an allowlist match rescue a DCG denial under blanket auto-deny", async () => { + const state = { ...baseState, alwaysDenyUnapprovedCommands: true, allowedCommands: ["rm"] } + + const result = await checkAutoApproval({ + state, + ask: "command", + text: "rm file", + dcgDecision: { decision: "deny", reason: "matches a destructive pattern" }, + }) + + expect(result).toEqual({ + decision: "deny", + autoDeny: { + kind: "dcg", + command: "rm file", + dcgReason: "matches a destructive pattern", + dcgRuleId: undefined, + }, + }) + }) + + it("ignores the verdict when DCG is disabled in settings", async () => { + const state = { ...baseState, destructiveCommandGuardEnabled: false } + + // The verdict is only consulted while DCG is enabled; with it off, + // the ordinary denylist still denies. + expect( + await checkAutoApproval({ + state, + ask: "command", + text: "rm file", + dcgDecision: { decision: "allow" }, + }), + ).toEqual({ decision: "deny", autoDeny: { kind: "denylist", command: "rm file", pattern: "rm" } }) + }) + }) }) diff --git a/src/core/auto-approval/__tests__/fixtures.ts b/src/core/auto-approval/__tests__/fixtures.ts index de19c95986..6ac092de43 100644 --- a/src/core/auto-approval/__tests__/fixtures.ts +++ b/src/core/auto-approval/__tests__/fixtures.ts @@ -45,6 +45,7 @@ export const baseState: State = { alwaysAllowExecute: false, alwaysAllowFollowupQuestions: false, destructiveCommandGuardEnabled: false, + alwaysDenyUnapprovedCommands: false, allowedCommands: [], deniedCommands: [], } diff --git a/src/core/auto-approval/autoDenyReason.ts b/src/core/auto-approval/autoDenyReason.ts new file mode 100644 index 0000000000..6eb04a172a --- /dev/null +++ b/src/core/auto-approval/autoDenyReason.ts @@ -0,0 +1,49 @@ +/** + * Structured detail attached to automatic command denials. + * + * An automatic denial is a denial produced by policy (denylist, blanket + * auto-deny, DCG block) rather than by a user clicking "reject". The detail + * travels from `checkAutoApproval` through `Task.ask` to + * `presentAssistantMessage`, where it selects the structured + * `formatResponse.toolAutoDenied` payload instead of the user-rejection + * wording — and marks the denial as scoped to its own tool call, so it never + * aborts the rest of the turn. + */ +export type AutoDenyDetail = { + kind: "dcg" | "denylist" | "not_allowlisted" | "dangerous_substitution" | "malformed_command" + /** Offending sub-command text (or the full command, for DCG denials). */ + command?: string + /** Matched denied prefix, for `denylist` denials. */ + pattern?: string + /** Raw DCG reason string, for `dcg` denials (not the i18n'd chat string). */ + dcgReason?: string + /** Raw DCG rule id, for `dcg` denials. */ + dcgRuleId?: string + /** Parse-error message, for `malformed_command` denials. */ + parseError?: string +} + +/** + * Build the model-facing reason string for an automatic denial. + * + * Hardcoded English, matching the existing `formatResponse` precedent: these + * strings go to the model, not the chat UI (chat rows stay i18n'd). The text + * deliberately never suggests asking the user — in hands-free mode that would + * invite `ask_followup_question` and defeat the purpose of the feature. + */ +export function buildAutoDenyReason(detail: AutoDenyDetail): string { + switch (detail.kind) { + case "denylist": + return `Command \`${detail.command ?? "(unknown)"}\` matches denied prefix \`${detail.pattern ?? "(unknown)"}\`.` + case "not_allowlisted": + return `Command \`${detail.command ?? "(unknown)"}\` is not on the command allowlist.` + case "dangerous_substitution": + return "Command contains shell expansions (${...} forms, process substitution, and similar) that require explicit approval." + case "malformed_command": + return detail.parseError ?? "Command contains a shell syntax error." + case "dcg": { + const base = `Destructive Command Guard denied the command: ${detail.dcgReason ?? "no reason provided"}` + return detail.dcgRuleId ? `${base} (Rule: ${detail.dcgRuleId})` : base + } + } +} diff --git a/src/core/auto-approval/commands.ts b/src/core/auto-approval/commands.ts index 82b937e66f..cf7cf49238 100644 --- a/src/core/auto-approval/commands.ts +++ b/src/core/auto-approval/commands.ts @@ -259,8 +259,45 @@ export function getCommandDecision( allowedCommands: string[], deniedCommands?: string[], ): CommandDecision { + return getCommandDecisionDetailed(command, allowedCommands, deniedCommands).decision +} + +/** + * Extended result of {@link getCommandDecisionDetailed}, naming the specific + * sub-command responsible for the decision so automatic denials can tell the + * model exactly what went wrong. + */ +export interface CommandDecisionDetail { + decision: CommandDecision + /** + * For `auto_deny`: the first sub-command matched by the denylist. + * For `ask_user`: the first sub-command lacking an allowlist match. + * For `malformed_command`: the raw (unsplit) command string. + */ + offendingCommand?: string + /** The matched denied prefix, for `auto_deny` decisions. */ + matchedPattern?: string + /** Human-readable shell syntax error, for `malformed_command` decisions. */ + parseError?: string +} + +/** + * Same decision logic as {@link getCommandDecision}, additionally naming the + * offending sub-command (and matched denied prefix) that produced the + * decision, so the auto-deny feedback can point the model at the specific + * part of a chained command that caused the rejection. + * + * The chain-level aggregation mirrors `getCommandDecision` exactly: + * "any denial blocks all", dangerous substitutions force `ask_user`, and a + * parse error yields `malformed_command`. + */ +export function getCommandDecisionDetailed( + command: string, + allowedCommands: string[], + deniedCommands?: string[], +): CommandDecisionDetail { if (!command?.trim()) { - return "auto_approve" + return { decision: "auto_approve" } } // Parse into sub-commands (split by &&, ||, ;, |). parseCommand also @@ -274,34 +311,45 @@ export function getCommandDecision( // distinct decision lets callers surface a useful message to the agent // rather than silently presenting the command for user approval. if (parseError !== null) { - return "malformed_command" + return { decision: "malformed_command", offendingCommand: command, parseError: parseError.message } } - // Check each sub-command and collect decisions - const decisions: CommandDecision[] = subCommands.map((cmd) => { - // Remove simple PowerShell-like redirections (e.g. 2>&1) before checking - const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim() + // Remove simple PowerShell-like redirections (e.g. 2>&1) before checking + const sanitizedCommands = subCommands.map((cmd) => cmd.replace(/\d*>&\d*/, "").trim()) - return getSingleCommandDecision(cmdWithoutRedirection, allowedCommands, deniedCommands) - }) + // Check each sub-command and collect decisions + const decisions: CommandDecision[] = sanitizedCommands.map((cmd) => + getSingleCommandDecision(cmd, allowedCommands, deniedCommands), + ) - // If any sub-command is denied, deny the whole command - if (decisions.includes("auto_deny")) { - return "auto_deny" + // If any sub-command is denied, deny the whole command; name the first + // denied sub-command and the denied prefix it matched. + const denyIndex = decisions.indexOf("auto_deny") + if (denyIndex !== -1) { + const offendingCommand = sanitizedCommands[denyIndex] + const lowerMatch = findLongestPrefixMatch(offendingCommand, deniedCommands || []) + // Prefer the original-cased pattern the user typed, falling back to the + // case-insensitive match itself. + const matchedPattern = + (deniedCommands || []).find((pattern) => pattern.toLowerCase() === lowerMatch) ?? lowerMatch ?? undefined + + return { decision: "auto_deny", offendingCommand, matchedPattern } } // Require explicit user approval for dangerous patterns if (containsDangerousSubstitution(command)) { - return "ask_user" + return { decision: "ask_user" } } // If all sub-commands are approved, approve the whole command if (decisions.every((decision) => decision === "auto_approve")) { - return "auto_approve" + return { decision: "auto_approve" } } - // Otherwise, ask user - return "ask_user" + // Otherwise, ask user; name the first sub-command with no allowlist match. + const askIndex = decisions.indexOf("ask_user") + + return { decision: "ask_user", offendingCommand: askIndex !== -1 ? sanitizedCommands[askIndex] : undefined } } /** diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index 751b5c0674..b23ff79ce4 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -7,12 +7,15 @@ import { isNonBlockingAsk, } from "@roo-code/types" +import type { DcgDecision } from "../../services/destructive-command-guard/runner" + import { ClineAskResponse } from "../../shared/WebviewMessage" import { isWriteToolAction, isReadOnlyToolAction } from "./tools" import { isMcpToolAlwaysAllowed } from "./mcp" -import { getCommandDecision } from "./commands" +import { containsDangerousSubstitution, getCommandDecisionDetailed } from "./commands" import { isFileMatchedByPatterns } from "./filePatterns" +import { type AutoDenyDetail } from "./autoDenyReason" // We have auto-approval actions for different categories. export type AutoApprovalState = @@ -37,6 +40,7 @@ export type AutoApprovalStateOptions = | "allowedCommands" // For `alwaysAllowExecute`. | "deniedCommands" | "destructiveCommandGuardEnabled" + | "alwaysDenyUnapprovedCommands" // For `alwaysAllowExecute` (blanket auto-deny). /** * Every file a tool action names, as far as the allowlists are concerned. @@ -135,7 +139,15 @@ function isWriteAllowedByPatterns( export type CheckAutoApprovalResult = | { decision: "approve" } - | { decision: "deny" } + /** + * Automatic denial. `autoDeny` carries the structured reason and the + * offending sub-command when the denial came from command policy (denylist + * match, blanket auto-deny, or a DCG block under blanket mode). It marks + * the denial as policy-scoped — the model receives an explanatory + * `auto_deny` result, and unlike a user rejection the denial does not + * abort the remaining tool calls of the turn. + */ + | { decision: "deny"; autoDeny?: AutoDenyDetail } | { decision: "ask" } | { decision: "timeout" @@ -149,6 +161,7 @@ export async function checkAutoApproval({ ask, text, isProtected, + dcgDecision, }: { state?: Pick /** @@ -167,6 +180,14 @@ export async function checkAutoApproval({ ask: ClineAsk text?: string isProtected?: boolean + /** + * The verdict from a Destructive Command Guard run that the caller + * (ExecuteCommandTool) already performed for this exact command. Only + * provided for `ask: "command"` when DCG is enabled; undefined otherwise. + * Infra failures in DCG never produce a verdict — they surface as a tool + * error before this check, so a verdict here is authoritative. + */ + dcgDecision?: DcgDecision }): Promise { if (isNonBlockingAsk(ask)) { return { decision: "approve" } @@ -233,23 +254,80 @@ export async function checkAutoApproval({ } if (state.alwaysAllowExecute === true) { + const blanketDeny = state.alwaysDenyUnapprovedCommands === true + // Execute commands immediately when DCG allows them. ExecuteCommandTool - // marks commands blocked by DCG as protected before reaching this check, - // which keeps the explicit user approval prompt for those commands. When - // enabled, DCG is the authoritative command policy, so Zoo's allow and deny - // lists are intentionally bypassed for commands that DCG allows. + // passes the guard's verdict through so this single decision point can + // act on it. When enabled, DCG is the authoritative command policy, so + // Zoo's allow and deny lists are intentionally bypassed for commands + // DCG rules on — and an allowlist match cannot rescue a DCG denial. if (state.destructiveCommandGuardEnabled === true) { + if (dcgDecision?.decision === "deny") { + // Blanket on: auto-deny, forwarding the guard's reason and + // rule to the model. Blanket off: fall through to the + // (protected) user prompt, as before. + return blanketDeny + ? { + decision: "deny", + autoDeny: { + kind: "dcg", + command: text, + dcgReason: dcgDecision.reason, + dcgRuleId: dcgDecision.ruleId, + }, + } + : { decision: "ask" } + } + + // DCG allowed the command (verdict provided), or no verdict was + // supplied for a DCG-enabled ask (partial asks never reach an + // auto-approval decision). DCG remains the authoritative policy: + // approve. return { decision: "approve" } } - const decision = getCommandDecision(text, state.allowedCommands || [], state.deniedCommands || []) + const { decision, offendingCommand, matchedPattern, parseError } = getCommandDecisionDetailed( + text, + state.allowedCommands || [], + state.deniedCommands || [], + ) if (decision === "auto_approve") { return { decision: "approve" } } else if (decision === "auto_deny") { - return { decision: "deny" } + // Denylist denials are automatic denials and carry their structured + // detail even when the blanket setting is off: they were never user + // rejections, so the model gets the precise reason and the denial + // stays scoped to this tool call. + return { + decision: "deny", + autoDeny: { kind: "denylist", command: offendingCommand, pattern: matchedPattern }, + } + } else if (decision === "malformed_command") { + // Defense in depth: ExecuteCommandTool blocks shell syntax errors as + // a retryable tool_error before any ask is created, so this is + // normally unreachable. Under blanket mode deny rather than ask. + return blanketDeny + ? { + decision: "deny", + autoDeny: { kind: "malformed_command", command: offendingCommand, parseError }, + } + : { decision: "ask" } } else { - return { decision: "ask" } + // ask_user: with the blanket setting on, unapproved commands are + // auto-denied instead of interrupting a hands-free session. + if (!blanketDeny) { + return { decision: "ask" } + } + + if (containsDangerousSubstitution(text)) { + return { + decision: "deny", + autoDeny: { kind: "dangerous_substitution", command: offendingCommand }, + } + } + + return { decision: "deny", autoDeny: { kind: "not_allowlisted", command: offendingCommand } } } } } @@ -329,3 +407,4 @@ export async function checkAutoApproval({ } export { AutoApprovalHandler } from "./AutoApprovalHandler" +export { type AutoDenyDetail, buildAutoDenyReason } from "./autoDenyReason" diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 60b5b4123a..7714438431 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -11,6 +11,28 @@ export const formatResponse = { message: "The user denied this operation.", }), + /** + * Structured result for an automatic (policy) denial of a command — a + * denylist match, a blanket auto-deny, or a Destructive Command Guard + * block under blanket mode. Distinct from `toolDenied` (a real user + * rejection): the reason names the offending part of the chain, states + * that nothing in it executed, and — deliberately — never suggests asking + * the user, which would invite `ask_followup_question` and stall a + * hands-free session. Hardcoded English like the rest of this module: + * model-facing strings are not i18n'd. + */ + toolAutoDenied: (detail: { reason: string; offendingCommand?: string; ruleId?: string }) => + JSON.stringify({ + status: "denied", + type: "auto_deny", + reason: detail.reason, + offending_command: detail.offendingCommand, + rule_id: detail.ruleId, + note: "The command chain was rejected in its entirety; none of the chained commands were executed.", + suggestion: + "Re-run the remaining commands as separate execute_command calls using approved commands only, or choose an approved alternative.", + }), + toolDeniedWithFeedback: (feedback?: string) => JSON.stringify({ status: "denied", diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 3a4e1b0060..6f5f848e1e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -73,7 +73,13 @@ import { t } from "../../i18n" import { getApiMetrics, hasTokenUsageChanged, hasToolUsageChanged } from "../../shared/getApiMetrics" import { ClineAskResponse } from "../../shared/WebviewMessage" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" -import { DiffStrategy, type ToolUse, type ToolParamName, toolParamNames } from "../../shared/tools" +import { + DiffStrategy, + type AutoApprovalContext, + type ToolUse, + type ToolParamName, + toolParamNames, +} from "../../shared/tools" import { getModelMaxOutputTokens } from "../../shared/api" // services @@ -131,7 +137,7 @@ import { import { processUserContentMentions } from "../mentions/processUserContentMentions" import { getMessagesSinceLastSummary, summarizeConversation, getEffectiveApiHistory } from "../condense" import { MessageQueueService } from "../message-queue/MessageQueueService" -import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval" +import { type AutoDenyDetail, AutoApprovalHandler, checkAutoApproval } from "../auto-approval" import { MessageManager } from "../message-manager" import { validateAndFixToolResultIds } from "./validateToolResultIds" import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages" @@ -347,6 +353,14 @@ export class Task extends EventEmitter implements TaskLike { private askResponse?: ClineAskResponse private askResponseText?: string private askResponseImages?: string[] + /** + * Structured detail of the automatic denial that resolved the current ask, + * set when `checkAutoApproval` denies via policy (denylist, blanket + * auto-deny, or a DCG block in blanket mode) and consumed (cleared) by the + * `ask()` result. System-generated, unlike `askResponseText` which carries + * user feedback and triggers a `user_feedback` say row. + */ + private pendingAutoDenyDetail?: AutoDenyDetail public lastMessageTs?: number private autoApprovalTimeoutRef?: NodeJS.Timeout @@ -1283,7 +1297,19 @@ export class Task extends EventEmitter implements TaskLike { partial?: boolean, progressStatus?: ToolProgressStatus, isProtected?: boolean, - ): Promise<{ response: ClineAskResponse; text?: string; images?: string[]; queuedMessageId?: string }> { + autoApprovalContext?: AutoApprovalContext, + ): Promise<{ + response: ClineAskResponse + text?: string + images?: string[] + queuedMessageId?: string + /** + * Present when the ask was resolved by an automatic (policy) denial + * rather than a user click. Consumers must not treat this as a user + * rejection: the denial is scoped to its own tool call. + */ + autoDenyDetail?: AutoDenyDetail + }> { // If this Cline instance was aborted by the provider, then the only // thing keeping us alive is a promise still running in the background, // in which case we don't want to send its result to the webview as it @@ -1306,8 +1332,22 @@ export class Task extends EventEmitter implements TaskLike { // rendered, leaving them stuck on-screen). const provider = this.providerRef.deref() const state = provider ? await provider.getState() : undefined + // The blanket auto-deny setting only engages while command auto-approval + // is on; with either master switch off, behavior is unchanged. + const blanketDenyEngaged = + state?.alwaysDenyUnapprovedCommands === true && + state?.autoApprovalEnabled === true && + state?.alwaysAllowExecute === true + // A queued message normally answers the pending ask, which for command asks + // means an unconditional auto-approve. That shortcut must never bypass + // blanket deny: while it is engaged, a command ask keeps its policy + // decision and the queued message is left in place for a later turn + // instead of being consumed as approval. + const queueMayAnswerThisAsk = !(blanketDenyEngaged && type === "command") const queuedMessage = - partial === true || type === "command_output" ? undefined : this.messageQueueService.claimNextMessage() + partial === true || type === "command_output" || !queueMayAnswerThisAsk + ? undefined + : this.messageQueueService.claimNextMessage() const queuedAskResolution = queuedMessage ? queuedResponseForAsk(type, text) : undefined // `this.cwd`, not `provider.cwd`: // The path inside `text` was made relative to this task's workspace, @@ -1315,7 +1355,14 @@ export class Task extends EventEmitter implements TaskLike { // currently reports. const approval = queuedAskResolution ? ({ decision: "ask" } as const) - : await checkAutoApproval({ state, cwd: this.cwd, ask: type, text, isProtected }) + : await checkAutoApproval({ + state, + cwd: this.cwd, + ask: type, + text, + isProtected, + dcgDecision: autoApprovalContext?.dcgDecision, + }) const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny" const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined @@ -1427,6 +1474,14 @@ export class Task extends EventEmitter implements TaskLike { const timeouts: NodeJS.Timeout[] = [] + // Record the structured detail of an automatic (policy) denial so the + // `ask()` result can hand it to the caller. Assigned unconditionally so + // a stale detail from a previous ask can never leak into this result. + // Deliberately not routed through `askResponseText`: that field means + // *user feedback* and triggers a `user_feedback` say row, while this + // reason is system-generated. + this.pendingAutoDenyDetail = approval.decision === "deny" ? approval.autoDeny : undefined + if (approval.decision === "approve") { this.approveAsk() } else if (approval.decision === "deny") { @@ -1505,8 +1560,10 @@ export class Task extends EventEmitter implements TaskLike { // If a queued message arrives while we're blocked on an ask (e.g. a follow-up // suggestion click that was incorrectly queued due to UI state), consume it - // immediately so the task doesn't hang. - if (shouldDrainQueuedMessageForAsk && !this.messageQueueService.isEmpty()) { + // immediately so the task doesn't hang. Command asks under blanket deny are + // excluded (`queueMayAnswerThisAsk`): a queued message must never stand in + // for the explicit approval the policy withheld. + if (queueMayAnswerThisAsk && shouldDrainQueuedMessageForAsk && !this.messageQueueService.isEmpty()) { const message = this.messageQueueService.claimNextMessage() const resolution = message ? queuedResponseForAsk(type, text) : undefined if (message && resolution) { @@ -1542,10 +1599,12 @@ export class Task extends EventEmitter implements TaskLike { text: this.askResponseText, images: this.askResponseImages, queuedMessageId, + autoDenyDetail: this.pendingAutoDenyDetail, } this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined + this.pendingAutoDenyDetail = undefined // Cancel the timeouts if they are still running. timeouts.forEach((timeout) => clearTimeout(timeout)) diff --git a/src/core/task/__tests__/ask-auto-deny.spec.ts b/src/core/task/__tests__/ask-auto-deny.spec.ts new file mode 100644 index 0000000000..7a6f992a96 --- /dev/null +++ b/src/core/task/__tests__/ask-auto-deny.spec.ts @@ -0,0 +1,197 @@ +// npx vitest run core/task/__tests__/ask-auto-deny.spec.ts + +import type { ExtensionState } from "@roo-code/types" + +import { Task } from "../Task" + +// Blanket auto-deny (`alwaysDenyUnapprovedCommands`) at the Task level: a +// command ask that policy denies must resolve immediately with the structured +// `autoDenyDetail` (so presentAssistantMessage can distinguish it from a user +// rejection), and the chat row must carry the auto-deny chip +// (`autoApprovalDecision: "deny"` + `isAnswered`). A subsequent ask must never +// see a stale detail from a previous denial. + +/** The parts of the provider that `Task.ask` reaches for. */ +type ProviderStub = { + getState: () => Promise> + postMessageToWebview: ReturnType + cwd: string +} + +function buildTask(provider: ProviderStub, taskCwd: string) { + const task = Object.create(Task.prototype) as Task + task["abort"] = false + task["clineMessages"] = [] + task["askResponse"] = undefined + task["askResponseText"] = undefined + task["askResponseImages"] = undefined + task["lastMessageTs"] = undefined + task["addToClineMessages"] = vi.fn(async () => {}) + task["saveClineMessages"] = vi.fn(async () => true) + task["updateClineMessage"] = vi.fn(async () => {}) + task["cancelAutoApprovalTimeout"] = vi.fn(() => {}) + task["checkpointSave"] = vi.fn(async () => {}) + task["emit"] = vi.fn() + // A double assertion is unavoidable here: `providerRef` is a `WeakRef`, + // and the stub is neither a `WeakRef` nor a whole `ClineProvider`. Constructing + // either would drag in the extension host, when `Task.ask` only ever calls + // `deref()`, `getState()` and `postMessageToWebview()` on it. + task["providerRef"] = { deref: () => provider } as unknown as Task["providerRef"] + Object.defineProperty(task, "workspacePath", { value: taskCwd }) + + return task +} + +async function attachQueue(task: Task) { + const { MessageQueueService } = await import("../../message-queue/MessageQueueService") + const queue = new MessageQueueService() + Object.defineProperty(task, "messageQueueService", { value: queue }) + return queue +} + +const TASK_CWD = "/path/to/task-workspace" + +describe("Task.ask resolves blanket command denials with structured detail", () => { + // Mutable state so a test can flip the policy between consecutive asks on + // the same task (mirrors the live per-ask `provider.getState()` read). + let state: Partial + let provider: ProviderStub + + beforeEach(() => { + state = { + autoApprovalEnabled: true, + alwaysAllowExecute: true, + alwaysDenyUnapprovedCommands: true, + allowedCommands: [], + deniedCommands: [], + destructiveCommandGuardEnabled: false, + } + provider = { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + cwd: TASK_CWD, + getState: async () => state, + } + }) + + it("auto-denies an unallowlisted command and stamps the deny chip on the chat row", async () => { + const task = buildTask(provider, TASK_CWD) + await attachQueue(task) + + const result = await task.ask("command", "rm x", false) + + // Policy denial: resolves without user interaction, carrying the reason. + expect(result.response).toBe("noButtonClicked") + expect(result.autoDenyDetail).toBeDefined() + expect(result.autoDenyDetail?.kind).toBe("not_allowlisted") + expect(result.autoDenyDetail?.command).toBe("rm x") + + // Chat row: the existing auto-deny chip (answered + deny decision), so no + // approval buttons ever appear. + const addToClineMessages = task["addToClineMessages"] as ReturnType + expect(addToClineMessages).toHaveBeenCalledTimes(1) + const message = addToClineMessages.mock.calls[0][0] + expect(message.type).toBe("ask") + expect(message.ask).toBe("command") + expect(message.isAnswered).toBe(true) + expect(message.autoApprovalDecision).toBe("deny") + }) + + it("a following approved ask does not leak the previous denial's detail", async () => { + const task = buildTask(provider, TASK_CWD) + await attachQueue(task) + + const denied = await task.ask("command", "rm x", false) + expect(denied.autoDenyDetail?.kind).toBe("not_allowlisted") + + // Approve the next command via the allowlist: the denial detail from the + // previous ask must not ride along into this result. + state.allowedCommands = ["git"] + const approved = await task.ask("command", "git status", false) + + expect(approved.response).toBe("yesButtonClicked") + expect(approved.autoDenyDetail).toBeUndefined() + + const addToClineMessages = task["addToClineMessages"] as ReturnType + expect(addToClineMessages).toHaveBeenCalledTimes(2) + expect(addToClineMessages.mock.calls[1][0].autoApprovalDecision).toBe("approve") + }) + + it("carries a denylist denial's detail even with the blanket setting off", async () => { + // Denylist denials were never user rejections: they carry structured + // detail regardless of the blanket flag (unified vocabulary). + state.alwaysDenyUnapprovedCommands = false + state.deniedCommands = ["rm"] + + const task = buildTask(provider, TASK_CWD) + await attachQueue(task) + + const result = await task.ask("command", "rm -rf build", false) + + expect(result.response).toBe("noButtonClicked") + expect(result.autoDenyDetail?.kind).toBe("denylist") + expect(result.autoDenyDetail?.command).toBe("rm -rf build") + expect(result.autoDenyDetail?.pattern).toBe("rm") + + const addToClineMessages = task["addToClineMessages"] as ReturnType + expect(addToClineMessages.mock.calls[0][0].autoApprovalDecision).toBe("deny") + }) +}) + +describe("Task.ask queue path cannot bypass blanket deny", () => { + let state: Partial + let provider: ProviderStub + + beforeEach(() => { + state = { + autoApprovalEnabled: true, + alwaysAllowExecute: true, + alwaysDenyUnapprovedCommands: true, + allowedCommands: [], + deniedCommands: [], + destructiveCommandGuardEnabled: false, + } + provider = { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + cwd: TASK_CWD, + getState: async () => state, + } + }) + + it("denies a blanket-denied command ask even when a queued message would auto-approve it", async () => { + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + // A queued message previously answered command asks with an unconditional + // yesButtonClicked — the one sequence that bypassed blanket deny. The + // policy denial must win, and it must carry the same structured detail + // as the main path. + queue.addMessage("queued feedback arriving while blanket deny is engaged") + + const result = await task.ask("command", "rm x", false) + + expect(result.response).toBe("noButtonClicked") + expect(result.autoDenyDetail).toBeDefined() + expect(result.autoDenyDetail?.kind).toBe("not_allowlisted") + expect(result.autoDenyDetail?.command).toBe("rm x") + // The queued message was not consumed as a fake approval: it stays in the + // queue for a later conversational turn. + expect(result.queuedMessageId).toBeUndefined() + expect(queue.messages).toHaveLength(1) + }) + + it("still lets a queued message answer a command ask when blanket deny is off", async () => { + // Behavior unchanged while the blanket configuration is disengaged: the + // queued-message auto-approval shortcut keeps working. + state.alwaysDenyUnapprovedCommands = false + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + queue.addMessage("queued feedback with blanket deny off") + + const result = await task.ask("command", "rm x", false) + + expect(result.response).toBe("yesButtonClicked") + expect(result.autoDenyDetail).toBeUndefined() + // Non-durable resolution consumed the queued message. + expect(queue.messages).toHaveLength(0) + }) +}) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 8383d9a4e1..ce0b22fe13 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -10,6 +10,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../task/Task" import type { ClineProvider } from "../webview/ClineProvider" +import type { DcgDecision } from "../../services/destructive-command-guard" import { ToolUse, ToolResponse } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { unescapeHtmlEntities } from "../../utils/text-normalization" @@ -129,7 +130,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } const provider = await task.providerRef.deref() - let dcgBlocked = false + let dcgDecision: DcgDecision | undefined if (provider?.contextProxy.getValue("destructiveCommandGuardEnabled") === true) { const { ensureDcgInstalled, runDcg } = await import("../../services/destructive-command-guard") // Resolve through the managed installer on use so an extension update @@ -143,27 +144,49 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { ? customCwd : path.resolve(task.cwd, customCwd) : task.cwd - const dcgResult = await runDcg(binaryPath, canonicalCommand, workingDirectory) - dcgBlocked = dcgResult.decision === "deny" - if (dcgResult.decision === "deny") { - await task.say("error", formatDcgBlockedMessage(dcgResult.reason, dcgResult.ruleId)) + // Infra failures (spawn/parse/timeout) reject here and surface as a + // retryable tool_error via the outer catch — never as a policy denial. + dcgDecision = await runDcg(binaryPath, canonicalCommand, workingDirectory) + if (dcgDecision.decision === "deny") { + await task.say("error", formatDcgBlockedMessage(dcgDecision.reason, dcgDecision.ruleId)) } } - // DCG-approved commands are auto-approved by checkAutoApproval. A DCG - // block is presented as Zoo's normal command prompt, with isProtected - // forcing the user to explicitly choose whether to execute it. - const didApprove = dcgBlocked - ? await askApproval("command", canonicalCommand, undefined, true) - : await askApproval("command", canonicalCommand) + // The blanket auto-deny setting only engages while command auto-approval + // is on; with either master switch off, behavior is unchanged and a DCG + // block keeps today's protected user prompt. The blanket decision is + // frozen to this pre-ask snapshot, while terminal behavior is re-read + // after approval so a settings flip during a pending prompt takes effect. + const providerState = await provider?.getState() + const blanketAutoDeny = + providerState?.alwaysDenyUnapprovedCommands === true && + providerState?.autoApprovalEnabled === true && + providerState?.alwaysAllowExecute === true + + // DCG-approved commands are auto-approved by checkAutoApproval (from the + // passed verdict). A DCG block is either auto-denied with the guard's + // reason delivered to the model (blanket mode), or presented as Zoo's + // normal command prompt, with isProtected forcing the user to explicitly + // choose whether to execute it. + let didApprove: boolean + if (dcgDecision === undefined) { + didApprove = await askApproval("command", canonicalCommand) + } else if (dcgDecision.decision === "allow") { + didApprove = await askApproval("command", canonicalCommand, undefined, false, { dcgDecision }) + } else if (blanketAutoDeny) { + didApprove = await askApproval("command", canonicalCommand, undefined, false, { dcgDecision }) + } else { + didApprove = await askApproval("command", canonicalCommand, undefined, true) + } if (!didApprove) { return } const executionId = task.lastMessageTs?.toString() ?? Date.now().toString() - const providerState = await provider?.getState() - const { terminalShellIntegrationDisabled = true } = providerState ?? {} + // Re-read after approval (as HEAD did) so a settings flip while the + // approval prompt was pending is honored for terminal behavior. + const { terminalShellIntegrationDisabled = true } = (await provider?.getState()) ?? {} // Get command execution timeout from VSCode configuration (in seconds) const commandExecutionTimeoutSeconds = vscode.workspace diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index a856b180ca..1a6f677e3e 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -334,10 +334,87 @@ describe("executeCommandTool", () => { pushToolResult: mockPushToolResult as unknown as PushToolResult, }) - expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test") + // The DCG verdict is forwarded so checkAutoApproval auto-approves from + // the verdict itself rather than inferring it from settings alone. + expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test", undefined, false, { + dcgDecision: { decision: "allow" }, + }) expect(mockPushToolResult).toHaveBeenCalled() }) + it("passes the DCG deny verdict unprotected when blanket auto-deny is engaged", async () => { + const provider = await mockCline.providerRef.deref() + provider.context = { globalStorageUri: { fsPath: "/test/storage" } } + provider.contextProxy.getValue.mockReturnValue(true) + provider.getState.mockResolvedValue({ + destructiveCommandGuardEnabled: true, + terminalShellIntegrationDisabled: true, + alwaysDenyUnapprovedCommands: true, + autoApprovalEnabled: true, + alwaysAllowExecute: true, + }) + mockRunDcg.mockResolvedValue({ decision: "deny", reason: "matches a destructive pattern" }) + mockAskApproval.mockResolvedValue(false) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + // Blanket mode: not protected, so checkAutoApproval resolves the ask as + // an automatic denial carrying the DCG reason instead of prompting. + expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test", undefined, false, { + dcgDecision: { decision: "deny", reason: "matches a destructive pattern" }, + }) + }) + + it("keeps the protected prompt when DCG denies and blanket auto-deny is off", async () => { + const provider = await mockCline.providerRef.deref() + provider.context = { globalStorageUri: { fsPath: "/test/storage" } } + provider.contextProxy.getValue.mockReturnValue(true) + provider.getState.mockResolvedValue({ + destructiveCommandGuardEnabled: true, + terminalShellIntegrationDisabled: true, + alwaysDenyUnapprovedCommands: false, + autoApprovalEnabled: true, + alwaysAllowExecute: true, + }) + mockRunDcg.mockResolvedValue({ decision: "deny", reason: "matches a destructive pattern" }) + mockAskApproval.mockResolvedValue(false) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test", undefined, true) + }) + + it("keeps the protected prompt when blanket auto-deny is on but command auto-approval is off", async () => { + const provider = await mockCline.providerRef.deref() + provider.context = { globalStorageUri: { fsPath: "/test/storage" } } + provider.contextProxy.getValue.mockReturnValue(true) + provider.getState.mockResolvedValue({ + destructiveCommandGuardEnabled: true, + terminalShellIntegrationDisabled: true, + alwaysDenyUnapprovedCommands: true, + autoApprovalEnabled: false, + alwaysAllowExecute: true, + }) + mockRunDcg.mockResolvedValue({ decision: "deny", reason: "matches a destructive pattern" }) + mockAskApproval.mockResolvedValue(false) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test", undefined, true) + }) + it("installs or updates DCG before evaluating an enabled command", async () => { const provider = await mockCline.providerRef.deref() provider.context = { globalStorageUri: { fsPath: "/test/storage" } } @@ -673,6 +750,38 @@ describe("executeCommandTool", () => { expect(mockPushToolResult.mock.calls[0][0]).toContain("Exit code: 0") }) + it("honors a terminal shell integration flip made during a pending approval", async () => { + vitest.useFakeTimers() + const provider = await mockCline.providerRef.deref() + // The pre-ask snapshot sees shell integration disabled, but the user + // flips the setting while the approval prompt is pending. HEAD read + // provider state after approval; execution must honor that fresher value. + provider.getState + .mockResolvedValueOnce({ terminalShellIntegrationDisabled: true }) + .mockResolvedValueOnce({ terminalShellIntegrationDisabled: false }) + vitest.spyOn(Terminal, "isActiveShellCmdExe").mockReturnValue(false) + const terminal = await setupControllableTerminal() + + const handlePromise = handleCommand("Write-Output hello") + + await vitest.waitFor(() => expect(terminal.callbacks).toBeDefined()) + // A stale pre-ask snapshot would have selected "execa"; the post-approval + // re-read sees shell integration enabled again and selects "vscode". + expect(terminal.provider).toBe("vscode") + + const callbacks = terminal.callbacks! + const proc = terminal.proc as unknown as RooTerminalProcess + callbacks.onShellExecutionStarted!(1234, proc) + await callbacks.onLine("hello\n", proc) + await callbacks.onCompleted!("hello\n", proc) + callbacks.onShellExecutionComplete!({ exitCode: 0 }, proc) + terminal.resolveProcess() + await vitest.advanceTimersByTimeAsync(100) + await handlePromise + + expect(mockPushToolResult).toHaveBeenCalled() + }) + it("allows an explicit agent timeout to move a command to the background", async () => { vitest.useFakeTimers() const terminal = await setupControllableTerminal() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 394da7c10f..4d11781509 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -46,6 +46,7 @@ import { DEFAULT_WRITE_DELAY_MS, DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, @@ -2595,6 +2596,7 @@ export class ClineProvider allowedWriteFiles, alwaysAllowExecute, destructiveCommandGuardEnabled, + alwaysDenyUnapprovedCommands, allowedCommands, deniedCommands, alwaysAllowMcp, @@ -2747,6 +2749,7 @@ export class ClineProvider allowedWriteFiles: allowedWriteFiles ?? [], alwaysAllowExecute: alwaysAllowExecute ?? false, destructiveCommandGuardEnabled, + alwaysDenyUnapprovedCommands: alwaysDenyUnapprovedCommands ?? false, alwaysAllowMcp: alwaysAllowMcp ?? false, alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: alwaysAllowSubtasks ?? false, @@ -2987,6 +2990,8 @@ export class ClineProvider alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + alwaysDenyUnapprovedCommands: + stateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1a6a82a5b0..6ad47eb294 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1487,6 +1487,20 @@ describe("ClineProvider", () => { expect(state.destructiveCommandGuardEnabled).toBe(true) }) + test("getState returns the saved blanket auto-deny setting", async () => { + await provider.contextProxy.setValue("alwaysDenyUnapprovedCommands", true) + + const state = await provider.getState() + + expect(state.alwaysDenyUnapprovedCommands).toBe(true) + }) + + test("getState defaults blanket auto-deny to false", async () => { + const state = await provider.getState() + + expect(state.alwaysDenyUnapprovedCommands).toBe(false) + }) + test("getState returns the saved allowed read files", async () => { await provider.contextProxy.setValue("allowedReadFiles", ["notes.md"]) @@ -1566,6 +1580,23 @@ describe("ClineProvider", () => { expect(state.destructiveCommandGuardEnabled).toBe(false) }) + test("getStateToPostToWebview returns the saved blanket auto-deny setting", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("alwaysDenyUnapprovedCommands", true) + + const state = await provider.getStateToPostToWebview() + + expect(state.alwaysDenyUnapprovedCommands).toBe(true) + }) + + test("getStateToPostToWebview disables blanket auto-deny by default", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const state = await provider.getStateToPostToWebview() + + expect(state.alwaysDenyUnapprovedCommands).toBe(false) + }) + test("language is set to VSCode language", async () => { // Mock VSCode language as Spanish ;(vscode.env as any).language = "pt-BR" diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 1a1fb03200..a1daf27d08 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -2,13 +2,31 @@ import { Anthropic } from "@anthropic-ai/sdk" import type { ClineAsk, ToolProgressStatus, ToolGroup, ToolName, GenerateImageParams } from "@roo-code/types" +import type { DcgDecision } from "../services/destructive-command-guard/runner" + export type ToolResponse = string | Array +/** + * Extra inputs that only some ask types consult when resolving auto-approval. + * Carried from the tool through `askApproval` into `Task.ask` and onward to + * `checkAutoApproval`. All fields are optional, so tools that pass nothing + * keep the existing behavior. + */ +export type AutoApprovalContext = { + /** + * Verdict of a Destructive Command Guard run the tool already performed + * for this exact command (`execute_command` only). Infra failures never + * produce a verdict — they surface as a retryable tool error beforehand. + */ + dcgDecision?: DcgDecision +} + export type AskApproval = ( type: ClineAsk, partialMessage?: string, progressStatus?: ToolProgressStatus, forceApproval?: boolean, + autoApprovalContext?: AutoApprovalContext, ) => Promise export type HandleError = (action: string, error: Error) => Promise diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 6276ea6514..c5a2b68bdf 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -32,6 +32,7 @@ type AutoApproveSettingsProps = HTMLAttributes & { alwaysAllowSubtasks?: boolean alwaysAllowExecute?: boolean destructiveCommandGuardEnabled?: boolean + alwaysDenyUnapprovedCommands?: boolean alwaysAllowFollowupQuestions?: boolean followupAutoApproveTimeoutMs?: number allowedCommands?: string[] @@ -51,6 +52,7 @@ type AutoApproveSettingsProps = HTMLAttributes & { | "alwaysAllowSubtasks" | "alwaysAllowExecute" | "destructiveCommandGuardEnabled" + | "alwaysDenyUnapprovedCommands" | "alwaysAllowFollowupQuestions" | "followupAutoApproveTimeoutMs" | "allowedCommands" @@ -73,6 +75,7 @@ export const AutoApproveSettings = ({ alwaysAllowSubtasks, alwaysAllowExecute, destructiveCommandGuardEnabled, + alwaysDenyUnapprovedCommands, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs = 60000, allowedCommands, @@ -340,6 +343,25 @@ export const AutoApproveSettings = ({ + {/* Visible in both DCG modes: it replaces the hidden command + lists as the fail-closed policy in hands-free setups. */} + + + setCachedStateField("alwaysDenyUnapprovedCommands", e.target.checked) + } + data-testid="auto-deny-unapproved-checkbox"> + {t("settings:autoApprove.execute.autoDeny.label")} + +
+ {t("settings:autoApprove.execute.autoDeny.description")} +
+
+ {!destructiveCommandGuardEnabled && ( <> (({ onDone, t language, alwaysAllowExecute, destructiveCommandGuardEnabled, + alwaysDenyUnapprovedCommands, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, @@ -393,6 +394,7 @@ const SettingsView = forwardRef(({ onDone, t allowedWriteFiles: allowedWriteFiles ?? [], alwaysAllowExecute: alwaysAllowExecute ?? undefined, destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? false, + alwaysDenyUnapprovedCommands: alwaysDenyUnapprovedCommands ?? false, alwaysAllowMcp, alwaysAllowModeSwitch, allowedCommands: allowedCommands ?? [], @@ -823,6 +825,7 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowSubtasks={alwaysAllowSubtasks} alwaysAllowExecute={alwaysAllowExecute} destructiveCommandGuardEnabled={destructiveCommandGuardEnabled} + alwaysDenyUnapprovedCommands={alwaysDenyUnapprovedCommands} alwaysAllowFollowupQuestions={alwaysAllowFollowupQuestions} followupAutoApproveTimeoutMs={followupAutoApproveTimeoutMs} allowedCommands={allowedCommands} diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx index c5c0e30024..e2458eedd0 100644 --- a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx @@ -226,4 +226,54 @@ describe("AutoApproveSettings - Save/Discard contract", () => { expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument() expect(screen.getByTestId("denied-commands-heading")).toBeInTheDocument() }) + + // The blanket auto-deny toggle replaces the hidden command lists as the + // fail-closed policy in hands-free setups, so it must stay reachable in + // BOTH DCG modes — unlike the list editors above. + it.each([ + ["disabled", false], + ["enabled", true], + ])("shows the blanket auto-deny toggle while destructive command guard is %s", (_label, dcgEnabled) => { + renderSettings({ destructiveCommandGuardEnabled: dcgEnabled }) + + expect(screen.getByTestId("auto-deny-unapproved-checkbox")).toBeInTheDocument() + }) + + it("renders blanket auto-deny disabled by default", () => { + renderSettings() + + expect(screen.getByTestId("auto-deny-unapproved-checkbox")).not.toBeChecked() + }) + + it("renders blanket auto-deny enabled from cached settings", () => { + renderSettings({ alwaysDenyUnapprovedCommands: true }) + + expect(screen.getByTestId("auto-deny-unapproved-checkbox")).toBeChecked() + }) + + it("buffers the blanket auto-deny setting", () => { + const { setCachedStateField } = renderSettings() + + fireEvent.click(screen.getByTestId("auto-deny-unapproved-checkbox")) + + expect(setCachedStateField).toHaveBeenCalledWith("alwaysDenyUnapprovedCommands", true) + expectNoImmediateUpdateSettings() + }) + + it("buffers disabling blanket auto-deny", () => { + const { setCachedStateField } = renderSettings({ alwaysDenyUnapprovedCommands: true }) + + fireEvent.click(screen.getByTestId("auto-deny-unapproved-checkbox")) + + expect(setCachedStateField).toHaveBeenCalledWith("alwaysDenyUnapprovedCommands", false) + expectNoImmediateUpdateSettings() + }) + + it("hides the blanket auto-deny toggle while command auto-approval is off", () => { + // The setting only engages together with alwaysAllowExecute; with the + // master switch off, the whole Execute section (and its toggles) is hidden. + renderSettings({ alwaysAllowExecute: false }) + + expect(screen.queryByTestId("auto-deny-unapproved-checkbox")).not.toBeInTheDocument() + }) }) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index 034038e27e..72c8e5971a 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -397,6 +397,8 @@ describe("SettingsView - Change Detection Fix", () => { allowedMaxCost: undefined, language: "en", alwaysAllowExecute: false, + alwaysDenyUnapprovedCommands: false, + destructiveCommandGuardEnabled: false, alwaysAllowMcp: false, alwaysAllowModeSwitch: false, alwaysAllowSubtasks: false, diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index a3aa131902..e7545bf562 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -276,6 +276,7 @@ const mockPostMessage = (state: any) => { shouldShowAnnouncement: false, allowedCommands: [], alwaysAllowExecute: false, + alwaysDenyUnapprovedCommands: false, ttsEnabled: false, ttsSpeed: 1, soundEnabled: false, diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 439b859d30..8e49b0ebe9 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -270,6 +270,8 @@ describe("SettingsView - Unsaved Changes Detection", () => { allowedMaxCost: undefined, language: "en", alwaysAllowExecute: false, + alwaysDenyUnapprovedCommands: false, + destructiveCommandGuardEnabled: false, alwaysAllowMcp: false, alwaysAllowModeSwitch: false, alwaysAllowSubtasks: false, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 762a1290af..39b585a6b1 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -346,6 +346,10 @@ "label": "Activa la protecció contra ordres destructives", "description": "Baixa i utilitza Destructive Command Guard (DCG) per a aquesta plataforma. Les ordres permeses per DCG s'executen automàticament. Les ordres bloquejades per DCG requereixen la teva aprovació. Les llistes d'ordres de Zoo es desactiven mentre aquesta opció està activa. L'executable baixat es conserva si la desactives." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "Comandes d'auto-execució permeses", "allowedCommandsDescription": "Prefixos de comandes que poden ser executats automàticament quan \"Aprovar sempre operacions d'execució\" està habilitat. Afegeix * per permetre totes les comandes (usar amb precaució).", "deniedCommands": "Comandes denegades", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index f56984bc73..cf6106a6dd 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -346,6 +346,10 @@ "label": "Schutz vor destruktiven Befehlen aktivieren", "description": "Lädt Destructive Command Guard (DCG) für diese Plattform herunter und verwendet es. Von DCG erlaubte Befehle werden automatisch ausgeführt. Von DCG blockierte Befehle benötigen deine Zustimmung. Die Befehlslisten von Zoo sind währenddessen deaktiviert. Die heruntergeladene ausführbare Datei bleibt erhalten, wenn du die Option ausschaltest." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "Erlaubte Auto-Ausführungsbefehle", "allowedCommandsDescription": "Befehlspräfixe, die automatisch ausgeführt werden können, wenn 'Ausführungsoperationen immer genehmigen' aktiviert ist. Fügen Sie * hinzu, um alle Befehle zu erlauben (mit Vorsicht verwenden).", "deniedCommands": "Verweigerte Befehle", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 77f8a4f86d..3ada77ccc3 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -424,6 +424,10 @@ "label": "Enable destructive command guard", "description": "Download and use Destructive Command Guard (DCG) for this platform. Commands allowed by DCG run automatically. Commands blocked by DCG require your approval. Zoo's command lists are disabled while this is enabled. The downloaded executable is retained if you turn this off." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "Allowed Auto-Execute Commands", "allowedCommandsDescription": "Command prefixes that can be auto-executed when \"Always approve execute operations\" is enabled. Add * to allow all commands (use with caution).", "deniedCommands": "Denied Commands", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index e730483fa0..edd35629b9 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -346,6 +346,10 @@ "label": "Activar la protección contra comandos destructivos", "description": "Descarga y usa Destructive Command Guard (DCG) para esta plataforma. Los comandos permitidos por DCG se ejecutan automáticamente. Los comandos bloqueados por DCG requieren tu aprobación. Las listas de comandos de Zoo se desactivan mientras esta opción está habilitada. El ejecutable descargado se conserva si la desactivas." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "Comandos de auto-ejecución permitidos", "allowedCommandsDescription": "Prefijos de comandos que pueden ser ejecutados automáticamente cuando \"Aprobar siempre operaciones de ejecución\" está habilitado. Añade * para permitir todos los comandos (usar con precaución).", "deniedCommands": "Comandos denegados", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 78770da21d..fbd2512e42 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -347,6 +347,10 @@ "label": "Activer la protection contre les commandes destructrices", "description": "Télécharge et utilise Destructive Command Guard (DCG) pour cette plateforme. Les commandes autorisées par DCG s'exécutent automatiquement. Les commandes bloquées par DCG nécessitent votre approbation. Les listes de commandes de Zoo sont désactivées tant que cette option est active. L'exécutable téléchargé est conservé si vous la désactivez." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "Commandes auto-exécutables autorisées", "allowedCommandsDescription": "Préfixes de commandes qui peuvent être auto-exécutés lorsque \"Toujours approuver les opérations d'exécution\" est activé. Ajoutez * pour autoriser toutes les commandes (à utiliser avec précaution).", "deniedCommands": "Commandes refusées", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 413d3515bc..c6dffef9a3 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -346,6 +346,10 @@ "label": "विनाशकारी कमांड सुरक्षा सक्षम करें", "description": "इस प्लेटफ़ॉर्म के लिए Destructive Command Guard (DCG) डाउनलोड करके उपयोग करें। DCG द्वारा अनुमत कमांड अपने आप चलते हैं। DCG द्वारा रोके गए कमांड चलाने के लिए आपकी मंज़ूरी आवश्यक होगी। इसके सक्षम रहने पर Zoo की कमांड सूचियाँ बंद रहेंगी। इसे बंद करने पर डाउनलोड किया गया executable रखा जाएगा।" }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "अनुमत स्वतः-निष्पादन कमांड", "allowedCommandsDescription": "कमांड प्रीफिक्स जो स्वचालित रूप से निष्पादित किए जा सकते हैं जब \"निष्पादन ऑपरेशन हमेशा अनुमोदित करें\" सक्षम है। सभी कमांड की अनुमति देने के लिए * जोड़ें (सावधानी से उपयोग करें)।", "deniedCommands": "अस्वीकृत कमांड", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 9b9928da64..1ca1c4035b 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -346,6 +346,10 @@ "label": "Aktifkan perlindungan perintah destruktif", "description": "Unduh dan gunakan Destructive Command Guard (DCG) untuk platform ini. Perintah yang diizinkan DCG dijalankan secara otomatis. Perintah yang diblokir DCG memerlukan persetujuanmu. Daftar perintah Zoo dinonaktifkan selama opsi ini aktif. File executable yang diunduh tetap disimpan jika kamu menonaktifkannya." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "Perintah Auto-Execute yang Diizinkan", "allowedCommandsDescription": "Prefix perintah yang dapat di-auto-execute ketika \"Selalu setujui operasi eksekusi\" diaktifkan. Tambahkan * untuk mengizinkan semua perintah (gunakan dengan hati-hati).", "deniedCommands": "Perintah yang ditolak", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 8a43ec3d35..12e801aa49 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -346,6 +346,10 @@ "label": "Abilita la protezione dai comandi distruttivi", "description": "Scarica e usa Destructive Command Guard (DCG) per questa piattaforma. I comandi consentiti da DCG vengono eseguiti automaticamente. I comandi bloccati da DCG richiedono la tua approvazione. Gli elenchi dei comandi di Zoo vengono disabilitati mentre questa opzione è attiva. L'eseguibile scaricato viene conservato se la disattivi." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "Comandi di auto-esecuzione consentiti", "allowedCommandsDescription": "Prefissi di comando che possono essere auto-eseguiti quando \"Approva sempre operazioni di esecuzione\" è abilitato. Aggiungi * per consentire tutti i comandi (usare con cautela).", "deniedCommands": "Comandi negati", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index b2cfbe977e..72f0b4e54f 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -346,6 +346,10 @@ "label": "破壊的コマンドガードを有効にする", "description": "このプラットフォーム用の Destructive Command Guard(DCG)をダウンロードして使用します。DCG が許可したコマンドは自動的に実行されます。DCG がブロックしたコマンドの実行には承認が必要です。有効な間は Zoo のコマンドリストが無効になります。無効にしても、ダウンロードした実行ファイルは保持されます。" }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "許可された自動実行コマンド", "allowedCommandsDescription": "「実行操作を常に承認」が有効な場合に自動実行できるコマンドプレフィックス。すべてのコマンドを許可するには * を追加します(注意して使用してください)。", "deniedCommands": "拒否されたコマンド", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 1ff9addeb4..8f7b1dbbcb 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -346,6 +346,10 @@ "label": "파괴적 명령어 보호 활성화", "description": "이 플랫폼용 Destructive Command Guard(DCG)를 다운로드하여 사용합니다. DCG가 허용한 명령어는 자동으로 실행됩니다. DCG가 차단한 명령어는 실행 전 승인이 필요합니다. 이 옵션을 사용하는 동안 Zoo의 명령어 목록은 비활성화됩니다. 옵션을 꺼도 다운로드한 실행 파일은 유지됩니다." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "허용된 자동 실행 명령", "allowedCommandsDescription": "\"실행 작업 항상 승인\"이 활성화되었을 때 자동 실행될 수 있는 명령 접두사. 모든 명령을 허용하려면 * 추가(주의해서 사용)", "deniedCommands": "거부된 명령", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 4361d091a1..9751728c80 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -346,6 +346,10 @@ "label": "Beveiliging tegen destructieve opdrachten inschakelen", "description": "Download en gebruik Destructive Command Guard (DCG) voor dit platform. Opdrachten die DCG toestaat, worden automatisch uitgevoerd. Voor opdrachten die DCG blokkeert, is jouw goedkeuring nodig. De opdrachtenlijsten van Zoo zijn uitgeschakeld zolang deze optie actief is. Het gedownloade uitvoerbare bestand blijft bewaard als je de optie uitschakelt." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "Toegestane automatisch uit te voeren commando's", "allowedCommandsDescription": "Commando-prefixen die automatisch kunnen worden uitgevoerd als 'Altijd goedkeuren voor uitvoeren' is ingeschakeld. Voeg * toe om alle commando's toe te staan (gebruik met voorzichtigheid).", "deniedCommands": "Geweigerde commando's", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 277bcaa470..5fd8bdfe32 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -346,6 +346,10 @@ "label": "Włącz ochronę przed destrukcyjnymi poleceniami", "description": "Pobiera i używa Destructive Command Guard (DCG) dla tej platformy. Polecenia dozwolone przez DCG są wykonywane automatycznie. Polecenia zablokowane przez DCG wymagają twojej zgody. Listy poleceń Zoo są wyłączone, gdy ta opcja jest aktywna. Pobrany plik wykonywalny pozostaje na dysku po wyłączeniu opcji." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "Dozwolone polecenia auto-wykonania", "allowedCommandsDescription": "Prefiksy poleceń, które mogą być automatycznie wykonywane, gdy \"Zawsze zatwierdzaj operacje wykonania\" jest włączone. Dodaj * aby zezwolić na wszystkie polecenia (używaj z ostrożnością).", "deniedCommands": "Odrzucone polecenia", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 8ce67bcd48..2b23f147b0 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -346,6 +346,10 @@ "label": "Ativar a proteção contra comandos destrutivos", "description": "Baixa e usa o Destructive Command Guard (DCG) para esta plataforma. Comandos permitidos pelo DCG são executados automaticamente. Comandos bloqueados pelo DCG precisam da sua aprovação. As listas de comandos do Zoo ficam desativadas enquanto esta opção estiver ativa. O executável baixado é mantido se você desativá-la." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "Comandos de auto-execução permitidos", "allowedCommandsDescription": "Prefixos de comando que podem ser auto-executados quando \"Aprovar sempre operações de execução\" está ativado. Adicione * para permitir todos os comandos (use com cautela).", "deniedCommands": "Comandos negados", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 0ac516c190..c684696317 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -346,6 +346,10 @@ "label": "Включить защиту от разрушительных команд", "description": "Скачивает и использует Destructive Command Guard (DCG) для этой платформы. Разрешённые DCG команды выполняются автоматически. Команды, заблокированные DCG, требуют твоего подтверждения. Пока эта настройка включена, списки команд Zoo отключены. Скачанный исполняемый файл сохраняется после отключения настройки." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "Разрешённые авто-выполняемые команды", "allowedCommandsDescription": "Префиксы команд, которые могут быть автоматически выполнены при включённом параметре \"Всегда одобрять выполнение операций\". Добавьте * для разрешения всех команд (используйте с осторожностью).", "deniedCommands": "Запрещенные команды", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 5d3a5cb89a..cd4556c7db 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -346,6 +346,10 @@ "label": "Yıkıcı komut korumasını etkinleştir", "description": "Bu platform için Destructive Command Guard'ı (DCG) indirir ve kullanır. DCG'nin izin verdiği komutlar otomatik olarak çalıştırılır. DCG tarafından engellenen komutlar onayını gerektirir. Bu seçenek etkinken Zoo'nun komut listeleri devre dışı bırakılır. Seçeneği kapatsan da indirilen çalıştırılabilir dosya korunur." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "İzin Verilen Otomatik Yürütme Komutları", "allowedCommandsDescription": "\"Yürütme işlemlerini her zaman onayla\" etkinleştirildiğinde otomatik olarak yürütülebilen komut önekleri. Tüm komutlara izin vermek için * ekleyin (dikkatli kullanın).", "deniedCommands": "Reddedilen komutlar", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index adb1be64e3..b81ad24a2f 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -346,6 +346,10 @@ "label": "Bật bảo vệ khỏi lệnh phá hoại", "description": "Tải xuống và sử dụng Destructive Command Guard (DCG) cho nền tảng này. Các lệnh được DCG cho phép sẽ tự động chạy. Các lệnh bị DCG chặn cần bạn phê duyệt. Danh sách lệnh của Zoo sẽ bị vô hiệu hóa khi tùy chọn này được bật. Tệp thực thi đã tải xuống vẫn được giữ lại nếu bạn tắt tùy chọn." }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "Các lệnh tự động thực thi được phép", "allowedCommandsDescription": "Tiền tố lệnh có thể được tự động thực thi khi \"Luôn phê duyệt các hoạt động thực thi\" được bật. Thêm * để cho phép tất cả các lệnh (sử dụng cẩn thận).", "deniedCommands": "Lệnh bị từ chối", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 916f629efe..53ec69eb80 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -346,6 +346,10 @@ "label": "启用破坏性命令防护", "description": "下载并使用适用于此平台的 Destructive Command Guard(DCG)。DCG 允许的命令会自动执行。被 DCG 拦截的命令需要你批准后才能执行。启用后,Zoo 的命令列表将被禁用。关闭此选项时,已下载的可执行文件会保留。" }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "命令白名单", "allowedCommandsDescription": "当\"自动批准命令行操作\"启用时可以自动执行的命令前缀。添加 * 以允许所有命令(谨慎使用)。", "deniedCommands": "拒绝的命令", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index d1f9258dcf..312b005843 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -371,6 +371,10 @@ "label": "啟用破壞性命令防護", "description": "下載並使用適用於此平台的 Destructive Command Guard(DCG)。DCG 允許的命令會自動執行。被 DCG 阻擋的命令需要你核准後才能執行。啟用後,Zoo 的命令清單將停用。關閉此選項時,已下載的執行檔會保留。" }, + "autoDeny": { + "label": "Auto-deny unapproved commands (never ask)", + "description": "When auto-approval for command execution is on, no confirmation prompts appear for commands. DCG off: everything not on the allowlist is auto-denied. DCG on: everything DCG does not approve is auto-denied, with the DCG reason sent to the model. No confirmation prompts." + }, "allowedCommands": "允許自動執行的命令", "allowedCommandsDescription": "啟用「始終核准執行」時,可自動執行的命令前綴。新增 * 可允許所有命令(請謹慎使用)。", "deniedCommands": "拒絕的命令", From 528b023f9df7e89459cc8bd5a14dfffc384e3311 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 8 Sep 2026 07:35:27 +0200 Subject: [PATCH 11/39] bound model fetch with timeout, typed provider state test doubles --- src/core/task/Task.ts | 38 ++- src/core/task/__tests__/Task.spec.ts | 411 +++++++++++++++++++++------ 2 files changed, 358 insertions(+), 91 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 20b09236a0..49e0e0ecbf 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -141,6 +141,17 @@ import { shouldAddUserMessageToHistory } from "./messageCounting" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds +// Upper bound on awaiting lazily loaded model metadata. Some model-catalog +// fetchers (e.g. OpenRouter's bare axios GET) have no request timeout, so a +// hung endpoint must not stall streaming, condense, or context-window +// handling. On expiry the fetch is abandoned (the API-handler contract +// exposes no AbortSignal) and callers fall back to the handler's existing +// getModel().info metadata — the same degradation a rejected fetch produces. +// Kept in sync with PREVIEW_MODEL_FETCH_TIMEOUT_MS in the preview path +// (src/core/webview/generateSystemPrompt.ts). Deliberately duplicated, not +// shared: importing from the webview layer would close the +// Task -> generateSystemPrompt -> ClineProvider -> Task import cycle. +export const MODEL_FETCH_TIMEOUT_MS = 5_000 const QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS = [250, 1_000, 4_000] as const type QueuedAskResolution = { response: ClineAskResponse; requiresDurableAck: boolean } @@ -4140,7 +4151,8 @@ export class Task extends EventEmitter implements TaskLike { /** * Ensures router-provider model metadata is loaded before getModel() is used for * context management or streaming. Failures fall back to hardcoded defaults rather - * than aborting the task. + * than aborting the task; the wait is bounded by MODEL_FETCH_TIMEOUT_MS (see the + * constant for the abandonment and degradation semantics). * * The prompt- and context-critical read sites of getModel() (streaming entry, * getSystemPrompt, context-window handling) each await this immediately before @@ -4154,13 +4166,35 @@ export class Task extends EventEmitter implements TaskLike { * matters. */ private async safeEnsureModelFetched(): Promise { + // Promise.race attaches handlers to both inputs, so the abandoned fetch + // rejecting after the timeout is already considered handled — no extra + // .catch is needed here. + let timeoutId: ReturnType | undefined + let timedOut = false try { - await this.api.ensureModelFetched?.() + await Promise.race([ + this.api.ensureModelFetched?.(), + new Promise((resolve) => { + timeoutId = setTimeout(() => { + timedOut = true + resolve() + }, MODEL_FETCH_TIMEOUT_MS) + }), + ]) + if (timedOut) { + console.warn( + `[Task#${this.taskId}] Timed out after ${MODEL_FETCH_TIMEOUT_MS}ms fetching model metadata; using fallback model info.`, + ) + } } catch (error) { console.error( `[Task#${this.taskId}] Failed to fetch model metadata:`, error instanceof Error ? error.message : error, ) + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } } } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index f6f49c49cb..1bdbd82388 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -17,7 +17,7 @@ import { } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { Task } from "../Task" +import { MODEL_FETCH_TIMEOUT_MS, Task } from "../Task" import { SYSTEM_PROMPT } from "../../prompts/system" import { createRateLimitClock } from "../RateLimitClock" import { summarizeConversation } from "../../condense" @@ -282,7 +282,19 @@ describe("Cline", () => { let mockOutputChannel: vscode.OutputChannel let mockExtensionContext: vscode.ExtensionContext - beforeEach(() => { + // Builds provider-state doubles on top of the real getState() result + // captured before any test stubs it, so required fields stay + // compile-checked while each test states only its own overrides. + // mcpEnabled defaults to false so doubles skip the MCP-hub path unless a + // test opts in. + let baseProviderState: ProviderState + const providerStateWith = (overrides: Partial = {}): ProviderState => ({ + ...baseProviderState, + mcpEnabled: false, + ...overrides, + }) + + beforeEach(async () => { if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) } @@ -397,6 +409,8 @@ describe("Cline", () => { }, ], })) + + baseProviderState = await mockProvider.getState() }) describe("empty-response retries", () => { @@ -736,10 +750,7 @@ describe("Cline", () => { ...mockApiConfig, todoListEnabled: true, } - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "architect", - mcpEnabled: false, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "architect" })) const task = new Task({ provider: mockProvider, @@ -749,11 +760,9 @@ describe("Cline", () => { }) await task.getTaskMode() - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "code", - mcpEnabled: false, - apiConfiguration: { ...mockApiConfig, todoListEnabled: false }, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue( + providerStateWith({ apiConfiguration: { ...mockApiConfig, todoListEnabled: false } }), + ) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") await getTaskTestAccess(task).getSystemPrompt() @@ -775,13 +784,16 @@ describe("Cline", () => { // First getState call: MCP disabled (avoids the MCP hub path). Later // calls (including the state read feeding `state?.disabledTools`): - // undefined. Dropping the optional chain on that read makes - // getSystemPrompt reject with a TypeError instead of resolving. - vi.spyOn(mockProvider, "getState") - // ProviderState requires all declared fields; the test deliberately supplies a partial state to exercise the fallback path. - .mockResolvedValueOnce({ mcpEnabled: false } as unknown as ProviderState) - // ProviderState requires all declared fields; the test deliberately supplies an absent state to exercise the fallback path. - .mockResolvedValue(undefined as unknown as ProviderState) + // undefined, modeling a provider that went unavailable mid-request; + // production reads guard with `state ?? {}`. Dropping the optional + // chain on that read makes getSystemPrompt reject with a TypeError + // instead of resolving. + Object.assign(mockProvider, { + getState: vi + .fn<() => Promise>() + .mockResolvedValueOnce(providerStateWith()) + .mockResolvedValue(undefined), + }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") await expect(getTaskTestAccess(task).getSystemPrompt()).resolves.toBe("mock system prompt") @@ -803,13 +815,8 @@ describe("Cline", () => { // First getState call: MCP disabled (avoids the MCP hub path). Later // calls supply the state that feeds `state?.disabledTools`. vi.spyOn(mockProvider, "getState") - // ProviderState requires all declared fields; the test supplies a partial state. - .mockResolvedValueOnce({ mcpEnabled: false } as unknown as ProviderState) - // ProviderState requires all declared fields; the test supplies a partial state. - .mockResolvedValue({ - mcpEnabled: false, - disabledTools: ["execute_command"], - } as unknown as ProviderState) + .mockResolvedValueOnce(providerStateWith()) + .mockResolvedValue(providerStateWith({ disabledTools: ["execute_command"] })) const modelInfo: ModelInfo = { contextWindow: 128_000, @@ -841,10 +848,7 @@ describe("Cline", () => { }) await task.getTaskMode() - // ProviderState requires all declared fields; the test supplies a partial state (MCP disabled). - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mcpEnabled: false, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) const fallbackInfo: ModelInfo = { contextWindow: 32_000, @@ -894,8 +898,7 @@ describe("Cline", () => { mcpEnabled: false, autoApprovalEnabled: false, disabledTools: ["execute_command"], - // ProviderState requires all declared fields; the test supplies the request-scoped subset the prompt consumes. - } as unknown as ProviderState) + }) // Any later getState() read returns different disabledTools, so a // re-read along the prompt path would change the observed behavior. .mockResolvedValue({ @@ -903,8 +906,7 @@ describe("Cline", () => { mcpEnabled: false, autoApprovalEnabled: false, disabledTools: ["read_file"], - // ProviderState requires all declared fields; the test supplies the changed-state subset to detect re-reads. - } as unknown as ProviderState) + }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(task, "getTokenUsage").mockReturnValue({ @@ -952,22 +954,13 @@ describe("Cline", () => { }) await task.getTaskMode() - // ProviderState requires all declared fields; the test supplies the - // snapshot subset (MCP disabled, one disabled tool). - const snapshot = { - mcpEnabled: false, - disabledTools: ["execute_command"], - } as unknown as ProviderState + const snapshot = providerStateWith({ disabledTools: ["execute_command"] }) vi.spyOn(mockProvider, "getState") // First call: the snapshot captured by condenseContext. .mockResolvedValueOnce(snapshot) // Any later getState() read returns different disabledTools, so a // re-read along the prompt path would change the observed behavior. - // ProviderState requires all declared fields; the test supplies the changed-state subset to detect re-reads. - .mockResolvedValue({ - mcpEnabled: false, - disabledTools: ["read_file"], - } as unknown as ProviderState) + .mockResolvedValue(providerStateWith({ disabledTools: ["read_file"] })) const getSystemPromptSpy = vi .spyOn(getTaskTestAccess(task), "getSystemPrompt") @@ -992,23 +985,14 @@ describe("Cline", () => { }) await task.getTaskMode() - // ProviderState requires all declared fields; the test supplies the - // snapshot subset (MCP disabled, one disabled tool). - const snapshot = { - mcpEnabled: false, - disabledTools: ["execute_command"], - } as unknown as ProviderState + const snapshot = providerStateWith({ disabledTools: ["execute_command"] }) vi.spyOn(mockProvider, "getState") // First call: the snapshot captured at the top of // handleContextWindowExceededError. .mockResolvedValueOnce(snapshot) // Any later getState() read returns different disabledTools, so a // re-read along the prompt path would change the observed behavior. - // ProviderState requires all declared fields; the test supplies the changed-state subset to detect re-reads. - .mockResolvedValue({ - mcpEnabled: false, - disabledTools: ["read_file"], - } as unknown as ProviderState) + .mockResolvedValue(providerStateWith({ disabledTools: ["read_file"] })) // Overflow the 50k window so manageContext takes the condense branch // (the module-mocked summarizeConversation returns a summary). @@ -1038,10 +1022,7 @@ describe("Cline", () => { }) it("uses the task mode when manually condensing after focused state changes", async () => { - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "architect", - mcpEnabled: false, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "architect" })) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -1049,10 +1030,7 @@ describe("Cline", () => { startTask: false, }) await task.getTaskMode() - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "code", - mcpEnabled: false, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "code" })) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") await task.condenseContext() @@ -1062,12 +1040,9 @@ describe("Cline", () => { }) it("uses the task mode in request metadata when focused provider state differs", async () => { - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "ask", - mcpEnabled: false, - autoApprovalEnabled: true, - requestDelaySeconds: 0, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue( + providerStateWith({ mode: "ask", autoApprovalEnabled: true, requestDelaySeconds: 0 }), + ) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -1077,12 +1052,9 @@ describe("Cline", () => { await task.getTaskMode() vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "code", - mcpEnabled: false, - autoApprovalEnabled: true, - requestDelaySeconds: 0, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue( + providerStateWith({ mode: "code", autoApprovalEnabled: true, requestDelaySeconds: 0 }), + ) const stream = (async function* () { yield { type: "text", text: "response" } as ApiStreamChunk })() @@ -1096,6 +1068,97 @@ describe("Cline", () => { const metadata = requireDefined(createMessage.mock.calls[0])[2] expect(metadata?.mode).toBe("ask") }) + + it("condenses with an undefined state snapshot when the provider is gone", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // Condensing must tolerate a collected provider: the snapshot read resolves to undefined and completes. + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + writable: false, + configurable: true, + }) + const getSystemPromptSpy = vi + .spyOn(getTaskTestAccess(task), "getSystemPrompt") + .mockResolvedValue("mock system prompt") + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 0, + }) + vi.mocked(summarizeConversation).mockResolvedValueOnce({ + messages: [{ role: "user", content: [{ type: "text", text: "condensed" }], ts: Date.now() }], + summary: "summary", + cost: 0, + newContextTokens: 1, + condenseId: "condense-id", + }) + const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + + await expect(task.condenseContext()).resolves.toBeUndefined() + + expect(getSystemPromptSpy).toHaveBeenCalledWith(undefined) + expect(overwriteSpy).toHaveBeenCalledTimes(1) + }) + + it("rejects with the view-transition error when the provider ref is lost", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // A collected provider must reject with the view-transition error, not with a state-read TypeError. + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + writable: false, + configurable: true, + }) + + await expect(getTaskTestAccess(task).getSystemPrompt()).rejects.toThrow( + "Provider reference lost during view transition", + ) + }) + + it("rejects with the provider-unavailable error when the provider dies between state reads", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // The ref dies after the MCP-gate state read and before the + // snapshot read, so only the provider guard can answer. + let providerAlive = true + Object.defineProperty(task, "providerRef", { + value: { + deref: () => { + if (providerAlive) { + providerAlive = false + return mockProvider + } + return undefined + }, + }, + writable: false, + configurable: true, + }) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + + await expect(getTaskTestAccess(task).getSystemPrompt()).rejects.toThrow("Provider not available") + }) }) describe("sayAndCreateMissingParamError", () => { @@ -2236,10 +2299,7 @@ describe("Cline", () => { }) it("uses a mode selected through submitUserMessage in the next API request", async () => { - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "ask", - mcpEnabled: false, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "ask" })) vi.spyOn(mockProvider, "setMode").mockResolvedValue(undefined) const task = new Task({ provider: mockProvider, @@ -2278,10 +2338,12 @@ describe("Cline", () => { }) task.setTaskApiConfigName("previous-profile") vi.spyOn(mockProvider, "setProviderProfile").mockResolvedValue(undefined) - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - currentApiConfigName: "selected-profile", - apiConfiguration: selectedConfiguration, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue( + providerStateWith({ + currentApiConfigName: "selected-profile", + apiConfiguration: selectedConfiguration, + }), + ) vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) await task.submitUserMessage("switch profiles", undefined, undefined, "selected-profile") @@ -3429,10 +3491,7 @@ describe("Cline", () => { }) it("should propagate AbortController signal through attemptApiRequest context-window retry path", async () => { - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "architect", - mcpEnabled: false, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "architect" })) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -3542,6 +3601,10 @@ describe("Cline", () => { }) describe("safeEnsureModelFetched", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + it("loads model metadata before getModel is used", async () => { const task = new Task({ provider: mockProvider, @@ -3576,7 +3639,6 @@ describe("Cline", () => { expect.stringContaining("Failed to fetch model metadata"), "network down", ) - errorSpy.mockRestore() }) it("is a no-op when the api handler does not implement ensureModelFetched", async () => { @@ -3590,6 +3652,61 @@ describe("Cline", () => { await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBeUndefined() }) + it("settles at the bound when ensureModelFetched never resolves", async () => { + // A hung metadata endpoint (some fetchers issue unbounded GETs) must + // not stall the task: the race resolves at MODEL_FETCH_TIMEOUT_MS and + // callers proceed with the handler's fallback metadata. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const settled = getTaskTestAccess(task).safeEnsureModelFetched() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + + await expect(settled).resolves.toBeUndefined() + } finally { + vi.useRealTimers() + } + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) + }) + + it("does not block getSystemPrompt when ensureModelFetched never settles", async () => { + // The prompt/condense guard site must proceed with fallback model + // info once the bounded wait expires instead of hanging the request. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + + Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + const callsBefore = vi.mocked(SYSTEM_PROMPT).mock.calls.length + vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const promptPromise = getTaskTestAccess(task).getSystemPrompt() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + + await expect(promptPromise).resolves.toBe("mock system prompt") + } finally { + vi.useRealTimers() + } + expect(vi.mocked(SYSTEM_PROMPT).mock.calls.length).toBe(callsBefore + 1) + }) + it("calls safeEnsureModelFetched from attemptApiRequest when context tokens are present", async () => { const task = new Task({ provider: mockProvider, @@ -3702,7 +3819,6 @@ describe("Cline", () => { value: { type: "text", text: "ok" }, }) expect(errorSpy).toHaveBeenCalled() - errorSpy.mockRestore() }) it("fetches model metadata before caching the streaming model", async () => { @@ -3763,6 +3879,123 @@ describe("Cline", () => { expect(ensureModelFetched).toHaveBeenCalled() expect(task.cachedStreamingModel?.id).toBe(mockApiConfig.apiModelId) }) + + it("stays silent when the api handler lacks ensureModelFetched", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // A missing optional fetcher is the normal case for static providers, + // so the call must resolve quietly instead of surfacing a caught TypeError. + await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBeUndefined() + + expect(errorSpy.mock.calls.flat().join(" ")).not.toContain("Failed to fetch model metadata") + }) + + it("does not warn when the fetch resolves within the bound", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + Object.assign(task.api, { ensureModelFetched: () => Promise.resolve() }) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + await getTaskTestAccess(task).safeEnsureModelFetched() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + } finally { + vi.useRealTimers() + } + + expect(warnSpy).not.toHaveBeenCalled() + }) + + it("warns only once the bound elapses", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const settled = getTaskTestAccess(task).safeEnsureModelFetched() + // The 5000 ms bound is asserted in absolute milliseconds so any + // change to it changes observed behavior, not just the schedule. + await vi.advanceTimersByTimeAsync(4_999) + + expect(warnSpy).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(warnSpy).toHaveBeenCalledTimes(1) + await expect(settled).resolves.toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + it("clears the race timer after the fetch wins", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + Object.assign(task.api, { ensureModelFetched: () => Promise.resolve() }) + const clearSpy = vi.spyOn(globalThis, "clearTimeout") + + await getTaskTestAccess(task).safeEnsureModelFetched() + + // The armed handle must be handed to clearTimeout on the winning + // path; a never-armed or never-cleared timer leaks a pending handle. + expect(clearSpy).toHaveBeenCalledWith(expect.any(Object)) + }) + + it("only clears a timer handle that was actually armed", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Hand production a falsy handle while still arming the real timer, + // so skipping clearTimeout for an unarmed handle is observable. + const realSetTimeout = globalThis.setTimeout + let armedHandle: ReturnType | undefined + vi.stubGlobal("setTimeout", (callback: () => void, delay?: number) => { + armedHandle = realSetTimeout(callback, delay) + return 0 + }) + const clearSpy = vi.spyOn(globalThis, "clearTimeout") + Object.assign(task.api, { ensureModelFetched: () => Promise.resolve() }) + + try { + await getTaskTestAccess(task).safeEnsureModelFetched() + + expect(clearSpy).not.toHaveBeenCalled() + } finally { + if (armedHandle !== undefined) { + clearTimeout(armedHandle) + } + vi.unstubAllGlobals() + } + }) }) describe("startTask", () => { From aa8f108cad2aafc90a07313b55b9d5cc214fa0b2 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 8 Sep 2026 07:36:04 +0200 Subject: [PATCH 12/39] cover preview model fetch timeout path with tests --- .../__tests__/generateSystemPrompt.spec.ts | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/src/core/webview/__tests__/generateSystemPrompt.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.spec.ts index 2388acbaf8..b703ded47c 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.spec.ts +++ b/src/core/webview/__tests__/generateSystemPrompt.spec.ts @@ -319,6 +319,12 @@ describe("generateSystemPrompt preview parity", () => { expect(capabilities).toContain("read files") expect(capabilities).toContain("execute CLI commands") expect(errorSpy).toHaveBeenCalled() + // The context string is part of the contract: an empty or generic log + // line would erase the only trace of a degraded preview. + expect(errorSpy).toHaveBeenCalledWith( + "Error fetching model metadata for system prompt preview:", + expect.anything(), + ) }) it("degrades to fallback metadata when ensureModelFetched hangs past the preview timeout", async () => { @@ -394,6 +400,152 @@ describe("generateSystemPrompt preview parity", () => { expect(prompt).toContain("OBJECTIVE") }) + + describe("preview metadata-fetch robustness", () => { + it("skips the metadata fetch silently when the handler has no ensureModelFetched", async () => { + // Providers without lazy model discovery legitimately lack + // ensureModelFetched: the optional call must skip it and still build + // the preview from the handler's current metadata, without logging. + // The property is redefined to undefined on the shared double (then + // restored) because the mocked factory reads it per buildApiHandler() + // call, so a missing method reaches the code under test untyped. + const descriptor = Object.getOwnPropertyDescriptor(modelMock, "ensureModelFetched") + Object.defineProperty(modelMock, "ensureModelFetched", { + value: undefined, + configurable: true, + writable: true, + }) + try { + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + expect(errorSpy).not.toHaveBeenCalled() + const capabilities = extractSection(preview, "CAPABILITIES") + // Fallback-fixture signature (see fallbackModelInfo): the preview is + // still built from a complete ModelInfo, not from undefined. + expect(capabilities).not.toContain("list files") + expect(capabilities).toContain("read files") + } finally { + if (descriptor) { + Object.defineProperty(modelMock, "ensureModelFetched", descriptor) + } + } + }) + + it("clears the pending preview timer once the fetch resolves first", async () => { + vi.useFakeTimers() + try { + modelMock.ensureModelFetched.mockResolvedValueOnce(undefined) + await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + // The fetch won the race, so the still-pending timeout must have been + // cancelled inside the same turn; a leftover timer means every fast + // preview leaves a five-second handle behind. + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it("hands the armed timer handle to clearTimeout when the fetch wins the race", async () => { + // The race arms its timeout through the real setTimeout before awaiting; + // the winning path must cancel exactly that handle. + const clearSpy = vi.spyOn(globalThis, "clearTimeout") + try { + modelMock.ensureModelFetched.mockResolvedValueOnce(undefined) + await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + expect(clearSpy).toHaveBeenCalledTimes(1) + expect(clearSpy).toHaveBeenCalledWith(expect.any(Object)) + } finally { + clearSpy.mockRestore() + } + }) + + it("does not clear a timer when setTimeout yields a falsy handle", async () => { + // The guard only treats a truthy handle as armed. The double assertion + // is unavoidable here: no platform handle type admits the numeric 0 + // that such environments return, and the guard's truthiness check is + // exactly what this case pins down. + const zeroHandle = 0 as unknown as ReturnType + const setSpy = vi.spyOn(globalThis, "setTimeout").mockReturnValue(zeroHandle) + const clearSpy = vi.spyOn(globalThis, "clearTimeout") + try { + modelMock.ensureModelFetched.mockResolvedValueOnce(undefined) + await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + expect(clearSpy).not.toHaveBeenCalled() + } finally { + setSpy.mockRestore() + clearSpy.mockRestore() + } + }) + + it("resolves the preview race exactly at the fetch timeout bound", async () => { + // The race bound is an absolute wall: a hung endpoint must be released + // precisely after 5000 ms, never a tick earlier, so a slow-but-alive + // fetch still wins at 4999 ms. + vi.useFakeTimers() + try { + modelMock.ensureModelFetched.mockImplementationOnce(() => new Promise(() => {})) + + let settled = false + const previewPromise = generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }).then( + (prompt) => { + settled = true + return prompt + }, + ) + await vi.advanceTimersByTimeAsync(4_999) + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + const preview = await previewPromise + + // Degradation at the bound mirrors the rejected-fetch path: fallback + // metadata, and no error logged (a timeout is not a failure). + const capabilities = extractSection(preview, "CAPABILITIES") + expect(capabilities).not.toContain("list files") + expect(capabilities).toContain("read files") + expect(errorSpy).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it("logs and degrades when the model info cannot be read", async () => { + // A throw while reading the model info escapes the fetch race and lands + // in the outer handler: the preview must still resolve — without model + // guidance — and log the outer-catch context string. The state double + // is swapped for a throwing getter because the mocked factory reads it + // inside getModel().info, which is the read the preview performs. + const stateDescriptor = Object.getOwnPropertyDescriptor(modelMock, "state") + Object.defineProperty(modelMock, "state", { + value: { + get fetched(): never { + throw new Error("model info unavailable") + }, + }, + configurable: true, + writable: true, + }) + try { + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + expect(errorSpy).toHaveBeenCalledWith( + "Error reading model info for system prompt preview:", + expect.anything(), + ) + const capabilities = extractSection(preview, "CAPABILITIES") + // modelInfo === undefined excludes nothing: both clause families appear. + expect(capabilities).toContain("read files") + expect(capabilities).toContain("list files") + } finally { + if (stateDescriptor) { + Object.defineProperty(modelMock, "state", stateDescriptor) + } + } + }) + }) }) // --------------------------------------------------------------------------- From 415422baaf26cace31000af6bea89a13cadc9537 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 8 Sep 2026 07:37:07 +0200 Subject: [PATCH 13/39] pin completion-time history save ordering with unit tests --- .../Task.history-persistence.spec.ts | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 src/core/task/__tests__/Task.history-persistence.spec.ts diff --git a/src/core/task/__tests__/Task.history-persistence.spec.ts b/src/core/task/__tests__/Task.history-persistence.spec.ts new file mode 100644 index 0000000000..5e341b7919 --- /dev/null +++ b/src/core/task/__tests__/Task.history-persistence.spec.ts @@ -0,0 +1,407 @@ +// cd src && npx vitest run core/task/__tests__/Task.history-persistence.spec.ts + +import * as os from "os" +import * as path from "path" +import * as vscode from "vscode" + +import type { GlobalState, ProviderSettings } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" +import { ContextProxy } from "../../config/ContextProxy" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +type TaskHistoryPersistenceAccess = { + saveApiConversationHistory: (merge?: boolean) => Promise + overwriteApiConversationHistory: (messages: unknown[], persist?: boolean) => Promise + flushPendingToolResultsToHistory: () => Promise + assistantMessageSavedToHistory: boolean + userMessageContent: unknown[] +} + +function getTaskHistoryPersistenceAccess(task: Task): TaskHistoryPersistenceAccess { + return task as unknown as TaskHistoryPersistenceAccess +} + +function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +async function drainMicrotasks() { + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + +// ─── Hoisted mocks ─────────────────────────────────────────────────────────── + +const { + mockSaveApiMessages, + mockSaveTaskMessages, + mockReadApiMessages, + mockReadTaskMessages, + mockTaskMetadata, + mockPWaitFor, +} = vi.hoisted(() => ({ + mockSaveApiMessages: vi.fn().mockResolvedValue(undefined), + mockSaveTaskMessages: vi.fn().mockResolvedValue(undefined), + mockReadApiMessages: vi.fn().mockResolvedValue([]), + mockReadTaskMessages: vi.fn().mockResolvedValue([]), + mockTaskMetadata: vi.fn().mockResolvedValue({ + historyItem: { id: "test-id", ts: Date.now(), task: "test" }, + tokenUsage: { + totalTokensIn: 0, + totalTokensOut: 0, + totalCacheWrites: 0, + totalCacheReads: 0, + totalCost: 0, + contextTokens: 0, + }, + }), + mockPWaitFor: vi.fn().mockResolvedValue(undefined), +})) + +// ─── Module mocks ──────────────────────────────────────────────────────────── + +vi.mock("delay", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +vi.mock("p-wait-for", () => ({ + default: mockPWaitFor, +})) + +vi.mock("../../task-persistence", async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + saveApiMessages: mockSaveApiMessages, + saveTaskMessages: mockSaveTaskMessages, + readApiMessages: mockReadApiMessages, + readTaskMessages: mockReadTaskMessages, + taskMetadata: mockTaskMetadata, + TaskHistoryStore: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + get: vi.fn(), + getAll: vi.fn().mockReturnValue([]), + upsert: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + deleteMany: vi.fn().mockResolvedValue(undefined), + reconcile: vi.fn().mockResolvedValue(undefined), + initialized: Promise.resolve(), + } + }), + } +}) + +vi.mock("vscode", () => { + const mockDisposable = { dispose: vi.fn() } + const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } + const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } } + const mockTextEditor = { document: mockTextDocument } + const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } } + const mockTabGroup = { tabs: [mockTab] } + + return { + TabInputTextDiff: vi.fn(), + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + window: { + createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), + visibleTextEditors: [mockTextEditor], + tabGroups: { + all: [mockTabGroup], + close: vi.fn(), + onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })), + }, + showErrorMessage: vi.fn(), + }, + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/mock/workspace/path" }, + name: "mock-workspace", + index: 0, + }, + ], + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(() => mockDisposable), + onDidDelete: vi.fn(() => mockDisposable), + onDidChange: vi.fn(() => mockDisposable), + dispose: vi.fn(), + })), + fs: { + stat: vi.fn().mockResolvedValue({ type: 1 }), + }, + onDidSaveTextDocument: vi.fn(() => mockDisposable), + getConfiguration: vi.fn(() => ({ get: (_key: string, defaultValue: unknown) => defaultValue })), + }, + env: { + uriScheme: "vscode", + language: "en", + }, + EventEmitter: vi.fn().mockImplementation(function () { + return mockEventEmitter + }), + Disposable: { + from: vi.fn(), + }, + TabInputText: vi.fn(), + } +}) + +vi.mock("../../mentions", () => ({ + parseMentions: vi.fn().mockImplementation((text) => { + return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }) + }), + openMention: vi.fn(), + getLatestTerminalOutput: vi.fn(), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + +vi.mock("../../ignore/RooIgnoreController") + +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), + getSettingsDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockReturnValue(false), +})) + +// ─── Test suite ────────────────────────────────────────────────────────────── + +describe("Task history persistence ordering contract", () => { + let mockProvider: ClineProvider + let mockApiConfig: ProviderSettings + let mockOutputChannel: vscode.OutputChannel + let mockExtensionContext: vscode.ExtensionContext + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") } + + mockExtensionContext = { + globalState: { + get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), + update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + keys: vi.fn().mockReturnValue([]), + }, + globalStorageUri: storageUri, + workspaceState: { + get: vi.fn().mockImplementation((_key) => undefined), + update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn().mockImplementation((_key) => Promise.resolve(undefined)), + store: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + delete: vi.fn().mockImplementation((_key) => Promise.resolve()), + }, + extensionUri: { fsPath: "/mock/extension/path" }, + extension: { packageJSON: { version: "1.0.0" } }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + + mockProvider = new ClineProvider( + mockExtensionContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockExtensionContext), + ) + + mockApiConfig = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + } + + mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) + mockProvider.updateTaskHistory = vi.fn().mockResolvedValue(undefined) + mockProvider.log = vi.fn() + }) + + function createTask(): Task { + return new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "history persistence test task", + startTask: false, + }) + } + + describe("completion-time save is durably awaited", () => { + it("does not resolve the save before the durable write settles", async () => { + const task = createTask() + const access = getTaskHistoryPersistenceAccess(task) + const deferred = createDeferred() + mockSaveApiMessages.mockImplementationOnce(() => deferred.promise) + + task.apiConversationHistory.push({ + role: "user", + content: [{ type: "text", text: "pending write" }], + }) + + let settled = false + const savePromise = access.saveApiConversationHistory().then((result) => { + settled = true + return result + }) + + await drainMicrotasks() + expect(settled).toBe(false) + + deferred.resolve(undefined) + await expect(savePromise).resolves.toBe(true) + expect(settled).toBe(true) + }) + + it("passes a snapshot with merge semantics on incremental saves", async () => { + const task = createTask() + const access = getTaskHistoryPersistenceAccess(task) + + task.apiConversationHistory.push({ + role: "user", + content: [{ type: "text", text: "snapshot" }], + }) + + await expect(access.saveApiConversationHistory()).resolves.toBe(true) + + expect(mockSaveApiMessages).toHaveBeenCalledTimes(1) + const args = mockSaveApiMessages.mock.calls[0][0] + expect(args.merge).toBe(true) + expect(args.messages).not.toBe(task.apiConversationHistory) + expect(args.messages).toEqual(task.apiConversationHistory) + }) + }) + + describe("authoritative overwrite vs hydration", () => { + it("persists explicit overwrites as authoritative (merge: false)", async () => { + const task = createTask() + const access = getTaskHistoryPersistenceAccess(task) + + await access.overwriteApiConversationHistory([{ role: "user", content: "authoritative" }]) + + expect(mockSaveApiMessages).toHaveBeenCalledTimes(1) + expect(mockSaveApiMessages).toHaveBeenCalledWith(expect.objectContaining({ merge: false })) + }) + + it("hydrates without touching the persisted file when persist is false", async () => { + const task = createTask() + const access = getTaskHistoryPersistenceAccess(task) + + await access.overwriteApiConversationHistory([{ role: "user", content: "hydrated only" }], false) + + expect(mockSaveApiMessages).not.toHaveBeenCalled() + expect(task.apiConversationHistory).toEqual([ + expect.objectContaining({ role: "user", content: "hydrated only", messageId: expect.any(String) }), + ]) + }) + }) + + describe("flushPendingToolResultsToHistory only clears pending content after a durable save", () => { + it("returns true and clears pending content when the save settles", async () => { + const task = createTask() + const access = getTaskHistoryPersistenceAccess(task) + access.assistantMessageSavedToHistory = true + access.userMessageContent = [ + { type: "tool_result", tool_use_id: "toolu_1", content: "done", is_error: false }, + ] + + await expect(access.flushPendingToolResultsToHistory()).resolves.toBe(true) + + expect(access.userMessageContent).toEqual([]) + expect(mockSaveApiMessages).toHaveBeenCalledTimes(1) + expect(task.apiConversationHistory).toHaveLength(1) + }) + + it("returns false and retains pending content when the save fails", async () => { + const task = createTask() + const access = getTaskHistoryPersistenceAccess(task) + access.assistantMessageSavedToHistory = true + const pending = [{ type: "tool_result", tool_use_id: "toolu_1", content: "done", is_error: false }] + access.userMessageContent = pending + + mockSaveApiMessages.mockRejectedValueOnce(new Error("disk unavailable")) + + await expect(access.flushPendingToolResultsToHistory()).resolves.toBe(false) + + expect(access.userMessageContent).toEqual(pending) + }) + }) + + // Guards the discriminating power of the "does not resolve the save before the + // durable write settles" pin above: a fire-and-forget seam (the regression this + // pin exists to catch) resolves the save before the write settles, so the pin's + // invariant would flip to false under that mutation. + describe("mutation guard: the pending-until-durable pin discriminates fire-and-forget", () => { + it("a fire-and-forget save wrapper resolves before the durable write settles", async () => { + const deferred = createDeferred() + const fireAndForgetSave = () => { + void deferred.promise + return Promise.resolve(true) + } + + let settled = false + await fireAndForgetSave().then(() => { + settled = true + }) + + expect(settled).toBe(true) + + const awaitedSave = async () => { + await deferred.promise + return true + } + let awaitedSettled = false + const awaitedPromise = awaitedSave().then(() => { + awaitedSettled = true + }) + await drainMicrotasks() + expect(awaitedSettled).toBe(false) + deferred.resolve() + await awaitedPromise + }) + }) +}) From 1db50a12c7797a00153ad3ebe896f5b85fdff062 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 8 Sep 2026 07:37:30 +0200 Subject: [PATCH 14/39] poll history length in restart e2e to tolerate atomic write window --- .../src/suite/restart-persistence.test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/vscode-e2e/src/suite/restart-persistence.test.ts b/apps/vscode-e2e/src/suite/restart-persistence.test.ts index 1f03e39e85..6c76d1f072 100644 --- a/apps/vscode-e2e/src/suite/restart-persistence.test.ts +++ b/apps/vscode-e2e/src/suite/restart-persistence.test.ts @@ -25,6 +25,19 @@ async function quitGracefully(): Promise { await vscode.commands.executeCommand("workbench.action.quit") } +// The API history file is committed via a backup-rename swap under an advisory +// lock, so an immediate post-completion read can transiently observe it as +// missing. Poll the same read the assertion uses until it reports a non-empty +// history; the assertion semantics below are unchanged. +async function waitForApiConversationHistoryLength(api: RooCodeAPI, taskId: string): Promise { + let length = 0 + await waitFor(async () => { + length = await api.getTaskApiConversationHistoryLength(taskId) + return length > 0 + }) + return length +} + async function runCreate(api: RooCodeAPI): Promise { let taskId: string | undefined let createPhasePassed = false @@ -46,7 +59,7 @@ async function runCreate(api: RooCodeAPI): Promise { const historyItem = await api.getTaskHistoryItem(taskId) assert.ok(historyItem, "Completed task should have a history item") assert.ok(historyItem.task.includes("RESTART_PERSISTENCE_SMOKE"), "History title should include the marker") - const conversationLength = await api.getTaskApiConversationHistoryLength(taskId) + const conversationLength = await waitForApiConversationHistoryLength(api, taskId) assert.ok(conversationLength > 0, "Completed task should persist API conversation history") const result: PhaseResult = { @@ -91,7 +104,7 @@ async function runVerify(api: RooCodeAPI): Promise { const historyItem = await api.getTaskHistoryItem(taskId) assert.ok(historyItem, "Task history item should be available after restart") assert.ok(historyItem.task.includes("RESTART_PERSISTENCE_SMOKE"), "History title should persist after restart") - const conversationLength = await api.getTaskApiConversationHistoryLength(taskId) + const conversationLength = await waitForApiConversationHistoryLength(api, taskId) assert.ok(conversationLength > 0, "API conversation history should be available after restart") await api.resumeTask(taskId) From 6a769b5cb61af2febdf7ee335dfc5bbaf41f8b6e Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 8 Sep 2026 10:56:52 +0200 Subject: [PATCH 15/39] share one model-info snapshot per request between prompt and tools --- src/core/task/Task.ts | 69 ++++--- src/core/task/__tests__/Task.spec.ts | 277 +++++++++++++++++++++++++-- 2 files changed, 299 insertions(+), 47 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 9f4025a62b..90bf2cbbd0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1860,11 +1860,13 @@ export class Task extends EventEmitter implements TaskLike { // to ensure tool_use/tool_result pairs are complete in history await this.flushPendingToolResultsToHistory() - // Capture provider state once and thread it into getSystemPrompt so the - // prompt and the condensing tool array below resolve from one snapshot. + // Capture provider state and one model-info snapshot once and thread them into + // getSystemPrompt so the prompt and the condensing tool array below resolve + // from one snapshot. const state = await this.providerRef.deref()?.getState() + const requestModelInfo = await this.safeEnsureModelFetched() - const systemPrompt = await this.getSystemPrompt(state) + const systemPrompt = await this.getSystemPrompt(state, requestModelInfo) // Get condensing configuration const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE @@ -1878,7 +1880,6 @@ export class Task extends EventEmitter implements TaskLike { 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, @@ -1887,7 +1888,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, disabledTools: state?.disabledTools, - modelInfo, + modelInfo: requestModelInfo, includeAllToolsWithRestrictions: false, }) allTools = toolsResult.tools @@ -4177,9 +4178,15 @@ export class Task extends EventEmitter implements TaskLike { * same request must pass their state snapshot as `requestState` so the prompt * and the tool array are resolved from one consistent snapshot - otherwise a * settings change during the MCP wait can make the prompt advertise a tool the - * runtime rejects, or hide a callable tool. + * runtime rejects, or hide a callable tool. Pass `requestModelInfo` (captured + * via safeEnsureModelFetched) in the same situation so the prompt's tool + * guidance and the request's tool arrays resolve from one model-metadata + * snapshot. */ - private async getSystemPrompt(requestState?: SystemPromptRequestState): Promise { + private async getSystemPrompt( + requestState?: SystemPromptRequestState, + requestModelInfo?: ModelInfo, + ): Promise { const { mcpEnabled } = requestState ?? (await this.providerRef.deref()?.getState()) ?? {} let mcpHub: McpHub | undefined if (mcpEnabled ?? true) { @@ -4221,9 +4228,8 @@ export class Task extends EventEmitter implements TaskLike { // Load dynamically discovered model metadata (router providers) before // reading it, so the prompt's included/excluded tool guidance matches - // the runtime path, which fetches before tool construction. - await this.safeEnsureModelFetched() - const modelInfo = this.api.getModel().info + // the runtime path; prefer the caller's per-request snapshot when threaded. + const modelInfo = requestModelInfo ?? (await this.safeEnsureModelFetched()) return SYSTEM_PROMPT( provider.context, @@ -4270,18 +4276,20 @@ export class Task extends EventEmitter implements TaskLike { * than aborting the task; the wait is bounded by MODEL_FETCH_TIMEOUT_MS (see the * constant for the abandonment and degradation semantics). * - * The prompt- and context-critical read sites of getModel() (streaming entry, - * getSystemPrompt, context-window handling) each await this immediately before - * reading; the condense/tool-array read sites are covered by the immediately - * preceding getSystemPrompt guard in the same request plus the router provider's - * success cache. That redundancy is deliberate: router-provider caches successes - * (repeat awaits are no-ops) but caches no failures, so a failing endpoint costs - * one retry per guard. The per-site invariant was chosen over fetch-ordering - * coupling between methods; negative caching in RouterProvider (recording failed - * fetches with a TTL) remains future work if the failure-path latency ever - * matters. + * The return value is the settled post-wait read of getModel().info: request + * entry points (attemptApiRequest, condenseContext, + * handleContextWindowExceededError) await this once before prompt generation + * and share the returned snapshot between getSystemPrompt and every tool-array + * build of the same request, so a fetch that resolves after the bounded wait + * was abandoned cannot move model-specific tool policy between prompt time and + * request time. Callers that do not thread a snapshot keep awaiting this + * immediately before their own getModel() read; that per-site guard remains the + * standalone/fallback read path, and repeat awaits stay cheap once a fetch has + * succeeded because the provider caches successes. RouterProvider already + * negative-caches catalog misses with a TTL (missingModelRefreshAt); recording + * rejected fetches remains future work if the failure-path latency ever matters. */ - private async safeEnsureModelFetched(): Promise { + private async safeEnsureModelFetched(): Promise { // Promise.race attaches handlers to both inputs, so the abandoned fetch // rejecting after the timeout is already considered handled — no extra // .catch is needed here. @@ -4312,6 +4320,8 @@ export class Task extends EventEmitter implements TaskLike { clearTimeout(timeoutId) } } + // The post-wait read is the settled snapshot callers must share per request. + return this.api.getModel().info } private async handleContextWindowExceededError(): Promise { @@ -4322,8 +4332,7 @@ export class Task extends EventEmitter implements TaskLike { const apiConfiguration = this.apiConfiguration const { contextTokens } = this.getTokenUsage() - await this.safeEnsureModelFetched() - const modelInfo = this.api.getModel().info + const modelInfo = await this.safeEnsureModelFetched() const maxTokens = getModelMaxOutputTokens({ modelId: this.api.getModel().id, @@ -4397,7 +4406,7 @@ export class Task extends EventEmitter implements TaskLike { apiHandler: this.api, autoCondenseContext: true, autoCondenseContextPercent: FORCED_CONTEXT_REDUCTION_PERCENT, - systemPrompt: await this.getSystemPrompt(state), + systemPrompt: await this.getSystemPrompt(state, modelInfo), taskId: this.taskId, profileThresholds, currentProfileId, @@ -4521,7 +4530,10 @@ export class Task extends EventEmitter implements TaskLike { // Thread the request state snapshot into prompt generation so the prompt // and the runtime tools (built below from the same `state`) stay aligned // even if settings change while this method waits on MCP or rate limits. - const systemPrompt = await this.getSystemPrompt(state) + // Capture one bounded-wait model-info snapshot per request, shared by the + // prompt and every tool array built below. + const requestModelInfo = await this.safeEnsureModelFetched() + const systemPrompt = await this.getSystemPrompt(state, requestModelInfo) const { contextTokens } = this.getTokenUsage() if (contextTokens) { @@ -4588,7 +4600,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, disabledTools: state?.disabledTools, - modelInfo, + modelInfo: requestModelInfo, includeAllToolsWithRestrictions: false, }) contextMgmtTools = toolsResult.tools @@ -4727,8 +4739,9 @@ 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 + // Tool policy resolves from the same model-info snapshot as the system prompt + // built earlier in this request, not from a fresh getModel() re-read. + const modelInfo = requestModelInfo // Build complete tools array: native tools + dynamic MCP tools // When includeAllToolsWithRestrictions is true, returns all tools but provides diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 6951cad7b5..77dfb8fbc4 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -30,7 +30,7 @@ import type { ApiMessage } from "../../task-persistence" import { asyncStreamFrom } from "../../../test-utils/stream" type TaskTestAccess = { - getSystemPrompt: (requestState?: unknown) => Promise + getSystemPrompt: (requestState?: unknown, requestModelInfo?: unknown) => Promise handleContextWindowExceededError: () => Promise getEnabledMcpToolsCount: () => Promise<{ enabledToolCount: number; enabledServerCount: number }> initiateTaskLoop: (userContent: Anthropic.Messages.ContentBlockParam[]) => Promise @@ -40,7 +40,7 @@ type TaskTestAccess = { addToClineMessages: (message: import("@roo-code/types").ClineMessage) => Promise updateClineMessage: (message: import("@roo-code/types").ClineMessage) => Promise saveClineMessages: () => Promise - safeEnsureModelFetched: () => Promise + safeEnsureModelFetched: () => Promise addToApiConversationHistory: (message: unknown, reasoning?: string) => Promise resetAssistantMessagePersistence: () => void } @@ -277,6 +277,14 @@ const mockMessages = [ }, ] +// Model-info stand-in for tests that stub safeEnsureModelFetched and only need +// the settled snapshot to be a defined ModelInfo. +const stubModelInfo: ModelInfo = { + contextWindow: 200_000, + maxTokens: 4096, + supportsPromptCache: true, +} + describe("Cline", () => { let mockProvider: ClineProvider let mockApiConfig: ProviderSettings @@ -521,7 +529,7 @@ describe("Cline", () => { for (const task of [firstTask, secondTask]) { vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) - vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) } vi.spyOn(firstTask, "attemptApiRequest").mockImplementation(() => firstStream()) @@ -582,7 +590,7 @@ describe("Cline", () => { }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) - vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) const firstStream = async function* (): AsyncGenerator { @@ -630,7 +638,7 @@ describe("Cline", () => { }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) - vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) vi.spyOn(task, "attemptApiRequest").mockImplementation(() => asyncStreamFrom([ @@ -876,6 +884,36 @@ describe("Cline", () => { expect(systemPromptCall[17]).toBe(fetchedInfo) }) + it("uses the threaded model-info snapshot and skips the fetch guard when one is provided", async () => { + // A threaded snapshot replaces the per-call guard entirely: the prompt + // must be built from the caller's snapshot without touching + // ensureModelFetched or the handler's current model info. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + + const handlerInfo: ModelInfo = { contextWindow: 100_000, supportsPromptCache: false } + const threadedInfo: ModelInfo = { contextWindow: 64_000, supportsPromptCache: true, excludedTools: [] } + const ensureModelFetched = vi.fn(async () => {}) + Object.assign(task.api, { ensureModelFetched }) + vi.spyOn(task.api, "getModel").mockReturnValue({ id: "threaded-model", info: handlerInfo }) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await expect(getTaskTestAccess(task).getSystemPrompt(undefined, threadedInfo)).resolves.toBe( + "mock system prompt", + ) + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 17 is modelInfo: the threaded snapshot, not the handler's. + expect(systemPromptCall[17]).toBe(threadedInfo) + expect(ensureModelFetched).not.toHaveBeenCalled() + }) + it("threads the request state snapshot into the system prompt when provider state changes mid-request", async () => { // attemptApiRequest captures provider state, then getSystemPrompt waits // on MCP initialization. A settings change during that window must NOT @@ -969,9 +1007,11 @@ describe("Cline", () => { await task.condenseContext() - // Reference equality: without threading, the argument would be - // undefined and getSystemPrompt would re-read the divergent state. - expect(getSystemPromptSpy).toHaveBeenCalledWith(snapshot) + // Reference equality: without threading, the arguments would be + // undefined and getSystemPrompt would re-read the divergent state and + // re-guard the model metadata. The second argument must be the handler's + // own settled snapshot, not just any object. + expect(getSystemPromptSpy).toHaveBeenCalledWith(snapshot, task.api.getModel().info) }) it("threads the captured state snapshot into the system prompt when the context window is exceeded", async () => { @@ -1003,9 +1043,10 @@ describe("Cline", () => { totalTokensOut: 0, contextTokens: 100_000, }) + const ctxModelInfo: ModelInfo = { contextWindow: 50_000, maxTokens: 1024, supportsPromptCache: false } vi.spyOn(task.api, "getModel").mockReturnValue({ id: "ctx-model", - info: { contextWindow: 50_000, maxTokens: 1024, supportsPromptCache: false } as ModelInfo, + info: ctxModelInfo, }) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) @@ -1017,9 +1058,11 @@ describe("Cline", () => { await getTaskTestAccess(task).handleContextWindowExceededError() - // Reference equality: without threading, the argument would be - // undefined and getSystemPrompt would re-read the divergent state. - expect(getSystemPromptSpy).toHaveBeenCalledWith(snapshot) + // Reference equality: without threading, the arguments would be + // undefined and getSystemPrompt would re-read the divergent state and + // the model info; the second argument must be the same settled snapshot + // the handler exposes. + expect(getSystemPromptSpy).toHaveBeenCalledWith(snapshot, ctxModelInfo) }) it("uses the task mode when manually condensing after focused state changes", async () => { @@ -1106,7 +1149,9 @@ describe("Cline", () => { await expect(task.condenseContext()).resolves.toBeUndefined() - expect(getSystemPromptSpy).toHaveBeenCalledWith(undefined) + // The state snapshot stays undefined for a gone provider; the model-info + // snapshot is still captured from the task's own api handler. + expect(getSystemPromptSpy).toHaveBeenCalledWith(undefined, task.api.getModel().info) expect(overwriteSpy).toHaveBeenCalledTimes(1) }) @@ -3634,9 +3679,11 @@ describe("Cline", () => { const ensureModelFetched = vi.fn().mockRejectedValue(new Error("network down")) Object.assign(task.api, { ensureModelFetched }) + const expectedInfo = task.api.getModel().info const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBeUndefined() + // A swallowed failure still returns the handler's settled fallback info. + await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBe(expectedInfo) expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Failed to fetch model metadata"), @@ -3652,7 +3699,9 @@ describe("Cline", () => { startTask: false, }) - await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBeUndefined() + const expectedInfo = task.api.getModel().info + + await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBe(expectedInfo) }) it("settles at the bound when ensureModelFetched never resolves", async () => { @@ -3667,6 +3716,7 @@ describe("Cline", () => { }) Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) + const expectedInfo = task.api.getModel().info const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) vi.useFakeTimers() @@ -3674,7 +3724,8 @@ describe("Cline", () => { const settled = getTaskTestAccess(task).safeEnsureModelFetched() await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) - await expect(settled).resolves.toBeUndefined() + // The timeout path returns the handler's settled fallback snapshot. + await expect(settled).resolves.toBe(expectedInfo) } finally { vi.useRealTimers() } @@ -3725,7 +3776,7 @@ describe("Cline", () => { totalTokensOut: 0, contextTokens: 50_000, }) - const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(undefined) + const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { @@ -3893,11 +3944,12 @@ describe("Cline", () => { startTask: false, }) + const expectedInfo = task.api.getModel().info const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // A missing optional fetcher is the normal case for static providers, // so the call must resolve quietly instead of surfacing a caught TypeError. - await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBeUndefined() + await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBe(expectedInfo) expect(errorSpy.mock.calls.flat().join(" ")).not.toContain("Failed to fetch model metadata") }) @@ -3933,6 +3985,7 @@ describe("Cline", () => { }) Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) + const expectedInfo = task.api.getModel().info const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) vi.useFakeTimers() @@ -3947,7 +4000,7 @@ describe("Cline", () => { await vi.advanceTimersByTimeAsync(1) expect(warnSpy).toHaveBeenCalledTimes(1) - await expect(settled).resolves.toBeUndefined() + await expect(settled).resolves.toBe(expectedInfo) } finally { vi.useRealTimers() } @@ -4001,6 +4054,192 @@ describe("Cline", () => { vi.unstubAllGlobals() } }) + + it("keeps prompt and request tools on one snapshot when the fetch stalls past the bound and resolves late", async () => { + // Stalled-then-late fetch: the entry snapshot times out at + // MODEL_FETCH_TIMEOUT_MS and captures fallback metadata; the fetch + // then resolves while the request is still being built. The prompt + // and every tool array of this request must both come from the same + // (fallback) snapshot, even though the handler's model info has + // already flipped to the loaded metadata with different exclusions. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + + const fallbackInfo: ModelInfo = { contextWindow: 32_000, supportsPromptCache: false } + const loadedInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: true, + // Divergent tool policy: only the loaded metadata excludes it. + excludedTools: ["read_file"], + } + const fetchState = { resolved: false } + let resolveFetch!: () => void + const metadataFetch = new Promise((resolve) => { + resolveFetch = () => { + fetchState.resolved = true + resolve() + } + }) + Object.assign(task.api, { ensureModelFetched: () => metadataFetch }) + vi.spyOn(task.api, "getModel").mockImplementation(() => ({ + id: "lazy-router-model", + info: fetchState.resolved ? loadedInfo : fallbackInfo, + })) + + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + // Far above allowedTokens under either snapshot (32k or 128k window), + // so the request-scoped condense sub-call always runs and its metadata + // tools are observable on the mocked summarizeConversation. + contextTokens: 500_000, + }) + vi.spyOn(task.api, "countTokens").mockResolvedValue(1_000) + const createMessageSpy = vi + .spyOn(task.api, "createMessage") + .mockReturnValue(asyncStreamFrom([{ type: "text", text: "ok" }])) + // Hold the prompt build open so the late flip lands after the request + // snapshot was captured but before any tool array is built: a flip + // between those points must not move either consumer. + let releasePrompt!: () => void + const promptGate = new Promise((resolve) => { + releasePrompt = () => resolve("mock system prompt") + }) + vi.mocked(SYSTEM_PROMPT).mockImplementationOnce(() => promptGate) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + + // The summarizeConversation module mock is never cleared, so pin the + // call count this request starts from. + const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length + + vi.useFakeTimers() + try { + const first = task.attemptApiRequest(0).next() + // The entry snapshot times out and resolves with fallback info; + // the prompt is then built from it and suspends on the gate. + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) + // Late success flips the handler's metadata mid-request. + resolveFetch() + releasePrompt() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + await expect(first).resolves.toMatchObject({ done: false, value: { type: "text", text: "ok" } }) + } finally { + vi.useRealTimers() + } + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 17 is modelInfo: the prompt used the captured snapshot. + expect(systemPromptCall[17]).toBe(fallbackInfo) + const [, , metadata] = requireDefined(createMessageSpy.mock.calls[0]) + // Indexed-access type keeps the helper import-free (the spec does not + // import the OpenAI types) and metadata itself stays possibly-undefined. + type MetadataTools = NonNullable["tools"] + const toolNames = (tools: MetadataTools): string[] => + requireDefined(tools).map((tool) => { + if (tool.type !== "function") { + throw new Error(`Unexpected tool type: ${tool.type}`) + } + return tool.function.name + }) + // The request's tools were built from the same fallback snapshot, so + // the tool the loaded metadata excludes is still declared. + expect(toolNames(metadata?.tools)).toContain("read_file") + + // The condense sub-call of this same request carries the same guarantee: + // manageContext forwards its metadata verbatim into summarizeConversation, + // so that array is the one built at the context-management tool site. + expect(summarizeConversation).toHaveBeenCalledTimes(summarizeCallsBefore + 1) + const [condenseOptions] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) + expect(condenseOptions.isAutomaticTrigger).toBe(true) + // Reverting that build to a fresh guarded re-read (like the per-site + // guard at the top of this block) would pick up the flipped (loaded) + // metadata and drop read_file from this array. + expect(toolNames(condenseOptions.metadata?.tools)).toContain("read_file") + }) + + it("keeps manual condense prompt and tools on one snapshot when the fetch resolves late", async () => { + // condenseContext twin of the stalled-fetch regression: the prompt and + // the condensing metadata's tool array must resolve from the single + // snapshot captured before prompt generation. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + + const fallbackInfo: ModelInfo = { contextWindow: 32_000, supportsPromptCache: false } + const loadedInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: true, + excludedTools: ["read_file"], + } + const fetchState = { resolved: false } + let resolveFetch!: () => void + const metadataFetch = new Promise((resolve) => { + resolveFetch = () => { + fetchState.resolved = true + resolve() + } + }) + Object.assign(task.api, { ensureModelFetched: () => metadataFetch }) + vi.spyOn(task.api, "getModel").mockImplementation(() => ({ + id: "lazy-router-model", + info: fetchState.resolved ? loadedInfo : fallbackInfo, + })) + // Hold the prompt build open so the test can flip the handler's + // metadata after the snapshot is captured but before the tools are + // built: a flip between those points must not move either consumer. + let releasePrompt!: () => void + const promptGate = new Promise((resolve) => { + releasePrompt = () => resolve("mock system prompt") + }) + vi.mocked(SYSTEM_PROMPT).mockReturnValueOnce(promptGate) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const condensing = task.condenseContext() + // The entry snapshot times out with fallback info and the prompt + // build suspends on the gate. + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) + // Late success lands after the snapshot but before the tools. + resolveFetch() + releasePrompt() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + await condensing + } finally { + vi.useRealTimers() + } + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 17 is modelInfo: the prompt used the captured snapshot. + expect(systemPromptCall[17]).toBe(fallbackInfo) + const [options] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) + const toolNames = requireDefined(options.metadata?.tools).map((tool) => { + if (tool.type !== "function") { + throw new Error(`Unexpected tool type: ${tool.type}`) + } + return tool.function.name + }) + // Same fallback snapshot: the loaded metadata's exclusion never applied. + expect(toolNames).toContain("read_file") + }) }) describe("startTask", () => { From 92c6f322eb9ad61b3b06e45c8b8df8ae8c5d90b2 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 8 Sep 2026 13:19:40 +0200 Subject: [PATCH 16/39] resolve provider state once before the MCP wait getSystemPrompt read provider state twice: once for the MCP gate and again after the hub wait. When the caller threaded no state, the two reads could observe different snapshots. Hoist the fallback resolution to the top of the call so the prompt and the tool guidance share one snapshot on the unthreaded path. Type the test harness getSystemPrompt signature with ProviderState and ModelInfo instead of unknown, and align the affected test title and comments with the single-read behavior. --- src/core/task/Task.ts | 8 ++++--- src/core/task/__tests__/Task.spec.ts | 34 ++++++++++++---------------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 90bf2cbbd0..991e8e1e08 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4187,7 +4187,11 @@ export class Task extends EventEmitter implements TaskLike { requestState?: SystemPromptRequestState, requestModelInfo?: ModelInfo, ): Promise { - const { mcpEnabled } = requestState ?? (await this.providerRef.deref()?.getState()) ?? {} + // Resolve the fallback state once, before the MCP wait, so the whole call - + // prompt and tool guidance alike - sees one snapshot even when the caller + // threaded none. + const state = requestState ?? (await this.providerRef.deref()?.getState()) + const { mcpEnabled } = state ?? {} let mcpHub: McpHub | undefined if (mcpEnabled ?? true) { const provider = this.providerRef.deref() @@ -4211,8 +4215,6 @@ export class Task extends EventEmitter implements TaskLike { const rooIgnoreInstructions = this.rooIgnoreController?.getInstructions() - const state = requestState ?? (await this.providerRef.deref()?.getState()) - const { customModes, customModePrompts, customInstructions, experiments, language, enableSubfolderRules } = state ?? {} // Use task-local values, not provider state, to prevent cross-task configuration leaks. diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 77dfb8fbc4..6f9bb8ddfc 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -30,7 +30,7 @@ import type { ApiMessage } from "../../task-persistence" import { asyncStreamFrom } from "../../../test-utils/stream" type TaskTestAccess = { - getSystemPrompt: (requestState?: unknown, requestModelInfo?: unknown) => Promise + getSystemPrompt: (requestState?: ProviderState, requestModelInfo?: ModelInfo) => Promise handleContextWindowExceededError: () => Promise getEnabledMcpToolsCount: () => Promise<{ enabledToolCount: number; enabledServerCount: number }> initiateTaskLoop: (userContent: Anthropic.Messages.ContentBlockParam[]) => Promise @@ -782,7 +782,7 @@ describe("Cline", () => { expect(settings).toMatchObject({ todoListEnabled: true }) }) - it("passes undefined disabledTools when provider state becomes unavailable", async () => { + it("passes undefined disabledTools when provider state carries none", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -791,17 +791,11 @@ describe("Cline", () => { }) await task.getTaskMode() - // First getState call: MCP disabled (avoids the MCP hub path). Later - // calls (including the state read feeding `state?.disabledTools`): - // undefined, modeling a provider that went unavailable mid-request; - // production reads guard with `state ?? {}`. Dropping the optional - // chain on that read makes getSystemPrompt reject with a TypeError - // instead of resolving. + // The single mcpEnabled:false gate read returns the full double, skipping + // the MCP-hub path; its disabledTools is undefined, so the prompt call + // receives undefined for that argument. Object.assign(mockProvider, { - getState: vi - .fn<() => Promise>() - .mockResolvedValueOnce(providerStateWith()) - .mockResolvedValue(undefined), + getState: vi.fn<() => Promise>().mockResolvedValueOnce(providerStateWith()), }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") @@ -821,11 +815,12 @@ describe("Cline", () => { }) await task.getTaskMode() - // First getState call: MCP disabled (avoids the MCP hub path). Later - // calls supply the state that feeds `state?.disabledTools`. - vi.spyOn(mockProvider, "getState") - .mockResolvedValueOnce(providerStateWith()) - .mockResolvedValue(providerStateWith({ disabledTools: ["execute_command"] })) + // getSystemPrompt resolves provider state once, before the MCP wait; + // that snapshot feeds `state?.disabledTools`. mcpEnabled stays false + // so the MCP-hub path is skipped. + vi.spyOn(mockProvider, "getState").mockResolvedValue( + providerStateWith({ disabledTools: ["execute_command"] }), + ) const modelInfo: ModelInfo = { contextWindow: 128_000, @@ -1185,8 +1180,9 @@ describe("Cline", () => { }) await task.getTaskMode() - // The ref dies after the MCP-gate state read and before the - // snapshot read, so only the provider guard can answer. + // The opening state read performs the only deref that finds the provider + // alive - it flips the mock's liveness flag - so the provider guard at the + // top of the prompt-building closure is what answers for the dead ref. let providerAlive = true Object.defineProperty(task, "providerRef", { value: { From 2ce570a3db480953110809f0e789a7b3758c581b Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 8 Sep 2026 14:02:48 +0200 Subject: [PATCH 17/39] cover the undefined provider state path in the system prompt tests The provider state read can resolve to nothing even while the provider reference stays alive. Add a test for that case so the system prompt call keeps receiving undefined disabledTools instead of failing. --- src/core/task/__tests__/Task.spec.ts | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 6f9bb8ddfc..3dc0f1fe16 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -28,6 +28,8 @@ import { processUserContentMentions } from "../../mentions/processUserContentMen import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" import type { ApiMessage } from "../../task-persistence" import { asyncStreamFrom } from "../../../test-utils/stream" +import { McpHub } from "../../../services/mcp/McpHub" +import { McpServerManager } from "../../../services/mcp/McpServerManager" type TaskTestAccess = { getSystemPrompt: (requestState?: ProviderState, requestModelInfo?: ModelInfo) => Promise @@ -806,6 +808,40 @@ describe("Cline", () => { expect(systemPromptCall[16]).toBeUndefined() }) + it("passes undefined disabledTools to the system prompt when the provider state read resolves nothing", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // The provider reference stays alive but its state read resolves nothing: + // the prompt path must still deliver undefined disabledTools rather than + // fail. Unlike the collected-provider cases, nothing here overrides + // providerRef, so every deref keeps finding the provider. + Object.assign(mockProvider, { + getState: vi.fn<() => Promise>().mockResolvedValue(undefined), + }) + // An unavailable state leaves the mcpEnabled gate open, so the hub branch + // runs; the spy also witnesses that the branch really was taken. The + // awaited connect wait is a no-op under this file's p-wait-for mock, so + // the hub double needs no members. + const hubSpy = vi.spyOn(McpServerManager, "getInstance") + hubSpy.mockResolvedValue(Object.create(McpHub.prototype)) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await expect(getTaskTestAccess(task).getSystemPrompt()).resolves.toBe("mock system prompt") + + expect(hubSpy).toHaveBeenCalledTimes(1) + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 16 is the disabledTools parameter fed by `state?.disabledTools`. + expect(systemPromptCall[16]).toBeUndefined() + + hubSpy.mockRestore() + }) + it("forwards non-empty disabledTools and modelInfo to the system prompt call", async () => { const task = new Task({ provider: mockProvider, From ac35870a5e70f2ba02bf197f8efdbbb09956dde7 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 8 Sep 2026 17:26:28 +0200 Subject: [PATCH 18/39] reuse one model-info snapshot per request and honor cancellation Request construction re-read model metadata twice after the streaming turn's bounded fetch; thread the captured snapshot through attemptApiRequest so prompt assembly, context sizing, and tool arrays agree on a single view, resolving the fallback only when no snapshot was supplied. A cancellation that lands during a request's waits now stops the request before any tool array, abort controller, or provider call is issued for it. --- src/core/task/Task.ts | 27 +++-- src/core/task/__tests__/Task.spec.ts | 153 ++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 7 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 991e8e1e08..e26f854aa1 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3211,7 +3211,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, + requestModelInfo: streamModelInfo, + }) let assistantMessage = "" let reasoningMessage = "" const pendingGroundingSources: GroundingSource[] = [] @@ -4498,7 +4501,7 @@ export class Task extends EventEmitter implements TaskLike { public async *attemptApiRequest( retryAttempt: number = 0, - options: { skipProviderRateLimit?: boolean } = {}, + options: { skipProviderRateLimit?: boolean; requestModelInfo?: ModelInfo } = {}, ): ApiStream { const state = await this.providerRef.deref()?.getState() @@ -4533,14 +4536,26 @@ export class Task extends EventEmitter implements TaskLike { // and the runtime tools (built below from the same `state`) stay aligned // even if settings change while this method waits on MCP or rate limits. // Capture one bounded-wait model-info snapshot per request, shared by the - // prompt and every tool array built below. - const requestModelInfo = await this.safeEnsureModelFetched() + // prompt and every tool array built below; prefer the caller's snapshot + // when one was threaded. + const requestModelInfo = options.requestModelInfo ?? (await this.safeEnsureModelFetched()) const systemPrompt = await this.getSystemPrompt(state, requestModelInfo) + + // A cancellation landing during the rate-limit countdown, the bounded metadata + // wait, or the MCP wait inside getSystemPrompt must stop this request before any + // tool array, AbortController, or createMessage call is issued for it. + if (this.abort || this.abandoned) { + throw new Error( + `[Task#attemptApiRequest] task ${this.taskId}.${this.instanceId} aborted during request construction`, + ) + } + const { contextTokens } = this.getTokenUsage() if (contextTokens) { - await this.safeEnsureModelFetched() - const modelInfo = this.api.getModel().info + // Context sizing resolves from the same model-info snapshot as the prompt and + // every tool array of this request, not from a fresh getModel() re-read. + const modelInfo = requestModelInfo const maxTokens = getModelMaxOutputTokens({ modelId: this.api.getModel().id, diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 3dc0f1fe16..d5d48cac0a 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -3793,6 +3793,63 @@ describe("Cline", () => { expect(vi.mocked(SYSTEM_PROMPT).mock.calls.length).toBe(callsBefore + 1) }) + it("refuses to send a request when the task is cancelled during the bounded metadata wait", async () => { + // Cancellation must be honored before any provider-visible work of + // the request: an abort landing while the metadata wait is pending + // rejects the generator instead of quietly sending a request the + // user already cancelled. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + vi.spyOn(task, "dispose").mockResolvedValue(undefined) + // The wait never settles on its own; only the bound expires it. + Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) + const createMessageSpy = vi + .spyOn(task.api, "createMessage") + .mockReturnValue(asyncStreamFrom([{ type: "text", text: "ok" }])) + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 0, + }) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const first = task.attemptApiRequest(0).next() + // Observe the rejection the moment it can land: the generator + // rejects during the timer advance below, before the assertion + // line runs, and an unobserved rejection would surface as an + // unhandled rejection independent of the awaited assertion. + void first.catch(() => {}) + await vi.advanceTimersByTimeAsync(0) + // The user cancels while the bounded metadata wait is still pending. + const cancelling = task.abortTask() + // The wait itself still expires at the bound, as it normally would. + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + + await expect(first).rejects.toThrow(/aborted during request construction/) + expect(createMessageSpy).not.toHaveBeenCalled() + // The per-request controller is only created once the request is + // committed, so a cancelled construction never reaches it. + expect(task.currentRequestAbortController).toBeUndefined() + await cancelling + } finally { + vi.useRealTimers() + } + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) + }) + it("calls safeEnsureModelFetched from attemptApiRequest when context tokens are present", async () => { const task = new Task({ provider: mockProvider, @@ -3932,7 +3989,7 @@ describe("Cline", () => { }) const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") const resetPersistenceSpy = vi.spyOn(getTaskTestAccess(task), "resetAssistantMessagePersistence") - vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { + const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { throw new Error("stop after model metadata fetch") }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) @@ -3965,6 +4022,12 @@ describe("Cline", () => { expect(safeSpy).toHaveBeenCalled() expect(resetPersistenceSpy).toHaveBeenCalledTimes(1) expect(ensureModelFetched).toHaveBeenCalled() + // Exact-object match on purpose: a partial matcher would stop pinning the + // options literal the streaming loop passes to attemptApiRequest. + expect(attemptApiRequestSpy).toHaveBeenCalledWith(0, { + skipProviderRateLimit: true, + requestModelInfo: task.cachedStreamingModel?.info, + }) expect(task.cachedStreamingModel?.id).toBe(mockApiConfig.apiModelId) }) @@ -4272,6 +4335,94 @@ describe("Cline", () => { // Same fallback snapshot: the loaded metadata's exclusion never applied. expect(toolNames).toContain("read_file") }) + + it("uses the caller's model-info snapshot for both the prompt and context sizing", async () => { + // A streaming turn captures its model-info snapshot before opening + // the request; attemptApiRequest must reuse it for the prompt, the + // context-window sizing, and every tool array, so a metadata fetch + // that lands mid-request cannot re-decide whether condensing runs. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + + const threadedInfo: ModelInfo = { contextWindow: 32_000, supportsPromptCache: false } + const lateInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: true, + // Divergent policy: only the late metadata excludes it. + excludedTools: ["read_file"], + } + const fetchState = { resolved: false } + let resolveFetch!: () => void + const metadataFetch = new Promise((resolve) => { + resolveFetch = () => { + fetchState.resolved = true + resolve() + } + }) + Object.assign(task.api, { ensureModelFetched: () => metadataFetch }) + vi.spyOn(task.api, "getModel").mockImplementation(() => ({ + id: "lazy-router-model", + info: fetchState.resolved ? lateInfo : threadedInfo, + })) + + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + // Above the hard limit for the 32k threaded window (~24.7k tokens) + // but well below the limit for a 128k re-read (~111k), so whether + // condensing runs exposes which snapshot the sizing resolved from. + contextTokens: 50_000, + }) + vi.spyOn(task.api, "countTokens").mockResolvedValue(1_000) + vi.spyOn(task.api, "createMessage").mockReturnValue( + asyncStreamFrom([{ type: "text", text: "ok" }]), + ) + const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") + // Hold the prompt build open so the metadata fetch can land after the + // snapshot was captured but before context sizing runs. + let releasePrompt!: () => void + const promptGate = new Promise((resolve) => { + releasePrompt = () => resolve("mock system prompt") + }) + vi.mocked(SYSTEM_PROMPT).mockImplementationOnce(() => promptGate) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + + // The summarizeConversation module mock is never cleared, so pin the + // call count this request starts from. + const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length + + vi.useFakeTimers() + try { + const first = task.attemptApiRequest(0, { requestModelInfo: threadedInfo }).next() + await vi.advanceTimersByTimeAsync(0) + // Late metadata arrives while the request is still being built; a + // fresh re-read here would return the wider 128k window instead. + resolveFetch() + releasePrompt() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + await expect(first).resolves.toMatchObject({ done: false, value: { type: "text", text: "ok" } }) + } finally { + vi.useRealTimers() + } + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 17 is modelInfo: the prompt used the caller's snapshot. + expect(systemPromptCall[17]).toBe(threadedInfo) + // The threaded snapshot replaces the per-request guard entirely. + expect(safeSpy).not.toHaveBeenCalled() + // Condensing ran because sizing resolved from the 32k threaded + // window; a 128k re-read would have cleared the threshold instead. + expect(summarizeConversation).toHaveBeenCalledTimes(summarizeCallsBefore + 1) + }) }) describe("startTask", () => { From b75e73b3b2296aa3104eb3a00aac20af2fdfee04 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 8 Sep 2026 18:09:39 +0200 Subject: [PATCH 19/39] pin the retry count the request seam receives The empty-response retry test now asserts that the retry iteration reaches attemptApiRequest with its own incremented attempt count (second call, retryAttempt 1), instead of only checking the resulting conversation history. --- src/core/task/__tests__/Task.spec.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index d5d48cac0a..7d267c49a1 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -454,7 +454,8 @@ describe("Cline", () => { let retryUserMessageCount: number | undefined vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) - vi.spyOn(task, "attemptApiRequest") + const attemptApiRequestSpy = vi + .spyOn(task, "attemptApiRequest") .mockImplementationOnce(() => stream([])) .mockImplementationOnce(() => { retryHistory = structuredClone(task.apiConversationHistory) @@ -468,6 +469,14 @@ describe("Cline", () => { await task.recursivelyMakeClineRequests([{ type: "text", text: "original user request" }]) expect(retryHistory).toHaveLength(1) + // The retry iteration must reach the request seam with its own incremented + // retry count; passing the initial-attempt value instead would make retries + // indistinguishable from the first attempt downstream. + expect(attemptApiRequestSpy).toHaveBeenNthCalledWith( + 2, + 1, + expect.objectContaining({ skipProviderRateLimit: true }), + ) expect(retryHistory?.[0]).toMatchObject({ role: "user", content: expect.arrayContaining([expect.objectContaining({ text: "original user request" })]), From b1236cd71ceaea0486a4d4b72855f0d8c9f6f2e0 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 8 Sep 2026 20:24:13 +0200 Subject: [PATCH 20/39] refactor(task): require callers to thread provider state into system prompt build getSystemPrompt no longer falls back to re-reading provider state; the provider-state snapshot parameter is now required. An explicit undefined declares that the caller's own read came back empty because the provider was already gone, and the prompt then resolves from defaults. The prompt and the request's runtime tool array now resolve from a single snapshot by construction rather than by caller convention. Behavior is unchanged on all reachable paths. Task.spec.ts grows from 128 to 129 tests to cover the required-parameter contract. --- src/core/task/Task.ts | 30 +++--- src/core/task/__tests__/Task.spec.ts | 134 ++++++++++++++++----------- 2 files changed, 95 insertions(+), 69 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e26f854aa1..0573b1741e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4177,24 +4177,22 @@ export class Task extends EventEmitter implements TaskLike { } /** - * Builds the SYSTEM_PROMPT. Callers that also construct runtime tools for the - * same request must pass their state snapshot as `requestState` so the prompt - * and the tool array are resolved from one consistent snapshot - otherwise a - * settings change during the MCP wait can make the prompt advertise a tool the - * runtime rejects, or hide a callable tool. Pass `requestModelInfo` (captured - * via safeEnsureModelFetched) in the same situation so the prompt's tool - * guidance and the request's tool arrays resolve from one model-metadata - * snapshot. + * Builds the SYSTEM_PROMPT from the caller's provider-state snapshot. This + * method never reads provider state itself: callers that also construct + * runtime tools for the same request must thread the very snapshot they build + * those tools from, or a settings change during the MCP wait can make the + * prompt advertise a tool the runtime rejects, or hide a callable tool. An + * `undefined` snapshot declares that the caller's own read came back empty + * because the provider was already gone; the prompt then resolves from + * defaults. Pass `requestModelInfo` (captured via safeEnsureModelFetched) in + * the same situation so the prompt's tool guidance and the request's tool + * arrays resolve from one model-metadata snapshot. */ private async getSystemPrompt( - requestState?: SystemPromptRequestState, + requestState: SystemPromptRequestState | undefined, requestModelInfo?: ModelInfo, ): Promise { - // Resolve the fallback state once, before the MCP wait, so the whole call - - // prompt and tool guidance alike - sees one snapshot even when the caller - // threaded none. - const state = requestState ?? (await this.providerRef.deref()?.getState()) - const { mcpEnabled } = state ?? {} + const { mcpEnabled } = requestState ?? {} let mcpHub: McpHub | undefined if (mcpEnabled ?? true) { const provider = this.providerRef.deref() @@ -4219,7 +4217,7 @@ export class Task extends EventEmitter implements TaskLike { const rooIgnoreInstructions = this.rooIgnoreController?.getInstructions() const { customModes, customModePrompts, customInstructions, experiments, language, enableSubfolderRules } = - state ?? {} + requestState ?? {} // Use task-local values, not provider state, to prevent cross-task configuration leaks. const mode = await this.getTaskMode() const apiConfiguration = this.apiConfiguration @@ -4262,7 +4260,7 @@ export class Task extends EventEmitter implements TaskLike { undefined, // todoList this.api.getModel().id, provider.getSkillsManager(), - state?.disabledTools, + requestState?.disabledTools, modelInfo, ) })() diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 7d267c49a1..e4d767d64c 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -32,7 +32,7 @@ import { McpHub } from "../../../services/mcp/McpHub" import { McpServerManager } from "../../../services/mcp/McpServerManager" type TaskTestAccess = { - getSystemPrompt: (requestState?: ProviderState, requestModelInfo?: ModelInfo) => Promise + getSystemPrompt: (requestState: ProviderState | undefined, requestModelInfo?: ModelInfo) => Promise handleContextWindowExceededError: () => Promise getEnabledMcpToolsCount: () => Promise<{ enabledToolCount: number; enabledServerCount: number }> initiateTaskLoop: (userContent: Anthropic.Messages.ContentBlockParam[]) => Promise @@ -780,12 +780,14 @@ describe("Cline", () => { }) await task.getTaskMode() - vi.spyOn(mockProvider, "getState").mockResolvedValue( - providerStateWith({ apiConfiguration: { ...mockApiConfig, todoListEnabled: false } }), - ) + // The focused provider's state diverges from the task's own + // configuration; threading it must not change what the prompt resolves. + const focusedProviderState = providerStateWith({ + apiConfiguration: { ...mockApiConfig, todoListEnabled: false }, + }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") - await getTaskTestAccess(task).getSystemPrompt() + await getTaskTestAccess(task).getSystemPrompt(focusedProviderState) const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) const [, , , , , mode, , , , , , , settings] = systemPromptCall @@ -793,7 +795,7 @@ describe("Cline", () => { expect(settings).toMatchObject({ todoListEnabled: true }) }) - it("passes undefined disabledTools when provider state carries none", async () => { + it("passes undefined disabledTools when the threaded snapshot carries none", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -802,22 +804,21 @@ describe("Cline", () => { }) await task.getTaskMode() - // The single mcpEnabled:false gate read returns the full double, skipping - // the MCP-hub path; its disabledTools is undefined, so the prompt call - // receives undefined for that argument. - Object.assign(mockProvider, { - getState: vi.fn<() => Promise>().mockResolvedValueOnce(providerStateWith()), - }) + // The threaded snapshot's mcpEnabled:false skips the MCP-hub path; its + // disabledTools is undefined, so the prompt call receives undefined for + // that argument. vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") - await expect(getTaskTestAccess(task).getSystemPrompt()).resolves.toBe("mock system prompt") + await expect(getTaskTestAccess(task).getSystemPrompt(providerStateWith())).resolves.toBe( + "mock system prompt", + ) const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) - // Argument index 16 is the disabledTools parameter fed by `state?.disabledTools`. + // Argument index 16 is the disabledTools parameter fed by `requestState?.disabledTools`. expect(systemPromptCall[16]).toBeUndefined() }) - it("passes undefined disabledTools to the system prompt when the provider state read resolves nothing", async () => { + it("passes undefined disabledTools to the system prompt when the threaded snapshot is undefined", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -826,14 +827,12 @@ describe("Cline", () => { }) await task.getTaskMode() - // The provider reference stays alive but its state read resolves nothing: - // the prompt path must still deliver undefined disabledTools rather than - // fail. Unlike the collected-provider cases, nothing here overrides - // providerRef, so every deref keeps finding the provider. - Object.assign(mockProvider, { - getState: vi.fn<() => Promise>().mockResolvedValue(undefined), - }) - // An unavailable state leaves the mcpEnabled gate open, so the hub branch + // A caller whose own read came back empty threads undefined; the prompt + // path must still deliver undefined disabledTools rather than fail, and + // it must not read provider state to fill the gap. providerRef stays + // alive, so the MCP-hub branch below can run. + const getStateSpy = vi.spyOn(mockProvider, "getState") + // An unavailable snapshot leaves the mcpEnabled gate open, so the hub branch // runs; the spy also witnesses that the branch really was taken. The // awaited connect wait is a no-op under this file's p-wait-for mock, so // the hub double needs no members. @@ -841,16 +840,45 @@ describe("Cline", () => { hubSpy.mockResolvedValue(Object.create(McpHub.prototype)) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") - await expect(getTaskTestAccess(task).getSystemPrompt()).resolves.toBe("mock system prompt") + await expect(getTaskTestAccess(task).getSystemPrompt(undefined)).resolves.toBe("mock system prompt") + expect(getStateSpy).not.toHaveBeenCalled() expect(hubSpy).toHaveBeenCalledTimes(1) const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) - // Argument index 16 is the disabledTools parameter fed by `state?.disabledTools`. + // Argument index 16 is the disabledTools parameter fed by `requestState?.disabledTools`. expect(systemPromptCall[16]).toBeUndefined() hubSpy.mockRestore() }) + it("builds the prompt from the threaded snapshot without reading provider state", async () => { + // A live provider whose state read returns a divergent snapshot must + // not be able to influence the prompt: the prompt path performs no + // provider-state read at all, so a re-read here would pick up the + // divergent disabledTools instead of the threaded ones. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + const getStateSpy = vi + .spyOn(mockProvider, "getState") + .mockResolvedValue(providerStateWith({ disabledTools: ["read_file"] })) + const snapshot = providerStateWith({ disabledTools: ["execute_command"] }) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await expect(getTaskTestAccess(task).getSystemPrompt(snapshot)).resolves.toBe("mock system prompt") + + expect(getStateSpy).not.toHaveBeenCalled() + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 16 is disabledTools: the threaded snapshot's value, + // not the value a provider-state re-read would have produced. + expect(systemPromptCall[16]).toEqual(["execute_command"]) + }) + it("forwards non-empty disabledTools and modelInfo to the system prompt call", async () => { const task = new Task({ provider: mockProvider, @@ -860,12 +888,9 @@ describe("Cline", () => { }) await task.getTaskMode() - // getSystemPrompt resolves provider state once, before the MCP wait; - // that snapshot feeds `state?.disabledTools`. mcpEnabled stays false - // so the MCP-hub path is skipped. - vi.spyOn(mockProvider, "getState").mockResolvedValue( - providerStateWith({ disabledTools: ["execute_command"] }), - ) + // The threaded snapshot feeds `requestState?.disabledTools`; mcpEnabled + // stays false so the MCP-hub path is skipped. + const snapshot = providerStateWith({ disabledTools: ["execute_command"] }) const modelInfo: ModelInfo = { contextWindow: 128_000, @@ -875,10 +900,10 @@ describe("Cline", () => { vi.spyOn(task.api, "getModel").mockReturnValue({ id: "distinctive-model-id", info: modelInfo }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") - await expect(getTaskTestAccess(task).getSystemPrompt()).resolves.toBe("mock system prompt") + await expect(getTaskTestAccess(task).getSystemPrompt(snapshot)).resolves.toBe("mock system prompt") const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) - // Argument index 16 is the disabledTools parameter fed by `state?.disabledTools`; + // Argument index 16 is the disabledTools parameter fed by `requestState?.disabledTools`; // index 17 is the modelInfo from `this.api.getModel().info`. expect(systemPromptCall[16]).toEqual(["execute_command"]) expect(systemPromptCall[17]).toBe(modelInfo) @@ -897,7 +922,7 @@ describe("Cline", () => { }) await task.getTaskMode() - vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + const snapshot = providerStateWith() const fallbackInfo: ModelInfo = { contextWindow: 32_000, @@ -916,7 +941,7 @@ describe("Cline", () => { vi.spyOn(task.api, "getModel").mockImplementation(() => ({ id: "router-model", info: currentInfo })) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") - await expect(getTaskTestAccess(task).getSystemPrompt()).resolves.toBe("mock system prompt") + await expect(getTaskTestAccess(task).getSystemPrompt(snapshot)).resolves.toBe("mock system prompt") expect(ensureModelFetched).toHaveBeenCalledTimes(1) const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) @@ -935,7 +960,6 @@ describe("Cline", () => { startTask: false, }) await task.getTaskMode() - vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) const handlerInfo: ModelInfo = { contextWindow: 100_000, supportsPromptCache: false } const threadedInfo: ModelInfo = { contextWindow: 64_000, supportsPromptCache: true, excludedTools: [] } @@ -944,7 +968,7 @@ describe("Cline", () => { vi.spyOn(task.api, "getModel").mockReturnValue({ id: "threaded-model", info: handlerInfo }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") - await expect(getTaskTestAccess(task).getSystemPrompt(undefined, threadedInfo)).resolves.toBe( + await expect(getTaskTestAccess(task).getSystemPrompt(providerStateWith(), threadedInfo)).resolves.toBe( "mock system prompt", ) @@ -1211,7 +1235,9 @@ describe("Cline", () => { configurable: true, }) - await expect(getTaskTestAccess(task).getSystemPrompt()).rejects.toThrow( + // The undefined snapshot keeps the mcpEnabled gate open, so the request + // reaches the provider guard and rejects there. + await expect(getTaskTestAccess(task).getSystemPrompt(undefined)).rejects.toThrow( "Provider reference lost during view transition", ) }) @@ -1225,26 +1251,29 @@ describe("Cline", () => { }) await task.getTaskMode() - // The opening state read performs the only deref that finds the provider - // alive - it flips the mock's liveness flag - so the provider guard at the - // top of the prompt-building closure is what answers for the dead ref. + // The caller's own snapshot read performs the only deref that finds the + // provider alive - it flips the mock's liveness flag - so the provider + // guard at the top of the prompt-building closure is what answers for the + // ref that died before the prompt was built. let providerAlive = true - Object.defineProperty(task, "providerRef", { - value: { - deref: () => { - if (providerAlive) { - providerAlive = false - return mockProvider - } - return undefined - }, + const providerRef = { + deref: () => { + if (providerAlive) { + providerAlive = false + return mockProvider + } + return undefined }, + } + Object.defineProperty(task, "providerRef", { + value: providerRef, writable: false, configurable: true, }) vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + const snapshot = await providerRef.deref()?.getState() - await expect(getTaskTestAccess(task).getSystemPrompt()).rejects.toThrow("Provider not available") + await expect(getTaskTestAccess(task).getSystemPrompt(snapshot)).rejects.toThrow("Provider not available") }) }) @@ -3783,7 +3812,6 @@ describe("Cline", () => { startTask: false, }) await task.getTaskMode() - vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") @@ -3792,7 +3820,7 @@ describe("Cline", () => { vi.useFakeTimers() try { - const promptPromise = getTaskTestAccess(task).getSystemPrompt() + const promptPromise = getTaskTestAccess(task).getSystemPrompt(providerStateWith()) await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) await expect(promptPromise).resolves.toBe("mock system prompt") From 85beb67b504c23ab0e3df6202da7fc8e7f0a7cd1 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Wed, 9 Sep 2026 08:19:12 +0200 Subject: [PATCH 21/39] fix(api): cancel abandoned model-metadata waits via AbortSignal The bounded metadata waits in Task.safeEnsureModelFetched and the system prompt preview cleared their timer but left the handler-side promise waiting on the model-catalog fetch. The ApiHandler contract now threads an optional AbortSignal through ensureModelFetched(): RouterProvider settles the waiter with a rejection when the signal aborts, so an abandoned or cancelled caller detaches instead of parking a promise on the shared fetch (which keeps running for other waiters and still populates the cache, by design). The task aborts its waiter both when the 5s bound expires and when cancelCurrentRequest runs (cancel and dispose paths); the preview aborts at its bound and on completion. The task-lifecycle doc's table padding was also reconciled with the PR base: the remaining diff there is now only prettier's column re-padding, which the repo's own pre-commit formatter enforces. --- src/api/index.ts | 9 ++- .../providers/__tests__/zoo-gateway.spec.ts | 27 ++++++++ src/api/providers/router-provider.ts | 31 ++++++++- src/core/task/Task.ts | 38 ++++++++-- src/core/task/__tests__/Task.spec.ts | 69 +++++++++++++++++++ .../__tests__/generateSystemPrompt.spec.ts | 21 ++++++ src/core/webview/generateSystemPrompt.ts | 17 +++-- 7 files changed, 197 insertions(+), 15 deletions(-) diff --git a/src/api/index.ts b/src/api/index.ts index 98c3c5dc7b..e662c78386 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -130,8 +130,15 @@ export interface ApiHandler { * Ensures model metadata has been fetched from the remote API so that getModel() * returns accurate info (context window, pricing, etc.) instead of hardcoded defaults. * Only router providers that discover models over the network implement this. + * + * `signal` bounds the caller's wait: when it aborts (e.g. the caller's bounded + * metadata wait expired or the owning task was cancelled), the returned promise + * settles with a rejection so no handler-side waiter outlives its caller. + * Fetchers that observe the signal may also stop their network request; the + * shared, de-duplicated catalog fetch may still complete and populate the model + * cache, which is by design for concurrent waiters. */ - ensureModelFetched?(): Promise + ensureModelFetched?(signal?: AbortSignal): Promise /** * Optional context window for context-management / auto-condense when it must differ from diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index c6f4c15c1e..f3803920fe 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -724,6 +724,33 @@ describe("ZooGatewayHandler", () => { expect(refreshModels).not.toHaveBeenCalled() }) + it("settles the waiter with a rejection when the signal aborts mid-fetch", async () => { + // A caller that gives up must not leave a handler-side waiter pending + // on the (shared) catalog fetch: with an observing signal, the + // ensureModelFetched promise rejects at abort time, while the + // underlying fetch continues untouched for any other waiter. + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockImplementationOnce(() => new Promise(() => {})) + + const handler = new ZooGatewayHandler(mockOptions) + const controller = new AbortController() + + const wait = handler.ensureModelFetched(controller.signal) + // Let the waiter attach its abort listener before cancelling. + await Promise.resolve() + controller.abort() + + await expect(wait).rejects.toThrow() + }) + + it("never starts a wait when the signal is already aborted", async () => { + const handler = new ZooGatewayHandler(mockOptions) + const controller = new AbortController() + controller.abort() + + await expect(handler.ensureModelFetched(controller.signal)).rejects.toThrow() + }) + it("skips the fetch when models are already populated", async () => { const handler = new ZooGatewayHandler(mockOptions) const { getModels, refreshModels } = await import("../fetchers/modelCache") diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index 7292824da8..5c457a71e7 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -108,8 +108,35 @@ export abstract class RouterProvider extends BaseProvider { return this.modelFetchPromise } - async ensureModelFetched(): Promise { - await this.fetchModel() + async ensureModelFetched(signal?: AbortSignal): Promise { + // A caller that already gave up must not start (or keep) a wait on the + // shared catalog fetch. + if (signal?.aborted) { + throw signal.reason + } + + const fetch = this.fetchModel() + if (!signal) { + await fetch + return + } + + // Detach this waiter as soon as the signal aborts; the shared in-flight + // fetch continues for any other waiter and still populates the cache. + await new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason) + signal.addEventListener("abort", onAbort, { once: true }) + fetch.then( + () => { + signal.removeEventListener("abort", onAbort) + resolve() + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort) + reject(error) + }, + ) + }) } override getModel(): { id: string; info: ModelInfo } { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 0573b1741e..12e25f51ad 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -144,8 +144,8 @@ const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds // Upper bound on awaiting lazily loaded model metadata. Some model-catalog // fetchers (e.g. OpenRouter's bare axios GET) have no request timeout, so a // hung endpoint must not stall streaming, condense, or context-window -// handling. On expiry the fetch is abandoned (the API-handler contract -// exposes no AbortSignal) and callers fall back to the handler's existing +// handling. On expiry the caller aborts its per-call AbortSignal (detaching +// the provider-side waiter) and falls back to the handler's existing // getModel().info metadata — the same degradation a rejected fetch produces. // Kept in sync with PREVIEW_MODEL_FETCH_TIMEOUT_MS in the preview path // (src/core/webview/generateSystemPrompt.ts). Deliberately duplicated, not @@ -328,6 +328,13 @@ export class Task extends EventEmitter implements TaskLike { private readonly globalStoragePath: string abort: boolean = false currentRequestAbortController?: AbortController + /** + * Controller for the waiter on an in-flight `ensureModelFetched()` call (see + * safeEnsureModelFetched). Aborting it detaches this task from the provider-side + * metadata fetch; it is aborted when the bounded wait expires and when the task's + * current request is cancelled (cancel/dispose path in cancelCurrentRequest). + */ + metadataFetchAbortController?: AbortController skipPrevResponseIdOnce: boolean = false // TaskStatus @@ -2625,6 +2632,12 @@ export class Task extends EventEmitter implements TaskLike { this.currentRequestAbortController.abort() this.currentRequestAbortController = undefined } + // A metadata-fetch waiter still in flight is as stale as an abandoned + // stream: detach it from the provider-side fetch on cancel/dispose. + if (this.metadataFetchAbortController) { + this.metadataFetchAbortController.abort() + this.metadataFetchAbortController = undefined + } } /** @@ -4277,7 +4290,9 @@ export class Task extends EventEmitter implements TaskLike { * Ensures router-provider model metadata is loaded before getModel() is used for * context management or streaming. Failures fall back to hardcoded defaults rather * than aborting the task; the wait is bounded by MODEL_FETCH_TIMEOUT_MS (see the - * constant for the abandonment and degradation semantics). + * constant for the degradation semantics). On expiry or cancellation the per-call + * AbortSignal detaches this task's waiter from the provider-side fetch instead of + * leaving a handler-side promise waiting on it indefinitely. * * The return value is the settled post-wait read of getModel().info: request * entry points (attemptApiRequest, condenseContext, @@ -4293,14 +4308,18 @@ export class Task extends EventEmitter implements TaskLike { * rejected fetches remains future work if the failure-path latency ever matters. */ private async safeEnsureModelFetched(): Promise { - // Promise.race attaches handlers to both inputs, so the abandoned fetch - // rejecting after the timeout is already considered handled — no extra - // .catch is needed here. + // Per-call controller: its signal makes ensureModelFetched() settle on + // abort, so neither this race nor the provider-side waiter outlives the + // bounded wait. cancel/dispose aborts it early via cancelCurrentRequest. + const controller = new AbortController() + this.metadataFetchAbortController = controller + // Promise.race attaches handlers to both inputs, so the fetch rejecting + // after the abort is already considered handled — no extra .catch needed. let timeoutId: ReturnType | undefined let timedOut = false try { await Promise.race([ - this.api.ensureModelFetched?.(), + this.api.ensureModelFetched?.(controller.signal), new Promise((resolve) => { timeoutId = setTimeout(() => { timedOut = true @@ -4322,6 +4341,11 @@ export class Task extends EventEmitter implements TaskLike { if (timeoutId) { clearTimeout(timeoutId) } + if (this.metadataFetchAbortController === controller) { + this.metadataFetchAbortController = undefined + } + // Unconditional: settles any waiter still attached to this call. + controller.abort() } // The post-wait read is the settled snapshot callers must share per request. return this.api.getModel().info diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index e4d767d64c..04a3b1a701 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -3802,6 +3802,75 @@ describe("Cline", () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) }) + it("aborts the fetch signal when the bounded wait expires", async () => { + // The bound must detach this task's waiter, not just stop waiting on + // it: a handler that observes its signal stops serving the abandoned + // fetch, and one that ignores it at least sees the task move on. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + let capturedSignal: AbortSignal | undefined + Object.assign(task.api, { + ensureModelFetched: (signal?: AbortSignal) => { + capturedSignal = signal + return new Promise(() => {}) + }, + }) + vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const settled = getTaskTestAccess(task).safeEnsureModelFetched() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + await settled + } finally { + vi.useRealTimers() + } + expect(capturedSignal?.aborted).toBe(true) + }) + + it("aborts an in-flight metadata wait when the current request is cancelled", async () => { + // Cancel/dispose must reach the metadata waiter, not only the stream: + // cancelCurrentRequest aborts the controller feeding the handler's + // signal, so a signal-observing handler settles the waiter immediately + // and the call degrades to fallback info instead of hanging until the + // bound. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + let capturedSignal: AbortSignal | undefined + Object.assign(task.api, { + ensureModelFetched: (signal?: AbortSignal) => + new Promise((_resolve, reject) => { + capturedSignal = signal + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }) + }), + }) + const expectedInfo = task.api.getModel().info + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const settled = getTaskTestAccess(task).safeEnsureModelFetched() + // Let the waiter attach to the handler before cancelling. + await Promise.resolve() + + task.cancelCurrentRequest() + + await expect(settled).resolves.toBe(expectedInfo) + expect(capturedSignal?.aborted).toBe(true) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to fetch model metadata"), + expect.anything(), + ) + }) + it("does not block getSystemPrompt when ensureModelFetched never settles", async () => { // The prompt/condense guard site must proceed with fallback model // info once the bounded wait expires instead of hanging the request. diff --git a/src/core/webview/__tests__/generateSystemPrompt.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.spec.ts index b703ded47c..f1d6326d8f 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.spec.ts +++ b/src/core/webview/__tests__/generateSystemPrompt.spec.ts @@ -512,6 +512,27 @@ describe("generateSystemPrompt preview parity", () => { } }) + it("aborts the handler signal when the preview fetch times out", async () => { + // The preview's bound must detach the handler-side waiter, mirroring + // the runtime path: a signal-observing handler stops serving the + // abandoned fetch once the bound expires. + let capturedSignal: AbortSignal | undefined + modelMock.ensureModelFetched.mockImplementationOnce((signal?: AbortSignal) => { + capturedSignal = signal + return new Promise(() => {}) + }) + + vi.useFakeTimers() + try { + const previewPromise = generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + await vi.advanceTimersByTimeAsync(5_000) + await previewPromise + } finally { + vi.useRealTimers() + } + expect(capturedSignal?.aborted).toBe(true) + }) + it("logs and degrades when the model info cannot be read", async () => { // A throw while reading the model info escapes the fetch race and lands // in the outer handler: the preview must still resolve — without model diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index 080df356cd..ffc4592206 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -53,15 +53,21 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web // A failed OR stalled metadata fetch degrades to the handler's fallback // metadata (mirroring Task.safeEnsureModelFetched) rather than dropping // model guidance entirely, so the preview keeps matching the runtime - // prompt's failure semantics. Promise.race attaches handlers to both - // inputs, so the abandoned fetch rejecting after the timeout is already - // considered handled — no extra .catch is needed here. + // prompt's failure semantics. The controller's signal makes the handler's + // waiter settle when the bound expires or this call ends, instead of + // leaving it pending on the shared catalog fetch. Promise.race attaches + // handlers to both inputs, so the fetch rejecting after the timeout is + // already considered handled — no extra .catch is needed here. let timeoutId: ReturnType | undefined + const controller = new AbortController() try { await Promise.race([ - tempApiHandler.ensureModelFetched?.(), + tempApiHandler.ensureModelFetched?.(controller.signal), new Promise((resolve) => { - timeoutId = setTimeout(resolve, PREVIEW_MODEL_FETCH_TIMEOUT_MS) + timeoutId = setTimeout(() => { + controller.abort() + resolve() + }, PREVIEW_MODEL_FETCH_TIMEOUT_MS) }), ]) } catch (error) { @@ -70,6 +76,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web if (timeoutId) { clearTimeout(timeoutId) } + controller.abort() } modelInfo = tempApiHandler.getModel().info } catch (error) { From d06883add47b428dc08e4f0d46ef4ce629b0c00a Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Wed, 9 Sep 2026 09:27:09 +0200 Subject: [PATCH 22/39] test(api): cover abort-signal detach paths and thread request model snapshot Mutation-diff gate kills (PR #1505): - zoo-gateway: signal-aware ensureModelFetched tests for the fetch-wins and fetch-rejects branches (block/CallExpression NoCoverage), an addEventListener spy pinning the { once: true } options, and paired add/remove listener assertions pinning the abort event name on both detach sites (StringLiteral mutants). - Task: ownership-guard tests for metadataFetchAbortController (clear on own completion, leave a replaced controller in place). - generateSystemPrompt: signal-capture tests pinning the timeout-bound and finally-block controller.abort() detaches (CallExpression mutants). CodeRabbit: thread the request model-info snapshot into buildCleanConversationHistory so preserveReasoning resolves from the same per-request snapshot as the prompt and tool arrays, plus regression tests. No Stryker-disable directives were needed; all 14 mutants are killed behaviorally. --- .../providers/__tests__/zoo-gateway.spec.ts | 39 ++++++ src/core/task/Task.ts | 13 +- src/core/task/__tests__/Task.spec.ts | 111 ++++++++++++++++++ .../__tests__/generateSystemPrompt.spec.ts | 28 +++++ 4 files changed, 188 insertions(+), 3 deletions(-) diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index f3803920fe..7f80985a07 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -743,6 +743,45 @@ describe("ZooGatewayHandler", () => { await expect(wait).rejects.toThrow() }) + it("settles the waiter when the fetch wins against a live signal and detaches the listener", async () => { + // Fetch-wins branch: resolve() must settle the await (a dropped + // resolve or a detached .then handler hangs this test), the abort + // listener must be registered with the real { once: true } options + // object, and the detach must target the *same* event name/handler + // pair that was registered — a mutated event name detaches nothing. + const handler = new ZooGatewayHandler(mockOptions) + const controller = new AbortController() + const addEventListenerSpy = vitest.spyOn(controller.signal, "addEventListener") + const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener") + + await handler.ensureModelFetched(controller.signal) + + expect(addEventListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function), { once: true }) + const registered = addEventListenerSpy.mock.calls.find(([event]) => event === "abort") + expect(registered).toBeDefined() + expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", registered?.[1]) + }) + + it("rejects a signal-observing waiter with the fetch error and detaches the listener", async () => { + // Rejection-branch twin of the fetch-wins test: reject(error) must + // propagate the catalog failure to the waiter (a dropped reject hangs + // this test) and the listener must be detached under the right event + // name. Existing reject coverage passes no signal, so this branch was + // never executed before. + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockRejectedValueOnce(new Error("network down")) + + const handler = new ZooGatewayHandler(mockOptions) + const controller = new AbortController() + const addEventListenerSpy = vitest.spyOn(controller.signal, "addEventListener") + const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener") + + await expect(handler.ensureModelFetched(controller.signal)).rejects.toThrow("network down") + const registered = addEventListenerSpy.mock.calls.find(([event]) => event === "abort") + expect(registered).toBeDefined() + expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", registered?.[1]) + }) + it("never starts a wait when the signal is already aborted", async () => { const handler = new ZooGatewayHandler(mockOptions) const controller = new AbortController() diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 12e25f51ad..8604afbf31 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4764,7 +4764,10 @@ export class Task extends EventEmitter implements TaskLike { // mergeConsecutiveApiMessages implementation) without mutating stored history. const mergedForApi = mergeConsecutiveApiMessages(messagesSinceLastSummary, { roles: ["user"] }) const messagesWithoutImages = maybeRemoveImageBlocks(mergedForApi, this.api) - const cleanConversationHistory = this.buildCleanConversationHistory(messagesWithoutImages as ApiMessage[]) + const cleanConversationHistory = this.buildCleanConversationHistory( + messagesWithoutImages as ApiMessage[], + requestModelInfo, + ) // Check auto-approval limits const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits( @@ -5038,6 +5041,7 @@ export class Task extends EventEmitter implements TaskLike { private buildCleanConversationHistory( messages: ApiMessage[], + requestModelInfo: ModelInfo, ): Array< Anthropic.Messages.MessageParam | { type: "reasoning"; encrypted_content: string; id?: string; summary?: any[] } > { @@ -5137,10 +5141,13 @@ export class Task extends EventEmitter implements TaskLike { continue } else if (hasPlainTextReasoning) { - // Check if the model's preserveReasoning flag is set + // Check if the model's preserveReasoning flag is set, resolved from + // the request's threaded model snapshot (same per-request source as + // the prompt and tool arrays) rather than a fresh getModel() re-read, + // so a mid-request metadata refresh cannot change what this request sends. // If true, include the reasoning block in API requests // If false/undefined, strip it out (stored for history only, not sent back to API) - const shouldPreserveForApi = this.api.getModel().info.preserveReasoning === true + const shouldPreserveForApi = requestModelInfo.preserveReasoning === true let assistantContent: Anthropic.Messages.MessageParam["content"] if (shouldPreserveForApi) { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 04a3b1a701..74f3326f2e 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -45,6 +45,10 @@ type TaskTestAccess = { safeEnsureModelFetched: () => Promise addToApiConversationHistory: (message: unknown, reasoning?: string) => Promise resetAssistantMessagePersistence: () => void + buildCleanConversationHistory: ( + messages: ApiMessage[], + requestModelInfo: ModelInfo, + ) => Array<{ role: string; content: unknown } | { type: "reasoning"; encrypted_content: string }> } type TaskAskResult = Awaited> @@ -3871,6 +3875,56 @@ describe("Cline", () => { ) }) + it("releases the metadata abort controller once the wait completes", async () => { + // The ownership guard in safeEnsureModelFetched's finally block must + // clear the field for the call that still owns it: a never-cleared + // controller would let cancelCurrentRequest abort a long-dead signal. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + Object.assign(task.api, { ensureModelFetched: () => Promise.resolve() }) + + await getTaskTestAccess(task).safeEnsureModelFetched() + + expect(task.metadataFetchAbortController).toBeUndefined() + }) + + it("does not clear a controller owned by a newer metadata wait", async () => { + // Overlap guard: if a newer call replaced this call's controller while + // the bounded wait was pending, the finished call's finally block must + // leave the foreign controller in place — clearing unconditionally + // would silently orphan the newer wait from cancel/dispose. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + let settleFetch!: () => void + Object.assign(task.api, { + ensureModelFetched: () => + new Promise((resolve) => { + settleFetch = resolve + }), + }) + + const settled = getTaskTestAccess(task).safeEnsureModelFetched() + // The per-call controller is installed synchronously before the race. + expect(task.metadataFetchAbortController).toBeInstanceOf(AbortController) + const foreignController = new AbortController() + task.metadataFetchAbortController = foreignController + + settleFetch() + await settled + + expect(task.metadataFetchAbortController).toBe(foreignController) + }) + it("does not block getSystemPrompt when ensureModelFetched never settles", async () => { // The prompt/condense guard site must proceed with fallback model // info once the bounded wait expires instead of hanging the request. @@ -4491,6 +4545,7 @@ describe("Cline", () => { asyncStreamFrom([{ type: "text", text: "ok" }]), ) const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") + const cleanHistorySpy = vi.spyOn(getTaskTestAccess(task), "buildCleanConversationHistory") // Hold the prompt build open so the metadata fetch can land after the // snapshot was captured but before context sizing runs. let releasePrompt!: () => void @@ -4525,12 +4580,68 @@ describe("Cline", () => { expect(systemPromptCall[17]).toBe(threadedInfo) // The threaded snapshot replaces the per-request guard entirely. expect(safeSpy).not.toHaveBeenCalled() + // The cleaned request history resolves its model-dependent flags from + // the same threaded snapshot, not from a fresh handler re-read. + expect(cleanHistorySpy).toHaveBeenCalledWith(expect.any(Array), threadedInfo) // Condensing ran because sizing resolved from the 32k threaded // window; a 128k re-read would have cleared the threshold instead. expect(summarizeConversation).toHaveBeenCalledTimes(summarizeCallsBefore + 1) }) }) + describe("buildCleanConversationHistory", () => { + // Assistant message carrying a plain-text (unencrypted) reasoning block: + // whether the block survives into the sent history depends solely on the + // model snapshot's preserveReasoning flag. + const reasoningMessage: ApiMessage = { + role: "assistant", + content: [ + { + type: "reasoning", + text: "hidden chain of thought", + summary: [], + } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "text", text: "answer" }, + ], + ts: 1, + } + + function historyFor(preserveReasoning: boolean) { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + // The handler re-read deliberately disagrees with the threaded + // snapshot: any output that follows the re-read instead of the + // parameter flips the assertions below. + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: "lazy-router-model", + info: { contextWindow: 1_000, supportsPromptCache: false, preserveReasoning: !preserveReasoning }, + }) + + const requestModelInfo: ModelInfo = { + contextWindow: 1_000, + supportsPromptCache: false, + preserveReasoning, + } + return getTaskTestAccess(task).buildCleanConversationHistory([reasoningMessage], requestModelInfo) + } + + it("keeps plain-text reasoning when the threaded snapshot sets preserveReasoning", () => { + const history = historyFor(true) + + expect(history).toEqual([{ role: "assistant", content: reasoningMessage.content }]) + }) + + it("strips plain-text reasoning when the threaded snapshot omits preserveReasoning", () => { + const history = historyFor(false) + + expect(history).toEqual([{ role: "assistant", content: "answer" }]) + }) + }) + describe("startTask", () => { it("posts a clean state immediately before adding the first task message", async () => { const task = new Task({ diff --git a/src/core/webview/__tests__/generateSystemPrompt.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.spec.ts index f1d6326d8f..e4265e4e32 100644 --- a/src/core/webview/__tests__/generateSystemPrompt.spec.ts +++ b/src/core/webview/__tests__/generateSystemPrompt.spec.ts @@ -521,18 +521,46 @@ describe("generateSystemPrompt preview parity", () => { capturedSignal = signal return new Promise(() => {}) }) + // Both abort sites are pinned by count: the timeout callback fires + // exactly when the bound elapses — detaching a hung waiter before + // the prompt is even built — and the finally block re-aborts on + // completion. Deleting either call leaves the other as the sole, + // strictly-too-late detach, and the count drops to one. + const abortSpy = vi.spyOn(AbortController.prototype, "abort") vi.useFakeTimers() try { const previewPromise = generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) await vi.advanceTimersByTimeAsync(5_000) + // Both abort sites have fired by the time the preview resolves: + // the finally block runs before generateSystemPrompt returns, so + // the count is asserted while the spy still holds its history + // (mockRestore would clear it). await previewPromise + expect(abortSpy).toHaveBeenCalledTimes(2) } finally { vi.useRealTimers() + abortSpy.mockRestore() } expect(capturedSignal?.aborted).toBe(true) }) + it("aborts the handler signal after a fast fetch so the waiter detaches on completion", async () => { + // The finally-block detach also covers the fetch-wins path: a + // signal-observing handler must not keep serving waiters for a + // preview that already finished. Without the finally abort, the + // captured signal is never aborted on this path (no timer fires). + let capturedSignal: AbortSignal | undefined + modelMock.ensureModelFetched.mockImplementationOnce((signal?: AbortSignal) => { + capturedSignal = signal + return Promise.resolve() + }) + + await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + expect(capturedSignal?.aborted).toBe(true) + }) + it("logs and degrades when the model info cannot be read", async () => { // A throw while reading the model info escapes the fetch race and lands // in the outer handler: the preview must still resolve — without model From 0c8fce9e3c05247a825dd1eb9599696dcbea379a Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Wed, 9 Sep 2026 17:07:32 +0200 Subject: [PATCH 23/39] test(webview): pin alwaysDenyUnapprovedCommands persistence through updateSettings --- .../__tests__/webviewMessageHandler.spec.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4c2a301965..dcd70f92f1 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1310,6 +1310,40 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { }) }) +// A plain boolean needs no normalization branch in the updateSettings loop, +// so this pins the generic persistence path: every polarity, including an +// explicit unset (undefined), must reach ContextProxy verbatim rather than +// being defaulted away. +it("persists alwaysDenyUnapprovedCommands through the generic updateSettings loop", async () => { + vi.clearAllMocks() + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { alwaysDenyUnapprovedCommands: true }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("alwaysDenyUnapprovedCommands", true) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) + + vi.clearAllMocks() + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { alwaysDenyUnapprovedCommands: false }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("alwaysDenyUnapprovedCommands", false) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) + + vi.clearAllMocks() + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { alwaysDenyUnapprovedCommands: undefined }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("alwaysDenyUnapprovedCommands", undefined) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) +}) + // Both allowlists are normalized by the same branch, so both are held to the // same contract. describe.each(["allowedReadFiles", "allowedWriteFiles"] as const)("webviewMessageHandler - %s", (key) => { From 2e4a8232510dd79b6619369fb2fac3e80c42b52b Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Wed, 9 Sep 2026 17:07:56 +0200 Subject: [PATCH 24/39] chore: tighten blanket auto-deny comments and test names for accuracy --- .../auto-approval/__tests__/blanket-deny.spec.ts | 5 +++-- src/core/auto-approval/__tests__/dcg.spec.ts | 3 ++- src/core/auto-approval/commands.ts | 1 - src/core/auto-approval/index.ts | 4 ++-- src/core/task/Task.ts | 3 ++- src/core/task/__tests__/ask-auto-deny.spec.ts | 5 +++-- src/core/tools/ExecuteCommandTool.ts | 15 +++++++++------ .../tools/__tests__/executeCommandTool.spec.ts | 6 +++--- .../__tests__/AutoApproveSettings.spec.tsx | 4 ++-- 9 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/core/auto-approval/__tests__/blanket-deny.spec.ts b/src/core/auto-approval/__tests__/blanket-deny.spec.ts index c374a1866a..2fbc89d623 100644 --- a/src/core/auto-approval/__tests__/blanket-deny.spec.ts +++ b/src/core/auto-approval/__tests__/blanket-deny.spec.ts @@ -3,7 +3,8 @@ import { checkAutoApproval } from ".." // Matrix over the blanket auto-deny feature (`alwaysDenyUnapprovedCommands`): // DCG on/off × blanket on/off × command shapes. The blanket setting only // engages while command auto-approval (`autoApprovalEnabled` + -// `alwaysAllowExecute`) is on, so every case here keeps both gates on. +// `alwaysAllowExecute`) is on, so the engagement cases keep both gates on; the +// disengagement cases turn them off to prove the setting is inert. describe("blanket auto-deny for unapproved commands", () => { const baseState = { autoApprovalEnabled: true, @@ -246,7 +247,7 @@ describe("blanket auto-deny for unapproved commands", () => { }), ).toEqual({ decision: "approve" }) - // A write that is not allowed still asks, exactly as before. + // A write that is not allowed still asks — the blanket setting is command-only. expect( await checkAutoApproval({ state: { ...state, alwaysAllowWrite: false }, diff --git a/src/core/auto-approval/__tests__/dcg.spec.ts b/src/core/auto-approval/__tests__/dcg.spec.ts index 49e300875b..5585c69acd 100644 --- a/src/core/auto-approval/__tests__/dcg.spec.ts +++ b/src/core/auto-approval/__tests__/dcg.spec.ts @@ -16,6 +16,7 @@ describe("Destructive Command Guard auto-approval precedence", () => { allowedCommands: ["echo"], deniedCommands: ["rm"], destructiveCommandGuardEnabled: true, + alwaysDenyUnapprovedCommands: false, mcpServers: [], } @@ -31,7 +32,7 @@ describe("Destructive Command Guard auto-approval precedence", () => { expect(await checkAutoApproval({ state, ask: "command", text: "rm file" })).toEqual({ decision: "ask" }) }) - it("requires explicit approval for a DCG-protected command", async () => { + it("requires explicit approval for a DCG-protected command when blanket auto-deny is off", async () => { expect( await checkAutoApproval({ state: baseState, ask: "command", text: "echo safe", isProtected: true }), ).toEqual({ decision: "ask" }) diff --git a/src/core/auto-approval/commands.ts b/src/core/auto-approval/commands.ts index cf7cf49238..cd3a6f9842 100644 --- a/src/core/auto-approval/commands.ts +++ b/src/core/auto-approval/commands.ts @@ -317,7 +317,6 @@ export function getCommandDecisionDetailed( // Remove simple PowerShell-like redirections (e.g. 2>&1) before checking const sanitizedCommands = subCommands.map((cmd) => cmd.replace(/\d*>&\d*/, "").trim()) - // Check each sub-command and collect decisions const decisions: CommandDecision[] = sanitizedCommands.map((cmd) => getSingleCommandDecision(cmd, allowedCommands, deniedCommands), ) diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index 25a5f55e99..02c0d9ddc9 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -269,8 +269,8 @@ export async function checkAutoApproval({ if (state.destructiveCommandGuardEnabled === true) { if (dcgDecision?.decision === "deny") { // Blanket on: auto-deny, forwarding the guard's reason and - // rule to the model. Blanket off: fall through to the - // (protected) user prompt, as before. + // rule to the model. Blanket off: this ask falls through + // to the normal user prompt. return blanketDeny ? { decision: "deny", diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e0159304b5..d084d4b8f5 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1482,7 +1482,8 @@ export class Task extends EventEmitter implements TaskLike { const provider = this.providerRef.deref() const state = provider ? await provider.getState() : undefined // The blanket auto-deny setting only engages while command auto-approval - // is on; with either master switch off, behavior is unchanged. + // is on; while it is disengaged, the queued-message shortcut below is + // unaffected. const blanketDenyEngaged = state?.alwaysDenyUnapprovedCommands === true && state?.autoApprovalEnabled === true && diff --git a/src/core/task/__tests__/ask-auto-deny.spec.ts b/src/core/task/__tests__/ask-auto-deny.spec.ts index 7a6f992a96..7125b79d71 100644 --- a/src/core/task/__tests__/ask-auto-deny.spec.ts +++ b/src/core/task/__tests__/ask-auto-deny.spec.ts @@ -160,8 +160,9 @@ describe("Task.ask queue path cannot bypass blanket deny", () => { it("denies a blanket-denied command ask even when a queued message would auto-approve it", async () => { const task = buildTask(provider, TASK_CWD) const queue = await attachQueue(task) - // A queued message previously answered command asks with an unconditional - // yesButtonClicked — the one sequence that bypassed blanket deny. The + // A queued message answers command asks with an unconditional + // yesButtonClicked — one of the two sequences that could bypass blanket + // deny (the other was DCG-enabled approval without a guard verdict). The // policy denial must win, and it must carry the same structured detail // as the main path. queue.addMessage("queued feedback arriving while blanket deny is engaged") diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index ce0b22fe13..33eb614706 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -153,10 +153,13 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } // The blanket auto-deny setting only engages while command auto-approval - // is on; with either master switch off, behavior is unchanged and a DCG - // block keeps today's protected user prompt. The blanket decision is - // frozen to this pre-ask snapshot, while terminal behavior is re-read - // after approval so a settings flip during a pending prompt takes effect. + // is on. A DCG block keeps its protected user prompt unless the blanket + // setting is fully engaged, in which case it is auto-denied. The blanket + // decision is frozen to this pre-ask snapshot, while terminal behavior is + // re-read after approval so a settings flip during a pending prompt takes + // effect. A blanket off-flip landing between this snapshot and Task.ask's + // own re-read routes a DCG block to the normal prompt instead of the + // protected one; a user still decides either way, so the snapshot stays. const providerState = await provider?.getState() const blanketAutoDeny = providerState?.alwaysDenyUnapprovedCommands === true && @@ -184,8 +187,8 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } const executionId = task.lastMessageTs?.toString() ?? Date.now().toString() - // Re-read after approval (as HEAD did) so a settings flip while the - // approval prompt was pending is honored for terminal behavior. + // Re-read after approval so a settings flip while the approval prompt + // was pending is honored for terminal behavior. const { terminalShellIntegrationDisabled = true } = (await provider?.getState()) ?? {} // Get command execution timeout from VSCode configuration (in seconds) diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 1a6f677e3e..edf16aed30 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -753,9 +753,9 @@ describe("executeCommandTool", () => { it("honors a terminal shell integration flip made during a pending approval", async () => { vitest.useFakeTimers() const provider = await mockCline.providerRef.deref() - // The pre-ask snapshot sees shell integration disabled, but the user - // flips the setting while the approval prompt is pending. HEAD read - // provider state after approval; execution must honor that fresher value. + // The pre-ask snapshot must not pin terminal behavior: execution + // reads provider state again after approval and must honor the + // fresher value. provider.getState .mockResolvedValueOnce({ terminalShellIntegrationDisabled: true }) .mockResolvedValueOnce({ terminalShellIntegrationDisabled: false }) diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx index e2458eedd0..3c5b9997c1 100644 --- a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx @@ -270,8 +270,8 @@ describe("AutoApproveSettings - Save/Discard contract", () => { }) it("hides the blanket auto-deny toggle while command auto-approval is off", () => { - // The setting only engages together with alwaysAllowExecute; with the - // master switch off, the whole Execute section (and its toggles) is hidden. + // With alwaysAllowExecute off, the whole Execute section (and its + // toggles, including the blanket auto-deny toggle) is hidden. renderSettings({ alwaysAllowExecute: false }) expect(screen.queryByTestId("auto-deny-unapproved-checkbox")).not.toBeInTheDocument() From 45b0ddb6c36f7b44617f7115f07627f447e999a6 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Sun, 20 Sep 2026 12:33:31 +0200 Subject: [PATCH 25/39] test: remove out-of-scope Task history-persistence spec Delete src/core/task/__tests__/Task.history-persistence.spec.ts (+407, branch-only) per the ratified removal set (ruling 2026-09-20b; cycle1-scope section 3 row 1; plan section 2.4 item #1). Pre-delete evidence (plans/1569/tmp/b2-removal-evidence.md): grep 'history-persistence' returns only the file's own header self-reference -- no importers; vitest collects the spec by glob. N_blocks = 12 grep-lines (7 it + 5 describe). T6 broad-suite delta vs merged head c1378c6b5: 94->93 files, 1533->1526 passed, 4 skipped -- exactly the deleted spec's contribution, zero collateral. warnProtocolToolOverrides grep-zero re-confirmed; eslint-suppressions byte-identical (no increase); cast baseline 3673->3670. --- .../Task.history-persistence.spec.ts | 407 ------------------ 1 file changed, 407 deletions(-) delete mode 100644 src/core/task/__tests__/Task.history-persistence.spec.ts diff --git a/src/core/task/__tests__/Task.history-persistence.spec.ts b/src/core/task/__tests__/Task.history-persistence.spec.ts deleted file mode 100644 index 5e341b7919..0000000000 --- a/src/core/task/__tests__/Task.history-persistence.spec.ts +++ /dev/null @@ -1,407 +0,0 @@ -// cd src && npx vitest run core/task/__tests__/Task.history-persistence.spec.ts - -import * as os from "os" -import * as path from "path" -import * as vscode from "vscode" - -import type { GlobalState, ProviderSettings } from "@roo-code/types" -import { TelemetryService } from "@roo-code/telemetry" - -import { Task } from "../Task" -import { ClineProvider } from "../../webview/ClineProvider" -import { ContextProxy } from "../../config/ContextProxy" -import { providerIdentifiers } from "@roo-code/types/provider-identifiers" - -type TaskHistoryPersistenceAccess = { - saveApiConversationHistory: (merge?: boolean) => Promise - overwriteApiConversationHistory: (messages: unknown[], persist?: boolean) => Promise - flushPendingToolResultsToHistory: () => Promise - assistantMessageSavedToHistory: boolean - userMessageContent: unknown[] -} - -function getTaskHistoryPersistenceAccess(task: Task): TaskHistoryPersistenceAccess { - return task as unknown as TaskHistoryPersistenceAccess -} - -function createDeferred() { - let resolve!: (value: T) => void - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise - }) - return { promise, resolve } -} - -async function drainMicrotasks() { - for (let i = 0; i < 10; i++) { - await new Promise((resolve) => setImmediate(resolve)) - } -} - -// ─── Hoisted mocks ─────────────────────────────────────────────────────────── - -const { - mockSaveApiMessages, - mockSaveTaskMessages, - mockReadApiMessages, - mockReadTaskMessages, - mockTaskMetadata, - mockPWaitFor, -} = vi.hoisted(() => ({ - mockSaveApiMessages: vi.fn().mockResolvedValue(undefined), - mockSaveTaskMessages: vi.fn().mockResolvedValue(undefined), - mockReadApiMessages: vi.fn().mockResolvedValue([]), - mockReadTaskMessages: vi.fn().mockResolvedValue([]), - mockTaskMetadata: vi.fn().mockResolvedValue({ - historyItem: { id: "test-id", ts: Date.now(), task: "test" }, - tokenUsage: { - totalTokensIn: 0, - totalTokensOut: 0, - totalCacheWrites: 0, - totalCacheReads: 0, - totalCost: 0, - contextTokens: 0, - }, - }), - mockPWaitFor: vi.fn().mockResolvedValue(undefined), -})) - -// ─── Module mocks ──────────────────────────────────────────────────────────── - -vi.mock("delay", () => ({ - __esModule: true, - default: vi.fn().mockResolvedValue(undefined), -})) - -vi.mock("execa", () => ({ - execa: vi.fn(), -})) - -vi.mock("p-wait-for", () => ({ - default: mockPWaitFor, -})) - -vi.mock("../../task-persistence", async (importOriginal) => { - const mod = await importOriginal() - return { - ...mod, - saveApiMessages: mockSaveApiMessages, - saveTaskMessages: mockSaveTaskMessages, - readApiMessages: mockReadApiMessages, - readTaskMessages: mockReadTaskMessages, - taskMetadata: mockTaskMetadata, - TaskHistoryStore: vi.fn().mockImplementation(function () { - return { - initialize: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - get: vi.fn(), - getAll: vi.fn().mockReturnValue([]), - upsert: vi.fn().mockResolvedValue([]), - delete: vi.fn().mockResolvedValue(undefined), - deleteMany: vi.fn().mockResolvedValue(undefined), - reconcile: vi.fn().mockResolvedValue(undefined), - initialized: Promise.resolve(), - } - }), - } -}) - -vi.mock("vscode", () => { - const mockDisposable = { dispose: vi.fn() } - const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } - const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } } - const mockTextEditor = { document: mockTextDocument } - const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } } - const mockTabGroup = { tabs: [mockTab] } - - return { - TabInputTextDiff: vi.fn(), - CodeActionKind: { - QuickFix: { value: "quickfix" }, - RefactorRewrite: { value: "refactor.rewrite" }, - }, - window: { - createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), - visibleTextEditors: [mockTextEditor], - tabGroups: { - all: [mockTabGroup], - close: vi.fn(), - onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })), - }, - showErrorMessage: vi.fn(), - }, - workspace: { - workspaceFolders: [ - { - uri: { fsPath: "/mock/workspace/path" }, - name: "mock-workspace", - index: 0, - }, - ], - createFileSystemWatcher: vi.fn(() => ({ - onDidCreate: vi.fn(() => mockDisposable), - onDidDelete: vi.fn(() => mockDisposable), - onDidChange: vi.fn(() => mockDisposable), - dispose: vi.fn(), - })), - fs: { - stat: vi.fn().mockResolvedValue({ type: 1 }), - }, - onDidSaveTextDocument: vi.fn(() => mockDisposable), - getConfiguration: vi.fn(() => ({ get: (_key: string, defaultValue: unknown) => defaultValue })), - }, - env: { - uriScheme: "vscode", - language: "en", - }, - EventEmitter: vi.fn().mockImplementation(function () { - return mockEventEmitter - }), - Disposable: { - from: vi.fn(), - }, - TabInputText: vi.fn(), - } -}) - -vi.mock("../../mentions", () => ({ - parseMentions: vi.fn().mockImplementation((text) => { - return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }) - }), - openMention: vi.fn(), - getLatestTerminalOutput: vi.fn(), -})) - -vi.mock("../../../integrations/misc/extract-text", () => ({ - extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), -})) - -vi.mock("../../environment/getEnvironmentDetails", () => ({ - getEnvironmentDetails: vi.fn().mockResolvedValue(""), -})) - -vi.mock("../../ignore/RooIgnoreController") - -vi.mock("../../../utils/storage", () => ({ - getTaskDirectoryPath: vi - .fn() - .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), - getSettingsDirectoryPath: vi - .fn() - .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), -})) - -vi.mock("../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockReturnValue(false), -})) - -// ─── Test suite ────────────────────────────────────────────────────────────── - -describe("Task history persistence ordering contract", () => { - let mockProvider: ClineProvider - let mockApiConfig: ProviderSettings - let mockOutputChannel: vscode.OutputChannel - let mockExtensionContext: vscode.ExtensionContext - - beforeEach(() => { - vi.clearAllMocks() - - if (!TelemetryService.hasInstance()) { - TelemetryService.createInstance([]) - } - - const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") } - - mockExtensionContext = { - globalState: { - get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), - update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), - keys: vi.fn().mockReturnValue([]), - }, - globalStorageUri: storageUri, - workspaceState: { - get: vi.fn().mockImplementation((_key) => undefined), - update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), - keys: vi.fn().mockReturnValue([]), - }, - secrets: { - get: vi.fn().mockImplementation((_key) => Promise.resolve(undefined)), - store: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), - delete: vi.fn().mockImplementation((_key) => Promise.resolve()), - }, - extensionUri: { fsPath: "/mock/extension/path" }, - extension: { packageJSON: { version: "1.0.0" } }, - } as unknown as vscode.ExtensionContext - - mockOutputChannel = { - appendLine: vi.fn(), - append: vi.fn(), - clear: vi.fn(), - show: vi.fn(), - hide: vi.fn(), - dispose: vi.fn(), - } as unknown as vscode.OutputChannel - - mockProvider = new ClineProvider( - mockExtensionContext, - mockOutputChannel, - "sidebar", - new ContextProxy(mockExtensionContext), - ) - - mockApiConfig = { - apiProvider: providerIdentifiers.anthropic, - apiModelId: "claude-3-5-sonnet-20241022", - apiKey: "test-api-key", - } - - mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) - mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) - mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) - mockProvider.updateTaskHistory = vi.fn().mockResolvedValue(undefined) - mockProvider.log = vi.fn() - }) - - function createTask(): Task { - return new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "history persistence test task", - startTask: false, - }) - } - - describe("completion-time save is durably awaited", () => { - it("does not resolve the save before the durable write settles", async () => { - const task = createTask() - const access = getTaskHistoryPersistenceAccess(task) - const deferred = createDeferred() - mockSaveApiMessages.mockImplementationOnce(() => deferred.promise) - - task.apiConversationHistory.push({ - role: "user", - content: [{ type: "text", text: "pending write" }], - }) - - let settled = false - const savePromise = access.saveApiConversationHistory().then((result) => { - settled = true - return result - }) - - await drainMicrotasks() - expect(settled).toBe(false) - - deferred.resolve(undefined) - await expect(savePromise).resolves.toBe(true) - expect(settled).toBe(true) - }) - - it("passes a snapshot with merge semantics on incremental saves", async () => { - const task = createTask() - const access = getTaskHistoryPersistenceAccess(task) - - task.apiConversationHistory.push({ - role: "user", - content: [{ type: "text", text: "snapshot" }], - }) - - await expect(access.saveApiConversationHistory()).resolves.toBe(true) - - expect(mockSaveApiMessages).toHaveBeenCalledTimes(1) - const args = mockSaveApiMessages.mock.calls[0][0] - expect(args.merge).toBe(true) - expect(args.messages).not.toBe(task.apiConversationHistory) - expect(args.messages).toEqual(task.apiConversationHistory) - }) - }) - - describe("authoritative overwrite vs hydration", () => { - it("persists explicit overwrites as authoritative (merge: false)", async () => { - const task = createTask() - const access = getTaskHistoryPersistenceAccess(task) - - await access.overwriteApiConversationHistory([{ role: "user", content: "authoritative" }]) - - expect(mockSaveApiMessages).toHaveBeenCalledTimes(1) - expect(mockSaveApiMessages).toHaveBeenCalledWith(expect.objectContaining({ merge: false })) - }) - - it("hydrates without touching the persisted file when persist is false", async () => { - const task = createTask() - const access = getTaskHistoryPersistenceAccess(task) - - await access.overwriteApiConversationHistory([{ role: "user", content: "hydrated only" }], false) - - expect(mockSaveApiMessages).not.toHaveBeenCalled() - expect(task.apiConversationHistory).toEqual([ - expect.objectContaining({ role: "user", content: "hydrated only", messageId: expect.any(String) }), - ]) - }) - }) - - describe("flushPendingToolResultsToHistory only clears pending content after a durable save", () => { - it("returns true and clears pending content when the save settles", async () => { - const task = createTask() - const access = getTaskHistoryPersistenceAccess(task) - access.assistantMessageSavedToHistory = true - access.userMessageContent = [ - { type: "tool_result", tool_use_id: "toolu_1", content: "done", is_error: false }, - ] - - await expect(access.flushPendingToolResultsToHistory()).resolves.toBe(true) - - expect(access.userMessageContent).toEqual([]) - expect(mockSaveApiMessages).toHaveBeenCalledTimes(1) - expect(task.apiConversationHistory).toHaveLength(1) - }) - - it("returns false and retains pending content when the save fails", async () => { - const task = createTask() - const access = getTaskHistoryPersistenceAccess(task) - access.assistantMessageSavedToHistory = true - const pending = [{ type: "tool_result", tool_use_id: "toolu_1", content: "done", is_error: false }] - access.userMessageContent = pending - - mockSaveApiMessages.mockRejectedValueOnce(new Error("disk unavailable")) - - await expect(access.flushPendingToolResultsToHistory()).resolves.toBe(false) - - expect(access.userMessageContent).toEqual(pending) - }) - }) - - // Guards the discriminating power of the "does not resolve the save before the - // durable write settles" pin above: a fire-and-forget seam (the regression this - // pin exists to catch) resolves the save before the write settles, so the pin's - // invariant would flip to false under that mutation. - describe("mutation guard: the pending-until-durable pin discriminates fire-and-forget", () => { - it("a fire-and-forget save wrapper resolves before the durable write settles", async () => { - const deferred = createDeferred() - const fireAndForgetSave = () => { - void deferred.promise - return Promise.resolve(true) - } - - let settled = false - await fireAndForgetSave().then(() => { - settled = true - }) - - expect(settled).toBe(true) - - const awaitedSave = async () => { - await deferred.promise - return true - } - let awaitedSettled = false - const awaitedPromise = awaitedSave().then(() => { - awaitedSettled = true - }) - await drainMicrotasks() - expect(awaitedSettled).toBe(false) - deferred.resolve() - await awaitedPromise - }) - }) -}) From c96cf92f61691126267485bec6216d0efc6f52bb Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Sun, 20 Sep 2026 15:14:30 +0200 Subject: [PATCH 26/39] test(auto-deny): pin AutoApprovalContext forwarding seam and fix stale comments F1: rewrite the queue-shortcut comment in ask-auto-deny.spec.ts to a timeless statement (drop the unresolvable past-tense claim about a second bypass path). F2: fix the self-referential docblock on CommandDecisionDetail in auto-approval/commands.ts; the relation is now stated relative to getCommandDecision, matching the inverse docblock on getCommandDecisionDetailed. F3: pin the widened askApproval -> cline.ask -> checkAutoApproval forwarding chain that no test previously referenced: - presentAssistantMessage-auto-deny.spec.ts +2 positional-forwarding cases (tool_use and MCP askApproval closure copies) - ask-auto-deny.spec.ts +1 case pinning Task.ask's dcgDecision forwarding into the policy decision Each of the three forwarding sites is mutation-killed by these cases. Gates at fix head: G1 261/14, auto-deny spec 6/1, G2 82/1, G3 6, G4 26, G6 1529 passed / 4 skipped / 93 files, tsc 0, eslint touched files 0 warnings, suppressions byte-identical, test blocks +3 added / 0 deleted, casts 3670 -> 3672 (+2, both the file's established per-call mockTask-as-Task idiom in the two mandated new tests). --- .../presentAssistantMessage-auto-deny.spec.ts | 60 +++++++++++++++++++ src/core/auto-approval/commands.ts | 7 ++- src/core/task/__tests__/ask-auto-deny.spec.ts | 26 ++++++-- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts index ef1bc2e6c4..18204aaa90 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest" import { presentAssistantMessage } from "../presentAssistantMessage" import { validateToolUse } from "../../tools/validateToolUse" import type { Task } from "../../task/Task" +import type { AskApproval } from "../../../shared/tools" vi.mock("../../task/Task") vi.mock("../../tools/validateToolUse", async (importOriginal) => { @@ -162,6 +163,9 @@ const NOT_ALLOWLISTED_DETAIL = { command: "rm x && npm test", } +const DCG_ALLOW = { decision: "allow" } as const +const AUTO_APPROVAL_CONTEXT = { dcgDecision: DCG_ALLOW } + describe("presentAssistantMessage - automatic (policy) denials", () => { let mockTask: MockTask @@ -331,4 +335,60 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { expect(payload.feedback).toBe("do not run that") expect(payload.type).toBeUndefined() }) + + it("forwards autoApprovalContext from the tool askApproval copy into cline.ask", async () => { + mockTask.assistantMessageContent = [executeCommandBlock] + + // The seam: whatever a tool hands to askApproval must reach Task.ask + // positionally — dropping the argument re-opens the DCG-context bypass. + executeCommandHandle.mockImplementation( + async (_task: unknown, _block: unknown, { askApproval }: { askApproval: AskApproval }) => { + await askApproval("command", "rm x", undefined, false, AUTO_APPROVAL_CONTEXT) + }, + ) + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.ask).toHaveBeenCalledWith("command", "rm x", false, undefined, false, AUTO_APPROVAL_CONTEXT) + }) + + it("forwards autoApprovalContext from the MCP askApproval copy into cline.ask", async () => { + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_mcp", + name: "mcp_my_server_do_thing", + serverName: "my_server", + toolName: "do_thing", + arguments: {}, + partial: false, + }, + ] + + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }), + getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined }), + }), + } + + // The same seam on the mcp_tool_use copy of the closure: the context + // must survive the positional forwarding to cline.ask here too. + useMcpToolHandle.mockImplementation( + async (_task: unknown, _block: unknown, { askApproval }: { askApproval: AskApproval }) => { + await askApproval("use_mcp_server", "{}", undefined, false, AUTO_APPROVAL_CONTEXT) + }, + ) + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.ask).toHaveBeenCalledWith( + "use_mcp_server", + "{}", + false, + undefined, + false, + AUTO_APPROVAL_CONTEXT, + ) + }) }) diff --git a/src/core/auto-approval/commands.ts b/src/core/auto-approval/commands.ts index cd3a6f9842..9d7289c265 100644 --- a/src/core/auto-approval/commands.ts +++ b/src/core/auto-approval/commands.ts @@ -263,9 +263,10 @@ export function getCommandDecision( } /** - * Extended result of {@link getCommandDecisionDetailed}, naming the specific - * sub-command responsible for the decision so automatic denials can tell the - * model exactly what went wrong. + * Result of {@link getCommandDecisionDetailed}: {@link getCommandDecision}'s + * plain decision plus the offending sub-command and the matched pattern that + * produced it, so automatic denials can tell the model exactly what went + * wrong. */ export interface CommandDecisionDetail { decision: CommandDecision diff --git a/src/core/task/__tests__/ask-auto-deny.spec.ts b/src/core/task/__tests__/ask-auto-deny.spec.ts index 7125b79d71..602f38cd15 100644 --- a/src/core/task/__tests__/ask-auto-deny.spec.ts +++ b/src/core/task/__tests__/ask-auto-deny.spec.ts @@ -135,6 +135,25 @@ describe("Task.ask resolves blanket command denials with structured detail", () const addToClineMessages = task["addToClineMessages"] as ReturnType expect(addToClineMessages.mock.calls[0][0].autoApprovalDecision).toBe("deny") }) + + it("routes a forwarded DCG verdict into the policy decision", async () => { + // The seam: Task.ask must hand the tool-supplied verdict to + // checkAutoApproval. Without the forwarding this DCG-enabled ask + // (verdict-less from checkAutoApproval's view) approves instead of + // carrying the guard's structured denial. + state.destructiveCommandGuardEnabled = true + const task = buildTask(provider, TASK_CWD) + await attachQueue(task) + + const result = await task.ask("command", "rm x", false, undefined, false, { + dcgDecision: { decision: "deny", reason: "matches a destructive pattern" }, + }) + + expect(result.response).toBe("noButtonClicked") + expect(result.autoDenyDetail?.kind).toBe("dcg") + expect(result.autoDenyDetail?.command).toBe("rm x") + expect(result.autoDenyDetail?.dcgReason).toBe("matches a destructive pattern") + }) }) describe("Task.ask queue path cannot bypass blanket deny", () => { @@ -161,10 +180,9 @@ describe("Task.ask queue path cannot bypass blanket deny", () => { const task = buildTask(provider, TASK_CWD) const queue = await attachQueue(task) // A queued message answers command asks with an unconditional - // yesButtonClicked — one of the two sequences that could bypass blanket - // deny (the other was DCG-enabled approval without a guard verdict). The - // policy denial must win, and it must carry the same structured detail - // as the main path. + // yesButtonClicked — exactly the shortcut that must never stand in for + // approval while blanket deny is engaged. The policy denial must win, + // and it must carry the same structured detail as the main path. queue.addMessage("queued feedback arriving while blanket deny is engaged") const result = await task.ask("command", "rm x", false) From 984ddfc3654c86de65d774612f95e70df098c757 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Wed, 23 Sep 2026 10:37:09 +0200 Subject: [PATCH 27/39] test(webview): visual snapshot for blanket auto-deny checkbox The blanket auto-deny setting makes command execution fail closed when command auto-approval is on: explicitly approved commands run, anything else is denied without prompting, and the denial reason goes back to the model. The settings row that controls it had no visual regression coverage. Add a Story Gallery snapshot test for the Auto-approve settings panel with the blanket auto-deny checkbox visible and checked, plus its story registration and baselines for the four supported themes (dark, light, high-contrast, high-contrast-light). The baselines were generated in the Playwright container image pinned by docker-compose.visual.yml and are committed so the surface is protected against future drift. The setting behavior itself landed in earlier commits of this branch; this commit only adds the visual coverage. --- webview-ui/playwright/gallery/stories.tsx | 5 ++ .../AutoApproveSettings.visual.fixture.tsx | 81 ++++++++++++++++++ .../__tests__/AutoApproveSettings.visual.tsx | 20 +++++ .../auto-approve-settings-dark.png | Bin 0 -> 111303 bytes ...o-approve-settings-high-contrast-light.png | Bin 0 -> 111942 bytes .../auto-approve-settings-high-contrast.png | Bin 0 -> 114183 bytes .../auto-approve-settings-light.png | Bin 0 -> 113328 bytes 7 files changed, 106 insertions(+) create mode 100644 webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.fixture.tsx create mode 100644 webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.tsx create mode 100644 webview-ui/src/components/settings/__tests__/__screenshots__/auto-approve-settings-dark.png create mode 100644 webview-ui/src/components/settings/__tests__/__screenshots__/auto-approve-settings-high-contrast-light.png create mode 100644 webview-ui/src/components/settings/__tests__/__screenshots__/auto-approve-settings-high-contrast.png create mode 100644 webview-ui/src/components/settings/__tests__/__screenshots__/auto-approve-settings-light.png diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 04ca36b2c6..0bef9b9ee5 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -56,6 +56,11 @@ export const stories: Record = { ) }, + "auto-approve-settings": async () => { + const { AutoApproveSettingsStory } = + await import("@/components/settings/__tests__/AutoApproveSettings.visual.fixture") + return + }, "chat-text-area": async () => { const { ChatTextAreaStory } = await import("@/components/chat/__tests__/ChatTextArea.visual.fixture") return diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.fixture.tsx new file mode 100644 index 0000000000..d7fa363fdc --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.fixture.tsx @@ -0,0 +1,81 @@ +import React, { useState } from "react" + +import type { ExtensionStateContextType } from "@/context/ExtensionStateContext" +import type { SetCachedStateField } from "../types" +import { AutoApproveSettings } from "../AutoApproveSettings" +import { AppProviders } from "../../../../playwright/AppProviders" + +interface AutoApproveState { + alwaysAllowReadOnly: boolean + alwaysAllowWrite: boolean + alwaysAllowMcp: boolean + alwaysAllowModeSwitch: boolean + alwaysAllowSubtasks: boolean + alwaysAllowExecute: boolean + alwaysAllowFollowupQuestions: boolean + destructiveCommandGuardEnabled: boolean + alwaysDenyUnapprovedCommands: boolean + allowedCommands: string[] + deniedCommands: string[] + allowedReadFiles: string[] + allowedWriteFiles: string[] + allowedMaxRequests?: number + allowedMaxCost?: number +} + +export function AutoApproveSettingsStory() { + // The blanket auto-deny checkbox is pinned here in its operative mode: with + // the destructive command guard on it is the fail-closed policy, and the + // guard's hidden command-list editors would otherwise vary the snapshot. + const [state, setState] = useState({ + alwaysAllowReadOnly: false, + alwaysAllowWrite: false, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowExecute: true, + alwaysAllowFollowupQuestions: false, + destructiveCommandGuardEnabled: true, + alwaysDenyUnapprovedCommands: true, + allowedCommands: [], + deniedCommands: [], + allowedReadFiles: [], + allowedWriteFiles: [], + }) + const setCachedStateField: SetCachedStateField = (field, value) => { + setState((current) => { + switch (field) { + case "alwaysAllowReadOnly": + case "alwaysAllowWrite": + case "alwaysAllowMcp": + case "alwaysAllowModeSwitch": + case "alwaysAllowSubtasks": + case "alwaysAllowExecute": + case "alwaysAllowFollowupQuestions": + case "destructiveCommandGuardEnabled": + case "alwaysDenyUnapprovedCommands": + return { ...current, [field]: Boolean(value) } + case "allowedCommands": + case "deniedCommands": + case "allowedReadFiles": + case "allowedWriteFiles": + return { ...current, [field]: Array.isArray(value) ? [...value] : [] } + case "allowedMaxRequests": + case "allowedMaxCost": + return { ...current, [field]: typeof value === "number" ? value : undefined } + default: + return current + } + }) + } + + return ( + +
+ +
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.tsx new file mode 100644 index 0000000000..049330e4cc --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.visual.tsx @@ -0,0 +1,20 @@ +import { expect, test } from "../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../playwright/mounted-story" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" + +// Declared in webview-ui/src/i18n/locales/en/settings.json under +// settings:autoApprove.execute.autoDeny.label; the checkbox takes its accessible +// name from that label text, so the two must stay in sync. +const autoDenyLabel = "Auto-deny unapproved commands (never ask)" + +for (const theme of visualThemes) { + test(`renders the blanket auto-deny checkbox in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + const component = mountedStory(await mount("auto-approve-settings")) + await applyVisualTheme(page, theme) + const story = component.getByTestId("auto-approve-settings-story") + const checkbox = story.getByRole("checkbox", { name: autoDenyLabel }) + await expect(checkbox).toBeVisible() + await expect(checkbox).toBeChecked() + await expect(story).toHaveScreenshot(`auto-approve-settings-${theme.name}.png`) + }) +} diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/auto-approve-settings-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/auto-approve-settings-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..d2c31eaa59271d61d343a5c068361db6a7b7be1e GIT binary patch literal 111303 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%0De>7LQVI$2}up7 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%0A^}^{3C{||v zUPObUqBq7tuof{#cSpL{Lx4k6uc+Bjf~ z?r*nXsUTE{Gtd_(IAeKX!*7rRPot?$dHdDbqi+^N+_Ds7{SrbOCOTT;5e{rtm?2DY zObp^A6gTfxMtL8E{xzW#|Gu=8%d;lKfs^Ir%7~6%<+q+y9<8mKG#1PMap0Fh&n3vS z7vDe1v|NWSP==L#Bep#t8IQN7%YY<`T7~Rgzxap?riX25N;VUfU=ou7;+8U(atn() z5|uJ8Z|`cM{gQ^9JEdRHgYouQ)YmdauOBa&-AI%5WD}``Rkw8$=)|W97qaz>wwWvW zW;djV70^`H-?EE-%=6^A-;#D*T)GEsO6R|Sh(rt_vR9~{!ZMzG4(ed{sjf##vIb+2 zS{Hg;PCI;J&|%!Cy&--DrO>ep%Iqxa@wc|4ISQ7nBFl`EoESc^*G9Sap4)b{I!Pc`-P7+Dv4u;73cCEEybU?~1xEItk(Y5Y4;i2KpsS zN^RS*p=8^DaNA=5H(6RU%{{TJXy-^~s|1R<9G9FH3Q+>Ef>Oc**G2UrCd@&x(wk)2 z=$a2t%%yy-jjcy(Q;K&7i~D|(nzCfko$wU0Pom%SB3h+cr_?NTYO%4*aWA_c2H&_> z8*X@a!FQA`I2bm;d1Bk;vA6Ch-6k=5q$(OAH=fLgp7LKORlpV-6)4RTxnT#Rgw5TMcOJ6Z2x_XoSN0 z>G@G_3>nTvz>^4wEm#30IdulSo422Vm++P#9CO$qd+0FWvyL*X`Y(GCThi1^t>2({ ziiPx}Ygpb~^?$s6^KKr_yBY*=mQ{%?*uOS}6`Tr{EA}HH)R;oQ?(%|b>ke!sGVV+A z;H`&hadisNB)tS(*tZ&6wQ}xLr_Ei^dhwaomqUjv1TTPxTy8Xd06XU;tjf4q+ju5V zAScpgwwh^vN11cv$&f*tuZQY=fF*Qa1DAVp{}^kIk5na=`~xWMgv8W5F<$3=zLtQ6 zWDpk9IXjX+(kHsGkHFgf^lYn)y;0cj!0t4}-L~1T>v41;6b3_h!57ZCQ8?r#vj#DL z_p}Gq(bP;uk3F0Eg(XG-gj=kyzmidwX4#t_K}50 zh5bYHbbFg^bhU2~TfAn4h>(cwOseh-mPIJA<%Vfv0rw ze^CpmGbcwe+;G5hOzc6jJ<}k?(uk}YD2J`7G=k21=lz!y#`fAg#;RF0P%H|ol5h!s zr~Jq|AkJ=C$#jFgq0WADq6m6=&$r47UEB6mUmto8S6Z^ikQ&s ztDCFYXd1J75e?5)_yZ*sk?iY6R!iMAms{SHl;xNIV_7I6=eYt zTKoY0#AaWE`ObzYRhF&Olc@^yg;Q!UU6dq2@?u6>wjEmv$DB2%UI=(B^pS2TJtUSl zk;Gx)Op1*TV?Pk%y43EEXBR-WJ276bOMKZkQaq<|#y7p9Ms6DM3}q z{G-6TfH8m;ZWMmO0wI^pxKu)RVB0jr^J@Ah9@TZ`@bN_KY`3%|F>9DKQG4(M%&#La zfH=uw9VphFn1}q&ekfAB{rKI-vxm|BimCb=+S@cbBoChUfrsS^T`Z12f0W0@KNz;R z!obUk1mu5V0S6igYGf8H6l+#6C3#2CDB?o+d?$)5u->bOx}c~pgAsU2i=Wr>V;itl zeNu!17I8x`KQLGA4pP_cH~%QG^%ZQ`97g1$-lpy9DYcoEIh-+wd+_SUIG))m#lx@|;=BZJaLsxA!CZLoMp)QF<76xQRsMut2zt8s-H%traZ(T}d zFbc%o%RYw&Zn3kE0Y?k*10LzqcB(?{QwB4!ahpx@zaeca0dw7}@!Ka~tql>hFRq(2#=EuSh{`o4EGu&yj}S0NC* zg;@l|Tz$U6i@t(h5|g$WLJME3%@)TQZF2_uQYY(Rc&)ij8JrhI<+fDp)EC<`AR&~> z;jYTA!pwq^aq|470agEE;;k4#t$>nXLquXpTySp;F)BRF3Lp(R z5GJP@-MsZByG1)4uE!jv#&jWRcMCW!X4yBZ21q=qS{4Kf+=po$T(y?4Zt&DS)3nai zLrN7HL37}@QMRp>9aNFKc`Q`k~=pe1~kNrQ)V#v1^#zFeBkgi2MOLQc}1J4_WQ znoKk$6XwkBxoIwVB#AN8vRM~v&x)-KXlz(8`)C+AQ)r|9@NT$x^Hpy0$nk`&DN5Mu zPNEZ^Ms?od`A)!yiEkk-ADf2p3`m-chv=EC;L5bG;YAdQIl?EXcs zl0p4m@E6Oxrde3)CAu?tAlq>XQC6RKm^fzQ7@NFPs;5fVmq`;&Mv2;t*-l%tzQU;M zH&NsbnhHZxDO@jUZ**IXHC6C@7Q5MvWcB;^@rT_6v0a5C?J29rwS>i2X&I=3TOKsg zX$ebSPQR_ztA(tas&OM5d^jm*woRQ>$1Dg94&GbZi*E_B284I5Fnk~)5%>|kUx;dB zV3p!7`$!rmTv%7On!skh8rw!56NOjEscD75*tkbqQ3Kg8y?uJAY&$&uNmyneBjr6$ zaz}PiRqj@;=BT3n&xz5Qo9o2g`GRV@eGAhypVm3ZF&mgvG1<##sgrm%65mGU;*(=O ze1_g>-o=MQN>dRdVE8~IgM9Up3q?rbBc<*X&un@FRfQ2F5j)MQ#ov0+8Hn}?(x9($ zkp{R3hHty3bzyE$-dz>-%kW7`VZjrTjh@bp_9PMyc+X2RSz0egRpY4@ryD1`tvoj~ z-IYh08yDqjt;MdNfb=F~8hdreaBX(f_t~)ZGpTP*-2S)E)Em$z-3iPX;=KL)>9OaP zZaP22{`A?9-#*+>26Mr|^ge@r-wiqR<78`r|+J zdC>nBA;iLX!?P4u2U|mpnVsx80bV?XZ|3`BKqQhanStNhIHc zMw${GB;hepe&iv`!xL)?7?>;;WAlUxJhhwcQl+@G_Cnorv4VDjVp#;USyAlWY1BpL ztO?TlpQDb|+X}tkih8bjMCU5q^1G=kivKJts0d>cP&F?YR>Yhpkd`+-QBg;pS#1Hm zlQn+y8($;a_G#Go)yuT)62DX>+gi~mbl!=G5lqSOTS#*%UVh;rPdVI{FPd#frpD@N z>SDduF2c^|k_FxudkKE?*Z3s^(VdgGAGUqH1GAd%G4sxjseYzKDp_+pu>U?NOt{8EIY* z9}YFvzHtxe_a(JfUooCn*HTy%0|M;Y_G#)X`;F(+g#3`Ga~`=&QHYi}?S!OMzn9os=Y^?&@-!Bl$2+PxY`(Lg=P@iNJo*Y_%IFW6gO6cu zKyxqnvvW0A8YCH=-}S0TVopjKBAbrzpI?8#IcS zt!r<^psW0xf6vW3LP#waBz>O&OT#)(9*rH@H4j;ADDs2Er#Zbkhui+jv}NXC^8oCw zQSZB5l7M%!(-RdUdwe{8Le7U^Tt=ypLd!{hjGxbDpntX3O`sGo6dyonsvdfGy3k0u zL_=w^c?Bo+^Af!nKM5vKH2A)|clz#s=&-Yu&i}0-7Y!MNWH$%e9|=E0Ot9H{q2}h6 zhgSz-{`mxiQR*GlQJm2Xd_s7fvk)IgDX9`enl1bE3=|?+VXoU!AJ;`A=uFu?u!+eQ z4(~9~d3O2^)5p$1y}iyYA{2+#U)A50RWYzWSgNPB(ukw%lVJ`4W@_>NVfupXqzkd2 zbl7%`jlv2DhgDa$yfF%BQo8Vzt%wPugbAtFyHkE{zesLYyEqa=-^9u#{Tg|YLaL-A zU1D#%A@7UaSROh{7o?uoHJ$aBOL3e0mdI|XnR={T)uw7{if~yuwI3WZZ;DhI6Vh0+ z4K}@L6cg6YcH{Z#wK3A^-l1#raL{PwlW>}@<>8UpmtSqSSb1HQpGBpd%r&|(uuuw< z2!}CoB9jv{P0lo7#?s&Rh;^aB_#*c-f{8*sslQhz8-iqAbfhq8k7&llJcP4f^j~Q^Y{(*UYEVKWSW}S_?jIM~9!rIkBV7IEGm16Xwe5N-(V2NBP4OdI? z&rn44mi((CL2?@f=P)U%Cx_$_vQPU$I?!x6!qXjPE>y)$J)eF$$3B!~=!jP*)FYOo zO_zrH19btVW{BB0_8CMwN>9Rat$BOL|3ALg=vf@u0~|a;A|firzd=lTyRJ)E4iwES zyMY;BAEoTDxDd-oYd5IY?5*O5Ovs}Zp{?NRD*VTnP#35YBzhl`YW>hrm{^F%3)E>~ z*ic5x``DuLVJ$^#u?f?aNolZ}wSnQ~6GdB|o@ezz5?lVejmoWW`o9s?MSZk3t~N__ zA9eNefRF8l^Uu$nWT!z{Sqg3`MP)QwwW-$~8)t(`uM#FOn+n z5UYnz%WINRGcrHhJXi91f~tmOB;LP-E2(#`T(6NbiCfDSgZ}G=W9Pvf+=5W7wWyB5 zC>lpbk)g;!Lq|-mB%a(5`B?$eklgqu7vYzWh{GD980*U?YowTCsHu-TcuN^_jUwXv z>Wa7RdK!!TdY{!dG`;;1bUmgsFmYezLzl&f?}As~qp%{E|M~?4q$QLP+*6`1i;huz zW5pUTgK+$`aR5iF;b$amMek5s1=-f#26p%I;IqvTq}k?e4L>wN8-@;?j`e4dCWnq1G!?$MDZCUE0V!REv z6%w{GK_vP~BPmHUGGygX> zb%NTG6r;4zHXDVO*2lN|yn;UuW}6EkYbsY1C$@QUmt&MA5m&R1TBDAojI?Y%bmFA& zMPo*`e`eUvYjz{UPXc8Oxt7gGje6s8U$g>ac;5bEVZ=T%+O=gt{@q2$zELk~+tdo4 z_>~qxScP2sZk|2eV^{*U---D4%o^z&aVrbnA5BaVl!TN7XuBL-vqND|rasUQuCsfWH`n#__$$Zt?I)&#HrEj;*gv+pIyspy z0U65fF>$QH%|7~@FBg0TDv73lDpW$usa85kn*iEB|GSksa;@KD%&@rwuRl?Qx)pX$ zPU*sSL9%uHOhk^QXK5Tc(?2YT?OoEPg?=g&7dS_sbXS6wL>htKJc{}*zDjlo^-)lz z0g>hlYH^g-`W)Qpc!ZOsR}LDl^8R0|2Dnx1(x&f#nC3fdfzxN0S9XHXAe$-)M?`U- zZAy~ViTs zw*n0U8^*;f0#NE8x;MG+zV11@Grm5WdkkE|kFxJ%LSIl5(o_E~4>Qh!vjb?R(Jl)> z{0L-EkXrqNMK=G11%wbC0S^Anqn{2DpRfh~xEg52Wz`vi&3H77z;YL{h_ca2`Ba`P zeS>g_e@Z~RLsS>3G?1%$g4$uB10rE!EQL5CdhH>vHTg=0ze;?vRhX(gb)W>Ddc_`dkcSPrJm?6}yC3m?_ z0gzC*japD!@uJ<};u=p~-7ny%FgwH-8{j^{VO8u2PGXv*B132 ztE&6mKSVuKWG9+aQ%Je*@hrKrWiNN?^d{^)$0RUyJcgKpvI5mJQBSsqq!S&IDXT$ORuDCtx9gG&6tTzj`D2KMY_YPLr1! zlh!}{1qdMYV7|%2HiEi4Sx2aVh3Pt&yV{GD?x5I`ss~iM%$WR}{LiCZUp>0i;b1<2 z9~u$+HCf*yKpV41R?@T5sLqW11cXhbEqs^5A(et<4)isDj573e$T)?cO~K&gSl|a( zz(WY*S0Cl0R<8MA@GjV`04oiKF{`kJ^HLi3*F;8nQ6Os>i6@ z5*0c0ok_z!mP`-Me79^r`&4z2ze3^q8FAQ1ElgX61CB?(Jd&!;61@kZo3x6<<;EKr zhN|7?)tvf(G185odjH0}S%@xvAcbF3tqu+2VTa|ri=XZ4XKO00)-TlKa2k`pCX~GG z!ED(dlVIbT?_p)kUS4Kcmt%N_KNZxt){a+Mn{p z-@K=veXmd3O)S7JAWxElJN_7jrQZy?blhqj^|Fy<2er6wuvW8gUo*~Xy2?P zmgq}X;ak#~|Lrt-=)UucOZLU-%}GrX^}-F$045QZU(dNF5;rJ*e7&-1Dj862K}p=3 zN>Dx^O}BY!Q^tmgW8QyoA6l+K(xiI2{@(aSd&+j&gkaLLjD}=XW9b^E9H};m133oI zZw~uCyD9>2rtIZprchD4SnUddtXY~<-ezw`onZWdpvbEZeXD~3PnY{lk&^dxrXs8M z@6E$tER+w`l-*6(+XLdQY2E@a#F@3%x+zL30AY>3!5{e+*{Fsbz9D#U=YQA9y;m;C zOocPTS5B{j0elLT?iWf__n%B%z6wkwnAf@C&KDarbSo1HXy7wI+{1xFHOgR)D8|?s{ zKefG>{voW9k-^R!o<6@ZpXUetVBG;at2()kGH;8pOG<}a?CGE&GmY0@tt0awqk(s} z>uXO>ioXOp&+r)dfiSF)>_I)}@^&lPq;ue8NqyCNdRAo;XH!HI-p2k) zpFND_-LPhEAHSL*_OsA=1xo|*KBE`zaPoqai>$#?h}47Y(W2dk`{kr%g?TJ>2C9}9 zmd(4bl>(R~OXTGuGCJv0Cf{|iwAC9pP~W3X@Ou822!n^XV?p4fqLiH&;U<5h0r4%P z9?GaD4kgTell|um$ON@DFA{5aQDx*)y1V5X-qrECS8#PdEmJ+3u^dE`Uzqvgw+fuj;aSxQkCEhi6%g6D zd2lJQraSGhQ5`3Z0&+W7SUc6!n_uE@k+iRk@~^ z`X{Rl*W!B=t}R=yfSa-K=q2m>Q}(UeTFL9ICKZKHS_9R@3-m)>3&O3cPp z7h9^u#(01AU{^w+0MGPJm<^so@MK-y1#QR}8j4_CF*1g^LN6)u8{-w56wG&uzz27wQ3x7PR2{r(A!TUoEl z{u?n+wzp3=MG2r3eb*d*^6NA2r%kjgMw%3@d(VWMIz`fqQ$IT{j$D6k*R3?J>1fop z!e?+8FBqkT-tz$NWaK0(2cfBrcFSjEjQzMz*@e$yD}`SW$!>gt_Rr=>0xV9H55V3j zOB6eg$RxOC(P(R(Ti%66y16C~mY;pSaH;eZu{Syvb7h~ln4zy;J|ZVc^iFC6JfryQ zigK38!%L!8fpNf2VmJ*jUdx}2_?SMYwbx3(0@*Zi#bIluZ8Bm1-he$QIM$hm2@ap` z_fl{hmWMF!JpzudKaQ%~1xNzLl6BEtj@w~LbVx!(JHN(pS~wq?hCyJBWmc zRxtFSC9mHc95O{wxmEB+RR2{K2$?;}Z_e9+xB?*m51YlY(AFvqgwIs2=YT5aWcy6Q zx}`a9%gYt!VLAUHmpBsDG~N)Yu#4WST1K#`$dL3De*+V!iX zN0#GrVfW{9;;3T7C=H2*zhy z!RSMZnE31^{~=6gzSYyBK6=M*rtKs}3CpNst6Zo;93NA-aG!BjM4Jt7G7=jH5(#21 za$;)S6V=mu5Jv>%f~5}yot!O5J!VSQZl882UbrT8IG|Y z%WFz%3maPG*_+i8k91zF%oKJpa+dS5Mei}$(yccY9B@4!6aSzk!m&0UdFr`bbAvL7K+&)b>pYqt) z!u&^62#NkV53!O(9|@MnFC9KR-k`blStgQB+BnCX2dOUn718`|_=K~QUax4INILPF zeu`Na9AtMKxfvO^T$qyoq?AQk8N0e)pVETXKnD*nR*@iPrP0*{Cua1`*cJ7QaSS}_ zCZ_M^x+2k1&TXqgwGLt(MC6}}kZ7;6&}yg^wN&m5uJkh&$++-MTB8-WL-GPzHvU)# z{csafY^IZW{gXe-(2qQE_2am{xNvcxZBXoCbAK7IK)7`&ZuXpG1M{3im>PUq4ffVWo{hYw^1{(f|IXY(}&)LBck} z*^Kh|SB}q%EZQgRUK_bL9ft%1!;G$`jpjsHBTGn1&EU?pg4*)|rUSc_x!?D!ojk7o zz38@s!p}pmm?X&a8D*XebIwJXE;>?dZBbh2rLBPpqmspit{VhV(*NV$1Zj`|4x;`y zaYqQX-!NZmC7ja`+D3%cj_jtLCz{ZK+M8rT(XDPgu|>wKtAN9w>xuMKQkrR;L{a+Q zXD^B<)m8O8(E)cZ_kryN0$)V{%SC|6uuKqc?2CB73u}dNuc8w>PrL}#(1I3cuaiCN z)H=rZL^bdAi$PQZMRgjE*S6gb8BFNagWnTNEb z2?7}*_QmXj|9k1K$9x5#p#Y)Ehp;CH&sD9u>WHm2p~`x$ujT_}6ZpP{C?Hr%pqUdP zS=8nO@rWxFetp_fO?Z%vCgw8VQ|IJgW77-6HjA}!^Rrvf2+yT?4u7%7Yx$|d z;0F|ArAZL_yirePwz2j6lRziM*&N30l{%Wq&yXSe9xU+mFF|!uxX5l3ItSvbeH4xq zQ)uS;vo=^4bm@=HG5!PW3D;!|!XF_0GTKLd#8OT=7CVvgok<-=vZ7f$#Tw0nr3z4?fuJJSGtUrP*)ah}ZxS zLr51xe+gqK!j9z_8pxAkU2B;a(;~fzvp*RcFn1TV?LO4 zRorje%#QA*s#>W)#)RLJ*^qgSjY@+omf)7U?y7Pac?9(AG3oJ4YBH(uUl zbM|n0(42aJaL}k)?bI)SLl4UhQqF;3?mMlM^Ob>>$kN&5eEzo_$REza)zw0GzSZncGEDJ@hc|L- zR;jMzh_&Zj>l6O=6!Zv=X2!(>sv_kl)nA)1YpgmubVMxTe29r8lv*kHEk;%s2Q}&g z8lInxfa)ig3!6>NoR}^4g#i&7Up8mB`ptV1WIYc35p0h>6L=FYvv|xTy=0rLa~7`- z9E{DK6i3q|SI3cT1>ghMLi@qICbjfw*8sD!wR&h@nIIRgOz$)0^asnzFWD@q3x7_V z194N((|6eI`+~|oos9(p>3FY6IZCZkBmP_IZ`Pi=;x+uKGo~k9Ds2A^Lb}Z<7 ziIi{;djxvW>JlSgaWN1+(Pw$es$Z$dE=|o8`hg@gp*k;($3#C!CCiRUVc2=oH36k^ zEQOt!k~8`;eL**Gh2FJRTI&-bT`KJnrGk(QSX<*9YvyT~6EvGl5+c@&?&Zr5+fuVU zbK*lUdAk-LsZ-AWAf?j$KyQ*(`{Pm^3}y zgs&x1E(V;HkivCOTZeR1M`Fa9R9V)ns9xZ)U{moO&)$?<1^0Op&0!AbJOOp%K=|!O zS_4*;_$7DRM>6i92W+%LzIcRM95EEe9_#IpWr$`N-w%UH_Sz`r0LXVWifJayO)Eeb zSIHJtkv_nx@Xm ze62ouyk;EI(uckP;fn6D5HiJD+e<61p$t3JfzuFLamK*BqG1N{+LOW4Dux>!v1$@= z4llQ7DpF|$|1{C!c_nmejKQdByu3;37qgnidfgUw7n#O<>=h?28HG|7v8EKP9iqR1 z4r43I6~BC8U@Us^a)>ZSbI|_6o!*|5tug6y=QBT}zE~j#{-4o@VH%YCU)%sWQ(lL4 zHkR5Z$75pqTX*wM5I}SL6jEj_M)cI(v_kb1;&yCZjNn}?Qo?+<$DVo$0_Xr|nKYco zEI6ws-}Bz(%q+hM`BDU@X2q0p#=Exb1OByqaOmKA8|j%$RLgA|EkA6zaq>H@?y=6z zf&9;_ZN~S6a?q*Lw*Rl9o-X8#vZm_6Yf?6&jx>!&v1V37Y}W~V z-NEXe<@K2BHFUX=Ej3Q$?wyx`?-x~P=?SO>49 zr(Xt5WB!K!XUc>BZmj#iGv@r)xNyV(scyM^m3V!U2;j!h4Gm)AWF=voGy7kH7gWxIp^0-{B`C1Z)D{*sve~k=@$%BiXei7_h<6zN+4bH z;!E)57jR(&U7vr))K`DCA~Fd>F{tQ1z)1oqGki(`D5Bbv` zq#p|~4%4gnt*Dag{RE!`)t!6d8-3rj?H=67TRzx`C!oFMF3xwvPb~x1 zw+@E;?(S^>z*ZQPr7%Vkw58$PCM4<+-vN%gkmweg;kNx0?6hyz!yv&`XJMVJ5Qhdm zA<~9wL@VX~im5?3CBAmytvr&m zSHU{tGnBo1Bs7+v3m}j9z#@&b08r!W%e=n7ThoqEusE;@?Euk>cd~`TQ^_r84zfKF zLa8>jb>qH5#RD>e@EPOo0^b#Ed>`K!^yni&#N+1&tq;RGi)*C0XWGk->-+hmTWAJ3 zg>W#p5`c0}rYf?I>nwi3Rcg4eE4~epQ8y#nwmo4}7E7uGO^I;)T|Oq$Hhf=D z;>SFs`OVpVpPqP&B^URJ8_`+hrz=wZ*S?&z?L3wr7dc~6`qp%o6K>^oxM$eFW=#B| zE8oZJYFN$==Ap3SZ^-{qs_}mXvlunukaF&U>|^a0$z$G{XrSDKi6Ltle3`d5lK-X0$bJ3Xy%3a<*?SYk%Kr=! zfpY3-*zjs<-r6#F`0kQ3l`r9B*Sqs9B9s5r%@+XlIi$ z9Xg)=K>C^3w2Cc&);b~)bPQ-sV2p6MR$!WwiGxM(%pzdtJ?q_%fqwcpoD?c8yDh&E zniVAkLjXYC&UD#0U?qd$f4S&y-+a$^_cbYR;5WY?&GD*dFr{&*y}kxFC6(LFDf|&@ z>W8pJnuA+qAPk!X&P5S58l51yvDZ?xZ%&O97|-5;YH|f|e>A0vHm@c~`3_mqZJC@U z>y+XE^Sa@4bDUrXy_QS*#gquC0oe z;|)uS9cwo+PMWp+K1%n(j(DzVG5OV6>YbAYaH7~Lf3gU^eEfPzht@y!eX9TDOk5ve zg%g&B?*!~AeTf@$6oq7ocEM;J_N@Y*es&@mX4$ce9o4F4r7k)xi1Iq!ZT-#J_MO7P z>HFYgcIr93d@vMWLydc!*-_wMqGo|#&8LW}ImV6y+m56zXf@Y|sih;Ywt43D-B@09 z197TDb&{#GxZI6LmpHDJma3GwXMoQM4^ZaI`2St90dy{@9b9J9yRKM*AfCY}NoBHc z8+HCq-kiK;`=Gh$$!SDP>0WbynB)}*Y?Wjw3B8XHK2GTh;~M7Yae4d4A)#3AW)db5dX=0?Dgz6x?idXHD<_fwNPY>_b zrlL5`a`oXPDVJGEo`Mtdn>UO`LV+jlISJW3y)R^7@>KVMYomCursKe~#o`yoCQI`> zDny&h*eXxgQ$I=m$@c!Bh)5>ZBD=~qs|Y7fB2U=vo{tn?Hn{^5*aZuN<~{_7J?HRV zp&WlCcsx-CWjOLTE~AD0dvV&(i0rD1JPxq9l4KvGa1HStYqb{j?k2f&kKwV7;gNDp zXcoPVu~tB)q^~5`_jMrr#PPJH;I$y52{CJs7)g?lu+^f(dPTy7d zmYt)1)PJdN*RxF=T#f70$9!z7YxP^V8eG9vQUdPd{|{|%9hLRk_lYXq4T5xo(p`#l zqXLQ`(kUI%(%mJ}QYtD)H%fPhNOvj?5>oTM_wzn8=d5{W&dgfp{b%pJmTtK3UtHJs z`sATu6-odRk12(GhxlCr%ETuq?7VZ{CRrs1l=jICf60dni1NrtO(Yzd4N1zi-;)uo zg}4+1klP_?VmnHuec~SMkxTV2$S;s6zs!rh&o{R}2BR9SrLZc|Q|jc0%bYwjQD5mj z4mdUMjwzln^76mOwsdw$eTmj5CYfS5A!~Z)@yv{R>CHU;pq*AE!>?r%L$Wr%*m>4! zwGuDUIhXB;_cJ3+$+^)IF)3M-qKhXS49_*)_Qxr^nCrCc5=YK$bS#Mz*dqK>X$O^)1e!yU8iOX2NEe^Y^zC-+opDkA3qP9>$fHO@W#T)Ov0B3vZzgo_mC z|0Glpf4YMFly$i{0sUU*(xxxU5BZyuC*8$iNPMjCQX_KLt|D0_kj^6fH--7lrh{D% zokA*aN1IReejc#UN^g#6{{1jWid!giUwW_$T}$a#aM^?><^+-|DH+;VXT8>P*W%8m z_kFN3Co!PX1i>MW(REA7ipzu8Qo=tOUjwJd#Cp4>+Q#HErcv}m$?8KiWtDQqcnBb#2-t+S7v#Arbrdtsha3X}7*UQgYt&)~ z+S_e4nktGx{1o{YFc|fKojoD-P9363o)*r^l83?SRENIH@$xc}@z@^SOQC)YGf^I1 zRSSc>sS&IU6;CcdQjNUUV7H>!6n~1R{xmhA>>_l4{jKF6#kwX+#IBJulr4bCw z-iE(r@k{5W9w7UZ@jj?o+Lb_~wJy&0YfwgU@tJzfm5DIwaNLR_LF2Xd0it;|m+H7Q z0X=tE)4k7hnR?ABd-FO%jn*LiUhw-q%*y@atPErx-jbTt>>Kqm(&hXPI&*A|`{tIe zCK_h&^wmfw_UPXcxOGz}r>JJ9aYN|SezJ!=wpz0#CiUE~Y;_4UF?;=xWyXQZXgu|k zWNIabtvHM#cLwR_r2Mq$Q|^pXg|B>!DJksVwMh}5jQZg`#t|&Nlvz(w3G&RRL^yX| z_gFK}`4~F!v6RS1SD)fj3Pmv*YSB5Ex&k^-zBlL8%Xx731SGWKwj}H%2jn2J+`l>v zx?1KWI4_1{m08VtW%g^x9b1%QLC(`ukD!ntkKGZs9$=nGLfA_Ed$Gl?wI|eUnv_Bv zj6npp~V#lmV&>r^(ux7|Q0Gjufe_+aQnC}=v^JbZK7HY9Xnz&mm$du$?vs<=0E zMux1yV<9Ocpmw><{R74@psNsp@vcF~7CN-#CGMM@Q?%sShM}RkX^p_iv z%hK@h5dFGOeyYm$?r!U#IUg@m(jzC^zHR~k*LQz2h;uu8K3G8FRv!&jCEHcvUDY3Zo}^S={s{})i?66*}04 z*6)6A`ekfA472_If<6F5`vgg^E>MYukH_<$X5(kp)Q>ZoTFaVD}xm(NXeh zGUY|PL#W=klAuE;MFt&Acou(Qz+d%8r@`4#!u*fa}eP%yqaWzL~0&RsTkrw#f%$6mhVuJ3;O&o3sN%PR{O`I{pdm@%1sGnM6s z;w=k@fD94GC4kEyL&f2m^zP579~ESF$ulsJlY%iFCgI)2>#HBy2D6}uMSBe*6<`oM z%c7(Z0^p5n`w!PZ`n51FF#-B^5OxZh@XM^9jUc84!}fE7T{!T$EfYchPa#NXR=$IZ zBqVs=qVacf)^C`=&0kFd&D&I*ScEF9$+N#;*JxIVUd9FCIuIsUtG*}+Ac9|OAA+;~ zm+@K*lA#j&5+<6rdPYgif<@-#rS1;)*0w}}$ zmQbA>pZj5rlq{{3!>J#fFF+uMaT$I=U^~C$rl-zd_+CK0YR~Z1DKRQ8MzkGaSe22z zs~5dXS1t8R$Mm-`Dgsgv!k=Zd5Ob7DH)aX!ra-;qP!Iv)PC_GL`Do5{2}LTA@T?0l zwIm>fV&To-;6gIUMSw<-F15)#A5jLx!?k(G9B#mJN_6#%Q!l`T8$ZFgUdHaVmTE!% z7i3l0Okp>MFIQTmD4q7;Td2(3aP0R4P}AwKmImP2@S69KC9?&)UTqPCKzdKZ(-SV( zT4+6-8{8;yr5mQgFS#_>_f9ak#H151%NR|$OHH!xD?)S}--D&_q9BPgMG3DbN|i^u zz4cz;g!{f{9A<*;NPf7>P3?rUw`Tj<57WW(PVFyLdNL749420wr%_OH#vF5#IDuyI z`Es!M0@f5S$c)d59iQv&7pdy!hp1}mIyU!d6NUMWNjoc{h4jE-GiYeEA>S&GE?n{o zVP8+K?LpqgaJk7$=zEcbvY8`x^Kpu#@Av%>|MX%MB{n#R+~o??%(XR43lh`DT)-2p zmO;)co?VMhp(8L@$?lqm+A>8U%B zR0VCVW{0r+AE1OgDWOGq@(y}n4Oje>G104e5oZOD5*?;pMYL=XUM-Fc8A{qu_wO%> z)d;RjWujOV8H`Ez>64B~?W%a9Y@2BQd#9k#S)UE&X}LPuPnzn$!9NW<-2h|LP$AV@xAZ#hI+WOI?bh*I}v=^i>m7th8Gqmn@F-GApQ<-|Y`ip%9*=dX;>5aHv z9#bjV!#ZLnF$V=t^*D@wUG~bF$yW!`slq}>ZLqE})0nUwQJs`X@hcJk@|gYW2}@j| zZY7q^qE;rOq<(M?s?WPiczQ+1vLXQ>Euy7N{f$Z1^B&cyW$yE zjWoK&seGGN@6^!jbVp7Glgj+qqNh`+pz=((KdzT6sTS$_gZ8##3saa0IMejwk+6Dk zk3B6dQGMwGWNT3#M0!YubMT=tRj8rBIyS`B#pQcJI4lGnfKWX?dhCn--;F!!aT`t4 zo1#(-VUPQQ9wB1n=$Q?6yHB?y88c!6jJnIjlpidP`crn^@QUaj{EeHJfyOu7@JYT- zaT=4zO(0G>F3J1b$DBbQdRy!dZE0GJFE&IZUKg|@if35`;sRiEz4)IZ<^P`>EHnEO zWx4cYC)(OaH2{6gfmXGt-Be--A^8C;z&{cp*@1T~`UD^QtMmQ0u!lfe%82`QFFyjm znmxT7oo)b_%=7y4Bz-i5vb<&Y={1l?WVhs$G(f}zL;;l9XP}>fzrnupx?v8I&|uB) zS-1XL+u_MV#KaZU&9)*(ry+IY;58ubSY#Y28~aHNXmRb(I;97WmBhdyjA2eVbB7{K z%*ej}jts5K0&*i5@Banb_3c$r+BXaz`!LLeQ;2mA-Wdz&xnbS~cvyERU<<6yzOZTF zQHcCg2U!N zZNd%^l@JMtx@+e6JjA_0o!Oh3fwP4`e5jD|Qq4k8N5wuMTIx?1?lt6mlzn47qeeJg z#4#5Zx^$LtW0)22Xe2g1+XA1#t6|=kVkGfn`ul6~^4VAJ_PbEf`vStlN&?_!FicIs zj^V@n7Ib5o#x*pn+vO0}#~cYC;lSUbeN-{KVxnPO@*ZlH2d7@mUX1vTJN(|q0xk+G z_Ao+#QHY+lM6E>U*du#tIGzGn#?_PT*r9pANBvVsNk(>`&bx=OqVb`c#1lt{IV?VG z?n)XUT3kbOlu=~x{_uTCSbgT@c_6>b&I}Z9VS?_eoSxf~O*l_B9xJNamthqwUF9T7 zox|ycHxZ$2hoF!=g}ms$U*yG6knB*13ZE9;zSoN;-J^>Br0`k%!23q>e?n^Zg|Pg| z39ppnMPve~Z?pn9IG4SPs4m#)r7*!bg3P+v0#rEkvlcT^Q){|Y&|F3OyJA!OT)AQ= z`QI6`Op3-=Rr-30%pO+6Si?(8?(V|O7>#Lm(#)==y4<%95Uw4>0rn{@i_#*n!evrJ zwkM#+k1=85TP^Ka8O2_S++6{Q40h81k8dkeE zptb8PYH2~G-MCBcG{LzSOXdCeMWqn^hv57;*tX$Kv7|uJ@=oi$M}kH;5B=r0k&vEk=dC_t76~L( zd)e8eFdimnDGxwSPFA3ZK(;HJxJNL}B>HDE%jhP?v9v5>+q~CpU{RA)Q}d_zLh_JH zISNA+SL}sEnezLtut1Tzl*Klumb3~GB$j@Nwip=wS$JuAkLaO-%dat|o~A(mr??ML zpL>;fIW)eS?uYw#KFhbq5m*i?AOS&YsUqe$q|vxYAJ#RlKD`doq8@Uxb%Rgpa0?y# zP)pa@M3Fh%zEmk$M2e4D-3!#m5j)aCL)Y!_zvbz#>=etffWx`$kM_MOxldd8HS;{8dX(3tXE+9 z3@=}>Fw#Q&Z7%g-JN3AO+;7PNCDw_ERtC=HbZy3>!1)3hls*jY6 z(szP`dvm!uZe|YD^7MCUb5y64y3o$o?t7Qg`=xjU}7bl^~ zQdLNR#J{B^Le+4f7vF!{v5=%#^3Mg@2LVjhx__{XPzLtllkBejf49;jK)yveN|dQeLvp6ef)zwdF0+Ul9MT+ zRS!Z?qzMdLop=IlySkB+zwc|YwCsBap!;pdB;Mb;G$b)0{jPTg%}+4|;hlOTF1yF&Dii^(4 zU^;E4ij$mHq`^s*J#Rxi0;xp4krV;_axbKxH_@`vGjL<0-M8XPdIuz0=>gpS(2x{| zQoBDg-9y2?Hv|HlBKCu$mFD*qgVN10J>p-=Z>{nY85}P(1fzYlB^2TH7u6szI?(1E zx}9`rjZa4I^XBsYP{UF1g;lg*P$@&vaV;+cO4V&G*$|XmMw2HUrXY-clshzZv%`y2 zN`?ZW8LxZTJZ4;-L*IQ9t-vw6$MaQ`0v|Wukk*aSJ}wO<%#cdCH(!ibZG!6UPc)o# zRoUJMYAb3*`h|-7M?zKZ4H>2ZyiqA=BX1Q&caz#s;wB2iN80w9@m5b0!ddMLF1i}{ zKKYTh-c>$M*rDZ=4ZZcC3iK4+E@l&tCBi3vRr$?JeuGlWiee%7DLB1<{&A3pdoIfey>v5KQnP#LR4q$u_Ll|sN5S9x&nt17Kby7Yf8Gw= zMiSb{5OwaG^w*uKrDb|Ppd@`=sQfs|J%{W>r&$UgLKp3%GF93C7osou9g>~@;QiKA z;}V3|N2U1GzwmFDNpw@C3tF$a5yB5RP7n|F+8lZBe_g@xDEk2<^A=)PkA}KC3t7Yv zGRk*duZ5ko>80l7W~{E1PlPJY(w~+!g|9ORmTBwK+D4*#~GFt2R{S-Jo9_afj4A+2{F>f8WW zuz~TUK!_P!TrQwug!NKR3=(85!I&2NmhEvs3u3T3*sX0E*-8v|J z+{UvWJchy53l7>5or`dnPk@>Hup@x_2a#iJOPfU`sV1NTIU4XC+(1lWaQRCcvhd7j zTgLV4T{-)mK=H029>K}A1L(*PINw8e5EaCs+)YAcppBnr(w8t2O%C0rU$HxstoT-57FMoS;PKDZUc zyU*5xFaewN4%2sW66T+Q6t^iO#pADymuupV>ve5^eExwLX0-w0DAj+3G*@93_;3lB zE7{BTZ+LZ80KNbRAvskkz#hk^(p9>KBjCGAF-}-6X=M%zoXw{ z9f$?p7Txvot;LG8;GjR|5FSYqi28s*j(N9i#vm)q{!;X|y_qM>T5O`x@|@DNh@?pC zMIh3)e3Q#>g!M9!`5Dv9&~eFH>N~E>YL+LJUBm?+3BDTaz9A6d)DhkIf)`gvc;`&x54pVXBq8s}l_&YvllhO!0WUUG-sI7@q%| zqqcH=u*uPOEB+O&>6uQAL(VmO-2+gvt_hzSh0pz9@!kKJ7qEq9)lzj7zr`>_3R^u8Unl&qI`7BHm-PYw)k#rkhv zkN+W6_W-D~H}h-oMjfYXWKTyxWdxB^z~C7^-%D(=#fmzZo-^K;P%kE-EvozomN~xu zw=Te}_T9>hpy@;5Iwdi-BdMQ@Xiic3&et%ci(JG%c&Z1PpTgErr=^KQ)i$;6*gY){ z$s$3TBr3YJZw%=pSPfJP;6vUTq57QbnBgx0lB)252TE-nsqz}gx2d=HYXLdhAUx(i zcMHc~I$7KLi>08?&N;P>w&^fSR&Th_OY1{dxzgLLE{k*XcSV}9VeKdf6^cnkPjz=a z$H_;E%cA~aW~JR<9fuKe-wLR4TbxwrColIBks@CTzZ_F!pm}72mp8#<|To4?sXpG{V||g6i5}0MnLYXq`C;C?-8r7P4}?D+@|A78CZYRiB@8#gDhhQ z8(b-cY{)}P!YWZjkG!1m{*Vq>VS27~l)joJ3k}fCvFyM$4YZLXpBlFkj6`AAWG9ox z%J|&t}jKeAgJXooru0=RtjVR3nz?`1Be~*(!n;Yk;NnY#)?lbtj;d)UAp9aJN-=K9447L%E^%~sw zK~Ykq|4o->QS8J_y}aK+mq!A#$!%$(p{QUpw0@gKl=>pHOTmK z`U-`w_(<3_WNlRK<^V%ye*55?T^THoi6RIKbelY4S^)vx<}I3CJoWp!8zYa zLX6=x3A@Uj?Q#JHO3wTDce~$qh(a_gTq>lPWgX2;j;U1i&3d?5X-~l-j5Vl%iYX}@ zAg~1(1yJ?I2p_bVTRA7POE{S8|R-0D~PGGY*}+?$zXfq>nStUqdv2RMwzDjveA-bz?E z{Y|W(p9S**-z`xinKm8lZ8GJ@PB#%Sv1HJ+FDh?k=S-N0vKt7DWS<(JkAHq*4Pj;o zvOqW1G>AqOs15u-Qm7?H%w^)SdDs)}Ou=F%c!|A$v7f!+;F0K-laAm2f4PL12#*3} zWe0|Kpk?z=#LbClJ8L`pK8U4O&6TCQ3E#?gGi+#~_^Yr4<)ILM2$yO9a z%@lLHtb)li85cd7+ryFnb|_sBl9iMNG$)2NN3yFuKMQ1M)g;#mV#8q{Iu&3E&Vg+0 z0zS@+f{GQDWzE-Vzd^Kf80t?(jxUf`SZ3OGV}XWdy&PCujXdE^aWca^w%TAg%NXJH z-W9=Su(W+x|5m}VjX>reFAi_>)^d(j4|-eXm?CXB$(K0HSb4>w{Fsmi#!YpH}&JE?Z|0>Fa|w}fV$ zRO2xO0$Lp*!2{V zcP&L}e3$`YO&jkQYiFI5MW+c|Do3Y`?YY)Pk;t33{i!=!n$Ue#2_B52sdujmU8hD( zxHv8OpLKi+VB=N-0sBBco;dPTjP95}f$!O#C*abpBRehl?Ky^U`7U%Vv934zu-)LM zS>;K4QwH)jr?$RjCxJduG*(dv`!Nj8(_CSX(M-5FBzlT!;~<)z%cG@-W2DTsO{J?l z)c|x?Qjp!!QJ}if#HR*5~pwKa--(J zxacQ3H{IAnW1VhGGE=Q9j39nCFn{^yuusqqJ!NX|cIOLTq`tA)JEFK_EOMB5NTGgK zyOf-42Vv*boDc0ssFS3sG^?~I%$4UKn`=DS7hMKKQp*V!p+%)Uz58Xf=fi!?QymZQ z^%Yv95#w8|9#g&JDVVhGU)T+4a+NV~SwH8m`Hlv=(v2U}$t|GXy4Bueay6>#q0z~et?#7 zGHcdtbHogE%&Bajrw3fPBFT1nR*kbOoRHr$Sknlk2E19Q34NTTcTlzrI_}`sOC{&?SD^%Ya8WOkiA98&lPpFCa zw!}m9yWu^P7kzqNpP z%Kw&KfSQ|>|IF6%U;jVx4Z>Yki^*#NCL}@W(KhUfb1Ne2aKT8JU&&TNFCYDX_=ocQ zD70+z6cF-*PfOai_m-i6SIG_ObCCt2gV!qh%ap}R1=B--wehJ$44op>UVqptKR6xu z$840DD;N3eA9vCJ+}sN^%gT|#n+)(0v)c!s-@Xbg;PZ8!k8e!urGLQ2T4s z?Ky`R0|vRCzkS?}C`VT78^3+yeYHs=>Vyak-7W^r?!}4kwOeA59(1CBB3QrFI2i0w z-7F)|YE-le^-t?J?OC;KMZUXjvl7oWF1cxIaTp2h@TD$1^!+<}u-e;iwG5$SkX!GE zaLG?9iqXDVSr1xqmv>%*~Rtn~+D%h66 zzOam_@tcO92e#`!AT5wfn)g0b`|7eg_ZnVmaO5l=f@QKE4B$R6J{~~}B2+J)_8MHZ z{JDD%c&1YjPJqF=A4c?i^}5E4K7-tyCf{q@*t%e~m_5J}Ah0)ECKL~p@9-?0zzlY< z)_;zu-+_n8=hvr?$Gh{_P^eog3JlMhN|Y zfw&Un75UfO)$?l%D$>9_%|oR`^_=GyL|j~&AlUEOJ|&KGv-V$8g%rA-L9I3u#y(^LaY2#dkaW9*rY3=SOQ#2@L#K65GENfD9H(| zg?2`?p!CR;b<5Y5OB?smM9j}YmJ)4|03&%?q&JPEk2mNz5Jnq>(~W61nDW@?-tjIs zzHGf$!`?}C`SW^6k0%vb?%2?8aCd~qA3BEGiy^QE()-64Vwb))M)OSYd?){X++pJ#0=gvSYKfiwTRj(G& z#B`_SCWZnPU?FpG7zWaBtMMZUT#mI+s5@`0D+Ud&XB;+ece7We^C=lQf_1(RZkVaR z`vtOkc*vz)eUGZwS@dYp6c4mF0Tm+wIiLt3aQ-;0c3Ukh8zc&WKE`fkyKVuP}KO_s8B?I*xxxkcBfosuE=v{&`u#De%AHo;v|H;?RD zJ%t)k)>M&%%>;&jV*!pJOU=5zI%%|@EMx9uN2KHl0Fmc3swSMXoPPn0jx%oauVe2W zlW&6OK2`JfKcB)wq;GXQwVPdI?}^4*n(y_=c%(s!MyYmvp#8wfsgA4A*aD)?o#-azw@ zGmG6|bLrCAF?j6-5RYT)zKz)PnadOA^+PA%r9^8nP zd-Yo3@}G4Lht%%BZfjvgYYxb9J>>d%pB#XK)_uYizZd?$2h1 zKjV7$-a{;)9CS1-nTnMUGbMPpQ@y@F7nNM1fe)uA-!NDs>9xEo{{4l-?w`g?5!W{O z${Eyc&uX;$y{s>_Y1g{btN+;gN`8Eh(^L(Bp7&@cSUiLivEXb4Am+1$C3qWVAnZAj z;d=M_vJsA=ACD9vI|o!=7JMpja01y7+dJ<1t+HGCU71GWpR|D!?%Vtb?VvcUd2CiP z68h@8F*H*KC41K!n*+Eiw;C>)XwzKIU#0fElCi%UeRoyieda_g1HEhpfUv%V6@f*Q zYultW6%43n9aPtqm|!Q zs7=EMrWnV(s>5Vg)2CSU`oL6~6fU--t+5ERq_)I{^p+xzH5^T3mpI zDL%cl9ID@1s`XMOeSPRY{j)+VGCN#~Y!(}P?2S9)`e*7>no$jWP z(W57EGUE(>$rn&nf3yl~>>Qwmh!wJL8`wNs*tXyozx*GjO(Qto0~|SDZT~J`2V_F9 zi7Pjk6jKV@c|&$8w4Oobn*`Kw(ibbxcm(Nvhaeo-cFO$XTL2va6G_h{z$V3y^E$w5 zl4nh*=m9rugo9)=0GsO>I8#pdmmg+|fxER5!15np#)K7ygtQ0|@yK1b!7u`+8J1W? z9T2=}`8!(4E(l4$O9gAqc{toVwZg{W2HwdZUtgq0v7z0vY64?T$*&prC^nD;3UDUPkcF@tyT_hRUUL3E?!!@uEkeKu&k zznZgJn*)0jWT>WyyM29}nQurWLZ80ISo+!;i3eLjsLL z;GE}gPOT_R+OCZ(uN`)c{4Ru9&2SP0w3Sh8hSv<)jc5J#D?BT1jpwt7T`ku40(o;q z0v(rGEg$O#@i)uS5B>k$TzW@<#4%}%pa(#peScDTD%VUVWSy~7igu)*#;p^k-#`kYTCeEOEZxFuE_2Ikg3uwWyB8Hda!>JWeRVn;M1dnSx1KhhiL}&`ekzP+FUW6Zy zOM{84?1NJ&C{@Sa{@8(jp*&nf(ijl{L78FE4(t7Ii?^vCW@>-!DQ!i^+q(MxV9~e; z{l2HVe*j^u;rP8JM_@FjBD*?4%MG_b=x>*|d}ivpCG#z~j+zxub~3=L2Gqta&+OBy zq|%vT>J?10wqF(KJxIs5e>J_*LQXsczJHa$c&zS~wQO7zV>0}06qvUb zHUKsnL^Ya^&u@B6V}T0 zUB-oTCJ6w*S%{6hZ@67XXOyT9Ho26J86|&+^1YXkdvmg%ZU`QNwyq*T5{r;1G5FdY zI-J(E?<1r%Hp&H8^Ve5yWL>Z*mMrJE@qCMRTaamt69-=NF9DtS`i*1^wXBPX@W#(#+A5}mh=wTzQ+qrN z+~Z71!a{9znn%26q}2Pqk@T@&b1)9>zB>zl9WivVi0@lc-Tk;5QC4Gh!=4+QwNXok z$hXls6cTSl4S~Xf&h_A~@ymw5+b8N}CO>17b7BRM0?1!P4kdZC(Ol8RQ1y0w_s8|u zGes>UWo2yBzCTSVQePrWccL+dh@m_rdj;w#{8|4}4mT$!1~+Ak{WqNWSk|MZbTuh` zHjZr5q}qDTWu`<;JO0~9==Vtyd=_Wqzm~iAqW>}*?6g@VWmXHD?CriD-l>0gX1BUd z`cmJHFWCA6uRpJ-=!Ygn(_WkF6qGXpE5igvwkw4p5?QIFpQ!hYDHc#}JWnu?BY7m? zbBP%JYb^;Qv-oxJm_zWWBfL%6(TQXowk>v&@RK6m!4@LZA|mj>@AS<*l#eU^92^D0R9occ ze{V*=qN8E5OH`wI8`s@KMd9#onvAV(7E;%%&o+r=9y&VONdojV3e1vr^Olmgxb*8h zN4bVi@=;uw*Bc6iAljuHt3iW*?%lX!3xWH+c8>}wmm8sLRF0>O?GHLU-yoaGs>NVW zxa2uf6&<9u1~p!+a_7flI8_bs3XJqEVlpt??$2!Sb&h=QR}a_@+Z%mr5LR2 zv5NSSD1XgJ#{-fbXZn=BYO&Ul<*;iia_Hzx7tpcNJlkQhHas5D0|-d&t|-^~J=5)kcS4C>tEj!#Q2h{V zGY#2E;ZJ~}iW8dT+J=4Pu^_d8=r70mK;zY);!@_+Lr_!4Q^~iM9lnduPTJ&Qm5_ns>lFJh~7^OXvmcL%g*{i02Lur>yD3vcltd}0lr{atg~MeJ?6{c z%`{bb)d|I25=V{hA7bdFaLBw-N`4!c-woldsKL135~&QpLFs#5?YoSDDq7Wfn=Gc* z{e8EJ;n|yVZvEU4q1y^84M>YoS!$%k_mC8f_7E=jtAXEUf5YBZ6LzjfBpj|RRz}@Z z>bvS?AyxO%%~My#yn2$ZDV*%`y5&BT#cRXdf$vE!r$m{;ac4$( z#|YGtik!#d7BgXdUcqok9Gt9gV(XzmUyPaTRgw$$6VBLp1v8ci{^CU@Bc4b3XSP}x zW0|!}n_HntSDtYJro*=Xi@{dZgoN!;HsI`__2;$f15!nXUggalvjMj7sn4*jKm>*q z=>GXs7h!U9yJY{s3HFI2uK+EimAvIkJ2rLUe-{`g)Sf}22e`F=y{B26Xfr2oE3xM96wPN_|mV*Bm-|m&}DKir~;G z1V?oskNL%G5Sfif7_>fDXBIy-J>aiw@^$zo%x=?CLR{Kmb09is0<0UE6_gL4axaC% zYX1AZcDz4L%Y&t0rbS5o@AktFD$0A&E-m@t91Elm+?0bxnn2l~Au{a9SFg*ge{xwd zhg`v&dEcldXefNKQs(U*Kvv1<{MFZrzAv$Z|!ZSX8{%30v z8hFkh{{r#iS9l#=F5%<`nSf@R#3iG{Z9o!@GpQX-Jo{x-M(P`rlzjA}^C*Iu<~i%u z4CMOmg7&j%&5Q&F_*wvSK-#;5P!^De9Duw5-f`BxRG2{d`gtM{`Sl1tFI?I8s3iv}>)rrCp&n1c z(c}@^u<`L45YbY6?m-rlbqREz9ybGDx4#9`@*}LjtAO1!!j(S@-H;N$+qjjgF3;i@&84 zcK~b`=qZ{kgl;NwuF<1oASw(E;;+EY&o1<|T%4wcErdqtELs`Sk@1b)mAQk1lTiUT zK4Y!^-&lZMG5v3?)MF#U_cydc<40i@^I`6UGr4?}KfG16_q``*@5-bIT9ehw)X`NG zS^Acd-}9`fxKo+vKvp4%YIre0jYE9%_nY)hOWhEC>!}L%b8RQnCv?_iH>Ur8>i+$Xjv{%YDjAk0h;=|j3lnF(zZ7%MJ5Iz*e;9C}iRg8C7N8b7Kz)CLYLg>; zG^kxIeulo;`yIapUgQS&lQ-`>eH1G+a$%4O@Z?punP{9*wi&3|)D`kG>q*M7k6M4| zvC}(W^)oRrR7be&Ra7bbmgV57_u|^e6i%qFnHx@ z&A=ejp2V)1!rCX#?!iD_tZKzBdKlywOByo4Tbv>ZhhukGx7aDwq%>4bO2^xYi8bTB z&3>WLX})Z(mS{oA|_9tu&HL=x_T)MI)?*5#E~|LRS^XATiR0 z2-zYP$y%<5_8n9p-GIxD<##tcU(W03#9{@ zVSu5n_$b>8Ltfz&JA7-((sd_OpbPpNyRkQuq!gD(!jtkkZwRv<7$_!=r03YmwVzq7 zW}*HFNou9}n0D+_k;U7TCVTT#Rc4qSXBbYL(>DnHzq)mzP4YSsIEoJhH)T70@f((z ze+|1PKwf*aL+EQUk_t>&6ww0wo-&~*ns0uDS?J(fuE5flNF#fbZ3P;un*J_=_~mt5 zcQ?spTUsnG13C-26i0cKXk99!1j`S2=;XvVq(ithjREuHtDz`Uz45zgTrKFyZOYI< z^$!BQ$iGpRqZXO5ZybO6oka0sfg`~xJUF1GM~-)he8)J`2w9m8dCi&6;%FsuWfpRs zv_fYYQu!P{Asxr;KSkwhX;40@c|sdSGQe99cDDMBj2Ndk(x2uwb-#NlBN20NI@_9HRIb?GsX&G}aMeCYJ(wpKYmXvk^i1$$PgdVz++gFrO%7}Ee;|d!|s9Bhni3~SX z)XILz-fdHjp+PQWbOARF5Osp4lQ zqE>Ur_uD9M$*Y1bxlylTSHpCGf7M1lr_1&tiQWIj83=PrNabWFS~X~T{62K0DuwU_ zTQVrCzobg<3acJ8jvZ1ae@zu^l8=)0rbMJlvE!C?yzWG#{~-!Cv1&^=bQ*98mG|0b z51aMbS~=88$JW@t-zqcpVp@8^D^9cDuSVf6#juJWh3@-JkBumI5S`?vN*t zpFB9CybB^>#13tW-z!V`ayxHSstYug&_A=9_*HA$j?wun{zJA+MS8T1S~`oF?1^oy}F#@!(OZHsZIdNbq^h9rc99Cnpl zRIslfuQWy;gGxR`E<(3=Q72Q zt7QFyp81n^Bv5lwof6-c4DXP?;r0mCF7B`L`82bn%TCZ*${YIT(~8XWb49nv^tYsi zbrX&lH+4Hm^<^COJFpqH2QW{hKovzhDXznlo}Q)wF!il3n3A09UY7 z_(Zm&%);)EXVQ<@=6ZR3AC*2LZgRJeb~lS??vZ}}|RTr4( zKTCfjr|b2}<1pAQn;NL&Pi#6*1Ja)2PsM(Fx6gkRd4M2a6D8@ZcCmiLy=8vE2sRZ{vo7s`*WG>pU{sz?(+`tp$BQ=jdxc3ozc#_Q+0SnPvO(NpGvk zsiGcw;l*&Vz7*3iG~D}85r@uQs)iIdJXE?!Hb<@HV@WKQ)G!e<5t-x6D$rSogGutP zS{ypf3c8~$H_FOyS7d3je0ZHZ>xuGgRaG>)GvyV*>p5EUTx`9KM@&8=`ARIkre-!6 zh~0KqB9WF}XKZgH`KabFY>76Vzng!{nOi+ ze@9exy!BB&B}l|`ATAJsfIwK~3bQ6Cb;qL=>ivj0NbuhHO?1YxeQviwjhXiFm z^ahs9!DiU)#fgvmBp%QWfMi{^r~Uw2VKZJh8G=jMc2llOfmD&SN9GN&sEX2?zV?6u zKmq}kd|IqeO-S4nEN)-BN=CD?4^Z>oDMc)PoT_0Q2+W4hm*^gq{18@ZV#`hIR={)2 zQ>{S_qx)^drmCv=`vKMl26ZiT0wGuy1%Z3p>9q@j$|!aP5s(g(z`or~*yufTf*BK>AhaM1`{DgFQz%=Z zDt{nTn?L^g@*Fm1w~Zlxry2~~$4JdA!)B0pboSF6^$YNgS@a`mi0(hqCC(FKj?-_> zD~t87e#=jTWcn3k9G0Maf)^QhyEYqe|9pNI(_|luEKv!p3$y_=>mS2k%KyAiA>JS4NVapzLN4p>?JsJ2*$r83R0&euLS+{=_EN-J)u9DrGCgk z#i)8coIqr0@TVgT1z6QV!-;o?i# zVye+z&nT$;=iTQ$kIm&H?rfkQuLvF@*&7k)@Fns`i{NGl^KjD#hgU7bVLJ^9zc6zw zt=Ra2F77RNlsP{3?f6qvJ=dZVpuC6Efs8NVSIl$0zYK*)1PVqIsw5v)6Lj%^&GQ!w z{RI?1$D|?dwy1!ic^Tcb8kHAUSD1gNncNwFjA1~qh_{EkdHvXcY?0>2nDDk=RG0kn)^-a_yeZ@jykYNZTCF6s-LZWG=h=t!PbhbvKimoOsEyHfz;_!V z58x?`a84shm0!a3`<{x*FcGenfd4xpIfUVE*L>J?1a)V;3Z{Noh?JRU7x$?Vx*o+l z#W>xQKf7llhq4&)-64mIlirVH7xr$DIeZE$BFkzXJ6IjXA_+fPT})Nq+ENvm+@Om= zbzGj;TF+Xi7o)@IEZMKV z@aG9CbQd;z%|pB%n(iz&((rDJ(|;ODKzIBKRh@Xa}Bs=XYrM3H5pZoD9xxWC^<+(sv@ zy8lsL&uDDfKg2H(DcAip_-U#CtGU&Sw-f#~StUP%7J6GpbRIon|0lzE6jkdG?p-P&{^lB1w@=$Ipc0I4E4A3DQ@G;_WM&s*X7q?^itqAwlSOBL)tFzPd-*9y+Bw z*|t2h@oz##mh9+m8uGcp>#{$stU$0m@9v*a6Nnd)|1!W&@O?W!{b}?`kwtEx+S3zY z19~s@430Q`fYD2bMi@`wv#Nn|p_WDi;)!aMT2I~G#ja*4b3sAt$lT=XvEo~5DW zwG!5z9a{&qD>>0_Wd|A>6G%FS9T0Y?+Xe$SfM1XL$o3Rr1 zzKGXog!g7Taof)V3`+vv7*H6JC18zMhLb^N*Dp}jx)$Q1R?Z%OSN0ytDISMZZJWTJ zSw!+1wV*hg$}rjmOXqYE3B(6T=R7jSSDwKNmX7CTbG%D}CD)PNR`iICgd&ir8u5oX z%o#O`IfsGi7T5HXA>=DFN~ks?~R_u^~lg4iu*IAM3p0L6L=38dCO zfCKNc_LdD)7k}YSSk431bfha$UCas$Jp=XNj3@gxh(glC5-bN>xa`IqJjd7-{3-hL zn~z%>JHLqqJob)cCR9CANd^exOzgb^MuK3?bcGe8XGIGP%|66Ns9TjE&_KVWp-Q78aI0ynmgOq@TA|)V* zAfW#Xyi4?N=BH#7J5x;|;v z@c3Pn1AYGmyzmQf(u25p15hh38pxA*4Y6{I0Jwr;C`NN007!?wuYp5{^?orD>xVX) z73P2K9V|&W9;wdmvy=!Unt>CFr^bQ`vCT4ZznjeV@veL_dt4;KJCMqPWnbCec@};SRxef7>XYuv$u$4XrvKIP2x@vbxv?sw#p)u^3z(QR@M)n$Omh?! z$yk4k<%@bi)IB7f7LLkAl3dT0c1p@|k`icq-8Gn`_RVh}Pq?or<&QpHSH>JY_rAM^ zA4NuZ)O)quTlv33byatxW28eof~)pyA1ITuM8zu|?HUPc>}H9H+^Hq`;SY1I$(4aZ z3LJVKfw|L+zkExyD5ocmp>zvEz6nB9eIf?Vw>Gv$v*bM}L9Xc2Jyg!+*!IZu438-@+m?_U0H)vXEJ4lq7h3oHT~PFUSJ zTP(Jb&sDgryO8%8)rk@2bGpk59NhcVy%s%pxQImPv&$F@A~#y1XmI~_Jjp4u_4q(% zr4$lJD{;k!F9^?QXcR?P^l+8CmPkcAGT8PuCZN^1iFMH1#)DkxGqsjit z#^RBjrA>ZXwmtbhk5w?>unu~4_nu3d=O;bcA(yJ!Eu#v?k}a1U#rU9l&xF$NzHzBc zO~u?Mw*V0feyhk=zIypI(Zijf+Z}o1Uu``cbr!R!B-}fr&7w&vv)Vg`*+brsBRDvP zL{oai%nh@YbTS^a*6=7qKezjkiu2B~wEMkQ5ltJld~Z3$)rubBaaJ;YUH`Xw{2T(B zx+iS^_=*mV2FHX@niBeTjU!iivEJOU9U%Z8{ zk1Z499=)7vHK2`PC=IP6)*Cpx-$NM8;d)xwX6kl`qS=nY>PAU-56lmAsLr;w+yfzm z>RkIGlWe4>QA}^11nF8kZXGdZq@V53bz{tZDrEN!YcBYh2-XK&UhJ_*!lf6m1#cYF z^B_gK!cFf!d3koDvAx}_8GREPlxBTG2|l|AvjiC1bItoMyg`p5SJ8gyH+~VoIfM0>K8TnEzRUN%bNLAV!JV!@Jbll|N_aQX5=9=*kHLe7~{|HL?V}LG8Qz|-QyqA&~=*1o8rLN(PIejTeklU9{>GR z0wYVO;=vtN!Q~zlIPI2R1!-8%-oV&($hX8%BAkElDH$O*_ zzi*=R(XGp7MV=)hfvOf?kEg*YAJ$J%g*Ru(^YwES;veVJu2mQo^}!(uXU4|aRt*w) zvXo5T`y%fT;$+B*#Tj~}n}C6WisF@{QLj!LrHcy;4gZF|O)#aRmC(9NpVi$Xcl?%! zG>7yOs5(t&^$CS#N8nI{u7T5qfKJSgy8sR4S{mjhAavAfpT09J`dxJv{3>id;pnbA zLqesM9u+;5PT~xQ3%+RAP}@5@NxrC0ze&Bgms`dT3>w%>Zg<2 zo3&<&`++$!L7&d1&nNgwE>4fCDW~Ay1xc?rYCjjx>PikR*}notw3yJe)C?s>u%xE@ z9GG56(B*qtU5^D8z|CS?O^P&qMBSjC0SHq=&#<}GV{=KR_kf*#lkHyr?2y;Q?-Ddh zhvR&a|1O}HZuv#L_`3d|$<#y`2}+vpQY8A9gPATVew>w3;MJ$MH`>AgBl3XmONvUV z1(@1`4b^HF0NVco0m~12VX1lqg9JF`87}E&s8=zZ>8C zQ)^*xeaX%8e6upYVZ*tSI5-{uEELcGPwNEV+Y9{d0Elt{>s|L0@mIpDC+|;0)V>Nk z5!Hzb4ATwMQs{Ih+^|-dnxpGI!Ji|G)ggBN`gXqOTlcr45GQJp;1g<=n*rr8US+x( zJ`__EQfPvF6}lv(=NFJGwiSXA&mYJ3Tmk(s%Mh5eJ-QYYzggdTt0Dht@R`AH`~Jz`JfyUBC# z1z2NX$EgLPNSZ4;#RweOcEjvW^P*Q>HCw1`!3F+Q@n*~1aJQ5dri&b z0(0LbIyM)lNe$r=9G0WlO8>VTsph|;Ui3jGzI6yg??7Y5*O~WXMVn$V@Z$Ep+iPDf zpiso!|M~USX_VEi6b!r5jlf=yzfJzW2qz48i63sg!g%I$zk7vJT9yCv`}TKEzI7S$ zMNR@MPa20*0)Evv{e~RdzaV0%CL-_#(Y(NT^vDkj%vNKPdYlzv9TiT^9z_Zb1FYHi z+r?U$)|51VSEKEoG8&U6wVV2q{gr*V2Y6t(18x>NfYjA$F z%|8^Jge3I`#E=-t%}h#0ble+DP|dK zO%v@K_TZ(!|HF3q->#nje|<9{js&SYeXxn#;qT5dzWSZi>fz7hwtLNY_xD%|VAthk zorIW@nZ{Sh?Pz$>67 z-iF@+Vuu6V>+4MoI2pFV9^?Y*pj-X;pqQ8jr3Y-dQ_2F>AU*d8!$W)XK=Zn=pv9kU z46=JTfI-H63e{u3po;J+g%Da~1GGcH>5o~qfU{j_3`zPidDRz!#!6MB1BbV!s`*1KO@Q#`>VR8WiomzIJHt0mnZD0 z(K+Xv?CMCsn$y3fARta?(xmpVH74N#gk~h8BVHF@!1v`ix$S=>_v`?SYe~2L4+_|K zT^q9xB5#qd2h0WG>&Y&sl$19_e?rqk{i2^*q6}z7*2poJa8Ir2Qd?lsp=}G!y*zEc zWDYT6?dlfcv;6`hIJAmolEvh_SKl9N{$l&`;t{|goPMs35H^#*slxI2ZPucoWi^)j zK6v(wx`Oc_da)To6uP1P>j$W&!W71=^M;7U>gI;zwtfG6{O-RCW~9jAwKD%L=8N$?atH#DV# z&i^qkGpqHgIYfRwxT*1^etv85!PoPD+j3t1hc~C9U&`I}JRP;~Uv9`j=qgiP@MWd8 zP^z}7URUkryT;B#651_$&n6z_wwp;96WLuKFn zLPZveIxvmZkXksZ2SUR5qn~yhf;bxgq1^#T&6n96i9Y==K}*HJXRFKgz`-W)*qWW% zedLkL{mQWu>hu~&8DpouvoZu_Hm(xniTU&q0@bFbxfcyr?uh)|{;6(iAm;+y4ms(7)em}U84$UUIV4JS^8p23#7thLmv zCY3g&Gh6|r5){uV1ryFGivNIX!nM)xDVxY$Un2cQ**N)-JYI$fHg`XG*gX33H}y-7 zH`e{!^F8rXg!47EDAm+>?LKy+qK&t+iqD5hDh$#aVF=JA>dzhE`k4c;6 za4ETxXC^uYae`z!I@a*{W!)Ir^Rge(L(P}o2a!)UToVX^;jSnTDq4J(Med0-53)d? zKa|u@Mm?soCtuv-a<+W%>Z0pWpu`{2kuN(6#rPc;44BR%{;6E`$zt7qhVRr~e{9tk zgkyd-DRq;6LpXK~r@3ZIeWrDufGOh1IV5Ft5o{g`81>DQL>Jv~t&YLmXxPR0Ud?Lb z97ee#W}fE6{~WpSY`Uc>C@j^hen^Jw)a|V;m=)d*322K+9^x#r&-g8}*OvO!n-rK3 zexg*FKj^UM%a>AC4wgIaQC%JNAopy)KgH)=FDw7>j8*?4L`@R&?-t*@>?7?|6xtNA8JlH=V#)m*I;I`R`(_DipYf^J8BW* zkn7>;WR+XKeSTPI9YOIC%*2s0DgY#2<5izPZA`$*nQ=foDHrT9*t_k(R*n zlYq0UHN`<(1jX|8p+|@xI`oC-@iMYak_JVWoJEg@URlgP1=EWnC+G3<^zt8>$l1_o zQpC7p3CGY4uAmreT=UCFihCOa=d7>Q_vDq$>gi?WQdNFyKL63&YGB+Qq98ld9V^YZ zT+5AFla8|S3;QTbkWXK~W#{GuwgPo+EYHZ7*v^_+<5WXeIlLmYWIJ-BL-BCpu#t-M zX|GLsOm4& zAIBtX+Zwv=vV_ARL1#Ts(P}#XQDXefc4YlIrjAA-#A$620leLw5!Ye<7T$3gES;f_`eP8pxpnBIBUs62khk5Z(P_I>oVG6Sn?Uhf$zX@` zXw1Vh;CMbFbuk*-geiKNlgHj;A_z5Uh_hX&Zn7J2@+!cF(P1iCDjK46SsfZ-o?8>i zBxh-1WMcED_%-d4`Xbj|M|DQEPjzZf9Dgcn6&wQLMk1yJs+$hLIqyV6W2y-w34JZYejQAvalP6F82Bu2U%kW ztu2^NGP+r6=$m_ci1Hk-q7Ps?!qw+-(Nx%t=bC4!{%69ISXgfT(|peaa(3MLbasLa zgF3nuUd_czO<9z>0xiO2gNijpCvj+Q8l>z=`a$^YXwZ-~I> z{LM)TO;}iiE?+`(2ihh9p#e&kM;qVuAFn6@0ePp!Q37;HIE-&pBEkG;b!BVk@~I#$>VsXqts()?iCDz$5R_7xTrxcyd4zJpi3 zBWwf)oup99Aa&ElQSF75F;Tx#>?y=thEnTI0-*^}0(B5P2EXp7uzY~$2DamDP{n&u zrH>&*bi7ibk{wz^EVrksBW^P!LW(&0kprE&lr|fX9g(Ry9Ak+ew@6Ov^5!&l58dPE zeSweJuB-9;3f(=Cx3Av10r3co>>3(S?csg_(E`c@v|Im$1)PG$PQ>U9$=xnnu7)3g z!;b4KW@k#!@P!quy|b%i0Qno3Fx@$?MV6tffJP*Up5*X?40&nn{waD6ftj)UoK767y<46&)#}<$NTGQ8?zpPw*zOK$73? zRJQ!nlW?>e!y3k}Ar6~o^4J>hOe@$fY;NtQwZK}~A<7el`3SO@w$_Ssx_&3^b*Wb6 z%Xw|FDEiB9+3yPwrOn6gLU=d21LcbI1kNW9Xc5M%#`@=A;T{i#uvvST2gQnCz>KZ8 z9%xbY4ff#a`pD+KU&++3fjqYb!*q@An4A)R>52F2n)FY{1iT~Mk@{6leI8+-7pq2& zRLHM)ocoL2YZ$Gdu`I;chqeW=gi-A7y-*%Vq$9_kNXb_DE|E_)@PTk%Oy5G zhfUg0#Kj+U`n+28Q67(!<7?3A8~>qXdyJDXaNxb63maH^gKzYduxzi$+iV#({@8k0 z?Ex+6uEH4Mej`?$*mY0mfz56^uN!SDg-+bx!d7UE)H5i~s>>KbvMfzQ%Q^d+5%DnPczjb}$-cTN$vvW&l=IvdC2>EY1Jkk+DFJ1M7Xo?;xd zC}$Rz+LCdP((Z~kz+hH>id+Xr%?FIryYe=mJ1C7$`hlr<-y(8C;>swYg3`BRydra- zCF#K(xvgQ230d{@kMBQ-J;=X{TU4pOpgiC#_c^&+j6AWqV{i<=dTvXn4IuIMpT*fv zZ$mGP`S5Z$;2iThBGXja^Ex^X6Hl?sHpTeoVv}(eOMeVC{)90eMYo^M+3|Ogu`xJz z08BqRDJhH(nc5%PM5W?j)oWAVGHFwAHn_GXgE08Vj53 z$%P+DT+iiMcn2AXTJL+6Kbn`fzaGkFFi#q8<4#h*dPX%~NhEp16T!*Jss5%l)FVka z-HX?m{2u4bkvop-59**b8tHf`>9hU}9%h2+&EHL949NJj$X4D5?O1$$_d8sCqyN4P zi0XILDkc~q-MBtq_HxIbubAG5_>h%9rBKSvLhO+ZzdNj9$^sqk@qNPO&bQ_yK4$&p zJub#x(4{WYqJFk_m)E~qDx-d;MNo=ha$?0UaKzrio3rEsASIqe3Y9?jNS?{=xm46C zO&Mb=BhR2fY0Pv3&#|ZE7;o%dHm(YGc9r`iw5_jtk^uo@*GOE{q>i&cb664LCaUNZ z@9GrgElI{D>)zL2FzI^DnL-gBWp~4qseO-Q?VXOQpRc)pD6r+ra_GA6~Li3s$il?1^1z+tX5*hcc8O zNSV4BnUtP*Fs;z{+)2v4d+||R04IZElE0&XICS0p?h`%4X6_FjDngZql$)P*X;U$j zWf;;q5{)Pw!ee?9X6;JRO-+zGZ~w1< zq~&L;`@nLXU?!&&fw1|uXnf2+^&1SIMSQ7~1Ic|_$n3XU-37)a&J1-qjHLG%@ zE}9;^DMs?2j z8%r%Zy-CxbLuj#53DFloG1}b>wXyw|)+54q zdu(%4j}3i~G-hI@<6Wmnzw1xxReheHjJeM`VE6`Y?MfytHVcnF<-}TPEOfDe7)Vah zmu)>&KNo93G3^mXxopm*AKIspCTXu7R78kmPnfCrfaj0B>d~O#t-Sq~_o7Ict=LT} z?8DqX(sImkUWX$2afB}3k3Z<53k>QUVT3M|gqm{ZqBorWK910NYv7yVsZ%ch_p4D> zmm5V#>6{KCEmoeFDwZ1mipRj<%`2>4srR2nA$NO#IIa<_!ATD%>a{kRus zosdB6hnVHK^w`!lnu%}|4o4HO_@sHshI>&Ve9-fT(LNn(tF|FmqTt`Aa$6|4+Ixa3 zfd0}>M*>Uq{xQ&tc8QCEIfJ)yh*74MZt7BBm}wYmf*jfl7baC1D6ak;BZ-e)e$922 zTOnlOdkjKH=W49_ACGd@oS%gEUPX+k65)o7->T#DGn616>fa%eb)lhfL`W?&<3^uh z;&i8$w}kB=F#gTS4nk&#&SC3KygcC;?L;Gm6REdIu<%K*at`WG_(!9yApR3eY=0#5DAqe}}>NsLH0YnU6rTl*a8!c4z+H10|xdtce6M2xI- zK^Ju6;JkB;jsUzg#<3S@2co~NKpGycf9vO2Q6X^PL58Y}1#3mof*vn*&!JD;;H3D2 zy?@2X%EdbA!S#@z%A-hT2_gVq z0~$_pJbIe}&=T3;DAss+;DQl252nbPEV1uzZ?;dHy6%7n5U(E-1%dtx}*@;+Ng?&V@z1_|2^=r2XnzYQW!&%$-99pTNSXjc z+@)R4PO-EqS4W3CSf^|iO)*46k05C!mUr>m`N4O}+#Mrcbb4Zv~&o zkaimBU+*;Qze#Rl@A5SCEW3u;9jSbA7S?mjF<9#nLf5pLJs<9M#)9=`CyFMEguGN; zm0TM119Q2_cM7$r7K&}H3srfuzAreP{WTJaib9aZ*~mn(J(K4~1s&1XzI%*t9h*C& zYH4nigO-=9!SY)JWLfFRkQi09UMC6PcD(+x*2HGYPq${ft3V>~=~-VBO#}BwGEXga zTm%7ncx6~R zah&D(u9I|lLbR>*_-bf@wNz>jFI2A!ynAU_dDv>*W~<7aAHIz!aaJO znRV4c@h{4)&W}baG1x`1K;+6lJ=dFaGZJ~mBnC2fSbW1^n=lJA`dz23K|s099x3s*oc#*+-qBm0SO1#0 z!(8RxJy9%Bw%I37YlIJgEETySmPFVRVxLk6)v|{8)g^cW{9`0vqhoWwgH9NV(4flaI@SpX&mVA`~RpK^v~qW~g{exe@2K zCa2q{nN8x8Vw?zu%@|pwxsGH+bIa-ZJef)MFC?9RIDX7h4X2Z&DUOi@!f(!5`JPUk z)Y1bUd~*`gVs?sh)hZcoRNrZGrgor6=1n$DqWLb}b%rbf|64eRmJ5h6swwFmch>}Q zNgFY;>q*U?%ghtvm(;N~NZZi$9 z3+RufF=XMo&kU!e0))kUThJ3X?e{6wP`j4mtze5)j8K<|(h>jnI9jH5t%Is=RX-2I z&WwT))ZL4W*mQwLzn_;E-oOf|TfB8=>&7D%;>`Kh@6~#G%t?HnY6x2FtNoai;s}rO zig7nIg>ir-x%~K-?^SNh2y)zCIWxu$;YS|7bgwSQa=DI$o^&n@N>PzDrLXKgq(&s} z`U<3#Pf;gX?2s~xBwtAj`3&g7&oiZ-w9*psFn`CQ3EFhLAlgR#9o|biR1f=lJ!n#W zm>%Zu8@#rwTBqkML+yGJ5bW`{s|0NapZzZ^;J8VOibW_n6~mDWSB(%8^$fBk)5l6T z4s9tz&j@EDD>PymPzT-W5xO5GTl=(((H#>kAW~jZx{Vv)}`VQ&~<*8H8P%$wLLO2| zT7i*WI-&&HluhQL8B7)R>CC?XIt5PL85nlQAFr=J2PNIzX8-fLtmLSsl8cMeZTiDg z5_H(cx03Gw1Ne6_-UpP(8;-ev%hOq1yHwyrA&B#-#ly)DXdFUV(G0T0NM+DoN6@wD z@`NRpEP#D$nP z>3<~uoXk(dX8Ia4oMp=HvGkA|GFD<}w>sziAqWQc zRPMxpZsP;kBxbq{E1kyql%KD^%rPNnS77Pth@HW{C&_89hvlwHnoBkLILgF6>#dOfb zUnC0fYwiqL0AH^T??5LWQrKalRV_ok8KRpl(1Lk^tCSKpZ-H6uzM`r2EhrqF*azAL z-_YKc=8nIQ4S1Av4?Zgte?K^YEEq+8EZl3&1>CmFG#emmk%bc6N*VPxnL5f(H6gc; z%@iF6()=)puwbJ6F7xFR$^GOc3D!GAhnAcC1VZj4ByE!Fuc8(i%(dT0oE6;yQAOVF zr00>;K9}j}3C!vfyj^>He`vmsjStyf%X_Hr| zaPc5)!!2k0y|+^=y({^yLp>Ud?(d@-4N%6z00_<&!?R$C`S>Dk&9_(O{q1F6uYh_DT`clBU~2r5pdnJ-V0kERk4{=i;=JS zzlu-4>|IT26yBR^U9I85>nFBV=Te>SvOR<(rytC}3(ju%@<`-DSn#`|*$un@y5I=i zCAXZO7|WJelMg>kbM<87$&yYPjQ#ZkXNBfn1(KqDi=NCfPn{#nZHbSb(-=P@4}KoG zu-gEQ2i3AIJwtRdWDj0t<8w2At*LSg9X;RTVL6XErJX9dK_#g&qv})o7K#6hh1`>Y z$AZXXo}g28X=pkg{|rPOF9PIWHPse$vV@9byKtHCn(?V~EO7Mge=OA1X=JVq{A`_p>coSUaaUqdgQx<*kR>>+*IgzIDeB@29eFDLvSg+{WP zR#u!#wZ;Fu(&oE>3{e<5o+U&@V&mT8*d)8SntEWqbC6^pd=P5jcp!wGzFEGd40 zY99%$1X(6ZR$%U09x1jU{u{1^hg|bc#i1$xrt2Uory!FpB`6#>MLnWY#c0X%1F_vE-sam3K`HpV8JEyBN1voT_59n^YPCo?XN`= zY6ec%%~NKsR*V-|ryGd6ZaaQlEXOJ1yRA3bZOt2DY1H6eg?RRF%k0ZB*YhV@%1s(G z@=SbN9CNnkR?vhLb?BAl)aUheM>)RCettRjAskaUg#&IAmiOCV8d_yWd%G<~nw(0G z@J$!)m#RNb@O09;gCKM&9P}QYfpSIws~G!O(D}QFuAvG9WO`&Hr~Y zy1Ou~Q+1r7T!PYE#Jp+=O2Igc3rRtjl_2<_HII{Sg_9YFXEnSjOS!?R1(ok4R2J_UbeT&$}WnTv=~!ZQry#a{tVC4-XC=wh7Fb4PVBeeUYnc8_;J-l z4hPN$d6Y%fL44@_V$0bH2xpD(jG745|csG;+q2Tb^`9~Oi;)^FnTC8 zsvoW9@=?ibHx4imXgV2Xp@U<*`(kps6*7c8&(}*KbIW@o*00YrANbCh7{2bhhHzvLbQRM#?uRyn;+K!)uA1D+clMil;1{>Pr`YPPH=m={p&iMd|+2>fTV9Q z{=b9JR(Gy2xFFo|f_gsUc*Dz~);58rsV>(p%%ozm^vuHw<25=LQU5N;+2l zCq-6*U$#aI@ExYWaS->8x{o|~450=!OpCJNg?6z@&%tZC3w;e;iouj{ZgyRt{DbDH zT>XE5E2jqBo2fV%m?f^0AM(J<;Y-m!k_{`45vzjrmtZo!gS&f9yAvR0hBBpghyZ^j zaEGcuC;QsVn_XBSLKXVJ7*By8bP1zSaOTZD02b?#d$RrKX}wp_W{8wjc_lrkSk0OH zz5y~sYN3tsb!9kB6}4=}6A=acI%x>^g$|gqqkVG=#>F*?RHc2_A#Tx*PFDFCJ%$5? z$gi8BT9hyKz52dB-zqfm*u=p&cY`qA^$WL2gho4&CtiLUhPaDsvL3Ch44j{1d48xLiS2N5{-9{@7S z;;X<7jTyALdYk#h$1+n$!TI#y>)T3TydbPH$4k~}s`6e$PClpoGg5s2Lp&3OtmI0v z)YK8pVrlIU3ip&RN(~71GT;LJfdKD+B|a+{{I=|lz26LLM#5ZT?r9hEqtMbQ^T;>4 zJUrrk=O)Fos_z~0pOIWa^4HHyh-0PjT(bj0Dk9|VCm1A?ZvXNYBqQ34OzuLdUh!G@)2{G&+ga5(;8b57 z3AZTXm6kNi2-_Ye3gf6?pSUwGZRK+PZK=LD7AfTeRF#3u0NdBK;i3uh$kKO3PjQsj z(4o}ldmz?TQ#Z$1qkg>M$!Q9;Ls?&mYS=A%c-J2_WoGBLXrGf1;-=;he<0VOkk|cN zvQLd=Cj2MHAghtiUG__;v=UA~LQp~J``niGdf3Ruu4Tp&MBK}Kh9nMFxZI*M?~$R zu0Zs67R-G&E_=(Eg}{WId-y|UW{%4e|Bo|841k)Rjgp;n3mC8`VZDO5bIX9MX{K3%xkt^J3woe{Jz zS4+n&LwS9IHA>Q+_d2nZmNv1|R%xuD*sq7(IVsh=C`KW?R1qr5$|q(or0_S{OR
%lLcdBXCKVI5S(}Sbxt2FqnW9X-GPiS8Qn>LQx@{u}1 zF!xzm1ZKLyf&R8sG&dd7e;o0tN*!g*S;qfMfTAU|&xwJ7iPTa>84E~;xBGRKMeu-i z?WT_7|J^$Of1ES_?IHC4@tX$!z^n$JZAo|qc|FgSz9$?iMt*+m6!#geoWTw(=loO9 ze6qp}2m|~#bznK12UgF(M+MBZ)PMb*OAc2{bd6g*T70v|LUHA>%hDa*tW>4>zMt^_ zBz{UTV{TaaJL;iz@ecYy;du$UL_PCY1LDB*AmER5%O4!qywENcRdo?@&y4&G8|M@F zz`hbRdh-|uZLx@EC&T9=knOIx48Xv^xV)@*8eO64OcWTNao=y7+Pw2 Ks^!Yo!T%S+g+;~y literal 0 HcmV?d00001 diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/auto-approve-settings-high-contrast-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/auto-approve-settings-high-contrast-light.png new file mode 100644 index 0000000000000000000000000000000000000000..75166431652744b2a2eb368df2082e06aa909868 GIT binary patch literal 111942 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

rR4rWNV&1c4FGu{EQfC3_-@V~37not_ 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

+$I!>M?BHR5N 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 literal 0 HcmV?d00001 diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/auto-approve-settings-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/auto-approve-settings-light.png new file mode 100644 index 0000000000000000000000000000000000000000..7b70bae540f8980eb129f7967c840f84fa9ea7af GIT binary patch literal 113328 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 z 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 Date: Wed, 23 Sep 2026 18:12:17 +0200 Subject: [PATCH 28/39] test(core): unit coverage for blanket auto-deny reasons and edge cases Add unit coverage closing this PR's patch coverage gap: per-kind denial reason strings with fallbacks and rule id suffix, rule_id payload pins, approval edge cases. Three defensive fallback branches are unreachable by proof and stay uncovered. No production changes. --- .../presentAssistantMessage-auto-deny.spec.ts | 48 ++++++++++++++++- .../__tests__/autoApprovalEdgeCases.spec.ts | 50 +++++++++++++++++ .../__tests__/autoDenyReason.spec.ts | 53 +++++++++++++++++++ .../__tests__/executeCommandTool.spec.ts | 27 ++++++++++ 4 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/core/auto-approval/__tests__/autoApprovalEdgeCases.spec.ts create mode 100644 src/core/auto-approval/__tests__/autoDenyReason.spec.ts diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts index 18204aaa90..9f36f97d97 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts @@ -161,6 +161,8 @@ const listFilesBlock = { const NOT_ALLOWLISTED_DETAIL = { kind: "not_allowlisted" as const, command: "rm x && npm test", + // The askApproval copies forward dcgRuleId into the payload's rule_id for any detail kind. + dcgRuleId: "R-1", } const DCG_ALLOW = { decision: "allow" } as const @@ -226,6 +228,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { expect(denialPayload.type).toBe("auto_deny") expect(denialPayload.reason).toContain("not on the command allowlist") expect(denialPayload.offending_command).toBe("rm x && npm test") + expect(denialPayload.rule_id).toBe("R-1") // An automatic denial is scoped to its own tool call: it must NOT abort the // turn the way a user rejection does. @@ -276,7 +279,8 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { mockTask.ask.mockResolvedValueOnce({ response: "noButtonClicked", - autoDenyDetail: { kind: "denylist", command: "rm x", pattern: "rm" }, + // dcgRuleId is forwarded into the payload's rule_id on this copy too, for any detail kind. + autoDenyDetail: { kind: "denylist", command: "rm x", pattern: "rm", dcgRuleId: "R-1" }, }) await presentAssistantMessage(mockTask as unknown as Task) @@ -286,6 +290,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { expect(payload.type).toBe("auto_deny") expect(payload.reason).toContain("matches denied prefix `rm`") expect(payload.offending_command).toBe("rm x") + expect(payload.rule_id).toBe("R-1") expect(mockTask.didRejectTool).toBe(false) expect(mockTask.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) }) @@ -391,4 +396,45 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { AUTO_APPROVAL_CONTEXT, ) }) + + it("keeps the user-rejection payload on the MCP copy when the denial carries no autoDenyDetail", async () => { + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_mcp", + name: "mcp_my_server_do_thing", + serverName: "my_server", + toolName: "do_thing", + arguments: {}, + partial: false, + }, + ] + + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }), + getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined }), + }), + } + + useMcpToolHandle.mockImplementation( + async (_task: unknown, _block: unknown, { askApproval }: { askApproval: AskApproval }) => { + await askApproval("use_mcp_server", "{}") + }, + ) + + // A rejection without structured detail is a real user click: legacy + // wording, and the rest of the turn aborts. + mockTask.ask.mockResolvedValueOnce({ response: "noButtonClicked" }) + + // Structural double — the mock Task implements only the fields this path reads; no typed alternative exists. + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.userMessageContent).toHaveLength(1) + const payload = JSON.parse(mockTask.userMessageContent[0].content as string) + expect(payload.message).toBe("The user denied this operation.") + expect(payload.type).toBeUndefined() + expect(mockTask.didRejectTool).toBe(true) + expect(mockTask.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) + }) }) diff --git a/src/core/auto-approval/__tests__/autoApprovalEdgeCases.spec.ts b/src/core/auto-approval/__tests__/autoApprovalEdgeCases.spec.ts new file mode 100644 index 0000000000..db3133a07b --- /dev/null +++ b/src/core/auto-approval/__tests__/autoApprovalEdgeCases.spec.ts @@ -0,0 +1,50 @@ +import type { ExtensionState } from "@roo-code/types" +import { checkAutoApproval, type AutoApprovalState, type AutoApprovalStateOptions } from ".." +import { getCommandDecisionDetailed } from "../commands" + +type State = Pick + +// Edge cases around the command auto-approval path that the blanket-deny +// matrix does not cover: command lists absent from state entirely, a +// DCG-enabled ask arriving without a verdict, and the original-cased denied +// pattern reported back for the model-facing denial detail. +describe("command auto-approval edge cases", () => { + const stateWithoutCommandLists: State = { + autoApprovalEnabled: true, + alwaysAllowExecute: true, + } + + it("asks for an unlisted command when the state omits both command lists", async () => { + expect(await checkAutoApproval({ state: stateWithoutCommandLists, ask: "command", text: "some-tool" })).toEqual( + { decision: "ask" }, + ) + }) + + it("auto-denies an unlisted command with blanket on when the state omits both command lists", async () => { + expect( + await checkAutoApproval({ + state: { ...stateWithoutCommandLists, alwaysDenyUnapprovedCommands: true }, + ask: "command", + text: "some-tool", + }), + ).toEqual({ decision: "deny", autoDeny: { kind: "not_allowlisted", command: "some-tool" } }) + }) + + it("approves a DCG-enabled command ask that carries no verdict", async () => { + expect( + await checkAutoApproval({ + state: { ...stateWithoutCommandLists, destructiveCommandGuardEnabled: true }, + ask: "command", + text: "rm file", + }), + ).toEqual({ decision: "approve" }) + }) + + it("reports the denied prefix in its original casing", () => { + expect(getCommandDecisionDetailed("rm x", ["git"], ["RM"])).toEqual({ + decision: "auto_deny", + offendingCommand: "rm x", + matchedPattern: "RM", + }) + }) +}) diff --git a/src/core/auto-approval/__tests__/autoDenyReason.spec.ts b/src/core/auto-approval/__tests__/autoDenyReason.spec.ts new file mode 100644 index 0000000000..d24eaeccd3 --- /dev/null +++ b/src/core/auto-approval/__tests__/autoDenyReason.spec.ts @@ -0,0 +1,53 @@ +import { buildAutoDenyReason } from "../autoDenyReason" + +// Strings are asserted against buildAutoDenyReason's own templates: these are +// model-facing payloads, so exact wording (backticks, placeholders, rule +// suffix) is the contract. +describe("buildAutoDenyReason", () => { + it("names the command and matched prefix for a complete denylist detail", () => { + expect(buildAutoDenyReason({ kind: "denylist", command: "rm -rf /", pattern: "rm" })).toBe( + "Command `rm -rf /` matches denied prefix `rm`.", + ) + }) + + it("falls back to (unknown) placeholders when a denylist detail omits command and pattern", () => { + expect(buildAutoDenyReason({ kind: "denylist" })).toBe("Command `(unknown)` matches denied prefix `(unknown)`.") + }) + + it("names the command for a complete not_allowlisted detail", () => { + expect(buildAutoDenyReason({ kind: "not_allowlisted", command: "unknown-tool" })).toBe( + "Command `unknown-tool` is not on the command allowlist.", + ) + }) + + it("falls back to an (unknown) command when a not_allowlisted detail omits it", () => { + expect(buildAutoDenyReason({ kind: "not_allowlisted" })).toBe( + "Command `(unknown)` is not on the command allowlist.", + ) + }) + + it("returns the fixed shell-expansion warning for dangerous_substitution", () => { + expect(buildAutoDenyReason({ kind: "dangerous_substitution", command: 'echo "${var@P}"' })).toBe( + "Command contains shell expansions (${...} forms, process substitution, and similar) that require explicit approval.", + ) + }) + + it("forwards the parse error for malformed_command and defaults when it is absent", () => { + expect(buildAutoDenyReason({ kind: "malformed_command", parseError: "boom" })).toBe("boom") + expect(buildAutoDenyReason({ kind: "malformed_command" })).toBe("Command contains a shell syntax error.") + }) + + it("appends the rule id for a dcg detail and omits the suffix without one", () => { + expect( + buildAutoDenyReason({ + kind: "dcg", + command: "rm -rf /", + dcgReason: "matches a destructive pattern", + dcgRuleId: "recursive-delete", + }), + ).toBe("Destructive Command Guard denied the command: matches a destructive pattern (Rule: recursive-delete)") + expect(buildAutoDenyReason({ kind: "dcg" })).toBe( + "Destructive Command Guard denied the command: no reason provided", + ) + }) +}) diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index edf16aed30..d255c28a0a 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -415,6 +415,33 @@ describe("executeCommandTool", () => { expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test", undefined, true) }) + it("keeps the protected prompt when blanket auto-deny is on but execute auto-approval is off", async () => { + const provider = await mockCline.providerRef.deref() + provider.context = { globalStorageUri: { fsPath: "/test/storage" } } + provider.contextProxy.getValue.mockReturnValue(true) + provider.getState.mockResolvedValue({ + destructiveCommandGuardEnabled: true, + terminalShellIntegrationDisabled: true, + alwaysDenyUnapprovedCommands: true, + autoApprovalEnabled: true, + alwaysAllowExecute: false, + }) + mockRunDcg.mockResolvedValue({ decision: "deny", reason: "matches a destructive pattern" }) + mockAskApproval.mockResolvedValue(false) + + // Structural harness double — mockCline carries only the fields the handler reads; a typed Task is impractical. + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + // vi.fn stands in for the AskApproval signature; every test in this block uses this identical cast. + askApproval: mockAskApproval as unknown as AskApproval, + // vi.fn stands in for the HandleError signature; every test in this block uses this identical cast. + handleError: mockHandleError as unknown as HandleError, + // vi.fn stands in for the PushToolResult signature; every test in this block uses this identical cast. + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test", undefined, true) + }) + it("installs or updates DCG before evaluating an enabled command", async () => { const provider = await mockCline.providerRef.deref() provider.context = { globalStorageUri: { fsPath: "/test/storage" } } From 25aa92e9530eac9d3a9fbbd64d5484d7a7bdde3d Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Thu, 24 Sep 2026 14:13:11 +0200 Subject: [PATCH 29/39] test(core): align auto-deny test fixtures with policy emission shapes The auto-deny fixtures attached a DCG rule id to not_allowlisted and denylist denials, a shape checkAutoApproval never emits, so the rule forwarding assertions could pass without covering the DCG path. The fixtures now mirror the emitted shapes: non-DCG denials carry no rule id and assert none reaches the tool payload, while new dcg-denial tests pin rule forwarding in both askApproval copies. The mocked tool handlers also capture askApproval's return value and pin it to false on the deny path instead of discarding it. This addresses the automated review comments on the branch's test diff. --- .../presentAssistantMessage-auto-deny.spec.ts | 117 +++++++++++++++--- 1 file changed, 102 insertions(+), 15 deletions(-) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts index 9f36f97d97..1b282ae3aa 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts @@ -158,13 +158,29 @@ const listFilesBlock = { partial: false, } +// Structural double — the mock Task implements only the fields this path reads; no typed alternative exists. +const asTask = (task: MockTask): Task => task as unknown as Task + +// checkAutoApproval emits dcgRuleId only on `dcg` denials, so a non-DCG detail +// carries no rule id for the askApproval copies to forward. const NOT_ALLOWLISTED_DETAIL = { kind: "not_allowlisted" as const, command: "rm x && npm test", - // The askApproval copies forward dcgRuleId into the payload's rule_id for any detail kind. +} + +// Mirrors checkAutoApproval's `dcg` branch — the only denial shape that carries +// a rule id. +const DCG_DENY_DETAIL = { + kind: "dcg" as const, + command: "rm -rf /", + dcgReason: "matches a destructive pattern", dcgRuleId: "R-1", } +// Captures the boolean a tool receives back from askApproval; vi.clearAllMocks() +// does not reset closures, so beforeEach must clear this explicitly. +let execApproval: boolean | undefined + const DCG_ALLOW = { decision: "allow" } as const const AUTO_APPROVAL_CONTEXT = { dcgDecision: DCG_ALLOW } @@ -173,6 +189,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { beforeEach(() => { vi.clearAllMocks() + execApproval = undefined vi.mocked(validateToolUse).mockImplementation(() => undefined) mockTask = buildMockTask() @@ -184,7 +201,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { _block: unknown, { askApproval }: { askApproval: (t: string, m?: string) => Promise }, ) => { - await askApproval("command", "rm x && npm test") + execApproval = await askApproval("command", "rm x && npm test") }, ) @@ -215,7 +232,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { .mockResolvedValueOnce({ response: "noButtonClicked", autoDenyDetail: NOT_ALLOWLISTED_DETAIL }) .mockResolvedValueOnce({ response: "yesButtonClicked" }) - await presentAssistantMessage(mockTask as unknown as Task) + await presentAssistantMessage(asTask(mockTask)) expect(mockTask.userMessageContent).toHaveLength(2) @@ -228,7 +245,12 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { expect(denialPayload.type).toBe("auto_deny") expect(denialPayload.reason).toContain("not on the command allowlist") expect(denialPayload.offending_command).toBe("rm x && npm test") - expect(denialPayload.rule_id).toBe("R-1") + // A detail without a dcgRuleId must not gain a rule_id key in the payload. + expect(denialPayload).not.toHaveProperty("rule_id") + + // The tool must treat an automatic denial as a refusal: proceeding on a + // `true` return would execute a policy-denied command. + expect(execApproval).toBe(false) // An automatic denial is scoped to its own tool call: it must NOT abort the // turn the way a user rejection does. @@ -267,30 +289,33 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { }), } + let mcpApproval: boolean | undefined useMcpToolHandle.mockImplementation( async ( _task: unknown, _block: unknown, { askApproval }: { askApproval: (t: string, m?: string) => Promise }, ) => { - await askApproval("use_mcp_server", "{}") + mcpApproval = await askApproval("use_mcp_server", "{}") }, ) + // A denylist denial is policy-emitted without a rule id: kind, command, pattern only. mockTask.ask.mockResolvedValueOnce({ response: "noButtonClicked", - // dcgRuleId is forwarded into the payload's rule_id on this copy too, for any detail kind. - autoDenyDetail: { kind: "denylist", command: "rm x", pattern: "rm", dcgRuleId: "R-1" }, + autoDenyDetail: { kind: "denylist", command: "rm x", pattern: "rm" }, }) - await presentAssistantMessage(mockTask as unknown as Task) + await presentAssistantMessage(asTask(mockTask)) expect(mockTask.userMessageContent).toHaveLength(1) const payload = JSON.parse(mockTask.userMessageContent[0].content as string) expect(payload.type).toBe("auto_deny") expect(payload.reason).toContain("matches denied prefix `rm`") expect(payload.offending_command).toBe("rm x") - expect(payload.rule_id).toBe("R-1") + expect(payload).not.toHaveProperty("rule_id") + // The MCP tool must also see the denial as a refusal to execute. + expect(mcpApproval).toBe(false) expect(mockTask.didRejectTool).toBe(false) expect(mockTask.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) }) @@ -301,7 +326,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { // Real user click: noButtonClicked with no structured detail. mockTask.ask.mockResolvedValueOnce({ response: "noButtonClicked" }) - await presentAssistantMessage(mockTask as unknown as Task) + await presentAssistantMessage(asTask(mockTask)) expect(mockTask.userMessageContent).toHaveLength(2) @@ -330,7 +355,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { mockTask.ask.mockResolvedValueOnce({ response: "noButtonClicked", text: "do not run that" }) - await presentAssistantMessage(mockTask as unknown as Task) + await presentAssistantMessage(asTask(mockTask)) expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "do not run that", undefined) expect(mockTask.didRejectTool).toBe(true) @@ -352,7 +377,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { }, ) - await presentAssistantMessage(mockTask as unknown as Task) + await presentAssistantMessage(asTask(mockTask)) expect(mockTask.ask).toHaveBeenCalledWith("command", "rm x", false, undefined, false, AUTO_APPROVAL_CONTEXT) }) @@ -385,7 +410,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { }, ) - await presentAssistantMessage(mockTask as unknown as Task) + await presentAssistantMessage(asTask(mockTask)) expect(mockTask.ask).toHaveBeenCalledWith( "use_mcp_server", @@ -427,8 +452,7 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { // wording, and the rest of the turn aborts. mockTask.ask.mockResolvedValueOnce({ response: "noButtonClicked" }) - // Structural double — the mock Task implements only the fields this path reads; no typed alternative exists. - await presentAssistantMessage(mockTask as unknown as Task) + await presentAssistantMessage(asTask(mockTask)) expect(mockTask.userMessageContent).toHaveLength(1) const payload = JSON.parse(mockTask.userMessageContent[0].content as string) @@ -437,4 +461,67 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { expect(mockTask.didRejectTool).toBe(true) expect(mockTask.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) }) + + it("forwards the DCG rule id into the payload rule_id (command askApproval copy)", async () => { + mockTask.assistantMessageContent = [executeCommandBlock] + + mockTask.ask.mockResolvedValueOnce({ response: "noButtonClicked", autoDenyDetail: DCG_DENY_DETAIL }) + + await presentAssistantMessage(asTask(mockTask)) + + expect(mockTask.userMessageContent).toHaveLength(1) + const payload = JSON.parse(mockTask.userMessageContent[0].content as string) + expect(payload.type).toBe("auto_deny") + // The DCG reason and rule id both reach the model: the reason string is + // built from the detail's own fields at this layer. + expect(payload.reason).toContain("matches a destructive pattern") + expect(payload.reason).toContain("(Rule: R-1)") + expect(payload.offending_command).toBe("rm -rf /") + expect(payload.rule_id).toBe("R-1") + expect(execApproval).toBe(false) + expect(mockTask.didRejectTool).toBe(false) + }) + + it("forwards the DCG rule id into the payload rule_id (MCP askApproval copy)", async () => { + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_mcp", + name: "mcp_my_server_do_thing", + serverName: "my_server", + toolName: "do_thing", + arguments: {}, + partial: false, + }, + ] + + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }), + getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined }), + }), + } + + let mcpApproval: boolean | undefined + useMcpToolHandle.mockImplementation( + async ( + _task: unknown, + _block: unknown, + { askApproval }: { askApproval: (t: string, m?: string) => Promise }, + ) => { + mcpApproval = await askApproval("use_mcp_server", "{}") + }, + ) + + mockTask.ask.mockResolvedValueOnce({ response: "noButtonClicked", autoDenyDetail: DCG_DENY_DETAIL }) + + await presentAssistantMessage(asTask(mockTask)) + + expect(mockTask.userMessageContent).toHaveLength(1) + const payload = JSON.parse(mockTask.userMessageContent[0].content as string) + expect(payload.type).toBe("auto_deny") + expect(payload.rule_id).toBe("R-1") + expect(mcpApproval).toBe(false) + expect(mockTask.didRejectTool).toBe(false) + }) }) From b4309997a8a37a3283216cbb10249875fafe78a4 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Thu, 24 Sep 2026 14:14:54 +0200 Subject: [PATCH 30/39] fix(core): deny verdictless DCG command asks with a retryable reason A verdictless command ask under an enabled Destructive Command Guard used to auto-approve. That state is a guard inconsistency, not a guard decision: it now denies with a guard_unavailable reason stating that the command did not run, that it was not denied by policy, and that the model may retry it. Shell-expansion denials now name the offending command and drop the approval cue that hands-free mode cannot honor. Guard-disabled and verdict-carrying flows are unchanged. --- .../__tests__/autoApprovalEdgeCases.spec.ts | 4 +-- .../__tests__/autoDenyReason.spec.ts | 15 +++++++- .../__tests__/blanket-deny.spec.ts | 31 +++++++++-------- src/core/auto-approval/__tests__/dcg.spec.ts | 10 +++--- src/core/auto-approval/autoDenyReason.ts | 17 ++++++---- src/core/auto-approval/index.ts | 34 ++++++++++++++----- src/core/task/__tests__/ask-auto-deny.spec.ts | 33 ++++++++++++++++++ 7 files changed, 107 insertions(+), 37 deletions(-) diff --git a/src/core/auto-approval/__tests__/autoApprovalEdgeCases.spec.ts b/src/core/auto-approval/__tests__/autoApprovalEdgeCases.spec.ts index db3133a07b..b0882e3da1 100644 --- a/src/core/auto-approval/__tests__/autoApprovalEdgeCases.spec.ts +++ b/src/core/auto-approval/__tests__/autoApprovalEdgeCases.spec.ts @@ -30,14 +30,14 @@ describe("command auto-approval edge cases", () => { ).toEqual({ decision: "deny", autoDeny: { kind: "not_allowlisted", command: "some-tool" } }) }) - it("approves a DCG-enabled command ask that carries no verdict", async () => { + it("denies a DCG-enabled command ask that carries no verdict with a retryable guard-state detail", async () => { expect( await checkAutoApproval({ state: { ...stateWithoutCommandLists, destructiveCommandGuardEnabled: true }, ask: "command", text: "rm file", }), - ).toEqual({ decision: "approve" }) + ).toEqual({ decision: "deny", autoDeny: { kind: "guard_unavailable", command: "rm file" } }) }) it("reports the denied prefix in its original casing", () => { diff --git a/src/core/auto-approval/__tests__/autoDenyReason.spec.ts b/src/core/auto-approval/__tests__/autoDenyReason.spec.ts index d24eaeccd3..30b0d0a8ec 100644 --- a/src/core/auto-approval/__tests__/autoDenyReason.spec.ts +++ b/src/core/auto-approval/__tests__/autoDenyReason.spec.ts @@ -28,7 +28,20 @@ describe("buildAutoDenyReason", () => { it("returns the fixed shell-expansion warning for dangerous_substitution", () => { expect(buildAutoDenyReason({ kind: "dangerous_substitution", command: 'echo "${var@P}"' })).toBe( - "Command contains shell expansions (${...} forms, process substitution, and similar) that require explicit approval.", + "Command contains shell expansions (${...} forms, process substitution, and similar) that are never auto-approved. Choose an approved command without shell expansions.", + ) + }) + + it("returns the honest retryable detail for guard_unavailable", () => { + const reason = buildAutoDenyReason({ kind: "guard_unavailable", command: "npm test" }) + expect(reason).toBe( + "Command `npm test` was not executed: the Destructive Command Guard is enabled but supplied no verdict, which is an internal guard-state inconsistency, not a policy denial. You may retry the same command.", + ) + // The never-ask posture: the reason must name the state and invite a + // retry without ever pointing at user approval. + expect(reason).not.toMatch(/approv|ask the user/i) + expect(buildAutoDenyReason({ kind: "guard_unavailable" })).toBe( + "Command `(unknown)` was not executed: the Destructive Command Guard is enabled but supplied no verdict, which is an internal guard-state inconsistency, not a policy denial. You may retry the same command.", ) }) diff --git a/src/core/auto-approval/__tests__/blanket-deny.spec.ts b/src/core/auto-approval/__tests__/blanket-deny.spec.ts index 2fbc89d623..f60380b8cf 100644 --- a/src/core/auto-approval/__tests__/blanket-deny.spec.ts +++ b/src/core/auto-approval/__tests__/blanket-deny.spec.ts @@ -1,4 +1,5 @@ import { checkAutoApproval } from ".." +import { baseState as sharedBaseState, type State } from "./fixtures" // Matrix over the blanket auto-deny feature (`alwaysDenyUnapprovedCommands`): // DCG on/off × blanket on/off × command shapes. The blanket setting only @@ -7,24 +8,13 @@ import { checkAutoApproval } from ".." // disengagement cases turn them off to prove the setting is inert. describe("blanket auto-deny for unapproved commands", () => { const baseState = { - autoApprovalEnabled: true, - alwaysAllowReadOnly: false, - alwaysAllowReadOnlyOutsideWorkspace: false, - alwaysAllowWrite: false, - alwaysAllowWriteOutsideWorkspace: false, - alwaysAllowWriteProtected: false, - alwaysAllowMcp: false, - alwaysAllowModeSwitch: false, - alwaysAllowSubtasks: false, - alwaysAllowFollowupQuestions: false, + ...sharedBaseState, alwaysAllowExecute: true, allowedCommands: ["git"], deniedCommands: ["rm"], - destructiveCommandGuardEnabled: false, - alwaysDenyUnapprovedCommands: false, } - const commandCase = (text: string, overrides: Partial = {}, extra: object = {}) => + const commandCase = (text: string, overrides: Partial = {}, extra: object = {}) => checkAutoApproval({ state: { ...baseState, ...overrides }, ask: "command", text, ...extra }) describe("DCG disabled", () => { @@ -80,7 +70,7 @@ describe("blanket auto-deny for unapproved commands", () => { }), ).toEqual({ decision: "deny", - autoDeny: { kind: "dangerous_substitution", command: undefined }, + autoDeny: { kind: "dangerous_substitution", command: 'echo "${var@P}"' }, }) }) @@ -171,6 +161,19 @@ describe("blanket auto-deny for unapproved commands", () => { ).toEqual({ decision: "approve" }) }) + it("auto-denies a verdictless command ask with the retryable guard-state detail in both blanket modes", async () => { + // Verdictless + guard-on is an inconsistent guard state, not a guard + // decision, so the denial is the same in both blanket modes. + expect(await commandCase("rm file", { ...dcgState })).toEqual({ + decision: "deny", + autoDeny: { kind: "guard_unavailable", command: "rm file" }, + }) + expect(await commandCase("rm file", { ...dcgState, alwaysDenyUnapprovedCommands: true })).toEqual({ + decision: "deny", + autoDeny: { kind: "guard_unavailable", command: "rm file" }, + }) + }) + it("auto-denies with the DCG reason when blanket is on", async () => { expect( await commandCase( diff --git a/src/core/auto-approval/__tests__/dcg.spec.ts b/src/core/auto-approval/__tests__/dcg.spec.ts index 5585c69acd..cfaffe007f 100644 --- a/src/core/auto-approval/__tests__/dcg.spec.ts +++ b/src/core/auto-approval/__tests__/dcg.spec.ts @@ -20,9 +20,10 @@ describe("Destructive Command Guard auto-approval precedence", () => { mcpServers: [], } - it("auto-approves commands allowed by DCG without consulting Zoo's deny list", async () => { + it("denies with the retryable guard-state detail when no verdict is supplied", async () => { expect(await checkAutoApproval({ state: baseState, ask: "command", text: "rm file" })).toEqual({ - decision: "approve", + decision: "deny", + autoDeny: { kind: "guard_unavailable", command: "rm file" }, }) }) @@ -38,9 +39,10 @@ describe("Destructive Command Guard auto-approval precedence", () => { ).toEqual({ decision: "ask" }) }) - it("auto-approves DCG-allowed commands without consulting Zoo's allowlist", async () => { + it("denies with the retryable guard-state detail without consulting Zoo's allowlist when no verdict is supplied", async () => { expect(await checkAutoApproval({ state: baseState, ask: "command", text: "unlisted-command" })).toEqual({ - decision: "approve", + decision: "deny", + autoDeny: { kind: "guard_unavailable", command: "unlisted-command" }, }) }) diff --git a/src/core/auto-approval/autoDenyReason.ts b/src/core/auto-approval/autoDenyReason.ts index 6eb04a172a..b13d438b46 100644 --- a/src/core/auto-approval/autoDenyReason.ts +++ b/src/core/auto-approval/autoDenyReason.ts @@ -1,17 +1,18 @@ /** * Structured detail attached to automatic command denials. * - * An automatic denial is a denial produced by policy (denylist, blanket - * auto-deny, DCG block) rather than by a user clicking "reject". The detail - * travels from `checkAutoApproval` through `Task.ask` to - * `presentAssistantMessage`, where it selects the structured + * An automatic denial is one produced by the system rather than by a user + * clicking "reject": from policy (denylist, blanket auto-deny, DCG block) or + * from a guard-state inconsistency (`guard_unavailable`), which is not itself + * a policy denial. The detail travels from `checkAutoApproval` through + * `Task.ask` to `presentAssistantMessage`, where it selects the structured * `formatResponse.toolAutoDenied` payload instead of the user-rejection * wording — and marks the denial as scoped to its own tool call, so it never * aborts the rest of the turn. */ export type AutoDenyDetail = { - kind: "dcg" | "denylist" | "not_allowlisted" | "dangerous_substitution" | "malformed_command" - /** Offending sub-command text (or the full command, for DCG denials). */ + kind: "dcg" | "denylist" | "not_allowlisted" | "dangerous_substitution" | "malformed_command" | "guard_unavailable" + /** Offending sub-command text (or the full command when no single offending sub-command applies). */ command?: string /** Matched denied prefix, for `denylist` denials. */ pattern?: string @@ -38,12 +39,14 @@ export function buildAutoDenyReason(detail: AutoDenyDetail): string { case "not_allowlisted": return `Command \`${detail.command ?? "(unknown)"}\` is not on the command allowlist.` case "dangerous_substitution": - return "Command contains shell expansions (${...} forms, process substitution, and similar) that require explicit approval." + return "Command contains shell expansions (${...} forms, process substitution, and similar) that are never auto-approved. Choose an approved command without shell expansions." case "malformed_command": return detail.parseError ?? "Command contains a shell syntax error." case "dcg": { const base = `Destructive Command Guard denied the command: ${detail.dcgReason ?? "no reason provided"}` return detail.dcgRuleId ? `${base} (Rule: ${detail.dcgRuleId})` : base } + case "guard_unavailable": + return `Command \`${detail.command ?? "(unknown)"}\` was not executed: the Destructive Command Guard is enabled but supplied no verdict, which is an internal guard-state inconsistency, not a policy denial. You may retry the same command.` } } diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index 02c0d9ddc9..2aa5008fc8 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -143,10 +143,11 @@ export type CheckAutoApprovalResult = /** * Automatic denial. `autoDeny` carries the structured reason and the * offending sub-command when the denial came from command policy (denylist - * match, blanket auto-deny, or a DCG block under blanket mode). It marks - * the denial as policy-scoped — the model receives an explanatory - * `auto_deny` result, and unlike a user rejection the denial does not - * abort the remaining tool calls of the turn. + * match, blanket auto-deny, or a DCG block under blanket mode) or from the + * guard-state inconsistency the command branch denies as + * `guard_unavailable`. It marks the denial as policy-scoped — the model + * receives an explanatory `auto_deny` result, and unlike a user rejection + * the denial does not abort the remaining tool calls of the turn. */ | { decision: "deny"; autoDeny?: AutoDenyDetail } | { decision: "ask" } @@ -284,10 +285,22 @@ export async function checkAutoApproval({ : { decision: "ask" } } - // DCG allowed the command (verdict provided), or no verdict was - // supplied for a DCG-enabled ask (partial asks never reach an - // auto-approval decision). DCG remains the authoritative policy: - // approve. + // A verdictless ask under an enabled guard is an inconsistent + // guard state, not a guard decision: ExecuteCommandTool computes + // and forwards its verdict on one straight-line path, so an ask + // arriving without one means the setting flipped on mid-flight or + // the caller never ran the guard. Deny with an explicitly + // retryable detail — the command did not run, the turn is not + // aborted, and a re-issue re-reads the setting. + if (dcgDecision === undefined) { + return { + decision: "deny", + autoDeny: { kind: "guard_unavailable", command: text }, + } + } + + // The guard returned an explicit allow verdict: DCG is the + // authoritative policy — approve. return { decision: "approve" } } @@ -326,9 +339,12 @@ export async function checkAutoApproval({ } if (containsDangerousSubstitution(text)) { + // The substitution check runs chain-wide, so the full command + // is the offending one; the list classifier reports no single + // offending sub-command when it defers to this branch. return { decision: "deny", - autoDeny: { kind: "dangerous_substitution", command: offendingCommand }, + autoDeny: { kind: "dangerous_substitution", command: offendingCommand ?? text }, } } diff --git a/src/core/task/__tests__/ask-auto-deny.spec.ts b/src/core/task/__tests__/ask-auto-deny.spec.ts index 602f38cd15..57c3fff212 100644 --- a/src/core/task/__tests__/ask-auto-deny.spec.ts +++ b/src/core/task/__tests__/ask-auto-deny.spec.ts @@ -154,6 +154,39 @@ describe("Task.ask resolves blanket command denials with structured detail", () expect(result.autoDenyDetail?.command).toBe("rm x") expect(result.autoDenyDetail?.dcgReason).toBe("matches a destructive pattern") }) + + it("flips to the retryable guard-state deny mid-session and heals once the retried ask carries a verdict", async () => { + // Simulates the guard setting flipping on between consecutive asks at + // the decision-bearing boundary (Task.ask reads provider state per ask, + // then checkAutoApproval sees no verdict for the newly-enabled guard). + // The sub-microsecond tool-vs-setting window itself is only reachable + // end-to-end; what is provable here is that the verdictless arrival + // denies with the retryable detail and that a re-issue carrying a + // verdict is approved again. + const task = buildTask(provider, TASK_CWD) + await attachQueue(task) + + // ask 1: guard off — the ordinary blanket path. + const before = await task.ask("command", "echo hi", false) + expect(before.response).toBe("noButtonClicked") + expect(before.autoDenyDetail?.kind).toBe("not_allowlisted") + + // The flip: the guard setting turns on, but this ask arrives without a + // verdict — an inconsistent guard state, denied retryably, not approved. + state.destructiveCommandGuardEnabled = true + const flipped = await task.ask("command", "rm x", false) + expect(flipped.response).toBe("noButtonClicked") + expect(flipped.autoDenyDetail?.kind).toBe("guard_unavailable") + expect(flipped.autoDenyDetail?.command).toBe("rm x") + + // The retry heals: the re-issued ask carries the guard's allow verdict + // and approves. + const healed = await task.ask("command", "rm x", false, undefined, false, { + dcgDecision: { decision: "allow" }, + }) + expect(healed.response).toBe("yesButtonClicked") + expect(healed.autoDenyDetail).toBeUndefined() + }) }) describe("Task.ask queue path cannot bypass blanket deny", () => { From 0162e54aa173643e24fdaa23dac3f995af775522 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Thu, 24 Sep 2026 17:24:14 +0200 Subject: [PATCH 31/39] test(webview): cover blanket auto-deny toggle save round-trip Saving the settings view posts the blanket auto-deny toggle, and an unset toggle is coerced to false. The local settings buffer holds user edits until Save, so the tests assert both the buffered state and the persisted payload. Addresses automated-review feedback on missing round-trip coverage for the setting. --- .../settings/__tests__/SettingsView.spec.tsx | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index e7545bf562..528cbda497 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -805,3 +805,110 @@ describe("SettingsView - Duplicate Commands", () => { expect(onDone).toHaveBeenCalledTimes(1) }) }) + +describe("SettingsView - Blanket Auto-Deny", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Completes the persisted-setting round trip required by the repo's + // AGENTS.md "Persisted Setting Checklist" for the blanket auto-deny + // toggle: UI binding buffers in cachedState, and only Save persists the + // value to the extension host. + it("saves the blanket auto-deny toggle when clicking Save", () => { + const { activateTab, getSettingsContent } = renderSettingsView() + + // Activate the autoApprove tab + activateTab("autoApprove") + + const content = getSettingsContent() + // Enable always allow execute to reveal the execute section + const executeCheckbox = within(content).getByTestId("always-allow-execute-toggle") + fireEvent.click(executeCheckbox) + + // Enable blanket auto-deny + const autoDenyCheckbox = within(content).getByTestId("auto-deny-unapproved-checkbox") + fireEvent.click(autoDenyCheckbox) + expect(autoDenyCheckbox).toBeChecked() + + // Click Save to save settings + const saveButton = screen.getByTestId("save-button") + fireEvent.click(saveButton) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ + alwaysDenyUnapprovedCommands: true, + }), + }), + ) + }) + + it("posts blanket auto-deny as false when the setting is unset", () => { + // The submit path coerces an omitted setting to false + // (`alwaysDenyUnapprovedCommands ?? false`) rather than omitting the + // key, so the host always receives an explicit boolean. + const { activateTab, getSettingsContent } = renderSettingsView({ + alwaysDenyUnapprovedCommands: undefined, + }) + + // Activate the autoApprove tab + activateTab("autoApprove") + + const content = getSettingsContent() + // Enable always allow execute to reveal the execute section; the + // auto-deny toggle stays un-checked. + const executeCheckbox = within(content).getByTestId("always-allow-execute-toggle") + fireEvent.click(executeCheckbox) + expect(within(content).getByTestId("auto-deny-unapproved-checkbox")).not.toBeChecked() + + // Click Save to save settings + const saveButton = screen.getByTestId("save-button") + fireEvent.click(saveButton) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ + alwaysDenyUnapprovedCommands: false, + }), + }), + ) + }) + + it("buffers the blanket auto-deny toggle until Save", () => { + const { activateTab, getSettingsContent } = renderSettingsView() + + // Activate the autoApprove tab + activateTab("autoApprove") + + const content = getSettingsContent() + // Enable always allow execute to reveal the execute section + const executeCheckbox = within(content).getByTestId("always-allow-execute-toggle") + fireEvent.click(executeCheckbox) + + // Toggle blanket auto-deny on + const autoDenyCheckbox = within(content).getByTestId("auto-deny-unapproved-checkbox") + fireEvent.click(autoDenyCheckbox) + expect(autoDenyCheckbox).toBeChecked() + + // Toggling must NOT persist before Save; it only buffers in cachedState. + expect(vscode.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ alwaysDenyUnapprovedCommands: true }), + }), + ) + + // Save now persists the buffered value. + fireEvent.click(screen.getByTestId("save-button")) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ alwaysDenyUnapprovedCommands: true }), + }), + ) + }) +}) From a8a71871ffec64e03a665c38a7ebb1c9b2489320 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Fri, 25 Sep 2026 17:31:54 +0200 Subject: [PATCH 32/39] fix(task): re-check auto-approval policy when queued answers resume an ask An ask answered from the message queue applied the auto-approval decision frozen when the ask was created: the queued path skipped checkAutoApproval, so a settings change made while the ask was pending (blanket deny switched on, or auto-approval switched off) had no effect and the queued answer could act as approval for a command the new policy would deny. Both queued-answer consume sites in Task now re-read current settings and re-run checkAutoApproval before honoring the answer. When the re-check denies, the path matches the ask-time denial: same bail-out and reason, and the queued message's claim is released so the answer is not lost to the denied ask. The queue guard on that path changed too. It tested only whether the queue was non-empty, so a message still claimed by a previous ask, or left unclaimed after a denied-command ask, counted as an approval source for the next ask. The guard now uses a new hasUnclaimed() on MessageQueueService, and Task latches blanketDeniedCommandThisTurn so no later ask in the same turn can consume a leftover message as approval. A guard_unavailable auto-deny detail now surfaces as a retryable tool_error instead of the terminal toolAutoDenied result: the kind marks a guard-state inconsistency, not a policy decision, and the model should be able to retry rather than see the turn denied. Tests: flip-on regressions at both consume sites, bail-out and claim-release tests, hasUnclaimed coverage, and a spec pinning the auto-deny reason text in the tool-response prompts. --- .../presentAssistantMessage-auto-deny.spec.ts | 115 ++++++++ .../presentAssistantMessage.ts | 44 ++- src/core/message-queue/MessageQueueService.ts | 13 + .../__tests__/MessageQueueService.spec.ts | 30 ++ .../responses-tool-auto-denied.spec.ts | 51 ++++ src/core/task/Task.ts | 263 +++++++++++++++++- src/core/task/__tests__/ask-auto-deny.spec.ts | 263 +++++++++++++++++- 7 files changed, 757 insertions(+), 22 deletions(-) create mode 100644 src/core/prompts/__tests__/responses-tool-auto-denied.spec.ts diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts index 1b282ae3aa..73c3a08137 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts @@ -177,6 +177,14 @@ const DCG_DENY_DETAIL = { dcgRuleId: "R-1", } +// Mirrors checkAutoApproval's guard-state branch: a verdictless ask under an +// enabled Destructive Command Guard denies with kind "guard_unavailable", a +// guard-state inconsistency that must reach the model as a retryable error. +const GUARD_UNAVAILABLE_DETAIL = { + kind: "guard_unavailable" as const, + command: "npm test", +} + // Captures the boolean a tool receives back from askApproval; vi.clearAllMocks() // does not reset closures, so beforeEach must clear this explicitly. let execApproval: boolean | undefined @@ -269,6 +277,113 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { expect(secondResult.is_error).toBeUndefined() }) + it("routes guard_unavailable to a retryable toolError while a policy kind keeps toolAutoDenied (command askApproval copy)", async () => { + mockTask.assistantMessageContent = [executeCommandBlock, listFilesBlock] + + // First ask: guard-state denial (retryable). Second ask: policy denial + // (sibling kind through the same harness) — must stay toolAutoDenied. + mockTask.ask + .mockResolvedValueOnce({ response: "noButtonClicked", autoDenyDetail: GUARD_UNAVAILABLE_DETAIL }) + .mockResolvedValueOnce({ response: "noButtonClicked", autoDenyDetail: NOT_ALLOWLISTED_DETAIL }) + + await presentAssistantMessage(asTask(mockTask)) + + expect(mockTask.userMessageContent).toHaveLength(2) + + // guard_unavailable is a guard-state inconsistency, not a policy denial: + // the model must receive the retryable toolError payload, not the + // auto_deny denial that advises switching to approved commands. + const guardPayload = JSON.parse(mockTask.userMessageContent[0].content as string) + expect(guardPayload.status).toBe("error") + expect(guardPayload.message).toBe("The tool execution failed") + expect(guardPayload.error).toContain("Command `npm test` was not executed") + expect(guardPayload.error).toContain("internal guard-state inconsistency") + expect(guardPayload.error).toContain("You may retry the same command") + expect(guardPayload).not.toHaveProperty("type") + expect(guardPayload).not.toHaveProperty("note") + expect(guardPayload).not.toHaveProperty("suggestion") + + // The tool treats it as a refusal (return false) without aborting the turn. + expect(execApproval).toBe(false) + expect(mockTask.didRejectTool).toBe(false) + + // Sibling policy kind is unchanged: structured auto_deny with the + // denial advice. + const policyPayload = JSON.parse(mockTask.userMessageContent[1].content as string) + expect(policyPayload.status).toBe("denied") + expect(policyPayload.type).toBe("auto_deny") + expect(policyPayload.reason).toContain("not on the command allowlist") + }) + + it("routes guard_unavailable to a retryable toolError while a policy kind keeps toolAutoDenied (MCP askApproval copy)", async () => { + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_mcp_a", + name: "mcp_my_server_do_thing", + serverName: "my_server", + toolName: "do_thing", + arguments: {}, + partial: false, + }, + { + type: "mcp_tool_use", + id: "call_mcp_b", + name: "mcp_my_server_other_thing", + serverName: "my_server", + toolName: "other_thing", + arguments: {}, + partial: false, + }, + ] + + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }), + getMcpHub: () => ({ findServerNameBySanitizedName: () => undefined }), + }), + } + + const approvals: boolean[] = [] + useMcpToolHandle.mockImplementation( + async ( + _task: unknown, + _block: unknown, + { askApproval }: { askApproval: (t: string, m?: string) => Promise }, + ) => { + approvals.push(await askApproval("use_mcp_server", "{}")) + }, + ) + + // Same split on the MCP closure: guard-state first (retryable error), + // denylist second (policy auto_deny). + mockTask.ask + .mockResolvedValueOnce({ response: "noButtonClicked", autoDenyDetail: GUARD_UNAVAILABLE_DETAIL }) + .mockResolvedValueOnce({ + response: "noButtonClicked", + autoDenyDetail: { kind: "denylist", command: "rm x", pattern: "rm" }, + }) + + await presentAssistantMessage(asTask(mockTask)) + + expect(mockTask.userMessageContent).toHaveLength(2) + + const guardPayload = JSON.parse(mockTask.userMessageContent[0].content as string) + expect(guardPayload.status).toBe("error") + expect(guardPayload.error).toContain("internal guard-state inconsistency") + expect(guardPayload.error).toContain("You may retry the same command") + expect(guardPayload).not.toHaveProperty("type") + expect(guardPayload).not.toHaveProperty("suggestion") + + const policyPayload = JSON.parse(mockTask.userMessageContent[1].content as string) + expect(policyPayload.status).toBe("denied") + expect(policyPayload.type).toBe("auto_deny") + expect(policyPayload.reason).toContain("matches denied prefix `rm`") + + expect(approvals).toEqual([false, false]) + expect(mockTask.didRejectTool).toBe(false) + }) + it("routes an auto-deny through the MCP askApproval copy without aborting the turn", async () => { mockTask.assistantMessageContent = [ { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index a8732f8959..1321e6a800 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -232,13 +232,21 @@ export async function presentAssistantMessage(cline: Task) { // `didRejectTool` (remaining tool calls in the turn proceed) // and no `user_feedback` say (the reason is system-generated). if (autoDenyDetail) { - pushToolResult( - formatResponse.toolAutoDenied({ - reason: buildAutoDenyReason(autoDenyDetail), - offendingCommand: autoDenyDetail.command, - ruleId: autoDenyDetail.dcgRuleId, - }), - ) + // `guard_unavailable` marks a guard-state inconsistency, not a + // policy denial: the command never ran and a re-issue re-reads + // the guard setting, so the payload must stay a retryable error + // instead of carrying policy-denial advice. + if (autoDenyDetail.kind === "guard_unavailable") { + pushToolResult(formatResponse.toolError(buildAutoDenyReason(autoDenyDetail))) + } else { + pushToolResult( + formatResponse.toolAutoDenied({ + reason: buildAutoDenyReason(autoDenyDetail), + offendingCommand: autoDenyDetail.command, + ruleId: autoDenyDetail.dcgRuleId, + }), + ) + } return false } @@ -558,13 +566,21 @@ export async function presentAssistantMessage(cline: Task) { // Automatic denials never carry queued feedback — a queued // message forces a real ask. if (autoDenyDetail) { - pushToolResult( - formatResponse.toolAutoDenied({ - reason: buildAutoDenyReason(autoDenyDetail), - offendingCommand: autoDenyDetail.command, - ruleId: autoDenyDetail.dcgRuleId, - }), - ) + // `guard_unavailable` marks a guard-state inconsistency, not a + // policy denial: the command never ran and a re-issue re-reads + // the guard setting, so the payload must stay a retryable error + // instead of carrying policy-denial advice. + if (autoDenyDetail.kind === "guard_unavailable") { + pushToolResult(formatResponse.toolError(buildAutoDenyReason(autoDenyDetail))) + } else { + pushToolResult( + formatResponse.toolAutoDenied({ + reason: buildAutoDenyReason(autoDenyDetail), + offendingCommand: autoDenyDetail.command, + ruleId: autoDenyDetail.dcgRuleId, + }), + ) + } return false } diff --git a/src/core/message-queue/MessageQueueService.ts b/src/core/message-queue/MessageQueueService.ts index a547dbe474..85a2192217 100644 --- a/src/core/message-queue/MessageQueueService.ts +++ b/src/core/message-queue/MessageQueueService.ts @@ -113,6 +113,19 @@ export class MessageQueueService extends EventEmitter { return this._messages.length === 0 } + /** + * Whether at least one queued message is still available to be claimed. + * + * `isEmpty()` measures the queue's length and is blind to claims, so a + * message some consumer is holding (claim is not a removal) still makes it + * report a non-empty queue. Consumers that are about to take a message need + * this instead: it answers "is there anything I may take", which is false + * when every remaining message is already spoken for. + */ + public hasUnclaimed(): boolean { + return this._messages.some((message) => !this.claimedMessageIds.has(message.id)) + } + public dispose(): void { this._messages = [] this.claimedMessageIds.clear() diff --git a/src/core/message-queue/__tests__/MessageQueueService.spec.ts b/src/core/message-queue/__tests__/MessageQueueService.spec.ts index d0abe86bf2..567dca314b 100644 --- a/src/core/message-queue/__tests__/MessageQueueService.spec.ts +++ b/src/core/message-queue/__tests__/MessageQueueService.spec.ts @@ -37,4 +37,34 @@ describe("MessageQueueService claims", () => { expect(queue.messages).toEqual([message]) expect(queue.claimNextMessage()).toEqual(message) }) + + it("reports no unclaimed message only when every queued message is spoken for", () => { + const queue = new MessageQueueService() + queue.addMessage("first") + queue.addMessage("second") + expect(queue.hasUnclaimed()).toBe(true) + + expect(queue.claimNextMessage()).toBeDefined() + // One message held, one still available: a new consumer can still claim. + expect(queue.hasUnclaimed()).toBe(true) + + expect(queue.claimNextMessage()).toBeDefined() + // Both held: the queue is not empty, but nothing remains claimable. + expect(queue.hasUnclaimed()).toBe(false) + }) + + it("pins isEmpty() to queue length so an outstanding claim does not empty it", () => { + const queue = new MessageQueueService() + const message = queue.addMessage("held")! + expect(queue.claimNextMessage()).toEqual(message) + + // A claim is not a removal, so length-based emptiness still counts the + // held message; consumers deciding whether they may take a message must + // use hasUnclaimed() instead. + expect(queue.isEmpty()).toBe(false) + expect(queue.hasUnclaimed()).toBe(false) + + queue.releaseMessage(message.id) + expect(queue.hasUnclaimed()).toBe(true) + }) }) diff --git a/src/core/prompts/__tests__/responses-tool-auto-denied.spec.ts b/src/core/prompts/__tests__/responses-tool-auto-denied.spec.ts new file mode 100644 index 0000000000..5ee03bfcd1 --- /dev/null +++ b/src/core/prompts/__tests__/responses-tool-auto-denied.spec.ts @@ -0,0 +1,51 @@ +// npx vitest run core/prompts/__tests__/responses-tool-auto-denied.spec.ts + +import { formatResponse } from "../responses" + +// `note`/`suggestion` are hardcoded model-facing copy: the payload must never +// advise asking the user, and wording edits must be deliberate, so they are +// pinned verbatim here rather than matched loosely. +const NOTE = "The command chain was rejected in its entirety; none of the chained commands were executed." +const SUGGESTION = + "Re-run the remaining commands as separate execute_command calls using approved commands only, or choose an approved alternative." + +describe("formatResponse.toolAutoDenied", () => { + it("emits the full auto_deny payload when every field is supplied", () => { + const parsed = JSON.parse( + formatResponse.toolAutoDenied({ + reason: "Command `rm x` is not on the command allowlist.", + offendingCommand: "rm x", + ruleId: "R-1", + }), + ) + + expect(parsed.status).toBe("denied") + expect(parsed.type).toBe("auto_deny") + expect(parsed.reason).toBe("Command `rm x` is not on the command allowlist.") + expect(parsed.offending_command).toBe("rm x") + expect(parsed.rule_id).toBe("R-1") + expect(parsed.note).toBe(NOTE) + expect(parsed.suggestion).toBe(SUGGESTION) + }) + + it("omits offending_command and rule_id keys when the detail carries neither", () => { + const parsed = JSON.parse(formatResponse.toolAutoDenied({ reason: "Denied by policy." })) + + expect(parsed.status).toBe("denied") + expect(parsed.type).toBe("auto_deny") + expect(parsed.reason).toBe("Denied by policy.") + expect(parsed).not.toHaveProperty("offending_command") + expect(parsed).not.toHaveProperty("rule_id") + }) + + it("pins the note and suggestion copy verbatim, including the no-ask-the-user wording", () => { + const parsed = JSON.parse(formatResponse.toolAutoDenied({ reason: "Denied by policy." })) + + expect(parsed.note).toBe(NOTE) + expect(parsed.suggestion).toBe(SUGGESTION) + // Asking the user would stall a hands-free session; the suggestion must + // route the model to re-issue approved commands instead. + expect(parsed.note).not.toMatch(/ask the user/i) + expect(parsed.suggestion).not.toMatch(/ask the user/i) + }) +}) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7de6c25260..5979aaa8c8 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -30,6 +30,7 @@ import { type ClineMessage, type ClineSay, type ClineAsk, + type ExtensionState, type ToolProgressStatus, type HistoryItem, type PendingTaskAction, @@ -163,6 +164,50 @@ const QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS = [250, 1_000, 4_000] as const type QueuedAskResolution = { response: ClineAskResponse; requiresDurableAck: boolean } +/** + * Denial kinds the command policy produces only while blanket auto-deny is on + * (`checkAutoApproval` returns `ask` for these when the setting is off — see + * the command branch of `src/core/auto-approval/index.ts`), so a denial of one + * of these kinds is blanket-caused. `denylist` and `guard_unavailable` are + * excluded: they deny independently of the blanket setting. + */ +const BLANKET_DENY_AUTO_DENY_KINDS: ReadonlySet = new Set([ + "dcg", + "not_allowlisted", + "dangerous_substitution", + "malformed_command", +]) + +/** + * What to do with a claimed queued message about to answer a command ask, + * decided against the auto-approval policy as it stands NOW. + */ +type QueuedCommandPolicyAction = + | { action: "consume" } + | { action: "approve" } + | { action: "deny"; detail?: AutoDenyDetail } + | { action: "release" } + +/** + * Whether blanket auto-deny currently engages. The three settings act as a + * conjunction: blanket deny only means anything while auto-approval is on and + * command auto-approval is on — without those two an unapproved command is + * prompted rather than auto-approved, so there is nothing to deny. + * + * Single source of truth for the derivation: the ask-time snapshot and the + * consume-site re-reads must not drift apart, or a flip landing between the + * two decides consumption on stale policy. + */ +function isBlanketDenyEngaged( + state?: Pick, +): boolean { + return ( + state?.alwaysDenyUnapprovedCommands === true && + state?.autoApprovalEnabled === true && + state?.alwaysAllowExecute === true + ) +} + function queuedResponseForAsk(type: ClineAsk, text?: string): QueuedAskResolution | undefined { if (type === "command_output") { return undefined @@ -383,6 +428,15 @@ export class Task extends EventEmitter implements TaskLike { * user feedback and triggers a `user_feedback` say row. */ private pendingAutoDenyDetail?: AutoDenyDetail + /** + * Set while the current turn has blanket-denied a command ask. The queued + * messages such a denial deliberately leaves in place were typed in response + * to that denial, not as approval — so a later ask in the same turn must not + * consume one as `yesButtonClicked` (which would silently approve it with the + * interactive prompt suppressed). Read at the queued-message consume sites; + * cleared by the per-turn reset beside `didToolFailInCurrentTurn`. + */ + private blanketDeniedCommandThisTurn = false public lastMessageTs?: number private autoApprovalTimeoutRef?: NodeJS.Timeout @@ -1017,6 +1071,101 @@ export class Task extends EventEmitter implements TaskLike { return undefined } + /** + * Re-read the auto-approval policy immediately before a queued message would + * stand in for a command ask's approval, and consult the full command policy + * under the fresh state if blanket deny now engages. + * + * The queued-answer shortcut skips `checkAutoApproval` and the ask's decision + * otherwise rides on a single settings snapshot taken before the prompt was + * shown. A blanket-deny flip landing between the snapshot and the consume is + * invisible to that frozen gate, and the queued message would auto-approve an + * unallowlisted command — fail-OPEN through a window as wide as the prompt + * dwell. Re-reading here closes it: while blanket deny engages, the message + * is never consumed as approval and the ask gets the structured denial policy + * would have produced without a queued message. + */ + private async recheckQueuedCommandPolicy({ + text, + isProtected, + dcgDecision, + }: { + text?: string + isProtected?: boolean + dcgDecision?: AutoApprovalContext["dcgDecision"] + }): Promise { + const freshState = await this.providerRef.deref()?.getState() + if (!isBlanketDenyEngaged(freshState)) { + // Disengaged: the queued answer is a legitimate approval, as before. + return { action: "consume" } + } + // Engaged: the full policy decides, with the fresh state. `cwd` and the + // forwarded DCG verdict match the ask-time `checkAutoApproval` call. + const approval = await checkAutoApproval({ + state: freshState, + cwd: this.cwd, + ask: "command", + text, + isProtected, + dcgDecision, + }) + if (approval.decision === "deny") { + return { action: "deny", detail: approval.autoDeny } + } + if (approval.decision === "approve") { + return { action: "approve" } + } + return { action: "release" } + } + + /** + * Apply a queued-command policy re-check outcome to a claimed message: the + * claim is released on every path except the legitimate consume, and an + * engaged-policy outcome resolves the ask the same way the no-queued-message + * path would (structured denial, approval, or — for a plain `ask` decision — + * left pending for the user). + */ + private applyQueuedCommandPolicyAction( + action: QueuedCommandPolicyAction, + message: QueuedMessage, + resolution: QueuedAskResolution, + ): string | undefined { + if (action.action !== "consume") { + this.messageQueueService.releaseMessage(message.id) + } + switch (action.action) { + case "consume": + return this.handleQueuedAskResponse(message, resolution) + case "deny": + if (action.detail && BLANKET_DENY_AUTO_DENY_KINDS.has(action.detail.kind)) { + this.blanketDeniedCommandThisTurn = true + } + this.pendingAutoDenyDetail = action.detail + this.denyAsk() + return undefined + case "approve": + this.approveAsk() + return undefined + case "release": + return undefined + } + } + + /** + * Shared claim-gate for the two queued-message consume sites. + * + * The latch blocks a message left in the queue by a command this turn already + * blanket-denied (it answers that denial, not the current ask), and + * `hasUnclaimed()` replaces the length-only `isEmpty()`, which reports a + * queue containing nothing but claims as available for a new consumer. + * `isMessageQueued`/`isStatusMutable` keep `isEmpty()` semantics on purpose: + * flipping those would re-enable interactive prompt timers whenever a claim + * is outstanding. + */ + private mayDrainQueuedMessageForAsk(): boolean { + return !this.blanketDeniedCommandThisTurn && this.messageQueueService.hasUnclaimed() + } + static create(options: TaskOptions): [Task, Promise] { const instance = new Task({ ...options, startTask: false }) const { images, task, historyItem } = options @@ -1487,10 +1636,7 @@ export class Task extends EventEmitter implements TaskLike { // The blanket auto-deny setting only engages while command auto-approval // is on; while it is disengaged, the queued-message shortcut below is // unaffected. - const blanketDenyEngaged = - state?.alwaysDenyUnapprovedCommands === true && - state?.autoApprovalEnabled === true && - state?.alwaysAllowExecute === true + const blanketDenyEngaged = isBlanketDenyEngaged(state) // A queued message normally answers the pending ask, which for command asks // means an unconditional auto-approve. That shortcut must never bypass // blanket deny: while it is engaged, a command ask keeps its policy @@ -1634,6 +1780,20 @@ export class Task extends EventEmitter implements TaskLike { // *user feedback* and triggers a `user_feedback` say row, while this // reason is system-generated. this.pendingAutoDenyDetail = approval.decision === "deny" ? approval.autoDeny : undefined + // A blanket-caused denial leaves the queued messages this turn for later + // turns; latch so no ask in this turn consumes one as approval. Set only on + // blanket-caused denials — here and at the consume-site re-check + // (`applyQueuedCommandPolicyAction`) — and never cleared mid-turn: the + // per-turn reset owns clearing, so a later non-denied ask cannot drop the + // latch prematurely. + if ( + type === "command" && + approval.decision === "deny" && + approval.autoDeny && + BLANKET_DENY_AUTO_DENY_KINDS.has(approval.autoDeny.kind) + ) { + this.blanketDeniedCommandThisTurn = true + } if (approval.decision === "approve") { this.approveAsk() @@ -1701,7 +1861,64 @@ export class Task extends EventEmitter implements TaskLike { ) } } else if (isMessageQueued && shouldDrainQueuedMessageForAsk && queuedMessage && queuedAskResolution) { - queuedMessageId = this.handleQueuedAskResponse(queuedMessage, queuedAskResolution) + if (this.blanketDeniedCommandThisTurn) { + // A command this turn already blanket-denied left the message in the + // queue — it answers that denial, not this ask. Release the claim and + // let the user answer the prompt normally. + this.messageQueueService.releaseMessage(queuedMessage.id) + } else if (type === "command") { + // The snapshot gate is frozen; blanket deny may have engaged since the + // ask began. Re-read policy before the message stands in for approval. + try { + const action = await this.recheckQueuedCommandPolicy({ + text, + isProtected, + dcgDecision: autoApprovalContext?.dcgDecision, + }) + if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { + // The user answered, the ask was superseded, or the task aborted + // while the fresh read was pending: the message is none of this + // ask's business — leave it for the next consumer. The ask + // resolves with the user's own response via the pWaitFor below. + this.messageQueueService.releaseMessage(queuedMessage.id) + } else { + queuedMessageId = this.applyQueuedCommandPolicyAction( + action, + queuedMessage, + queuedAskResolution, + ) + } + } catch (error) { + // Drain-site parity: a failed re-check must not reject ask() nor + // strand the claim; the prompt stays pending for the user. + console.error("[Task#ask] queued command policy re-check failed:", error) + this.messageQueueService.releaseMessage(queuedMessage.id) + } + } else { + queuedMessageId = this.handleQueuedAskResponse(queuedMessage, queuedAskResolution) + } + } + + // At most one drain-site policy re-check is in flight per ask; the + // pWaitFor predicate is synchronous, so its await runs out here. + let queuedCommandPolicyCheck: Promise | undefined + const verifyDrainedCommandMessage = async ( + message: QueuedMessage, + resolution: QueuedAskResolution, + ): Promise => { + const action = await this.recheckQueuedCommandPolicy({ + text, + isProtected, + dcgDecision: autoApprovalContext?.dcgDecision, + }) + if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { + // The user answered, the ask was superseded, or the task aborted + // while the fresh read was pending: the message is none of this + // ask's business — leave it for the next consumer. + this.messageQueueService.releaseMessage(message.id) + return + } + queuedMessageId = this.applyQueuedCommandPolicyAction(action, message, resolution) } // Wait for askResponse to be set @@ -1716,11 +1933,32 @@ export class Task extends EventEmitter implements TaskLike { // immediately so the task doesn't hang. Command asks under blanket deny are // excluded (`queueMayAnswerThisAsk`): a queued message must never stand in // for the explicit approval the policy withheld. - if (queueMayAnswerThisAsk && shouldDrainQueuedMessageForAsk && !this.messageQueueService.isEmpty()) { + if ( + queueMayAnswerThisAsk && + shouldDrainQueuedMessageForAsk && + !queuedCommandPolicyCheck && + this.mayDrainQueuedMessageForAsk() + ) { const message = this.messageQueueService.claimNextMessage() const resolution = message ? queuedResponseForAsk(type, text) : undefined if (message && resolution) { - queuedMessageId = this.handleQueuedAskResponse(message, resolution) + if (type === "command") { + // Claim first, then verify the policy off-predicate: a + // blanket-deny flip landing during the prompt dwell is + // invisible to the frozen snapshot gate, so the claim is + // provisional until the fresh check clears it. + queuedCommandPolicyCheck = verifyDrainedCommandMessage(message, resolution).catch( + (error) => { + // The background check must never reject unhandled; + // on failure the claim is released so the message + // stays available to a later consumer. + console.error("[Task#ask] queued command policy re-check failed:", error) + this.messageQueueService.releaseMessage(message.id) + }, + ) + } else { + queuedMessageId = this.handleQueuedAskResponse(message, resolution) + } } } @@ -1729,6 +1967,13 @@ export class Task extends EventEmitter implements TaskLike { { interval: 100 }, ) + // Let a policy re-check that was in flight when the wait resolved run to + // completion: its bail-out path releases the claim, and leaving it + // unresolved would let the consume race the result below. + if (queuedCommandPolicyCheck) { + await queuedCommandPolicyCheck + } + /* v8 ignore next 3 -- abort-while-waiting path; covered by e2e standalone-resume test */ if (this.abort) { if (queuedMessageId) { @@ -3300,6 +3545,10 @@ export class Task extends EventEmitter implements TaskLike { // only prevent attempt_completion within the same assistant message, not across turns // (e.g., if a tool fails, then user sends a message saying "just complete anyway") this.didToolFailInCurrentTurn = false + // A blanket command denial only suppresses queued-message approval + // for the turn that earned it: a message the user queues afterward + // (a new turn) is fair game for the next ask to answer. + this.blanketDeniedCommandThisTurn = false this.presentAssistantMessageLocked = false this.presentAssistantMessageHasPendingUpdates = false // No legacy text-stream tool parser. diff --git a/src/core/task/__tests__/ask-auto-deny.spec.ts b/src/core/task/__tests__/ask-auto-deny.spec.ts index 57c3fff212..7ee42f5273 100644 --- a/src/core/task/__tests__/ask-auto-deny.spec.ts +++ b/src/core/task/__tests__/ask-auto-deny.spec.ts @@ -2,8 +2,16 @@ import type { ExtensionState } from "@roo-code/types" +import { createRateLimitClock } from "../RateLimitClock" import { Task } from "../Task" +// The streaming-loop drive below never asserts on environment details; the +// real collector reaches into the VS Code window API, which this file's +// lightweight task stub does not model. +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + // Blanket auto-deny (`alwaysDenyUnapprovedCommands`) at the Task level: a // command ask that policy denies must resolve immediately with the structured // `autoDenyDetail` (so presentAssistantMessage can distinguish it from a user @@ -11,10 +19,12 @@ import { Task } from "../Task" // (`autoApprovalDecision: "deny"` + `isAnswered`). A subsequent ask must never // see a stale detail from a previous denial. -/** The parts of the provider that `Task.ask` reaches for. */ +/** The parts of the provider that `Task.ask` and the streaming-loop drive reach for. */ type ProviderStub = { getState: () => Promise> postMessageToWebview: ReturnType + postStateToWebviewWithoutTaskHistory: ReturnType + getSkillsManager: () => undefined cwd: string } @@ -49,6 +59,37 @@ async function attachQueue(task: Task) { return queue } +/** + * Adds the fields `recursivelyMakeClineRequests` touches before its per-turn + * reset, so a test can drive one assistant turn without the full provider + * harness. The stubbed `attemptApiRequest` streams a single text chunk; + * `abandoned` ends the loop right after the stream via the loop's + * abort/abandoned exit, so the drive stops before any post-stream tool + * presentation and never reaches the retry/backoff paths. + */ +function attachTurnHarness(task: Task) { + task.messageCounts = { user: 0, assistant: 0 } + task.apiConversationHistory = [] + task["abandoned"] = true + Object.defineProperty(task, "api", { + value: { getModel: () => ({ id: "gpt-4.1", info: {} }) }, + }) + Object.defineProperty(task, "apiConfiguration", { value: { apiProvider: undefined } }) + Object.defineProperty(task, "rateLimitClock", { value: createRateLimitClock() }) + Object.defineProperty(task, "diffViewProvider", { value: { isEditing: false, reset: async () => {} } }) + Object.defineProperty(task, "streamingToolCallIndices", { value: new Map() }) + task["saveApiConversationHistory"] = vi.fn(async () => true) + task["say"] = vi.fn(async () => undefined) + // The loop rewrites the newest api_req_started row with cost data; the + // no-op `say` stub above never adds one itself. + task["clineMessages"].push({ type: "say", say: "api_req_started", text: "{}", ts: Date.now() }) + task["attemptApiRequest"] = vi.fn().mockImplementation(() => + (async function* () { + yield { type: "text" as const, text: "next turn reply" } + })(), + ) +} + const TASK_CWD = "/path/to/task-workspace" describe("Task.ask resolves blanket command denials with structured detail", () => { @@ -68,6 +109,8 @@ describe("Task.ask resolves blanket command denials with structured detail", () } provider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + getSkillsManager: () => undefined, cwd: TASK_CWD, getState: async () => state, } @@ -204,6 +247,8 @@ describe("Task.ask queue path cannot bypass blanket deny", () => { } provider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + getSkillsManager: () => undefined, cwd: TASK_CWD, getState: async () => state, } @@ -246,4 +291,220 @@ describe("Task.ask queue path cannot bypass blanket deny", () => { // Non-durable resolution consumed the queued message. expect(queue.messages).toHaveLength(0) }) + + it("keeps the queue gate open (queued message answers the command ask) while autoApprovalEnabled is false", async () => { + // The blanket gate is a conjunction of three settings; each false member + // alone must disengage it, so the queued shortcut stays live. + state.autoApprovalEnabled = false + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + queue.addMessage("queued feedback with the conjunction incomplete") + + const result = await task.ask("command", "rm x", false) + + expect(result.response).toBe("yesButtonClicked") + expect(result.text).toBe("queued feedback with the conjunction incomplete") + expect(queue.messages).toHaveLength(0) + }) + + it("keeps the queue gate open (queued message answers the command ask) while alwaysAllowExecute is false", async () => { + state.alwaysAllowExecute = false + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + queue.addMessage("queued feedback with the conjunction incomplete") + + const result = await task.ask("command", "rm x", false) + + expect(result.response).toBe("yesButtonClicked") + expect(result.text).toBe("queued feedback with the conjunction incomplete") + expect(queue.messages).toHaveLength(0) + }) + + it("denies the command ask when blanket deny engages between the snapshot and the immediate queued consume", async () => { + // The ask-time snapshot must not be the last word: the queued shortcut + // skips checkAutoApproval, so a blanket-deny engagement landing after the + // snapshot is invisible to it and would auto-approve an unallowlisted + // command. Interleaving here: first getState = snapshot (deny OFF, so the + // message is claimed); second getState = the consume-site re-check, at + // which the settings save has landed (deny ON). + state.alwaysDenyUnapprovedCommands = false + let getStateCalls = 0 + provider.getState = async () => { + getStateCalls++ + if (getStateCalls >= 2) { + state.alwaysDenyUnapprovedCommands = true + } + return state + } + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + queue.addMessage("queued feedback arriving during the flip window") + + const result = await task.ask("command", "rm x", false) + + expect(result.response).toBe("noButtonClicked") + expect(result.autoDenyDetail?.kind).toBe("not_allowlisted") + // The claimed message was released, not consumed as a fake approval. + expect(result.queuedMessageId).toBeUndefined() + expect(queue.messages).toHaveLength(1) + expect(queue.hasUnclaimed()).toBe(true) + }) + + it("the user's Deny during the immediate re-check await is not overwritten", async () => { + // The Deny lands while the fresh policy read is pending (queued behind + // the backlog). The re-check itself still resolves `consume` — applying + // that outcome would answer the ask with the queued message's + // yesButtonClicked over the user's decision. + state.alwaysDenyUnapprovedCommands = false + const task = buildTask(provider, TASK_CWD) + let getStateCalls = 0 + provider.getState = async () => { + getStateCalls++ + if (getStateCalls === 2) { + task.handleWebviewAskResponse("noButtonClicked") + } + return state + } + const queue = await attachQueue(task) + queue.addMessage("queued feedback arriving behind the user's Deny") + + const result = await task.ask("command", "rm x", false) + + expect(result.response).toBe("noButtonClicked") + expect(result.text).toBeUndefined() + expect(result.queuedMessageId).toBeUndefined() + // The claim was released, not consumed: the message survives for the + // next consumer. + expect(queue.messages).toHaveLength(1) + expect(queue.hasUnclaimed()).toBe(true) + }) + + it("a throwing re-check at the immediate site releases the claim and leaves the prompt pending", async () => { + // A rejected policy read must neither reject ask() nor strand the claim: + // the prompt stays pending for the user and the message survives for a + // later consumer. + state.alwaysDenyUnapprovedCommands = false + let getStateCalls = 0 + // Sticky: every read after the snapshot fails, so the drain site's + // re-check also fails and the released claim survives for the assertion + // poll instead of being re-claimed and legitimately consumed. + provider.getState = async () => { + getStateCalls++ + if (getStateCalls >= 2) { + throw new Error("policy read failed") + } + return state + } + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + queue.addMessage("queued feedback with a failing policy read") + const addToClineMessages = task["addToClineMessages"] as ReturnType + + const askPromise = task.ask("command", "rm x", false) + // Past the prompt post, into the re-check await; then poll for the + // catch-arm's claim release (no fixed sleep). + await vi.waitFor(() => expect(addToClineMessages).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(queue.hasUnclaimed()).toBe(true)) + + task.approveAsk() + const result = await askPromise + expect(result.response).toBe("yesButtonClicked") + expect(result.text).toBeUndefined() + expect(result.queuedMessageId).toBeUndefined() + expect(queue.messages).toHaveLength(1) + }) + + it("denies the command ask when blanket deny engages during the prompt dwell and the drain claims a message", async () => { + // The seconds-wide fail-open window: the flip and the queue arrival both + // land during the pWaitFor dwell, after the frozen snapshot gate opened. + state.alwaysDenyUnapprovedCommands = false + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + const addToClineMessages = task["addToClineMessages"] as ReturnType + + const askPromise = task.ask("command", "rm x", false) + // Past the snapshot read, into the prompt dwell. + await vi.waitFor(() => expect(addToClineMessages).toHaveBeenCalledTimes(1)) + state.alwaysDenyUnapprovedCommands = true + queue.addMessage("queued during the dwell") + + const result = await askPromise + + expect(result.response).toBe("noButtonClicked") + expect(result.autoDenyDetail?.kind).toBe("not_allowlisted") + expect(result.queuedMessageId).toBeUndefined() + expect(queue.messages).toHaveLength(1) + expect(queue.hasUnclaimed()).toBe(true) + }) + + it("a tool ask after a blanket-denied command leaves both queued messages for the user instead of self-approving", async () => { + // A message left in the queue by a blanket denial answers that denial, not + // whatever ask runs next in the same turn; consuming it as + // yesButtonClicked would silently approve (and suppress the prompt for) + // the next ask. + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + const addToClineMessages = task["addToClineMessages"] as ReturnType + queue.addMessage("feedback on the denied command") + + const denied = await task.ask("command", "rm x", false) + expect(denied.response).toBe("noButtonClicked") + expect(task["blanketDeniedCommandThisTurn"]).toBe(true) + + // Same turn: a non-command ask may claim the surviving message; the latch + // must stop it from being consumed as approval. + let settled: Awaited> | undefined + const toolAsk = task + .ask("tool", JSON.stringify({ tool: "write_to_file", path: "a.txt" }), false) + .then((result) => { + settled = result + return result + }) + await vi.waitFor(() => expect(addToClineMessages).toHaveBeenCalledTimes(2)) + // A second message arriving during the dwell exercises the drain site's + // latch guard, not just the immediate-consume guard. + queue.addMessage("second message during the tool-ask dwell") + // Poll with a deadline for the failure mode (ask self-resolving via the + // queue); a correctly latched ask stays pending for the user. + await vi.waitFor(() => expect(settled).toBeDefined(), { timeout: 350, interval: 25 }).catch(() => undefined) + expect(settled).toBeUndefined() + expect(queue.messages).toHaveLength(2) + + // The user answers the prompt themselves; neither queued message rides along. + task.approveAsk() + const result = await toolAsk + expect(result.response).toBe("yesButtonClicked") + expect(result.text).toBeUndefined() + expect(result.queuedMessageId).toBeUndefined() + expect(queue.messages).toHaveLength(2) + expect(queue.hasUnclaimed()).toBe(true) + }) + + it("the next turn's tool ask consumes the queued message once the latch reset clears it", async () => { + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + attachTurnHarness(task) + queue.addMessage("queued feedback") + + const denied = await task.ask("command", "rm x", false) + expect(denied.response).toBe("noButtonClicked") + expect(task["blanketDeniedCommandThisTurn"]).toBe(true) + + // The only production clear is the per-turn reset inside the streaming + // loop (beside didToolFailInCurrentTurn), so the latch is released by + // driving a real assistant turn — a hand-set flag would not pin it. + await task.recursivelyMakeClineRequests([{ type: "text", text: "next turn" }], false) + expect(task["blanketDeniedCommandThisTurn"]).toBe(false) + + const result = await task.ask("tool", JSON.stringify({ tool: "readFile", path: "a.txt" }), false) + + expect(result.response).toBe("yesButtonClicked") + expect(result.text).toBe("queued feedback") + expect(queue.messages).toHaveLength(0) + }) }) From f59c6665f25e0e3b7f0fceb555e74a4b599722f8 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Mon, 28 Sep 2026 02:02:25 +0200 Subject: [PATCH 33/39] fix(tools): re-check command policy before an approved command runs An approved execute_command request ran on the policy snapshot taken before the ask: engaging blanket auto-deny while the approval prompt was open had no effect, and the command still reached the terminal. After approval the tool now re-reads settings and, with blanket auto-deny engaged, re-runs checkAutoApproval before the shell starts. A fresh deny is honored: a blanket cause returns the same auto-denied response as the ask-time denial and marks the turn, so a queued message left by the denial cannot answer a later ask; a guard-state inconsistency (guard_unavailable) or a deny without a reason returns a retryable tool_error instead, since neither is a policy decision. The blanket-deny derivation no longer lives in two places. ExecuteCommandTool calls isBlanketDenyEngaged and consults BLANKET_DENY_AUTO_DENY_KINDS, both now exported from Task and already used by the ask-time snapshot and the queued-answer re-checks, so the decision points cannot drift apart. The protected-prompt flag sent with the command ask is derived from the same snapshot instead of hardcoded, and is forwarded to the execute-time re-check so its decision is computed against the ask the user actually answered. Tests: blanket deny engaging during the approval dwell is denied at execute time; a guard flip during the dwell returns a retryable error without marking the turn; a user Deny landing while the queued-answer re-check awaits is not overwritten; a denial at the queue drain marks the turn and blocks the follow-up ask. --- src/core/task/Task.ts | 23 +++- src/core/task/__tests__/ask-auto-deny.spec.ts | 92 +++++++++++++++ src/core/tools/ExecuteCommandTool.ts | 87 +++++++++++--- .../__tests__/executeCommandTool.spec.ts | 110 +++++++++++++++++- 4 files changed, 292 insertions(+), 20 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index f3a2314dc8..0933853e17 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -172,7 +172,7 @@ type QueuedAskResolution = { response: ClineAskResponse; requiresDurableAck: boo * of these kinds is blanket-caused. `denylist` and `guard_unavailable` are * excluded: they deny independently of the blanket setting. */ -const BLANKET_DENY_AUTO_DENY_KINDS: ReadonlySet = new Set([ +export const BLANKET_DENY_AUTO_DENY_KINDS: ReadonlySet = new Set([ "dcg", "not_allowlisted", "dangerous_substitution", @@ -195,11 +195,12 @@ type QueuedCommandPolicyAction = * command auto-approval is on — without those two an unapproved command is * prompted rather than auto-approved, so there is nothing to deny. * - * Single source of truth for the derivation: the ask-time snapshot and the - * consume-site re-reads must not drift apart, or a flip landing between the - * two decides consumption on stale policy. + * Single source of truth for the derivation: the ask-time snapshot, the + * consume-site re-reads, and the tool-level execute-time re-check must not + * drift apart, or a flip landing between two of them decides execution or + * consumption on stale policy. */ -function isBlanketDenyEngaged( +export function isBlanketDenyEngaged( state?: Pick, ): boolean { return ( @@ -1167,6 +1168,18 @@ export class Task extends EventEmitter implements TaskLike { return !this.blanketDeniedCommandThisTurn && this.messageQueueService.hasUnclaimed() } + /** + * Latch the turn after a blanket-caused command denial detected outside + * the ask path (the tool's execute-time policy re-check). A message left + * in the queue by that denial answers the denial, not the next ask, so + * without the latch a later non-command ask would consume it as an + * approval. Deliberately not cleared mid-turn: the per-turn reset owns + * clearing. + */ + public recordBlanketCommandDenial(): void { + this.blanketDeniedCommandThisTurn = true + } + static create(options: TaskOptions): [Task, Promise] { const instance = new Task({ ...options, startTask: false }) const { images, task, historyItem } = options diff --git a/src/core/task/__tests__/ask-auto-deny.spec.ts b/src/core/task/__tests__/ask-auto-deny.spec.ts index 7ee42f5273..e6582e1ad3 100644 --- a/src/core/task/__tests__/ask-auto-deny.spec.ts +++ b/src/core/task/__tests__/ask-auto-deny.spec.ts @@ -442,6 +442,98 @@ describe("Task.ask queue path cannot bypass blanket deny", () => { expect(queue.hasUnclaimed()).toBe(true) }) + it("the user's Deny landing while the drain-site re-check awaits is not overwritten", async () => { + // Distinct from the immediate-site supersede case: the message arrives + // during the prompt dwell, so the claim and the policy re-check run + // inside the pWaitFor predicate's drain closure. The Deny lands while + // that re-check awaits; without the closure's bail-out guard the drained + // message would answer the ask over the user's decision. + state.alwaysDenyUnapprovedCommands = false + const task = buildTask(provider, TASK_CWD) + let getStateCalls = 0 + provider.getState = async () => { + getStateCalls++ + if (getStateCalls === 2) { + // The Deny lands while the drain closure's fresh policy read is + // pending (first call is the ask's snapshot). + task.handleWebviewAskResponse("noButtonClicked") + } + return state + } + const queue = await attachQueue(task) + const addToClineMessages = task["addToClineMessages"] as ReturnType + + const askPromise = task.ask("command", "rm x", false) + // Past the snapshot read, into the dwell; the message arriving now + // forces the drain site rather than the immediate consume path. + await vi.waitFor(() => expect(addToClineMessages).toHaveBeenCalledTimes(1)) + queue.addMessage("queued during the dwell, superseded by the user's Deny") + + const result = await askPromise + + expect(result.response).toBe("noButtonClicked") + expect(result.text).toBeUndefined() + expect(result.queuedMessageId).toBeUndefined() + // The provisional claim was released, not consumed: the message + // survives for the next consumer. + expect(queue.messages).toHaveLength(1) + expect(queue.hasUnclaimed()).toBe(true) + }) + + it("latches the turn when the denial fires at the drain site and the latch blocks the follow-up ask", async () => { + // The drain-site denial takes the same latch path as the immediate site + // (the deny branch of `applyQueuedCommandPolicyAction`); the follow-up + // ask proves the latch — not just this ask's denial result — keeps + // queue messages from standing in as approval for the rest of the turn. + state.alwaysDenyUnapprovedCommands = false + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + const addToClineMessages = task["addToClineMessages"] as ReturnType + + const askPromise = task.ask("command", "rm x", false) + // Past the snapshot read, into the prompt dwell: the flip and the + // message arrival both land during the dwell, so the denial is produced + // by the drain site's re-check rather than the frozen snapshot gate. + await vi.waitFor(() => expect(addToClineMessages).toHaveBeenCalledTimes(1)) + state.alwaysDenyUnapprovedCommands = true + queue.addMessage("queued during the dwell") + + const denied = await askPromise + expect(denied.response).toBe("noButtonClicked") + expect(denied.autoDenyDetail?.kind).toBe("not_allowlisted") + // The latch set is the drain-site path's, not the main ask path's: the + // snapshot saw the blanket setting off, so `checkAutoApproval` asked. + expect(task["blanketDeniedCommandThisTurn"]).toBe(true) + + // Same turn: with the latch set, `mayDrainQueuedMessageForAsk` gates the + // claim itself at both consume sites, so the follow-up ask never claims + // either queued message — no message may answer it as approval. + let settled: Awaited> | undefined + const toolAsk = task + .ask("tool", JSON.stringify({ tool: "write_to_file", path: "a.txt" }), false) + .then((result) => { + settled = result + return result + }) + await vi.waitFor(() => expect(addToClineMessages).toHaveBeenCalledTimes(2)) + queue.addMessage("second message during the tool-ask dwell") + // Poll with a deadline for the failure mode (ask self-resolving via the + // queue); a correctly latched ask stays pending for the user. + await vi.waitFor(() => expect(settled).toBeDefined(), { timeout: 350, interval: 25 }).catch(() => undefined) + expect(settled).toBeUndefined() + expect(queue.messages).toHaveLength(2) + + // The user answers the prompt themselves; neither queued message rides along. + task.approveAsk() + const result = await toolAsk + expect(result.response).toBe("yesButtonClicked") + expect(result.text).toBeUndefined() + expect(result.queuedMessageId).toBeUndefined() + expect(queue.messages).toHaveLength(2) + expect(queue.hasUnclaimed()).toBe(true) + }) + it("a tool ask after a blanket-denied command leaves both queued messages for the user instead of self-approving", async () => { // A message left in the queue by a blanket denial answers that denial, not // whatever ask runs next in the same turn; consuming it as diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 33eb614706..7a104b04e0 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -7,7 +7,8 @@ import delay from "delay" import { CommandExecutionStatus, DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE, PersistedCommandOutput } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { Task } from "../task/Task" +import { buildAutoDenyReason, checkAutoApproval } from "../auto-approval" +import { BLANKET_DENY_AUTO_DENY_KINDS, isBlanketDenyEngaged, Task } from "../task/Task" import type { ClineProvider } from "../webview/ClineProvider" import type { DcgDecision } from "../../services/destructive-command-guard" @@ -154,17 +155,22 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { // The blanket auto-deny setting only engages while command auto-approval // is on. A DCG block keeps its protected user prompt unless the blanket - // setting is fully engaged, in which case it is auto-denied. The blanket - // decision is frozen to this pre-ask snapshot, while terminal behavior is - // re-read after approval so a settings flip during a pending prompt takes - // effect. A blanket off-flip landing between this snapshot and Task.ask's - // own re-read routes a DCG block to the normal prompt instead of the - // protected one; a user still decides either way, so the snapshot stays. + // setting is fully engaged, in which case it is auto-denied. This + // snapshot governs only how the ask is presented; after approval, the + // engagement, the command policy, and terminal behavior are re-derived + // from a fresh read (below), so a settings flip during a pending prompt + // takes effect — engaging blanket deny denies the command instead of + // executing it. A blanket off-flip landing between this snapshot and + // Task.ask's own re-read routes a DCG block to the normal prompt instead + // of the protected one; a user still decides either way, so the snapshot + // stays. const providerState = await provider?.getState() - const blanketAutoDeny = - providerState?.alwaysDenyUnapprovedCommands === true && - providerState?.autoApprovalEnabled === true && - providerState?.alwaysAllowExecute === true + const blanketAutoDeny = isBlanketDenyEngaged(providerState) + + // Outside blanket mode a DCG block is presented as the protected + // prompt. The execute-time re-check forwards the same flag so the fresh + // decision is computed against the ask the user actually answered. + const isProtectedAsk = dcgDecision !== undefined && dcgDecision.decision === "deny" && !blanketAutoDeny // DCG-approved commands are auto-approved by checkAutoApproval (from the // passed verdict). A DCG block is either auto-denied with the guard's @@ -179,7 +185,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } else if (blanketAutoDeny) { didApprove = await askApproval("command", canonicalCommand, undefined, false, { dcgDecision }) } else { - didApprove = await askApproval("command", canonicalCommand, undefined, true) + didApprove = await askApproval("command", canonicalCommand, undefined, isProtectedAsk) } if (!didApprove) { @@ -188,8 +194,61 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { const executionId = task.lastMessageTs?.toString() ?? Date.now().toString() // Re-read after approval so a settings flip while the approval prompt - // was pending is honored for terminal behavior. - const { terminalShellIntegrationDisabled = true } = (await provider?.getState()) ?? {} + // was pending is honored for terminal behavior and policy. + const freshState = await provider?.getState() + const { terminalShellIntegrationDisabled = true } = freshState ?? {} + + if (isBlanketDenyEngaged(freshState)) { + // Execute-time re-validate: the approval rode on the pre-ask + // snapshot, so a blanket-deny engagement that landed between the + // approval and this point would otherwise execute a command the + // fresh policy denies. Parity call with the drain-site re-check, + // forwarding the verdict already computed above — re-running the + // guard would respawn the process for no new information. A fresh + // policy that still approves executes normally. Routing keeps the + // ask-path distinction: `guard_unavailable` marks a guard-state + // inconsistency (retryable error, not a policy denial, and no + // latch); blanket kinds latch the turn so a queue message left by + // this denial cannot be consumed as approval by a later ask. + // Consulting the latch instead of the policy would deny commands + // whose own approval was legal, inverting approval semantics. + const recheck = await checkAutoApproval({ + state: freshState, + cwd: task.cwd, + ask: "command", + text: canonicalCommand, + isProtected: isProtectedAsk, + dcgDecision, + }) + if (recheck.decision === "deny") { + const detail = recheck.autoDeny + // Fail closed: a deny without structured detail (permitted by the + // result type, produced by no command-policy branch today) must + // still block execution — it rides the retryable-error channel + // like `guard_unavailable` instead of falling through to the terminal. + if (!detail || detail.kind === "guard_unavailable") { + pushToolResult( + formatResponse.toolError( + detail + ? buildAutoDenyReason(detail) + : `Command \`${canonicalCommand}\` was not executed: the command policy denied it without a reason. You may retry the same command.`, + ), + ) + return + } + if (BLANKET_DENY_AUTO_DENY_KINDS.has(detail.kind)) { + task.recordBlanketCommandDenial() + } + pushToolResult( + formatResponse.toolAutoDenied({ + reason: buildAutoDenyReason(detail), + offendingCommand: detail.command, + ruleId: detail.dcgRuleId, + }), + ) + return + } + } // Get command execution timeout from VSCode configuration (in seconds) const commandExecutionTimeoutSeconds = vscode.workspace diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index d255c28a0a..bd23db2938 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -26,6 +26,11 @@ vitest.mock("vscode", () => ({ workspace: { getConfiguration: vitest.fn(), }, + // The Task module's real blanket-policy helpers load the editor decoration + // controller, which builds a decoration type at import time. + window: { + createTextEditorDecorationType: vitest.fn().mockReturnValue({ dispose: vitest.fn() }), + }, })) vitest.mock("../../../integrations/terminal/TerminalRegistry", () => ({ @@ -43,7 +48,13 @@ vitest.mock("../../../integrations/terminal/TerminalRegistry", () => ({ }, })) -vitest.mock("../../task/Task") +// The handler calls `isBlanketDenyEngaged` and reads +// `BLANKET_DENY_AUTO_DENY_KINDS` from the Task module, so the blanket-policy +// helpers must stay real while the Task class itself stays a stub. +vitest.mock("../../task/Task", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, Task: vitest.fn() } +}) vitest.mock("../../prompts/responses") const mockRunDcg = vitest.fn() @@ -88,6 +99,7 @@ describe("executeCommandTool", () => { recordToolUsage: vitest.fn().mockReturnValue({} as ToolUsage), recordToolError: vitest.fn(), supersedePendingAsk: vitest.fn(), + recordBlanketCommandDenial: vitest.fn(), providerRef: { deref: vitest.fn().mockResolvedValue({ contextProxy: { @@ -442,6 +454,102 @@ describe("executeCommandTool", () => { expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test", undefined, true) }) + it("denies an approved command at execute time when blanket deny engages during the approval dwell", async () => { + // The seconds-wide window: the approval rode on the pre-ask snapshot + // taken with blanket deny off, and the engagement save lands before + // the command reaches the terminal. The execute-time re-check must + // deny instead of executing, and the blanket-kind denial must latch + // the turn so the queue message this denial leaves cannot stand in + // as approval for a later ask. + const provider = await mockCline.providerRef.deref() + provider.getState + .mockResolvedValueOnce({ + alwaysDenyUnapprovedCommands: false, + autoApprovalEnabled: true, + alwaysAllowExecute: true, + allowedCommands: [], + deniedCommands: [], + destructiveCommandGuardEnabled: false, + terminalShellIntegrationDisabled: true, + }) + .mockResolvedValue({ + alwaysDenyUnapprovedCommands: true, + autoApprovalEnabled: true, + alwaysAllowExecute: true, + allowedCommands: [], + deniedCommands: [], + destructiveCommandGuardEnabled: false, + terminalShellIntegrationDisabled: true, + }) + mockAskApproval.mockResolvedValue(true) + + // The `as Task` cast is documentary — the harness double is `any`-typed, + // so it and the `vi.fn` callbacks already satisfy the parameter types. + await executeCommandTool.handle(mockCline as Task, mockToolUse, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Position pin: the denial must arrive after an ask actually happened — + // a pre-approval gate would deny without ever asking. + expect(mockAskApproval).toHaveBeenCalledTimes(1) + + // Denied, not executed: the command never reaches the terminal. + expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled() + expect(mockCline.recordBlanketCommandDenial).toHaveBeenCalledTimes(1) + expect(formatResponse.toolAutoDenied).toHaveBeenCalledWith({ + reason: expect.stringContaining("not on the command allowlist"), + offendingCommand: "echo test", + ruleId: undefined, + }) + expect(formatResponse.toolError).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + }) + + it("routes a guard flip during the approval dwell to a retryable error without latching", async () => { + // The DCG setting flips on between approval and execution: the tool + // holds no verdict for this command, so the fresh read denies with the + // guard-state inconsistency. That is not a policy denial: the payload + // stays a retryable tool error, and nothing latches — a re-issue + // carrying a verdict may still execute. + const provider = await mockCline.providerRef.deref() + provider.getState + .mockResolvedValueOnce({ + alwaysDenyUnapprovedCommands: true, + autoApprovalEnabled: true, + alwaysAllowExecute: true, + allowedCommands: [], + deniedCommands: [], + destructiveCommandGuardEnabled: false, + terminalShellIntegrationDisabled: true, + }) + .mockResolvedValue({ + alwaysDenyUnapprovedCommands: true, + autoApprovalEnabled: true, + alwaysAllowExecute: true, + allowedCommands: [], + deniedCommands: [], + destructiveCommandGuardEnabled: true, + terminalShellIntegrationDisabled: true, + }) + mockAskApproval.mockResolvedValue(true) + + await executeCommandTool.handle(mockCline as Task, mockToolUse, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Same position pin as the sibling test: asked first, denied by the re-check. + expect(mockAskApproval).toHaveBeenCalledTimes(1) + expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled() + expect(mockCline.recordBlanketCommandDenial).not.toHaveBeenCalled() + expect(formatResponse.toolAutoDenied).not.toHaveBeenCalled() + expect(formatResponse.toolError).toHaveBeenCalledWith(expect.stringContaining("not a policy denial")) + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + }) + it("installs or updates DCG before evaluating an enabled command", async () => { const provider = await mockCline.providerRef.deref() provider.context = { globalStorageUri: { fsPath: "/test/storage" } } From 43a5ddf6c206f01b313334d97951272cee38e3fc Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Mon, 28 Sep 2026 10:57:45 +0200 Subject: [PATCH 34/39] test(tools): cover blanket-deny kinds missing from the auto-deny spec The spec pinned only dcg and not_allowlisted. dangerous_substitution and malformed_command now have regression tests at the presentAssistantMessage layer: each denies without executing the command and without aborting the turn. --- .../presentAssistantMessage-auto-deny.spec.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts index 73c3a08137..1a3cbcefd8 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts @@ -185,6 +185,22 @@ const GUARD_UNAVAILABLE_DETAIL = { command: "npm test", } +// Mirrors checkAutoApproval's dangerous-substitution branch: under blanket deny +// a command with shell expansions is never auto-approved; the detail carries a +// kind-fixed reason and no rule id or parse error. +const DANGEROUS_SUBSTITUTION_DETAIL = { + kind: "dangerous_substitution" as const, + command: 'echo "${var@P}"', +} + +// Mirrors checkAutoApproval's malformed_command branch: an unterminated quote +// denies with the parser's syntax error forwarded verbatim. +const MALFORMED_COMMAND_DETAIL = { + kind: "malformed_command" as const, + command: "sh -c 'echo a", + parseError: "unexpected EOF while looking for matching quote", +} + // Captures the boolean a tool receives back from askApproval; vi.clearAllMocks() // does not reset closures, so beforeEach must clear this explicitly. let execApproval: boolean | undefined @@ -639,4 +655,65 @@ describe("presentAssistantMessage - automatic (policy) denials", () => { expect(mcpApproval).toBe(false) expect(mockTask.didRejectTool).toBe(false) }) + + it("denies dangerous_substitution with the fixed shell-expansion reason without aborting the turn", async () => { + mockTask.assistantMessageContent = [executeCommandBlock, listFilesBlock] + + // First ask: blanket denial of a shell-expansion command. Second ask: + // approval — the denial must stay scoped to its own tool call. + mockTask.ask + .mockResolvedValueOnce({ response: "noButtonClicked", autoDenyDetail: DANGEROUS_SUBSTITUTION_DETAIL }) + .mockResolvedValueOnce({ response: "yesButtonClicked" }) + + await presentAssistantMessage(asTask(mockTask)) + + expect(mockTask.userMessageContent).toHaveLength(2) + + const denialPayload = JSON.parse(mockTask.userMessageContent[0].content as string) + expect(denialPayload.status).toBe("denied") + expect(denialPayload.type).toBe("auto_deny") + // The reason is kind-fixed and carries no rule id: expansion forms are + // never auto-approved under blanket deny. + expect(denialPayload.reason).toContain("shell expansions") + expect(denialPayload.reason).toContain("never auto-approved") + expect(denialPayload.offending_command).toBe('echo "${var@P}"') + expect(denialPayload).not.toHaveProperty("rule_id") + + // The boundary assertions of the dcg/not_allowlisted pins: the tool sees a + // refusal — it must not execute the command — and the denial does not + // abort the turn the way a user rejection does. + expect(execApproval).toBe(false) + expect(mockTask.didRejectTool).toBe(false) + + // The reason is system-generated: it must not surface as user feedback. + expect(mockTask.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) + + // The denial is scoped to its own tool call: the next tool still executes. + expect(listFilesHandle).toHaveBeenCalledTimes(1) + expect(mockTask.userMessageContent[1].content).toBe("second tool executed") + }) + + it("denies malformed_command with the forwarded parse error without aborting the turn", async () => { + mockTask.assistantMessageContent = [executeCommandBlock] + + mockTask.ask.mockResolvedValueOnce({ response: "noButtonClicked", autoDenyDetail: MALFORMED_COMMAND_DETAIL }) + + await presentAssistantMessage(asTask(mockTask)) + + expect(mockTask.userMessageContent).toHaveLength(1) + const denialPayload = JSON.parse(mockTask.userMessageContent[0].content as string) + expect(denialPayload.status).toBe("denied") + expect(denialPayload.type).toBe("auto_deny") + // A malformed_command denial carries its parse error through to the payload + // verbatim — the boundary must not flatten it to the generic reason. + expect(denialPayload.reason).toBe("unexpected EOF while looking for matching quote") + expect(denialPayload.offending_command).toBe("sh -c 'echo a") + expect(denialPayload).not.toHaveProperty("rule_id") + + // Same boundary assertions as the dcg/not_allowlisted pins: refusal + // without execution, no turn abort, no user_feedback row. + expect(execApproval).toBe(false) + expect(mockTask.didRejectTool).toBe(false) + expect(mockTask.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) + }) }) From f479dad4a3ca2e3ca188f1ad5dcaa4cdaa11b7c9 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 29 Sep 2026 17:49:22 +0200 Subject: [PATCH 35/39] fix(task): arm ask status timers from all release paths An approved ask that gets handed back to the user - because a fresh command-policy read comes back denying, or because its claim release lands while other messages are queued - left the interactive, resumable, and idle status timers unarmed. The only arm site was gated on `isStatusMutable`, which is computed once before the queue claim and stays false while the ask waits on the user, so hands-free and API consumers saw no interaction signal for a prompt that was pending. The arming now lives in an idempotent helper that every release path calls; the timers re-check liveness at fire time and are torn down in the ask's finally block, so a late timer cannot emit for an answered, superseded, or aborted task. The post-approval command-policy read awaited `provider.getState()` with no abort channel, so an abort could leave `ask()` blocked on the pending read. The read now races a 100 ms abort watcher and settles within one tick of an abort. The auto-deny settings checkbox handler typed its event as `any`; it now narrows `Event | FormEvent` to an `HTMLInputElement`. Five new regression tests cover each release path, the single-emit idempotence, and the abort race; each was verified to fail against the pre-fix code. --- src/core/task/Task.ts | 288 ++++++++++++------ src/core/task/__tests__/ask-auto-deny.spec.ts | 267 +++++++++++++++- .../settings/AutoApproveSettings.tsx | 12 +- 3 files changed, 466 insertions(+), 101 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 01b041b2a6..7abefaae98 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1833,12 +1833,45 @@ export class Task extends EventEmitter implements TaskLike { const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask" let queuedMessageId: string | undefined - if (isStatusMutable) { + // Arm the interactive/resumable/idle status timers for this ask: the + // single source of that arm, shared between the queue-free case and the + // queued-release paths below. A release keeps the message in the queue, + // so `isMessageQueued` stays true and `isStatusMutable` — which requires + // an empty queue — stays false while the ask waits for the user; arming + // only from `isStatusMutable` would leave hands-free/API consumers seeing + // `Running` with no `TaskInteractive`/`interactionRequired` for a prompt + // that is in fact pending. Idempotent: several arm sites can fire for one + // ask (e.g. the + // queue-free arm, then a drain-site release), and a second arm would + // double-emit the event. The `timeouts` array is the arm ledger, and a + // `finally` around the wait owns the `clearTimeout` teardown on every + // settle path, including the abort and supersession throws. The + // callbacks still re-check liveness at fire time: an already-due timer + // can outrun that teardown, and a status transition published after + // abort would describe a task that no longer runs. + const armAskStatusTimers = (): void => { + const askStillPending = this.askResponse === undefined && this.lastMessageTs === askTs + if (!askStillPending || this.abort || partial || approval.decision !== "ask" || timeouts.length > 0) { + return + } + const statusMutationTimeout = 2_000 + // Fire-time liveness check for the timers armed below. Clearing a due + // timer does not retract it: when abort lands between the wait's last + // poll tick and the timer's due time, the callback runs before + // `ask()`'s continuation reaches the teardown sweep, so liveness has + // to be re-checked here. Read-only: the `timeouts` ledger is never + // touched from the callbacks. + const statusTimerStillLive = (): boolean => + !this.abort && this.askResponse === undefined && this.lastMessageTs === askTs + if (isInteractiveAsk(type)) { timeouts.push( setTimeout(() => { + if (!statusTimerStillLive()) { + return + } const message = this.findMessageByTimestamp(askTs) if (message) { @@ -1854,6 +1887,9 @@ export class Task extends EventEmitter implements TaskLike { } else if (isResumableAsk(type)) { timeouts.push( setTimeout(() => { + if (!statusTimerStillLive()) { + return + } const message = this.findMessageByTimestamp(askTs) if (message) { @@ -1865,6 +1901,9 @@ export class Task extends EventEmitter implements TaskLike { } else if (isIdleAsk(type)) { timeouts.push( setTimeout(() => { + if (!statusTimerStillLive()) { + return + } const message = this.findMessageByTimestamp(askTs) if (message) { @@ -1874,12 +1913,19 @@ export class Task extends EventEmitter implements TaskLike { }, statusMutationTimeout), ) } + } + + if (isStatusMutable) { + armAskStatusTimers() } else if (isMessageQueued && shouldDrainQueuedMessageForAsk && queuedMessage && queuedAskResolution) { if (this.blanketDeniedCommandThisTurn) { // A command this turn already blanket-denied left the message in the // queue — it answers that denial, not this ask. Release the claim and // let the user answer the prompt normally. this.messageQueueService.releaseMessage(queuedMessage.id) + // The prompt stays pending with the queue still non-empty, so the + // `isStatusMutable` arm above was skipped and must run from here. + armAskStatusTimers() } else if (type === "command") { // The snapshot gate is frozen; blanket deny may have engaged since the // ask began. Re-read policy before the message stands in for approval. @@ -1901,12 +1947,16 @@ export class Task extends EventEmitter implements TaskLike { queuedMessage, queuedAskResolution, ) + // A "release" outcome leaves the ask pending; consume/deny/approve + // resolved it, and the helper's pending check declines to arm then. + armAskStatusTimers() } } catch (error) { // Drain-site parity: a failed re-check must not reject ask() nor // strand the claim; the prompt stays pending for the user. console.error("[Task#ask] queued command policy re-check failed:", error) this.messageQueueService.releaseMessage(queuedMessage.id) + armAskStatusTimers() } } else { queuedMessageId = this.handleQueuedAskResponse(queuedMessage, queuedAskResolution) @@ -1920,117 +1970,163 @@ export class Task extends EventEmitter implements TaskLike { message: QueuedMessage, resolution: QueuedAskResolution, ): Promise => { - const action = await this.recheckQueuedCommandPolicy({ - text, - isProtected, - dcgDecision: autoApprovalContext?.dcgDecision, - }) - if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { - // The user answered, the ask was superseded, or the task aborted - // while the fresh read was pending: the message is none of this - // ask's business — leave it for the next consumer. - this.messageQueueService.releaseMessage(message.id) - return + // The fresh policy read ends in `provider.getState()`, which awaits + // custom-mode file work that cannot be cancelled from here. Racing it + // against an abort poller settles this re-check within one tick of an + // abort instead of leaving `ask()` blocked on the pending read. The race + // attaches handlers to both inputs, so the read rejecting after the + // watcher wins is already considered handled — no extra `.catch` needed. + let abortWatcher: ReturnType | undefined + try { + const outcome = await Promise.race([ + this.recheckQueuedCommandPolicy({ + text, + isProtected, + dcgDecision: autoApprovalContext?.dcgDecision, + }).then((action) => ({ action })), + new Promise<{ aborted: true }>((resolve) => { + const checkAbort = () => { + if (this.abort) { + resolve({ aborted: true }) + } + } + checkAbort() + abortWatcher = setInterval(checkAbort, 100) + }), + ]) + if ( + !("aborted" in outcome) && + !this.abort && + this.askResponse === undefined && + this.lastMessageTs === askTs + ) { + queuedMessageId = this.applyQueuedCommandPolicyAction(outcome.action, message, resolution) + // A "release" outcome leaves the ask pending while the queue stays + // non-empty, so the arm that `isStatusMutable` gates — computed + // once, before the claim — must run here. + armAskStatusTimers() + } + } finally { + if (abortWatcher !== undefined) { + clearInterval(abortWatcher) + } + // One release path for abort, throw, supersession, and "release". + // `releaseMessage` is idempotent, so it stays safe beside the + // branch-local releases. The durable consume is the one outcome + // that must keep its claim until persistence removes the message — + // it is the only path that assigned `queuedMessageId`. + if (queuedMessageId !== message.id) { + this.messageQueueService.releaseMessage(message.id) + } } - queuedMessageId = this.applyQueuedCommandPolicyAction(action, message, resolution) } // Wait for askResponse to be set - await pWaitFor( - () => { - if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { - return true - } + try { + await pWaitFor( + () => { + if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { + return true + } - // If a queued message arrives while we're blocked on an ask (e.g. a follow-up - // suggestion click that was incorrectly queued due to UI state), consume it - // immediately so the task doesn't hang. Command asks under blanket deny are - // excluded (`queueMayAnswerThisAsk`): a queued message must never stand in - // for the explicit approval the policy withheld. - if ( - queueMayAnswerThisAsk && - shouldDrainQueuedMessageForAsk && - !queuedCommandPolicyCheck && - this.mayDrainQueuedMessageForAsk() - ) { - const message = this.messageQueueService.claimNextMessage() - const resolution = message ? queuedResponseForAsk(type, text) : undefined - if (message && resolution) { - if (type === "command") { - // Claim first, then verify the policy off-predicate: a - // blanket-deny flip landing during the prompt dwell is - // invisible to the frozen snapshot gate, so the claim is - // provisional until the fresh check clears it. - queuedCommandPolicyCheck = verifyDrainedCommandMessage(message, resolution).catch( - (error) => { - // The background check must never reject unhandled; - // on failure the claim is released so the message - // stays available to a later consumer. - console.error("[Task#ask] queued command policy re-check failed:", error) - this.messageQueueService.releaseMessage(message.id) - }, - ) - } else { - queuedMessageId = this.handleQueuedAskResponse(message, resolution) + // If a queued message arrives while we're blocked on an ask (e.g. a follow-up + // suggestion click that was incorrectly queued due to UI state), consume it + // immediately so the task doesn't hang. Command asks under blanket deny are + // excluded (`queueMayAnswerThisAsk`): a queued message must never stand in + // for the explicit approval the policy withheld. + if ( + queueMayAnswerThisAsk && + shouldDrainQueuedMessageForAsk && + !queuedCommandPolicyCheck && + this.mayDrainQueuedMessageForAsk() + ) { + const message = this.messageQueueService.claimNextMessage() + const resolution = message ? queuedResponseForAsk(type, text) : undefined + if (message && resolution) { + if (type === "command") { + // Claim first, then verify the policy off-predicate: a + // blanket-deny flip landing during the prompt dwell is + // invisible to the frozen snapshot gate, so the claim is + // provisional until the fresh check clears it. + queuedCommandPolicyCheck = verifyDrainedCommandMessage(message, resolution).catch( + (error) => { + // The background check must never reject unhandled; + // on failure the claim is released so the message + // stays available to a later consumer. + console.error("[Task#ask] queued command policy re-check failed:", error) + this.messageQueueService.releaseMessage(message.id) + armAskStatusTimers() + }, + ) + } else { + queuedMessageId = this.handleQueuedAskResponse(message, resolution) + } } } - } - - return false - }, - { interval: 100 }, - ) - // Let a policy re-check that was in flight when the wait resolved run to - // completion: its bail-out path releases the claim, and leaving it - // unresolved would let the consume race the result below. - if (queuedCommandPolicyCheck) { - await queuedCommandPolicyCheck - } + return false + }, + { interval: 100 }, + ) - /* v8 ignore next 3 -- abort-while-waiting path; covered by e2e standalone-resume test */ - if (this.abort) { - if (queuedMessageId) { - this.messageQueueService.releaseMessage(queuedMessageId) + // Let a policy re-check that was in flight when the wait resolved run to + // completion: its bail-out path releases the claim, and leaving it + // unresolved would let the consume race the result below. On abort, detach + // instead: the re-check races the abort and its `finally` releases the claim + // either way, while awaiting an uncancellable read past the abort would + // retain this task instead of letting the throw below settle it. + if (queuedCommandPolicyCheck && !this.abort) { + await queuedCommandPolicyCheck } - throw new Error(`[ZooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) - } - if (this.lastMessageTs !== askTs) { - // Could happen if we send multiple asks in a row i.e. with - // command_output. It's important that when we know an ask could - // fail, it is handled gracefully. - if (queuedMessageId) { - this.messageQueueService.releaseMessage(queuedMessageId) + /* v8 ignore next 3 -- abort-while-waiting path; covered by e2e standalone-resume test */ + if (this.abort) { + if (queuedMessageId) { + this.messageQueueService.releaseMessage(queuedMessageId) + } + throw new Error(`[ZooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) } - throw new AskIgnoredError("superseded") - } - const result = { - response: this.askResponse!, - text: this.askResponseText, - images: this.askResponseImages, - queuedMessageId, - autoDenyDetail: this.pendingAutoDenyDetail, - } - this.askResponse = undefined - this.askResponseText = undefined - this.askResponseImages = undefined - this.pendingAutoDenyDetail = undefined + if (this.lastMessageTs !== askTs) { + // Could happen if we send multiple asks in a row i.e. with + // command_output. It's important that when we know an ask could + // fail, it is handled gracefully. + if (queuedMessageId) { + this.messageQueueService.releaseMessage(queuedMessageId) + } + throw new AskIgnoredError("superseded") + } - // Cancel the timeouts if they are still running. - timeouts.forEach((timeout) => clearTimeout(timeout)) + const result = { + response: this.askResponse!, + text: this.askResponseText, + images: this.askResponseImages, + queuedMessageId, + autoDenyDetail: this.pendingAutoDenyDetail, + } + this.askResponse = undefined + this.askResponseText = undefined + this.askResponseImages = undefined + this.pendingAutoDenyDetail = undefined + + // Switch back to an active state. + if (this.idleAsk || this.resumableAsk || this.interactiveAsk) { + this.idleAsk = undefined + this.resumableAsk = undefined + this.interactiveAsk = undefined + this.emit(RooCodeEventName.TaskActive, this.taskId) + } - // Switch back to an active state. - if (this.idleAsk || this.resumableAsk || this.interactiveAsk) { - this.idleAsk = undefined - this.resumableAsk = undefined - this.interactiveAsk = undefined - this.emit(RooCodeEventName.TaskActive, this.taskId) + this.emit(RooCodeEventName.TaskAskResponded) + return result + } finally { + // Teardown for every settle path: the normal resolve and the + // abort/supersession throws that never reach the result handling + // above. The fire-time guard in the armed callbacks covers the + // sub-tick window where an already-due timer fires before this + // sweep runs. + timeouts.forEach((timeout) => clearTimeout(timeout)) } - - this.emit(RooCodeEventName.TaskAskResponded) - return result } handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { diff --git a/src/core/task/__tests__/ask-auto-deny.spec.ts b/src/core/task/__tests__/ask-auto-deny.spec.ts index e6582e1ad3..907a43a862 100644 --- a/src/core/task/__tests__/ask-auto-deny.spec.ts +++ b/src/core/task/__tests__/ask-auto-deny.spec.ts @@ -1,6 +1,6 @@ // npx vitest run core/task/__tests__/ask-auto-deny.spec.ts -import type { ExtensionState } from "@roo-code/types" +import { type ClineMessage, type ExtensionState, RooCodeEventName } from "@roo-code/types" import { createRateLimitClock } from "../RateLimitClock" import { Task } from "../Task" @@ -92,6 +92,29 @@ function attachTurnHarness(task: Task) { const TASK_CWD = "/path/to/task-workspace" +/** + * Swaps the no-op `addToClineMessages` stub for a recording one, so the 2 s + * status timers' `findMessageByTimestamp(askTs)` lookup finds the pending ask + * row and the interactive emit can actually run. + */ +function recordClineMessages(task: Task) { + const addToClineMessages = vi.fn(async (message: ClineMessage) => { + task["clineMessages"].push(message) + }) + task["addToClineMessages"] = addToClineMessages + return addToClineMessages +} + +/** + * Installs a fresh `emit` recorder (replacing `buildTask`'s no-op stub) and + * returns a counter of this task's `TaskInteractive` emissions. + */ +function installInteractiveEmitRecorder(task: Task) { + const emit = vi.fn() + task["emit"] = emit + return () => emit.mock.calls.filter(([event]) => event === RooCodeEventName.TaskInteractive).length +} + describe("Task.ask resolves blanket command denials with structured detail", () => { // Mutable state so a test can flip the policy between consecutive asks on // the same task (mirrors the live per-ask `provider.getState()` read). @@ -577,6 +600,174 @@ describe("Task.ask queue path cannot bypass blanket deny", () => { expect(queue.hasUnclaimed()).toBe(true) }) + it("arms the interactive status timer when the latch release leaves the ask pending", async () => { + // A released claim keeps the queue non-empty, so `isStatusMutable` is + // false for the whole dwell and the 2 s interactive arm is skipped + // unless the release branch re-arms it: without that, hands-free/API + // consumers see `Running` with no `TaskInteractive` for a prompt that + // is waiting on the user. + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + recordClineMessages(task) + const interactive = installInteractiveEmitRecorder(task) + queue.addMessage("feedback on the denied command") + + const denied = await task.ask("command", "rm x", false) + expect(denied.response).toBe("noButtonClicked") + // The denial resolved on its own: no interactive state was ever entered. + expect(interactive()).toBe(0) + + const toolAsk = task.ask("tool", JSON.stringify({ tool: "write_to_file", path: "a.txt" }), false) + await vi.waitFor(() => expect(interactive()).toBe(1), { timeout: 6_000 }) + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ type: "interactionRequired" }) + + task.approveAsk() + const result = await toolAsk + expect(result.response).toBe("yesButtonClicked") + expect(queue.messages).toHaveLength(1) + }) + + it("arms the interactive status timer when a throwing re-check releases the prompt pending", async () => { + // A rejected policy read leaves the prompt pending for the user, so the + // catch's claim release must arm the 2 s interactive timer: with the + // queue non-empty, `isStatusMutable` — computed once, before the claim — never armed. + state.alwaysDenyUnapprovedCommands = false + let getStateCalls = 0 + provider.getState = async () => { + getStateCalls++ + if (getStateCalls >= 2) { + throw new Error("policy read failed") + } + return state + } + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + recordClineMessages(task) + const interactive = installInteractiveEmitRecorder(task) + queue.addMessage("queued feedback with a failing policy read") + + const askPromise = task.ask("command", "rm x", false) + await vi.waitFor(() => expect(queue.hasUnclaimed()).toBe(true)) + await vi.waitFor(() => expect(interactive()).toBe(1), { timeout: 6_000 }) + + task.approveAsk() + const result = await askPromise + expect(result.response).toBe("yesButtonClicked") + expect(queue.messages).toHaveLength(1) + }) + + it("emits exactly one TaskInteractive when back-to-back policy releases leave the prompt pending", async () => { + // Two release branches run for one prompt: the immediate-site fresh + // read answers `release` (blanket engages at the re-read, and the + // protected prompt survives it), which arms the interactive timer even + // though the non-empty queue skipped the `isStatusMutable`-gated arm + // (computed once, before the claim); the predicate then re-claims the + // released message and the drain + // release re-arms. The idempotence guard must make that second (and + // every later) arm a no-op: without it, one prompt emits + // `TaskInteractive`/`interactionRequired` more than once. + state.alwaysDenyUnapprovedCommands = false + let getStateCalls = 0 + provider.getState = async () => { + getStateCalls++ + if (getStateCalls === 2) { + // The flip lands at the immediate-site fresh read: the frozen + // snapshot let the message be claimed, the fresh check must now + // neither consume it nor let it answer the ask. + state.alwaysDenyUnapprovedCommands = true + } + return state + } + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + recordClineMessages(task) + const interactive = installInteractiveEmitRecorder(task) + queue.addMessage("queued feedback facing the engaged blanket deny") + + const askPromise = task.ask("command", "some-unknown-command", false, undefined, true) + + const armedAt = Date.now() + await vi.waitFor(() => expect(interactive()).toBe(1), { timeout: 6_000 }) + // Wait past the window in which a second (drain-site) arm would also + // fire, then assert the total: the first timer can show up alone + // briefly before a hypothetical second one, so the count is only + // meaningful after both would have expired. + await vi.waitFor(() => expect(Date.now() - armedAt).toBeGreaterThan(2_600), { timeout: 5_000 }) + expect(interactive()).toBe(1) + expect( + provider.postMessageToWebview.mock.calls.filter(([message]) => message?.type === "interactionRequired"), + ).toHaveLength(1) + + task.approveAsk() + const result = await askPromise + expect(result.response).toBe("yesButtonClicked") + expect(queue.messages).toHaveLength(1) + expect(queue.hasUnclaimed()).toBe(true) + }) + + it("settles ask() and releases the claim when the task aborts while the drain re-check is pending", async () => { + // The drain's fresh policy read ends in an uncancellable + // `provider.getState()`. With that read pending, an abort must not + // leave `ask()` riding it: the abort race settles the re-check, the + // re-check's finally releases the claim, and `ask()` rejects at the + // post-abort throw. + state.alwaysDenyUnapprovedCommands = false + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + const addToClineMessages = recordClineMessages(task) + + let getStateCalls = 0 + provider.getState = () => { + getStateCalls++ + if (getStateCalls >= 2) { + // The drain-site re-check never settles: nothing resolves it, + // only the abort race ends the ask's dependence on it. + return new Promise>(() => {}) + } + return Promise.resolve(state) + } + + const setIntervalSpy = vi.spyOn(globalThis, "setInterval") + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval") + + const askPromise = task.ask("command", "rm x", false) + await vi.waitFor(() => expect(addToClineMessages).toHaveBeenCalledTimes(1)) + // The message arriving during the dwell routes the re-check through the + // drain site, where the provisional claim and the pending read live. + queue.addMessage("queued during the dwell") + await vi.waitFor(() => expect(getStateCalls).toBe(2)) + expect(queue.hasUnclaimed()).toBe(false) + + task["abort"] = true + + // `ask()` must reject on abort; racing a deadline distinguishes a wrong + // settle from a hang on the pending read. + const outcome = await Promise.race([ + askPromise.then( + () => "resolved", + (error: unknown) => (error instanceof Error ? error.message : String(error)), + ), + new Promise((resolve) => setTimeout(() => resolve("deadline-exceeded"), 3_000)), + ]) + expect(outcome).toContain("aborted") + + // The claim releases from the re-check's finally once the abort race + // settles it, so a later consumer can take the message. + await vi.waitFor(() => expect(queue.hasUnclaimed()).toBe(true)) + + // The abort watcher must not keep polling past the settle. + const intervals = setIntervalSpy.mock.results.map((result) => result.value) + expect(intervals.length).toBeGreaterThan(0) + for (const interval of intervals) { + expect(clearIntervalSpy.mock.calls.some(([cleared]) => cleared === interval)).toBe(true) + } + setIntervalSpy.mockRestore() + clearIntervalSpy.mockRestore() + }) + it("the next turn's tool ask consumes the queued message once the latch reset clears it", async () => { const task = buildTask(provider, TASK_CWD) const queue = await attachQueue(task) @@ -599,4 +790,78 @@ describe("Task.ask queue path cannot bypass blanket deny", () => { expect(result.text).toBe("queued feedback") expect(queue.messages).toHaveLength(0) }) + + it("does not emit TaskInteractive or retain the armed timer when the task aborts during the 2 s window", async () => { + // A status timer armed for 2 s can outlive an abort that lands inside + // its window: the ask then throws without ever reaching the response + // handling, and a live webview would still receive + // `interactionRequired` for a task that no longer runs. Two independent + // defenses are pinned: the callbacks' fire-time liveness check, exercised + // by invoking the captured callback after the rejection (the sub-tick + // race where an already-due timer fires before the teardown sweep cannot + // be interleaved deterministically on the real clock), and the + // `finally`-sweep, exercised by the clearTimeout handle inventory. + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + recordClineMessages(task) + const interactive = installInteractiveEmitRecorder(task) + queue.addMessage("feedback on the denied command") + + const denied = await task.ask("command", "rm x", false) + expect(denied.response).toBe("noButtonClicked") + + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout") + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout") + + const toolAsk = task.ask("tool", JSON.stringify({ tool: "write_to_file", path: "a.txt" }), false) + // The latch-release arm is the only >= 2 s timer this ask can create + // (the wait's own poller schedules 100 ms handles), so the exact-delay + // filter isolates the status-timer handle without fake timers. + const armedArms = () => + setTimeoutSpy.mock.calls + .map(([callback, delay], i) => ({ callback, delay, handle: setTimeoutSpy.mock.results[i].value })) + .filter((entry) => entry.delay === 2_000) + await vi.waitFor(() => expect(armedArms().length).toBe(1), { timeout: 6_000 }) + const armedAt = Date.now() + + // Abort while the window is still open, shortly after the arm: the + // longer this wait runs, the less event-loop-stall slack is left before + // the 2 s timer could legitimately fire while the task is still live. + await vi.waitFor(() => expect(Date.now() - armedAt).toBeGreaterThanOrEqual(300), { timeout: 2_000 }) + task["abort"] = true + + const outcome = await toolAsk.then( + () => "resolved", + (error: unknown) => (error instanceof Error ? error.message : String(error)), + ) + expect(outcome).toContain("aborted") + + // Fire-time guard pin: the captured armed callback, invoked synchronously + // after the rejection, must decline on its own — clearing covers only + // timers that had not fired yet when the sweep ran. + const [armed] = armedArms() + armed.callback() + expect(interactive()).toBe(0) + expect( + provider.postMessageToWebview.mock.calls.filter(([message]) => message?.type === "interactionRequired"), + ).toHaveLength(0) + + // Behavior pin: past the window the timer would have needed, nothing + // fires on its own either. + await vi.waitFor(() => expect(Date.now() - armedAt).toBeGreaterThan(2_500), { timeout: 6_000 }) + expect(interactive()).toBe(0) + expect( + provider.postMessageToWebview.mock.calls.filter(([message]) => message?.type === "interactionRequired"), + ).toHaveLength(0) + + // Ledger pin: every status-timer handle this ask armed was cleared, on + // the throw path included. + const clearedHandles = clearTimeoutSpy.mock.calls.map(([cleared]) => cleared) + for (const { handle } of armedArms()) { + expect(clearedHandles).toContain(handle) + } + + setTimeoutSpy.mockRestore() + clearTimeoutSpy.mockRestore() + }) }) diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index c5a2b68bdf..226f277f96 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes, useState } from "react" +import { FormEvent, HTMLAttributes, useState } from "react" import { X } from "lucide-react" import { Trans } from "react-i18next" import { Package } from "@roo/package" @@ -351,9 +351,13 @@ export const AutoApproveSettings = ({ label={t("settings:autoApprove.execute.autoDeny.label")}> - setCachedStateField("alwaysDenyUnapprovedCommands", e.target.checked) - } + onChange={(e: Event | FormEvent) => { + const target = e.target instanceof HTMLInputElement ? e.target : null + if (!target) { + return + } + setCachedStateField("alwaysDenyUnapprovedCommands", target.checked) + }} data-testid="auto-deny-unapproved-checkbox"> {t("settings:autoApprove.execute.autoDeny.label")} From c3388310e7d209102128c09a4c7ec813f9a6ab27 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Tue, 29 Sep 2026 18:49:31 +0200 Subject: [PATCH 36/39] fix(settings): read blanket auto-deny toggle from the checkbox target The blanket auto-deny checkbox handler only accepted a change event whose target is an HTMLInputElement. The toolkit checkbox is a shadow-DOM custom element, so its change events reach the wrapper retargeted to the host element, which exposes `checked` without being an input; the check swallowed those events and the toggle never updated the setting. Read the target structurally instead: a local type predicate accepts any target carrying a boolean `checked` and declines the rest. Both the host element and a directly rendered input now update the setting, and a target without `checked` is declined rather than writing `undefined` through. Two regression tests pin the behavior: one fires a change event retargeted to a non-input host element and asserts the setting write, one fires an event whose target carries no `checked` and asserts no write. The existing settings specs cover the toggle round-trip through Save unchanged. --- .../settings/AutoApproveSettings.tsx | 12 ++- .../__tests__/AutoApproveSettings.spec.tsx | 77 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 226f277f96..58034960fb 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -62,6 +62,13 @@ type AutoApproveSettingsProps = HTMLAttributes & { > } +// The toolkit checkbox delivers change events retargeted to its custom-element host, which mirrors +// `checked` without being an HTMLInputElement; a directly rendered input is one. Match on the +// property so both target shapes update the setting, and decline targets that carry no boolean +// `checked` rather than writing `undefined` through the guard. +const isCheckboxTarget = (target: EventTarget | null): target is EventTarget & { checked: boolean } => + target !== null && "checked" in target && typeof target.checked === "boolean" + export const AutoApproveSettings = ({ alwaysAllowReadOnly, alwaysAllowReadOnlyOutsideWorkspace, @@ -352,11 +359,10 @@ export const AutoApproveSettings = ({ ) => { - const target = e.target instanceof HTMLInputElement ? e.target : null - if (!target) { + if (!isCheckboxTarget(e.target)) { return } - setCachedStateField("alwaysDenyUnapprovedCommands", target.checked) + setCachedStateField("alwaysDenyUnapprovedCommands", e.target.checked) }} data-testid="auto-deny-unapproved-checkbox"> {t("settings:autoApprove.execute.autoDeny.label")} diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx index 3c5b9997c1..12090b42b5 100644 --- a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx @@ -1,5 +1,7 @@ // npx vitest src/components/settings/__tests__/AutoApproveSettings.spec.tsx +import React from "react" + import { render, screen, fireEvent } from "@/utils/test-utils" import { AutoApproveSettings } from "../AutoApproveSettings" @@ -11,6 +13,54 @@ vi.mock("@/utils/vscode", () => ({ }, })) +// The toolkit checkbox is a shadow-DOM custom element, so its change events reach the wrapper +// retargeted to the host element, which exposes `checked` itself and is not an HTMLInputElement. +// The global JSX mock renders a plain , hiding that shape from every test in this file; +// this override renders the host tag so the suite exercises the event shape the real webview sends. +type CheckboxHost = HTMLElement & { checked: boolean } + +type CheckboxHostProps = { + children?: React.ReactNode + checked?: boolean + "data-testid"?: string + onChange?: (event: Event) => void +} + +vi.mock("@vscode/webview-ui-toolkit/react", async () => { + const toolkitMock = await import("@/__mocks__/@vscode/webview-ui-toolkit/react") + + const VSCodeCheckboxHost = ({ children, onChange, checked, "data-testid": dataTestId }: CheckboxHostProps) => + React.createElement( + "vscode-checkbox", + { + "data-testid": dataTestId, + role: "checkbox", + "aria-checked": String(checked ?? false), + // Mirror the controlled prop onto the host like the real component's internal state, + // and flip it on click so the dispatched change event carries the post-click value. + // An unset prop leaves the host without `checked` at all — the degenerate shape the + // handler must decline instead of writing `undefined`. + ref: (el: CheckboxHost | null) => { + if (!el) { + return + } + if (checked !== undefined) { + el.checked = checked + } + el.onclick = () => { + el.checked = !el.checked + el.setAttribute("aria-checked", String(el.checked)) + el.dispatchEvent(new CustomEvent("change", { bubbles: true, composed: true })) + } + el.onchange = (e: Event) => onChange?.(e) + }, + }, + children, + ) + + return { ...toolkitMock, VSCodeCheckbox: VSCodeCheckboxHost } +}) + vi.mock("@/i18n/TranslationContext", () => ({ useAppTranslation: () => ({ t: (key: string) => key }), })) @@ -276,4 +326,31 @@ describe("AutoApproveSettings - Save/Discard contract", () => { expect(screen.queryByTestId("auto-deny-unapproved-checkbox")).not.toBeInTheDocument() }) + + // A change event from the real toolkit arrives with the custom-element host as + // e.target, which is not an HTMLInputElement; an instanceof-style guard would + // silently swallow it, leaving the toggle dead in the real webview. + it("writes the blanket auto-deny value from a change event retargeted to the custom-element host", () => { + const { setCachedStateField } = renderSettings() + const host = screen.getByTestId("auto-deny-unapproved-checkbox") + + expect(host).not.toBeInstanceOf(HTMLInputElement) + fireEvent.click(host) + + expect(setCachedStateField).toHaveBeenCalledWith("alwaysDenyUnapprovedCommands", true) + expectNoImmediateUpdateSettings() + }) + + it("declines a blanket auto-deny change whose target carries no checked value", () => { + const { setCachedStateField } = renderSettings() + const host = screen.getByTestId("auto-deny-unapproved-checkbox") + + // The degenerate shape: a change event whose target exposes no boolean + // `checked`. The handler must decline it entirely rather than buffer + // `undefined` through the guard. + fireEvent.change(host) + + expect(setCachedStateField).not.toHaveBeenCalled() + expectNoImmediateUpdateSettings() + }) }) From 89165a480f8433c7b4a8f56a9df34bfb2416ad9b Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Wed, 30 Sep 2026 16:35:26 +0200 Subject: [PATCH 37/39] fix(task): enforce blanket deny at queue claims and policy re-checks Follow-up fixes to the blanket command denial work, covering three behaviors around the message queue and the execute-time policy re-check. Claiming a queued message now respects the per-turn denial latch. A blanket denial leaves its queued message in place to be answered later; previously the next ask could still claim that message at the immediate site, and the claim forced a manual prompt even when the auto-approval policy would have answered the ask itself - a hands-free session could stall on a prompt no user needed to see. The claim is now gated by the same latch the drain path uses, so the policy decision stands and the message stays queued. The immediate execute-time policy re-check is now abort-safe. That re-check ends in an uncancellable provider read, so an abort landing while it was pending could leave ask() blocked and the queued message claimed. The wait now races an abort watcher, and one finally releases the claim on the abort, throw, and release outcomes alike - the shape the drain path already used. A command approved while its prompt was open can no longer execute past a blanket deny. The execute-time re-check now re-derives command protection from the fresh policy state instead of reusing the flag captured when the prompt was shown: if blanket denial was engaged in the meantime, the fresh derivation is unprotected, so the denial is evaluated. Any re-check result other than an explicit approval is also handled fail-closed - the command is not executed, and the tool returns a retryable error instead. Adds five regression tests: two for the queue-claim gate and the abort-safe re-check (ask-auto-deny.spec.ts), three for the execute-time re-check (executeCommandTool.spec.ts). --- src/core/task/Task.ts | 105 +++++++++++------ src/core/task/__tests__/ask-auto-deny.spec.ts | 103 +++++++++++++++- src/core/tools/ExecuteCommandTool.ts | 30 ++++- .../__tests__/executeCommandTool.spec.ts | 110 ++++++++++++++++++ 4 files changed, 304 insertions(+), 44 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7abefaae98..93b5a2b053 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1655,10 +1655,18 @@ export class Task extends EventEmitter implements TaskLike { // means an unconditional auto-approve. That shortcut must never bypass // blanket deny: while it is engaged, a command ask keeps its policy // decision and the queued message is left in place for a later turn - // instead of being consumed as approval. + // instead of being consumed as approval. The shared claim gate adds the + // per-turn latch: a message a blanket denial left behind answers that + // denial, not this ask, and a claim would force the `ask` decision below + // while the policy would have auto-answered it — holding a prompt no user + // has to see and stalling a hands-free session. Leaving the message + // unclaimed lets the policy decision stand and keeps the message queued. const queueMayAnswerThisAsk = !(blanketDenyEngaged && type === "command") const queuedMessage = - partial === true || type === "command_output" || !queueMayAnswerThisAsk + partial === true || + type === "command_output" || + !queueMayAnswerThisAsk || + !this.mayDrainQueuedMessageForAsk() ? undefined : this.messageQueueService.claimNextMessage() const queuedAskResolution = queuedMessage ? queuedResponseForAsk(type, text) : undefined @@ -1835,13 +1843,13 @@ export class Task extends EventEmitter implements TaskLike { let queuedMessageId: string | undefined // Arm the interactive/resumable/idle status timers for this ask: the // single source of that arm, shared between the queue-free case and the - // queued-release paths below. A release keeps the message in the queue, - // so `isMessageQueued` stays true and `isStatusMutable` — which requires - // an empty queue — stays false while the ask waits for the user; arming - // only from `isStatusMutable` would leave hands-free/API consumers seeing - // `Running` with no `TaskInteractive`/`interactionRequired` for a prompt - // that is in fact pending. Idempotent: several arm sites can fire for one - // ask (e.g. the + // claim-gated and queued-release paths below. A gated or released claim + // keeps the message in the queue, so `isMessageQueued` stays true and + // `isStatusMutable` — which requires an empty queue — stays false while + // the ask waits for the user; arming only from `isStatusMutable` would + // leave hands-free/API consumers seeing `Running` with no + // `TaskInteractive`/`interactionRequired` for a prompt that is in fact + // pending. Idempotent: several arm sites can fire for one ask (e.g. the // queue-free arm, then a drain-site release), and a second arm would // double-emit the event. The `timeouts` array is the arm ledger, and a // `finally` around the wait owns the `clearTimeout` teardown on every @@ -1917,50 +1925,79 @@ export class Task extends EventEmitter implements TaskLike { if (isStatusMutable) { armAskStatusTimers() - } else if (isMessageQueued && shouldDrainQueuedMessageForAsk && queuedMessage && queuedAskResolution) { - if (this.blanketDeniedCommandThisTurn) { - // A command this turn already blanket-denied left the message in the - // queue — it answers that denial, not this ask. Release the claim and - // let the user answer the prompt normally. - this.messageQueueService.releaseMessage(queuedMessage.id) - // The prompt stays pending with the queue still non-empty, so the - // `isStatusMutable` arm above was skipped and must run from here. - armAskStatusTimers() - } else if (type === "command") { + } else if (queuedMessage && queuedAskResolution) { + if (type === "command") { // The snapshot gate is frozen; blanket deny may have engaged since the // ask began. Re-read policy before the message stands in for approval. + // Drain-site parity for cancellation and cleanup: the fresh policy + // read ends in an uncancellable provider read, so an abort poller + // races it and one `finally` releases the claim on the abort, throw, + // supersession, and "release" outcomes alike. + let abortWatcher: ReturnType | undefined try { - const action = await this.recheckQueuedCommandPolicy({ - text, - isProtected, - dcgDecision: autoApprovalContext?.dcgDecision, - }) - if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { - // The user answered, the ask was superseded, or the task aborted - // while the fresh read was pending: the message is none of this - // ask's business — leave it for the next consumer. The ask - // resolves with the user's own response via the pWaitFor below. - this.messageQueueService.releaseMessage(queuedMessage.id) - } else { + const outcome = await Promise.race([ + this.recheckQueuedCommandPolicy({ + text, + isProtected, + dcgDecision: autoApprovalContext?.dcgDecision, + }).then((action) => ({ action })), + new Promise<{ aborted: true }>((resolve) => { + const checkAbort = () => { + if (this.abort) { + resolve({ aborted: true }) + } + } + checkAbort() + abortWatcher = setInterval(checkAbort, 100) + }), + ]) + if ( + !("aborted" in outcome) && + !this.abort && + this.askResponse === undefined && + this.lastMessageTs === askTs + ) { + // Any other outcome — the user answered, the ask was + // superseded, the task aborted — leaves the message for the + // next consumer; the `finally` below releases the claim and the + // ask resolves with the user's own response via the pWaitFor. queuedMessageId = this.applyQueuedCommandPolicyAction( - action, + outcome.action, queuedMessage, queuedAskResolution, ) // A "release" outcome leaves the ask pending; consume/deny/approve - // resolved it, and the helper's pending check declines to arm then. + // resolved it, and the arm's pending check declines to arm then. armAskStatusTimers() } } catch (error) { // Drain-site parity: a failed re-check must not reject ask() nor // strand the claim; the prompt stays pending for the user. console.error("[Task#ask] queued command policy re-check failed:", error) - this.messageQueueService.releaseMessage(queuedMessage.id) armAskStatusTimers() + } finally { + if (abortWatcher !== undefined) { + clearInterval(abortWatcher) + } + // One release path for abort, throw, supersession, and "release". + // `releaseMessage` is idempotent, so it stays safe beside the + // helper-internal release. The durable consume is the one outcome + // that must keep its claim until persistence removes the message — + // it is the only path that assigned `queuedMessageId` here. + if (queuedMessageId !== queuedMessage.id) { + this.messageQueueService.releaseMessage(queuedMessage.id) + } } } else { queuedMessageId = this.handleQueuedAskResponse(queuedMessage, queuedAskResolution) } + } else if (shouldDrainQueuedMessageForAsk && isMessageQueued) { + // The claim gate (per-turn latch, or blanket deny engaged for a command + // ask) left the queued message untouched. If the policy still leaves the + // prompt pending, the non-empty queue keeps `isStatusMutable` false, so + // the interactive arm must run from here — the same reason a release + // re-arms. For an auto-answered ask the arm's pending check declines. + armAskStatusTimers() } // At most one drain-site policy re-check is in flight per ask; the diff --git a/src/core/task/__tests__/ask-auto-deny.spec.ts b/src/core/task/__tests__/ask-auto-deny.spec.ts index 907a43a862..9a6738e310 100644 --- a/src/core/task/__tests__/ask-auto-deny.spec.ts +++ b/src/core/task/__tests__/ask-auto-deny.spec.ts @@ -600,12 +600,12 @@ describe("Task.ask queue path cannot bypass blanket deny", () => { expect(queue.hasUnclaimed()).toBe(true) }) - it("arms the interactive status timer when the latch release leaves the ask pending", async () => { - // A released claim keeps the queue non-empty, so `isStatusMutable` is - // false for the whole dwell and the 2 s interactive arm is skipped - // unless the release branch re-arms it: without that, hands-free/API - // consumers see `Running` with no `TaskInteractive` for a prompt that - // is waiting on the user. + it("arms the interactive status timer when the latch gate leaves the ask pending", async () => { + // A gated claim keeps the queue non-empty, so `isStatusMutable` is false + // for the whole dwell and the 2 s interactive arm is skipped unless the + // gate branch arms it: without that, hands-free/API consumers see + // `Running` with no `TaskInteractive` for a prompt that is waiting on + // the user. const task = buildTask(provider, TASK_CWD) const queue = await attachQueue(task) recordClineMessages(task) @@ -864,4 +864,95 @@ describe("Task.ask queue path cannot bypass blanket deny", () => { setTimeoutSpy.mockRestore() clearTimeoutSpy.mockRestore() }) + + it("auto-approves a policy-approved tool ask while the latch leaves the queue for later", async () => { + // The latch is a reason not to claim, not a reason to claim-and-release: + // a claim forces the `ask` decision before the policy runs, so a + // `read_file` that `alwaysAllowReadOnly` auto-approves would sit waiting + // for a user who has nothing to decide, stalling a hands-free session. + // The policy must decide as if the queue were empty, and the message the + // denial left behind must stay unclaimed for a later turn. + state.alwaysAllowReadOnly = true + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + queue.addMessage("feedback on the denied command") + + const denied = await task.ask("command", "rm x", false) + expect(denied.response).toBe("noButtonClicked") + expect(task["blanketDeniedCommandThisTurn"]).toBe(true) + + // Race a deadline against the ask: with a claim-forced prompt the ask + // stays pending forever, and a hang must read as a failure, not a pass. + const outcome = await Promise.race([ + task.ask("tool", JSON.stringify({ tool: "readFile", path: "a.txt" }), false), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 1_500)), + ]) + expect(outcome).not.toBe("pending") + const result = outcome as Awaited> + // Approved through policy, not through the queued message: the result + // carries no feedback text, and the message survives unclaimed. + expect(result.response).toBe("yesButtonClicked") + expect(result.text).toBeUndefined() + expect(result.queuedMessageId).toBeUndefined() + expect(queue.messages).toHaveLength(1) + expect(queue.hasUnclaimed()).toBe(true) + }) + + it("settles ask() and releases the claim when the task aborts while the immediate re-check is pending", async () => { + // The immediate site's fresh policy read ends in the same uncancellable + // `provider.getState()` as the drain site's. With the claim held and the + // read pending before the wait even starts, an abort must settle the + // re-check through the abort race, release the claim from its `finally`, + // tear the watcher down, and reject `ask()`. + state.alwaysDenyUnapprovedCommands = false + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + queue.addMessage("queued before the ask begins") + + let getStateCalls = 0 + provider.getState = () => { + getStateCalls++ + if (getStateCalls >= 2) { + // The immediate-site re-check never settles: nothing resolves it, + // only the abort race ends the ask's dependence on it. + return new Promise>(() => {}) + } + return Promise.resolve(state) + } + + const setIntervalSpy = vi.spyOn(globalThis, "setInterval") + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval") + + const askPromise = task.ask("command", "rm x", false) + await vi.waitFor(() => expect(getStateCalls).toBe(2)) + expect(queue.hasUnclaimed()).toBe(false) + + task["abort"] = true + + // `ask()` must reject on abort; racing a deadline distinguishes a wrong + // settle from a hang on the pending read. + const outcome = await Promise.race([ + askPromise.then( + () => "resolved", + (error: unknown) => (error instanceof Error ? error.message : String(error)), + ), + new Promise((resolve) => setTimeout(() => resolve("deadline-exceeded"), 3_000)), + ]) + expect(outcome).toContain("aborted") + + // The claim releases from the re-check's finally once the abort race + // settles it, so a later consumer can take the message. + await vi.waitFor(() => expect(queue.hasUnclaimed()).toBe(true)) + + // The abort watcher must not keep polling past the settle. + const intervals = setIntervalSpy.mock.results.map((result) => result.value) + expect(intervals.length).toBeGreaterThan(0) + for (const interval of intervals) { + expect(clearIntervalSpy.mock.calls.some(([cleared]) => cleared === interval)).toBe(true) + } + setIntervalSpy.mockRestore() + clearIntervalSpy.mockRestore() + }) }) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 7a104b04e0..a6034e4f4d 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -168,8 +168,10 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { const blanketAutoDeny = isBlanketDenyEngaged(providerState) // Outside blanket mode a DCG block is presented as the protected - // prompt. The execute-time re-check forwards the same flag so the fresh - // decision is computed against the ask the user actually answered. + // prompt. This flag governs prompt presentation only: protection is a + // property of the policy that produced the prompt, so the execute-time + // re-check re-derives it from the fresh state rather than reusing this + // value (below). const isProtectedAsk = dcgDecision !== undefined && dcgDecision.decision === "deny" && !blanketAutoDeny // DCG-approved commands are auto-approved by checkAutoApproval (from the @@ -205,7 +207,15 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { // fresh policy denies. Parity call with the drain-site re-check, // forwarding the verdict already computed above — re-running the // guard would respawn the process for no new information. A fresh - // policy that still approves executes normally. Routing keeps the + // policy that still approves executes normally. Protection is + // re-derived from the fresh state, not latched from the prompt: + // `isProtected` short-circuits the command policy to an ask before + // any denial is evaluated, so forwarding the ask-time flag would + // let a DCG block approved during the dwell execute even though + // the blanket setting is now on. Inside this branch blanket deny + // is engaged, so the protection rule (`DCG block && blanket off`) + // cannot hold under the current policy — the fresh derivation is + // `false`, and the blanket denial is evaluated. Routing keeps the // ask-path distinction: `guard_unavailable` marks a guard-state // inconsistency (retryable error, not a policy denial, and no // latch); blanket kinds latch the turn so a queue message left by @@ -217,7 +227,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { cwd: task.cwd, ask: "command", text: canonicalCommand, - isProtected: isProtectedAsk, + isProtected: false, dcgDecision, }) if (recheck.decision === "deny") { @@ -247,6 +257,18 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { }), ) return + } else if (recheck.decision !== "approve") { + // Fail closed on every non-approval, not just `deny`: with + // blanket engaged the command policy should never answer + // `ask`/`timeout` here, but the result union allows it, and an + // unexpected non-approval must not reach the terminal. Retry + // is safe — nothing ran and nothing latched. + pushToolResult( + formatResponse.toolError( + `Command \`${canonicalCommand}\` was not executed: the fresh command policy re-check returned "${recheck.decision}" instead of an approval. You may retry the same command.`, + ), + ) + return } } diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index bd23db2938..2797897f46 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -4,6 +4,7 @@ import type { ToolUsage } from "@roo-code/types" import * as vscode from "vscode" import { Task } from "../../task/Task" +import * as autoApprovalModule from "../../auto-approval" import { formatResponse } from "../../prompts/responses" import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" import { unescapeHtmlEntities } from "../../../utils/text-normalization" @@ -550,6 +551,115 @@ describe("executeCommandTool", () => { expect(mockPushToolResult).toHaveBeenCalledTimes(1) }) + // Shared scenario for the protected-prompt transition: the command is + // DCG-denied with blanket auto-deny OFF, so the user sees the protected + // prompt, explicitly approves it, and the blanket setting engages before + // the command reaches the terminal. + const setupProtectedDwellFlip = async () => { + const provider = await mockCline.providerRef.deref() + provider.context = { globalStorageUri: { fsPath: "/test/storage" } } + provider.contextProxy.getValue.mockReturnValue(true) + provider.getState + .mockResolvedValueOnce({ + destructiveCommandGuardEnabled: true, + alwaysDenyUnapprovedCommands: false, + autoApprovalEnabled: true, + alwaysAllowExecute: true, + allowedCommands: [], + deniedCommands: [], + terminalShellIntegrationDisabled: true, + }) + .mockResolvedValue({ + destructiveCommandGuardEnabled: true, + alwaysDenyUnapprovedCommands: true, + autoApprovalEnabled: true, + alwaysAllowExecute: true, + allowedCommands: [], + deniedCommands: [], + terminalShellIntegrationDisabled: true, + }) + mockRunDcg.mockResolvedValue({ + decision: "deny", + reason: "matches a destructive pattern", + ruleId: "recursive-delete", + }) + // The user affirmatively approves the protected prompt during the dwell. + mockAskApproval.mockResolvedValue(true) + return provider + } + + it("auto-denies a DCG-denied protected command when blanket deny engages during the approval dwell", async () => { + await setupProtectedDwellFlip() + + await executeCommandTool.handle(mockCline, mockToolUse, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The prompt shown was the protected one — the blanket setting was off + // when the ask was built. + expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test", undefined, true) + + // The blanket denial wins over the affirmative approval: the command + // never reaches the terminal. The provider-level assertion is the + // load-bearing one — it is the real execution boundary. + expect(TerminalRegistry.getOrCreateTerminal).not.toHaveBeenCalled() + expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled() + expect(mockCline.recordBlanketCommandDenial).toHaveBeenCalledTimes(1) + expect(formatResponse.toolAutoDenied).toHaveBeenCalledWith({ + reason: expect.stringContaining("matches a destructive pattern"), + offendingCommand: "echo test", + ruleId: "recursive-delete", + }) + expect(formatResponse.toolError).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + }) + + it("re-derives the recheck's protected flag from the fresh state instead of forwarding the latched prompt flag", async () => { + await setupProtectedDwellFlip() + const originalCheckAutoApproval = autoApprovalModule.checkAutoApproval + const recheckSpy = vitest + .spyOn(autoApprovalModule, "checkAutoApproval") + .mockImplementation((args) => originalCheckAutoApproval(args)) + + await executeCommandTool.handle(mockCline, mockToolUse, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(recheckSpy).toHaveBeenCalledTimes(1) + // The ask the user answered was protected, but protection is a property + // of the policy that produced the prompt. Forwarding the latched flag + // would make `checkAutoApproval` short-circuit to `ask` before ever + // evaluating the now-engaged blanket denial. + expect(recheckSpy).toHaveBeenCalledWith(expect.objectContaining({ ask: "command", isProtected: false })) + expect(TerminalRegistry.getOrCreateTerminal).not.toHaveBeenCalled() + }) + + it("fails closed when the execute-time recheck returns a non-approval other than deny", async () => { + await setupProtectedDwellFlip() + // The blanket-engaged command policy never answers `ask` today; force a + // result from the permitted-but-unexpected tail of the union to pin that + // any non-approval blocks execution instead of falling through. + vitest.spyOn(autoApprovalModule, "checkAutoApproval").mockResolvedValue({ decision: "ask" }) + + await executeCommandTool.handle(mockCline, mockToolUse, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(TerminalRegistry.getOrCreateTerminal).not.toHaveBeenCalled() + expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled() + // Not a policy denial: no latch, and the retryable-error channel instead. + expect(mockCline.recordBlanketCommandDenial).not.toHaveBeenCalled() + expect(formatResponse.toolAutoDenied).not.toHaveBeenCalled() + expect(formatResponse.toolError).toHaveBeenCalledWith(expect.stringContaining("instead of an approval")) + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + }) + it("installs or updates DCG before evaluating an enabled command", async () => { const provider = await mockCline.providerRef.deref() provider.context = { globalStorageUri: { fsPath: "/test/storage" } } From 3d921b19c5df2441cd4b50888b3e8dc9dee251a1 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Wed, 30 Sep 2026 19:25:53 +0200 Subject: [PATCH 38/39] fix(auto-approval): cancel queued-command policy re-check on task abort --- src/core/task/Task.ts | 164 ++++++++++-------- src/core/task/__tests__/ask-auto-deny.spec.ts | 64 +++++++ 2 files changed, 156 insertions(+), 72 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 93b5a2b053..0807a5b539 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1086,17 +1086,47 @@ export class Task extends EventEmitter implements TaskLike { * dwell. Re-reading here closes it: while blanket deny engages, the message * is never consumed as approval and the ask gets the structured denial policy * would have produced without a queued message. + * + * With `abortSignal`, the re-check settles on the abort itself instead of + * merely losing a race against it: the fresh-state read ends in uncancellable + * provider work, so a post-await check alone would leave this promise pending + * whenever the read never lands, retaining the task and running policy work + * after the abort. An aborted re-check resolves to the `release` no-op, + * leaving the claim and the pending ask to the caller's release path. */ - private async recheckQueuedCommandPolicy({ - text, - isProtected, - dcgDecision, - }: { - text?: string - isProtected?: boolean - dcgDecision?: AutoApprovalContext["dcgDecision"] - }): Promise { - const freshState = await this.providerRef.deref()?.getState() + private async recheckQueuedCommandPolicy( + { + text, + isProtected, + dcgDecision, + }: { + text?: string + isProtected?: boolean + dcgDecision?: AutoApprovalContext["dcgDecision"] + }, + abortSignal?: AbortSignal, + ): Promise { + // Resolving a sentinel rather than rejecting keeps a late rejection from + // the uncancellable read unhandled once the abort has won the race, and + // routes the abort through the same release early-return as the checks. + const signal = abortSignal + const abortPromise = signal + ? new Promise<"aborted">((resolve) => { + if (signal.aborted) { + resolve("aborted") + } else { + signal.addEventListener("abort", () => resolve("aborted"), { once: true }) + } + }) + : undefined + const freshState = abortPromise + ? await Promise.race([this.providerRef.deref()?.getState(), abortPromise]) + : await this.providerRef.deref()?.getState() + // The abort either won the race (sentinel) or landed while the read was + // still resolving; both settle the re-check before any policy work. + if (freshState === "aborted" || signal?.aborted) { + return { action: "release" } + } if (!isBlanketDenyEngaged(freshState)) { // Disengaged: the queued answer is a legitimate approval, as before. return { action: "consume" } @@ -1111,6 +1141,9 @@ export class Task extends EventEmitter implements TaskLike { isProtected, dcgDecision, }) + if (signal?.aborted) { + return { action: "release" } + } if (approval.decision === "deny") { return { action: "deny", detail: approval.autoDeny } } @@ -1931,38 +1964,34 @@ export class Task extends EventEmitter implements TaskLike { // ask began. Re-read policy before the message stands in for approval. // Drain-site parity for cancellation and cleanup: the fresh policy // read ends in an uncancellable provider read, so an abort poller - // races it and one `finally` releases the claim on the abort, throw, - // supersession, and "release" outcomes alike. - let abortWatcher: ReturnType | undefined + // aborts a signal the re-check itself awaits — settling it on the + // abort instead of leaving a pending promise that retains this task + // and runs policy post-abort — and one `finally` releases the claim + // on the abort, throw, supersession, and "release" outcomes alike. + const recheckAbort = new AbortController() + const checkAbort = () => { + if (this.abort) { + recheckAbort.abort() + } + } + checkAbort() + const abortWatcher = setInterval(checkAbort, 100) try { - const outcome = await Promise.race([ - this.recheckQueuedCommandPolicy({ + const action = await this.recheckQueuedCommandPolicy( + { text, isProtected, dcgDecision: autoApprovalContext?.dcgDecision, - }).then((action) => ({ action })), - new Promise<{ aborted: true }>((resolve) => { - const checkAbort = () => { - if (this.abort) { - resolve({ aborted: true }) - } - } - checkAbort() - abortWatcher = setInterval(checkAbort, 100) - }), - ]) - if ( - !("aborted" in outcome) && - !this.abort && - this.askResponse === undefined && - this.lastMessageTs === askTs - ) { - // Any other outcome — the user answered, the ask was - // superseded, the task aborted — leaves the message for the - // next consumer; the `finally` below releases the claim and the - // ask resolves with the user's own response via the pWaitFor. + }, + recheckAbort.signal, + ) + if (!this.abort && this.askResponse === undefined && this.lastMessageTs === askTs) { + // Any outcome on a still-live ask — the user answered or the + // ask was superseded — leaves the message for the next + // consumer; the `finally` below releases the claim and the ask + // resolves with the user's own response via the pWaitFor. queuedMessageId = this.applyQueuedCommandPolicyAction( - outcome.action, + action, queuedMessage, queuedAskResolution, ) @@ -1976,9 +2005,7 @@ export class Task extends EventEmitter implements TaskLike { console.error("[Task#ask] queued command policy re-check failed:", error) armAskStatusTimers() } finally { - if (abortWatcher !== undefined) { - clearInterval(abortWatcher) - } + clearInterval(abortWatcher) // One release path for abort, throw, supersession, and "release". // `releaseMessage` is idempotent, so it stays safe beside the // helper-internal release. The durable consume is the one outcome @@ -2008,45 +2035,38 @@ export class Task extends EventEmitter implements TaskLike { resolution: QueuedAskResolution, ): Promise => { // The fresh policy read ends in `provider.getState()`, which awaits - // custom-mode file work that cannot be cancelled from here. Racing it - // against an abort poller settles this re-check within one tick of an - // abort instead of leaving `ask()` blocked on the pending read. The race - // attaches handlers to both inputs, so the read rejecting after the - // watcher wins is already considered handled — no extra `.catch` needed. - let abortWatcher: ReturnType | undefined + // custom-mode file work that cannot be cancelled from here. An abort + // poller aborts a signal the re-check itself awaits, settling it within + // one poll of the abort instead of leaving `ask()` blocked on the pending + // read or a lost race retaining this task. The re-check's own race + // attaches handlers to the read, so it rejecting after the abort settles + // is already considered handled — no extra `.catch` needed. + const recheckAbort = new AbortController() + const checkAbort = () => { + if (this.abort) { + recheckAbort.abort() + } + } + checkAbort() + const abortWatcher = setInterval(checkAbort, 100) try { - const outcome = await Promise.race([ - this.recheckQueuedCommandPolicy({ + const action = await this.recheckQueuedCommandPolicy( + { text, isProtected, dcgDecision: autoApprovalContext?.dcgDecision, - }).then((action) => ({ action })), - new Promise<{ aborted: true }>((resolve) => { - const checkAbort = () => { - if (this.abort) { - resolve({ aborted: true }) - } - } - checkAbort() - abortWatcher = setInterval(checkAbort, 100) - }), - ]) - if ( - !("aborted" in outcome) && - !this.abort && - this.askResponse === undefined && - this.lastMessageTs === askTs - ) { - queuedMessageId = this.applyQueuedCommandPolicyAction(outcome.action, message, resolution) + }, + recheckAbort.signal, + ) + if (!this.abort && this.askResponse === undefined && this.lastMessageTs === askTs) { + queuedMessageId = this.applyQueuedCommandPolicyAction(action, message, resolution) // A "release" outcome leaves the ask pending while the queue stays // non-empty, so the arm that `isStatusMutable` gates — computed // once, before the claim — must run here. armAskStatusTimers() } } finally { - if (abortWatcher !== undefined) { - clearInterval(abortWatcher) - } + clearInterval(abortWatcher) // One release path for abort, throw, supersession, and "release". // `releaseMessage` is idempotent, so it stays safe beside the // branch-local releases. The durable consume is the one outcome @@ -2109,9 +2129,9 @@ export class Task extends EventEmitter implements TaskLike { // Let a policy re-check that was in flight when the wait resolved run to // completion: its bail-out path releases the claim, and leaving it // unresolved would let the consume race the result below. On abort, detach - // instead: the re-check races the abort and its `finally` releases the claim - // either way, while awaiting an uncancellable read past the abort would - // retain this task instead of letting the throw below settle it. + // instead: the re-check settles on the abort and its `finally` releases the + // claim either way, while awaiting past the abort could stall on an + // uncancellable read instead of letting the throw below settle the ask. if (queuedCommandPolicyCheck && !this.abort) { await queuedCommandPolicyCheck } diff --git a/src/core/task/__tests__/ask-auto-deny.spec.ts b/src/core/task/__tests__/ask-auto-deny.spec.ts index 9a6738e310..4254791025 100644 --- a/src/core/task/__tests__/ask-auto-deny.spec.ts +++ b/src/core/task/__tests__/ask-auto-deny.spec.ts @@ -2,6 +2,7 @@ import { type ClineMessage, type ExtensionState, RooCodeEventName } from "@roo-code/types" +import * as autoApprovalModule from "../../auto-approval" import { createRateLimitClock } from "../RateLimitClock" import { Task } from "../Task" @@ -955,4 +956,67 @@ describe("Task.ask queue path cannot bypass blanket deny", () => { setIntervalSpy.mockRestore() clearIntervalSpy.mockRestore() }) + + it("never consults the policy when the fresh-state read lands after the abort", async () => { + // An abort must settle the re-check itself, not merely win a race against + // it: a re-check left pending on the uncancellable read retains the task + // and, when the read finally lands with the blanket deny engaged, runs + // `checkAutoApproval` post-abort. Here the read resolves only after the + // abort, with deny now ON — the settled re-check must not take it + // through the policy, and the claimed message must stay unconsumed. + state.alwaysDenyUnapprovedCommands = false + const checkAutoApprovalSpy = vi.spyOn(autoApprovalModule, "checkAutoApproval") + + let resolveLateState: (() => void) | undefined + let getStateCalls = 0 + provider.getState = () => { + getStateCalls++ + if (getStateCalls >= 2) { + // The immediate-site re-check's fresh read stays pending until the + // test releases it, past the abort, with the policy engaged. + return new Promise>((resolve) => { + resolveLateState = () => { + state.alwaysDenyUnapprovedCommands = true + resolve(state) + } + }) + } + return Promise.resolve(state) + } + + const task = buildTask(provider, TASK_CWD) + const queue = await attachQueue(task) + queue.addMessage("queued before the ask begins") + + const askPromise = task.ask("command", "rm x", false) + await vi.waitFor(() => expect(getStateCalls).toBe(2)) + expect(queue.hasUnclaimed()).toBe(false) + + task["abort"] = true + + // `ask()` must reject on abort; racing a deadline distinguishes a wrong + // settle from a hang on the pending read. + const outcome = await Promise.race([ + askPromise.then( + () => "resolved", + (error: unknown) => (error instanceof Error ? error.message : String(error)), + ), + new Promise((resolve) => setTimeout(() => resolve("deadline-exceeded"), 3_000)), + ]) + expect(outcome).toContain("aborted") + + // The uncancellable read lands only now — the settled re-check must have + // detached from it, so its continuation never reaches the policy. + resolveLateState!() + await vi.waitFor(() => expect(queue.hasUnclaimed()).toBe(true)) + // Past a full abort-watcher poll: enough for any retained continuation + // of the late read to have run and been caught by the spy. + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(checkAutoApprovalSpy).not.toHaveBeenCalled() + // No post-abort consume: the message survives, unclaimed, for a later + // consumer. + expect(queue.messages).toHaveLength(1) + expect(queue.hasUnclaimed()).toBe(true) + checkAutoApprovalSpy.mockRestore() + }) }) From fe30882e91ff5a91bf029b558a04586948ef0561 Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Wed, 30 Sep 2026 20:42:17 +0200 Subject: [PATCH 39/39] fix(ci): treat negative v8 branch deltas as uncovered when merging lcov --- src/scripts/__tests__/merge-lcov.spec.mjs | 18 ++++++++++++++++++ src/scripts/merge-lcov.mjs | 12 +++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/scripts/__tests__/merge-lcov.spec.mjs b/src/scripts/__tests__/merge-lcov.spec.mjs index 64706eaf02..4b231fe54c 100644 --- a/src/scripts/__tests__/merge-lcov.spec.mjs +++ b/src/scripts/__tests__/merge-lcov.spec.mjs @@ -42,6 +42,24 @@ describe("mergeLcov", () => { expect(merged).not.toContain("DA:3,1") }) + it("treats a negative v8 branch delta as uncovered instead of failing the merge", () => { + // The v8 provider emits negative BRDA counts for short-circuit conditions + // (e.g. `freshState === "aborted" || signal?.aborted`), which previously + // aborted the CI coverage merge with "Invalid BRDA ... -3". + const merged = mergeLcov([ + ["core", report(new Set([1])).replace("BRDA:2,0,0,-", "BRDA:2,0,0,-3")], + ["api", report(new Set([1, 2]))], + ]) + + // The negative-delta branch merges to uncovered, and the positive + // duplicate from the other lane still wins the union. + expect(merged).toContain("BRDA:2,0,0,1") + + const solo = mergeLcov([["core", report(new Set([1])).replace("BRDA:2,0,0,-", "BRDA:2,0,0,-3")]]) + expect(solo).toContain("BRDA:2,0,0,-") + expect(solo).not.toContain("-3") + }) + it("merges disjoint source records without changing their paths", () => { const merged = mergeLcov([ ["api", report(new Set([1])).replaceAll("src/example.ts", "src/api.ts")], diff --git a/src/scripts/merge-lcov.mjs b/src/scripts/merge-lcov.mjs index cfcbd46cb6..8720697076 100644 --- a/src/scripts/merge-lcov.mjs +++ b/src/scripts/merge-lcov.mjs @@ -7,6 +7,16 @@ const parseCount = (value, description) => { return count } +// The v8 provider emits negative BRDA counts for short-circuit conditions, where +// an uncovered inner branch range is subtracted from a covered outer one +// (e.g. `a === X || b?.flag`). A negative delta means the branch was never taken +// on its own, so clamp it to uncovered instead of failing the merge. +const parseBranchCount = (value, description) => { + const count = Number(value) + if (!Number.isSafeInteger(count)) throw new Error(`Invalid ${description}: ${value}`) + return count > 0 ? count : 0 +} + const mergeCount = (records, key, count) => records.set(key, Math.max(records.get(key) ?? 0, count)) const parseLcov = (lcov, label) => { @@ -48,7 +58,7 @@ const parseLcov = (lcov, label) => { } else if (record && line.startsWith("BRDA:")) { const [lineNumber, block, branch, taken] = line.slice(5).split(",") const key = `${lineNumber},${block},${branch}` - const count = taken === "-" ? 0 : parseCount(taken, `BRDA for ${record.source}`) + const count = taken === "-" ? 0 : parseBranchCount(taken, `BRDA for ${record.source}`) mergeCount(record.branches, key, count) } else if (record && line.startsWith("DA:")) { const [lineNumber, count, checksum] = line.slice(3).split(",")