mirror of
https://github.com/cline/cline.git
synced 2026-09-03 12:14:00 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea57ccaa06 | |||
| 04616fbbe2 |
+8
-1
@@ -318,7 +318,14 @@
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Cline",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"cline.taskStoragePath": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Specifies a custom absolute path for storing Cline task data. Useful for ensuring persistence in devcontainer or remote environments where the default global storage might be ephemeral. If empty, uses the default VS Code global storage path.",
|
||||
"scope": "machine-overridable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -130,7 +130,24 @@ export class Controller {
|
||||
}
|
||||
|
||||
async initTask(task?: string, images?: string[], files?: string[], historyItem?: HistoryItem) {
|
||||
console.log("[TASK_LOAD] Controller: initTask called with historyItem:", historyItem?.id)
|
||||
console.log("[TASK_LOAD] Controller: Task details:", {
|
||||
hasTask: !!task,
|
||||
hasImages: !!images,
|
||||
hasFiles: !!files,
|
||||
historyItemDetails: historyItem
|
||||
? {
|
||||
id: historyItem.id,
|
||||
task: historyItem.task?.substring(0, 50) + "...",
|
||||
ts: historyItem.ts,
|
||||
isFavorited: historyItem.isFavorited,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
|
||||
await this.clearTask() // ensures that an existing task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
|
||||
console.log("[TASK_LOAD] Controller: Cleared existing task")
|
||||
|
||||
const {
|
||||
apiConfiguration,
|
||||
autoApprovalSettings,
|
||||
@@ -145,6 +162,8 @@ export class Controller {
|
||||
taskHistory,
|
||||
} = await getAllExtensionState(this.context)
|
||||
|
||||
console.log("[TASK_LOAD] Controller: Got extension state, taskHistory length:", taskHistory?.length)
|
||||
|
||||
const NEW_USER_TASK_COUNT_THRESHOLD = 10
|
||||
|
||||
// Check if the user has completed enough tasks to no longer be considered a "new user"
|
||||
@@ -160,6 +179,7 @@ export class Controller {
|
||||
}
|
||||
await updateGlobalState(this.context, "autoApprovalSettings", updatedAutoApprovalSettings)
|
||||
}
|
||||
console.log("[TASK_LOAD] Controller: Creating new Task instance")
|
||||
this.task = new Task(
|
||||
this.context,
|
||||
this.mcpHub,
|
||||
@@ -183,6 +203,7 @@ export class Controller {
|
||||
files,
|
||||
historyItem,
|
||||
)
|
||||
console.log("[TASK_LOAD] Controller: Task instance created successfully")
|
||||
}
|
||||
|
||||
async reinitExistingTaskFromId(taskId: string) {
|
||||
|
||||
@@ -12,20 +12,25 @@ import { sendChatButtonClickedEvent } from "../ui/subscribeToChatButtonClicked"
|
||||
export async function showTaskWithId(controller: Controller, request: StringRequest): Promise<TaskResponse> {
|
||||
try {
|
||||
const id = request.value
|
||||
console.log("[TASK_LOAD] Backend: showTaskWithId called with ID:", id)
|
||||
|
||||
// First check if task exists in global state for faster access
|
||||
const taskHistory = ((await controller.context.globalState.get("taskHistory")) as any[]) || []
|
||||
console.log("[TASK_LOAD] Backend: Total tasks in history:", taskHistory.length)
|
||||
const historyItem = taskHistory.find((item) => item.id === id)
|
||||
|
||||
// We need to initialize the task before returning data
|
||||
if (historyItem) {
|
||||
console.log("[TASK_LOAD] Backend: Found task in global state, initializing...")
|
||||
// Always initialize the task with the history item
|
||||
await controller.initTask(undefined, undefined, undefined, historyItem)
|
||||
|
||||
// Send UI update to show the chat view
|
||||
console.log("[TASK_LOAD] Backend: Sending chat button clicked event")
|
||||
await sendChatButtonClickedEvent(controller.id)
|
||||
|
||||
// Return task data for gRPC response
|
||||
console.log("[TASK_LOAD] Backend: Returning task data from global state")
|
||||
return TaskResponse.create({
|
||||
id: historyItem.id,
|
||||
task: historyItem.task || "",
|
||||
@@ -41,14 +46,18 @@ export async function showTaskWithId(controller: Controller, request: StringRequ
|
||||
}
|
||||
|
||||
// If not in global state, fetch from storage
|
||||
console.log("[TASK_LOAD] Backend: Task not in global state, fetching from storage...")
|
||||
const { historyItem: fetchedItem } = await controller.getTaskWithId(id)
|
||||
|
||||
// Initialize the task with the fetched item
|
||||
console.log("[TASK_LOAD] Backend: Fetched task from storage, initializing...")
|
||||
await controller.initTask(undefined, undefined, undefined, fetchedItem)
|
||||
|
||||
// Send UI update to show the chat view
|
||||
console.log("[TASK_LOAD] Backend: Sending chat button clicked event")
|
||||
await sendChatButtonClickedEvent(controller.id)
|
||||
|
||||
console.log("[TASK_LOAD] Backend: Returning task data from storage")
|
||||
return TaskResponse.create({
|
||||
id: fetchedItem.id,
|
||||
task: fetchedItem.task || "",
|
||||
@@ -62,7 +71,7 @@ export async function showTaskWithId(controller: Controller, request: StringRequ
|
||||
cacheReads: fetchedItem.cacheReads || 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error in showTaskWithId:", error)
|
||||
console.error("[TASK_LOAD] Backend: Error in showTaskWithId:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
+232
-22
@@ -7,6 +7,26 @@ import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { TaskMetadata } from "@core/context/context-tracking/ContextTrackerTypes"
|
||||
import os from "os"
|
||||
import { execa } from "@packages/execa"
|
||||
import { sanitizeStringForJSON } from "@utils/string"
|
||||
import { isDataValidJSON } from "@utils/validation"
|
||||
|
||||
// Helper function to recursively sanitize strings within an object or array
|
||||
function sanitizeObjectForJSON(data: any): any {
|
||||
if (typeof data === "string") {
|
||||
return sanitizeStringForJSON(data)
|
||||
} else if (Array.isArray(data)) {
|
||||
return data.map(sanitizeObjectForJSON)
|
||||
} else if (typeof data === "object" && data !== null) {
|
||||
const sanitizedObject: { [key: string]: any } = {}
|
||||
for (const key in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||
sanitizedObject[key] = sanitizeObjectForJSON(data[key])
|
||||
}
|
||||
}
|
||||
return sanitizedObject
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
@@ -59,9 +79,43 @@ export async function getDocumentsPath(): Promise<string> {
|
||||
}
|
||||
|
||||
export async function ensureTaskDirectoryExists(context: vscode.ExtensionContext, taskId: string): Promise<string> {
|
||||
const globalStoragePath = context.globalStorageUri.fsPath
|
||||
const taskDir = path.join(globalStoragePath, "tasks", taskId)
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
const config = vscode.workspace.getConfiguration("cline")
|
||||
const customTaskStoragePath = config.get<string>("taskStoragePath")?.trim()
|
||||
|
||||
let baseStoragePath: string
|
||||
if (customTaskStoragePath && customTaskStoragePath.length > 0) {
|
||||
// Ensure the custom path is absolute. If not, this could lead to unpredictable behavior.
|
||||
// For simplicity, we'll currently assume users provide a valid absolute path.
|
||||
// More robust validation (e.g., checking if path.isAbsolute) could be added.
|
||||
if (!path.isAbsolute(customTaskStoragePath)) {
|
||||
console.warn(
|
||||
`Custom task storage path "${customTaskStoragePath}" is not absolute. Using default global storage.`,
|
||||
)
|
||||
baseStoragePath = context.globalStorageUri.fsPath
|
||||
} else {
|
||||
baseStoragePath = customTaskStoragePath
|
||||
console.log(`Using custom task storage path: ${baseStoragePath}`)
|
||||
}
|
||||
} else {
|
||||
baseStoragePath = context.globalStorageUri.fsPath
|
||||
}
|
||||
|
||||
const taskDir = path.join(baseStoragePath, "tasks", taskId)
|
||||
try {
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error(`Failed to create task directory at ${taskDir}:`, error)
|
||||
// Fallback to default global storage if custom path fails, to prevent total failure.
|
||||
// This could happen due to permission issues with the custom path.
|
||||
if (baseStoragePath !== context.globalStorageUri.fsPath) {
|
||||
console.warn(`Falling back to default global storage path due to error with custom path.`)
|
||||
baseStoragePath = context.globalStorageUri.fsPath
|
||||
const fallbackTaskDir = path.join(baseStoragePath, "tasks", taskId)
|
||||
await fs.mkdir(fallbackTaskDir, { recursive: true }) // Attempt with fallback
|
||||
return fallbackTaskDir
|
||||
}
|
||||
throw error // Re-throw if default path also fails
|
||||
}
|
||||
return taskDir
|
||||
}
|
||||
|
||||
@@ -109,9 +163,32 @@ export async function getSavedApiConversationHistory(
|
||||
taskId: string,
|
||||
): Promise<Anthropic.MessageParam[]> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.apiConversationHistory)
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
if (fileExists) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
const backupFilePath = `${filePath}.bak`
|
||||
|
||||
try {
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
let fileContent = await fs.readFile(filePath, "utf8")
|
||||
// Strip BOM if present
|
||||
if (fileContent.startsWith("\uFEFF")) {
|
||||
fileContent = fileContent.substring(1)
|
||||
}
|
||||
return JSON.parse(fileContent)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse ${filePath}:`, error, "Attempting to restore from backup.")
|
||||
try {
|
||||
if (await fileExistsAtPath(backupFilePath)) {
|
||||
const backupContent = await fs.readFile(backupFilePath, "utf8")
|
||||
const jsonData = JSON.parse(backupContent) // Validate backup JSON
|
||||
await fs.writeFile(filePath, backupContent, "utf8") // Restore main file from backup
|
||||
console.log(`Successfully restored ${filePath} from backup.`)
|
||||
return jsonData
|
||||
} else {
|
||||
console.warn(`Backup file ${backupFilePath} not found.`)
|
||||
}
|
||||
} catch (backupError) {
|
||||
console.error(`Failed to restore ${filePath} from backup:`, backupError)
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -122,8 +199,35 @@ export async function saveApiConversationHistory(
|
||||
apiConversationHistory: Anthropic.MessageParam[],
|
||||
) {
|
||||
try {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.apiConversationHistory)
|
||||
await fs.writeFile(filePath, JSON.stringify(apiConversationHistory))
|
||||
const taskDir = await ensureTaskDirectoryExists(context, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
|
||||
const backupFilePath = `${filePath}.bak`
|
||||
|
||||
// Create backup
|
||||
try {
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
await fs.copyFile(filePath, backupFilePath)
|
||||
}
|
||||
} catch (backupError) {
|
||||
console.error(`Failed to create backup for ${filePath}:`, backupError)
|
||||
// Continue even if backup fails, as saving the current data is more critical
|
||||
}
|
||||
|
||||
const sanitizedHistory = sanitizeObjectForJSON(apiConversationHistory)
|
||||
if (!isDataValidJSON(sanitizedHistory)) {
|
||||
console.error(
|
||||
`Skipping save for ${filePath} due to invalid JSON structure after sanitization. Please check the data.`,
|
||||
)
|
||||
return // Do not write corrupted data
|
||||
}
|
||||
|
||||
const stringifiedData = JSON.stringify(sanitizedHistory)
|
||||
const dataSizeMB = Buffer.byteLength(stringifiedData, "utf8") / (1024 * 1024)
|
||||
if (dataSizeMB > 5) { // Log if data is larger than 5MB
|
||||
console.warn(`Saving large API conversation history: ${filePath}, Size: ${dataSizeMB.toFixed(2)}MB`)
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, stringifiedData, "utf8")
|
||||
} catch (error) {
|
||||
// in the off chance this fails, we don't want to stop the task
|
||||
console.error("Failed to save API conversation history:", error)
|
||||
@@ -131,18 +235,53 @@ export async function saveApiConversationHistory(
|
||||
}
|
||||
|
||||
export async function getSavedClineMessages(context: vscode.ExtensionContext, taskId: string): Promise<ClineMessage[]> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.uiMessages)
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
} else {
|
||||
// check old location
|
||||
const oldPath = path.join(await ensureTaskDirectoryExists(context, taskId), "claude_messages.json")
|
||||
if (await fileExistsAtPath(oldPath)) {
|
||||
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
|
||||
await fs.unlink(oldPath) // remove old file
|
||||
return data
|
||||
const taskDir = await ensureTaskDirectoryExists(context, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
|
||||
const backupFilePath = `${filePath}.bak`
|
||||
|
||||
try {
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
let fileContent = await fs.readFile(filePath, "utf8")
|
||||
// Strip BOM if present
|
||||
if (fileContent.startsWith("\uFEFF")) {
|
||||
fileContent = fileContent.substring(1)
|
||||
}
|
||||
return JSON.parse(fileContent)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse ${filePath}:`, error, "Attempting to restore from backup.")
|
||||
try {
|
||||
if (await fileExistsAtPath(backupFilePath)) {
|
||||
const backupContent = await fs.readFile(backupFilePath, "utf8")
|
||||
const jsonData = JSON.parse(backupContent) // Validate backup JSON
|
||||
await fs.writeFile(filePath, backupContent, "utf8") // Restore main file from backup
|
||||
console.log(`Successfully restored ${filePath} from backup.`)
|
||||
return jsonData
|
||||
} else {
|
||||
console.warn(`Backup file ${backupFilePath} not found.`)
|
||||
}
|
||||
} catch (backupError) {
|
||||
console.error(`Failed to restore ${filePath} from backup:`, backupError)
|
||||
}
|
||||
}
|
||||
|
||||
// If both primary and backup fail, check old location as a last resort
|
||||
const oldPath = path.join(taskDir, "claude_messages.json")
|
||||
if (await fileExistsAtPath(oldPath)) {
|
||||
console.warn(`Primary and backup for ${filePath} failed. Checking old location ${oldPath}.`)
|
||||
try {
|
||||
const oldFileContent = await fs.readFile(oldPath, "utf8")
|
||||
const data = JSON.parse(oldFileContent)
|
||||
// Attempt to save it to the new location (this will also create a backup)
|
||||
await saveClineMessages(context, taskId, data)
|
||||
await fs.unlink(oldPath) // remove old file after successful save
|
||||
console.log(`Successfully migrated data from ${oldPath} to ${filePath}.`)
|
||||
return data
|
||||
} catch (oldFileError) {
|
||||
console.error(`Failed to read or migrate from old file ${oldPath}:`, oldFileError)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -150,7 +289,32 @@ export async function saveClineMessages(context: vscode.ExtensionContext, taskId
|
||||
try {
|
||||
const taskDir = await ensureTaskDirectoryExists(context, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
|
||||
await fs.writeFile(filePath, JSON.stringify(uiMessages))
|
||||
const backupFilePath = `${filePath}.bak`
|
||||
|
||||
// Create backup
|
||||
try {
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
await fs.copyFile(filePath, backupFilePath)
|
||||
}
|
||||
} catch (backupError) {
|
||||
console.error(`Failed to create backup for ${filePath}:`, backupError)
|
||||
}
|
||||
|
||||
const sanitizedMessages = sanitizeObjectForJSON(uiMessages)
|
||||
if (!isDataValidJSON(sanitizedMessages)) {
|
||||
console.error(
|
||||
`Skipping save for ${filePath} due to invalid JSON structure after sanitization. Please check the data.`,
|
||||
)
|
||||
return // Do not write corrupted data
|
||||
}
|
||||
|
||||
const stringifiedData = JSON.stringify(sanitizedMessages)
|
||||
const dataSizeMB = Buffer.byteLength(stringifiedData, "utf8") / (1024 * 1024)
|
||||
if (dataSizeMB > 5) { // Log if data is larger than 5MB
|
||||
console.warn(`Saving large UI messages: ${filePath}, Size: ${dataSizeMB.toFixed(2)}MB`)
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, stringifiedData, "utf8")
|
||||
} catch (error) {
|
||||
console.error("Failed to save ui messages:", error)
|
||||
}
|
||||
@@ -158,13 +322,34 @@ export async function saveClineMessages(context: vscode.ExtensionContext, taskId
|
||||
|
||||
export async function getTaskMetadata(context: vscode.ExtensionContext, taskId: string): Promise<TaskMetadata> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.taskMetadata)
|
||||
const backupFilePath = `${filePath}.bak`
|
||||
|
||||
try {
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
let fileContent = await fs.readFile(filePath, "utf8")
|
||||
// Strip BOM if present
|
||||
if (fileContent.startsWith("\uFEFF")) {
|
||||
fileContent = fileContent.substring(1)
|
||||
}
|
||||
return JSON.parse(fileContent)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to read task metadata:", error)
|
||||
console.warn(`Failed to parse ${filePath}:`, error, "Attempting to restore from backup.")
|
||||
try {
|
||||
if (await fileExistsAtPath(backupFilePath)) {
|
||||
const backupContent = await fs.readFile(backupFilePath, "utf8")
|
||||
const jsonData = JSON.parse(backupContent) // Validate backup
|
||||
await fs.writeFile(filePath, backupContent, "utf8") // Restore main file
|
||||
console.log(`Successfully restored ${filePath} from backup.`)
|
||||
return jsonData
|
||||
} else {
|
||||
console.warn(`Backup file ${backupFilePath} not found.`)
|
||||
}
|
||||
} catch (backupError) {
|
||||
console.error(`Failed to restore ${filePath} from backup:`, backupError)
|
||||
}
|
||||
}
|
||||
// Default empty metadata if all attempts fail
|
||||
return { files_in_context: [], model_usage: [] }
|
||||
}
|
||||
|
||||
@@ -172,7 +357,32 @@ export async function saveTaskMetadata(context: vscode.ExtensionContext, taskId:
|
||||
try {
|
||||
const taskDir = await ensureTaskDirectoryExists(context, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
|
||||
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2))
|
||||
const backupFilePath = `${filePath}.bak`
|
||||
|
||||
// Create backup
|
||||
try {
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
await fs.copyFile(filePath, backupFilePath)
|
||||
}
|
||||
} catch (backupError) {
|
||||
console.error(`Failed to create backup for ${filePath}:`, backupError)
|
||||
}
|
||||
|
||||
const sanitizedMetadata = sanitizeObjectForJSON(metadata)
|
||||
if (!isDataValidJSON(sanitizedMetadata)) {
|
||||
console.error(
|
||||
`Skipping save for ${filePath} due to invalid JSON structure after sanitization. Please check the data.`,
|
||||
)
|
||||
return // Do not write corrupted data
|
||||
}
|
||||
|
||||
const stringifiedData = JSON.stringify(sanitizedMetadata, null, 2)
|
||||
const dataSizeMB = Buffer.byteLength(stringifiedData, "utf8") / (1024 * 1024)
|
||||
if (dataSizeMB > 1) { // Metadata is usually smaller, log if > 1MB
|
||||
console.warn(`Saving large task metadata: ${filePath}, Size: ${dataSizeMB.toFixed(2)}MB`)
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, stringifiedData, "utf8")
|
||||
} catch (error) {
|
||||
console.error("Failed to save task metadata:", error)
|
||||
}
|
||||
|
||||
@@ -276,8 +276,10 @@ export class Task {
|
||||
|
||||
// Continue with task initialization
|
||||
if (historyItem) {
|
||||
console.log("[TASK_LOAD] Task: Resuming task from history item:", historyItem.id)
|
||||
this.resumeTaskFromHistory()
|
||||
} else if (task || images || files) {
|
||||
console.log("[TASK_LOAD] Task: Starting new task")
|
||||
this.startTask(task, images, files)
|
||||
}
|
||||
|
||||
@@ -977,6 +979,7 @@ export class Task {
|
||||
}
|
||||
|
||||
private async resumeTaskFromHistory() {
|
||||
console.log("[TASK_LOAD] Task: resumeTaskFromHistory called for task:", this.taskId)
|
||||
try {
|
||||
await this.clineIgnoreController.initialize()
|
||||
} catch (error) {
|
||||
@@ -991,6 +994,7 @@ export class Task {
|
||||
// }
|
||||
|
||||
const savedClineMessages = await getSavedClineMessages(this.getContext(), this.taskId)
|
||||
console.log("[TASK_LOAD] Task: Loaded saved Cline messages, count:", savedClineMessages.length)
|
||||
|
||||
// Remove any resume messages that may have been added before
|
||||
const lastRelevantMessageIndex = findLastIndex(
|
||||
@@ -1018,6 +1022,7 @@ export class Task {
|
||||
// This is important in case the user deletes messages without resuming the task first
|
||||
const context = this.getContext()
|
||||
const savedApiConversationHistory = await getSavedApiConversationHistory(context, this.taskId)
|
||||
console.log("[TASK_LOAD] Task: Loaded saved API conversation history, count:", savedApiConversationHistory.length)
|
||||
this.messageStateHandler.setApiConversationHistory(savedApiConversationHistory)
|
||||
|
||||
// load the context history state
|
||||
@@ -1039,6 +1044,7 @@ export class Task {
|
||||
}
|
||||
|
||||
this.taskState.isInitialized = true
|
||||
console.log("[TASK_LOAD] Task: Task initialized, asking user to resume with type:", askType)
|
||||
|
||||
const { response, text, images, files } = await this.ask(askType) // calls poststatetowebview
|
||||
let responseText: string | undefined
|
||||
|
||||
@@ -55,3 +55,50 @@ describe("removeInvalidChars", () => {
|
||||
removeInvalidChars("normal string").should.equal("normal string")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sanitizeStringForJSON", () => {
|
||||
const { sanitizeStringForJSON } = require("./string") // Use require for conditional import if needed or ensure build step
|
||||
|
||||
it("should replace multiplication sign × with x", () => {
|
||||
sanitizeStringForJSON("Error: 2 × 3").should.equal("Error: 2 x 3")
|
||||
})
|
||||
|
||||
it("should remove Unicode replacement character �", () => {
|
||||
sanitizeStringForJSON("Hello\uFFFDWorld").should.equal("HelloWorld")
|
||||
})
|
||||
|
||||
it("should remove multiple Unicode replacement characters", () => {
|
||||
sanitizeStringForJSON("H\uFFFDe\uFFFDl\uFFFDlo").should.equal("Hello")
|
||||
})
|
||||
|
||||
it("should handle strings that are already clean", () => {
|
||||
sanitizeStringForJSON("This is a clean string.").should.equal("This is a clean string.")
|
||||
})
|
||||
|
||||
it("should return non-string input as is", () => {
|
||||
const obj = { a: 1 }
|
||||
sanitizeStringForJSON(obj).should.equal(obj)
|
||||
sanitizeStringForJSON(null).should.be.null()
|
||||
sanitizeStringForJSON(undefined).should.be.undefined()
|
||||
sanitizeStringForJSON(123).should.equal(123)
|
||||
})
|
||||
|
||||
it("should attempt to filter invalid UTF-8 sequences (basic test)", () => {
|
||||
// This is a simple test. Real invalid sequences are harder to inject directly in JS strings.
|
||||
// Buffer conversion often helps clean up some malformed sequences.
|
||||
const invalidSequenceAttempt = "test" + String.fromCharCode(0xD800) + "sequence" // High surrogate without low
|
||||
// The behavior of Buffer.from().toString() with isolated surrogates can be platform/Node version dependent.
|
||||
// It might replace them with � (which then gets removed) or handle them differently.
|
||||
// The goal is it doesn't crash and produces a string.
|
||||
const result = sanitizeStringForJSON(invalidSequenceAttempt)
|
||||
result.should.not.containEql(String.fromCharCode(0xD800)) // Expect the invalid part to be changed/removed
|
||||
})
|
||||
|
||||
it("should handle empty string", () => {
|
||||
sanitizeStringForJSON("").should.equal("")
|
||||
})
|
||||
|
||||
it("should handle mixed problematic characters", () => {
|
||||
sanitizeStringForJSON("Error × \uFFFD fixed").should.equal("Error x fixed")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,3 +20,29 @@ export function fixModelHtmlEscaping(text: string): string {
|
||||
export function removeInvalidChars(text: string): string {
|
||||
return text.replace(/\uFFFD/g, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a string to be safely included in JSON.
|
||||
* Handles known problematic characters and ensures basic UTF-8 validity.
|
||||
* @param text String to sanitize
|
||||
* @returns Sanitized string
|
||||
*/
|
||||
export function sanitizeStringForJSON(text: string): string {
|
||||
if (typeof text !== "string") {
|
||||
return text
|
||||
}
|
||||
|
||||
// Replace specific problematic characters
|
||||
let sanitizedText = text.replace(/×/g, "x") // Replace multiplication sign often found in npm errors
|
||||
|
||||
// Remove Unicode replacement character � (often indicates encoding issues)
|
||||
sanitizedText = sanitizedText.replace(/\uFFFD/g, "")
|
||||
|
||||
// Attempt to filter out invalid UTF-8 sequences.
|
||||
// This is a basic approach; more complex scenarios might need a dedicated library.
|
||||
sanitizedText = Buffer.from(sanitizedText, "utf8").toString("utf8")
|
||||
|
||||
// Add any other specific character replacements or removals here if needed
|
||||
|
||||
return sanitizedText
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { isDataValidJSON, validateThinkingBudget } from "./validation" // Assuming validateThinkingBudget is also in validation.ts
|
||||
|
||||
describe("isDataValidJSON", () => {
|
||||
it("should return true for valid JSON-serializable objects", () => {
|
||||
isDataValidJSON({ a: 1, b: "hello", c: [1, 2, 3] }).should.be.true()
|
||||
isDataValidJSON([{ x: true }, { y: null }]).should.be.true()
|
||||
isDataValidJSON("string").should.be.true()
|
||||
isDataValidJSON(123).should.be.true()
|
||||
isDataValidJSON(true).should.be.true()
|
||||
isDataValidJSON(null).should.be.true()
|
||||
})
|
||||
|
||||
it("should return false for objects with circular references", () => {
|
||||
const obj: any = { a: 1 }
|
||||
obj.b = obj // Circular reference
|
||||
isDataValidJSON(obj).should.be.false()
|
||||
})
|
||||
|
||||
it("should return false for BigInt by default (requires custom replacer)", () => {
|
||||
// JSON.stringify throws for BigInt unless a replacer is used
|
||||
isDataValidJSON({ val: BigInt(123) }).should.be.false()
|
||||
})
|
||||
|
||||
it("should return true for objects containing undefined (as they are handled by JSON.stringify)", () => {
|
||||
// JSON.stringify omits object properties with undefined values
|
||||
// and converts undefined in arrays to null.
|
||||
isDataValidJSON({ a: undefined, b: 1 }).should.be.true()
|
||||
isDataValidJSON([1, undefined, 2]).should.be.true()
|
||||
})
|
||||
|
||||
it("should return true for functions (as they are handled by JSON.stringify)", () => {
|
||||
// JSON.stringify converts functions to null in arrays or omits them in objects.
|
||||
isDataValidJSON({ func: () => console.log("hello") }).should.be.true()
|
||||
isDataValidJSON([() => 1, 2]).should.be.true()
|
||||
})
|
||||
|
||||
it("should return true for an empty object and empty array", () => {
|
||||
isDataValidJSON({}).should.be.true()
|
||||
isDataValidJSON([]).should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
// Basic placeholder test for validateThinkingBudget if it's in the same file
|
||||
// This should be expanded based on its actual logic if testing thoroughly
|
||||
describe("validateThinkingBudget", () => {
|
||||
it("should return 0 if input is 0", () => {
|
||||
validateThinkingBudget(0, 200000).should.equal(0)
|
||||
})
|
||||
it("should handle other cases of validateThinkingBudget (add more tests if needed)", () => {
|
||||
validateThinkingBudget(500, 200000).should.equal(1024) // less than min
|
||||
validateThinkingBudget(1500, 200000).should.equal(1500) // valid
|
||||
validateThinkingBudget(180000, 200000).should.equal(160000) // Math.floor(200000 * 0.8)
|
||||
})
|
||||
})
|
||||
@@ -34,3 +34,22 @@ export function validateThinkingBudget(
|
||||
// Otherwise, return the original value
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given data can be successfully stringified and parsed as JSON.
|
||||
* This is a basic test to catch unserializable data or very broken structures.
|
||||
* @param data The data to validate.
|
||||
* @returns True if the data is valid for JSON serialization, false otherwise.
|
||||
*/
|
||||
export function isDataValidJSON(data: any): boolean {
|
||||
try {
|
||||
// Attempt to stringify and then parse. If this succeeds, the structure is generally valid.
|
||||
const stringified = JSON.stringify(data)
|
||||
JSON.parse(stringified)
|
||||
return true
|
||||
} catch (error) {
|
||||
// Log the specific error for debugging, but return false to indicate validation failure.
|
||||
console.error("JSON validation failed during stringify/parse check:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,11 +165,30 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
}
|
||||
}, [searchQuery, sortOption, lastNonRelevantSort])
|
||||
|
||||
const handleShowTaskWithId = useCallback((id: string) => {
|
||||
TaskServiceClient.showTaskWithId(StringRequest.create({ value: id })).catch((error) =>
|
||||
console.error("Error showing task:", error),
|
||||
)
|
||||
}, [])
|
||||
const handleShowTaskWithId = useCallback(
|
||||
(id: string) => {
|
||||
console.log("[TASK_LOAD] Frontend: User clicked task with ID:", id)
|
||||
console.log("[TASK_LOAD] Frontend: Current task history length:", taskHistory.length)
|
||||
console.log("[TASK_LOAD] Frontend: Filtered tasks length:", filteredTasks.length)
|
||||
|
||||
const clickedTask = filteredTasks.find((task) => task.id === id)
|
||||
console.log("[TASK_LOAD] Frontend: Clicked task details:", {
|
||||
id: clickedTask?.id,
|
||||
task: clickedTask?.task?.substring(0, 50) + "...",
|
||||
ts: clickedTask?.ts,
|
||||
isFavorited: clickedTask?.isFavorited,
|
||||
})
|
||||
|
||||
TaskServiceClient.showTaskWithId(StringRequest.create({ value: id }))
|
||||
.then(() => {
|
||||
console.log("[TASK_LOAD] Frontend: gRPC request sent successfully for task:", id)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[TASK_LOAD] Frontend: Error showing task:", error)
|
||||
})
|
||||
},
|
||||
[taskHistory, filteredTasks],
|
||||
)
|
||||
|
||||
const handleHistorySelect = useCallback((itemId: string, checked: boolean) => {
|
||||
setSelectedItems((prev) => {
|
||||
|
||||
@@ -262,7 +262,9 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
if (response.stateJson) {
|
||||
try {
|
||||
const stateData = JSON.parse(response.stateJson) as ExtensionState
|
||||
console.log("[DEBUG] parsed state JSON, updating state")
|
||||
console.log("[TASK_LOAD] Frontend: Received state update from backend")
|
||||
console.log("[TASK_LOAD] Frontend: Number of messages:", stateData.clineMessages?.length || 0)
|
||||
console.log("[TASK_LOAD] Frontend: Number of tasks in history:", stateData.taskHistory?.length || 0)
|
||||
setState((prevState) => {
|
||||
// Versioning logic for autoApprovalSettings
|
||||
const incomingVersion = stateData.autoApprovalSettings?.version ?? 1
|
||||
|
||||
Reference in New Issue
Block a user