Compare commits

...

2 Commits

Author SHA1 Message Date
Cline Evaluation ea57ccaa06 Adding claudia 2025-06-23 10:23:35 -06:00
google-labs-jules[bot] 04616fbbe2 feat: Implement robust task persistence and recovery (Part 1)
This commit introduces several improvements to task persistence and
recovery mechanisms to address issues of data corruption and loss.

Key changes include:

- Safe JSON Writing:
    - Added string sanitization for JSON content to handle problematic
      characters (e.g., '×', Unicode replacement char �).
    - Ensured UTF-8 encoding for all task file writes.
- Incremental Backup System:
    - Task files (`api_conversation_history.json`, `ui_messages.json`,
      `task_metadata.json`) are now backed up (`.bak`) before each write.
- Automatic Recovery from Backups:
    - If loading a primary task file fails due to corruption, the system
      now attempts to restore from its `.bak` file.
- JSON Validation Layer:
    - Basic pre-save validation is performed to check JSON structural
      integrity, preventing writes of known invalid data.
- Character Encoding Safeguards:
    - File reading functions now explicitly strip UTF-8 BOM before parsing.
- Memory Management (Initial Steps):
    - Added logging to warn when saving task files that exceed size
      thresholds (5MB for history/messages, 1MB for metadata).
- Persistent Storage Option:
    - Introduced a `cline.taskStoragePath` VS Code setting to allow
      users to specify a custom storage path, crucial for devcontainer/remote
      environments.
- Unit Tests:
    - Added unit tests for new string sanitization and JSON validation
      utility functions.

These changes form the first part of addressing issue #4359, focusing
on data integrity and foundational recovery mechanisms.
2025-06-22 19:49:26 +00:00
11 changed files with 452 additions and 30 deletions
+8 -1
View File
@@ -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": {
+21
View File
@@ -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) {
+10 -1
View File
@@ -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
View File
@@ -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)
}
+6
View File
@@ -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
+47
View File
@@ -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")
})
})
+26
View File
@@ -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
}
+56
View File
@@ -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)
})
})
+19
View File
@@ -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