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
23 changes: 23 additions & 0 deletions apps/vscode-e2e/src/suite/restart-persistence.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,11 +73,18 @@ async function runCreate(api: RooCodeAPI): Promise<void> {
}

async function runVerify(api: RooCodeAPI): Promise<void> {
const taskMessages: Array<{ type: string; ask?: string }> = []
const messageHandler = ({ taskId, message }: { taskId: string; message: (typeof taskMessages)[number] }) => {
if (taskId === verifiedTaskId) taskMessages.push(message)
}
let verifiedTaskId: string | undefined
try {
const createResult = await readPhaseResult(getResultsDir(), "create")
assert.strictEqual(createResult.status, "passed")
const taskId = createResult.values?.taskId
assert.ok(taskId, "Create phase should record a task ID")
verifiedTaskId = taskId
api.on(RooCodeEventName.Message, messageHandler)

await waitFor(() => api.isReady())
assert.strictEqual(await api.isTaskInHistory(taskId), true, "Task should be present after restart")
Expand All@@ -87,6 +94,20 @@ async function runVerify(api: RooCodeAPI): Promise<void> {
const conversationLength = await api.getTaskApiConversationHistoryLength(taskId)
assert.ok(conversationLength > 0, "API conversation history should be available after restart")

await api.resumeTask(taskId)
await waitFor(() => taskMessages.some(({ type, ask }) => type === "ask" && ask === "resume_completed_task"))
assert.strictEqual(await api.isTaskInHistory(taskId), true, "Reopened task should remain in history")
const reopenedHistoryItem = await api.getTaskHistoryItem(taskId)
assert.ok(reopenedHistoryItem, "Reopened task should retain its history item")
assert.ok(
reopenedHistoryItem.task.includes("RESTART_PERSISTENCE_SMOKE"),
"Reopened task should retain its persisted history title",
)
assert.ok(
(await api.getTaskApiConversationHistoryLength(taskId)) >= conversationLength,
"Reopened task should retain its persisted API conversation history",
)

await writePhaseResult(getResultsDir(), {
version: PHASE_RESULT_VERSION,
phase: "verify",
Expand All@@ -102,6 +123,8 @@ async function runVerify(api: RooCodeAPI): Promise<void> {
error: serializePhaseError(error),
})
throw error
} finally {
api.off(RooCodeEventName.Message, messageHandler)
}
}

Expand Down
39 changes: 39 additions & 0 deletions src/__tests__/history-resume-delegation.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,6 +231,7 @@ describe("History resume delegation - parent metadata transitions", () => {
]),
taskId: "p1",
globalStoragePath: "/storage",
merge: true,
}),
)

Expand All@@ -250,6 +251,7 @@ describe("History resume delegation - parent metadata transitions", () => {
]),
taskId: "p1",
globalStoragePath: "/storage",
merge: true,
}),
)

Expand All@@ -261,6 +263,43 @@ describe("History resume delegation - parent metadata transitions", () => {
expect(apiCall.messages).toHaveLength(2) // 1 original + 1 injected
})

it("does not reopen or overwrite a parent when its UI history cannot be read", async () => {
const parentItem = {
id: "parent-read-failure",
status: "delegated",
awaitingChildId: "child-read-failure",
childIds: ["child-read-failure"],
ts: 100,
task: "Parent",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
}
const log = vi.fn()
const taskHistoryStore = makeTaskHistoryStoreStub({ id: "child-read-failure", status: "active" }, parentItem)
const provider = makeProviderStub({
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }),
getCurrentTask: vi.fn(() => ({ taskId: "child-read-failure" })),
taskHistoryStore,
log,
})
vi.mocked(readTaskMessages).mockRejectedValue(new Error("history unavailable"))

const result = await ClineProvider.prototype.reopenParentFromDelegation.call(provider, {
parentTaskId: "parent-read-failure",
childTaskId: "child-read-failure",
completionResultSummary: "Child done",
})

expect(result).toBe(false)
expect(log).toHaveBeenCalledWith(expect.stringContaining("history unavailable"))
expect(readApiMessages).not.toHaveBeenCalled()
expect(saveTaskMessages).not.toHaveBeenCalled()
expect(saveApiMessages).not.toHaveBeenCalled()
expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled()
})

it("reopenParentFromDelegation injects tool_result when new_task tool_use exists in API history", async () => {
const parentItem = {
id: "p-tool",
Expand Down
134 changes: 112 additions & 22 deletions src/core/task-persistence/__tests__/apiMessages.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,31 +4,36 @@ import * as os from "os"
import * as path from "path"
import * as fs from "fs/promises"

import { readApiMessages } from "../apiMessages"
const hoisted = vi.hoisted(() => ({ readFileMock: vi.fn() }))
vi.mock("fs/promises", async (importOriginal) => ({
...(await importOriginal<typeof import("fs/promises")>()),
readFile: hoisted.readFileMock,
}))

import { readApiMessages, saveApiMessages } from "../apiMessages"

let tmpBaseDir: string

beforeEach(async () => {
const actualFs = await vi.importActual<typeof import("fs/promises")>("fs/promises")
hoisted.readFileMock.mockReset().mockImplementation(actualFs.readFile)
tmpBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-api-"))
})

describe("apiMessages.readApiMessages", () => {
it("returns empty array when api_conversation_history.json contains invalid JSON", async () => {
it("rejects invalid api_conversation_history.json without treating it as empty history", async () => {
const taskId = "task-corrupt-api"
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
const filePath = path.join(taskDir, "api_conversation_history.json")
await fs.writeFile(filePath, "<<<corrupt data>>>", "utf8")

const result = await readApiMessages({
taskId,
globalStoragePath: tmpBaseDir,
await expect(readApiMessages({ taskId, globalStoragePath: tmpBaseDir })).rejects.toMatchObject({
kind: "invalid",
})

expect(result).toEqual([])
})

it("returns empty array when claude_messages.json fallback contains invalid JSON", async () => {
it("rejects invalid claude_messages.json without deleting it", async () => {
const taskId = "task-corrupt-fallback"
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
Expand All@@ -37,13 +42,10 @@ describe("apiMessages.readApiMessages", () => {
const oldPath = path.join(taskDir, "claude_messages.json")
await fs.writeFile(oldPath, "not json at all {[!", "utf8")

const result = await readApiMessages({
taskId,
globalStoragePath: tmpBaseDir,
await expect(readApiMessages({ taskId, globalStoragePath: tmpBaseDir })).rejects.toMatchObject({
kind: "invalid",
})

expect(result).toEqual([])

// The corrupted fallback file should NOT be deleted
const stillExists = await fs
.access(oldPath)
Expand All@@ -52,22 +54,19 @@ describe("apiMessages.readApiMessages", () => {
expect(stillExists).toBe(true)
})

it("returns [] when file contains valid JSON that is not an array", async () => {
it("rejects valid non-array JSON in the current file", async () => {
const taskId = "task-non-array-api"
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
const filePath = path.join(taskDir, "api_conversation_history.json")
await fs.writeFile(filePath, JSON.stringify("hello"), "utf8")

const result = await readApiMessages({
taskId,
globalStoragePath: tmpBaseDir,
await expect(readApiMessages({ taskId, globalStoragePath: tmpBaseDir })).rejects.toMatchObject({
kind: "invalid",
})

expect(result).toEqual([])
})

it("returns [] when fallback file contains valid JSON that is not an array", async () => {
it("rejects valid non-array JSON in the fallback file", async () => {
const taskId = "task-non-array-fallback"
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
Expand All@@ -76,11 +75,102 @@ describe("apiMessages.readApiMessages", () => {
const oldPath = path.join(taskDir, "claude_messages.json")
await fs.writeFile(oldPath, JSON.stringify({ key: "value" }), "utf8")

const result = await readApiMessages({
await expect(readApiMessages({ taskId, globalStoragePath: tmpBaseDir })).rejects.toMatchObject({
kind: "invalid",
})
})

it("returns empty history only when current and legacy files are both missing", async () => {
await expect(readApiMessages({ taskId: "task-missing", globalStoragePath: tmpBaseDir })).resolves.toEqual([])
})

it("migrates valid legacy history before deleting its source", async () => {
const taskId = "task-legacy-api"
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
const oldPath = path.join(taskDir, "claude_messages.json")
const currentPath = path.join(taskDir, "api_conversation_history.json")
const legacyMessages = [{ role: "user", content: "legacy", ts: 1 }]
await fs.writeFile(oldPath, JSON.stringify(legacyMessages), "utf8")

await expect(readApiMessages({ taskId, globalStoragePath: tmpBaseDir })).resolves.toEqual(legacyMessages)
await expect(fs.readFile(currentPath, "utf8").then(JSON.parse)).resolves.toEqual(legacyMessages)
await expect(fs.access(oldPath)).rejects.toMatchObject({ code: "ENOENT" })
})

it("retries one transient missing-file read", async () => {
vi.spyOn(Math, "random").mockReturnValue(0)
const missing = Object.assign(new Error("missing"), { code: "ENOENT" })
hoisted.readFileMock.mockRejectedValueOnce(missing).mockResolvedValueOnce("[]")

await expect(readApiMessages({ taskId: "task-retry", globalStoragePath: tmpBaseDir })).resolves.toEqual([])
expect(hoisted.readFileMock).toHaveBeenCalledTimes(2)
})

it("does not retry non-ENOENT read failures", async () => {
const denied = Object.assign(new Error("denied"), { code: "EACCES" })
hoisted.readFileMock.mockRejectedValueOnce(denied)

await expect(readApiMessages({ taskId: "task-denied", globalStoragePath: tmpBaseDir })).rejects.toMatchObject({
kind: "io_error",
})
expect(hoisted.readFileMock).toHaveBeenCalledTimes(1)
})
})

describe("apiMessages.saveApiMessages", () => {
it("merges a concurrent disk suffix when requested", async () => {
const taskId = "task-merge-api"
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
const filePath = path.join(taskDir, "api_conversation_history.json")
await fs.writeFile(
filePath,
JSON.stringify([
{ role: "user", content: "disk prefix", ts: 1 },
{ role: "assistant", content: "disk suffix", ts: 3 },
]),
"utf8",
)

await saveApiMessages({
taskId,
globalStoragePath: tmpBaseDir,
merge: true,
messages: [
{ role: "user", content: "updated prefix", ts: 1 },
{ role: "assistant", content: "incoming", ts: 2 },
],
})

expect(JSON.parse(await fs.readFile(filePath, "utf8"))).toEqual([
expect.objectContaining({ content: "updated prefix", ts: 1 }),
expect.objectContaining({ content: "incoming", ts: 2 }),
expect.objectContaining({ content: "disk suffix", ts: 3 }),
])
})

it("replaces the persisted snapshot when merge is false", async () => {
const taskId = "task-replace-api"
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
const filePath = path.join(taskDir, "api_conversation_history.json")
await fs.writeFile(
filePath,
JSON.stringify([
{ role: "user", content: "A", ts: 1 },
{ role: "assistant", content: "B", ts: 2 },
]),
"utf8",
)

await saveApiMessages({
taskId,
globalStoragePath: tmpBaseDir,
merge: false,
messages: [{ role: "user", content: "C", ts: 3 }],
})

expect(result).toEqual([])
expect(JSON.parse(await fs.readFile(filePath, "utf8"))).toEqual([{ role: "user", content: "C", ts: 3 }])
})
})
Loading
Loading