merge: resolve conflicts after upstream merge

This commit is contained in:
RaySinner
2025-01-08 23:47:26 +03:00
34 changed files with 2418 additions and 175 deletions

View File

@@ -766,7 +766,7 @@ export class Cline {
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
let mcpHub: McpHub | undefined
const { mcpEnabled } = await this.providerRef.deref()?.getState() ?? {}
const { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds } = await this.providerRef.deref()?.getState() ?? {}
if (mcpEnabled ?? true) {
mcpHub = this.providerRef.deref()?.mcpHub
@@ -799,8 +799,30 @@ export class Cline {
}
}
// Convert to Anthropic.MessageParam by spreading only the API-required properties
const cleanConversationHistory = this.apiConversationHistory.map(({ role, content }) => ({ role, content }))
// Clean conversation history by:
// 1. Converting to Anthropic.MessageParam by spreading only the API-required properties
// 2. Converting image blocks to text descriptions if model doesn't support images
const cleanConversationHistory = this.apiConversationHistory.map(({ role, content }) => {
// Handle array content (could contain image blocks)
if (Array.isArray(content)) {
if (!this.api.getModel().info.supportsImages) {
// Convert image blocks to text descriptions
content = content.map(block => {
if (block.type === 'image') {
// Convert image blocks to text descriptions
// Note: We can't access the actual image content/url due to API limitations,
// but we can indicate that an image was present in the conversation
return {
type: 'text',
text: '[Referenced image in conversation]'
};
}
return block;
});
}
}
return { role, content }
})
const stream = this.api.createMessage(systemPrompt, cleanConversationHistory)
const iterator = stream[Symbol.asyncIterator]()
@@ -810,18 +832,33 @@ export class Cline {
yield firstChunk.value
} catch (error) {
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
const { response } = await this.ask(
"api_req_failed",
error.message ?? JSON.stringify(serializeError(error), null, 2),
)
if (response !== "yesButtonClicked") {
// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
throw new Error("API request failed")
if (alwaysApproveResubmit) {
const requestDelay = requestDelaySeconds || 5
// Automatically retry with delay
await this.say(
"error",
`${error.message ?? "Unknown error"} ↺ Retrying in ${requestDelay} seconds...`,
)
await this.say("api_req_retry_delayed")
await delay(requestDelay * 1000)
await this.say("api_req_retried")
// delegate generator output from the recursive call
yield* this.attemptApiRequest(previousApiReqIndex)
return
} else {
const { response } = await this.ask(
"api_req_failed",
error.message ?? JSON.stringify(serializeError(error), null, 2),
)
if (response !== "yesButtonClicked") {
// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
throw new Error("API request failed")
}
await this.say("api_req_retried")
// delegate generator output from the recursive call
yield* this.attemptApiRequest(previousApiReqIndex)
return
}
await this.say("api_req_retried")
// delegate generator output from the recursive call
yield* this.attemptApiRequest(previousApiReqIndex)
return
}
// no error, so we can continue to yield all remaining chunks

View File

@@ -1,7 +1,8 @@
import { Cline } from '../Cline';
import { ClineProvider } from '../webview/ClineProvider';
import { ApiConfiguration } from '../../shared/api';
import { ApiConfiguration, ModelInfo } from '../../shared/api';
import { ApiStreamChunk } from '../../api/transform/stream';
import { Anthropic } from '@anthropic-ai/sdk';
import * as vscode from 'vscode';
// Mock all MCP-related modules
@@ -498,6 +499,133 @@ describe('Cline', () => {
expect(passedMessage).not.toHaveProperty('ts');
expect(passedMessage).not.toHaveProperty('extraProp');
});
it('should handle image blocks based on model capabilities', async () => {
// Create two configurations - one with image support, one without
const configWithImages = {
...mockApiConfig,
apiModelId: 'claude-3-sonnet'
};
const configWithoutImages = {
...mockApiConfig,
apiModelId: 'gpt-3.5-turbo'
};
// Create test conversation history with mixed content
const conversationHistory: (Anthropic.MessageParam & { ts?: number })[] = [
{
role: 'user' as const,
content: [
{
type: 'text' as const,
text: 'Here is an image'
} satisfies Anthropic.TextBlockParam,
{
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: 'image/jpeg',
data: 'base64data'
}
} satisfies Anthropic.ImageBlockParam
]
},
{
role: 'assistant' as const,
content: [{
type: 'text' as const,
text: 'I see the image'
} satisfies Anthropic.TextBlockParam]
}
];
// Test with model that supports images
const clineWithImages = new Cline(
mockProvider,
configWithImages,
undefined,
false,
undefined,
'test task'
);
// Mock the model info to indicate image support
jest.spyOn(clineWithImages.api, 'getModel').mockReturnValue({
id: 'claude-3-sonnet',
info: {
supportsImages: true,
supportsPromptCache: true,
supportsComputerUse: true,
contextWindow: 200000,
maxTokens: 4096,
inputPrice: 0.25,
outputPrice: 0.75
} as ModelInfo
});
clineWithImages.apiConversationHistory = conversationHistory;
// Test with model that doesn't support images
const clineWithoutImages = new Cline(
mockProvider,
configWithoutImages,
undefined,
false,
undefined,
'test task'
);
// Mock the model info to indicate no image support
jest.spyOn(clineWithoutImages.api, 'getModel').mockReturnValue({
id: 'gpt-3.5-turbo',
info: {
supportsImages: false,
supportsPromptCache: false,
supportsComputerUse: false,
contextWindow: 16000,
maxTokens: 2048,
inputPrice: 0.1,
outputPrice: 0.2
} as ModelInfo
});
clineWithoutImages.apiConversationHistory = conversationHistory;
// Create message spy for both instances
const createMessageSpyWithImages = jest.fn();
const createMessageSpyWithoutImages = jest.fn();
const mockStream = {
async *[Symbol.asyncIterator]() {
yield { type: 'text', text: '' };
}
} as AsyncGenerator<ApiStreamChunk>;
jest.spyOn(clineWithImages.api, 'createMessage').mockImplementation((...args) => {
createMessageSpyWithImages(...args);
return mockStream;
});
jest.spyOn(clineWithoutImages.api, 'createMessage').mockImplementation((...args) => {
createMessageSpyWithoutImages(...args);
return mockStream;
});
// Trigger API requests for both instances
await clineWithImages.recursivelyMakeClineRequests([{ type: 'text', text: 'test' }]);
await clineWithoutImages.recursivelyMakeClineRequests([{ type: 'text', text: 'test' }]);
// Verify model with image support preserves image blocks
const callsWithImages = createMessageSpyWithImages.mock.calls;
const historyWithImages = callsWithImages[0][1][0];
expect(historyWithImages.content).toHaveLength(2);
expect(historyWithImages.content[0]).toEqual({ type: 'text', text: 'Here is an image' });
expect(historyWithImages.content[1]).toHaveProperty('type', 'image');
// Verify model without image support converts image blocks to text
const callsWithoutImages = createMessageSpyWithoutImages.mock.calls;
const historyWithoutImages = callsWithoutImages[0][1][0];
expect(historyWithoutImages.content).toHaveLength(2);
expect(historyWithoutImages.content[0]).toEqual({ type: 'text', text: 'Here is an image' });
expect(historyWithoutImages.content[1]).toEqual({
type: 'text',
text: '[Referenced image in conversation]'
});
});
});
});
});

View File

@@ -0,0 +1,164 @@
import { ExtensionContext } from 'vscode'
import { ApiConfiguration } from '../../shared/api'
import { ApiConfigMeta } from '../../shared/ExtensionMessage'
export interface ApiConfigData {
currentApiConfigName: string
apiConfigs: {
[key: string]: ApiConfiguration
}
}
export class ConfigManager {
private readonly defaultConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
default: {}
}
}
private readonly SCOPE_PREFIX = "roo_cline_config_"
private readonly context: ExtensionContext
constructor(context: ExtensionContext) {
this.context = context
}
/**
* Initialize config if it doesn't exist
*/
async initConfig(): Promise<void> {
try {
const config = await this.readConfig()
if (!config) {
await this.writeConfig(this.defaultConfig)
}
} catch (error) {
throw new Error(`Failed to initialize config: ${error}`)
}
}
/**
* List all available configs with metadata
*/
async ListConfig(): Promise<ApiConfigMeta[]> {
try {
const config = await this.readConfig()
return Object.entries(config.apiConfigs).map(([name, apiConfig]) => ({
name,
apiProvider: apiConfig.apiProvider,
}))
} catch (error) {
throw new Error(`Failed to list configs: ${error}`)
}
}
/**
* Save a config with the given name
*/
async SaveConfig(name: string, config: ApiConfiguration): Promise<void> {
try {
const currentConfig = await this.readConfig()
currentConfig.apiConfigs[name] = config
await this.writeConfig(currentConfig)
} catch (error) {
throw new Error(`Failed to save config: ${error}`)
}
}
/**
* Load a config by name
*/
async LoadConfig(name: string): Promise<ApiConfiguration> {
try {
const config = await this.readConfig()
const apiConfig = config.apiConfigs[name]
if (!apiConfig) {
throw new Error(`Config '${name}' not found`)
}
config.currentApiConfigName = name;
await this.writeConfig(config)
return apiConfig
} catch (error) {
throw new Error(`Failed to load config: ${error}`)
}
}
/**
* Delete a config by name
*/
async DeleteConfig(name: string): Promise<void> {
try {
const currentConfig = await this.readConfig()
if (!currentConfig.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
}
// Don't allow deleting the default config
if (Object.keys(currentConfig.apiConfigs).length === 1) {
throw new Error(`Cannot delete the last remaining configuration.`)
}
delete currentConfig.apiConfigs[name]
await this.writeConfig(currentConfig)
} catch (error) {
throw new Error(`Failed to delete config: ${error}`)
}
}
/**
* Set the current active API configuration
*/
async SetCurrentConfig(name: string): Promise<void> {
try {
const currentConfig = await this.readConfig()
if (!currentConfig.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
}
currentConfig.currentApiConfigName = name
await this.writeConfig(currentConfig)
} catch (error) {
throw new Error(`Failed to set current config: ${error}`)
}
}
/**
* Check if a config exists by name
*/
async HasConfig(name: string): Promise<boolean> {
try {
const config = await this.readConfig()
return name in config.apiConfigs
} catch (error) {
throw new Error(`Failed to check config existence: ${error}`)
}
}
private async readConfig(): Promise<ApiConfigData> {
try {
const configKey = `${this.SCOPE_PREFIX}api_config`
const content = await this.context.secrets.get(configKey)
if (!content) {
return this.defaultConfig
}
return JSON.parse(content)
} catch (error) {
throw new Error(`Failed to read config from secrets: ${error}`)
}
}
private async writeConfig(config: ApiConfigData): Promise<void> {
try {
const configKey = `${this.SCOPE_PREFIX}api_config`
const content = JSON.stringify(config, null, 2)
await this.context.secrets.store(configKey, content)
} catch (error) {
throw new Error(`Failed to write config to secrets: ${error}`)
}
}
}

View File

@@ -0,0 +1,384 @@
import { ExtensionContext } from 'vscode'
import { ConfigManager, ApiConfigData } from '../ConfigManager'
import { ApiConfiguration } from '../../../shared/api'
// Mock VSCode ExtensionContext
const mockSecrets = {
get: jest.fn(),
store: jest.fn(),
delete: jest.fn()
}
const mockContext = {
secrets: mockSecrets
} as unknown as ExtensionContext
describe('ConfigManager', () => {
let configManager: ConfigManager
beforeEach(() => {
jest.clearAllMocks()
configManager = new ConfigManager(mockContext)
})
describe('initConfig', () => {
it('should not write to storage when secrets.get returns null', async () => {
// Mock readConfig to return null
mockSecrets.get.mockResolvedValueOnce(null)
await configManager.initConfig()
// Should not write to storage because readConfig returns defaultConfig
expect(mockSecrets.store).not.toHaveBeenCalled()
})
it('should not initialize config if it exists', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: {
default: {}
}
}))
await configManager.initConfig()
expect(mockSecrets.store).not.toHaveBeenCalled()
})
it('should throw error if secrets storage fails', async () => {
mockSecrets.get.mockRejectedValue(new Error('Storage failed'))
await expect(configManager.initConfig()).rejects.toThrow(
'Failed to initialize config: Error: Failed to read config from secrets: Error: Storage failed'
)
})
})
describe('ListConfig', () => {
it('should list all available configs', async () => {
const existingConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
default: {},
test: {
apiProvider: 'anthropic'
}
}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
const configs = await configManager.ListConfig()
expect(configs).toEqual([
{ name: 'default', apiProvider: undefined },
{ name: 'test', apiProvider: 'anthropic' }
])
})
it('should handle empty config file', async () => {
const emptyConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(emptyConfig))
const configs = await configManager.ListConfig()
expect(configs).toEqual([])
})
it('should throw error if reading from secrets fails', async () => {
mockSecrets.get.mockRejectedValue(new Error('Read failed'))
await expect(configManager.ListConfig()).rejects.toThrow(
'Failed to list configs: Error: Failed to read config from secrets: Error: Read failed'
)
})
})
describe('SaveConfig', () => {
it('should save new config', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: {
default: {}
}
}))
const newConfig: ApiConfiguration = {
apiProvider: 'anthropic',
apiKey: 'test-key'
}
await configManager.SaveConfig('test', newConfig)
const expectedConfig = {
currentApiConfigName: 'default',
apiConfigs: {
default: {},
test: newConfig
}
}
expect(mockSecrets.store).toHaveBeenCalledWith(
'roo_cline_config_api_config',
JSON.stringify(expectedConfig, null, 2)
)
})
it('should update existing config', async () => {
const existingConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
test: {
apiProvider: 'anthropic',
apiKey: 'old-key'
}
}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
const updatedConfig: ApiConfiguration = {
apiProvider: 'anthropic',
apiKey: 'new-key'
}
await configManager.SaveConfig('test', updatedConfig)
const expectedConfig = {
currentApiConfigName: 'default',
apiConfigs: {
test: updatedConfig
}
}
expect(mockSecrets.store).toHaveBeenCalledWith(
'roo_cline_config_api_config',
JSON.stringify(expectedConfig, null, 2)
)
})
it('should throw error if secrets storage fails', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: { default: {} }
}))
mockSecrets.store.mockRejectedValueOnce(new Error('Storage failed'))
await expect(configManager.SaveConfig('test', {})).rejects.toThrow(
'Failed to save config: Error: Failed to write config to secrets: Error: Storage failed'
)
})
})
describe('DeleteConfig', () => {
it('should delete existing config', async () => {
const existingConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
default: {},
test: {
apiProvider: 'anthropic'
}
}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
await configManager.DeleteConfig('test')
const expectedConfig = {
currentApiConfigName: 'default',
apiConfigs: {
default: {}
}
}
expect(mockSecrets.store).toHaveBeenCalledWith(
'roo_cline_config_api_config',
JSON.stringify(expectedConfig, null, 2)
)
})
it('should throw error when trying to delete non-existent config', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: { default: {} }
}))
await expect(configManager.DeleteConfig('nonexistent')).rejects.toThrow(
"Config 'nonexistent' not found"
)
})
it('should throw error when trying to delete last remaining config', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: { default: {} }
}))
await expect(configManager.DeleteConfig('default')).rejects.toThrow(
'Cannot delete the last remaining configuration.'
)
})
})
describe('LoadConfig', () => {
it('should load config and update current config name', async () => {
const existingConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
test: {
apiProvider: 'anthropic',
apiKey: 'test-key'
}
}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
const config = await configManager.LoadConfig('test')
expect(config).toEqual({
apiProvider: 'anthropic',
apiKey: 'test-key'
})
const expectedConfig = {
currentApiConfigName: 'test',
apiConfigs: {
test: {
apiProvider: 'anthropic',
apiKey: 'test-key'
}
}
}
expect(mockSecrets.store).toHaveBeenCalledWith(
'roo_cline_config_api_config',
JSON.stringify(expectedConfig, null, 2)
)
})
it('should throw error when config does not exist', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: { default: {} }
}))
await expect(configManager.LoadConfig('nonexistent')).rejects.toThrow(
"Config 'nonexistent' not found"
)
})
it('should throw error if secrets storage fails', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: {
test: { apiProvider: 'anthropic' }
}
}))
mockSecrets.store.mockRejectedValueOnce(new Error('Storage failed'))
await expect(configManager.LoadConfig('test')).rejects.toThrow(
'Failed to load config: Error: Failed to write config to secrets: Error: Storage failed'
)
})
})
describe('SetCurrentConfig', () => {
it('should set current config', async () => {
const existingConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
default: {},
test: {
apiProvider: 'anthropic'
}
}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
await configManager.SetCurrentConfig('test')
const expectedConfig = {
currentApiConfigName: 'test',
apiConfigs: {
default: {},
test: {
apiProvider: 'anthropic'
}
}
}
expect(mockSecrets.store).toHaveBeenCalledWith(
'roo_cline_config_api_config',
JSON.stringify(expectedConfig, null, 2)
)
})
it('should throw error when config does not exist', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: { default: {} }
}))
await expect(configManager.SetCurrentConfig('nonexistent')).rejects.toThrow(
"Config 'nonexistent' not found"
)
})
it('should throw error if secrets storage fails', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: {
test: { apiProvider: 'anthropic' }
}
}))
mockSecrets.store.mockRejectedValueOnce(new Error('Storage failed'))
await expect(configManager.SetCurrentConfig('test')).rejects.toThrow(
'Failed to set current config: Error: Failed to write config to secrets: Error: Storage failed'
)
})
})
describe('HasConfig', () => {
it('should return true for existing config', async () => {
const existingConfig: ApiConfigData = {
currentApiConfigName: 'default',
apiConfigs: {
default: {},
test: {
apiProvider: 'anthropic'
}
}
}
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
const hasConfig = await configManager.HasConfig('test')
expect(hasConfig).toBe(true)
})
it('should return false for non-existent config', async () => {
mockSecrets.get.mockResolvedValue(JSON.stringify({
currentApiConfigName: 'default',
apiConfigs: { default: {} }
}))
const hasConfig = await configManager.HasConfig('nonexistent')
expect(hasConfig).toBe(false)
})
it('should throw error if secrets storage fails', async () => {
mockSecrets.get.mockRejectedValue(new Error('Storage failed'))
await expect(configManager.HasConfig('test')).rejects.toThrow(
'Failed to check config existence: Error: Failed to read config from secrets: Error: Storage failed'
)
})
})
})

View File

@@ -12,9 +12,9 @@ import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
import { ApiProvider, ModelInfo } from "../../shared/api"
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
import { findLast } from "../../shared/array"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
import { HistoryItem } from "../../shared/HistoryItem"
import { WebviewMessage } from "../../shared/WebviewMessage"
import { fileExistsAtPath } from "../../utils/fs"
@@ -23,8 +23,10 @@ import { openMention } from "../mentions"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { enhancePrompt } from "../../utils/enhance-prompt"
import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git"
import { ConfigManager } from "../config/ConfigManager"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -84,6 +86,10 @@ type GlobalStateKey =
| "writeDelayMs"
| "terminalOutputLineLimit"
| "mcpEnabled"
| "alwaysApproveResubmit"
| "requestDelaySeconds"
| "currentApiConfigName"
| "listApiConfigMeta"
| "vsCodeLmModelSelector"
export const GlobalFileNames = {
@@ -104,6 +110,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub
private latestAnnouncementId = "dec-10-2024" // update to some unique identifier when we add a new announcement
configManager: ConfigManager
constructor(
readonly context: vscode.ExtensionContext,
@@ -113,6 +120,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
ClineProvider.activeInstances.add(this)
this.workspaceTracker = new WorkspaceTracker(this)
this.mcpHub = new McpHub(this)
this.configManager = new ConfigManager(this.context)
}
/*
@@ -411,6 +419,55 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
})
this.configManager.ListConfig().then(async (listApiConfig) => {
if (!listApiConfig) {
return
}
if (listApiConfig.length === 1) {
// check if first time init then sync with exist config
if (!checkExistKey(listApiConfig[0])) {
const {
apiConfiguration,
} = await this.getState()
await this.configManager.SaveConfig(listApiConfig[0].name ?? "default", apiConfiguration)
listApiConfig[0].apiProvider = apiConfiguration.apiProvider
}
}
let currentConfigName = await this.getGlobalState("currentApiConfigName") as string
if (currentConfigName) {
if (!await this.configManager.HasConfig(currentConfigName)) {
// current config name not valid, get first config in list
await this.updateGlobalState("currentApiConfigName", listApiConfig?.[0]?.name)
if (listApiConfig?.[0]?.name) {
const apiConfig = await this.configManager.LoadConfig(listApiConfig?.[0]?.name);
await Promise.all([
this.updateGlobalState("listApiConfigMeta", listApiConfig),
this.postMessageToWebview({ type: "listApiConfig", listApiConfig }),
this.updateApiConfiguration(apiConfig),
])
await this.postStateToWebview()
return
}
}
}
await Promise.all(
[
await this.updateGlobalState("listApiConfigMeta", listApiConfig),
await this.postMessageToWebview({ type: "listApiConfig", listApiConfig })
]
)
}).catch(console.error);
break
case "newTask":
// Code that should run in response to the hello message command
@@ -491,6 +548,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
if (this.cline) {
this.cline.api = buildApiHandler(message.apiConfiguration)
}
await this.updateApiConfiguration(message.apiConfiguration)
}
await this.postStateToWebview()
break
@@ -684,6 +742,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("fuzzyMatchThreshold", message.value)
await this.postStateToWebview()
break
case "alwaysApproveResubmit":
await this.updateGlobalState("alwaysApproveResubmit", message.bool ?? false)
await this.postStateToWebview()
break
case "requestDelaySeconds":
await this.updateGlobalState("requestDelaySeconds", message.value ?? 5)
await this.postStateToWebview()
break
case "preferredLanguage":
await this.updateGlobalState("preferredLanguage", message.text)
await this.postStateToWebview()
@@ -698,21 +764,65 @@ export class ClineProvider implements vscode.WebviewViewProvider {
break
case "deleteMessage": {
const answer = await vscode.window.showInformationMessage(
"Are you sure you want to delete this message and all subsequent messages?",
"What would you like to delete?",
{ modal: true },
"Yes",
"No"
"Just this message",
"This and all subsequent messages",
)
if (answer === "Yes" && this.cline && typeof message.value === 'number' && message.value) {
if ((answer === "Just this message" || answer === "This and all subsequent messages") &&
this.cline && typeof message.value === 'number' && message.value) {
const timeCutoff = message.value - 1000; // 1 second buffer before the message to delete
const messageIndex = this.cline.clineMessages.findIndex(msg => msg.ts && msg.ts >= timeCutoff)
const apiConversationHistoryIndex = this.cline.apiConversationHistory.findIndex(msg => msg.ts && msg.ts >= timeCutoff)
if (messageIndex !== -1) {
const { historyItem } = await this.getTaskWithId(this.cline.taskId)
await this.cline.overwriteClineMessages(this.cline.clineMessages.slice(0, messageIndex))
if (apiConversationHistoryIndex !== -1) {
await this.cline.overwriteApiConversationHistory(this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex))
if (answer === "Just this message") {
// Find the next user message first
const nextUserMessage = this.cline.clineMessages
.slice(messageIndex + 1)
.find(msg => msg.type === "say" && msg.say === "user_feedback")
// Handle UI messages
if (nextUserMessage) {
// Find absolute index of next user message
const nextUserMessageIndex = this.cline.clineMessages.findIndex(msg => msg === nextUserMessage)
// Keep messages before current message and after next user message
await this.cline.overwriteClineMessages([
...this.cline.clineMessages.slice(0, messageIndex),
...this.cline.clineMessages.slice(nextUserMessageIndex)
])
} else {
// If no next user message, keep only messages before current message
await this.cline.overwriteClineMessages(
this.cline.clineMessages.slice(0, messageIndex)
)
}
// Handle API messages
if (apiConversationHistoryIndex !== -1) {
if (nextUserMessage && nextUserMessage.ts) {
// Keep messages before current API message and after next user message
await this.cline.overwriteApiConversationHistory([
...this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex),
...this.cline.apiConversationHistory.filter(msg => msg.ts && msg.ts >= nextUserMessage.ts)
])
} else {
// If no next user message, keep only messages before current API message
await this.cline.overwriteApiConversationHistory(
this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex)
)
}
}
} else if (answer === "This and all subsequent messages") {
// Delete this message and all that follow
await this.cline.overwriteClineMessages(this.cline.clineMessages.slice(0, messageIndex))
if (apiConversationHistoryIndex !== -1) {
await this.cline.overwriteApiConversationHistory(this.cline.apiConversationHistory.slice(0, apiConversationHistoryIndex))
}
}
await this.initClineWithHistoryItem(historyItem)
}
}
@@ -760,6 +870,113 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
}
case "upsertApiConfiguration":
if (message.text && message.apiConfiguration) {
try {
await this.configManager.SaveConfig(message.text, message.apiConfiguration);
let listApiConfig = await this.configManager.ListConfig();
await Promise.all([
this.updateApiConfiguration(message.apiConfiguration),
this.updateGlobalState("currentApiConfigName", message.text),
this.updateGlobalState("listApiConfigMeta", listApiConfig),
])
this.postStateToWebview()
} catch (error) {
console.error("Error create new api configuration:", error)
vscode.window.showErrorMessage("Failed to create api configuration")
}
}
break
case "renameApiConfiguration":
if (message.values && message.apiConfiguration) {
try {
const { oldName, newName } = message.values
await this.configManager.SaveConfig(newName, message.apiConfiguration);
await this.configManager.DeleteConfig(oldName)
let listApiConfig = await this.configManager.ListConfig();
await Promise.all([
this.updateGlobalState("currentApiConfigName", newName),
this.updateGlobalState("listApiConfigMeta", listApiConfig),
])
this.postStateToWebview()
} catch (error) {
console.error("Error create new api configuration:", error)
vscode.window.showErrorMessage("Failed to create api configuration")
}
}
break
case "loadApiConfiguration":
if (message.text) {
try {
const apiConfig = await this.configManager.LoadConfig(message.text);
await Promise.all([
this.updateGlobalState("currentApiConfigName", message.text),
this.updateApiConfiguration(apiConfig),
])
await this.postStateToWebview()
} catch (error) {
console.error("Error load api configuration:", error)
vscode.window.showErrorMessage("Failed to load api configuration")
}
}
break
case "deleteApiConfiguration":
if (message.text) {
const answer = await vscode.window.showInformationMessage(
"Are you sure you want to delete this configuration profile?",
{ modal: true },
"Yes",
)
if (answer !== "Yes") {
break
}
try {
await this.configManager.DeleteConfig(message.text);
let listApiConfig = await this.configManager.ListConfig()
let currentApiConfigName = await this.getGlobalState("currentApiConfigName")
if (message.text === currentApiConfigName) {
await this.updateGlobalState("currentApiConfigName", listApiConfig?.[0]?.name)
if (listApiConfig?.[0]?.name) {
const apiConfig = await this.configManager.LoadConfig(listApiConfig?.[0]?.name);
await Promise.all([
this.updateGlobalState("listApiConfigMeta", listApiConfig),
this.updateApiConfiguration(apiConfig),
])
await this.postStateToWebview()
}
}
} catch (error) {
console.error("Error delete api configuration:", error)
vscode.window.showErrorMessage("Failed to delete api configuration")
}
}
break
case "getListApiConfiguration":
try {
let listApiConfig = await this.configManager.ListConfig();
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
this.postMessageToWebview({ type: "listApiConfig", listApiConfig })
} catch (error) {
console.error("Error get list api configuration:", error)
vscode.window.showErrorMessage("Failed to get list api configuration")
}
break
}
},
null,
@@ -767,6 +984,74 @@ export class ClineProvider implements vscode.WebviewViewProvider {
)
}
private async updateApiConfiguration(apiConfiguration: ApiConfiguration) {
const {
apiProvider,
apiModelId,
apiKey,
glamaModelId,
glamaModelInfo,
glamaApiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
azureApiVersion,
openAiStreamingEnabled,
openRouterModelId,
openRouterModelInfo,
openRouterUseMiddleOutTransform,
} = apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId)
await this.storeSecret("apiKey", apiKey)
await this.updateGlobalState("glamaModelId", glamaModelId)
await this.updateGlobalState("glamaModelInfo", glamaModelInfo)
await this.storeSecret("glamaApiKey", glamaApiKey)
await this.storeSecret("openRouterApiKey", openRouterApiKey)
await this.storeSecret("awsAccessKey", awsAccessKey)
await this.storeSecret("awsSecretKey", awsSecretKey)
await this.storeSecret("awsSessionToken", awsSessionToken)
await this.updateGlobalState("awsRegion", awsRegion)
await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
await this.updateGlobalState("vertexProjectId", vertexProjectId)
await this.updateGlobalState("vertexRegion", vertexRegion)
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
await this.storeSecret("openAiApiKey", openAiApiKey)
await this.updateGlobalState("openAiModelId", openAiModelId)
await this.updateGlobalState("ollamaModelId", ollamaModelId)
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl)
await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl)
await this.storeSecret("geminiApiKey", geminiApiKey)
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
await this.storeSecret("deepSeekApiKey", deepSeekApiKey)
await this.updateGlobalState("azureApiVersion", azureApiVersion)
await this.updateGlobalState("openAiStreamingEnabled", openAiStreamingEnabled)
await this.updateGlobalState("openRouterModelId", openRouterModelId)
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform)
if (this.cline) {
this.cline.api = buildApiHandler(apiConfiguration)
}
}
async updateCustomInstructions(instructions?: string) {
// User may be clearing the field
await this.updateGlobalState("customInstructions", instructions || undefined)
@@ -1220,9 +1505,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
terminalOutputLineLimit,
fuzzyMatchThreshold,
mcpEnabled,
alwaysApproveResubmit,
requestDelaySeconds,
currentApiConfigName,
listApiConfigMeta,
} = await this.getState()
const allowedCommands = vscode.workspace
.getConfiguration('roo-cline')
.get<string[]>('allowedCommands') || []
@@ -1253,6 +1541,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
mcpEnabled: mcpEnabled ?? true,
alwaysApproveResubmit: alwaysApproveResubmit ?? false,
requestDelaySeconds: requestDelaySeconds ?? 5,
currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [],
}
}
@@ -1358,7 +1650,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
screenshotQuality,
terminalOutputLineLimit,
mcpEnabled,
vsCodeLmModelSelector,
alwaysApproveResubmit,
requestDelaySeconds,
currentApiConfigName,
listApiConfigMeta,
vsCodeLmModelSelector
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -1409,7 +1705,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("screenshotQuality") as Promise<number | undefined>,
this.getGlobalState("terminalOutputLineLimit") as Promise<number | undefined>,
this.getGlobalState("mcpEnabled") as Promise<boolean | undefined>,
this.getGlobalState("alwaysApproveResubmit") as Promise<boolean | undefined>,
this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>,
this.getGlobalState("currentApiConfigName") as Promise<string | undefined>,
this.getGlobalState("listApiConfigMeta") as Promise<ApiConfigMeta[] | undefined>,
this.getGlobalState("vsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
])
let apiProvider: ApiProvider
@@ -1505,6 +1806,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return langMap[vscodeLang.split('-')[0]] ?? 'English';
})(),
mcpEnabled: mcpEnabled ?? true,
alwaysApproveResubmit: alwaysApproveResubmit ?? false,
requestDelaySeconds: requestDelaySeconds ?? 5,
currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [],
}
}

View File

@@ -59,6 +59,9 @@ jest.mock('vscode', () => ({
joinPath: jest.fn(),
file: jest.fn()
},
window: {
showInformationMessage: jest.fn(),
},
workspace: {
getConfiguration: jest.fn().mockReturnValue({
get: jest.fn().mockReturnValue([]),
@@ -123,7 +126,11 @@ jest.mock('../../Cline', () => {
Cline: jest.fn().mockImplementation(() => ({
abortTask: jest.fn(),
handleWebviewAskResponse: jest.fn(),
clineMessages: []
clineMessages: [],
apiConversationHistory: [],
overwriteClineMessages: jest.fn(),
overwriteApiConversationHistory: jest.fn(),
taskId: 'test-task-id'
}))
}
})
@@ -256,6 +263,7 @@ describe('ClineProvider', () => {
browserViewportSize: "900x600",
fuzzyMatchThreshold: 1.0,
mcpEnabled: true,
requestDelaySeconds: 5
}
const message: ExtensionMessage = {
@@ -375,9 +383,190 @@ describe('ClineProvider', () => {
expect(mockPostMessage).toHaveBeenCalled()
})
test('requestDelaySeconds defaults to 5 seconds', async () => {
// Mock globalState.get to return undefined for requestDelaySeconds
(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => {
if (key === 'requestDelaySeconds') {
return undefined
}
return null
})
const state = await provider.getState()
expect(state.requestDelaySeconds).toBe(5)
})
test('alwaysApproveResubmit defaults to false', async () => {
// Mock globalState.get to return undefined for alwaysApproveResubmit
(mockContext.globalState.get as jest.Mock).mockReturnValue(undefined)
const state = await provider.getState()
expect(state.alwaysApproveResubmit).toBe(false)
})
test('handles request delay settings messages', async () => {
provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
// Test alwaysApproveResubmit
await messageHandler({ type: 'alwaysApproveResubmit', bool: true })
expect(mockContext.globalState.update).toHaveBeenCalledWith('alwaysApproveResubmit', true)
expect(mockPostMessage).toHaveBeenCalled()
// Test requestDelaySeconds
await messageHandler({ type: 'requestDelaySeconds', value: 10 })
expect(mockContext.globalState.update).toHaveBeenCalledWith('requestDelaySeconds', 10)
expect(mockPostMessage).toHaveBeenCalled()
})
test('file content includes line numbers', async () => {
const { extractTextFromFile } = require('../../../integrations/misc/extract-text')
const result = await extractTextFromFile('test.js')
expect(result).toBe('1 | const x = 1;\n2 | const y = 2;\n3 | const z = 3;')
})
describe('deleteMessage', () => {
beforeEach(() => {
// Mock window.showInformationMessage
;(vscode.window.showInformationMessage as jest.Mock) = jest.fn()
provider.resolveWebviewView(mockWebviewView)
})
test('handles "Just this message" deletion correctly', async () => {
// Mock user selecting "Just this message"
;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue('Just this message')
// Setup mock messages
const mockMessages = [
{ ts: 1000, type: 'say', say: 'user_feedback' }, // User message 1
{ ts: 2000, type: 'say', say: 'tool' }, // Tool message
{ ts: 3000, type: 'say', say: 'text', value: 4000 }, // Message to delete
{ ts: 4000, type: 'say', say: 'browser_action' }, // Response to delete
{ ts: 5000, type: 'say', say: 'user_feedback' }, // Next user message
{ ts: 6000, type: 'say', say: 'user_feedback' } // Final message
]
const mockApiHistory = [
{ ts: 1000 },
{ ts: 2000 },
{ ts: 3000 },
{ ts: 4000 },
{ ts: 5000 },
{ ts: 6000 }
]
// Setup Cline instance with mock data
const mockCline = {
clineMessages: mockMessages,
apiConversationHistory: mockApiHistory,
overwriteClineMessages: jest.fn(),
overwriteApiConversationHistory: jest.fn(),
taskId: 'test-task-id',
abortTask: jest.fn(),
handleWebviewAskResponse: jest.fn()
}
// @ts-ignore - accessing private property for testing
provider.cline = mockCline
// Mock getTaskWithId
;(provider as any).getTaskWithId = jest.fn().mockResolvedValue({
historyItem: { id: 'test-task-id' }
})
// Trigger message deletion
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
await messageHandler({ type: 'deleteMessage', value: 4000 })
// Verify correct messages were kept
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([
mockMessages[0],
mockMessages[1],
mockMessages[4],
mockMessages[5]
])
// Verify correct API messages were kept
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([
mockApiHistory[0],
mockApiHistory[1],
mockApiHistory[4],
mockApiHistory[5]
])
})
test('handles "This and all subsequent messages" deletion correctly', async () => {
// Mock user selecting "This and all subsequent messages"
;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue('This and all subsequent messages')
// Setup mock messages
const mockMessages = [
{ ts: 1000, type: 'say', say: 'user_feedback' },
{ ts: 2000, type: 'say', say: 'text', value: 3000 }, // Message to delete
{ ts: 3000, type: 'say', say: 'user_feedback' },
{ ts: 4000, type: 'say', say: 'user_feedback' }
]
const mockApiHistory = [
{ ts: 1000 },
{ ts: 2000 },
{ ts: 3000 },
{ ts: 4000 }
]
// Setup Cline instance with mock data
const mockCline = {
clineMessages: mockMessages,
apiConversationHistory: mockApiHistory,
overwriteClineMessages: jest.fn(),
overwriteApiConversationHistory: jest.fn(),
taskId: 'test-task-id',
abortTask: jest.fn(),
handleWebviewAskResponse: jest.fn()
}
// @ts-ignore - accessing private property for testing
provider.cline = mockCline
// Mock getTaskWithId
;(provider as any).getTaskWithId = jest.fn().mockResolvedValue({
historyItem: { id: 'test-task-id' }
})
// Trigger message deletion
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
await messageHandler({ type: 'deleteMessage', value: 3000 })
// Verify only messages before the deleted message were kept
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([
mockMessages[0]
])
// Verify only API messages before the deleted message were kept
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([
mockApiHistory[0]
])
})
test('handles Cancel correctly', async () => {
// Mock user selecting "Cancel"
;(vscode.window.showInformationMessage as jest.Mock).mockResolvedValue('Cancel')
const mockCline = {
clineMessages: [{ ts: 1000 }, { ts: 2000 }],
apiConversationHistory: [{ ts: 1000 }, { ts: 2000 }],
overwriteClineMessages: jest.fn(),
overwriteApiConversationHistory: jest.fn(),
taskId: 'test-task-id'
}
// @ts-ignore - accessing private property for testing
provider.cline = mockCline
// Trigger message deletion
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
await messageHandler({ type: 'deleteMessage', value: 2000 })
// Verify no messages were deleted
expect(mockCline.overwriteClineMessages).not.toHaveBeenCalled()
expect(mockCline.overwriteApiConversationHistory).not.toHaveBeenCalled()
})
})
})