Skip to content
Merged
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
79 changes: 79 additions & 0 deletions src/__tests__/conversation-checkpoint-task.spec.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>
listConversationCheckpoints: (this: object) => Promise<unknown[]>
}
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 <taskDir>/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: <storage>/tasks/<taskId>/checkpoints/<id>.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])
})
})
143 changes: 143 additions & 0 deletions src/core/checkpoints/__tests__/conversation-checkpoint.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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()
})
})
117 changes: 117 additions & 0 deletions src/core/checkpoints/conversation-checkpoint.ts
Original file line number Diff line number Diff line change
@@ -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 `<taskDir>/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 `<taskDir>/checkpoints/<id>.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<ConversationCheckpoint> {
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<ConversationCheckpoint[]> {
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<ConversationCheckpoint | undefined> {
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<boolean> {
try {
await fs.access(file)
return true
} catch {
return false
}
}
28 changes: 28 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1187,6 +1192,29 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}

/**
* Manually-triggered conversation checkpoint (P4 of the Task Tree plan).
*
* Snapshots the task's full message history to `<taskDir>/checkpoints/<id>.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<ConversationCheckpoint> {
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<ConversationCheckpoint[]> {
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) {
Expand Down
Loading