mirror of
https://github.com/cline/cline.git
synced 2026-09-06 20:41:02 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b351989e5 | |||
| c78c1fe683 | |||
| e7ccfd1ca9 | |||
| d8573e196e | |||
| eea6af7571 | |||
| aaa2d1629b | |||
| dd89188a3b | |||
| c25dfdd797 | |||
| 611f2ff7bf | |||
| de252a8e2b | |||
| 98caaefe1d | |||
| 6bf15fa401 | |||
| 5c18331cc2 | |||
| 6fc7807184 | |||
| 3b57b14674 | |||
| beefb6700d | |||
| 517a055bc2 | |||
| 3536b7aa36 | |||
| 64633fb34a | |||
| e9b426d464 | |||
| e52edf090a | |||
| ccc796bb45 | |||
| b3e61b3894 | |||
| 50b623fada | |||
| 764ad94826 | |||
| ef85c1af01 | |||
| deb179789f | |||
| db778ac247 | |||
| 37cd78a016 | |||
| b45fc74106 | |||
| 2869ae4821 | |||
| 4c5bd571a0 | |||
| 67f721abd3 | |||
| f97efb05d9 | |||
| 3053c9e33d | |||
| 970aa02519 | |||
| 314e76c2fd | |||
| 3c306a2db6 | |||
| a6f0a0f929 | |||
| 7d25dc379e | |||
| 77c3d5c799 | |||
| 6859a89786 | |||
| 3f277b7260 | |||
| 24f1e0b0d5 | |||
| c0ac1f8cf6 | |||
| 6adcb7c8cc | |||
| 7b514bb65f |
@@ -3,7 +3,7 @@ import { Empty, Int64Request } from "@shared/proto/cline/common"
|
||||
|
||||
export async function checkpointDiff(controller: Controller, request: Int64Request): Promise<Empty> {
|
||||
if (request.value) {
|
||||
await controller.task?.presentMultifileDiff(request.value, false)
|
||||
await controller.task?.checkpointManager?.presentMultifileDiff(request.value, false)
|
||||
}
|
||||
return Empty
|
||||
return Empty.create()
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@ export async function checkpointRestore(controller: Controller, request: Checkpo
|
||||
})
|
||||
|
||||
// NOTE: cancelTask awaits abortTask, which awaits diffViewProvider.revertChanges, which reverts any edited files, allowing us to reset to a checkpoint rather than running into a state where the revertChanges function is called alongside or after the checkpoint reset
|
||||
await controller.task?.restoreCheckpoint(request.number, request.restoreType as ClineCheckpointRestore, request.offset)
|
||||
await controller.task?.checkpointManager?.restoreCheckpoint(
|
||||
request.number,
|
||||
request.restoreType as ClineCheckpointRestore,
|
||||
request.offset,
|
||||
)
|
||||
}
|
||||
return Empty.create({})
|
||||
}
|
||||
|
||||
@@ -687,7 +687,7 @@ export class Controller {
|
||||
const workflowToggles = this.cacheService.getWorkspaceStateKey("workflowToggles")
|
||||
|
||||
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
|
||||
const checkpointTrackerErrorMessage = this.task?.taskState.checkpointTrackerErrorMessage
|
||||
const checkpointManagerErrorMessage = this.task?.taskState.checkpointManagerErrorMessage
|
||||
const clineMessages = this.task?.messageStateHandler.getClineMessages() || []
|
||||
|
||||
const processedTaskHistory = (taskHistory || [])
|
||||
@@ -707,12 +707,12 @@ export class Controller {
|
||||
apiConfiguration,
|
||||
uriScheme,
|
||||
currentTaskItem,
|
||||
checkpointTrackerErrorMessage,
|
||||
checkpointManagerErrorMessage: this.task?.taskState.checkpointManagerErrorMessage,
|
||||
clineMessages,
|
||||
currentFocusChainChecklist: this.task?.taskState.currentFocusChainChecklist || null,
|
||||
taskHistory: processedTaskHistory,
|
||||
shouldShowAnnouncement,
|
||||
platform,
|
||||
platform: process.platform as Platform,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
focusChainSettings,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Int64Request } from "@shared/proto/cline/common"
|
||||
export async function taskCompletionViewChanges(controller: Controller, request: Int64Request): Promise<Empty> {
|
||||
try {
|
||||
if (request.value && controller.task) {
|
||||
await controller.task.presentMultifileDiff(request.value, true)
|
||||
await controller.task.checkpointManager?.presentMultifileDiff(request.value, true)
|
||||
}
|
||||
return Empty.create()
|
||||
} catch (error) {
|
||||
|
||||
@@ -43,7 +43,7 @@ export class TaskState {
|
||||
// Error tracking
|
||||
consecutiveMistakeCount: number = 0
|
||||
didAutomaticallyRetryFailedApiRequest = false
|
||||
checkpointTrackerErrorMessage?: string
|
||||
checkpointManagerErrorMessage?: string
|
||||
|
||||
// Task Initialization
|
||||
isInitialized = false
|
||||
|
||||
+88
-2150
File diff suppressed because it is too large
Load Diff
+92
-511
@@ -21,7 +21,6 @@ import {
|
||||
refreshExternalRulesToggles,
|
||||
} from "@core/context/instructions/user-instructions/external-rules"
|
||||
import { sendPartialMessageEvent } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import { sendRelinquishControlEvent } from "@core/controller/ui/subscribeToRelinquishControl"
|
||||
import { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
|
||||
import { parseMentions } from "@core/mentions"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
@@ -83,6 +82,9 @@ import { ToolExecutor } from "./ToolExecutor"
|
||||
import { updateApiReqMsg } from "./utils"
|
||||
import { FocusChainManager } from "./focus-chain"
|
||||
import { summarizeTask } from "@core/prompts/contextManagement"
|
||||
import { TaskCheckpointManager, createTaskCheckpointManager } from "@integrations/checkpoints"
|
||||
|
||||
export const USE_EXPERIMENTAL_CLAUDE4_FEATURES = false
|
||||
|
||||
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
|
||||
type UserContent = Array<Anthropic.ContentBlockParam>
|
||||
@@ -110,7 +112,7 @@ export class Task {
|
||||
browserSession: BrowserSession
|
||||
contextManager: ContextManager
|
||||
private diffViewProvider: DiffViewProvider
|
||||
private checkpointTracker?: CheckpointTracker
|
||||
public checkpointManager?: TaskCheckpointManager
|
||||
private clineIgnoreController: ClineIgnoreController
|
||||
private toolExecutor: ToolExecutor
|
||||
|
||||
@@ -219,8 +221,8 @@ export class Task {
|
||||
this.ulid = historyItem.ulid ?? ulid()
|
||||
this.taskIsFavorited = historyItem.isFavorited
|
||||
this.taskState.conversationHistoryDeletedRange = historyItem.conversationHistoryDeletedRange
|
||||
if (historyItem.checkpointTrackerErrorMessage) {
|
||||
this.taskState.checkpointTrackerErrorMessage = historyItem.checkpointTrackerErrorMessage
|
||||
if (historyItem.checkpointManagerErrorMessage) {
|
||||
this.taskState.checkpointManagerErrorMessage = historyItem.checkpointManagerErrorMessage
|
||||
}
|
||||
} else if (task || images || files) {
|
||||
this.taskId = Date.now().toString()
|
||||
@@ -238,6 +240,45 @@ export class Task {
|
||||
updateTaskHistory: this.updateTaskHistory,
|
||||
})
|
||||
|
||||
// Initialize file context tracker
|
||||
this.fileContextTracker = new FileContextTracker(this.controller, this.taskId)
|
||||
this.modelContextTracker = new ModelContextTracker(this.controller.context, this.taskId)
|
||||
|
||||
// Initialize checkpoint manager
|
||||
try {
|
||||
this.checkpointManager = createTaskCheckpointManager(
|
||||
{
|
||||
taskId: this.taskId,
|
||||
},
|
||||
{
|
||||
enableCheckpoints: enableCheckpointsSetting,
|
||||
},
|
||||
{
|
||||
context: this.controller.context,
|
||||
diffViewProvider: this.diffViewProvider,
|
||||
messageStateHandler: this.messageStateHandler,
|
||||
fileContextTracker: this.fileContextTracker,
|
||||
taskState: this.taskState,
|
||||
},
|
||||
{
|
||||
updateTaskHistory: this.updateTaskHistory,
|
||||
say: this.say.bind(this),
|
||||
cancelTask: this.cancelTask,
|
||||
postStateToWebview: this.postStateToWebview,
|
||||
},
|
||||
{
|
||||
conversationHistoryDeletedRange: this.taskState.conversationHistoryDeletedRange,
|
||||
checkpointManagerErrorMessage: this.taskState.checkpointManagerErrorMessage,
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize checkpoint manager:", error)
|
||||
if (enableCheckpointsSetting) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
vscode.window.showErrorMessage(`Failed to initialize checkpoint manager: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize file context tracker
|
||||
this.fileContextTracker = new FileContextTracker(controller, this.taskId)
|
||||
this.modelContextTracker = new ModelContextTracker(controller.context, this.taskId)
|
||||
@@ -251,7 +292,13 @@ export class Task {
|
||||
context: this.getContext(),
|
||||
cacheService: this.cacheService,
|
||||
postStateToWebview: this.postStateToWebview,
|
||||
say: this.say.bind(this),
|
||||
say: this.say.bind(this) as (
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
) => Promise<undefined>,
|
||||
focusChainSettings: this.focusChainSettings,
|
||||
})
|
||||
}
|
||||
@@ -356,11 +403,11 @@ export class Task {
|
||||
strictPlanModeEnabled,
|
||||
this.say.bind(this),
|
||||
this.ask.bind(this),
|
||||
this.saveCheckpoint.bind(this),
|
||||
this.saveCheckpointCallback.bind(this),
|
||||
this.sayAndCreateMissingParamError.bind(this),
|
||||
this.removeLastPartialMessageIfExistsWithType.bind(this),
|
||||
this.executeCommandTool.bind(this),
|
||||
this.doesLatestTaskCompletionHaveNewChanges.bind(this),
|
||||
this.checkLatestTaskCompletionHasNewChanges.bind(this),
|
||||
this.FocusChainManager?.updateFCListFromToolResponse.bind(this.FocusChainManager) || (async () => {}),
|
||||
)
|
||||
}
|
||||
@@ -404,310 +451,6 @@ export class Task {
|
||||
}
|
||||
}
|
||||
|
||||
async restoreCheckpoint(messageTs: number, restoreType: ClineCheckpointRestore, offset?: number) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const messageIndex = clineMessages.findIndex((m) => m.ts === messageTs) - (offset || 0)
|
||||
// Find the last message before messageIndex that has a lastCheckpointHash
|
||||
const lastHashIndex = findLastIndex(clineMessages.slice(0, messageIndex), (m) => m.lastCheckpointHash !== undefined)
|
||||
const message = clineMessages[messageIndex]
|
||||
const lastMessageWithHash = clineMessages[lastHashIndex]
|
||||
|
||||
if (!message) {
|
||||
console.error("Message not found", clineMessages)
|
||||
return
|
||||
}
|
||||
|
||||
let didWorkspaceRestoreFail = false
|
||||
|
||||
switch (restoreType) {
|
||||
case "task":
|
||||
break
|
||||
case "taskAndWorkspace":
|
||||
case "workspace":
|
||||
if (!this.enableCheckpoints) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Checkpoints are disabled in settings.",
|
||||
})
|
||||
didWorkspaceRestoreFail = true
|
||||
break
|
||||
}
|
||||
|
||||
if (!this.checkpointTracker && !this.taskState.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.controller.context.globalStorageUri.fsPath,
|
||||
this.enableCheckpoints,
|
||||
)
|
||||
this.messageStateHandler.setCheckpointTracker(this.checkpointTracker)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
this.taskState.checkpointTrackerErrorMessage = errorMessage
|
||||
await this.postStateToWebview()
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: errorMessage,
|
||||
})
|
||||
didWorkspaceRestoreFail = true
|
||||
}
|
||||
}
|
||||
if (message.lastCheckpointHash && this.checkpointTracker) {
|
||||
try {
|
||||
await this.checkpointTracker.resetHead(message.lastCheckpointHash)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Failed to restore checkpoint: " + errorMessage,
|
||||
})
|
||||
didWorkspaceRestoreFail = true
|
||||
}
|
||||
} else if (offset && lastMessageWithHash.lastCheckpointHash && this.checkpointTracker) {
|
||||
try {
|
||||
await this.checkpointTracker.resetHead(lastMessageWithHash.lastCheckpointHash)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Failed to restore offsetcheckpoint: " + errorMessage,
|
||||
})
|
||||
didWorkspaceRestoreFail = true
|
||||
}
|
||||
} else if (!offset && lastMessageWithHash.lastCheckpointHash && this.checkpointTracker) {
|
||||
// Fallback: restore to most recent checkpoint when target message has no checkpoint hash
|
||||
console.warn(`Message ${messageTs} has no checkpoint hash, falling back to previous checkpoint`)
|
||||
try {
|
||||
await this.checkpointTracker.resetHead(lastMessageWithHash.lastCheckpointHash)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Failed to restore checkpoint: " + errorMessage,
|
||||
})
|
||||
didWorkspaceRestoreFail = true
|
||||
}
|
||||
} else {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Failed to restore checkpoint",
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (!didWorkspaceRestoreFail) {
|
||||
switch (restoreType) {
|
||||
case "task":
|
||||
case "taskAndWorkspace": {
|
||||
this.taskState.conversationHistoryDeletedRange = message.conversationHistoryDeletedRange
|
||||
const apiConversationHistory = this.messageStateHandler.getApiConversationHistory()
|
||||
const newConversationHistory = apiConversationHistory.slice(0, (message.conversationHistoryIndex || 0) + 2) // +1 since this index corresponds to the last user message, and another +1 since slice end index is exclusive
|
||||
await this.messageStateHandler.overwriteApiConversationHistory(newConversationHistory)
|
||||
|
||||
// update the context history state
|
||||
await this.contextManager.truncateContextHistory(
|
||||
message.ts,
|
||||
await ensureTaskDirectoryExists(this.getContext(), this.taskId),
|
||||
)
|
||||
|
||||
// aggregate deleted api reqs info so we don't lose costs/tokens
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const deletedMessages = clineMessages.slice(messageIndex + 1)
|
||||
const deletedApiReqsMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(deletedMessages)))
|
||||
|
||||
// Detect files edited after this message timestamp for file context warning
|
||||
// Only needed for task-only restores when a user edits a message or restores the task context, but not the files.
|
||||
if (restoreType === "task") {
|
||||
const filesEditedAfterMessage = await this.fileContextTracker.detectFilesEditedAfterMessage(
|
||||
messageTs,
|
||||
deletedMessages,
|
||||
)
|
||||
if (filesEditedAfterMessage.length > 0) {
|
||||
await this.fileContextTracker.storePendingFileContextWarning(filesEditedAfterMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const newClineMessages = clineMessages.slice(0, messageIndex + 1)
|
||||
await this.messageStateHandler.overwriteClineMessages(newClineMessages) // calls saveClineMessages which saves historyItem
|
||||
|
||||
await this.say(
|
||||
"deleted_api_reqs",
|
||||
JSON.stringify({
|
||||
tokensIn: deletedApiReqsMetrics.totalTokensIn,
|
||||
tokensOut: deletedApiReqsMetrics.totalTokensOut,
|
||||
cacheWrites: deletedApiReqsMetrics.totalCacheWrites,
|
||||
cacheReads: deletedApiReqsMetrics.totalCacheReads,
|
||||
cost: deletedApiReqsMetrics.totalCost,
|
||||
} satisfies ClineApiReqInfo),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "workspace":
|
||||
break
|
||||
}
|
||||
|
||||
switch (restoreType) {
|
||||
case "task":
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Task messages have been restored to the checkpoint",
|
||||
})
|
||||
break
|
||||
case "workspace":
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Workspace files have been restored to the checkpoint",
|
||||
})
|
||||
break
|
||||
case "taskAndWorkspace":
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Task and workspace have been restored to the checkpoint",
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
if (restoreType !== "task") {
|
||||
// Set isCheckpointCheckedOut flag on the message
|
||||
// Find all checkpoint messages before this one
|
||||
const checkpointMessages = this.messageStateHandler
|
||||
.getClineMessages()
|
||||
.filter((m) => m.say === "checkpoint_created")
|
||||
const currentMessageIndex = checkpointMessages.findIndex((m) => m.ts === messageTs)
|
||||
|
||||
// Set isCheckpointCheckedOut to false for all checkpoint messages
|
||||
checkpointMessages.forEach((m, i) => {
|
||||
m.isCheckpointCheckedOut = i === currentMessageIndex
|
||||
})
|
||||
}
|
||||
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
|
||||
this.cancelTask() // the task is already cancelled by the provider beforehand, but we need to re-init to get the updated messages
|
||||
} else {
|
||||
sendRelinquishControlEvent()
|
||||
}
|
||||
}
|
||||
|
||||
async presentMultifileDiff(messageTs: number, seeNewChangesSinceLastTaskCompletion: boolean) {
|
||||
try {
|
||||
if (!this.enableCheckpoints) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Checkpoints are disabled in settings. Cannot show diff.",
|
||||
})
|
||||
return
|
||||
}
|
||||
// TODO: handle if this is called from outside original workspace, in which case we need to
|
||||
// show user error message we can't show diff outside of workspace?
|
||||
if (!this.checkpointTracker && !this.taskState.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.controller.context.globalStorageUri.fsPath,
|
||||
this.enableCheckpoints,
|
||||
)
|
||||
this.messageStateHandler.setCheckpointTracker(this.checkpointTracker)
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize checkpoint tracker:", error)
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
this.taskState.checkpointTrackerErrorMessage = errorMessage
|
||||
await this.postStateToWebview()
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: errorMessage,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!this.checkpointTracker) {
|
||||
return
|
||||
}
|
||||
|
||||
showChangedFilesDiff(
|
||||
this.messageStateHandler,
|
||||
this.checkpointTracker,
|
||||
messageTs,
|
||||
seeNewChangesSinceLastTaskCompletion,
|
||||
)
|
||||
} finally {
|
||||
sendRelinquishControlEvent()
|
||||
}
|
||||
}
|
||||
|
||||
async doesLatestTaskCompletionHaveNewChanges() {
|
||||
if (!this.enableCheckpoints) {
|
||||
return false
|
||||
}
|
||||
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const messageIndex = findLastIndex(clineMessages, (m) => m.say === "completion_result")
|
||||
const message = clineMessages[messageIndex]
|
||||
if (!message) {
|
||||
console.error("Completion message not found")
|
||||
return false
|
||||
}
|
||||
const hash = message.lastCheckpointHash
|
||||
if (!hash) {
|
||||
console.error("No checkpoint hash found")
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.enableCheckpoints && !this.checkpointTracker && !this.taskState.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.controller.context.globalStorageUri.fsPath,
|
||||
this.enableCheckpoints,
|
||||
)
|
||||
this.messageStateHandler.setCheckpointTracker(this.checkpointTracker)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Get last task completed
|
||||
const lastTaskCompletedMessage = findLast(
|
||||
this.messageStateHandler.getClineMessages().slice(0, messageIndex),
|
||||
(m) => m.say === "completion_result",
|
||||
)
|
||||
|
||||
try {
|
||||
// Get last task completed
|
||||
const lastTaskCompletedMessageCheckpointHash = lastTaskCompletedMessage?.lastCheckpointHash // ask is only used to relinquish control, its the last say we care about
|
||||
// if undefined, then we get diff from beginning of git
|
||||
// if (!lastTaskCompletedMessage) {
|
||||
// console.error("No previous task completion message found")
|
||||
// return
|
||||
// }
|
||||
// This value *should* always exist
|
||||
const firstCheckpointMessageCheckpointHash = this.messageStateHandler
|
||||
.getClineMessages()
|
||||
.find((m) => m.say === "checkpoint_created")?.lastCheckpointHash
|
||||
|
||||
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash // either use the diff between the first checkpoint and the task completion, or the diff between the latest two task completions
|
||||
|
||||
if (!previousCheckpointHash) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get count of changed files between current state and commit
|
||||
const changedFilesCount = (await this.checkpointTracker?.getDiffCount(previousCheckpointHash, hash)) || 0
|
||||
if (changedFilesCount > 0) {
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get diff set:", error)
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Communicate with webview
|
||||
|
||||
// partial has three valid states true (partial message), false (completion of partial message), undefined (individual complete message)
|
||||
@@ -851,7 +594,13 @@ export class Task {
|
||||
this.taskState.askResponseFiles = files
|
||||
}
|
||||
|
||||
async say(type: ClineSay, text?: string, images?: string[], files?: string[], partial?: boolean): Promise<undefined> {
|
||||
async say(
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
): Promise<number | undefined> {
|
||||
if (this.taskState.abort) {
|
||||
throw new Error("Cline instance aborted")
|
||||
}
|
||||
@@ -869,6 +618,7 @@ export class Task {
|
||||
lastMessage.partial = partial
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage)
|
||||
return undefined
|
||||
} else {
|
||||
// this is a new partial message, so add it with partial state
|
||||
const sayTs = Date.now()
|
||||
@@ -883,6 +633,7 @@ export class Task {
|
||||
partial,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
}
|
||||
} else {
|
||||
// partial=false means its a complete version of a previously partial message
|
||||
@@ -900,6 +651,7 @@ export class Task {
|
||||
// await this.postStateToWebview()
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage) // more performant than an entire postStateToWebview
|
||||
return undefined
|
||||
} else {
|
||||
// this is a new partial=false message, so add it like normal
|
||||
const sayTs = Date.now()
|
||||
@@ -913,6 +665,7 @@ export class Task {
|
||||
files,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -928,6 +681,7 @@ export class Task {
|
||||
files,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,6 +704,14 @@ export class Task {
|
||||
}
|
||||
}
|
||||
|
||||
private async saveCheckpointCallback(isAttemptCompletionMessage?: boolean, completionMessageTs?: number): Promise<void> {
|
||||
return this.checkpointManager?.saveCheckpoint(isAttemptCompletionMessage, completionMessageTs) ?? Promise.resolve()
|
||||
}
|
||||
|
||||
private async checkLatestTaskCompletionHasNewChanges(): Promise<boolean> {
|
||||
return this.checkpointManager?.doesLatestTaskCompletionHaveNewChanges() ?? Promise.resolve(false)
|
||||
}
|
||||
|
||||
// Task lifecycle
|
||||
|
||||
private async startTask(task?: string, images?: string[], files?: string[]): Promise<void> {
|
||||
@@ -1000,12 +762,6 @@ export class Task {
|
||||
console.error("Failed to initialize ClineIgnoreController:", error)
|
||||
// Optionally, inform the user or handle the error appropriately
|
||||
}
|
||||
// UPDATE: we don't need this anymore since most tasks are now created with checkpoints enabled
|
||||
// right now we let users init checkpoints for old tasks, assuming they're continuing them from the same workspace (which we never tied to tasks, so no way for us to know if it's opened in the right workspace)
|
||||
// const doesShadowGitExist = await CheckpointTracker.doesShadowGitExist(this.taskId, this.controllerRef.deref())
|
||||
// if (!doesShadowGitExist) {
|
||||
// this.checkpointTrackerErrorMessage = "Checkpoints are only available for new tasks"
|
||||
// }
|
||||
|
||||
const savedClineMessages = await getSavedClineMessages(this.getContext(), this.taskId)
|
||||
|
||||
@@ -1063,9 +819,7 @@ export class Task {
|
||||
let responseFiles: string[] | undefined
|
||||
if (response === "messageResponse") {
|
||||
await this.say("user_feedback", text, images, files)
|
||||
if (!this.taskState.checkpointTrackerErrorMessage?.includes("Checkpoints initialization timed out.")) {
|
||||
await this.saveCheckpoint()
|
||||
}
|
||||
await this.checkpointManager?.saveCheckpoint()
|
||||
responseText = text
|
||||
responseImages = images
|
||||
responseFiles = files
|
||||
@@ -1230,147 +984,6 @@ export class Task {
|
||||
}
|
||||
}
|
||||
|
||||
// Checkpoints
|
||||
|
||||
async saveCheckpoint(isAttemptCompletionMessage: boolean = false) {
|
||||
if (
|
||||
!this.enableCheckpoints ||
|
||||
this.taskState.checkpointTrackerErrorMessage?.includes("Checkpoints initialization timed out.")
|
||||
) {
|
||||
// If checkpoints are disabled or previously encountered a timeout error, do nothing.
|
||||
return
|
||||
}
|
||||
// Set isCheckpointCheckedOut to false for all checkpoint_created messages
|
||||
this.messageStateHandler.getClineMessages().forEach((message) => {
|
||||
if (message.say === "checkpoint_created") {
|
||||
message.isCheckpointCheckedOut = false
|
||||
}
|
||||
})
|
||||
|
||||
if (!isAttemptCompletionMessage) {
|
||||
// ensure we aren't creating a duplicate checkpoint
|
||||
const lastMessage = this.messageStateHandler.getClineMessages().at(-1)
|
||||
if (lastMessage?.say === "checkpoint_created") {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize checkpoint tracker if it doesn't exist
|
||||
if (!this.checkpointTracker && !this.taskState.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.controller.context.globalStorageUri.fsPath,
|
||||
this.enableCheckpoints,
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
this.taskState.checkpointTrackerErrorMessage = errorMessage
|
||||
await this.postStateToWebview()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create a checkpoint commit and update clineMessages with a commitHash
|
||||
if (this.checkpointTracker) {
|
||||
// We are letting this run in a non-blocking way so that the UI doesn't freeze when creating checkpoints.
|
||||
// We show that a checkpoint is created in the chatview, then in the background run the git operation (which can take multiple seconds for large shadow git repos), and once that's been completed update the previous checkpoint message with the newly created hash to be associated with.
|
||||
// NOTE: the attempt completion flow is different in that it requires the latest checkpoint hash to be present before determining if it can present the 'see new changes' button. In ToolExecutor, when we call saveCheckpoint(true), we must make sure that the checkpoint hash is present in the last completion_result message before returning, since it is always followed by a addNewChangesFlagToLastCompletionResultMessage(), which calls doesLatestTaskCompletionHaveNewChanges() that uses the latest message hash to determine if there any changes since the last attempt_completion checkpoint.
|
||||
await this.say("checkpoint_created")
|
||||
this.checkpointTracker.commit().then(async (commitHash) => {
|
||||
if (commitHash) {
|
||||
const lastCheckpointMessageIndex = findLastIndex(
|
||||
this.messageStateHandler.getClineMessages(),
|
||||
(m) => m.say === "checkpoint_created",
|
||||
)
|
||||
if (lastCheckpointMessageIndex !== -1) {
|
||||
await this.messageStateHandler.updateClineMessage(lastCheckpointMessageIndex, {
|
||||
lastCheckpointHash: commitHash,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
} // silently fails for now
|
||||
|
||||
//
|
||||
} else {
|
||||
// attempt completion requires checkpoint to be sync so that we can present button after attempt_completion
|
||||
// Check if checkpoint tracker exists, if not, create it. Skip if there was a previous checkpoints initialization timeout error.
|
||||
if (
|
||||
!this.checkpointTracker &&
|
||||
!this.taskState.checkpointTrackerErrorMessage?.includes("Checkpoints initialization timed out.")
|
||||
) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.controller.context.globalStorageUri.fsPath,
|
||||
this.enableCheckpoints,
|
||||
)
|
||||
this.messageStateHandler.setCheckpointTracker(this.checkpointTracker)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker for attempt completion:", errorMessage)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.checkpointTracker &&
|
||||
!this.taskState.checkpointTrackerErrorMessage?.includes("Checkpoints initialization timed out.")
|
||||
) {
|
||||
const commitHash = await this.checkpointTracker.commit()
|
||||
|
||||
// For attempt_completion, find the last completion_result message and set its checkpoint hash. This will be used to present the 'see new changes' button
|
||||
const lastCompletionResultMessage = findLast(
|
||||
this.messageStateHandler.getClineMessages(),
|
||||
(m) => m.say === "completion_result" || m.ask === "completion_result",
|
||||
)
|
||||
if (lastCompletionResultMessage) {
|
||||
lastCompletionResultMessage.lastCheckpointHash = commitHash
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
} else {
|
||||
console.error("Checkpoint tracker does not exist and could not be initialized for attempt completion")
|
||||
}
|
||||
}
|
||||
|
||||
// if (commitHash) {
|
||||
|
||||
// Previously we checkpointed every message, but this is excessive and unnecessary.
|
||||
// // Start from the end and work backwards until we find a tool use or another message with a hash
|
||||
// for (let i = this.clineMessages.length - 1; i >= 0; i--) {
|
||||
// const message = this.clineMessages[i]
|
||||
// if (message.lastCheckpointHash) {
|
||||
// // Found a message with a hash, so we can stop
|
||||
// break
|
||||
// }
|
||||
// // Update this message with a hash
|
||||
// message.lastCheckpointHash = commitHash
|
||||
|
||||
// // We only care about adding the hash to the last tool use (we don't want to add this hash to every prior message ie for tasks pre-checkpoint)
|
||||
// const isToolUse =
|
||||
// message.say === "tool" ||
|
||||
// message.ask === "tool" ||
|
||||
// message.say === "command" ||
|
||||
// message.ask === "command" ||
|
||||
// message.say === "completion_result" ||
|
||||
// message.ask === "completion_result" ||
|
||||
// message.ask === "followup" ||
|
||||
// message.say === "use_mcp_server" ||
|
||||
// message.ask === "use_mcp_server" ||
|
||||
// message.say === "browser_action" ||
|
||||
// message.say === "browser_action_launch" ||
|
||||
// message.ask === "browser_action_launch"
|
||||
|
||||
// if (isToolUse) {
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// // Save the updated messages
|
||||
// await this.saveClineMessagesAndUpdateHistory()
|
||||
// }
|
||||
}
|
||||
|
||||
// Tools
|
||||
|
||||
/**
|
||||
@@ -1567,7 +1180,7 @@ export class Task {
|
||||
|
||||
if (userFeedback) {
|
||||
await this.say("user_feedback", userFeedback.text, userFeedback.images, userFeedback.files)
|
||||
await this.saveCheckpoint()
|
||||
await this.checkpointManager?.saveCheckpoint()
|
||||
|
||||
let fileContentString = ""
|
||||
if (userFeedback.files && userFeedback.files.length > 0) {
|
||||
@@ -2060,63 +1673,31 @@ export class Task {
|
||||
}),
|
||||
)
|
||||
|
||||
// Initialize checkpoint tracker first if enabled and it's the first request
|
||||
// Initialize checkpointManager first if enabled and it's the first request
|
||||
if (
|
||||
isFirstRequest &&
|
||||
this.enableCheckpoints &&
|
||||
!this.checkpointTracker &&
|
||||
!this.taskState.checkpointTrackerErrorMessage
|
||||
this.checkpointManager && // TODO REVIEW: may be able to implement a replacement for the 15s timer
|
||||
!this.taskState.checkpointManagerErrorMessage
|
||||
) {
|
||||
try {
|
||||
// Warning Timer - If checkpoints take a while to to initialize, show a warning message
|
||||
let checkpointsWarningTimer: NodeJS.Timeout | null = null
|
||||
let checkpointsWarningShown = false
|
||||
|
||||
checkpointsWarningTimer = setTimeout(async () => {
|
||||
if (!checkpointsWarningShown) {
|
||||
checkpointsWarningShown = true
|
||||
this.taskState.checkpointTrackerErrorMessage =
|
||||
"Checkpoints are taking longer than expected to initialize. Working in a large repository? Consider re-opening Cline in a project that uses git, or disabling checkpoints."
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
}, 7_000)
|
||||
|
||||
// Timeout - If checkpoints take too long to initialize, warn user and disable checkpoints for the task
|
||||
this.checkpointTracker = await pTimeout(
|
||||
CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.controller.context.globalStorageUri.fsPath,
|
||||
this.enableCheckpoints,
|
||||
),
|
||||
{
|
||||
milliseconds: 15_000,
|
||||
message:
|
||||
"Checkpoints taking too long to initialize. Consider re-opening Cline in a project that uses git, or disabling checkpoints.",
|
||||
},
|
||||
)
|
||||
if (checkpointsWarningTimer) {
|
||||
clearTimeout(checkpointsWarningTimer)
|
||||
checkpointsWarningTimer = null
|
||||
}
|
||||
await pTimeout(this.checkpointManager.checkpointTrackerCheckAndInit(), {
|
||||
milliseconds: 15_000,
|
||||
message:
|
||||
"Checkpoints taking too long to initialize. Consider re-opening Cline in a project that uses git, or disabling checkpoints.",
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
|
||||
// If the error was a timeout, we disabled all checkpoint operations for the rest of the task
|
||||
if (errorMessage.includes("Checkpoints taking too long to initialize")) {
|
||||
this.taskState.checkpointTrackerErrorMessage =
|
||||
"Checkpoints initialization timed out. Consider re-opening Cline in a project that uses git, or disabling checkpoints."
|
||||
await this.postStateToWebview()
|
||||
} else {
|
||||
this.taskState.checkpointTrackerErrorMessage = errorMessage // will be displayed right away since we saveClineMessages next which posts state to webview
|
||||
}
|
||||
console.error("Failed to initialize checkpoint manager:", errorMessage)
|
||||
this.taskState.checkpointManagerErrorMessage = errorMessage // will be displayed right away since we saveClineMessages next which posts state to webview
|
||||
vscode.window.showErrorMessage(`Checkpoint initialization timed out: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Now, if it's the first request AND checkpoints are enabled AND tracker was successfully initialized,
|
||||
// then say "checkpoint_created" and perform the commit.
|
||||
if (isFirstRequest && this.enableCheckpoints && this.checkpointTracker) {
|
||||
const commitHash = await this.checkpointTracker.commit() // Actual commit
|
||||
if (isFirstRequest && this.enableCheckpoints && this.checkpointManager) {
|
||||
const commitHash = await this.checkpointManager.commit() // Actual commit
|
||||
await this.say("checkpoint_created") // Now this is conditional
|
||||
const lastCheckpointMessageIndex = findLastIndex(
|
||||
this.messageStateHandler.getClineMessages(),
|
||||
@@ -2133,11 +1714,11 @@ export class Task {
|
||||
} else if (
|
||||
isFirstRequest &&
|
||||
this.enableCheckpoints &&
|
||||
!this.checkpointTracker &&
|
||||
this.taskState.checkpointTrackerErrorMessage
|
||||
!this.checkpointManager &&
|
||||
this.taskState.checkpointManagerErrorMessage
|
||||
) {
|
||||
// Checkpoints are enabled, but tracker failed to initialize.
|
||||
// checkpointTrackerErrorMessage is already set and will be part of the state.
|
||||
// checkpointManagerErrorMessage is already set and will be part of the state.
|
||||
// No explicit UI message here, error message will be in ExtensionState.
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ interface MessageStateHandlerParams {
|
||||
taskIsFavorited?: boolean
|
||||
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
|
||||
taskState: TaskState
|
||||
checkpointTrackerErrorMessage?: string
|
||||
checkpointManagerErrorMessage?: string
|
||||
}
|
||||
|
||||
export class MessageStateHandler {
|
||||
@@ -29,7 +29,7 @@ export class MessageStateHandler {
|
||||
private clineMessages: ClineMessage[] = []
|
||||
private taskIsFavorited: boolean
|
||||
private checkpointTracker: CheckpointTracker | undefined
|
||||
private checkpointTrackerErrorMessage: string | undefined
|
||||
private checkpointManagerErrorMessage: string | undefined
|
||||
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
|
||||
private context: vscode.ExtensionContext
|
||||
private taskId: string
|
||||
@@ -43,7 +43,7 @@ export class MessageStateHandler {
|
||||
this.taskState = params.taskState
|
||||
this.taskIsFavorited = params.taskIsFavorited ?? false
|
||||
this.updateTaskHistory = params.updateTaskHistory
|
||||
this.checkpointTrackerErrorMessage = this.taskState.checkpointTrackerErrorMessage
|
||||
this.checkpointManagerErrorMessage = this.taskState.checkpointManagerErrorMessage
|
||||
}
|
||||
|
||||
setCheckpointTracker(tracker: CheckpointTracker | undefined) {
|
||||
@@ -105,7 +105,7 @@ export class MessageStateHandler {
|
||||
cwdOnTaskInitialization: cwd,
|
||||
conversationHistoryDeletedRange: this.taskState.conversationHistoryDeletedRange,
|
||||
isFavorited: this.taskIsFavorited,
|
||||
checkpointTrackerErrorMessage: this.taskState.checkpointTrackerErrorMessage,
|
||||
checkpointManagerErrorMessage: this.taskState.checkpointManagerErrorMessage,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to save cline messages:", error)
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
import * as path from "path"
|
||||
import { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
|
||||
import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import { ToolUse, ToolUseName } from "../../assistant-message"
|
||||
import { ToolExecutorCoordinator } from "./ToolExecutorCoordinator"
|
||||
import { ToolValidator } from "./ToolValidator"
|
||||
import { ToolDisplayUtils } from "./utils/ToolDisplayUtils"
|
||||
import { ToolValidationUtils } from "./utils/ToolValidationUtils"
|
||||
import { ToolMessageUtils } from "./utils/ToolMessageUtils"
|
||||
import { ToolApprovalManager } from "./utils/ToolApprovalManager"
|
||||
import { ToolErrorHandler } from "./utils/ToolErrorHandler"
|
||||
import { ToolExecutionStrategies } from "./utils/ToolExecutionStrategies"
|
||||
import { ListFilesToolHandler } from "./handlers/ListFilesToolHandler"
|
||||
import { ReadFileToolHandler } from "./handlers/ReadFileToolHandler"
|
||||
import { BrowserToolHandler } from "./handlers/BrowserToolHandler"
|
||||
import { AskFollowupQuestionToolHandler } from "./handlers/AskFollowupQuestionToolHandler"
|
||||
import { WebFetchToolHandler } from "./handlers/WebFetchToolHandler"
|
||||
import { WriteToFileToolHandler } from "./handlers/WriteToFileToolHandler"
|
||||
import { ListCodeDefinitionNamesToolHandler } from "./handlers/ListCodeDefinitionNamesToolHandler"
|
||||
import { SearchFilesToolHandler } from "./handlers/SearchFilesToolHandler"
|
||||
import { ExecuteCommandToolHandler } from "./handlers/ExecuteCommandToolHandler"
|
||||
import { UseMcpToolHandler } from "./handlers/UseMcpToolHandler"
|
||||
import { AccessMcpResourceHandler } from "./handlers/AccessMcpResourceHandler"
|
||||
import { LoadMcpDocumentationHandler } from "./handlers/LoadMcpDocumentationHandler"
|
||||
import { PlanModeRespondHandler } from "./handlers/PlanModeRespondHandler"
|
||||
import { NewTaskHandler } from "./handlers/NewTaskHandler"
|
||||
import { AttemptCompletionHandler } from "./handlers/AttemptCompletionHandler"
|
||||
import { CondenseHandler } from "./handlers/CondenseHandler"
|
||||
import { SummarizeTaskHandler } from "./handlers/SummarizeTaskHandler"
|
||||
import { ReportBugHandler } from "./handlers/ReportBugHandler"
|
||||
|
||||
/**
|
||||
* Manages the execution of tools registered with the coordinator.
|
||||
* This class encapsulates all the approval flow, UI updates, telemetry,
|
||||
* and orchestration logic, keeping the main ToolExecutor thin and focused.
|
||||
*/
|
||||
export class ToolExecutionManager {
|
||||
private approvalManager: ToolApprovalManager
|
||||
|
||||
constructor(
|
||||
private coordinator: ToolExecutorCoordinator,
|
||||
private config: any,
|
||||
private pushToolResult: (content: any, block: ToolUse) => void,
|
||||
private removeClosingTag: (block: ToolUse, tag: any, text?: string) => string,
|
||||
private shouldAutoApproveToolWithPath: (toolName: ToolUseName, path?: string) => Promise<boolean>,
|
||||
private sayAndCreateMissingParamError: (toolName: ToolUseName, paramName: string) => Promise<any>,
|
||||
private removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: any) => Promise<void>,
|
||||
private say: (
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
) => Promise<number | undefined>,
|
||||
private ask: (
|
||||
type: ClineAsk,
|
||||
text?: string,
|
||||
partial?: boolean,
|
||||
) => Promise<{
|
||||
response: ClineAskResponse
|
||||
text?: string
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
}>,
|
||||
private askApproval: (type: ClineAsk, block: ToolUse, message: string) => Promise<boolean>,
|
||||
private saveCheckpoint: () => Promise<void>,
|
||||
private updateFCListFromToolResponse: (taskProgress?: string) => Promise<void>,
|
||||
private handleError: (action: string, error: Error, block: ToolUse) => Promise<void>,
|
||||
) {
|
||||
// Initialize the approval manager
|
||||
this.approvalManager = new ToolApprovalManager(
|
||||
config,
|
||||
shouldAutoApproveToolWithPath,
|
||||
removeLastPartialMessageIfExistsWithType,
|
||||
say,
|
||||
ask,
|
||||
askApproval,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a ToolExecutionManager with all tool handlers registered
|
||||
*/
|
||||
static create(
|
||||
config: any,
|
||||
pushToolResult: (content: any, block: ToolUse) => void,
|
||||
shouldAutoApproveToolWithPath: (toolName: ToolUseName, path?: string) => Promise<boolean>,
|
||||
sayAndCreateMissingParamError: (toolName: ToolUseName, paramName: string) => Promise<any>,
|
||||
removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: any) => Promise<void>,
|
||||
say: (
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
) => Promise<number | undefined>,
|
||||
ask: (
|
||||
type: ClineAsk,
|
||||
text?: string,
|
||||
partial?: boolean,
|
||||
) => Promise<{
|
||||
response: ClineAskResponse
|
||||
text?: string
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
}>,
|
||||
askApproval: (type: ClineAsk, block: ToolUse, message: string) => Promise<boolean>,
|
||||
saveCheckpoint: () => Promise<void>,
|
||||
updateFCListFromToolResponse: (taskProgress?: string) => Promise<void>,
|
||||
handleError: (action: string, error: Error, block: ToolUse) => Promise<void>,
|
||||
): ToolExecutionManager {
|
||||
// Create and configure the coordinator
|
||||
const coordinator = new ToolExecutorCoordinator()
|
||||
|
||||
// Register tool handlers
|
||||
const validator = new ToolValidator(config.services.clineIgnoreController)
|
||||
coordinator.register(new ListFilesToolHandler(validator))
|
||||
coordinator.register(new ReadFileToolHandler(validator))
|
||||
coordinator.register(new BrowserToolHandler())
|
||||
coordinator.register(new AskFollowupQuestionToolHandler())
|
||||
coordinator.register(new WebFetchToolHandler())
|
||||
|
||||
// Register WriteToFileToolHandler for all three file tools
|
||||
const writeHandler = new WriteToFileToolHandler(validator)
|
||||
coordinator.register(writeHandler) // registers as "write_to_file"
|
||||
coordinator.register({ name: "replace_in_file", execute: writeHandler.execute.bind(writeHandler) })
|
||||
coordinator.register({ name: "new_rule", execute: writeHandler.execute.bind(writeHandler) })
|
||||
|
||||
coordinator.register(new ListCodeDefinitionNamesToolHandler(validator))
|
||||
coordinator.register(new SearchFilesToolHandler(validator))
|
||||
coordinator.register(new ExecuteCommandToolHandler(validator))
|
||||
coordinator.register(new UseMcpToolHandler())
|
||||
coordinator.register(new AccessMcpResourceHandler())
|
||||
coordinator.register(new LoadMcpDocumentationHandler())
|
||||
coordinator.register(new PlanModeRespondHandler())
|
||||
coordinator.register(new NewTaskHandler())
|
||||
coordinator.register(new AttemptCompletionHandler())
|
||||
coordinator.register(new CondenseHandler())
|
||||
coordinator.register(new SummarizeTaskHandler())
|
||||
coordinator.register(new ReportBugHandler())
|
||||
|
||||
// Create and return the execution manager
|
||||
return new ToolExecutionManager(
|
||||
coordinator,
|
||||
config,
|
||||
pushToolResult,
|
||||
ToolDisplayUtils.removeClosingTag,
|
||||
shouldAutoApproveToolWithPath,
|
||||
sayAndCreateMissingParamError,
|
||||
removeLastPartialMessageIfExistsWithType,
|
||||
say,
|
||||
ask,
|
||||
askApproval,
|
||||
saveCheckpoint,
|
||||
updateFCListFromToolResponse,
|
||||
handleError,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool through the coordinator if it's registered
|
||||
*/
|
||||
async execute(block: ToolUse): Promise<boolean> {
|
||||
if (!this.coordinator.has(block.name)) {
|
||||
return false // Tool not handled by coordinator
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle partial blocks
|
||||
if (block.partial) {
|
||||
await this.handlePartialBlock(block)
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle complete blocks
|
||||
await this.handleCompleteBlock(block)
|
||||
return true
|
||||
} catch (error) {
|
||||
await this.handleError(`executing ${block.name}`, error as Error, block)
|
||||
await this.saveCheckpoint()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle partial block streaming UI updates
|
||||
*/
|
||||
private async handlePartialBlock(block: ToolUse): Promise<void> {
|
||||
// Handle different tools that support partial streaming
|
||||
switch (block.name) {
|
||||
case "read_file":
|
||||
case "list_files":
|
||||
case "list_code_definition_names":
|
||||
case "search_files":
|
||||
await this.handleFileToolPartialBlock(block)
|
||||
break
|
||||
case "write_to_file":
|
||||
case "replace_in_file":
|
||||
case "new_rule":
|
||||
await this.handleWriteToolPartialBlock(block)
|
||||
break
|
||||
case "execute_command":
|
||||
await this.handleCommandPartialBlock(block)
|
||||
break
|
||||
case "use_mcp_tool":
|
||||
case "access_mcp_resource":
|
||||
await this.handleMcpToolPartialBlock(block)
|
||||
break
|
||||
case "load_mcp_documentation":
|
||||
// load_mcp_documentation doesn't support partial streaming
|
||||
return
|
||||
default:
|
||||
// Other tools don't support partial streaming yet
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle partial blocks for file-related tools
|
||||
*/
|
||||
private async handleFileToolPartialBlock(block: ToolUse): Promise<void> {
|
||||
const sharedMessageProps = await ToolMessageUtils.createFileToolMessageProps(
|
||||
block,
|
||||
this.config.cwd,
|
||||
this.removeClosingTag,
|
||||
)
|
||||
|
||||
const partialMessage = JSON.stringify(sharedMessageProps)
|
||||
|
||||
if (await this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
await this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool" as ClineSay, partialMessage, undefined, undefined, block.partial)
|
||||
} else {
|
||||
await this.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
await this.ask("tool" as ClineAsk, partialMessage, block.partial).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle partial blocks for write-related tools
|
||||
*/
|
||||
private async handleWriteToolPartialBlock(block: ToolUse): Promise<void> {
|
||||
const fileExists = this.config.services.diffViewProvider.editType === "modify"
|
||||
const sharedMessageProps = await ToolMessageUtils.createWriteToolMessageProps(
|
||||
block,
|
||||
this.config.cwd,
|
||||
fileExists,
|
||||
this.removeClosingTag,
|
||||
)
|
||||
|
||||
const partialMessage = JSON.stringify(sharedMessageProps)
|
||||
|
||||
if (await this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
await this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool" as ClineSay, partialMessage, undefined, undefined, block.partial)
|
||||
} else {
|
||||
await this.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
await this.ask("tool" as ClineAsk, partialMessage, block.partial).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle partial blocks for command execution
|
||||
*/
|
||||
private async handleCommandPartialBlock(block: ToolUse): Promise<void> {
|
||||
const command = block.params.command
|
||||
|
||||
// For commands, we need to wait for the requires_approval parameter before showing UI
|
||||
// This is because the approval flow depends on that parameter
|
||||
if (!block.params.requires_approval) {
|
||||
return // Wait for complete block
|
||||
}
|
||||
|
||||
// Command partial streaming is handled differently - just show the command
|
||||
const partialCommand = this.removeClosingTag(block, "command", command)
|
||||
|
||||
// Don't auto-approve partial commands - wait for complete block
|
||||
await this.removeLastPartialMessageIfExistsWithType("say", "command")
|
||||
await this.ask("command" as ClineAsk, partialCommand, block.partial).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle partial blocks for MCP tools
|
||||
*/
|
||||
private async handleMcpToolPartialBlock(block: ToolUse): Promise<void> {
|
||||
const partialMessage = JSON.stringify(ToolMessageUtils.createMcpToolMessageProps(block, this.removeClosingTag))
|
||||
|
||||
// MCP tools use a different message type
|
||||
if (this.config.autoApprovalSettings.enabled) {
|
||||
await this.removeLastPartialMessageIfExistsWithType("ask", "use_mcp_server")
|
||||
await this.say("use_mcp_server" as ClineSay, partialMessage, undefined, undefined, block.partial)
|
||||
} else {
|
||||
await this.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
|
||||
await this.ask("use_mcp_server" as ClineAsk, partialMessage, block.partial).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle complete block execution with approval flow
|
||||
*/
|
||||
private async handleCompleteBlock(block: ToolUse): Promise<void> {
|
||||
// Handle different tool types with their specific approval flows
|
||||
switch (block.name) {
|
||||
case "read_file":
|
||||
case "list_files":
|
||||
case "list_code_definition_names":
|
||||
case "search_files":
|
||||
await this.handleFileToolExecution(block)
|
||||
break
|
||||
case "write_to_file":
|
||||
case "replace_in_file":
|
||||
case "new_rule":
|
||||
await this.handleWriteToolExecution(block)
|
||||
break
|
||||
case "execute_command":
|
||||
await this.handleCommandExecution(block)
|
||||
break
|
||||
case "use_mcp_tool":
|
||||
case "access_mcp_resource":
|
||||
await this.handleMcpToolExecution(block)
|
||||
break
|
||||
case "load_mcp_documentation":
|
||||
await this.handleLoadMcpDocumentationExecution(block)
|
||||
break
|
||||
case "plan_mode_respond":
|
||||
case "attempt_completion":
|
||||
case "new_task":
|
||||
await this.handleTaskManagementExecution(block)
|
||||
break
|
||||
case "condense":
|
||||
case "summarize_task":
|
||||
case "report_bug":
|
||||
await this.handleContextAndUtilityExecution(block)
|
||||
break
|
||||
case "ask_followup_question":
|
||||
case "web_fetch":
|
||||
case "browser_action":
|
||||
// These tools have simpler approval flows - just execute and push result
|
||||
await ToolExecutionStrategies.executeSimpleTool(block, this.coordinator, this.config, this.pushToolResult)
|
||||
break
|
||||
default:
|
||||
// For any other tools that might be added, just execute and push result
|
||||
await ToolExecutionStrategies.executeSimpleTool(block, this.coordinator, this.config, this.pushToolResult)
|
||||
break
|
||||
}
|
||||
|
||||
// Handle focus chain updates
|
||||
if (!block.partial && this.config.focusChainSettings.enabled) {
|
||||
await this.updateFCListFromToolResponse(block.params.task_progress)
|
||||
}
|
||||
|
||||
await this.saveCheckpoint()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle execution of file-related tools (read_file, list_files)
|
||||
*/
|
||||
private async handleFileToolExecution(block: ToolUse): Promise<void> {
|
||||
const relPath = block.params.path
|
||||
|
||||
// Execute the tool to get the result (handlers validate params and check clineignore)
|
||||
const result = await this.coordinator.execute(this.config, block)
|
||||
|
||||
// Handle validation errors using the error handler
|
||||
if (
|
||||
await ToolErrorHandler.handleValidationError(
|
||||
block,
|
||||
result,
|
||||
this.config,
|
||||
this.pushToolResult,
|
||||
this.saveCheckpoint,
|
||||
this.sayAndCreateMissingParamError,
|
||||
)
|
||||
) {
|
||||
return // Error was handled
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(this.config.cwd, relPath || "")
|
||||
const tool = ToolDisplayUtils.getToolDisplayName(block)
|
||||
|
||||
// Handle approval flow using the approval manager
|
||||
const approved = await this.approvalManager.handleFileToolApproval(block, relPath || "", absolutePath, tool, result)
|
||||
if (!approved) {
|
||||
await this.saveCheckpoint()
|
||||
return
|
||||
}
|
||||
|
||||
// Tool was approved, push the result
|
||||
this.pushToolResult(result, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle execution of write-related tools (write_to_file, replace_in_file, new_rule)
|
||||
*/
|
||||
private async handleWriteToolExecution(block: ToolUse): Promise<void> {
|
||||
const relPath = block.params.path
|
||||
const content = block.params.content || block.params.diff
|
||||
|
||||
// Validate path parameter using error handler
|
||||
if (
|
||||
await ToolErrorHandler.handleValidationError(
|
||||
block,
|
||||
null, // No result yet, just checking params
|
||||
this.config,
|
||||
this.pushToolResult,
|
||||
this.saveCheckpoint,
|
||||
this.sayAndCreateMissingParamError,
|
||||
)
|
||||
) {
|
||||
return // Error was handled
|
||||
}
|
||||
|
||||
// Check if file exists for UI messaging
|
||||
const absolutePath = path.resolve(this.config.cwd, relPath || "")
|
||||
const fileExists =
|
||||
this.config.services.diffViewProvider.editType === "modify" || (await this.config.services.diffViewProvider.isEditing)
|
||||
? this.config.services.diffViewProvider.editType === "modify"
|
||||
: await require("@utils/fs").fileExistsAtPath(absolutePath)
|
||||
|
||||
// Handle approval flow using the approval manager
|
||||
const approved = await this.approvalManager.handleWriteToolApproval(block, relPath || "", fileExists, content || "")
|
||||
if (!approved) {
|
||||
// Reset diff view if user rejected
|
||||
await ToolErrorHandler.handleDiffViewReset(this.config)
|
||||
return
|
||||
}
|
||||
|
||||
// User approved or auto-approved, now execute the tool
|
||||
const result = await this.coordinator.execute(this.config, block)
|
||||
|
||||
// Check if handler returned an error
|
||||
if (ToolValidationUtils.isValidationError(result)) {
|
||||
this.pushToolResult(result, block)
|
||||
return
|
||||
}
|
||||
|
||||
// Push the successful result
|
||||
this.pushToolResult(result, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle execution of command tool
|
||||
*/
|
||||
private async handleCommandExecution(block: ToolUse): Promise<void> {
|
||||
// Execute the command through the handler
|
||||
const result = await this.coordinator.execute(this.config, block)
|
||||
|
||||
// Check if handler returned an error
|
||||
if (ToolValidationUtils.isValidationError(result)) {
|
||||
this.pushToolResult(result, block)
|
||||
return
|
||||
}
|
||||
|
||||
// For commands, the handler manages the approval flow and execution
|
||||
// The result is already the final formatted response
|
||||
this.pushToolResult(result, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle execution of MCP tools (use_mcp_tool, access_mcp_resource)
|
||||
*/
|
||||
private async handleMcpToolExecution(block: ToolUse): Promise<void> {
|
||||
// Handle approval flow using the approval manager
|
||||
const approved = await this.approvalManager.handleMcpToolApproval(block)
|
||||
if (!approved) {
|
||||
return
|
||||
}
|
||||
|
||||
// Show MCP request started message
|
||||
await this.say("mcp_server_request_started" as ClineSay)
|
||||
|
||||
// Execute the MCP tool through the handler
|
||||
const result = await this.coordinator.execute(this.config, block)
|
||||
|
||||
// Check if handler returned an error
|
||||
if (ToolValidationUtils.isValidationError(result)) {
|
||||
this.pushToolResult(result, block)
|
||||
return
|
||||
}
|
||||
|
||||
// Push the successful result
|
||||
this.pushToolResult(result, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle execution of load_mcp_documentation tool
|
||||
*/
|
||||
private async handleLoadMcpDocumentationExecution(block: ToolUse): Promise<void> {
|
||||
await ToolExecutionStrategies.executeToolWithLoadingMessage(
|
||||
block,
|
||||
this.coordinator,
|
||||
this.config,
|
||||
this.pushToolResult,
|
||||
this.say,
|
||||
"load_mcp_documentation" as ClineSay,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle execution of task management tools (plan_mode_respond, attempt_completion, new_task)
|
||||
*/
|
||||
private async handleTaskManagementExecution(block: ToolUse): Promise<void> {
|
||||
await ToolExecutionStrategies.executeToolWithValidation(block, this.coordinator, this.config, this.pushToolResult)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle execution of context and utility tools (condense, summarize_task, report_bug)
|
||||
*/
|
||||
private async handleContextAndUtilityExecution(block: ToolUse): Promise<void> {
|
||||
await ToolExecutionStrategies.executeToolWithValidation(block, this.coordinator, this.config, this.pushToolResult)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../index"
|
||||
|
||||
export interface IToolHandler {
|
||||
readonly name: string
|
||||
execute(config: any, block: ToolUse): Promise<ToolResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates tool execution by routing to registered handlers.
|
||||
* Falls back to legacy switch for unregistered tools.
|
||||
*/
|
||||
export class ToolExecutorCoordinator {
|
||||
private handlers = new Map<string, IToolHandler>()
|
||||
|
||||
/**
|
||||
* Register a tool handler
|
||||
*/
|
||||
register(handler: IToolHandler): void {
|
||||
this.handlers.set(handler.name, handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a handler is registered for the given tool
|
||||
*/
|
||||
has(toolName: string): boolean {
|
||||
return this.handlers.has(toolName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool through its registered handler
|
||||
*/
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
const handler = this.handlers.get(block.name)
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for tool: ${block.name}`)
|
||||
}
|
||||
return handler.execute(config, block)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ToolUse, ToolParamName } from "@core/assistant-message"
|
||||
import type { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
|
||||
|
||||
export type ValidationResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Lightweight validator used by new tool handlers.
|
||||
* The legacy ToolExecutor switch remains unchanged and does not depend on this.
|
||||
*/
|
||||
export class ToolValidator {
|
||||
constructor(private readonly clineIgnoreController: ClineIgnoreController) {}
|
||||
|
||||
/**
|
||||
* Verifies required parameters exist on the tool block.
|
||||
* Returns a message suitable for displaying in an error.
|
||||
*/
|
||||
assertRequiredParams(block: ToolUse, ...params: ToolParamName[]): ValidationResult {
|
||||
for (const p of params) {
|
||||
// params are stored under block.params using their tag name
|
||||
const val = (block as any)?.params?.[p]
|
||||
if (val === undefined || val === null || String(val).trim() === "") {
|
||||
return { ok: false, error: `Missing required parameter '${p}' for tool '${block.name}'.` }
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies access is allowed to a given path via .clineignore rules.
|
||||
* Callers should pass a repo-relative (workspace-relative) path.
|
||||
*/
|
||||
checkClineIgnorePath(relPath: string): ValidationResult {
|
||||
const accessAllowed = this.clineIgnoreController.validateAccess(relPath)
|
||||
if (!accessAllowed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Access to path '${relPath}' is blocked by .clineignore settings.`,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
|
||||
export class AccessMcpResourceHandler implements IToolHandler {
|
||||
readonly name = "access_mcp_resource"
|
||||
|
||||
constructor() {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const server_name: string | undefined = block.params.server_name
|
||||
const uri: string | undefined = block.params.uri
|
||||
|
||||
// Validate required parameters
|
||||
if (!server_name) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: server_name"
|
||||
}
|
||||
|
||||
if (!uri) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: uri"
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
try {
|
||||
// Execute the MCP resource access
|
||||
const resourceResult = await config.services.mcpHub.readResource(server_name, uri)
|
||||
|
||||
// Process the resource result
|
||||
const resourceResultPretty =
|
||||
resourceResult?.contents
|
||||
.map((item: any) => {
|
||||
if (item.text) {
|
||||
return item.text
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n\n") || "(Empty response)"
|
||||
|
||||
// Display result to user
|
||||
await config.callbacks.say("mcp_server_response", resourceResultPretty)
|
||||
|
||||
// Return formatted result
|
||||
return formatResponse.toolResult(resourceResultPretty)
|
||||
} catch (error) {
|
||||
return `Error accessing MCP resource: ${(error as Error)?.message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ClineAskQuestion } from "@shared/ExtensionMessage"
|
||||
import { parsePartialArrayString, findLast } from "@shared/array"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import { telemetryService } from "@services/posthog/PostHogClientProvider"
|
||||
import { ToolResponse } from "../.."
|
||||
import { ToolUse } from "../../../assistant-message"
|
||||
import { formatResponse } from "../../../prompts/responses"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import { ToolUseName } from "../../../assistant-message"
|
||||
|
||||
export class AskFollowupQuestionToolHandler implements IToolHandler {
|
||||
name = "ask_followup_question"
|
||||
supportedTools: ToolUseName[] = ["ask_followup_question"]
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
const question: string | undefined = block.params.question
|
||||
const optionsRaw: string | undefined = block.params.options
|
||||
|
||||
if (!question) {
|
||||
throw new Error("Question is required for ask_followup_question")
|
||||
}
|
||||
|
||||
// Store the number of options for telemetry
|
||||
const options = parsePartialArrayString(optionsRaw || "[]")
|
||||
|
||||
const sharedMessage = {
|
||||
question: question,
|
||||
options: options,
|
||||
} satisfies ClineAskQuestion
|
||||
|
||||
// Ask the question
|
||||
const {
|
||||
text,
|
||||
images,
|
||||
files: followupFiles,
|
||||
} = await config.callbacks.ask("followup", JSON.stringify(sharedMessage), false)
|
||||
|
||||
// Check if options contains the text response
|
||||
if (optionsRaw && text && options.includes(text)) {
|
||||
telemetryService.captureOptionSelected(config.ulid, options.length, "act")
|
||||
|
||||
// Valid option selected, update last followup message with selected option
|
||||
const clineMessages = config.messageState.getClineMessages()
|
||||
const lastFollowupMessage = findLast(clineMessages, (m: any) => m.ask === "followup")
|
||||
if (lastFollowupMessage) {
|
||||
lastFollowupMessage.text = JSON.stringify({
|
||||
...sharedMessage,
|
||||
selected: text,
|
||||
} satisfies ClineAskQuestion)
|
||||
await config.messageState.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
} else {
|
||||
// Option not selected, send user feedback
|
||||
telemetryService.captureOptionsIgnored(config.ulid, options.length, "act")
|
||||
await config.callbacks.say("user_feedback", text ?? "", images, followupFiles)
|
||||
}
|
||||
|
||||
// Process any attached files
|
||||
let fileContentString = ""
|
||||
if (followupFiles && followupFiles.length > 0) {
|
||||
fileContentString = await processFilesIntoText(followupFiles)
|
||||
}
|
||||
|
||||
return formatResponse.toolResult(`<answer>\n${text}\n</answer>`, images, fileContentString)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { showSystemNotification } from "@integrations/notifications"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import { telemetryService } from "@services/posthog/PostHogClientProvider"
|
||||
import { findLastIndex } from "@shared/array"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { COMPLETION_RESULT_CHANGES_FLAG } from "@shared/ExtensionMessage"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import type Anthropic from "@anthropic-ai/sdk"
|
||||
|
||||
export class AttemptCompletionHandler implements IToolHandler {
|
||||
readonly name = "attempt_completion"
|
||||
|
||||
constructor() {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const result: string | undefined = block.params.result
|
||||
const command: string | undefined = block.params.command
|
||||
|
||||
// Validate required parameters
|
||||
if (!result) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: result"
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Show notification if auto-approval is enabled
|
||||
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
subtitle: "Task Completed",
|
||||
message: result.replace(/\n/g, " "),
|
||||
})
|
||||
}
|
||||
|
||||
const addNewChangesFlagToLastCompletionResultMessage = async () => {
|
||||
// Add newchanges flag if there are new changes to the workspace
|
||||
const hasNewChanges = await config.callbacks.doesLatestTaskCompletionHaveNewChanges()
|
||||
const clineMessages = config.messageState.getClineMessages()
|
||||
|
||||
const lastCompletionResultMessageIndex = findLastIndex(clineMessages, (m: any) => m.say === "completion_result")
|
||||
const lastCompletionResultMessage =
|
||||
lastCompletionResultMessageIndex !== -1 ? clineMessages[lastCompletionResultMessageIndex] : undefined
|
||||
if (
|
||||
lastCompletionResultMessage &&
|
||||
lastCompletionResultMessageIndex !== -1 &&
|
||||
hasNewChanges &&
|
||||
!lastCompletionResultMessage.text?.endsWith(COMPLETION_RESULT_CHANGES_FLAG)
|
||||
) {
|
||||
await config.messageState.updateClineMessage(lastCompletionResultMessageIndex, {
|
||||
text: lastCompletionResultMessage.text + COMPLETION_RESULT_CHANGES_FLAG,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let commandResult: any = undefined
|
||||
const lastMessage = config.messageState.getClineMessages().at(-1)
|
||||
|
||||
if (command) {
|
||||
if (lastMessage && lastMessage.ask !== "command") {
|
||||
// haven't sent a command message yet so first send completion_result then command
|
||||
const completionMessageTs = await config.callbacks.say("completion_result", result, undefined, undefined, false)
|
||||
await config.callbacks.saveCheckpoint(true, completionMessageTs)
|
||||
await addNewChangesFlagToLastCompletionResultMessage()
|
||||
telemetryService.captureTaskCompleted(config.ulid)
|
||||
|
||||
if (config.focusChainSettings.enabled) {
|
||||
await config.callbacks.updateFCListFromToolResponse(block.params.task_progress)
|
||||
}
|
||||
} else {
|
||||
// we already sent a command message, meaning the complete completion message has also been sent
|
||||
await config.callbacks.saveCheckpoint(true)
|
||||
|
||||
if (config.focusChainSettings.enabled) {
|
||||
await config.callbacks.updateFCListFromToolResponse(block.params.task_progress)
|
||||
}
|
||||
}
|
||||
|
||||
// complete command message - need to ask for approval
|
||||
const { response, text, images, files } = await config.callbacks.ask("command", command, false)
|
||||
if (response !== "yesButtonClicked") {
|
||||
// User rejected the command
|
||||
return "The user denied the command execution."
|
||||
}
|
||||
|
||||
// User approved, execute the command
|
||||
const [userRejected, execCommandResult] = await config.callbacks.executeCommandTool(command!)
|
||||
if (userRejected) {
|
||||
config.taskState.didRejectTool = true
|
||||
return execCommandResult
|
||||
}
|
||||
// user didn't reject, but the command may have output
|
||||
commandResult = execCommandResult
|
||||
} else {
|
||||
const completionMessageTs = await config.callbacks.say("completion_result", result, undefined, undefined, false)
|
||||
await config.callbacks.saveCheckpoint(true, completionMessageTs)
|
||||
await addNewChangesFlagToLastCompletionResultMessage()
|
||||
telemetryService.captureTaskCompleted(config.ulid)
|
||||
|
||||
if (config.focusChainSettings.enabled) {
|
||||
await config.callbacks.updateFCListFromToolResponse(block.params.task_progress)
|
||||
}
|
||||
}
|
||||
|
||||
// we already sent completion_result says, an empty string asks relinquishes control over button and field
|
||||
const { response, text, images, files: completionFiles } = await config.callbacks.ask("completion_result", "", false)
|
||||
if (response === "yesButtonClicked") {
|
||||
return "" // signals to recursive loop to stop (for now this never happens since yesButtonClicked will trigger a new task)
|
||||
}
|
||||
|
||||
await config.callbacks.say("user_feedback", text ?? "", images, completionFiles)
|
||||
await config.callbacks.saveCheckpoint()
|
||||
|
||||
const toolResults: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
|
||||
if (commandResult) {
|
||||
if (typeof commandResult === "string") {
|
||||
toolResults.push({
|
||||
type: "text",
|
||||
text: commandResult,
|
||||
})
|
||||
} else if (Array.isArray(commandResult)) {
|
||||
toolResults.push(...commandResult)
|
||||
}
|
||||
}
|
||||
toolResults.push({
|
||||
type: "text",
|
||||
text: `The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n<feedback>\n${text}\n</feedback>`,
|
||||
})
|
||||
toolResults.push(...formatResponse.imageBlocks(images))
|
||||
|
||||
let fileContentString = ""
|
||||
if (completionFiles && completionFiles.length > 0) {
|
||||
fileContentString = await processFilesIntoText(completionFiles)
|
||||
}
|
||||
|
||||
// Return the tool results as a complex response
|
||||
return [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `[attempt_completion] Result:`,
|
||||
},
|
||||
...toolResults,
|
||||
...(fileContentString
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: fileContentString,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BrowserSession } from "@services/browser/BrowserSession"
|
||||
import { BrowserAction, BrowserActionResult, browserActions } from "@shared/ExtensionMessage"
|
||||
import { modelDoesntSupportWebp } from "@utils/model-utils"
|
||||
import { ToolResponse } from "../.."
|
||||
import { ToolUse } from "../../../assistant-message"
|
||||
import { formatResponse } from "../../../prompts/responses"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import { ToolUseName } from "../../../assistant-message"
|
||||
|
||||
export class BrowserToolHandler implements IToolHandler {
|
||||
name = "browser"
|
||||
supportedTools: ToolUseName[] = ["browser_action"]
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
const action: BrowserAction | undefined = block.params.action as BrowserAction
|
||||
const url: string | undefined = block.params.url
|
||||
const coordinate: string | undefined = block.params.coordinate
|
||||
const text: string | undefined = block.params.text
|
||||
|
||||
// Validate action
|
||||
if (!action || !browserActions.includes(action)) {
|
||||
throw new Error(`Invalid or missing browser action: ${action}`)
|
||||
}
|
||||
|
||||
const browserSession: BrowserSession = config.services.browserSession
|
||||
|
||||
let browserActionResult: BrowserActionResult
|
||||
|
||||
switch (action) {
|
||||
case "launch":
|
||||
if (!url) {
|
||||
throw new Error("URL is required for browser launch action")
|
||||
}
|
||||
|
||||
// Re-make browserSession to make sure latest settings apply
|
||||
if (config.context) {
|
||||
await browserSession.dispose()
|
||||
const useWebp = config.api ? !modelDoesntSupportWebp(config.api) : true
|
||||
const newBrowserSession = new BrowserSession(config.context, config.browserSettings, useWebp)
|
||||
// Update the browserSession reference
|
||||
config.services.browserSession = newBrowserSession
|
||||
await newBrowserSession.launchBrowser()
|
||||
browserActionResult = await newBrowserSession.navigateToUrl(url)
|
||||
} else {
|
||||
console.warn("no controller context available for browserSession")
|
||||
await browserSession.launchBrowser()
|
||||
browserActionResult = await browserSession.navigateToUrl(url)
|
||||
}
|
||||
break
|
||||
|
||||
case "click":
|
||||
if (!coordinate) {
|
||||
throw new Error("Coordinate is required for click action")
|
||||
}
|
||||
browserActionResult = await browserSession.click(coordinate)
|
||||
break
|
||||
|
||||
case "type":
|
||||
if (!text) {
|
||||
throw new Error("Text is required for type action")
|
||||
}
|
||||
browserActionResult = await browserSession.type(text)
|
||||
break
|
||||
|
||||
case "scroll_down":
|
||||
browserActionResult = await browserSession.scrollDown()
|
||||
break
|
||||
|
||||
case "scroll_up":
|
||||
browserActionResult = await browserSession.scrollUp()
|
||||
break
|
||||
|
||||
case "close":
|
||||
browserActionResult = await browserSession.closeBrowser()
|
||||
break
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown browser action: ${action}`)
|
||||
}
|
||||
|
||||
// Return appropriate result based on action
|
||||
switch (action) {
|
||||
case "launch":
|
||||
case "click":
|
||||
case "type":
|
||||
case "scroll_down":
|
||||
case "scroll_up":
|
||||
return formatResponse.toolResult(
|
||||
`The browser action has been executed. The console logs and screenshot have been captured for your analysis.\n\nConsole logs:\n${
|
||||
browserActionResult.logs || "(No new logs)"
|
||||
}\n\n(REMEMBER: if you need to proceed to using non-\`browser_action\` tools or launch a new browser, you MUST first close this browser. For example, if after analyzing the logs and screenshot you need to edit a file, you must first close the browser before you can use the write_to_file tool.)`,
|
||||
browserActionResult.screenshot ? [browserActionResult.screenshot] : [],
|
||||
)
|
||||
|
||||
case "close":
|
||||
return formatResponse.toolResult(`The browser has been closed. You may now proceed to using other tools.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { showSystemNotification } from "@integrations/notifications"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { ensureTaskDirectoryExists } from "@core/storage/disk"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
|
||||
export class CondenseHandler implements IToolHandler {
|
||||
readonly name = "condense"
|
||||
|
||||
constructor() {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const context: string | undefined = block.params.context
|
||||
|
||||
// Validate required parameters
|
||||
if (!context) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: context"
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Show notification if auto-approval is enabled
|
||||
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
subtitle: "Cline wants to condense the conversation...",
|
||||
message: `Cline is suggesting to condense your conversation with: ${context}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Ask user for response
|
||||
const { text, images, files: condenseFiles } = await config.callbacks.ask("condense", context, false)
|
||||
|
||||
// If the user provided a response, treat it as feedback
|
||||
if (text || (images && images.length > 0) || (condenseFiles && condenseFiles.length > 0)) {
|
||||
let fileContentString = ""
|
||||
if (condenseFiles && condenseFiles.length > 0) {
|
||||
fileContentString = await processFilesIntoText(condenseFiles)
|
||||
}
|
||||
|
||||
await config.callbacks.say("user_feedback", text ?? "", images, condenseFiles)
|
||||
return formatResponse.toolResult(
|
||||
`The user provided feedback on the condensed conversation summary:\n<feedback>\n${text}\n</feedback>`,
|
||||
images,
|
||||
fileContentString,
|
||||
)
|
||||
} else {
|
||||
// If no response, the user accepted the condensed version
|
||||
const apiConversationHistory = config.messageState.getApiConversationHistory()
|
||||
const lastMessage = apiConversationHistory[apiConversationHistory.length - 1]
|
||||
const summaryAlreadyAppended = lastMessage && lastMessage.role === "assistant"
|
||||
const keepStrategy = summaryAlreadyAppended ? "lastTwo" : "none"
|
||||
|
||||
// clear the context history at this point in time
|
||||
config.taskState.conversationHistoryDeletedRange = config.services.contextManager.getNextTruncationRange(
|
||||
apiConversationHistory,
|
||||
config.taskState.conversationHistoryDeletedRange,
|
||||
keepStrategy,
|
||||
)
|
||||
await config.messageState.saveClineMessagesAndUpdateHistory()
|
||||
await config.services.contextManager.triggerApplyStandardContextTruncationNoticeChange(
|
||||
Date.now(),
|
||||
await ensureTaskDirectoryExists(config.context, config.taskId),
|
||||
)
|
||||
|
||||
return formatResponse.toolResult(formatResponse.condense())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { fixModelHtmlEscaping } from "@utils/string"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
|
||||
export class ExecuteCommandToolHandler implements IToolHandler {
|
||||
readonly name = "execute_command"
|
||||
|
||||
constructor(private validator: ToolValidator) {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
let command: string | undefined = block.params.command
|
||||
const requiresApprovalRaw: string | undefined = block.params.requires_approval
|
||||
const requiresApprovalPerLLM = requiresApprovalRaw?.toLowerCase() === "true"
|
||||
|
||||
// Validate required parameters
|
||||
if (!command) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return await config.callbacks.sayAndCreateMissingParamError("execute_command", "command")
|
||||
}
|
||||
|
||||
if (!requiresApprovalRaw) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return await config.callbacks.sayAndCreateMissingParamError("execute_command", "requires_approval")
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Pre-process command for certain models
|
||||
if (config.api.getModel().id.includes("gemini")) {
|
||||
command = fixModelHtmlEscaping(command)
|
||||
}
|
||||
|
||||
// Check clineignore validation for command
|
||||
const ignoredFileAttemptedToAccess = config.services.clineIgnoreController.validateCommand(command)
|
||||
if (ignoredFileAttemptedToAccess) {
|
||||
await config.callbacks.say("clineignore_error", ignoredFileAttemptedToAccess)
|
||||
return `Error: Command blocked by .clineignore rules. The command attempted to access: ${ignoredFileAttemptedToAccess}`
|
||||
}
|
||||
|
||||
// Execute the command using the callback
|
||||
const [userRejected, result] = await config.callbacks.executeCommandTool(command)
|
||||
|
||||
if (userRejected) {
|
||||
config.taskState.didRejectTool = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this command should be auto-approved based on the dual approval system
|
||||
* Returns [autoApproveSafe, autoApproveAll] tuple
|
||||
*/
|
||||
shouldAutoApprove(config: any, requiresApprovalPerLLM: boolean): [boolean, boolean] {
|
||||
// This logic is handled by the AutoApprove class in the main ToolExecutor
|
||||
// The handler just executes the command - approval logic is handled by the coordinator
|
||||
return [false, false]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as path from "path"
|
||||
import { parseSourceCodeForDefinitionsTopLevel } from "@services/tree-sitter"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
|
||||
export class ListCodeDefinitionNamesToolHandler implements IToolHandler {
|
||||
readonly name = "list_code_definition_names"
|
||||
|
||||
constructor(private validator: ToolValidator) {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const relDirPath: string | undefined = block.params.path
|
||||
|
||||
// Validate required parameters
|
||||
const pathValidation = this.validator.assertRequiredParams(block, "path")
|
||||
if (!pathValidation.ok) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return await config.callbacks.sayAndCreateMissingParamError("list_code_definition_names", "path")
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
const absolutePath = path.resolve(config.cwd, relDirPath!)
|
||||
|
||||
// Execute the actual parse source code operation
|
||||
const result = await parseSourceCodeForDefinitionsTopLevel(absolutePath, config.services.clineIgnoreController)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as path from "path"
|
||||
import { listFiles } from "@services/glob/list-files"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
|
||||
export class ListFilesToolHandler implements IToolHandler {
|
||||
readonly name = "list_files"
|
||||
|
||||
constructor(private validator: ToolValidator) {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const relDirPath: string | undefined = block.params.path
|
||||
const recursiveRaw: string | undefined = block.params.recursive
|
||||
const recursive = recursiveRaw?.toLowerCase() === "true"
|
||||
|
||||
// Validate required parameters
|
||||
const pathValidation = this.validator.assertRequiredParams(block, "path")
|
||||
if (!pathValidation.ok) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return await config.callbacks.sayAndCreateMissingParamError("list_files", "path")
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
const absolutePath = path.resolve(config.cwd, relDirPath!)
|
||||
|
||||
// Execute the actual list files operation
|
||||
const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
|
||||
|
||||
const result = formatResponse.formatFilesList(absolutePath, files, didHitLimit, config.services.clineIgnoreController)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { loadMcpDocumentation } from "@core/prompts/loadMcpDocumentation"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
|
||||
export class LoadMcpDocumentationHandler implements IToolHandler {
|
||||
readonly name = "load_mcp_documentation"
|
||||
|
||||
constructor() {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet (though this tool shouldn't have partial blocks)
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
try {
|
||||
// Load MCP documentation
|
||||
const documentation = await loadMcpDocumentation(config.services.mcpHub)
|
||||
return documentation
|
||||
} catch (error) {
|
||||
return `Error loading MCP documentation: ${(error as Error)?.message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { showSystemNotification } from "@integrations/notifications"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
|
||||
export class NewTaskHandler implements IToolHandler {
|
||||
readonly name = "new_task"
|
||||
|
||||
constructor() {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const context: string | undefined = block.params.context
|
||||
|
||||
// Validate required parameters
|
||||
if (!context) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: context"
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Show notification if auto-approval is enabled
|
||||
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
subtitle: "Cline wants to start a new task...",
|
||||
message: `Cline is suggesting to start a new task with: ${context}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Ask user for response
|
||||
const { text, images, files: newTaskFiles } = await config.callbacks.ask("new_task", context, false)
|
||||
|
||||
// If the user provided a response, treat it as feedback
|
||||
if (text || (images && images.length > 0) || (newTaskFiles && newTaskFiles.length > 0)) {
|
||||
let fileContentString = ""
|
||||
if (newTaskFiles && newTaskFiles.length > 0) {
|
||||
fileContentString = await processFilesIntoText(newTaskFiles)
|
||||
}
|
||||
|
||||
await config.callbacks.say("user_feedback", text ?? "", images, newTaskFiles)
|
||||
return formatResponse.toolResult(
|
||||
`The user provided feedback instead of creating a new task:\n<feedback>\n${text}\n</feedback>`,
|
||||
images,
|
||||
fileContentString,
|
||||
)
|
||||
} else {
|
||||
// If no response, the user clicked the "Create New Task" button
|
||||
return formatResponse.toolResult(`The user has created a new task with the provided context.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { telemetryService } from "@services/posthog/PostHogClientProvider"
|
||||
import { findLast, parsePartialArrayString } from "@shared/array"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
|
||||
export class PlanModeRespondHandler implements IToolHandler {
|
||||
readonly name = "plan_mode_respond"
|
||||
|
||||
constructor() {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const response: string | undefined = block.params.response
|
||||
const optionsRaw: string | undefined = block.params.options
|
||||
const needsMoreExploration: boolean = block.params.needs_more_exploration === "true"
|
||||
|
||||
// Validate required parameters
|
||||
if (!response) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: response"
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Handle needs_more_exploration escape hatch
|
||||
if (needsMoreExploration) {
|
||||
return formatResponse.toolResult(
|
||||
`[You have indicated that you need more exploration. Proceed with calling tools to continue the planning process.]`,
|
||||
)
|
||||
}
|
||||
|
||||
// Store the number of options for telemetry
|
||||
const options = parsePartialArrayString(optionsRaw || "[]")
|
||||
|
||||
// Handle focus chain updates
|
||||
if (!block.partial && config.focusChainSettings.enabled) {
|
||||
await config.callbacks.updateFCListFromToolResponse(block.params.task_progress)
|
||||
}
|
||||
|
||||
// Set awaiting plan response state
|
||||
config.taskState.isAwaitingPlanResponse = true
|
||||
|
||||
const sharedMessage = {
|
||||
response: response,
|
||||
options: options,
|
||||
}
|
||||
|
||||
// Ask for user response
|
||||
let {
|
||||
text,
|
||||
images,
|
||||
files: planResponseFiles,
|
||||
} = await config.callbacks.ask("plan_mode_respond", JSON.stringify(sharedMessage), false)
|
||||
|
||||
config.taskState.isAwaitingPlanResponse = false
|
||||
|
||||
// Handle mode toggle marker
|
||||
if (text === "PLAN_MODE_TOGGLE_RESPONSE") {
|
||||
text = ""
|
||||
}
|
||||
|
||||
// Check if options contains the text response
|
||||
if (optionsRaw && text && parsePartialArrayString(optionsRaw).includes(text)) {
|
||||
telemetryService.captureOptionSelected(config.ulid, options.length, "plan")
|
||||
// Valid option selected, don't show user message in UI
|
||||
// Update last plan message with selected option
|
||||
const lastPlanMessage = findLast(config.messageState.getClineMessages(), (m: any) => m.ask === "plan_mode_respond")
|
||||
if (lastPlanMessage) {
|
||||
lastPlanMessage.text = JSON.stringify({
|
||||
...sharedMessage,
|
||||
selected: text,
|
||||
})
|
||||
await config.messageState.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
} else {
|
||||
// Option not selected, send user feedback
|
||||
if (text || (images && images.length > 0) || (planResponseFiles && planResponseFiles.length > 0)) {
|
||||
telemetryService.captureOptionsIgnored(config.ulid, options.length, "plan")
|
||||
await config.callbacks.say("user_feedback", text ?? "", images, planResponseFiles)
|
||||
await config.callbacks.saveCheckpoint()
|
||||
}
|
||||
}
|
||||
|
||||
let fileContentString = ""
|
||||
if (planResponseFiles && planResponseFiles.length > 0) {
|
||||
const { processFilesIntoText } = await import("@integrations/misc/extract-text")
|
||||
fileContentString = await processFilesIntoText(planResponseFiles)
|
||||
}
|
||||
|
||||
// Handle mode switching response
|
||||
if (config.taskState.didRespondToPlanAskBySwitchingMode) {
|
||||
const result = formatResponse.toolResult(
|
||||
`[The user has switched to ACT MODE, so you may now proceed with the task.]` +
|
||||
(text
|
||||
? `\n\nThe user also provided the following message when switching to ACT MODE:\n<user_message>\n${text}\n</user_message>`
|
||||
: ""),
|
||||
images,
|
||||
fileContentString,
|
||||
)
|
||||
// Reset the flag after using it to prevent it from persisting
|
||||
config.taskState.didRespondToPlanAskBySwitchingMode = false
|
||||
return result
|
||||
} else {
|
||||
// if we didn't switch to ACT MODE, then we can just send the user_feedback message
|
||||
return formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images, fileContentString)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as path from "path"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { extractFileContent } from "@integrations/misc/extract-file-content"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
|
||||
export class ReadFileToolHandler implements IToolHandler {
|
||||
readonly name = "read_file"
|
||||
|
||||
constructor(private validator: ToolValidator) {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const relPath: string | undefined = block.params.path
|
||||
|
||||
// Validate required parameters
|
||||
const pathValidation = this.validator.assertRequiredParams(block, "path")
|
||||
if (!pathValidation.ok) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return await config.callbacks.sayAndCreateMissingParamError("read_file", "path")
|
||||
}
|
||||
|
||||
// Check clineignore access
|
||||
const accessValidation = this.validator.checkClineIgnorePath(relPath!)
|
||||
if (!accessValidation.ok) {
|
||||
await config.callbacks.say("clineignore_error", relPath)
|
||||
return formatResponse.toolError(formatResponse.clineIgnoreError(relPath!))
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
const absolutePath = path.resolve(config.cwd, relPath!)
|
||||
|
||||
// Execute the actual file read operation
|
||||
const supportsImages = config.api.getModel().info.supportsImages ?? false
|
||||
const result = await extractFileContent(absolutePath, supportsImages)
|
||||
|
||||
// Track file read operation
|
||||
await config.services.fileContextTracker.trackFileContext(relPath!, "read_tool")
|
||||
|
||||
// Handle image blocks separately - they need to be pushed to userMessageContent
|
||||
if (result.imageBlock) {
|
||||
config.taskState.userMessageContent.push(result.imageBlock)
|
||||
}
|
||||
|
||||
return result.text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { showSystemNotification } from "@integrations/notifications"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import { createAndOpenGitHubIssue } from "@utils/github-url-utils"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import * as os from "os"
|
||||
import * as vscode from "vscode"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
|
||||
export class ReportBugHandler implements IToolHandler {
|
||||
readonly name = "report_bug"
|
||||
|
||||
constructor() {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const title = block.params.title
|
||||
const what_happened = block.params.what_happened
|
||||
const steps_to_reproduce = block.params.steps_to_reproduce
|
||||
const api_request_output = block.params.api_request_output
|
||||
const additional_context = block.params.additional_context
|
||||
|
||||
// Validate required parameters
|
||||
if (!title) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: title"
|
||||
}
|
||||
if (!what_happened) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: what_happened"
|
||||
}
|
||||
if (!steps_to_reproduce) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: steps_to_reproduce"
|
||||
}
|
||||
if (!api_request_output) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: api_request_output"
|
||||
}
|
||||
if (!additional_context) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: additional_context"
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Show notification if auto-approval is enabled
|
||||
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
subtitle: "Cline wants to create a github issue...",
|
||||
message: `Cline is suggesting to create a github issue with the title: ${title}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Derive system information values algorithmically
|
||||
const operatingSystem = os.platform() + " " + os.release()
|
||||
const clineVersion = vscode.extensions.getExtension("saoudrizwan.claude-dev")?.packageJSON.version || "Unknown"
|
||||
const systemInfo = `VSCode: ${vscode.version}, Node.js: ${process.version}, Architecture: ${os.arch()}`
|
||||
const currentMode = config.mode
|
||||
const apiConfig = config.services.cacheService.getApiConfiguration()
|
||||
const apiProvider = currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
|
||||
const providerAndModel = `${apiProvider} / ${config.api.getModel().id}`
|
||||
|
||||
// Ask user for confirmation
|
||||
const bugReportData = JSON.stringify({
|
||||
title,
|
||||
what_happened,
|
||||
steps_to_reproduce,
|
||||
api_request_output,
|
||||
additional_context,
|
||||
// Include derived values in the JSON for display purposes
|
||||
provider_and_model: providerAndModel,
|
||||
operating_system: operatingSystem,
|
||||
system_info: systemInfo,
|
||||
cline_version: clineVersion,
|
||||
})
|
||||
|
||||
const { text, images, files: reportBugFiles } = await config.callbacks.ask("report_bug", bugReportData, false)
|
||||
|
||||
// If the user provided a response, treat it as feedback
|
||||
if (text || (images && images.length > 0) || (reportBugFiles && reportBugFiles.length > 0)) {
|
||||
let fileContentString = ""
|
||||
if (reportBugFiles && reportBugFiles.length > 0) {
|
||||
fileContentString = await processFilesIntoText(reportBugFiles)
|
||||
}
|
||||
|
||||
await config.callbacks.say("user_feedback", text ?? "", images, reportBugFiles)
|
||||
return formatResponse.toolResult(
|
||||
`The user did not submit the bug, and provided feedback on the Github issue generated instead:\n<feedback>\n${text}\n</feedback>`,
|
||||
images,
|
||||
fileContentString,
|
||||
)
|
||||
} else {
|
||||
// If no response, the user accepted the bug report
|
||||
try {
|
||||
// Create a Map of parameters for the GitHub issue
|
||||
const params = new Map<string, string>()
|
||||
params.set("title", title)
|
||||
params.set("operating-system", operatingSystem)
|
||||
params.set("cline-version", clineVersion)
|
||||
params.set("system-info", systemInfo)
|
||||
params.set("additional-context", additional_context)
|
||||
params.set("what-happened", what_happened)
|
||||
params.set("steps", steps_to_reproduce)
|
||||
params.set("provider-model", providerAndModel)
|
||||
params.set("logs", api_request_output)
|
||||
|
||||
// Use our utility function to create and open the GitHub issue URL
|
||||
// This bypasses VS Code's URI handling issues with special characters
|
||||
await createAndOpenGitHubIssue("cline", "cline", "bug_report.yml", params)
|
||||
} catch (error) {
|
||||
console.error(`An error occurred while attempting to report the bug: ${error}`)
|
||||
}
|
||||
|
||||
return formatResponse.toolResult(`The user accepted the creation of the Github issue.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as path from "path"
|
||||
import { regexSearchFiles } from "@services/ripgrep"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
|
||||
export class SearchFilesToolHandler implements IToolHandler {
|
||||
readonly name = "search_files"
|
||||
|
||||
constructor(private validator: ToolValidator) {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const relDirPath: string | undefined = block.params.path
|
||||
const regex: string | undefined = block.params.regex
|
||||
const filePattern: string | undefined = block.params.file_pattern
|
||||
|
||||
// Validate required parameters
|
||||
const pathValidation = this.validator.assertRequiredParams(block, "path")
|
||||
if (!pathValidation.ok) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return await config.callbacks.sayAndCreateMissingParamError("search_files", "path")
|
||||
}
|
||||
|
||||
if (!regex) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return await config.callbacks.sayAndCreateMissingParamError("search_files", "regex")
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
const absolutePath = path.resolve(config.cwd, relDirPath!)
|
||||
|
||||
// Execute the actual regex search operation
|
||||
const results = await regexSearchFiles(
|
||||
config.cwd,
|
||||
absolutePath,
|
||||
regex,
|
||||
filePattern,
|
||||
config.services.clineIgnoreController,
|
||||
)
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { continuationPrompt } from "@core/prompts/contextManagement"
|
||||
import { ensureTaskDirectoryExists } from "@core/storage/disk"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
|
||||
export class SummarizeTaskHandler implements IToolHandler {
|
||||
readonly name = "summarize_task"
|
||||
|
||||
constructor() {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const context: string | undefined = block.params.context
|
||||
|
||||
// Validate required parameters
|
||||
if (!context) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: context"
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Show completed summary in tool UI
|
||||
await config.callbacks.say(
|
||||
"tool",
|
||||
JSON.stringify({
|
||||
tool: "summarizeTask",
|
||||
content: context,
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
)
|
||||
|
||||
// Use the continuationPrompt to format the tool result
|
||||
const toolResult = formatResponse.toolResult(continuationPrompt(context))
|
||||
|
||||
// Handle context management
|
||||
const apiConversationHistory = config.messageState.getApiConversationHistory()
|
||||
const keepStrategy = "none"
|
||||
|
||||
// clear the context history at this point in time. note that this will not include the assistant message
|
||||
// for summarizing, which we will need to delete later
|
||||
config.taskState.conversationHistoryDeletedRange = config.services.contextManager.getNextTruncationRange(
|
||||
apiConversationHistory,
|
||||
config.taskState.conversationHistoryDeletedRange,
|
||||
keepStrategy,
|
||||
)
|
||||
await config.messageState.saveClineMessagesAndUpdateHistory()
|
||||
await config.services.contextManager.triggerApplyStandardContextTruncationNoticeChange(
|
||||
Date.now(),
|
||||
await ensureTaskDirectoryExists(config.context, config.taskId),
|
||||
)
|
||||
|
||||
// Set summarizing state
|
||||
config.taskState.currentlySummarizing = true
|
||||
|
||||
return toolResult
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
|
||||
export class UseMcpToolHandler implements IToolHandler {
|
||||
readonly name = "use_mcp_tool"
|
||||
|
||||
constructor() {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, don't execute yet
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const server_name: string | undefined = block.params.server_name
|
||||
const tool_name: string | undefined = block.params.tool_name
|
||||
const mcp_arguments: string | undefined = block.params.arguments
|
||||
|
||||
// Validate required parameters
|
||||
if (!server_name) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: server_name"
|
||||
}
|
||||
|
||||
if (!tool_name) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: tool_name"
|
||||
}
|
||||
|
||||
// Parse and validate arguments if provided
|
||||
let parsedArguments: Record<string, unknown> | undefined
|
||||
if (mcp_arguments) {
|
||||
try {
|
||||
parsedArguments = JSON.parse(mcp_arguments)
|
||||
} catch (error) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return `Error: Invalid JSON arguments for ${tool_name} on ${server_name}`
|
||||
}
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
try {
|
||||
// Check for any pending notifications before the tool call
|
||||
const notificationsBefore = config.services.mcpHub.getPendingNotifications()
|
||||
for (const notification of notificationsBefore) {
|
||||
await config.callbacks.say("mcp_notification", `[${notification.serverName}] ${notification.message}`)
|
||||
}
|
||||
|
||||
// Execute the MCP tool
|
||||
const toolResult = await config.services.mcpHub.callTool(server_name, tool_name, parsedArguments)
|
||||
|
||||
// Check for any pending notifications after the tool call
|
||||
const notificationsAfter = config.services.mcpHub.getPendingNotifications()
|
||||
for (const notification of notificationsAfter) {
|
||||
await config.callbacks.say("mcp_notification", `[${notification.serverName}] ${notification.message}`)
|
||||
}
|
||||
|
||||
// Process tool result
|
||||
const toolResultImages =
|
||||
toolResult?.content
|
||||
.filter((item: any) => item.type === "image")
|
||||
.map((item: any) => `data:${item.mimeType};base64,${item.data}`) || []
|
||||
|
||||
let toolResultText =
|
||||
(toolResult?.isError ? "Error:\n" : "") +
|
||||
toolResult?.content
|
||||
.map((item: any) => {
|
||||
if (item.type === "text") {
|
||||
return item.text
|
||||
}
|
||||
if (item.type === "resource") {
|
||||
const { blob, ...rest } = item.resource
|
||||
return JSON.stringify(rest, null, 2)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n\n") || "(No response)"
|
||||
|
||||
// Display result to user
|
||||
const toolResultToDisplay = toolResultText + toolResultImages?.map((image: any) => `\n\n${image}`).join("")
|
||||
await config.callbacks.say("mcp_server_response", toolResultToDisplay)
|
||||
|
||||
// Handle model image support
|
||||
const supportsImages = config.api.getModel().info.supportsImages ?? false
|
||||
if (toolResultImages.length > 0 && !supportsImages) {
|
||||
toolResultText += `\n\n[${toolResultImages.length} images were provided in the response, and while they are displayed to the user, you do not have the ability to view them.]`
|
||||
}
|
||||
|
||||
// Return formatted result (only pass images if model supports them)
|
||||
return formatResponse.toolResult(toolResultText, supportsImages ? toolResultImages : undefined)
|
||||
} catch (error) {
|
||||
return `Error executing MCP tool: ${(error as Error)?.message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
|
||||
import { ToolResponse } from "../.."
|
||||
import { ToolUse, ToolUseName } from "../../../assistant-message"
|
||||
import { formatResponse } from "../../../prompts/responses"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
|
||||
export class WebFetchToolHandler implements IToolHandler {
|
||||
name = "web_fetch"
|
||||
supportedTools: ToolUseName[] = ["web_fetch"]
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
const url: string | undefined = block.params.url
|
||||
|
||||
if (!url) {
|
||||
throw new Error("URL is required for web_fetch")
|
||||
}
|
||||
|
||||
const urlContentFetcher: UrlContentFetcher = config.urlContentFetcher
|
||||
|
||||
try {
|
||||
// Fetch Markdown content
|
||||
await urlContentFetcher.launchBrowser()
|
||||
const markdownContent = await urlContentFetcher.urlToMarkdown(url)
|
||||
await urlContentFetcher.closeBrowser()
|
||||
|
||||
// TODO: Implement secondary AI call to process markdownContent with prompt
|
||||
// For now, returning markdown directly.
|
||||
// This will be a significant sub-task.
|
||||
// Placeholder for processed summary:
|
||||
const processedSummary = `Fetched Markdown for ${url}:\n\n${markdownContent}`
|
||||
|
||||
return formatResponse.toolResult(processedSummary)
|
||||
} catch (error) {
|
||||
// Ensure browser is closed on error
|
||||
await urlContentFetcher.closeBrowser()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import * as path from "path"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { fixModelHtmlEscaping, removeInvalidChars } from "@utils/string"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import { constructNewFileContent } from "@core/assistant-message/diff"
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
|
||||
export class WriteToFileToolHandler implements IToolHandler {
|
||||
readonly name = "write_to_file" // This handler supports write_to_file, replace_in_file, and new_rule
|
||||
|
||||
constructor(private validator: ToolValidator) {}
|
||||
|
||||
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
|
||||
// For partial blocks, return empty string to let coordinator handle UI
|
||||
if (block.partial) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const relPath: string | undefined = block.params.path
|
||||
let content: string | undefined = block.params.content // for write_to_file and new_rule
|
||||
let diff: string | undefined = block.params.diff // for replace_in_file
|
||||
|
||||
// Validate required parameters based on tool type
|
||||
if (!relPath) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: path"
|
||||
}
|
||||
|
||||
if (block.name === "replace_in_file" && !diff) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: diff"
|
||||
}
|
||||
|
||||
if ((block.name === "write_to_file" || block.name === "new_rule") && !content) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return "Missing required parameter: content"
|
||||
}
|
||||
|
||||
// Check clineignore access
|
||||
const accessValidation = this.validator.checkClineIgnorePath(relPath)
|
||||
if (!accessValidation.ok) {
|
||||
return `Error: File access blocked by .clineignore rules: ${relPath}`
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Check if file exists
|
||||
const absolutePath = path.resolve(config.cwd, relPath)
|
||||
let fileExists: boolean
|
||||
if (config.services.diffViewProvider.editType !== undefined) {
|
||||
fileExists = config.services.diffViewProvider.editType === "modify"
|
||||
} else {
|
||||
fileExists = await fileExistsAtPath(absolutePath)
|
||||
config.services.diffViewProvider.editType = fileExists ? "modify" : "create"
|
||||
}
|
||||
|
||||
try {
|
||||
// Construct newContent from diff or content
|
||||
let newContent: string = ""
|
||||
|
||||
if (diff) {
|
||||
// Handle replace_in_file with diff construction
|
||||
if (!config.api.getModel().id.includes("claude")) {
|
||||
// deepseek models tend to use unescaped html entities in diffs
|
||||
diff = fixModelHtmlEscaping(diff)
|
||||
diff = removeInvalidChars(diff)
|
||||
}
|
||||
|
||||
// Open the editor if not done already
|
||||
if (!config.services.diffViewProvider.isEditing) {
|
||||
await config.services.diffViewProvider.open(relPath)
|
||||
}
|
||||
|
||||
try {
|
||||
newContent = await constructNewFileContent(
|
||||
diff,
|
||||
config.services.diffViewProvider.originalContent || "",
|
||||
true, // isFinal = true since we're not streaming
|
||||
)
|
||||
} catch (error) {
|
||||
await config.services.diffViewProvider.revertChanges()
|
||||
await config.services.diffViewProvider.reset()
|
||||
return `Error: ${(error as Error)?.message}\n\nDiff parsing failed for ${relPath}`
|
||||
}
|
||||
} else if (content) {
|
||||
// Handle write_to_file and new_rule with direct content
|
||||
newContent = content
|
||||
|
||||
// Pre-processing newContent for cases where weaker models might add artifacts
|
||||
if (newContent.startsWith("```")) {
|
||||
newContent = newContent.split("\n").slice(1).join("\n").trim()
|
||||
}
|
||||
if (newContent.endsWith("```")) {
|
||||
newContent = newContent.split("\n").slice(0, -1).join("\n").trim()
|
||||
}
|
||||
|
||||
if (!config.api.getModel().id.includes("claude")) {
|
||||
newContent = fixModelHtmlEscaping(newContent)
|
||||
newContent = removeInvalidChars(newContent)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing newlines
|
||||
newContent = newContent.trimEnd()
|
||||
|
||||
// Open the diff view if not already editing
|
||||
if (!config.services.diffViewProvider.isEditing) {
|
||||
await config.services.diffViewProvider.open(relPath)
|
||||
}
|
||||
|
||||
// Update the diff view with the new content
|
||||
await config.services.diffViewProvider.update(newContent, true)
|
||||
await setTimeoutPromise(300) // wait for diff view to update
|
||||
await config.services.diffViewProvider.scrollToFirstDiff()
|
||||
|
||||
// Mark the file as edited by Cline
|
||||
config.services.fileContextTracker.markFileAsEditedByCline(relPath)
|
||||
|
||||
// Save the changes and get the result
|
||||
const { newProblemsMessage, userEdits, autoFormattingEdits, finalContent } =
|
||||
await config.services.diffViewProvider.saveChanges()
|
||||
|
||||
config.taskState.didEditFile = true
|
||||
|
||||
// Track file edit operation
|
||||
await config.services.fileContextTracker.trackFileContext(relPath, "cline_edited")
|
||||
|
||||
// Reset the diff view
|
||||
await config.services.diffViewProvider.reset()
|
||||
|
||||
// Handle user edits if any
|
||||
if (userEdits) {
|
||||
await config.services.fileContextTracker.trackFileContext(relPath, "user_edited")
|
||||
await config.callbacks.say(
|
||||
"user_feedback_diff",
|
||||
JSON.stringify({
|
||||
tool: fileExists ? "editedExistingFile" : "newFileCreated",
|
||||
path: relPath,
|
||||
diff: userEdits,
|
||||
}),
|
||||
)
|
||||
return formatResponse.fileEditWithUserChanges(
|
||||
relPath,
|
||||
userEdits,
|
||||
autoFormattingEdits,
|
||||
finalContent,
|
||||
newProblemsMessage,
|
||||
)
|
||||
} else {
|
||||
return formatResponse.fileEditWithoutUserChanges(relPath, autoFormattingEdits, finalContent, newProblemsMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
// Reset diff view on error
|
||||
await config.services.diffViewProvider.revertChanges()
|
||||
await config.services.diffViewProvider.reset()
|
||||
return `Error: ${(error as Error)?.message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import * as path from "path"
|
||||
import { telemetryService } from "@services/posthog/PostHogClientProvider"
|
||||
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
|
||||
import { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
|
||||
import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import { ToolUse, ToolUseName } from "../../../assistant-message"
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
|
||||
/**
|
||||
* Manages the approval flow for tool executions, including auto-approval logic,
|
||||
* notification generation, telemetry capture, and UI message routing.
|
||||
*/
|
||||
export class ToolApprovalManager {
|
||||
constructor(
|
||||
private config: any,
|
||||
private shouldAutoApproveToolWithPath: (toolName: ToolUseName, path?: string) => Promise<boolean>,
|
||||
private removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: any) => Promise<void>,
|
||||
private say: (
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
) => Promise<number | undefined>,
|
||||
private ask: (
|
||||
type: ClineAsk,
|
||||
text?: string,
|
||||
partial?: boolean,
|
||||
) => Promise<{
|
||||
response: ClineAskResponse
|
||||
text?: string
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
}>,
|
||||
private askApproval: (type: ClineAsk, block: ToolUse, message: string) => Promise<boolean>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle approval flow for file-related tools (read_file, list_files, etc.)
|
||||
*/
|
||||
async handleFileToolApproval(
|
||||
block: ToolUse,
|
||||
relPath: string,
|
||||
absolutePath: string,
|
||||
tool: string,
|
||||
result: any,
|
||||
): Promise<boolean> {
|
||||
const sharedMessageProps = {
|
||||
tool,
|
||||
path: getReadablePath(this.config.cwd, relPath),
|
||||
content: block.name === "list_files" ? result : absolutePath,
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
}
|
||||
|
||||
const completeMessage = JSON.stringify(sharedMessageProps)
|
||||
|
||||
if (await this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
await this.handleAutoApproval("tool", completeMessage, block)
|
||||
return true
|
||||
} else {
|
||||
const notificationMessage = this.createFileToolNotificationMessage(block, absolutePath)
|
||||
return await this.handleManualApproval("tool", completeMessage, block, notificationMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle approval flow for write-related tools (write_to_file, replace_in_file, new_rule)
|
||||
*/
|
||||
async handleWriteToolApproval(block: ToolUse, relPath: string, fileExists: boolean, content: string): Promise<boolean> {
|
||||
const sharedMessageProps = {
|
||||
tool: fileExists ? "editedExistingFile" : "newFileCreated",
|
||||
path: getReadablePath(this.config.cwd, relPath),
|
||||
content: content,
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
}
|
||||
|
||||
const completeMessage = JSON.stringify(sharedMessageProps)
|
||||
|
||||
if (await this.shouldAutoApproveToolWithPath(block.name, relPath)) {
|
||||
await this.handleAutoApproval("tool", completeMessage, block)
|
||||
return true
|
||||
} else {
|
||||
const notificationMessage = `Cline wants to ${fileExists ? "edit" : "create"} ${path.basename(relPath)}`
|
||||
return await this.handleManualApproval("tool", completeMessage, block, notificationMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle approval flow for MCP tools (use_mcp_tool, access_mcp_resource)
|
||||
*/
|
||||
async handleMcpToolApproval(block: ToolUse): Promise<boolean> {
|
||||
const server_name = block.params.server_name
|
||||
const tool_name = block.params.tool_name
|
||||
const uri = block.params.uri
|
||||
const mcp_arguments = block.params.arguments
|
||||
|
||||
const completeMessage = JSON.stringify({
|
||||
type: block.name === "use_mcp_tool" ? "use_mcp_tool" : "access_mcp_resource",
|
||||
serverName: server_name,
|
||||
toolName: tool_name,
|
||||
uri: uri,
|
||||
arguments: mcp_arguments,
|
||||
})
|
||||
|
||||
const shouldAutoApprove = this.shouldAutoApproveMcpTool(block, server_name || "", tool_name || "")
|
||||
|
||||
if (shouldAutoApprove) {
|
||||
await this.handleAutoApproval("use_mcp_server", completeMessage, block)
|
||||
return true
|
||||
} else {
|
||||
const notificationMessage = this.createMcpToolNotificationMessage(block, tool_name, server_name, uri)
|
||||
return await this.handleManualApproval("use_mcp_server", completeMessage, block, notificationMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auto-approval flow
|
||||
*/
|
||||
private async handleAutoApproval(messageType: string, message: string, block: ToolUse): Promise<void> {
|
||||
await this.removeLastPartialMessageIfExistsWithType("ask", messageType)
|
||||
await this.say(messageType as ClineSay, message, undefined, undefined, false)
|
||||
this.config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
this.captureTelemetry(block, true, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle manual approval flow
|
||||
*/
|
||||
private async handleManualApproval(
|
||||
messageType: string,
|
||||
message: string,
|
||||
block: ToolUse,
|
||||
notificationMessage: string,
|
||||
): Promise<boolean> {
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
notificationMessage,
|
||||
this.config.autoApprovalSettings.enabled,
|
||||
this.config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
|
||||
await this.removeLastPartialMessageIfExistsWithType("say", messageType)
|
||||
const didApprove = await this.askApproval(messageType as ClineAsk, block, message)
|
||||
|
||||
if (!didApprove) {
|
||||
this.captureTelemetry(block, false, false)
|
||||
return false
|
||||
}
|
||||
|
||||
this.captureTelemetry(block, false, true)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if MCP tool should be auto-approved
|
||||
*/
|
||||
private shouldAutoApproveMcpTool(block: ToolUse, server_name: string, tool_name: string): boolean {
|
||||
if (block.name === "use_mcp_tool") {
|
||||
// Check if this specific tool is auto-approved on the server
|
||||
const isToolAutoApproved = this.config.services.mcpHub.connections
|
||||
?.find((conn: any) => conn.server.name === server_name)
|
||||
?.server.tools?.find((tool: any) => tool.name === tool_name)?.autoApprove
|
||||
|
||||
return this.config.autoApprovalSettings.enabled && isToolAutoApproved
|
||||
} else {
|
||||
// access_mcp_resource uses general auto-approval
|
||||
return this.config.autoApprovalSettings.enabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notification message for file tools
|
||||
*/
|
||||
private createFileToolNotificationMessage(block: ToolUse, absolutePath: string): string {
|
||||
return block.name === "list_files"
|
||||
? `Cline wants to view directory ${path.basename(absolutePath)}/`
|
||||
: `Cline wants to read ${path.basename(absolutePath)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notification message for MCP tools
|
||||
*/
|
||||
private createMcpToolNotificationMessage(
|
||||
block: ToolUse,
|
||||
tool_name: string | undefined,
|
||||
server_name: string | undefined,
|
||||
uri: string | undefined,
|
||||
): string {
|
||||
return block.name === "use_mcp_tool"
|
||||
? `Cline wants to use ${tool_name || "unknown tool"} on ${server_name || "unknown server"}`
|
||||
: `Cline wants to access ${uri || "unknown resource"} on ${server_name || "unknown server"}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture telemetry for tool usage
|
||||
*/
|
||||
private captureTelemetry(block: ToolUse, isAutoApproved: boolean, wasApproved: boolean): void {
|
||||
telemetryService.captureToolUsage(
|
||||
this.config.ulid,
|
||||
block.name,
|
||||
this.config.api.getModel().id,
|
||||
isAutoApproved,
|
||||
wasApproved,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ToolUse, ToolUseName, ToolParamName } from "@core/assistant-message"
|
||||
|
||||
/**
|
||||
* Utility functions for tool display and formatting
|
||||
*/
|
||||
export class ToolDisplayUtils {
|
||||
/**
|
||||
* Get the display name for a tool based on its parameters
|
||||
*/
|
||||
static getToolDisplayName(block: ToolUse): string {
|
||||
if (block.name === "list_files") {
|
||||
return block.params.recursive?.toLowerCase() === "true" ? "listFilesRecursive" : "listFilesTopLevel"
|
||||
}
|
||||
return "readFile"
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a descriptive string for a tool execution
|
||||
*/
|
||||
static getToolDescription(block: ToolUse): string {
|
||||
switch (block.name) {
|
||||
case "execute_command":
|
||||
return `[${block.name} for '${block.params.command}']`
|
||||
case "read_file":
|
||||
return `[${block.name} for '${block.params.path}']`
|
||||
case "write_to_file":
|
||||
return `[${block.name} for '${block.params.path}']`
|
||||
case "replace_in_file":
|
||||
return `[${block.name} for '${block.params.path}']`
|
||||
case "search_files":
|
||||
return `[${block.name} for '${block.params.regex}'${
|
||||
block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
|
||||
}]`
|
||||
case "list_files":
|
||||
return `[${block.name} for '${block.params.path}']`
|
||||
case "list_code_definition_names":
|
||||
return `[${block.name} for '${block.params.path}']`
|
||||
case "browser_action":
|
||||
return `[${block.name} for '${block.params.action}']`
|
||||
case "use_mcp_tool":
|
||||
return `[${block.name} for '${block.params.server_name}']`
|
||||
case "access_mcp_resource":
|
||||
return `[${block.name} for '${block.params.server_name}']`
|
||||
case "ask_followup_question":
|
||||
return `[${block.name} for '${block.params.question}']`
|
||||
case "plan_mode_respond":
|
||||
return `[${block.name}]`
|
||||
case "load_mcp_documentation":
|
||||
return `[${block.name}]`
|
||||
case "attempt_completion":
|
||||
return `[${block.name}]`
|
||||
case "new_task":
|
||||
return `[${block.name} for creating a new task]`
|
||||
case "condense":
|
||||
return `[${block.name}]`
|
||||
case "summarize_task":
|
||||
return `[${block.name}]`
|
||||
case "report_bug":
|
||||
return `[${block.name}]`
|
||||
case "new_rule":
|
||||
return `[${block.name} for '${block.params.path}']`
|
||||
case "web_fetch":
|
||||
return `[${block.name} for '${block.params.url}']`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove partial closing tag from tool parameter text
|
||||
* If block is partial, remove partial closing tag so it's not presented to user
|
||||
*/
|
||||
static removeClosingTag(block: ToolUse, tag: ToolParamName, text?: string): string {
|
||||
if (!block.partial) {
|
||||
return text || ""
|
||||
}
|
||||
if (!text) {
|
||||
return ""
|
||||
}
|
||||
// This regex dynamically constructs a pattern to match the closing tag:
|
||||
// - Optionally matches whitespace before the tag
|
||||
// - Matches '<' or '</' optionally followed by any subset of characters from the tag name
|
||||
const tagRegex = new RegExp(
|
||||
`\\s?<\/?${tag
|
||||
.split("")
|
||||
.map((char) => `(?:${char})?`)
|
||||
.join("")}$`,
|
||||
"g",
|
||||
)
|
||||
return text.replace(tagRegex, "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ToolUse } from "../../../assistant-message"
|
||||
import { ToolValidationUtils } from "./ToolValidationUtils"
|
||||
|
||||
/**
|
||||
* Centralized error handling for tool execution
|
||||
*/
|
||||
export class ToolErrorHandler {
|
||||
/**
|
||||
* Handle validation errors and parameter validation
|
||||
*/
|
||||
static async handleValidationError(
|
||||
block: ToolUse,
|
||||
result: any,
|
||||
config: any,
|
||||
pushToolResult: (content: any, block: ToolUse) => void,
|
||||
saveCheckpoint: () => Promise<void>,
|
||||
sayAndCreateMissingParamError: (toolName: any, paramName: string) => Promise<any>,
|
||||
): Promise<boolean> {
|
||||
// Check for missing path parameter (common across file tools)
|
||||
if (!block.params.path && this.requiresPathParameter(block.name)) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
pushToolResult(await sayAndCreateMissingParamError(block.name, "path"), block)
|
||||
await saveCheckpoint()
|
||||
return true // Error was handled
|
||||
}
|
||||
|
||||
// Check if handler returned a validation error
|
||||
if (ToolValidationUtils.isValidationError(result)) {
|
||||
pushToolResult(result, block)
|
||||
await saveCheckpoint()
|
||||
return true // Error was handled
|
||||
}
|
||||
|
||||
return false // No error to handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool requires a path parameter
|
||||
*/
|
||||
private static requiresPathParameter(toolName: string): boolean {
|
||||
const pathRequiredTools = [
|
||||
"read_file",
|
||||
"write_to_file",
|
||||
"replace_in_file",
|
||||
"new_rule",
|
||||
"list_files",
|
||||
"list_code_definition_names",
|
||||
"search_files",
|
||||
]
|
||||
return pathRequiredTools.includes(toolName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle diff view reset on tool rejection
|
||||
*/
|
||||
static async handleDiffViewReset(config: any): Promise<void> {
|
||||
await config.services.diffViewProvider.revertChanges()
|
||||
await config.services.diffViewProvider.reset()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ToolUse } from "../../../assistant-message"
|
||||
import { ToolExecutorCoordinator } from "../ToolExecutorCoordinator"
|
||||
import { ToolValidationUtils } from "./ToolValidationUtils"
|
||||
import { ClineSay } from "@shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Simple execution strategies for different tool categories
|
||||
*/
|
||||
export class ToolExecutionStrategies {
|
||||
/**
|
||||
* Execute simple tools that don't require complex approval flows
|
||||
*/
|
||||
static async executeSimpleTool(
|
||||
block: ToolUse,
|
||||
coordinator: ToolExecutorCoordinator,
|
||||
config: any,
|
||||
pushToolResult: (content: any, block: ToolUse) => void,
|
||||
): Promise<void> {
|
||||
const result = await coordinator.execute(config, block)
|
||||
pushToolResult(result, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute tools that require validation error checking
|
||||
*/
|
||||
static async executeToolWithValidation(
|
||||
block: ToolUse,
|
||||
coordinator: ToolExecutorCoordinator,
|
||||
config: any,
|
||||
pushToolResult: (content: any, block: ToolUse) => void,
|
||||
): Promise<void> {
|
||||
const result = await coordinator.execute(config, block)
|
||||
|
||||
// Check if handler returned an error
|
||||
if (ToolValidationUtils.isValidationError(result)) {
|
||||
pushToolResult(result, block)
|
||||
return
|
||||
}
|
||||
|
||||
// Push the successful result
|
||||
pushToolResult(result, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute tools that show a loading message first
|
||||
*/
|
||||
static async executeToolWithLoadingMessage(
|
||||
block: ToolUse,
|
||||
coordinator: ToolExecutorCoordinator,
|
||||
config: any,
|
||||
pushToolResult: (content: any, block: ToolUse) => void,
|
||||
say: (
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
) => Promise<number | undefined>,
|
||||
messageType: ClineSay = "load_mcp_documentation" as ClineSay,
|
||||
): Promise<void> {
|
||||
// Show loading message
|
||||
await say(messageType, "", undefined, undefined, false)
|
||||
|
||||
const result = await coordinator.execute(config, block)
|
||||
|
||||
// Check if handler returned an error
|
||||
if (ToolValidationUtils.isValidationError(result)) {
|
||||
pushToolResult(result, block)
|
||||
return
|
||||
}
|
||||
|
||||
// Push the successful result
|
||||
pushToolResult(result, block)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as path from "path"
|
||||
import { ToolUse } from "@core/assistant-message"
|
||||
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
|
||||
import { ToolDisplayUtils } from "./ToolDisplayUtils"
|
||||
|
||||
/**
|
||||
* Utility functions for creating tool-related UI messages
|
||||
*/
|
||||
export class ToolMessageUtils {
|
||||
/**
|
||||
* Create shared message properties for file-related tools
|
||||
*/
|
||||
static async createFileToolMessageProps(
|
||||
block: ToolUse,
|
||||
cwd: string,
|
||||
removeClosingTag: (block: ToolUse, tag: any, text?: string) => string,
|
||||
result?: any,
|
||||
): Promise<any> {
|
||||
const relPath = block.params.path
|
||||
const tool = ToolDisplayUtils.getToolDisplayName(block)
|
||||
|
||||
return {
|
||||
tool,
|
||||
path: getReadablePath(cwd, removeClosingTag(block, "path", relPath)),
|
||||
content: block.name === "list_files" ? result || "" : undefined,
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create shared message properties for write-related tools
|
||||
*/
|
||||
static async createWriteToolMessageProps(
|
||||
block: ToolUse,
|
||||
cwd: string,
|
||||
fileExists: boolean,
|
||||
removeClosingTag: (block: ToolUse, tag: any, text?: string) => string,
|
||||
): Promise<any> {
|
||||
const relPath = block.params.path
|
||||
const content = block.params.content || block.params.diff
|
||||
|
||||
return {
|
||||
tool: fileExists ? "editedExistingFile" : "newFileCreated",
|
||||
path: getReadablePath(cwd, removeClosingTag(block, "path", relPath)),
|
||||
content: removeClosingTag(block, block.name === "replace_in_file" ? "diff" : "content", content),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create message properties for MCP tools
|
||||
*/
|
||||
static createMcpToolMessageProps(block: ToolUse, removeClosingTag: (block: ToolUse, tag: any, text?: string) => string): any {
|
||||
const server_name = block.params.server_name
|
||||
const tool_name = block.params.tool_name
|
||||
const uri = block.params.uri
|
||||
const mcp_arguments = block.params.arguments
|
||||
|
||||
return {
|
||||
type: block.name === "use_mcp_tool" ? "use_mcp_tool" : "access_mcp_resource",
|
||||
serverName: removeClosingTag(block, "server_name", server_name),
|
||||
toolName: removeClosingTag(block, "tool_name", tool_name),
|
||||
uri: removeClosingTag(block, "uri", uri),
|
||||
arguments: removeClosingTag(block, "arguments", mcp_arguments),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notification message for tool approval
|
||||
*/
|
||||
static createNotificationMessage(block: ToolUse, relPath?: string, fileExists?: boolean): string {
|
||||
switch (block.name) {
|
||||
case "list_files":
|
||||
return `Cline wants to view directory ${path.basename(path.resolve(relPath || ""))}/`
|
||||
case "read_file":
|
||||
case "list_code_definition_names":
|
||||
case "search_files":
|
||||
return `Cline wants to read ${path.basename(path.resolve(relPath || ""))}`
|
||||
case "write_to_file":
|
||||
case "replace_in_file":
|
||||
case "new_rule":
|
||||
return `Cline wants to ${fileExists ? "edit" : "create"} ${path.basename(relPath || "")}`
|
||||
case "use_mcp_tool":
|
||||
return `Cline wants to use ${block.params.tool_name} on ${block.params.server_name}`
|
||||
case "access_mcp_resource":
|
||||
return `Cline wants to access ${block.params.uri} on ${block.params.server_name}`
|
||||
default:
|
||||
return `Cline wants to use ${block.name}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ToolUse } from "@core/assistant-message"
|
||||
import { ToolResponse } from "@core/task"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import { isNextGenModelFamily } from "@utils/model-utils"
|
||||
import { ApiHandler } from "@api/index"
|
||||
|
||||
/**
|
||||
* Utility functions for handling tool results and feedback
|
||||
*/
|
||||
export class ToolResultUtils {
|
||||
/**
|
||||
* Push tool result to user message content with proper formatting
|
||||
*/
|
||||
static pushToolResult(
|
||||
content: ToolResponse,
|
||||
block: ToolUse,
|
||||
userMessageContent: any[],
|
||||
toolDescription: (block: ToolUse) => string,
|
||||
api: ApiHandler,
|
||||
markToolAsUsed: () => void,
|
||||
): void {
|
||||
const isNextGenModel = isNextGenModelFamily(api)
|
||||
|
||||
if (typeof content === "string") {
|
||||
const resultText = content || "(tool did not return anything)"
|
||||
|
||||
// Non-Claude 4: Use traditional format with header
|
||||
userMessageContent.push({
|
||||
type: "text",
|
||||
text: `${toolDescription(block)} Result:`,
|
||||
})
|
||||
userMessageContent.push({
|
||||
type: "text",
|
||||
text: resultText,
|
||||
})
|
||||
} else {
|
||||
userMessageContent.push(...content)
|
||||
}
|
||||
// once a tool result has been collected, ignore all other tool uses since we should only ever present one tool result per message
|
||||
markToolAsUsed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Push additional tool feedback from user to message content
|
||||
*/
|
||||
static pushAdditionalToolFeedback(
|
||||
userMessageContent: any[],
|
||||
feedback?: string,
|
||||
images?: string[],
|
||||
fileContentString?: string,
|
||||
): void {
|
||||
if (!feedback && (!images || images.length === 0) && !fileContentString) {
|
||||
return
|
||||
}
|
||||
const content = formatResponse.toolResult(
|
||||
`The user provided the following feedback:\n<feedback>\n${feedback}\n</feedback>`,
|
||||
images,
|
||||
fileContentString,
|
||||
)
|
||||
if (typeof content === "string") {
|
||||
userMessageContent.push({
|
||||
type: "text",
|
||||
text: content,
|
||||
})
|
||||
} else {
|
||||
userMessageContent.push(...content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process files into text content for feedback
|
||||
*/
|
||||
static async processFilesForFeedback(files?: string[]): Promise<string> {
|
||||
if (!files || files.length === 0) {
|
||||
return ""
|
||||
}
|
||||
return await processFilesIntoText(files)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Utility functions for tool validation and error checking
|
||||
*/
|
||||
export class ToolValidationUtils {
|
||||
/**
|
||||
* Check if a result is a validation error
|
||||
*/
|
||||
static isValidationError(result: any): boolean {
|
||||
return (
|
||||
typeof result === "string" &&
|
||||
(result.includes("Missing required parameter") || result.includes("blocked by .clineignore"))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool result indicates an error condition
|
||||
*/
|
||||
static isToolError(result: any): boolean {
|
||||
return (
|
||||
typeof result === "string" &&
|
||||
(result.includes("Error") ||
|
||||
result.includes("Failed") ||
|
||||
result.includes("blocked by .clineignore") ||
|
||||
result.includes("Missing required parameter"))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { ToolDisplayUtils } from "./ToolDisplayUtils"
|
||||
export { ToolValidationUtils } from "./ToolValidationUtils"
|
||||
export { ToolResultUtils } from "./ToolResultUtils"
|
||||
export { ToolMessageUtils } from "./ToolMessageUtils"
|
||||
export { ToolApprovalManager } from "./ToolApprovalManager"
|
||||
export { ToolErrorHandler } from "./ToolErrorHandler"
|
||||
export { ToolExecutionStrategies } from "./ToolExecutionStrategies"
|
||||
@@ -0,0 +1,872 @@
|
||||
import * as vscode from "vscode"
|
||||
import { findLast, findLastIndex } from "@shared/array"
|
||||
import { ClineCheckpointRestore } from "@shared/WebviewMessage"
|
||||
import { ClineMessage, ClineApiReqInfo, ClineSay } from "@shared/ExtensionMessage"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
|
||||
import { DiffViewProvider } from "@integrations/editor/DiffViewProvider"
|
||||
import { MessageStateHandler } from "../../core/task/message-state"
|
||||
import { ensureTaskDirectoryExists } from "@core/storage/disk"
|
||||
import { sendRelinquishControlEvent } from "@core/controller/ui/subscribeToRelinquishControl"
|
||||
import { ContextManager } from "@core/context/context-management/ContextManager"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import { combineApiRequests } from "@shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
|
||||
import { TaskState } from "../../core/task/TaskState"
|
||||
import pTimeout from "p-timeout"
|
||||
|
||||
// Type definitions for better code organization
|
||||
type SayFunction = (
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
) => Promise<number | undefined>
|
||||
type UpdateTaskHistoryFunction = (historyItem: HistoryItem) => Promise<HistoryItem[]>
|
||||
|
||||
interface CheckpointManagerTask {
|
||||
readonly taskId: string
|
||||
}
|
||||
interface CheckpointManagerConfig {
|
||||
readonly enableCheckpoints: boolean
|
||||
}
|
||||
interface CheckpointManagerServices {
|
||||
readonly fileContextTracker: FileContextTracker
|
||||
readonly diffViewProvider: DiffViewProvider
|
||||
readonly messageStateHandler: MessageStateHandler
|
||||
readonly context: vscode.ExtensionContext
|
||||
readonly taskState: TaskState
|
||||
}
|
||||
interface CheckpointManagerCallbacks {
|
||||
readonly updateTaskHistory: UpdateTaskHistoryFunction
|
||||
readonly cancelTask: () => Promise<void>
|
||||
readonly say: SayFunction
|
||||
readonly postStateToWebview: () => Promise<void>
|
||||
}
|
||||
interface CheckpointManagerInternalState {
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
checkpointTracker?: CheckpointTracker
|
||||
checkpointManagerErrorMessage?: string
|
||||
checkpointTrackerInitPromise?: Promise<CheckpointTracker | undefined>
|
||||
}
|
||||
|
||||
interface CheckpointRestoreStateUpdate {
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
checkpointManagerErrorMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskCheckpointManager
|
||||
*
|
||||
* A dedicated service for managing all checkpoint-related operations within a task.
|
||||
* Provides a clean separation of concerns from the main Task class while maintaining
|
||||
* full access to necessary dependencies and state.
|
||||
*
|
||||
* Public API:
|
||||
* - saveCheckpoint: Creates a new checkpoint of the current workspace state
|
||||
* - restoreCheckpoint: Restores the task to a previous checkpoint
|
||||
* - presentMultifileDiff: Displays a multi-file diff view between checkpoints
|
||||
* - doesLatestTaskCompletionHaveNewChanges: Checks if the latest task completion has new changes, used by the "See New Changes" button
|
||||
*
|
||||
* This class is designed as the main interface between the task and the checkpoint system. It is responsible for:
|
||||
* - Task-specific checkpoint operations (save/restore/diff)
|
||||
* - State management and coordination with other Task components
|
||||
* - Interaction with message state, file context tracking etc.
|
||||
* - User interaction (error messages, notifications)
|
||||
*
|
||||
* For checkpoint operations, the CheckpointTracker class is used to interact with the underlying git logic.
|
||||
*/
|
||||
export class TaskCheckpointManager {
|
||||
private readonly task: CheckpointManagerTask
|
||||
private readonly config: CheckpointManagerConfig
|
||||
private readonly services: CheckpointManagerServices
|
||||
private readonly callbacks: CheckpointManagerCallbacks
|
||||
private readonly taskState: TaskState
|
||||
|
||||
private state: CheckpointManagerInternalState
|
||||
|
||||
constructor(
|
||||
task: CheckpointManagerTask,
|
||||
config: CheckpointManagerConfig,
|
||||
services: CheckpointManagerServices,
|
||||
callbacks: CheckpointManagerCallbacks,
|
||||
initialState: CheckpointManagerInternalState,
|
||||
) {
|
||||
this.task = Object.freeze(task)
|
||||
this.config = config
|
||||
this.services = services
|
||||
this.callbacks = Object.freeze(callbacks)
|
||||
this.taskState = services.taskState
|
||||
this.state = { ...initialState }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API - Core checkpoints operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a checkpoint of the current workspace state
|
||||
* @param isAttemptCompletionMessage - Whether this checkpoint is for an attempt completion message
|
||||
* @param completionMessageTs - Optional timestamp of the completion message to update with checkpoint hash
|
||||
*/
|
||||
async saveCheckpoint(isAttemptCompletionMessage: boolean = false, completionMessageTs?: number): Promise<void> {
|
||||
try {
|
||||
// If checkpoints are disabled or previously encountered a timeout error, return early
|
||||
if (
|
||||
!this.config.enableCheckpoints ||
|
||||
this.state.checkpointManagerErrorMessage?.includes("Checkpoints initialization timed out.")
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set isCheckpointCheckedOut to false for all prior checkpoint_created messages
|
||||
const clineMessages = this.services.messageStateHandler.getClineMessages()
|
||||
clineMessages.forEach((message) => {
|
||||
if (message.say === "checkpoint_created") {
|
||||
message.isCheckpointCheckedOut = false
|
||||
}
|
||||
})
|
||||
|
||||
// Prevent repetitive checkpointTracker initialization errors on non-attempt completion messages
|
||||
if (!this.state.checkpointTracker && !isAttemptCompletionMessage && !this.state.checkpointManagerErrorMessage) {
|
||||
await this.checkpointTrackerCheckAndInit()
|
||||
}
|
||||
// attempt completion messages give it one last chance. Skip if there was a previous checkpoints initialization timeout error.
|
||||
else if (
|
||||
!this.state.checkpointTracker &&
|
||||
isAttemptCompletionMessage &&
|
||||
!this.state.checkpointManagerErrorMessage?.includes("Checkpoints initialization timed out.")
|
||||
) {
|
||||
await this.checkpointTrackerCheckAndInit()
|
||||
}
|
||||
|
||||
// Critical failure to initialize checkpoint tracker, return early
|
||||
if (!this.state.checkpointTracker) {
|
||||
console.error(
|
||||
`[TaskCheckpointManager] Failed to save checkpoint for task ${this.task.taskId}: Checkpoint tracker not available`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Non attempt-completion messages call for a checkpoint_created message to be added
|
||||
if (!isAttemptCompletionMessage) {
|
||||
// Ensure we aren't creating back-to-back checkpoint_created messages
|
||||
const lastMessage = clineMessages.at(-1)
|
||||
if (lastMessage?.say === "checkpoint_created") {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new checkpoint_created message and asynchronously add the commitHash to the say message
|
||||
const messageTs = await this.callbacks.say("checkpoint_created")
|
||||
if (messageTs) {
|
||||
const messages = this.services.messageStateHandler.getClineMessages()
|
||||
const targetMessage = messages.find((m) => m.ts === messageTs)
|
||||
|
||||
if (targetMessage) {
|
||||
this.state.checkpointTracker
|
||||
?.commit()
|
||||
.then(async (commitHash) => {
|
||||
if (commitHash) {
|
||||
targetMessage.lastCheckpointHash = commitHash
|
||||
await this.services.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`[TaskCheckpointManager] Failed to create checkpoint commit for task ${this.task.taskId}:`,
|
||||
error,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// attempt_completion messages are special
|
||||
// First check last 3 messages to see if we already have a recent completion checkpoint
|
||||
// If we do, skip creating a duplicate checkpoint
|
||||
const lastFiveclineMessages = this.services.messageStateHandler.getClineMessages().slice(-3)
|
||||
const lastCompletionResultMessage = findLast(lastFiveclineMessages, (m) => m.say === "completion_result")
|
||||
if (lastCompletionResultMessage?.lastCheckpointHash) {
|
||||
console.log("Completion checkpoint already exists, skipping duplicate checkpoint creation")
|
||||
return
|
||||
}
|
||||
|
||||
// For attempt_completion, commit then update the completion_result message with the checkpoint hash
|
||||
if (this.state.checkpointTracker) {
|
||||
const commitHash = await this.state.checkpointTracker.commit()
|
||||
|
||||
// If a completionMessageTs is provided, update that specific message with the checkpoint hash
|
||||
if (completionMessageTs) {
|
||||
const targetMessage = this.services.messageStateHandler
|
||||
.getClineMessages()
|
||||
.find((m) => m.ts === completionMessageTs)
|
||||
if (targetMessage) {
|
||||
targetMessage.lastCheckpointHash = commitHash
|
||||
await this.services.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
} else {
|
||||
// Fallback to findLast if no timestamp provided - update the last completion_result message
|
||||
if (lastCompletionResultMessage) {
|
||||
lastCompletionResultMessage.lastCheckpointHash = commitHash
|
||||
await this.services.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`[TaskCheckpointManager] Checkpoint tracker does not exist and could not be initialized for attempt completion for task ${this.task.taskId}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error(`[TaskCheckpointManager] Failed to save checkpoint for task ${this.task.taskId}:`, errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a checkpoint by message timestamp
|
||||
* @param messageTs - Timestamp of the message to restore to
|
||||
* @param restoreType - Type of restoration (task, workspace, or both)
|
||||
* @param offset - Optional offset for the message index
|
||||
* @returns checkpointManagerStateUpdate with any state changes that need to be applied
|
||||
*/
|
||||
async restoreCheckpoint(
|
||||
messageTs: number,
|
||||
restoreType: ClineCheckpointRestore,
|
||||
offset?: number,
|
||||
): Promise<CheckpointRestoreStateUpdate> {
|
||||
try {
|
||||
const clineMessages = this.services.messageStateHandler.getClineMessages()
|
||||
const messageIndex = clineMessages.findIndex((m) => m.ts === messageTs) - (offset || 0)
|
||||
// Find the last message before messageIndex that has a lastCheckpointHash
|
||||
const lastHashIndex = findLastIndex(clineMessages.slice(0, messageIndex), (m) => m.lastCheckpointHash !== undefined)
|
||||
const message = clineMessages[messageIndex]
|
||||
const lastMessageWithHash = clineMessages[lastHashIndex]
|
||||
|
||||
if (!message) {
|
||||
console.error(`[TaskCheckpointManager] Message not found for timestamp ${messageTs} in task ${this.task.taskId}`)
|
||||
return {}
|
||||
}
|
||||
|
||||
let didWorkspaceRestoreFail = false
|
||||
|
||||
switch (restoreType) {
|
||||
case "task":
|
||||
break
|
||||
case "taskAndWorkspace":
|
||||
case "workspace":
|
||||
if (!this.config.enableCheckpoints) {
|
||||
const errorMessage = "Checkpoints are disabled in settings."
|
||||
console.error(`[TaskCheckpointManager] ${errorMessage} for task ${this.task.taskId}`)
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
didWorkspaceRestoreFail = true
|
||||
break
|
||||
}
|
||||
|
||||
if (!this.state.checkpointTracker && !this.state.checkpointManagerErrorMessage) {
|
||||
try {
|
||||
this.state.checkpointTracker = await CheckpointTracker.create(
|
||||
this.task.taskId,
|
||||
this.services.context.globalStorageUri.fsPath,
|
||||
this.config.enableCheckpoints,
|
||||
)
|
||||
this.services.messageStateHandler.setCheckpointTracker(this.state.checkpointTracker)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error(
|
||||
`[TaskCheckpointManager] Failed to initialize checkpoint tracker for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
this.state.checkpointManagerErrorMessage = errorMessage
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
didWorkspaceRestoreFail = true
|
||||
}
|
||||
}
|
||||
if (message.lastCheckpointHash && this.state.checkpointTracker) {
|
||||
try {
|
||||
await this.state.checkpointTracker.resetHead(message.lastCheckpointHash)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error(
|
||||
`[TaskCheckpointManager] Failed to restore checkpoint for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
vscode.window.showErrorMessage("Failed to restore checkpoint: " + errorMessage)
|
||||
didWorkspaceRestoreFail = true
|
||||
}
|
||||
} else if (offset && lastMessageWithHash.lastCheckpointHash && this.state.checkpointTracker) {
|
||||
try {
|
||||
await this.state.checkpointTracker.resetHead(lastMessageWithHash.lastCheckpointHash)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error(
|
||||
`[TaskCheckpointManager] Failed to restore offset checkpoint for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
vscode.window.showErrorMessage("Failed to restore offsetcheckpoint: " + errorMessage)
|
||||
didWorkspaceRestoreFail = true
|
||||
}
|
||||
} else if (!offset && lastMessageWithHash.lastCheckpointHash && this.state.checkpointTracker) {
|
||||
// Fallback: restore to most recent checkpoint when target message has no checkpoint hash
|
||||
console.warn(
|
||||
`[TaskCheckpointManager] Message ${messageTs} has no checkpoint hash, falling back to previous checkpoint for task ${this.task.taskId}`,
|
||||
)
|
||||
try {
|
||||
await this.state.checkpointTracker.resetHead(lastMessageWithHash.lastCheckpointHash)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error(
|
||||
`[TaskCheckpointManager] Failed to restore fallback checkpoint for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
vscode.window.showErrorMessage("Failed to restore checkpoint: " + errorMessage)
|
||||
didWorkspaceRestoreFail = true
|
||||
}
|
||||
} else {
|
||||
const errorMessage = "Failed to restore checkpoint: No valid checkpoint hash found"
|
||||
console.error(`[TaskCheckpointManager] ${errorMessage} for task ${this.task.taskId}`)
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
didWorkspaceRestoreFail = true
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const checkpointManagerStateUpdate: CheckpointRestoreStateUpdate = {}
|
||||
|
||||
if (!didWorkspaceRestoreFail) {
|
||||
await this.handleSuccessfulRestore(restoreType, message, messageIndex, messageTs)
|
||||
|
||||
// Collect state updates
|
||||
if (this.state.conversationHistoryDeletedRange !== undefined) {
|
||||
checkpointManagerStateUpdate.conversationHistoryDeletedRange = this.state.conversationHistoryDeletedRange
|
||||
}
|
||||
} else {
|
||||
sendRelinquishControlEvent()
|
||||
|
||||
if (this.state.checkpointManagerErrorMessage !== undefined) {
|
||||
checkpointManagerStateUpdate.checkpointManagerErrorMessage = this.state.checkpointManagerErrorMessage
|
||||
}
|
||||
}
|
||||
|
||||
return checkpointManagerStateUpdate
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error(`[TaskCheckpointManager] Failed to restore checkpoint for task ${this.task.taskId}:`, errorMessage)
|
||||
sendRelinquishControlEvent()
|
||||
return {
|
||||
checkpointManagerErrorMessage: errorMessage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents a multi-file diff view between checkpoints
|
||||
* @param messageTs - Timestamp of the message to show diff for
|
||||
* @param seeNewChangesSinceLastTaskCompletion - Whether to show changes since last completion
|
||||
*/
|
||||
async presentMultifileDiff(messageTs: number, seeNewChangesSinceLastTaskCompletion: boolean): Promise<void> {
|
||||
const relinquishButton = () => {
|
||||
sendRelinquishControlEvent()
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.config.enableCheckpoints) {
|
||||
const errorMessage = "Checkpoints are disabled in settings. Cannot show diff."
|
||||
console.error(`[TaskCheckpointManager] ${errorMessage} for task ${this.task.taskId}`)
|
||||
vscode.window.showInformationMessage(errorMessage)
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[TaskCheckpointManager] presentMultifileDiff for task ${this.task.taskId}, messageTs: ${messageTs}`)
|
||||
const clineMessages = this.services.messageStateHandler.getClineMessages()
|
||||
const messageIndex = clineMessages.findIndex((m) => m.ts === messageTs)
|
||||
const message = clineMessages[messageIndex]
|
||||
if (!message) {
|
||||
console.error(`[TaskCheckpointManager] Message not found for timestamp ${messageTs} in task ${this.task.taskId}`)
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
const hash = message.lastCheckpointHash
|
||||
if (!hash) {
|
||||
console.error(
|
||||
`[TaskCheckpointManager] No checkpoint hash found for message ${messageTs} in task ${this.task.taskId}`,
|
||||
)
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize checkpoint tracker if needed
|
||||
if (!this.state.checkpointTracker && this.config.enableCheckpoints && !this.state.checkpointManagerErrorMessage) {
|
||||
try {
|
||||
this.state.checkpointTracker = await CheckpointTracker.create(
|
||||
this.task.taskId,
|
||||
this.services.context.globalStorageUri.fsPath,
|
||||
this.config.enableCheckpoints,
|
||||
)
|
||||
this.services.messageStateHandler.setCheckpointTracker(this.state.checkpointTracker)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error(
|
||||
`[TaskCheckpointManager] Failed to initialize checkpoint tracker for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
this.state.checkpointManagerErrorMessage = errorMessage
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.state.checkpointTracker) {
|
||||
console.error(`[TaskCheckpointManager] Checkpoint tracker not available for task ${this.task.taskId}`)
|
||||
vscode.window.showErrorMessage("Checkpoint tracker not available")
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
|
||||
let changedFiles:
|
||||
| {
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
before: string
|
||||
after: string
|
||||
}[]
|
||||
| undefined
|
||||
|
||||
if (seeNewChangesSinceLastTaskCompletion) {
|
||||
// Get last task completed
|
||||
const lastTaskCompletedMessageCheckpointHash = findLast(
|
||||
this.services.messageStateHandler.getClineMessages().slice(0, messageIndex),
|
||||
(m) => m.say === "completion_result",
|
||||
)?.lastCheckpointHash
|
||||
|
||||
// This value *should* always exist
|
||||
const firstCheckpointMessageCheckpointHash = this.services.messageStateHandler
|
||||
.getClineMessages()
|
||||
.find((m) => m.say === "checkpoint_created")?.lastCheckpointHash
|
||||
|
||||
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash
|
||||
|
||||
if (!previousCheckpointHash) {
|
||||
const errorMessage = "Unexpected error: No checkpoint hash found"
|
||||
console.error(`[TaskCheckpointManager] ${errorMessage} for task ${this.task.taskId}`)
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
|
||||
// Get changed files between current state and commit
|
||||
changedFiles = await this.state.checkpointTracker.getDiffSet(previousCheckpointHash, hash)
|
||||
if (!changedFiles?.length) {
|
||||
vscode.window.showInformationMessage("No changes found")
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Get changed files between current state and commit
|
||||
changedFiles = await this.state.checkpointTracker.getDiffSet(hash)
|
||||
if (!changedFiles?.length) {
|
||||
vscode.window.showInformationMessage("No changes found")
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Open multi-diff editor
|
||||
await vscode.commands.executeCommand(
|
||||
"vscode.changes",
|
||||
seeNewChangesSinceLastTaskCompletion ? "New changes" : "Changes since snapshot",
|
||||
changedFiles.map((file) => [
|
||||
vscode.Uri.file(file.absolutePath),
|
||||
vscode.Uri.parse(`${DiffViewProvider}:${file.relativePath}`).with({
|
||||
query: Buffer.from(file.before ?? "").toString("base64"),
|
||||
}),
|
||||
vscode.Uri.parse(`${DiffViewProvider}:${file.relativePath}`).with({
|
||||
query: Buffer.from(file.after ?? "").toString("base64"),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
relinquishButton()
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error(`[TaskCheckpointManager] Failed to present multifile diff for task ${this.task.taskId}:`, errorMessage)
|
||||
vscode.window.showErrorMessage("Failed to retrieve diff set: " + errorMessage)
|
||||
relinquishButton()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a checkpoint commit in the underlying tracker
|
||||
* @returns Promise<string | undefined> The created commit hash, or undefined if failed
|
||||
*/
|
||||
async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
if (!this.config.enableCheckpoints) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!this.state.checkpointTracker) {
|
||||
await this.checkpointTrackerCheckAndInit()
|
||||
}
|
||||
|
||||
if (!this.state.checkpointTracker) {
|
||||
console.error(`[TaskCheckpointManager] Checkpoint tracker not available for commit in task ${this.task.taskId}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return await this.state.checkpointTracker.commit()
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error(
|
||||
`[TaskCheckpointManager] Failed to create checkpoint commit for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the latest task completion has new changes
|
||||
* @returns Promise<boolean> - True if there are new changes since last completion
|
||||
*/
|
||||
async doesLatestTaskCompletionHaveNewChanges(): Promise<boolean> {
|
||||
try {
|
||||
if (!this.config.enableCheckpoints) {
|
||||
return false
|
||||
}
|
||||
|
||||
const clineMessages = this.services.messageStateHandler.getClineMessages()
|
||||
const messageIndex = findLastIndex(clineMessages, (m) => m.say === "completion_result")
|
||||
const message = clineMessages[messageIndex]
|
||||
if (!message) {
|
||||
console.error(`[TaskCheckpointManager] Completion message not found for task ${this.task.taskId}`)
|
||||
return false
|
||||
}
|
||||
const hash = message.lastCheckpointHash
|
||||
if (!hash) {
|
||||
console.error(
|
||||
`[TaskCheckpointManager] No checkpoint hash found for completion message in task ${this.task.taskId}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.config.enableCheckpoints && !this.state.checkpointTracker && !this.state.checkpointManagerErrorMessage) {
|
||||
try {
|
||||
this.state.checkpointTracker = await CheckpointTracker.create(
|
||||
this.task.taskId,
|
||||
this.services.context.globalStorageUri.fsPath,
|
||||
this.config.enableCheckpoints,
|
||||
)
|
||||
this.services.messageStateHandler.setCheckpointTracker(this.state.checkpointTracker)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error(
|
||||
`[TaskCheckpointManager] Failed to initialize checkpoint tracker for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.state.checkpointTracker) {
|
||||
console.error(`[TaskCheckpointManager] Checkpoint tracker not available for task ${this.task.taskId}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Get last task completed
|
||||
const lastTaskCompletedMessage = findLast(
|
||||
this.services.messageStateHandler.getClineMessages().slice(0, messageIndex),
|
||||
(m) => m.say === "completion_result",
|
||||
)
|
||||
|
||||
// Get last task completed
|
||||
const lastTaskCompletedMessageCheckpointHash = lastTaskCompletedMessage?.lastCheckpointHash
|
||||
|
||||
// This value *should* always exist
|
||||
const firstCheckpointMessageCheckpointHash = this.services.messageStateHandler
|
||||
.getClineMessages()
|
||||
.find((m) => m.say === "checkpoint_created")?.lastCheckpointHash
|
||||
|
||||
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash
|
||||
|
||||
if (!previousCheckpointHash) {
|
||||
console.error(`[TaskCheckpointManager] No previous checkpoint hash found for task ${this.task.taskId}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Get count of changed files between current state and commit
|
||||
const changedFilesCount = (await this.state.checkpointTracker.getDiffCount(previousCheckpointHash, hash)) || 0
|
||||
return changedFilesCount > 0
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error(`[TaskCheckpointManager] Failed to check for new changes in task ${this.task.taskId}:`, errorMessage)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the successful restoration logic for different restore types
|
||||
*/
|
||||
// Largely unchanged from original Task class implementation
|
||||
private async handleSuccessfulRestore(
|
||||
restoreType: ClineCheckpointRestore,
|
||||
message: ClineMessage,
|
||||
messageIndex: number,
|
||||
messageTs: number,
|
||||
): Promise<void> {
|
||||
switch (restoreType) {
|
||||
case "task":
|
||||
case "taskAndWorkspace":
|
||||
// Update conversation history deleted range in our state
|
||||
this.state.conversationHistoryDeletedRange = message.conversationHistoryDeletedRange
|
||||
|
||||
const apiConversationHistory = this.services.messageStateHandler.getApiConversationHistory()
|
||||
const newConversationHistory = apiConversationHistory.slice(0, (message.conversationHistoryIndex || 0) + 2) // +1 since this index corresponds to the last user message, and another +1 since slice end index is exclusive
|
||||
await this.services.messageStateHandler.overwriteApiConversationHistory(newConversationHistory)
|
||||
|
||||
// update the context history state
|
||||
const contextManager = new ContextManager()
|
||||
await contextManager.truncateContextHistory(
|
||||
message.ts,
|
||||
await ensureTaskDirectoryExists(this.getContext(), this.task.taskId),
|
||||
)
|
||||
|
||||
// aggregate deleted api reqs info so we don't lose costs/tokens
|
||||
const clineMessages = this.services.messageStateHandler.getClineMessages()
|
||||
const deletedMessages = clineMessages.slice(messageIndex + 1)
|
||||
const deletedApiReqsMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(deletedMessages)))
|
||||
|
||||
// Detect files edited after this message timestamp for file context warning
|
||||
// Only needed for task-only restores when a user edits a message or restores the task context, but not the files.
|
||||
if (restoreType === "task") {
|
||||
const filesEditedAfterMessage = await this.services.fileContextTracker.detectFilesEditedAfterMessage(
|
||||
messageTs,
|
||||
deletedMessages,
|
||||
)
|
||||
if (filesEditedAfterMessage.length > 0) {
|
||||
await this.services.fileContextTracker.storePendingFileContextWarning(filesEditedAfterMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const newClineMessages = clineMessages.slice(0, messageIndex + 1)
|
||||
await this.services.messageStateHandler.overwriteClineMessages(newClineMessages) // calls saveClineMessages which saves historyItem
|
||||
|
||||
await this.callbacks.say(
|
||||
"deleted_api_reqs",
|
||||
JSON.stringify({
|
||||
tokensIn: deletedApiReqsMetrics.totalTokensIn,
|
||||
tokensOut: deletedApiReqsMetrics.totalTokensOut,
|
||||
cacheWrites: deletedApiReqsMetrics.totalCacheWrites,
|
||||
cacheReads: deletedApiReqsMetrics.totalCacheReads,
|
||||
cost: deletedApiReqsMetrics.totalCost,
|
||||
} satisfies ClineApiReqInfo),
|
||||
)
|
||||
break
|
||||
case "workspace":
|
||||
break
|
||||
}
|
||||
|
||||
switch (restoreType) {
|
||||
case "task":
|
||||
vscode.window.showInformationMessage("Task messages have been restored to the checkpoint")
|
||||
break
|
||||
case "workspace":
|
||||
vscode.window.showInformationMessage("Workspace files have been restored to the checkpoint")
|
||||
break
|
||||
case "taskAndWorkspace":
|
||||
vscode.window.showInformationMessage("Task and workspace have been restored to the checkpoint")
|
||||
break
|
||||
}
|
||||
|
||||
if (restoreType !== "task") {
|
||||
// Set isCheckpointCheckedOut flag on the message
|
||||
// Find all checkpoint messages before this one
|
||||
const checkpointMessages = this.services.messageStateHandler
|
||||
.getClineMessages()
|
||||
.filter((m) => m.say === "checkpoint_created")
|
||||
const currentMessageIndex = checkpointMessages.findIndex((m) => m.ts === messageTs)
|
||||
|
||||
// Set isCheckpointCheckedOut to false for all checkpoint messages
|
||||
checkpointMessages.forEach((m, i) => {
|
||||
m.isCheckpointCheckedOut = i === currentMessageIndex
|
||||
})
|
||||
}
|
||||
|
||||
await this.services.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
|
||||
// Cancel and reinitialize the task to get updated messages
|
||||
await this.callbacks.cancelTask()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State management - interfaces for updating internal state
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Checks for an active checkpoint tracker instance, creates if needed
|
||||
* Uses promise-based synchronization to prevent race conditions when called concurrently
|
||||
*/
|
||||
async checkpointTrackerCheckAndInit(): Promise<CheckpointTracker | undefined> {
|
||||
// If tracker already exists or there was an error, return immediately
|
||||
if (this.state.checkpointTracker) {
|
||||
return this.state.checkpointTracker
|
||||
}
|
||||
|
||||
// If initialization is already in progress, wait for it to complete
|
||||
if (this.state.checkpointTrackerInitPromise) {
|
||||
return await this.state.checkpointTrackerInitPromise
|
||||
}
|
||||
|
||||
// Start initialization and store the promise to prevent concurrent attempts
|
||||
this.state.checkpointTrackerInitPromise = this.initializeCheckpointTracker()
|
||||
|
||||
try {
|
||||
const tracker = await this.state.checkpointTrackerInitPromise
|
||||
return tracker
|
||||
} finally {
|
||||
// Clear the promise once initialization is complete (success or failure)
|
||||
this.state.checkpointTrackerInitPromise = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to actually create the checkpoint tracker
|
||||
*/
|
||||
private async initializeCheckpointTracker(): Promise<CheckpointTracker | undefined> {
|
||||
// Warning Timer - If checkpoints take a while to initialize, show a warning message
|
||||
let checkpointsWarningTimer: NodeJS.Timeout | null = null
|
||||
let checkpointsWarningShown = false
|
||||
|
||||
try {
|
||||
checkpointsWarningTimer = setTimeout(async () => {
|
||||
if (!checkpointsWarningShown) {
|
||||
checkpointsWarningShown = true
|
||||
await this.setcheckpointManagerErrorMessage(
|
||||
"Checkpoints are taking longer than expected to initialize. Working in a large repository? Consider re-opening Cline in a project that uses git, or disabling checkpoints.",
|
||||
)
|
||||
}
|
||||
}, 7_000)
|
||||
|
||||
// Timeout - If checkpoints take too long to initialize, warn user and disable checkpoints for the task
|
||||
const tracker = await pTimeout(
|
||||
CheckpointTracker.create(
|
||||
this.task.taskId,
|
||||
this.services.context.globalStorageUri.fsPath,
|
||||
this.config.enableCheckpoints,
|
||||
),
|
||||
{
|
||||
milliseconds: 15_000,
|
||||
message:
|
||||
"Checkpoints taking too long to initialize. Consider re-opening Cline in a project that uses git, or disabling checkpoints.",
|
||||
},
|
||||
)
|
||||
|
||||
// Update the state with the created tracker
|
||||
this.state.checkpointTracker = tracker
|
||||
return tracker
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
|
||||
// If the error was a timeout, we disable all checkpoint operations for the rest of the task
|
||||
if (errorMessage.includes("Checkpoints taking too long to initialize")) {
|
||||
await this.setcheckpointManagerErrorMessage(
|
||||
"Checkpoints initialization timed out. Consider re-opening Cline in a project that uses git, or disabling checkpoints.",
|
||||
)
|
||||
} else {
|
||||
await this.setcheckpointManagerErrorMessage(errorMessage)
|
||||
}
|
||||
return undefined
|
||||
} finally {
|
||||
// Always clean up the timer to prevent memory leaks
|
||||
if (checkpointsWarningTimer) {
|
||||
clearTimeout(checkpointsWarningTimer)
|
||||
checkpointsWarningTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the checkpoint tracker instance
|
||||
*/
|
||||
setCheckpointTracker(checkpointTracker: CheckpointTracker | undefined): void {
|
||||
this.state.checkpointTracker = checkpointTracker
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the checkpoint tracker error message and posts to webview
|
||||
*/
|
||||
async setcheckpointManagerErrorMessage(errorMessage: string | undefined): Promise<void> {
|
||||
this.state.checkpointManagerErrorMessage = errorMessage
|
||||
this.taskState.checkpointManagerErrorMessage = errorMessage
|
||||
// Post state to webview so users can see the error message immediately
|
||||
try {
|
||||
await this.callbacks.postStateToWebview()
|
||||
} catch (error) {
|
||||
console.error("Failed to post state to webview after checkpoint error:", error)
|
||||
}
|
||||
// TODO - Future telemetry event capture here
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the conversation history deleted range
|
||||
*/
|
||||
updateConversationHistoryDeletedRange(range: [number, number] | undefined): void {
|
||||
this.state.conversationHistoryDeletedRange = range
|
||||
// TODO - Future telemetry event capture here
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Internal utilities - Private helpers for checkpoint operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Gets the extension context with proper error handling
|
||||
*/
|
||||
private getContext(): vscode.ExtensionContext {
|
||||
if (!this.services.context) {
|
||||
throw new Error("Unable to access extension context")
|
||||
}
|
||||
return this.services.context
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides read-only access to current state for internal operations
|
||||
*/
|
||||
//private get currentState(): Readonly<CheckpointManagerInternalState> {
|
||||
// return Object.freeze({ ...this.state })
|
||||
//}
|
||||
|
||||
/**
|
||||
* Provides public read-only access to current state
|
||||
*/
|
||||
public getCurrentState(): Readonly<CheckpointManagerInternalState> {
|
||||
return Object.freeze({ ...this.state })
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides read-only access to dependencies for internal operations
|
||||
*/
|
||||
//private get deps(): Readonly<CheckpointManagerDependencies> {
|
||||
// return this.dependencies
|
||||
//}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Factory function for clean instantiation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a new TaskCheckpointManager instance
|
||||
*/
|
||||
export function createTaskCheckpointManager(
|
||||
task: CheckpointManagerTask,
|
||||
config: CheckpointManagerConfig,
|
||||
services: CheckpointManagerServices,
|
||||
callbacks: CheckpointManagerCallbacks,
|
||||
initialState: CheckpointManagerInternalState,
|
||||
): TaskCheckpointManager {
|
||||
return new TaskCheckpointManager(task, config, services, callbacks, initialState)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export interface ExtensionState {
|
||||
preferredLanguage?: string
|
||||
openaiReasoningEffort?: OpenaiReasoningEffort
|
||||
mode: Mode
|
||||
checkpointTrackerErrorMessage?: string
|
||||
checkpointManagerErrorMessage?: string
|
||||
clineMessages: ClineMessage[]
|
||||
currentTaskItem?: HistoryItem
|
||||
currentFocusChainChecklist?: string | null
|
||||
|
||||
@@ -14,5 +14,5 @@ export type HistoryItem = {
|
||||
cwdOnTaskInitialization?: string
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
isFavorited?: boolean
|
||||
checkpointTrackerErrorMessage?: string
|
||||
checkpointManagerErrorMessage?: string
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedText, setEditedText] = useState(text || "")
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const { checkpointTrackerErrorMessage } = useExtensionState()
|
||||
const { checkpointManagerErrorMessage } = useExtensionState()
|
||||
|
||||
// Create refs for the buttons to check in the blur handler
|
||||
const restoreAllButtonRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -77,7 +77,7 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsEditing(false)
|
||||
} else if (e.key === "Enter" && e.metaKey && !checkpointTrackerErrorMessage) {
|
||||
} else if (e.key === "Enter" && e.metaKey && !checkpointManagerErrorMessage) {
|
||||
handleRestoreWorkspace("taskAndWorkspace")
|
||||
} else if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) {
|
||||
e.preventDefault()
|
||||
@@ -124,7 +124,7 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: "8px", marginTop: "8px", justifyContent: "flex-end" }}>
|
||||
{!checkpointTrackerErrorMessage && (
|
||||
{!checkpointManagerErrorMessage && (
|
||||
<RestoreButton
|
||||
ref={restoreAllButtonRef}
|
||||
type="taskAndWorkspace"
|
||||
|
||||
@@ -78,7 +78,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
onClose,
|
||||
onScrollToMessage,
|
||||
}) => {
|
||||
const { apiConfiguration, currentTaskItem, checkpointTrackerErrorMessage, clineMessages, navigateToSettings, mode } =
|
||||
const { apiConfiguration, currentTaskItem, checkpointManagerErrorMessage, clineMessages, navigateToSettings, mode } =
|
||||
useExtensionState()
|
||||
const [isTaskExpanded, setIsTaskExpanded] = useState(true)
|
||||
const [isTextExpanded, setIsTextExpanded] = useState(false)
|
||||
@@ -91,13 +91,13 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
const contextWindow = selectedModelInfo?.contextWindow
|
||||
|
||||
// Open task header when checkpoint tracker error message is set
|
||||
const prevErrorMessageRef = useRef(checkpointTrackerErrorMessage)
|
||||
const prevErrorMessageRef = useRef(checkpointManagerErrorMessage)
|
||||
useEffect(() => {
|
||||
if (checkpointTrackerErrorMessage !== prevErrorMessageRef.current) {
|
||||
if (checkpointManagerErrorMessage !== prevErrorMessageRef.current) {
|
||||
setIsTaskExpanded(true)
|
||||
prevErrorMessageRef.current = checkpointTrackerErrorMessage
|
||||
prevErrorMessageRef.current = checkpointManagerErrorMessage
|
||||
}
|
||||
}, [checkpointTrackerErrorMessage])
|
||||
}, [checkpointManagerErrorMessage])
|
||||
|
||||
// Reset isTextExpanded when task is collapsed
|
||||
useEffect(() => {
|
||||
@@ -729,7 +729,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkpointTrackerErrorMessage && (
|
||||
{checkpointManagerErrorMessage && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -740,8 +740,8 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
}}>
|
||||
<i className="codicon codicon-warning" />
|
||||
<span>
|
||||
{checkpointTrackerErrorMessage.replace(/disabling checkpoints\.$/, "")}
|
||||
{checkpointTrackerErrorMessage.endsWith("disabling checkpoints.") && (
|
||||
{checkpointManagerErrorMessage.replace(/disabling checkpoints\.$/, "")}
|
||||
{checkpointManagerErrorMessage.endsWith("disabling checkpoints.") && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -764,7 +764,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{checkpointTrackerErrorMessage.includes("Git must be installed to use checkpoints.") && (
|
||||
{checkpointManagerErrorMessage.includes("Git must be installed to use checkpoints.") && (
|
||||
<>
|
||||
{" "}
|
||||
<a
|
||||
|
||||
Reference in New Issue
Block a user