diff --git a/src/__tests__/conversation-checkpoint-task.spec.ts b/src/__tests__/conversation-checkpoint-task.spec.ts new file mode 100644 index 0000000000..e034ec8b6c --- /dev/null +++ b/src/__tests__/conversation-checkpoint-task.spec.ts @@ -0,0 +1,79 @@ +// npx vitest run __tests__/conversation-checkpoint-task.spec.ts + +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import type { ClineMessage } from "@roo-code/types" +import { Task } from "../core/task/Task" + +function makeMessages(n: number): ClineMessage[] { + return Array.from( + { length: n }, + (_, i) => + ({ + type: "user", + text: `message ${i}`, + ts: 1000 + i, + }) as unknown as ClineMessage, + ) +} + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "conv-checkpoint-task-")) +}) + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +/** + * Task double carrying only the fields the checkpoint trigger methods read. + * The real prototype methods are bound onto the stub so we exercise the actual + * Task code path (getTaskDirectoryPath + storage module) without instantiating a Task. + */ +function makeTaskDouble(taskId: string, messages: ClineMessage[]) { + const proto = Task.prototype as unknown as { + createConversationCheckpoint: (this: object, summary?: string) => Promise + listConversationCheckpoints: (this: object) => Promise + } + const stub = { taskId, globalStoragePath: tmpDir, clineMessages: messages } + return Object.assign(stub, { + createConversationCheckpoint: proto.createConversationCheckpoint.bind(stub), + listConversationCheckpoints: proto.listConversationCheckpoints.bind(stub), + }) as unknown as Task +} + +describe("Task.createConversationCheckpoint", () => { + it("persists the full message history under /checkpoints and returns the checkpoint", async () => { + const task = makeTaskDouble("task-1", makeMessages(2)) + + const cp = await task.createConversationCheckpoint("halfway") + + expect(cp.taskId).toBe("task-1") + expect(cp.summary).toBe("halfway") + expect(cp.messages).toHaveLength(2) + + // Lands in the standard task directory layout: /tasks//checkpoints/.json + const raw = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", "task-1", "checkpoints", `${cp.id}.json`), "utf8"), + ) as { taskId: string; messages: unknown[] } + expect(raw.taskId).toBe("task-1") + expect(raw.messages).toHaveLength(2) + }) + + it("lists checkpoints newest first via listConversationCheckpoints", async () => { + const task = makeTaskDouble("task-1", makeMessages(1)) + + const cp1 = await task.createConversationCheckpoint() + // Ensure a distinct timestamp so ordering is unambiguous. + await new Promise((resolve) => setTimeout(resolve, 5)) + const cp2 = await task.createConversationCheckpoint("second") + + const list = await task.listConversationCheckpoints() + expect(list.map((c) => c.id)).toEqual([cp2.id, cp1.id]) + }) +}) diff --git a/src/core/checkpoints/__tests__/conversation-checkpoint.spec.ts b/src/core/checkpoints/__tests__/conversation-checkpoint.spec.ts new file mode 100644 index 0000000000..0481c80d61 --- /dev/null +++ b/src/core/checkpoints/__tests__/conversation-checkpoint.spec.ts @@ -0,0 +1,143 @@ +// npx vitest run core/checkpoints/__tests__/conversation-checkpoint.spec.ts + +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import type { ClineMessage } from "@roo-code/types" +import { + saveConversationCheckpoint, + listConversationCheckpoints, + loadConversationCheckpoint, +} from "../conversation-checkpoint" + +function makeMessages(n: number): ClineMessage[] { + return Array.from( + { length: n }, + (_, i) => + ({ + type: "user", + text: `message ${i}`, + ts: 1000 + i, + }) as unknown as ClineMessage, + ) +} + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "conv-checkpoint-")) +}) + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +describe("saveConversationCheckpoint", () => { + it("writes a JSON file named by the creation timestamp and returns the checkpoint", async () => { + const messages = makeMessages(3) + + const cp = await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "task-1", messages, now: 2000 }) + + expect(cp.id).toBe("2000") + expect(cp.taskId).toBe("task-1") + expect(cp.createdAt).toBe(2000) + const raw = await fs.readFile(path.join(tmpDir, "checkpoints", "2000.json"), "utf8") + const parsed = JSON.parse(raw) as { id: string; taskId: string; messages: unknown[] } + expect(parsed.id).toBe("2000") + expect(parsed.taskId).toBe("task-1") + expect(parsed.messages).toHaveLength(3) + }) + + it("omits the summary field when none is provided and includes it when given", async () => { + const noSummary = await saveConversationCheckpoint({ + taskDir: tmpDir, + taskId: "t", + messages: makeMessages(1), + now: 100, + }) + expect(noSummary.summary).toBeUndefined() + + const withSummary = await saveConversationCheckpoint({ + taskDir: tmpDir, + taskId: "t", + messages: makeMessages(1), + summary: "halfway done", + now: 200, + }) + expect(withSummary.summary).toBe("halfway done") + + const raw = await fs.readFile(path.join(tmpDir, "checkpoints", "100.json"), "utf8") + expect(JSON.parse(raw) as Record).not.toHaveProperty("summary") + }) + + it("appends a numeric suffix on same-millisecond collisions instead of overwriting", async () => { + await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "t", messages: makeMessages(1), now: 500 }) + const second = await saveConversationCheckpoint({ + taskDir: tmpDir, + taskId: "t", + messages: makeMessages(2), + now: 500, + }) + + expect(second.id).toBe("500-1") + // Both files coexist. + await expect(fs.access(path.join(tmpDir, "checkpoints", "500.json"))).resolves.toBeUndefined() + await expect(fs.access(path.join(tmpDir, "checkpoints", "500-1.json"))).resolves.toBeUndefined() + }) + + it("does not mutate the caller's messages array (deep clone)", async () => { + const messages = makeMessages(2) + await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "t", messages, now: 300 }) + ;(messages[0] as { text?: string }).text = "mutated" + + const loaded = await loadConversationCheckpoint(tmpDir, "300") + expect((loaded?.messages[0] as { text?: string }).text).toBe("message 0") + }) +}) + +describe("listConversationCheckpoints", () => { + it("returns an empty list when the checkpoints directory does not exist", async () => { + const result = await listConversationCheckpoints(tmpDir) + expect(result).toEqual([]) + }) + + it("lists checkpoints newest first (createdAt desc, then id desc on ties)", async () => { + await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "t", messages: makeMessages(1), now: 100 }) + await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "t", messages: makeMessages(1), now: 300 }) + await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "t", messages: makeMessages(1), now: 200 }) + + const result = await listConversationCheckpoints(tmpDir) + expect(result.map((c) => c.id)).toEqual(["300", "200", "100"]) + }) + + it("skips corrupt files rather than failing the listing", async () => { + await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "t", messages: makeMessages(1), now: 100 }) + const dir = path.join(tmpDir, "checkpoints") + await fs.writeFile(path.join(dir, "999.json"), "{ not valid json", "utf8") + + const result = await listConversationCheckpoints(tmpDir) + expect(result.map((c) => c.id)).toEqual(["100"]) + }) +}) + +describe("loadConversationCheckpoint", () => { + it("loads a saved checkpoint by id and returns undefined when missing", async () => { + await saveConversationCheckpoint({ + taskDir: tmpDir, + taskId: "t", + messages: makeMessages(2), + summary: "s", + now: 400, + }) + + const loaded = await loadConversationCheckpoint(tmpDir, "400") + expect(loaded?.taskId).toBe("t") + expect(loaded?.summary).toBe("s") + expect(loaded?.messages).toHaveLength(2) + + const missing = await loadConversationCheckpoint(tmpDir, "does-not-exist") + expect(missing).toBeUndefined() + }) +}) diff --git a/src/core/checkpoints/conversation-checkpoint.ts b/src/core/checkpoints/conversation-checkpoint.ts new file mode 100644 index 0000000000..c60fab2355 --- /dev/null +++ b/src/core/checkpoints/conversation-checkpoint.ts @@ -0,0 +1,117 @@ +import * as fs from "fs/promises" +import * as path from "path" + +import type { ClineMessage } from "@roo-code/types" + +/** + * A manually-triggered conversation checkpoint. + * + * Unlike the git-based file checkpoints (RepoPerTaskCheckpointService), a conversation + * checkpoint snapshots the task's full message history so the user can restore the + * conversation to this point later. Stored as JSON under `/checkpoints/`. + */ +export interface ConversationCheckpoint { + /** Filename stem: epoch ms, with a `-N` suffix on same-millisecond collisions. */ + id: string + taskId: string + createdAt: number + summary?: string + messages: ClineMessage[] +} + +const CHECKPOINT_DIR = "checkpoints" + +function checkpointDir(taskDir: string): string { + return path.join(taskDir, CHECKPOINT_DIR) +} + +/** + * Saves a conversation checkpoint to `/checkpoints/.json`. + * + * The id is the creation timestamp; if that file already exists (two checkpoints in the + * same millisecond) a `-1`, `-2`, ... suffix is appended so no data is lost. + */ +export async function saveConversationCheckpoint(opts: { + taskDir: string + taskId: string + messages: ClineMessage[] + summary?: string + /** Injectable clock for deterministic tests. Defaults to Date.now(). */ + now?: number +}): Promise { + const dir = checkpointDir(opts.taskDir) + await fs.mkdir(dir, { recursive: true }) + + const createdAt = opts.now ?? Date.now() + let id = String(createdAt) + // Same-millisecond collision guard: append a numeric suffix until the file is free. + for (let n = 1; await fileExists(path.join(dir, `${id}.json`)); n++) { + id = `${createdAt}-${n}` + } + + const checkpoint: ConversationCheckpoint = { + id, + taskId: opts.taskId, + createdAt, + ...(opts.summary !== undefined ? { summary: opts.summary } : {}), + messages: structuredClone(opts.messages), + } + + await fs.writeFile(path.join(dir, `${id}.json`), JSON.stringify(checkpoint, null, 2), "utf8") + return checkpoint +} + +/** Lists all conversation checkpoints for a task, newest first. Missing dir → empty list. */ +export async function listConversationCheckpoints(taskDir: string): Promise { + const dir = checkpointDir(taskDir) + let entries: string[] + try { + entries = await fs.readdir(dir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return [] + } + throw error + } + + const checkpoints: ConversationCheckpoint[] = [] + for (const entry of entries) { + if (!entry.endsWith(".json")) { + continue + } + try { + const raw = await fs.readFile(path.join(dir, entry), "utf8") + checkpoints.push(JSON.parse(raw) as ConversationCheckpoint) + } catch { + // Skip corrupt/partial files rather than failing the whole listing. + } + } + + return checkpoints.sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id)) +} + +/** Loads a single checkpoint by id. Returns undefined when not found or unreadable. */ +export async function loadConversationCheckpoint( + taskDir: string, + id: string, +): Promise { + const file = path.join(checkpointDir(taskDir), `${id}.json`) + try { + const raw = await fs.readFile(file, "utf8") + return JSON.parse(raw) as ConversationCheckpoint + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined + } + throw error + } +} + +async function fileExists(file: string): Promise { + try { + await fs.access(file) + return true + } catch { + return false + } +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 8dfad1d4e0..377ab1484c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -117,6 +117,11 @@ import { saveTaskMessages, taskMetadata, } from "../task-persistence" +import { + saveConversationCheckpoint, + listConversationCheckpoints, + type ConversationCheckpoint, +} from "../checkpoints/conversation-checkpoint" import { getEnvironmentDetails } from "../environment/getEnvironmentDetails" import { checkContextWindowExceededError } from "../context/context-management/context-error-handling" import { @@ -1187,6 +1192,29 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Manually-triggered conversation checkpoint (P4 of the Task Tree plan). + * + * Snapshots the task's full message history to `/checkpoints/.json` so the + * user can restore the conversation to this point later. This is distinct from the + * git-based file checkpoints: it captures the CONVERSATION, not the working tree. + */ + async createConversationCheckpoint(summary?: string): Promise { + const taskDir = await getTaskDirectoryPath(this.globalStoragePath, this.taskId) + return saveConversationCheckpoint({ + taskDir, + taskId: this.taskId, + messages: structuredClone(this.clineMessages), + summary, + }) + } + + /** Lists all conversation checkpoints for this task, newest first. */ + async listConversationCheckpoints(): Promise { + const taskDir = await getTaskDirectoryPath(this.globalStoragePath, this.taskId) + return listConversationCheckpoints(taskDir) + } + private findMessageByTimestamp(ts: number): ClineMessage | undefined { for (let i = this.clineMessages.length - 1; i >= 0; i--) { if (this.clineMessages[i].ts === ts) {