Resolved merge conflicts in Cline.ts and ClineProvider.ts

This commit is contained in:
ShayBC 2025-02-28 03:33:13 +02:00
commit 03e0aa545b
40 changed files with 1777 additions and 269 deletions

View file

@ -1,5 +0,0 @@
---
"roo-cline": patch
---
Handle really long text in the ChatRow similar to TaskHeader

View file

@ -1,5 +0,0 @@
---
"roo-cline": patch
---
Support multiple files in drag-and-drop

View file

@ -1,5 +0,0 @@
---
"roo-cline": patch
---
Better OpenRouter error handling

View file

@ -1,5 +1,25 @@
# Roo Code Changelog
## [3.7.8]
- Add Vertex AI prompt caching support for Claude models (thanks @aitoroses and @lupuletic!)
- Add gpt-4.5-preview
- Add an advanced feature to customize the system prompt
## [3.7.7]
- Graduate checkpoints out of beta
- Fix enhance prompt button when using Thinking Sonnet
- Add tooltips to make what buttons do more obvious
## [3.7.6]
- Handle really long text better in the in the ChatRow similar to TaskHeader (thanks @joemanley201!)
- Support multiple files in drag-and-drop
- Truncate search_file output to avoid crashing the extension
- Better OpenRouter error handling (no more "Provider Error")
- Add slider to control max output tokens for thinking models
## [3.7.5]
- Fix context window truncation math (see [#1173](https://github.com/RooVetGit/Roo-Code/issues/1173))

14
package-lock.json generated
View file

@ -1,16 +1,16 @@
{
"name": "roo-cline",
"version": "3.7.5",
"version": "3.7.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "roo-cline",
"version": "3.7.5",
"version": "3.7.8",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.4.1",
"@anthropic-ai/vertex-sdk": "^0.7.0",
"@aws-sdk/client-bedrock-runtime": "^3.706.0",
"@google/generative-ai": "^0.18.0",
"@mistralai/mistralai": "^1.3.6",
@ -150,11 +150,11 @@
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
},
"node_modules/@anthropic-ai/vertex-sdk": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.4.3.tgz",
"integrity": "sha512-2Uef0C5P2Hx+T88RnUSRA3u4aZqmqnrRSOb2N64ozgKPiSUPTM5JlggAq2b32yWMj5d3MLYa6spJXKMmHXOcoA==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.7.0.tgz",
"integrity": "sha512-zNm3hUXgYmYDTyveIxOyxbcnh5VXFkrLo4bSnG6LAfGzW7k3k2iCNDSVKtR9qZrK2BCid7JtVu7jsEKaZ/9dSw==",
"dependencies": {
"@anthropic-ai/sdk": ">=0.14 <1",
"@anthropic-ai/sdk": ">=0.35 <1",
"google-auth-library": "^9.4.2"
}
},

View file

@ -3,7 +3,7 @@
"displayName": "Roo Code (prev. Roo Cline)",
"description": "A whole dev team of AI agents in your editor.",
"publisher": "RooVeterinaryInc",
"version": "3.7.5",
"version": "3.7.8",
"icon": "assets/icons/rocket.png",
"galleryBanner": {
"color": "#617A91",
@ -305,7 +305,7 @@
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.4.1",
"@anthropic-ai/vertex-sdk": "^0.7.0",
"@aws-sdk/client-bedrock-runtime": "^3.706.0",
"@google/generative-ai": "^0.18.0",
"@mistralai/mistralai": "^1.3.6",

View file

@ -140,7 +140,6 @@ const mockFs = {
currentPath += "/" + parts[parts.length - 1]
mockDirectories.add(currentPath)
return Promise.resolve()
return Promise.resolve()
}),
access: jest.fn().mockImplementation(async (path: string) => {

View file

@ -15,3 +15,33 @@ jest.mock("../utils/logging", () => ({
}),
},
}))
// Add toPosix method to String prototype for all tests, mimicking src/utils/path.ts
// This is needed because the production code expects strings to have this method
// Note: In production, this is added via import in the entry point (extension.ts)
export {}
declare global {
interface String {
toPosix(): string
}
}
// Implementation that matches src/utils/path.ts
function toPosixPath(p: string) {
// Extended-Length Paths in Windows start with "\\?\" to allow longer paths
// and bypass usual parsing. If detected, we return the path unmodified.
const isExtendedLengthPath = p.startsWith("\\\\?\\")
if (isExtendedLengthPath) {
return p
}
return p.replace(/\\/g, "/")
}
if (!String.prototype.toPosix) {
String.prototype.toPosix = function (this: string): string {
return toPosixPath(this)
}
}

View file

@ -153,7 +153,7 @@ describe("AnthropicHandler", () => {
})
it("should handle API errors", async () => {
mockCreate.mockRejectedValueOnce(new Error("API Error"))
mockCreate.mockRejectedValueOnce(new Error("Anthropic completion error: API Error"))
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Anthropic completion error: API Error")
})

View file

@ -357,7 +357,7 @@ describe("OpenAiNativeHandler", () => {
const modelInfo = handler.getModel()
expect(modelInfo.id).toBe(mockOptions.apiModelId)
expect(modelInfo.info).toBeDefined()
expect(modelInfo.info.maxTokens).toBe(4096)
expect(modelInfo.info.maxTokens).toBe(16384)
expect(modelInfo.info.contextWindow).toBe(128_000)
})

View file

@ -2,8 +2,10 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta"
import { VertexHandler } from "../vertex"
import { ApiStreamChunk } from "../../transform/stream"
// Mock Vertex SDK
jest.mock("@anthropic-ai/vertex-sdk", () => ({
@ -128,7 +130,7 @@ describe("VertexHandler", () => {
;(handler["client"].messages as any).create = mockCreate
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
const chunks: ApiStreamChunk[] = []
for await (const chunk of stream) {
chunks.push(chunk)
@ -158,8 +160,29 @@ describe("VertexHandler", () => {
model: "claude-3-5-sonnet-v2@20241022",
max_tokens: 8192,
temperature: 0,
system: systemPrompt,
messages: mockMessages,
system: [
{
type: "text",
text: "You are a helpful assistant",
cache_control: { type: "ephemeral" },
},
],
messages: [
{
role: "user",
content: [
{
type: "text",
text: "Hello",
cache_control: { type: "ephemeral" },
},
],
},
{
role: "assistant",
content: "Hi there!",
},
],
stream: true,
})
})
@ -196,7 +219,7 @@ describe("VertexHandler", () => {
;(handler["client"].messages as any).create = mockCreate
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks = []
const chunks: ApiStreamChunk[] = []
for await (const chunk of stream) {
chunks.push(chunk)
@ -230,6 +253,315 @@ describe("VertexHandler", () => {
}
}).rejects.toThrow("Vertex API error")
})
it("should handle prompt caching for supported models", async () => {
const mockStream = [
{
type: "message_start",
message: {
usage: {
input_tokens: 10,
output_tokens: 0,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 2,
},
},
},
{
type: "content_block_start",
index: 0,
content_block: {
type: "text",
text: "Hello",
},
},
{
type: "content_block_delta",
delta: {
type: "text_delta",
text: " world!",
},
},
{
type: "message_delta",
usage: {
output_tokens: 5,
},
},
]
const asyncIterator = {
async *[Symbol.asyncIterator]() {
for (const chunk of mockStream) {
yield chunk
}
},
}
const mockCreate = jest.fn().mockResolvedValue(asyncIterator)
;(handler["client"].messages as any).create = mockCreate
const stream = handler.createMessage(systemPrompt, [
{
role: "user",
content: "First message",
},
{
role: "assistant",
content: "Response",
},
{
role: "user",
content: "Second message",
},
])
const chunks: ApiStreamChunk[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Verify usage information
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(2)
expect(usageChunks[0]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 0,
cacheWriteTokens: 3,
cacheReadTokens: 2,
})
expect(usageChunks[1]).toEqual({
type: "usage",
inputTokens: 0,
outputTokens: 5,
})
// Verify text content
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(2)
expect(textChunks[0].text).toBe("Hello")
expect(textChunks[1].text).toBe(" world!")
// Verify cache control was added correctly
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
system: [
{
type: "text",
text: "You are a helpful assistant",
cache_control: { type: "ephemeral" },
},
],
messages: [
expect.objectContaining({
role: "user",
content: [
{
type: "text",
text: "First message",
cache_control: { type: "ephemeral" },
},
],
}),
expect.objectContaining({
role: "assistant",
content: "Response",
}),
expect.objectContaining({
role: "user",
content: [
{
type: "text",
text: "Second message",
cache_control: { type: "ephemeral" },
},
],
}),
],
}),
)
})
it("should handle cache-related usage metrics", async () => {
const mockStream = [
{
type: "message_start",
message: {
usage: {
input_tokens: 10,
output_tokens: 0,
cache_creation_input_tokens: 5,
cache_read_input_tokens: 3,
},
},
},
{
type: "content_block_start",
index: 0,
content_block: {
type: "text",
text: "Hello",
},
},
]
const asyncIterator = {
async *[Symbol.asyncIterator]() {
for (const chunk of mockStream) {
yield chunk
}
},
}
const mockCreate = jest.fn().mockResolvedValue(asyncIterator)
;(handler["client"].messages as any).create = mockCreate
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks: ApiStreamChunk[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Check for cache-related metrics in usage chunk
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks.length).toBeGreaterThan(0)
expect(usageChunks[0]).toHaveProperty("cacheWriteTokens", 5)
expect(usageChunks[0]).toHaveProperty("cacheReadTokens", 3)
})
})
describe("thinking functionality", () => {
const mockMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello",
},
]
const systemPrompt = "You are a helpful assistant"
it("should handle thinking content blocks and deltas", async () => {
const mockStream = [
{
type: "message_start",
message: {
usage: {
input_tokens: 10,
output_tokens: 0,
},
},
},
{
type: "content_block_start",
index: 0,
content_block: {
type: "thinking",
thinking: "Let me think about this...",
},
},
{
type: "content_block_delta",
delta: {
type: "thinking_delta",
thinking: " I need to consider all options.",
},
},
{
type: "content_block_start",
index: 1,
content_block: {
type: "text",
text: "Here's my answer:",
},
},
]
// Setup async iterator for mock stream
const asyncIterator = {
async *[Symbol.asyncIterator]() {
for (const chunk of mockStream) {
yield chunk
}
},
}
const mockCreate = jest.fn().mockResolvedValue(asyncIterator)
;(handler["client"].messages as any).create = mockCreate
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks: ApiStreamChunk[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Verify thinking content is processed correctly
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
expect(reasoningChunks).toHaveLength(2)
expect(reasoningChunks[0].text).toBe("Let me think about this...")
expect(reasoningChunks[1].text).toBe(" I need to consider all options.")
// Verify text content is processed correctly
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(2) // One for the text block, one for the newline
expect(textChunks[0].text).toBe("\n")
expect(textChunks[1].text).toBe("Here's my answer:")
})
it("should handle multiple thinking blocks with line breaks", async () => {
const mockStream = [
{
type: "content_block_start",
index: 0,
content_block: {
type: "thinking",
thinking: "First thinking block",
},
},
{
type: "content_block_start",
index: 1,
content_block: {
type: "thinking",
thinking: "Second thinking block",
},
},
]
const asyncIterator = {
async *[Symbol.asyncIterator]() {
for (const chunk of mockStream) {
yield chunk
}
},
}
const mockCreate = jest.fn().mockResolvedValue(asyncIterator)
;(handler["client"].messages as any).create = mockCreate
const stream = handler.createMessage(systemPrompt, mockMessages)
const chunks: ApiStreamChunk[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks.length).toBe(3)
expect(chunks[0]).toEqual({
type: "reasoning",
text: "First thinking block",
})
expect(chunks[1]).toEqual({
type: "reasoning",
text: "\n",
})
expect(chunks[2]).toEqual({
type: "reasoning",
text: "Second thinking block",
})
})
})
describe("completePrompt", () => {
@ -240,7 +572,13 @@ describe("VertexHandler", () => {
model: "claude-3-5-sonnet-v2@20241022",
max_tokens: 8192,
temperature: 0,
messages: [{ role: "user", content: "Test prompt" }],
system: "",
messages: [
{
role: "user",
content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }],
},
],
stream: false,
})
})
@ -295,4 +633,109 @@ describe("VertexHandler", () => {
expect(modelInfo.id).toBe("claude-3-7-sonnet@20250219") // Default model
})
})
describe("thinking model configuration", () => {
it("should configure thinking for models with :thinking suffix", () => {
const thinkingHandler = new VertexHandler({
apiModelId: "claude-3-7-sonnet@20250219:thinking",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
modelMaxTokens: 16384,
modelMaxThinkingTokens: 4096,
})
const modelInfo = thinkingHandler.getModel()
// Verify thinking configuration
expect(modelInfo.id).toBe("claude-3-7-sonnet@20250219")
expect(modelInfo.thinking).toBeDefined()
const thinkingConfig = modelInfo.thinking as { type: "enabled"; budget_tokens: number }
expect(thinkingConfig.type).toBe("enabled")
expect(thinkingConfig.budget_tokens).toBe(4096)
expect(modelInfo.temperature).toBe(1.0) // Thinking requires temperature 1.0
})
it("should calculate thinking budget correctly", () => {
// Test with explicit thinking budget
const handlerWithBudget = new VertexHandler({
apiModelId: "claude-3-7-sonnet@20250219:thinking",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
modelMaxTokens: 16384,
modelMaxThinkingTokens: 5000,
})
expect((handlerWithBudget.getModel().thinking as any).budget_tokens).toBe(5000)
// Test with default thinking budget (80% of max tokens)
const handlerWithDefaultBudget = new VertexHandler({
apiModelId: "claude-3-7-sonnet@20250219:thinking",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
modelMaxTokens: 10000,
})
expect((handlerWithDefaultBudget.getModel().thinking as any).budget_tokens).toBe(8000) // 80% of 10000
// Test with minimum thinking budget (should be at least 1024)
const handlerWithSmallMaxTokens = new VertexHandler({
apiModelId: "claude-3-7-sonnet@20250219:thinking",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
modelMaxTokens: 1000, // This would result in 800 tokens for thinking, but minimum is 1024
})
expect((handlerWithSmallMaxTokens.getModel().thinking as any).budget_tokens).toBe(1024)
})
it("should pass thinking configuration to API", async () => {
const thinkingHandler = new VertexHandler({
apiModelId: "claude-3-7-sonnet@20250219:thinking",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
modelMaxTokens: 16384,
modelMaxThinkingTokens: 4096,
})
const mockCreate = jest.fn().mockImplementation(async (options) => {
if (!options.stream) {
return {
id: "test-completion",
content: [{ type: "text", text: "Test response" }],
role: "assistant",
model: options.model,
usage: {
input_tokens: 10,
output_tokens: 5,
},
}
}
return {
async *[Symbol.asyncIterator]() {
yield {
type: "message_start",
message: {
usage: {
input_tokens: 10,
output_tokens: 5,
},
},
}
},
}
})
;(thinkingHandler["client"].messages as any).create = mockCreate
await thinkingHandler
.createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }])
.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
thinking: { type: "enabled", budget_tokens: 4096 },
temperature: 1.0, // Thinking requires temperature 1.0
}),
)
})
})
})

View file

@ -30,21 +30,7 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
let stream: AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>
const cacheControl: CacheControlEphemeral = { type: "ephemeral" }
let { id: modelId, info: modelInfo } = this.getModel()
const maxTokens = modelInfo.maxTokens || 8192
let temperature = this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE
let thinking: BetaThinkingConfigParam | undefined = undefined
// Anthropic "Thinking" models require a temperature of 1.0.
if (modelId === "claude-3-7-sonnet-20250219:thinking") {
// 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.
modelId = "claude-3-7-sonnet-20250219"
const budgetTokens = this.options.anthropicThinking ?? Math.max(maxTokens * 0.8, 1024)
thinking = { type: "enabled", budget_tokens: budgetTokens }
temperature = 1.0
}
let { id: modelId, temperature, maxTokens, thinking } = this.getModel()
switch (modelId) {
case "claude-3-7-sonnet-20250219":
@ -194,40 +180,62 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
}
}
getModel(): { id: AnthropicModelId; info: ModelInfo } {
getModel() {
const modelId = this.options.apiModelId
let temperature = this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE
let thinking: BetaThinkingConfigParam | undefined = undefined
if (modelId && modelId in anthropicModels) {
const id = modelId as AnthropicModelId
return { id, info: anthropicModels[id] }
let id = modelId as AnthropicModelId
const info: ModelInfo = anthropicModels[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") {
id = "claude-3-7-sonnet-20250219"
}
const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192
if (info.thinking) {
// Anthropic "Thinking" models require a temperature of 1.0.
temperature = 1.0
// Clamp the thinking budget to be at most 80% of max tokens and at
// least 1024 tokens.
const maxBudgetTokens = Math.floor(maxTokens * 0.8)
const budgetTokens = Math.max(
Math.min(this.options.modelMaxThinkingTokens ?? maxBudgetTokens, maxBudgetTokens),
1024,
)
thinking = { type: "enabled", budget_tokens: budgetTokens }
}
return { id, info, temperature, maxTokens, thinking }
}
return { id: anthropicDefaultModelId, info: anthropicModels[anthropicDefaultModelId] }
const id = anthropicDefaultModelId
const info: ModelInfo = anthropicModels[id]
const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192
return { id, info, temperature, maxTokens, thinking }
}
async completePrompt(prompt: string): Promise<string> {
try {
const response = await this.client.messages.create({
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens || 8192,
temperature: this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE,
messages: [{ role: "user", content: prompt }],
stream: false,
})
async completePrompt(prompt: string) {
let { id: modelId, temperature, maxTokens, thinking } = this.getModel()
const content = response.content[0]
const message = await this.client.messages.create({
model: modelId,
max_tokens: maxTokens,
temperature,
thinking,
messages: [{ role: "user", content: prompt }],
stream: false,
})
if (content.type === "text") {
return content.text
}
return ""
} catch (error) {
if (error instanceof Error) {
throw new Error(`Anthropic completion error: ${error.message}`)
}
throw error
}
const content = message.content.find(({ type }) => type === "text")
return content?.type === "text" ? content.text : ""
}
}

View file

@ -108,12 +108,19 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
topP = 0.95
}
const maxTokens = this.options.modelMaxTokens || modelInfo.maxTokens
let temperature = this.options.modelTemperature ?? defaultTemperature
let thinking: BetaThinkingConfigParam | undefined = undefined
if (modelInfo.thinking) {
const maxTokens = modelInfo.maxTokens || 8192
const budgetTokens = this.options.anthropicThinking ?? Math.max(maxTokens * 0.8, 1024)
// Clamp the thinking budget to be at most 80% of max tokens and at
// least 1024 tokens.
const maxBudgetTokens = Math.floor((maxTokens || 8192) * 0.8)
const budgetTokens = Math.max(
Math.min(this.options.modelMaxThinkingTokens ?? maxBudgetTokens, maxBudgetTokens),
1024,
)
thinking = { type: "enabled", budget_tokens: budgetTokens }
temperature = 1.0
}
@ -271,7 +278,7 @@ export async function getOpenRouterModels() {
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
modelInfo.maxTokens = 16384
modelInfo.maxTokens = 64_000
break
case rawModel.id.startsWith("anthropic/claude-3.5-sonnet-20240620"):
modelInfo.supportsPromptCache = true

View file

@ -1,9 +1,97 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
import { ApiHandler, SingleCompletionHandler } from "../"
import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta"
import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api"
import { ApiStream } from "../transform/stream"
// Types for Vertex SDK
/**
* Vertex API has specific limitations for prompt caching:
* 1. Maximum of 4 blocks can have cache_control
* 2. Only text blocks can be cached (images and other content types cannot)
* 3. Cache control can only be applied to user messages, not assistant messages
*
* Our caching strategy:
* - Cache the system prompt (1 block)
* - Cache the last text block of the second-to-last user message (1 block)
* - Cache the last text block of the last user message (1 block)
* This ensures we stay under the 4-block limit while maintaining effective caching
* for the most relevant context.
*/
interface VertexTextBlock {
type: "text"
text: string
cache_control?: { type: "ephemeral" }
}
interface VertexImageBlock {
type: "image"
source: {
type: "base64"
media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"
data: string
}
}
type VertexContentBlock = VertexTextBlock | VertexImageBlock
interface VertexUsage {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
}
interface VertexMessage extends Omit<Anthropic.Messages.MessageParam, "content"> {
content: string | VertexContentBlock[]
}
interface VertexMessageCreateParams {
model: string
max_tokens: number
temperature: number
system: string | VertexTextBlock[]
messages: VertexMessage[]
stream: boolean
}
interface VertexMessageResponse {
content: Array<{ type: "text"; text: string }>
}
interface VertexMessageStreamEvent {
type: "message_start" | "message_delta" | "content_block_start" | "content_block_delta"
message?: {
usage: VertexUsage
}
usage?: {
output_tokens: number
}
content_block?:
| {
type: "text"
text: string
}
| {
type: "thinking"
thinking: string
}
index?: number
delta?:
| {
type: "text_delta"
text: string
}
| {
type: "thinking_delta"
thinking: string
}
}
// https://docs.anthropic.com/en/api/claude-on-vertex-ai
export class VertexHandler implements ApiHandler, SingleCompletionHandler {
private options: ApiHandlerOptions
@ -18,37 +106,122 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler {
})
}
private formatMessageForCache(message: Anthropic.Messages.MessageParam, shouldCache: boolean): VertexMessage {
// Assistant messages are kept as-is since they can't be cached
if (message.role === "assistant") {
return message as VertexMessage
}
// For string content, we convert to array format with optional cache control
if (typeof message.content === "string") {
return {
...message,
content: [
{
type: "text" as const,
text: message.content,
// For string content, we only have one block so it's always the last
...(shouldCache && { cache_control: { type: "ephemeral" } }),
},
],
}
}
// For array content, find the last text block index once before mapping
const lastTextBlockIndex = message.content.reduce(
(lastIndex, content, index) => (content.type === "text" ? index : lastIndex),
-1,
)
// Then use this pre-calculated index in the map function
return {
...message,
content: message.content.map((content, contentIndex) => {
// Images and other non-text content are passed through unchanged
if (content.type === "image") {
return content as VertexImageBlock
}
// Check if this is the last text block using our pre-calculated index
const isLastTextBlock = contentIndex === lastTextBlockIndex
return {
type: "text" as const,
text: (content as { text: string }).text,
...(shouldCache && isLastTextBlock && { cache_control: { type: "ephemeral" } }),
}
}),
}
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const stream = await this.client.messages.create({
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens || 8192,
temperature: this.options.modelTemperature ?? 0,
system: systemPrompt,
messages,
const model = this.getModel()
let { id, info, temperature, maxTokens, thinking } = model
const useCache = model.info.supportsPromptCache
// Find indices of user messages that we want to cache
// We only cache the last two user messages to stay within the 4-block limit
// (1 block for system + 1 block each for last two user messages = 3 total)
const userMsgIndices = useCache
? messages.reduce((acc, msg, i) => (msg.role === "user" ? [...acc, i] : acc), [] as number[])
: []
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
// Create the stream with appropriate caching configuration
const params = {
model: id,
max_tokens: maxTokens,
temperature,
thinking,
// Cache the system prompt if caching is enabled
system: useCache
? [
{
text: systemPrompt,
type: "text" as const,
cache_control: { type: "ephemeral" },
},
]
: systemPrompt,
messages: messages.map((message, index) => {
// Only cache the last two user messages
const shouldCache = useCache && (index === lastUserMsgIndex || index === secondLastMsgUserIndex)
return this.formatMessageForCache(message, shouldCache)
}),
stream: true,
})
}
const stream = (await this.client.messages.create(
params as Anthropic.Messages.MessageCreateParamsStreaming,
)) as unknown as AnthropicStream<VertexMessageStreamEvent>
// Process the stream chunks
for await (const chunk of stream) {
switch (chunk.type) {
case "message_start":
const usage = chunk.message.usage
case "message_start": {
const usage = chunk.message!.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
}
break
case "message_delta":
}
case "message_delta": {
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
outputTokens: chunk.usage!.output_tokens || 0,
}
break
case "content_block_start":
switch (chunk.content_block.type) {
case "text":
if (chunk.index > 0) {
}
case "content_block_start": {
switch (chunk.content_block!.type) {
case "text": {
if (chunk.index! > 0) {
yield {
type: "text",
text: "\n",
@ -56,43 +229,124 @@ export class VertexHandler implements ApiHandler, SingleCompletionHandler {
}
yield {
type: "text",
text: chunk.content_block.text,
text: chunk.content_block!.text,
}
break
}
case "thinking": {
if (chunk.index! > 0) {
yield {
type: "reasoning",
text: "\n",
}
}
yield {
type: "reasoning",
text: (chunk.content_block as any).thinking,
}
break
}
}
break
case "content_block_delta":
switch (chunk.delta.type) {
case "text_delta":
}
case "content_block_delta": {
switch (chunk.delta!.type) {
case "text_delta": {
yield {
type: "text",
text: chunk.delta.text,
text: chunk.delta!.text,
}
break
}
case "thinking_delta": {
yield {
type: "reasoning",
text: (chunk.delta as any).thinking,
}
break
}
}
break
}
}
}
}
getModel(): { id: VertexModelId; info: ModelInfo } {
getModel(): {
id: VertexModelId
info: ModelInfo
temperature: number
maxTokens: number
thinking?: BetaThinkingConfigParam
} {
const modelId = this.options.apiModelId
let temperature = this.options.modelTemperature ?? 0
let thinking: BetaThinkingConfigParam | undefined = undefined
if (modelId && modelId in vertexModels) {
const id = modelId as VertexModelId
return { id, info: vertexModels[id] }
const info: ModelInfo = vertexModels[id]
// The `:thinking` variant is a virtual identifier for thinking-enabled models
// Similar to how it's handled in the Anthropic provider
let actualId = id
if (id.endsWith(":thinking")) {
actualId = id.replace(":thinking", "") as VertexModelId
}
const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192
if (info.thinking) {
temperature = 1.0 // Thinking requires temperature 1.0
const maxBudgetTokens = Math.floor(maxTokens * 0.8)
const budgetTokens = Math.max(
Math.min(this.options.modelMaxThinkingTokens ?? maxBudgetTokens, maxBudgetTokens),
1024,
)
thinking = { type: "enabled", budget_tokens: budgetTokens }
}
return { id: actualId, info, temperature, maxTokens, thinking }
}
return { id: vertexDefaultModelId, info: vertexModels[vertexDefaultModelId] }
const id = vertexDefaultModelId
const info = vertexModels[id]
const maxTokens = this.options.modelMaxTokens || info.maxTokens || 8192
return { id, info, temperature, maxTokens, thinking }
}
async completePrompt(prompt: string): Promise<string> {
try {
const response = await this.client.messages.create({
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens || 8192,
temperature: this.options.modelTemperature ?? 0,
messages: [{ role: "user", content: prompt }],
let { id, info, temperature, maxTokens, thinking } = this.getModel()
const useCache = info.supportsPromptCache
const params = {
model: id,
max_tokens: maxTokens,
temperature,
thinking,
system: "", // No system prompt needed for single completions
messages: [
{
role: "user",
content: useCache
? [
{
type: "text" as const,
text: prompt,
cache_control: { type: "ephemeral" },
},
]
: prompt,
},
],
stream: false,
})
}
const response = (await this.client.messages.create(
params as Anthropic.Messages.MessageCreateParamsNonStreaming,
)) as unknown as VertexMessageResponse
const content = response.content[0]
if (content.type === "text") {

View file

@ -93,6 +93,7 @@ export class Cline {
// a flag that indicated if this Cline instance is paused (waiting for provider to resume it after subtask completion)
private isPaused: boolean = false
private pausedModeSlug: string = defaultModeSlug
readonly apiConfiguration: ApiConfiguration
api: ApiHandler
private terminalManager: TerminalManager
private urlContentFetcher: UrlContentFetcher
@ -120,7 +121,7 @@ export class Cline {
isInitialized = false
// checkpoints
checkpointsEnabled: boolean = false
enableCheckpoints: boolean = false
private checkpointService?: CheckpointService
// streaming
@ -155,6 +156,7 @@ export class Cline {
this.taskId = crypto.randomUUID()
this.taskNumber = -1
this.apiConfiguration = apiConfiguration
this.api = buildApiHandler(apiConfiguration)
this.terminalManager = new TerminalManager()
this.urlContentFetcher = new UrlContentFetcher(provider.context)
@ -164,7 +166,7 @@ export class Cline {
this.fuzzyMatchThreshold = fuzzyMatchThreshold ?? 1.0
this.providerRef = new WeakRef(provider)
this.diffViewProvider = new DiffViewProvider(cwd)
this.checkpointsEnabled = enableCheckpoints ?? false
this.enableCheckpoints = enableCheckpoints ?? false
if (historyItem) {
this.taskId = historyItem.id
@ -1018,13 +1020,21 @@ export class Cline {
cacheWrites = 0,
cacheReads = 0,
}: ClineApiReqInfo = JSON.parse(previousRequest)
const totalTokens = tokensIn + tokensOut + cacheWrites + cacheReads
const trimmedMessages = truncateConversationIfNeeded(
this.apiConversationHistory,
const modelInfo = this.api.getModel().info
const maxTokens = modelInfo.thinking
? this.apiConfiguration.modelMaxTokens || modelInfo.maxTokens
: modelInfo.maxTokens
const contextWindow = modelInfo.contextWindow
const trimmedMessages = truncateConversationIfNeeded({
messages: this.apiConversationHistory,
totalTokens,
this.api.getModel().info,
)
maxTokens,
contextWindow,
})
if (trimmedMessages !== this.apiConversationHistory) {
await this.overwriteApiConversationHistory(trimmedMessages)
@ -3436,7 +3446,7 @@ export class Cline {
// Checkpoints
private async getCheckpointService() {
if (!this.checkpointsEnabled) {
if (!this.enableCheckpoints) {
throw new Error("Checkpoints are disabled")
}
@ -3477,7 +3487,7 @@ export class Cline {
commitHash: string
mode: "full" | "checkpoint"
}) {
if (!this.checkpointsEnabled) {
if (!this.enableCheckpoints) {
return
}
@ -3516,12 +3526,12 @@ export class Cline {
)
} catch (err) {
this.providerRef.deref()?.log("[checkpointDiff] disabling checkpoints for this task")
this.checkpointsEnabled = false
this.enableCheckpoints = false
}
}
public async checkpointSave({ isFirst }: { isFirst: boolean }) {
if (!this.checkpointsEnabled) {
if (!this.enableCheckpoints) {
return
}
@ -3542,7 +3552,7 @@ export class Cline {
}
} catch (err) {
this.providerRef.deref()?.log("[checkpointSave] disabling checkpoints for this task")
this.checkpointsEnabled = false
this.enableCheckpoints = false
}
}
@ -3555,7 +3565,7 @@ export class Cline {
commitHash: string
mode: "preview" | "restore"
}) {
if (!this.checkpointsEnabled) {
if (!this.enableCheckpoints) {
return
}
@ -3610,7 +3620,7 @@ export class Cline {
this.providerRef.deref()?.cancelTask()
} catch (err) {
this.providerRef.deref()?.log("[checkpointRestore] disabling checkpoints for this task")
this.checkpointsEnabled = false
this.enableCheckpoints = false
}
}
}

View file

@ -0,0 +1,172 @@
import { SYSTEM_PROMPT } from "../system"
import { defaultModeSlug, modes } from "../../../shared/modes"
import * as vscode from "vscode"
import * as fs from "fs/promises"
// Mock the fs/promises module
jest.mock("fs/promises", () => ({
readFile: jest.fn(),
mkdir: jest.fn().mockResolvedValue(undefined),
access: jest.fn().mockResolvedValue(undefined),
}))
// Get the mocked fs module
const mockedFs = fs as jest.Mocked<typeof fs>
// Mock the fileExistsAtPath function
jest.mock("../../../utils/fs", () => ({
fileExistsAtPath: jest.fn().mockResolvedValue(true),
createDirectoriesForFile: jest.fn().mockResolvedValue([]),
}))
// Create a mock ExtensionContext with relative paths instead of absolute paths
const mockContext = {
extensionPath: "mock/extension/path",
globalStoragePath: "mock/storage/path",
storagePath: "mock/storage/path",
logPath: "mock/log/path",
subscriptions: [],
workspaceState: {
get: () => undefined,
update: () => Promise.resolve(),
},
globalState: {
get: () => undefined,
update: () => Promise.resolve(),
setKeysForSync: () => {},
},
extensionUri: { fsPath: "mock/extension/path" },
globalStorageUri: { fsPath: "mock/settings/path" },
asAbsolutePath: (relativePath: string) => `mock/extension/path/${relativePath}`,
extension: {
packageJSON: {
version: "1.0.0",
},
},
} as unknown as vscode.ExtensionContext
describe("File-Based Custom System Prompt", () => {
const experiments = {}
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks()
// Default behavior: file doesn't exist
mockedFs.readFile.mockRejectedValue({ code: "ENOENT" })
})
it("should use default generation when no file-based system prompt is found", async () => {
const customModePrompts = {
[defaultModeSlug]: {
roleDefinition: "Test role definition",
},
}
const prompt = await SYSTEM_PROMPT(
mockContext,
"test/path", // Using a relative path without leading slash
false,
undefined,
undefined,
undefined,
defaultModeSlug,
customModePrompts,
undefined,
undefined,
undefined,
undefined,
experiments,
true,
)
// Should contain default sections
expect(prompt).toContain("TOOL USE")
expect(prompt).toContain("CAPABILITIES")
expect(prompt).toContain("MODES")
expect(prompt).toContain("Test role definition")
})
it("should use file-based custom system prompt when available", async () => {
// Mock the readFile to return content from a file
const fileCustomSystemPrompt = "Custom system prompt from file"
// When called with utf-8 encoding, return a string
mockedFs.readFile.mockImplementation((filePath, options) => {
if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") {
return Promise.resolve(fileCustomSystemPrompt)
}
return Promise.reject({ code: "ENOENT" })
})
const prompt = await SYSTEM_PROMPT(
mockContext,
"test/path", // Using a relative path without leading slash
false,
undefined,
undefined,
undefined,
defaultModeSlug,
undefined,
undefined,
undefined,
undefined,
undefined,
experiments,
true,
)
// Should contain role definition and file-based system prompt
expect(prompt).toContain(modes[0].roleDefinition)
expect(prompt).toContain(fileCustomSystemPrompt)
// Should not contain any of the default sections
expect(prompt).not.toContain("TOOL USE")
expect(prompt).not.toContain("CAPABILITIES")
expect(prompt).not.toContain("MODES")
})
it("should combine file-based system prompt with role definition and custom instructions", async () => {
// Mock the readFile to return content from a file
const fileCustomSystemPrompt = "Custom system prompt from file"
mockedFs.readFile.mockImplementation((filePath, options) => {
if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") {
return Promise.resolve(fileCustomSystemPrompt)
}
return Promise.reject({ code: "ENOENT" })
})
// Define custom role definition
const customRoleDefinition = "Custom role definition"
const customModePrompts = {
[defaultModeSlug]: {
roleDefinition: customRoleDefinition,
},
}
const prompt = await SYSTEM_PROMPT(
mockContext,
"test/path", // Using a relative path without leading slash
false,
undefined,
undefined,
undefined,
defaultModeSlug,
customModePrompts,
undefined,
undefined,
undefined,
undefined,
experiments,
true,
)
// Should contain custom role definition and file-based system prompt
expect(prompt).toContain(customRoleDefinition)
expect(prompt).toContain(fileCustomSystemPrompt)
// Should not contain any of the default sections
expect(prompt).not.toContain("TOOL USE")
expect(prompt).not.toContain("CAPABILITIES")
expect(prompt).not.toContain("MODES")
})
})

View file

@ -0,0 +1,60 @@
import fs from "fs/promises"
import path from "path"
import { Mode } from "../../../shared/modes"
import { fileExistsAtPath } from "../../../utils/fs"
/**
* Safely reads a file, returning an empty string if the file doesn't exist
*/
async function safeReadFile(filePath: string): Promise<string> {
try {
const content = await fs.readFile(filePath, "utf-8")
// When reading with "utf-8" encoding, content should be a string
return content.trim()
} catch (err) {
const errorCode = (err as NodeJS.ErrnoException).code
if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) {
throw err
}
return ""
}
}
/**
* Get the path to a system prompt file for a specific mode
*/
export function getSystemPromptFilePath(cwd: string, mode: Mode): string {
return path.join(cwd, ".roo", `system-prompt-${mode}`)
}
/**
* Loads custom system prompt from a file at .roo/system-prompt-[mode slug]
* If the file doesn't exist, returns an empty string
*/
export async function loadSystemPromptFile(cwd: string, mode: Mode): Promise<string> {
const filePath = getSystemPromptFilePath(cwd, mode)
return safeReadFile(filePath)
}
/**
* Ensures the .roo directory exists, creating it if necessary
*/
export async function ensureRooDirectory(cwd: string): Promise<void> {
const rooDir = path.join(cwd, ".roo")
// Check if directory already exists
if (await fileExistsAtPath(rooDir)) {
return
}
// Create the directory
try {
await fs.mkdir(rooDir, { recursive: true })
} catch (err) {
// If directory already exists (race condition), ignore the error
const errorCode = (err as NodeJS.ErrnoException).code
if (errorCode !== "EEXIST") {
throw err
}
}
}

View file

@ -23,6 +23,7 @@ import {
getModesSection,
addCustomInstructions,
} from "./sections"
import { loadSystemPromptFile } from "./sections/custom-system-prompt"
import fs from "fs/promises"
import path from "path"
@ -119,11 +120,25 @@ export const SYSTEM_PROMPT = async (
return undefined
}
// Try to load custom system prompt from file
const fileCustomSystemPrompt = await loadSystemPromptFile(cwd, mode)
// Check if it's a custom mode
const promptComponent = getPromptComponent(customModePrompts?.[mode])
// Get full mode config from custom modes or fall back to built-in modes
const currentMode = getModeBySlug(mode, customModes) || modes.find((m) => m.slug === mode) || modes[0]
// If a file-based custom system prompt exists, use it
if (fileCustomSystemPrompt) {
const roleDefinition = promptComponent?.roleDefinition || currentMode.roleDefinition
return `${roleDefinition}
${fileCustomSystemPrompt}
${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}`
}
// If diff is disabled, don't pass the diffStrategy
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined

View file

@ -119,11 +119,21 @@ describe("getMaxTokens", () => {
// Max tokens = 100000 - 50000 = 50000
// Below max tokens - no truncation
const result1 = truncateConversationIfNeeded(messages, 49999, modelInfo)
const result1 = truncateConversationIfNeeded({
messages,
totalTokens: 49999,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
})
expect(result1).toEqual(messages)
// Above max tokens - truncate
const result2 = truncateConversationIfNeeded(messages, 50001, modelInfo)
const result2 = truncateConversationIfNeeded({
messages,
totalTokens: 50001,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
})
expect(result2).not.toEqual(messages)
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
})
@ -133,11 +143,21 @@ describe("getMaxTokens", () => {
// Max tokens = 100000 - (100000 * 0.2) = 80000
// Below max tokens - no truncation
const result1 = truncateConversationIfNeeded(messages, 79999, modelInfo)
const result1 = truncateConversationIfNeeded({
messages,
totalTokens: 79999,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
})
expect(result1).toEqual(messages)
// Above max tokens - truncate
const result2 = truncateConversationIfNeeded(messages, 80001, modelInfo)
const result2 = truncateConversationIfNeeded({
messages,
totalTokens: 80001,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
})
expect(result2).not.toEqual(messages)
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
})
@ -147,11 +167,21 @@ describe("getMaxTokens", () => {
// Max tokens = 50000 - 10000 = 40000
// Below max tokens - no truncation
const result1 = truncateConversationIfNeeded(messages, 39999, modelInfo)
const result1 = truncateConversationIfNeeded({
messages,
totalTokens: 39999,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
})
expect(result1).toEqual(messages)
// Above max tokens - truncate
const result2 = truncateConversationIfNeeded(messages, 40001, modelInfo)
const result2 = truncateConversationIfNeeded({
messages,
totalTokens: 40001,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
})
expect(result2).not.toEqual(messages)
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
})
@ -161,11 +191,21 @@ describe("getMaxTokens", () => {
// Max tokens = 200000 - 30000 = 170000
// Below max tokens - no truncation
const result1 = truncateConversationIfNeeded(messages, 169999, modelInfo)
const result1 = truncateConversationIfNeeded({
messages,
totalTokens: 169999,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
})
expect(result1).toEqual(messages)
// Above max tokens - truncate
const result2 = truncateConversationIfNeeded(messages, 170001, modelInfo)
const result2 = truncateConversationIfNeeded({
messages,
totalTokens: 170001,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
})
expect(result2).not.toEqual(messages)
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
})
@ -194,7 +234,12 @@ describe("truncateConversationIfNeeded", () => {
const maxTokens = 100000 - 30000 // 70000
const totalTokens = 69999 // Below threshold
const result = truncateConversationIfNeeded(messages, totalTokens, modelInfo)
const result = truncateConversationIfNeeded({
messages,
totalTokens,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
})
expect(result).toEqual(messages) // No truncation occurs
})
@ -207,7 +252,12 @@ describe("truncateConversationIfNeeded", () => {
// With 4 messages after the first, 0.5 fraction means remove 2 messages
const expectedResult = [messages[0], messages[3], messages[4]]
const result = truncateConversationIfNeeded(messages, totalTokens, modelInfo)
const result = truncateConversationIfNeeded({
messages,
totalTokens,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
})
expect(result).toEqual(expectedResult)
})
@ -218,14 +268,38 @@ describe("truncateConversationIfNeeded", () => {
// Test below threshold
const belowThreshold = 69999
expect(truncateConversationIfNeeded(messages, belowThreshold, modelInfo1)).toEqual(
truncateConversationIfNeeded(messages, belowThreshold, modelInfo2),
expect(
truncateConversationIfNeeded({
messages,
totalTokens: belowThreshold,
contextWindow: modelInfo1.contextWindow,
maxTokens: modelInfo1.maxTokens,
}),
).toEqual(
truncateConversationIfNeeded({
messages,
totalTokens: belowThreshold,
contextWindow: modelInfo2.contextWindow,
maxTokens: modelInfo2.maxTokens,
}),
)
// Test above threshold
const aboveThreshold = 70001
expect(truncateConversationIfNeeded(messages, aboveThreshold, modelInfo1)).toEqual(
truncateConversationIfNeeded(messages, aboveThreshold, modelInfo2),
expect(
truncateConversationIfNeeded({
messages,
totalTokens: aboveThreshold,
contextWindow: modelInfo1.contextWindow,
maxTokens: modelInfo1.maxTokens,
}),
).toEqual(
truncateConversationIfNeeded({
messages,
totalTokens: aboveThreshold,
contextWindow: modelInfo2.contextWindow,
maxTokens: modelInfo2.maxTokens,
}),
)
})
})

View file

@ -1,7 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ModelInfo } from "../../shared/api"
/**
* Truncates a conversation by removing a fraction of the messages.
*
@ -26,28 +24,29 @@ export function truncateConversation(
}
/**
* Conditionally truncates the conversation messages if the total token count exceeds the model's limit.
* Conditionally truncates the conversation messages if the total token count
* exceeds the model's limit.
*
* @param {Anthropic.Messages.MessageParam[]} messages - The conversation messages.
* @param {number} totalTokens - The total number of tokens in the conversation.
* @param {ModelInfo} modelInfo - Model metadata including context window size.
* @param {number} contextWindow - The context window size.
* @param {number} maxTokens - The maximum number of tokens allowed.
* @returns {Anthropic.Messages.MessageParam[]} The original or truncated conversation messages.
*/
export function truncateConversationIfNeeded(
messages: Anthropic.Messages.MessageParam[],
totalTokens: number,
modelInfo: ModelInfo,
): Anthropic.Messages.MessageParam[] {
return totalTokens < getMaxTokens(modelInfo) ? messages : truncateConversation(messages, 0.5)
type TruncateOptions = {
messages: Anthropic.Messages.MessageParam[]
totalTokens: number
contextWindow: number
maxTokens?: number
}
/**
* Calculates the maximum allowed tokens
*
* @param {ModelInfo} modelInfo - The model information containing the context window size.
* @returns {number} The maximum number of tokens allowed
*/
function getMaxTokens(modelInfo: ModelInfo): number {
// The buffer needs to be at least as large as `modelInfo.maxTokens`, or 20% of the context window if for some reason it's not set.
return modelInfo.contextWindow - (modelInfo.maxTokens || modelInfo.contextWindow * 0.2)
export function truncateConversationIfNeeded({
messages,
totalTokens,
contextWindow,
maxTokens,
}: TruncateOptions): Anthropic.Messages.MessageParam[] {
const allowedTokens = contextWindow - (maxTokens || contextWindow * 0.2)
return totalTokens < allowedTokens ? messages : truncateConversation(messages, 0.5)
}

View file

@ -64,7 +64,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private clineStack: Cline[] = []
private workspaceTracker?: WorkspaceTracker
protected mcpHub?: McpHub // Change from private to protected
private latestAnnouncementId = "jan-21-2025-custom-modes" // update to some unique identifier when we add a new announcement
private latestAnnouncementId = "feb-27-2025-automatic-checkpoints" // update to some unique identifier when we add a new announcement
configManager: ConfigManager
customModesManager: CustomModesManager
private lastTaskNumber = -1
@ -402,7 +402,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration,
customModePrompts,
diffEnabled,
checkpointsEnabled,
enableCheckpoints,
fuzzyMatchThreshold,
mode,
customInstructions: globalInstructions,
@ -417,7 +417,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration,
customInstructions: effectiveInstructions,
enableDiff: diffEnabled,
enableCheckpoints: checkpointsEnabled,
enableCheckpoints,
fuzzyMatchThreshold,
task,
images,
@ -433,7 +433,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration,
customModePrompts,
diffEnabled,
checkpointsEnabled,
enableCheckpoints,
fuzzyMatchThreshold,
mode,
customInstructions: globalInstructions,
@ -448,7 +448,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
apiConfiguration,
customInstructions: effectiveInstructions,
enableDiff: diffEnabled,
enableCheckpoints: checkpointsEnabled,
enableCheckpoints,
fuzzyMatchThreshold,
historyItem,
experiments,
@ -1110,9 +1110,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("diffEnabled", diffEnabled)
await this.postStateToWebview()
break
case "checkpointsEnabled":
const checkpointsEnabled = message.bool ?? false
await this.updateGlobalState("checkpointsEnabled", checkpointsEnabled)
case "enableCheckpoints":
const enableCheckpoints = message.bool ?? true
await this.updateGlobalState("enableCheckpoints", enableCheckpoints)
await this.postStateToWebview()
break
case "browserViewportSize":
@ -1751,7 +1751,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
anthropicThinking,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
@ -1771,6 +1770,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
requestyModelId,
requestyModelInfo,
modelTemperature,
modelMaxTokens,
modelMaxThinkingTokens,
} = apiConfiguration
await Promise.all([
this.updateGlobalState("apiProvider", apiProvider),
@ -1799,7 +1800,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.updateGlobalState("lmStudioModelId", lmStudioModelId),
this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl),
this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl),
this.updateGlobalState("anthropicThinking", anthropicThinking),
this.storeSecret("geminiApiKey", geminiApiKey),
this.storeSecret("openAiNativeApiKey", openAiNativeApiKey),
this.storeSecret("deepSeekApiKey", deepSeekApiKey),
@ -1819,6 +1819,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.updateGlobalState("requestyModelId", requestyModelId),
this.updateGlobalState("requestyModelInfo", requestyModelInfo),
this.updateGlobalState("modelTemperature", modelTemperature),
this.updateGlobalState("modelMaxTokens", modelMaxTokens),
this.updateGlobalState("anthropicThinking", modelMaxThinkingTokens),
])
if (this.getCurrentCline()) {
this.getCurrentCline()!.api = buildApiHandler(apiConfiguration)
@ -2023,12 +2025,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.deleteTaskFromState(id)
// check if checkpoints are enabled
const { checkpointsEnabled } = await this.getState()
const { enableCheckpoints } = await this.getState()
// get the base directory of the project
const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
// delete checkpoints branch from project git repo
if (checkpointsEnabled && baseDir) {
if (enableCheckpoints && baseDir) {
const branchSummary = await simpleGit(baseDir)
.branch(["-D", `roo-code-checkpoints-${id}`])
.catch(() => undefined)
@ -2077,7 +2080,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowModeSwitch,
soundEnabled,
diffEnabled,
checkpointsEnabled,
enableCheckpoints,
taskHistory,
soundVolume,
browserViewportSize,
@ -2126,7 +2129,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
soundEnabled: soundEnabled ?? false,
diffEnabled: diffEnabled ?? true,
checkpointsEnabled: checkpointsEnabled ?? false,
enableCheckpoints: enableCheckpoints ?? true,
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
allowedCommands,
soundVolume: soundVolume ?? 0.5,
@ -2230,7 +2233,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
anthropicThinking,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
@ -2254,7 +2256,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
allowedCommands,
soundEnabled,
diffEnabled,
checkpointsEnabled,
enableCheckpoints,
soundVolume,
browserViewportSize,
fuzzyMatchThreshold,
@ -2285,6 +2287,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
requestyModelId,
requestyModelInfo,
modelTemperature,
modelMaxTokens,
modelMaxThinkingTokens,
maxOpenTabsContext,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
@ -2313,7 +2317,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("lmStudioModelId") as Promise<string | undefined>,
this.getGlobalState("lmStudioBaseUrl") as Promise<string | undefined>,
this.getGlobalState("anthropicBaseUrl") as Promise<string | undefined>,
this.getGlobalState("anthropicThinking") as Promise<number | undefined>,
this.getSecret("geminiApiKey") as Promise<string | undefined>,
this.getSecret("openAiNativeApiKey") as Promise<string | undefined>,
this.getSecret("deepSeekApiKey") as Promise<string | undefined>,
@ -2337,7 +2340,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
this.getGlobalState("diffEnabled") as Promise<boolean | undefined>,
this.getGlobalState("checkpointsEnabled") as Promise<boolean | undefined>,
this.getGlobalState("enableCheckpoints") as Promise<boolean | undefined>,
this.getGlobalState("soundVolume") as Promise<number | undefined>,
this.getGlobalState("browserViewportSize") as Promise<string | undefined>,
this.getGlobalState("fuzzyMatchThreshold") as Promise<number | undefined>,
@ -2368,6 +2371,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("requestyModelId") as Promise<string | undefined>,
this.getGlobalState("requestyModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("modelTemperature") as Promise<number | undefined>,
this.getGlobalState("modelMaxTokens") as Promise<number | undefined>,
this.getGlobalState("anthropicThinking") as Promise<number | undefined>,
this.getGlobalState("maxOpenTabsContext") as Promise<number | undefined>,
])
@ -2413,7 +2418,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
anthropicThinking,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
@ -2433,6 +2437,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
requestyModelId,
requestyModelInfo,
modelTemperature,
modelMaxTokens,
modelMaxThinkingTokens,
},
lastShownAnnouncementId,
customInstructions,
@ -2446,7 +2452,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
allowedCommands,
soundEnabled: soundEnabled ?? false,
diffEnabled: diffEnabled ?? true,
checkpointsEnabled: checkpointsEnabled ?? false,
enableCheckpoints: enableCheckpoints ?? true,
soundVolume,
browserViewportSize: browserViewportSize ?? "900x600",
screenshotQuality: screenshotQuality ?? 75,

View file

@ -373,7 +373,7 @@ describe("ClineProvider", () => {
uriScheme: "vscode",
soundEnabled: false,
diffEnabled: false,
checkpointsEnabled: false,
enableCheckpoints: false,
writeDelayMs: 1000,
browserViewportSize: "900x600",
fuzzyMatchThreshold: 1.0,
@ -712,7 +712,7 @@ describe("ClineProvider", () => {
},
mode: "code",
diffEnabled: true,
checkpointsEnabled: false,
enableCheckpoints: false,
fuzzyMatchThreshold: 1.0,
experiments: experimentDefault,
} as any)

View file

@ -0,0 +1,51 @@
// npx jest src/services/ripgrep/__tests__/index.test.ts
import { describe, expect, it } from "@jest/globals"
import { truncateLine } from "../index"
describe("Ripgrep line truncation", () => {
// The default MAX_LINE_LENGTH is 500 in the implementation
const MAX_LINE_LENGTH = 500
it("should truncate lines longer than MAX_LINE_LENGTH", () => {
const longLine = "a".repeat(600) // Line longer than MAX_LINE_LENGTH
const truncated = truncateLine(longLine)
expect(truncated).toContain("[truncated...]")
expect(truncated.length).toBeLessThan(longLine.length)
expect(truncated.length).toEqual(MAX_LINE_LENGTH + " [truncated...]".length)
})
it("should not truncate lines shorter than MAX_LINE_LENGTH", () => {
const shortLine = "Short line of text"
const truncated = truncateLine(shortLine)
expect(truncated).toEqual(shortLine)
expect(truncated).not.toContain("[truncated...]")
})
it("should correctly truncate a line at exactly MAX_LINE_LENGTH characters", () => {
const exactLine = "a".repeat(MAX_LINE_LENGTH)
const exactPlusOne = exactLine + "x"
// Should not truncate when exactly MAX_LINE_LENGTH
expect(truncateLine(exactLine)).toEqual(exactLine)
// Should truncate when exceeding MAX_LINE_LENGTH by even 1 character
expect(truncateLine(exactPlusOne)).toContain("[truncated...]")
})
it("should handle empty lines without errors", () => {
expect(truncateLine("")).toEqual("")
})
it("should allow custom maximum length", () => {
const customLength = 100
const line = "a".repeat(customLength + 50)
const truncated = truncateLine(line, customLength)
expect(truncated.length).toEqual(customLength + " [truncated...]".length)
expect(truncated).toContain("[truncated...]")
})
})

View file

@ -58,7 +58,19 @@ interface SearchResult {
afterContext: string[]
}
// Constants
const MAX_RESULTS = 300
const MAX_LINE_LENGTH = 500
/**
* Truncates a line if it exceeds the maximum length
* @param line The line to truncate
* @param maxLength The maximum allowed length (defaults to MAX_LINE_LENGTH)
* @returns The truncated line, or the original line if it's shorter than maxLength
*/
export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH): string {
return line.length > maxLength ? line.substring(0, maxLength) + " [truncated...]" : line
}
async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
const checkPath = async (pkgFolder: string) => {
@ -140,7 +152,8 @@ export async function regexSearchFiles(
let output: string
try {
output = await execRipgrep(rgPath, args)
} catch {
} catch (error) {
console.error("Error executing ripgrep:", error)
return "No results found"
}
const results: SearchResult[] = []
@ -154,19 +167,28 @@ export async function regexSearchFiles(
if (currentResult) {
results.push(currentResult as SearchResult)
}
// Safety check: truncate extremely long lines to prevent excessive output
const matchText = parsed.data.lines.text
const truncatedMatch = truncateLine(matchText)
currentResult = {
file: parsed.data.path.text,
line: parsed.data.line_number,
column: parsed.data.submatches[0].start,
match: parsed.data.lines.text,
match: truncatedMatch,
beforeContext: [],
afterContext: [],
}
} else if (parsed.type === "context" && currentResult) {
// Apply the same truncation logic to context lines
const contextText = parsed.data.lines.text
const truncatedContext = truncateLine(contextText)
if (parsed.data.line_number < currentResult.line!) {
currentResult.beforeContext!.push(parsed.data.lines.text)
currentResult.beforeContext!.push(truncatedContext)
} else {
currentResult.afterContext!.push(parsed.data.lines.text)
currentResult.afterContext!.push(truncatedContext)
}
}
} catch (error) {

View file

@ -111,7 +111,7 @@ export interface ExtensionState {
soundEnabled?: boolean
soundVolume?: number
diffEnabled?: boolean
checkpointsEnabled: boolean
enableCheckpoints: boolean
browserViewportSize?: string
screenshotQuality?: number
fuzzyMatchThreshold?: number

View file

@ -52,7 +52,7 @@ export interface WebviewMessage {
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "checkpointsEnabled"
| "enableCheckpoints"
| "browserViewportSize"
| "screenshotQuality"
| "openMcpSettings"

View file

@ -32,7 +32,7 @@ describe("checkExistKey", () => {
apiKey: "test-key",
apiProvider: undefined,
anthropicBaseUrl: undefined,
anthropicThinking: undefined,
modelMaxThinkingTokens: undefined,
}
expect(checkExistKey(config)).toBe(true)
})

View file

@ -21,7 +21,6 @@ export interface ApiHandlerOptions {
apiModelId?: string
apiKey?: string // anthropic
anthropicBaseUrl?: string
anthropicThinking?: number
vsCodeLmModelSelector?: vscode.LanguageModelChatSelector
glamaModelId?: string
glamaModelInfo?: ModelInfo
@ -68,6 +67,8 @@ export interface ApiHandlerOptions {
requestyModelId?: string
requestyModelInfo?: ModelInfo
modelTemperature?: number
modelMaxTokens?: number
modelMaxThinkingTokens?: number
}
export type ApiConfiguration = ApiHandlerOptions & {
@ -92,19 +93,13 @@ export interface ModelInfo {
thinking?: boolean
}
export const THINKING_BUDGET = {
step: 1024,
min: 1024,
default: 8 * 1024,
}
// Anthropic
// https://docs.anthropic.com/en/docs/about-claude/models
export type AnthropicModelId = keyof typeof anthropicModels
export const anthropicDefaultModelId: AnthropicModelId = "claude-3-7-sonnet-20250219"
export const anthropicModels = {
"claude-3-7-sonnet-20250219:thinking": {
maxTokens: 16384,
maxTokens: 64_000,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
@ -116,7 +111,7 @@ export const anthropicModels = {
thinking: true,
},
"claude-3-7-sonnet-20250219": {
maxTokens: 16384,
maxTokens: 64_000,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
@ -441,55 +436,80 @@ export const openRouterDefaultModelInfo: ModelInfo = {
export type VertexModelId = keyof typeof vertexModels
export const vertexDefaultModelId: VertexModelId = "claude-3-7-sonnet@20250219"
export const vertexModels = {
"claude-3-7-sonnet@20250219:thinking": {
maxTokens: 64000,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
thinking: true,
},
"claude-3-7-sonnet@20250219": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
thinking: false,
},
"claude-3-5-sonnet-v2@20241022": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
"claude-3-5-sonnet@20240620": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
"claude-3-5-haiku@20241022": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 1.0,
outputPrice: 5.0,
cacheWritesPrice: 1.25,
cacheReadsPrice: 0.1,
},
"claude-3-opus@20240229": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 15.0,
outputPrice: 75.0,
cacheWritesPrice: 18.75,
cacheReadsPrice: 1.5,
},
"claude-3-haiku@20240307": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 0.25,
outputPrice: 1.25,
cacheWritesPrice: 0.3,
cacheReadsPrice: 0.03,
},
} as const satisfies Record<string, ModelInfo>
@ -671,8 +691,16 @@ export const openAiNativeModels = {
inputPrice: 1.1,
outputPrice: 4.4,
},
"gpt-4.5-preview": {
maxTokens: 16_384,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 75,
outputPrice: 150,
},
"gpt-4o": {
maxTokens: 4_096,
maxTokens: 16_384,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,

View file

@ -42,7 +42,6 @@ export type GlobalStateKey =
| "lmStudioModelId"
| "lmStudioBaseUrl"
| "anthropicBaseUrl"
| "anthropicThinking"
| "azureApiVersion"
| "openAiStreamingEnabled"
| "openRouterModelId"
@ -53,7 +52,7 @@ export type GlobalStateKey =
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "checkpointsEnabled"
| "enableCheckpoints"
| "browserViewportSize"
| "screenshotQuality"
| "fuzzyMatchThreshold"
@ -81,5 +80,7 @@ export type GlobalStateKey =
| "requestyModelInfo"
| "unboundModelInfo"
| "modelTemperature"
| "modelMaxTokens"
| "anthropicThinking" // TODO: Rename to `modelMaxThinkingTokens`.
| "mistralCodestralUrl"
| "maxOpenTabsContext"

View file

@ -1,8 +1,5 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { memo } from "react"
// import VSCodeButtonLink from "./VSCodeButtonLink"
// import { getOpenRouterAuthUrl } from "./ApiOptions"
// import { vscode } from "../utils/vscode"
interface AnnouncementProps {
version: string
@ -25,39 +22,42 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
<VSCodeButton
appearance="icon"
onClick={hideAnnouncement}
title="Hide announcement"
style={{ position: "absolute", top: "8px", right: "8px" }}>
<span className="codicon codicon-close"></span>
</VSCodeButton>
<h2 style={{ margin: "0 0 8px" }}>🎉{" "}Introducing Roo Code 3.2</h2>
<h2 style={{ margin: "0 0 8px" }}>🎉{" "}Automatic Checkpoints Now Enabled</h2>
<p style={{ margin: "5px 0px" }}>
Our biggest update yet is here - we're officially changing our name from Roo Cline to Roo Code! After
growing beyond 50,000 installations, we're ready to chart our own course. Our heartfelt thanks to
everyone in the Cline community who helped us reach this milestone.
We're thrilled to announce that our experimental Checkpoints feature is now enabled by default for all
users. This powerful feature automatically tracks your project changes during a task, allowing you to
quickly review or revert to earlier states if needed.
</p>
<h3 style={{ margin: "12px 0 8px" }}>Custom Modes: Celebrating Our New Identity</h3>
<h3 style={{ margin: "12px 0 8px" }}>What's New</h3>
<p style={{ margin: "5px 0px" }}>
To mark this new chapter, we're introducing the power to shape Roo Code into any role you need! Create
specialized personas and create an entire team of agents with deeply customized prompts:
Automatic Checkpoints provide you with:
<ul style={{ margin: "4px 0 6px 20px", padding: 0 }}>
<li>QA Engineers who write thorough test cases and catch edge cases</li>
<li>Product Managers who excel at user stories and feature prioritization</li>
<li>UI/UX Designers who craft beautiful, accessible interfaces</li>
<li>Code Reviewers who ensure quality and maintainability</li>
<li>Peace of mind when making significant changes</li>
<li>Ability to visually inspect changes between steps</li>
<li>Easy rollback if you're not satisfied with certain code modifications</li>
<li>Improved navigation through complex task execution</li>
</ul>
Just click the <span className="codicon codicon-notebook" style={{ fontSize: "10px" }}></span> icon to
get started with Custom Modes!
</p>
<h3 style={{ margin: "12px 0 8px" }}>Join Us for the Next Chapter</h3>
<h3 style={{ margin: "12px 0 8px" }}>Customize Your Experience</h3>
<p style={{ margin: "5px 0px" }}>
We can't wait to see how you'll push Roo Code's potential even further! Share your custom modes and join
the discussion at{" "}
<VSCodeLink href="https://www.reddit.com/r/RooCode" style={{ display: "inline" }}>
reddit.com/r/RooCode
</VSCodeLink>
.
While we recommend keeping this feature enabled, you can disable it if needed.{" "}
<VSCodeLink
href="#"
onClick={(e) => {
e.preventDefault()
window.postMessage({ type: "action", action: "settingsButtonClicked" }, "*")
}}
style={{ display: "inline", padding: "0 2px" }}>
Open Settings
</VSCodeLink>{" "}
and look for the "Enable automatic checkpoints" option in the Advanced Settings section.
</p>
</div>
)

View file

@ -798,6 +798,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
<select
value={mode}
disabled={textAreaDisabled}
title="Select mode for interaction"
onChange={(e) => {
const value = e.target.value
if (value === "prompts-action") {
@ -849,6 +850,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
<select
value={currentApiConfigName || ""}
disabled={textAreaDisabled}
title="Select API configuration"
onChange={(e) => {
const value = e.target.value
if (value === "settings-action") {
@ -915,6 +917,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
role="button"
aria-label="enhance prompt"
data-testid="enhance-prompt-button"
title="Enhance prompt with additional context"
className={`input-icon-button ${
textAreaDisabled ? "disabled" : ""
} codicon codicon-sparkle`}
@ -927,11 +930,13 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
className={`input-icon-button ${
shouldDisableImages ? "disabled" : ""
} codicon codicon-device-camera`}
title="Add images to message"
onClick={() => !shouldDisableImages && onSelectImages()}
style={{ fontSize: 16.5 }}
/>
<span
className={`input-icon-button ${textAreaDisabled ? "disabled" : ""} codicon codicon-send`}
title="Send message"
onClick={() => !textAreaDisabled && onSend()}
style={{ fontSize: 15 }}
/>

View file

@ -1077,7 +1077,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
onClick={() => {
scrollToBottomSmooth()
disableAutoScrollRef.current = false
}}>
}}
title="Scroll to bottom of chat">
<span className="codicon codicon-chevron-down" style={{ fontSize: "18px" }}></span>
</ScrollToBottomButton>
</div>
@ -1101,6 +1102,25 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
flex: secondaryButtonText ? 1 : 2,
marginRight: secondaryButtonText ? "6px" : "0",
}}
title={
primaryButtonText === "Retry"
? "Try the operation again"
: primaryButtonText === "Save"
? "Save the file changes"
: primaryButtonText === "Approve"
? "Approve this action"
: primaryButtonText === "Run Command"
? "Execute this command"
: primaryButtonText === "Start New Task"
? "Begin a new task"
: primaryButtonText === "Resume Task"
? "Continue the current task"
: primaryButtonText === "Proceed Anyways"
? "Continue despite warnings"
: primaryButtonText === "Proceed While Running"
? "Continue while command executes"
: undefined
}
onClick={(e) => handlePrimaryButtonClick(inputValue, selectedImages)}>
{primaryButtonText}
</VSCodeButton>
@ -1113,6 +1133,17 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
flex: isStreaming ? 2 : 1,
marginLeft: isStreaming ? 0 : "6px",
}}
title={
isStreaming
? "Cancel the current operation"
: secondaryButtonText === "Start New Task"
? "Begin a new task"
: secondaryButtonText === "Reject"
? "Reject this action"
: secondaryButtonText === "Terminate"
? "End the current task"
: undefined
}
onClick={(e) => handleSecondaryButtonClick(inputValue, selectedImages)}>
{isStreaming ? "Cancel" : secondaryButtonText}
</VSCodeButton>

View file

@ -183,7 +183,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
${totalCost?.toFixed(4)}
</div>
)}
<VSCodeButton appearance="icon" onClick={onClose} style={{ marginLeft: 6, flexShrink: 0 }}>
<VSCodeButton
appearance="icon"
onClick={onClose}
style={{ marginLeft: 6, flexShrink: 0 }}
title="Close task and start a new one">
<span className="codicon codicon-close"></span>
</VSCodeButton>
</div>
@ -351,13 +355,18 @@ export const highlightMentions = (text?: string, withShadow = true) => {
const TaskActions = ({ item }: { item: HistoryItem | undefined }) => (
<div className="flex flex-row gap-1">
<Button variant="ghost" size="sm" onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
<Button
variant="ghost"
size="sm"
title="Export task history"
onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
<span className="codicon codicon-cloud-download" />
</Button>
{!!item?.size && item.size > 0 && (
<Button
variant="ghost"
size="sm"
title="Delete task from history"
onClick={() => vscode.postMessage({ type: "deleteTaskWithId", text: item.id })}>
<span className="codicon codicon-trash" />
{prettyBytes(item.size)}

View file

@ -88,6 +88,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
const [showConfigMenu, setShowConfigMenu] = useState(false)
const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false)
const [activeSupportTab, setActiveSupportTab] = useState<SupportPromptType>("ENHANCE")
const [isSystemPromptDisclosureOpen, setIsSystemPromptDisclosureOpen] = useState(false)
// Direct update functions
const updateAgentPrompt = useCallback(
@ -971,6 +972,45 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
<span className="codicon codicon-copy"></span>
</VSCodeButton>
</div>
{/* Custom System Prompt Disclosure */}
<div className="mb-3 mt-12">
<button
onClick={() => setIsSystemPromptDisclosureOpen(!isSystemPromptDisclosureOpen)}
className="flex items-center text-xs text-vscode-foreground hover:text-vscode-textLink-foreground focus:outline-none"
aria-expanded={isSystemPromptDisclosureOpen}>
<span
className={`codicon codicon-${isSystemPromptDisclosureOpen ? "chevron-down" : "chevron-right"} mr-1`}></span>
<span>Advanced: Override System Prompt</span>
</button>
{isSystemPromptDisclosureOpen && (
<div className="text-xs text-vscode-descriptionForeground mt-2 ml-5">
You can completely replace the system prompt for this mode (aside from the role
definition and custom instructions) by creating a file at{" "}
<span
className="text-vscode-textLink-foreground cursor-pointer underline"
onClick={() => {
const currentMode = getCurrentMode()
if (!currentMode) return
// Open or create an empty file
vscode.postMessage({
type: "openFile",
text: `./.roo/system-prompt-${currentMode.slug}`,
values: {
create: true,
content: "",
},
})
}}>
.roo/system-prompt-{getCurrentMode()?.slug || "code"}
</span>{" "}
in your workspace. This is a very advanced feature that bypasses built-in safeguards and
consistency checks (especially around tool usage), so be careful!
</div>
)}
</div>
</div>
<div

View file

@ -7,6 +7,7 @@ import * as vscodemodels from "vscode"
import {
ApiConfiguration,
ModelInfo,
ApiProvider,
anthropicDefaultModelId,
anthropicModels,
azureOpenAiDefaultApiVersion,
@ -1380,9 +1381,11 @@ const ApiOptions = ({
/>
</div>
<ThinkingBudget
key={`${selectedProvider}-${selectedModelId}`}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
modelInfo={selectedModelInfo}
provider={selectedProvider as ApiProvider}
/>
<ModelInfoView
selectedModelId={selectedModelId}

View file

@ -52,7 +52,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
alwaysAllowWrite,
alwaysApproveResubmit,
browserViewportSize,
checkpointsEnabled,
enableCheckpoints,
diffEnabled,
experiments,
fuzzyMatchThreshold,
@ -143,7 +143,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
vscode.postMessage({ type: "soundVolume", value: soundVolume })
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
vscode.postMessage({ type: "checkpointsEnabled", bool: checkpointsEnabled })
vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints })
vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
@ -706,6 +706,25 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
</p>
</div>
<div style={{ marginBottom: 15 }}>
<VSCodeCheckbox
checked={enableCheckpoints}
onChange={(e: any) => {
setCachedStateField("enableCheckpoints", e.target.checked)
}}>
<span style={{ fontWeight: "500" }}>Enable automatic checkpoints</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Roo will automatically create checkpoints during task execution, making it
easy to review changes or revert to earlier states.
</p>
</div>
<div style={{ marginBottom: 15 }}>
<VSCodeCheckbox
checked={diffEnabled}
@ -779,28 +798,6 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
</div>
)}
<div style={{ marginBottom: 15 }}>
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
<span style={{ color: "var(--vscode-errorForeground)" }}></span>
<VSCodeCheckbox
checked={checkpointsEnabled}
onChange={(e: any) => {
setCachedStateField("checkpointsEnabled", e.target.checked)
}}>
<span style={{ fontWeight: "500" }}>Enable experimental checkpoints</span>
</VSCodeCheckbox>
</div>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Roo will save a checkpoint whenever a file in the workspace is modified,
added or deleted, letting you easily revert to a previous state.
</p>
</div>
{Object.entries(experimentConfigsMap)
.filter((config) => config[0] !== "DIFF_STRATEGY")
.map((config) => (

View file

@ -1,29 +1,73 @@
import { useEffect, useMemo } from "react"
import { ApiProvider } from "../../../../src/shared/api"
import { Slider } from "@/components/ui"
import { ApiConfiguration, ModelInfo, THINKING_BUDGET } from "../../../../src/shared/api"
import { ApiConfiguration, ModelInfo } from "../../../../src/shared/api"
interface ThinkingBudgetProps {
apiConfiguration: ApiConfiguration
setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
modelInfo?: ModelInfo
provider?: ApiProvider
}
export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, modelInfo }: ThinkingBudgetProps) => {
const budget = apiConfiguration?.anthropicThinking ?? THINKING_BUDGET.default
export const ThinkingBudget = ({
apiConfiguration,
setApiConfigurationField,
modelInfo,
provider,
}: ThinkingBudgetProps) => {
const tokens = apiConfiguration?.modelMaxTokens || modelInfo?.maxTokens || 64_000
const tokensMin = 8192
const tokensMax = modelInfo?.maxTokens || 64_000
return modelInfo && modelInfo.thinking ? (
<div className="flex flex-col gap-1 mt-2">
<div className="font-medium">Thinking Budget</div>
<div className="flex items-center gap-1">
<Slider
min={THINKING_BUDGET.min}
max={(modelInfo.maxTokens ?? THINKING_BUDGET.default) - 1}
step={THINKING_BUDGET.step}
value={[budget]}
onValueChange={(value) => setApiConfigurationField("anthropicThinking", value[0])}
/>
<div className="w-12 text-sm text-center">{budget}</div>
// Get the appropriate thinking tokens based on provider
const thinkingTokens = useMemo(() => {
const value = apiConfiguration?.modelMaxThinkingTokens
return value || Math.min(Math.floor(0.8 * tokens), 8192)
}, [apiConfiguration, tokens])
const thinkingTokensMin = 1024
const thinkingTokensMax = Math.floor(0.8 * tokens)
useEffect(() => {
if (thinkingTokens > thinkingTokensMax) {
setApiConfigurationField("modelMaxThinkingTokens", thinkingTokensMax)
}
}, [thinkingTokens, thinkingTokensMax, setApiConfigurationField])
if (!modelInfo?.thinking) {
return null
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1 mt-2">
<div className="font-medium">Max Tokens</div>
<div className="flex items-center gap-1">
<Slider
min={tokensMin}
max={tokensMax}
step={1024}
value={[tokens]}
onValueChange={([value]) => setApiConfigurationField("modelMaxTokens", value)}
/>
<div className="w-12 text-sm text-center">{tokens}</div>
</div>
</div>
<div className="flex flex-col gap-1 mt-2">
<div className="font-medium">Max Thinking Tokens</div>
<div className="flex items-center gap-1">
<Slider
min={thinkingTokensMin}
max={thinkingTokensMax}
step={1024}
value={[thinkingTokens]}
onValueChange={([value]) => setApiConfigurationField("modelMaxThinkingTokens", value)}
/>
<div className="w-12 text-sm text-center">{thinkingTokens}</div>
</div>
</div>
</div>
) : null
)
}

View file

@ -46,6 +46,16 @@ jest.mock("../TemperatureControl", () => ({
),
}))
// Mock ThinkingBudget component
jest.mock("../ThinkingBudget", () => ({
ThinkingBudget: ({ apiConfiguration, setApiConfigurationField, modelInfo, provider }: any) =>
modelInfo?.thinking ? (
<div data-testid="thinking-budget" data-provider={provider}>
<input data-testid="thinking-tokens" value={apiConfiguration?.modelMaxThinkingTokens} />
</div>
) : null,
}))
describe("ApiOptions", () => {
const renderApiOptions = (props = {}) => {
render(
@ -72,5 +82,45 @@ describe("ApiOptions", () => {
expect(screen.queryByTestId("temperature-control")).not.toBeInTheDocument()
})
//TODO: More test cases needed
describe("thinking functionality", () => {
it("should show ThinkingBudget for Anthropic models that support thinking", () => {
renderApiOptions({
apiConfiguration: {
apiProvider: "anthropic",
apiModelId: "claude-3-7-sonnet-20250219:thinking",
},
})
expect(screen.getByTestId("thinking-budget")).toBeInTheDocument()
expect(screen.getByTestId("thinking-budget")).toHaveAttribute("data-provider", "anthropic")
})
it("should show ThinkingBudget for Vertex models that support thinking", () => {
renderApiOptions({
apiConfiguration: {
apiProvider: "vertex",
apiModelId: "claude-3-7-sonnet@20250219:thinking",
},
})
expect(screen.getByTestId("thinking-budget")).toBeInTheDocument()
expect(screen.getByTestId("thinking-budget")).toHaveAttribute("data-provider", "vertex")
})
it("should not show ThinkingBudget for models that don't support thinking", () => {
renderApiOptions({
apiConfiguration: {
apiProvider: "anthropic",
apiModelId: "claude-3-opus-20240229",
modelInfo: { thinking: false }, // Non-thinking model
},
})
expect(screen.queryByTestId("thinking-budget")).not.toBeInTheDocument()
})
// Note: We don't need to test the actual ThinkingBudget component functionality here
// since we have separate tests for that component. We just need to verify that
// it's included in the ApiOptions component when appropriate.
})
})

View file

@ -0,0 +1,145 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { ThinkingBudget } from "../ThinkingBudget"
import { ApiProvider, ModelInfo } from "../../../../../src/shared/api"
// Mock Slider component
jest.mock("@/components/ui", () => ({
Slider: ({ value, onValueChange, min, max }: any) => (
<input
type="range"
data-testid="slider"
min={min}
max={max}
value={value[0]}
onChange={(e) => onValueChange([parseInt(e.target.value)])}
/>
),
}))
describe("ThinkingBudget", () => {
const mockModelInfo: ModelInfo = {
thinking: true,
maxTokens: 16384,
contextWindow: 200000,
supportsPromptCache: true,
supportsImages: true,
}
const defaultProps = {
apiConfiguration: {},
setApiConfigurationField: jest.fn(),
modelInfo: mockModelInfo,
provider: "anthropic" as ApiProvider,
}
beforeEach(() => {
jest.clearAllMocks()
})
it("should render nothing when model doesn't support thinking", () => {
const { container } = render(
<ThinkingBudget
{...defaultProps}
modelInfo={{
...mockModelInfo,
thinking: false,
maxTokens: 16384,
contextWindow: 200000,
supportsPromptCache: true,
supportsImages: true,
}}
/>,
)
expect(container.firstChild).toBeNull()
})
it("should render sliders when model supports thinking", () => {
render(<ThinkingBudget {...defaultProps} />)
expect(screen.getAllByTestId("slider")).toHaveLength(2)
})
it("should use modelMaxThinkingTokens field for Anthropic provider", () => {
const setApiConfigurationField = jest.fn()
render(
<ThinkingBudget
{...defaultProps}
apiConfiguration={{ modelMaxThinkingTokens: 4096 }}
setApiConfigurationField={setApiConfigurationField}
provider="anthropic"
/>,
)
const sliders = screen.getAllByTestId("slider")
fireEvent.change(sliders[1], { target: { value: "5000" } })
expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 5000)
})
it("should use modelMaxThinkingTokens field for Vertex provider", () => {
const setApiConfigurationField = jest.fn()
render(
<ThinkingBudget
{...defaultProps}
apiConfiguration={{ modelMaxThinkingTokens: 4096 }}
setApiConfigurationField={setApiConfigurationField}
provider="vertex"
/>,
)
const sliders = screen.getAllByTestId("slider")
fireEvent.change(sliders[1], { target: { value: "5000" } })
expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 5000)
})
it("should cap thinking tokens at 80% of max tokens", () => {
const setApiConfigurationField = jest.fn()
render(
<ThinkingBudget
{...defaultProps}
apiConfiguration={{ modelMaxTokens: 10000, modelMaxThinkingTokens: 9000 }}
setApiConfigurationField={setApiConfigurationField}
/>,
)
// Effect should trigger and cap the value
expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxThinkingTokens", 8000) // 80% of 10000
})
it("should use default thinking tokens if not provided", () => {
render(<ThinkingBudget {...defaultProps} apiConfiguration={{ modelMaxTokens: 10000 }} />)
// Default is 80% of max tokens, capped at 8192
const sliders = screen.getAllByTestId("slider")
expect(sliders[1]).toHaveValue("8000") // 80% of 10000
})
it("should use min thinking tokens of 1024", () => {
render(<ThinkingBudget {...defaultProps} apiConfiguration={{ modelMaxTokens: 1000 }} />)
const sliders = screen.getAllByTestId("slider")
expect(sliders[1].getAttribute("min")).toBe("1024")
})
it("should update max tokens when slider changes", () => {
const setApiConfigurationField = jest.fn()
render(
<ThinkingBudget
{...defaultProps}
apiConfiguration={{ modelMaxTokens: 10000 }}
setApiConfigurationField={setApiConfigurationField}
/>,
)
const sliders = screen.getAllByTestId("slider")
fireEvent.change(sliders[0], { target: { value: "12000" } })
expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", 12000)
})
})

View file

@ -32,7 +32,7 @@ export interface ExtensionStateContextType extends ExtensionState {
setSoundEnabled: (value: boolean) => void
setSoundVolume: (value: number) => void
setDiffEnabled: (value: boolean) => void
setCheckpointsEnabled: (value: boolean) => void
setEnableCheckpoints: (value: boolean) => void
setBrowserViewportSize: (value: string) => void
setFuzzyMatchThreshold: (value: number) => void
preferredLanguage: string
@ -79,7 +79,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
soundEnabled: false,
soundVolume: 0.5,
diffEnabled: false,
checkpointsEnabled: false,
enableCheckpoints: true,
fuzzyMatchThreshold: 1.0,
preferredLanguage: "English",
writeDelayMs: 1000,
@ -219,7 +219,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),
setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
setCheckpointsEnabled: (value) => setState((prevState) => ({ ...prevState, checkpointsEnabled: value })),
setEnableCheckpoints: (value) => setState((prevState) => ({ ...prevState, enableCheckpoints: value })),
setBrowserViewportSize: (value: string) =>
setState((prevState) => ({ ...prevState, browserViewportSize: value })),
setFuzzyMatchThreshold: (value) => setState((prevState) => ({ ...prevState, fuzzyMatchThreshold: value })),