mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40df78aa97 | |||
| a1faa6b2aa | |||
| 7345906e88 | |||
| 3777c55ed2 | |||
| d731360780 | |||
| efa70e3e60 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user