Skip to content
Draft
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
36 changes: 36 additions & 0 deletions src/__tests__/history-resume-delegation.spec.ts
Original file line number Diff line number Diff 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,40 @@ 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 provider = makeProviderStub({
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }),
getCurrentTask: vi.fn(() => ({ taskId: "child-read-failure" })),
taskHistoryStore: makeTaskHistoryStoreStub({ id: "child-read-failure", status: "active" }, parentItem),
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(saveTaskMessages).not.toHaveBeenCalled()
expect(saveApiMessages).not.toHaveBeenCalled()
})

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

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

let tmpBaseDir: string

Expand Down Expand Up @@ -84,3 +84,36 @@ describe("apiMessages.readApiMessages", () => {
expect(result).toEqual([])
})
})

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 }),
])
})
})
106 changes: 106 additions & 0 deletions src/core/task-persistence/__tests__/mergeMessageSnapshots.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { mergeApiMessageSnapshots, mergeClineMessageSnapshots } from "../mergeMessageSnapshots"

describe("mergeClineMessageSnapshots", () => {
it("preserves disk-only messages and applies incoming updates in timestamp order", () => {
const result = mergeClineMessageSnapshots(
[
{ ts: 1, type: "say", say: "text", text: "old" },
{ ts: 3, type: "say", say: "text", text: "newer disk suffix" },
],
[
{ ts: 1, type: "say", say: "text", text: "updated" },
{ ts: 2, type: "say", say: "text", text: "incoming" },
],
)

expect(result).toEqual([
expect.objectContaining({ ts: 1, text: "updated" }),
expect.objectContaining({ ts: 2, text: "incoming" }),
expect.objectContaining({ ts: 3, text: "newer disk suffix" }),
])
})

it("does not regress completed or answered message state", () => {
const result = mergeClineMessageSnapshots(
[{ ts: 1, type: "ask", ask: "tool", partial: false, isAnswered: true }],
[{ ts: 1, type: "ask", ask: "tool", partial: true, isAnswered: false }],
)

expect(result).toEqual([expect.objectContaining({ ts: 1, partial: false, isAnswered: true })])
})

it("uses the incoming message when a timestamp is reused for a different message identity", () => {
expect(
mergeClineMessageSnapshots(
[{ ts: 1, type: "say", say: "text", text: "old" }],
[{ ts: 1, type: "ask", ask: "followup", text: "new" }],
),
).toEqual([{ ts: 1, type: "ask", ask: "followup", text: "new" }])
})

it("returns incoming data when either snapshot is not an array", () => {
expect(mergeClineMessageSnapshots(null, [{ ts: 1 }])).toEqual([{ ts: 1 }])
expect(mergeClineMessageSnapshots([], "invalid")).toBe("invalid")
})
})

describe("mergeApiMessageSnapshots", () => {
it("retains equal-timestamp records and keeps tool calls before their results", () => {
const result = mergeApiMessageSnapshots(
[
{ role: "assistant", content: "old", ts: 1 },
{ role: "user", content: "same timestamp sibling", ts: 1 },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call-1", content: "ok" }], ts: 3 },
],
[
{ role: "assistant", content: "updated", ts: 1 },
{
role: "assistant",
content: [{ type: "tool_use", id: "call-1", name: "read_file", input: {} }],
ts: 2,
},
],
)

expect(result).toEqual([
expect.objectContaining({ role: "assistant", content: "updated", ts: 1 }),
expect.objectContaining({ role: "user", content: "same timestamp sibling", ts: 1 }),
expect.objectContaining({ role: "assistant", ts: 2 }),
expect.objectContaining({ role: "user", ts: 3 }),
])
})

it("preserves only the unmatched legacy disk tail", () => {
const result = mergeApiMessageSnapshots(
[
{ role: "user", content: "old prefix" },
{ role: "assistant", content: "disk tail" },
],
[{ role: "user", content: "updated prefix" }],
)

expect(result).toEqual([
{ role: "user", content: "updated prefix" },
{ role: "assistant", content: "disk tail" },
])
})

it("keeps legacy prefixes ahead of newer timestamped messages", () => {
const result = mergeApiMessageSnapshots(
[
{ role: "user", content: "legacy prefix" },
{ role: "assistant", content: "disk suffix", ts: 3 },
],
[
{ role: "user", content: "updated legacy prefix" },
{ role: "assistant", content: "incoming", ts: 2 },
],
)

expect(result).toEqual([
{ role: "user", content: "updated legacy prefix" },
expect.objectContaining({ content: "incoming", ts: 2 }),
expect.objectContaining({ content: "disk suffix", ts: 3 }),
])
})
})
95 changes: 83 additions & 12 deletions src/core/task-persistence/__tests__/taskMessages.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import * as os from "os"
import * as path from "path"
import * as fs from "fs/promises"

import type { ClineMessage } from "@roo-code/types"

// Mocks (use hoisted to avoid initialization ordering issues)
const hoisted = vi.hoisted(() => ({
safeWriteJsonMock: vi.fn().mockResolvedValue(undefined),
readFileMock: vi.fn(),
}))
vi.mock("fs/promises", async (importOriginal) => ({
...(await importOriginal<typeof import("fs/promises")>()),
readFile: hoisted.readFileMock,
}))
vi.mock("../../../utils/safeWriteJson", () => ({
safeWriteJson: hoisted.safeWriteJsonMock,
Expand All @@ -18,10 +25,17 @@ let tmpBaseDir: string

beforeEach(async () => {
hoisted.safeWriteJsonMock.mockClear()
const actualFs = await vi.importActual<typeof import("fs/promises")>("fs/promises")
hoisted.readFileMock.mockReset().mockImplementation(actualFs.readFile)
// Create a unique, writable temp directory to act as globalStoragePath
tmpBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-"))
})

afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})

describe("taskMessages.saveTaskMessages", () => {
beforeEach(() => {
hoisted.safeWriteJsonMock.mockClear()
Expand All @@ -48,6 +62,7 @@ describe("taskMessages.saveTaskMessages", () => {
expect(hoisted.safeWriteJsonMock).toHaveBeenCalledTimes(1)
const [, persisted] = hoisted.safeWriteJsonMock.mock.calls[0]
expect(persisted).toEqual(messages)
expect(hoisted.safeWriteJsonMock.mock.calls[0][2]).toBeUndefined()
})

it("persists messages without modification when no metadata", async () => {
Expand All @@ -65,37 +80,93 @@ describe("taskMessages.saveTaskMessages", () => {
const [, persisted] = hoisted.safeWriteJsonMock.mock.calls[0]
expect(persisted).toEqual(messages)
})

it("passes the history merge callback only when requested", async () => {
const messages: ClineMessage[] = [{ ts: 2, type: "say", say: "text", text: "incoming" }]
await saveTaskMessages({
messages,
taskId: "task-merge",
globalStoragePath: tmpBaseDir,
merge: true,
})

const merge = hoisted.safeWriteJsonMock.mock.calls[0][2]?.merge
expect(merge).toBeTypeOf("function")
expect(merge([{ ts: 1, type: "say", say: "text", text: "disk" }], messages)).toEqual([
expect.objectContaining({ ts: 1, text: "disk" }),
expect.objectContaining({ ts: 2, text: "incoming" }),
])
})
})

describe("taskMessages.readTaskMessages", () => {
it("returns empty array when file contains invalid JSON", async () => {
it("rejects invalid JSON without treating it as empty history", async () => {
const taskId = "task-corrupt-json"
// Manually create the task directory and write corrupted JSON
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
const filePath = path.join(taskDir, "ui_messages.json")
await fs.writeFile(filePath, "{not valid json!!!", "utf8")

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

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

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

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

expect(result).toEqual([])
it("distinguishes a missing history file from an empty history", async () => {
await expect(readTaskMessages({ taskId: "task-missing", globalStoragePath: tmpBaseDir })).rejects.toMatchObject(
{ kind: "not_found" },
)
})

it("returns an explicitly persisted empty history", async () => {
const taskId = "task-empty"
const taskDir = path.join(tmpBaseDir, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
await fs.writeFile(path.join(taskDir, "ui_messages.json"), "[]", "utf8")

await expect(readTaskMessages({ taskId, globalStoragePath: tmpBaseDir })).resolves.toEqual([])
})

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

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

it("throws when the missing-file retry also fails", async () => {
vi.spyOn(Math, "random").mockReturnValue(0)
const missing = Object.assign(new Error("missing"), { code: "ENOENT" })
hoisted.readFileMock.mockRejectedValue(missing)

await expect(
readTaskMessages({ taskId: "task-still-missing", globalStoragePath: tmpBaseDir }),
).rejects.toMatchObject({ kind: "not_found" })
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(readTaskMessages({ taskId: "task-denied", globalStoragePath: tmpBaseDir })).rejects.toMatchObject({
kind: "io_error",
})
expect(hoisted.readFileMock).toHaveBeenCalledTimes(1)
})
})
5 changes: 4 additions & 1 deletion src/core/task-persistence/apiMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { fileExistsAtPath } from "../../utils/fs"

import { GlobalFileNames } from "../../shared/globalFileNames"
import { getTaskDirectoryPath } from "../../utils/storage"
import { mergeApiMessageSnapshots } from "./mergeMessageSnapshots"

export type ApiMessage = Anthropic.MessageParam & {
ts?: number
Expand Down Expand Up @@ -110,12 +111,14 @@ export async function saveApiMessages({
messages,
taskId,
globalStoragePath,
merge = false,
}: {
messages: ApiMessage[]
taskId: string
globalStoragePath: string
merge?: boolean
}) {
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
await safeWriteJson(filePath, messages)
await safeWriteJson(filePath, messages, merge ? { merge: mergeApiMessageSnapshots } : undefined)
}
7 changes: 6 additions & 1 deletion src/core/task-persistence/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages"
export { readTaskMessages, saveTaskMessages } from "./taskMessages"
export {
readTaskMessages,
saveTaskMessages,
TaskMessagesReadError,
type TaskMessagesReadErrorKind,
} from "./taskMessages"
export { taskMetadata } from "./taskMetadata"
export { TaskHistoryStore, assertValidTransition } from "./TaskHistoryStore"
Loading
Loading