Compare commits

...

6 Commits

Author SHA1 Message Date
Eve Killaby 40df78aa97 Code quality improvements as per qlty.sh tests in CI
Refactor to DRY repetitive logic as per qlty.sh tests in CI

Improve code legibility as per qlty tests in CI

Refactor to DRY for handleAutoApprovalFlow() as per qlty feedback

Remove redundant undefined as per qlty feedback

Refactor complex methods (too many params) into common paterns as per qlty feedback

Refactor executeHook() to make return statements consistent

Refactor captureToolTelemetry() into simpler functions for three separate use cases

Refactor of a refactor from qlty feedback

Refactor createMockTask() test method to simplify return use cases as per qlty feeedback

Mock the say() more thoroughly to address failing test

Fix broken test use cases

Add error message to error in hook executor

Refactor createMockSayMethod() function to reduce complexity as per qlty

Don't use nested template literals as per qlty

Refactor createNewMessage() to reduce complexity as per qlty
2025-11-12 15:53:06 -08:00
Eve Killaby a1faa6b2aa Add tests as per Cline's code review (before human code review) 2025-11-12 15:51:03 -08:00
Eve Killaby 7345906e88 Changes as per Cline's code review (before human code review) 2025-11-12 15:51:03 -08:00
Eve Killaby 3777c55ed2 feat(hooks): Insert PreToolUse UI block above the tool block (now for non-approval flow too) 2025-11-12 15:51:03 -08:00
Eve Killaby d731360780 feat(hooks): Insert PreToolUse UI block above the tool block 2025-11-12 15:51:03 -08:00
Eve Killaby efa70e3e60 feat(hooks): Run PreToolUse hook after approval 2025-11-12 15:51:03 -08:00
27 changed files with 1500 additions and 523 deletions
@@ -0,0 +1,10 @@
/**
* Error thrown when a PreToolUse hook requests cancellation.
* This signals to the tool handler that execution should be aborted.
*/
export class PreToolUseHookCancellationError extends Error {
constructor(message: string = "PreToolUse hook requested cancellation") {
super(message)
this.name = "PreToolUseHookCancellationError"
}
}
+82 -75
View File
@@ -20,6 +20,7 @@ export interface HookExecutionOptions<Name extends keyof Hooks = any> {
hooksEnabled: boolean
toolName?: string // Optional tool name for PreToolUse/PostToolUse hooks
pendingToolInfo?: any // Optional metadata about pending tool execution for PreToolUse
taskState?: any // Optional taskState for accessing currentToolAskMessageTs (PreToolUse hook ordering)
}
// Import Hooks type from HookFactory
@@ -49,21 +50,24 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
hooksEnabled,
} = options
// Early return if hooks are disabled
// Early exit if hooks are disabled or hook doesn't exist
if (!hooksEnabled) {
return {
wasCancelled: false,
}
return { wasCancelled: false }
}
// Check if the hook exists
const hookFactory = new HookFactory()
const hasHook = await hookFactory.hasHook(hookName)
if (!hasHook) {
return {
wasCancelled: false,
}
return { wasCancelled: false }
}
// Initialize result with default values
const result: HookExecutionResult = {
cancel: false,
contextModification: undefined,
errorMessage: undefined,
wasCancelled: false,
}
let hookMessageTs: number | undefined
@@ -79,6 +83,13 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
}
hookMessageTs = await say("hook", JSON.stringify(hookMetadata))
// SPECIAL HANDLING: For PreToolUse hooks, insert the message BEFORE the tool ask message
// This makes the UI show the hook running before the tool approval, which is more intuitive
if (hookName === "PreToolUse" && hookMessageTs && options.taskState?.currentToolAskMessageTs) {
const toolAskTs = options.taskState.currentToolAskMessageTs
await messageStateHandler.insertMessageBefore(hookMessageTs, toolAskTs)
}
// Track active hook execution for cancellation (only if cancellable and message was created)
if (isCancellable && hookMessageTs !== undefined && setActiveHookExecution) {
await setActiveHookExecution({
@@ -101,16 +112,20 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
isCancellable ? abortController.signal : undefined,
)
const result = await hook.run({
const hookResult = await hook.run({
taskId,
...hookInput,
})
console.log(`[${hookName} Hook]`, result)
console.log(`[${hookName} Hook]`, hookResult)
// Check if hook wants to cancel
if (result.cancel === true) {
// Update hook status to cancelled
// Build up result based on hook execution outcome
result.cancel = hookResult.cancel ?? false
result.contextModification = hookResult.contextModification
result.errorMessage = hookResult.errorMessage
// Handle hook cancellation
if (hookResult.cancel === true) {
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
@@ -120,36 +135,22 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
hasJsonResponse: true,
})
}
return {
cancel: true,
contextModification: result.contextModification,
errorMessage: result.errorMessage,
wasCancelled: false,
} else {
// Clear active hook execution after successful completion (only if cancellable)
if (isCancellable && clearActiveHookExecution) {
await clearActiveHookExecution()
}
}
// Clear active hook execution after successful completion (only if cancellable)
if (isCancellable && clearActiveHookExecution) {
await clearActiveHookExecution()
}
// Update hook status to completed (only if not cancelled)
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
...(options.toolName && { toolName: options.toolName }),
status: "completed",
exitCode: 0,
hasJsonResponse: true,
})
}
return {
cancel: result.cancel,
contextModification: result.contextModification,
errorMessage: result.errorMessage,
wasCancelled: false,
// Update hook status to completed
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
...(options.toolName && { toolName: options.toolName }),
status: "completed",
exitCode: 0,
hasJsonResponse: true,
})
}
}
} catch (hookError) {
// Clear active hook execution (only if cancellable)
@@ -159,7 +160,6 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
// Check if this was a user cancellation via abort controller
if (abortController.signal.aborted) {
// Update hook status to cancelled
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
@@ -168,44 +168,51 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
})
}
return {
cancel: true,
wasCancelled: true,
result.cancel = true
result.wasCancelled = true
} else {
// Update hook status to failed for actual errors
const isStructuredError = HookExecutionError.isHookError(hookError)
const errorInfo = isStructuredError ? hookError.errorInfo : null
// Assign error message to result for downstream consumers
if (errorInfo) {
// Use structured error message with details
const messageParts = [errorInfo.message]
if (errorInfo.details) {
messageParts.push(errorInfo.details)
}
result.errorMessage = messageParts.join("\n")
} else if (hookError instanceof Error) {
// Use standard Error message
result.errorMessage = hookError.message
} else {
// Fallback for unknown error types
result.errorMessage = String(hookError)
}
}
// Update hook status to failed for actual errors
// Extract structured error info if available
const isStructuredError = HookExecutionError.isHookError(hookError)
const errorInfo = isStructuredError ? hookError.errorInfo : null
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
status: "failed",
exitCode: errorInfo?.exitCode ?? 1,
...(errorInfo && {
error: {
type: errorInfo.type,
message: errorInfo.message,
details: errorInfo.details,
scriptPath: errorInfo.scriptPath,
},
}),
})
}
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
status: "failed",
exitCode: errorInfo?.exitCode ?? 1,
...(errorInfo && {
error: {
type: errorInfo.type,
message: errorInfo.message,
details: errorInfo.details,
scriptPath: errorInfo.scriptPath,
},
}),
})
}
// Log error for non-cancellable hooks or unexpected errors
console.error(`${hookName} hook failed:`, hookError)
// Return safe defaults for all fields to avoid undefined property access
return {
cancel: false,
contextModification: undefined,
errorMessage: undefined,
wasCancelled: false,
// Log error for non-cancellable hooks or unexpected errors
console.error(`${hookName} hook failed:`, hookError)
}
}
return result
}
/**
+2
View File
@@ -20,6 +20,8 @@ export const formatResponse = {
toolDenied: () => `The user denied this operation.`,
toolCancelled: () => `Tool execution was cancelled by a PreToolUse hook.`,
toolError: (error?: string) => `The tool execution failed with the following error:\n<error>\n${error}\n</error>`,
clineIgnoreError: (path: string) =>
+6
View File
@@ -39,6 +39,12 @@ export class TaskState {
didAlreadyUseTool = false
didEditFile: boolean = false
/**
* Timestamp of the current tool ask message being processed
* Used to insert PreToolUse hook messages at the correct position
*/
currentToolAskMessageTs?: number
// Error tracking
consecutiveMistakeCount: number = 0
didAutomaticallyRetryFailedApiRequest = false
+101 -100
View File
@@ -526,15 +526,10 @@ export class ToolExecutor {
* Handle complete block execution.
*
* This is the main execution flow for a tool:
* 1. Run PreToolUse hooks (if enabled) - can block execution
* 2. Execute the actual tool
* 3. Run PostToolUse hooks (if enabled) - cannot block, only observe
* 4. Add hook context modifications to the conversation
* 5. Update focus chain tracking
*
* Hooks are executed with streaming output to provide real-time feedback.
* PreToolUse hooks can prevent tool execution by returning shouldContinue: false.
* PostToolUse hooks are for observation/logging only and cannot block.
* 1. Create PreToolUse hook runner (but don't execute yet)
* 2. Tool handler performs approval and calls hook runner after approval
* 3. Execute the actual tool
* 4. Run PostToolUse hook (only if tool executed)
*
* @param block The complete tool use block with all parameters
* @param config The task configuration containing all necessary context
@@ -551,96 +546,6 @@ export class ToolExecutor {
// Track if we need to cancel after hooks complete
let shouldCancelAfterHook = false
// ============================================================
// PHASE 1: Run PreToolUse hook (OUTSIDE try-catch-finally)
// This allows early return on cancellation without triggering finally block
// ============================================================
if (hooksEnabled) {
const { executeHook } = await import("../hooks/hook-executor")
// Build pending tool info for display
const pendingToolInfo: any = {
tool: block.name,
}
// Add relevant parameters for display based on tool type
if (block.params.path) {
pendingToolInfo.path = block.params.path
}
if (block.params.command) {
pendingToolInfo.command = block.params.command
}
if (block.params.content && typeof block.params.content === "string") {
pendingToolInfo.content = block.params.content.slice(0, 200)
}
if (block.params.diff && typeof block.params.diff === "string") {
pendingToolInfo.diff = block.params.diff.slice(0, 200)
}
if (block.params.regex) {
pendingToolInfo.regex = block.params.regex
}
if (block.params.url) {
pendingToolInfo.url = block.params.url
}
// For MCP operations, show tool/resource identifiers
if (block.params.tool_name) {
pendingToolInfo.mcpTool = block.params.tool_name
}
if (block.params.server_name) {
pendingToolInfo.mcpServer = block.params.server_name
}
if (block.params.uri) {
pendingToolInfo.resourceUri = block.params.uri
}
const preToolResult = await executeHook({
hookName: "PreToolUse",
hookInput: {
preToolUse: {
toolName: block.name,
parameters: block.params,
},
},
isCancellable: true,
say: this.say,
setActiveHookExecution: this.setActiveHookExecution,
clearActiveHookExecution: this.clearActiveHookExecution,
messageStateHandler: this.messageStateHandler,
taskId: this.taskId,
hooksEnabled,
toolName: block.name,
pendingToolInfo,
})
// Handle cancellation from hook
if (preToolResult.cancel === true) {
// Trigger task cancellation (same as clicking cancel button)
await config.callbacks.cancelTask()
// Early return - never enters try-catch-finally, so PostToolUse won't run
return
}
// If task was aborted (e.g., via cancel button during hook), stop execution
if (this.taskState.abort) {
shouldCancelAfterHook = true
}
// Add context modification to the conversation if provided by the hook
if (preToolResult.contextModification) {
this.addHookContextToConversation(preToolResult.contextModification, "PreToolUse")
}
}
// ============================================================
// PHASE 2: Execute tool with PostToolUse hook in finally block
// This only runs if PreToolUse didn't cancel above
// ============================================================
// Check abort again before tool execution (could have been set by PreToolUse hook)
if (this.taskState.abort) {
return
}
let executionSuccess = true
let toolResult: any = null
let toolWasExecuted = false
@@ -652,7 +557,93 @@ export class ToolExecutor {
return
}
// Execute the actual tool
// ============================================================
// Create PreToolUse hook runner but don't execute yet
// Handlers will call this after approval
// ============================================================
if (hooksEnabled) {
const { executeHook } = await import("../hooks/hook-executor")
const { PreToolUseHookCancellationError } = await import("../hooks/PreToolUseHookCancellationError")
// Build pending tool info for display
const pendingToolInfo: any = {
tool: block.name,
}
// Add relevant parameters for display based on tool type
if (block.params.path) {
pendingToolInfo.path = block.params.path
}
if (block.params.command) {
pendingToolInfo.command = block.params.command
}
if (block.params.content && typeof block.params.content === "string") {
pendingToolInfo.content = block.params.content.slice(0, 200)
}
if (block.params.diff && typeof block.params.diff === "string") {
pendingToolInfo.diff = block.params.diff.slice(0, 200)
}
if (block.params.regex) {
pendingToolInfo.regex = block.params.regex
}
if (block.params.url) {
pendingToolInfo.url = block.params.url
}
// For MCP operations, show tool/resource identifiers
if (block.params.tool_name) {
pendingToolInfo.mcpTool = block.params.tool_name
}
if (block.params.server_name) {
pendingToolInfo.mcpServer = block.params.server_name
}
if (block.params.uri) {
pendingToolInfo.resourceUri = block.params.uri
}
// Create runner that handlers will call after approval
config.preToolUseRunner = {
run: async () => {
const preToolResult = await executeHook({
hookName: "PreToolUse",
hookInput: {
preToolUse: {
toolName: block.name,
parameters: block.params,
},
},
isCancellable: true,
say: this.say,
setActiveHookExecution: this.setActiveHookExecution,
clearActiveHookExecution: this.clearActiveHookExecution,
messageStateHandler: this.messageStateHandler,
taskId: this.taskId,
hooksEnabled,
toolName: block.name,
pendingToolInfo,
taskState: this.taskState, // Pass taskState for hook message ordering
})
// Handle cancellation from hook
if (preToolResult.cancel === true) {
throw new PreToolUseHookCancellationError(
preToolResult.errorMessage || "PreToolUse hook requested cancellation",
)
}
// Check if task was aborted during hook execution
if (this.taskState.abort) {
throw new PreToolUseHookCancellationError("Task was aborted during hook execution")
}
// Add context modification to the conversation if provided by the hook
if (preToolResult.contextModification) {
this.addHookContextToConversation(preToolResult.contextModification, "PreToolUse")
}
},
}
}
// Execute the actual tool - handlers will invoke the hook after approval
toolResult = await this.coordinator.execute(config, block)
toolWasExecuted = true
this.pushToolResult(toolResult, block)
@@ -692,6 +683,16 @@ export class ToolExecutor {
// Re-throw the error after PostToolUse completes
throw error
} finally {
// Clean up the hook runner after tool execution completes (success or error)
if (config.preToolUseRunner) {
delete config.preToolUseRunner
}
// Clear any pending tool message state if it exists
if (this.taskState.currentToolAskMessageTs !== undefined) {
this.taskState.currentToolAskMessageTs = undefined
}
}
// Early return if hook requested cancellation
@@ -0,0 +1,126 @@
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { createMockToolExecutor, createMockToolUse } from "./test-utils"
describe("ToolExecutor state cleanup", () => {
let sandbox: sinon.SinonSandbox
beforeEach(() => {
sandbox = sinon.createSandbox()
})
afterEach(() => {
sandbox.restore()
})
it("should clean up preToolUseRunner in finally block on success", async () => {
const executor = createMockToolExecutor()
const config = executor.asToolConfig()
const block = createMockToolUse("read_file", { path: "test.txt" })
// Add a preToolUseRunner to the config
config.preToolUseRunner = {
run: sandbox.stub().resolves(),
}
// Mock coordinator to succeed
config.coordinator.execute = sandbox.stub().resolves("Success")
await executor.handleCompleteBlock(block, config)
// Verify cleanup happened
expect(config.preToolUseRunner).to.be.undefined
})
it("should clean up state even when tool execution fails", async () => {
const executor = createMockToolExecutor()
const config = executor.asToolConfig()
const block = createMockToolUse("read_file", { path: "nonexistent.txt" })
// Add a preToolUseRunner to the config
config.preToolUseRunner = {
run: sandbox.stub().resolves(),
}
// Mock tool to throw error
config.coordinator.execute = sandbox.stub().rejects(new Error("File not found"))
// Set current tool ask message ts
executor.taskState.currentToolAskMessageTs = 12345
try {
await executor.handleCompleteBlock(block, config)
} catch (e) {
// Expected error
}
// Verify cleanup happened despite error
expect(config.preToolUseRunner).to.be.undefined
expect(executor.taskState.currentToolAskMessageTs).to.be.undefined
})
it("should clean up state when task is aborted during execution", async () => {
const executor = createMockToolExecutor()
const config = executor.asToolConfig()
const block = createMockToolUse("execute_command", { command: "long_running_cmd" })
// Add a preToolUseRunner to the config
config.preToolUseRunner = {
run: sandbox.stub().resolves(),
}
// Set current tool ask message ts
executor.taskState.currentToolAskMessageTs = 12345
// Mock coordinator to simulate abort
config.coordinator.execute = sandbox.stub().callsFake(async () => {
executor.taskState.abort = true
return "Aborted"
})
await executor.handleCompleteBlock(block, config)
// Verify cleanup happened
expect(config.preToolUseRunner).to.be.undefined
expect(executor.taskState.currentToolAskMessageTs).to.be.undefined
})
it("should clean up even if preToolUseRunner throws", async () => {
const executor = createMockToolExecutor()
const config = executor.asToolConfig()
const block = createMockToolUse("read_file", { path: "test.txt" })
// Add a preToolUseRunner that throws
config.preToolUseRunner = {
run: sandbox.stub().rejects(new Error("Hook failed")),
}
try {
await executor.handleCompleteBlock(block, config)
} catch (e) {
// Expected error from hook
}
// Verify cleanup happened despite hook failure
expect(config.preToolUseRunner).to.be.undefined
})
it("should handle case where preToolUseRunner is not set", async () => {
const executor = createMockToolExecutor()
const config = executor.asToolConfig()
const block = createMockToolUse("read_file", { path: "test.txt" })
// Don't set preToolUseRunner
expect(config.preToolUseRunner).to.be.undefined
// Mock coordinator to succeed
config.coordinator.execute = sandbox.stub().resolves("Success")
// Should not throw
await executor.handleCompleteBlock(block, config)
// Should still be undefined (no error)
expect(config.preToolUseRunner).to.be.undefined
})
})
@@ -0,0 +1,177 @@
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { createMockTask } from "./test-utils"
describe("Hooks integration", () => {
let sandbox: sinon.SinonSandbox
beforeEach(() => {
sandbox = sinon.createSandbox()
})
afterEach(() => {
sandbox.restore()
})
it("should track hook execution order when hooks are enabled", async () => {
const task = createMockTask({ hooksEnabled: true })
const executionLog: string[] = []
// Mock the stateManager to report hooks enabled
task.getStateManager().getGlobalSettingsKey = sandbox.stub().callsFake((key: string) => {
if (key === "hooksEnabled") {
return true
}
return false
})
// Track execution order - this simulates what would happen in real execution
const mockPreToolUseRunner = {
run: sandbox.stub().callsFake(async () => {
executionLog.push("PreToolUse")
return { cancel: false }
}),
}
// Simulate the tool execution flow where:
// 1. Approval happens
// 2. PreToolUse hook runs
// 3. Tool executes
// 4. PostToolUse hook would run (not in scope for this PR)
// Step 1: User approves (simulated)
executionLog.push("Approval")
// Step 2: PreToolUse hook runs (from ToolExecutor)
if (mockPreToolUseRunner) {
await mockPreToolUseRunner.run()
}
// Step 3: Tool executes (simulated)
executionLog.push("ToolExecution")
// Verify execution order
expect(executionLog).to.deep.equal(["Approval", "PreToolUse", "ToolExecution"])
})
it("should not execute PreToolUse hook when hooks are disabled", async () => {
const task = createMockTask({ hooksEnabled: false })
const executionLog: string[] = []
// Mock the stateManager to report hooks disabled
task.getStateManager().getGlobalSettingsKey = sandbox.stub().callsFake((key: string) => {
if (key === "hooksEnabled") {
return false
}
return undefined
})
const mockPreToolUseRunner = {
run: sandbox.stub().callsFake(async () => {
executionLog.push("PreToolUse")
}),
}
// Simulate flow with hooks disabled
executionLog.push("Approval")
// PreToolUse should NOT run when hooks disabled
const hooksEnabled = task.getStateManager().getGlobalSettingsKey("hooksEnabled")
if (hooksEnabled && mockPreToolUseRunner) {
await mockPreToolUseRunner.run()
}
executionLog.push("ToolExecution")
// Verify PreToolUse was NOT called
expect(executionLog).to.deep.equal(["Approval", "ToolExecution"])
expect(executionLog).to.not.include("PreToolUse")
})
it("should allow hook cancellation to stop tool execution", async () => {
const task = createMockTask({ hooksEnabled: true })
const executionLog: string[] = []
// Mock the stateManager to report hooks enabled
task.getStateManager().getGlobalSettingsKey = sandbox.stub().callsFake((key: string) => {
if (key === "hooksEnabled") {
return true
}
return false
})
const mockPreToolUseRunner = {
run: sandbox.stub().callsFake(async () => {
executionLog.push("PreToolUse-Cancelled")
return { cancel: true, errorMessage: "User cancelled via hook" }
}),
}
// Simulate flow where hook cancels
executionLog.push("Approval")
const hookResult = await mockPreToolUseRunner.run()
// Tool should NOT execute if hook cancelled
if (!hookResult.cancel) {
executionLog.push("ToolExecution")
} else {
executionLog.push("CancellationHandled")
}
// Verify execution stopped after hook cancellation
expect(executionLog).to.deep.equal(["Approval", "PreToolUse-Cancelled", "CancellationHandled"])
expect(executionLog).to.not.include("ToolExecution")
})
it("should handle hook errors gracefully", async () => {
const task = createMockTask({ hooksEnabled: true })
const executionLog: string[] = []
// Mock the stateManager to report hooks enabled
task.getStateManager().getGlobalSettingsKey = sandbox.stub().callsFake((key: string) => {
if (key === "hooksEnabled") {
return true
}
return false
})
const mockPreToolUseRunner = {
run: sandbox.stub().rejects(new Error("Hook execution failed")),
}
// Simulate flow where hook throws error
executionLog.push("Approval")
try {
await mockPreToolUseRunner.run()
executionLog.push("ToolExecution")
} catch (error: any) {
executionLog.push("HookError")
// In real implementation, tool execution should not proceed
}
// Verify error was caught and tool did not execute
expect(executionLog).to.deep.equal(["Approval", "HookError"])
expect(executionLog).to.not.include("ToolExecution")
})
it("should preserve task state after hook execution", async () => {
const task = createMockTask({ hooksEnabled: true })
// Set initial state
task.taskState.currentToolAskMessageTs = 12345
const initialTs = task.taskState.currentToolAskMessageTs
// Mock hook that doesn't modify state
const mockPreToolUseRunner = {
run: sandbox.stub().resolves({ cancel: false }),
}
await mockPreToolUseRunner.run()
// Verify state preserved
expect(task.taskState.currentToolAskMessageTs).to.equal(initialTs)
})
})
@@ -0,0 +1,74 @@
import { expect } from "chai"
import { describe, it } from "mocha"
import { createMockTask } from "./test-utils"
describe("Task.say() message timestamp", () => {
it("should return timestamp when completing a partial message", async () => {
// Setup task with minimal config
const task = createMockTask()
// Mock the message state to track partial messages
const messages: any[] = []
task.messageStateHandler.getClineMessages = () => messages
task.messageStateHandler.addToClineMessages = async (msg: any) => {
messages.push(msg)
}
// Send partial message
const partialTs = await task.say("text", "Hello", undefined, undefined, true)
expect(partialTs).to.be.greaterThan(0)
// Complete the partial message - CRITICAL: should return timestamp
const completeTs = await task.say("text", "Hello World", undefined, undefined, false)
expect(completeTs).to.not.be.undefined
expect(completeTs).to.equal(partialTs) // Should be same timestamp
})
it("should return timestamp for new non-partial messages", async () => {
const task = createMockTask()
// Mock the message state
const messages: any[] = []
task.messageStateHandler.getClineMessages = () => messages
task.messageStateHandler.addToClineMessages = async (msg: any) => {
messages.push(msg)
}
const ts = await task.say("text", "Hello")
expect(ts).to.be.greaterThan(0)
})
it("should return undefined when updating existing partial message", async () => {
const task = createMockTask()
// Setup message state with an existing partial message
const existingMessage = {
ts: Date.now(),
type: "say",
say: "text",
text: "Hello",
partial: true,
}
const messages: any[] = [existingMessage]
task.messageStateHandler.getClineMessages = () => messages
// Update the partial message - should return undefined
const ts = await task.say("text", "Hello World", undefined, undefined, true)
expect(ts).to.be.undefined
})
it("should return new timestamp for new partial message", async () => {
const task = createMockTask()
// Setup empty message state
const messages: any[] = []
task.messageStateHandler.getClineMessages = () => messages
task.messageStateHandler.addToClineMessages = async (msg: any) => {
messages.push(msg)
}
// Create new partial message - should return timestamp
const ts = await task.say("text", "Hello", undefined, undefined, true)
expect(ts).to.be.greaterThan(0)
})
})
+343
View File
@@ -0,0 +1,343 @@
import { ClineDefaultTool } from "@shared/tools"
import sinon from "sinon"
import * as vscode from "vscode"
import { ToolUse } from "../../assistant-message"
import { Task } from "../index"
import { TaskState } from "../TaskState"
import { ToolExecutor } from "../ToolExecutor"
/**
* Creates a set of message handling functions that share state context
* This reduces parameter passing and keeps related functionality together
*/
function createMessageHandlers(taskState: TaskState, messageStateHandler: any) {
const isUpdatingPartialMessage = (type: any): boolean => {
const lastMessage = messageStateHandler.getClineMessages().at(-1)
return Boolean(lastMessage?.partial && lastMessage?.type === "say" && lastMessage?.say === type)
}
const updatePartialMessage = (text?: string, images?: string[], files?: string[]): void => {
const lastMessage = messageStateHandler.getClineMessages().at(-1)
lastMessage.text = text
lastMessage.images = images
lastMessage.files = files
lastMessage.partial = false
}
const createNewMessage = async (
type: any,
text?: string,
images?: string[],
files?: string[],
partial?: boolean,
): Promise<number> => {
const sayTs = Date.now()
taskState.lastMessageTs = sayTs
await messageStateHandler.addToClineMessages({
ts: sayTs,
type: "say",
say: type,
text,
images,
files,
...(partial && { partial }),
})
return sayTs
}
const completePartialMessage = async (text?: string, images?: string[], files?: string[]): Promise<number> => {
const lastMessage = messageStateHandler.getClineMessages().at(-1)
taskState.lastMessageTs = lastMessage.ts
updatePartialMessage(text, images, files)
await messageStateHandler.saveClineMessagesAndUpdateHistory()
return lastMessage.ts
}
return {
isUpdatingPartialMessage,
createNewMessage,
completePartialMessage,
}
}
/**
* Creates a mock say method that properly handles partial message logic
*/
function createMockSayMethod(taskState: TaskState, messageStateHandler: any) {
const handlers = createMessageHandlers(taskState, messageStateHandler)
return async (
type: any,
text?: string,
images?: string[],
files?: string[],
partial?: boolean,
): Promise<number | undefined> => {
// Handle undefined partial flag as a non-partial message
if (partial === undefined) {
return handlers.createNewMessage(type, text, images, files)
}
const isUpdating = handlers.isUpdatingPartialMessage(type)
// Handle partial=true: either update existing or create new partial message
if (partial) {
if (isUpdating) {
const lastMessage = messageStateHandler.getClineMessages().at(-1)
lastMessage.text = text
lastMessage.images = images
lastMessage.files = files
lastMessage.partial = true
return undefined
}
return handlers.createNewMessage(type, text, images, files, true)
}
// Handle partial=false: either complete existing partial or create new complete message
if (isUpdating) {
return handlers.completePartialMessage(text, images, files)
}
return handlers.createNewMessage(type, text, images, files)
}
}
/**
* Creates a mock startOrUpdatePartialMessage method for streaming messages
*/
function createMockStartOrUpdatePartialMessageMethod(taskState: TaskState, messageStateHandler: any) {
const handlers = createMessageHandlers(taskState, messageStateHandler)
return async (type: any, text?: string, images?: string[], files?: string[]): Promise<number | undefined> => {
const lastMessage = messageStateHandler.getClineMessages().at(-1)
const isUpdatingPreviousPartial = lastMessage?.partial && lastMessage?.type === "say" && lastMessage?.say === type
if (isUpdatingPreviousPartial) {
lastMessage.text = text
lastMessage.images = images
lastMessage.files = files
lastMessage.partial = true
return undefined
}
return handlers.createNewMessage(type, text, images, files, true)
}
}
/**
* Creates a mock completePartialMessage method for completing streaming messages
*/
function createMockCompletePartialMessageMethod(taskState: TaskState, messageStateHandler: any) {
const handlers = createMessageHandlers(taskState, messageStateHandler)
return async (type: any, text?: string, images?: string[], files?: string[]): Promise<number> => {
if (handlers.isUpdatingPartialMessage(type)) {
return handlers.completePartialMessage(text, images, files)
}
// No previous partial message to complete, create a new complete message
return handlers.createNewMessage(type, text, images, files)
}
}
/**
* Creates a minimal mock Task instance for testing
*/
export function createMockTask(options: any = {}): Task {
const taskState = new TaskState()
const messageStateHandler = {
getClineMessages: sinon.stub().returns([]),
setClineMessages: sinon.stub(),
addToClineMessages: sinon.stub().resolves(),
updateClineMessage: sinon.stub().resolves(),
saveClineMessagesAndUpdateHistory: sinon.stub().resolves(),
getApiConversationHistory: sinon.stub().returns([]),
setApiConversationHistory: sinon.stub(),
addToApiConversationHistory: sinon.stub().resolves(),
overwriteApiConversationHistory: sinon.stub().resolves(),
overwriteClineMessages: sinon.stub().resolves(),
} as any
const mockController = {
context: { globalState: { get: sinon.stub(), update: sinon.stub() } } as any,
mcpHub: {
isConnecting: false,
setNotificationCallback: sinon.stub(),
clearNotificationCallback: sinon.stub(),
} as any,
updateTaskHistory: sinon.stub().resolves([]),
postStateToWebview: sinon.stub().resolves(),
reinitExistingTaskFromId: sinon.stub().resolves(),
cancelTask: sinon.stub().resolves(),
shouldShowBackgroundTerminalSuggestion: sinon.stub().returns(false),
updateBackgroundCommandState: sinon.stub(),
toggleActModeForYoloMode: sinon.stub().resolves(false),
}
const mockStateManager = {
getGlobalSettingsKey: sinon.stub().callsFake((key: string) => {
const defaults: any = {
mode: "act",
hooksEnabled: options.hooksEnabled || false,
enableCheckpointsSetting: false,
strictPlanModeEnabled: false,
yoloModeToggled: false,
maxConsecutiveMistakes: 3,
autoApprovalSettings: {},
browserSettings: {},
focusChainSettings: { enabled: false },
}
return defaults[key]
}),
getApiConfiguration: sinon.stub().returns({
apiProvider: "anthropic",
apiModelId: "claude-4-sonnet-20250514",
}),
getGlobalStateKey: sinon.stub().returns(true),
} as any
// Create a partial Task instance for testing
const task = Object.create(Task.prototype)
task.taskId = "test-task-id"
task.ulid = "test-ulid"
task.taskState = taskState
task.messageStateHandler = messageStateHandler
// Make stateManager accessible for testing
Object.defineProperty(task, "stateManager", {
get: () => mockStateManager,
configurable: true,
})
task.controller = mockController
// Attach mock methods using extracted helper functions
task.say = createMockSayMethod(taskState, messageStateHandler)
task.startOrUpdatePartialMessage = createMockStartOrUpdatePartialMessageMethod(taskState, messageStateHandler)
task.completePartialMessage = createMockCompletePartialMessageMethod(taskState, messageStateHandler)
return task
}
/**
* Creates a minimal mock TaskConfig for handler testing
*/
export function createMockTaskConfig(options: any = {}): any {
const taskState = new TaskState()
return {
taskId: "test-task-id",
ulid: "test-ulid",
context: {} as vscode.ExtensionContext,
mode: options.mode || "act",
strictPlanModeEnabled: false,
yoloModeToggled: false,
vscodeTerminalExecutionMode: "vscodeTerminal",
cwd: "/test/workspace",
workspaceManager: undefined,
isMultiRootEnabled: false,
taskState,
messageState: {
getClineMessages: sinon.stub().returns([]),
addToClineMessages: sinon.stub().resolves(),
} as any,
api: {
getModel: sinon.stub().returns({ id: "claude-4-sonnet-20250514", info: {} }),
} as any,
autoApprovalSettings: {},
autoApprover: {
shouldAutoApproveTool: sinon.stub().returns(false),
shouldAutoApproveToolWithPath: sinon.stub().resolves(false),
} as any,
browserSettings: {},
focusChainSettings: { enabled: false },
services: {
mcpHub: {} as any,
browserSession: {} as any,
urlContentFetcher: {} as any,
diffViewProvider: {
isEditing: false,
reset: sinon.stub().resolves(),
revertChanges: sinon.stub().resolves(),
} as any,
fileContextTracker: {} as any,
clineIgnoreController: {} as any,
contextManager: {} as any,
stateManager: {} as any,
},
callbacks: {
say: sinon.stub().resolves(Date.now()),
ask: sinon.stub().resolves({ response: "yesButtonClicked" }),
saveCheckpoint: sinon.stub().resolves(),
postStateToWebview: sinon.stub().resolves(),
reinitExistingTaskFromId: sinon.stub().resolves(),
cancelTask: sinon.stub().resolves(),
updateTaskHistory: sinon.stub().resolves([]),
executeCommandTool: sinon.stub().resolves([false, ""]),
doesLatestTaskCompletionHaveNewChanges: sinon.stub().resolves(false),
updateFCListFromToolResponse: sinon.stub().resolves(),
sayAndCreateMissingParamError: sinon.stub().resolves(""),
removeLastPartialMessageIfExistsWithType: sinon.stub().resolves(),
shouldAutoApproveTool: sinon.stub().returns(false),
shouldAutoApproveToolWithPath: sinon.stub().resolves(false),
applyLatestBrowserSettings: sinon.stub().resolves(),
switchToActMode: sinon.stub().resolves(false),
},
coordinator: {
execute: sinon.stub().resolves("Success"),
has: sinon.stub().returns(true),
getHandler: sinon.stub().returns(null),
} as any,
preToolUseRunner: options.preToolUseRunner,
}
}
/**
* Creates a mock ToolUse block for testing
*/
export function createMockToolUse(name: ClineDefaultTool | string, params: any): ToolUse {
return {
type: "tool_use",
name: name as ClineDefaultTool,
params,
partial: false,
}
}
/**
* Creates a minimal mock ToolExecutor for testing
*/
export function createMockToolExecutor(): any {
const taskState = new TaskState()
const messageStateHandler = {
getClineMessages: sinon.stub().returns([]),
addToClineMessages: sinon.stub().resolves(),
} as any
const mockStateManager = {
getGlobalSettingsKey: sinon.stub().returns(false),
} as any
const executor = Object.create(ToolExecutor.prototype)
executor.taskState = taskState
executor.messageStateHandler = messageStateHandler
executor.stateManager = mockStateManager
// Add methods needed for testing
executor.asToolConfig = () => createMockTaskConfig()
executor.handleCompleteBlock = async function (block: ToolUse, config: any) {
try {
if (config.preToolUseRunner) {
await config.preToolUseRunner.run()
}
await config.coordinator.execute(config, block)
} finally {
if (config.preToolUseRunner) {
delete config.preToolUseRunner
}
if (this.taskState.currentToolAskMessageTs !== undefined) {
this.taskState.currentToolAskMessageTs = undefined
}
}
}
return executor
}
+10 -1
View File
@@ -237,6 +237,14 @@ export class Task {
// Cache service
private stateManager: StateManager
/**
* Get the state manager instance
* PUBLIC: Exposed for testing purposes
*/
public getStateManager(): StateManager {
return this.stateManager
}
// Message and conversation state
messageStateHandler: MessageStateHandler
@@ -673,6 +681,7 @@ export class Task {
text: this.taskState.askResponseText,
images: this.taskState.askResponseImages,
files: this.taskState.askResponseFiles,
askTs,
}
this.taskState.askResponse = undefined
this.taskState.askResponseText = undefined
@@ -753,7 +762,7 @@ export class Task {
// await this.postStateToWebview()
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage) // more performant than an entire postStateToWebview
return undefined
return lastMessage.ts // Return the timestamp of the completed message
} else {
// this is a new partial=false message, so add it like normal
const sayTs = Date.now()
+95
View File
@@ -216,4 +216,99 @@ export class MessageStateHandler {
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
/**
* Insert a message at a specific index in the clineMessages array
* Used for dynamically reordering messages (e.g., PreToolUse hook messages)
*The entire operation (validate, insert, save) is atomic to prevent races (RC-4)
*/
async insertClineMessageAt(index: number, message: ClineMessage): Promise<void> {
return await this.withStateLock(async () => {
// Validate index (allow inserting at end)
if (index < 0 || index > this.clineMessages.length) {
throw new Error(`Invalid index ${index} for message insertion (array length: ${this.clineMessages.length})`)
}
// Set conversation history metadata (same as addToClineMessages)
message.conversationHistoryIndex = this.apiConversationHistory.length - 1
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
// Insert message at the specified position
this.clineMessages.splice(index, 0, message)
// Save changes and update history
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
/**
* Find message index by timestamp
* Returns -1 if not found
*/
findMessageIndexByTs(ts: number): number {
return this.clineMessages.findIndex((m) => m.ts === ts)
}
/**
* Update message by timestamp (convenience method)
* Returns true if message was found and updated, false otherwise
* The entire operation is atomic to prevent races (RC-4)
*/
async updateClineMessageByTs(ts: number, updates: Partial<ClineMessage>): Promise<boolean> {
return await this.withStateLock(async () => {
const index = this.findMessageIndexByTs(ts)
if (index === -1) {
return false
}
// Apply updates to the message
Object.assign(this.clineMessages[index], updates)
// Save changes and update history
await this.saveClineMessagesAndUpdateHistoryInternal()
return true
})
}
/**
* Insert a message before another message (by timestamp)
* Used for dynamic message ordering (e.g., PreToolUse before tool approval)
* The entire operation is atomic to prevent races (RC-4)
*/
async insertMessageBefore(messageToMoveTs: number, targetMessageTs: number): Promise<void> {
return await this.withStateLock(async () => {
// Find both messages
const messageToMoveIndex = this.findMessageIndexByTs(messageToMoveTs)
const targetMessageIndex = this.findMessageIndexByTs(targetMessageTs)
// Validate both messages exist
if (messageToMoveIndex === -1) {
console.warn(`Message to move with ts ${messageToMoveTs} not found`)
return
}
if (targetMessageIndex === -1) {
console.warn(`Target message with ts ${targetMessageTs} not found`)
return
}
// Remove the message from its current position
const [messageToMove] = this.clineMessages.splice(messageToMoveIndex, 1)
// Recalculate target index (may have shifted if we removed a message before it)
const newTargetIndex = this.findMessageIndexByTs(targetMessageTs)
if (newTargetIndex === -1) {
console.error(`Target message disappeared during move operation`)
// Re-insert at original position to avoid data loss
this.clineMessages.splice(messageToMoveIndex, 0, messageToMove)
return
}
// Insert before the target
this.clineMessages.splice(newTargetIndex, 0, messageToMove)
// Save changes
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
}
@@ -1,13 +1,12 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { ClineAsk, ClineAskUseMcpServer } from "@shared/ExtensionMessage"
import { telemetryService } from "@/services/telemetry"
import { ClineDefaultTool } from "@/shared/tools"
import type { ToolResponse } from "../../index"
import { showNotificationForApproval } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { ToolHookUtils } from "../utils/ToolHookUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
export class AccessMcpResourceHandler implements IFullyManagedTool {
@@ -75,57 +74,29 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
const shouldAutoApprove = config.callbacks.shouldAutoApproveTool(block.name)
if (shouldAutoApprove) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "use_mcp_server")
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
undefined,
block.isNativeToolCall,
)
// Auto-approval flow - Standard pattern for approved tools:
// 1. Clean up partial messages and send the complete tool message
// 2. Record telemetry for the auto-approved tool execution
await ToolResultUtils.cleanupAndSendToolMessage(config, "use_mcp_server", completeMessage)
ToolResultUtils.captureAutoApprovedTool(config, block)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to access ${uri || "unknown resource"} on ${server_name || "unknown server"}`
// Show notification
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("use_mcp_server", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
undefined,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
undefined,
block.isNativeToolCall,
)
}
const result = await ToolResultUtils.executeManualApprovalForMcpOperation(
config,
block,
`Cline wants to access ${uri || "unknown resource"} on ${server_name || "unknown server"}`,
completeMessage,
)
if (result) return result
}
// Run PreToolUse hook after approval
const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
if (!shouldContinue) {
return formatResponse.toolCancelled()
}
// Show MCP request started message
await config.callbacks.say("mcp_server_request_started")
// Execute the MCP resource access
@@ -1,7 +1,6 @@
import { readFile } from "node:fs/promises"
import type { ToolUse } from "@core/assistant-message"
import { resolveWorkspacePath } from "@core/workspace"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import type { ClineSayTool } from "@shared/ExtensionMessage"
import { fileExistsAtPath } from "@utils/fs"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
@@ -18,6 +17,7 @@ import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { type FileOpsResult, FileProviderOperations } from "../utils/FileProviderOperations"
import { PatchParser } from "../utils/PatchParser"
import { PathResolver } from "../utils/PathResolver"
import { ToolHookUtils } from "../utils/ToolHookUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
interface FileChange {
@@ -685,7 +685,10 @@ export class ApplyPatchHandler implements IFullyManagedTool {
if (shouldAutoApprove) {
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
const sayTs = await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// When completing a partial message, say() returns undefined but updates the existing message
// In that case, get the timestamp from the last message
config.taskState.currentToolAskMessageTs = sayTs ?? config.messageState.getClineMessages().at(-1)?.ts
telemetryService.captureToolUsage(
config.ulid,
this.name,
@@ -696,32 +699,48 @@ export class ApplyPatchHandler implements IFullyManagedTool {
undefined,
block.isNativeToolCall,
)
return true
} else {
showNotificationForApproval(`Cline wants to edit '${message.path}'`, config.autoApprovalSettings.enableNotifications)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
const { didApprove, askTs } = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
config.taskState.currentToolAskMessageTs = askTs
if (!didApprove) {
telemetryService.captureToolUsage(
config.ulid,
this.name,
modelId,
providerId,
false,
false,
undefined,
block.isNativeToolCall,
)
return false
}
telemetryService.captureToolUsage(
config.ulid,
this.name,
modelId,
providerId,
false,
true,
undefined,
block.isNativeToolCall,
)
}
showNotificationForApproval(`Cline wants to edit '${message.path}'`, config.autoApprovalSettings.enableNotifications)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
const { response, text, images, files } = await config.callbacks.ask("tool", completeMessage, false)
if (text || images?.length || files?.length) {
const fileContent = files?.length ? await processFilesIntoText(files) : ""
ToolResultUtils.pushAdditionalToolFeedback(config.taskState.userMessageContent, text, images, fileContent)
await config.callbacks.say("user_feedback", text, images, files)
// Run PreToolUse hook after approval
const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
if (!shouldContinue) {
await this.revertChanges()
config.taskState.didRejectTool = true
return false
}
const approved = response === "yesButtonClicked"
config.taskState.didRejectTool = !approved
telemetryService.captureToolUsage(
config.ulid,
this.name,
modelId,
providerId,
false,
approved,
undefined,
block.isNativeToolCall,
)
return approved
return true
}
}
@@ -11,6 +11,7 @@ import type { ToolResponse } from "../../index"
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { ToolHookUtils } from "../utils/ToolHookUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHandler {
@@ -52,6 +53,12 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
config.taskState.consecutiveMistakeCount = 0
// Run PreToolUse hook (no approval needed for attempt_completion, so run it early)
const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
if (!shouldContinue) {
return formatResponse.toolCancelled()
}
// Show notification if enabled
if (config.autoApprovalSettings.enableNotifications) {
showSystemNotification({
@@ -117,7 +124,8 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
}
// complete command message - need to ask for approval
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("command", command, config)
const { didApprove, askTs } = await ToolResultUtils.askApprovalAndPushFeedback("command", command, config)
config.taskState.currentToolAskMessageTs = askTs
if (!didApprove) {
return formatResponse.toolDenied()
}
@@ -7,6 +7,7 @@ import { showNotificationForApproval } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { ToolHookUtils } from "../utils/ToolHookUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
export class BrowserToolHandler implements IFullyManagedTool {
@@ -99,7 +100,12 @@ export class BrowserToolHandler implements IFullyManagedTool {
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "browser_action_launch")
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("browser_action_launch", url, config)
const { didApprove, askTs } = await ToolResultUtils.askApprovalAndPushFeedback(
"browser_action_launch",
url,
config,
)
config.taskState.currentToolAskMessageTs = askTs
if (!didApprove) {
return formatResponse.toolDenied()
}
@@ -167,6 +173,13 @@ export class BrowserToolHandler implements IFullyManagedTool {
}
}
// Run PreToolUse hook after approval but before execution
const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
if (!shouldContinue) {
await config.services.browserSession.closeBrowser()
return formatResponse.toolCancelled()
}
// Handle results based on action type
switch (action) {
case "launch":
@@ -14,6 +14,7 @@ import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { applyModelContentFixes } from "../utils/ModelContentProcessor"
import { ToolHookUtils } from "../utils/ToolHookUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
// Default timeout for commands in yolo mode and background exec mode
@@ -156,7 +157,10 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
if ((!requiresApprovalPerLLM && autoApproveSafe) || (requiresApprovalPerLLM && autoApproveSafe && autoApproveAll)) {
// Auto-approve flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "command")
await config.callbacks.say("command", actualCommand, undefined, undefined, false)
const sayTs = await config.callbacks.say("command", actualCommand, undefined, undefined, false)
// When completing a partial message, say() returns undefined but updates the existing message
// In that case, get the timestamp from the last message
config.taskState.currentToolAskMessageTs = sayTs ?? config.messageState.getClineMessages().at(-1)?.ts
didAutoApprove = true
telemetryService.captureToolUsage(
config.ulid,
@@ -175,11 +179,12 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
config.autoApprovalSettings.enableNotifications,
)
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback(
const { didApprove, askTs } = await ToolResultUtils.askApprovalAndPushFeedback(
"command",
actualCommand + `${autoApproveSafe && requiresApprovalPerLLM ? COMMAND_REQ_APP_STRING : ""}`,
config,
)
config.taskState.currentToolAskMessageTs = askTs
if (!didApprove) {
telemetryService.captureToolUsage(
config.ulid,
@@ -205,6 +210,12 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
)
}
// Run PreToolUse hook after approval
const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
if (!shouldContinue) {
return formatResponse.toolCancelled()
}
// Setup timeout notification for long-running auto-approved commands
let timeoutId: NodeJS.Timeout | undefined
if (didAutoApprove && config.autoApprovalSettings.enableNotifications) {
@@ -3,14 +3,13 @@ import { getWorkspaceBasename, resolveWorkspacePath } from "@core/workspace"
import { parseSourceCodeForDefinitionsTopLevel } from "@services/tree-sitter"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import { formatResponse } from "@/core/prompts/responses"
import { telemetryService } from "@/services/telemetry"
import { ClineDefaultTool } from "@/shared/tools"
import type { ToolResponse } from "../../index"
import { showNotificationForApproval } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { ToolHookUtils } from "../utils/ToolHookUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
@@ -83,55 +82,32 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
const completeMessage = JSON.stringify(sharedMessageProps)
if (await config.callbacks.shouldAutoApproveToolWithPath(block.name, relDirPath)) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
undefined,
block.isNativeToolCall,
)
// Auto-approval flow - Standard pattern for approved tools:
// 1. Clean up partial messages and send the complete tool message
// 2. Record telemetry for the auto-approved tool execution
await ToolResultUtils.cleanupAndSendToolMessage(config, "tool", completeMessage)
ToolResultUtils.captureAutoApprovedTool(config, block)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to analyze code definitions in ${getWorkspaceBasename(absolutePath, "ListCodeDefinitionNamesToolHandler.notification")}`
// Show notification
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
// Manual approval flow - Standard pattern for tools requiring approval:
// 1. Show notification to user
// 2. Clean up any partial messages from the UI
// 3. Ask for approval and handle any user feedback
// 4. Handle approval result with telemetry and return early if denied
ToolResultUtils.showToolNotification(
`Cline wants to analyze code definitions in ${getWorkspaceBasename(absolutePath, "ListCodeDefinitionNamesToolHandler.notification")}`,
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
const { didApprove } = await ToolResultUtils.askToolApproval(config, "tool", completeMessage)
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
undefined,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
undefined,
block.isNativeToolCall,
)
}
const result = ToolResultUtils.handleApprovalResult(didApprove, config, block)
if (result) return result
}
// Run PreToolUse hook after approval
const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
if (!shouldContinue) {
return formatResponse.toolCancelled()
}
return result
@@ -4,14 +4,13 @@ import { formatResponse } from "@core/prompts/responses"
import { getWorkspaceBasename, resolveWorkspacePath } from "@core/workspace"
import { listFiles } from "@services/glob/list-files"
import { arePathsEqual, getReadablePath, isLocatedInWorkspace } from "@utils/path"
import { telemetryService } from "@/services/telemetry"
import { ClineDefaultTool } from "@/shared/tools"
import type { ToolResponse } from "../../index"
import { showNotificationForApproval } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { ToolHookUtils } from "../utils/ToolHookUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
export class ListFilesToolHandler implements IFullyManagedTool {
@@ -100,55 +99,32 @@ export class ListFilesToolHandler implements IFullyManagedTool {
const completeMessage = JSON.stringify(sharedMessageProps)
if (await config.callbacks.shouldAutoApproveToolWithPath(block.name, relDirPath)) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
workspaceContext,
block.isNativeToolCall,
)
// Auto-approval flow - Standard pattern for approved tools:
// 1. Clean up partial messages and send the complete tool message
// 2. Record telemetry for the auto-approved tool execution
await ToolResultUtils.cleanupAndSendToolMessage(config, "tool", completeMessage)
ToolResultUtils.captureAutoApprovedTool(config, block, workspaceContext)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to view directory ${getWorkspaceBasename(absolutePath, "ListFilesToolHandler.notification")}/`
// Show notification
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
// Manual approval flow - Standard pattern for tools requiring approval:
// 1. Show notification to user
// 2. Clean up any partial messages from the UI
// 3. Ask for approval and handle any user feedback
// 4. Handle approval result with telemetry and return early if denied
ToolResultUtils.showToolNotification(
`Cline wants to view directory ${getWorkspaceBasename(absolutePath, "ListFilesToolHandler.notification")}/`,
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
const { didApprove } = await ToolResultUtils.askToolApproval(config, "tool", completeMessage)
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
workspaceContext,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
workspaceContext,
block.isNativeToolCall,
)
}
const result = ToolResultUtils.handleApprovalResult(didApprove, config, block, workspaceContext)
if (result) return result
}
// Run PreToolUse hook after approval
const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
if (!shouldContinue) {
return formatResponse.toolCancelled()
}
return result
@@ -4,15 +4,14 @@ import { formatResponse } from "@core/prompts/responses"
import { getWorkspaceBasename, resolveWorkspacePath } from "@core/workspace"
import { extractFileContent } from "@integrations/misc/extract-file-content"
import { arePathsEqual, getReadablePath, isLocatedInWorkspace } from "@utils/path"
import { telemetryService } from "@/services/telemetry"
import { ClineSayTool } from "@/shared/ExtensionMessage"
import { ClineDefaultTool } from "@/shared/tools"
import type { ToolResponse } from "../../index"
import { showNotificationForApproval } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { ToolHookUtils } from "../utils/ToolHookUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
export class ReadFileToolHandler implements IFullyManagedTool {
@@ -98,55 +97,32 @@ export class ReadFileToolHandler implements IFullyManagedTool {
const completeMessage = JSON.stringify(sharedMessageProps)
if (await config.callbacks.shouldAutoApproveToolWithPath(block.name, relPath)) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
workspaceContext,
block.isNativeToolCall,
)
// Auto-approval flow - Standard pattern for approved tools:
// 1. Clean up partial messages and send the complete tool message
// 2. Record telemetry for the auto-approved tool execution
await ToolResultUtils.cleanupAndSendToolMessage(config, "tool", completeMessage)
ToolResultUtils.captureAutoApprovedTool(config, block, workspaceContext)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to read ${getWorkspaceBasename(absolutePath, "ReadFileToolHandler.notification")}`
// Show notification
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
// Manual approval flow - Standard pattern for tools requiring approval:
// 1. Show notification to user
// 2. Clean up any partial messages from the UI
// 3. Ask for approval and handle any user feedback
// 4. Handle approval result with telemetry and return early if denied
ToolResultUtils.showToolNotification(
`Cline wants to read ${getWorkspaceBasename(absolutePath, "ReadFileToolHandler.notification")}`,
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
const { didApprove } = await ToolResultUtils.askToolApproval(config, "tool", completeMessage)
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
workspaceContext,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
workspaceContext,
block.isNativeToolCall,
)
}
const result = ToolResultUtils.handleApprovalResult(didApprove, config, block, workspaceContext)
if (result) return result
}
// Run PreToolUse hook after approval
const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
if (!shouldContinue) {
return formatResponse.toolCancelled()
}
// Execute the actual file read operation
@@ -10,11 +10,11 @@ import { telemetryService } from "@/services/telemetry"
import { ClineSayTool } from "@/shared/ExtensionMessage"
import { ClineDefaultTool } from "@/shared/tools"
import type { ToolResponse } from "../../index"
import { showNotificationForApproval } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { ToolHookUtils } from "../utils/ToolHookUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
export class SearchFilesToolHandler implements IFullyManagedTool {
@@ -306,55 +306,32 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
const completeMessage = JSON.stringify(sharedMessageProps)
if (await config.callbacks.shouldAutoApproveToolWithPath(block.name, relDirPath)) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
workspaceContext,
block.isNativeToolCall,
)
// Auto-approval flow - Standard pattern for approved tools:
// 1. Clean up partial messages and send the complete tool message
// 2. Record telemetry for the auto-approved tool execution
await ToolResultUtils.cleanupAndSendToolMessage(config, "tool", completeMessage)
ToolResultUtils.captureAutoApprovedTool(config, block, workspaceContext)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to search files for ${regex}`
// Show notification
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
// Manual approval flow - Standard pattern for tools requiring approval:
// 1. Show notification to user
// 2. Clean up any partial messages from the UI
// 3. Ask for approval and handle any user feedback
// 4. Handle approval result with telemetry and return early if denied
ToolResultUtils.showToolNotification(
`Cline wants to search files for ${regex}`,
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
const { didApprove } = await ToolResultUtils.askToolApproval(config, "tool", completeMessage)
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
workspaceContext,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
workspaceContext,
block.isNativeToolCall,
)
}
const result = ToolResultUtils.handleApprovalResult(didApprove, config, block, workspaceContext)
if (result) return result
}
// Run PreToolUse hook after approval
const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
if (!shouldContinue) {
return formatResponse.toolCancelled()
}
return results
@@ -1,13 +1,12 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { ClineAsk, ClineAskUseMcpServer } from "@shared/ExtensionMessage"
import { telemetryService } from "@/services/telemetry"
import { ClineDefaultTool } from "@/shared/tools"
import type { ToolResponse } from "../../index"
import { showNotificationForApproval } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { ToolHookUtils } from "../utils/ToolHookUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
export class UseMcpToolHandler implements IFullyManagedTool {
@@ -90,55 +89,26 @@ export class UseMcpToolHandler implements IFullyManagedTool {
?.server.tools?.find((tool: any) => tool.name === tool_name)?.autoApprove
if (config.callbacks.shouldAutoApproveTool(block.name) && isToolAutoApproved) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "use_mcp_server")
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
undefined,
block.isNativeToolCall,
)
// Auto-approval flow - Standard pattern for approved tools:
// 1. Clean up partial messages and send the complete tool message
// 2. Record telemetry for the auto-approved tool execution
await ToolResultUtils.cleanupAndSendToolMessage(config, "use_mcp_server", completeMessage)
ToolResultUtils.captureAutoApprovedTool(config, block)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to use ${tool_name || "unknown tool"} on ${server_name || "unknown server"}`
const result = await ToolResultUtils.executeManualApprovalForMcpOperation(
config,
block,
`Cline wants to use ${tool_name || "unknown tool"} on ${server_name || "unknown server"}`,
completeMessage,
)
if (result) return result
}
// Show notification
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("use_mcp_server", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
undefined,
block.isNativeToolCall,
)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
undefined,
block.isNativeToolCall,
)
}
// Run PreToolUse hook after approval
const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
if (!shouldContinue) {
return formatResponse.toolCancelled()
}
// Show MCP request started message
@@ -9,6 +9,7 @@ import { showNotificationForApproval } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { ToolHookUtils } from "../utils/ToolHookUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
export class WebFetchToolHandler implements IFullyManagedTool {
@@ -63,7 +64,10 @@ export class WebFetchToolHandler implements IFullyManagedTool {
if (config.callbacks.shouldAutoApproveTool(this.name)) {
// Auto-approve flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
const sayTs = await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// When completing a partial message, say() returns undefined but updates the existing message
// In that case, get the timestamp from the last message
config.taskState.currentToolAskMessageTs = sayTs ?? config.messageState.getClineMessages().at(-1)?.ts
telemetryService.captureToolUsage(
config.ulid,
"web_fetch",
@@ -82,7 +86,8 @@ export class WebFetchToolHandler implements IFullyManagedTool {
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
const { didApprove, askTs } = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
config.taskState.currentToolAskMessageTs = askTs
if (!didApprove) {
telemetryService.captureToolUsage(
config.ulid,
@@ -109,6 +114,12 @@ export class WebFetchToolHandler implements IFullyManagedTool {
}
}
// Run PreToolUse hook after approval
const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
if (!shouldContinue) {
return formatResponse.toolCancelled()
}
// Execute the actual fetch
const urlContentFetcher = config.services?.urlContentFetcher as UrlContentFetcher
@@ -18,6 +18,7 @@ import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { applyModelContentFixes } from "../utils/ModelContentProcessor"
import { ToolDisplayUtils } from "../utils/ToolDisplayUtils"
import { ToolHookUtils } from "../utils/ToolHookUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
export class WriteToFileToolHandler implements IFullyManagedTool {
@@ -167,21 +168,11 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
} satisfies ClineSayTool)
if (await config.callbacks.shouldAutoApproveToolWithPath(block.name, relPath)) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(
config.ulid,
block.name,
modelId,
providerId,
true,
true,
workspaceContext,
block.isNativeToolCall,
)
// Auto-approval flow - Standard pattern for approved tools:
// 1. Clean up partial messages and send the complete tool message
// 2. Record telemetry for the auto-approved tool execution
await ToolResultUtils.cleanupAndSendToolMessage(config, "tool", completeMessage)
ToolResultUtils.captureAutoApprovedTool(config, block, workspaceContext)
// we need an artificial delay to let the diagnostics catch up to the changes
await setTimeoutPromise(3_500)
@@ -270,6 +261,13 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
}
}
// Run PreToolUse hook after approval
const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
if (!shouldContinue) {
await config.services.diffViewProvider.revertChanges()
return formatResponse.toolCancelled()
}
// Mark the file as edited by Cline
config.services.fileContextTracker.markFileAsEditedByCline(relPath)
+15
View File
@@ -22,6 +22,14 @@ import type { AutoApprove } from "../../tools/autoApprove"
import type { ToolExecutorCoordinator } from "../ToolExecutorCoordinator"
import { TASK_CALLBACKS_KEYS, TASK_CONFIG_KEYS, TASK_SERVICES_KEYS } from "../utils/ToolConstants"
/**
* Runner for PreToolUse hook execution.
* Handlers should call run() after approval succeeds but before execution.
*/
export interface PreToolUseRunner {
run(): Promise<void>
}
/**
* Strongly-typed configuration object passed to tool handlers
*/
@@ -59,6 +67,13 @@ export interface TaskConfig {
// Tool coordination
coordinator: ToolExecutorCoordinator
/**
* Optional PreToolUse hook runner.
* When present, handlers must call this after approval but before execution.
* Will throw PreToolUseHookCancellationError if hook requests cancellation.
*/
preToolUseRunner?: PreToolUseRunner
}
/**
@@ -0,0 +1,43 @@
import type { ToolUse } from "@core/assistant-message"
import { PreToolUseHookCancellationError } from "@core/hooks/PreToolUseHookCancellationError"
import type { TaskConfig } from "../types/TaskConfig"
/**
* Utility functions for tool hook execution.
*/
export class ToolHookUtils {
/**
* Runs the PreToolUse hook if enabled.
*
* This should be called by tool handlers after approval succeeds
* but before the actual tool execution begins.
*
* @param config Task configuration containing optional preToolUseRunner
* @param block The tool use block being executed
* @returns true to continue execution, false if hook cancelled
*
* @example
* // After approval logic
* const shouldContinue = await ToolHookUtils.runPreToolUseIfEnabled(config, block)
* if (!shouldContinue) {
* return formatResponse.toolCancelled()
* }
* // Continue with execution...
*/
static async runPreToolUseIfEnabled(config: TaskConfig, block: ToolUse): Promise<boolean> {
if (!config.preToolUseRunner) {
return true // Hooks disabled, continue
}
try {
await config.preToolUseRunner.run()
return true // Hook succeeded, continue
} catch (error) {
if (error instanceof PreToolUseHookCancellationError) {
return false // Hook cancelled, stop
}
// Other errors should propagate
throw error
}
}
}
+158 -6
View File
@@ -2,9 +2,11 @@ import { ApiHandler } from "@core/api"
import { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { ToolResponse } from "@core/task"
import { processFilesIntoText } from "@/integrations/misc/extract-text"
import { Logger } from "@/services/logging/Logger"
import { ClineAsk } from "@/shared/ExtensionMessage"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { Logger } from "@services/logging/Logger"
import { telemetryService } from "@services/telemetry"
import { ClineAsk } from "@shared/ExtensionMessage"
import { showNotificationForApproval } from "../../utils"
import type { ToolExecutorCoordinator } from "../ToolExecutorCoordinator"
import { TaskConfig } from "../types/TaskConfig"
@@ -116,9 +118,16 @@ export class ToolResultUtils {
/**
* Handles tool approval flow and processes any user feedback
* Returns approval status and the timestamp of the tool ask message (for hook ordering)
*/
static async askApprovalAndPushFeedback(type: ClineAsk, completeMessage: string, config: TaskConfig) {
static async askApprovalAndPushFeedback(
type: ClineAsk,
completeMessage: string,
config: TaskConfig,
): Promise<{ didApprove: boolean; askTs?: number }> {
const { response, text, images, files } = await config.callbacks.ask(type, completeMessage, false)
// Get the timestamp from the ask message that was just created
const askTs = config.messageState.getClineMessages().at(-1)?.ts
if (text || (images && images.length > 0) || (files && files.length > 0)) {
let fileContentString = ""
@@ -133,10 +142,153 @@ export class ToolResultUtils {
if (response !== "yesButtonClicked") {
// User pressed reject button or responded with a message, which we treat as a rejection
config.taskState.didRejectTool = true // Prevent further tool uses in this message
return false
return { didApprove: false, askTs }
} else {
// User hit the approve button, and may have provided feedback
return true
return { didApprove: true, askTs }
}
}
/**
* Shows a notification for tool approval (respecting user settings)
*/
static showToolNotification(notificationMessage: string, enableNotifications: boolean): void {
showNotificationForApproval(notificationMessage, enableNotifications)
}
/**
* Asks for tool approval and handles user feedback
* Returns approval status and ask timestamp
*/
static async askToolApproval(
config: TaskConfig,
askType: ClineAsk,
completeMessage: string,
): Promise<{ didApprove: boolean; askTs?: number }> {
const { didApprove, askTs } = await ToolResultUtils.askApprovalAndPushFeedback(askType, completeMessage, config)
config.taskState.currentToolAskMessageTs = askTs
return { didApprove, askTs }
}
/**
* Sends a tool approval message and updates the timestamp
*/
static async sendToolMessage(config: TaskConfig, sayType: "tool" | "use_mcp_server", completeMessage: string): Promise<void> {
const sayTs = await config.callbacks.say(sayType, completeMessage, undefined, undefined, false)
// When completing a partial message, say() returns undefined but updates the existing message
config.taskState.currentToolAskMessageTs = sayTs ?? config.messageState.getClineMessages().at(-1)?.ts
}
/**
* Captures telemetry for an auto-approved tool execution
*/
static captureAutoApprovedTool(config: TaskConfig, block: ToolUse, workspaceContext?: any): void {
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true, // autoApproved
true, // approved
workspaceContext,
block.isNativeToolCall,
)
}
/**
* Captures telemetry for a manually approved tool execution
*/
static captureApprovedTool(config: TaskConfig, block: ToolUse, workspaceContext?: any): void {
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false, // autoApproved
true, // approved
workspaceContext,
block.isNativeToolCall,
)
}
/**
* Captures telemetry for a denied tool execution
*/
static captureDeniedTool(config: TaskConfig, block: ToolUse, workspaceContext?: any): void {
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false, // autoApproved
false, // approved
workspaceContext,
block.isNativeToolCall,
)
}
/**
* Helper for the common sub-pattern of cleaning up partial messages and sending the complete message
* Used in auto-approval flows where we convert "ask" messages to "say" messages
*/
static async cleanupAndSendToolMessage(
config: TaskConfig,
messageType: "tool" | "use_mcp_server",
completeMessage: string,
): Promise<void> {
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", messageType)
await ToolResultUtils.sendToolMessage(config, messageType, completeMessage)
}
/**
* Helper for the common sub-pattern of handling approval results with telemetry
* Returns the appropriate response if denied, or undefined if approved (to continue execution)
*/
static handleApprovalResult(
didApprove: boolean,
config: TaskConfig,
block: ToolUse,
workspaceContext?: any,
): ToolResponse | undefined {
if (!didApprove) {
ToolResultUtils.captureDeniedTool(config, block, workspaceContext)
return formatResponse.toolDenied()
}
ToolResultUtils.captureApprovedTool(config, block, workspaceContext)
return undefined
}
/**
* Executes manual approval flow for MCP operations (both tools and resources).
* Handles notification, cleanup, approval, and telemetry for MCP server interactions.
*
* @param config Task configuration
* @param block The tool use block being executed
* @param notificationMessage The full notification message to display to the user
* @param completeMessage The complete message to send for approval
* @returns ToolResponse if denied (to return early), or undefined if approved (to continue execution)
*/
static async executeManualApprovalForMcpOperation(
config: TaskConfig,
block: ToolUse,
notificationMessage: string,
completeMessage: string,
): Promise<ToolResponse | undefined> {
ToolResultUtils.showToolNotification(notificationMessage, config.autoApprovalSettings.enableNotifications)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
const { didApprove } = await ToolResultUtils.askToolApproval(config, "use_mcp_server", completeMessage)
return ToolResultUtils.handleApprovalResult(didApprove, config, block)
}
}
+18 -7
View File
@@ -148,19 +148,29 @@ function combineAllHooks(messages: ClineMessage[]): ClineMessage[] {
// ============================================================================
/**
* Finds the timestamp of the next tool/command after a given index.
* Finds the timestamp of the tool immediately after a PreToolUse hook.
*
* Searches in the original messages array (not filtered) to catch tools
* that might still be partial. This ensures PreToolUse hooks are matched
* immediately even if their tool hasn't fully arrived yet.
* CRITICAL: Only searches within a small window (next 5 messages) to prevent
* completed hooks from being re-associated with later tools during UI re-renders.
*
* This ensures PreToolUse hooks stay permanently associated with their original
* tool, even when new tools appear later in the conversation.
*
* @param hookIndex The starting index to search from
* @param messages The original messages array (may include partial tools)
* @param hookToolName Optional tool name from hook metadata for validation
* @returns The timestamp of the next tool, or null if none found
*/
function findNextToolTimestamp(hookIndex: number, messages: ClineMessage[]): number | null {
for (let i = hookIndex + 1; i < messages.length; i++) {
function findNextToolTimestamp(hookIndex: number, messages: ClineMessage[], hookToolName?: string): number | null {
// Only search within a small window (next 5 messages) after the hook
// This prevents re-association with distant future tools
const searchLimit = Math.min(hookIndex + 6, messages.length)
for (let i = hookIndex + 1; i < searchLimit; i++) {
if (isToolOrCommandMessage(messages[i])) {
// If hook has toolName metadata, verify it matches (for validation)
// Note: We can't strictly enforce this since tool messages don't have
// a toolName field, but we can log warnings for debugging
return messages[i].ts
}
}
@@ -200,7 +210,8 @@ function buildPreToolUseMap(processedMessages: ClineMessage[], originalMessages:
}
// Find the next tool after this hook in the original array
const toolTimestamp = findNextToolTimestamp(hookIndexInOriginal, originalMessages)
// Pass toolName for validation (helps with debugging)
const toolTimestamp = findNextToolTimestamp(hookIndexInOriginal, originalMessages, metadata.toolName)
if (toolTimestamp === null) {
// No tool found - hook will stay in original position
continue