Skip to content
29 changes: 29 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "./provider-settings.js"
import { telemetrySettingsSchema } from "./telemetry.js"
import { toolNamesSchema } from "./tool.js"
import { changeCardDetailSchema, type ChangeCardDetail } from "./message.js"
import { type Keys } from "./type-fu.js"
import { languagesSchema } from "./vscode.js"

Expand Down Expand Up @@ -99,6 +100,21 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60
*/
export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15

/**
* Whether per-write checkpoints and task-start baseline are enabled by default.
* Master switch for the B cluster of checkpoint features.
* @default true
*/
export const DEFAULT_PER_WRITE_CHECKPOINTS = true

/**
* Default detail level for per-step change cards (B3a).
* "summary" keeps cards compact (file list with +/− counts; the UI fetches
* diffs lazily); "full" carries the unified diff inline per file.
* @default "summary"
*/
export const DEFAULT_CHANGE_CARD_DETAIL: ChangeCardDetail = "summary"

/**
* GlobalSettings
*/
Expand Down Expand Up @@ -200,6 +216,19 @@ export const globalSettingsSchema = z.object({
.min(MIN_CHECKPOINT_TIMEOUT_SECONDS)
.max(MAX_CHECKPOINT_TIMEOUT_SECONDS)
.optional(),
/**
* Whether to record a shadow-git checkpoint after every successful write_to_file,
* edit_file, and apply_patch (per-write checkpoints), plus a task-start baseline.
* @default true
*/
perWriteCheckpoints: z.boolean().optional(),
/**
* Detail level for per-step change cards: "full" includes the unified diff
* inline for every changed file, "summary" carries only the file list with
* +/− counts (diffs are fetched lazily by the UI).
* @default "summary"
*/
changeCardDetail: changeCardDetailSchema.optional(),

ttsEnabled: z.boolean().optional(),
ttsSpeed: z.number().optional(),
Expand Down
45 changes: 45 additions & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk {
* - `mcp_server_response`: Response received from MCP server
* - `subtask_result`: Result of a completed subtask
* - `checkpoint_saved`: Indicates a checkpoint has been saved
* - `change_card`: Per-step change card summarizing the files a completed tool step wrote (B3a)
* - `rooignore_error`: Error related to .rooignore file processing
* - `diff_error`: Error occurred while applying a diff/patch
* - `condense_context`: Context condensation/summarization has started
Expand Down Expand Up @@ -162,6 +163,7 @@ export const clineSays = [
"mcp_server_response",
"subtask_result",
"checkpoint_saved",
"change_card",
"rooignore_error",
"diff_error",
"condense_context",
Expand Down Expand Up @@ -235,6 +237,49 @@ export const contextTruncationSchema = z.object({

export type ContextTruncation = z.infer<typeof contextTruncationSchema>

/**
* ChangeCard
*
* Payload of the per-step change card (B3a). The extension host emits one
* `say: "change_card"` message per completed tool write step, keyed by the
* shadow-git checkpoint the step produced. The JSON payload (see
* {@link ChangeCardData}) is carried in the message `text` field, the same
* way tool approval messages carry their serialized ClineSayTool.
*
* `detail: "full"` carries the unified diff inline for every file so the UI
* can render it directly; `detail: "summary"` carries only the file list with
* +/− counts and the UI fetches diffs lazily (B3b). Auto-approved steps are
* always emitted with `detail: "summary"` regardless of the user setting.
*/
export const changeCardDetailSchema = z.enum(["full", "summary"])

export type ChangeCardDetail = z.infer<typeof changeCardDetailSchema>

export const changeCardFileSchema = z.object({
path: z.string(),
additions: z.number(),
deletions: z.number(),
/**
* Unified diff for this file. Only present when the card was emitted with
* `detail: "full"`; summary cards leave it out to stay compact.
*/
diff: z.string().optional(),
})

export type ChangeCardFile = z.infer<typeof changeCardFileSchema>

export const changeCardSchema = z.object({
/** Opaque step identifier, reserved for future tool-step tracking. */
stepId: z.string().optional(),
/** Checkpoint commit SHAs produced by the step (one per per-write checkpoint). */
checkpointIds: z.array(z.string()),
files: z.array(changeCardFileSchema),
totalFiles: z.number(),
detail: changeCardDetailSchema,
})

export type ChangeCardData = z.infer<typeof changeCardSchema>

/**
* ClineMessage
*
Expand Down
4 changes: 3 additions & 1 deletion packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ProviderSettings, ProviderSettingsEntry } from "./provider-setting
import type { HistoryItem } from "./history.js"
import type { ModeConfig, PromptComponent } from "./mode.js"
import type { Experiments } from "./experiment.js"
import type { ClineMessage, QueuedMessage } from "./message.js"
import type { ChangeCardDetail, ClineMessage, QueuedMessage } from "./message.js"
import type { MarketplaceItem, MarketplaceInstalledMetadata, InstallMarketplaceItemOptions } from "./marketplace.js"
import type { TodoItem } from "./todo.js"
import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, ShareVisibility } from "./cloud.js"
Expand Down Expand Up @@ -348,6 +348,8 @@ export type ExtensionState = Pick<

enableCheckpoints: boolean
checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15)
perWriteCheckpoints: boolean
changeCardDetail: ChangeCardDetail
maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500)
maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500)
showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
Expand Down
108 changes: 108 additions & 0 deletions src/core/checkpoints/__tests__/changeCard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest"

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

import {
buildChangeCard,
buildChangeCardPayload,
isAutoApprovedStep,
resolveChangeCardDetail,
type ChangeCardWrite,
} from "../changeCard"

describe("changeCard (B3a)", () => {
function write(overrides: Partial<ChangeCardWrite> = {}): ChangeCardWrite {
return {
path: "src/a.ts",
diffStats: { additions: 2, deletions: 1 },
diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1,2 @@\n-old\n+new-1\n+new-2",
...overrides,
}
}

describe("isAutoApprovedStep", () => {
it("returns false for an empty step", () => {
expect(isAutoApprovedStep([])).toBe(false)
})

it("returns true only when every write was auto-approved", () => {
expect(isAutoApprovedStep([write({ autoApproved: true }), write({ autoApproved: true })])).toBe(true)
expect(isAutoApprovedStep([write({ autoApproved: true }), write()])).toBe(false)
expect(isAutoApprovedStep([write()])).toBe(false)
})
})

describe("resolveChangeCardDetail", () => {
it("forces summary for auto-approved steps even when the setting is full", () => {
const writes = [write({ autoApproved: true })]
expect(resolveChangeCardDetail(writes, "full")).toBe("summary")
expect(resolveChangeCardDetail(writes, undefined)).toBe("summary")
})

it("follows the setting for interactive steps, defaulting to summary when unset", () => {
const writes = [write()]
expect(resolveChangeCardDetail(writes, "full")).toBe("full")
expect(resolveChangeCardDetail(writes, "summary")).toBe("summary")
expect(resolveChangeCardDetail(writes, undefined)).toBe("summary")
})
})

describe("buildChangeCard", () => {
it("carries the inline diff per file for full detail on a multi-file step", () => {
const card = buildChangeCard(
"sha-1",
[write(), write({ path: "src/b.ts", diffStats: { additions: 1, deletions: 0 }, diff: "+b" })],
"full",
)

expect(card).toEqual({
checkpointIds: ["sha-1"],
files: [
{
path: "src/a.ts",
additions: 2,
deletions: 1,
diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1,2 @@\n-old\n+new-1\n+new-2",
},
{ path: "src/b.ts", additions: 1, deletions: 0, diff: "+b" },
],
totalFiles: 2,
detail: "full",
})
})

it("omits the diff per file for summary detail (lazy fetch is B3b)", () => {
const card = buildChangeCard("sha-1", [write()], "summary")

expect(card.files).toEqual([{ path: "src/a.ts", additions: 2, deletions: 1 }])
expect(card.files[0]).not.toHaveProperty("diff")
expect(card.detail).toBe("summary")
expect(card.totalFiles).toBe(1)
})

it("defaults missing diffStats to zero counts and keeps full detail without diff for a write without one", () => {
const card = buildChangeCard("sha-1", [write({ diffStats: undefined, diff: undefined })], "full")

expect(card.files[0]).toEqual({ path: "src/a.ts", additions: 0, deletions: 0 })
})
})

describe("buildChangeCardPayload", () => {
it("resolves the detail level and builds the payload in one call", () => {
// The expectations are typed against the shared ChangeCardData
// contract in @roo-code/types, so the builder's output is checked
// against the same single source of truth the webview consumes.
// Interactive step with the full setting: diff inline.
const full: ChangeCardData = buildChangeCardPayload("sha-1", [write()], "full")
expect(full.detail).toBe("full")
expect(full.files[0].diff).toBe("--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1,2 @@\n-old\n+new-1\n+new-2")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Auto-approved step with the full setting: compact summary, no diff.
const compact: ChangeCardData = buildChangeCardPayload("sha-1", [write({ autoApproved: true })], "full")
expect(compact.detail).toBe("summary")
expect(compact.files[0]).not.toHaveProperty("diff")
expect(compact.checkpointIds).toEqual(["sha-1"])
expect(compact.totalFiles).toBe(1)
})
})
})
138 changes: 138 additions & 0 deletions src/core/checkpoints/__tests__/changeJournal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import fs from "fs/promises"
import os from "os"
import path from "path"

import { afterEach, beforeEach, describe, expect, it } from "vitest"

import { appendChange, journalPath, loadChanges, type ChangeJournalEntry } from "../changeJournal"

describe("changeJournal", () => {
const taskId = "test-task"

let tmpRoot: string

beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-"))
})

afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true })
})

function entry(overrides: Partial<ChangeJournalEntry> = {}): ChangeJournalEntry {
return {
path: "src/foo.ts",
operation: "create",
checkpointId: "abc12345",
...overrides,
}
}

describe("appendChange", () => {
it("writes one JSON line per entry with the documented field shape", async () => {
await appendChange(tmpRoot, taskId, entry({ operation: "create", checkpointId: "aaa" }))

const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8")
const lines = raw.split("\n").filter((line) => line !== "")
expect(lines).toHaveLength(1)
const parsed = JSON.parse(lines[0]) as ChangeJournalEntry
expect(parsed.path).toBe("src/foo.ts")
expect(parsed.operation).toBe("create")
expect(parsed.checkpointId).toBe("aaa")
})

it("appends multiple entries sequentially", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "a" }))
await appendChange(tmpRoot, taskId, entry({ checkpointId: "b" }))

const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8")
expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2)
})
})

describe("loadChanges", () => {
it("returns [] for an absent journal file", async () => {
expect(await loadChanges(tmpRoot, taskId)).toEqual([])
})

it("returns [] for an empty journal file", async () => {
await fs.mkdir(path.dirname(journalPath(tmpRoot, taskId)), { recursive: true })
await fs.writeFile(journalPath(tmpRoot, taskId), "")

expect(await loadChanges(tmpRoot, taskId)).toEqual([])
})

it("parses all entries in order with a clean tail", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "x" }))
await appendChange(tmpRoot, taskId, entry({ checkpointId: "y" }))
await appendChange(tmpRoot, taskId, entry({ checkpointId: "z" }))

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(3)
expect(result[0].checkpointId).toBe("x")
expect(result[1].checkpointId).toBe("y")
expect(result[2].checkpointId).toBe("z")
})

it("parses a journal whose final line has no trailing newline", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" }))

// Rewrite the file without the trailing newline of the last line.
const filePath = journalPath(tmpRoot, taskId)
const content = (await fs.readFile(filePath, "utf8")).replace(/\n$/, "")
await fs.writeFile(filePath, content)

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(1)
expect(result[0].checkpointId).toBe("ok")
})

it("discards a torn final line and returns the complete entries", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" }))

// Append a second line truncated mid-content, with no trailing newline.
await fs.appendFile(journalPath(tmpRoot, taskId), '{"path":"src/half.ts","operation":"upd')

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(1)
expect(result[0].checkpointId).toBe("ok")
})

it("skips a corrupt middle line and still loads the later valid entries", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" }))

// Corrupt the first line in place, then append a valid entry after it.
await fs.writeFile(
journalPath(tmpRoot, taskId),
'"{"path":"src/corrupt.ts","operation":"update"\n' + JSON.stringify(entry({ checkpointId: "after" })) + "\n",
)

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(1)
expect(result[0].checkpointId).toBe("after")
})

it("does not throw when the entire journal is torn", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" }))

// Truncate to a single character — definitely invalid JSON.
await fs.writeFile(journalPath(tmpRoot, taskId), "{")

expect(await loadChanges(tmpRoot, taskId)).toEqual([])
})

it("includes diffStats when present", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "s", diffStats: { additions: 5, deletions: 2 } }))

const result = await loadChanges(tmpRoot, taskId)
expect(result[0].diffStats).toEqual({ additions: 5, deletions: 2 })
})

it("omits diffStats when not provided", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "n" }))

const result = await loadChanges(tmpRoot, taskId)
expect(result[0].diffStats).toBeUndefined()
})
})
})
Loading
Loading