Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion src/core/task-persistence/__tests__/taskMessages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vi.mock("../../../utils/safeWriteJson", () => ({
}))

// Import after mocks
import { saveTaskMessages, readTaskMessages } from "../taskMessages"
import { saveTaskMessages, readTaskMessages, updateTaskMessages } from "../taskMessages"

let tmpBaseDir: string

Expand Down Expand Up @@ -101,6 +101,55 @@ describe("taskMessages.saveTaskMessages", () => {
})
})

describe("taskMessages.updateTaskMessages", () => {
it("keeps legacy same-timestamp identities distinct across removal and rollback", async () => {
const { safeWriteJson } =
await vi.importActual<typeof import("../../../utils/safeWriteJson")>("../../../utils/safeWriteJson")
const options = { taskId: "task-legacy-update", globalStoragePath: tmpBaseDir }
hoisted.safeWriteJsonMock.mockImplementationOnce(safeWriteJson)
await saveTaskMessages({ ...options, messages: [] })
await fs.writeFile(
path.join(tmpBaseDir, "tasks", options.taskId, "ui_messages.json"),
JSON.stringify([
{ ts: 1, type: "say", say: "checkpoint_saved", text: "checkpoint" },
{ ts: 1, type: "say", say: "text", text: "keep" },
]),
)
let removed: ClineMessage[] = []
hoisted.safeWriteJsonMock.mockImplementationOnce(safeWriteJson)
await updateTaskMessages({
...options,
update: (messages) => {
removed = messages.slice(0, 1)
return messages.slice(1)
},
})
hoisted.safeWriteJsonMock.mockImplementationOnce(safeWriteJson)
await saveTaskMessages({ ...options, messages: removed, merge: true })
const messages = await readTaskMessages(options)
expect(messages.map((message) => message.text).sort()).toEqual(["checkpoint", "keep"])
expect(new Set(messages.map((message) => message.messageId)).size).toBe(2)
})

it.each([null, "invalid", {}])("rejects a missing or invalid locked snapshot: %j", async (existing: unknown) => {
const { safeWriteJson } =
await vi.importActual<typeof import("../../../utils/safeWriteJson")>("../../../utils/safeWriteJson")
hoisted.safeWriteJsonMock.mockImplementationOnce(safeWriteJson)
const taskId = "task-invalid-update"
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
const filePath = path.join(taskDir, "ui_messages.json")
if (existing !== null) await fs.writeFile(filePath, JSON.stringify(existing))
const update = vi.fn((messages: ClineMessage[]) => messages)
await expect(updateTaskMessages({ taskId, globalStoragePath: tmpBaseDir, update })).rejects.toMatchObject({
kind: "invalid",
})
expect(update).not.toHaveBeenCalled()
if (existing !== null) expect(JSON.parse(await fs.readFile(filePath, "utf8"))).toEqual(existing)
else await expect(fs.stat(filePath)).rejects.toMatchObject({ code: "ENOENT" })
})
})

describe("taskMessages.readTaskMessages", () => {
it("rejects invalid JSON without treating it as empty history", async () => {
const taskId = "task-corrupt-json"
Expand Down
18 changes: 18 additions & 0 deletions src/core/task-persistence/taskMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@
merge?: boolean
}

/** Update the current disk snapshot under the same lock used by every message writer. */
export async function updateTaskMessages({
taskId,
globalStoragePath,
update,
}: ReadTaskMessagesOptions & { update: (messages: ClineMessage[]) => ClineMessage[] }): Promise<void> {
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
await safeWriteJson(filePath, null, {
merge: (existing) => {
if (!Array.isArray(existing)) {
throw new TaskMessagesReadError("invalid", `Cannot update missing or invalid messages for ${taskId}`)

Check warning on line 79 in src/core/task-persistence/taskMessages.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task-persistence/taskMessages.ts:79: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
}
return ensureMessageIdentifiers(update(ensureMessageIdentifiers(existing)))
},
})
}

export async function saveTaskMessages({
messages,
taskId,
Expand Down
125 changes: 122 additions & 3 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,9 @@
import { fileExistsAtPath } from "../../utils/fs"
import { setTtsEnabled, setTtsSpeed } from "../../utils/tts"
import { getWorkspaceGitInfo } from "../../utils/git"
import { getWorkspacePath } from "../../utils/path"
import { arePathsEqual, getWorkspacePath } from "../../utils/path"
import { OrganizationAllowListViolationError } from "../../utils/errors"
import { getTaskDirectoryPath } from "../../utils/storage"

import { setPanel } from "../../activate/registerCommands"

Expand Down Expand Up @@ -128,7 +129,7 @@
interruptDelegatedChild,
LifecycleTransitionError,
} from "../task-persistence"
import { readTaskMessages } from "../task-persistence/taskMessages"
import { readTaskMessages, updateTaskMessages } from "../task-persistence/taskMessages"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { REQUESTY_BASE_URL } from "../../shared/utils/requesty"
Expand Down Expand Up @@ -2298,12 +2299,130 @@
if (id !== this.getCurrentTask()?.taskId) {
// Non-current task.
const { historyItem } = await this.getTaskWithId(id)
await this.createTaskWithHistoryItem(historyItem) // Clears existing task.
const preparedHistoryItem = await this.prepareHistoryItemForResume(historyItem)
if (!preparedHistoryItem) {
return
}
Comment thread
PierrunoYT marked this conversation as resolved.
await this.createTaskWithHistoryItem(preparedHistoryItem) // Clears existing task.
}

await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
}

public async prepareHistoryItemForResume<T extends HistoryItem>(historyItem: T): Promise<T | undefined> {
const currentWorkspace = this.cwd
const originalWorkspace = historyItem.workspace

if (!currentWorkspace || !originalWorkspace || arePathsEqual(currentWorkspace, originalWorkspace)) {
return historyItem
}

const useCurrentWorkspace = { title: "Use Current Workspace" }
const openOriginalWorkspace = { title: "Open Original Workspace" }
const selection = await vscode.window.showWarningMessage(
`This conversation was created in "${originalWorkspace}", but the current workspace is "${currentWorkspace}". ` +

Check warning on line 2323 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2323: Survived StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
"Choose where to continue. Using the current workspace resets checkpoints created in the original workspace.",

Check warning on line 2324 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2324: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
{ modal: true },
useCurrentWorkspace,
openOriginalWorkspace,
)

if (selection?.title === openOriginalWorkspace.title) {
await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(originalWorkspace), {
forceNewWindow: true,
})
return undefined
}

if (selection?.title !== useCurrentWorkspace.title) {
return undefined
}

const updatedHistoryItem = { ...historyItem, workspace: currentWorkspace }
await this.resetTaskCheckpointsForWorkspaceChange(historyItem, updatedHistoryItem)
return updatedHistoryItem
}

private async resetTaskCheckpointsForWorkspaceChange(
originalHistoryItem: HistoryItem,
updatedHistoryItem: HistoryItem,
): Promise<void> {
const taskId = originalHistoryItem.id
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
let removedCheckpoints: ClineMessage[] = []

Check warning on line 2352 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2352: Survived ArrayDeclaration mutant (replacement: ["Stryker was here"]). See the job summary for the complete list and resolution guidance.
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
const checkpointsDir = path.join(taskDir, "checkpoints")
const checkpointBackupDir = path.join(taskDir, `checkpoints.workspace-change-${crypto.randomUUID()}`)
let checkpointDirectoryStaged = false

Check warning on line 2356 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2356: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.

try {
await fs.rename(checkpointsDir, checkpointBackupDir)
checkpointDirectoryStaged = true
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {

Check warning on line 2362 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2362: 6 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
throw error
}
}

try {
await updateTaskMessages({
taskId,
globalStoragePath,
update: (messages) => {
removedCheckpoints = messages.filter(
(message) => message.type === "say" && message.say === "checkpoint_saved",

Check warning on line 2373 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2373: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
)
return messages.filter((message) => !removedCheckpoints.includes(message))
},
})
await this.updateTaskHistory(updatedHistoryItem)
} catch (error) {
if (removedCheckpoints.length > 0) {

Check warning on line 2380 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2380: 2 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
try {
// Restore only the removed rows; never replace intervening message writes.
await saveTaskMessages({ messages: removedCheckpoints, taskId, globalStoragePath, merge: true })
} catch (rollbackError) {
this.log(
`[resetTaskCheckpointsForWorkspaceChange] Failed to restore messages for ${taskId}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
)
}
}
if (checkpointDirectoryStaged) {
try {
await fs.rename(checkpointBackupDir, checkpointsDir)
} catch (rollbackError) {
this.log(
`[resetTaskCheckpointsForWorkspaceChange] Failed to restore checkpoints for ${taskId} from ${checkpointBackupDir}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
)
}
}
try {
if (this.taskHistoryStore.get(taskId)?.workspace === updatedHistoryItem.workspace) {
await this.updateTaskHistory(originalHistoryItem)
}
} catch (rollbackError) {
this.log(
`[resetTaskCheckpointsForWorkspaceChange] Failed to restore history for ${taskId}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
)
}
throw error
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if (checkpointDirectoryStaged) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
await fs.rm(checkpointBackupDir, { recursive: true, force: true })
break
} catch (error) {
this.log(
`[resetTaskCheckpointsForWorkspaceChange] Failed to remove checkpoint backup ${checkpointBackupDir}: ${error instanceof Error ? error.message : String(error)}`,
)
if (attempt < 2) await delay(100)
}
}
}
}

async exportTaskWithId(id: string) {
const { historyItem, apiConversationHistory } = await this.getTaskWithId(id)
const fileName = getTaskFileName(historyItem.ts)
Expand Down
Loading
Loading