mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
delete unused files
This commit is contained in:
@@ -129,6 +129,7 @@
|
||||
"!!**/playwright",
|
||||
"!!**/.vscode-test",
|
||||
"!!**/test-results",
|
||||
"!!**/coverage",
|
||||
"!!**/node_modules",
|
||||
"!!**/webview-ui/build",
|
||||
"!!**/generated",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,606 +0,0 @@
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
|
||||
const APPLY_PATCH_PATCH_REGEX = /\*\*\* Begin Patch\s+([\s\S]*?)\s+\*\*\* End Patch/m
|
||||
|
||||
/**
|
||||
* Convert apply_patch tool calls to write_to_file and replace_in_file format
|
||||
*/
|
||||
export function convertApplyPatchToolCalls(messages: Array<ClineStorageMessage>): Array<ClineStorageMessage> {
|
||||
// Map to track tool_use_id to converted tool info and original input
|
||||
const toolUseIdMap = new Map<string, { name: string; input: any; originalInput: any }>()
|
||||
|
||||
return messages.map((message) => {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message
|
||||
}
|
||||
|
||||
const convertedContent = message.content.map((block) => {
|
||||
// Handle tool_use blocks
|
||||
if (block.type === "tool_use" && block.name === "apply_patch") {
|
||||
const converted = convertApplyPatchToToolCalls(block.input)
|
||||
// Store the conversion with original input for matching tool_result
|
||||
toolUseIdMap.set(block.id, { ...converted, originalInput: block.input })
|
||||
|
||||
return {
|
||||
...block,
|
||||
name: converted.name,
|
||||
input: converted.input,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool_result blocks
|
||||
if (block.type === "tool_result") {
|
||||
const conversion = toolUseIdMap.get(block.tool_use_id)
|
||||
if (conversion) {
|
||||
// Reconstruct the tool_result content to match apply_patch format
|
||||
const reconstructedContent = reconstructApplyPatchResult(
|
||||
block,
|
||||
conversion.name,
|
||||
conversion.input,
|
||||
conversion.originalInput,
|
||||
)
|
||||
return {
|
||||
...block,
|
||||
content: reconstructedContent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: convertedContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface ConvertedTool {
|
||||
name: string
|
||||
input: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse apply_patch input and convert to write_to_file or replace_in_file format
|
||||
*/
|
||||
function convertApplyPatchToToolCalls(input: any): ConvertedTool {
|
||||
const patchInput = typeof input === "string" ? input : input?.input || ""
|
||||
|
||||
// Parse the patch format
|
||||
const patchMatch = patchInput.match(APPLY_PATCH_PATCH_REGEX)
|
||||
if (!patchMatch) {
|
||||
// If we can't parse it, return as-is with write_to_file
|
||||
return {
|
||||
name: "write_to_file",
|
||||
input: input,
|
||||
}
|
||||
}
|
||||
|
||||
const patchContent = patchMatch[1]
|
||||
|
||||
// Extract file operation (Add, Update, or Delete)
|
||||
const fileMatch = patchContent.match(/\*\*\* (Add|Update|Delete) File: (.+?)(?:\n|$)/m)
|
||||
if (!fileMatch) {
|
||||
return {
|
||||
name: "write_to_file",
|
||||
input: input,
|
||||
}
|
||||
}
|
||||
|
||||
const action = fileMatch[1]
|
||||
const filePath = fileMatch[2].trim()
|
||||
|
||||
// If it's an Add operation, convert to write_to_file
|
||||
if (action === "Add") {
|
||||
// Extract the content after the file line
|
||||
const contentAfterFile = patchContent.substring(fileMatch.index! + fileMatch[0].length)
|
||||
return {
|
||||
name: "write_to_file",
|
||||
input: {
|
||||
absolutePath: filePath,
|
||||
content: extractNewContentFromPatch(contentAfterFile),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// If it's Update or Delete, convert to replace_in_file
|
||||
if (action === "Update" || action === "Delete") {
|
||||
const diff = convertPatchToDiff(patchContent.substring(fileMatch.index! + fileMatch[0].length))
|
||||
return {
|
||||
name: "replace_in_file",
|
||||
input: {
|
||||
absolutePath: filePath,
|
||||
diff: diff,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return {
|
||||
name: "write_to_file",
|
||||
input: input,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract new content from add operation patch
|
||||
*/
|
||||
function extractNewContentFromPatch(patchContent: string): string {
|
||||
// For Add operations, the patch should contain lines starting with +
|
||||
const lines = patchContent.split("\n")
|
||||
const contentLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("+")) {
|
||||
// Remove the + prefix and exactly ONE space if present (but not if it's a tab)
|
||||
let content = line.substring(1)
|
||||
if (content.startsWith(" ") && !content.startsWith("\t")) {
|
||||
content = content.substring(1)
|
||||
}
|
||||
contentLines.push(content)
|
||||
}
|
||||
}
|
||||
|
||||
return contentLines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert V4A patch format to SEARCH/REPLACE format
|
||||
*/
|
||||
function convertPatchToDiff(patchContent: string): string {
|
||||
const diffBlocks: string[] = []
|
||||
const lines = patchContent.split("\n")
|
||||
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
// Skip empty lines at the start
|
||||
if (!line.trim() && i === 0) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is the start of a hunk (@@) or a direct change line
|
||||
if (line.trim().startsWith("@@") || line.startsWith("-") || line.startsWith("+")) {
|
||||
const currentSearch: string[] = []
|
||||
const currentReplace: string[] = []
|
||||
|
||||
// Collect @@ context marker lines
|
||||
// @@ prefix marks context lines. If @@something, then "something" is context.
|
||||
// If just @@, then it's an empty context line.
|
||||
while (i < lines.length && lines[i].trim().startsWith("@@")) {
|
||||
const trimmedLine = lines[i].trim()
|
||||
// Extract the actual context content after @@
|
||||
const contextLine = trimmedLine.substring(2)
|
||||
// Always add the context line (even if empty)
|
||||
currentSearch.push(contextLine)
|
||||
currentReplace.push(contextLine)
|
||||
i++
|
||||
}
|
||||
|
||||
if (i >= lines.length) {
|
||||
break
|
||||
}
|
||||
|
||||
// Collect all remaining lines in this hunk until we hit end of content or next @@
|
||||
const hunkLines: string[] = []
|
||||
while (i < lines.length) {
|
||||
// Check if this is a new hunk (starts with @@)
|
||||
if (lines[i].trim().startsWith("@@")) {
|
||||
break
|
||||
}
|
||||
hunkLines.push(lines[i])
|
||||
i++
|
||||
}
|
||||
|
||||
// Now process the hunk to build SEARCH/REPLACE
|
||||
let hasChanges = false
|
||||
for (let j = 0; j < hunkLines.length; j++) {
|
||||
const hunkLine = hunkLines[j]
|
||||
|
||||
if (hunkLine.startsWith("-")) {
|
||||
hasChanges = true
|
||||
// Strip the - prefix and exactly ONE space if present (but not if it's a tab)
|
||||
let content = hunkLine.substring(1)
|
||||
if (content.startsWith(" ") && !content.startsWith(" \t")) {
|
||||
content = content.substring(1)
|
||||
}
|
||||
currentSearch.push(content)
|
||||
} else if (hunkLine.startsWith("+")) {
|
||||
hasChanges = true
|
||||
// Strip the + prefix and exactly ONE space if present (but not if it's a tab)
|
||||
let content = hunkLine.substring(1)
|
||||
if (content.startsWith(" ") && !content.startsWith(" \t")) {
|
||||
content = content.substring(1)
|
||||
}
|
||||
currentReplace.push(content)
|
||||
} else {
|
||||
// Context line without @@ prefix - add to both sides
|
||||
currentSearch.push(hunkLine)
|
||||
currentReplace.push(hunkLine)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the diff block if we have changes
|
||||
if (hasChanges && (currentSearch.length > 0 || currentReplace.length > 0)) {
|
||||
diffBlocks.push(
|
||||
"------- SEARCH\n" +
|
||||
currentSearch.join("\n") +
|
||||
"\n=======\n" +
|
||||
currentReplace.join("\n") +
|
||||
"\n+++++++ REPLACE",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return diffBlocks.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct tool_result content to match apply_patch format by extracting
|
||||
* the final file content and converting it back to V4A patch format
|
||||
*/
|
||||
function reconstructApplyPatchResult(
|
||||
block: any,
|
||||
convertedToolName: string,
|
||||
_convertedInput: any,
|
||||
originalInput: any,
|
||||
): string | any[] {
|
||||
// Extract the content from the tool_result
|
||||
const content = typeof block.content === "string" ? block.content : ""
|
||||
|
||||
// Try to extract the final_file_content
|
||||
const finalContentMatch = content.match(/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/)
|
||||
|
||||
if (!finalContentMatch) {
|
||||
// If no final_file_content found, return original content
|
||||
return block.content
|
||||
}
|
||||
|
||||
const filePath = finalContentMatch[1]
|
||||
const finalContent = finalContentMatch[2]
|
||||
|
||||
// Reconstruct the result message based on the converted tool type
|
||||
if (convertedToolName === "write_to_file") {
|
||||
// For write_to_file, we just need to confirm the file was created/written
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
|
||||
}
|
||||
|
||||
if (convertedToolName === "replace_in_file") {
|
||||
// For replace_in_file, we need to reconstruct the V4A patch format result
|
||||
// Try to parse the original patch to get the action and build context
|
||||
const patchInput = typeof originalInput === "string" ? originalInput : originalInput?.input || ""
|
||||
const patchMatch = patchInput.match(APPLY_PATCH_PATCH_REGEX)
|
||||
|
||||
if (patchMatch) {
|
||||
const patchContent = patchMatch[1]
|
||||
const fileMatch = patchContent.match(/\*\*\* (Add|Update|Delete) File: (.+?)(?:\n|$)/m)
|
||||
|
||||
if (fileMatch) {
|
||||
const action = fileMatch[1]
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified using ${action} operation.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for replace_in_file
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return block.content
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert write_to_file and replace_in_file tool calls to apply_patch format
|
||||
*/
|
||||
export function convertWriteToFileToolCalls(messages: Array<ClineStorageMessage>): Array<ClineStorageMessage> {
|
||||
// Map to track tool_use_id to converted tool info and original input
|
||||
const toolUseIdMap = new Map<string, { originalName: string; originalInput: any; patchInput?: string }>()
|
||||
|
||||
// First pass: collect tool_use blocks
|
||||
for (const message of messages) {
|
||||
if (!Array.isArray(message.content)) {
|
||||
continue
|
||||
}
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_use" && (block.name === "write_to_file" || block.name === "replace_in_file")) {
|
||||
toolUseIdMap.set(block.id, {
|
||||
originalName: block.name,
|
||||
originalInput: block.input,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: find tool_results and extract final content to build proper patches
|
||||
const finalContentMap = new Map<string, string>()
|
||||
for (const message of messages) {
|
||||
if (!Array.isArray(message.content)) {
|
||||
continue
|
||||
}
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_result" && toolUseIdMap.has(block.tool_use_id)) {
|
||||
const content = typeof block.content === "string" ? block.content : ""
|
||||
const finalContentMatch = content.match(
|
||||
/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/,
|
||||
)
|
||||
if (finalContentMatch) {
|
||||
finalContentMap.set(block.tool_use_id, finalContentMatch[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: convert messages
|
||||
return messages.map((message) => {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return message
|
||||
}
|
||||
|
||||
const convertedContent = message.content.map((block) => {
|
||||
// Handle tool_use blocks for write_to_file and replace_in_file
|
||||
if (block.type === "tool_use" && (block.name === "write_to_file" || block.name === "replace_in_file")) {
|
||||
const finalContent = finalContentMap.get(block.id)
|
||||
const patchInput = convertToPatchFormat(block.name, block.input, finalContent)
|
||||
|
||||
// Update the map with the generated patch
|
||||
const existingEntry = toolUseIdMap.get(block.id)
|
||||
if (existingEntry) {
|
||||
existingEntry.patchInput = patchInput
|
||||
}
|
||||
|
||||
return {
|
||||
...block,
|
||||
name: "apply_patch",
|
||||
input: {
|
||||
input: patchInput,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool_result blocks
|
||||
if (block.type === "tool_result") {
|
||||
const conversion = toolUseIdMap.get(block.tool_use_id)
|
||||
if (conversion) {
|
||||
// Reconstruct the tool_result content to match apply_patch format
|
||||
const reconstructedContent = reconstructWriteToFileResult(
|
||||
block,
|
||||
conversion.originalName,
|
||||
conversion.originalInput,
|
||||
)
|
||||
return {
|
||||
...block,
|
||||
content: reconstructedContent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: convertedContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert write_to_file or replace_in_file input to apply_patch format
|
||||
*/
|
||||
function convertToPatchFormat(toolName: string, input: any, finalContent?: string): string {
|
||||
const filePath = input.absolutePath || input.path || ""
|
||||
|
||||
if (toolName === "write_to_file") {
|
||||
// Convert write_to_file to Add operation
|
||||
const content = input.content || ""
|
||||
const lines = content.split("\n")
|
||||
const patchLines = ["@@"]
|
||||
patchLines.push(...lines.map((line: string) => `+ ${line}`))
|
||||
|
||||
return `apply_patch <<"EOF"
|
||||
*** Begin Patch
|
||||
*** Add File: ${filePath}
|
||||
${patchLines.join("\n")}
|
||||
*** End Patch
|
||||
EOF`
|
||||
}
|
||||
|
||||
if (toolName === "replace_in_file") {
|
||||
// Convert replace_in_file to Update operation
|
||||
const diff = input.diff || ""
|
||||
|
||||
// Parse SEARCH/REPLACE blocks and convert to V4A format with context
|
||||
const patchContent = convertDiffToPatchWithContext(diff, finalContent)
|
||||
|
||||
return `apply_patch <<"EOF"
|
||||
*** Begin Patch
|
||||
*** Update File: ${filePath}
|
||||
${patchContent}
|
||||
*** End Patch
|
||||
EOF`
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SEARCH/REPLACE diff format to V4A patch format with additional context from final content
|
||||
*/
|
||||
function convertDiffToPatchWithContext(diff: string, finalContent?: string): string {
|
||||
const patchLines: string[] = []
|
||||
|
||||
// Match all SEARCH/REPLACE blocks
|
||||
const blockRegex = /------- SEARCH\s*\n([\s\S]*?)\n=======\s*\n([\s\S]*?)\n\+{7} REPLACE/g
|
||||
let match
|
||||
|
||||
while ((match = blockRegex.exec(diff)) !== null) {
|
||||
const searchContent = match[1]
|
||||
const replaceContent = match[2]
|
||||
|
||||
const searchLines = searchContent.split("\n")
|
||||
const replaceLines = replaceContent.split("\n")
|
||||
|
||||
// Find common prefix and suffix between search and replace
|
||||
let prefixEnd = 0
|
||||
while (
|
||||
prefixEnd < searchLines.length &&
|
||||
prefixEnd < replaceLines.length &&
|
||||
searchLines[prefixEnd] === replaceLines[prefixEnd]
|
||||
) {
|
||||
prefixEnd++
|
||||
}
|
||||
|
||||
let suffixStart = searchLines.length
|
||||
let replaceSuffixStart = replaceLines.length
|
||||
while (
|
||||
suffixStart > prefixEnd &&
|
||||
replaceSuffixStart > prefixEnd &&
|
||||
searchLines[suffixStart - 1] === replaceLines[replaceSuffixStart - 1]
|
||||
) {
|
||||
suffixStart--
|
||||
replaceSuffixStart--
|
||||
}
|
||||
|
||||
// If we have finalContent, extract additional context from it
|
||||
if (finalContent) {
|
||||
const finalLines = finalContent.split("\n")
|
||||
|
||||
// Find where the replaced content appears in the final file
|
||||
let matchIndex = -1
|
||||
for (let i = 0; i < finalLines.length; i++) {
|
||||
// Try to match the first replace line
|
||||
if (replaceLines.length > 0 && finalLines[i] === replaceLines[0]) {
|
||||
// Check if subsequent lines also match
|
||||
let allMatch = true
|
||||
for (let j = 1; j < replaceLines.length && i + j < finalLines.length; j++) {
|
||||
if (finalLines[i + j] !== replaceLines[j]) {
|
||||
allMatch = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (allMatch) {
|
||||
matchIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex >= 0) {
|
||||
// Extract up to 3 lines before as context
|
||||
const contextStart = Math.max(0, matchIndex - 3)
|
||||
const contextLines: string[] = []
|
||||
for (let i = contextStart; i < matchIndex; i++) {
|
||||
contextLines.push(finalLines[i])
|
||||
}
|
||||
|
||||
// Pad to 3 lines if needed (with empty strings)
|
||||
while (contextLines.length < 3) {
|
||||
contextLines.unshift("")
|
||||
}
|
||||
|
||||
// Add @@ marker with the first context line
|
||||
if (contextLines[0] === "") {
|
||||
patchLines.push("@@")
|
||||
} else {
|
||||
patchLines.push(`@@${contextLines[0]}`)
|
||||
}
|
||||
|
||||
// Add remaining context lines (without @@ marker)
|
||||
for (let i = 1; i < contextLines.length; i++) {
|
||||
patchLines.push(contextLines[i])
|
||||
}
|
||||
|
||||
// Add common prefix lines (without +/- markers)
|
||||
for (let i = 0; i < prefixEnd; i++) {
|
||||
patchLines.push(searchLines[i])
|
||||
}
|
||||
|
||||
// Add the actual changes (lines that differ)
|
||||
for (let i = prefixEnd; i < suffixStart; i++) {
|
||||
patchLines.push(`- ${searchLines[i]}`)
|
||||
}
|
||||
for (let i = prefixEnd; i < replaceSuffixStart; i++) {
|
||||
patchLines.push(`+ ${replaceLines[i]}`)
|
||||
}
|
||||
|
||||
// Add common suffix lines (without +/- markers)
|
||||
for (let i = suffixStart; i < searchLines.length; i++) {
|
||||
patchLines.push(searchLines[i])
|
||||
}
|
||||
|
||||
// Extract up to 3 lines after as trailing context (without @@ markers)
|
||||
const contextEnd = Math.min(finalLines.length, matchIndex + replaceLines.length + 3)
|
||||
for (let i = matchIndex + replaceLines.length; i < contextEnd; i++) {
|
||||
patchLines.push(finalLines[i])
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no finalContent or couldn't find match, use the prefix/suffix from SEARCH/REPLACE
|
||||
patchLines.push("@@")
|
||||
|
||||
// Add common prefix lines (without +/- markers)
|
||||
for (let i = 0; i < prefixEnd; i++) {
|
||||
patchLines.push(searchLines[i])
|
||||
}
|
||||
|
||||
// Add the actual changes (lines that differ)
|
||||
for (let i = prefixEnd; i < suffixStart; i++) {
|
||||
patchLines.push(`- ${searchLines[i]}`)
|
||||
}
|
||||
for (let i = prefixEnd; i < replaceSuffixStart; i++) {
|
||||
patchLines.push(`+ ${replaceLines[i]}`)
|
||||
}
|
||||
|
||||
// Add common suffix lines (without +/- markers)
|
||||
for (let i = suffixStart; i < searchLines.length; i++) {
|
||||
patchLines.push(searchLines[i])
|
||||
}
|
||||
}
|
||||
|
||||
return patchLines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct tool_result content to match apply_patch result format
|
||||
*/
|
||||
function reconstructWriteToFileResult(block: any, originalToolName: string, originalInput: any): string | any[] {
|
||||
// Extract the content from the tool_result
|
||||
const content = typeof block.content === "string" ? block.content : ""
|
||||
|
||||
// Try to extract the final_file_content
|
||||
const finalContentMatch = content.match(/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/)
|
||||
|
||||
const filePath = originalInput.absolutePath || originalInput.path || ""
|
||||
|
||||
if (!finalContentMatch) {
|
||||
// If no final_file_content found, create a simple success message
|
||||
if (originalToolName === "write_to_file") {
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
|
||||
} else {
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified.`
|
||||
}
|
||||
}
|
||||
|
||||
const finalContent = finalContentMatch[2]
|
||||
|
||||
// Reconstruct the result message based on the original tool type
|
||||
if (originalToolName === "write_to_file") {
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
|
||||
}
|
||||
|
||||
if (originalToolName === "replace_in_file") {
|
||||
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified using Update operation.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return block.content
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import { convertApplyPatchToolCalls, convertWriteToFileToolCalls } from "./diff-editors"
|
||||
|
||||
/**
|
||||
* Transforms tool call messages between different tool formats based on native tool support.
|
||||
* Converts between apply_patch and write_to_file/replace_in_file formats as needed.
|
||||
*
|
||||
* @param clineMessages - Array of messages containing tool calls to transform
|
||||
* @param nativeTools - Array of tools natively supported by the current provider
|
||||
* @returns Transformed messages array, or original if no transformation needed
|
||||
*/
|
||||
export function transformToolCallMessages(
|
||||
clineMessages: ClineStorageMessage[],
|
||||
nativeTools?: ClineDefaultTool[],
|
||||
): ClineStorageMessage[] {
|
||||
// Early return if no messages or native tools provided
|
||||
if (!clineMessages?.length || !nativeTools?.length) {
|
||||
return clineMessages
|
||||
}
|
||||
|
||||
// Create Sets for O(1) lookup performance
|
||||
const nativeToolSet = new Set(nativeTools)
|
||||
const usedToolSet = new Set<string>()
|
||||
|
||||
// Single pass: collect all tools used in assistant messages
|
||||
for (const msg of clineMessages) {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use" && block.name) {
|
||||
usedToolSet.add(block.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Early return if no tools were used
|
||||
if (usedToolSet.size === 0) {
|
||||
return clineMessages
|
||||
}
|
||||
|
||||
// Determine which conversion to apply
|
||||
const hasApplyPatchNative = nativeToolSet.has(ClineDefaultTool.APPLY_PATCH)
|
||||
const hasFileEditNative = nativeToolSet.has(ClineDefaultTool.FILE_EDIT) || nativeToolSet.has(ClineDefaultTool.FILE_NEW)
|
||||
|
||||
const hasApplyPatchUsed = usedToolSet.has(ClineDefaultTool.APPLY_PATCH)
|
||||
const hasFileEditUsed = usedToolSet.has(ClineDefaultTool.FILE_EDIT) || usedToolSet.has(ClineDefaultTool.FILE_NEW)
|
||||
|
||||
// Convert write_to_file/replace_in_file → apply_patch
|
||||
if (hasApplyPatchNative && hasFileEditUsed) {
|
||||
return convertWriteToFileToolCalls(clineMessages)
|
||||
}
|
||||
|
||||
// Convert apply_patch → write_to_file/replace_in_file
|
||||
if (hasFileEditNative && hasApplyPatchUsed) {
|
||||
return convertApplyPatchToolCalls(clineMessages)
|
||||
}
|
||||
|
||||
return clineMessages
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
import { expect } from "chai"
|
||||
import { describe, it } from "mocha"
|
||||
import { constructNewFileContent as cnfc } from "./diff"
|
||||
|
||||
async function cnfc2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
const result = await cnfc(diffContent, originalContent, isFinal, "v2")
|
||||
return result.newContent
|
||||
}
|
||||
|
||||
describe("constructNewFileContent", () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: "empty file",
|
||||
original: "",
|
||||
diff: `------- SEARCH
|
||||
=======
|
||||
new content
|
||||
+++++++ REPLACE`,
|
||||
expected: "new content\n",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "malformed search - mixed symbols",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `<<-- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed search - insufficient dashes",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `-- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed search - missing space",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `-------SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "exact match replacement",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
expected: "line1\nreplaced\nline3",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "line-trimmed match replacement",
|
||||
original: "line1\n line2 \nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
expected: "line1\nreplaced\nline3",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "block anchor match replacement",
|
||||
original: "line1\nstart\nmiddle\nend\nline5",
|
||||
diff: `------- SEARCH
|
||||
start
|
||||
middle
|
||||
end
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
expected: "line1\nreplaced\nline5",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "incremental processing",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: [
|
||||
`------- SEARCH
|
||||
line2
|
||||
=======`,
|
||||
"replaced\n",
|
||||
"+++++++ REPLACE",
|
||||
].join("\n"),
|
||||
expected: "line1\nreplaced\n\nline3",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "final chunk with remaining content",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
expected: "line1\nreplaced\nline3",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "multiple ordered replacements",
|
||||
original: "First\nSecond\nThird\nFourth",
|
||||
diff: `------- SEARCH
|
||||
First
|
||||
=======
|
||||
1st
|
||||
+++++++ REPLACE
|
||||
|
||||
------- SEARCH
|
||||
Third
|
||||
=======
|
||||
3rd
|
||||
+++++++ REPLACE`,
|
||||
expected: "1st\nSecond\n3rd\nFourth",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "replace then delete",
|
||||
original: "line1\nline2\nline3\nline4",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE
|
||||
|
||||
------- SEARCH
|
||||
line4
|
||||
=======
|
||||
+++++++ REPLACE`,
|
||||
expected: "line1\nreplaced\nline3\n",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "delete then replace",
|
||||
original: "line1\nline2\nline3\nline4",
|
||||
diff: `------- SEARCH
|
||||
line1
|
||||
=======
|
||||
+++++++ REPLACE
|
||||
|
||||
------- SEARCH
|
||||
line3
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
expected: "line2\nreplaced\nline4",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - missing separator",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
+++++++ REPLACE
|
||||
replaced`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - trailing space on separator",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - double replace markers",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
+++++++ REPLACE
|
||||
first replacement
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - malformed separator with dashes",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
------- =======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
]
|
||||
//.filter(({name}) => name === "multiple ordered replacements")
|
||||
//.filter(({name}) => name === "delete then replace")
|
||||
testCases.forEach(({ name, original, diff, expected, isFinal, shouldThrow }) => {
|
||||
it(`should handle ${name} case correctly`, async () => {
|
||||
if (shouldThrow) {
|
||||
try {
|
||||
await cnfc(diff, original, isFinal ?? true)
|
||||
expect.fail("Expected an error to be thrown")
|
||||
} catch (err) {
|
||||
expect(err).to.be.an("error")
|
||||
}
|
||||
|
||||
try {
|
||||
await cnfc2(diff, original, isFinal ?? true)
|
||||
expect.fail("Expected an error to be thrown")
|
||||
} catch (err) {
|
||||
expect(err).to.be.an("error")
|
||||
}
|
||||
} else {
|
||||
const result1 = await cnfc(diff, original, isFinal ?? true)
|
||||
const result2 = await cnfc2(diff, original, isFinal ?? true)
|
||||
const _equal = result1.newContent === result2
|
||||
const _equal2 = result1.newContent === expected
|
||||
// Verify both implementations produce same result
|
||||
expect(result1.newContent).to.equal(result2)
|
||||
|
||||
// Verify result matches expected
|
||||
expect(result1.newContent).to.equal(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error when no match found", async () => {
|
||||
const original = "line1\nline2\nline3"
|
||||
const diff = `------- SEARCH
|
||||
non-existent
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`
|
||||
|
||||
try {
|
||||
await cnfc(diff, original, true)
|
||||
expect.fail("Expected an error to be thrown")
|
||||
} catch (err) {
|
||||
expect(err).to.be.an("error")
|
||||
}
|
||||
|
||||
try {
|
||||
await cnfc2(diff, original, true)
|
||||
expect.fail("Expected an error to be thrown")
|
||||
} catch (err) {
|
||||
expect(err).to.be.an("error")
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle missing final REPLACE marker when isFinal is true", async () => {
|
||||
const original = "line1\nline2\nline3"
|
||||
const diff = `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced`
|
||||
// Note: missing +++++++ REPLACE marker
|
||||
|
||||
const result1 = await cnfc(diff, original, true) // isFinal = true
|
||||
|
||||
// Should still work and replace line2 with "replaced"
|
||||
const expected = "line1\nreplaced\nline3"
|
||||
|
||||
expect(result1.newContent).to.equal(expected)
|
||||
})
|
||||
|
||||
it("should handle missing final REPLACE marker with multiple lines of replacement", async () => {
|
||||
const original = "function test() {\n\tconst a = 1;\n\treturn a;\n}"
|
||||
const diff = `------- SEARCH
|
||||
const a = 1;
|
||||
return a;
|
||||
=======
|
||||
const a = 42;
|
||||
console.log('updated');
|
||||
return a;`
|
||||
// Note: missing +++++++ REPLACE marker
|
||||
|
||||
const result1 = await cnfc(diff, original, true) // isFinal = true
|
||||
const expected = "function test() {\n\tconst a = 42;\n\tconsole.log('updated');\n\treturn a;\n}"
|
||||
|
||||
expect(result1.newContent).to.equal(expected)
|
||||
})
|
||||
|
||||
// it("should NOT process incomplete replacement when isFinal is false", async () => {
|
||||
// const original = "line1\nline2\nline3"
|
||||
// const diff = `------- SEARCH
|
||||
// line2
|
||||
// =======
|
||||
// replaced`
|
||||
// // Note: missing +++++++ REPLACE marker AND isFinal = false
|
||||
|
||||
// const result1 = await cnfc(diff, original, false) // isFinal = false
|
||||
|
||||
// // Should not make any changes since the block is incomplete
|
||||
// const expected = "line1\nline2\nline3"
|
||||
|
||||
// expect(result1).to.equal(expected)
|
||||
// })
|
||||
})
|
||||
|
||||
// Test cases for out-of-order search/replace blocks
|
||||
|
||||
describe("Diff Format Out of Order Cases", () => {
|
||||
it("should handle out-of-order replacements with different positions", async () => {
|
||||
const isFinal = true
|
||||
const original = "first\nsecond\nthird\nfourth\n"
|
||||
const diff = `------- SEARCH
|
||||
fourth
|
||||
=======
|
||||
new fourth
|
||||
+++++++ REPLACE
|
||||
------- SEARCH
|
||||
second
|
||||
=======
|
||||
new second
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const expectedResult = "first\nnew second\nthird\nnew fourth\n"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle multiple out-of-order replacements", async () => {
|
||||
const isFinal = true
|
||||
const original = "one\ntwo\nthree\nfour\nfive\n"
|
||||
const diff = `------- SEARCH
|
||||
four
|
||||
=======
|
||||
fourth
|
||||
+++++++ REPLACE
|
||||
------- SEARCH
|
||||
two
|
||||
=======
|
||||
second
|
||||
+++++++ REPLACE
|
||||
------- SEARCH
|
||||
five
|
||||
=======
|
||||
fifth
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const expectedResult = "one\nsecond\nthree\nfourth\nfifth\n"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle out-of-order replacements with indentation", async () => {
|
||||
const isFinal = true
|
||||
const original = "function test() {\n\tconst a = 1;\n\tconst b = 2;\n\tconst c = 3;\n\n}"
|
||||
const diff = `------- SEARCH
|
||||
const c = 3;
|
||||
=======
|
||||
const c = 30;
|
||||
+++++++ REPLACE
|
||||
------- SEARCH
|
||||
const a = 1;
|
||||
=======
|
||||
const a = 10;
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const expectedResult = "function test() {\n\tconst a = 10;\n\tconst b = 2;\n\tconst c = 30;\n\n}"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle out-of-order replacements with empty lines", async () => {
|
||||
const isFinal = true
|
||||
const original = "header\n\nbody\n\nfooter\n"
|
||||
const diff = `------- SEARCH
|
||||
footer
|
||||
=======
|
||||
new footer
|
||||
+++++++ REPLACE
|
||||
------- SEARCH
|
||||
|
||||
body
|
||||
|
||||
=======
|
||||
new body content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const expectedResult = "header\nnew body content\nnew footer\n"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
})
|
||||
})
|
||||
@@ -1,855 +0,0 @@
|
||||
const SEARCH_BLOCK_START = "------- SEARCH"
|
||||
const SEARCH_BLOCK_END = "======="
|
||||
const REPLACE_BLOCK_END = "+++++++ REPLACE"
|
||||
|
||||
/**
|
||||
* Converts a character index in a string to a 1-based line number.
|
||||
* @param content - The full content string
|
||||
* @param charIndex - The character index in the content
|
||||
* @returns The 1-based line number where charIndex falls
|
||||
*/
|
||||
export function getLineNumberFromCharIndex(content: string, charIndex: number): number {
|
||||
if (charIndex <= 0) return 1
|
||||
return content.substring(0, charIndex).split("\n").length
|
||||
}
|
||||
|
||||
const SEARCH_BLOCK_CHAR = "-"
|
||||
const REPLACE_BLOCK_CHAR = "+"
|
||||
const LEGACY_SEARCH_BLOCK_CHAR = "<"
|
||||
const LEGACY_REPLACE_BLOCK_CHAR = ">"
|
||||
|
||||
// Replace the exact string constants with flexible regex patterns
|
||||
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH>?$/
|
||||
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH>?$/
|
||||
|
||||
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
|
||||
|
||||
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE>?$/
|
||||
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE>?$/
|
||||
|
||||
// Helper functions to check if a line matches the flexible patterns
|
||||
function isSearchBlockStart(line: string): boolean {
|
||||
return SEARCH_BLOCK_START_REGEX.test(line) || LEGACY_SEARCH_BLOCK_START_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isSearchBlockEnd(line: string): boolean {
|
||||
return SEARCH_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isReplaceBlockEnd(line: string): boolean {
|
||||
return REPLACE_BLOCK_END_REGEX.test(line) || LEGACY_REPLACE_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts a line-trimmed fallback match for the given search content in the original content.
|
||||
* It tries to match `searchContent` lines against a block of lines in `originalContent` starting
|
||||
* from `lastProcessedIndex`. Lines are matched by trimming leading/trailing whitespace and ensuring
|
||||
* they are identical afterwards.
|
||||
*
|
||||
* Returns [matchIndexStart, matchIndexEnd] if found, or false if not found.
|
||||
*/
|
||||
function lineTrimmedFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
|
||||
// Split both contents into lines
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Trim trailing empty line if exists (from the trailing \n in searchContent)
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1 // +1 for \n
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// For each possible starting position in original content
|
||||
for (let i = startLineNum; i <= originalLines.length - searchLines.length; i++) {
|
||||
let matches = true
|
||||
|
||||
// Try to match all search lines from this position
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = originalLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a match, calculate the exact character positions
|
||||
if (matches) {
|
||||
// Find start character index
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
// Find end character index
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
return [matchStartIndex, matchEndIndex]
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to match blocks of code by using the first and last lines as anchors.
|
||||
* This is a third-tier fallback strategy that helps match blocks where we can identify
|
||||
* the correct location by matching the beginning and end, even if the exact content
|
||||
* differs slightly.
|
||||
*
|
||||
* The matching strategy:
|
||||
* 1. Only attempts to match blocks of 3 or more lines to avoid false positives
|
||||
* 2. Extracts from the search content:
|
||||
* - First line as the "start anchor"
|
||||
* - Last line as the "end anchor"
|
||||
* 3. For each position in the original content:
|
||||
* - Checks if the next line matches the start anchor
|
||||
* - If it does, jumps ahead by the search block size
|
||||
* - Checks if that line matches the end anchor
|
||||
* - All comparisons are done after trimming whitespace
|
||||
*
|
||||
* This approach is particularly useful for matching blocks of code where:
|
||||
* - The exact content might have minor differences
|
||||
* - The beginning and end of the block are distinctive enough to serve as anchors
|
||||
* - The overall structure (number of lines) remains the same
|
||||
*
|
||||
* @param originalContent - The full content of the original file
|
||||
* @param searchContent - The content we're trying to find in the original file
|
||||
* @param startIndex - The character index in originalContent where to start searching
|
||||
* @returns A tuple of [startIndex, endIndex] if a match is found, false otherwise
|
||||
*/
|
||||
function blockAnchorFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Only use this approach for blocks of 3+ lines
|
||||
if (searchLines.length < 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trim trailing empty line if exists
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
const searchBlockSize = searchLines.length
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// Look for matching start and end anchors
|
||||
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
|
||||
// Check if first line matches
|
||||
if (originalLines[i].trim() !== firstLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if last line matches at the expected position
|
||||
if (originalLines[i + searchBlockSize - 1].trim() !== lastLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate exact character positions
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchBlockSize; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
|
||||
return [matchStartIndex, matchEndIndex]
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* This function reconstructs the file content by applying a streamed diff (in a
|
||||
* specialized SEARCH/REPLACE block format) to the original file content. It is designed
|
||||
* to handle both incremental updates and the final resulting file after all chunks have
|
||||
* been processed.
|
||||
*
|
||||
* The diff format is a custom structure that uses three markers to define changes:
|
||||
*
|
||||
* ------- SEARCH
|
||||
* [Exact content to find in the original file]
|
||||
* =======
|
||||
* [Content to replace with]
|
||||
* +++++++ REPLACE
|
||||
*
|
||||
* Behavior and Assumptions:
|
||||
* 1. The file is processed chunk-by-chunk. Each chunk of `diffContent` may contain
|
||||
* partial or complete SEARCH/REPLACE blocks. By calling this function with each
|
||||
* incremental chunk (with `isFinal` indicating the last chunk), the final reconstructed
|
||||
* file content is produced.
|
||||
*
|
||||
* 2. Matching Strategy (in order of attempt):
|
||||
* a. Exact Match: First attempts to find the exact SEARCH block text in the original file
|
||||
* b. Line-Trimmed Match: Falls back to line-by-line comparison ignoring leading/trailing whitespace
|
||||
* c. Block Anchor Match: For blocks of 3+ lines, tries to match using first/last lines as anchors
|
||||
* If all matching strategies fail, an error is thrown.
|
||||
*
|
||||
* 3. Empty SEARCH Section:
|
||||
* - If SEARCH is empty and the original file is empty, this indicates creating a new file
|
||||
* (pure insertion).
|
||||
* - If SEARCH is empty and the original file is not empty, this indicates a complete
|
||||
* file replacement (the entire original content is considered matched and replaced).
|
||||
*
|
||||
* 4. Applying Changes:
|
||||
* - Before encountering the "=======" marker, lines are accumulated as search content.
|
||||
* - After "=======" and before ">>>>>>> REPLACE", lines are accumulated as replacement content.
|
||||
* - Once the block is complete (">>>>>>> REPLACE"), the matched section in the original
|
||||
* file is replaced with the accumulated replacement lines, and the position in the original
|
||||
* file is advanced.
|
||||
*
|
||||
* 5. Incremental Output:
|
||||
* - As soon as the match location is found and we are in the REPLACE section, each new
|
||||
* replacement line is appended to the result so that partial updates can be viewed
|
||||
* incrementally.
|
||||
*
|
||||
* 6. Partial Markers:
|
||||
* - If the final line of the chunk looks like it might be part of a marker but is not one
|
||||
* of the known markers, it is removed. This prevents incomplete or partial markers
|
||||
* from corrupting the output.
|
||||
*
|
||||
* 7. Finalization:
|
||||
* - Once all chunks have been processed (when `isFinal` is true), any remaining original
|
||||
* content after the last replaced section is appended to the result.
|
||||
* - Trailing newlines are not forcibly added. The code tries to output exactly what is specified.
|
||||
*
|
||||
* Errors:
|
||||
* - If the search block cannot be matched using any of the available matching strategies,
|
||||
* an error is thrown.
|
||||
*/
|
||||
export async function constructNewFileContent(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
version: "v1" | "v2" = "v1",
|
||||
): Promise<{ newContent: string; matchIndices: number[] }> {
|
||||
const constructor = constructNewFileContentVersionMapping[version]
|
||||
if (!constructor) {
|
||||
throw new Error(`Invalid version '${version}' for file content constructor`)
|
||||
}
|
||||
return constructor(diffContent, originalContent, isFinal)
|
||||
}
|
||||
|
||||
const constructNewFileContentVersionMapping: Record<
|
||||
string,
|
||||
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<{ newContent: string; matchIndices: number[] }>
|
||||
> = {
|
||||
v1: constructNewFileContentV1,
|
||||
v2: constructNewFileContentV2,
|
||||
} as const
|
||||
|
||||
async function constructNewFileContentV1(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
): Promise<{ newContent: string; matchIndices: number[] }> {
|
||||
let result = ""
|
||||
let lastProcessedIndex = 0
|
||||
|
||||
let currentSearchContent = ""
|
||||
let currentReplaceContent = ""
|
||||
let inSearch = false
|
||||
let inReplace = false
|
||||
|
||||
let searchMatchIndex = -1
|
||||
let searchEndIndex = -1
|
||||
|
||||
// Track all replacements to handle out-of-order edits
|
||||
const replacements: Array<{ start: number; end: number; content: string }> = []
|
||||
let pendingOutOfOrderReplacement = false
|
||||
|
||||
const lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
!isSearchBlockStart(lastLine) &&
|
||||
!isSearchBlockEnd(lastLine) &&
|
||||
!isReplaceBlockEnd(lastLine)
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (isSearchBlockStart(line)) {
|
||||
inSearch = true
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if (isSearchBlockEnd(line)) {
|
||||
inSearch = false
|
||||
inReplace = true
|
||||
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!currentSearchContent) {
|
||||
// Empty search block
|
||||
if (originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
searchMatchIndex = 0
|
||||
searchEndIndex = 0
|
||||
} else {
|
||||
// ERROR: Empty search block with non-empty file indicates malformed SEARCH marker
|
||||
throw new Error(
|
||||
"Empty SEARCH block detected with non-empty file. This usually indicates a malformed SEARCH marker.\n" +
|
||||
"Please ensure your SEARCH marker follows the correct format:\n" +
|
||||
"- Use '------- SEARCH' (7+ dashes + space + SEARCH)\n",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
|
||||
// Exact search match scenario
|
||||
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
searchMatchIndex = exactIndex
|
||||
searchEndIndex = exactIndex + currentSearchContent.length
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (lineMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = lineMatch
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (blockMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = blockMatch
|
||||
} else {
|
||||
// Last resort: search the entire file from the beginning
|
||||
const fullFileIndex = originalContent.indexOf(currentSearchContent, 0)
|
||||
if (fullFileIndex !== -1) {
|
||||
// Found in the file - could be out of order
|
||||
searchMatchIndex = fullFileIndex
|
||||
searchEndIndex = fullFileIndex + currentSearchContent.length
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is an out-of-order replacement
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
|
||||
// For in-order replacements, output everything up to the match location
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
result += originalContent.slice(lastProcessedIndex, searchMatchIndex)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (isReplaceBlockEnd(line)) {
|
||||
// Finished one replace block
|
||||
|
||||
if (searchMatchIndex === -1) {
|
||||
throw new Error(`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...is malformatted.`)
|
||||
}
|
||||
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset for next block
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
pendingOutOfOrderReplacement = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (inSearch) {
|
||||
currentSearchContent += line + "\n"
|
||||
} else if (inReplace) {
|
||||
currentReplaceContent += line + "\n"
|
||||
// Only output replacement lines immediately for in-order replacements
|
||||
if (searchMatchIndex !== -1 && !pendingOutOfOrderReplacement) {
|
||||
result += line + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the final chunk, we need to apply all replacements and build the final result
|
||||
if (isFinal) {
|
||||
// Handle the case where we're still in replace mode when processing ends
|
||||
// and this is the final chunk - treat it as if we encountered the REPLACE marker
|
||||
if (inReplace && searchMatchIndex !== -1) {
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset state
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
pendingOutOfOrderReplacement = false
|
||||
}
|
||||
// end of handling missing replace marker
|
||||
|
||||
// Sort replacements by start position
|
||||
replacements.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Rebuild the entire result by applying all replacements
|
||||
result = ""
|
||||
let currentPos = 0
|
||||
|
||||
for (const replacement of replacements) {
|
||||
// Add original content up to this replacement
|
||||
result += originalContent.slice(currentPos, replacement.start)
|
||||
// Add the replacement content
|
||||
result += replacement.content
|
||||
// Move position to after the replaced section
|
||||
currentPos = replacement.end
|
||||
}
|
||||
|
||||
// Add any remaining original content
|
||||
result += originalContent.slice(currentPos)
|
||||
}
|
||||
|
||||
// Return all match indices from the replacements
|
||||
// This is used to determine the line numbers for each SEARCH/REPLACE block in the UI
|
||||
return { newContent: result, matchIndices: replacements.map((r) => r.start) }
|
||||
}
|
||||
|
||||
enum ProcessingState {
|
||||
Idle = 0,
|
||||
StateSearch = 1 << 0,
|
||||
StateReplace = 1 << 1,
|
||||
}
|
||||
|
||||
class NewFileContentConstructor {
|
||||
private originalContent: string
|
||||
private isFinal: boolean
|
||||
private state: number
|
||||
private pendingNonStandardLines: string[]
|
||||
private result: string
|
||||
private lastProcessedIndex: number
|
||||
private currentSearchContent: string
|
||||
private searchMatchIndex: number
|
||||
private searchEndIndex: number
|
||||
|
||||
constructor(originalContent: string, isFinal: boolean) {
|
||||
this.originalContent = originalContent
|
||||
this.isFinal = isFinal
|
||||
this.pendingNonStandardLines = []
|
||||
this.result = ""
|
||||
this.lastProcessedIndex = 0
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private resetForNextBlock() {
|
||||
// Reset for next block
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private findLastMatchingLineIndex(regx: RegExp, lineLimit: number) {
|
||||
for (let i = lineLimit; i > 0; ) {
|
||||
i--
|
||||
if (this.pendingNonStandardLines[i].match(regx)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private updateProcessingState(newState: ProcessingState) {
|
||||
const isValidTransition =
|
||||
(this.state === ProcessingState.Idle && newState === ProcessingState.StateSearch) ||
|
||||
(this.state === ProcessingState.StateSearch && newState === ProcessingState.StateReplace)
|
||||
|
||||
if (!isValidTransition) {
|
||||
throw new Error(
|
||||
`Invalid state transition.\n` +
|
||||
"Valid transitions are:\n" +
|
||||
"- Idle → StateSearch\n" +
|
||||
"- StateSearch → StateReplace",
|
||||
)
|
||||
}
|
||||
|
||||
this.state |= newState
|
||||
}
|
||||
|
||||
private isStateActive(state: ProcessingState): boolean {
|
||||
return (this.state & state) === state
|
||||
}
|
||||
|
||||
private activateReplaceState() {
|
||||
this.updateProcessingState(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private activateSearchState() {
|
||||
this.updateProcessingState(ProcessingState.StateSearch)
|
||||
this.currentSearchContent = ""
|
||||
}
|
||||
|
||||
private isSearchingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateSearch)
|
||||
}
|
||||
|
||||
private isReplacingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private hasPendingNonStandardLines(pendingNonStandardLineLimit: number): boolean {
|
||||
return this.pendingNonStandardLines.length - pendingNonStandardLineLimit < this.pendingNonStandardLines.length
|
||||
}
|
||||
|
||||
public processLine(line: string) {
|
||||
this.internalProcessLine(line, true, this.pendingNonStandardLines.length)
|
||||
}
|
||||
|
||||
public getResult(): { newContent: string; matchIndices: number[] } {
|
||||
// If this is the final chunk, append any remaining original content
|
||||
if (this.isFinal && this.lastProcessedIndex < this.originalContent.length) {
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex)
|
||||
}
|
||||
if (this.isFinal && this.state !== ProcessingState.Idle) {
|
||||
throw new Error("File processing incomplete - SEARCH/REPLACE operations still active during finalization")
|
||||
}
|
||||
// Note: V2 implementation doesn't currently track match indices
|
||||
// For now, return empty array. If V2 becomes the default and we need line numbers,
|
||||
// we should add state to track all match indices.
|
||||
return { newContent: this.result, matchIndices: [] }
|
||||
}
|
||||
|
||||
private internalProcessLine(
|
||||
line: string,
|
||||
canWritependingNonStandardLines: boolean,
|
||||
pendingNonStandardLineLimit: number,
|
||||
): number {
|
||||
let removeLineCount = 0
|
||||
if (isSearchBlockStart(line)) {
|
||||
removeLineCount = this.trimPendingNonStandardTrailingEmptyLines(pendingNonStandardLineLimit)
|
||||
if (removeLineCount > 0) {
|
||||
pendingNonStandardLineLimit = pendingNonStandardLineLimit - removeLineCount
|
||||
}
|
||||
if (this.hasPendingNonStandardLines(pendingNonStandardLineLimit)) {
|
||||
this.tryFixSearchReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateSearchState()
|
||||
} else if (isSearchBlockEnd(line)) {
|
||||
// 校验非标内容
|
||||
if (!this.isSearchingActive()) {
|
||||
this.tryFixSearchBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateReplaceState()
|
||||
this.beforeReplace()
|
||||
} else if (isReplaceBlockEnd(line)) {
|
||||
if (!this.isReplacingActive()) {
|
||||
this.tryFixReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.lastProcessedIndex = this.searchEndIndex
|
||||
this.resetForNextBlock()
|
||||
} else {
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (this.isReplacingActive()) {
|
||||
// Output replacement lines immediately if we know the insertion point
|
||||
if (this.searchMatchIndex !== -1) {
|
||||
this.result += line + "\n"
|
||||
}
|
||||
} else if (this.isSearchingActive()) {
|
||||
this.currentSearchContent += line + "\n"
|
||||
} else {
|
||||
const appendToPendingNonStandardLines = canWritependingNonStandardLines
|
||||
if (appendToPendingNonStandardLines) {
|
||||
// 处理非标内容
|
||||
this.pendingNonStandardLines.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private beforeReplace() {
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!this.currentSearchContent) {
|
||||
// Empty search block
|
||||
if (this.originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = 0
|
||||
} else {
|
||||
// Complete file replacement scenario: treat the entire file as matched
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = this.originalContent.length
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
// Exact search match scenario
|
||||
const exactIndex = this.originalContent.indexOf(this.currentSearchContent, this.lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
this.searchMatchIndex = exactIndex
|
||||
this.searchEndIndex = exactIndex + this.currentSearchContent.length
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (lineMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex] = lineMatch
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (blockMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex] = blockMatch
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.searchMatchIndex < this.lastProcessedIndex) {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...matched an incorrect content in the file.`,
|
||||
)
|
||||
}
|
||||
// Output everything up to the match location
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex, this.searchMatchIndex)
|
||||
}
|
||||
|
||||
private tryFixSearchBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error("Invalid SEARCH/REPLACE block structure - no lines available to process")
|
||||
}
|
||||
const searchTagRegexp = /^([-]{3,}|[<]{3,}) SEARCH$/
|
||||
const searchTagIndex = this.findLastMatchingLineIndex(searchTagRegexp, lineLimit)
|
||||
if (searchTagIndex !== -1) {
|
||||
const fixLines = this.pendingNonStandardLines.slice(searchTagIndex, lineLimit)
|
||||
fixLines[0] = SEARCH_BLOCK_START
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, searchTagIndex)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid REPLACE marker detected - could not find matching SEARCH block starting from line ${searchTagIndex + 1}`,
|
||||
)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
const replaceBeginTagRegexp = /^[=]{3,}$/
|
||||
const replaceBeginTagIndex = this.findLastMatchingLineIndex(replaceBeginTagRegexp, lineLimit)
|
||||
if (replaceBeginTagIndex !== -1) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isSearchingActive()) {
|
||||
// removeLineCount += this.tryFixSearchBlock(replaceBeginTagIndex)
|
||||
// }
|
||||
const fixLines = this.pendingNonStandardLines.slice(
|
||||
replaceBeginTagIndex - removeLineCount,
|
||||
lineLimit - removeLineCount,
|
||||
)
|
||||
fixLines[0] = SEARCH_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceBeginTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Malformed REPLACE block - missing valid separator after line ${replaceBeginTagIndex + 1}`)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixSearchReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
const replaceEndTagRegexp = /^([+]{3,}|[>]{3,}) REPLACE$/
|
||||
const replaceEndTagIndex = this.findLastMatchingLineIndex(replaceEndTagRegexp, lineLimit)
|
||||
const likeReplaceEndTag = replaceEndTagIndex === lineLimit - 1
|
||||
if (likeReplaceEndTag) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isReplacingActive()) {
|
||||
// removeLineCount += this.tryFixReplaceBlock(replaceEndTagIndex)
|
||||
// }
|
||||
const fixLines = this.pendingNonStandardLines.slice(replaceEndTagIndex - removeLineCount, lineLimit - removeLineCount)
|
||||
fixLines[fixLines.length - 1] = REPLACE_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceEndTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error("Malformed SEARCH/REPLACE block structure: Missing valid closing REPLACE marker")
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes trailing empty lines from the pendingNonStandardLines array
|
||||
* @param lineLimit - The index to start checking from (exclusive).
|
||||
* Removes empty lines from lineLimit-1 backwards.
|
||||
* @returns The number of empty lines removed
|
||||
*/
|
||||
private trimPendingNonStandardTrailingEmptyLines(lineLimit: number): number {
|
||||
let removedCount = 0
|
||||
let i = Math.min(lineLimit, this.pendingNonStandardLines.length) - 1
|
||||
|
||||
while (i >= 0 && this.pendingNonStandardLines[i].trim() === "") {
|
||||
this.pendingNonStandardLines.pop()
|
||||
removedCount++
|
||||
i--
|
||||
}
|
||||
|
||||
return removedCount
|
||||
}
|
||||
}
|
||||
|
||||
export async function constructNewFileContentV2(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
): Promise<{ newContent: string; matchIndices: number[] }> {
|
||||
const newFileContentConstructor = new NewFileContentConstructor(originalContent, isFinal)
|
||||
|
||||
const lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
lastLine !== SEARCH_BLOCK_START &&
|
||||
lastLine !== SEARCH_BLOCK_END &&
|
||||
lastLine !== REPLACE_BLOCK_END
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
newFileContentConstructor.processLine(line)
|
||||
}
|
||||
|
||||
const result = newFileContentConstructor.getResult()
|
||||
return result
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { expect } from "chai"
|
||||
import { describe, it } from "mocha"
|
||||
import { constructNewFileContent as cnfc } from "./diff"
|
||||
|
||||
async function cnfc2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
const result = await cnfc(diffContent, originalContent, isFinal, "v2")
|
||||
return result.newContent
|
||||
}
|
||||
|
||||
describe("Diff Format Edge Cases", () => {
|
||||
it("should handle SEARCH prefix symbols - less than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\ncontent\nafter"
|
||||
const diff = `----- SEARCH
|
||||
content
|
||||
=======
|
||||
new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle SEARCH prefix symbols - more than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\ncontent\nafter"
|
||||
const diff = `----------- SEARCH
|
||||
content
|
||||
=======
|
||||
new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle SEARCH - less than 7 and REPLACE = less than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\ncontent\nafter"
|
||||
const diff = `----- SEARCH
|
||||
content
|
||||
=====
|
||||
new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle SEARCH - less than 7 and REPLACE = more than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\ncontent\nafter"
|
||||
const diff = `----- SEARCH
|
||||
content
|
||||
========
|
||||
new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
expect(result1.newContent).to.equal("before\nnew content\nafter")
|
||||
expect(result2).to.equal("before\nnew content\nafter")
|
||||
})
|
||||
|
||||
it("should handle SEARCH - more than 7 and REPLACE = more than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\ncontent\nafter"
|
||||
const diff = `----------- SEARCH
|
||||
content
|
||||
==========
|
||||
new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle SEARCH - more than 7 and REPLACE = less than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\ncontent\nafter"
|
||||
const diff = `----------- SEARCH
|
||||
content
|
||||
=====
|
||||
new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nnew content\nafter"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle consecutive SEARCH-REPLACE with second block SEARCH - less than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\nfirst content\nafter\nsecond content\nend"
|
||||
const diff = `------- SEARCH
|
||||
first content
|
||||
=======
|
||||
first new content
|
||||
+++++++ REPLACE
|
||||
----- SEARCH
|
||||
second content
|
||||
=======
|
||||
second new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nfirst new content\nafter\nsecond new content\nend"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
|
||||
it("should handle consecutive SEARCH-REPLACE with second block SEARCH - less than 7 and REPLACE = less than 7", async () => {
|
||||
const isFinal = true
|
||||
const original = "before\nfirst content\nafter\nsecond content\nend"
|
||||
const diff = `------- SEARCH
|
||||
first content
|
||||
=======
|
||||
first new content
|
||||
+++++++ REPLACE
|
||||
----- SEARCH
|
||||
second content
|
||||
=====
|
||||
second new content
|
||||
+++++++ REPLACE`
|
||||
const result1 = await cnfc(diff, original, isFinal)
|
||||
const result2 = await cnfc2(diff, original, isFinal)
|
||||
const expectedResult = "before\nfirst new content\nafter\nsecond new content\nend"
|
||||
expect(result1.newContent).to.equal(expectedResult)
|
||||
expect(result2).to.equal(expectedResult)
|
||||
})
|
||||
})
|
||||
@@ -1,361 +0,0 @@
|
||||
// import { constructNewFileContent as cnfc } from "./diff"
|
||||
// import { describe, it } from "mocha"
|
||||
// import { expect } from "chai"
|
||||
|
||||
// async function cnfc2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
// return cnfc(diffContent, originalContent, isFinal, "v2")
|
||||
// }
|
||||
|
||||
// describe("Diff Format Edge Cases", () => {
|
||||
// it("should handle missing search block", async () => {
|
||||
// const original = "line1\nline2"
|
||||
// const diff = `=======
|
||||
// new content
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// expect(result1).to.equal("new content\n")
|
||||
// try {
|
||||
// await cnfc2(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// })
|
||||
|
||||
// it("should handle consecutive search blocks", async () => {
|
||||
// const original = "text"
|
||||
// const diff = `------- SEARCH
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
// ------- SEARCH
|
||||
// =======
|
||||
// another
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// expect(result1).to.equal("replaced\nanother\n")
|
||||
// try {
|
||||
// await cnfc2(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// })
|
||||
|
||||
// it("should handle reverse markers order", async () => {
|
||||
// const original = "content"
|
||||
// const diff = `+++++++ SEARCH
|
||||
// =======
|
||||
// invalid
|
||||
// ------- REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// expect(result1).to.equal("invalid\ncontent")
|
||||
// try {
|
||||
// await cnfc2(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// })
|
||||
|
||||
// it("should handle incomplete block structure", async () => {
|
||||
// const original = "valid text"
|
||||
// const diff = `------- SEARCH
|
||||
// text
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// expect(result1).to.equal("t")
|
||||
// try {
|
||||
// await cnfc2(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// })
|
||||
|
||||
// it("should handle empty search block", async () => {
|
||||
// const original = "any content"
|
||||
// const diff = `------- SEARCH
|
||||
// =======
|
||||
// inserted
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result1).to.equal("inserted\n")
|
||||
// expect(result1).to.equal(result2)
|
||||
// })
|
||||
|
||||
// it("should handle mixed line endings", async () => {
|
||||
// const original = "line1\r\nline2"
|
||||
// const diff = `------- SEARCH
|
||||
// line1\r
|
||||
// =======
|
||||
// line1
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result1).to.equal("line1\nline2")
|
||||
// expect(result1).to.equal(result2)
|
||||
// })
|
||||
|
||||
// it("should handle special characters in search", async () => {
|
||||
// const original = "text with $^.*\nend"
|
||||
// const diff = `------- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result1).to.equal("text with replaced\nend")
|
||||
// expect(result1).to.equal(result2)
|
||||
// })
|
||||
|
||||
// it("should handle special regex chars and nested search markers", async () => {
|
||||
// const original = `text with $^.*\n--- SEARCH\nend`
|
||||
// const diff = `------- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------- SEARCH
|
||||
// --- SEARCH
|
||||
// =======
|
||||
// before
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result1).to.equal("text with replaced\nbefore\nend")
|
||||
// expect(result1).to.equal(result2)
|
||||
// })
|
||||
|
||||
// it("cnfc2 should handle invalid search marker format", async () => {
|
||||
// const original = `text with $^.*\n--- SEARCH\nend`
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------- SEARCH
|
||||
// --- SEARCH
|
||||
// =======
|
||||
// before
|
||||
// +++++++ REPLACE`
|
||||
// try {
|
||||
// await cnfc(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result2).to.equal("text with replaced\nbefore\nend")
|
||||
// })
|
||||
|
||||
// it("cnfc2 should throw error for incomplete search marker", async () => {
|
||||
// const original = `text with $^.*\n--- SEARCH\nend`
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------ SEARCH
|
||||
// --- SEARCH
|
||||
// =======
|
||||
// before
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// expect(result1).to.equal("replaced\nbefore\n")
|
||||
// try {
|
||||
// await cnfc2(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// })
|
||||
|
||||
// it("cnfc2 should handle custom nested search markers", async () => {
|
||||
// const original = `text with $^.*\n--- SEARCH2\nend`
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------ SEARCH
|
||||
// --- SEARCH2
|
||||
// =======
|
||||
// before
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result1).to.equal("replaced\nbefore\n")
|
||||
// expect(result2).to.equal("text with replaced\nbefore\nend")
|
||||
// })
|
||||
|
||||
// it("cnfc2 should handle text containing nested search markers", async () => {
|
||||
// const original = `text with $^.*\ntext with --- SEARCH2\nend`
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------ SEARCH
|
||||
// text with --- SEARCH2
|
||||
// =======
|
||||
// before
|
||||
// +++++++ REPLACE`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// const result2 = await cnfc2(diff, original, true)
|
||||
// expect(result1).to.equal("replaced\nbefore\n")
|
||||
// expect(result2).to.equal("text with replaced\nbefore\nend")
|
||||
// })
|
||||
|
||||
// it("cnfc2 should handle missing replacement marker in lenient mode", async () => {
|
||||
// const original = `text with $^.*\ntext with --- SEARCH2\nend`
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------ SEARCH
|
||||
// text with --- SEARCH2
|
||||
// =======
|
||||
// before`
|
||||
// const result1 = await cnfc(diff, original, false)
|
||||
// const result2 = await cnfc2(diff, original, false)
|
||||
// expect(result1).to.equal("replaced\nbefore\n")
|
||||
// expect(result2).to.equal("text with replaced\nbefore\n")
|
||||
// })
|
||||
|
||||
// it("cnfc2 should throw error for missing replacement marker in strict mode", async () => {
|
||||
// const original = `text with $^.*\ntext with --- SEARCH2\nend`
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------ SEARCH
|
||||
// text with --- SEARCH2
|
||||
// =======
|
||||
// before`
|
||||
// const result1 = await cnfc(diff, original, true)
|
||||
// expect(result1).to.equal("replaced\nbefore\n")
|
||||
// try {
|
||||
// await cnfc2(diff, original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// })
|
||||
|
||||
// it("cnfc2 should handle long text with multiple search-replace blocks", async () => {
|
||||
// const original = `This is a long text with multiple sections.
|
||||
// Section 1: Lorem ipsum dolor sit amet
|
||||
// Section 2: consectetur adipiscing elit
|
||||
// Section 3: sed do eiusmod tempor
|
||||
// Section 4: incididunt ut labore
|
||||
// Section 5: et dolore magna aliqua`
|
||||
|
||||
// const diff = `--- SEARCH
|
||||
// Section 1: Lorem ipsum dolor sit amet
|
||||
// =======
|
||||
// Section 1: Replaced text
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------- SEARCH
|
||||
// Section 3: sed do eiusmod tempor
|
||||
// =======
|
||||
// Section 3: Modified content
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------- SEARCH
|
||||
// Section 5: et dolore magna aliqua
|
||||
// =======
|
||||
// Section 5: Final replacement
|
||||
// +++++++ REPLACE`
|
||||
|
||||
// const expected = `This is a long text with multiple sections.
|
||||
// Section 1: Replaced text
|
||||
// Section 2: consectetur adipiscing elit
|
||||
// Section 3: Modified content
|
||||
// Section 4: incididunt ut labore
|
||||
// Section 5: Final replacement
|
||||
// `
|
||||
|
||||
// const result = await cnfc2(diff, original, true)
|
||||
// expect(result).to.equal(expected)
|
||||
// })
|
||||
|
||||
// // Test diff containing special regex characters and nested search markers
|
||||
// const diff = `--- SEARCH
|
||||
// $^.*
|
||||
// =======
|
||||
// replaced
|
||||
// +++++++ REPLACE
|
||||
|
||||
// ------ SEARCH
|
||||
// --- SEARCH
|
||||
// =======
|
||||
// before
|
||||
// +++++++ REPLACE`
|
||||
// // expected1 shows the incremental results when processing the diff line by line
|
||||
// // Each element represents the result after processing that line number
|
||||
// const expected1 = [
|
||||
// "",
|
||||
// "",
|
||||
// "",
|
||||
// "replaced\n",
|
||||
// "replaced\n",
|
||||
// "replaced\n",
|
||||
// "replaced\n",
|
||||
// "replaced\n",
|
||||
// "replaced\n",
|
||||
// "replaced\nbefore\n",
|
||||
// ]
|
||||
// // expected2 shows the results when processing with original content
|
||||
// // Each element represents the result after processing that line number
|
||||
// const expected2 = [
|
||||
// "",
|
||||
// "",
|
||||
// "text with ",
|
||||
// "text with replaced\n",
|
||||
// "text with replaced\n",
|
||||
// "text with replaced\n",
|
||||
// "text with replaced\n",
|
||||
// "text with replaced\n",
|
||||
// new Error(),
|
||||
// new Error(),
|
||||
// ]
|
||||
// const diffLines = diff.split("\n")
|
||||
// for (let i = 1; i < diffLines.length; i++) {
|
||||
// it(`cnfc2 should handle partial diff configuration (line ${i})`, async () => {
|
||||
// const original = `text with $^.*\n--- SEARCH\nend`
|
||||
// const result1 = await cnfc(diffLines.slice(0, i).join("\n"), original, i === diffLines.length - 1)
|
||||
// expect(result1).to.equal(expected1[i - 1])
|
||||
// })
|
||||
// }
|
||||
|
||||
// for (let i = 1; i < diffLines.length; i++) {
|
||||
// it(`cnfc2 should handle partial diff configuration (line ${i})`, async () => {
|
||||
// const original = `text with $^.*\n--- SEARCH\nend`
|
||||
// let expected = expected2[i - 1]
|
||||
// if (expected instanceof Error) {
|
||||
// try {
|
||||
// await cnfc2(diffLines.slice(0, i).join("\n"), original, true)
|
||||
// expect.fail("Expected an error to be thrown")
|
||||
// } catch (err) {
|
||||
// expect(err).to.be.an("error")
|
||||
// }
|
||||
// } else {
|
||||
// const result2 = await cnfc2(diffLines.slice(0, i).join("\n"), original, i === diffLines.length - 1)
|
||||
// expect(result2).to.equal(expected)
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
@@ -1,109 +0,0 @@
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
|
||||
export type AssistantMessageContent = TextStreamContent | ToolUse | ReasoningStreamContent
|
||||
|
||||
export interface TextStreamContent {
|
||||
type: "text"
|
||||
content: string
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
export const toolParamNames = [
|
||||
"command",
|
||||
"requires_approval",
|
||||
"path",
|
||||
"absolutePath",
|
||||
"content",
|
||||
"diff",
|
||||
"regex",
|
||||
"file_pattern",
|
||||
"recursive",
|
||||
"action",
|
||||
"url",
|
||||
"coordinate",
|
||||
"text",
|
||||
"query",
|
||||
"allowed_domains",
|
||||
"blocked_domains",
|
||||
"prompt",
|
||||
"server_name",
|
||||
"tool_name",
|
||||
"arguments",
|
||||
"uri",
|
||||
"question",
|
||||
"options",
|
||||
"response",
|
||||
"result",
|
||||
"context",
|
||||
"title",
|
||||
"what_happened",
|
||||
"steps_to_reproduce",
|
||||
"api_request_output",
|
||||
"additional_context",
|
||||
"needs_more_exploration",
|
||||
"task_progress",
|
||||
"timeout",
|
||||
"input",
|
||||
"from_ref",
|
||||
"to_ref",
|
||||
"skill_name",
|
||||
"prompt_1",
|
||||
"prompt_2",
|
||||
"prompt_3",
|
||||
"prompt_4",
|
||||
"prompt_5",
|
||||
"start_line",
|
||||
"end_line",
|
||||
] as const
|
||||
|
||||
export type ToolParamName = (typeof toolParamNames)[number]
|
||||
|
||||
export interface ToolUse {
|
||||
type: "tool_use"
|
||||
name: ClineDefaultTool // id of the tool being used
|
||||
// params is a partial record, allowing only some or none of the possible parameters to be used
|
||||
params: Partial<Record<ToolParamName, string>>
|
||||
partial: boolean
|
||||
/**
|
||||
* Whether this tool use was initiated by a native tool call
|
||||
*/
|
||||
isNativeToolCall?: boolean
|
||||
/**
|
||||
* The call / response ID this tool use is associated with.
|
||||
*/
|
||||
call_id?: string // optional call ID for tracking tool use calls
|
||||
/**
|
||||
* Thought signature associated with this tool use, used by Gemini
|
||||
*/
|
||||
signature?: string
|
||||
}
|
||||
|
||||
export interface ReasoningStreamContent {
|
||||
type: "reasoning"
|
||||
/**
|
||||
* The reasoning text generated by the model.
|
||||
* Redacted reasoning block will have this field set to "[REDACTED]" or an empty string.
|
||||
*/
|
||||
reasoning: string
|
||||
/**
|
||||
* openrouter has various properties that we can pass back unmodified in api requests to preserve reasoning traces
|
||||
*/
|
||||
details?: any
|
||||
/**
|
||||
* It's used when sending the thinking block back to the API.
|
||||
* API expects this in completed form, not as array of deltas.
|
||||
*/
|
||||
signature?: string
|
||||
/**
|
||||
* whether this reasoning block has been redacted
|
||||
*/
|
||||
redacted?: boolean
|
||||
/**
|
||||
* redacted data
|
||||
*/
|
||||
data?: string
|
||||
/**
|
||||
* Indicates whether this is a partial reasoning block
|
||||
*/
|
||||
partial: boolean
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,511 +0,0 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { expect } from "chai"
|
||||
import { ContextManager } from "../ContextManager"
|
||||
|
||||
// Minimal mock for ApiHandler — only getModel() fields are used by shouldCompactContextWindow
|
||||
function createMockApi(contextWindow: number, providerId?: string) {
|
||||
return {
|
||||
getModel: () => ({ id: "test-model", info: { contextWindow }, providerId }),
|
||||
} as any
|
||||
}
|
||||
|
||||
function createApiReqMessage(tokens: {
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
}): ClineMessage {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
describe("ContextManager", () => {
|
||||
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
|
||||
const messages: Anthropic.Messages.MessageParam[] = []
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: "Initial task message",
|
||||
})
|
||||
|
||||
let role: "user" | "assistant" = "assistant"
|
||||
for (let i = 1; i < count; i++) {
|
||||
messages.push({
|
||||
role,
|
||||
content: `Message ${i}`,
|
||||
})
|
||||
role = role === "user" ? "assistant" : "user"
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
describe("getNextTruncationRange", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("first truncation with half keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
expect(result).to.deep.equal([2, 5])
|
||||
})
|
||||
|
||||
it("first truncation with quarter keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
expect(result).to.deep.equal([2, 7])
|
||||
})
|
||||
|
||||
it("sequential truncation with half keep", () => {
|
||||
const messages = createMessages(21)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
expect(firstRange).to.deep.equal([2, 9])
|
||||
|
||||
// Pass the previous range for sequential truncation
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "half")
|
||||
expect(secondRange).to.deep.equal([2, 13])
|
||||
})
|
||||
|
||||
it("sequential truncation with quarter keep", () => {
|
||||
const messages = createMessages(41)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "quarter")
|
||||
|
||||
expect(secondRange[0]).to.equal(2)
|
||||
expect(secondRange[1]).to.be.greaterThan(firstRange[1])
|
||||
})
|
||||
|
||||
it("ensures the last message in range is a user message", () => {
|
||||
const messages = createMessages(14)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
// Check if the message at the end of range is an assistant message
|
||||
const lastRemovedMessage = messages[result[1]]
|
||||
expect(lastRemovedMessage.role).to.equal("assistant")
|
||||
|
||||
// Check if the next message after the range is a user message
|
||||
const nextMessage = messages[result[1] + 1]
|
||||
expect(nextMessage.role).to.equal("user")
|
||||
})
|
||||
|
||||
it("handles small message arrays", () => {
|
||||
const messages = createMessages(3)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
expect(result).to.deep.equal([2, 1])
|
||||
})
|
||||
|
||||
it("preserves the message structure when truncating", () => {
|
||||
const messages = createMessages(20)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
// Get messages after removing the range
|
||||
const effectiveMessages = [...messages.slice(0, result[0]), ...messages.slice(result[1] + 1)]
|
||||
|
||||
// Check first message and alternating pattern
|
||||
expect(effectiveMessages[0].role).to.equal("user")
|
||||
for (let i = 1; i < effectiveMessages.length; i++) {
|
||||
const expectedRole = i % 2 === 1 ? "assistant" : "user"
|
||||
expect(effectiveMessages[i].role).to.equal(expectedRole)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyContextOptimizations", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("detects duplicate file reads across write_to_file, replace_in_file, and file mentions (normal tool calling)", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Initial task" },
|
||||
{ role: "assistant", content: "Response" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[write_to_file for 'test.txt'] Result:\nThe content was successfully saved to test.txt.\n\nHere is the full, updated content of the file that was saved:\n\n<final_file_content path=\"test.txt\">\ntest\n\n</final_file_content>",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "<environment_details>\n# Visual Studio Code Visible Files\ntest.txt\n\n# Current Mode\nACT MODE\n</environment_details>",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: "Response" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[replace_in_file for 'test.txt'] Result:\nThe content was successfully saved to test.txt.\n\nHere is the full, updated content of the file that was saved:\n\n<final_file_content path=\"test.txt\">\ntest 2\n\n</final_file_content>",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "<environment_details>\n# Visual Studio Code Visible Files\ntest.txt\n\n# Current Mode\nACT MODE\n</environment_details>",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: "Response" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[TASK RESUMPTION] This task was interrupted just now. The conversation may have been incomplete.",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "New message to respond to:\n<user_message>\n'test.txt' (see below for file content) tell me whats in this file\n</user_message>\n\n<file_content path=\"test.txt\">\ntest 2\n\n</file_content>",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const timestamp = Date.now()
|
||||
const [didUpdate, indices] = contextManager.applyContextOptimizations(messages, 2, timestamp)
|
||||
|
||||
expect(didUpdate).to.equal(true)
|
||||
expect(indices.size).to.equal(2)
|
||||
expect(indices.has(2)).to.equal(true)
|
||||
expect(indices.has(4)).to.equal(true)
|
||||
expect(indices.has(6)).to.equal(false)
|
||||
})
|
||||
|
||||
it("returns false when no duplicate file reads exist", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Initial task" },
|
||||
{ role: "assistant", content: "Response" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[write_to_file for 'test.txt'] Result:\n<final_file_content path=\"test.txt\">\ntest\n\n</final_file_content>",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: "Response" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[write_to_file for 'other.txt'] Result:\n<final_file_content path=\"other.txt\">\nother content\n\n</final_file_content>",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const [didUpdate, indices] = contextManager.applyContextOptimizations(messages, 2, Date.now())
|
||||
|
||||
expect(didUpdate).to.equal(false)
|
||||
expect(indices.size).to.equal(0)
|
||||
})
|
||||
|
||||
it("returns false for empty messages beyond startFromIndex", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Initial task" },
|
||||
{ role: "assistant", content: "Response" },
|
||||
]
|
||||
|
||||
const [didUpdate, indices] = contextManager.applyContextOptimizations(messages, 2, Date.now())
|
||||
|
||||
expect(didUpdate).to.equal(false)
|
||||
expect(indices.size).to.equal(0)
|
||||
})
|
||||
|
||||
it("detects duplicate file reads with native tool calling format (tool_result blocks)", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Initial task" },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_001", name: "plan_mode_respond", input: {} }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_001",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[plan_mode_respond] Result:\n<user_message>\n'test2.txt' (see below for file content)\n</user_message>\n\n<file_content path=\"/Users/toshi/Desktop/cline_testing_repo/test2.txt\">\ntest\n\n</file_content>",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_002", name: "write_to_file", input: {} }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_002",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[write_to_file for '/Users/toshi/Desktop/cline_testing_repo/test2.txt'] Result:\nThe content was successfully saved.\n\n<final_file_content path=\"/Users/toshi/Desktop/cline_testing_repo/test2.txt\">\ntest\n\n</final_file_content>",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "text", text: "<environment_details>\n# Current Mode\nACT MODE\n</environment_details>" },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_003", name: "text", input: {} }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[TASK RESUMPTION] This task was interrupted just now. The conversation may have been incomplete.",
|
||||
},
|
||||
{ type: "text", text: "New message to respond to with plan_mode_respond tool" },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_004", name: "replace_in_file", input: {} }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_004",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[replace_in_file for '/Users/toshi/Desktop/cline_testing_repo/test2.txt'] Result:\nThe content was successfully saved.\n\n<final_file_content path=\"/Users/toshi/Desktop/cline_testing_repo/test2.txt\">\ntest2\n\n</final_file_content>",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "text", text: "<environment_details>\n# Current Mode\nACT MODE\n</environment_details>" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const timestamp = Date.now()
|
||||
const [didUpdate, indices] = contextManager.applyContextOptimizations(messages, 2, timestamp)
|
||||
|
||||
expect(didUpdate).to.equal(true)
|
||||
expect(indices.size).to.equal(2)
|
||||
expect(indices.has(2)).to.equal(true)
|
||||
expect(indices.has(4)).to.equal(true)
|
||||
expect(indices.has(8)).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTruncatedMessages", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("returns original messages when no range is provided", () => {
|
||||
const messages = createMessages(3)
|
||||
|
||||
const result = contextManager.getTruncatedMessages(messages, undefined)
|
||||
expect(result).to.deep.equal(messages)
|
||||
})
|
||||
|
||||
it("correctly removes messages in the specified range", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 3]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[1])
|
||||
expect(result[2]).to.deep.equal(messages[4])
|
||||
})
|
||||
|
||||
it("works with a range that starts at the first message after task", () => {
|
||||
const messages = createMessages(4)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[1])
|
||||
expect(result[2]).to.deep.equal(messages[3])
|
||||
})
|
||||
|
||||
it("correctly handles removing a range while preserving alternation pattern", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [2, 3]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[1])
|
||||
expect(result[2]).to.deep.equal(messages[4])
|
||||
|
||||
expect(result[0].role).to.equal("user")
|
||||
expect(result[1].role).to.equal("assistant")
|
||||
expect(result[2].role).to.equal("user")
|
||||
})
|
||||
|
||||
it("removes orphaned tool_results after truncation", () => {
|
||||
// Create messages with tool_use and tool_result blocks
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Initial task" },
|
||||
{ role: "assistant", content: "Response 1" },
|
||||
// Assistant message with tool_use that will be truncated
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Using a tool" },
|
||||
{ type: "tool_use", id: "tool_123", name: "read_file", input: { path: "test.ts" } },
|
||||
],
|
||||
},
|
||||
// User message with tool_result - should have tool_result removed after truncation
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "tool_123", content: "file content here" },
|
||||
{ type: "text", text: "Additional user text" },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: "Response 2" },
|
||||
]
|
||||
|
||||
// Truncate to remove the assistant message with tool_use
|
||||
const range: [number, number] = [2, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
// Should have 4 messages (original 5 minus 1 truncated)
|
||||
expect(result).to.have.lengthOf(4)
|
||||
|
||||
// The user message at index 2 should have tool_result removed but text preserved
|
||||
const userMessageAfterTruncation = result[2]
|
||||
expect(userMessageAfterTruncation.role).to.equal("user")
|
||||
expect(Array.isArray(userMessageAfterTruncation.content)).to.be.true
|
||||
|
||||
const content = userMessageAfterTruncation.content as Anthropic.Messages.ContentBlockParam[]
|
||||
// Should only have the text block, not the tool_result
|
||||
expect(content).to.have.lengthOf(1)
|
||||
expect(content[0].type).to.equal("text")
|
||||
expect((content[0] as Anthropic.Messages.TextBlockParam).text).to.equal("Additional user text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldCompactContextWindow", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("does not compact at 33K tokens with default 0.75 threshold on 200K context", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 30_000, tokensOut: 3_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("compacts when tokens exceed 0.75 threshold on 200K context", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 140_000, tokensOut: 15_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("compacts at only 10K tokens when threshold is accidentally set to 0.05", () => {
|
||||
const contextWindow = 200_000
|
||||
const accidentalThreshold = 0.05
|
||||
// floor(200000 * 0.05) = 10000 — this is the bug case from PR #9348.
|
||||
// Accidental clicks on the progress bar set threshold to ~5%, triggering
|
||||
// compaction at 10K tokens instead of the intended 150K (0.75 * 200K).
|
||||
const compactionTriggersAt = Math.floor(contextWindow * accidentalThreshold) // 10,000
|
||||
const totalTokens = compactionTriggersAt + 500 // 10,500 — just above the trigger
|
||||
|
||||
const api = createMockApi(contextWindow)
|
||||
const tokensIn = totalTokens - 1_500
|
||||
const tokensOut = 1_500
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn, tokensOut })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, accidentalThreshold)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("falls back to maxAllowedSize when threshold is undefined", () => {
|
||||
const api = createMockApi(200_000)
|
||||
// 155K tokens — above 0.75 threshold (150K) but below maxAllowedSize (160K)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 150_000, tokensOut: 5_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, undefined)
|
||||
// undefined → uses maxAllowedSize (160K), so 155K < 160K → false
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("falls back to maxAllowedSize when threshold is 0", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 150_000, tokensOut: 5_000 })]
|
||||
|
||||
// 0 is falsy, so ternary falls back to maxAllowedSize (160K)
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("includes cacheWrites and cacheReads in total token count", () => {
|
||||
const api = createMockApi(200_000)
|
||||
// Low direct tokens but high cache reads push total over threshold
|
||||
const clineMessages: ClineMessage[] = [
|
||||
createApiReqMessage({ tokensIn: 5_000, tokensOut: 500, cacheWrites: 0, cacheReads: 150_000 }),
|
||||
]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("returns false when previousApiReqIndex is negative", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 200_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, -1, 0.75)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("threshold is capped at maxAllowedSize even when percentage is very high", () => {
|
||||
const api = createMockApi(200_000)
|
||||
// threshold of 1.0 → floor(200000 * 1.0) = 200000, but min(200000, 160000) = 160000
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 165_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 1.0)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("compacts OpenAI Codex OAuth 400K models before their 272K input cap", () => {
|
||||
const api = createMockApi(400_000, "openai-codex")
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 250_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("keeps the normal threshold for non-Codex 400K models", () => {
|
||||
const api = createMockApi(400_000, "openai-native")
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 250_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { expect } from "chai"
|
||||
import { checkContextWindowExceededError } from "../context-error-handling"
|
||||
|
||||
describe("checkContextWindowExceededError", () => {
|
||||
it("detects OpenRouter context errors using structured status", () => {
|
||||
const error = Object.assign(
|
||||
new Error("This endpoint's maximum context length is 204800 tokens. However, you requested about 244027 tokens."),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
)
|
||||
|
||||
expect(checkContextWindowExceededError(error)).to.equal(true)
|
||||
})
|
||||
|
||||
it("detects OpenRouter JSON-encoded status + context length errors", () => {
|
||||
const error = new Error(
|
||||
'OpenRouter Mid-Stream Error: {"status":400,"message":"This endpoint\'s maximum context length is 200000 tokens"}',
|
||||
)
|
||||
|
||||
expect(checkContextWindowExceededError(error)).to.equal(true)
|
||||
})
|
||||
|
||||
it("does not classify unrelated 400 errors as context window failures", () => {
|
||||
const error = new Error("OpenRouter API Error 400: Invalid API key")
|
||||
|
||||
expect(checkContextWindowExceededError(error)).to.equal(false)
|
||||
})
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
import LengthFinishReasonError, { APIError } from "openai"
|
||||
|
||||
export function checkContextWindowExceededError(error: unknown): boolean {
|
||||
return (
|
||||
checkIsOpenAIContextWindowError(error) ||
|
||||
checkIsOpenRouterContextWindowError(error) ||
|
||||
checkIsAnthropicContextWindowError(error) ||
|
||||
checkIsCerebrasContextWindowError(error) ||
|
||||
checkIsBedrockContextWindowError(error) ||
|
||||
checkIsVercelContextWindowError(error)
|
||||
)
|
||||
}
|
||||
|
||||
function checkIsOpenRouterContextWindowError(error: any): boolean {
|
||||
try {
|
||||
// OpenRouter errors can reach us in two shapes:
|
||||
// 1) Direct chunk.error path wrapped as Error with status/code attached.
|
||||
// 2) Mid-stream finish_reason="error" path where JSON is stringified into message.
|
||||
// So we check structured status first, then JSON-encoded status/code in message text.
|
||||
const status = error?.status ?? error?.code ?? error?.error?.status ?? error?.response?.status
|
||||
const message: string = String(error?.message || error?.error?.message || "")
|
||||
|
||||
// Handle JSON-encoded errors where status/code is embedded in the message string.
|
||||
const statusFromMessage = message.match(/"code":\s*(\d+)/)?.[1] ?? message.match(/"status":\s*(\d+)/)?.[1]
|
||||
const finalStatus = statusFromMessage || status
|
||||
|
||||
// Known OpenAI/OpenRouter-style signal (code 400 and message includes "context length")
|
||||
const CONTEXT_ERROR_PATTERNS = [
|
||||
/\bcontext\s*(?:length|window)\b/i,
|
||||
/\bmaximum\s*context\b/i,
|
||||
/\b(?:input\s*)?tokens?\s*exceed/i,
|
||||
/\btoo\s*many\s*tokens?\b/i,
|
||||
] as const
|
||||
|
||||
return String(finalStatus) === "400" && CONTEXT_ERROR_PATTERNS.some((pattern) => pattern.test(message))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Docs: https://platform.openai.com/docs/guides/error-codes/api-errors
|
||||
function checkIsOpenAIContextWindowError(error: unknown): boolean {
|
||||
try {
|
||||
if (error instanceof LengthFinishReasonError) {
|
||||
return true
|
||||
}
|
||||
|
||||
const KNOWN_CONTEXT_ERROR_SUBSTRINGS = ["token", "context length"] as const
|
||||
|
||||
return (
|
||||
Boolean(error) &&
|
||||
error instanceof APIError &&
|
||||
error.code?.toString() === "400" &&
|
||||
KNOWN_CONTEXT_ERROR_SUBSTRINGS.some((substring) => error.message.includes(substring))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
try {
|
||||
return response?.error?.error?.type === "invalid_request_error"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function checkIsCerebrasContextWindowError(response: any): boolean {
|
||||
try {
|
||||
const status = response?.status ?? response?.code ?? response?.error?.status ?? response?.response?.status
|
||||
const message: string = String(response?.message || response?.error?.message || "")
|
||||
|
||||
return String(status) === "400" && message.includes("Please reduce the length of the messages or completion")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function checkIsBedrockContextWindowError(error: any): boolean {
|
||||
try {
|
||||
// Bedrock returns ValidationException for context window errors
|
||||
const errorType = error?.name ?? error?.error?.type ?? error?.__type
|
||||
const errorCode = error?.code ?? error?.error?.code ?? error?.$metadata?.httpStatusCode
|
||||
|
||||
// Handle nested error structures (e.g., through Vercel AI SDK)
|
||||
const nestedError = error?.error?.param
|
||||
const nestedErrorCode = nestedError?.statusCode ?? error?.details?.code
|
||||
const nestedMessage = nestedError?.message ?? nestedError?.error
|
||||
|
||||
const message: string = String(error?.message || error?.error?.message || nestedMessage || "")
|
||||
|
||||
// Check for ValidationException with HTTP 400
|
||||
const isValidationException =
|
||||
errorType === "ValidationException" ||
|
||||
errorType === "AI_APICallError" ||
|
||||
String(errorCode) === "400" ||
|
||||
String(nestedErrorCode) === "400" ||
|
||||
error?.code === "stream_initialization_failed"
|
||||
|
||||
if (!isValidationException) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Known Bedrock context window error patterns
|
||||
const BEDROCK_CONTEXT_PATTERNS = [
|
||||
/maximum tokens.*exceeds.*model limit/i,
|
||||
/input length and max_tokens exceed context limit/i,
|
||||
/context length.*exceeds/i,
|
||||
/total number of tokens.*exceeds.*limit/i,
|
||||
/requested.*tokens.*exceeds.*limit/i,
|
||||
/reduce.*length.*messages.*completion/i,
|
||||
/input is too long/i,
|
||||
] as const
|
||||
|
||||
return BEDROCK_CONTEXT_PATTERNS.some((pattern) => pattern.test(message))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function checkIsVercelContextWindowError(error: any): boolean {
|
||||
try {
|
||||
const status = error?.status ?? error?.error?.param?.statusCode ?? error?.statusCode
|
||||
|
||||
// Check for explicit context_length_exceeded code (OpenAI streaming errors)
|
||||
const errorCode = error?.error?.error?.code
|
||||
if (errorCode === "context_length_exceeded") {
|
||||
return true
|
||||
}
|
||||
|
||||
const messages: string[] = [
|
||||
error?.message,
|
||||
error?.error?.message,
|
||||
error?.error?.param?.message,
|
||||
error?.error?.param?.error,
|
||||
error?.error?.error?.message,
|
||||
error?.error?.value?.error_message, // Alibaba Qwen validation errors
|
||||
].filter((msg) => msg != null)
|
||||
|
||||
if (messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must be a 400 error OR have 400 embedded in error_message (Alibaba Qwen case)
|
||||
const hasValidStatus = String(status) === "400"
|
||||
const errorMessage = error?.error?.value?.error_message
|
||||
const has400InMessage =
|
||||
errorMessage &&
|
||||
typeof errorMessage === "string" &&
|
||||
(errorMessage.includes('"code":400') || errorMessage.includes('"code": 400'))
|
||||
|
||||
if (!hasValidStatus && !has400InMessage) {
|
||||
return false
|
||||
}
|
||||
|
||||
const CONTEXT_ERROR_PATTERNS = [
|
||||
/input is too long/i,
|
||||
/input token count exceeds.*maximum.*tokens? allowed/i,
|
||||
/input exceeds.*context window/i,
|
||||
/requested input length.*exceeds.*maximum input length/i,
|
||||
/prompt is too long.*tokens?\s*>\s*\d+\s*maximum/i,
|
||||
/\bcontext\s*(?:length|window)\b.*exceed/i,
|
||||
/\bmaximum\s*context\b/i,
|
||||
/\b(?:input\s*)?tokens?\s*exceed/i,
|
||||
/too\s*many\s*tokens/i,
|
||||
] as const
|
||||
|
||||
return messages
|
||||
.map((msg) => String(msg).toLowerCase())
|
||||
.some((message) => CONTEXT_ERROR_PATTERNS.some((pattern) => pattern.test(message)))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { ApiHandler } from "@core/api"
|
||||
|
||||
/**
|
||||
* Gets context window information for the given API handler
|
||||
*
|
||||
* @param api The API handler to get context window information for
|
||||
* @returns An object containing the raw context window size and the effective max allowed size
|
||||
*/
|
||||
export function getContextWindowInfo(api: ApiHandler) {
|
||||
const model = api.getModel()
|
||||
const contextWindow = model.info.contextWindow || 128_000
|
||||
const isOpenAiCodexOAuth = model.providerId === "openai-codex"
|
||||
const defaultMaxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8)
|
||||
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
case 400_000:
|
||||
// OpenAI Codex OAuth has a 272K input cap inside the 400K total context window.
|
||||
maxAllowedSize = isOpenAiCodexOAuth ? 272_000 - 40_000 : defaultMaxAllowedSize
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = defaultMaxAllowedSize // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
|
||||
return { contextWindow, maxAllowedSize }
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { collectEnvironmentMetadata, getTaskMetadata, saveTaskMetadata } from "@core/storage/disk"
|
||||
import type { EnvironmentMetadataEntry } from "./ContextTrackerTypes"
|
||||
|
||||
export class EnvironmentContextTracker {
|
||||
readonly taskId: string
|
||||
|
||||
constructor(taskId: string) {
|
||||
this.taskId = taskId
|
||||
}
|
||||
|
||||
async recordEnvironment() {
|
||||
const metadata = await getTaskMetadata(this.taskId)
|
||||
|
||||
if (!metadata.environment_history) {
|
||||
metadata.environment_history = []
|
||||
}
|
||||
|
||||
const currentEnv = await collectEnvironmentMetadata()
|
||||
const currentEnvWithTs: EnvironmentMetadataEntry = {
|
||||
ts: Date.now(),
|
||||
...currentEnv,
|
||||
}
|
||||
|
||||
const lastEntry = metadata.environment_history[metadata.environment_history.length - 1]
|
||||
if (lastEntry && this.isSameEnvironment(lastEntry, currentEnvWithTs)) {
|
||||
return // No change, don't add duplicate
|
||||
}
|
||||
|
||||
metadata.environment_history.push(currentEnvWithTs)
|
||||
await saveTaskMetadata(this.taskId, metadata)
|
||||
}
|
||||
|
||||
private isSameEnvironment(a: EnvironmentMetadataEntry, b: EnvironmentMetadataEntry): boolean {
|
||||
return (
|
||||
a.os_name === b.os_name &&
|
||||
a.os_version === b.os_version &&
|
||||
a.os_arch === b.os_arch &&
|
||||
a.host_name === b.host_name &&
|
||||
a.host_version === b.host_version &&
|
||||
a.cline_version === b.cline_version
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import * as diskModule from "@core/storage/disk"
|
||||
import { expect } from "chai"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import * as sinon from "sinon"
|
||||
import type { TaskMetadata } from "./ContextTrackerTypes"
|
||||
import { ModelContextTracker } from "./ModelContextTracker"
|
||||
|
||||
describe("ModelContextTracker", () => {
|
||||
const taskId = "test-task-id"
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let tracker: ModelContextTracker
|
||||
let mockTaskMetadata: TaskMetadata
|
||||
let getTaskMetadataStub: sinon.SinonStub
|
||||
let saveTaskMetadataStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Mock disk module functions
|
||||
mockTaskMetadata = { files_in_context: [], model_usage: [], environment_history: [] }
|
||||
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
|
||||
|
||||
// Create tracker instance
|
||||
tracker = new ModelContextTracker(taskId)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it("should record model usage with correct data", async () => {
|
||||
// Test data
|
||||
const apiProviderId = "anthropic"
|
||||
const modelId = "claude-3-opus"
|
||||
const mode = "act"
|
||||
|
||||
// Use a fake timer to have a predictable timestamp
|
||||
const fakeNow = 1617293940000 // Some fixed timestamp
|
||||
const clock = sandbox.useFakeTimers(fakeNow)
|
||||
|
||||
try {
|
||||
// Call the method being tested
|
||||
await tracker.recordModelUsage(apiProviderId, modelId, mode)
|
||||
|
||||
// Verify getTaskMetadata was called with correct parameters
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(getTaskMetadataStub.firstCall.args[0]).to.equal(taskId)
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Extract the saved metadata from the call arguments
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
|
||||
|
||||
// Verify model_usage array has one entry
|
||||
expect(savedMetadata.model_usage.length).to.equal(1)
|
||||
|
||||
// Verify the entry has the correct properties
|
||||
const modelUsageEntry = savedMetadata.model_usage[0]
|
||||
expect(modelUsageEntry.ts).to.equal(fakeNow)
|
||||
expect(modelUsageEntry.model_id).to.equal(modelId)
|
||||
expect(modelUsageEntry.model_provider_id).to.equal(apiProviderId)
|
||||
expect(modelUsageEntry.mode).to.equal(mode)
|
||||
} finally {
|
||||
// Restore the clock
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("should append model usage to existing entries", async () => {
|
||||
// Add an existing model usage entry
|
||||
const existingTimestamp = 1617200000000
|
||||
mockTaskMetadata.model_usage = [
|
||||
{
|
||||
ts: existingTimestamp,
|
||||
model_id: "existing-model",
|
||||
model_provider_id: "existing-provider",
|
||||
mode: "plan",
|
||||
},
|
||||
]
|
||||
|
||||
// Test data for new entry
|
||||
const apiProviderId = "anthropic"
|
||||
const modelId = "claude-3-sonnet"
|
||||
const mode = "act"
|
||||
|
||||
// Use a fake timer
|
||||
const newTimestamp = 1617300000000
|
||||
const clock = sandbox.useFakeTimers(newTimestamp)
|
||||
|
||||
try {
|
||||
// Call the method being tested
|
||||
await tracker.recordModelUsage(apiProviderId, modelId, mode)
|
||||
|
||||
// Verify saveTaskMetadata was called
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Extract the saved metadata
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
|
||||
|
||||
// Verify model_usage array now has two entries
|
||||
expect(savedMetadata.model_usage.length).to.equal(2)
|
||||
|
||||
// Verify the existing entry is preserved
|
||||
expect(savedMetadata.model_usage[0]).to.deep.equal({
|
||||
ts: existingTimestamp,
|
||||
model_id: "existing-model",
|
||||
model_provider_id: "existing-provider",
|
||||
mode: "plan",
|
||||
})
|
||||
|
||||
// Verify the new entry has correct data
|
||||
expect(savedMetadata.model_usage[1]).to.deep.equal({
|
||||
ts: newTimestamp,
|
||||
model_id: modelId,
|
||||
model_provider_id: apiProviderId,
|
||||
mode: mode,
|
||||
})
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle multiple model usages in sequence", async () => {
|
||||
// Test data for sequential calls
|
||||
const usages = [
|
||||
{ provider: "anthropic", model: "claude-3-opus", mode: "plan" },
|
||||
{ provider: "openai", model: "gpt-4", mode: "act" },
|
||||
{ provider: "anthropic", model: "claude-3-haiku", mode: "plan" },
|
||||
]
|
||||
|
||||
// Use a fake timer that advances with each call
|
||||
const startTime = 1617300000000
|
||||
const clock = sandbox.useFakeTimers(startTime)
|
||||
|
||||
try {
|
||||
// Record multiple model usages
|
||||
for (let i = 0; i < usages.length; i++) {
|
||||
const { provider, model, mode } = usages[i]
|
||||
|
||||
// Advance time by 1 second for each call
|
||||
clock.tick(1000)
|
||||
const expectedTime = startTime + (i + 1) * 1000
|
||||
|
||||
// Reset history between calls to check individual call behavior
|
||||
getTaskMetadataStub.resetHistory()
|
||||
saveTaskMetadataStub.resetHistory()
|
||||
|
||||
// Reset mock metadata for each iteration to avoid accumulation
|
||||
mockTaskMetadata.model_usage = []
|
||||
|
||||
// Call the method
|
||||
await tracker.recordModelUsage(provider, model, mode)
|
||||
|
||||
// Verify interaction with disk module
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Get the saved metadata
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
|
||||
|
||||
// Since we reset the array for each call, we should always have 1 entry
|
||||
expect(savedMetadata.model_usage.length).to.equal(1)
|
||||
|
||||
// Check the entry
|
||||
const entry = savedMetadata.model_usage[0]
|
||||
expect(entry.ts).to.equal(expectedTime)
|
||||
expect(entry.model_id).to.equal(model)
|
||||
expect(entry.model_provider_id).to.equal(provider)
|
||||
expect(entry.mode).to.equal(mode)
|
||||
}
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { getTaskMetadata, saveTaskMetadata } from "@core/storage/disk"
|
||||
|
||||
export class ModelContextTracker {
|
||||
readonly taskId: string
|
||||
|
||||
constructor(taskId: string) {
|
||||
this.taskId = taskId
|
||||
}
|
||||
|
||||
async recordModelUsage(apiProviderId: string, modelId: string, mode: string) {
|
||||
const metadata = await getTaskMetadata(this.taskId)
|
||||
|
||||
if (!metadata.model_usage) {
|
||||
metadata.model_usage = []
|
||||
}
|
||||
|
||||
// check to see if the last entry is the same as the new one
|
||||
const lastEntry = metadata.model_usage[metadata.model_usage.length - 1]
|
||||
if (
|
||||
lastEntry &&
|
||||
lastEntry.model_id === modelId &&
|
||||
lastEntry.model_provider_id === apiProviderId &&
|
||||
lastEntry.mode === mode
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
metadata.model_usage.push({
|
||||
ts: Date.now(),
|
||||
model_id: modelId,
|
||||
model_provider_id: apiProviderId,
|
||||
mode: mode,
|
||||
})
|
||||
|
||||
await saveTaskMetadata(this.taskId, metadata)
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { extractPathLikeStrings, RuleEvaluationContext, toWorkspaceRelativePosixPath } from "./rule-conditionals"
|
||||
|
||||
type WorkspaceRoot = { path: string }
|
||||
type WorkspaceManagerLike = { getRoots(): WorkspaceRoot[] }
|
||||
|
||||
type ClineMessageLike = {
|
||||
type: string
|
||||
ask?: string
|
||||
say?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
type MessageStateHandlerLike = {
|
||||
getClineMessages(): ClineMessageLike[]
|
||||
}
|
||||
|
||||
export type RuleContextBuilderDeps = {
|
||||
cwd: string
|
||||
messageStateHandler: MessageStateHandlerLike
|
||||
workspaceManager?: WorkspaceManagerLike
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the evaluation context used for conditional Cline Rules (e.g. YAML frontmatter `paths:`).
|
||||
*
|
||||
* Kept in the user-instructions domain so Task remains orchestration-focused.
|
||||
*
|
||||
* Path context is gathered from multiple sources in clineMessages:
|
||||
* - User messages (task, user_feedback)
|
||||
* - Visible/open tabs
|
||||
* - Tool results (say="tool") - completed operations
|
||||
* - Tool requests (ask="tool") - pending operations (captures intent before execution)
|
||||
*/
|
||||
export class RuleContextBuilder {
|
||||
/**
|
||||
* Maximum number of path candidates to consider for rule activation.
|
||||
* This cap prevents performance degradation in long-running tasks with many file operations.
|
||||
*/
|
||||
static readonly MAX_RULE_PATH_CANDIDATES = 100
|
||||
|
||||
static async buildEvaluationContext(deps: RuleContextBuilderDeps): Promise<RuleEvaluationContext> {
|
||||
return {
|
||||
paths: await RuleContextBuilder.getRulePathContext(deps),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse apply_patch input to extract target file paths from patch headers.
|
||||
* Matches lines like: *** Add File: path/to/file.ts
|
||||
*/
|
||||
private static extractPathsFromApplyPatch(input: string): string[] {
|
||||
if (typeof input !== "string" || !input) return []
|
||||
|
||||
const paths: string[] = []
|
||||
const fileHeaderRegex = /^\*\*\* (?:Add|Update|Delete) File: (.+?)(?:\n|$)/gm
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = fileHeaderRegex.exec(input))) {
|
||||
const filePath = (m[1] || "").trim()
|
||||
if (filePath) {
|
||||
paths.push(filePath)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
private static async getRulePathContext(deps: RuleContextBuilderDeps): Promise<string[]> {
|
||||
const candidates: string[] = []
|
||||
const clineMessages = deps.messageStateHandler.getClineMessages()
|
||||
|
||||
// (1) Current-turn user message evidence:
|
||||
// Use the most recent user-authored text (initial task or subsequent feedback).
|
||||
// NOTE: We intentionally prefer the latest user_feedback over the original task to
|
||||
// support first-turn activation on later turns.
|
||||
const lastUserMsg = [...clineMessages]
|
||||
.reverse()
|
||||
.find((m) => m.type === "say" && (m.say === "user_feedback" || m.say === "task") && typeof m.text === "string")
|
||||
if (lastUserMsg?.text) {
|
||||
candidates.push(...extractPathLikeStrings(lastUserMsg.text))
|
||||
}
|
||||
|
||||
// (2) Visible + open tabs
|
||||
const roots = deps.workspaceManager?.getRoots().map((r) => r.path) ?? [deps.cwd]
|
||||
const rawVisiblePaths = (await HostProvider.window.getVisibleTabs({}))?.paths ?? []
|
||||
const rawOpenTabPaths = (await HostProvider.window.getOpenTabs({}))?.paths ?? []
|
||||
for (const abs of [...rawVisiblePaths, ...rawOpenTabPaths]) {
|
||||
for (const root of roots) {
|
||||
const rel = toWorkspaceRelativePosixPath(abs, root)
|
||||
if (rel) {
|
||||
candidates.push(rel)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Files edited by Cline during this task (completed operations):
|
||||
// Parse say="tool" messages for tool results indicating file operations.
|
||||
for (const msg of clineMessages) {
|
||||
if (msg.type !== "say" || msg.say !== "tool" || !msg.text) continue
|
||||
try {
|
||||
const tool = JSON.parse(msg.text) as { tool?: string; path?: string }
|
||||
if (
|
||||
(tool.tool === "editedExistingFile" || tool.tool === "newFileCreated" || tool.tool === "fileDeleted") &&
|
||||
tool.path
|
||||
) {
|
||||
candidates.push(tool.path)
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// (4) Tool requests (pending operations):
|
||||
// Parse ask="tool" messages to capture the assistant's intent BEFORE tool execution.
|
||||
// This enables rule activation even when:
|
||||
// - The tool hasn't completed yet
|
||||
// - The tool fails (intent was still expressed)
|
||||
// - Files don't exist yet (new file creation)
|
||||
for (const msg of clineMessages) {
|
||||
if (msg.type !== "ask" || msg.ask !== "tool" || !msg.text) continue
|
||||
try {
|
||||
const tool = JSON.parse(msg.text) as {
|
||||
tool?: string
|
||||
path?: string
|
||||
content?: string // apply_patch stores patch content here
|
||||
}
|
||||
|
||||
// Extract path from standard file tools
|
||||
if (tool.path) {
|
||||
candidates.push(tool.path)
|
||||
}
|
||||
|
||||
// Handle apply_patch specially: parse patch headers for file paths
|
||||
if (tool.tool === "applyPatch" && tool.content) {
|
||||
candidates.push(...RuleContextBuilder.extractPathsFromApplyPatch(tool.content))
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize/dedupe/cap
|
||||
const seen = new Set<string>()
|
||||
const normalized: string[] = []
|
||||
for (const c of candidates) {
|
||||
const posix = c.replace(/\\/g, "/").replace(/^\//, "")
|
||||
if (!posix || posix === "/") continue
|
||||
if (seen.has(posix)) continue
|
||||
seen.add(posix)
|
||||
normalized.push(posix)
|
||||
if (normalized.length >= RuleContextBuilder.MAX_RULE_PATH_CANDIDATES) break
|
||||
}
|
||||
return normalized.sort()
|
||||
}
|
||||
}
|
||||
-284
@@ -1,284 +0,0 @@
|
||||
import { expect } from "chai"
|
||||
import sinon from "sinon"
|
||||
import { RuleContextBuilder, RuleContextBuilderDeps } from "../RuleContextBuilder"
|
||||
|
||||
// Mock HostProvider to avoid actual VSCode API calls
|
||||
const mockHostProvider = {
|
||||
window: {
|
||||
getVisibleTabs: sinon.stub().resolves({ paths: [] }),
|
||||
getOpenTabs: sinon.stub().resolves({ paths: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
describe("RuleContextBuilder", () => {
|
||||
let hostProviderStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
// Stub HostProvider to use mock
|
||||
hostProviderStub = sinon.stub(require("@/hosts/host-provider"), "HostProvider").value(mockHostProvider)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("getRulePathContext from ask='tool' messages", () => {
|
||||
it("extracts path from ask='tool' message with write_to_file", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src/components/Button.tsx",
|
||||
content: "// new file",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/components/Button.tsx")
|
||||
})
|
||||
|
||||
it("extracts paths from multiple sequential tool requests", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src/utils/helper.ts",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "replace_in_file",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/utils/helper.ts")
|
||||
expect(context.paths).to.include("src/index.ts")
|
||||
})
|
||||
|
||||
it("handles malformed JSON gracefully", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: "not valid json {{{",
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "valid/path.ts",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// Should not throw and should extract the valid path
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("valid/path.ts")
|
||||
})
|
||||
|
||||
it("extracts paths from apply_patch tool request", async () => {
|
||||
const patchContent = `*** Add File: src/new-feature.ts
|
||||
+const x = 1
|
||||
|
||||
*** Update File: src/existing.ts
|
||||
---
|
||||
+++
|
||||
@@ 1,1 @@
|
||||
-const y = 2
|
||||
+const y = 3`
|
||||
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "applyPatch",
|
||||
content: patchContent,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/new-feature.ts")
|
||||
expect(context.paths).to.include("src/existing.ts")
|
||||
})
|
||||
|
||||
it("deduplicates paths from multiple sources", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
text: "Update src/index.ts",
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "say",
|
||||
say: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "editedExistingFile",
|
||||
path: "src/index.ts",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
// Should only appear once despite being in 3 messages
|
||||
const indexCount = (context.paths ?? []).filter((p) => p === "src/index.ts").length
|
||||
expect(indexCount).to.equal(1)
|
||||
})
|
||||
|
||||
it("normalizes Windows-style paths to POSIX", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: "src\\components\\Button.tsx",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/components/Button.tsx")
|
||||
})
|
||||
|
||||
it("respects MAX_RULE_PATH_CANDIDATES limit", async () => {
|
||||
// Create more messages than the limit
|
||||
const messages: Array<{ type: string; ask: string; text: string }> = []
|
||||
for (let i = 0; i < RuleContextBuilder.MAX_RULE_PATH_CANDIDATES + 50; i++) {
|
||||
messages.push({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "write_to_file",
|
||||
path: `src/file${i}.ts`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => messages,
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect((context.paths ?? []).length).to.be.at.most(RuleContextBuilder.MAX_RULE_PATH_CANDIDATES)
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractPathsFromApplyPatch", () => {
|
||||
it("extracts paths from Add File headers", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "applyPatch",
|
||||
content: "*** Add File: src/new.ts\n+content",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/new.ts")
|
||||
})
|
||||
|
||||
it("extracts paths from Update File headers", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "applyPatch",
|
||||
content: "*** Update File: src/existing.ts\n--- \n+++ \n@@ 1,1 @@\n-old\n+new",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/existing.ts")
|
||||
})
|
||||
|
||||
it("extracts paths from Delete File headers", async () => {
|
||||
const deps: RuleContextBuilderDeps = {
|
||||
cwd: "/workspace",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "applyPatch",
|
||||
content: "*** Delete File: src/old.ts",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const context = await RuleContextBuilder.buildEvaluationContext(deps)
|
||||
expect(context.paths).to.include("src/old.ts")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,154 +0,0 @@
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { StreamingResponseHandler } from "./grpc-handler"
|
||||
import { Controller } from "./index"
|
||||
|
||||
/**
|
||||
* Generic type for service method handlers
|
||||
*/
|
||||
export type ServiceMethodHandler = (controller: Controller, message: any) => Promise<any>
|
||||
|
||||
/**
|
||||
* Type for streaming method handlers
|
||||
*/
|
||||
export type StreamingMethodHandler = (
|
||||
controller: Controller,
|
||||
message: any,
|
||||
responseStream: StreamingResponseHandler<any>,
|
||||
requestId?: string,
|
||||
) => Promise<void>
|
||||
|
||||
/**
|
||||
* Method metadata including streaming information
|
||||
*/
|
||||
export interface MethodMetadata {
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic service registry for gRPC services
|
||||
*/
|
||||
export class ServiceRegistry {
|
||||
private serviceName: string
|
||||
private methodRegistry: Record<string, ServiceMethodHandler> = {}
|
||||
private streamingMethodRegistry: Record<string, StreamingMethodHandler> = {}
|
||||
private methodMetadata: Record<string, MethodMetadata> = {}
|
||||
|
||||
/**
|
||||
* Create a new service registry
|
||||
* @param serviceName The name of the service (used for logging)
|
||||
*/
|
||||
constructor(serviceName: string) {
|
||||
Logger.log(`Registering Protobus service: ${serviceName}...`)
|
||||
this.serviceName = serviceName
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a method handler
|
||||
* @param methodName The name of the method to register
|
||||
* @param handler The handler function for the method
|
||||
* @param metadata Optional metadata about the method
|
||||
*/
|
||||
registerMethod(methodName: string, handler: ServiceMethodHandler | StreamingMethodHandler, metadata?: MethodMetadata): void {
|
||||
const isStreaming = metadata?.isStreaming || false
|
||||
|
||||
if (isStreaming) {
|
||||
this.streamingMethodRegistry[methodName] = handler as StreamingMethodHandler
|
||||
} else {
|
||||
this.methodRegistry[methodName] = handler as ServiceMethodHandler
|
||||
}
|
||||
|
||||
this.methodMetadata[methodName] = { isStreaming, ...metadata }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a method is a streaming method
|
||||
* @param method The method name
|
||||
* @returns True if the method is a streaming method
|
||||
*/
|
||||
isStreamingMethod(method: string): boolean {
|
||||
return this.methodMetadata[method]?.isStreaming || false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a streaming method handler
|
||||
* @param method The method name
|
||||
* @returns The streaming method handler or undefined if not found
|
||||
*/
|
||||
getStreamingHandler(method: string): StreamingMethodHandler | undefined {
|
||||
return this.streamingMethodRegistry[method]
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a service request
|
||||
* @param controller The controller instance
|
||||
* @param method The method name
|
||||
* @param message The request message
|
||||
* @returns The response message
|
||||
*/
|
||||
async handleRequest(controller: Controller, method: string, message: any): Promise<any> {
|
||||
const handler = this.methodRegistry[method]
|
||||
|
||||
if (!handler) {
|
||||
if (this.isStreamingMethod(method)) {
|
||||
throw new Error(`Method ${method} is a streaming method and should be handled with handleStreamingRequest`)
|
||||
}
|
||||
throw new Error(`Unknown ${this.serviceName} method: ${method}`)
|
||||
}
|
||||
|
||||
return handler(controller, message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a streaming service request
|
||||
* @param controller The controller instance
|
||||
* @param method The method name
|
||||
* @param message The request message
|
||||
* @param responseStream The streaming response handler
|
||||
* @param requestId The request ID for correlation and cleanup
|
||||
*/
|
||||
async handleStreamingRequest(
|
||||
controller: Controller,
|
||||
method: string,
|
||||
message: any,
|
||||
responseStream: StreamingResponseHandler<any>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
const handler = this.streamingMethodRegistry[method]
|
||||
|
||||
if (!handler) {
|
||||
if (this.methodRegistry[method]) {
|
||||
throw new Error(`Method ${method} is not a streaming method and should be handled with handleRequest`)
|
||||
}
|
||||
throw new Error(`Unknown ${this.serviceName} streaming method: ${method}`)
|
||||
}
|
||||
|
||||
await handler(controller, message, responseStream, requestId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a service registry factory function
|
||||
* @param serviceName The name of the service
|
||||
* @returns An object with register and handle functions
|
||||
*/
|
||||
export function createServiceRegistry(serviceName: string) {
|
||||
const registry = new ServiceRegistry(serviceName)
|
||||
|
||||
return {
|
||||
registerMethod: (methodName: string, handler: ServiceMethodHandler | StreamingMethodHandler, metadata?: MethodMetadata) =>
|
||||
registry.registerMethod(methodName, handler, metadata),
|
||||
|
||||
handleRequest: (controller: Controller, method: string, message: any) =>
|
||||
registry.handleRequest(controller, method, message),
|
||||
|
||||
handleStreamingRequest: (
|
||||
controller: Controller,
|
||||
method: string,
|
||||
message: any,
|
||||
responseStream: StreamingResponseHandler<any>,
|
||||
requestId?: string,
|
||||
) => registry.handleStreamingRequest(controller, method, message, responseStream, requestId),
|
||||
|
||||
isStreamingMethod: (method: string) => registry.isStreamingMethod(method),
|
||||
}
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
import type { HookOutputStreamMeta } from "@shared/ExtensionMessage"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { HookOutput } from "@shared/proto/cline/hooks"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { MessageStateHandler } from "../task/message-state"
|
||||
import { HookExecutionError } from "./HookError"
|
||||
import type { HookModelInputContext } from "./hook-factory"
|
||||
import { HookFactory } from "./hook-factory"
|
||||
|
||||
export interface HookExecutionOptions<Name extends keyof Hooks = any> {
|
||||
hookName: Name
|
||||
hookInput: Hooks[Name]
|
||||
isCancellable: boolean
|
||||
say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
|
||||
setActiveHookExecution?: (execution: {
|
||||
hookName: string
|
||||
toolName: string | undefined
|
||||
messageTs: number
|
||||
abortController: AbortController
|
||||
}) => Promise<void>
|
||||
clearActiveHookExecution?: () => Promise<void>
|
||||
messageStateHandler: MessageStateHandler
|
||||
taskId: string
|
||||
hooksEnabled: boolean
|
||||
model?: HookModelInputContext
|
||||
toolName?: string // Optional tool name for PreToolUse/PostToolUse hooks
|
||||
pendingToolInfo?: any // Optional metadata about pending tool execution for PreToolUse
|
||||
}
|
||||
|
||||
// Import Hooks type from HookFactory
|
||||
type Hooks = import("./hook-factory").Hooks
|
||||
|
||||
export interface HookExecutionResult {
|
||||
cancel?: boolean
|
||||
contextModification?: string
|
||||
errorMessage?: string
|
||||
wasCancelled: boolean
|
||||
}
|
||||
|
||||
function fromHookOutput(output: HookOutput): HookExecutionResult {
|
||||
// HookOutput is protobuf-generated, so fields are defaulted (e.g. ""). Treat empty
|
||||
// strings as “unset” in the hook executor API.
|
||||
const contextModification = output.contextModification?.trim() ? output.contextModification : undefined
|
||||
const errorMessage = output.errorMessage?.trim() ? output.errorMessage : undefined
|
||||
|
||||
return {
|
||||
cancel: output.cancel,
|
||||
contextModification,
|
||||
errorMessage,
|
||||
wasCancelled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a hook with standardized error handling, status tracking, and cleanup.
|
||||
* This consolidates the common pattern used across all hook execution sites.
|
||||
*/
|
||||
export async function executeHook<Name extends keyof Hooks>(options: HookExecutionOptions<Name>): Promise<HookExecutionResult> {
|
||||
const {
|
||||
hookName,
|
||||
hookInput,
|
||||
isCancellable,
|
||||
say,
|
||||
setActiveHookExecution,
|
||||
clearActiveHookExecution,
|
||||
messageStateHandler,
|
||||
taskId,
|
||||
hooksEnabled,
|
||||
} = options
|
||||
|
||||
// Early return if hooks are disabled
|
||||
if (!hooksEnabled) {
|
||||
return {
|
||||
wasCancelled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the hook exists
|
||||
const hookFactory = new HookFactory()
|
||||
const hasHook = await hookFactory.hasHook(hookName)
|
||||
|
||||
if (!hasHook) {
|
||||
return { wasCancelled: false }
|
||||
}
|
||||
|
||||
let hookMessageTs: number | undefined
|
||||
const abortController = new AbortController()
|
||||
|
||||
// Declare hookInfo with empty default - populated inside try block.
|
||||
// If getHookInfo throws, error handlers will use the empty default.
|
||||
let hookInfo: { scriptPaths: string[] } = { scriptPaths: [] }
|
||||
|
||||
try {
|
||||
// Get hook info including script paths
|
||||
hookInfo = await hookFactory.getHookInfo(hookName)
|
||||
|
||||
// Show hook execution indicator and capture timestamp
|
||||
const hookMetadata = {
|
||||
hookName,
|
||||
...(options.toolName && { toolName: options.toolName }),
|
||||
status: "running",
|
||||
scriptPaths: hookInfo.scriptPaths,
|
||||
...(options.pendingToolInfo && { pendingToolInfo: options.pendingToolInfo }),
|
||||
}
|
||||
hookMessageTs = await say("hook_status", JSON.stringify(hookMetadata))
|
||||
|
||||
// Reorder messages immediately so hook UI appears above tool UI
|
||||
// This must happen right after creating the hook message, before the hook runs
|
||||
if (hookName === "PreToolUse") {
|
||||
await reorderHookAndToolMessages(messageStateHandler)
|
||||
}
|
||||
|
||||
// Track active hook execution for cancellation (only if cancellable and message was created)
|
||||
if (isCancellable && hookMessageTs !== undefined && setActiveHookExecution) {
|
||||
await setActiveHookExecution({
|
||||
hookName,
|
||||
toolName: options.toolName,
|
||||
messageTs: hookMessageTs,
|
||||
abortController,
|
||||
})
|
||||
}
|
||||
|
||||
// Create streaming callback
|
||||
const streamCallback = async (line: string, stream: "stdout" | "stderr", meta?: HookOutputStreamMeta) => {
|
||||
// Preserve script identity for multi-hook (global + workspace) scenarios.
|
||||
// Without this, concurrent hooks interleave output and it's hard to tell which
|
||||
// script produced which line (and can look like only one hook is printing).
|
||||
//
|
||||
// NOTE: We keep backward compatibility by encoding metadata into the string.
|
||||
// The CLI prints this as-is in verbose mode.
|
||||
const prefixParts: string[] = []
|
||||
if (meta?.source) prefixParts.push(meta.source)
|
||||
prefixParts.push(stream)
|
||||
// Use a shortened path for readability; full path is still available in hook_status.
|
||||
if (meta?.scriptPath) {
|
||||
const parts = meta.scriptPath.split(/[/\\]/).filter(Boolean)
|
||||
prefixParts.push(parts.slice(-3).join("/"))
|
||||
}
|
||||
const prefix = prefixParts.length ? `[${prefixParts.join(" ")}] ` : ""
|
||||
await say("hook_output_stream", prefix + line)
|
||||
}
|
||||
|
||||
// Create and execute hook
|
||||
const hook = await hookFactory.createWithStreaming(
|
||||
hookName,
|
||||
streamCallback,
|
||||
isCancellable ? abortController.signal : undefined,
|
||||
taskId,
|
||||
options.toolName,
|
||||
)
|
||||
|
||||
const result = await hook.run({
|
||||
taskId,
|
||||
...hookInput,
|
||||
model: options.model,
|
||||
})
|
||||
|
||||
Logger.log(`[${hookName} Hook]`, result)
|
||||
|
||||
// NoOp hooks return proto defaults; preserve the minimal legacy return shape.
|
||||
if (result.cancel === false && result.contextModification === "" && result.errorMessage === "") {
|
||||
return { wasCancelled: false }
|
||||
}
|
||||
|
||||
// Check if hook wants to cancel
|
||||
if (result.cancel === true) {
|
||||
// Update hook status to cancelled
|
||||
if (hookMessageTs !== undefined) {
|
||||
await updateHookMessage(messageStateHandler, hookMessageTs, {
|
||||
hookName,
|
||||
...(options.toolName && { toolName: options.toolName }),
|
||||
status: "cancelled",
|
||||
exitCode: 130,
|
||||
hasJsonResponse: true,
|
||||
scriptPaths: hookInfo.scriptPaths,
|
||||
})
|
||||
}
|
||||
|
||||
return fromHookOutput(result)
|
||||
}
|
||||
|
||||
// Clear active hook execution after successful completion (only if cancellable)
|
||||
if (isCancellable && clearActiveHookExecution) {
|
||||
await clearActiveHookExecution()
|
||||
}
|
||||
|
||||
// Update hook status to completed (only if not cancelled)
|
||||
if (hookMessageTs !== undefined) {
|
||||
await updateHookMessage(messageStateHandler, hookMessageTs, {
|
||||
hookName,
|
||||
...(options.toolName && { toolName: options.toolName }),
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
hasJsonResponse: true,
|
||||
scriptPaths: hookInfo.scriptPaths,
|
||||
})
|
||||
}
|
||||
|
||||
return fromHookOutput(result)
|
||||
} catch (hookError) {
|
||||
// Clear active hook execution (only if cancellable)
|
||||
if (isCancellable && clearActiveHookExecution) {
|
||||
await clearActiveHookExecution()
|
||||
}
|
||||
|
||||
// Check if this was a user cancellation via abort controller
|
||||
if (abortController.signal.aborted) {
|
||||
// Update hook status to cancelled
|
||||
if (hookMessageTs !== undefined) {
|
||||
await updateHookMessage(messageStateHandler, hookMessageTs, {
|
||||
hookName,
|
||||
status: "cancelled",
|
||||
exitCode: 130,
|
||||
scriptPaths: hookInfo.scriptPaths,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
cancel: true,
|
||||
wasCancelled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Update hook status to failed for actual errors
|
||||
// Extract structured error info if available
|
||||
const isStructuredError = HookExecutionError.isHookError(hookError)
|
||||
const errorInfo = isStructuredError ? hookError.errorInfo : null
|
||||
|
||||
if (hookMessageTs !== undefined) {
|
||||
await updateHookMessage(messageStateHandler, hookMessageTs, {
|
||||
hookName,
|
||||
status: "failed",
|
||||
exitCode: errorInfo?.exitCode ?? 1,
|
||||
scriptPaths: hookInfo.scriptPaths,
|
||||
...(errorInfo && {
|
||||
error: {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
details: errorInfo.details,
|
||||
scriptPath: errorInfo.scriptPath,
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// Log error for non-cancellable hooks or unexpected errors
|
||||
Logger.error(`${hookName} hook failed:`, hookError)
|
||||
|
||||
// Return safe defaults for all fields to avoid undefined property access
|
||||
return {
|
||||
cancel: false,
|
||||
contextModification: undefined,
|
||||
errorMessage: undefined,
|
||||
wasCancelled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to update hook message status in message state
|
||||
*/
|
||||
async function updateHookMessage(
|
||||
messageStateHandler: MessageStateHandler,
|
||||
hookMessageTs: number,
|
||||
metadata: Record<string, any>,
|
||||
): Promise<void> {
|
||||
const clineMessages = messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m: ClineMessage) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
await messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(metadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders hook and tool messages so hook UI appears before tool UI.
|
||||
* This is called immediately after a hook message is created.
|
||||
*
|
||||
* The algorithm:
|
||||
* 1. Find the most recent tool message (ask or say with type "tool", "command", "use_mcp_server", or "browser_action_launch")
|
||||
* 2. Find any hook messages that came after it
|
||||
* 3. Delete the tool message
|
||||
* 4. Re-add the tool message at the end (after hook messages)
|
||||
*/
|
||||
async function reorderHookAndToolMessages(messageStateHandler: MessageStateHandler): Promise<void> {
|
||||
const clineMessages = messageStateHandler.getClineMessages()
|
||||
|
||||
// Define all message types that represent tool executions with PreToolUse hooks
|
||||
const toolMessageTypes = ["tool", "command", "use_mcp_server", "browser_action_launch"]
|
||||
|
||||
// Find the most recent tool message
|
||||
let lastToolMessageIndex = -1
|
||||
for (let i = clineMessages.length - 1; i >= 0; i--) {
|
||||
const msgType = clineMessages[i].ask || clineMessages[i].say
|
||||
if (msgType && toolMessageTypes.includes(msgType)) {
|
||||
lastToolMessageIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (lastToolMessageIndex === -1) {
|
||||
return // No tool message found, nothing to reorder
|
||||
}
|
||||
|
||||
// Check if there are any hook messages after the tool message
|
||||
let hasHookMessagesAfterTool = false
|
||||
for (let i = lastToolMessageIndex + 1; i < clineMessages.length; i++) {
|
||||
if (clineMessages[i].say === "hook_status" || clineMessages[i].say === "hook_output_stream") {
|
||||
hasHookMessagesAfterTool = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasHookMessagesAfterTool) {
|
||||
return // No reordering needed
|
||||
}
|
||||
|
||||
// Delete the tool message at its current position
|
||||
await messageStateHandler.deleteClineMessage(lastToolMessageIndex)
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { SqliteLockManager } from "./SqliteLockManager"
|
||||
import type { FolderLockOptions, FolderLockResult, FolderLockWithRetryResult } from "./types"
|
||||
|
||||
/**
|
||||
* Retry configuration for folder lock acquisition
|
||||
*/
|
||||
export interface FolderLockRetryConfig {
|
||||
initialDelayMs: number
|
||||
incrementPerAttemptMs: number
|
||||
maxTotalTimeoutMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Default retry configuration for folder locks:
|
||||
* - 500ms initial wait - this is typically enough for most cases
|
||||
* - +1s backoff per attempt
|
||||
* - 30s max total timeout
|
||||
*/
|
||||
export const DEFAULT_RETRY_CONFIG: FolderLockRetryConfig = {
|
||||
initialDelayMs: 500,
|
||||
incrementPerAttemptMs: 1000,
|
||||
maxTotalTimeoutMs: 30000,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lock manager instance for standalone mode.
|
||||
*/
|
||||
export async function getStandaloneLockManager(): Promise<SqliteLockManager | undefined> {
|
||||
try {
|
||||
const { getLockManager } = await import("../../standalone/lock-manager")
|
||||
return getLockManager()
|
||||
} catch (_importError) {
|
||||
Logger.debug("Lock manager not available")
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to acquire a folder lock with retry logic.
|
||||
* This is a generic utility that works with any folder path.
|
||||
*
|
||||
* @param lockTarget - The folder path to lock
|
||||
* @param config - Optional retry configuration if defaults are not suitable
|
||||
* @returns Promise<boolean> true if lock acquired, false if timeout
|
||||
*/
|
||||
export async function tryAcquireFolderLockWithRetry(
|
||||
options: FolderLockOptions,
|
||||
config?: FolderLockRetryConfig,
|
||||
): Promise<FolderLockWithRetryResult> {
|
||||
return await retryFolderLockAcquisition(async () => {
|
||||
try {
|
||||
const lockManager = await getStandaloneLockManager()
|
||||
|
||||
if (!lockManager) {
|
||||
Logger.debug("Lock manager not available - skipping lock acquisition")
|
||||
return { acquired: false, skipped: true }
|
||||
}
|
||||
|
||||
Logger.log(`Attempting to acquire folder lock for: ${options.lockTarget}`)
|
||||
|
||||
const result = await acquireFolderLock(options)
|
||||
|
||||
return { acquired: result.acquired, conflictingLock: result.conflictingLock, skipped: false }
|
||||
} catch (error) {
|
||||
Logger.error("Error in folder lock acquisition attempt:", error)
|
||||
return { acquired: false }
|
||||
}
|
||||
}, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a folder lock safely with error handling.
|
||||
* This is a generic utility that works with any folder path.
|
||||
*
|
||||
* @param lockTarget - The folder path to release
|
||||
*/
|
||||
export async function releaseFolderLock(taskId: string, lockTarget: string): Promise<void> {
|
||||
try {
|
||||
const lockManager = await getStandaloneLockManager()
|
||||
|
||||
if (!lockManager) {
|
||||
Logger.debug("Lock manager not available - skipping lock release")
|
||||
return
|
||||
}
|
||||
|
||||
await lockManager.releaseFolderLockByTarget(taskId, lockTarget)
|
||||
Logger.log(`Released folder lock for: ${lockTarget}`)
|
||||
} catch (error) {
|
||||
Logger.error("Error releasing folder lock:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a folder lock with no retry
|
||||
* @param options - Folder lock options including heldBy
|
||||
* @returns Result indicating if lock was acquired and any conflicting lock
|
||||
*/
|
||||
export async function acquireFolderLock(options: FolderLockOptions): Promise<FolderLockResult> {
|
||||
const lockManager = await getStandaloneLockManager()
|
||||
|
||||
if (!lockManager) {
|
||||
Logger.debug("Lock manager not available - cannot acquire folder lock")
|
||||
return { acquired: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const conflictingLock = await lockManager.registerFolderLock(options.heldBy, options.lockTarget)
|
||||
|
||||
if (conflictingLock === null) {
|
||||
// Lock was successfully acquired
|
||||
return { acquired: true }
|
||||
} else {
|
||||
// Lock already exists, return the conflicting lock
|
||||
return {
|
||||
acquired: false,
|
||||
conflictingLock,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Failed to acquire folder lock:", error)
|
||||
return { acquired: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a folder lock acquisition with exponential backoff.
|
||||
* @param operation - Function that attempts to acquire the lock
|
||||
* @param config - Optional retry configuration, uses defaults if not provided
|
||||
* @returns Promise that resolves with acquisition status and details
|
||||
*/
|
||||
export async function retryFolderLockAcquisition(
|
||||
operation: () => Promise<FolderLockWithRetryResult>,
|
||||
config: FolderLockRetryConfig = DEFAULT_RETRY_CONFIG,
|
||||
): Promise<FolderLockWithRetryResult> {
|
||||
const startTime = Date.now()
|
||||
let attemptCount = 0
|
||||
let lastResult: FolderLockWithRetryResult | undefined
|
||||
|
||||
while (true) {
|
||||
const elapsedTime = Date.now() - startTime
|
||||
|
||||
// Retries = check timeout before starting next attempt
|
||||
if (elapsedTime >= config.maxTotalTimeoutMs) {
|
||||
Logger.warn(`Folder lock acquisition timed out after ${config.maxTotalTimeoutMs}ms`)
|
||||
return lastResult || { acquired: false }
|
||||
}
|
||||
|
||||
// Attempt lock acquisition
|
||||
try {
|
||||
const result = await operation()
|
||||
lastResult = result
|
||||
|
||||
// Return immediately if skipped or acquired
|
||||
if (result.skipped || result.acquired) {
|
||||
if (result.acquired && attemptCount > 0) {
|
||||
Logger.debug(`Folder lock acquired after ${attemptCount + 1} attempts (${elapsedTime}ms)`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Error during folder lock acquisition attempt ${attemptCount + 1}:`, error)
|
||||
}
|
||||
|
||||
// Prep for next attempt
|
||||
attemptCount++
|
||||
const baseDelay = config.initialDelayMs + attemptCount * config.incrementPerAttemptMs
|
||||
const remainingTime = config.maxTotalTimeoutMs - (Date.now() - startTime)
|
||||
const delay = Math.min(baseDelay, Math.max(0, remainingTime))
|
||||
|
||||
if (delay <= 0) {
|
||||
Logger.warn(`Folder lock acquisition timed out after ${config.maxTotalTimeoutMs}ms`)
|
||||
return lastResult || { acquired: false }
|
||||
}
|
||||
|
||||
Logger.log(`Folder lock held by another instance, retrying in ${delay}ms (attempt ${attemptCount})`)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { CommandPermissionConfig, PermissionValidationResult } from "./types"
|
||||
export { COMMAND_PERMISSIONS_ENV_VAR } from "./types"
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Configuration structure for command permissions from environment variable
|
||||
*/
|
||||
export interface CommandPermissionConfig {
|
||||
allow?: string[] // Glob patterns for allowed commands
|
||||
deny?: string[] // Glob patterns for denied commands
|
||||
allowRedirects?: boolean // Whether to allow shell redirects (>, >>, <, etc.) - defaults to false
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a permission validation check
|
||||
*/
|
||||
export interface PermissionValidationResult {
|
||||
allowed: boolean
|
||||
matchedPattern?: string // The pattern that matched (for error messages)
|
||||
reason:
|
||||
| "no_config"
|
||||
| "allowed"
|
||||
| "denied"
|
||||
| "no_match_deny_default"
|
||||
| "shell_operator_detected"
|
||||
| "redirect_detected" // Redirect operators (>, >>, <) were used but not allowed
|
||||
| "segment_denied" // A segment in a chained command matched a deny pattern
|
||||
| "segment_no_match" // A segment in a chained command didn't match any allow pattern
|
||||
detectedOperator?: string // The shell operator that was detected (for error messages)
|
||||
failedSegment?: string // The command segment that failed validation (for chained commands)
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment variable name for command permissions
|
||||
*/
|
||||
export const COMMAND_PERMISSIONS_ENV_VAR = "CLINE_COMMAND_PERMISSIONS"
|
||||
|
||||
/**
|
||||
* Shell operators that indicate command chaining, piping, substitution, or redirection.
|
||||
* These are security-sensitive because they can be used to bypass command restrictions.
|
||||
*/
|
||||
export interface ShellOperatorMatch {
|
||||
operator: string
|
||||
description: string
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { AssistantMessageContent } from "@core/assistant-message"
|
||||
import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import type { HookExecution } from "./types/HookExecution"
|
||||
|
||||
export class TaskState {
|
||||
// Task-level timing
|
||||
taskStartTimeMs = Date.now()
|
||||
taskFirstTokenTimeMs?: number
|
||||
|
||||
// Streaming flags
|
||||
isStreaming = false
|
||||
isWaitingForFirstChunk = false
|
||||
didCompleteReadingStream = false
|
||||
|
||||
// Content processing
|
||||
currentStreamingContentIndex = 0
|
||||
assistantMessageContent: AssistantMessageContent[] = []
|
||||
userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = []
|
||||
userMessageContentReady = false
|
||||
// Map of tool names to their tool_use_id for creating proper ToolResultBlockParam
|
||||
toolUseIdMap: Map<string, string> = new Map()
|
||||
|
||||
// Presentation locks
|
||||
presentAssistantMessageLocked = false
|
||||
presentAssistantMessageHasPendingUpdates = false
|
||||
|
||||
// Ask/Response handling
|
||||
askResponse?: ClineAskResponse
|
||||
askResponseText?: string
|
||||
askResponseImages?: string[]
|
||||
askResponseFiles?: string[]
|
||||
lastMessageTs?: number
|
||||
|
||||
// Plan mode specific state
|
||||
isAwaitingPlanResponse = false
|
||||
didRespondToPlanAskBySwitchingMode = false
|
||||
|
||||
// Context and history
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
|
||||
// Tool execution flags
|
||||
didRejectTool = false
|
||||
didAlreadyUseTool = false
|
||||
didEditFile = false
|
||||
lastToolName = "" // Track last tool used for consecutive call detection
|
||||
lastToolParams = "" // Canonical signature of last tool's params (via toolCallSignature)
|
||||
consecutiveIdenticalToolCount = 0 // Consecutive calls with identical tool name + params
|
||||
|
||||
// File read deduplication cache - prevents the model from endlessly reading the same files
|
||||
// Maps absolute file path → { readCount: times read in this task, mtime: last modified timestamp, imageBlock: optional image data for multimodal models }
|
||||
fileReadCache: Map<string, { readCount: number; mtime: number; imageBlock?: Anthropic.ImageBlockParam }> = new Map()
|
||||
|
||||
// Error tracking
|
||||
consecutiveMistakeCount = 0
|
||||
doubleCheckCompletionPending = false
|
||||
didAutomaticallyRetryFailedApiRequest = false
|
||||
checkpointManagerErrorMessage?: string
|
||||
|
||||
// Retry tracking for auto-retry feature
|
||||
autoRetryAttempts = 0
|
||||
|
||||
// Task Initialization
|
||||
isInitialized = false
|
||||
|
||||
// Focus Chain / Todo List Management
|
||||
apiRequestCount = 0
|
||||
apiRequestsSinceLastTodoUpdate = 0
|
||||
currentFocusChainChecklist: string | null = null
|
||||
todoListWasUpdatedByUser = false
|
||||
|
||||
// Task Abort / Cancellation
|
||||
abort = false
|
||||
didFinishAbortingStream = false
|
||||
abandoned = false
|
||||
|
||||
// Hook execution tracking for cancellation
|
||||
activeHookExecution?: HookExecution
|
||||
|
||||
// Auto-context summarization
|
||||
currentlySummarizing = false
|
||||
lastAutoCompactTriggerIndex?: number
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
|
||||
import { EventEmitter } from "events"
|
||||
import Mutex from "p-mutex"
|
||||
import { ClineMessage } from "@/shared/ExtensionMessage"
|
||||
import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { TaskState } from "./TaskState"
|
||||
|
||||
// Event types for clineMessages changes
|
||||
export type ClineMessageChangeType = "add" | "update" | "delete" | "set"
|
||||
|
||||
export interface ClineMessageChange {
|
||||
type: ClineMessageChangeType
|
||||
/** The full array after the change */
|
||||
messages: ClineMessage[]
|
||||
/** The affected index (for add/update/delete) */
|
||||
index?: number
|
||||
/** The new/updated message (for add/update) */
|
||||
message?: ClineMessage
|
||||
/** The old message before change (for update/delete) */
|
||||
previousMessage?: ClineMessage
|
||||
/** The entire previous array (for set) */
|
||||
previousMessages?: ClineMessage[]
|
||||
}
|
||||
|
||||
// Strongly-typed event emitter interface
|
||||
export interface MessageStateHandlerEvents {
|
||||
clineMessagesChanged: [change: ClineMessageChange]
|
||||
}
|
||||
|
||||
interface MessageStateHandlerParams {
|
||||
taskId: string
|
||||
ulid: string
|
||||
taskIsFavorited?: boolean
|
||||
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
|
||||
taskState: TaskState
|
||||
checkpointManagerErrorMessage?: string
|
||||
}
|
||||
|
||||
export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents> {
|
||||
private apiConversationHistory: ClineStorageMessage[] = []
|
||||
private clineMessages: ClineMessage[] = []
|
||||
private taskId: string
|
||||
private taskState: TaskState
|
||||
|
||||
// Mutex to prevent concurrent state modifications (RC-4)
|
||||
// Protects against data loss from race conditions when multiple
|
||||
// operations try to modify message state simultaneously
|
||||
// This follows the same pattern as Task.stateMutex for consistency
|
||||
private stateMutex = new Mutex()
|
||||
|
||||
constructor(params: MessageStateHandlerParams) {
|
||||
super()
|
||||
this.taskId = params.taskId
|
||||
this.taskState = params.taskState
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a clineMessagesChanged event with the change details
|
||||
*/
|
||||
private emitClineMessagesChanged(change: ClineMessageChange): void {
|
||||
this.emit("clineMessagesChanged", change)
|
||||
}
|
||||
|
||||
setCheckpointTracker(_tracker: CheckpointTracker | undefined) {}
|
||||
|
||||
/**
|
||||
* Execute function with exclusive lock on message state
|
||||
* Use this for ANY state modification to prevent race conditions
|
||||
* This follows the same pattern as Task.withStateLock for consistency
|
||||
*/
|
||||
private async withStateLock<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||
return await this.stateMutex.withLock(fn)
|
||||
}
|
||||
|
||||
getApiConversationHistory(): ClineStorageMessage[] {
|
||||
return this.apiConversationHistory
|
||||
}
|
||||
|
||||
setApiConversationHistory(newHistory: ClineStorageMessage[]): void {
|
||||
this.apiConversationHistory = newHistory
|
||||
}
|
||||
|
||||
getClineMessages(): ClineMessage[] {
|
||||
return this.clineMessages
|
||||
}
|
||||
|
||||
setClineMessages(newMessages: ClineMessage[]) {
|
||||
const previousMessages = this.clineMessages
|
||||
this.clineMessages = newMessages
|
||||
this.emitClineMessagesChanged({
|
||||
type: "set",
|
||||
messages: this.clineMessages,
|
||||
previousMessages,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cline messages and update task history (public API with mutex protection)
|
||||
* This is the main entry point for saving message state from external callers
|
||||
*/
|
||||
async saveClineMessagesAndUpdateHistory(): Promise<void> {}
|
||||
|
||||
async addToApiConversationHistory(message: ClineStorageMessage) {
|
||||
// Protect with mutex to prevent concurrent modifications from corrupting data (RC-4)
|
||||
return await this.withStateLock(async () => {
|
||||
this.apiConversationHistory.push(message)
|
||||
})
|
||||
}
|
||||
|
||||
async overwriteApiConversationHistory(newHistory: ClineStorageMessage[]): Promise<void> {
|
||||
// Protect with mutex to prevent concurrent modifications from corrupting data (RC-4)
|
||||
return await this.withStateLock(async () => {
|
||||
this.apiConversationHistory = newHistory
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new message to clineMessages array with proper index tracking
|
||||
* CRITICAL: This entire operation must be atomic to prevent race conditions (RC-4)
|
||||
* The conversationHistoryIndex must be set correctly based on the current state,
|
||||
* and the message must be added and saved without any interleaving operations
|
||||
*/
|
||||
async addToClineMessages(message: ClineMessage) {
|
||||
return await this.withStateLock(async () => {
|
||||
// these values allow us to reconstruct the conversation history at the time this cline message was created
|
||||
// it's important that apiConversationHistory is initialized before we add cline messages
|
||||
message.conversationHistoryIndex = this.apiConversationHistory.length - 1 // NOTE: this is the index of the last added message which is the user message, and once the clinemessages have been presented we update the apiconversationhistory with the completed assistant message. This means when resetting to a message, we need to +1 this index to get the correct assistant message that this tool use corresponds to
|
||||
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
|
||||
const index = this.clineMessages.length
|
||||
this.clineMessages.push(message)
|
||||
this.emitClineMessagesChanged({
|
||||
type: "add",
|
||||
messages: this.clineMessages,
|
||||
index,
|
||||
message,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire clineMessages array with new messages
|
||||
* Protected by mutex to prevent concurrent modifications (RC-4)
|
||||
*/
|
||||
async overwriteClineMessages(newMessages: ClineMessage[]) {
|
||||
return await this.withStateLock(async () => {
|
||||
const previousMessages = this.clineMessages
|
||||
this.clineMessages = newMessages
|
||||
this.emitClineMessagesChanged({
|
||||
type: "set",
|
||||
messages: this.clineMessages,
|
||||
previousMessages,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific message in the clineMessages array
|
||||
* The entire operation (validate, update, save) is atomic to prevent races (RC-4)
|
||||
*/
|
||||
async updateClineMessage(index: number, updates: Partial<ClineMessage>): Promise<void> {
|
||||
return await this.withStateLock(async () => {
|
||||
if (index < 0 || index >= this.clineMessages.length) {
|
||||
throw new Error(`Invalid message index: ${index}`)
|
||||
}
|
||||
|
||||
// Capture previous state before mutation
|
||||
const previousMessage = { ...this.clineMessages[index] }
|
||||
|
||||
// Apply updates to the message
|
||||
Object.assign(this.clineMessages[index], updates)
|
||||
|
||||
this.emitClineMessagesChanged({
|
||||
type: "update",
|
||||
messages: this.clineMessages,
|
||||
index,
|
||||
previousMessage,
|
||||
message: this.clineMessages[index],
|
||||
})
|
||||
|
||||
// Save changes and update history
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific message from the clineMessages array
|
||||
* The entire operation (validate, delete, save) is atomic to prevent races (RC-4)
|
||||
*/
|
||||
async deleteClineMessage(index: number): Promise<void> {
|
||||
return await this.withStateLock(async () => {
|
||||
if (index < 0 || index >= this.clineMessages.length) {
|
||||
throw new Error(`Invalid message index: ${index}`)
|
||||
}
|
||||
|
||||
// Capture the message before deletion
|
||||
const previousMessage = this.clineMessages[index]
|
||||
|
||||
// Remove the message at the specified index
|
||||
this.clineMessages.splice(index, 1)
|
||||
|
||||
this.emitClineMessagesChanged({
|
||||
type: "delete",
|
||||
messages: this.clineMessages,
|
||||
index,
|
||||
previousMessage,
|
||||
})
|
||||
|
||||
// Save changes and update history
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
import { MessageStateHandler } from "@core/task/message-state"
|
||||
import { showChangedFilesDiff } from "@core/task/multifile-diff"
|
||||
import { expect } from "chai"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import CheckpointTracker from "@/integrations/checkpoints/CheckpointTracker"
|
||||
import { ClineMessage } from "@/shared/ExtensionMessage"
|
||||
import { ShowMessageType } from "@/shared/proto/index.host"
|
||||
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
|
||||
|
||||
describe("multifile-diff", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let messageStateHandlerStub: sinon.SinonStubbedInstance<MessageStateHandler>
|
||||
let checkpointTrackerStub: sinon.SinonStubbedInstance<CheckpointTracker>
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Create a mock hostBridge client with the necessary methods
|
||||
const mockHostBridgeClient = {
|
||||
windowClient: {
|
||||
showMessage: sandbox.stub(),
|
||||
},
|
||||
diffClient: {
|
||||
openMultiFileDiff: sandbox.stub(),
|
||||
},
|
||||
} as any
|
||||
|
||||
// Initialize HostProvider with the mock
|
||||
setVscodeHostProviderMock({
|
||||
hostBridgeClient: mockHostBridgeClient,
|
||||
})
|
||||
|
||||
// Create stubs for dependencies
|
||||
messageStateHandlerStub = sandbox.createStubInstance(MessageStateHandler)
|
||||
checkpointTrackerStub = sandbox.createStubInstance(CheckpointTracker)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("showChangedFilesDiff", () => {
|
||||
const mockMessageTs = 1234567890
|
||||
const mockHash = "abc123def456"
|
||||
const mockMessages: ClineMessage[] = [
|
||||
{
|
||||
ts: mockMessageTs,
|
||||
type: "say",
|
||||
lastCheckpointHash: mockHash,
|
||||
say: "text",
|
||||
text: "Test message",
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
messageStateHandlerStub.getClineMessages.returns(mockMessages)
|
||||
})
|
||||
|
||||
it("should successfully show diff for changes since last task completion", async () => {
|
||||
// Arrange
|
||||
const mockChangedFiles = [
|
||||
{
|
||||
relativePath: "src/file1.ts",
|
||||
absolutePath: "/project/src/file1.ts",
|
||||
before: "const a = 1;",
|
||||
after: "const a = 2;",
|
||||
},
|
||||
{
|
||||
relativePath: "src/file2.ts",
|
||||
absolutePath: "/project/src/file2.ts",
|
||||
before: "function test() {}",
|
||||
after: "function test() { return true; }",
|
||||
},
|
||||
]
|
||||
|
||||
// Mock finding last completion message
|
||||
const messagesWithCompletion: ClineMessage[] = [
|
||||
{
|
||||
ts: 1234567000,
|
||||
type: "say",
|
||||
say: "completion_result",
|
||||
lastCheckpointHash: "previous123",
|
||||
},
|
||||
...mockMessages,
|
||||
]
|
||||
messageStateHandlerStub.getClineMessages.returns(messagesWithCompletion)
|
||||
|
||||
checkpointTrackerStub.getDiffSet.resolves(mockChangedFiles)
|
||||
|
||||
// Act
|
||||
await showChangedFilesDiff(
|
||||
messageStateHandlerStub as any,
|
||||
checkpointTrackerStub as any,
|
||||
mockMessageTs,
|
||||
true, // seeNewChangesSinceLastTaskCompletion
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(checkpointTrackerStub.getDiffSet.calledWith("previous123", mockHash)).to.be.true
|
||||
expect(
|
||||
(HostProvider.diff.openMultiFileDiff as sinon.SinonStub).calledWith({
|
||||
title: "New changes",
|
||||
diffs: [
|
||||
{
|
||||
filePath: "/project/src/file1.ts",
|
||||
leftContent: "const a = 1;",
|
||||
rightContent: "const a = 2;",
|
||||
},
|
||||
{
|
||||
filePath: "/project/src/file2.ts",
|
||||
leftContent: "function test() {}",
|
||||
rightContent: "function test() { return true; }",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).to.be.true
|
||||
})
|
||||
|
||||
it("should successfully show diff for changes since snapshot", async () => {
|
||||
// Arrange
|
||||
const mockChangedFiles = [
|
||||
{
|
||||
relativePath: "README.md",
|
||||
absolutePath: "/project/README.md",
|
||||
before: "# Project",
|
||||
after: "# My Project\n\nDescription added.",
|
||||
},
|
||||
]
|
||||
|
||||
checkpointTrackerStub.getDiffSet.resolves(mockChangedFiles)
|
||||
|
||||
// Act
|
||||
await showChangedFilesDiff(
|
||||
messageStateHandlerStub as any,
|
||||
checkpointTrackerStub as any,
|
||||
mockMessageTs,
|
||||
false, // seeNewChangesSinceLastTaskCompletion
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(checkpointTrackerStub.getDiffSet.calledWith(mockHash)).to.be.true
|
||||
expect(
|
||||
(HostProvider.diff.openMultiFileDiff as sinon.SinonStub).calledWith({
|
||||
title: "Changes since snapshot",
|
||||
diffs: [
|
||||
{
|
||||
filePath: "/project/README.md",
|
||||
leftContent: "# Project",
|
||||
rightContent: "# My Project\n\nDescription added.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).to.be.true
|
||||
})
|
||||
|
||||
it("should handle message not found error", async () => {
|
||||
// Arrange
|
||||
messageStateHandlerStub.getClineMessages.returns([])
|
||||
|
||||
// Act
|
||||
await showChangedFilesDiff(messageStateHandlerStub as any, checkpointTrackerStub as any, mockMessageTs, false)
|
||||
|
||||
// Assert
|
||||
expect(checkpointTrackerStub.getDiffSet.called).to.be.false
|
||||
expect((HostProvider.diff.openMultiFileDiff as sinon.SinonStub).called).to.be.false
|
||||
})
|
||||
|
||||
it("should handle missing checkpoint hash", async () => {
|
||||
// Arrange
|
||||
const messagesWithoutHash: ClineMessage[] = [
|
||||
{
|
||||
ts: mockMessageTs,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "Test message",
|
||||
// lastCheckpointHash is missing
|
||||
},
|
||||
]
|
||||
messageStateHandlerStub.getClineMessages.returns(messagesWithoutHash)
|
||||
|
||||
// Act
|
||||
await showChangedFilesDiff(messageStateHandlerStub as any, checkpointTrackerStub as any, mockMessageTs, false)
|
||||
|
||||
// Assert
|
||||
expect(checkpointTrackerStub.getDiffSet.called).to.be.false
|
||||
expect((HostProvider.diff.openMultiFileDiff as sinon.SinonStub).called).to.be.false
|
||||
})
|
||||
|
||||
it("should show information message when no changes found", async () => {
|
||||
// Arrange
|
||||
checkpointTrackerStub.getDiffSet.resolves([])
|
||||
|
||||
// Act
|
||||
await showChangedFilesDiff(messageStateHandlerStub as any, checkpointTrackerStub as any, mockMessageTs, false)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
(HostProvider.window.showMessage as sinon.SinonStub).calledWith({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "No changes found",
|
||||
}),
|
||||
).to.be.true
|
||||
expect((HostProvider.diff.openMultiFileDiff as sinon.SinonStub).called).to.be.false
|
||||
})
|
||||
|
||||
it("should handle getDiffSet errors gracefully", async () => {
|
||||
// Arrange
|
||||
const errorMessage = "Git operation failed"
|
||||
checkpointTrackerStub.getDiffSet.rejects(new Error(errorMessage))
|
||||
|
||||
// Act
|
||||
await showChangedFilesDiff(messageStateHandlerStub as any, checkpointTrackerStub as any, mockMessageTs, false)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
(HostProvider.window.showMessage as sinon.SinonStub).calledWith({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Failed to retrieve diff set: " + errorMessage,
|
||||
}),
|
||||
).to.be.true
|
||||
expect((HostProvider.diff.openMultiFileDiff as sinon.SinonStub).called).to.be.false
|
||||
})
|
||||
|
||||
it("should use first checkpoint when no last completion found", async () => {
|
||||
// Arrange
|
||||
const messagesWithFirstCheckpoint: ClineMessage[] = [
|
||||
{
|
||||
ts: 1234567000,
|
||||
type: "say",
|
||||
say: "checkpoint_created",
|
||||
lastCheckpointHash: "first123",
|
||||
},
|
||||
...mockMessages,
|
||||
]
|
||||
messageStateHandlerStub.getClineMessages.returns(messagesWithFirstCheckpoint)
|
||||
|
||||
checkpointTrackerStub.getDiffSet.resolves([
|
||||
{
|
||||
relativePath: "test.js",
|
||||
absolutePath: "/project/test.js",
|
||||
before: "",
|
||||
after: "console.log('test');",
|
||||
},
|
||||
])
|
||||
|
||||
// Act
|
||||
await showChangedFilesDiff(
|
||||
messageStateHandlerStub as any,
|
||||
checkpointTrackerStub as any,
|
||||
mockMessageTs,
|
||||
true, // seeNewChangesSinceLastTaskCompletion
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(checkpointTrackerStub.getDiffSet.calledWith("first123", mockHash)).to.be.true
|
||||
})
|
||||
|
||||
it("should show error when no previous checkpoint hash found for new changes", async () => {
|
||||
// Arrange
|
||||
// No completion_result or checkpoint_created messages
|
||||
messageStateHandlerStub.getClineMessages.returns(mockMessages)
|
||||
|
||||
// Act
|
||||
await showChangedFilesDiff(
|
||||
messageStateHandlerStub as any,
|
||||
checkpointTrackerStub as any,
|
||||
mockMessageTs,
|
||||
true, // seeNewChangesSinceLastTaskCompletion
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
(HostProvider.window.showMessage as sinon.SinonStub).calledWith({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Unexpected error: No checkpoint hash found",
|
||||
}),
|
||||
).to.be.true
|
||||
expect(checkpointTrackerStub.getDiffSet.called).to.be.false
|
||||
})
|
||||
|
||||
it("should handle large number of changed files", async () => {
|
||||
// Arrange
|
||||
const mockChangedFiles = Array.from({ length: 100 }, (_, i) => ({
|
||||
relativePath: `src/file${i}.ts`,
|
||||
absolutePath: `/project/src/file${i}.ts`,
|
||||
before: `// File ${i}`,
|
||||
after: `// Modified file ${i}`,
|
||||
}))
|
||||
|
||||
checkpointTrackerStub.getDiffSet.resolves(mockChangedFiles)
|
||||
|
||||
// Act
|
||||
await showChangedFilesDiff(messageStateHandlerStub as any, checkpointTrackerStub as any, mockMessageTs, false)
|
||||
|
||||
// Assert
|
||||
expect((HostProvider.diff.openMultiFileDiff as sinon.SinonStub).calledOnce).to.be.true
|
||||
const call = (HostProvider.diff.openMultiFileDiff as sinon.SinonStub).getCall(0)
|
||||
expect(call.args[0].diffs).to.have.lengthOf(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,122 +0,0 @@
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import CheckpointTracker from "@/integrations/checkpoints/CheckpointTracker"
|
||||
import { findLast } from "@/shared/array"
|
||||
import { ShowMessageType } from "@/shared/proto/index.host"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
|
||||
export async function showChangedFilesDiff(
|
||||
messageStateHandler: MessageStateHandler,
|
||||
checkpointTracker: CheckpointTracker,
|
||||
messageTs: number,
|
||||
seeNewChangesSinceLastTaskCompletion: boolean,
|
||||
) {
|
||||
Logger.log("presentMultifileDiff", messageTs)
|
||||
const clineMessages = messageStateHandler.getClineMessages()
|
||||
const messageIndex = clineMessages.findIndex((m) => m.ts === messageTs)
|
||||
const message = clineMessages[messageIndex]
|
||||
if (!message) {
|
||||
Logger.error("Message not found")
|
||||
return
|
||||
}
|
||||
const lastCheckpointHash = message.lastCheckpointHash
|
||||
if (!lastCheckpointHash) {
|
||||
Logger.error("No checkpoint hash found")
|
||||
return
|
||||
}
|
||||
|
||||
const changedFiles = await getChangedFiles(
|
||||
messageStateHandler,
|
||||
checkpointTracker,
|
||||
seeNewChangesSinceLastTaskCompletion,
|
||||
messageIndex,
|
||||
lastCheckpointHash,
|
||||
)
|
||||
if (!changedFiles.length) {
|
||||
return
|
||||
}
|
||||
const title = seeNewChangesSinceLastTaskCompletion ? "New changes" : "Changes since snapshot"
|
||||
const diffs = changedFiles.map((file) => ({
|
||||
filePath: file.absolutePath,
|
||||
leftContent: file.before,
|
||||
rightContent: file.after,
|
||||
}))
|
||||
HostProvider.diff.openMultiFileDiff({ title, diffs })
|
||||
}
|
||||
|
||||
type ChangedFile = {
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
before: string
|
||||
after: string
|
||||
}
|
||||
|
||||
async function getChangedFiles(
|
||||
messageStateHandler: MessageStateHandler,
|
||||
checkpointTracker: CheckpointTracker,
|
||||
changesSinceLastTaskCompletion: boolean,
|
||||
messageIndex: number,
|
||||
lastCheckpointHash: string,
|
||||
): Promise<ChangedFile[]> {
|
||||
try {
|
||||
let changedFiles
|
||||
if (changesSinceLastTaskCompletion) {
|
||||
changedFiles = await getChangesSinceLastTaskCompletion(
|
||||
messageStateHandler,
|
||||
checkpointTracker,
|
||||
messageIndex,
|
||||
lastCheckpointHash,
|
||||
)
|
||||
} else {
|
||||
// Get changed files between current state and commit
|
||||
changedFiles = await checkpointTracker.getDiffSet(lastCheckpointHash)
|
||||
}
|
||||
if (!changedFiles.length) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "No changes found",
|
||||
})
|
||||
}
|
||||
return changedFiles
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Failed to retrieve diff set: " + errorMessage,
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function getChangesSinceLastTaskCompletion(
|
||||
messageStateHandler: MessageStateHandler,
|
||||
checkpointTracker: CheckpointTracker,
|
||||
messageIndex: number,
|
||||
lastCheckpointHash: string,
|
||||
): Promise<ChangedFile[]> {
|
||||
// Get last task completed
|
||||
const lastTaskCompletedMessageCheckpointHash = findLast(
|
||||
messageStateHandler.getClineMessages().slice(0, messageIndex),
|
||||
(m) => m.say === "completion_result",
|
||||
)?.lastCheckpointHash // ask is only used to relinquish control, its the last say we care about
|
||||
|
||||
// This value *should* always exist
|
||||
const firstCheckpointMessageCheckpointHash = messageStateHandler
|
||||
.getClineMessages()
|
||||
.find((m) => m.say === "checkpoint_created")?.lastCheckpointHash
|
||||
|
||||
// either use the diff between the first checkpoint and the task completion, or the diff
|
||||
// between the latest two task completions
|
||||
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash
|
||||
|
||||
if (!previousCheckpointHash) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Unexpected error: No checkpoint hash found",
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
// Get changed files between current state and commit
|
||||
return await checkpointTracker.getDiffSet(previousCheckpointHash, lastCheckpointHash)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* Represents an active hook execution that can be cancelled.
|
||||
* This is tracked in TaskState to allow cancellation via UI or programmatic triggers.
|
||||
*/
|
||||
export interface HookExecution {
|
||||
/** The name of the hook being executed (e.g., "PreToolUse", "PostToolUse") */
|
||||
hookName: string
|
||||
/** The name of the tool that triggered this hook (for PreToolUse/PostToolUse hooks) */
|
||||
toolName?: string
|
||||
/** The timestamp of the message showing hook execution status */
|
||||
messageTs: number
|
||||
/** The abort controller used to cancel the hook execution */
|
||||
abortController: AbortController
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import type { WorkspaceRoot } from "@shared/multi-root/types"
|
||||
import { VcsType } from "@shared/multi-root/types"
|
||||
import { expect } from "chai"
|
||||
import * as path from "path"
|
||||
import sinon from "sinon"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import * as telemetry from "@/services/telemetry"
|
||||
import * as pathUtils from "@/utils/path"
|
||||
import { setupWorkspaceManager } from "../setup"
|
||||
import { WorkspaceRootManager } from "../WorkspaceRootManager"
|
||||
|
||||
describe("setupWorkspaceManager", () => {
|
||||
const sandbox = sinon.createSandbox()
|
||||
let fakeTelemetry: {
|
||||
captureWorkspaceInitialized: sinon.SinonStub
|
||||
captureWorkspaceInitError: sinon.SinonStub
|
||||
}
|
||||
|
||||
const cwd = "/Users/test/project"
|
||||
const defaultRoots: WorkspaceRoot[] = [
|
||||
{ path: "/ws/root1", name: "root1", vcs: VcsType.Git, commitHash: "abc" },
|
||||
{ path: "/ws/root2", name: "root2", vcs: VcsType.None },
|
||||
]
|
||||
|
||||
// Minimal stateManager stub with behavior we assert
|
||||
const makeStateManager = ({
|
||||
multiRootEnabled = true,
|
||||
savedRoots,
|
||||
savedPrimaryIndex = 0,
|
||||
}: {
|
||||
multiRootEnabled?: boolean
|
||||
savedRoots?: WorkspaceRoot[]
|
||||
savedPrimaryIndex?: number
|
||||
}) => {
|
||||
const state: { roots?: WorkspaceRoot[]; primaryIndex?: number } = {}
|
||||
return {
|
||||
getGlobalStateKey: (key: string) => {
|
||||
switch (key) {
|
||||
case "multiRootEnabled":
|
||||
return multiRootEnabled
|
||||
case "workspaceRoots":
|
||||
return savedRoots
|
||||
case "primaryRootIndex":
|
||||
return savedPrimaryIndex
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
setGlobalState: (key: string, value: any) => {
|
||||
switch (key) {
|
||||
case "workspaceRoots":
|
||||
state.roots = value
|
||||
break
|
||||
case "primaryRootIndex":
|
||||
state.primaryIndex = value
|
||||
break
|
||||
}
|
||||
},
|
||||
// for assertions
|
||||
_state: state,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Stub out path utils for stable behavior
|
||||
sandbox.stub(pathUtils, "getDesktopDir").returns("/Users/test/Desktop" as any)
|
||||
sandbox.stub(pathUtils, "getCwd").resolves(cwd as any)
|
||||
|
||||
// Stub HostProvider window + workspace methods used by setup() error path
|
||||
sandbox.stub(HostProvider, "window").value({
|
||||
showMessage: sandbox.stub().resolves({ selectedOption: undefined }),
|
||||
openSettings: sandbox.stub().resolves(),
|
||||
getVisibleTabs: sandbox.stub().resolves({ paths: [] }),
|
||||
getOpenTabs: sandbox.stub().resolves({ paths: [] }),
|
||||
} as any)
|
||||
|
||||
sandbox.stub(HostProvider, "workspace").value({
|
||||
getWorkspacePaths: sandbox.stub().resolves({ paths: ["/ws/root1", "/ws/root2"] }),
|
||||
} as any)
|
||||
|
||||
// Telemetry stubs by replacing the exported proxy with a test double
|
||||
fakeTelemetry = {
|
||||
captureWorkspaceInitialized: sandbox.stub().resolves(),
|
||||
captureWorkspaceInitError: sandbox.stub().resolves(),
|
||||
}
|
||||
sandbox.stub(telemetry, "telemetryService").value(fakeTelemetry)
|
||||
|
||||
// Stub WorkspaceRootManager.fromLegacyCwd to be deterministic
|
||||
sandbox.stub(WorkspaceRootManager, "fromLegacyCwd").callsFake(async (legacyCwd: string) => {
|
||||
// emulate single-root manager with cwd as only root
|
||||
return new WorkspaceRootManager([{ path: legacyCwd, name: path.basename(legacyCwd), vcs: VcsType.None }], 0)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it("initializes multi-root manager when multi-root is enabled and persists roots + primary index", async () => {
|
||||
const stateManager = makeStateManager({ multiRootEnabled: true })
|
||||
const detectRoots = sandbox.stub().resolves(defaultRoots)
|
||||
|
||||
// Multi-root workspace is now always enabled, no feature flag stub needed
|
||||
|
||||
const manager = await setupWorkspaceManager({
|
||||
stateManager: stateManager as any,
|
||||
historyItem: undefined,
|
||||
detectRoots,
|
||||
})
|
||||
|
||||
// detectRoots used
|
||||
expect(detectRoots.calledOnce).to.equal(true)
|
||||
// manager configured with multi roots
|
||||
expect(manager.getRoots()).to.have.length(2)
|
||||
expect(manager.getPrimaryIndex()).to.equal(0)
|
||||
|
||||
// persisted to state
|
||||
expect(stateManager._state.roots).to.have.length(2)
|
||||
expect(stateManager._state.primaryIndex).to.equal(0)
|
||||
|
||||
// telemetry captured (skipped assertion in unit tests)
|
||||
expect(fakeTelemetry.captureWorkspaceInitialized.calledOnce).to.equal(true)
|
||||
expect(fakeTelemetry.captureWorkspaceInitialized.firstCall.args[0]).to.equal(2)
|
||||
expect(fakeTelemetry.captureWorkspaceInitialized.firstCall.args[1]).to.deep.equal(["git", "none"])
|
||||
expect(fakeTelemetry.captureWorkspaceInitialized.firstCall.args[3]).to.equal(true)
|
||||
})
|
||||
|
||||
it("uses single-root cwd when history restore is disabled (historyItem present)", async () => {
|
||||
const savedRoots: WorkspaceRoot[] = [{ path: "/saved/root", name: "saved", vcs: VcsType.None }]
|
||||
const stateManager = makeStateManager({ multiRootEnabled: false, savedRoots, savedPrimaryIndex: 0 })
|
||||
const detectRoots = sandbox.stub().resolves(defaultRoots) // not used
|
||||
|
||||
const manager = await setupWorkspaceManager({
|
||||
stateManager: stateManager as any,
|
||||
historyItem: { id: "h1", ulid: "u1" } as any,
|
||||
detectRoots,
|
||||
})
|
||||
|
||||
// detectRoots not used
|
||||
expect(detectRoots.called).to.equal(false)
|
||||
// current design: single-root path uses cwd (stubbed earlier to "/Users/test/project")
|
||||
expect(manager.getRoots()).to.have.length(1)
|
||||
expect(manager.getRoots()[0].path).to.equal(cwd)
|
||||
// state persisted
|
||||
expect(stateManager._state.roots?.[0].path).to.equal(cwd)
|
||||
})
|
||||
|
||||
it("falls back to fromLegacyCwd in single-root mode when no saved state", async () => {
|
||||
const stateManager = makeStateManager({
|
||||
multiRootEnabled: false,
|
||||
savedRoots: undefined,
|
||||
})
|
||||
const detectRoots = sandbox.stub().resolves(defaultRoots) // not used
|
||||
|
||||
const manager = await setupWorkspaceManager({
|
||||
stateManager: stateManager as any,
|
||||
historyItem: { id: "h2", ulid: "u2" } as any,
|
||||
detectRoots,
|
||||
})
|
||||
|
||||
expect(detectRoots.called).to.equal(false)
|
||||
expect(manager.getRoots()).to.have.length(1)
|
||||
expect(manager.getRoots()[0].path).to.equal(cwd)
|
||||
// persisted
|
||||
expect(stateManager._state.roots?.[0].path).to.equal(cwd)
|
||||
// telemetry called (skipped assertion in unit tests)
|
||||
expect(fakeTelemetry.captureWorkspaceInitialized.calledOnce).to.equal(true)
|
||||
expect(fakeTelemetry.captureWorkspaceInitialized.firstCall.args[0]).to.equal(1)
|
||||
expect(fakeTelemetry.captureWorkspaceInitialized.firstCall.args[1]).to.deep.equal(["none"])
|
||||
expect(fakeTelemetry.captureWorkspaceInitialized.firstCall.args[3]).to.equal(false)
|
||||
})
|
||||
|
||||
it("gracefully handles errors and falls back to fromLegacyCwd while warning user", async () => {
|
||||
// Multi-root enabled but detectRoots throws
|
||||
const stateManager = makeStateManager({ multiRootEnabled: true })
|
||||
const detectRoots = sandbox.stub().rejects(new Error("boom"))
|
||||
|
||||
// Multi-root workspace is now always enabled, no feature flag stub needed
|
||||
|
||||
const manager = await setupWorkspaceManager({
|
||||
stateManager: stateManager as any,
|
||||
historyItem: undefined,
|
||||
detectRoots,
|
||||
})
|
||||
|
||||
// fell back to single-root manager from legacy cwd
|
||||
expect(manager.getRoots()).to.have.length(1)
|
||||
expect(manager.getRoots()[0].path).to.equal(cwd)
|
||||
|
||||
expect(fakeTelemetry.captureWorkspaceInitError.calledOnce).to.equal(true)
|
||||
expect(fakeTelemetry.captureWorkspaceInitError.firstCall.args[0]).to.be.instanceOf(Error)
|
||||
expect(fakeTelemetry.captureWorkspaceInitError.firstCall.args[1]).to.equal(true)
|
||||
expect(fakeTelemetry.captureWorkspaceInitError.firstCall.args[2]).to.equal(2)
|
||||
|
||||
// persisted fallback state
|
||||
expect(stateManager._state.roots?.[0].path).to.equal(cwd)
|
||||
|
||||
// message shown to user
|
||||
const showMessageSpy = HostProvider.window.showMessage as sinon.SinonStub
|
||||
expect(showMessageSpy.calledOnce).to.equal(true)
|
||||
const msg = showMessageSpy.getCall(0).args[0]
|
||||
expect(msg?.message || "").to.match(/Failed to initialize workspace/i)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { StateManager } from "../storage/StateManager"
|
||||
|
||||
/**
|
||||
* Determines if multi-root workspace mode should be enabled.
|
||||
*
|
||||
* Multi-root is enabled when the user has opted in via their settings.
|
||||
*
|
||||
* @param stateManager - The state manager to check user preferences
|
||||
* @returns true if user setting is enabled
|
||||
*/
|
||||
export function isMultiRootEnabled(stateManager: StateManager): boolean {
|
||||
const userSetting = stateManager.getGlobalStateKey("multiRootEnabled")
|
||||
return !!userSetting
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { WorkspaceRoot } from "@shared/multi-root/types"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import type { HistoryItem } from "@/shared/HistoryItem"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
import { StateManager } from "../storage/StateManager"
|
||||
import { isMultiRootEnabled } from "./multi-root-utils"
|
||||
import { WorkspaceRootManager } from "./WorkspaceRootManager"
|
||||
|
||||
type DetectRoots = () => Promise<WorkspaceRoot[]>
|
||||
|
||||
/**
|
||||
* Initializes and persists the WorkspaceRootManager (multi-root or single-root),
|
||||
* emits telemetry, and handles fallback on error.
|
||||
*
|
||||
* The caller injects detectRoots to avoid tight coupling to Controller.
|
||||
*/
|
||||
export async function setupWorkspaceManager({
|
||||
stateManager,
|
||||
detectRoots,
|
||||
}: {
|
||||
stateManager: StateManager
|
||||
historyItem?: HistoryItem
|
||||
detectRoots: DetectRoots
|
||||
}): Promise<WorkspaceRootManager> {
|
||||
const cwd = await getCwd(getDesktopDir())
|
||||
const startTime = performance.now()
|
||||
const multiRootEnabled = isMultiRootEnabled(stateManager)
|
||||
try {
|
||||
let manager: WorkspaceRootManager
|
||||
// Multi-root mode condition - requires both feature flag and user setting to be enabled
|
||||
if (multiRootEnabled) {
|
||||
// Multi-root: detect workspace folders
|
||||
const roots = await detectRoots()
|
||||
manager = new WorkspaceRootManager(roots, 0)
|
||||
Logger.log(`[WorkspaceManager] Multi-root mode: ${roots.length} roots detected`)
|
||||
|
||||
// Telemetry
|
||||
telemetryService.captureWorkspaceInitialized(
|
||||
roots.length,
|
||||
roots.map((r) => r.vcs.toString()),
|
||||
performance.now() - startTime,
|
||||
true,
|
||||
)
|
||||
|
||||
// Persist
|
||||
stateManager.setGlobalState("workspaceRoots", manager.getRoots())
|
||||
stateManager.setGlobalState("primaryRootIndex", manager.getPrimaryIndex())
|
||||
return manager
|
||||
}
|
||||
|
||||
// Single-root mode code for when we actually start using workspacerootmanager
|
||||
// if (historyItem) {
|
||||
// const savedRoots = stateManager.getWorkspaceRoots()
|
||||
// if (savedRoots && savedRoots.length > 0) {
|
||||
// const primaryIndex = stateManager.getPrimaryRootIndex()
|
||||
// manager = new WorkspaceRootManager(savedRoots, primaryIndex)
|
||||
// Logger.log(`[WorkspaceManager] Restored ${savedRoots.length} roots from state`)
|
||||
// telemetryService.captureWorkspaceInitialized(
|
||||
// savedRoots.length,
|
||||
// savedRoots.map((r) => r.vcs.toString()),
|
||||
// performance.now() - startTime,
|
||||
// false,
|
||||
// )
|
||||
// } else {
|
||||
// manager = await WorkspaceRootManager.fromLegacyCwd(cwd)
|
||||
// telemetryService.captureWorkspaceInitialized(
|
||||
// 1,
|
||||
// [manager.getRoots()[0].vcs.toString()],
|
||||
// performance.now() - startTime,
|
||||
// false,
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
|
||||
manager = await WorkspaceRootManager.fromLegacyCwd(cwd)
|
||||
telemetryService.captureWorkspaceInitialized(
|
||||
1,
|
||||
[manager.getRoots()[0].vcs.toString()],
|
||||
performance.now() - startTime,
|
||||
false,
|
||||
)
|
||||
|
||||
Logger.log(`[WorkspaceManager] Single-root mode: ${cwd}`)
|
||||
const roots = manager.getRoots()
|
||||
stateManager.setGlobalState("workspaceRoots", roots)
|
||||
stateManager.setGlobalState("primaryRootIndex", manager.getPrimaryIndex())
|
||||
return manager
|
||||
} catch (error) {
|
||||
// Telemetry + graceful fallback to single-root from cwd
|
||||
const workspaceCount = (await HostProvider.workspace.getWorkspacePaths({})).paths?.length
|
||||
telemetryService.captureWorkspaceInitError(error as Error, true, workspaceCount)
|
||||
|
||||
Logger.error("[WorkspaceManager] Initialization failed:", error)
|
||||
const manager = await WorkspaceRootManager.fromLegacyCwd(cwd)
|
||||
const roots = manager.getRoots()
|
||||
stateManager.setGlobalState("workspaceRoots", roots)
|
||||
stateManager.setGlobalState("primaryRootIndex", manager.getPrimaryIndex())
|
||||
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.WARNING,
|
||||
message: "Failed to initialize workspace. Using single folder mode.",
|
||||
})
|
||||
return manager
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,325 +0,0 @@
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import fs from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { GIT_DISABLED_SUFFIX } from "./CheckpointGitOperations"
|
||||
|
||||
/**
|
||||
* CheckpointExclusions Module
|
||||
*
|
||||
* A specialized module within Cline's Checkpoints system that manages file exclusion rules
|
||||
* for the checkpoint tracking process. It provides:
|
||||
*
|
||||
* File Filtering:
|
||||
* - File types (build artifacts, media, cache files, etc.)
|
||||
* - Git LFS patterns from workspace
|
||||
* - Environment and configuration files
|
||||
* - Temporary and cache files
|
||||
*
|
||||
* Pattern Management:
|
||||
* - Extensible category-based pattern system
|
||||
* - Comprehensive file type coverage
|
||||
* - Easy pattern updates and maintenance
|
||||
*
|
||||
* Git Integration:
|
||||
* - Seamless integration with Git's exclude mechanism
|
||||
* - Support for workspace-specific LFS patterns
|
||||
* - Automatic pattern updates during checkpoints
|
||||
*
|
||||
* The module ensures efficient checkpoint creation by preventing unnecessary tracking
|
||||
* of large files, binary files, and temporary artifacts while maintaining a clean
|
||||
* and organized checkpoint history.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns the default list of file and directory patterns to exclude from checkpoints.
|
||||
* Combines built-in patterns with workspace-specific LFS patterns.
|
||||
*
|
||||
* @param lfsPatterns - Optional array of Git LFS patterns from workspace
|
||||
* @returns Array of glob patterns to exclude
|
||||
* @todo Make this configurable by the user
|
||||
*/
|
||||
export const getDefaultExclusions = (lfsPatterns: string[] = []): string[] => [
|
||||
// Build and Development Artifacts
|
||||
".git/",
|
||||
`.git${GIT_DISABLED_SUFFIX}/`,
|
||||
...getBuildArtifactPatterns(),
|
||||
|
||||
// Media Files
|
||||
...getMediaFilePatterns(),
|
||||
|
||||
// Cache and Temporary Files
|
||||
...getCacheFilePatterns(),
|
||||
|
||||
// Environment and Config Files
|
||||
...getConfigFilePatterns(),
|
||||
|
||||
// Large Data Files
|
||||
...getLargeDataFilePatterns(),
|
||||
|
||||
// Database Files
|
||||
...getDatabaseFilePatterns(),
|
||||
|
||||
// Geospatial Datasets
|
||||
...getGeospatialPatterns(),
|
||||
|
||||
// Log Files
|
||||
...getLogFilePatterns(),
|
||||
|
||||
...lfsPatterns,
|
||||
]
|
||||
|
||||
/**
|
||||
* Returns patterns for common build and development artifact directories
|
||||
* @returns Array of glob patterns for build artifacts
|
||||
*/
|
||||
function getBuildArtifactPatterns(): string[] {
|
||||
return [
|
||||
".gradle/",
|
||||
".idea/",
|
||||
".parcel-cache/",
|
||||
".pytest_cache/",
|
||||
".next/",
|
||||
".nuxt/",
|
||||
".sass-cache/",
|
||||
".vs/",
|
||||
".vscode/",
|
||||
".clinerules/",
|
||||
"Pods/",
|
||||
"__pycache__/",
|
||||
"bin/",
|
||||
"build/",
|
||||
"bundle/",
|
||||
"coverage/",
|
||||
"deps/",
|
||||
"dist/",
|
||||
"env/",
|
||||
"node_modules/",
|
||||
"obj/",
|
||||
"out/",
|
||||
"pycache/",
|
||||
"target/dependency/",
|
||||
"temp/",
|
||||
"vendor/",
|
||||
"venv/",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for common media and image file types
|
||||
* @returns Array of glob patterns for media files
|
||||
*/
|
||||
function getMediaFilePatterns(): string[] {
|
||||
return [
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.png",
|
||||
"*.gif",
|
||||
"*.bmp",
|
||||
"*.ico",
|
||||
"*.webp",
|
||||
"*.tiff",
|
||||
"*.tif",
|
||||
// "*.svg",
|
||||
"*.raw",
|
||||
"*.heic",
|
||||
"*.avif",
|
||||
"*.eps",
|
||||
"*.psd",
|
||||
"*.3gp",
|
||||
"*.aac",
|
||||
"*.aiff",
|
||||
"*.asf",
|
||||
"*.avi",
|
||||
"*.divx",
|
||||
"*.flac",
|
||||
"*.m4a",
|
||||
"*.m4v",
|
||||
"*.mkv",
|
||||
"*.mov",
|
||||
"*.mp3",
|
||||
"*.mp4",
|
||||
"*.mpeg",
|
||||
"*.mpg",
|
||||
"*.ogg",
|
||||
"*.opus",
|
||||
"*.rm",
|
||||
"*.rmvb",
|
||||
"*.vob",
|
||||
"*.wav",
|
||||
"*.webm",
|
||||
"*.wma",
|
||||
"*.wmv",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for cache, temporary, and system files
|
||||
* @returns Array of glob patterns for cache files
|
||||
*/
|
||||
function getCacheFilePatterns(): string[] {
|
||||
return [
|
||||
"*.DS_Store",
|
||||
"*.bak",
|
||||
"*.cache",
|
||||
"*.crdownload",
|
||||
"*.dmp",
|
||||
"*.dump",
|
||||
"*.eslintcache",
|
||||
"*.lock",
|
||||
"*.log",
|
||||
"*.old",
|
||||
"*.part",
|
||||
"*.partial",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
"*.stackdump",
|
||||
"*.swo",
|
||||
"*.swp",
|
||||
"*.temp",
|
||||
"*.tmp",
|
||||
"*.Thumbs.db",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for environment and configuration files
|
||||
* @returns Array of glob patterns for config files
|
||||
*/
|
||||
function getConfigFilePatterns(): string[] {
|
||||
return ["*.env*", "*.local", "*.development", "*.production"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for common large binary and archive files
|
||||
* @returns Array of glob patterns for large data files
|
||||
*/
|
||||
function getLargeDataFilePatterns(): string[] {
|
||||
return [
|
||||
"*.zip",
|
||||
"*.tar",
|
||||
"*.gz",
|
||||
"*.rar",
|
||||
"*.7z",
|
||||
"*.iso",
|
||||
"*.bin",
|
||||
"*.exe",
|
||||
"*.dll",
|
||||
"*.so",
|
||||
"*.dylib",
|
||||
"*.dat",
|
||||
"*.dmg",
|
||||
"*.msi",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for database and data storage files
|
||||
* @returns Array of glob patterns for database files
|
||||
*/
|
||||
function getDatabaseFilePatterns(): string[] {
|
||||
return [
|
||||
"*.arrow",
|
||||
"*.accdb",
|
||||
"*.aof",
|
||||
"*.avro",
|
||||
"*.bak",
|
||||
"*.bson",
|
||||
"*.csv",
|
||||
"*.db",
|
||||
"*.dbf",
|
||||
"*.dmp",
|
||||
"*.frm",
|
||||
"*.ibd",
|
||||
"*.mdb",
|
||||
"*.myd",
|
||||
"*.myi",
|
||||
"*.orc",
|
||||
"*.parquet",
|
||||
"*.pdb",
|
||||
"*.rdb",
|
||||
"*.sqlite",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for geospatial and mapping data files
|
||||
* @returns Array of glob patterns for geospatial files
|
||||
*/
|
||||
function getGeospatialPatterns(): string[] {
|
||||
return [
|
||||
"*.shp",
|
||||
"*.shx",
|
||||
"*.dbf",
|
||||
"*.prj",
|
||||
"*.sbn",
|
||||
"*.sbx",
|
||||
"*.shp.xml",
|
||||
"*.cpg",
|
||||
"*.gdb",
|
||||
"*.mdb",
|
||||
"*.gpkg",
|
||||
"*.kml",
|
||||
"*.kmz",
|
||||
"*.gml",
|
||||
"*.geojson",
|
||||
"*.dem",
|
||||
"*.asc",
|
||||
"*.img",
|
||||
"*.ecw",
|
||||
"*.las",
|
||||
"*.laz",
|
||||
"*.mxd",
|
||||
"*.qgs",
|
||||
"*.grd",
|
||||
"*.csv",
|
||||
"*.dwg",
|
||||
"*.dxf",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for log and debug output files
|
||||
* @returns Array of glob patterns for log files
|
||||
*/
|
||||
function getLogFilePatterns(): string[] {
|
||||
return ["*.error", "*.log", "*.logs", "*.npm-debug.log*", "*.out", "*.stdout", "yarn-debug.log*", "yarn-error.log*"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the combined exclusion patterns to Git's exclude file.
|
||||
* Creates the info directory if it doesn't exist.
|
||||
*
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @param lfsPatterns - Optional array of Git LFS patterns to include
|
||||
*/
|
||||
export const writeExcludesFile = async (gitPath: string, lfsPatterns: string[] = []): Promise<void> => {
|
||||
const excludesPath = join(gitPath, "info", "exclude")
|
||||
await fs.mkdir(join(gitPath, "info"), { recursive: true })
|
||||
|
||||
const patterns = getDefaultExclusions(lfsPatterns)
|
||||
await fs.writeFile(excludesPath, patterns.join("\n"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves Git LFS patterns from the workspace's .gitattributes file.
|
||||
* Returns an empty array if no patterns found or file doesn't exist.
|
||||
*
|
||||
* @param workspacePath - Path to the workspace root
|
||||
* @returns Array of Git LFS patterns found in .gitattributes
|
||||
*/
|
||||
export const getLfsPatterns = async (workspacePath: string): Promise<string[]> => {
|
||||
try {
|
||||
const attributesPath = join(workspacePath, ".gitattributes")
|
||||
if (await fileExistsAtPath(attributesPath)) {
|
||||
const attributesContent = await fs.readFile(attributesPath, "utf8")
|
||||
return attributesContent
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("filter=lfs"))
|
||||
.map((line) => line.split(" ")[0].trim())
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn("Failed to read .gitattributes:", error)
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { retryWithBackoff } from "@utils/retry"
|
||||
import fs from "fs/promises"
|
||||
import { globby } from "globby"
|
||||
import * as path from "path"
|
||||
import simpleGit, { type SimpleGit } from "simple-git"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
|
||||
|
||||
interface CheckpointAddResult {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* GitOperations Class
|
||||
*
|
||||
* Handles git-specific operations for Cline's Checkpoints system.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Git repository initialization and configuration
|
||||
* - Git settings management (user, LFS, etc.)
|
||||
* - Worktree configuration and management
|
||||
* - Managing nested git repositories during checkpoint operations
|
||||
* - File staging and checkpoint creation
|
||||
* - Shadow git repository maintenance and cleanup
|
||||
*/
|
||||
export class GitOperations {
|
||||
private cwd: string
|
||||
|
||||
/**
|
||||
* Creates a new GitOperations instance.
|
||||
*
|
||||
* @param cwd - The current working directory for git operations
|
||||
*/
|
||||
constructor(cwd: string) {
|
||||
this.cwd = cwd
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes or verifies a shadow Git repository for checkpoint tracking.
|
||||
* Creates a new repository if one doesn't exist, or verifies the worktree
|
||||
* configuration if it does.
|
||||
*
|
||||
* Key operations:
|
||||
* - Creates/verifies shadow git repository
|
||||
* - Configures git settings (user, LFS, etc.)
|
||||
* - Sets up worktree to point to workspace
|
||||
*
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @param cwd - The current working directory for git operations
|
||||
* @returns Promise<string> Path to the initialized .git directory
|
||||
* @throws Error if:
|
||||
* - Worktree verification fails for existing repository
|
||||
* - Git initialization or configuration fails
|
||||
* - Unable to create initial commit
|
||||
* - LFS pattern setup fails
|
||||
*/
|
||||
public async initShadowGit(gitPath: string, cwd: string, taskId: string): Promise<string> {
|
||||
Logger.info(`Initializing shadow git`)
|
||||
// Clean up any leftover .git_disabled directories from a previous crash/interruption.
|
||||
// If addCheckpointFiles() was interrupted mid disable/enable cycle, nested repos may still be disabled.
|
||||
await this.renameNestedGitRepos(false).catch((error) => {
|
||||
Logger.warn("CheckpointTracker failed best-effort nested git cleanup during shadow git init:", error)
|
||||
})
|
||||
|
||||
// If repo exists, just verify worktree
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
if (worktree.value !== cwd) {
|
||||
throw new Error("Checkpoints can only be used in the original workspace: " + worktree.value)
|
||||
}
|
||||
Logger.warn(`Using existing shadow git at ${gitPath}`)
|
||||
|
||||
// shadow git repo already exists, but update the excludes just in case
|
||||
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
|
||||
|
||||
return gitPath
|
||||
}
|
||||
|
||||
// Initialize new repo
|
||||
const startTime = performance.now()
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
Logger.warn(`Creating new shadow git in ${checkpointsDir}`)
|
||||
|
||||
const git = simpleGit(checkpointsDir)
|
||||
await git.init()
|
||||
|
||||
// Configure repo with git settings
|
||||
await git.addConfig("core.worktree", cwd)
|
||||
await git.addConfig("commit.gpgSign", "false")
|
||||
await git.addConfig("user.name", "Cline Checkpoint")
|
||||
await git.addConfig("user.email", "checkpoint@cline.bot")
|
||||
|
||||
// Set up LFS patterns
|
||||
const lfsPatterns = await getLfsPatterns(cwd)
|
||||
await writeExcludesFile(gitPath, lfsPatterns)
|
||||
|
||||
const addFilesResult = await this.addCheckpointFiles(git)
|
||||
if (!addFilesResult.success) {
|
||||
Logger.error("Failed to add at least one file(s) to checkpoints shadow git")
|
||||
throw new Error("Failed to add at least one file(s) to checkpoints shadow git")
|
||||
}
|
||||
|
||||
// Initial commit only on first repo creation
|
||||
await git.commit("initial commit", { "--allow-empty": null, "--no-verify": null })
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
|
||||
|
||||
Logger.warn(`Shadow git initialization completed`)
|
||||
|
||||
return gitPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the worktree path from the shadow git configuration.
|
||||
* The worktree path indicates where the shadow git repository is tracking files,
|
||||
* which should match the current workspace directory.
|
||||
*
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @returns Promise<string | undefined> The worktree path or undefined if not found
|
||||
* @throws Error if unable to get worktree path
|
||||
*/
|
||||
public async getShadowGitConfigWorkTree(gitPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
return worktree.value || undefined
|
||||
} catch (error) {
|
||||
Logger.error("Failed to get shadow git config worktree:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's
|
||||
* requirement of using submodules for nested repos.
|
||||
*
|
||||
* This method renames nested .git directories by adding/removing a suffix to temporarily disable/enable them.
|
||||
* The root .git directory is preserved. Uses VS Code's workspace API to find nested .git directories and
|
||||
* only processes actual directories (not files named .git).
|
||||
*
|
||||
* @param disable - If true, adds suffix to disable nested git repos. If false, removes suffix to re-enable them.
|
||||
* @throws Error if renaming any .git directory fails
|
||||
*/
|
||||
public async renameNestedGitRepos(disable: boolean) {
|
||||
// Find all .git directories that are not at the root level
|
||||
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
|
||||
cwd: this.cwd,
|
||||
onlyDirectories: true,
|
||||
ignore: [".git", "**/node_modules/**"], // Ignore root level .git and node_modules (can contain recursive .git dirs that cause 10s+ scans)
|
||||
dot: true,
|
||||
markDirectories: false,
|
||||
suppressErrors: true,
|
||||
})
|
||||
|
||||
// For each nested .git directory, rename it based on operation
|
||||
for (const gitPath of gitPaths) {
|
||||
const fullPath = path.join(this.cwd, gitPath)
|
||||
let newPath: string
|
||||
if (disable) {
|
||||
newPath = fullPath + GIT_DISABLED_SUFFIX
|
||||
} else {
|
||||
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(fullPath, newPath)
|
||||
Logger.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
|
||||
} catch (error) {
|
||||
Logger.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
|
||||
throw new Error(
|
||||
`Failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds files to the shadow git repository while handling nested git repos.
|
||||
* Uses git commands to list files and stages them for commit.
|
||||
* Respects .gitignore and handles LFS patterns.
|
||||
*
|
||||
* Process:
|
||||
* 1. Updates exclude patterns from LFS config
|
||||
* 2. Temporarily disables nested git repos
|
||||
* 3. Gets list of tracked and untracked files from git (respecting .gitignore)
|
||||
* 4. Adds all files to git staging
|
||||
* 5. Re-enables nested git repos
|
||||
*
|
||||
* @param git - SimpleGit instance configured for the shadow git repo
|
||||
* @returns Promise<CheckpointAddResult> Object containing success status, message, and file count
|
||||
* @throws Error if:
|
||||
* - File operations fail
|
||||
* - Git commands error
|
||||
* - LFS pattern updates fail
|
||||
* - Nested git repo handling fails
|
||||
*/
|
||||
public async addCheckpointFiles(git: SimpleGit): Promise<CheckpointAddResult> {
|
||||
const startTime = performance.now()
|
||||
try {
|
||||
// Update exclude patterns before each commit
|
||||
await this.renameNestedGitRepos(true)
|
||||
Logger.info("Starting checkpoint add operation...")
|
||||
|
||||
// Attempt to add all files. Any files with permissions errors will not be added,
|
||||
// but the process will proceed and add the rest (--ignore-errors).
|
||||
try {
|
||||
await git.add([".", "--ignore-errors"])
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
Logger.debug(`Checkpoint add operation completed in ${durationMs}ms`)
|
||||
return { success: true }
|
||||
} catch (_error) {
|
||||
return { success: false }
|
||||
}
|
||||
} catch (_error) {
|
||||
return { success: false }
|
||||
} finally {
|
||||
await retryWithBackoff(() => this.renameNestedGitRepos(false), {
|
||||
operationName: "CheckpointTracker re-enable nested git repos",
|
||||
maxAttempts: 3,
|
||||
baseDelayMs: 50,
|
||||
onRetry: (_error, attempt, maxAttempts, delayMs) => {
|
||||
Logger.warn(
|
||||
`CheckpointTracker re-enable nested git repos failed on attempt ${attempt}/${maxAttempts}. Retrying in ${delayMs}ms`,
|
||||
)
|
||||
},
|
||||
}).catch((error) => {
|
||||
Logger.error("CheckpointTracker failed to re-enable nested git repos after retries:", error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GIT_DISABLED_SUFFIX = "_disabled"
|
||||
@@ -1,37 +0,0 @@
|
||||
import { releaseFolderLock, tryAcquireFolderLockWithRetry } from "@/core/locks/FolderLockUtils"
|
||||
import type { FolderLockOptions, FolderLockWithRetryResult } from "@/core/locks/types"
|
||||
|
||||
/**
|
||||
* Base path for checkpoint folders
|
||||
*/
|
||||
const CHECKPOINTS_BASE_PATH = "~/.cline/data/checkpoints"
|
||||
|
||||
/**
|
||||
* Attempt to acquire checkpoint folder lock with retry logic.
|
||||
* This is a convenience wrapper around the generic folder lock utility
|
||||
* that automatically derives the correct folder path from the cwdHash.
|
||||
*
|
||||
* @param cwdHash - The hash of the working directory
|
||||
* @param taskId - The task ID (swapped to instance address in SqliteLockManager)
|
||||
* @returns Promise<FolderLockWithRetryResult> with acquisition status and any conflicting lock info
|
||||
*/
|
||||
export async function tryAcquireCheckpointLockWithRetry(cwdHash: string, taskId: string): Promise<FolderLockWithRetryResult> {
|
||||
const options: FolderLockOptions = {
|
||||
lockTarget: `${CHECKPOINTS_BASE_PATH}/${cwdHash}`,
|
||||
heldBy: taskId,
|
||||
}
|
||||
|
||||
const result = await tryAcquireFolderLockWithRetry(options)
|
||||
return { acquired: result.acquired, skipped: result.skipped, conflictingLock: result.conflictingLock }
|
||||
}
|
||||
|
||||
/**
|
||||
* Release checkpoint folder lock safely.
|
||||
* This is a convenience wrapper around the generic folder lock utility
|
||||
* that automatically derives the correct folder path from the cwdHash.
|
||||
*
|
||||
* @param cwdHash - The hash of the working directory
|
||||
*/
|
||||
export async function releaseCheckpointLock(cwdHash: string, taskId: string): Promise<void> {
|
||||
await releaseFolderLock(taskId, `${CHECKPOINTS_BASE_PATH}/${cwdHash}`)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
/**
|
||||
* Cleans up legacy checkpoints from task folders.
|
||||
* This is a one-time operation that runs when the extension is updated to use the new checkpoint system.
|
||||
*
|
||||
* @param globalStoragePath - Path to the extension's global storage
|
||||
*/
|
||||
export async function cleanupLegacyCheckpoints(): Promise<void> {
|
||||
try {
|
||||
const tasksDir = path.join(HostProvider.get().globalStorageFsPath, "tasks")
|
||||
|
||||
// Check if tasks directory exists
|
||||
if (!(await fileExistsAtPath(tasksDir))) {
|
||||
return // No tasks directory, nothing to clean up
|
||||
}
|
||||
|
||||
// Get all task folders
|
||||
const taskFolders = await fs.readdir(tasksDir)
|
||||
if (taskFolders.length === 0) {
|
||||
return // No task folders, nothing to clean up
|
||||
}
|
||||
|
||||
// Get stats for each folder to sort by creation time
|
||||
const folderStats = await Promise.all(
|
||||
taskFolders.map(async (folder) => {
|
||||
const folderPath = path.join(tasksDir, folder)
|
||||
const stats = await fs.stat(folderPath)
|
||||
return { folder, path: folderPath, stats }
|
||||
}),
|
||||
)
|
||||
|
||||
// Sort by creation time, newest first
|
||||
folderStats.sort((a, b) => b.stats.birthtimeMs - a.stats.birthtimeMs)
|
||||
|
||||
// Check if the most recent task folder has a checkpoints directory
|
||||
if (folderStats.length > 0) {
|
||||
const mostRecentFolder = folderStats[0]
|
||||
const checkpointsDir = path.join(mostRecentFolder.path, "checkpoints")
|
||||
|
||||
if (await fileExistsAtPath(checkpointsDir)) {
|
||||
const results = { deleted: [] as string[], failed: [] as string[] }
|
||||
// Legacy checkpoints found, delete checkpoints directories in all task folders
|
||||
for (const folder of folderStats) {
|
||||
const folderCheckpointsDir = path.join(folder.path, "checkpoints")
|
||||
if (await fileExistsAtPath(folderCheckpointsDir)) {
|
||||
try {
|
||||
await fs.rm(folderCheckpointsDir, { recursive: true, force: true })
|
||||
results.deleted.push(folder.folder)
|
||||
} catch (_error) {
|
||||
// Ignore error if directory removal fails
|
||||
results.failed.push(folder.folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
`Legacy checkpoints cleanup completed. Deleted: ${results.deleted.length}, Failed: ${results.failed.length}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error("Error cleaning up legacy checkpoints.", { cause: error })
|
||||
}
|
||||
}
|
||||
@@ -1,511 +0,0 @@
|
||||
import { sendCheckpointEvent } from "@core/controller/checkpoints/subscribeToCheckpoints"
|
||||
import fs from "fs/promises"
|
||||
import { isBinaryFile } from "isbinaryfile"
|
||||
import * as path from "path"
|
||||
import simpleGit from "simple-git"
|
||||
import type { FolderLockWithRetryResult } from "@/core/locks/types"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { GitOperations } from "./CheckpointGitOperations"
|
||||
import { releaseCheckpointLock, tryAcquireCheckpointLockWithRetry } from "./CheckpointLockUtils"
|
||||
import { getShadowGitPath, hashWorkingDir } from "./CheckpointUtils"
|
||||
|
||||
/**
|
||||
* Operation types for checkpoint events
|
||||
*/
|
||||
type CheckpointOperation = "CHECKPOINT_INIT" | "CHECKPOINT_COMMIT" | "CHECKPOINT_RESTORE"
|
||||
|
||||
/**
|
||||
* CheckpointTracker Module
|
||||
*
|
||||
* Core implementation of Cline's Checkpoints system that provides version control
|
||||
* capabilities without interfering with the user's main Git repository. Key features:
|
||||
*
|
||||
* Shadow Git Repository:
|
||||
* - Creates and manages an isolated Git repository for tracking checkpoints
|
||||
* - Handles nested Git repositories by temporarily disabling them
|
||||
* - Configures Git settings automatically (identity, LFS, etc.)
|
||||
*
|
||||
* File Management:
|
||||
* - Integrates with CheckpointExclusions for file filtering
|
||||
* - Handles workspace validation and path resolution
|
||||
* - Manages Git worktree configuration
|
||||
*
|
||||
* Checkpoint Operations:
|
||||
* - Creates checkpoints (commits) of the current state
|
||||
* - Provides diff capabilities between checkpoints
|
||||
* - Supports resetting to previous checkpoints
|
||||
*
|
||||
* Safety Features:
|
||||
* - Prevents usage in sensitive directories (home, desktop, etc.)
|
||||
* - Validates workspace configuration
|
||||
* - Handles cleanup and resource disposal
|
||||
*
|
||||
* Checkpoint Architecture:
|
||||
* - Unique shadow git repository for each workspace
|
||||
* - Workspaces are identified by name, and hashed to a unique number
|
||||
* - All commits for a workspace are stored in one shadow git, under a single branch
|
||||
*/
|
||||
|
||||
class CheckpointTracker {
|
||||
private taskId: string
|
||||
private cwd: string
|
||||
private cwdHash: string
|
||||
private lastRetrievedShadowGitConfigWorkTree?: string
|
||||
private gitOperations: GitOperations
|
||||
|
||||
/**
|
||||
* Helper method to clean commit hashes that might have a "HEAD " prefix.
|
||||
* Used for backward compatibility with old tasks that stored hashes with the prefix.
|
||||
*/
|
||||
private cleanCommitHash(hash: string): string {
|
||||
return hash.startsWith("HEAD ") ? hash.slice(5) : hash
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a checkpoint event to all subscribers.
|
||||
*
|
||||
* @param operation - The operation type (CHECKPOINT_INIT, CHECKPOINT_COMMIT, or CHECKPOINT_RESTORE)
|
||||
* @param isActive - true when operation starts, false when complete
|
||||
* @param commitHash - Optional commit hash for CHECKPOINT_COMMIT and CHECKPOINT_RESTORE operations
|
||||
*/
|
||||
private async sendCheckpointSubscriptionEvent(
|
||||
operation: CheckpointOperation,
|
||||
isActive: boolean,
|
||||
commitHash?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await sendCheckpointEvent({
|
||||
operation,
|
||||
cwdHash: this.cwdHash,
|
||||
isActive,
|
||||
taskId: this.taskId,
|
||||
commitHash,
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.debug("Failed to send checkpoint event:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new CheckpointTracker instance to manage checkpoints for a specific task.
|
||||
* The constructor is private - use the static create() method to instantiate.
|
||||
*
|
||||
* @param taskId - Unique identifier for the task being tracked
|
||||
* @param cwd - The current working directory to track files in
|
||||
* @param cwdHash - Hash of the working directory path for shadow git organization
|
||||
*/
|
||||
private constructor(taskId: string, cwd: string, cwdHash: string) {
|
||||
this.taskId = taskId
|
||||
this.cwd = cwd
|
||||
this.cwdHash = cwdHash
|
||||
this.gitOperations = new GitOperations(cwd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new CheckpointTracker instance for tracking changes in a task.
|
||||
* Handles initialization of the shadow git repository.
|
||||
*
|
||||
* @param taskId - Unique identifier for the task to track
|
||||
* @param globalStoragePath - the globalStorage path
|
||||
* @param enableCheckpointsSetting - Whether checkpoints are enabled in settings
|
||||
* @param workspacePaths - The workspace directory path(s) to track (string or array of strings)
|
||||
* @returns Promise resolving to new CheckpointTracker instance, or undefined if checkpoints are disabled
|
||||
* @throws Error if:
|
||||
* - globalStoragePath is not supplied
|
||||
* - Git is not installed
|
||||
* - Working directory is invalid or in a protected location
|
||||
* - Shadow git initialization fails
|
||||
*
|
||||
* Key operations:
|
||||
* - Validates git installation and settings
|
||||
* - Creates/initializes shadow git repository
|
||||
*
|
||||
* Configuration:
|
||||
* - Respects 'cline.enableCheckpoints' VS Code setting
|
||||
*/
|
||||
public static async create(
|
||||
taskId: string,
|
||||
enableCheckpointsSetting: boolean,
|
||||
workspacePaths: string | string[],
|
||||
): Promise<CheckpointTracker | undefined> {
|
||||
try {
|
||||
Logger.info(`Creating new CheckpointTracker for task ${taskId}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
// Check if checkpoints are disabled by setting
|
||||
if (!enableCheckpointsSetting) {
|
||||
Logger.info(`Checkpoints disabled by setting for task ${taskId}`)
|
||||
return undefined // Don't create tracker when disabled
|
||||
}
|
||||
|
||||
// Check if git is installed by attempting to get version
|
||||
try {
|
||||
await simpleGit().version()
|
||||
} catch (_error) {
|
||||
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
|
||||
}
|
||||
|
||||
// Validate and normalize workspace paths - for now, we just use the first valid path
|
||||
const pathsToValidate = Array.isArray(workspacePaths) ? workspacePaths : [workspacePaths]
|
||||
const { validateWorkspacePath } = await import("./CheckpointUtils")
|
||||
|
||||
for (const workspacePath of pathsToValidate) {
|
||||
if (!workspacePath) {
|
||||
throw new Error("At least one workspace path must be provided")
|
||||
}
|
||||
|
||||
await validateWorkspacePath(workspacePath)
|
||||
}
|
||||
|
||||
// For now, we just use the first valid path
|
||||
const workingDir = Array.isArray(workspacePaths) ? workspacePaths[0] : workspacePaths
|
||||
|
||||
const cwdHash = hashWorkingDir(workingDir)
|
||||
Logger.debug(`Repository ID (cwdHash): ${cwdHash}`)
|
||||
|
||||
const newTracker = new CheckpointTracker(taskId, workingDir, cwdHash)
|
||||
await newTracker.sendCheckpointSubscriptionEvent("CHECKPOINT_INIT", true)
|
||||
|
||||
const gitPath = await getShadowGitPath(newTracker.cwdHash)
|
||||
await newTracker.gitOperations.initShadowGit(gitPath, workingDir, taskId)
|
||||
await newTracker.sendCheckpointSubscriptionEvent("CHECKPOINT_INIT", false)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
|
||||
|
||||
return newTracker
|
||||
} catch (error) {
|
||||
Logger.error("Failed to create CheckpointTracker:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new checkpoint commit in the shadow git repository.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Acquires folder lock before proceeding to prevent conflicts
|
||||
* - Creates commit with checkpoint files in shadow git repo
|
||||
* - Releases folder lock after completion
|
||||
* - Caches the created commit hash
|
||||
*
|
||||
* Commit structure:
|
||||
* - Commit message: "checkpoint-{cwdHash}-{taskId}"
|
||||
* - Always allows empty commits
|
||||
*
|
||||
* Dependencies:
|
||||
* - Requires initialized shadow git (getShadowGitPath)
|
||||
* - Uses addCheckpointFiles to stage changes using 'git add .'
|
||||
* - Relies on git's native exclusion handling via the exclude file
|
||||
*
|
||||
* @returns Promise<string | undefined> The created commit hash, or undefined if:
|
||||
* - Folder lock acquisition fails or times out
|
||||
* - Shadow git access fails
|
||||
* - Staging files fails
|
||||
* - Commit creation fails
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Stage or commit files
|
||||
*/
|
||||
public async commit(): Promise<string | undefined> {
|
||||
let lockAcquired: boolean = false
|
||||
|
||||
try {
|
||||
await this.sendCheckpointSubscriptionEvent("CHECKPOINT_COMMIT", true)
|
||||
Logger.info(`Creating new checkpoint commit for task ${this.taskId}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
const lockResult: FolderLockWithRetryResult = await tryAcquireCheckpointLockWithRetry(this.cwdHash, this.taskId)
|
||||
|
||||
// Locking failed due to conflicting lock
|
||||
if (!lockResult.acquired && !lockResult.skipped) {
|
||||
throw new Error(
|
||||
"Failed to acquire checkpoint folder lock - another Cline instance may be performing checkpoint operations",
|
||||
)
|
||||
}
|
||||
|
||||
// Locking skipped as we are in VS Code
|
||||
if (!lockResult.acquired && lockResult.skipped) {
|
||||
Logger.log("Skipping Checkpoints lock - VS Code")
|
||||
}
|
||||
|
||||
if (lockResult.acquired) {
|
||||
lockAcquired = true
|
||||
}
|
||||
|
||||
const gitPath = await getShadowGitPath(this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
Logger.info(`Using shadow git at: ${gitPath}`)
|
||||
|
||||
const addFilesResult = await this.gitOperations.addCheckpointFiles(git)
|
||||
if (!addFilesResult.success) {
|
||||
Logger.error("Failed to add at least one file(s) to checkpoints shadow git")
|
||||
}
|
||||
|
||||
const commitMessage = "checkpoint-" + this.cwdHash + "-" + this.taskId
|
||||
|
||||
Logger.info(`Creating checkpoint commit with message: ${commitMessage}`)
|
||||
const result = await git.commit(commitMessage, {
|
||||
"--allow-empty": null,
|
||||
"--no-verify": null,
|
||||
})
|
||||
const commitHash = (result.commit || "").replace(/^HEAD\s+/, "")
|
||||
Logger.warn(`Checkpoint commit created: `, commitHash)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
await this.sendCheckpointSubscriptionEvent("CHECKPOINT_COMMIT", false, commitHash)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "commit_created", durationMs)
|
||||
|
||||
return commitHash
|
||||
} catch (error) {
|
||||
Logger.error("Failed to create checkpoint:", {
|
||||
taskId: this.taskId,
|
||||
error,
|
||||
})
|
||||
throw new Error(`Failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`)
|
||||
} finally {
|
||||
if (lockAcquired) {
|
||||
Logger.info("Releasing checkpoint folder lock")
|
||||
await releaseCheckpointLock(this.cwdHash, this.taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the worktree path from the shadow git configuration.
|
||||
* The worktree path indicates where the shadow git repository is tracking files,
|
||||
* which should match the current workspace directory.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Caches result in lastRetrievedShadowGitConfigWorkTree to avoid repeated reads
|
||||
* - Returns cached value if available
|
||||
* - Reads git config if no cached value exists
|
||||
*
|
||||
* Configuration read:
|
||||
* - Uses simple-git to read core.worktree config
|
||||
* - Operates on shadow git at path from getShadowGitPath()
|
||||
*
|
||||
* @returns Promise<string | undefined> The configured worktree path, or undefined if:
|
||||
* - Shadow git repository doesn't exist
|
||||
* - Config read fails
|
||||
* - No worktree is configured
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Read git configuration
|
||||
*/
|
||||
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
|
||||
if (this.lastRetrievedShadowGitConfigWorkTree) {
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
}
|
||||
try {
|
||||
const gitPath = await getShadowGitPath(this.cwdHash)
|
||||
this.lastRetrievedShadowGitConfigWorkTree = await this.gitOperations.getShadowGitConfigWorkTree(gitPath)
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
} catch (error) {
|
||||
Logger.error("Failed to get shadow git config worktree:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the shadow git repository's HEAD to a specific checkpoint commit.
|
||||
* This will discard all changes after the target commit and restore the
|
||||
* working directory to that checkpoint's state.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Acquires folder lock before proceeding to prevent conflicts
|
||||
* - Performs hard reset to target commit
|
||||
* - Releases folder lock after completion
|
||||
*
|
||||
* Dependencies:
|
||||
* - Requires initialized shadow git (getShadowGitPath)
|
||||
* - Must be called with a valid commit hash from this task's history
|
||||
*
|
||||
* @param commitHash - The hash of the checkpoint commit to reset to
|
||||
* @returns Promise<void> Resolves when reset is complete
|
||||
* @throws Error if unable to:
|
||||
* - Acquire folder lock (timeout or conflict)
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Reset to target commit
|
||||
*/
|
||||
public async resetHead(commitHash: string): Promise<void> {
|
||||
let lockAcquired: boolean = false
|
||||
|
||||
try {
|
||||
Logger.info(`Resetting to checkpoint: ${commitHash}`)
|
||||
const startTime = performance.now()
|
||||
await this.sendCheckpointSubscriptionEvent("CHECKPOINT_RESTORE", true, commitHash)
|
||||
const lockResult: FolderLockWithRetryResult = await tryAcquireCheckpointLockWithRetry(this.cwdHash, this.taskId)
|
||||
|
||||
// Locking failed due to conflicting lock
|
||||
if (!lockResult.acquired && !lockResult.skipped) {
|
||||
throw new Error(
|
||||
"Failed to acquire checkpoint folder lock - another Cline instance may be performing checkpoint operations",
|
||||
)
|
||||
}
|
||||
|
||||
// Locking skipped as we are in VS Code
|
||||
if (!lockResult.acquired && lockResult.skipped) {
|
||||
Logger.log("Skipping Checkpoints lock - VS Code")
|
||||
}
|
||||
|
||||
if (lockResult.acquired) {
|
||||
lockAcquired = true
|
||||
}
|
||||
|
||||
const gitPath = await getShadowGitPath(this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
Logger.debug(`Using shadow git at: ${gitPath}`)
|
||||
await git.reset(["--hard", this.cleanCommitHash(commitHash)]) // Hard reset to target commit
|
||||
Logger.debug(`Successfully reset to checkpoint: ${commitHash}`)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
await this.sendCheckpointSubscriptionEvent("CHECKPOINT_RESTORE", false, commitHash)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "restored", durationMs)
|
||||
} catch (error) {
|
||||
Logger.error("Failed to reset to checkpoint:", {
|
||||
taskId: this.taskId,
|
||||
commitHash,
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
if (lockAcquired) {
|
||||
await releaseCheckpointLock(this.cwdHash, this.taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array describing changed files between one commit and either:
|
||||
* - another commit, or
|
||||
* - the current working directory (including uncommitted changes).
|
||||
*
|
||||
* If `rhsHash` is omitted, compares `lhsHash` to the working directory.
|
||||
* If you want truly untracked files to appear, `git add` them first.
|
||||
*
|
||||
* @param lhsHash - The commit to compare from (older commit)
|
||||
* @param rhsHash - The commit to compare to (newer commit).
|
||||
* If omitted, we compare to the working directory.
|
||||
* @returns Array of file changes with before/after content
|
||||
*/
|
||||
public async getDiffSet(
|
||||
lhsHash: string,
|
||||
rhsHash?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
before: string
|
||||
after: string
|
||||
}>
|
||||
> {
|
||||
const startTime = performance.now()
|
||||
|
||||
const gitPath = await getShadowGitPath(this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
Logger.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
|
||||
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.gitOperations.addCheckpointFiles(git)
|
||||
|
||||
const cleanRhs = rhsHash ? this.cleanCommitHash(rhsHash) : undefined
|
||||
const diffRange = cleanRhs ? `${this.cleanCommitHash(lhsHash)}..${cleanRhs}` : this.cleanCommitHash(lhsHash)
|
||||
Logger.info(`Diff range: ${diffRange}`)
|
||||
const diffSummary = await git.diffSummary([diffRange])
|
||||
|
||||
const result = []
|
||||
for (const file of diffSummary.files) {
|
||||
const filePath = file.file
|
||||
const absolutePath = path.join(this.cwd, filePath)
|
||||
|
||||
// For extensionless files or dotfiles: exclude from diff result if binary
|
||||
const lastDotIndex = filePath.lastIndexOf(".")
|
||||
const lastSlashIndex = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"))
|
||||
const ext = lastDotIndex > lastSlashIndex ? filePath.substring(lastDotIndex).toLowerCase() : ""
|
||||
const isDotfile = lastDotIndex !== -1 && lastDotIndex === lastSlashIndex + 1
|
||||
|
||||
if (!ext || isDotfile) {
|
||||
try {
|
||||
const isBinary = await isBinaryFile(absolutePath).catch(() => false)
|
||||
if (isBinary) {
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let beforeContent = ""
|
||||
try {
|
||||
beforeContent = await git.show([`${this.cleanCommitHash(lhsHash)}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in older commit => remains empty
|
||||
}
|
||||
|
||||
let afterContent = ""
|
||||
if (rhsHash) {
|
||||
try {
|
||||
afterContent = await git.show([`${this.cleanCommitHash(rhsHash)}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in newer commit => remains empty
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
afterContent = await fs.readFile(absolutePath, "utf8")
|
||||
} catch (_) {
|
||||
// file might be deleted => remains empty
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
relativePath: filePath,
|
||||
absolutePath,
|
||||
before: beforeContent,
|
||||
after: afterContent,
|
||||
})
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of files changed between two commits.
|
||||
*
|
||||
* @param lhsHash - The commit to compare from (older commit)
|
||||
* @param rhsHash - The commit to compare to (newer commit).
|
||||
* If omitted, we compare to the working directory.
|
||||
* @returns The number of files changed between the commits
|
||||
*/
|
||||
public async getDiffCount(lhsHash: string, rhsHash?: string): Promise<number> {
|
||||
const startTime = performance.now()
|
||||
|
||||
const gitPath = await getShadowGitPath(this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
Logger.info(`Getting diff count between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
|
||||
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.gitOperations.addCheckpointFiles(git)
|
||||
|
||||
const cleanRhs = rhsHash ? this.cleanCommitHash(rhsHash) : undefined
|
||||
const diffRange = cleanRhs ? `${this.cleanCommitHash(lhsHash)}..${cleanRhs}` : this.cleanCommitHash(lhsHash)
|
||||
const diffSummary = await git.diffSummary([diffRange])
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
|
||||
|
||||
return diffSummary.files.length
|
||||
}
|
||||
}
|
||||
|
||||
export default CheckpointTracker
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* TODO: MULTI-ROOT CHECKPOINT MANAGER - NOT YET IN USE
|
||||
*
|
||||
* This MultiRootCheckpointManager class has been implemented as part of Phase 1
|
||||
* of the multi-workspace support initiative, but it is NOT currently being used
|
||||
* anywhere in the codebase.
|
||||
*
|
||||
* Current Status:
|
||||
* - The infrastructure is complete and ready
|
||||
* - The feature flag for multi-root is disabled by default
|
||||
* - The checkpoint factory (src/integrations/checkpoints/factory.ts) will
|
||||
* instantiate this manager when multi-root is enabled
|
||||
*
|
||||
* Follow-up Implementation Required:
|
||||
* 1. Enable the multi-root feature flag in StateManager
|
||||
* 2. Update the checkpoint factory to use this manager when appropriate
|
||||
* 3. Test thoroughly with multiple workspace roots
|
||||
* 4. Add proper restoration logic for all workspace roots (not just primary)
|
||||
* 5. Implement full diff checking across all workspace roots
|
||||
*
|
||||
* See PRD: Multi-Workspace Folder Support for complete requirements
|
||||
*/
|
||||
|
||||
import { MessageStateHandler } from "@core/task/message-state"
|
||||
import { showChangedFilesDiff } from "@core/task/multifile-diff"
|
||||
import { WorkspaceRootManager } from "@core/workspace"
|
||||
import { telemetryService } from "@services/telemetry"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import CheckpointTracker from "./CheckpointTracker"
|
||||
import { ICheckpointManager } from "./types"
|
||||
|
||||
/**
|
||||
* Manages checkpoints across multiple workspace roots.
|
||||
* Only created when multiple roots are detected and feature flag is enabled.
|
||||
*
|
||||
* This implementation follows Option B: Simple All-Workspace Approach
|
||||
* - Creates checkpoints instance for each input workspace root
|
||||
* - Commits run in parallel in the background (non-blocking)
|
||||
* - Maintains backward compatibility with single-root expectations
|
||||
*/
|
||||
export class MultiRootCheckpointManager implements ICheckpointManager {
|
||||
private trackers: Map<string, CheckpointTracker> = new Map()
|
||||
private initialized = false
|
||||
private initPromise?: Promise<void>
|
||||
|
||||
constructor(
|
||||
private workspaceManager: WorkspaceRootManager,
|
||||
private taskId: string,
|
||||
private enableCheckpoints: boolean,
|
||||
private messageStateHandler: MessageStateHandler,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initialize checkpoint trackers for all workspace roots
|
||||
* This is called separately to avoid blocking the Task constructor
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// Prevent multiple initialization attempts
|
||||
if (this.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.initPromise) {
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
this.initPromise = this.doInitialize()
|
||||
await this.initPromise
|
||||
this.initPromise = undefined
|
||||
}
|
||||
|
||||
private async doInitialize(): Promise<void> {
|
||||
if (!this.enableCheckpoints) {
|
||||
Logger.log("[MultiRootCheckpointManager] Checkpoints disabled, skipping initialization")
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const roots = this.workspaceManager.getRoots()
|
||||
Logger.log(`[MultiRootCheckpointManager] Initializing for ${roots.length} workspace roots`)
|
||||
|
||||
// Initialize all workspace roots in parallel
|
||||
const initPromises = roots.map(async (root) => {
|
||||
try {
|
||||
Logger.log(`[MultiRootCheckpointManager] Creating tracker for ${root.name} at ${root.path}`)
|
||||
const tracker = await CheckpointTracker.create(this.taskId, this.enableCheckpoints, root.path)
|
||||
if (tracker) {
|
||||
this.trackers.set(root.path, tracker)
|
||||
Logger.log(`[MultiRootCheckpointManager] Successfully initialized tracker for ${root.name}`)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
Logger.error(`[MultiRootCheckpointManager] Failed to initialize checkpoint for ${root.name}:`, error)
|
||||
// Continue with other roots even if one fails
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(initPromises)
|
||||
const successCount = results.filter((r) => r).length
|
||||
const failureCount = results.length - successCount
|
||||
|
||||
this.initialized = true
|
||||
Logger.log(`[MultiRootCheckpointManager] Initialization complete. Active trackers: ${this.trackers.size}`)
|
||||
|
||||
// TELEMETRY: Track multi-root checkpoint initialization
|
||||
telemetryService.captureMultiRootCheckpoint(
|
||||
this.taskId,
|
||||
"initialized",
|
||||
roots.length,
|
||||
successCount,
|
||||
failureCount,
|
||||
performance.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save checkpoint across all workspace roots
|
||||
* Commits happen in parallel in the background (non-blocking)
|
||||
*/
|
||||
async saveCheckpoint(): Promise<void> {
|
||||
if (!this.enableCheckpoints || !this.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.trackers.size === 0) {
|
||||
Logger.log("[MultiRootCheckpointManager] No trackers available for checkpoint")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.log(`[MultiRootCheckpointManager] Creating checkpoint across ${this.trackers.size} workspace(s)`)
|
||||
|
||||
// Commit all roots in parallel (fire and forget for performance)
|
||||
const commitPromises = Array.from(this.trackers.entries()).map(async ([path, tracker]) => {
|
||||
try {
|
||||
const hash = await tracker.commit()
|
||||
if (hash) {
|
||||
const rootName = this.workspaceManager.getRoots().find((r) => r.path === path)?.name || path
|
||||
Logger.log(`[MultiRootCheckpointManager] Checkpoint created for ${rootName}: ${hash}`)
|
||||
}
|
||||
return { path, hash, success: true }
|
||||
} catch (error) {
|
||||
const rootName = this.workspaceManager.getRoots().find((r) => r.path === path)?.name || path
|
||||
Logger.error(`[MultiRootCheckpointManager] Failed to checkpoint ${rootName}:`, error)
|
||||
return { path, hash: undefined, success: false }
|
||||
}
|
||||
})
|
||||
|
||||
// Don't await - let commits happen in background for better performance
|
||||
// But do catch any errors to prevent unhandled promise rejections
|
||||
const startTime = performance.now()
|
||||
Promise.all(commitPromises)
|
||||
.then((results) => {
|
||||
const successful = results.filter((r) => r.success).length
|
||||
const failed = results.length - successful
|
||||
Logger.log(`[MultiRootCheckpointManager] Checkpoint complete: ${successful}/${results.length} successful`)
|
||||
|
||||
// TELEMETRY: Track checkpoint commits
|
||||
telemetryService.captureMultiRootCheckpoint(
|
||||
this.taskId,
|
||||
"committed",
|
||||
results.length,
|
||||
successful,
|
||||
failed,
|
||||
performance.now() - startTime,
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error("[MultiRootCheckpointManager] Unexpected error during checkpoint:", error)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore checkpoint for workspace roots
|
||||
* For now, this restores the primary root only for simplicity
|
||||
* Future enhancement: restore all roots to their respective checkpoints
|
||||
*/
|
||||
async restoreCheckpoint(): Promise<any> {
|
||||
const primaryRoot = this.workspaceManager.getPrimaryRoot()
|
||||
if (!primaryRoot) {
|
||||
Logger.error("[MultiRootCheckpointManager] No primary root found")
|
||||
return { error: "No primary workspace found" }
|
||||
}
|
||||
|
||||
const tracker = this.trackers.get(primaryRoot.path)
|
||||
|
||||
if (!tracker) {
|
||||
Logger.error(`[MultiRootCheckpointManager] No tracker found for primary root: ${primaryRoot.path}`)
|
||||
return { error: "No checkpoint tracker for primary workspace" }
|
||||
}
|
||||
|
||||
Logger.log(`[MultiRootCheckpointManager] Restoring checkpoint for primary root: ${primaryRoot.name}`)
|
||||
|
||||
// TODO: Implement full restore logic similar to TaskCheckpointManager
|
||||
// For now, this is a placeholder that would delegate to the existing restore logic
|
||||
// In a full implementation, we'd restore all roots or provide options to the user
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the latest task completion has new changes
|
||||
* Returns true if ANY workspace has changes
|
||||
*/
|
||||
async doesLatestTaskCompletionHaveNewChanges(): Promise<boolean> {
|
||||
if (!this.initialized || this.trackers.size === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if any root has changes
|
||||
for (const [path] of this.trackers.entries()) {
|
||||
try {
|
||||
// TODO: Implement proper diff checking logic
|
||||
// This would need to track checkpoint hashes per root
|
||||
// For now, return false as a safe default
|
||||
const rootName = this.workspaceManager.getRoots().find((r) => r.path === path)?.name || path
|
||||
Logger.log(`[MultiRootCheckpointManager] Checking for changes in ${rootName}`)
|
||||
} catch (error) {
|
||||
Logger.error(`[MultiRootCheckpointManager] Error checking changes for ${path}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit changes across all workspaces
|
||||
* Returns the primary root's commit hash for backward compatibility
|
||||
*/
|
||||
async commit(): Promise<string | undefined> {
|
||||
if (!this.initialized || this.trackers.size === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const primaryRoot = this.workspaceManager.getPrimaryRoot()
|
||||
if (!primaryRoot) {
|
||||
Logger.warn("[MultiRootCheckpointManager] No primary root found, committing all roots")
|
||||
// Just commit all roots and return undefined
|
||||
const commitPromises = Array.from(this.trackers.values()).map((tracker) =>
|
||||
tracker.commit().catch((error) => {
|
||||
Logger.error("[MultiRootCheckpointManager] Commit error:", error)
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
await Promise.all(commitPromises)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Commit all roots in parallel
|
||||
const commitPromises = Array.from(this.trackers.values()).map((tracker) =>
|
||||
tracker.commit().catch((error) => {
|
||||
Logger.error("[MultiRootCheckpointManager] Commit error:", error)
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
|
||||
const results = await Promise.all(commitPromises)
|
||||
|
||||
// Return primary root's hash for compatibility with existing code
|
||||
const primaryIndex = Array.from(this.trackers.keys()).indexOf(primaryRoot.path)
|
||||
return results[primaryIndex]
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents a multi-file diff view for the primary workspace root.
|
||||
* For multi-root v1, this shows diffs for the primary root only.
|
||||
*/
|
||||
async presentMultifileDiff(messageTs: number, seeNewChangesSinceLastTaskCompletion: boolean): Promise<void> {
|
||||
try {
|
||||
if (!this.enableCheckpoints || !this.initialized) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Checkpoint manager is not initialized.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const primaryRoot = this.workspaceManager.getPrimaryRoot()
|
||||
if (!primaryRoot) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "No primary workspace root configured.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const tracker = this.trackers.get(primaryRoot.path)
|
||||
if (!tracker) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "No checkpoint tracker available for the primary workspace.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await showChangedFilesDiff(this.messageStateHandler, tracker, messageTs, seeNewChangesSinceLastTaskCompletion)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
Logger.error("[MultiRootCheckpointManager] Failed to present multifile diff:", errorMessage)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Failed to present diff: " + errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { StateManager } from "@core/storage/StateManager"
|
||||
import type { WorkspaceRootManager } from "@core/workspace/WorkspaceRootManager"
|
||||
import { expect } from "chai"
|
||||
import { shouldUseMultiRoot } from "../factory"
|
||||
|
||||
describe("shouldUseMultiRoot", () => {
|
||||
const makeWr = (roots: { path: string }[]): WorkspaceRootManager => {
|
||||
// minimal mock: only getRoots is used
|
||||
return {
|
||||
getRoots: () => roots as any,
|
||||
} as unknown as WorkspaceRootManager
|
||||
}
|
||||
|
||||
const makeStateManager = (): StateManager => {
|
||||
// minimal mock for tests
|
||||
return {} as unknown as StateManager
|
||||
}
|
||||
|
||||
it("returns true when feature flag is on, checkpoints enabled, and more than one root exists", () => {
|
||||
const wr = makeWr([{ path: "/r1" }, { path: "/r2" }])
|
||||
const result = shouldUseMultiRoot({
|
||||
multiRootEnabledOverride: true,
|
||||
workspaceManager: wr,
|
||||
enableCheckpoints: true,
|
||||
stateManager: makeStateManager(),
|
||||
})
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("returns false when feature flag is off", () => {
|
||||
const wr = makeWr([{ path: "/r1" }, { path: "/r2" }])
|
||||
const result = shouldUseMultiRoot({
|
||||
multiRootEnabledOverride: false,
|
||||
workspaceManager: wr,
|
||||
enableCheckpoints: true,
|
||||
stateManager: makeStateManager(),
|
||||
})
|
||||
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("returns false when checkpoints are disabled", () => {
|
||||
const wr = makeWr([{ path: "/r1" }, { path: "/r2" }])
|
||||
const result = shouldUseMultiRoot({
|
||||
multiRootEnabledOverride: true,
|
||||
workspaceManager: wr,
|
||||
enableCheckpoints: false,
|
||||
stateManager: makeStateManager(),
|
||||
})
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("returns false when workspaceManager is undefined", () => {
|
||||
const result = shouldUseMultiRoot({
|
||||
multiRootEnabledOverride: true,
|
||||
workspaceManager: undefined,
|
||||
enableCheckpoints: true,
|
||||
stateManager: makeStateManager(),
|
||||
})
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("returns false when only a single root exists", () => {
|
||||
const wr = makeWr([{ path: "/r1" }])
|
||||
const result = shouldUseMultiRoot({
|
||||
multiRootEnabledOverride: true,
|
||||
workspaceManager: wr,
|
||||
enableCheckpoints: true,
|
||||
stateManager: makeStateManager(),
|
||||
})
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("returns false when there are no roots", () => {
|
||||
const wr = makeWr([])
|
||||
const result = shouldUseMultiRoot({
|
||||
multiRootEnabledOverride: true,
|
||||
workspaceManager: wr,
|
||||
enableCheckpoints: true,
|
||||
stateManager: makeStateManager(),
|
||||
})
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
import type { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
|
||||
import type { MessageStateHandler } from "@core/task/message-state"
|
||||
import type { TaskState } from "@core/task/TaskState"
|
||||
import { isMultiRootEnabled } from "@core/workspace/multi-root-utils"
|
||||
import { WorkspaceRootManager } from "@core/workspace/WorkspaceRootManager"
|
||||
import { createTaskCheckpointManager } from "@integrations/checkpoints"
|
||||
import { MultiRootCheckpointManager } from "@integrations/checkpoints/MultiRootCheckpointManager"
|
||||
import type { ICheckpointManager } from "@integrations/checkpoints/types"
|
||||
import type { DiffViewProvider } from "@integrations/editor/DiffViewProvider"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
|
||||
/**
|
||||
* Simple predicate abstracting our multi-root decision.
|
||||
*/
|
||||
export function shouldUseMultiRoot({
|
||||
workspaceManager,
|
||||
enableCheckpoints,
|
||||
stateManager,
|
||||
multiRootEnabledOverride,
|
||||
}: {
|
||||
workspaceManager?: WorkspaceRootManager
|
||||
enableCheckpoints: boolean
|
||||
stateManager: StateManager
|
||||
multiRootEnabledOverride?: boolean
|
||||
}): boolean {
|
||||
const multiRootEnabled = multiRootEnabledOverride ?? isMultiRootEnabled(stateManager)
|
||||
return Boolean(multiRootEnabled && enableCheckpoints && workspaceManager && workspaceManager.getRoots().length > 1)
|
||||
}
|
||||
|
||||
type BuildArgs = {
|
||||
// common
|
||||
taskId: string
|
||||
messageStateHandler: MessageStateHandler
|
||||
// single-root deps
|
||||
fileContextTracker: FileContextTracker
|
||||
diffViewProvider: DiffViewProvider
|
||||
taskState: TaskState
|
||||
// multi-root deps
|
||||
workspaceManager?: WorkspaceRootManager
|
||||
|
||||
// callbacks for single-root TaskCheckpointManager
|
||||
updateTaskHistory: (historyItem: any) => Promise<any[]>
|
||||
say: (...args: any[]) => Promise<number | undefined>
|
||||
cancelTask: () => Promise<void>
|
||||
postStateToWebview: () => Promise<void>
|
||||
|
||||
// initial state for single-root
|
||||
initialConversationHistoryDeletedRange?: [number, number]
|
||||
initialCheckpointManagerErrorMessage?: string
|
||||
|
||||
stateManager: StateManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Central factory for creating the appropriate checkpoint manager.
|
||||
* - MultiRootCheckpointManager for multi-root tasks
|
||||
* - TaskCheckpointManager for single-root tasks
|
||||
*/
|
||||
export function buildCheckpointManager(args: BuildArgs): ICheckpointManager {
|
||||
const {
|
||||
taskId,
|
||||
messageStateHandler,
|
||||
fileContextTracker,
|
||||
diffViewProvider,
|
||||
taskState,
|
||||
workspaceManager,
|
||||
updateTaskHistory,
|
||||
say,
|
||||
cancelTask,
|
||||
postStateToWebview,
|
||||
initialConversationHistoryDeletedRange,
|
||||
initialCheckpointManagerErrorMessage,
|
||||
stateManager,
|
||||
} = args
|
||||
|
||||
const enableCheckpoints = stateManager.getGlobalSettingsKey("enableCheckpointsSetting")
|
||||
|
||||
if (shouldUseMultiRoot({ workspaceManager, enableCheckpoints, stateManager })) {
|
||||
// Multi-root manager (init should be kicked off externally, non-blocking)
|
||||
return new MultiRootCheckpointManager(workspaceManager!, taskId, enableCheckpoints, messageStateHandler)
|
||||
}
|
||||
|
||||
// Single-root manager
|
||||
return createTaskCheckpointManager(
|
||||
{ taskId },
|
||||
{ enableCheckpoints },
|
||||
{
|
||||
diffViewProvider,
|
||||
messageStateHandler,
|
||||
fileContextTracker,
|
||||
taskState,
|
||||
workspaceManager,
|
||||
},
|
||||
{
|
||||
updateTaskHistory,
|
||||
say,
|
||||
cancelTask,
|
||||
postStateToWebview,
|
||||
},
|
||||
{
|
||||
conversationHistoryDeletedRange: initialConversationHistoryDeletedRange,
|
||||
checkpointManagerErrorMessage: initialCheckpointManagerErrorMessage,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,931 +0,0 @@
|
||||
import { ContextManager } from "@core/context/context-management/ContextManager"
|
||||
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
|
||||
import { sendRelinquishControlEvent } from "@core/controller/ui/subscribeToRelinquishControl"
|
||||
import { ensureTaskDirectoryExists } from "@core/storage/disk"
|
||||
import { WorkspaceRootManager } from "@core/workspace/WorkspaceRootManager"
|
||||
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
|
||||
import { DiffViewProvider } from "@integrations/editor/DiffViewProvider"
|
||||
import { findLast, findLastIndex } from "@shared/array"
|
||||
import { combineApiRequests } from "@shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import { ClineApiReqInfo, ClineMessage, ClineSay } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { ClineCheckpointRestore } from "@shared/WebviewMessage"
|
||||
import pTimeout from "p-timeout"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { MessageStateHandler } from "../../core/task/message-state"
|
||||
import { TaskState } from "../../core/task/TaskState"
|
||||
import { ICheckpointManager } from "./types"
|
||||
|
||||
// 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 taskState: TaskState
|
||||
readonly workspaceManager?: WorkspaceRootManager
|
||||
}
|
||||
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 implements ICheckpointManager {
|
||||
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) {
|
||||
Logger.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) => {
|
||||
Logger.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) {
|
||||
Logger.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 {
|
||||
Logger.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"
|
||||
Logger.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) {
|
||||
Logger.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."
|
||||
Logger.error(`[TaskCheckpointManager] ${errorMessage} for task ${this.task.taskId}`)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: errorMessage,
|
||||
})
|
||||
didWorkspaceRestoreFail = true
|
||||
break
|
||||
}
|
||||
|
||||
if (!this.state.checkpointTracker && !this.state.checkpointManagerErrorMessage) {
|
||||
try {
|
||||
const workspacePath = await this.getWorkspacePath()
|
||||
this.state.checkpointTracker = await CheckpointTracker.create(
|
||||
this.task.taskId,
|
||||
this.config.enableCheckpoints,
|
||||
workspacePath,
|
||||
)
|
||||
this.services.messageStateHandler.setCheckpointTracker(this.state.checkpointTracker)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
Logger.error(
|
||||
`[TaskCheckpointManager] Failed to initialize checkpoint tracker for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
this.state.checkpointManagerErrorMessage = errorMessage
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: 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"
|
||||
Logger.error(
|
||||
`[TaskCheckpointManager] Failed to restore checkpoint for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "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"
|
||||
Logger.error(
|
||||
`[TaskCheckpointManager] Failed to restore offset checkpoint for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Failed to restore offset checkpoint: " + errorMessage,
|
||||
})
|
||||
didWorkspaceRestoreFail = true
|
||||
}
|
||||
} else if (!offset && lastMessageWithHash.lastCheckpointHash && this.state.checkpointTracker) {
|
||||
// Fallback: restore to most recent checkpoint when target message has no checkpoint hash
|
||||
Logger.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"
|
||||
Logger.error(
|
||||
`[TaskCheckpointManager] Failed to restore fallback checkpoint for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Failed to restore checkpoint: " + errorMessage,
|
||||
})
|
||||
didWorkspaceRestoreFail = true
|
||||
}
|
||||
} else {
|
||||
const errorMessage = "Failed to restore checkpoint: No valid checkpoint hash found"
|
||||
Logger.error(`[TaskCheckpointManager] ${errorMessage} for task ${this.task.taskId}`)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: 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"
|
||||
Logger.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."
|
||||
Logger.error(`[TaskCheckpointManager] ${errorMessage} for task ${this.task.taskId}`)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: errorMessage,
|
||||
})
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
|
||||
Logger.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) {
|
||||
Logger.error(`[TaskCheckpointManager] Message not found for timestamp ${messageTs} in task ${this.task.taskId}`)
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
const hash = message.lastCheckpointHash
|
||||
if (!hash) {
|
||||
Logger.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 {
|
||||
const workspacePath = await this.getWorkspacePath()
|
||||
this.state.checkpointTracker = await CheckpointTracker.create(
|
||||
this.task.taskId,
|
||||
this.config.enableCheckpoints,
|
||||
workspacePath,
|
||||
)
|
||||
this.services.messageStateHandler.setCheckpointTracker(this.state.checkpointTracker)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
Logger.error(
|
||||
`[TaskCheckpointManager] Failed to initialize checkpoint tracker for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
this.state.checkpointManagerErrorMessage = errorMessage
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: errorMessage,
|
||||
})
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.state.checkpointTracker) {
|
||||
Logger.error(`[TaskCheckpointManager] Checkpoint tracker not available for task ${this.task.taskId}`)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "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"
|
||||
Logger.error(`[TaskCheckpointManager] ${errorMessage} for task ${this.task.taskId}`)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: errorMessage,
|
||||
})
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
|
||||
// Get changed files between current state and commit
|
||||
changedFiles = await this.state.checkpointTracker.getDiffSet(previousCheckpointHash, hash)
|
||||
if (!changedFiles?.length) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "No changes found",
|
||||
})
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Get changed files between current state and commit
|
||||
changedFiles = await this.state.checkpointTracker.getDiffSet(hash)
|
||||
if (!changedFiles?.length) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "No changes found",
|
||||
})
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Open multi-diff editor
|
||||
const title = seeNewChangesSinceLastTaskCompletion ? "New changes" : "Changes since snapshot"
|
||||
const diffs = changedFiles.map((file) => ({
|
||||
filePath: file.absolutePath,
|
||||
leftContent: file.before,
|
||||
rightContent: file.after,
|
||||
}))
|
||||
await HostProvider.diff.openMultiFileDiff({ title, diffs })
|
||||
|
||||
relinquishButton()
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
Logger.error(`[TaskCheckpointManager] Failed to present multifile diff for task ${this.task.taskId}:`, errorMessage)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "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) {
|
||||
Logger.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"
|
||||
Logger.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) {
|
||||
Logger.error(`[TaskCheckpointManager] Completion message not found for task ${this.task.taskId}`)
|
||||
return false
|
||||
}
|
||||
const hash = message.lastCheckpointHash
|
||||
if (!hash) {
|
||||
Logger.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 {
|
||||
const workspacePath = await this.getWorkspacePath()
|
||||
this.state.checkpointTracker = await CheckpointTracker.create(
|
||||
this.task.taskId,
|
||||
this.config.enableCheckpoints,
|
||||
workspacePath,
|
||||
)
|
||||
this.services.messageStateHandler.setCheckpointTracker(this.state.checkpointTracker)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
Logger.error(
|
||||
`[TaskCheckpointManager] Failed to initialize checkpoint tracker for task ${this.task.taskId}:`,
|
||||
errorMessage,
|
||||
)
|
||||
await this.setcheckpointManagerErrorMessage(errorMessage)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.state.checkpointTracker) {
|
||||
Logger.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) {
|
||||
Logger.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"
|
||||
Logger.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
|
||||
this.taskState.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.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":
|
||||
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.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 workspacePath = await this.getWorkspacePath()
|
||||
const tracker = await pTimeout(
|
||||
CheckpointTracker.create(this.task.taskId, this.config.enableCheckpoints, workspacePath),
|
||||
{
|
||||
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"
|
||||
Logger.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) {
|
||||
Logger.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 workspace path from WorkspaceRootManager when available, otherwise falls back to CheckpointUtils
|
||||
* @returns Promise<string> The workspace path to use for checkpoint operations
|
||||
*/
|
||||
private async getWorkspacePath(): Promise<string> {
|
||||
// Try to use the centralized WorkspaceRootManager first
|
||||
if (this.services.workspaceManager) {
|
||||
try {
|
||||
const primaryRoot = this.services.workspaceManager.getPrimaryRoot()
|
||||
if (primaryRoot) {
|
||||
return primaryRoot.path
|
||||
}
|
||||
Logger.warn(`[TaskCheckpointManager] WorkspaceRootManager returned no primary root for task ${this.task.taskId}`)
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
`[TaskCheckpointManager] Failed to get workspace path from WorkspaceRootManager for task ${this.task.taskId}:`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the legacy CheckpointUtils implementation
|
||||
const { getWorkingDirectory: getWorkingDirectoryImpl } = await import("./CheckpointUtils")
|
||||
return getWorkingDirectoryImpl()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { ICheckpointManager } from "@integrations/checkpoints/types"
|
||||
import pTimeout from "p-timeout"
|
||||
|
||||
/**
|
||||
* Ensures a checkpoint manager is initialized, handling both single-root and multi-root implementations.
|
||||
* - TaskCheckpointManager exposes `checkpointTrackerCheckAndInit()`
|
||||
* - MultiRootCheckpointManager exposes `initialize()`
|
||||
*/
|
||||
export async function ensureCheckpointInitialized({
|
||||
checkpointManager,
|
||||
timeoutMs = 15_000,
|
||||
timeoutMessage = "Checkpoints taking too long to initialize. Consider re-opening Cline in a project that uses git, or disabling checkpoints.",
|
||||
}: {
|
||||
checkpointManager: ICheckpointManager | undefined
|
||||
timeoutMs?: number
|
||||
timeoutMessage?: string
|
||||
}): Promise<void> {
|
||||
if (!checkpointManager) {
|
||||
return
|
||||
}
|
||||
// TaskCheckpointManager path
|
||||
const maybeInit = checkpointManager.checkpointTrackerCheckAndInit
|
||||
if (typeof maybeInit === "function") {
|
||||
await pTimeout(maybeInit.call(checkpointManager), {
|
||||
milliseconds: timeoutMs,
|
||||
message: timeoutMessage,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// MultiRootCheckpointManager path
|
||||
const maybeInitialize = checkpointManager.initialize
|
||||
if (typeof maybeInitialize === "function") {
|
||||
await pTimeout(maybeInitialize.call(checkpointManager), {
|
||||
milliseconds: timeoutMs,
|
||||
message: timeoutMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Common interface for checkpoint managers
|
||||
* Allows single-root and multi-root managers to be used interchangeably
|
||||
*/
|
||||
export interface ICheckpointManager {
|
||||
saveCheckpoint(isAttemptCompletionMessage?: boolean, completionMessageTs?: number): Promise<void>
|
||||
|
||||
restoreCheckpoint(messageTs: number, restoreType: any, offset?: number): Promise<any>
|
||||
|
||||
doesLatestTaskCompletionHaveNewChanges(): Promise<boolean>
|
||||
|
||||
commit(): Promise<string | undefined>
|
||||
|
||||
presentMultifileDiff?(messageTs: number, seeNewChangesSinceLastTaskCompletion: boolean): Promise<void>
|
||||
|
||||
// Optional method for multi-root specific initialization
|
||||
initialize?(): Promise<void>
|
||||
|
||||
// Optional method for checking and initializing checkpoint tracker
|
||||
checkpointTrackerCheckAndInit?(): Promise<any>
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
type InitMessage = {
|
||||
type: "system"
|
||||
subtype: "init"
|
||||
session_id: string
|
||||
tools: string[]
|
||||
mcp_servers: string[]
|
||||
apiKeySource: "none" | "/login managed key" | string
|
||||
}
|
||||
|
||||
type AssistantMessage = {
|
||||
type: "assistant"
|
||||
message: Anthropic.Messages.Message & {
|
||||
// Newer Claude Code CLI versions may include an error field on the message
|
||||
error?: string
|
||||
}
|
||||
session_id: string
|
||||
}
|
||||
|
||||
type ErrorMessage = {
|
||||
type: "error"
|
||||
}
|
||||
|
||||
// Older CLI versions emit rate_limit_event as a system subtype:
|
||||
// { type: "system", subtype: "rate_limit_event", message?: string, retryAfterSeconds?: number }
|
||||
type LegacyRateLimitEvent = {
|
||||
type: "system"
|
||||
subtype: "rate_limit_event"
|
||||
message?: string
|
||||
retryAfterSeconds?: number
|
||||
}
|
||||
|
||||
// Newer Claude Code CLI versions (2.1+) emit rate_limit_event as a top-level type.
|
||||
type RateLimitEvent = {
|
||||
type: "rate_limit_event"
|
||||
rate_limit_info?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// User messages can appear in the stream when Claude Code executes tools
|
||||
// and returns tool results. These should be ignored by Cline since we manage
|
||||
// our own tool execution.
|
||||
type UserMessage = {
|
||||
type: "user"
|
||||
message: {
|
||||
role: "user"
|
||||
content: unknown[]
|
||||
}
|
||||
session_id: string
|
||||
}
|
||||
|
||||
type ResultMessage = {
|
||||
type: "result"
|
||||
subtype: "success" | "error" | "error_max_turns"
|
||||
total_cost_usd: number
|
||||
is_error: boolean
|
||||
duration_ms: number
|
||||
duration_api_ms: number
|
||||
num_turns: number
|
||||
result: string
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export type ClaudeCodeMessage =
|
||||
| InitMessage
|
||||
| LegacyRateLimitEvent
|
||||
| AssistantMessage
|
||||
| ErrorMessage
|
||||
| ResultMessage
|
||||
| RateLimitEvent
|
||||
| UserMessage
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
/**
|
||||
* Formats a content block to markdown for display in API request messages.
|
||||
* Used by Task class to format user content for the api_req_started message.
|
||||
*/
|
||||
export function formatContentBlockToMarkdown(block: Anthropic.ContentBlockParam): string {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return block.text
|
||||
case "image":
|
||||
return `[Image]`
|
||||
case "document":
|
||||
return `[Document]`
|
||||
case "tool_use":
|
||||
let input: string
|
||||
if (typeof block.input === "object" && block.input !== null) {
|
||||
input = Object.entries(block.input)
|
||||
.map(([key, value]) => `${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`)
|
||||
.join("\n")
|
||||
} else {
|
||||
input = String(block.input)
|
||||
}
|
||||
return `[Tool Use: ${block.name}]\n${input}`
|
||||
case "tool_result":
|
||||
if (typeof block.content === "string") {
|
||||
return `[Tool${block.is_error ? " (Error)" : ""}]\n${block.content}`
|
||||
} else if (Array.isArray(block.content)) {
|
||||
return `[Tool${block.is_error ? " (Error)" : ""}]\n${block.content
|
||||
.map((contentBlock) => formatContentBlockToMarkdown(contentBlock))
|
||||
.join("\n")}`
|
||||
} else {
|
||||
return `[Tool${block.is_error ? " (Error)" : ""}]`
|
||||
}
|
||||
default:
|
||||
return "[Unexpected content type]"
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Public surface of the SDK-backed model catalog.
|
||||
*
|
||||
* Consumers outside `src/sdk/model-catalog/` import from this barrel only.
|
||||
* Internal helpers (`fingerprint`, `effective-config`, `shape-adapter`) are
|
||||
* deliberately not re-exported; they are implementation details of the two
|
||||
* factory functions. `parseProviderId` is exported as the branded ProviderId
|
||||
* boundary constructor for RPC/serialization edges.
|
||||
*/
|
||||
|
||||
export { createProviderCatalog } from "./catalog"
|
||||
export type {
|
||||
CatalogError,
|
||||
CatalogSource,
|
||||
Disposable,
|
||||
EffectiveProviderConfig,
|
||||
Fingerprint,
|
||||
KnownProviderId,
|
||||
Mode,
|
||||
ModelInfo,
|
||||
ModelSelection,
|
||||
ProviderCatalog,
|
||||
ProviderConfigChange,
|
||||
ProviderConfigChangeListener,
|
||||
ProviderConfigPatch,
|
||||
ProviderConfigReader,
|
||||
ProviderConfigStore,
|
||||
ProviderId,
|
||||
ProviderListing,
|
||||
ProviderModelsEvent,
|
||||
ProviderModelsResult,
|
||||
} from "./contracts"
|
||||
export { isKnownProviderId, parseProviderId } from "./provider-id"
|
||||
export { createProviderConfigStore } from "./store"
|
||||
@@ -1,473 +0,0 @@
|
||||
import axios from "axios"
|
||||
import { type JwtPayload } from "jwt-decode"
|
||||
import { ClineEnv, EnvironmentConfig } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { buildBasicClineHeaders } from "@/services/EnvUtils"
|
||||
import { AuthInvalidTokenError, AuthNetworkError } from "@/services/error/ClineError"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
|
||||
import { fetch, getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { type ClineAccountUserInfo, type ClineAuthInfo } from "../AuthService"
|
||||
import { parseJwtPayload } from "../oca/utils/utils"
|
||||
|
||||
interface ClineAuthApiUser {
|
||||
subject: string | null
|
||||
email: string
|
||||
name: string
|
||||
clineUserId: string | null
|
||||
accounts: string[] | null
|
||||
}
|
||||
|
||||
// Unified API response data shape for token exchange/refresh
|
||||
interface ClineAuthResponseData {
|
||||
/**
|
||||
* Auth token to be used for authenticated requests
|
||||
*/
|
||||
accessToken: string
|
||||
/**
|
||||
* Refresh token to be used for refreshing the access token
|
||||
*/
|
||||
refreshToken?: string
|
||||
/**
|
||||
* Token type
|
||||
* E.g. "Bearer"
|
||||
*/
|
||||
tokenType: string
|
||||
/**
|
||||
* Access token expiration time in ISO 8601 format
|
||||
* E.g. "2025-09-17T04:32:24.842636548Z"
|
||||
*/
|
||||
expiresAt: string
|
||||
/**
|
||||
* User information associated with the token
|
||||
*/
|
||||
userInfo: ClineAuthApiUser
|
||||
}
|
||||
|
||||
type TokenData = JwtPayload & {
|
||||
sid?: string
|
||||
external_id?: string
|
||||
}
|
||||
|
||||
export interface ClineAuthApiTokenExchangeResponse {
|
||||
success: boolean
|
||||
data: ClineAuthResponseData
|
||||
}
|
||||
|
||||
export interface ClineAuthApiTokenRefreshResponse {
|
||||
success: boolean
|
||||
data: ClineAuthResponseData
|
||||
}
|
||||
|
||||
export class ClineAuthProvider {
|
||||
readonly name = "cline"
|
||||
private refreshRetryCount = 0
|
||||
private lastRefreshAttempt = 0
|
||||
private readonly MAX_REFRESH_RETRIES = 3
|
||||
private readonly RETRY_DELAY_MS = 30000 // 30 seconds
|
||||
|
||||
get config(): EnvironmentConfig {
|
||||
return ClineEnv.config()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the access token needs to be refreshed (expired or about to expire).
|
||||
* Since the new flow doesn't support refresh tokens, this will return true if token is expired.
|
||||
* @param _refreshToken - The existing refresh token to check.
|
||||
* @returns {Promise<boolean>} True if the token is expired or about to expire.
|
||||
*/
|
||||
async shouldRefreshIdToken(_refreshToken: string, expiresAt?: number): Promise<boolean> {
|
||||
try {
|
||||
// expiresAt is in seconds
|
||||
const expirationTime = expiresAt || 0
|
||||
const currentTime = Date.now() / 1000
|
||||
const next5Min = currentTime + 5 * 60
|
||||
|
||||
// Check if token is expired or will expire in the next 5 minutes
|
||||
return expirationTime < next5Min // Access token is expired or about to expire
|
||||
} catch (error) {
|
||||
Logger.error("Error checking token expiration:", error)
|
||||
return true // If we can't decode the token, assume it needs refresh
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the time in seconds until token expiry
|
||||
*/
|
||||
timeUntilExpiry(jwt: string): number {
|
||||
const data = this.extractTokenData(jwt)
|
||||
if (!data.exp) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const currentTime = Date.now() / 1000
|
||||
const expirationTime = data.exp
|
||||
|
||||
return expirationTime - currentTime
|
||||
}
|
||||
|
||||
private clearSession(controller: Controller, reason: string, storedAuthData?: ClineAuthInfo) {
|
||||
Logger.error(reason)
|
||||
|
||||
const startedAt = storedAuthData?.startedAt
|
||||
const timeSinceStarted = Date.now() - (startedAt || 0)
|
||||
|
||||
const tokenData = this.extractTokenData(storedAuthData?.idToken)
|
||||
telemetryService.capture({
|
||||
event: "extension_logging_user_out",
|
||||
properties: {
|
||||
reason,
|
||||
time_since_started: timeSinceStarted,
|
||||
session_id: tokenData.sid,
|
||||
user_id: tokenData.external_id,
|
||||
},
|
||||
})
|
||||
|
||||
controller.stateManager.setSecret("cline:clineAccountId", undefined)
|
||||
this.refreshRetryCount = 0
|
||||
this.lastRefreshAttempt = 0
|
||||
return null
|
||||
}
|
||||
|
||||
private logFailedRefreshAttempt(response: Response, storedAuthData?: ClineAuthInfo) {
|
||||
const startedAt = storedAuthData?.startedAt
|
||||
const timeSinceStarted = Date.now() - (startedAt || 0)
|
||||
|
||||
const tokenData = this.extractTokenData(storedAuthData?.idToken)
|
||||
telemetryService.capture({
|
||||
event: "extension_refresh_attempt_failed",
|
||||
properties: {
|
||||
status_code: response.status,
|
||||
request_id: response.headers.get("x-request-id"),
|
||||
session_id: tokenData.sid,
|
||||
user_id: tokenData.external_id,
|
||||
time_since_started: timeSinceStarted,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private extractTokenData(token: string | undefined): Partial<TokenData> {
|
||||
if (!token) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return parseJwtPayload<TokenData>(token) || {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves Cline auth info using the stored access token.
|
||||
* @param controller - The controller instance to access stored secrets.
|
||||
* @returns {Promise<ClineAuthInfo | null>} A promise that resolves with the auth info or null.
|
||||
*/
|
||||
async retrieveClineAuthInfo(controller: Controller): Promise<ClineAuthInfo | null> {
|
||||
try {
|
||||
// Get the stored auth data from secure storage
|
||||
const storedAuthDataString = controller.stateManager.getSecretKey("cline:clineAccountId")
|
||||
|
||||
if (!storedAuthDataString) {
|
||||
Logger.debug("No stored authentication data found")
|
||||
// Reset retry count when there's no stored auth
|
||||
this.refreshRetryCount = 0
|
||||
this.lastRefreshAttempt = 0
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse the stored auth data
|
||||
let storedAuthData: ClineAuthInfo
|
||||
try {
|
||||
storedAuthData = JSON.parse(storedAuthDataString)
|
||||
} catch (e) {
|
||||
Logger.error("Failed to parse stored auth data:", e)
|
||||
return this.clearSession(controller, "Failed to parse stored auth data")
|
||||
}
|
||||
|
||||
if (!storedAuthData.refreshToken || !storedAuthData?.idToken) {
|
||||
return this.clearSession(controller, "No refresh token or ID token found in store", storedAuthData)
|
||||
}
|
||||
|
||||
if (await this.shouldRefreshIdToken(storedAuthData.refreshToken, storedAuthData.expiresAt)) {
|
||||
// If the token hasn't expired yet,
|
||||
// and it failed the first refresh attempt
|
||||
// with something other than invalid token
|
||||
// continue with the request
|
||||
if (this.refreshRetryCount > 0 && this.timeUntilExpiry(storedAuthData.idToken) > 30) {
|
||||
this.refreshRetryCount = 0
|
||||
this.lastRefreshAttempt = 0
|
||||
return storedAuthData
|
||||
}
|
||||
|
||||
// Check if we need to wait before retrying
|
||||
const now = Date.now()
|
||||
const timeSinceLastAttempt = now - this.lastRefreshAttempt
|
||||
if (timeSinceLastAttempt < this.RETRY_DELAY_MS && this.refreshRetryCount > 0) {
|
||||
Logger.debug(
|
||||
`Waiting ${Math.ceil((this.RETRY_DELAY_MS - timeSinceLastAttempt) / 1000)}s before retry attempt ${this.refreshRetryCount + 1}/${this.MAX_REFRESH_RETRIES}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if we've exceeded max retries
|
||||
if (this.refreshRetryCount >= this.MAX_REFRESH_RETRIES) {
|
||||
Logger.error(`Max refresh retries (${this.MAX_REFRESH_RETRIES}) exceeded.`)
|
||||
// Don't clear session - return stored data and let API request fail later
|
||||
return storedAuthData
|
||||
}
|
||||
|
||||
// Try to refresh the token using the refresh token
|
||||
this.refreshRetryCount++
|
||||
this.lastRefreshAttempt = now
|
||||
Logger.debug(
|
||||
`Token expired or expiring soon, attempting refresh (attempt ${this.refreshRetryCount}/${this.MAX_REFRESH_RETRIES}). API Base URL: ${this.config.apiBaseUrl}`,
|
||||
)
|
||||
|
||||
try {
|
||||
const authInfo = await this.refreshToken(storedAuthData.refreshToken, storedAuthData)
|
||||
const newAuthInfoString = JSON.stringify(authInfo)
|
||||
if (newAuthInfoString !== storedAuthDataString) {
|
||||
controller.stateManager.setSecret("clineAccountId", undefined) // cleanup old key
|
||||
controller.stateManager.setSecret("cline:clineAccountId", newAuthInfoString)
|
||||
}
|
||||
// Reset retry count on success
|
||||
this.refreshRetryCount = 0
|
||||
this.lastRefreshAttempt = 0
|
||||
Logger.debug("Token refresh successful")
|
||||
return authInfo || null
|
||||
} catch (refreshError) {
|
||||
Logger.error(
|
||||
`Token refresh failed (attempt ${this.refreshRetryCount}/${this.MAX_REFRESH_RETRIES}):`,
|
||||
refreshError,
|
||||
)
|
||||
|
||||
// If it's an invalid token error, clear immediately and don't retry
|
||||
if (refreshError instanceof AuthInvalidTokenError) {
|
||||
this.clearSession(controller, "Invalid or expired refresh token. Clearing auth state.", storedAuthData)
|
||||
|
||||
throw refreshError
|
||||
}
|
||||
|
||||
// For network errors, return stored data - let the API request fail later
|
||||
// when the user actually tries to use Cline, not at startup
|
||||
return storedAuthData
|
||||
}
|
||||
}
|
||||
|
||||
// Token is still valid and not expired, reset retry count
|
||||
this.refreshRetryCount = 0
|
||||
this.lastRefreshAttempt = 0
|
||||
|
||||
// Is the token valid?
|
||||
if (storedAuthData.idToken && storedAuthData.refreshToken && storedAuthData.userInfo.id) {
|
||||
return storedAuthData
|
||||
}
|
||||
|
||||
// Verify the token structure
|
||||
const tokenParts = storedAuthData.idToken.split(".")
|
||||
if (tokenParts.length !== 3) {
|
||||
throw new Error("Invalid token format")
|
||||
}
|
||||
|
||||
// Decode the token to verify it's a valid JWT
|
||||
const payload = JSON.parse(Buffer.from(tokenParts[1], "base64").toString("utf-8"))
|
||||
if (payload.external_id) {
|
||||
storedAuthData.userInfo.id = payload.external_id
|
||||
}
|
||||
return storedAuthData
|
||||
} catch (error) {
|
||||
Logger.error("Authentication failed with stored credential:", error)
|
||||
// Reset retry count on unexpected errors
|
||||
if (!(error instanceof AuthInvalidTokenError)) {
|
||||
this.refreshRetryCount = 0
|
||||
this.lastRefreshAttempt = 0
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an access token using a refresh token.
|
||||
* @param refreshToken - The refresh token.
|
||||
* @returns {Promise<ClineAuthInfo>} The new access token and user info.
|
||||
*/
|
||||
async refreshToken(refreshToken: string, storedData: ClineAuthInfo): Promise<ClineAuthInfo> {
|
||||
try {
|
||||
const endpoint = new URL(CLINE_API_ENDPOINT.REFRESH_TOKEN, this.config.apiBaseUrl)
|
||||
const response = await fetch(endpoint.toString(), {
|
||||
method: "POST",
|
||||
headers: await this.headers(),
|
||||
body: JSON.stringify({
|
||||
refreshToken: storedData.refreshToken,
|
||||
grantType: "refresh_token",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
this.logFailedRefreshAttempt(response, storedData)
|
||||
|
||||
// 400/401 = Invalid/expired token (permanent failure)
|
||||
if (response.status === 400 || response.status === 401) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const errorMessage = errorData?.error || "Invalid or expired token"
|
||||
throw new AuthInvalidTokenError(errorMessage)
|
||||
}
|
||||
// 5xx, 429, network errors = transient failures
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new AuthNetworkError(`status: ${response.status}`, errorData)
|
||||
}
|
||||
|
||||
const data: ClineAuthApiTokenExchangeResponse = await response.json()
|
||||
|
||||
if (!data.success || !data.data.refreshToken || !data.data.accessToken) {
|
||||
throw new Error("Failed to exchange authorization code for access token")
|
||||
}
|
||||
|
||||
const userInfo = await this.fetchRemoteUserInfo(data.data)
|
||||
|
||||
return {
|
||||
idToken: data.data.accessToken,
|
||||
// data.data.expiresAt example: "2025-09-17T03:43:57Z"; store in seconds
|
||||
expiresAt: new Date(data.data.expiresAt).getTime() / 1000,
|
||||
refreshToken: data.data.refreshToken || refreshToken,
|
||||
userInfo,
|
||||
provider: this.name,
|
||||
startedAt: storedData.startedAt || Date.now(),
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Network errors (ECONNREFUSED, timeout, etc)
|
||||
if (error.name === "TypeError" || error.code === "ECONNREFUSED" || error.code === "ETIMEDOUT") {
|
||||
throw new AuthNetworkError("Network error during token refresh", error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthRequest(callbackUrl: string): Promise<string> {
|
||||
const authUrl = new URL(CLINE_API_ENDPOINT.AUTH, this.config.apiBaseUrl)
|
||||
authUrl.searchParams.set("client_type", "extension")
|
||||
authUrl.searchParams.set("callback_url", callbackUrl)
|
||||
// Ensure the redirect_uri is properly encoded and included
|
||||
authUrl.searchParams.set("redirect_uri", callbackUrl)
|
||||
|
||||
// The server will respond with a 302 redirect to the OAuth provider
|
||||
// We need to follow the redirect and get the final URL
|
||||
let response: Response
|
||||
try {
|
||||
// Set redirect: 'manual' to handle the redirect manually
|
||||
response = await fetch(authUrl.toString(), {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
credentials: "include", // Important for cookies if needed
|
||||
headers: await this.headers(),
|
||||
})
|
||||
|
||||
// If we get a redirect status (3xx), get the Location header
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const redirectUrl = response.headers.get("Location")
|
||||
if (!redirectUrl) {
|
||||
throw new Error("No redirect URL found in the response")
|
||||
}
|
||||
|
||||
return redirectUrl
|
||||
}
|
||||
|
||||
// If we didn't get a redirect, try to parse the response as JSON
|
||||
const responseData = await response.json()
|
||||
if (responseData.redirect_url) {
|
||||
return responseData.redirect_url
|
||||
}
|
||||
|
||||
throw new Error("Unexpected response from auth server")
|
||||
} catch (error) {
|
||||
Logger.error("Error during authentication request:", error)
|
||||
throw new Error(`Authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`)
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(controller: Controller, authorizationCode: string, provider: string): Promise<ClineAuthInfo | null> {
|
||||
try {
|
||||
// Get the callback URL that was used during the initial auth request
|
||||
const callbackUrl = await HostProvider.get().getCallbackUrl("/auth")
|
||||
|
||||
// Exchange the authorization code for tokens
|
||||
const tokenUrl = new URL(CLINE_API_ENDPOINT.TOKEN_EXCHANGE, this.config.apiBaseUrl)
|
||||
|
||||
const response = await fetch(tokenUrl.toString(), {
|
||||
method: "POST",
|
||||
headers: await this.headers(),
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code: authorizationCode,
|
||||
client_type: "extension",
|
||||
redirect_uri: callbackUrl,
|
||||
provider: provider,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error_description || "Failed to exchange authorization code for tokens")
|
||||
}
|
||||
|
||||
const responseJSON = await response.json()
|
||||
const responseType: ClineAuthApiTokenExchangeResponse = responseJSON
|
||||
const tokenData = responseType.data
|
||||
|
||||
if (!tokenData.accessToken || !tokenData.refreshToken || !tokenData.userInfo) {
|
||||
throw new Error("Invalid token response from server")
|
||||
}
|
||||
|
||||
const userInfo = await this.fetchRemoteUserInfo(tokenData)
|
||||
|
||||
// Store the tokens and user info
|
||||
const clineAuthInfo = {
|
||||
idToken: tokenData.accessToken,
|
||||
refreshToken: tokenData.refreshToken,
|
||||
userInfo,
|
||||
expiresAt: new Date(tokenData.expiresAt).getTime() / 1000, // "2025-09-17T04:32:24.842636548Z"
|
||||
provider: this.name,
|
||||
startedAt: Date.now(),
|
||||
}
|
||||
|
||||
controller.stateManager.setSecret("cline:clineAccountId", JSON.stringify(clineAuthInfo))
|
||||
|
||||
return clineAuthInfo
|
||||
} catch (error) {
|
||||
Logger.error("Error handling auth callback:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchRemoteUserInfo(tokenData: ClineAuthApiTokenExchangeResponse["data"]): Promise<ClineAccountUserInfo> {
|
||||
try {
|
||||
const userResponse = await axios.get(`${ClineEnv.config().apiBaseUrl}/api/v1/users/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer workos:${tokenData.accessToken}`,
|
||||
...(await this.headers()),
|
||||
},
|
||||
...getAxiosSettings(),
|
||||
})
|
||||
|
||||
return userResponse.data.data
|
||||
} catch (error) {
|
||||
Logger.error("Error fetching user info:", error)
|
||||
|
||||
// If fetching user info fail for whatever reason, fallback to the token data and refetch on token expiry (10 minutes)
|
||||
return {
|
||||
id: tokenData.userInfo.clineUserId || "",
|
||||
email: tokenData.userInfo.email || "",
|
||||
displayName: tokenData.userInfo.name || "",
|
||||
createdAt: new Date().toISOString(),
|
||||
organizations: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async headers() {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(await buildBasicClineHeaders()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Apply Patch constants
|
||||
*/
|
||||
export const PATCH_MARKERS = {
|
||||
BEGIN: "*** Begin Patch",
|
||||
END: "*** End Patch",
|
||||
ADD: "*** Add File: ",
|
||||
UPDATE: "*** Update File: ",
|
||||
DELETE: "*** Delete File: ",
|
||||
MOVE: "*** Move to: ",
|
||||
SECTION: "@@",
|
||||
END_FILE: "*** End of File",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Expected bash wrappers for apply patch content
|
||||
*/
|
||||
export const BASH_WRAPPERS = ["%%bash", "apply_patch", "EOF", "```"] as const
|
||||
|
||||
/**
|
||||
* Domains of patch actions
|
||||
*/
|
||||
export enum PatchActionType {
|
||||
ADD = "add",
|
||||
DELETE = "delete", // TODO: Implement delete action in diff editor.
|
||||
UPDATE = "update",
|
||||
}
|
||||
|
||||
export interface PatchChunk {
|
||||
origIndex: number // line index in original file where change starts
|
||||
delLines: string[] // Lines to delete (without the "-" prefix)
|
||||
insLines: string[] // Lines to insert (without the "+" prefix)
|
||||
}
|
||||
|
||||
export interface PatchAction {
|
||||
type: PatchActionType
|
||||
newFile?: string
|
||||
chunks: PatchChunk[]
|
||||
movePath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning information for skipped/problematic chunks
|
||||
*/
|
||||
export interface PatchWarning {
|
||||
path: string
|
||||
chunkIndex?: number
|
||||
message: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Patch structure
|
||||
*/
|
||||
export interface Patch {
|
||||
actions: Record<string, PatchAction>
|
||||
warnings?: PatchWarning[]
|
||||
}
|
||||
|
||||
export class DiffError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "DiffError"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Constants for identifying user-generated content and system-generated markers
|
||||
* in task conversations. These are used to parse and filter content appropriately.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tags that wrap user-generated content in the conversation.
|
||||
* Used to identify content that comes from the user vs system-generated content.
|
||||
*/
|
||||
export const USER_CONTENT_TAGS = ["<task>", "<feedback>", "<answer>", "<user_message>"] as const
|
||||
|
||||
/**
|
||||
* Markers for system-generated content that should be excluded when parsing user input.
|
||||
* These indicate content added by the system rather than the user.
|
||||
*/
|
||||
export const SYSTEM_CONTENT_MARKERS = [
|
||||
"[TASK RESUMPTION]",
|
||||
"<hook_context",
|
||||
"[Response interrupted",
|
||||
"Task was interrupted",
|
||||
] as const
|
||||
@@ -1,18 +0,0 @@
|
||||
export enum ModelFamily {
|
||||
CLAUDE = "claude",
|
||||
GPT = "gpt",
|
||||
GPT_5 = "gpt-5",
|
||||
NATIVE_GPT_5 = "gpt-5-native", // Uses native tool calling
|
||||
NATIVE_GPT_5_1 = "gpt-5-1-native", // Uses native tool calling
|
||||
GEMINI = "gemini",
|
||||
GEMINI_3 = "gemini3", // Uses native tool calling
|
||||
QWEN = "qwen",
|
||||
GLM = "glm",
|
||||
HERMES = "hermes",
|
||||
DEVSTRAL = "devstral",
|
||||
NEXT_GEN = "next-gen",
|
||||
TRINITY = "trinity",
|
||||
GENERIC = "generic",
|
||||
XS = "xs",
|
||||
NATIVE_NEXT_GEN = "native-next-gen", // Uses native tool calling
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import { nanoid } from "nanoid"
|
||||
|
||||
export interface ToolCallRecord {
|
||||
name: string
|
||||
success?: boolean
|
||||
startTime: number
|
||||
lastUpdateTime: number
|
||||
}
|
||||
|
||||
export interface ResourceUsage {
|
||||
// Memory (in bytes)
|
||||
heapUsed: number
|
||||
heapTotal: number
|
||||
external: number
|
||||
rss: number // Resident Set Size - total memory allocated for the process
|
||||
// CPU time (in milliseconds)
|
||||
userCpuMs: number
|
||||
systemCpuMs: number
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
sessionId: string
|
||||
// Tool calls
|
||||
totalToolCalls: number
|
||||
successfulToolCalls: number
|
||||
failedToolCalls: number
|
||||
// Timing
|
||||
sessionStartTime: number
|
||||
apiTimeMs: number
|
||||
toolTimeMs: number
|
||||
// Resources
|
||||
resources: ResourceUsage
|
||||
peakMemoryBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Session singleton for tracking current session statistics.
|
||||
* Used by CLI to display interaction summary.
|
||||
*/
|
||||
export class Session {
|
||||
private static instance: Session | null = null
|
||||
|
||||
private sessionId: string
|
||||
private sessionStartTime: number
|
||||
private toolCalls: ToolCallRecord[] = []
|
||||
private apiTimeMs: number = 0
|
||||
private toolTimeMs: number = 0
|
||||
|
||||
// Track in-flight operations
|
||||
private currentApiCallStart: number | null = null
|
||||
private inFlightToolCalls: Map<string, ToolCallRecord> = new Map()
|
||||
|
||||
// Resource tracking
|
||||
private initialCpuUsage: NodeJS.CpuUsage
|
||||
private peakMemoryBytes: number = 0
|
||||
|
||||
private constructor() {
|
||||
this.sessionId = nanoid(10)
|
||||
this.sessionStartTime = Date.now()
|
||||
this.initialCpuUsage = process.cpuUsage()
|
||||
this.updatePeakMemory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update peak memory if current usage is higher.
|
||||
*/
|
||||
private updatePeakMemory(): void {
|
||||
const memUsage = process.memoryUsage()
|
||||
if (memUsage.rss > this.peakMemoryBytes) {
|
||||
this.peakMemoryBytes = memUsage.rss
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current resource usage for this process.
|
||||
*/
|
||||
getResourceUsage(): ResourceUsage {
|
||||
this.updatePeakMemory()
|
||||
const memUsage = process.memoryUsage()
|
||||
const cpuUsage = process.cpuUsage(this.initialCpuUsage)
|
||||
|
||||
return {
|
||||
heapUsed: memUsage.heapUsed,
|
||||
heapTotal: memUsage.heapTotal,
|
||||
external: memUsage.external,
|
||||
rss: memUsage.rss,
|
||||
// cpuUsage returns microseconds, convert to milliseconds
|
||||
userCpuMs: cpuUsage.user / 1000,
|
||||
systemCpuMs: cpuUsage.system / 1000,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance, creating it if necessary.
|
||||
*/
|
||||
static get(): Session {
|
||||
if (!Session.instance) {
|
||||
Session.instance = new Session()
|
||||
}
|
||||
return Session.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the session (creates a new session with fresh ID and stats).
|
||||
*/
|
||||
static reset(): Session {
|
||||
Session.instance = new Session()
|
||||
return Session.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session ID.
|
||||
*/
|
||||
getSessionId(): string {
|
||||
return this.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the start of an API call.
|
||||
*/
|
||||
startApiCall(): void {
|
||||
this.currentApiCallStart = Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the end of an API call.
|
||||
*/
|
||||
endApiCall(): void {
|
||||
if (this.currentApiCallStart !== null) {
|
||||
this.apiTimeMs += Date.now() - this.currentApiCallStart
|
||||
this.currentApiCallStart = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a tool call - starts tracking if new, updates lastUpdateTime if existing.
|
||||
* @param callId - Unique identifier for this tool call
|
||||
* @param toolName - The name of the tool (required when starting a new call)
|
||||
* @param success - Optional success status (only set when finalizing)
|
||||
*/
|
||||
updateToolCall(callId: string, toolName: string, success?: boolean): void {
|
||||
const now = Date.now()
|
||||
const existing = this.inFlightToolCalls.get(callId)
|
||||
|
||||
if (existing) {
|
||||
// Update existing tool call
|
||||
existing.lastUpdateTime = now
|
||||
if (success !== undefined) {
|
||||
existing.success = success
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Start tracking new tool call
|
||||
this.inFlightToolCalls.set(callId, {
|
||||
name: toolName,
|
||||
startTime: now,
|
||||
lastUpdateTime: now,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add API time directly (useful when timing is tracked elsewhere).
|
||||
*/
|
||||
addApiTime(ms: number): void {
|
||||
this.apiTimeMs += ms
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a request - moves all in-flight tool calls to completed and calculates durations.
|
||||
* Call this when an API request completes to close out all pending tool calls.
|
||||
*/
|
||||
finalizeRequest(): void {
|
||||
for (const [callId, record] of this.inFlightToolCalls) {
|
||||
const duration = record.lastUpdateTime - record.startTime
|
||||
this.toolTimeMs += duration
|
||||
this.toolCalls.push({
|
||||
name: record.name,
|
||||
success: record.success,
|
||||
startTime: record.startTime,
|
||||
lastUpdateTime: record.lastUpdateTime,
|
||||
})
|
||||
this.inFlightToolCalls.delete(callId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session statistics.
|
||||
* Includes in-flight tool calls in the totals using their lastUpdateTime as end time.
|
||||
*/
|
||||
getStats(): SessionStats {
|
||||
this.finalizeRequest()
|
||||
|
||||
// Combine completed and in-flight for totals
|
||||
const allToolCalls = this.toolCalls
|
||||
const successful = allToolCalls.filter((t) => t.success === true).length
|
||||
const failed = allToolCalls.filter((t) => t.success === false).length
|
||||
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
totalToolCalls: allToolCalls.length,
|
||||
successfulToolCalls: successful,
|
||||
failedToolCalls: failed,
|
||||
sessionStartTime: this.sessionStartTime,
|
||||
apiTimeMs: this.apiTimeMs,
|
||||
toolTimeMs: this.toolTimeMs,
|
||||
resources: this.getResourceUsage(),
|
||||
peakMemoryBytes: this.peakMemoryBytes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the wall time (time since session started) in milliseconds.
|
||||
*/
|
||||
getWallTimeMs(): number {
|
||||
return Date.now() - this.sessionStartTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session start time as a Date object.
|
||||
*/
|
||||
getStartTime(): Date {
|
||||
return new Date(this.sessionStartTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current time (session end time) as a Date object.
|
||||
*/
|
||||
getEndTime(): Date {
|
||||
return new Date()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp for display (e.g., "2:34:56 PM").
|
||||
*/
|
||||
formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the agent active time (API time + tool time) in milliseconds.
|
||||
* Includes in-flight tool calls.
|
||||
*/
|
||||
getAgentActiveTimeMs(): number {
|
||||
const stats = this.getStats()
|
||||
return this.apiTimeMs + stats.toolTimeMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the success rate as a percentage (0-100).
|
||||
* Includes in-flight tool calls.
|
||||
*/
|
||||
getSuccessRate(): number {
|
||||
const stats = this.getStats()
|
||||
if (stats.totalToolCalls === 0) {
|
||||
return 0
|
||||
}
|
||||
return (stats.successfulToolCalls / stats.totalToolCalls) * 100
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { canonicalize, preserveEscaping } from "./string"
|
||||
|
||||
describe("String: canonicalize", () => {
|
||||
describe("Unicode normalization", () => {
|
||||
it("should normalize composed and decomposed unicode", () => {
|
||||
// é as single character vs e + combining acute
|
||||
const composed = "café"
|
||||
const decomposed = "cafe\u0301"
|
||||
canonicalize(composed).should.equal(canonicalize(decomposed))
|
||||
})
|
||||
})
|
||||
|
||||
describe("Hyphen and dash normalization", () => {
|
||||
it("should normalize regular hyphen to ASCII hyphen", () => {
|
||||
canonicalize("hello-world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize HYPHEN (U+2010) to ASCII hyphen", () => {
|
||||
canonicalize("hello\u2010world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize NO-BREAK HYPHEN (U+2011) to ASCII hyphen", () => {
|
||||
canonicalize("hello\u2011world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize FIGURE DASH (U+2012) to ASCII hyphen", () => {
|
||||
canonicalize("hello\u2012world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize EN DASH (U+2013) to ASCII hyphen", () => {
|
||||
canonicalize("hello\u2013world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize EM DASH (U+2014) to ASCII hyphen", () => {
|
||||
canonicalize("hello\u2014world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize MINUS SIGN (U+2212) to ASCII hyphen", () => {
|
||||
canonicalize("hello\u2212world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize multiple different dashes", () => {
|
||||
canonicalize("a\u2013b\u2014c\u2212d").should.equal("a-b-c-d")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Double quote normalization", () => {
|
||||
it("should keep ASCII double quotes unchanged", () => {
|
||||
canonicalize('say "hello"').should.equal('say "hello"')
|
||||
})
|
||||
|
||||
it("should normalize LEFT DOUBLE QUOTATION MARK (U+201C)", () => {
|
||||
canonicalize("say \u201Chello\u201D").should.equal('say "hello"')
|
||||
})
|
||||
|
||||
it("should normalize RIGHT DOUBLE QUOTATION MARK (U+201D)", () => {
|
||||
canonicalize("say \u201Chello\u201D").should.equal('say "hello"')
|
||||
})
|
||||
|
||||
it("should normalize DOUBLE LOW-9 QUOTATION MARK (U+201E)", () => {
|
||||
canonicalize("say \u201Ehello\u201D").should.equal('say "hello"')
|
||||
})
|
||||
|
||||
it("should normalize LEFT-POINTING DOUBLE ANGLE QUOTATION MARK (U+00AB)", () => {
|
||||
canonicalize("say \u00ABhello\u00BB").should.equal('say "hello"')
|
||||
})
|
||||
|
||||
it("should normalize RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK (U+00BB)", () => {
|
||||
canonicalize("say \u00ABhello\u00BB").should.equal('say "hello"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("Single quote normalization", () => {
|
||||
it("should keep ASCII apostrophe unchanged", () => {
|
||||
canonicalize("it's here").should.equal("it's here")
|
||||
})
|
||||
|
||||
it("should normalize LEFT SINGLE QUOTATION MARK (U+2018)", () => {
|
||||
canonicalize("it\u2018s here").should.equal("it's here")
|
||||
})
|
||||
|
||||
it("should normalize RIGHT SINGLE QUOTATION MARK (U+2019)", () => {
|
||||
canonicalize("it\u2019s here").should.equal("it's here")
|
||||
})
|
||||
|
||||
it("should normalize SINGLE HIGH-REVERSED-9 QUOTATION MARK (U+201B)", () => {
|
||||
canonicalize("it\u201Bs here").should.equal("it's here")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Space normalization", () => {
|
||||
it("should keep regular spaces unchanged", () => {
|
||||
canonicalize("hello world").should.equal("hello world")
|
||||
})
|
||||
|
||||
it("should normalize NO-BREAK SPACE (U+00A0) to regular space", () => {
|
||||
canonicalize("hello\u00A0world").should.equal("hello world")
|
||||
})
|
||||
|
||||
it("should normalize NARROW NO-BREAK SPACE (U+202F) to regular space", () => {
|
||||
canonicalize("hello\u202Fworld").should.equal("hello world")
|
||||
})
|
||||
|
||||
it("should normalize multiple non-breaking spaces", () => {
|
||||
canonicalize("a\u00A0b\u202Fc").should.equal("a b c")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Escaped quote normalization", () => {
|
||||
it("should normalize escaped backticks to unescaped", () => {
|
||||
canonicalize("\\`code\\`").should.equal("`code`")
|
||||
})
|
||||
|
||||
it("should normalize escaped single quotes to unescaped", () => {
|
||||
canonicalize("\\'hello\\'").should.equal("'hello'")
|
||||
})
|
||||
|
||||
it("should normalize escaped double quotes to unescaped", () => {
|
||||
canonicalize('\\"hello\\"').should.equal('"hello"')
|
||||
})
|
||||
|
||||
it("should handle multiple escaped quotes", () => {
|
||||
canonicalize("\\`code\\` with \\'single\\' and \\\"double\\\"").should.equal("`code` with 'single' and \"double\"")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Combined normalization", () => {
|
||||
it("should normalize unicode punctuation and escaped quotes together", () => {
|
||||
const input = "it\u2019s a \u201Ctest\u201D with\\`backticks\\` and\u2013dashes"
|
||||
const expected = 'it\'s a "test" with`backticks` and-dashes'
|
||||
canonicalize(input).should.equal(expected)
|
||||
})
|
||||
|
||||
it("should handle complex real-world code snippet", () => {
|
||||
const input = "const msg\u00A0=\u00A0\u201CHello\u2014world\u201D;"
|
||||
const expected = 'const msg = "Hello-world";'
|
||||
canonicalize(input).should.equal(expected)
|
||||
})
|
||||
|
||||
it("should be idempotent", () => {
|
||||
const input = "test\u2019s \u201Cvalue\u201D\u2013here"
|
||||
const once = canonicalize(input)
|
||||
const twice = canonicalize(once)
|
||||
once.should.equal(twice)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty strings", () => {
|
||||
canonicalize("").should.equal("")
|
||||
})
|
||||
|
||||
it("should handle strings with no special characters", () => {
|
||||
canonicalize("hello world").should.equal("hello world")
|
||||
})
|
||||
|
||||
it("should handle strings with only special characters", () => {
|
||||
canonicalize("\u2013\u201C\u00A0").should.equal('-" ')
|
||||
})
|
||||
|
||||
it("should handle multiline strings", () => {
|
||||
const input = "line1\u2013test\nline2\u201Cquoted\u201D"
|
||||
const expected = 'line1-test\nline2"quoted"'
|
||||
canonicalize(input).should.equal(expected)
|
||||
})
|
||||
|
||||
it("should handle strings with emoji", () => {
|
||||
const input = "hello \u{1F44B} world"
|
||||
const expected = "hello \u{1F44B} world"
|
||||
canonicalize(input).should.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("preserveEscaping", () => {
|
||||
describe("Backtick escaping", () => {
|
||||
it("should preserve escaped backticks from original text", () => {
|
||||
const original = "1. \\`file_path\\` MUST be an absolute path"
|
||||
const newText = "1. `absolute_path` MUST be an absolute path"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("1. \\`absolute_path\\` MUST be an absolute path")
|
||||
})
|
||||
|
||||
it("should not add escaping if original has no escaped backticks", () => {
|
||||
const original = "1. file_path MUST be an absolute path"
|
||||
const newText = "1. `absolute_path` MUST be an absolute path"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("1. `absolute_path` MUST be an absolute path")
|
||||
})
|
||||
|
||||
it("should not double-escape already escaped backticks", () => {
|
||||
const original = "1. \\`file_path\\` MUST be an absolute path"
|
||||
const newText = "1. \\`absolute_path\\` MUST be an absolute path"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("1. \\`absolute_path\\` MUST be an absolute path")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Single quote escaping", () => {
|
||||
it("should preserve escaped single quotes from original text", () => {
|
||||
const original = "const str = \\'hello\\'"
|
||||
const newText = "const str = 'world'"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("const str = \\'world\\'")
|
||||
})
|
||||
|
||||
it("should not add escaping if original has no escaped quotes", () => {
|
||||
const original = "const str = hello"
|
||||
const newText = "const str = 'world'"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("const str = 'world'")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Double quote escaping", () => {
|
||||
it("should preserve escaped double quotes from original text", () => {
|
||||
const original = 'const str = \\"hello\\"'
|
||||
const newText = 'const str = "world"'
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal('const str = \\"world\\"')
|
||||
})
|
||||
|
||||
it("should not add escaping if original has no escaped quotes", () => {
|
||||
const original = "const str = hello"
|
||||
const newText = 'const str = "world"'
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal('const str = "world"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("Multiple escape types", () => {
|
||||
it("should preserve multiple escape types from original", () => {
|
||||
const original = "\`code\` with \'single\' and \"double\""
|
||||
const newText = "`test` with 'foo' and \"bar\""
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("\`test\` with \'foo\' and \"bar\"")
|
||||
})
|
||||
|
||||
it("should handle text with only some escape types", () => {
|
||||
const original = "\\`code\\` with 'single'"
|
||||
const newText = "`test` with 'foo' and \"bar\""
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("\\`test\\` with 'foo' and \"bar\"")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Real-world patch scenario", () => {
|
||||
it("should handle the documented use case from markdown files", () => {
|
||||
const original =
|
||||
"Expectation for required parameters:\n1. \\`file_path\\` MUST be an absolute path; otherwise an error will be thrown."
|
||||
const newText =
|
||||
"Expectation for required parameters:\n1. `absolute_path` MUST be an absolute path; otherwise an error will be thrown."
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal(
|
||||
"Expectation for required parameters:\n1. \\`absolute_path\\` MUST be an absolute path; otherwise an error will be thrown.",
|
||||
)
|
||||
})
|
||||
|
||||
it("should work with multiline patches", () => {
|
||||
const original = "line 1 with \\`code\\`\nline 2 with \\`more\\`"
|
||||
const newText = "line 1 with `test`\nline 2 with `changed`"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("line 1 with \\`test\\`\nline 2 with \\`changed\\`")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty original text", () => {
|
||||
const original = ""
|
||||
const newText = "`code`"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("`code`")
|
||||
})
|
||||
|
||||
it("should handle empty new text", () => {
|
||||
const original = "\\`code\\`"
|
||||
const newText = ""
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("")
|
||||
})
|
||||
|
||||
it("should handle text with no quotes", () => {
|
||||
const original = "hello world"
|
||||
const newText = "goodbye world"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("goodbye world")
|
||||
})
|
||||
|
||||
it("should not affect text without matching escape patterns", () => {
|
||||
const original = "\\`code\\`"
|
||||
const newText = "no quotes here"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("no quotes here")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Unicode punctuation normalisation helpers
|
||||
* Makes patch matching resilient to visually identical but different Unicode code-points
|
||||
*/
|
||||
const PUNCT_EQUIV: Record<string, string> = {
|
||||
// Hyphen / dash variants
|
||||
"-": "-",
|
||||
"\u2010": "-", // HYPHEN
|
||||
"\u2011": "-", // NO-BREAK HYPHEN
|
||||
"\u2012": "-", // FIGURE DASH
|
||||
"\u2013": "-", // EN DASH
|
||||
"\u2014": "-", // EM DASH
|
||||
"\u2212": "-", // MINUS SIGN
|
||||
// Double quotes
|
||||
"\u0022": '"', // QUOTATION MARK
|
||||
"\u201C": '"', // LEFT DOUBLE QUOTATION MARK
|
||||
"\u201D": '"', // RIGHT DOUBLE QUOTATION MARK
|
||||
"\u201E": '"', // DOUBLE LOW-9 QUOTATION MARK
|
||||
"\u00AB": '"', // LEFT-POINTING DOUBLE ANGLE QUOTATION MARK
|
||||
"\u00BB": '"', // RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK
|
||||
// Single quotes
|
||||
"\u0027": "'", // APOSTROPHE
|
||||
"\u2018": "'", // LEFT SINGLE QUOTATION MARK
|
||||
"\u2019": "'", // RIGHT SINGLE QUOTATION MARK
|
||||
"\u201B": "'", // SINGLE HIGH-REVERSED-9 QUOTATION MARK
|
||||
// Spaces
|
||||
"\u00A0": " ", // NO-BREAK SPACE
|
||||
"\u202F": " ", // NARROW NO-BREAK SPACE
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a string by normalizing unicode punctuation and quotes
|
||||
*/
|
||||
export function canonicalize(s: string): string {
|
||||
// First normalize unicode and punctuation
|
||||
let normalized = s.normalize("NFC").replace(/./gu, (c) => PUNCT_EQUIV[c] ?? c)
|
||||
|
||||
// Then normalize escaped/unescaped quotes to handle cases where:
|
||||
// - patch has ` but file has \`
|
||||
// - patch has ' but file has \'
|
||||
// - patch has " but file has \"
|
||||
normalized = normalized
|
||||
.replace(/\\`/g, "`") // \` -> `
|
||||
.replace(/\\'/g, "'") // \' -> '
|
||||
.replace(/\\"/g, '"') // \" -> "
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve the escaping style from original text when applying new text
|
||||
* If original has \`, preserve that style in the replacement
|
||||
*/
|
||||
export function preserveEscaping(originalText: string, newText: string): string {
|
||||
// Check if original has escaped backticks, quotes, or apostrophes
|
||||
const hasEscapedBacktick = originalText.includes("\\`")
|
||||
const hasEscapedSingleQuote = originalText.includes("\\'")
|
||||
const hasEscapedDoubleQuote = originalText.includes('\\"')
|
||||
|
||||
let result = newText
|
||||
|
||||
// Apply escaping to match original style
|
||||
if (hasEscapedBacktick && !newText.includes("\\`")) {
|
||||
// Escape backslashes first, then backticks
|
||||
result = result.replace(/\\/g, "\\\\").replace(/`/g, "\\`")
|
||||
}
|
||||
if (hasEscapedSingleQuote && !newText.includes("\\'")) {
|
||||
// Escape backslashes first, then single quotes
|
||||
result = result.replace(/\\/g, "\\\\").replace(/'/g, "\\'")
|
||||
}
|
||||
if (hasEscapedDoubleQuote && !newText.includes('\\"')) {
|
||||
// Escape backslashes first, then double quotes
|
||||
result = result.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,688 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import * as fs from "fs/promises"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import * as sinon from "sinon"
|
||||
import { hookFileName } from "../core/hooks/__tests__/test-utils"
|
||||
import { HookDiscoveryCache } from "../core/hooks/HookDiscoveryCache"
|
||||
import { executeHook } from "../core/hooks/hook-executor"
|
||||
import { StateManager } from "../core/storage/StateManager"
|
||||
import { MessageStateHandler } from "../core/task/message-state"
|
||||
import { TaskState } from "../core/task/TaskState"
|
||||
import { ClineMessage } from "../shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Unit tests for the hook-executor module
|
||||
* These tests verify the consolidated hook execution logic that replaced
|
||||
* ~400 lines of duplicated code across TaskStart, TaskResume, UserPromptSubmit, and TaskCancel
|
||||
*/
|
||||
describe("Hook Executor", () => {
|
||||
const isWindows = process.platform === "win32"
|
||||
let tempDir: string
|
||||
let baseTempDir: string // Store base directory for cleanup
|
||||
let testHandler: MessageStateHandler
|
||||
let mockMessages: ClineMessage[]
|
||||
let stateManagerStub: sinon.SinonStub
|
||||
|
||||
/**
|
||||
* Helper to create a minimal MessageStateHandler for testing
|
||||
*/
|
||||
function createTestHandler(): MessageStateHandler {
|
||||
const taskState = new TaskState()
|
||||
return new MessageStateHandler({
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
taskState,
|
||||
updateTaskHistory: async () => [],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a test hook script
|
||||
*/
|
||||
async function createHookScript(
|
||||
hookName: string,
|
||||
output: { cancel?: boolean; contextModification?: string; errorMessage?: string },
|
||||
exitCode = 0,
|
||||
delayMs = 0,
|
||||
): Promise<string> {
|
||||
const scriptPath = path.join(tempDir, hookFileName(hookName))
|
||||
const scriptContent = isWindows
|
||||
? `Start-Sleep -Milliseconds ${delayMs}
|
||||
Write-Output '${JSON.stringify(output).replace(/'/g, "''")}'
|
||||
exit ${exitCode}
|
||||
`
|
||||
: `#!/usr/bin/env node
|
||||
const delay = ${delayMs};
|
||||
setTimeout(() => {
|
||||
console.log(${JSON.stringify(JSON.stringify(output))});
|
||||
process.exit(${exitCode});
|
||||
}, delay);
|
||||
`
|
||||
await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 })
|
||||
return scriptPath
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset the hook discovery cache before each test
|
||||
// This ensures tests get a fresh cache and can discover newly created hooks
|
||||
HookDiscoveryCache.resetForTesting()
|
||||
|
||||
// Create temporary directory for test hooks
|
||||
baseTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hook-test-"))
|
||||
// Create .clinerules/hooks subdirectory structure
|
||||
tempDir = path.join(baseTempDir, ".clinerules", "hooks")
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
testHandler = createTestHandler()
|
||||
mockMessages = []
|
||||
|
||||
// Mock StateManager to return baseTempDir as workspace root
|
||||
// This allows HookFactory to find hooks in baseTempDir/.clinerules/hooks/
|
||||
stateManagerStub = sinon.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: (key: string) => {
|
||||
if (key === "workspaceRoots") {
|
||||
return [{ path: baseTempDir }]
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
} as any)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up temporary directory (including entire base directory)
|
||||
try {
|
||||
await fs.rm(baseTempDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
// Restore StateManager stub
|
||||
stateManagerStub.restore()
|
||||
})
|
||||
|
||||
describe("Basic Hook Execution", () => {
|
||||
it("should return wasCancelled: false when hooks are disabled", async () => {
|
||||
const result = await executeHook({
|
||||
hookName: "TaskStart",
|
||||
hookInput: {
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => undefined,
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: false, // Disabled
|
||||
})
|
||||
|
||||
result.should.deepEqual({
|
||||
wasCancelled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should return wasCancelled: false when hook doesn't exist", async () => {
|
||||
// Point to non-existent directory
|
||||
const result = await executeHook({
|
||||
hookName: "TaskStart",
|
||||
hookInput: {
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => undefined,
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
result.should.deepEqual({
|
||||
wasCancelled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should execute hook successfully and return result", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
// Create a simple hook that returns success
|
||||
await createHookScript("TaskStart", {
|
||||
cancel: false,
|
||||
contextModification: "Test context modification",
|
||||
})
|
||||
|
||||
const sayMessages: Array<{ type: string; text: string }> = []
|
||||
const result = await executeHook({
|
||||
hookName: "TaskStart",
|
||||
hookInput: {
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async (type: any, text?: string) => {
|
||||
sayMessages.push({ type, text: text || "" })
|
||||
return Date.now()
|
||||
},
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
// Verify result
|
||||
result.cancel!.should.equal(false)
|
||||
result.contextModification!.should.equal("Test context modification")
|
||||
result.wasCancelled.should.equal(false)
|
||||
|
||||
// Verify messages were sent
|
||||
sayMessages.should.matchAny((msg: any) => msg.type === "hook_status")
|
||||
})
|
||||
|
||||
it("should handle hook that requests cancellation", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
await createHookScript("TaskStart", {
|
||||
cancel: true,
|
||||
contextModification: "Cancelling task",
|
||||
errorMessage: "Task cancelled by hook",
|
||||
})
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "TaskStart",
|
||||
hookInput: {
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
result.cancel!.should.equal(true)
|
||||
result.contextModification!.should.equal("Cancelling task")
|
||||
result.errorMessage!.should.equal("Task cancelled by hook")
|
||||
result.wasCancelled.should.equal(false) // Not user-cancelled, hook requested cancel
|
||||
})
|
||||
})
|
||||
|
||||
describe("Cancellable Hooks", () => {
|
||||
it("should support user cancellation for cancellable hooks", async function () {
|
||||
this.timeout(isWindows ? 10000 : 5000)
|
||||
const hookDelayMs = isWindows ? 1500 : 500
|
||||
const abortDelayMs = isWindows ? 300 : 50
|
||||
|
||||
// Create a hook that takes some time to execute
|
||||
await createHookScript(
|
||||
"TaskStart",
|
||||
{
|
||||
cancel: false,
|
||||
},
|
||||
0,
|
||||
hookDelayMs,
|
||||
)
|
||||
|
||||
let capturedAbortController: AbortController | null = null
|
||||
let setHookCalled = false
|
||||
let clearHookCalled = false
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "TaskStart",
|
||||
hookInput: {
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
setActiveHookExecution: async (execution) => {
|
||||
setHookCalled = true
|
||||
capturedAbortController = execution.abortController
|
||||
// Give the spawned hook process enough time to become fully active,
|
||||
// especially on slower Windows/PowerShell CI runners, before aborting.
|
||||
setTimeout(() => {
|
||||
capturedAbortController?.abort()
|
||||
}, abortDelayMs)
|
||||
},
|
||||
clearActiveHookExecution: async () => {
|
||||
clearHookCalled = true
|
||||
},
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
result.cancel!.should.equal(true)
|
||||
result.wasCancelled.should.equal(true)
|
||||
setHookCalled.should.equal(true)
|
||||
// clearHookCalled should be true after abort
|
||||
clearHookCalled.should.equal(true)
|
||||
})
|
||||
|
||||
it("should not allow cancellation for non-cancellable hooks", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
await createHookScript("TaskCancel", {
|
||||
cancel: false,
|
||||
})
|
||||
|
||||
// For non-cancellable hooks, setActiveHookExecution should not be called
|
||||
let setHookCalled = false
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "TaskCancel",
|
||||
hookInput: {
|
||||
taskCancel: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
completionStatus: "cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: false, // Not cancellable
|
||||
say: async () => Date.now(),
|
||||
setActiveHookExecution: async () => {
|
||||
setHookCalled = true
|
||||
},
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
// With no hook scripts present, the executor returns the minimal shape.
|
||||
// This test is primarily verifying the call succeeds for non-cancellable hooks.
|
||||
if (result.cancel !== undefined) {
|
||||
result.cancel.should.equal(false)
|
||||
}
|
||||
result.wasCancelled.should.equal(false)
|
||||
// setActiveHookExecution should not be called for non-cancellable hooks
|
||||
// (In real execution, this would be verified, but test doesn't reach that point)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should handle hook execution failure gracefully", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
// Create a hook that exits with non-zero status
|
||||
await createHookScript("TaskStart", {}, 1)
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "TaskStart",
|
||||
hookInput: {
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
// Hook failure should not crash, just return safe defaults
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
|
||||
it("should update message state on hook failure", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
await createHookScript("TaskStart", {}, 1) // Exit with error
|
||||
|
||||
const messages: ClineMessage[] = []
|
||||
const mockHandler = {
|
||||
...testHandler,
|
||||
getClineMessages: () => messages,
|
||||
addToClineMessages: async (msg: ClineMessage) => {
|
||||
messages.push(msg)
|
||||
},
|
||||
updateClineMessage: async (index: number, updates: Partial<ClineMessage>) => {
|
||||
if (messages[index]) {
|
||||
Object.assign(messages[index], updates)
|
||||
}
|
||||
},
|
||||
} as any
|
||||
|
||||
await executeHook({
|
||||
hookName: "TaskStart",
|
||||
hookInput: {
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async (type: any, text?: string) => {
|
||||
const msg: ClineMessage = {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: type,
|
||||
text,
|
||||
}
|
||||
messages.push(msg)
|
||||
return msg.ts
|
||||
},
|
||||
messageStateHandler: mockHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
// Should have recorded hook message
|
||||
messages.length.should.be.greaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Message State Updates", () => {
|
||||
it("should create hook message with running status", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
await createHookScript("TaskStart", {
|
||||
cancel: false,
|
||||
})
|
||||
|
||||
const messages: ClineMessage[] = []
|
||||
|
||||
await executeHook({
|
||||
hookName: "TaskStart",
|
||||
hookInput: {
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async (type: any, text?: string) => {
|
||||
const msg: ClineMessage = {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: type,
|
||||
text,
|
||||
}
|
||||
messages.push(msg)
|
||||
return msg.ts
|
||||
},
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
// Should have at least one hook message
|
||||
messages.length.should.be.greaterThan(0)
|
||||
const hookMessage = messages.find((m) => m.say === "hook_status")
|
||||
should.exist(hookMessage)
|
||||
})
|
||||
|
||||
it("should update hook message to completed status on success", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
await createHookScript("TaskStart", {
|
||||
cancel: false,
|
||||
})
|
||||
|
||||
const messages: ClineMessage[] = []
|
||||
const mockHandler = {
|
||||
...testHandler,
|
||||
getClineMessages: () => messages,
|
||||
updateClineMessage: async (index: number, updates: Partial<ClineMessage>) => {
|
||||
if (messages[index]) {
|
||||
Object.assign(messages[index], updates)
|
||||
}
|
||||
},
|
||||
} as any
|
||||
|
||||
await executeHook({
|
||||
hookName: "TaskStart",
|
||||
hookInput: {
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async (type: any, text?: string) => {
|
||||
const msg: ClineMessage = {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: type,
|
||||
text,
|
||||
}
|
||||
messages.push(msg)
|
||||
return msg.ts
|
||||
},
|
||||
messageStateHandler: mockHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
// Verify hook message exists
|
||||
messages.length.should.be.greaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Different Hook Types", () => {
|
||||
it("should execute TaskResume hook with correct input structure", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
await createHookScript("TaskResume", {
|
||||
cancel: false,
|
||||
contextModification: "Resume context",
|
||||
})
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "TaskResume",
|
||||
hookInput: {
|
||||
taskResume: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
},
|
||||
previousState: {
|
||||
lastMessageTs: "12345",
|
||||
messageCount: "10",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
|
||||
it("should execute UserPromptSubmit hook with correct input structure", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
await createHookScript("UserPromptSubmit", {
|
||||
cancel: false,
|
||||
})
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "UserPromptSubmit",
|
||||
hookInput: {
|
||||
userPromptSubmit: {
|
||||
prompt: "Test prompt",
|
||||
attachments: [],
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
|
||||
it("should execute TaskCancel hook as non-cancellable", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
await createHookScript("TaskCancel", {
|
||||
cancel: false,
|
||||
})
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "TaskCancel",
|
||||
hookInput: {
|
||||
taskCancel: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
completionStatus: "cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: false, // TaskCancel is not cancellable
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
|
||||
it("should execute Notification hook with attention payload", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
await createHookScript("Notification", {
|
||||
cancel: false,
|
||||
contextModification: "Notification received",
|
||||
})
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "Notification",
|
||||
hookInput: {
|
||||
notification: {
|
||||
event: "user_attention",
|
||||
source: "tool",
|
||||
message: "Approve this action",
|
||||
waitingForUserInput: true,
|
||||
eventVersion: "1",
|
||||
eventId: "evt_123",
|
||||
messageTruncated: false,
|
||||
sourceType: "ask",
|
||||
sourceId: "tool",
|
||||
requiresUserAction: true,
|
||||
severity: "info",
|
||||
},
|
||||
},
|
||||
isCancellable: false,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
result.contextModification!.should.equal("Notification received")
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle empty context modification", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
await createHookScript("TaskStart", {
|
||||
cancel: false,
|
||||
contextModification: "",
|
||||
})
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "TaskStart",
|
||||
hookInput: {
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
// If the hook script returned an empty string, this may be treated as
|
||||
// "no modification" and omitted depending on executor normalization.
|
||||
if (result.contextModification !== undefined) {
|
||||
result.contextModification.should.equal("")
|
||||
}
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
|
||||
it("should handle undefined optional fields in result", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
await createHookScript("TaskStart", {
|
||||
cancel: false,
|
||||
})
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "TaskStart",
|
||||
hookInput: {
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "test task",
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
// contextModification and errorMessage may be undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,267 +0,0 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import should from "should"
|
||||
import { MessageStateHandler } from "../core/task/message-state"
|
||||
import { TaskState } from "../core/task/TaskState"
|
||||
import { ClineMessage } from "../shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Unit tests for MessageStateHandler's mutex protection (RC-4)
|
||||
* These tests verify that concurrent operations on message state are properly serialized
|
||||
* to prevent race conditions, particularly the TOCTOU bug in addToClineMessages
|
||||
*/
|
||||
describe("MessageStateHandler Mutex Protection", () => {
|
||||
/**
|
||||
* Helper to create a minimal MessageStateHandler for testing
|
||||
*/
|
||||
function createTestHandler(): MessageStateHandler {
|
||||
const taskState = new TaskState()
|
||||
return new MessageStateHandler({
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
taskState,
|
||||
updateTaskHistory: async () => [],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a test ClineMessage
|
||||
*/
|
||||
function createTestMessage(text: string): ClineMessage {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
it("should initialize with empty message arrays", () => {
|
||||
const handler = createTestHandler()
|
||||
handler.getClineMessages().length.should.equal(0)
|
||||
handler.getApiConversationHistory().length.should.equal(0)
|
||||
})
|
||||
|
||||
it("should set and get API conversation history", () => {
|
||||
const handler = createTestHandler()
|
||||
const testHistory = [{ role: "user" as const, content: "test message" }]
|
||||
|
||||
handler.setApiConversationHistory(testHistory)
|
||||
handler.getApiConversationHistory().should.deepEqual(testHistory)
|
||||
})
|
||||
|
||||
it("should set and get cline messages", () => {
|
||||
const handler = createTestHandler()
|
||||
const testMessages = [createTestMessage("test1"), createTestMessage("test2")]
|
||||
|
||||
handler.setClineMessages(testMessages)
|
||||
handler.getClineMessages().should.deepEqual(testMessages)
|
||||
})
|
||||
|
||||
/**
|
||||
* CRITICAL TEST: Verify that addToClineMessages is atomic
|
||||
* This test simulates the race condition that can occur when multiple
|
||||
* addToClineMessages calls happen concurrently without proper mutex protection
|
||||
*/
|
||||
it("should handle concurrent addToClineMessages atomically", async function () {
|
||||
// Increase timeout for this test as it involves async operations
|
||||
this.timeout(5000)
|
||||
|
||||
const handler = createTestHandler()
|
||||
|
||||
// Set up initial API conversation history
|
||||
const initialHistory = [
|
||||
{ role: "user" as const, content: "msg1" },
|
||||
{ role: "assistant" as const, content: "response1" },
|
||||
{ role: "user" as const, content: "msg2" },
|
||||
]
|
||||
handler.setApiConversationHistory(initialHistory)
|
||||
|
||||
// Add initial message to establish baseline
|
||||
const initialMsg = createTestMessage("initial")
|
||||
await handler.addToClineMessages(initialMsg)
|
||||
|
||||
// Verify initial state
|
||||
const messages = handler.getClineMessages()
|
||||
messages.length.should.equal(1)
|
||||
messages[0].conversationHistoryIndex?.should.equal(2) // length - 1 = 3 - 1 = 2
|
||||
|
||||
// Now simulate concurrent additions
|
||||
// Without mutex protection, these could race and get the same index
|
||||
const msg1 = createTestMessage("concurrent1")
|
||||
const msg2 = createTestMessage("concurrent2")
|
||||
const msg3 = createTestMessage("concurrent3")
|
||||
|
||||
// Add more messages to API history to simulate ongoing conversation
|
||||
handler.setApiConversationHistory([
|
||||
...initialHistory,
|
||||
{ role: "assistant" as const, content: "response2" },
|
||||
{ role: "user" as const, content: "msg3" },
|
||||
])
|
||||
|
||||
// Execute concurrent operations
|
||||
const results = await Promise.all([
|
||||
handler.addToClineMessages(msg1),
|
||||
handler.addToClineMessages(msg2),
|
||||
handler.addToClineMessages(msg3),
|
||||
])
|
||||
|
||||
// Verify all operations completed
|
||||
results.length.should.equal(3)
|
||||
|
||||
// Get final state
|
||||
const finalMessages = handler.getClineMessages()
|
||||
finalMessages.length.should.equal(4) // initial + 3 concurrent
|
||||
|
||||
// CRITICAL ASSERTION: Each message should have a valid conversationHistoryIndex
|
||||
// With proper mutex protection, these indices should be set correctly
|
||||
// even though the operations ran concurrently
|
||||
finalMessages.forEach((msg, _idx) => {
|
||||
should.exist(msg.conversationHistoryIndex)
|
||||
msg.conversationHistoryIndex?.should.be.a.Number()
|
||||
msg.conversationHistoryIndex?.should.be.greaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that updateClineMessage operations are atomic
|
||||
*/
|
||||
it("should handle concurrent updateClineMessage atomically", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
const handler = createTestHandler()
|
||||
|
||||
// Set up initial messages
|
||||
const msgs = [createTestMessage("msg1"), createTestMessage("msg2"), createTestMessage("msg3")]
|
||||
handler.setClineMessages(msgs)
|
||||
|
||||
// Perform concurrent updates to different messages
|
||||
await Promise.all([
|
||||
handler.updateClineMessage(0, { text: "updated1" }),
|
||||
handler.updateClineMessage(1, { text: "updated2" }),
|
||||
handler.updateClineMessage(2, { text: "updated3" }),
|
||||
])
|
||||
|
||||
const finalMessages = handler.getClineMessages()
|
||||
finalMessages[0]?.text?.should.equal("updated1")
|
||||
finalMessages[1]?.text?.should.equal("updated2")
|
||||
finalMessages[2]?.text?.should.equal("updated3")
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that deleteClineMessage operations are atomic
|
||||
*/
|
||||
it("should handle deleteClineMessage with proper validation", async () => {
|
||||
const handler = createTestHandler()
|
||||
|
||||
// Set up initial messages
|
||||
const msgs = [createTestMessage("msg1"), createTestMessage("msg2"), createTestMessage("msg3")]
|
||||
handler.setClineMessages(msgs)
|
||||
|
||||
// Delete middle message
|
||||
await handler.deleteClineMessage(1)
|
||||
|
||||
const finalMessages = handler.getClineMessages()
|
||||
finalMessages.length.should.equal(2)
|
||||
finalMessages[0]?.text?.should.equal("msg1")
|
||||
finalMessages[1]?.text?.should.equal("msg3")
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that invalid indices are rejected
|
||||
*/
|
||||
it("should throw error for invalid message index in updateClineMessage", async () => {
|
||||
const handler = createTestHandler()
|
||||
handler.setClineMessages([createTestMessage("msg1")])
|
||||
|
||||
try {
|
||||
await handler.updateClineMessage(5, { text: "invalid" })
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
error.message.should.match(/Invalid message index/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that invalid indices are rejected in deleteClineMessage
|
||||
*/
|
||||
it("should throw error for invalid message index in deleteClineMessage", async () => {
|
||||
const handler = createTestHandler()
|
||||
handler.setClineMessages([createTestMessage("msg1")])
|
||||
|
||||
try {
|
||||
await handler.deleteClineMessage(-1)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
error.message.should.match(/Invalid message index/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Test API conversation history operations
|
||||
*/
|
||||
it("should handle concurrent API conversation history operations", async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
const handler = createTestHandler()
|
||||
|
||||
// Perform concurrent additions
|
||||
await Promise.all([
|
||||
handler.addToApiConversationHistory({ role: "user", content: "msg1", ts: Date.now() }),
|
||||
handler.addToApiConversationHistory({ role: "assistant", content: "response1", ts: Date.now() }),
|
||||
handler.addToApiConversationHistory({ role: "user", content: "msg2", ts: Date.now() }),
|
||||
])
|
||||
|
||||
const history = handler.getApiConversationHistory()
|
||||
history.length.should.equal(3)
|
||||
history[0].role.should.equal("user")
|
||||
history[1].role.should.equal("assistant")
|
||||
history[2].role.should.equal("user")
|
||||
})
|
||||
|
||||
/**
|
||||
* Test overwrite operations
|
||||
*/
|
||||
it("should handle overwriteClineMessages atomically", async () => {
|
||||
const handler = createTestHandler()
|
||||
|
||||
// Set initial messages
|
||||
handler.setClineMessages([createTestMessage("old1"), createTestMessage("old2")])
|
||||
|
||||
// Overwrite with new messages
|
||||
const newMessages = [createTestMessage("new1"), createTestMessage("new2"), createTestMessage("new3")]
|
||||
await handler.overwriteClineMessages(newMessages)
|
||||
|
||||
const finalMessages = handler.getClineMessages()
|
||||
finalMessages.length.should.equal(3)
|
||||
finalMessages[0]?.text?.should.equal("new1")
|
||||
finalMessages[1]?.text?.should.equal("new2")
|
||||
finalMessages[2]?.text?.should.equal("new3")
|
||||
})
|
||||
|
||||
/**
|
||||
* Test overwrite API conversation history
|
||||
*/
|
||||
it("should handle overwriteApiConversationHistory atomically", async () => {
|
||||
const handler = createTestHandler()
|
||||
|
||||
// Set initial history
|
||||
handler.setApiConversationHistory([{ role: "user", content: "old", ts: Date.now() }])
|
||||
|
||||
// Overwrite with new history
|
||||
const newHistory = [
|
||||
{ role: "user" as const, content: "new1", ts: Date.now() },
|
||||
{ role: "assistant" as const, content: "new2", ts: Date.now() },
|
||||
]
|
||||
await handler.overwriteApiConversationHistory(newHistory)
|
||||
|
||||
const finalHistory = handler.getApiConversationHistory()
|
||||
finalHistory.length.should.equal(2)
|
||||
finalHistory[0].content.should.equal("new1")
|
||||
finalHistory[1].content.should.equal("new2")
|
||||
})
|
||||
})
|
||||
@@ -1,379 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import * as sinon from "sinon"
|
||||
import { executeHook } from "../core/hooks/hook-executor"
|
||||
import { StateManager } from "../core/storage/StateManager"
|
||||
import { MessageStateHandler } from "../core/task/message-state"
|
||||
import { TaskState } from "../core/task/TaskState"
|
||||
|
||||
/**
|
||||
* Unit tests for tool hook execution (PreToolUse and PostToolUse)
|
||||
* These tests verify the consolidated hook execution logic for tool-specific hooks
|
||||
*/
|
||||
describe("Tool Executor Hooks", () => {
|
||||
let stateManagerStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock StateManager to return empty workspace roots
|
||||
stateManagerStub = sinon.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: (key: string) => {
|
||||
if (key === "workspaceRoots") {
|
||||
return []
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
} as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore StateManager stub
|
||||
stateManagerStub.restore()
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper to create a minimal MessageStateHandler for testing
|
||||
*/
|
||||
function createTestHandler(): MessageStateHandler {
|
||||
const taskState = new TaskState()
|
||||
return new MessageStateHandler({
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
taskState,
|
||||
updateTaskHistory: async () => [],
|
||||
})
|
||||
}
|
||||
|
||||
describe("PreToolUse Hook", () => {
|
||||
it("should include toolName and pendingToolInfo in hook metadata", async () => {
|
||||
const testHandler = createTestHandler()
|
||||
const sayMessages: Array<{ type: string; text: string }> = []
|
||||
|
||||
const pendingToolInfo = {
|
||||
tool: "write_to_file",
|
||||
path: "/test/file.ts",
|
||||
content: "test content",
|
||||
}
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "PreToolUse",
|
||||
hookInput: {
|
||||
preToolUse: {
|
||||
toolName: "write_to_file",
|
||||
parameters: { path: "/test/file.ts", content: "test content" },
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async (type: any, text?: string) => {
|
||||
sayMessages.push({ type, text: text || "" })
|
||||
return Date.now()
|
||||
},
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: false, // Disabled so hook doesn't actually run
|
||||
toolName: "write_to_file",
|
||||
pendingToolInfo,
|
||||
})
|
||||
|
||||
// Should return early since hooks are disabled
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
|
||||
it("should handle PreToolUse hook with pendingToolInfo parameter", async () => {
|
||||
const testHandler = createTestHandler()
|
||||
const pendingToolInfo = {
|
||||
tool: "execute_command",
|
||||
command: "npm test",
|
||||
}
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "PreToolUse",
|
||||
hookInput: {
|
||||
preToolUse: {
|
||||
toolName: "execute_command",
|
||||
parameters: { command: "npm test" },
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: false, // Hook doesn't exist, so returns early
|
||||
toolName: "execute_command",
|
||||
pendingToolInfo,
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
|
||||
it("should support cancellation for PreToolUse hooks", async () => {
|
||||
const testHandler = createTestHandler()
|
||||
|
||||
// Test that cancellable hooks can use setActiveHookExecution
|
||||
let setHookCalled = false
|
||||
const result = await executeHook({
|
||||
hookName: "PreToolUse",
|
||||
hookInput: {
|
||||
preToolUse: {
|
||||
toolName: "write_to_file",
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
setActiveHookExecution: async () => {
|
||||
setHookCalled = true
|
||||
},
|
||||
clearActiveHookExecution: async () => {},
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: false, // Doesn't run, but we verify the parameter is accepted
|
||||
toolName: "write_to_file",
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
// In real execution, setHookCalled would be true, but hook doesn't exist here
|
||||
})
|
||||
|
||||
it("should pass through context modification from PreToolUse", async () => {
|
||||
const testHandler = createTestHandler()
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "PreToolUse",
|
||||
hookInput: {
|
||||
preToolUse: {
|
||||
toolName: "read_file",
|
||||
parameters: { path: "/test/file.ts" },
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: false,
|
||||
toolName: "read_file",
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
// Hook doesn't exist, so no context modification
|
||||
})
|
||||
})
|
||||
|
||||
describe("PostToolUse Hook", () => {
|
||||
it("should include toolName in hook metadata", async () => {
|
||||
const testHandler = createTestHandler()
|
||||
const sayMessages: Array<{ type: string; text: string }> = []
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "PostToolUse",
|
||||
hookInput: {
|
||||
postToolUse: {
|
||||
toolName: "write_to_file",
|
||||
parameters: { path: "/test/file.ts" },
|
||||
result: "File written successfully",
|
||||
success: true,
|
||||
executionTimeMs: 150,
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async (type: any, text?: string) => {
|
||||
sayMessages.push({ type, text: text || "" })
|
||||
return Date.now()
|
||||
},
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: false,
|
||||
toolName: "write_to_file",
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
|
||||
it("should include execution metrics in PostToolUse hook input", async () => {
|
||||
const testHandler = createTestHandler()
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "PostToolUse",
|
||||
hookInput: {
|
||||
postToolUse: {
|
||||
toolName: "execute_command",
|
||||
parameters: { command: "npm test" },
|
||||
result: "Tests passed",
|
||||
success: true,
|
||||
executionTimeMs: 5000, // 5 seconds
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: false,
|
||||
toolName: "execute_command",
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
|
||||
it("should handle PostToolUse hook for failed tool execution", async () => {
|
||||
const testHandler = createTestHandler()
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "PostToolUse",
|
||||
hookInput: {
|
||||
postToolUse: {
|
||||
toolName: "read_file",
|
||||
parameters: { path: "/nonexistent/file.ts" },
|
||||
result: "Error: File not found",
|
||||
success: false,
|
||||
executionTimeMs: 50,
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: false,
|
||||
toolName: "read_file",
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
|
||||
it("should support cancellation for PostToolUse hooks", async () => {
|
||||
const testHandler = createTestHandler()
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "PostToolUse",
|
||||
hookInput: {
|
||||
postToolUse: {
|
||||
toolName: "browser_action",
|
||||
parameters: { action: "launch", url: "https://example.com" },
|
||||
result: "Browser launched",
|
||||
success: true,
|
||||
executionTimeMs: 1200,
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
setActiveHookExecution: async () => {},
|
||||
clearActiveHookExecution: async () => {},
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: false,
|
||||
toolName: "browser_action",
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Tool Hook Edge Cases", () => {
|
||||
it("should handle hook execution when hooks are disabled", async () => {
|
||||
const testHandler = createTestHandler()
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "PreToolUse",
|
||||
hookInput: {
|
||||
preToolUse: {
|
||||
toolName: "write_to_file",
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: false, // Explicitly disabled
|
||||
toolName: "write_to_file",
|
||||
})
|
||||
|
||||
result.should.deepEqual({
|
||||
wasCancelled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle hook execution when hook doesn't exist", async () => {
|
||||
const testHandler = createTestHandler()
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "PostToolUse",
|
||||
hookInput: {
|
||||
postToolUse: {
|
||||
toolName: "list_files",
|
||||
parameters: {},
|
||||
result: "[]",
|
||||
success: true,
|
||||
executionTimeMs: 10,
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: true, // Enabled but hook doesn't exist
|
||||
toolName: "list_files",
|
||||
})
|
||||
|
||||
result.should.deepEqual({
|
||||
wasCancelled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle PreToolUse with complex pendingToolInfo", async () => {
|
||||
const testHandler = createTestHandler()
|
||||
|
||||
const complexPendingInfo = {
|
||||
tool: "use_mcp_tool",
|
||||
mcpServer: "github",
|
||||
mcpTool: "create_issue",
|
||||
}
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "PreToolUse",
|
||||
hookInput: {
|
||||
preToolUse: {
|
||||
toolName: "use_mcp_tool",
|
||||
parameters: {
|
||||
server_name: "github",
|
||||
tool_name: "create_issue",
|
||||
arguments: JSON.stringify({ title: "Bug report", body: "Found an issue..." }),
|
||||
},
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: false,
|
||||
toolName: "use_mcp_tool",
|
||||
pendingToolInfo: complexPendingInfo,
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
|
||||
it("should handle PostToolUse with execution time metrics", async () => {
|
||||
const testHandler = createTestHandler()
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "PostToolUse",
|
||||
hookInput: {
|
||||
postToolUse: {
|
||||
toolName: "search_files",
|
||||
parameters: { path: ".", regex: "test.*", file_pattern: "*.ts" },
|
||||
result: "Found 25 matches",
|
||||
success: true,
|
||||
executionTimeMs: 2500,
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: async () => Date.now(),
|
||||
messageStateHandler: testHandler,
|
||||
taskId: "test-task",
|
||||
hooksEnabled: false,
|
||||
toolName: "search_files",
|
||||
})
|
||||
|
||||
result.wasCancelled.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
export const ClineCompactIcon = () => (
|
||||
<svg height="16" viewBox="0 0 92 96" width="16">
|
||||
<g fill="currentColor">
|
||||
<path d="M65.4492701,16.3 C76.3374701,16.3 85.1635558,25.16479 85.1635558,36.1 L85.1635558,42.7 L90.9027661,54.1647464 C91.4694141,55.2966923 91.4668177,56.6300535 90.8957658,57.7597839 L85.1635558,69.1 L85.1635558,75.7 C85.1635558,86.63554 76.3374701,95.5 65.4492701,95.5 L26.0206986,95.5 C15.1328272,95.5 6.30641291,86.63554 6.30641291,75.7 L6.30641291,69.1 L0.448507752,57.7954874 C-0.14693501,56.6464093 -0.149634367,55.2802504 0.441262896,54.1288283 L6.30641291,42.7 L6.30641291,36.1 C6.30641291,25.16479 15.1328272,16.3 26.0206986,16.3 L65.4492701,16.3 Z M62.9301895,22 L29.189529,22 C19.8723267,22 12.3191987,29.5552188 12.3191987,38.875 L12.3191987,44.5 L7.44288578,53.9634655 C6.84794449,55.1180686 6.85066096,56.4896598 7.45017099,57.6418974 L12.3191987,67 L12.3191987,72.625 C12.3191987,81.9450625 19.8723267,89.5 29.189529,89.5 L62.9301895,89.5 C72.2476729,89.5 79.8005198,81.9450625 79.8005198,72.625 L79.8005198,67 L84.5682187,57.6061395 C85.1432011,56.473244 85.1458141,55.1345713 84.5752587,53.9994398 L79.8005198,44.5 L79.8005198,38.875 C79.8005198,29.5552188 72.2476729,22 62.9301895,22 Z" />
|
||||
<ellipse cx="45.7349843" cy="11" rx="12" ry="14" />
|
||||
<ellipse cx="33.5" cy="55.5" rx="8" ry="9" />
|
||||
<ellipse cx="57.5" cy="55.5" rx="8" ry="9" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
import { SVGProps } from "react"
|
||||
|
||||
const ClineLogoBlack = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg fill="none" height="50" viewBox="0 0 47 50" width="47" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M46.4075 28.1192L43.5011 22.3166V18.9747C43.5011 13.4354 39.0302 8.94931 33.5162 8.94931H28.5491C28.9086 8.21513 29.106 7.3898 29.106 6.5189C29.106 3.44039 26.6149 0.949219 23.5363 0.949219C20.4578 0.949219 17.9667 3.44039 17.9667 6.5189C17.9667 7.3898 18.1641 8.21513 18.5236 8.94931H13.5565C8.04249 8.94931 3.57155 13.4354 3.57155 18.9747V22.3166L0.604424 28.104C0.305687 28.6863 0.305687 29.3799 0.604424 29.9622L3.57155 35.6838V39.0256C3.57155 44.5649 8.04249 49.0511 13.5565 49.0511H33.5162C39.0302 49.0511 43.5011 44.5649 43.5011 39.0256V35.6838L46.4024 29.942C46.691 29.3698 46.691 28.6964 46.4075 28.1192ZM20.4983 32.8483C20.4983 35.3648 18.4578 37.4053 15.9413 37.4053C13.4248 37.4053 11.3843 35.3648 11.3843 32.8483V24.747C11.3843 22.2305 13.4248 20.19 15.9413 20.19C18.4578 20.19 20.4983 22.2305 20.4983 24.747V32.8483ZM35.182 32.8483C35.182 35.3648 33.1415 37.4053 30.625 37.4053C28.1085 37.4053 26.068 35.3648 26.068 32.8483V24.747C26.068 22.2305 28.1085 20.19 30.625 20.19C33.1415 20.19 35.182 22.2305 35.182 24.747V32.8483Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
export default ClineLogoBlack
|
||||
@@ -1,127 +0,0 @@
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { TaskFeedbackType } from "@shared/WebviewMessage"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import styled from "styled-components"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { TaskServiceClient } from "@/services/grpc-client"
|
||||
|
||||
interface TaskFeedbackButtonsProps {
|
||||
messageTs: number
|
||||
isFromHistory?: boolean
|
||||
classNames?: string
|
||||
}
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
color: var(--vscode-descriptionForeground);
|
||||
`
|
||||
|
||||
const ButtonWrapper = styled.div`
|
||||
transform: scale(0.85);
|
||||
`
|
||||
|
||||
const TaskFeedbackButtons: React.FC<TaskFeedbackButtonsProps> = ({ messageTs, isFromHistory = false, classNames }) => {
|
||||
const [feedback, setFeedback] = useState<TaskFeedbackType | null>(null)
|
||||
const [shouldShow, setShouldShow] = useState<boolean>(true)
|
||||
|
||||
// Check localStorage on mount to see if feedback was already given for this message
|
||||
useEffect(() => {
|
||||
try {
|
||||
const feedbackHistory = localStorage.getItem("taskFeedbackHistory") || "{}"
|
||||
const history = JSON.parse(feedbackHistory)
|
||||
// Check if this specific message timestamp has received feedback
|
||||
if (history[messageTs]) {
|
||||
setShouldShow(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error checking feedback history:", e)
|
||||
}
|
||||
}, [messageTs])
|
||||
|
||||
// Don't show buttons if this is from history or feedback was already given
|
||||
if (isFromHistory || !shouldShow) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleFeedback = async (type: TaskFeedbackType) => {
|
||||
if (feedback !== null) {
|
||||
return // Already provided feedback
|
||||
}
|
||||
|
||||
setFeedback(type)
|
||||
|
||||
try {
|
||||
await TaskServiceClient.taskFeedback(
|
||||
StringRequest.create({
|
||||
value: type,
|
||||
}),
|
||||
)
|
||||
|
||||
// Store in localStorage that feedback was provided for this message
|
||||
try {
|
||||
const feedbackHistory = localStorage.getItem("taskFeedbackHistory") || "{}"
|
||||
const history = JSON.parse(feedbackHistory)
|
||||
history[messageTs] = true
|
||||
localStorage.setItem("taskFeedbackHistory", JSON.stringify(history))
|
||||
} catch (e) {
|
||||
console.error("Error updating feedback history:", e)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending task feedback:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-end shrink-0", classNames)}>
|
||||
<ButtonsContainer>
|
||||
<ButtonWrapper>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
aria-label="This was helpful"
|
||||
disabled={feedback !== null}
|
||||
onClick={() => handleFeedback("thumbs_up")}
|
||||
title="This was helpful">
|
||||
<IconWrapper>
|
||||
<span
|
||||
className={`codicon ${feedback === "thumbs_up" ? "codicon-thumbsup-filled" : "codicon-thumbsup"}`}
|
||||
/>
|
||||
</IconWrapper>
|
||||
</VSCodeButton>
|
||||
</ButtonWrapper>
|
||||
<ButtonWrapper>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
aria-label="This wasn't helpful"
|
||||
disabled={feedback !== null && feedback !== "thumbs_down"}
|
||||
onClick={() => handleFeedback("thumbs_down")}
|
||||
title="This wasn't helpful">
|
||||
<IconWrapper>
|
||||
<span
|
||||
className={`codicon ${feedback === "thumbs_down" ? "codicon-thumbsdown-filled" : "codicon-thumbsdown"}`}
|
||||
/>
|
||||
</IconWrapper>
|
||||
</VSCodeButton>
|
||||
</ButtonWrapper>
|
||||
{/* <VSCodeButtonLink
|
||||
href="https://github.com/cline/cline/issues/new?template=bug_report.yml"
|
||||
appearance="icon"
|
||||
title="Report a bug"
|
||||
aria-label="Report a bug">
|
||||
<span className="codicon codicon-bug" />
|
||||
</VSCodeButtonLink> */}
|
||||
</ButtonsContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 0px;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
export default TaskFeedbackButtons
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* STASHED CODE - Idle Indicator with MutationObserver
|
||||
*
|
||||
* This code implements a "Thinking..."/"Working..." indicator that appears after 3 seconds
|
||||
* of DOM silence. It was removed from MessagesArea.tsx but preserved here for future use.
|
||||
*
|
||||
* To re-enable:
|
||||
* 1. Import this hook in MessagesArea.tsx
|
||||
* 2. Call useIdleIndicator() and get showIdleIndicator state
|
||||
* 3. Add the indicator to the Virtuoso Footer component
|
||||
*/
|
||||
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
// Idle timeout in milliseconds before showing indicator
|
||||
const IDLE_TIMEOUT_MS = 3000
|
||||
|
||||
/**
|
||||
* Hook that detects when the DOM has been idle for IDLE_TIMEOUT_MS
|
||||
* Uses MutationObserver to track actual content changes
|
||||
*/
|
||||
export function useIdleIndicator(scrollContainerRef: React.RefObject<HTMLDivElement>, clineMessages: ClineMessage[]): boolean {
|
||||
const [showIdleIndicator, setShowIdleIndicator] = useState(false)
|
||||
const idleTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const timerStartTimeRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if task is complete
|
||||
const isTaskComplete = clineMessages.some(
|
||||
(msg) => msg.ask === "completion_result" || msg.say === "completion_result" || msg.ask === "plan_mode_respond",
|
||||
)
|
||||
|
||||
if (isTaskComplete) {
|
||||
// Don't show indicator if task is complete
|
||||
setShowIdleIndicator(false)
|
||||
timerStartTimeRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[IdleIndicator] Setting up MutationObserver")
|
||||
|
||||
const resetIdleTimer = () => {
|
||||
// Clear existing timer
|
||||
if (idleTimerRef.current) {
|
||||
clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
|
||||
// Hide indicator immediately when new content arrives
|
||||
setShowIdleIndicator(false)
|
||||
|
||||
// Record start time if this is the first mutation
|
||||
if (!timerStartTimeRef.current) {
|
||||
timerStartTimeRef.current = Date.now()
|
||||
}
|
||||
|
||||
// Calculate elapsed and remaining time
|
||||
const elapsed = Date.now() - timerStartTimeRef.current
|
||||
const remaining = Math.max(0, IDLE_TIMEOUT_MS - elapsed)
|
||||
|
||||
console.log(
|
||||
`[IdleIndicator] DOM mutation detected, restarting timer. Elapsed: ${elapsed}ms, Remaining: ${remaining}ms`,
|
||||
)
|
||||
|
||||
// Start new timer for remaining duration
|
||||
idleTimerRef.current = setTimeout(() => {
|
||||
console.log("[IdleIndicator] DOM idle for 3s, showing indicator")
|
||||
setShowIdleIndicator(true)
|
||||
}, remaining)
|
||||
}
|
||||
|
||||
// Observe changes to the chat container
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
// Only reset timer if there are actual content changes
|
||||
const hasContentChange = mutations.some((mutation) => {
|
||||
return (
|
||||
mutation.type === "childList" ||
|
||||
mutation.type === "characterData" ||
|
||||
(mutation.type === "attributes" && mutation.attributeName !== "style")
|
||||
)
|
||||
})
|
||||
|
||||
if (hasContentChange) {
|
||||
resetIdleTimer()
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
})
|
||||
|
||||
// Start initial timer
|
||||
resetIdleTimer()
|
||||
|
||||
return () => {
|
||||
console.log("[IdleIndicator] Cleaning up MutationObserver")
|
||||
observer.disconnect()
|
||||
if (idleTimerRef.current) {
|
||||
clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
timerStartTimeRef.current = null
|
||||
}
|
||||
}, [scrollContainerRef, clineMessages])
|
||||
|
||||
return showIdleIndicator
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to render in Virtuoso Footer
|
||||
*
|
||||
* Usage:
|
||||
* <Virtuoso
|
||||
* components={{
|
||||
* Footer: () => (
|
||||
* <div>
|
||||
* <div className="min-h-1" />
|
||||
* {showIdleIndicator && (
|
||||
* <div className="flex items-center text-description text-sm px-4 pt-2.5 pb-2.5">
|
||||
* <div className="ml-1">
|
||||
* <TypewriterText text={mode === "plan" ? "Thinking..." : "Working..."} />
|
||||
* </div>
|
||||
* </div>
|
||||
* )}
|
||||
* </div>
|
||||
* ),
|
||||
* }}
|
||||
* />
|
||||
*/
|
||||
@@ -1,9 +0,0 @@
|
||||
// Color constants for timeline and tooltips
|
||||
export const COLOR_WHITE = "#E5E5E5" // Light gray for system prompt and user feedback
|
||||
export const COLOR_GRAY = "#8B949E" // Medium gray for assistant responses and user messages
|
||||
export const COLOR_DARK_GRAY = "#6E7681" // Dark gray for unknown types
|
||||
export const COLOR_BEIGE = "#F0C674" // Warm yellow for file read operations
|
||||
export const COLOR_BLUE = "#58A6FF" // Bright blue for file edit/create operations
|
||||
export const COLOR_RED = "#F85149" // Coral red for terminal commands
|
||||
export const COLOR_PURPLE = "#BC8CFF" // Soft purple for browser actions
|
||||
export const COLOR_GREEN = "#56D364" // Bright green for task success
|
||||
@@ -1,244 +0,0 @@
|
||||
import { cn } from "@heroui/react"
|
||||
import { isCompletedFocusChainItem, isFocusChainItem } from "@shared/focus-chain-utils"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||||
import React, { memo, useCallback, useMemo, useState } from "react"
|
||||
import ChecklistRenderer from "@/components/common/ChecklistRenderer"
|
||||
import LightMarkdown from "@/components/common/LightMarkdown"
|
||||
import { FileServiceClient } from "@/services/grpc-client"
|
||||
|
||||
// Optimized interface with readonly properties to prevent accidental mutations
|
||||
interface TodoInfo {
|
||||
readonly currentTodo: { text: string; completed: boolean; index: number } | null
|
||||
readonly currentIndex: number
|
||||
readonly completedCount: number
|
||||
readonly totalCount: number
|
||||
readonly progressPercentage: number
|
||||
}
|
||||
|
||||
interface FocusChainProps {
|
||||
readonly lastProgressMessageText?: string
|
||||
readonly currentTaskItemId?: string
|
||||
readonly showPlaceholderWhenEmpty?: boolean
|
||||
}
|
||||
|
||||
// Static strings to avoid recreating them
|
||||
const COMPLETED_MESSAGE = "All tasks have been completed!"
|
||||
const TODO_LIST_LABEL = "To-Do list"
|
||||
const NEW_STEPS_MESSAGE = "New steps will be generated if you continue the task"
|
||||
const CLICK_TO_EDIT_TITLE = "Click to edit to-do list in file"
|
||||
|
||||
// Optimized header component with minimal re-renders
|
||||
const ToDoListHeader = memo<{
|
||||
todoInfo: TodoInfo
|
||||
isExpanded: boolean
|
||||
}>(({ todoInfo, isExpanded }) => {
|
||||
const { currentTodo, currentIndex, totalCount, completedCount, progressPercentage } = todoInfo
|
||||
const isCompleted = completedCount === totalCount
|
||||
|
||||
// Pre-compute display text
|
||||
const displayText = isCompleted ? COMPLETED_MESSAGE : currentTodo?.text || TODO_LIST_LABEL
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative w-full h-full", {
|
||||
"text-success": isCompleted,
|
||||
})}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 transition-[width] duration-300 ease-in-out pointer-events-none z-1 h-1 bg-success",
|
||||
{
|
||||
"opacity-0": progressPercentage === 0 || progressPercentage === 100,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2 z-10 py-2 px-2.5">
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0 text-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-lg px-2 py-0.25 inline-block shrink-0 bg-badge-foreground/20 text-foreground text-sm",
|
||||
{
|
||||
"bg-success text-black": isCompleted,
|
||||
},
|
||||
)}>
|
||||
{currentIndex}/{totalCount}
|
||||
</span>
|
||||
<div className="header-text flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium">
|
||||
<LightMarkdown compact text={displayText} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-foreground shrink-0">
|
||||
{isExpanded ? <ChevronDownIcon className="ml-0.25" size="16" /> : <ChevronRightIcon size="16" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ToDoListHeader.displayName = "ToDoListHeader"
|
||||
|
||||
// Cache for parsed todo info to avoid re-parsing identical text
|
||||
const todoInfoCache = new Map<string, TodoInfo | null>()
|
||||
const MAX_CACHE_SIZE = 100
|
||||
|
||||
// Highly optimized parsing with minimal allocations
|
||||
const parseCurrentTodoInfo = (text: string): TodoInfo | null => {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cached = todoInfoCache.get(text)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
let completedCount = 0
|
||||
let totalCount = 0
|
||||
let firstIncompleteIndex = -1
|
||||
let firstIncompleteText: string | null = null
|
||||
|
||||
// Process text line by line without creating intermediate arrays
|
||||
let lineStart = 0
|
||||
let lineEnd = text.indexOf("\n")
|
||||
|
||||
while (lineStart < text.length) {
|
||||
const line = lineEnd === -1 ? text.substring(lineStart).trim() : text.substring(lineStart, lineEnd).trim()
|
||||
|
||||
if (isFocusChainItem(line)) {
|
||||
const isCompleted = isCompletedFocusChainItem(line)
|
||||
|
||||
if (isCompleted) {
|
||||
completedCount++
|
||||
} else if (firstIncompleteIndex === -1) {
|
||||
firstIncompleteIndex = totalCount
|
||||
// Extract text only for the first incomplete item
|
||||
firstIncompleteText = line.substring(5).trim()
|
||||
}
|
||||
|
||||
totalCount++
|
||||
}
|
||||
|
||||
if (lineEnd === -1) {
|
||||
break
|
||||
}
|
||||
lineStart = lineEnd + 1
|
||||
lineEnd = text.indexOf("\n", lineStart)
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
todoInfoCache.set(text, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const currentTodo = firstIncompleteText ? { text: firstIncompleteText, completed: false, index: firstIncompleteIndex } : null
|
||||
|
||||
const result: TodoInfo = {
|
||||
currentTodo,
|
||||
currentIndex: firstIncompleteIndex >= 0 ? firstIncompleteIndex + 1 : totalCount,
|
||||
completedCount,
|
||||
totalCount,
|
||||
progressPercentage: (completedCount / totalCount) * 100,
|
||||
}
|
||||
|
||||
// Cache the result with size management
|
||||
if (todoInfoCache.size >= MAX_CACHE_SIZE) {
|
||||
// Remove oldest entry (first key)
|
||||
const firstKey = todoInfoCache.keys().next().value
|
||||
if (firstKey) {
|
||||
todoInfoCache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
todoInfoCache.set(text, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// Main component with aggressive optimization
|
||||
export const FocusChain: React.FC<FocusChainProps> = memo(
|
||||
({ currentTaskItemId, lastProgressMessageText, showPlaceholderWhenEmpty }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
// Parse todo info with caching
|
||||
const todoInfo = useMemo(
|
||||
() => (lastProgressMessageText ? parseCurrentTodoInfo(lastProgressMessageText) : null),
|
||||
[lastProgressMessageText],
|
||||
)
|
||||
|
||||
// Static callbacks that don't change
|
||||
const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), [])
|
||||
|
||||
const handleEditClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (currentTaskItemId) {
|
||||
FileServiceClient.openFocusChainFile(StringRequest.create({ value: currentTaskItemId }))
|
||||
}
|
||||
},
|
||||
[currentTaskItemId],
|
||||
)
|
||||
|
||||
// Early return for no content
|
||||
if (!todoInfo) {
|
||||
if (!showPlaceholderWhenEmpty) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="relative rounded-sm bg-toolbar-hover/65 flex items-center gap-2 select-none overflow-hidden opacity-80 px-2.5 py-2">
|
||||
<span className="rounded-lg px-2 py-0.25 inline-block shrink-0 bg-badge-foreground/20 text-foreground text-sm">
|
||||
0/0
|
||||
</span>
|
||||
<span className="text-sm text-foreground/80 truncate">TODOs</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpanded && !lastProgressMessageText) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isCompleted = todoInfo.completedCount === todoInfo.totalCount
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={isExpanded ? "Collapse focus chain" : "Expand focus chain"}
|
||||
className="relative rounded-sm bg-toolbar-hover/65 flex flex-col gap-1.5 select-none hover:bg-toolbar-hover overflow-hidden opacity-80 hover:opacity-100 transition-[transform,box-shadow] duration-200 cursor-pointer"
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleToggle()
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
title={CLICK_TO_EDIT_TITLE}>
|
||||
<ToDoListHeader isExpanded={isExpanded} todoInfo={todoInfo} />
|
||||
{isExpanded && (
|
||||
<div className="mx-1 pb-2 px-1 relative" onClick={handleEditClick}>
|
||||
<ChecklistRenderer text={lastProgressMessageText!} />
|
||||
{isCompleted && (
|
||||
<div className="mt-2 text-xs font-semibold text-muted-foreground">{NEW_STEPS_MESSAGE}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison for better performance
|
||||
return (
|
||||
prevProps.lastProgressMessageText === nextProps.lastProgressMessageText &&
|
||||
prevProps.currentTaskItemId === nextProps.currentTaskItemId &&
|
||||
prevProps.showPlaceholderWhenEmpty === nextProps.showPlaceholderWhenEmpty
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
FocusChain.displayName = "FocusChain"
|
||||
@@ -1,99 +0,0 @@
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { COLOR_BEIGE, COLOR_BLUE, COLOR_DARK_GRAY, COLOR_GRAY, COLOR_GREEN, COLOR_PURPLE, COLOR_WHITE } from "../colors"
|
||||
|
||||
/**
|
||||
*
|
||||
* Get the color for a block or the indicator based on the message type
|
||||
*
|
||||
* @param message ClineMessage - The message to determine the color for
|
||||
* @returns string - The color code for the block or indicator
|
||||
*/
|
||||
export const getColor = (message: ClineMessage): string => {
|
||||
if (message.type === "say") {
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return COLOR_WHITE // White for system prompt
|
||||
case "user_feedback":
|
||||
return COLOR_WHITE // White for user feedback
|
||||
case "text":
|
||||
return COLOR_GRAY // Gray for assistant responses
|
||||
case "tool":
|
||||
if (message.text) {
|
||||
try {
|
||||
const toolData = JSON.parse(message.text)
|
||||
if (
|
||||
toolData.tool === "readFile" ||
|
||||
toolData.tool === "listFilesTopLevel" ||
|
||||
toolData.tool === "listFilesRecursive" ||
|
||||
toolData.tool === "listCodeDefinitionNames" ||
|
||||
toolData.tool === "searchFiles"
|
||||
) {
|
||||
return COLOR_BEIGE // Beige for file read operations
|
||||
} else if (
|
||||
toolData.tool === "editedExistingFile" ||
|
||||
toolData.tool === "newFileCreated" ||
|
||||
toolData.tool === "deletedFile"
|
||||
) {
|
||||
return COLOR_BLUE // Blue for file edit/create operations
|
||||
} else if (toolData.tool === "webFetch" || toolData.tool === "webSearch") {
|
||||
return COLOR_PURPLE // Purple for web fetch/search operations
|
||||
}
|
||||
} catch (_e) {
|
||||
// JSON parse error here
|
||||
}
|
||||
}
|
||||
return COLOR_BEIGE // Default beige for tool use
|
||||
case "command":
|
||||
case "command_output":
|
||||
return COLOR_PURPLE // Red for terminal commands
|
||||
case "browser_action":
|
||||
case "browser_action_result":
|
||||
return COLOR_PURPLE // Purple for browser actions
|
||||
case "completion_result":
|
||||
return COLOR_GREEN // Green for task success
|
||||
default:
|
||||
return COLOR_DARK_GRAY // Dark gray for unknown
|
||||
}
|
||||
} else if (message.type === "ask") {
|
||||
switch (message.ask) {
|
||||
case "followup":
|
||||
return COLOR_GRAY // Gray for user messages
|
||||
case "plan_mode_respond":
|
||||
return COLOR_GRAY // Gray for planning responses
|
||||
case "tool":
|
||||
// Match the color of the tool approval with the tool type
|
||||
if (message.text) {
|
||||
try {
|
||||
const toolData = JSON.parse(message.text)
|
||||
if (
|
||||
toolData.tool === "readFile" ||
|
||||
toolData.tool === "listFilesTopLevel" ||
|
||||
toolData.tool === "listFilesRecursive" ||
|
||||
toolData.tool === "listCodeDefinitionNames" ||
|
||||
toolData.tool === "searchFiles"
|
||||
) {
|
||||
return COLOR_BEIGE // Beige for file read operations
|
||||
} else if (
|
||||
toolData.tool === "editedExistingFile" ||
|
||||
toolData.tool === "newFileCreated" ||
|
||||
toolData.tool === "deletedFile"
|
||||
) {
|
||||
return COLOR_BLUE // Blue for file edit/create operations
|
||||
} else if (toolData.tool === "webFetch" || toolData.tool === "webSearch") {
|
||||
return COLOR_PURPLE // Purple for web fetch/search operations
|
||||
}
|
||||
} catch (_e) {
|
||||
// JSON parse error here
|
||||
}
|
||||
}
|
||||
return COLOR_BEIGE // Default beige for tool approvals
|
||||
case "command":
|
||||
return COLOR_PURPLE // Red for command approvals (same as terminal commands)
|
||||
case "browser_action_launch":
|
||||
return COLOR_PURPLE // Purple for browser launch approvals (same as browser actions)
|
||||
default:
|
||||
return COLOR_DARK_GRAY // Dark gray for unknown
|
||||
}
|
||||
}
|
||||
return COLOR_WHITE // Default color
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import React, { ReactNode } from "react"
|
||||
import { OPENROUTER_MODEL_PICKER_Z_INDEX } from "../settings/OpenRouterModelPicker"
|
||||
|
||||
interface AlertDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AlertDialog({ open, onOpenChange, children }: AlertDialogProps) {
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Close the dialog when clicking on the backdrop
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/50 flex items-center justify-center`}
|
||||
onClick={handleBackdropClick}
|
||||
style={{ zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 50 }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AlertDialogContent({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-[50%] left-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}>
|
||||
<div className="bg-(--vscode-editor-background) rounded-sm gap-3 border border-(--vscode-panel-border) p-6 shadow-lg sm:max-w-lg">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AlertDialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={`flex flex-col gap-1 text-left ${className}`} {...props} />
|
||||
}
|
||||
|
||||
export function AlertDialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={`flex flex-row justify-end gap-3 mt-6 ${className}`} {...props} />
|
||||
}
|
||||
|
||||
export function AlertDialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h2
|
||||
className={`text-base font-medium text-(--vscode-editor-foreground) flex items-center gap-2 text-left ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AlertDialogDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={`text-(--vscode-descriptionForeground) text-sm text-left ${className}`} {...props} />
|
||||
}
|
||||
|
||||
export function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof VSCodeButton>) {
|
||||
return <VSCodeButton appearance="primary" {...props} />
|
||||
}
|
||||
|
||||
export function AlertDialogCancel({ className, ...props }: React.ComponentProps<typeof VSCodeButton>) {
|
||||
return <VSCodeButton appearance="secondary" {...props} />
|
||||
}
|
||||
|
||||
export function UnsavedChangesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onSave,
|
||||
title = "Unsaved Changes",
|
||||
description = "You have unsaved changes. Are you sure you want to discard them?",
|
||||
confirmText = "Discard Changes",
|
||||
saveText = "Save & Continue",
|
||||
showSaveOption = false,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
onSave?: () => void
|
||||
title?: string
|
||||
description?: string
|
||||
confirmText?: string
|
||||
saveText?: string
|
||||
showSaveOption?: boolean
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<AlertTriangle className="w-5 h-5 text-(--vscode-errorForeground)" />
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||
{showSaveOption && onSave && <AlertDialogAction onClick={onSave}>{saveText}</AlertDialogAction>}
|
||||
<AlertDialogAction appearance={showSaveOption ? "secondary" : "primary"} onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { cn } from "@heroui/react"
|
||||
import { parseFocusChainItem } from "@shared/focus-chain-utils"
|
||||
import { CheckIcon, CircleIcon } from "lucide-react"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import LightMarkdown from "./LightMarkdown"
|
||||
|
||||
interface ChecklistRendererProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
interface ChecklistItem {
|
||||
checked: boolean
|
||||
text: string
|
||||
}
|
||||
|
||||
const ChecklistRenderer: React.FC<ChecklistRendererProps> = ({ text }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [lastCompletedIndex, setLastCompletedIndex] = useState(-1)
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false)
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout>()
|
||||
|
||||
const parseChecklistItems = (text: string): ChecklistItem[] => {
|
||||
const lines = text.split("\n").filter((line) => line.trim())
|
||||
const items: ChecklistItem[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim()
|
||||
const parsed = parseFocusChainItem(trimmedLine)
|
||||
if (parsed) {
|
||||
items.push({ checked: parsed.checked, text: parsed.text })
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
const items = parseChecklistItems(text)
|
||||
|
||||
// Handle user scroll detection
|
||||
// This prevents jumpy scrolling when the task is streaming and users are viewing the focus chain list
|
||||
const handleScroll = useCallback(() => {
|
||||
setIsUserScrolling(true)
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current)
|
||||
}
|
||||
scrollTimeoutRef.current = setTimeout(() => {
|
||||
setIsUserScrolling(false)
|
||||
}, 1000) // Reset after 1 second of no scrolling
|
||||
}, [])
|
||||
|
||||
// Auto-scroll to show the most recently completed item when in scroll mode
|
||||
useEffect(() => {
|
||||
if (items.length >= 10 && containerRef.current && !isUserScrolling) {
|
||||
// Find the last completed item
|
||||
let currentLastCompletedIndex = -1
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
if (items[i].checked) {
|
||||
currentLastCompletedIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Only auto-scroll if there's a new completion or first time
|
||||
if (currentLastCompletedIndex >= 0 && currentLastCompletedIndex !== lastCompletedIndex) {
|
||||
setLastCompletedIndex(currentLastCompletedIndex)
|
||||
|
||||
// Use scrollIntoView for more accurate positioning
|
||||
const container = containerRef.current
|
||||
const itemElements = container.children
|
||||
if (itemElements[currentLastCompletedIndex]) {
|
||||
itemElements[currentLastCompletedIndex].scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [items, lastCompletedIndex, isUserScrolling])
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (items.length === 0) {
|
||||
// If no checklist items found, return the original text
|
||||
return <div style={{ whiteSpace: "pre-wrap" }}>{text}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("text-sm flex flex-col gap-0.5", items.length >= 10 ? "max-h-52 overflow-y-auto" : "h-auto visible")}
|
||||
onScroll={handleScroll}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
lineHeight: "1.3",
|
||||
}}>
|
||||
{items.map((item, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Using index as key for checklist items
|
||||
<div className="flex items-start gap-1.5 p-0.5" key={`checklist-item-${index}`}>
|
||||
<span className={cn("text-sm shrink-0 mt-0.5", item.checked ? "text-success" : "text-foreground")}>
|
||||
{item.checked ? <CheckIcon size={10} /> : <CircleIcon size={10} />}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm break-words flex-1 leading-5",
|
||||
item.checked ? "text-description line-through" : "text-foreground",
|
||||
)}>
|
||||
<LightMarkdown compact text={item.text} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChecklistRenderer
|
||||
@@ -1,290 +0,0 @@
|
||||
import { CheckpointRestoreRequest } from "@shared/proto/cline/checkpoints"
|
||||
import { Int64Request } from "@shared/proto/cline/common"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useClickAway } from "react-use"
|
||||
import styled from "styled-components"
|
||||
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { CheckpointsServiceClient } from "@/services/grpc-client"
|
||||
|
||||
interface CheckpointOverlayProps {
|
||||
messageTs?: number
|
||||
}
|
||||
|
||||
export const CheckpointOverlay = ({ messageTs }: CheckpointOverlayProps) => {
|
||||
const [compareDisabled, setCompareDisabled] = useState(false)
|
||||
const [restoreTaskDisabled, setRestoreTaskDisabled] = useState(false)
|
||||
const [restoreWorkspaceDisabled, setRestoreWorkspaceDisabled] = useState(false)
|
||||
const [restoreBothDisabled, setRestoreBothDisabled] = useState(false)
|
||||
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false)
|
||||
const [hasMouseEntered, setHasMouseEntered] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const tooltipRef = useRef<HTMLDivElement>(null)
|
||||
const { onRelinquishControl } = useExtensionState()
|
||||
|
||||
useClickAway(containerRef, () => {
|
||||
if (showRestoreConfirm) {
|
||||
setShowRestoreConfirm(false)
|
||||
setHasMouseEntered(false)
|
||||
}
|
||||
})
|
||||
|
||||
// Use the onRelinquishControl hook instead of message event
|
||||
useEffect(() => {
|
||||
return onRelinquishControl(() => {
|
||||
setCompareDisabled(false)
|
||||
setRestoreTaskDisabled(false)
|
||||
setRestoreWorkspaceDisabled(false)
|
||||
setRestoreBothDisabled(false)
|
||||
setShowRestoreConfirm(false)
|
||||
})
|
||||
}, [onRelinquishControl])
|
||||
|
||||
const handleRestoreTask = async () => {
|
||||
setRestoreTaskDisabled(true)
|
||||
try {
|
||||
await CheckpointsServiceClient.checkpointRestore(
|
||||
CheckpointRestoreRequest.create({
|
||||
number: messageTs,
|
||||
restoreType: "task",
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
console.error("Checkpoint restore task error:", err)
|
||||
setRestoreTaskDisabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestoreWorkspace = async () => {
|
||||
setRestoreWorkspaceDisabled(true)
|
||||
try {
|
||||
await CheckpointsServiceClient.checkpointRestore(
|
||||
CheckpointRestoreRequest.create({
|
||||
number: messageTs,
|
||||
restoreType: "workspace",
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
console.error("Checkpoint restore workspace error:", err)
|
||||
setRestoreWorkspaceDisabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestoreBoth = async () => {
|
||||
setRestoreBothDisabled(true)
|
||||
try {
|
||||
await CheckpointsServiceClient.checkpointRestore(
|
||||
CheckpointRestoreRequest.create({
|
||||
number: messageTs,
|
||||
restoreType: "taskAndWorkspace",
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
console.error("Checkpoint restore both error:", err)
|
||||
setRestoreBothDisabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHasMouseEntered(true)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (hasMouseEntered) {
|
||||
setShowRestoreConfirm(false)
|
||||
setHasMouseEntered(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleControlsMouseLeave = (e: React.MouseEvent) => {
|
||||
const tooltipElement = tooltipRef.current
|
||||
|
||||
if (tooltipElement && showRestoreConfirm) {
|
||||
const tooltipRect = tooltipElement.getBoundingClientRect()
|
||||
|
||||
// If mouse is moving towards the tooltip, don't close it
|
||||
if (
|
||||
e.clientY >= tooltipRect.top &&
|
||||
e.clientY <= tooltipRect.bottom &&
|
||||
e.clientX >= tooltipRect.left &&
|
||||
e.clientX <= tooltipRect.right
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setShowRestoreConfirm(false)
|
||||
setHasMouseEntered(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CheckpointControls className="hover:opacity-100" onMouseLeave={handleControlsMouseLeave}>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
disabled={compareDisabled}
|
||||
onClick={async () => {
|
||||
setCompareDisabled(true)
|
||||
try {
|
||||
await CheckpointsServiceClient.checkpointDiff(
|
||||
Int64Request.create({
|
||||
value: messageTs,
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
console.error("CheckpointDiff error:", err)
|
||||
} finally {
|
||||
setCompareDisabled(false)
|
||||
}
|
||||
}}
|
||||
style={{ cursor: compareDisabled ? "wait" : "pointer" }}
|
||||
title="Compare">
|
||||
<i className="codicon codicon-diff-multiple" style={{ position: "absolute" }} />
|
||||
</VSCodeButton>
|
||||
<div ref={containerRef} style={{ position: "relative" }}>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
onClick={() => setShowRestoreConfirm(true)}
|
||||
style={{ cursor: "pointer" }}
|
||||
title="Restore">
|
||||
<i className="codicon codicon-discard" style={{ position: "absolute" }} />
|
||||
</VSCodeButton>
|
||||
{showRestoreConfirm && (
|
||||
<RestoreConfirmTooltip onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} ref={tooltipRef}>
|
||||
<RestoreOption>
|
||||
<VSCodeButton
|
||||
disabled={restoreBothDisabled}
|
||||
onClick={handleRestoreBoth}
|
||||
style={{
|
||||
cursor: restoreBothDisabled ? "wait" : "pointer",
|
||||
}}>
|
||||
Restore Task and Workspace
|
||||
</VSCodeButton>
|
||||
<p>Restores the task and your project's files back to a snapshot taken at this point</p>
|
||||
</RestoreOption>
|
||||
<RestoreOption>
|
||||
<VSCodeButton
|
||||
disabled={restoreTaskDisabled}
|
||||
onClick={handleRestoreTask}
|
||||
style={{
|
||||
cursor: restoreTaskDisabled ? "wait" : "pointer",
|
||||
}}>
|
||||
Restore Task Only
|
||||
</VSCodeButton>
|
||||
<p>Deletes messages after this point (does not affect workspace)</p>
|
||||
</RestoreOption>
|
||||
<RestoreOption>
|
||||
<VSCodeButton
|
||||
disabled={restoreWorkspaceDisabled}
|
||||
onClick={handleRestoreWorkspace}
|
||||
style={{
|
||||
cursor: restoreWorkspaceDisabled ? "wait" : "pointer",
|
||||
}}>
|
||||
Restore Workspace Only
|
||||
</VSCodeButton>
|
||||
<p>Restores your project's files to a snapshot taken at this point (task may become out of sync)</p>
|
||||
</RestoreOption>
|
||||
</RestoreConfirmTooltip>
|
||||
)}
|
||||
</div>
|
||||
</CheckpointControls>
|
||||
)
|
||||
}
|
||||
|
||||
export const CheckpointControls = styled.div`
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
padding: 3px 0 3px 3px;
|
||||
|
||||
& > vscode-button,
|
||||
& > div > vscode-button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
& > vscode-button i,
|
||||
& > div > vscode-button i {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
`
|
||||
|
||||
const RestoreOption = styled.div`
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--vscode-editorGroup-border);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 2px 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
&:last-child p {
|
||||
margin: 0 0 -2px 0;
|
||||
}
|
||||
|
||||
vscode-button {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
`
|
||||
|
||||
const RestoreConfirmTooltip = styled.div`
|
||||
position: absolute;
|
||||
top: calc(100% - 0.5px);
|
||||
right: 0;
|
||||
background: ${CODE_BLOCK_BG_COLOR};
|
||||
border: 1px solid var(--vscode-editorGroup-border);
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
margin-top: 8px;
|
||||
width: calc(100vw - 57px);
|
||||
min-width: 0px;
|
||||
max-width: 100vw;
|
||||
z-index: 1000;
|
||||
|
||||
// Add invisible padding to create a safe hover zone
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -8px; // Same as margin-top
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
// Adjust arrow to be above the padding
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: ${CODE_BLOCK_BG_COLOR};
|
||||
border-left: 1px solid var(--vscode-editorGroup-border);
|
||||
border-top: 1px solid var(--vscode-editorGroup-border);
|
||||
transform: rotate(45deg);
|
||||
z-index: 1; // Ensure arrow stays above the padding
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 6px 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
`
|
||||
@@ -1,123 +0,0 @@
|
||||
import {
|
||||
VSCodeBadge,
|
||||
VSCodeButton,
|
||||
VSCodeCheckbox,
|
||||
VSCodeDataGrid,
|
||||
VSCodeDataGridCell,
|
||||
VSCodeDataGridRow,
|
||||
VSCodeDivider,
|
||||
VSCodeDropdown,
|
||||
VSCodeLink,
|
||||
VSCodeOption,
|
||||
VSCodePanels,
|
||||
VSCodePanelTab,
|
||||
VSCodePanelView,
|
||||
VSCodeProgressRing,
|
||||
VSCodeRadio,
|
||||
VSCodeRadioGroup,
|
||||
VSCodeTag,
|
||||
VSCodeTextArea,
|
||||
VSCodeTextField,
|
||||
} from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
function Demo() {
|
||||
const rowData = [
|
||||
{
|
||||
cell1: "Cell Data",
|
||||
cell2: "Cell Data",
|
||||
cell3: "Cell Data",
|
||||
cell4: "Cell Data",
|
||||
},
|
||||
{
|
||||
cell1: "Cell Data",
|
||||
cell2: "Cell Data",
|
||||
cell3: "Cell Data",
|
||||
cell4: "Cell Data",
|
||||
},
|
||||
{
|
||||
cell1: "Cell Data",
|
||||
cell2: "Cell Data",
|
||||
cell3: "Cell Data",
|
||||
cell4: "Cell Data",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Hello World!</h1>
|
||||
<VSCodeButton>Howdy!</VSCodeButton>
|
||||
|
||||
<div className="grid gap-3 p-2 place-items-start">
|
||||
<VSCodeDataGrid>
|
||||
<VSCodeDataGridRow row-type="header">
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="1">
|
||||
A Custom Header Title
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="2">
|
||||
Another Custom Title
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="3">
|
||||
Title Is Custom
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="4">
|
||||
Custom Title
|
||||
</VSCodeDataGridCell>
|
||||
</VSCodeDataGridRow>
|
||||
{rowData.map((row, index) => (
|
||||
<VSCodeDataGridRow key={index}>
|
||||
<VSCodeDataGridCell grid-column="1">{row.cell1}</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell grid-column="2">{row.cell2}</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell grid-column="3">{row.cell3}</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell grid-column="4">{row.cell4}</VSCodeDataGridCell>
|
||||
</VSCodeDataGridRow>
|
||||
))}
|
||||
</VSCodeDataGrid>
|
||||
|
||||
<VSCodeTextField>
|
||||
<section slot="end" style={{ display: "flex", alignItems: "center" }}>
|
||||
<VSCodeButton appearance="icon" aria-label="Match Case">
|
||||
<span className="codicon codicon-case-sensitive"></span>
|
||||
</VSCodeButton>
|
||||
<VSCodeButton appearance="icon" aria-label="Match Whole Word">
|
||||
<span className="codicon codicon-whole-word"></span>
|
||||
</VSCodeButton>
|
||||
<VSCodeButton appearance="icon" aria-label="Use Regular Expression">
|
||||
<span className="codicon codicon-regex"></span>
|
||||
</VSCodeButton>
|
||||
</section>
|
||||
</VSCodeTextField>
|
||||
<span className="codicon codicon-chevron-right" slot="end"></span>
|
||||
|
||||
<span className="flex gap-3">
|
||||
<VSCodeProgressRing />
|
||||
<VSCodeTextField />
|
||||
<VSCodeButton>Add</VSCodeButton>
|
||||
<VSCodeButton appearance="secondary">Remove</VSCodeButton>
|
||||
</span>
|
||||
|
||||
<VSCodeBadge>Badge</VSCodeBadge>
|
||||
<VSCodeCheckbox>Checkbox</VSCodeCheckbox>
|
||||
<VSCodeDivider />
|
||||
<VSCodeDropdown>
|
||||
<VSCodeOption>Option 1</VSCodeOption>
|
||||
<VSCodeOption>Option 2</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
<VSCodeLink href="#">Link</VSCodeLink>
|
||||
<VSCodePanels>
|
||||
<VSCodePanelTab id="tab-1">Tab 1</VSCodePanelTab>
|
||||
<VSCodePanelTab id="tab-2">Tab 2</VSCodePanelTab>
|
||||
<VSCodePanelView id="view-1">Panel View 1</VSCodePanelView>
|
||||
<VSCodePanelView id="view-2">Panel View 2</VSCodePanelView>
|
||||
</VSCodePanels>
|
||||
<VSCodeRadioGroup>
|
||||
<VSCodeRadio>Radio 1</VSCodeRadio>
|
||||
<VSCodeRadio>Radio 2</VSCodeRadio>
|
||||
</VSCodeRadioGroup>
|
||||
<VSCodeTag>Tag</VSCodeTag>
|
||||
<VSCodeTextArea placeholder="Text Area" />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default Demo
|
||||
@@ -1,166 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface LightMarkdownProps {
|
||||
text: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Super-lightweight emphasis parser.
|
||||
* Scope:
|
||||
* - Supported: bold (**text**), italic (*text*)
|
||||
* - Not supported: headers, links, code, lists, HTML, full Markdown spec
|
||||
* - Underscore-based emphasis is intentionally NOT supported to avoid snake_case false positives
|
||||
* - Unmatched markers render literally
|
||||
*
|
||||
* Design goals:
|
||||
* - O(n) single-pass scanning with minimal allocations
|
||||
* - No recursive substring tail mutation
|
||||
* - Memoize parsed output to avoid recomputation on parent re-renders
|
||||
*/
|
||||
|
||||
// Parse inline emphasis for a single line of text.
|
||||
// Supports nested emphasis in a simple way by parsing inner segments recursively.
|
||||
// Returns an array of strings and React elements (<strong>, <em>).
|
||||
function parseInlineEmphasis(text: string, nextKey: () => string): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = []
|
||||
const len = text.length
|
||||
|
||||
// Fast path: if no '*' at all, return as a single text segment
|
||||
const firstStar = text.indexOf("*")
|
||||
if (firstStar === -1) {
|
||||
out.push(text)
|
||||
return out
|
||||
}
|
||||
|
||||
let i = 0
|
||||
let segmentStart = 0
|
||||
|
||||
while (i < len) {
|
||||
const starIdx = text.indexOf("*", i)
|
||||
if (starIdx === -1) {
|
||||
// push trailing literal
|
||||
if (segmentStart < len) {
|
||||
out.push(text.slice(segmentStart, len))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Check for bold start (**)
|
||||
if (starIdx + 1 < len && text[starIdx + 1] === "*") {
|
||||
const contentStart = starIdx + 2
|
||||
const endIdx = text.indexOf("**", contentStart)
|
||||
if (endIdx !== -1 && endIdx > contentStart) {
|
||||
// flush literal before match
|
||||
if (segmentStart < starIdx) {
|
||||
out.push(text.slice(segmentStart, starIdx))
|
||||
}
|
||||
const inner = text.slice(contentStart, endIdx)
|
||||
// Allow simple nested emphasis by parsing inner content
|
||||
const children = parseInlineEmphasis(inner, nextKey)
|
||||
out.push(<strong key={nextKey()}>{children}</strong>)
|
||||
i = endIdx + 2
|
||||
segmentStart = i
|
||||
continue
|
||||
} else {
|
||||
// unmatched bold opener - treat the first '*' as literal and continue
|
||||
i = starIdx + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Italic start (*)
|
||||
const contentStart = starIdx + 1
|
||||
const endIdx = text.indexOf("*", contentStart)
|
||||
if (endIdx !== -1 && endIdx > contentStart) {
|
||||
// flush literal before match
|
||||
if (segmentStart < starIdx) {
|
||||
out.push(text.slice(segmentStart, starIdx))
|
||||
}
|
||||
const inner = text.slice(contentStart, endIdx)
|
||||
// Allow simple nested emphasis by parsing inner content
|
||||
const children = parseInlineEmphasis(inner, nextKey)
|
||||
out.push(<em key={nextKey()}>{children}</em>)
|
||||
i = endIdx + 1
|
||||
segmentStart = i
|
||||
} else {
|
||||
// unmatched italic opener - treat '*' as literal and continue
|
||||
i = starIdx + 1
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Split by lines and compose inline nodes.
|
||||
// compact=false: each line becomes a block-level span
|
||||
// compact=true: inline-only across lines, with no additional separators (preserves prior behavior)
|
||||
function parseTextToNodes(text: string, compact: boolean): React.ReactNode {
|
||||
// Global fast path: if no '*' anywhere, short-circuit
|
||||
if (text.indexOf("*") === -1) {
|
||||
if (compact) {
|
||||
// Return as a single text node (no extra wrappers)
|
||||
return text
|
||||
}
|
||||
// Non-compact: render each line as block span for layout consistency
|
||||
const lines = text.split(/\r?\n/)
|
||||
let keyCounter = 0
|
||||
const nextKey = () => `lm-${keyCounter++}`
|
||||
return (
|
||||
<React.Fragment>
|
||||
{lines.map((line) => (
|
||||
<span key={nextKey()} style={{ display: "block" }}>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
const lines = text.split(/\r?\n/)
|
||||
let keyCounter = 0
|
||||
const nextKey = () => `lm-${keyCounter++}`
|
||||
|
||||
if (compact) {
|
||||
// Flatten inline nodes across lines; no extra separators to preserve minimalism
|
||||
const flat: React.ReactNode[] = []
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const inlineNodes = parseInlineEmphasis(lines[li], nextKey)
|
||||
for (let j = 0; j < inlineNodes.length; j++) {
|
||||
const node = inlineNodes[j]
|
||||
// Ensure each node in the top-level array has a key to avoid React key warnings
|
||||
if (React.isValidElement(node)) {
|
||||
flat.push(node.key == null ? React.cloneElement(node, { key: nextKey() }) : node)
|
||||
} else {
|
||||
// Wrap strings in keyed fragment (no extra DOM)
|
||||
flat.push(<React.Fragment key={nextKey()}>{node}</React.Fragment>)
|
||||
}
|
||||
}
|
||||
}
|
||||
return <>{flat}</>
|
||||
} else {
|
||||
// Block-level lines; keys applied at line level
|
||||
return (
|
||||
<React.Fragment>
|
||||
{lines.map((line) => (
|
||||
<span key={nextKey()} style={{ display: "block" }}>
|
||||
{parseInlineEmphasis(line, nextKey)}
|
||||
</span>
|
||||
))}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const LightMarkdown: React.FC<LightMarkdownProps> = ({ text, compact = false }) => {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Memoize parsed output; recompute only when inputs change
|
||||
const content = React.useMemo(() => parseTextToNodes(text, compact), [text, compact])
|
||||
|
||||
return <>{content}</>
|
||||
}
|
||||
|
||||
export default React.memo(LightMarkdown)
|
||||
@@ -1,50 +0,0 @@
|
||||
import { TelemetrySettingEnum, TelemetrySettingRequest } from "@shared/proto/cline/state"
|
||||
import { useCallback } from "react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { StateServiceClient } from "@/services/grpc-client"
|
||||
|
||||
const telemetryRequest = TelemetrySettingRequest.create({
|
||||
setting: TelemetrySettingEnum.ENABLED,
|
||||
})
|
||||
|
||||
export const TelemetryBanner: React.FC = () => {
|
||||
const { navigateToSettings } = useExtensionState()
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
StateServiceClient.updateTelemetrySetting(telemetryRequest).catch(console.error)
|
||||
}, [])
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
handleClose()
|
||||
navigateToSettings()
|
||||
}, [handleClose, navigateToSettings])
|
||||
|
||||
return (
|
||||
<div className="bg-banner-background text-banner-foreground px-3 py-2 flex flex-col gap-1 shrink-0 mb-1 relative text-sm m-4">
|
||||
<h3 className="m-0">Help Improve Cline</h3>
|
||||
<i>(and access experimental features)</i>
|
||||
<p className="m-0">
|
||||
Cline collects error and usage data to help us fix bugs and improve the extension. No code, prompts, or personal
|
||||
information is ever sent.
|
||||
</p>
|
||||
<p className="m-0">
|
||||
<span>You can turn this setting off in </span>
|
||||
<span className="text-link cursor-pointer" onClick={handleOpenSettings}>
|
||||
settings
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
aria-label="Close banner and enable telemetry"
|
||||
className="absolute top-3 right-3 opacity-70 hover:opacity-100 cursor-pointer border-0 bg-transparent p-0 text-inherit"
|
||||
onClick={handleClose}
|
||||
type="button">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TelemetryBanner
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import styled from "styled-components"
|
||||
import { LINKS } from "@/constants"
|
||||
import { McpServiceClient } from "@/services/grpc-client"
|
||||
|
||||
type AddLocalServerFormProps = {
|
||||
onServerAdded: () => void
|
||||
}
|
||||
|
||||
const AddLocalServerForm = ({}: AddLocalServerFormProps) => {
|
||||
return (
|
||||
<FormContainer>
|
||||
<div className="text-(--vscode-foreground)">
|
||||
Add a local MCP server by configuring it in <code>cline_mcp_settings.json</code>. You'll need to specify the
|
||||
server name, command, arguments, and any required environment variables in the JSON configuration. Learn more
|
||||
<VSCodeLink href={LINKS.DOCUMENTATION.LOCAL_MCP_SERVER_DOCS} style={{ display: "inline" }}>
|
||||
here.
|
||||
</VSCodeLink>
|
||||
</div>
|
||||
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
onClick={() => {
|
||||
McpServiceClient.openMcpSettings(EmptyRequest.create({})).catch((error) => {
|
||||
console.error("Error opening MCP settings:", error)
|
||||
})
|
||||
}}
|
||||
style={{ width: "100%", marginBottom: "5px", marginTop: 8 }}>
|
||||
Open cline_mcp_settings.json
|
||||
</VSCodeButton>
|
||||
</FormContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const FormContainer = styled.div`
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
export default AddLocalServerForm
|
||||
@@ -1,16 +0,0 @@
|
||||
import styled from "styled-components"
|
||||
|
||||
const CollapsibleContent = styled.div<{ isOpen: boolean }>`
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.3s ease-in-out,
|
||||
opacity 0.3s ease-in-out,
|
||||
margin-top 0.3s ease-in-out,
|
||||
visibility 0.3s ease-in-out;
|
||||
max-height: ${({ isOpen }) => (isOpen ? "1000px" : "0")};
|
||||
opacity: ${({ isOpen }) => (isOpen ? 1 : 0)};
|
||||
margin-top: ${({ isOpen }) => (isOpen ? "15px" : "0")};
|
||||
visibility: ${({ isOpen }) => (isOpen ? "visible" : "hidden")};
|
||||
`
|
||||
|
||||
export default CollapsibleContent
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from "react"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
|
||||
interface SettingsSliderProps {
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
description?: string
|
||||
/** Width of the value display span (default: w-12) */
|
||||
valueWidth?: string
|
||||
}
|
||||
|
||||
const SettingsSlider: React.FC<SettingsSliderProps> = ({
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
onChange,
|
||||
description,
|
||||
valueWidth = "w-12",
|
||||
}) => {
|
||||
const handleSliderChange = (values: number[]) => {
|
||||
onChange(values[0])
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label className="space-y-0.5 flex-1 text-xs text-description">{label}</Label>
|
||||
<span className={`text-sm font-mono text-foreground ${valueWidth} text-right`}>{value}</span>
|
||||
</div>
|
||||
<Slider className="mt-2" max={max} min={min} onValueChange={handleSliderChange} step={step} value={[value]} />
|
||||
{description && <p className="text-xs text-description mt-2">{description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsSlider
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
/**
|
||||
* Props for the ErrorMessage component
|
||||
*/
|
||||
interface ErrorMessageProps {
|
||||
message: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable component for displaying error messages
|
||||
*/
|
||||
export const ErrorMessage = ({ message, style }: ErrorMessageProps) => {
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
margin: "-10px 0 4px 0",
|
||||
fontSize: 12,
|
||||
color: "var(--vscode-errorForeground)",
|
||||
...style,
|
||||
}}>
|
||||
{message}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import styled from "styled-components"
|
||||
import { BROWSER_VIEWPORT_PRESETS } from "../../../../../src/shared/BrowserSettings"
|
||||
import { useExtensionState } from "../../../context/ExtensionStateContext"
|
||||
import { BrowserServiceClient } from "../../../services/grpc-client"
|
||||
import CollapsibleContent from "../CollapsibleContent"
|
||||
import { DebouncedTextField } from "../common/DebouncedTextField"
|
||||
import Section from "../Section"
|
||||
import { updateSetting } from "../utils/settingsHandlers"
|
||||
|
||||
interface BrowserSettingsSectionProps {
|
||||
renderSectionHeader: (tabId: string) => JSX.Element | null
|
||||
}
|
||||
|
||||
const ConnectionStatusIndicator = ({
|
||||
isChecking,
|
||||
isConnected,
|
||||
remoteBrowserEnabled,
|
||||
}: {
|
||||
isChecking: boolean
|
||||
isConnected: boolean | null
|
||||
remoteBrowserEnabled?: boolean
|
||||
}) => {
|
||||
if (!remoteBrowserEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusContainer>
|
||||
{isChecking ? (
|
||||
<>
|
||||
<Spinner />
|
||||
<StatusText>Checking connection...</StatusText>
|
||||
</>
|
||||
) : isConnected === true ? (
|
||||
<>
|
||||
<CheckIcon className="codicon codicon-check" />
|
||||
<StatusText style={{ color: "var(--vscode-terminal-ansiGreen)" }}>Connected</StatusText>
|
||||
</>
|
||||
) : isConnected === false ? (
|
||||
<StatusText style={{ color: "var(--vscode-errorForeground)" }}>Not connected</StatusText>
|
||||
) : null}
|
||||
</StatusContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export const BrowserSettingsSection: React.FC<BrowserSettingsSectionProps> = ({ renderSectionHeader }) => {
|
||||
const { browserSettings } = useExtensionState()
|
||||
const [isCheckingConnection, setIsCheckingConnection] = useState(false)
|
||||
const [connectionStatus, setConnectionStatus] = useState<boolean | null>(null)
|
||||
const [relaunchResult, setRelaunchResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [debugMode, setDebugMode] = useState(false)
|
||||
const [isBundled, setIsBundled] = useState(false)
|
||||
const [detectedChromePath, setDetectedChromePath] = useState<string | null>(null)
|
||||
|
||||
// Auto-clear relaunch result message after 15 seconds
|
||||
useEffect(() => {
|
||||
if (relaunchResult) {
|
||||
const timer = setTimeout(() => {
|
||||
setRelaunchResult(null)
|
||||
}, 15000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [relaunchResult])
|
||||
|
||||
// Request detected Chrome path on mount
|
||||
useEffect(() => {
|
||||
BrowserServiceClient.getDetectedChromePath(EmptyRequest.create({}))
|
||||
.then((result) => {
|
||||
setDetectedChromePath(result.path)
|
||||
setIsBundled(result.isBundled)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error getting detected Chrome path:", error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Function to check connection once without changing UI state immediately
|
||||
const checkConnectionOnce = useCallback(() => {
|
||||
if (browserSettings.remoteBrowserHost) {
|
||||
BrowserServiceClient.testBrowserConnection(StringRequest.create({ value: browserSettings.remoteBrowserHost }))
|
||||
.then((result) => {
|
||||
setConnectionStatus(result.success)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error testing browser connection:", error)
|
||||
setConnectionStatus(false)
|
||||
})
|
||||
} else {
|
||||
BrowserServiceClient.discoverBrowser(EmptyRequest.create({}))
|
||||
.then((result) => {
|
||||
setConnectionStatus(result.success)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error discovering browser:", error)
|
||||
setConnectionStatus(false)
|
||||
})
|
||||
}
|
||||
}, [browserSettings.remoteBrowserHost])
|
||||
|
||||
// Setup continuous polling for connection status when remote browser is enabled
|
||||
useEffect(() => {
|
||||
if (!browserSettings.remoteBrowserEnabled) {
|
||||
setIsCheckingConnection(false)
|
||||
return
|
||||
}
|
||||
|
||||
checkConnectionOnce()
|
||||
const pollInterval = setInterval(() => {
|
||||
checkConnectionOnce()
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(pollInterval)
|
||||
}, [browserSettings.remoteBrowserEnabled, checkConnectionOnce])
|
||||
|
||||
const handleViewportChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const selectedSize = BROWSER_VIEWPORT_PRESETS[target.value as keyof typeof BROWSER_VIEWPORT_PRESETS]
|
||||
if (selectedSize) {
|
||||
updateSetting("browserSettings", {
|
||||
viewport: {
|
||||
width: selectedSize.width,
|
||||
height: selectedSize.height,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const relaunchChromeDebugMode = () => {
|
||||
setDebugMode(true)
|
||||
setRelaunchResult(null)
|
||||
|
||||
BrowserServiceClient.relaunchChromeDebugMode(EmptyRequest.create({}))
|
||||
.then((result) => {
|
||||
setRelaunchResult({
|
||||
success: true,
|
||||
message: result.value,
|
||||
})
|
||||
setDebugMode(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error relaunching Chrome:", error)
|
||||
setRelaunchResult({
|
||||
success: false,
|
||||
message: `Error relaunching Chrome: ${error.message}`,
|
||||
})
|
||||
setDebugMode(false)
|
||||
})
|
||||
}
|
||||
|
||||
// Determine if we should show the relaunch button
|
||||
const isRemoteEnabled = Boolean(browserSettings.remoteBrowserEnabled)
|
||||
const shouldShowRelaunchButton = isRemoteEnabled && connectionStatus === false
|
||||
const isSubSettingsOpen = !(browserSettings.disableToolUse || false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderSectionHeader("browser")}
|
||||
<Section>
|
||||
<div id="browser-settings-section" style={{ marginBottom: 20 }}>
|
||||
{/* Master Toggle */}
|
||||
<div style={{ marginBottom: isSubSettingsOpen ? 0 : 10 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={browserSettings.disableToolUse || false}
|
||||
onChange={(e) =>
|
||||
updateSetting("browserSettings", { disableToolUse: (e.target as HTMLInputElement).checked })
|
||||
}>
|
||||
Disable browser tool usage
|
||||
</VSCodeCheckbox>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
margin: "4px 0 0 0px",
|
||||
}}>
|
||||
Prevent Cline from using browser actions (e.g. launch, click, type).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent isOpen={isSubSettingsOpen}>
|
||||
<div style={{ marginBottom: 15 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>Viewport size</label>
|
||||
<VSCodeDropdown
|
||||
onChange={(event) => handleViewportChange(event as Event)}
|
||||
style={{ width: "100%" }}
|
||||
value={
|
||||
Object.entries(BROWSER_VIEWPORT_PRESETS).find(([_, size]) => {
|
||||
const typedSize = size as { width: number; height: number }
|
||||
return (
|
||||
typedSize.width === browserSettings.viewport.width &&
|
||||
typedSize.height === browserSettings.viewport.height
|
||||
)
|
||||
})?.[0]
|
||||
}>
|
||||
{Object.entries(BROWSER_VIEWPORT_PRESETS).map(([name]) => (
|
||||
<VSCodeOption key={name} value={name}>
|
||||
{name}
|
||||
</VSCodeOption>
|
||||
))}
|
||||
</VSCodeDropdown>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
margin: 0,
|
||||
}}>
|
||||
Set the size of the browser viewport for screenshots and interactions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
{" "}
|
||||
{/* This div now contains Remote Connection & Chrome Path */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}>
|
||||
<VSCodeCheckbox
|
||||
checked={browserSettings.remoteBrowserEnabled}
|
||||
onChange={(e) => {
|
||||
const enabled = (e.target as HTMLInputElement).checked
|
||||
updateSetting("browserSettings", { remoteBrowserEnabled: enabled })
|
||||
// If disabling, also clear the host
|
||||
if (!enabled) {
|
||||
updateSetting("browserSettings", { remoteBrowserHost: undefined })
|
||||
}
|
||||
}}>
|
||||
Use remote browser connection
|
||||
</VSCodeCheckbox>
|
||||
<ConnectionStatusIndicator
|
||||
isChecking={isCheckingConnection}
|
||||
isConnected={connectionStatus}
|
||||
remoteBrowserEnabled={browserSettings.remoteBrowserEnabled}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
margin: "0 0 6px 0px",
|
||||
}}>
|
||||
Enable Cline to use your Chrome
|
||||
{isBundled
|
||||
? "(not detected on your machine)"
|
||||
: detectedChromePath
|
||||
? ` (${detectedChromePath})`
|
||||
: ""}
|
||||
. You can specify a custom path below. Using a remote browser connection requires starting Chrome
|
||||
in debug mode
|
||||
{browserSettings.remoteBrowserEnabled ? (
|
||||
<>
|
||||
{" "}
|
||||
manually (<code>--remote-debugging-port=9222</code>) or using the button below. Enter the
|
||||
host address or leave it blank for automatic discovery.
|
||||
</>
|
||||
) : (
|
||||
"."
|
||||
)}
|
||||
</p>
|
||||
{/* Moved remote-specific settings to appear directly after enabling remote connection */}
|
||||
{browserSettings.remoteBrowserEnabled && (
|
||||
<div style={{ marginLeft: 0, marginTop: 8 }}>
|
||||
<DebouncedTextField
|
||||
initialValue={browserSettings.remoteBrowserHost || ""}
|
||||
onChange={(value) =>
|
||||
updateSetting("browserSettings", { remoteBrowserHost: value || undefined })
|
||||
}
|
||||
placeholder="http://localhost:9222"
|
||||
style={{ width: "100%", marginBottom: 8 }}
|
||||
/>
|
||||
|
||||
{shouldShowRelaunchButton && (
|
||||
<div style={{ display: "flex", gap: "10px", marginBottom: 8, justifyContent: "center" }}>
|
||||
<VSCodeButton
|
||||
disabled={debugMode}
|
||||
onClick={relaunchChromeDebugMode}
|
||||
style={{ flex: 1 }}>
|
||||
{debugMode ? "Launching Browser..." : "Launch Browser with Debug Mode"}
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{relaunchResult && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px",
|
||||
marginBottom: "8px",
|
||||
backgroundColor: relaunchResult.success
|
||||
? "rgba(0, 128, 0, 0.1)"
|
||||
: "rgba(255, 0, 0, 0.1)",
|
||||
color: relaunchResult.success
|
||||
? "var(--vscode-terminal-ansiGreen)"
|
||||
: "var(--vscode-terminal-ansiRed)",
|
||||
borderRadius: "3px",
|
||||
fontSize: "11px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{relaunchResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
margin: 0,
|
||||
}}></p>
|
||||
</div>
|
||||
)}
|
||||
{/* Chrome Executable Path section now follows remote-specific settings */}
|
||||
<div style={{ marginBottom: 8, marginTop: 8 }}>
|
||||
<label
|
||||
htmlFor="chrome-executable-path"
|
||||
style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>
|
||||
Chrome Executable Path (Optional)
|
||||
</label>
|
||||
<DebouncedTextField
|
||||
id="chrome-executable-path"
|
||||
initialValue={browserSettings.chromeExecutablePath || ""}
|
||||
onChange={(value) => updateSetting("browserSettings", { chromeExecutablePath: value })}
|
||||
placeholder="e.g., /usr/bin/google-chrome or C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
margin: "4px 0 0 0",
|
||||
}}>
|
||||
Leave blank to auto-detect.
|
||||
</p>
|
||||
</div>
|
||||
{/* Custom Browser Arguments section */}
|
||||
<div style={{ marginBottom: 8, marginTop: 8 }}>
|
||||
<label
|
||||
htmlFor="custom-browser-args"
|
||||
style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>
|
||||
Custom Browser Arguments (Optional)
|
||||
</label>
|
||||
<DebouncedTextField
|
||||
id="custom-browser-args"
|
||||
initialValue={browserSettings.customArgs || ""}
|
||||
onChange={(value) => updateSetting("browserSettings", { customArgs: value })}
|
||||
placeholder="e.g., --no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --disable-gpu --no-first-run --no-zygote"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
margin: "4px 0 0 0",
|
||||
}}>
|
||||
Space-separated arguments to pass to the browser executable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const StatusContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 12px;
|
||||
height: 20px;
|
||||
`
|
||||
|
||||
const StatusText = styled.span`
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
`
|
||||
|
||||
const CheckIcon = styled.i`
|
||||
color: var(--vscode-terminal-ansiGreen);
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
const Spinner = styled.div`
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--vscode-progressBar-background);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default BrowserSettingsSection
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
ref={ref}
|
||||
{...props}>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-foreground" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-foreground/50 bg-foreground shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useFeatureFlagEnabled } from "posthog-js/react"
|
||||
import { useExtensionState } from "../context/ExtensionStateContext"
|
||||
|
||||
/**
|
||||
* Hook to check feature flag status in the webview
|
||||
* Feature flags work independently of telemetry settings to ensure
|
||||
* proper extension functionality regardless of user privacy preferences.
|
||||
*
|
||||
* In self-hosted mode, always returns false since PostHog is disabled.
|
||||
*/
|
||||
export const useHasFeatureFlag = (flagName: string): boolean => {
|
||||
const { environment } = useExtensionState()
|
||||
// Treat unknown/undefined/null/empty environment as selfHosted (safety fallback)
|
||||
const isSelfHostedOrUnknown = !environment || environment === "selfHosted"
|
||||
|
||||
// Note: We must call useFeatureFlagEnabled unconditionally due to React's Rules of Hooks.
|
||||
// In selfHosted mode, PostHog isn't initialized so this returns undefined (harmless no-op).
|
||||
const flagEnabled = useFeatureFlagEnabled(isSelfHostedOrUnknown ? "" : flagName)
|
||||
|
||||
if (isSelfHostedOrUnknown) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (flagEnabled && typeof flagEnabled === "boolean") {
|
||||
return flagEnabled
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import type { ApiConfiguration, ApiProvider } from "@shared/api"
|
||||
import PROVIDERS from "@shared/providers/providers.json"
|
||||
import type { RemoteConfigFields } from "@shared/storage/state-keys"
|
||||
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
|
||||
|
||||
/**
|
||||
* Returns a list of API providers that are configured (have required credentials/settings)
|
||||
* Based on validation logic from validate.ts
|
||||
*/
|
||||
export function getConfiguredProviders(
|
||||
remoteConfig: Partial<RemoteConfigFields> | undefined,
|
||||
apiConfiguration: ApiConfiguration | undefined,
|
||||
): ApiProvider[] {
|
||||
if (remoteConfig?.remoteConfiguredProviders?.length) {
|
||||
return remoteConfig.remoteConfiguredProviders
|
||||
}
|
||||
|
||||
const configured: ApiProvider[] = []
|
||||
|
||||
if (!apiConfiguration) {
|
||||
return ["cline"] // Cline is always available
|
||||
}
|
||||
|
||||
// Cline - always available (uses account-based auth)
|
||||
configured.push("cline")
|
||||
|
||||
// Anthropic - requires API key
|
||||
if (apiConfiguration.apiKey) {
|
||||
configured.push("anthropic")
|
||||
}
|
||||
|
||||
// OpenRouter - requires API key
|
||||
if (apiConfiguration.openRouterApiKey) {
|
||||
configured.push("openrouter")
|
||||
}
|
||||
|
||||
// Bedrock - requires region
|
||||
if (apiConfiguration.awsRegion) {
|
||||
configured.push("bedrock")
|
||||
}
|
||||
|
||||
// Vertex - requires project ID and region
|
||||
if (apiConfiguration.vertexProjectId && apiConfiguration.vertexRegion) {
|
||||
configured.push("vertex")
|
||||
}
|
||||
|
||||
// Gemini - requires API key
|
||||
if (apiConfiguration.geminiApiKey) {
|
||||
configured.push("gemini")
|
||||
}
|
||||
|
||||
// OpenAI Native - requires API key
|
||||
if (apiConfiguration.openAiNativeApiKey) {
|
||||
configured.push("openai-native")
|
||||
}
|
||||
|
||||
// OpenAI Codex - subscription-based OAuth, always available
|
||||
configured.push("openai-codex")
|
||||
|
||||
// DeepSeek - requires API key
|
||||
if (apiConfiguration.deepSeekApiKey) {
|
||||
configured.push("deepseek")
|
||||
}
|
||||
|
||||
// xAI - requires API key
|
||||
if (apiConfiguration.xaiApiKey) {
|
||||
configured.push("xai")
|
||||
}
|
||||
|
||||
// Qwen - requires API key
|
||||
if (apiConfiguration.qwenApiKey) {
|
||||
configured.push("qwen")
|
||||
}
|
||||
|
||||
// Doubao - requires API key
|
||||
if (apiConfiguration.doubaoApiKey) {
|
||||
configured.push("doubao")
|
||||
}
|
||||
|
||||
// Mistral - requires API key
|
||||
if (apiConfiguration.mistralApiKey) {
|
||||
configured.push("mistral")
|
||||
}
|
||||
|
||||
// Requesty - requires API key
|
||||
if (apiConfiguration.requestyApiKey) {
|
||||
configured.push("requesty")
|
||||
}
|
||||
|
||||
// Fireworks - requires API key
|
||||
if (apiConfiguration.fireworksApiKey) {
|
||||
configured.push("fireworks")
|
||||
}
|
||||
|
||||
// Together - requires API key
|
||||
if (apiConfiguration.togetherApiKey) {
|
||||
configured.push("together")
|
||||
}
|
||||
|
||||
// Moonshot - requires API key
|
||||
if (apiConfiguration.moonshotApiKey) {
|
||||
configured.push("moonshot")
|
||||
}
|
||||
|
||||
// Nebius - requires API key
|
||||
if (apiConfiguration.nebiusApiKey) {
|
||||
configured.push("nebius")
|
||||
}
|
||||
|
||||
// AskSage - requires API key
|
||||
if (apiConfiguration.asksageApiKey) {
|
||||
configured.push("asksage")
|
||||
}
|
||||
|
||||
// SambaNova - requires API key
|
||||
if (apiConfiguration.sambanovaApiKey) {
|
||||
configured.push("sambanova")
|
||||
}
|
||||
|
||||
// Cerebras - requires API key
|
||||
if (apiConfiguration.cerebrasApiKey) {
|
||||
configured.push("cerebras")
|
||||
}
|
||||
|
||||
// SAP AI Core - requires base URL, client ID, client secret, and token URL
|
||||
if (
|
||||
apiConfiguration.sapAiCoreBaseUrl &&
|
||||
apiConfiguration.sapAiCoreClientId &&
|
||||
apiConfiguration.sapAiCoreClientSecret &&
|
||||
apiConfiguration.sapAiCoreTokenUrl
|
||||
) {
|
||||
configured.push("sapaicore")
|
||||
}
|
||||
|
||||
// Z AI - requires API key
|
||||
if (apiConfiguration.zaiApiKey) {
|
||||
configured.push("zai")
|
||||
}
|
||||
|
||||
// Groq - requires API key
|
||||
if (apiConfiguration.groqApiKey) {
|
||||
configured.push("groq")
|
||||
}
|
||||
|
||||
// Hugging Face - requires API key
|
||||
if (apiConfiguration.huggingFaceApiKey) {
|
||||
configured.push("huggingface")
|
||||
}
|
||||
|
||||
// Baseten - requires API key
|
||||
if (apiConfiguration.basetenApiKey) {
|
||||
configured.push("baseten")
|
||||
}
|
||||
|
||||
// Dify - requires base URL and API key
|
||||
if (apiConfiguration.difyBaseUrl && apiConfiguration.difyApiKey) {
|
||||
configured.push("dify")
|
||||
}
|
||||
|
||||
// Minimax - requires API key
|
||||
if (apiConfiguration.minimaxApiKey) {
|
||||
configured.push("minimax")
|
||||
}
|
||||
|
||||
// Hicap - requires API key
|
||||
if (apiConfiguration.hicapApiKey) {
|
||||
configured.push("hicap")
|
||||
}
|
||||
|
||||
// Huawei Cloud MaaS - requires API key
|
||||
if (apiConfiguration.huaweiCloudMaasApiKey) {
|
||||
configured.push("huawei-cloud-maas")
|
||||
}
|
||||
|
||||
// Vercel AI Gateway - requires API key
|
||||
if (apiConfiguration.vercelAiGatewayApiKey) {
|
||||
configured.push("vercel-ai-gateway")
|
||||
}
|
||||
|
||||
// AIHubMix - requires API key
|
||||
if (apiConfiguration.aihubmixApiKey) {
|
||||
configured.push("aihubmix")
|
||||
}
|
||||
|
||||
// NousResearch - requires API key
|
||||
if (apiConfiguration.nousResearchApiKey) {
|
||||
configured.push("nousResearch")
|
||||
}
|
||||
|
||||
// OpenAI Compatible - requires base URL and API key, OR has model configured
|
||||
if (
|
||||
(apiConfiguration.openAiBaseUrl && apiConfiguration.openAiApiKey) ||
|
||||
apiConfiguration.planModeOpenAiModelId ||
|
||||
apiConfiguration.actModeOpenAiModelId
|
||||
) {
|
||||
configured.push("openai")
|
||||
}
|
||||
|
||||
// Ollama - local provider, check base URL OR model configured
|
||||
if (apiConfiguration.ollamaBaseUrl || apiConfiguration.planModeOllamaModelId || apiConfiguration.actModeOllamaModelId) {
|
||||
configured.push("ollama")
|
||||
}
|
||||
|
||||
// LM Studio - local provider, check base URL OR model configured
|
||||
if (apiConfiguration.lmStudioBaseUrl || apiConfiguration.planModeLmStudioModelId || apiConfiguration.actModeLmStudioModelId) {
|
||||
configured.push("lmstudio")
|
||||
}
|
||||
|
||||
// LiteLLM - check base URL, API key OR model configured
|
||||
if (
|
||||
apiConfiguration.liteLlmBaseUrl ||
|
||||
apiConfiguration.liteLlmApiKey ||
|
||||
apiConfiguration.planModeLiteLlmModelId ||
|
||||
apiConfiguration.actModeLiteLlmModelId
|
||||
) {
|
||||
configured.push("litellm")
|
||||
}
|
||||
|
||||
// VSCode LM - VS Code only (the vscode.lm API does not exist on other hosts
|
||||
// such as JetBrains). Potentially available there whenever models are installed.
|
||||
if (PLATFORM_CONFIG.type === PlatformType.VSCODE) {
|
||||
configured.push("vscode-lm")
|
||||
}
|
||||
|
||||
// Claude Code - requires path
|
||||
if (apiConfiguration.claudeCodePath) {
|
||||
configured.push("claude-code")
|
||||
}
|
||||
|
||||
// Qwen Code - requires API key (same as Qwen)
|
||||
if (apiConfiguration.qwenApiKey) {
|
||||
configured.push("qwen-code")
|
||||
}
|
||||
|
||||
// OCA - requires base URL
|
||||
if (apiConfiguration.ocaBaseUrl) {
|
||||
configured.push("oca")
|
||||
}
|
||||
|
||||
return configured
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider display label from provider value
|
||||
* Uses the canonical providers.json as source of truth
|
||||
*/
|
||||
export function getProviderLabel(provider: ApiProvider): string {
|
||||
const providerEntry = PROVIDERS.list.find((p) => p.value === provider)
|
||||
return providerEntry?.label || provider
|
||||
}
|
||||
Reference in New Issue
Block a user