Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions packages/types/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const experimentIds = [
"preventFocusDisruption",
"imageGeneration",
"runSlashCommand",
"filesChangedOverview",
] as const

export const experimentIdsSchema = z.enum(experimentIds)
Expand All @@ -28,6 +29,7 @@ export const experimentsSchema = z.object({
preventFocusDisruption: z.boolean().optional(),
imageGeneration: z.boolean().optional(),
runSlashCommand: z.boolean().optional(),
filesChangedOverview: z.boolean().optional(),
})

export type Experiments = z.infer<typeof experimentsSchema>
Expand Down
21 changes: 21 additions & 0 deletions packages/types/src/file-changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type FileChangeType = "create" | "delete" | "edit"

export interface FileChange {
uri: string
type: FileChangeType
// Note: Checkpoint hashes are for backend use, but can be included
fromCheckpoint: string
toCheckpoint: string
// Line count information for display
linesAdded?: number
linesRemoved?: number
}

/**
* Represents the set of file changes for the webview.
* The `files` property is an array for easy serialization.
*/
export interface FileChangeset {
baseCheckpoint: string
files: FileChange[]
}
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ export * from "./type-fu.js"
export * from "./vscode.js"

export * from "./providers/index.js"
export * from "./file-changes.js"
1 change: 1 addition & 0 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { Task } from "../task/Task"
import { codebaseSearchTool } from "../tools/codebaseSearchTool"
import { experiments, EXPERIMENT_IDS } from "../../shared/experiments"
import { applyDiffToolLegacy } from "../tools/applyDiffTool"
// Live FCO updates removed: FCO now updates on checkpoint events only

/**
* Processes and presents assistant message content to the user interface.
Expand Down
5 changes: 3 additions & 2 deletions src/core/checkpoints/__tests__/checkpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe("Checkpoint functionality", () => {
getDiff: vi.fn().mockResolvedValue([]),
on: vi.fn(),
initShadowGit: vi.fn().mockResolvedValue(undefined),
getCurrentCheckpoint: vi.fn().mockReturnValue("base-hash"),
}

// Create mock provider
Expand Down Expand Up @@ -111,7 +112,7 @@ describe("Checkpoint functionality", () => {
// saveCheckpoint should have been called
expect(mockCheckpointService.saveCheckpoint).toHaveBeenCalledWith(
expect.stringContaining("Task: test-task-id"),
{ allowEmpty: true, suppressMessage: false },
{ allowEmpty: true, files: undefined },
)

// Result should contain the commit hash
Expand Down Expand Up @@ -329,7 +330,7 @@ describe("Checkpoint functionality", () => {
})
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
"vscode.changes",
"Changes compare with next checkpoint",
"Changes since previous checkpoint",
expect.any(Array),
)
})
Expand Down
53 changes: 53 additions & 0 deletions src/core/checkpoints/__tests__/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { vitest } from "vitest"

export const createMockTask = (options: {
taskId: string
hasExistingCheckpoints?: boolean
enableCheckpoints?: boolean
provider?: any
}) => {
const mockTask = {
taskId: options.taskId,
instanceId: "test-instance",
rootTask: undefined as any,
parentTask: undefined as any,
taskNumber: 1,
workspacePath: "/mock/workspace",
enableCheckpoints: options.enableCheckpoints ?? true,
checkpointService: null as any,
checkpointServiceInitializing: false,
ongoingCheckpointSaves: new Map(),
clineMessages: options.hasExistingCheckpoints
? [{ say: "checkpoint_saved", ts: Date.now(), text: "existing-checkpoint-hash" }]
: [],
providerRef: {
deref: () => options.provider || createMockProvider(),
},
fileContextTracker: {},
todoList: undefined,
}

return mockTask
}

export const createMockProvider = () => ({
getFileChangeManager: vitest.fn(),
ensureFileChangeManager: vitest.fn(),
log: vitest.fn(),
postMessageToWebview: vitest.fn(),
getGlobalState: vitest.fn(),
})

// Mock checkpoint service for testing
export const createMockCheckpointService = () => ({
saveCheckpoint: vitest.fn().mockResolvedValue({
commit: "mock-checkpoint-hash",
message: "Mock checkpoint",
}),
restoreCheckpoint: vitest.fn().mockResolvedValue(true),
getDiff: vitest.fn().mockResolvedValue([]),
getCheckpoints: vitest.fn().mockReturnValue([]),
getCurrentCheckpoint: vitest.fn().mockReturnValue("mock-current-checkpoint"),
initShadowGit: vitest.fn().mockResolvedValue(true),
baseHash: "mock-base-hash",
})
227 changes: 227 additions & 0 deletions src/core/checkpoints/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// Use doMock to apply the mock dynamically
vitest.doMock("../../utils/path", () => ({
getWorkspacePath: vitest.fn(() => {
console.log("getWorkspacePath mock called, returning:", "/mock/workspace")
return "/mock/workspace"
}),
}))

// Mock the RepoPerTaskCheckpointService
vitest.mock("../../../services/checkpoints", () => ({
RepoPerTaskCheckpointService: {
create: vitest.fn(),
},
}))

// Mock the TelemetryService to prevent unhandled rejections
vitest.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureCheckpointCreated: vitest.fn(),
captureCheckpointRestored: vitest.fn(),
captureCheckpointDiffed: vitest.fn(),
},
},
}))

import { describe, it, expect, beforeEach, afterEach, vitest } from "vitest"
import * as path from "path"
import * as fs from "fs/promises"
import * as os from "os"
import { EventEmitter } from "events"

// Import these modules after mocks are set up
let getCheckpointService: any
let RepoPerTaskCheckpointService: any

// Set up the imports after mocks
beforeAll(async () => {
const checkpointsModule = await import("../index")
const checkpointServiceModule = await import("../../../services/checkpoints")
getCheckpointService = checkpointsModule.getCheckpointService
RepoPerTaskCheckpointService = checkpointServiceModule.RepoPerTaskCheckpointService
})

// Mock the FileChangeManager to avoid complex dependencies
const mockFileChangeManager = {
_baseline: "HEAD" as string,
getChanges: vitest.fn(),
updateBaseline: vitest.fn(),
setFiles: vitest.fn(),
getLLMOnlyChanges: vitest.fn(),
}

// Create a temporary directory for mock global storage
let mockGlobalStorageDir: string

// Mock the provider
const mockProvider = {
getFileChangeManager: vitest.fn(() => mockFileChangeManager),
log: vitest.fn(),
get context() {
return {
globalStorageUri: {
fsPath: mockGlobalStorageDir,
},
}
},
}

// Mock the Task object with proper typing
const createMockTask = (options: { taskId: string; hasExistingCheckpoints: boolean; enableCheckpoints?: boolean }) => {
const mockTask = {
taskId: options.taskId,
instanceId: "test-instance",
rootTask: undefined as any,
parentTask: undefined as any,
taskNumber: 1,
workspacePath: "/mock/workspace",
enableCheckpoints: options.enableCheckpoints ?? true,
checkpointService: null as any,
checkpointServiceInitializing: false,
ongoingCheckpointSaves: new Map(),
clineMessages: options.hasExistingCheckpoints
? [{ say: "checkpoint_saved", ts: Date.now(), text: "existing-checkpoint-hash" }]
: [],
providerRef: {
deref: () => mockProvider,
},
fileContextTracker: {},
// Add minimal required properties to satisfy Task interface
todoList: undefined,
userMessageContent: "",
apiConversationHistory: [],
customInstructions: "",
alwaysAllowReadOnly: false,
alwaysAllowWrite: false,
alwaysAllowExecute: false,
alwaysAllowBrowser: false,
alwaysAllowMcp: false,
createdAt: Date.now(),
historyErrors: [],
askResponse: undefined,
askResponseText: "",
abort: vitest.fn(),
isAborting: false,
} as any // Cast to any to avoid needing to implement all Task methods
return mockTask
}

describe("getCheckpointService orchestration", () => {
let tmpDir: string
let mockService: any

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "checkpoint-test-"))
mockGlobalStorageDir = path.join(tmpDir, "global-storage")
await fs.mkdir(mockGlobalStorageDir, { recursive: true })

// Reset mocks
vitest.clearAllMocks()

// Override the global vscode mock to have a workspace folder
const vscode = await import("vscode")
// @ts-ignore - Mock the workspace.workspaceFolders
vscode.workspace.workspaceFolders = [
{
uri: {
fsPath: "/mock/workspace",
},
},
]

// Mock the checkpoint service
mockService = new EventEmitter()
mockService.baseHash = "mock-base-hash-abc123"
mockService.getCurrentCheckpoint = vitest.fn(() => "mock-current-checkpoint-def456")
mockService.isInitialized = true
mockService.initShadowGit = vitest.fn(() => {
// Simulate the initialize event being emitted after initShadowGit completes
setImmediate(() => {
mockService.emit("initialize")
})
return Promise.resolve()
})
mockService.saveCheckpoint = vitest.fn(() => {
return Promise.resolve({
commit: "mock-checkpoint-hash",
message: "Mock checkpoint",
})
})

// Mock the service creation
;(RepoPerTaskCheckpointService.create as any).mockReturnValue(mockService)
})

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

describe("Service creation and caching", () => {
it("should create and return a new checkpoint service", async () => {
const task = createMockTask({
taskId: "new-task-123",
hasExistingCheckpoints: false,
})

const service = await getCheckpointService(task)
console.log("Service returned:", service)
expect(service).toBe(mockService)
expect(RepoPerTaskCheckpointService.create).toHaveBeenCalledWith({
taskId: "new-task-123",
shadowDir: mockGlobalStorageDir,
workspaceDir: "/mock/workspace",
log: expect.any(Function),
})
})

it("should return existing service if already initialized", async () => {
const task = createMockTask({
taskId: "existing-service-task",
hasExistingCheckpoints: false,
})

// Set existing checkpoint service
task.checkpointService = mockService

const service = await getCheckpointService(task)
expect(service).toBe(mockService)

// Should not create a new service
expect(RepoPerTaskCheckpointService.create).not.toHaveBeenCalled()
})

it("should return undefined when checkpoints are disabled", async () => {
const task = createMockTask({
taskId: "disabled-task",
hasExistingCheckpoints: false,
enableCheckpoints: false,
})

const service = await getCheckpointService(task)
expect(service).toBeUndefined()
})
})

describe("Service initialization", () => {
it("should call initShadowGit and set up event handlers", async () => {
const task = createMockTask({
taskId: "init-test-task",
hasExistingCheckpoints: false,
})

const service = await getCheckpointService(task)
expect(service).toBe(mockService)

// initShadowGit should be called
expect(mockService.initShadowGit).toHaveBeenCalled()

// Wait for the initialize event to be emitted and the service to be assigned
await new Promise((resolve) => setImmediate(resolve))

// Service should be assigned to task after initialization
expect(task.checkpointService).toBe(mockService)
})
})
})
Loading
Loading