From dd961d18a8a7a71f6890a09a8fd86cab69ad392c Mon Sep 17 00:00:00 2001 From: sam hoang Date: Sun, 2 Mar 2025 23:56:35 +0700 Subject: [PATCH 01/19] feat(mentions): improve path handling for Windows and escaped spaces Enhance the mention regex to properly support: - Windows paths with drive letters (C:\folder\file.txt) - Windows network shares (\\server\share\file.txt) - Windows relative paths (folder\file.txt) - Paths with escaped spaces in both Unix and Windows formats - Maintain compatibility with existing URL, git hash, and keyword patterns - Add comprehensive test suite with 200+ test cases validating all path formats and edge cases to ensure reliable pattern matching across platforms. --- src/shared/__tests__/context-mentions.test.ts | 233 ++++++++++++++++++ src/shared/context-mentions.ts | 131 ++++++---- 2 files changed, 315 insertions(+), 49 deletions(-) create mode 100644 src/shared/__tests__/context-mentions.test.ts diff --git a/src/shared/__tests__/context-mentions.test.ts b/src/shared/__tests__/context-mentions.test.ts new file mode 100644 index 0000000000..39fe5fae19 --- /dev/null +++ b/src/shared/__tests__/context-mentions.test.ts @@ -0,0 +1,233 @@ +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"]) + }) + }) +}) diff --git a/src/shared/context-mentions.ts b/src/shared/context-mentions.ts index 915114ab93..ddfc6650f5 100644 --- a/src/shared/context-mentions.ts +++ b/src/shared/context-mentions.ts @@ -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" From aec03d71c36e938822cff825927b78fc1f96b9d2 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Mon, 3 Mar 2025 07:47:12 +0700 Subject: [PATCH 02/19] feat: add virtual id for extended output capabilities --- src/api/providers/anthropic.ts | 101 ++++++++++++++++++++++++++------- src/shared/api.ts | 12 ++++ 2 files changed, 92 insertions(+), 21 deletions(-) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index a23f99261e..6cd57704c0 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -29,7 +29,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { let stream: AnthropicStream 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,6 +82,15 @@ 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 + + // Check for the thinking-128k variant first + if (virtualId === "claude-3-7-sonnet-20250219:thinking-128k") { + return { + headers: { "anthropic-beta": "output-128k-2025-02-19" }, + } + } + + // Then check for models that support prompt caching switch (modelId) { case "claude-3-5-sonnet-20241022": case "claude-3-5-haiku-20241022": @@ -184,31 +193,58 @@ 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. - if (id === "claude-3-7-sonnet-20250219:thinking") { + if (id === "claude-3-7-sonnet-20250219:thinking" || id === "claude-3-7-sonnet-20250219:thinking-128k") { id = "claude-3-7-sonnet-20250219" } return { id, info, + virtualId, // Include the original ID to use for header selection ...getModelParams({ options: this.options, model: info, defaultMaxTokens: ANTHROPIC_DEFAULT_MAX_TOKENS }), } } async completePrompt(prompt: string) { - let { id: modelId, maxTokens, thinking, temperature } = this.getModel() + let { id: modelId, maxTokens, thinking, temperature, virtualId } = this.getModel() - const message = await this.client.messages.create({ - model: modelId, - max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, - thinking, - temperature, - messages: [{ role: "user", content: prompt }], - stream: false, - }) + const message = await this.client.messages.create( + { + model: modelId, + max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, + thinking, + temperature, + messages: [{ role: "user", content: prompt }], + stream: false, + }, + (() => { + // Check for the thinking-128k variant first + if (virtualId === "claude-3-7-sonnet-20250219:thinking-128k") { + return { + headers: { "anthropic-beta": "output-128k-2025-02-19" }, + } + } + + // Then check for models that support prompt caching + switch (modelId) { + case "claude-3-5-sonnet-20241022": + case "claude-3-5-haiku-20241022": + case "claude-3-opus-20240229": + case "claude-3-haiku-20240307": + return { + headers: { "anthropic-beta": "prompt-caching-2024-07-31" }, + } + default: + return undefined + } + })(), + ) const content = message.content.find(({ type }) => type === "text") return content?.type === "text" ? content.text : "" @@ -223,17 +259,40 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa override async countTokens(content: Array): Promise { try { // Use the current model - const actualModelId = this.getModel().id + const { id: actualModelId, virtualId } = this.getModel() - const response = await this.client.messages.countTokens({ - model: actualModelId, - messages: [ - { - role: "user", - content: content, - }, - ], - }) + const response = await this.client.messages.countTokens( + { + model: actualModelId, + messages: [ + { + role: "user", + content: content, + }, + ], + }, + (() => { + // Check for the thinking-128k variant first + if (virtualId === "claude-3-7-sonnet-20250219:thinking-128k") { + return { + headers: { "anthropic-beta": "output-128k-2025-02-19" }, + } + } + + // Then check for models that support prompt caching + switch (actualModelId) { + case "claude-3-5-sonnet-20241022": + case "claude-3-5-haiku-20241022": + case "claude-3-opus-20240229": + case "claude-3-haiku-20240307": + return { + headers: { "anthropic-beta": "prompt-caching-2024-07-31" }, + } + default: + return undefined + } + })(), + ) return response.input_tokens } catch (error) { diff --git a/src/shared/api.ts b/src/shared/api.ts index b16e5142a0..f6085aba98 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -98,6 +98,18 @@ export interface ModelInfo { export type AnthropicModelId = keyof typeof anthropicModels export const anthropicDefaultModelId: AnthropicModelId = "claude-3-7-sonnet-20250219" export const anthropicModels = { + "claude-3-7-sonnet-20250219:thinking-128k": { + maxTokens: 128_000, + contextWindow: 200_000, + supportsImages: true, + supportsComputerUse: true, + supportsPromptCache: true, + inputPrice: 3.0, // $3 per million input tokens + outputPrice: 15.0, // $15 per million output tokens + cacheWritesPrice: 3.75, // $3.75 per million tokens + cacheReadsPrice: 0.3, // $0.30 per million tokens + thinking: true, + }, "claude-3-7-sonnet-20250219:thinking": { maxTokens: 64_000, contextWindow: 200_000, From d2b4a836059e86c33ded35145635c332a3087d97 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Mon, 3 Mar 2025 08:33:35 +0700 Subject: [PATCH 03/19] fix: simplify logic and allow for multiple betas --- src/api/providers/anthropic.ts | 93 +++++++++------------------------- 1 file changed, 24 insertions(+), 69 deletions(-) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 6cd57704c0..4df53244c8 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -83,11 +83,11 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa // 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-128k") { - return { - headers: { "anthropic-beta": "output-128k-2025-02-19" }, - } + betas.push("output-128k-2025-02-19") } // Then check for models that support prompt caching @@ -96,8 +96,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa 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 @@ -212,39 +213,16 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa } async completePrompt(prompt: string) { - let { id: modelId, maxTokens, thinking, temperature, virtualId } = this.getModel() + let { id: modelId, maxTokens, thinking, temperature } = this.getModel() - const message = await this.client.messages.create( - { - model: modelId, - max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, - thinking, - temperature, - messages: [{ role: "user", content: prompt }], - stream: false, - }, - (() => { - // Check for the thinking-128k variant first - if (virtualId === "claude-3-7-sonnet-20250219:thinking-128k") { - return { - headers: { "anthropic-beta": "output-128k-2025-02-19" }, - } - } - - // Then check for models that support prompt caching - switch (modelId) { - case "claude-3-5-sonnet-20241022": - case "claude-3-5-haiku-20241022": - case "claude-3-opus-20240229": - case "claude-3-haiku-20240307": - return { - headers: { "anthropic-beta": "prompt-caching-2024-07-31" }, - } - default: - return undefined - } - })(), - ) + const message = await this.client.messages.create({ + model: modelId, + max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, + thinking, + temperature, + messages: [{ role: "user", content: prompt }], + stream: false, + }) const content = message.content.find(({ type }) => type === "text") return content?.type === "text" ? content.text : "" @@ -259,40 +237,17 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa override async countTokens(content: Array): Promise { try { // Use the current model - const { id: actualModelId, virtualId } = this.getModel() + const actualModelId = this.getModel().id - const response = await this.client.messages.countTokens( - { - model: actualModelId, - messages: [ - { - role: "user", - content: content, - }, - ], - }, - (() => { - // Check for the thinking-128k variant first - if (virtualId === "claude-3-7-sonnet-20250219:thinking-128k") { - return { - headers: { "anthropic-beta": "output-128k-2025-02-19" }, - } - } - - // Then check for models that support prompt caching - switch (actualModelId) { - case "claude-3-5-sonnet-20241022": - case "claude-3-5-haiku-20241022": - case "claude-3-opus-20240229": - case "claude-3-haiku-20240307": - return { - headers: { "anthropic-beta": "prompt-caching-2024-07-31" }, - } - default: - return undefined - } - })(), - ) + const response = await this.client.messages.countTokens({ + model: actualModelId, + messages: [ + { + role: "user", + content: content, + }, + ], + }) return response.input_tokens } catch (error) { From 38cf054391da259465ee8238cf26ebbdb6ad47e7 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Mon, 3 Mar 2025 09:11:18 +0700 Subject: [PATCH 04/19] chore: add missing 3.7 sonnet modelid --- src/api/providers/anthropic.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 4df53244c8..53d28593e9 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -92,6 +92,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa // 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": From ef93f5d4220cecfee4527c8733f8801e85148577 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Mon, 3 Mar 2025 11:17:01 +0700 Subject: [PATCH 05/19] refactor: always enable 128k for simplification --- src/api/providers/anthropic.ts | 4 ++-- src/shared/api.ts | 14 +------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 53d28593e9..173550dc58 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -86,7 +86,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa const betas = [] // Check for the thinking-128k variant first - if (virtualId === "claude-3-7-sonnet-20250219:thinking-128k") { + if (virtualId === "claude-3-7-sonnet-20250219:thinking") { betas.push("output-128k-2025-02-19") } @@ -201,7 +201,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa // 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. - if (id === "claude-3-7-sonnet-20250219:thinking" || id === "claude-3-7-sonnet-20250219:thinking-128k") { + if (id === "claude-3-7-sonnet-20250219:thinking") { id = "claude-3-7-sonnet-20250219" } diff --git a/src/shared/api.ts b/src/shared/api.ts index f6085aba98..462afc46cd 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -98,20 +98,8 @@ export interface ModelInfo { export type AnthropicModelId = keyof typeof anthropicModels export const anthropicDefaultModelId: AnthropicModelId = "claude-3-7-sonnet-20250219" export const anthropicModels = { - "claude-3-7-sonnet-20250219:thinking-128k": { - maxTokens: 128_000, - contextWindow: 200_000, - supportsImages: true, - supportsComputerUse: true, - supportsPromptCache: true, - inputPrice: 3.0, // $3 per million input tokens - outputPrice: 15.0, // $15 per million output tokens - cacheWritesPrice: 3.75, // $3.75 per million tokens - cacheReadsPrice: 0.3, // $0.30 per million tokens - thinking: true, - }, "claude-3-7-sonnet-20250219:thinking": { - maxTokens: 64_000, + maxTokens: 128_000, contextWindow: 200_000, supportsImages: true, supportsComputerUse: true, From 6b0347e0bd4a2303bf50c00f5f011867ff4499bd Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 3 Mar 2025 00:17:24 -0500 Subject: [PATCH 06/19] Add a checkbox to enable/disable the browser tool --- .changeset/nervous-candles-refuse.md | 5 + src/core/Cline.ts | 3 +- src/core/webview/ClineProvider.ts | 9 ++ .../webview/__tests__/ClineProvider.test.ts | 16 +++ src/shared/ExtensionMessage.ts | 2 + src/shared/WebviewMessage.ts | 1 + src/shared/globalState.ts | 1 + .../src/components/settings/SettingsView.tsx | 122 +++++++++++------- .../src/context/ExtensionStateContext.tsx | 3 + 9 files changed, 113 insertions(+), 49 deletions(-) create mode 100644 .changeset/nervous-candles-refuse.md diff --git a/.changeset/nervous-candles-refuse.md b/.changeset/nervous-candles-refuse.md new file mode 100644 index 0000000000..314711606d --- /dev/null +++ b/.changeset/nervous-candles-refuse.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Add a checkbox to disable the browser tool diff --git a/src/core/Cline.ts b/src/core/Cline.ts index c339e713c3..c66dec2c86 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -946,6 +946,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 +957,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, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e23e8762a7..0a2e385b6a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1224,6 +1224,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() @@ -2022,6 +2026,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { autoApprovalEnabled, experiments, maxOpenTabsContext, + browserToolEnabled, } = await this.getState() const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get("allowedCommands") || [] @@ -2075,6 +2080,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpServers: this.mcpHub?.getAllServers() ?? [], maxOpenTabsContext: maxOpenTabsContext ?? 20, cwd: cwd, + browserToolEnabled: browserToolEnabled ?? true, } } @@ -2214,6 +2220,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { modelMaxTokens, modelMaxThinkingTokens, maxOpenTabsContext, + browserToolEnabled, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -2298,6 +2305,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("modelMaxTokens") as Promise, this.getGlobalState("anthropicThinking") as Promise, this.getGlobalState("maxOpenTabsContext") as Promise, + this.getGlobalState("browserToolEnabled") as Promise, ]) let apiProvider: ApiProvider @@ -2431,6 +2439,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { customModes, maxOpenTabsContext: maxOpenTabsContext ?? 20, openRouterUseMiddleOutTransform: openRouterUseMiddleOutTransform ?? true, + browserToolEnabled: browserToolEnabled ?? true, } } diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index c8742cd3f4..154c24bc27 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -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] diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 34abd38dbf..858b20ca7f 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -45,6 +45,7 @@ export interface ExtensionMessage { | "updateCustomMode" | "deleteCustomMode" | "currentCheckpointUpdated" + | "browserToolEnabled" text?: string action?: | "chatButtonClicked" @@ -103,6 +104,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 diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 8d3a114e65..3b1750bcb6 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -94,6 +94,7 @@ export interface WebviewMessage { | "checkpointRestore" | "deleteMcpServer" | "maxOpenTabsContext" + | "browserToolEnabled" text?: string disabled?: boolean askResponse?: ClineAskResponse diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index aabc77cc01..64a222be81 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -84,3 +84,4 @@ export type GlobalStateKey = | "anthropicThinking" // TODO: Rename to `modelMaxThinkingTokens`. | "mistralCodestralUrl" | "maxOpenTabsContext" + | "browserToolEnabled" // Setting to enable/disable the browser tool diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 51ef4fe81d..612be3aab3 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -51,6 +51,7 @@ const SettingsView = forwardRef(({ onDone }, alwaysAllowModeSwitch, alwaysAllowWrite, alwaysApproveResubmit, + browserToolEnabled, browserViewportSize, enableCheckpoints, diffEnabled, @@ -140,6 +141,7 @@ const SettingsView = forwardRef(({ 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(({ onDone },

Browser Settings

- -
- { - 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)" }, - ]} - /> -
-

- Select the viewport size for browser interactions. This affects how websites are displayed - and interacted with. + setCachedStateField("browserToolEnabled", e.target.checked)}> + Enable browser tool + +

+ When enabled, Roo can use a browser to interact with websites when using models that support + computer use.

+ {browserToolEnabled && ( +
+
+ +
+ { + 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)" }, + ]} + /> +
+

+ Select the viewport size for browser interactions. This affects how websites are + displayed and interacted with. +

+
-
-
- Screenshot quality -
- setCachedStateField("screenshotQuality", parseInt(e.target.value))} - /> - {screenshotQuality ?? 75}% +
+
+ Screenshot quality +
+ + setCachedStateField("screenshotQuality", parseInt(e.target.value)) + } + /> + {screenshotQuality ?? 75}% +
+
+

+ Adjust the WebP quality of browser screenshots. Higher values provide clearer + screenshots but increase token usage. +

-

- Adjust the WebP quality of browser screenshots. Higher values provide clearer screenshots - but increase token usage. -

-
+ )}
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index abf921dc2b..ba735f27b2 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -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 {children} From 62677071e8b27bb4c0bf21eee9f2cbfd46e15568 Mon Sep 17 00:00:00 2001 From: aheizi Date: Mon, 3 Mar 2025 14:44:56 +0800 Subject: [PATCH 07/19] fix: switch mode state --- webview-ui/src/components/chat/ChatView.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index fe0dc7e168..3f1d5fd10b 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -973,7 +973,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 From 63cecf8b22b91b0d2b3e78277eda9e424768d394 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 3 Mar 2025 09:37:55 -0500 Subject: [PATCH 08/19] Support 128k max tokens for openrouter thinking --- src/api/providers/__tests__/openrouter.test.ts | 2 +- src/api/providers/openrouter.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/openrouter.test.ts b/src/api/providers/__tests__/openrouter.test.ts index 892c138172..981c9ad096 100644 --- a/src/api/providers/__tests__/openrouter.test.ts +++ b/src/api/providers/__tests__/openrouter.test.ts @@ -72,7 +72,7 @@ describe("OpenRouterHandler", () => { openRouterModelId: "test-model", openRouterModelInfo: { ...mockOpenRouterModelInfo, - maxTokens: 64_000, + maxTokens: 128_000, thinking: true, }, modelMaxTokens: 32_768, diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 0bf98ba18e..db5c094d02 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -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 From 2b7df5e106f663fbdeaa885a3d8da641eef40e75 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 3 Mar 2025 09:44:02 -0500 Subject: [PATCH 09/19] Update max tokens for vertex too --- src/shared/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/api.ts b/src/shared/api.ts index 462afc46cd..13251f2783 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -477,7 +477,7 @@ export const vertexModels = { outputPrice: 5, }, "claude-3-7-sonnet@20250219:thinking": { - maxTokens: 64_000, + maxTokens: 128_000, contextWindow: 200_000, supportsImages: true, supportsComputerUse: true, From 003cd1bce172bc5caf1e64925e83fd5c71fac5c5 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 3 Mar 2025 10:46:54 -0500 Subject: [PATCH 10/19] Exclude MCP from system prompt if mode does not support it --- .changeset/fluffy-pumpkins-sip.md | 5 +++++ src/core/prompts/system.ts | 13 ++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 .changeset/fluffy-pumpkins-sip.md diff --git a/.changeset/fluffy-pumpkins-sip.md b/.changeset/fluffy-pumpkins-sip.md new file mode 100644 index 0000000000..9b44d7950e --- /dev/null +++ b/.changeset/fluffy-pumpkins-sip.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Exclude MCP instructions from the prompt if the mode doesn't support MCP diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 90791f6358..3147c97ae3 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -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()} From c3e36deed6d50bcca585089c52428c9b51bfd646 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 3 Mar 2025 10:51:45 -0500 Subject: [PATCH 11/19] Update the warning text for the VS LM API --- .changeset/tidy-donuts-rhyme.md | 5 +++++ webview-ui/src/components/settings/ApiOptions.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/tidy-donuts-rhyme.md diff --git a/.changeset/tidy-donuts-rhyme.md b/.changeset/tidy-donuts-rhyme.md new file mode 100644 index 0000000000..f931bdaf28 --- /dev/null +++ b/.changeset/tidy-donuts-rhyme.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Update the warning text for the VS LM API" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 4b1707afa6..e69be19ef2 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -1209,8 +1209,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.

From 17da9f051194114db77661b7baa311831d28294b Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 3 Mar 2025 10:56:22 -0500 Subject: [PATCH 12/19] Update .changeset/tidy-donuts-rhyme.md Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- .changeset/tidy-donuts-rhyme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tidy-donuts-rhyme.md b/.changeset/tidy-donuts-rhyme.md index f931bdaf28..51fbea3fc2 100644 --- a/.changeset/tidy-donuts-rhyme.md +++ b/.changeset/tidy-donuts-rhyme.md @@ -2,4 +2,4 @@ "roo-cline": patch --- -Update the warning text for the VS LM API" +Update the warning text for the VS LM API From 41a1c82fc5e09085c5ba2d6be40298f43f1fe569 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Mon, 3 Mar 2025 22:59:13 +0700 Subject: [PATCH 13/19] add test from pr comment --- src/shared/__tests__/context-mentions.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/shared/__tests__/context-mentions.test.ts b/src/shared/__tests__/context-mentions.test.ts index 39fe5fae19..99bad21ebb 100644 --- a/src/shared/__tests__/context-mentions.test.ts +++ b/src/shared/__tests__/context-mentions.test.ts @@ -230,4 +230,96 @@ describe("Mention Regex", () => { 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/withchars.txt", "@/path/withchars.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") + // }) + // }) }) From cd46ab5af3b1f5864e74f2fe2ff8e3a9dc6be734 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 3 Mar 2025 11:12:08 -0500 Subject: [PATCH 14/19] Show a warning if checkpoints are taking too long to load --- .changeset/stale-toys-think.md | 5 ++ webview-ui/src/components/chat/ChatView.tsx | 76 +++++++++++++++++---- 2 files changed, 69 insertions(+), 12 deletions(-) create mode 100644 .changeset/stale-toys-think.md diff --git a/.changeset/stale-toys-think.md b/.changeset/stale-toys-think.md new file mode 100644 index 0000000000..88be2ada87 --- /dev/null +++ b/.changeset/stale-toys-think.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Show a warning if checkpoints are taking too long to load diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 3f1d5fd10b..d56594a6f8 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -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(false) + const [showCheckpointWarning, setShowCheckpointWarning] = useState(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( + () => ( +
+ + + Still initializing checkpoint... If this takes too long, you can{" "} + { + e.preventDefault() + window.postMessage({ type: "action", action: "settingsButtonClicked" }, "*") + }} + className="inline px-0.5"> + disable checkpoints in settings + {" "} + and restart your task. + +
+ ), + [], + ) + const placeholderText = useMemo(() => { const baseText = task ? "Type a message..." : "Type your task here..." const contextText = "(@ to add context, / to switch modes" @@ -1014,17 +1057,26 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie overflow: "hidden", }}> {task ? ( - + <> + + + {/* Checkpoint warning message */} + {showCheckpointWarning && ( +
+ +
+ )} + ) : (
Date: Mon, 3 Mar 2025 16:32:15 +0000 Subject: [PATCH 15/19] changeset version bump --- .changeset/fluffy-pumpkins-sip.md | 5 ----- .changeset/healthy-buckets-attack.md | 5 ----- .changeset/nervous-candles-refuse.md | 5 ----- .changeset/stale-toys-think.md | 5 ----- .changeset/tidy-donuts-rhyme.md | 5 ----- .changeset/weak-cameras-hope.md | 5 ----- CHANGELOG.md | 11 +++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 9 files changed, 14 insertions(+), 33 deletions(-) delete mode 100644 .changeset/fluffy-pumpkins-sip.md delete mode 100644 .changeset/healthy-buckets-attack.md delete mode 100644 .changeset/nervous-candles-refuse.md delete mode 100644 .changeset/stale-toys-think.md delete mode 100644 .changeset/tidy-donuts-rhyme.md delete mode 100644 .changeset/weak-cameras-hope.md diff --git a/.changeset/fluffy-pumpkins-sip.md b/.changeset/fluffy-pumpkins-sip.md deleted file mode 100644 index 9b44d7950e..0000000000 --- a/.changeset/fluffy-pumpkins-sip.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Exclude MCP instructions from the prompt if the mode doesn't support MCP diff --git a/.changeset/healthy-buckets-attack.md b/.changeset/healthy-buckets-attack.md deleted file mode 100644 index 8961d7151e..0000000000 --- a/.changeset/healthy-buckets-attack.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -ExtensionStateContext does not correctly merge state diff --git a/.changeset/nervous-candles-refuse.md b/.changeset/nervous-candles-refuse.md deleted file mode 100644 index 314711606d..0000000000 --- a/.changeset/nervous-candles-refuse.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Add a checkbox to disable the browser tool diff --git a/.changeset/stale-toys-think.md b/.changeset/stale-toys-think.md deleted file mode 100644 index 88be2ada87..0000000000 --- a/.changeset/stale-toys-think.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Show a warning if checkpoints are taking too long to load diff --git a/.changeset/tidy-donuts-rhyme.md b/.changeset/tidy-donuts-rhyme.md deleted file mode 100644 index 51fbea3fc2..0000000000 --- a/.changeset/tidy-donuts-rhyme.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Update the warning text for the VS LM API diff --git a/.changeset/weak-cameras-hope.md b/.changeset/weak-cameras-hope.md deleted file mode 100644 index 62eecb7fe0..0000000000 --- a/.changeset/weak-cameras-hope.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Default middle-out compression to on for OpenRouter diff --git a/CHANGELOG.md b/CHANGELOG.md index c4be3d8838..da89aeca95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Roo Code Changelog +## 3.7.12 + +### Patch Changes + +- Exclude MCP instructions from the prompt if the mode doesn't support MCP +- ExtensionStateContext does not correctly merge state +- 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 +- Default middle-out compression to on for OpenRouter + ## [3.7.11] - Don't honor custom max tokens for non thinking models diff --git a/package-lock.json b/package-lock.json index 0e3e29d44e..228a51a720 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5e75c57ad4..68767cbed5 100644 --- a/package.json +++ b/package.json @@ -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", From a910dd45f7f75a22dbbbcf0b6a94d4a27c620c42 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 3 Mar 2025 11:45:06 -0500 Subject: [PATCH 16/19] Update CHANGELOG.md --- CHANGELOG.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da89aeca95..f1973b6e94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,16 @@ # Roo Code Changelog -## 3.7.12 - -### Patch Changes +## [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 -- ExtensionStateContext does not correctly merge state - 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 -- Default middle-out compression to on for OpenRouter +- Correctly populate the default OpenRouter model on the welcome screen ## [3.7.11] From 9e471444de4b27546fbd586d87ba85b07d66e7f8 Mon Sep 17 00:00:00 2001 From: cte Date: Mon, 3 Mar 2025 12:28:16 -0800 Subject: [PATCH 17/19] Async checkpoints --- src/core/Cline.ts | 142 +++--- .../checkpoints/CheckpointServiceFactory.ts | 29 -- .../checkpoints/LocalCheckpointService.ts | 440 ------------------ .../checkpoints/ShadowCheckpointService.ts | 135 ++++-- .../__tests__/LocalCheckpointService.test.ts | 385 --------------- .../__tests__/ShadowCheckpointService.test.ts | 176 ++++++- src/services/checkpoints/index.ts | 2 - src/services/checkpoints/types.ts | 39 +- .../chat/checkpoints/CheckpointSaved.tsx | 4 +- .../src/components/chat/checkpoints/schema.ts | 2 - 10 files changed, 392 insertions(+), 962 deletions(-) delete mode 100644 src/services/checkpoints/CheckpointServiceFactory.ts delete mode 100644 src/services/checkpoints/LocalCheckpointService.ts delete mode 100644 src/services/checkpoints/__tests__/LocalCheckpointService.test.ts delete mode 100644 src/services/checkpoints/index.ts diff --git a/src/core/Cline.ts b/src/core/Cline.ts index c66dec2c86..b9b243dbc1 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -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 { + 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 @@ -2773,7 +2776,7 @@ export class Cline { } if (isCheckpointPossible) { - await this.checkpointSave({ isFirst: false }) + this.checkpointSave() } /* @@ -2839,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( @@ -3356,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({ @@ -3398,7 +3445,7 @@ export class Cline { commitHash: string mode: "full" | "checkpoint" }) { - if (!this.enableCheckpoints) { + if (!this.checkpointService || !this.enableCheckpoints) { return } @@ -3414,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.") @@ -3441,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({ @@ -3476,7 +3505,7 @@ export class Cline { commitHash: string mode: "preview" | "restore" }) { - if (!this.enableCheckpoints) { + if (!this.checkpointService || !this.enableCheckpoints) { return } @@ -3487,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 }) diff --git a/src/services/checkpoints/CheckpointServiceFactory.ts b/src/services/checkpoints/CheckpointServiceFactory.ts deleted file mode 100644 index ff39a1fe7e..0000000000 --- a/src/services/checkpoints/CheckpointServiceFactory.ts +++ /dev/null @@ -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 { strategy: "local" } - ? LocalCheckpointService - : T extends { strategy: "shadow" } - ? ShadowCheckpointService - : never - -export class CheckpointServiceFactory { - public static create(options: T): CheckpointServiceType { - switch (options.strategy) { - case "local": - return LocalCheckpointService.create(options.options) as any - case "shadow": - return ShadowCheckpointService.create(options.options) as any - } - } -} diff --git a/src/services/checkpoints/LocalCheckpointService.ts b/src/services/checkpoints/LocalCheckpointService.ts deleted file mode 100644 index ce5c6bd6ea..0000000000 --- a/src/services/checkpoints/LocalCheckpointService.ts +++ /dev/null @@ -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, - ) { - 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 } - } -} diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 301602312e..70b3c68484 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -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,18 @@ export class ShadowCheckpointService implements CheckpointService { this._baseHash = value } + public get isInitialized() { + return this._isInitialized + } + + private set isInitialized(value: boolean) { + this._isInitialized = value + } + + public get isDirty() { + return this._checkpoints.length > 0 + } + private readonly shadowGitDir: string private shadowGitConfigWorktree?: string @@ -35,18 +48,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 +78,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 +99,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 +114,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 +139,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 +175,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 +192,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 +202,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 +291,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 +310,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) } } diff --git a/src/services/checkpoints/__tests__/LocalCheckpointService.test.ts b/src/services/checkpoints/__tests__/LocalCheckpointService.test.ts deleted file mode 100644 index 29ab7133a5..0000000000 --- a/src/services/checkpoints/__tests__/LocalCheckpointService.test.ts +++ /dev/null @@ -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 }) - }) - }) -}) diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts index 5bcc5d444c..138e1130e8 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts @@ -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() + }) + }) }) diff --git a/src/services/checkpoints/index.ts b/src/services/checkpoints/index.ts deleted file mode 100644 index e62b6119ce..0000000000 --- a/src/services/checkpoints/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./types" -export * from "./CheckpointServiceFactory" diff --git a/src/services/checkpoints/types.ts b/src/services/checkpoints/types.ts index 50843abbd3..3ae9457fe8 100644 --- a/src/services/checkpoints/types.ts +++ b/src/services/checkpoints/types.ts @@ -1,3 +1,4 @@ +import EventEmitter from "events" import { CommitResult } from "simple-git" export type CheckpointResult = Partial & Pick @@ -13,15 +14,12 @@ export type CheckpointDiff = { } } -export type CheckpointStrategy = "local" | "shadow" - export interface CheckpointService { saveCheckpoint(message: string): Promise restoreCheckpoint(commit: string): Promise getDiff(range: { from?: string; to?: string }): Promise 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(event: K, data: CheckpointEventMap[K]): boolean { + return super.emit(event, data) + } + + override on(event: K, listener: (data: CheckpointEventMap[K]) => void): this { + return super.on(event, listener) + } + + override off(event: K, listener: (data: CheckpointEventMap[K]) => void): this { + return super.off(event, listener) + } + + override once(event: K, listener: (data: CheckpointEventMap[K]) => void): this { + return super.once(event, listener) + } +} diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx index c15bbd102c..5d3475c4d0 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx @@ -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 } diff --git a/webview-ui/src/components/chat/checkpoints/schema.ts b/webview-ui/src/components/chat/checkpoints/schema.ts index 7f097966b8..4acd32a6ab 100644 --- a/webview-ui/src/components/chat/checkpoints/schema.ts +++ b/webview-ui/src/components/chat/checkpoints/schema.ts @@ -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 From e227a9e86ef8e2c906144792c86603b427193ec1 Mon Sep 17 00:00:00 2001 From: cte Date: Mon, 3 Mar 2025 12:35:00 -0800 Subject: [PATCH 18/19] Remove unused method --- src/services/checkpoints/ShadowCheckpointService.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 70b3c68484..bdad6d686a 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -34,10 +34,6 @@ export class ShadowCheckpointService extends CheckpointEventEmitter implements C this._isInitialized = value } - public get isDirty() { - return this._checkpoints.length > 0 - } - private readonly shadowGitDir: string private shadowGitConfigWorktree?: string From af06af136c045fa8bfcb56f894fdf22751fd531f Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 3 Mar 2025 16:03:22 -0500 Subject: [PATCH 19/19] Set Vertex maxTokens to 64000 --- src/shared/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/api.ts b/src/shared/api.ts index 13251f2783..462afc46cd 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -477,7 +477,7 @@ export const vertexModels = { outputPrice: 5, }, "claude-3-7-sonnet@20250219:thinking": { - maxTokens: 128_000, + maxTokens: 64_000, contextWindow: 200_000, supportsImages: true, supportsComputerUse: true,