Merge branch 'main' of github.com:NyxJae/Roo-Code into human-relay

This commit is contained in:
Felix NyxJae 2025-03-04 11:23:04 +08:00
commit 40dd7ac532
31 changed files with 1017 additions and 1097 deletions

View file

@ -1,5 +0,0 @@
---
"roo-cline": patch
---
ExtensionStateContext does not correctly merge state

View file

@ -1,5 +0,0 @@
---
"roo-cline": patch
---
Default middle-out compression to on for OpenRouter

View file

@ -1,5 +1,17 @@
# Roo Code Changelog
## [3.7.12]
- Expand max tokens of thinking models to 128k, and max thinking budget to over 100k (thanks @monotykamary!)
- Fix issue where keyboard mode switcher wasn't updating API profile (thanks @aheizi!)
- Use the count_tokens API in the Anthropic provider for more accurate context window management
- Default middle-out compression to on for OpenRouter
- Exclude MCP instructions from the prompt if the mode doesn't support MCP
- Add a checkbox to disable the browser tool
- Show a warning if checkpoints are taking too long to load
- Update the warning text for the VS LM API
- Correctly populate the default OpenRouter model on the welcome screen
## [3.7.11]
- Don't honor custom max tokens for non thinking models

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "roo-cline",
"version": "3.7.11",
"version": "3.7.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "roo-cline",
"version": "3.7.11",
"version": "3.7.12",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.37.0",

View file

@ -3,7 +3,7 @@
"displayName": "Roo Code (prev. Roo Cline)",
"description": "A whole dev team of AI agents in your editor.",
"publisher": "RooVeterinaryInc",
"version": "3.7.11",
"version": "3.7.12",
"icon": "assets/icons/rocket.png",
"galleryBanner": {
"color": "#617A91",

View file

@ -72,7 +72,7 @@ describe("OpenRouterHandler", () => {
openRouterModelId: "test-model",
openRouterModelInfo: {
...mockOpenRouterModelInfo,
maxTokens: 64_000,
maxTokens: 128_000,
thinking: true,
},
modelMaxTokens: 32_768,

View file

@ -29,7 +29,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
let stream: AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>
const cacheControl: CacheControlEphemeral = { type: "ephemeral" }
let { id: modelId, maxTokens, thinking, temperature } = this.getModel()
let { id: modelId, maxTokens, thinking, temperature, virtualId } = this.getModel()
switch (modelId) {
case "claude-3-7-sonnet-20250219":
@ -82,13 +82,24 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
// prompt caching: https://x.com/alexalbert__/status/1823751995901272068
// https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers
// https://github.com/anthropics/anthropic-sdk-typescript/commit/c920b77fc67bd839bfeb6716ceab9d7c9bbe7393
const betas = []
// Check for the thinking-128k variant first
if (virtualId === "claude-3-7-sonnet-20250219:thinking") {
betas.push("output-128k-2025-02-19")
}
// Then check for models that support prompt caching
switch (modelId) {
case "claude-3-7-sonnet-20250219":
case "claude-3-5-sonnet-20241022":
case "claude-3-5-haiku-20241022":
case "claude-3-opus-20240229":
case "claude-3-haiku-20240307":
betas.push("prompt-caching-2024-07-31")
return {
headers: { "anthropic-beta": "prompt-caching-2024-07-31" },
headers: { "anthropic-beta": betas.join(",") },
}
default:
return undefined
@ -184,6 +195,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
let id = modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId
const info: ModelInfo = anthropicModels[id]
// Track the original model ID for special variant handling
const virtualId = id
// The `:thinking` variant is a virtual identifier for the
// `claude-3-7-sonnet-20250219` model with a thinking budget.
// We can handle this more elegantly in the future.
@ -194,6 +208,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
return {
id,
info,
virtualId, // Include the original ID to use for header selection
...getModelParams({ options: this.options, model: info, defaultMaxTokens: ANTHROPIC_DEFAULT_MAX_TOKENS }),
}
}

View file

@ -263,7 +263,7 @@ export async function getOpenRouterModels() {
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
modelInfo.maxTokens = rawModel.id === "anthropic/claude-3.7-sonnet:thinking" ? 64_000 : 16_384
modelInfo.maxTokens = rawModel.id === "anthropic/claude-3.7-sonnet:thinking" ? 128_000 : 16_384
break
case rawModel.id.startsWith("anthropic/claude-3.5-sonnet-20240620"):
modelInfo.supportsPromptCache = true

View file

@ -10,10 +10,10 @@ import getFolderSize from "get-folder-size"
import * as path from "path"
import { serializeError } from "serialize-error"
import * as vscode from "vscode"
import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
import { ApiHandler, buildApiHandler } from "../api"
import { ApiStream } from "../api/transform/stream"
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
import { CheckpointService, CheckpointServiceFactory } from "../services/checkpoints"
import { ShadowCheckpointService } from "../services/checkpoints/ShadowCheckpointService"
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
import {
extractTextFromFile,
@ -116,7 +116,7 @@ export class Cline {
// checkpoints
enableCheckpoints: boolean = false
private checkpointService?: CheckpointService
private checkpointService?: ShadowCheckpointService
// streaming
isWaitingForFirstChunk = false
@ -747,8 +747,11 @@ export class Cline {
}
private async initiateTaskLoop(userContent: UserContent): Promise<void> {
this.initializeCheckpoints()
let nextUserContent = userContent
let includeFileDetails = true
while (!this.abort) {
const didEndLoop = await this.recursivelyMakeClineRequests(nextUserContent, includeFileDetails)
includeFileDetails = false // we only need file details the first time
@ -946,6 +949,7 @@ export class Cline {
preferredLanguage,
experiments,
enableMcpServerCreation,
browserToolEnabled,
} = (await this.providerRef.deref()?.getState()) ?? {}
const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
const systemPrompt = await (async () => {
@ -956,7 +960,7 @@ export class Cline {
return SYSTEM_PROMPT(
provider.context,
cwd,
this.api.getModel().info.supportsComputerUse ?? false,
(this.api.getModel().info.supportsComputerUse ?? false) && (browserToolEnabled ?? true),
mcpHub,
this.diffStrategy,
browserViewportSize,
@ -2772,7 +2776,7 @@ export class Cline {
}
if (isCheckpointPossible) {
await this.checkpointSave({ isFirst: false })
this.checkpointSave()
}
/*
@ -2838,13 +2842,6 @@ export class Cline {
// get previous api req's index to check token usage and determine if we need to truncate conversation history
const previousApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
// Save checkpoint if this is the first API request.
const isFirstRequest = this.clineMessages.filter((m) => m.say === "api_req_started").length === 0
if (isFirstRequest) {
await this.checkpointSave({ isFirst: true })
}
// getting verbose details is an expensive operation, it uses globby to top-down build file structure of project which for large projects can take a few seconds
// for the best UX we show a placeholder api_req_started message with a loading spinner as this happens
await this.say(
@ -3355,37 +3352,88 @@ export class Cline {
// Checkpoints
private async getCheckpointService() {
private async initializeCheckpoints() {
if (!this.enableCheckpoints) {
throw new Error("Checkpoints are disabled")
return
}
if (!this.checkpointService) {
const log = (message: string) => {
console.log(message)
try {
this.providerRef.deref()?.log(message)
} catch (err) {
// NO-OP
}
}
try {
if (this.checkpointService) {
log("[Cline#initializeCheckpoints] checkpointService already initialized")
return
}
const workspaceDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
const shadowDir = this.providerRef.deref()?.context.globalStorageUri.fsPath
if (!workspaceDir) {
this.providerRef.deref()?.log("[getCheckpointService] workspace folder not found")
throw new Error("Workspace directory not found")
log("[Cline#initializeCheckpoints] workspace folder not found, disabling checkpoints")
this.enableCheckpoints = false
return
}
const shadowDir = this.providerRef.deref()?.context.globalStorageUri.fsPath
if (!shadowDir) {
this.providerRef.deref()?.log("[getCheckpointService] shadowDir not found")
throw new Error("Global storage directory not found")
log("[Cline#initializeCheckpoints] shadowDir not found, disabling checkpoints")
this.enableCheckpoints = false
return
}
this.checkpointService = await CheckpointServiceFactory.create({
strategy: "shadow",
options: {
taskId: this.taskId,
workspaceDir,
shadowDir,
log: (message) => this.providerRef.deref()?.log(message),
},
})
}
const service = await ShadowCheckpointService.create({ taskId: this.taskId, workspaceDir, shadowDir, log })
return this.checkpointService
if (!service) {
log("[Cline#initializeCheckpoints] failed to create checkpoint service, disabling checkpoints")
this.enableCheckpoints = false
return
}
service.on("initialize", ({ workspaceDir, created, duration }) => {
try {
if (created) {
log(`[Cline#initializeCheckpoints] created new shadow repo (${workspaceDir}) in ${duration}ms`)
} else {
log(
`[Cline#initializeCheckpoints] found existing shadow repo (${workspaceDir}) in ${duration}ms`,
)
}
this.checkpointService = service
this.checkpointSave()
} catch (err) {
log("[Cline#initializeCheckpoints] caught error in on('initialize'), disabling checkpoints")
this.enableCheckpoints = false
}
})
service.on("checkpoint", ({ isFirst, fromHash: from, toHash: to }) => {
try {
log(`[Cline#initializeCheckpoints] ${isFirst ? "initial" : "incremental"} checkpoint saved: ${to}`)
this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: to })
this.say("checkpoint_saved", to, undefined, undefined, { isFirst, from, to }).catch((e) =>
console.error("Error saving checkpoint message:", e),
)
} catch (err) {
log("[Cline#initializeCheckpoints] caught error in on('checkpoint'), disabling checkpoints")
this.enableCheckpoints = false
}
})
service.initShadowGit()
} catch (err) {
log("[Cline#initializeCheckpoints] caught error in initializeCheckpoints(), disabling checkpoints")
this.enableCheckpoints = false
}
}
public async checkpointDiff({
@ -3397,7 +3445,7 @@ export class Cline {
commitHash: string
mode: "full" | "checkpoint"
}) {
if (!this.enableCheckpoints) {
if (!this.checkpointService || !this.enableCheckpoints) {
return
}
@ -3413,8 +3461,7 @@ export class Cline {
}
try {
const service = await this.getCheckpointService()
const changes = await service.getDiff({ from: previousCommitHash, to: commitHash })
const changes = await this.checkpointService.getDiff({ from: previousCommitHash, to: commitHash })
if (!changes?.length) {
vscode.window.showInformationMessage("No changes found.")
@ -3440,30 +3487,13 @@ export class Cline {
}
}
public async checkpointSave({ isFirst }: { isFirst: boolean }) {
if (!this.enableCheckpoints) {
public checkpointSave() {
if (!this.checkpointService || !this.enableCheckpoints) {
return
}
try {
const service = await this.getCheckpointService()
const strategy = service.strategy
const version = service.version
const commit = await service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
const fromHash = service.baseHash
const toHash = isFirst ? commit?.commit || fromHash : commit?.commit
if (toHash) {
await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: toHash })
const checkpoint = { isFirst, from: fromHash, to: toHash, strategy, version }
await this.say("checkpoint_saved", toHash, undefined, undefined, checkpoint)
}
} catch (err) {
this.providerRef.deref()?.log("[checkpointSave] disabling checkpoints for this task")
this.enableCheckpoints = false
}
// Start the checkpoint process in the background.
this.checkpointService.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
}
public async checkpointRestore({
@ -3475,7 +3505,7 @@ export class Cline {
commitHash: string
mode: "preview" | "restore"
}) {
if (!this.enableCheckpoints) {
if (!this.checkpointService || !this.enableCheckpoints) {
return
}
@ -3486,8 +3516,7 @@ export class Cline {
}
try {
const service = await this.getCheckpointService()
await service.restoreCheckpoint(commitHash)
await this.checkpointService.restoreCheckpoint(commitHash)
await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: commitHash })

View file

@ -7,6 +7,7 @@ import {
defaultModeSlug,
ModeConfig,
getModeBySlug,
getGroupName,
} from "../../shared/modes"
import { DiffStrategy } from "../diff/DiffStrategy"
import { McpHub } from "../../services/mcp/McpHub"
@ -50,15 +51,17 @@ async function generatePrompt(
// If diff is disabled, don't pass the diffStrategy
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
const [mcpServersSection, modesSection] = await Promise.all([
getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation),
getModesSection(context),
])
// Get the full mode config to ensure we have the role definition
const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0]
const roleDefinition = promptComponent?.roleDefinition || modeConfig.roleDefinition
const [modesSection, mcpServersSection] = await Promise.all([
getModesSection(context),
modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp")
? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation)
: Promise.resolve(""),
])
const basePrompt = `${roleDefinition}
${getSharedToolUseSection()}

View file

@ -1234,6 +1234,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("maxOpenTabsContext", tabCount)
await this.postStateToWebview()
break
case "browserToolEnabled":
await this.updateGlobalState("browserToolEnabled", message.bool ?? true)
await this.postStateToWebview()
break
case "enhancementApiConfigId":
await this.updateGlobalState("enhancementApiConfigId", message.text)
await this.postStateToWebview()
@ -2051,6 +2055,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
autoApprovalEnabled,
experiments,
maxOpenTabsContext,
browserToolEnabled,
} = await this.getState()
const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || []
@ -2104,6 +2109,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
mcpServers: this.mcpHub?.getAllServers() ?? [],
maxOpenTabsContext: maxOpenTabsContext ?? 20,
cwd: cwd,
browserToolEnabled: browserToolEnabled ?? true,
}
}
@ -2243,6 +2249,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
modelMaxTokens,
modelMaxThinkingTokens,
maxOpenTabsContext,
browserToolEnabled,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@ -2327,6 +2334,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("modelMaxTokens") as Promise<number | undefined>,
this.getGlobalState("anthropicThinking") as Promise<number | undefined>,
this.getGlobalState("maxOpenTabsContext") as Promise<number | undefined>,
this.getGlobalState("browserToolEnabled") as Promise<boolean | undefined>,
])
let apiProvider: ApiProvider
@ -2460,6 +2468,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
customModes,
maxOpenTabsContext: maxOpenTabsContext ?? 20,
openRouterUseMiddleOutTransform: openRouterUseMiddleOutTransform ?? true,
browserToolEnabled: browserToolEnabled ?? true,
}
}

View file

@ -381,6 +381,7 @@ describe("ClineProvider", () => {
customModes: [],
experiments: experimentDefault,
maxOpenTabsContext: 20,
browserToolEnabled: true,
}
const message: ExtensionMessage = {
@ -591,6 +592,21 @@ describe("ClineProvider", () => {
expect(provider.configManager.setModeConfig).toHaveBeenCalledWith("architect", "new-id")
})
test("handles browserToolEnabled setting", async () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
// Test browserToolEnabled
await messageHandler({ type: "browserToolEnabled", bool: true })
expect(mockContext.globalState.update).toHaveBeenCalledWith("browserToolEnabled", true)
expect(mockPostMessage).toHaveBeenCalled()
// Verify state includes browserToolEnabled
const state = await provider.getState()
expect(state).toHaveProperty("browserToolEnabled")
expect(state.browserToolEnabled).toBe(true) // Default value should be true
})
test("handles request delay settings messages", async () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]

View file

@ -1,29 +0,0 @@
import { LocalCheckpointService, LocalCheckpointServiceOptions } from "./LocalCheckpointService"
import { ShadowCheckpointService, ShadowCheckpointServiceOptions } from "./ShadowCheckpointService"
export type CreateCheckpointServiceFactoryOptions =
| {
strategy: "local"
options: LocalCheckpointServiceOptions
}
| {
strategy: "shadow"
options: ShadowCheckpointServiceOptions
}
type CheckpointServiceType<T extends CreateCheckpointServiceFactoryOptions> = T extends { strategy: "local" }
? LocalCheckpointService
: T extends { strategy: "shadow" }
? ShadowCheckpointService
: never
export class CheckpointServiceFactory {
public static create<T extends CreateCheckpointServiceFactoryOptions>(options: T): CheckpointServiceType<T> {
switch (options.strategy) {
case "local":
return LocalCheckpointService.create(options.options) as any
case "shadow":
return ShadowCheckpointService.create(options.options) as any
}
}
}

View file

@ -1,440 +0,0 @@
import fs from "fs/promises"
import { existsSync } from "fs"
import path from "path"
import simpleGit, { SimpleGit, CleanOptions } from "simple-git"
import { CheckpointStrategy, CheckpointService, CheckpointServiceOptions } from "./types"
export interface LocalCheckpointServiceOptions extends CheckpointServiceOptions {}
/**
* The CheckpointService provides a mechanism for storing a snapshot of the
* current VSCode workspace each time a Roo Code tool is executed. It uses Git
* under the hood.
*
* HOW IT WORKS
*
* Two branches are used:
* - A main branch for normal operation (the branch you are currently on).
* - A hidden branch for storing checkpoints.
*
* Saving a checkpoint:
* - A temporary branch is created to store the current state.
* - All changes (including untracked files) are staged and committed on the temp branch.
* - The hidden branch is reset to match main.
* - The temporary branch commit is cherry-picked onto the hidden branch.
* - The workspace is restored to its original state and the temp branch is deleted.
*
* Restoring a checkpoint:
* - The workspace is restored to the state of the specified checkpoint using
* `git restore` and `git clean`.
*
* This approach allows for:
* - Non-destructive version control (main branch remains untouched).
* - Preservation of the full history of checkpoints.
* - Safe restoration to any previous checkpoint.
* - Atomic checkpoint operations with proper error recovery.
*
* NOTES
*
* - Git must be installed.
* - If the current working directory is not a Git repository, we will
* initialize a new one with a .gitkeep file.
* - If you manually edit files and then restore a checkpoint, the changes
* will be lost. Addressing this adds some complexity to the implementation
* and it's not clear whether it's worth it.
*/
export class LocalCheckpointService implements CheckpointService {
private static readonly USER_NAME = "Roo Code"
private static readonly USER_EMAIL = "support@roocode.com"
private static readonly CHECKPOINT_BRANCH = "roo-code-checkpoints"
private static readonly STASH_BRANCH = "roo-code-stash"
public readonly strategy: CheckpointStrategy = "local"
public readonly version = 1
public get baseHash() {
return this._baseHash
}
constructor(
public readonly taskId: string,
public readonly git: SimpleGit,
public readonly workspaceDir: string,
private readonly mainBranch: string,
private _baseHash: string,
private readonly hiddenBranch: string,
private readonly log: (message: string) => void,
) {}
private async ensureBranch(expectedBranch: string) {
const branch = await this.git.revparse(["--abbrev-ref", "HEAD"])
if (branch.trim() !== expectedBranch) {
throw new Error(`Git branch mismatch: expected '${expectedBranch}' but found '${branch}'`)
}
}
public async getDiff({ from, to }: { from?: string; to?: string }) {
const result = []
if (!from) {
from = this.baseHash
}
const { files } = await this.git.diffSummary([`${from}..${to}`])
for (const file of files.filter((f) => !f.binary)) {
const relPath = file.file
const absPath = path.join(this.workspaceDir, relPath)
const before = await this.git.show([`${from}:${relPath}`]).catch(() => "")
const after = to
? await this.git.show([`${to}:${relPath}`]).catch(() => "")
: await fs.readFile(absPath, "utf8").catch(() => "")
result.push({
paths: { relative: relPath, absolute: absPath },
content: { before, after },
})
}
return result
}
private async restoreMain({
branch,
stashSha,
force = false,
}: {
branch: string
stashSha: string
force?: boolean
}) {
let currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
if (currentBranch !== this.mainBranch) {
if (force) {
try {
await this.git.checkout(["-f", this.mainBranch])
} catch (err) {
this.log(
`[restoreMain] failed to force checkout ${this.mainBranch}: ${err instanceof Error ? err.message : String(err)}`,
)
}
} else {
try {
await this.git.checkout(this.mainBranch)
} catch (err) {
this.log(
`[restoreMain] failed to checkout ${this.mainBranch}: ${err instanceof Error ? err.message : String(err)}`,
)
// Escalate to a forced checkout if we can't checkout the
// main branch under normal circumstances.
currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
if (currentBranch !== this.mainBranch) {
await this.git.checkout(["-f", this.mainBranch]).catch(() => {})
}
}
}
}
currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
if (currentBranch !== this.mainBranch) {
throw new Error(`Unable to restore ${this.mainBranch}`)
}
if (stashSha) {
this.log(`[restoreMain] applying stash ${stashSha}`)
try {
await this.git.raw(["stash", "apply", "--index", stashSha])
} catch (err) {
this.log(`[restoreMain] Failed to apply stash: ${err instanceof Error ? err.message : String(err)}`)
}
}
this.log(`[restoreMain] restoring from ${branch} branch`)
try {
await this.git.raw(["restore", "--source", branch, "--worktree", "--", "."])
} catch (err) {
this.log(`[restoreMain] Failed to restore branch: ${err instanceof Error ? err.message : String(err)}`)
}
}
public async saveCheckpoint(message: string) {
const startTime = Date.now()
await this.ensureBranch(this.mainBranch)
const stashSha = (await this.git.raw(["stash", "create"])).trim()
const latestSha = await this.git.revparse([this.hiddenBranch])
/**
* PHASE: Create stash
* Mutations:
* - Create branch
* - Change branch
*/
const stashBranch = `${LocalCheckpointService.STASH_BRANCH}-${Date.now()}`
await this.git.checkout(["-b", stashBranch])
this.log(`[saveCheckpoint] created and checked out ${stashBranch}`)
/**
* Phase: Stage stash
* Mutations: None
* Recovery:
* - UNDO: Create branch
* - UNDO: Change branch
*/
try {
await this.git.add(["-A"])
} catch (err) {
this.log(
`[saveCheckpoint] failed in stage stash phase: ${err instanceof Error ? err.message : String(err)}`,
)
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
await this.git.branch(["-D", stashBranch]).catch(() => {})
throw err
}
/**
* Phase: Commit stash
* Mutations:
* - Commit stash
* - Change branch
* Recovery:
* - UNDO: Create branch
* - UNDO: Change branch
*/
let stashCommit
try {
stashCommit = await this.git.commit(message, undefined, { "--no-verify": null })
this.log(`[saveCheckpoint] stashCommit: ${message} -> ${JSON.stringify(stashCommit)}`)
} catch (err) {
this.log(
`[saveCheckpoint] failed in stash commit phase: ${err instanceof Error ? err.message : String(err)}`,
)
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
await this.git.branch(["-D", stashBranch]).catch(() => {})
throw err
}
if (!stashCommit) {
this.log("[saveCheckpoint] no stash commit")
await this.restoreMain({ branch: stashBranch, stashSha })
await this.git.branch(["-D", stashBranch])
return undefined
}
/**
* PHASE: Diff
* Mutations:
* - Checkout hidden branch
* Recovery:
* - UNDO: Create branch
* - UNDO: Change branch
* - UNDO: Commit stash
*/
let diff
try {
diff = await this.git.diff([latestSha, stashBranch])
} catch (err) {
this.log(`[saveCheckpoint] failed in diff phase: ${err instanceof Error ? err.message : String(err)}`)
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
await this.git.branch(["-D", stashBranch]).catch(() => {})
throw err
}
if (!diff) {
this.log("[saveCheckpoint] no diff")
await this.restoreMain({ branch: stashBranch, stashSha })
await this.git.branch(["-D", stashBranch])
return undefined
}
/**
* PHASE: Reset
* Mutations:
* - Reset hidden branch
* Recovery:
* - UNDO: Create branch
* - UNDO: Change branch
* - UNDO: Commit stash
*/
try {
await this.git.checkout(this.hiddenBranch)
this.log(`[saveCheckpoint] checked out ${this.hiddenBranch}`)
await this.git.reset(["--hard", this.mainBranch])
this.log(`[saveCheckpoint] reset ${this.hiddenBranch}`)
} catch (err) {
this.log(`[saveCheckpoint] failed in reset phase: ${err instanceof Error ? err.message : String(err)}`)
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
await this.git.branch(["-D", stashBranch]).catch(() => {})
throw err
}
/**
* PHASE: Cherry pick
* Mutations:
* - Hidden commit (NOTE: reset on hidden branch no longer needed in
* success scenario.)
* Recovery:
* - UNDO: Create branch
* - UNDO: Change branch
* - UNDO: Commit stash
* - UNDO: Reset hidden branch
*/
let commit = ""
try {
try {
await this.git.raw(["cherry-pick", stashBranch])
} catch (err) {
// Check if we're in the middle of a cherry-pick.
// If the cherry-pick resulted in an empty commit (e.g., only
// deletions) then complete it with --allow-empty.
// Otherwise, rethrow the error.
if (existsSync(path.join(this.workspaceDir, ".git/CHERRY_PICK_HEAD"))) {
await this.git.raw(["commit", "--allow-empty", "--no-edit"])
} else {
throw err
}
}
commit = await this.git.revparse(["HEAD"])
this.log(`[saveCheckpoint] cherry-pick commit = ${commit}`)
} catch (err) {
this.log(
`[saveCheckpoint] failed in cherry pick phase: ${err instanceof Error ? err.message : String(err)}`,
)
await this.git.reset(["--hard", latestSha]).catch(() => {})
await this.restoreMain({ branch: stashBranch, stashSha, force: true })
await this.git.branch(["-D", stashBranch]).catch(() => {})
throw err
}
await this.restoreMain({ branch: stashBranch, stashSha })
await this.git.branch(["-D", stashBranch])
// We've gotten reports that checkpoints can be slow in some cases, so
// we'll log the duration of the checkpoint save.
const duration = Date.now() - startTime
this.log(`[saveCheckpoint] saved checkpoint ${commit} in ${duration}ms`)
return { commit }
}
public async restoreCheckpoint(commitHash: string) {
const startTime = Date.now()
await this.ensureBranch(this.mainBranch)
await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
const duration = Date.now() - startTime
this.log(`[restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
}
public static async create({ taskId, workspaceDir, log = console.log }: LocalCheckpointServiceOptions) {
const git = simpleGit(workspaceDir)
const version = await git.version()
if (!version?.installed) {
throw new Error(`Git is not installed. Please install Git if you wish to use checkpoints.`)
}
if (!workspaceDir || !existsSync(workspaceDir)) {
throw new Error(`Base directory is not set or does not exist.`)
}
const { currentBranch, currentSha, hiddenBranch } = await LocalCheckpointService.initRepo(git, {
taskId,
workspaceDir,
log,
})
log(
`[create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`,
)
return new LocalCheckpointService(taskId, git, workspaceDir, currentBranch, currentSha, hiddenBranch, log)
}
private static async initRepo(
git: SimpleGit,
{ taskId, workspaceDir, log }: Required<LocalCheckpointServiceOptions>,
) {
const isExistingRepo = existsSync(path.join(workspaceDir, ".git"))
if (!isExistingRepo) {
await git.init()
log(`[initRepo] Initialized new Git repository at ${workspaceDir}`)
}
const globalUserName = await git.getConfig("user.name", "global")
const localUserName = await git.getConfig("user.name", "local")
const userName = localUserName.value || globalUserName.value
const globalUserEmail = await git.getConfig("user.email", "global")
const localUserEmail = await git.getConfig("user.email", "local")
const userEmail = localUserEmail.value || globalUserEmail.value
// Prior versions of this service indiscriminately set the local user
// config, and it should not override the global config. To address
// this we remove the local user config if it matches the default
// user name and email and there's a global config.
if (globalUserName.value && localUserName.value === LocalCheckpointService.USER_NAME) {
await git.raw(["config", "--unset", "--local", "user.name"])
}
if (globalUserEmail.value && localUserEmail.value === LocalCheckpointService.USER_EMAIL) {
await git.raw(["config", "--unset", "--local", "user.email"])
}
// Only set user config if not already configured.
if (!userName) {
await git.addConfig("user.name", LocalCheckpointService.USER_NAME)
}
if (!userEmail) {
await git.addConfig("user.email", LocalCheckpointService.USER_EMAIL)
}
if (!isExistingRepo) {
// We need at least one file to commit, otherwise the initial
// commit will fail, unless we use the `--allow-empty` flag.
// However, using an empty commit causes problems when restoring
// the checkpoint (i.e. the `git restore` command doesn't work
// for empty commits).
await fs.writeFile(path.join(workspaceDir, ".gitkeep"), "")
await git.add(".gitkeep")
const commit = await git.commit("Initial commit")
if (!commit.commit) {
throw new Error("Failed to create initial commit")
}
log(`[initRepo] Initial commit: ${commit.commit}`)
}
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
const currentSha = await git.revparse(["HEAD"])
const hiddenBranch = `${LocalCheckpointService.CHECKPOINT_BRANCH}-${taskId}`
const branchSummary = await git.branch()
if (!branchSummary.all.includes(hiddenBranch)) {
await git.checkoutBranch(hiddenBranch, currentBranch)
await git.checkout(currentBranch)
}
return { currentBranch, currentSha, hiddenBranch }
}
}

View file

@ -5,17 +5,18 @@ import { globby } from "globby"
import simpleGit, { SimpleGit } from "simple-git"
import { GIT_DISABLED_SUFFIX, GIT_EXCLUDES } from "./constants"
import { CheckpointStrategy, CheckpointService, CheckpointServiceOptions } from "./types"
import { CheckpointService, CheckpointServiceOptions, CheckpointEventEmitter } from "./types"
export interface ShadowCheckpointServiceOptions extends CheckpointServiceOptions {
shadowDir: string
}
export class ShadowCheckpointService implements CheckpointService {
public readonly strategy: CheckpointStrategy = "shadow"
export class ShadowCheckpointService extends CheckpointEventEmitter implements CheckpointService {
public readonly version = 1
private _checkpoints: string[] = []
private _baseHash?: string
private _isInitialized = false
public get baseHash() {
return this._baseHash
@ -25,6 +26,14 @@ export class ShadowCheckpointService implements CheckpointService {
this._baseHash = value
}
public get isInitialized() {
return this._isInitialized
}
private set isInitialized(value: boolean) {
this._isInitialized = value
}
private readonly shadowGitDir: string
private shadowGitConfigWorktree?: string
@ -35,18 +44,26 @@ export class ShadowCheckpointService implements CheckpointService {
public readonly workspaceDir: string,
private readonly log: (message: string) => void,
) {
super()
this.shadowGitDir = path.join(this.shadowDir, "tasks", this.taskId, "checkpoints", ".git")
}
private async initShadowGit() {
public async initShadowGit() {
if (this.isInitialized) {
return
}
const fileExistsAtPath = (path: string) =>
fs
.access(path)
.then(() => true)
.catch(() => false)
let created = false
const startTime = Date.now()
if (await fileExistsAtPath(this.shadowGitDir)) {
this.log(`[initShadowGit] shadow git repo already exists at ${this.shadowGitDir}`)
this.log(`[CheckpointService#initShadowGit] shadow git repo already exists at ${this.shadowGitDir}`)
const worktree = await this.getShadowGitConfigWorktree()
if (worktree !== this.workspaceDir) {
@ -57,7 +74,7 @@ export class ShadowCheckpointService implements CheckpointService {
this.baseHash = await this.git.revparse(["--abbrev-ref", "HEAD"])
} else {
this.log(`[initShadowGit] creating shadow git repo at ${this.workspaceDir}`)
this.log(`[CheckpointService#initShadowGit] creating shadow git repo at ${this.workspaceDir}`)
await this.git.init()
await this.git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
@ -78,7 +95,7 @@ export class ShadowCheckpointService implements CheckpointService {
}
} catch (error) {
this.log(
`[initShadowGit] failed to read .gitattributes: ${error instanceof Error ? error.message : String(error)}`,
`[CheckpointService#initShadowGit] failed to read .gitattributes: ${error instanceof Error ? error.message : String(error)}`,
)
}
@ -93,8 +110,23 @@ export class ShadowCheckpointService implements CheckpointService {
await this.stageAll()
const { commit } = await this.git.commit("initial commit", { "--allow-empty": null })
this.baseHash = commit
this.log(`[initShadowGit] base commit is ${commit}`)
this.log(`[CheckpointService#initShadowGit] base commit is ${commit}`)
created = true
}
const duration = Date.now() - startTime
this.log(`[CheckpointService#initShadowGit] initialized shadow git in ${duration}ms`)
this.isInitialized = true
this.emit("initialize", {
type: "initialize",
workspaceDir: this.workspaceDir,
baseHash: this.baseHash,
created,
duration,
})
}
private async stageAll() {
@ -103,7 +135,9 @@ export class ShadowCheckpointService implements CheckpointService {
try {
await this.git.add(".")
} catch (error) {
this.log(`[stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`)
this.log(
`[CheckpointService#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
)
} finally {
await this.renameNestedGitRepos(false)
}
@ -137,10 +171,12 @@ export class ShadowCheckpointService implements CheckpointService {
try {
await fs.rename(fullPath, newPath)
this.log(`${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
this.log(
`[CheckpointService#renameNestedGitRepos] ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`,
)
} catch (error) {
this.log(
`failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`,
`[CheckpointService#renameNestedGitRepos] failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
@ -152,7 +188,7 @@ export class ShadowCheckpointService implements CheckpointService {
this.shadowGitConfigWorktree = (await this.git.getConfig("core.worktree")).value || undefined
} catch (error) {
this.log(
`[getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
`[CheckpointService#getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
@ -162,35 +198,63 @@ export class ShadowCheckpointService implements CheckpointService {
public async saveCheckpoint(message: string) {
try {
this.log("[CheckpointService#saveCheckpoint] starting checkpoint save")
if (!this.isInitialized) {
throw new Error("Shadow git repo not initialized")
}
const startTime = Date.now()
await this.stageAll()
const result = await this.git.commit(message)
if (result.commit) {
const duration = Date.now() - startTime
this.log(`[saveCheckpoint] saved checkpoint ${result.commit} in ${duration}ms`)
return result
} else {
return undefined
}
} catch (error) {
this.log(
`[saveCheckpoint] failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`,
)
const isFirst = this._checkpoints.length === 0
const fromHash = this._checkpoints[this._checkpoints.length - 1] ?? this.baseHash!
const toHash = result.commit ?? fromHash
this._checkpoints.push(toHash)
const duration = Date.now() - startTime
this.emit("checkpoint", { type: "checkpoint", isFirst, fromHash, toHash, duration })
return result.commit ? result : undefined
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
this.log(`[CheckpointService#saveCheckpoint] failed to create checkpoint: ${error.message}`)
this.emit("error", { type: "error", error })
throw error
}
}
public async restoreCheckpoint(commitHash: string) {
const start = Date.now()
await this.git.clean("f", ["-d", "-f"])
await this.git.reset(["--hard", commitHash])
const duration = Date.now() - start
this.log(`[restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
try {
if (!this.isInitialized) {
throw new Error("Shadow git repo not initialized")
}
const start = Date.now()
await this.git.clean("f", ["-d", "-f"])
await this.git.reset(["--hard", commitHash])
// Remove all checkpoints after the specified commitHash.
const checkpointIndex = this._checkpoints.indexOf(commitHash)
if (checkpointIndex !== -1) {
this._checkpoints = this._checkpoints.slice(0, checkpointIndex + 1)
}
const duration = Date.now() - start
this.emit("restore", { type: "restore", commitHash, duration })
this.log(`[CheckpointService#restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
this.log(`[CheckpointService#restoreCheckpoint] failed to restore checkpoint: ${error.message}`)
this.emit("error", { type: "error", error })
throw error
}
}
public async getDiff({ from, to }: { from?: string; to?: string }) {
if (!this.isInitialized) {
throw new Error("Shadow git repo not initialized")
}
const result = []
if (!from) {
@ -223,6 +287,7 @@ export class ShadowCheckpointService implements CheckpointService {
try {
await simpleGit().version()
} catch (error) {
log("[CheckpointService#create] git is not installed")
throw new Error("Git must be installed to use checkpoints.")
}
@ -241,9 +306,7 @@ export class ShadowCheckpointService implements CheckpointService {
const gitDir = path.join(checkpointsDir, ".git")
const git = simpleGit(path.dirname(gitDir))
log(`[create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, shadowDir = ${shadowDir}`)
const service = new ShadowCheckpointService(taskId, git, shadowDir, workspaceDir, log)
await service.initShadowGit()
return service
log(`[CheckpointService#create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, shadowDir = ${shadowDir}`)
return new ShadowCheckpointService(taskId, git, shadowDir, workspaceDir, log)
}
}

View file

@ -1,385 +0,0 @@
// npx jest src/services/checkpoints/__tests__/LocalCheckpointService.test.ts
import fs from "fs/promises"
import path from "path"
import os from "os"
import { simpleGit, SimpleGit } from "simple-git"
import { CheckpointServiceFactory } from "../CheckpointServiceFactory"
import { LocalCheckpointService } from "../LocalCheckpointService"
const tmpDir = path.join(os.tmpdir(), "test-LocalCheckpointService")
describe("LocalCheckpointService", () => {
const taskId = "test-task"
let testFile: string
let service: LocalCheckpointService
const initRepo = async ({
workspaceDir,
userName = "Roo Code",
userEmail = "support@roocode.com",
testFileName = "test.txt",
textFileContent = "Hello, world!",
}: {
workspaceDir: string
userName?: string
userEmail?: string
testFileName?: string
textFileContent?: string
}) => {
// Create a temporary directory for testing.
await fs.mkdir(workspaceDir, { recursive: true })
// Initialize git repo.
const git = simpleGit(workspaceDir)
await git.init()
await git.addConfig("user.name", userName)
await git.addConfig("user.email", userEmail)
// Create test file.
const testFile = path.join(workspaceDir, testFileName)
await fs.writeFile(testFile, textFileContent)
// Create initial commit.
await git.add(".")
await git.commit("Initial commit")!
return { testFile }
}
beforeEach(async () => {
const workspaceDir = path.join(tmpDir, `checkpoint-service-test-${Date.now()}`)
const repo = await initRepo({ workspaceDir })
testFile = repo.testFile
service = await CheckpointServiceFactory.create({
strategy: "local",
options: { taskId, workspaceDir, log: () => {} },
})
})
afterEach(async () => {
jest.restoreAllMocks()
})
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true })
})
describe("getDiff", () => {
it("returns the correct diff between commits", async () => {
await fs.writeFile(testFile, "Ahoy, world!")
const commit1 = await service.saveCheckpoint("First checkpoint")
expect(commit1?.commit).toBeTruthy()
await fs.writeFile(testFile, "Goodbye, world!")
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeTruthy()
const diff1 = await service.getDiff({ to: commit1!.commit })
expect(diff1).toHaveLength(1)
expect(diff1[0].paths.relative).toBe("test.txt")
expect(diff1[0].paths.absolute).toBe(testFile)
expect(diff1[0].content.before).toBe("Hello, world!")
expect(diff1[0].content.after).toBe("Ahoy, world!")
const diff2 = await service.getDiff({ to: commit2!.commit })
expect(diff2).toHaveLength(1)
expect(diff2[0].paths.relative).toBe("test.txt")
expect(diff2[0].paths.absolute).toBe(testFile)
expect(diff2[0].content.before).toBe("Hello, world!")
expect(diff2[0].content.after).toBe("Goodbye, world!")
const diff12 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
expect(diff12).toHaveLength(1)
expect(diff12[0].paths.relative).toBe("test.txt")
expect(diff12[0].paths.absolute).toBe(testFile)
expect(diff12[0].content.before).toBe("Ahoy, world!")
expect(diff12[0].content.after).toBe("Goodbye, world!")
})
it("handles new files in diff", async () => {
const newFile = path.join(service.workspaceDir, "new.txt")
await fs.writeFile(newFile, "New file content")
const commit = await service.saveCheckpoint("Add new file")
expect(commit?.commit).toBeTruthy()
const changes = await service.getDiff({ to: commit!.commit })
const change = changes.find((c) => c.paths.relative === "new.txt")
expect(change).toBeDefined()
expect(change?.content.before).toBe("")
expect(change?.content.after).toBe("New file content")
})
it("handles deleted files in diff", async () => {
const fileToDelete = path.join(service.workspaceDir, "new.txt")
await fs.writeFile(fileToDelete, "New file content")
const commit1 = await service.saveCheckpoint("Add file")
expect(commit1?.commit).toBeTruthy()
await fs.unlink(fileToDelete)
const commit2 = await service.saveCheckpoint("Delete file")
expect(commit2?.commit).toBeTruthy()
const changes = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
const change = changes.find((c) => c.paths.relative === "new.txt")
expect(change).toBeDefined()
expect(change!.content.before).toBe("New file content")
expect(change!.content.after).toBe("")
})
})
describe("saveCheckpoint", () => {
it("creates a checkpoint if there are pending changes", async () => {
await fs.writeFile(testFile, "Ahoy, world!")
const commit1 = await service.saveCheckpoint("First checkpoint")
expect(commit1?.commit).toBeTruthy()
const details1 = await service.git.show([commit1!.commit])
expect(details1).toContain("-Hello, world!")
expect(details1).toContain("+Ahoy, world!")
await fs.writeFile(testFile, "Hola, world!")
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeTruthy()
const details2 = await service.git.show([commit2!.commit])
expect(details2).toContain("-Hello, world!")
expect(details2).toContain("+Hola, world!")
// Switch to checkpoint 1.
await service.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!")
// Switch to checkpoint 2.
await service.restoreCheckpoint(commit2!.commit)
expect(await fs.readFile(testFile, "utf-8")).toBe("Hola, world!")
// Switch back to initial commit.
expect(service.baseHash).toBeTruthy()
await service.restoreCheckpoint(service.baseHash!)
expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
})
it("preserves workspace and index state after saving checkpoint", async () => {
// Create three files with different states: staged, unstaged, and mixed.
const unstagedFile = path.join(service.workspaceDir, "unstaged.txt")
const stagedFile = path.join(service.workspaceDir, "staged.txt")
const mixedFile = path.join(service.workspaceDir, "mixed.txt")
await fs.writeFile(unstagedFile, "Initial unstaged")
await fs.writeFile(stagedFile, "Initial staged")
await fs.writeFile(mixedFile, "Initial mixed")
await service.git.add(["."])
const result = await service.git.commit("Add initial files")
expect(result?.commit).toBeTruthy()
await fs.writeFile(unstagedFile, "Modified unstaged")
await fs.writeFile(stagedFile, "Modified staged")
await service.git.add([stagedFile])
await fs.writeFile(mixedFile, "Modified mixed - staged")
await service.git.add([mixedFile])
await fs.writeFile(mixedFile, "Modified mixed - unstaged")
// Save checkpoint.
const commit = await service.saveCheckpoint("Test checkpoint")
expect(commit?.commit).toBeTruthy()
// Verify workspace state is preserved.
const status = await service.git.status()
// All files should be modified.
expect(status.modified).toContain("unstaged.txt")
expect(status.modified).toContain("staged.txt")
expect(status.modified).toContain("mixed.txt")
// Only staged and mixed files should be staged.
expect(status.staged).not.toContain("unstaged.txt")
expect(status.staged).toContain("staged.txt")
expect(status.staged).toContain("mixed.txt")
// Verify file contents.
expect(await fs.readFile(unstagedFile, "utf-8")).toBe("Modified unstaged")
expect(await fs.readFile(stagedFile, "utf-8")).toBe("Modified staged")
expect(await fs.readFile(mixedFile, "utf-8")).toBe("Modified mixed - unstaged")
// Verify staged changes (--cached shows only staged changes).
const stagedDiff = await service.git.diff(["--cached", "mixed.txt"])
expect(stagedDiff).toContain("-Initial mixed")
expect(stagedDiff).toContain("+Modified mixed - staged")
// Verify unstaged changes (shows working directory changes).
const unstagedDiff = await service.git.diff(["mixed.txt"])
expect(unstagedDiff).toContain("-Modified mixed - staged")
expect(unstagedDiff).toContain("+Modified mixed - unstaged")
})
it("does not create a checkpoint if there are no pending changes", async () => {
const commit0 = await service.saveCheckpoint("Zeroth checkpoint")
expect(commit0?.commit).toBeFalsy()
await fs.writeFile(testFile, "Ahoy, world!")
const commit1 = await service.saveCheckpoint("First checkpoint")
expect(commit1?.commit).toBeTruthy()
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeFalsy()
})
it("includes untracked files in checkpoints", async () => {
// Create an untracked file.
const untrackedFile = path.join(service.workspaceDir, "untracked.txt")
await fs.writeFile(untrackedFile, "I am untracked!")
// Save a checkpoint with the untracked file.
const commit1 = await service.saveCheckpoint("Checkpoint with untracked file")
expect(commit1?.commit).toBeTruthy()
// Verify the untracked file was included in the checkpoint.
const details = await service.git.show([commit1!.commit])
expect(details).toContain("+I am untracked!")
// Create another checkpoint with a different state.
await fs.writeFile(testFile, "Changed tracked file")
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeTruthy()
// Restore first checkpoint and verify untracked file is preserved.
await service.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
// Restore second checkpoint and verify untracked file remains (since
// restore preserves untracked files)
await service.restoreCheckpoint(commit2!.commit)
expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
expect(await fs.readFile(testFile, "utf-8")).toBe("Changed tracked file")
})
it("throws if we're on the wrong branch", async () => {
// Create and switch to a feature branch.
const currentBranch = await service.git.revparse(["--abbrev-ref", "HEAD"])
await service.git.checkoutBranch("feature", currentBranch)
// Attempt to save checkpoint from feature branch.
await expect(service.saveCheckpoint("test")).rejects.toThrow(
`Git branch mismatch: expected '${currentBranch}' but found 'feature'`,
)
// Attempt to restore checkpoint from feature branch.
expect(service.baseHash).toBeTruthy()
await expect(service.restoreCheckpoint(service.baseHash!)).rejects.toThrow(
`Git branch mismatch: expected '${currentBranch}' but found 'feature'`,
)
})
it("cleans up staged files if a commit fails", async () => {
await fs.writeFile(testFile, "Changed content")
// Mock git commit to simulate failure.
jest.spyOn(service.git, "commit").mockRejectedValue(new Error("Simulated commit failure"))
// Attempt to save checkpoint.
await expect(service.saveCheckpoint("test")).rejects.toThrow("Simulated commit failure")
// Verify files are unstaged.
const status = await service.git.status()
expect(status.staged).toHaveLength(0)
})
it("handles file deletions correctly", async () => {
await fs.writeFile(testFile, "I am tracked!")
const untrackedFile = path.join(service.workspaceDir, "new.txt")
await fs.writeFile(untrackedFile, "I am untracked!")
const commit1 = await service.saveCheckpoint("First checkpoint")
expect(commit1?.commit).toBeTruthy()
await fs.unlink(testFile)
await fs.unlink(untrackedFile)
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeTruthy()
// Verify files are gone.
await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
// Restore first checkpoint.
await service.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(testFile, "utf-8")).toBe("I am tracked!")
expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
// Restore second checkpoint.
await service.restoreCheckpoint(commit2!.commit)
await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
})
})
describe("create", () => {
it("initializes a git repository if one does not already exist", async () => {
const workspaceDir = path.join(tmpDir, `checkpoint-service-test2-${Date.now()}`)
await fs.mkdir(workspaceDir)
const newTestFile = path.join(workspaceDir, "test.txt")
await fs.writeFile(newTestFile, "Hello, world!")
// Ensure the git repository was initialized.
const gitDir = path.join(workspaceDir, ".git")
await expect(fs.stat(gitDir)).rejects.toThrow()
const newService = await LocalCheckpointService.create({ taskId, workspaceDir, log: () => {} })
expect(await fs.stat(gitDir)).toBeTruthy()
// Save a checkpoint: Hello, world!
const commit1 = await newService.saveCheckpoint("Hello, world!")
expect(commit1?.commit).toBeTruthy()
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
// Restore initial commit; the file should no longer exist.
expect(newService.baseHash).toBeTruthy()
await newService.restoreCheckpoint(newService.baseHash!)
await expect(fs.access(newTestFile)).rejects.toThrow()
// Restore to checkpoint 1; the file should now exist.
await newService.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
// Save a new checkpoint: Ahoy, world!
await fs.writeFile(newTestFile, "Ahoy, world!")
const commit2 = await newService.saveCheckpoint("Ahoy, world!")
expect(commit2?.commit).toBeTruthy()
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
// Restore "Hello, world!"
await newService.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
// Restore "Ahoy, world!"
await newService.restoreCheckpoint(commit2!.commit)
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
// Restore initial commit.
expect(newService.baseHash).toBeTruthy()
await newService.restoreCheckpoint(newService.baseHash!)
await expect(fs.access(newTestFile)).rejects.toThrow()
await fs.rm(newService.workspaceDir, { recursive: true, force: true })
})
it("respects existing git user configuration", async () => {
const workspaceDir = path.join(tmpDir, `checkpoint-service-test-config2-${Date.now()}`)
const userName = "Custom User"
const userEmail = "custom@example.com"
await initRepo({ workspaceDir, userName, userEmail })
const newService = await LocalCheckpointService.create({ taskId, workspaceDir, log: () => {} })
expect((await newService.git.getConfig("user.name")).value).toBe(userName)
expect((await newService.git.getConfig("user.email")).value).toBe(userEmail)
await fs.rm(workspaceDir, { recursive: true, force: true })
})
})
})

View file

@ -3,11 +3,11 @@
import fs from "fs/promises"
import path from "path"
import os from "os"
import { EventEmitter } from "events"
import { simpleGit, SimpleGit } from "simple-git"
import { ShadowCheckpointService } from "../ShadowCheckpointService"
import { CheckpointServiceFactory } from "../CheckpointServiceFactory"
jest.mock("globby", () => ({
globby: jest.fn().mockResolvedValue([]),
@ -63,13 +63,10 @@ describe("ShadowCheckpointService", () => {
const repo = await initRepo({ workspaceDir })
testFile = repo.testFile
service = await CheckpointServiceFactory.create({
strategy: "shadow",
options: { taskId, shadowDir, workspaceDir, log: () => {} },
})
workspaceGit = repo.git
service = await ShadowCheckpointService.create({ taskId, shadowDir, workspaceDir, log: () => {} })
await service.initShadowGit()
})
afterEach(async () => {
@ -313,6 +310,7 @@ describe("ShadowCheckpointService", () => {
const gitDir = path.join(shadowDir, "tasks", taskId, "checkpoints", ".git")
await expect(fs.stat(gitDir)).rejects.toThrow()
const newService = await ShadowCheckpointService.create({ taskId, shadowDir, workspaceDir, log: () => {} })
await newService.initShadowGit()
expect(await fs.stat(gitDir)).toBeTruthy()
// Save a new checkpoint: Ahoy, world!
@ -333,4 +331,168 @@ describe("ShadowCheckpointService", () => {
await fs.rm(newService.workspaceDir, { recursive: true, force: true })
})
})
describe("events", () => {
it("emits initialize event when service is created", async () => {
const shadowDir = path.join(tmpDir, `shadow-event-test-${Date.now()}`)
const workspaceDir = path.join(tmpDir, `workspace-event-test-${Date.now()}`)
await fs.mkdir(workspaceDir, { recursive: true })
const newTestFile = path.join(workspaceDir, "test.txt")
await fs.writeFile(newTestFile, "Testing events!")
// Create a mock implementation of emit to track events.
const emitSpy = jest.spyOn(EventEmitter.prototype, "emit")
// Create the service - this will trigger the initialize event.
const newService = await ShadowCheckpointService.create({ taskId, shadowDir, workspaceDir, log: () => {} })
await newService.initShadowGit()
// Find the initialize event in the emit calls.
let initializeEvent = null
for (let i = 0; i < emitSpy.mock.calls.length; i++) {
const call = emitSpy.mock.calls[i]
if (call[0] === "initialize") {
initializeEvent = call[1]
break
}
}
// Restore the spy.
emitSpy.mockRestore()
// Verify the event was emitted with the correct data.
expect(initializeEvent).not.toBeNull()
expect(initializeEvent.type).toBe("initialize")
expect(initializeEvent.workspaceDir).toBe(workspaceDir)
expect(initializeEvent.baseHash).toBeTruthy()
expect(typeof initializeEvent.created).toBe("boolean")
expect(typeof initializeEvent.duration).toBe("number")
// Verify the event was emitted with the correct data.
expect(initializeEvent).not.toBeNull()
expect(initializeEvent.type).toBe("initialize")
expect(initializeEvent.workspaceDir).toBe(workspaceDir)
expect(initializeEvent.baseHash).toBeTruthy()
expect(typeof initializeEvent.created).toBe("boolean")
expect(typeof initializeEvent.duration).toBe("number")
// Clean up.
await fs.rm(shadowDir, { recursive: true, force: true })
await fs.rm(workspaceDir, { recursive: true, force: true })
})
it("emits checkpoint event when saving checkpoint", async () => {
const checkpointHandler = jest.fn()
service.on("checkpoint", checkpointHandler)
await fs.writeFile(testFile, "Changed content for checkpoint event test")
const result = await service.saveCheckpoint("Test checkpoint event")
expect(result?.commit).toBeDefined()
expect(checkpointHandler).toHaveBeenCalledTimes(1)
const eventData = checkpointHandler.mock.calls[0][0]
expect(eventData.type).toBe("checkpoint")
expect(eventData.toHash).toBeDefined()
expect(eventData.toHash).toBe(result!.commit)
expect(typeof eventData.duration).toBe("number")
})
it("emits restore event when restoring checkpoint", async () => {
// First create a checkpoint to restore.
await fs.writeFile(testFile, "Content for restore test")
const commit = await service.saveCheckpoint("Checkpoint for restore test")
expect(commit?.commit).toBeTruthy()
// Change the file again.
await fs.writeFile(testFile, "Changed after checkpoint")
// Setup restore event listener.
const restoreHandler = jest.fn()
service.on("restore", restoreHandler)
// Restore the checkpoint.
await service.restoreCheckpoint(commit!.commit)
// Verify the event was emitted.
expect(restoreHandler).toHaveBeenCalledTimes(1)
const eventData = restoreHandler.mock.calls[0][0]
expect(eventData.type).toBe("restore")
expect(eventData.commitHash).toBe(commit!.commit)
expect(typeof eventData.duration).toBe("number")
// Verify the file was actually restored.
expect(await fs.readFile(testFile, "utf-8")).toBe("Content for restore test")
})
it("emits error event when an error occurs", async () => {
const errorHandler = jest.fn()
service.on("error", errorHandler)
// Force an error by providing an invalid commit hash.
const invalidCommitHash = "invalid-commit-hash"
// Try to restore an invalid checkpoint.
try {
await service.restoreCheckpoint(invalidCommitHash)
} catch (error) {
// Expected to throw, we're testing the event emission.
}
// Verify the error event was emitted.
expect(errorHandler).toHaveBeenCalledTimes(1)
const eventData = errorHandler.mock.calls[0][0]
expect(eventData.type).toBe("error")
expect(eventData.error).toBeInstanceOf(Error)
})
it("supports multiple event listeners for the same event", async () => {
const checkpointHandler1 = jest.fn()
const checkpointHandler2 = jest.fn()
service.on("checkpoint", checkpointHandler1)
service.on("checkpoint", checkpointHandler2)
await fs.writeFile(testFile, "Content for multiple listeners test")
const result = await service.saveCheckpoint("Testing multiple listeners")
// Verify both handlers were called with the same event data.
expect(checkpointHandler1).toHaveBeenCalledTimes(1)
expect(checkpointHandler2).toHaveBeenCalledTimes(1)
const eventData1 = checkpointHandler1.mock.calls[0][0]
const eventData2 = checkpointHandler2.mock.calls[0][0]
expect(eventData1).toEqual(eventData2)
expect(eventData1.type).toBe("checkpoint")
expect(eventData1.toHash).toBe(result?.commit)
})
it("allows removing event listeners", async () => {
const checkpointHandler = jest.fn()
// Add the listener.
service.on("checkpoint", checkpointHandler)
// Make a change and save a checkpoint.
await fs.writeFile(testFile, "Content for remove listener test - part 1")
await service.saveCheckpoint("Testing listener - part 1")
// Verify handler was called.
expect(checkpointHandler).toHaveBeenCalledTimes(1)
checkpointHandler.mockClear()
// Remove the listener.
service.off("checkpoint", checkpointHandler)
// Make another change and save a checkpoint.
await fs.writeFile(testFile, "Content for remove listener test - part 2")
await service.saveCheckpoint("Testing listener - part 2")
// Verify handler was not called after being removed.
expect(checkpointHandler).not.toHaveBeenCalled()
})
})
})

View file

@ -1,2 +0,0 @@
export * from "./types"
export * from "./CheckpointServiceFactory"

View file

@ -1,3 +1,4 @@
import EventEmitter from "events"
import { CommitResult } from "simple-git"
export type CheckpointResult = Partial<CommitResult> & Pick<CommitResult, "commit">
@ -13,15 +14,12 @@ export type CheckpointDiff = {
}
}
export type CheckpointStrategy = "local" | "shadow"
export interface CheckpointService {
saveCheckpoint(message: string): Promise<CheckpointResult | undefined>
restoreCheckpoint(commit: string): Promise<void>
getDiff(range: { from?: string; to?: string }): Promise<CheckpointDiff[]>
workspaceDir: string
baseHash?: string
strategy: CheckpointStrategy
version: number
}
@ -30,3 +28,38 @@ export interface CheckpointServiceOptions {
workspaceDir: string
log?: (message: string) => void
}
/**
* EventEmitter
*/
export interface CheckpointEventMap {
initialize: { type: "initialize"; workspaceDir: string; baseHash: string; created: boolean; duration: number }
checkpoint: {
type: "checkpoint"
isFirst: boolean
fromHash: string
toHash: string
duration: number
}
restore: { type: "restore"; commitHash: string; duration: number }
error: { type: "error"; error: Error }
}
export class CheckpointEventEmitter extends EventEmitter {
override emit<K extends keyof CheckpointEventMap>(event: K, data: CheckpointEventMap[K]): boolean {
return super.emit(event, data)
}
override on<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void): this {
return super.on(event, listener)
}
override off<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void): this {
return super.off(event, listener)
}
override once<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void): this {
return super.once(event, listener)
}
}

View file

@ -48,6 +48,7 @@ export interface ExtensionMessage {
| "showHumanRelayDialog"
| "humanRelayResponse"
| "humanRelayCancel"
| "browserToolEnabled"
text?: string
action?:
| "chatButtonClicked"
@ -106,6 +107,7 @@ export interface ExtensionState {
alwaysAllowMcp?: boolean
alwaysApproveResubmit?: boolean
alwaysAllowModeSwitch?: boolean
browserToolEnabled?: boolean
requestDelaySeconds: number
rateLimitSeconds: number // Minimum time between successive requests (0 = disabled)
uriScheme?: string

View file

@ -96,6 +96,7 @@ export interface WebviewMessage {
| "maxOpenTabsContext"
| "humanRelayResponse"
| "humanRelayCancel"
| "browserToolEnabled"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse

View file

@ -0,0 +1,325 @@
import { mentionRegex, mentionRegexGlobal } from "../context-mentions"
interface TestResult {
actual: string | null
expected: string | null
}
function testMention(input: string, expected: string | null): TestResult {
const match = mentionRegex.exec(input)
return {
actual: match ? match[0] : null,
expected,
}
}
function expectMatch(result: TestResult) {
if (result.expected === null) {
return expect(result.actual).toBeNull()
}
if (result.actual !== result.expected) {
// Instead of console.log, use expect().toBe() with a descriptive message
expect(result.actual).toBe(result.expected)
}
}
describe("Mention Regex", () => {
describe("Windows Path Support", () => {
it("matches simple Windows paths", () => {
const cases: Array<[string, string]> = [
["@C:\\folder\\file.txt", "@C:\\folder\\file.txt"],
["@c:\\Program/ Files\\file.txt", "@c:\\Program/ Files\\file.txt"],
["@C:\\file.txt", "@C:\\file.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches Windows network shares", () => {
const cases: Array<[string, string]> = [
["@\\\\server\\share\\file.txt", "@\\\\server\\share\\file.txt"],
["@\\\\127.0.0.1\\network-path\\file.txt", "@\\\\127.0.0.1\\network-path\\file.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches mixed separators", () => {
const result = testMention("@C:\\folder\\file.txt", "@C:\\folder\\file.txt")
expectMatch(result)
})
it("matches Windows relative paths", () => {
const cases: Array<[string, string]> = [
["@folder\\file.txt", "@folder\\file.txt"],
["@.\\folder\\file.txt", "@.\\folder\\file.txt"],
["@..\\parent\\file.txt", "@..\\parent\\file.txt"],
["@path\\to\\directory\\", "@path\\to\\directory\\"],
["@.\\current\\path\\with/ space.txt", "@.\\current\\path\\with/ space.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Escaped Spaces Support", () => {
it("matches Unix paths with escaped spaces", () => {
const cases: Array<[string, string]> = [
["@/path/to/file\\ with\\ spaces.txt", "@/path/to/file\\ with\\ spaces.txt"],
["@/path/with\\ \\ multiple\\ spaces.txt", "@/path/with\\ \\ multiple\\ spaces.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches Windows paths with escaped spaces", () => {
const cases: Array<[string, string]> = [
["@C:\\path\\to\\file/ with/ spaces.txt", "@C:\\path\\to\\file/ with/ spaces.txt"],
["@C:\\Program/ Files\\app\\file.txt", "@C:\\Program/ Files\\app\\file.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Combined Path Variations", () => {
it("matches complex path combinations", () => {
const cases: Array<[string, string]> = [
[
"@C:\\Users\\name\\Documents\\file/ with/ spaces.txt",
"@C:\\Users\\name\\Documents\\file/ with/ spaces.txt",
],
[
"@\\\\server\\share\\path/ with/ spaces\\file.txt",
"@\\\\server\\share\\path/ with/ spaces\\file.txt",
],
["@C:\\path/ with/ spaces\\file.txt", "@C:\\path/ with/ spaces\\file.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Edge Cases", () => {
it("handles edge cases correctly", () => {
const cases: Array<[string, string]> = [
["@C:\\", "@C:\\"],
["@/path/to/folder", "@/path/to/folder"],
["@C:\\folder\\file with spaces.txt", "@C:\\folder\\file"],
["@C:\\Users\\name\\path\\to\\文件夹\\file.txt", "@C:\\Users\\name\\path\\to\\文件夹\\file.txt"],
["@/path123/file-name_2.0.txt", "@/path123/file-name_2.0.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Existing Functionality", () => {
it("matches Unix paths", () => {
const cases: Array<[string, string]> = [
["@/usr/local/bin/file", "@/usr/local/bin/file"],
["@/path/to/file.txt", "@/path/to/file.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches URLs", () => {
const cases: Array<[string, string]> = [
["@http://example.com", "@http://example.com"],
["@https://example.com/path/to/file.html", "@https://example.com/path/to/file.html"],
["@ftp://server.example.com/file.zip", "@ftp://server.example.com/file.zip"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches git hashes", () => {
const cases: Array<[string, string]> = [
["@a1b2c3d4e5f6g7h8i9j0", "@a1b2c3d4e5f6g7h8i9j0"],
["@abcdef1234567890abcdef1234567890abcdef12", "@abcdef1234567890abcdef1234567890abcdef12"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches special keywords", () => {
const cases: Array<[string, string]> = [
["@problems", "@problems"],
["@git-changes", "@git-changes"],
["@terminal", "@terminal"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Invalid Patterns", () => {
it("rejects invalid patterns", () => {
const cases: Array<[string, null]> = [
["C:\\folder\\file.txt", null],
["@", null],
["@ C:\\file.txt", null],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
it("matches only until invalid characters", () => {
const result = testMention("@C:\\folder\\file.txt invalid suffix", "@C:\\folder\\file.txt")
expectMatch(result)
})
})
describe("In Context", () => {
it("matches mentions within text", () => {
const cases: Array<[string, string]> = [
["Check the file at @C:\\folder\\file.txt for details.", "@C:\\folder\\file.txt"],
["See @/path/to/file\\ with\\ spaces.txt for an example.", "@/path/to/file\\ with\\ spaces.txt"],
["Review @problems and @git-changes.", "@problems"],
["Multiple: @/file1.txt and @C:\\file2.txt and @terminal", "@/file1.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Multiple Mentions", () => {
it("finds all mentions in a string using global regex", () => {
const text = "Check @/path/file1.txt and @C:\\folder\\file2.txt and report any @problems to @git-changes"
const matches = text.match(mentionRegexGlobal)
expect(matches).toEqual(["@/path/file1.txt", "@C:\\folder\\file2.txt", "@problems", "@git-changes"])
})
})
describe("Special Characters in Paths", () => {
it("handles special characters in file paths", () => {
const cases: Array<[string, string]> = [
["@/path/with-dash/file_underscore.txt", "@/path/with-dash/file_underscore.txt"],
["@C:\\folder+plus\\file(parens)[]brackets.txt", "@C:\\folder+plus\\file(parens)[]brackets.txt"],
["@/path/with/file#hash%percent.txt", "@/path/with/file#hash%percent.txt"],
["@/path/with/file@symbol$dollar.txt", "@/path/with/file@symbol$dollar.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Mixed Path Types in Single String", () => {
it("correctly identifies the first path in a string with multiple path types", () => {
const text = "Check both @/unix/path and @C:\\windows\\path for details."
const result = mentionRegex.exec(text)
expect(result?.[0]).toBe("@/unix/path")
// Test starting from after the first match
const secondSearchStart = text.indexOf("@C:")
const secondResult = mentionRegex.exec(text.substring(secondSearchStart))
expect(secondResult?.[0]).toBe("@C:\\windows\\path")
})
})
describe("Non-Latin Character Support", () => {
it("handles international characters in paths", () => {
const cases: Array<[string, string]> = [
["@/path/to/你好/file.txt", "@/path/to/你好/file.txt"],
["@C:\\用户\\документы\\файл.txt", "@C:\\用户\\документы\\файл.txt"],
["@/путь/к/файлу.txt", "@/путь/к/файлу.txt"],
["@C:\\folder\\file_äöü.txt", "@C:\\folder\\file_äöü.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Mixed Path Delimiters", () => {
// Modifying expectations to match current behavior
it("documents behavior with mixed forward and backward slashes in Windows paths", () => {
const cases: Array<[string, null]> = [
// Current implementation doesn't support mixed slashes
["@C:\\Users/Documents\\folder/file.txt", null],
["@C:/Windows\\System32/drivers\\etc/hosts", null],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
describe("Extended Negative Tests", () => {
// Modifying expectations to match current behavior
it("documents behavior with potentially invalid characters", () => {
const cases: Array<[string, string]> = [
// Current implementation actually matches these patterns
["@/path/with<illegal>chars.txt", "@/path/with<illegal>chars.txt"],
["@C:\\folder\\file|with|pipe.txt", "@C:\\folder\\file|with|pipe.txt"],
['@/path/with"quotes".txt', '@/path/with"quotes".txt'],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
expectMatch(result)
})
})
})
// // These are documented as "not implemented yet"
// describe("Future Enhancement Candidates", () => {
// it("identifies patterns that could be supported in future enhancements", () => {
// // These patterns aren't currently supported by the regex
// // but might be considered for future improvements
// console.log(
// "The following patterns are not currently supported but might be considered for future enhancements:",
// )
// console.log("- Paths with double slashes: @/path//with/double/slash.txt")
// console.log("- Complex path traversals: @/very/./long/../../path/.././traversal.txt")
// console.log("- Environment variables in paths: @$HOME/file.txt, @C:\\Users\\%USERNAME%\\file.txt")
// })
// })
})

View file

@ -100,7 +100,7 @@ export type AnthropicModelId = keyof typeof anthropicModels
export const anthropicDefaultModelId: AnthropicModelId = "claude-3-7-sonnet-20250219"
export const anthropicModels = {
"claude-3-7-sonnet-20250219:thinking": {
maxTokens: 64_000,
maxTokens: 128_000,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,

View file

@ -1,57 +1,90 @@
/*
Mention regex:
- **Purpose**:
- To identify and highlight specific mentions in text that start with '@'.
- These mentions can be file paths, URLs, or the exact word 'problems'.
- Ensures that trailing punctuation marks (like commas, periods, etc.) are not included in the match, allowing punctuation to follow the mention without being part of it.
- **Regex Breakdown**:
- `/@`:
- **@**: The mention must start with the '@' symbol.
- `((?:\/|\w+:\/\/)[^\s]+?|problems\b|git-changes\b)`:
- **Capturing Group (`(...)`)**: Captures the part of the string that matches one of the specified patterns.
- `(?:\/|\w+:\/\/)`:
- **Non-Capturing Group (`(?:...)`)**: Groups the alternatives without capturing them for back-referencing.
- `\/`:
- **Slash (`/`)**: Indicates that the mention is a file or folder path starting with a '/'.
- `|`: Logical OR.
- `\w+:\/\/`:
- **Protocol (`\w+://`)**: Matches URLs that start with a word character sequence followed by '://', such as 'http://', 'https://', 'ftp://', etc.
- `[^\s]+?`:
- **Non-Whitespace Characters (`[^\s]+`)**: Matches one or more characters that are not whitespace.
- **Non-Greedy (`+?`)**: Ensures the smallest possible match, preventing the inclusion of trailing punctuation.
- `|`: Logical OR.
- `problems\b`:
- **Exact Word ('problems')**: Matches the exact word 'problems'.
- **Word Boundary (`\b`)**: Ensures that 'problems' is matched as a whole word and not as part of another word (e.g., 'problematic').
- `|`: Logical OR.
- `terminal\b`:
- **Exact Word ('terminal')**: Matches the exact word 'terminal'.
- **Word Boundary (`\b`)**: Ensures that 'terminal' is matched as a whole word and not as part of another word (e.g., 'terminals').
- `(?=[.,;:!?]?(?=[\s\r\n]|$))`:
- **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match.
- `[.,;:!?]?`:
- **Optional Punctuation (`[.,;:!?]?`)**: Matches zero or one of the specified punctuation marks.
- `(?=[\s\r\n]|$)`:
- **Nested Positive Lookahead (`(?=[\s\r\n]|$)`)**: Ensures that the punctuation (if present) is followed by a whitespace character, a line break, or the end of the string.
- **Summary**:
- The regex effectively matches:
- Mentions that are file or folder paths starting with '/' and containing any non-whitespace characters (including periods within the path).
- URLs that start with a protocol (like 'http://') followed by any non-whitespace characters (including query parameters).
- The exact word 'problems'.
- The exact word 'git-changes'.
- The exact word 'terminal'.
- It ensures that any trailing punctuation marks (such as ',', '.', '!', etc.) are not included in the matched mention, allowing the punctuation to follow the mention naturally in the text.
- **Global Regex**:
- `mentionRegexGlobal`: Creates a global version of the `mentionRegex` to find all matches within a given string.
1. **Pattern Components**:
- The regex is built from multiple patterns joined with OR (|) operators
- Each pattern handles a specific type of mention:
- Unix/Linux paths
- Windows paths with drive letters
- Windows relative paths
- Windows network shares
- URLs with protocols
- Git commit hashes
- Special keywords (problems, git-changes, terminal)
2. **Unix Path Pattern**:
- `(?:\\/|^)`: Starts with a forward slash or beginning of line
- `(?:[^\\/\\s\\\\]|\\\\[ \\t])+`: Path segment that can include escaped spaces
- `(?:\\/(?:[^\\/\\s\\\\]|\\\\[ \\t])+)*`: Additional path segments after slashes
- `\\/?`: Optional trailing slash
3. **Windows Path Pattern**:
- `[A-Za-z]:\\\\`: Drive letter followed by colon and double backslash
- `(?:(?:[^\\\\\\s/]+|\\/[ ])+`: Path segment that can include spaces escaped with forward slash
- `(?:\\\\(?:[^\\\\\\s/]+|\\/[ ])+)*)?`: Additional path segments after backslashes
4. **Windows Relative Path Pattern**:
- `(?:\\.{0,2}|[^\\\\\\s/]+)`: Path prefix that can be:
- Current directory (.)
- Parent directory (..)
- Any directory name not containing spaces, backslashes, or forward slashes
- `\\\\`: Backslash separator
- `(?:[^\\\\\\s/]+|\\\\[ \\t]|\\/[ ])+`: Path segment that can include spaces escaped with backslash or forward slash
- `(?:\\\\(?:[^\\\\\\s/]+|\\\\[ \\t]|\\/[ ])+)*`: Additional path segments after backslashes
- `\\\\?`: Optional trailing backslash
5. **Network Share Pattern**:
- `\\\\\\\\`: Double backslash (escaped) to start network path
- `[^\\\\\\s]+`: Server name
- `(?:\\\\(?:[^\\\\\\s/]+|\\/[ ])+)*`: Share name and additional path components
- `(?:\\\\)?`: Optional trailing backslash
6. **URL Pattern**:
- `\\w+:\/\/`: Protocol (http://, https://, etc.)
- `[^\\s]+`: Rest of the URL (non-whitespace characters)
7. **Git Hash Pattern**:
- `[a-zA-Z0-9]{7,40}\\b`: 7-40 alphanumeric characters followed by word boundary
8. **Special Keywords Pattern**:
- `problems\\b`, `git-changes\\b`, `terminal\\b`: Exact word matches with word boundaries
9. **Termination Logic**:
- `(?=[.,;:!?]?(?=[\\s\\r\\n]|$))`: Positive lookahead that:
- Allows an optional punctuation mark after the mention
- Ensures the mention (and optional punctuation) is followed by whitespace or end of string
- **Behavior Summary**:
- Matches @-prefixed mentions
- Handles different path formats across operating systems
- Supports escaped spaces in paths using OS-appropriate conventions
- Cleanly terminates at whitespace or end of string
- Excludes trailing punctuation from the match
- Creates both single-match and global-match regex objects
*/
export const mentionRegex =
/@((?:\/|\w+:\/\/)[^\s]+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")
const mentionPatterns = [
// Unix paths with escaped spaces using backslash
"(?:\\/|^)(?:[^\\/\\s\\\\]|\\\\[ \\t])+(?:\\/(?:[^\\/\\s\\\\]|\\\\[ \\t])+)*\\/?",
// Windows paths with drive letters (C:\path) with support for escaped spaces using forward slash
"[A-Za-z]:\\\\(?:(?:[^\\\\\\s/]+|\\/[ ])+(?:\\\\(?:[^\\\\\\s/]+|\\/[ ])+)*)?",
// Windows relative paths (folder\file or .\folder\file) with support for escaped spaces
"(?:\\.{0,2}|[^\\\\\\s/]+)\\\\(?:[^\\\\\\s/]+|\\\\[ \\t]|\\/[ ])+(?:\\\\(?:[^\\\\\\s/]+|\\\\[ \\t]|\\/[ ])+)*\\\\?",
// Windows network shares (\\server\share) with support for escaped spaces using forward slash
"\\\\\\\\[^\\\\\\s]+(?:\\\\(?:[^\\\\\\s/]+|\\/[ ])+)*(?:\\\\)?",
// URLs with protocols (http://, https://, etc.)
"\\w+:\/\/[^\\s]+",
// Git hashes (7-40 alphanumeric characters)
"[a-zA-Z0-9]{7,40}\\b",
// Special keywords
"problems\\b",
"git-changes\\b",
"terminal\\b",
]
// Build the full regex pattern by joining the patterns with OR operator
const mentionRegexPattern = `@(${mentionPatterns.join("|")})(?=[.,;:!?]?(?=[\\s\\r\\n]|$))`
export const mentionRegex = new RegExp(mentionRegexPattern)
export const mentionRegexGlobal = new RegExp(mentionRegexPattern, "g")
export interface MentionSuggestion {
type: "file" | "folder" | "git" | "problems"

View file

@ -84,3 +84,4 @@ export type GlobalStateKey =
| "anthropicThinking" // TODO: Rename to `modelMaxThinkingTokens`.
| "mistralCodestralUrl"
| "maxOpenTabsContext"
| "browserToolEnabled" // Setting to enable/disable the browser tool

View file

@ -1,4 +1,4 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useDeepCompareEffect, useEvent, useMount } from "react-use"
@ -88,6 +88,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const [isAtBottom, setIsAtBottom] = useState(false)
const [wasStreaming, setWasStreaming] = useState<boolean>(false)
const [showCheckpointWarning, setShowCheckpointWarning] = useState<boolean>(false)
// UI layout depends on the last 2 messages
// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
@ -882,6 +883,48 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}, [])
useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance
// Effect to handle showing the checkpoint warning after a delay
useEffect(() => {
// Only show the warning when there's a task but no visible messages yet
if (task && modifiedMessages.length === 0 && !isStreaming) {
const timer = setTimeout(() => {
setShowCheckpointWarning(true)
}, 5000) // 5 seconds
return () => clearTimeout(timer)
}
}, [task, modifiedMessages.length, isStreaming])
// Effect to hide the checkpoint warning when messages appear
useEffect(() => {
if (modifiedMessages.length > 0 || isStreaming) {
setShowCheckpointWarning(false)
}
}, [modifiedMessages.length, isStreaming])
// Checkpoint warning component
const CheckpointWarningMessage = useCallback(
() => (
<div className="flex items-center p-3 my-3 bg-vscode-inputValidation-warningBackground border border-vscode-inputValidation-warningBorder rounded">
<span className="codicon codicon-loading codicon-modifier-spin mr-2" />
<span className="text-vscode-foreground">
Still initializing checkpoint... If this takes too long, you can{" "}
<VSCodeLink
href="#"
onClick={(e) => {
e.preventDefault()
window.postMessage({ type: "action", action: "settingsButtonClicked" }, "*")
}}
className="inline px-0.5">
disable checkpoints in settings
</VSCodeLink>{" "}
and restart your task.
</span>
</div>
),
[],
)
const placeholderText = useMemo(() => {
const baseText = task ? "Type a message..." : "Type your task here..."
const contextText = "(@ to add context, / to switch modes"
@ -973,7 +1016,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const allModes = getAllModes(customModes)
const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
const nextModeIndex = (currentModeIndex + 1) % allModes.length
// Update local state and notify extension to sync mode change
setMode(allModes[nextModeIndex].slug)
vscode.postMessage({
type: "mode",
text: allModes[nextModeIndex].slug,
})
}, [mode, setMode, customModes])
// Add keyboard event handler
@ -1009,17 +1057,26 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
overflow: "hidden",
}}>
{task ? (
<TaskHeader
task={task}
tokensIn={apiMetrics.totalTokensIn}
tokensOut={apiMetrics.totalTokensOut}
doesModelSupportPromptCache={selectedModelInfo.supportsPromptCache}
cacheWrites={apiMetrics.totalCacheWrites}
cacheReads={apiMetrics.totalCacheReads}
totalCost={apiMetrics.totalCost}
contextTokens={apiMetrics.contextTokens}
onClose={handleTaskCloseButtonClick}
/>
<>
<TaskHeader
task={task}
tokensIn={apiMetrics.totalTokensIn}
tokensOut={apiMetrics.totalTokensOut}
doesModelSupportPromptCache={selectedModelInfo.supportsPromptCache}
cacheWrites={apiMetrics.totalCacheWrites}
cacheReads={apiMetrics.totalCacheReads}
totalCost={apiMetrics.totalCost}
contextTokens={apiMetrics.contextTokens}
onClose={handleTaskCloseButtonClick}
/>
{/* Checkpoint warning message */}
{showCheckpointWarning && (
<div className="px-3">
<CheckpointWarningMessage />
</div>
)}
</>
) : (
<div
style={{

View file

@ -3,8 +3,6 @@ import { useMemo } from "react"
import { CheckpointMenu } from "./CheckpointMenu"
import { checkpointSchema } from "./schema"
const REQUIRED_VERSION = 1
type CheckpointSavedProps = {
ts: number
commitHash: string
@ -22,7 +20,7 @@ export const CheckpointSaved = ({ checkpoint, ...props }: CheckpointSavedProps)
const result = checkpointSchema.safeParse(checkpoint)
if (!result.success || result.data.version < REQUIRED_VERSION) {
if (!result.success) {
return undefined
}

View file

@ -4,8 +4,6 @@ export const checkpointSchema = z.object({
isFirst: z.boolean(),
from: z.string(),
to: z.string(),
strategy: z.enum(["local", "shadow"]),
version: z.number(),
})
export type Checkpoint = z.infer<typeof checkpointSchema>

View file

@ -1210,8 +1210,8 @@ const ApiOptions = ({
color: "var(--vscode-errorForeground)",
fontWeight: 500,
}}>
Note: This is a very experimental integration and may not work as expected. Please report
any issues to the Roo-Code GitHub repository.
Note: This is a very experimental integration and provider support will vary. If you get an
error about a model not being supported, that's an issue on the provider's end.
</p>
</div>
</div>

View file

@ -51,6 +51,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
alwaysAllowModeSwitch,
alwaysAllowWrite,
alwaysApproveResubmit,
browserToolEnabled,
browserViewportSize,
enableCheckpoints,
diffEnabled,
@ -140,6 +141,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser })
vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp })
vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
vscode.postMessage({ type: "browserToolEnabled", bool: browserToolEnabled })
vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
vscode.postMessage({ type: "soundVolume", value: soundVolume })
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
@ -537,59 +539,83 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
<div style={{ marginBottom: 40 }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: "0 0 15px 0" }}>Browser Settings</h3>
<div style={{ marginBottom: 15 }}>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>Viewport size</label>
<div className="dropdown-container">
<Dropdown
value={browserViewportSize}
onChange={(value: unknown) => {
setCachedStateField("browserViewportSize", (value as DropdownOption).value)
}}
style={{ width: "100%" }}
options={[
{ value: "1280x800", label: "Large Desktop (1280x800)" },
{ value: "900x600", label: "Small Desktop (900x600)" },
{ value: "768x1024", label: "Tablet (768x1024)" },
{ value: "360x640", label: "Mobile (360x640)" },
]}
/>
</div>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Select the viewport size for browser interactions. This affects how websites are displayed
and interacted with.
<VSCodeCheckbox
checked={browserToolEnabled}
onChange={(e: any) => setCachedStateField("browserToolEnabled", e.target.checked)}>
<span style={{ fontWeight: "500" }}>Enable browser tool</span>
</VSCodeCheckbox>
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
When enabled, Roo can use a browser to interact with websites when using models that support
computer use.
</p>
</div>
{browserToolEnabled && (
<div
style={{
marginLeft: 0,
paddingLeft: 10,
borderLeft: "2px solid var(--vscode-button-background)",
}}>
<div style={{ marginBottom: 15 }}>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>
Viewport size
</label>
<div className="dropdown-container">
<Dropdown
value={browserViewportSize}
onChange={(value: unknown) => {
setCachedStateField("browserViewportSize", (value as DropdownOption).value)
}}
style={{ width: "100%" }}
options={[
{ value: "1280x800", label: "Large Desktop (1280x800)" },
{ value: "900x600", label: "Small Desktop (900x600)" },
{ value: "768x1024", label: "Tablet (768x1024)" },
{ value: "360x640", label: "Mobile (360x640)" },
]}
/>
</div>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Select the viewport size for browser interactions. This affects how websites are
displayed and interacted with.
</p>
</div>
<div style={{ marginBottom: 15 }}>
<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
<span style={{ fontWeight: "500" }}>Screenshot quality</span>
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
<input
type="range"
min="1"
max="100"
step="1"
value={screenshotQuality ?? 75}
className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background"
onChange={(e) => setCachedStateField("screenshotQuality", parseInt(e.target.value))}
/>
<span style={{ ...sliderLabelStyle }}>{screenshotQuality ?? 75}%</span>
<div style={{ marginBottom: 15 }}>
<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
<span style={{ fontWeight: "500" }}>Screenshot quality</span>
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
<input
type="range"
min="1"
max="100"
step="1"
value={screenshotQuality ?? 75}
className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background"
onChange={(e) =>
setCachedStateField("screenshotQuality", parseInt(e.target.value))
}
/>
<span style={{ ...sliderLabelStyle }}>{screenshotQuality ?? 75}%</span>
</div>
</div>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Adjust the WebP quality of browser screenshots. Higher values provide clearer
screenshots but increase token usage.
</p>
</div>
</div>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Adjust the WebP quality of browser screenshots. Higher values provide clearer screenshots
but increase token usage.
</p>
</div>
)}
</div>
<div style={{ marginBottom: 40 }}>

View file

@ -27,6 +27,7 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysAllowBrowser: (value: boolean) => void
setAlwaysAllowMcp: (value: boolean) => void
setAlwaysAllowModeSwitch: (value: boolean) => void
setBrowserToolEnabled: (value: boolean) => void
setShowAnnouncement: (value: boolean) => void
setAllowedCommands: (value: string[]) => void
setSoundEnabled: (value: boolean) => void
@ -128,6 +129,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
customModes: [],
maxOpenTabsContext: 20,
cwd: "",
browserToolEnabled: true,
})
const [didHydrateState, setDidHydrateState] = useState(false)
@ -265,6 +267,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })),
setCustomModes: (value) => setState((prevState) => ({ ...prevState, customModes: value })),
setMaxOpenTabsContext: (value) => setState((prevState) => ({ ...prevState, maxOpenTabsContext: value })),
setBrowserToolEnabled: (value) => setState((prevState) => ({ ...prevState, browserToolEnabled: value })),
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>