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

@@ -24,6 +24,7 @@ export class OpenAiNativeHandler implements ApiHandler {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
switch (this.getModel().id) {
case "o1":
case "o1-preview":
case "o1-mini": {
// o1 doesnt support streaming, non-1 temp, or system prompt

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()
})
})
})

View File

@@ -34,4 +34,9 @@ export interface ClineAPI {
* Simulates pressing the secondary button in the chat interface.
*/
pressSecondaryButton(): Promise<void>
/**
* The sidebar provider instance.
*/
sidebarProvider: ClineSidebarProvider
}

View File

@@ -56,6 +56,8 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
invoke: "secondaryButtonClick",
})
},
sidebarProvider: sidebarProvider,
}
return api

View File

@@ -1,6 +1,6 @@
// type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonClicked' or 'settingsButtonClicked' or 'hello'
import { ApiConfiguration, ModelInfo } from "./api"
import { ApiConfiguration, ApiProvider, ModelInfo } from "./api"
import { HistoryItem } from "./HistoryItem"
import { McpServer } from "./mcp"
import { GitCommit } from "../utils/git"
@@ -9,25 +9,25 @@ import { GitCommit } from "../utils/git"
export interface ExtensionMessage {
type:
| "action"
| "state"
| "selectedImages"
| "ollamaModels"
| "lmStudioModels"
| "vsCodeLmModels"
| "vsCodeLmApiAvailable"
| "requestVsCodeLmModels"
| "theme"
| "workspaceUpdated"
| "invoke"
| "partialMessage"
| "glamaModels"
| "openRouterModels"
| "openAiModels"
| "mcpServers"
| "enhancedPrompt"
| "commitSearchResults"
| "action"
| "state"
| "selectedImages"
| "ollamaModels"
| "lmStudioModels"
| "theme"
| "workspaceUpdated"
| "invoke"
| "partialMessage"
| "glamaModels"
| "openRouterModels"
| "openAiModels"
| "mcpServers"
| "enhancedPrompt"
| "commitSearchResults"
| "listApiConfig"
| "vsCodeLmModels"
| "vsCodeLmApiAvailable"
| "requestVsCodeLmModels"
text?: string
action?:
| "chatButtonClicked"
@@ -48,6 +48,12 @@ export interface ExtensionMessage {
openAiModels?: string[]
mcpServers?: McpServer[]
commits?: GitCommit[]
listApiConfig?: ApiConfigMeta[]
}
export interface ApiConfigMeta {
name: string
apiProvider?: ApiProvider
}
export interface ExtensionState {
@@ -56,12 +62,16 @@ export interface ExtensionState {
taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean
apiConfiguration?: ApiConfiguration
currentApiConfigName?: string
listApiConfigMeta?: ApiConfigMeta[]
customInstructions?: string
alwaysAllowReadOnly?: boolean
alwaysAllowWrite?: boolean
alwaysAllowExecute?: boolean
alwaysAllowBrowser?: boolean
alwaysAllowMcp?: boolean
alwaysApproveResubmit?: boolean
requestDelaySeconds: number
uriScheme?: string
allowedCommands?: string[]
soundEnabled?: boolean
@@ -109,6 +119,7 @@ export type ClineSay =
| "user_feedback"
| "user_feedback_diff"
| "api_req_retried"
| "api_req_retry_delayed"
| "command_output"
| "tool"
| "shell_integration_warning"

View File

@@ -4,56 +4,65 @@ export type AudioType = "notification" | "celebration" | "progress_loop"
export interface WebviewMessage {
type:
| "apiConfiguration"
| "customInstructions"
| "allowedCommands"
| "alwaysAllowReadOnly"
| "alwaysAllowWrite"
| "alwaysAllowExecute"
| "webviewDidLaunch"
| "newTask"
| "askResponse"
| "clearTask"
| "didShowAnnouncement"
| "selectImages"
| "exportCurrentTask"
| "showTaskWithId"
| "deleteTaskWithId"
| "exportTaskWithId"
| "resetState"
| "requestOllamaModels"
| "requestLmStudioModels"
| "requestVsCodeLmModels"
| "openImage"
| "openFile"
| "openMention"
| "cancelTask"
| "refreshOpenRouterModels"
| "refreshOpenAiModels"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "playSound"
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "browserViewportSize"
| "screenshotQuality"
| "openMcpSettings"
| "restartMcpServer"
| "toggleToolAlwaysAllow"
| "toggleMcpServer"
| "fuzzyMatchThreshold"
| "preferredLanguage"
| "writeDelayMs"
| "enhancePrompt"
| "enhancedPrompt"
| "draggedImages"
| "deleteMessage"
| "terminalOutputLineLimit"
| "mcpEnabled"
| "refreshGlamaModels"
| "searchCommits"
| "apiConfiguration"
| "currentApiConfigName"
| "upsertApiConfiguration"
| "deleteApiConfiguration"
| "loadApiConfiguration"
| "renameApiConfiguration"
| "getListApiConfiguration"
| "customInstructions"
| "allowedCommands"
| "alwaysAllowReadOnly"
| "alwaysAllowWrite"
| "alwaysAllowExecute"
| "webviewDidLaunch"
| "newTask"
| "askResponse"
| "clearTask"
| "didShowAnnouncement"
| "selectImages"
| "exportCurrentTask"
| "showTaskWithId"
| "deleteTaskWithId"
| "exportTaskWithId"
| "resetState"
| "requestOllamaModels"
| "requestLmStudioModels"
| "openImage"
| "openFile"
| "openMention"
| "cancelTask"
| "refreshGlamaModels"
| "refreshOpenRouterModels"
| "refreshOpenAiModels"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "playSound"
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "browserViewportSize"
| "screenshotQuality"
| "openMcpSettings"
| "restartMcpServer"
| "toggleToolAlwaysAllow"
| "toggleMcpServer"
| "fuzzyMatchThreshold"
| "preferredLanguage"
| "writeDelayMs"
| "enhancePrompt"
| "enhancedPrompt"
| "draggedImages"
| "deleteMessage"
| "terminalOutputLineLimit"
| "mcpEnabled"
| "searchCommits"
| "refreshGlamaModels"
| "alwaysApproveResubmit"
| "requestDelaySeconds"
| "setApiConfigPassword"
| "requestVsCodeLmModels"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse

View File

@@ -486,6 +486,14 @@ export type OpenAiNativeModelId = keyof typeof openAiNativeModels
export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-4o"
export const openAiNativeModels = {
// don't support tool use yet
"o1": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 15,
outputPrice: 60,
},
"o1-preview": {
maxTokens: 32_768,
contextWindow: 128_000,

View File

@@ -0,0 +1,19 @@
import { ApiConfiguration } from "../shared/api";
export function checkExistKey(config: ApiConfiguration | undefined) {
return config
? [
config.apiKey,
config.glamaApiKey,
config.openRouterApiKey,
config.awsRegion,
config.vertexProjectId,
config.openAiApiKey,
config.ollamaModelId,
config.lmStudioModelId,
config.geminiApiKey,
config.openAiNativeApiKey,
config.deepSeekApiKey
].some((key) => key !== undefined)
: false;
}

View File

@@ -1,15 +1,345 @@
import * as assert from "assert"
const assert = require('assert');
const vscode = require('vscode');
const path = require('path');
const fs = require('fs');
const dotenv = require('dotenv');
// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
import * as vscode from "vscode"
// import * as myExtension from '../../extension';
// Load test environment variables
const testEnvPath = path.join(__dirname, '.test_env');
dotenv.config({ path: testEnvPath });
suite("Extension Test Suite", () => {
vscode.window.showInformationMessage("Start all tests.")
suite('Roo Cline Extension Test Suite', () => {
vscode.window.showInformationMessage('Starting Roo Cline extension tests.');
test("Sample test", () => {
assert.strictEqual(-1, [1, 2, 3].indexOf(5))
assert.strictEqual(-1, [1, 2, 3].indexOf(0))
})
})
test('Extension should be present', () => {
const extension = vscode.extensions.getExtension('RooVeterinaryInc.roo-cline');
assert.notStrictEqual(extension, undefined);
});
test('Extension should activate', async () => {
const extension = vscode.extensions.getExtension('RooVeterinaryInc.roo-cline');
if (!extension) {
assert.fail('Extension not found');
}
await extension.activate();
assert.strictEqual(extension.isActive, true);
});
test('OpenRouter API key and models should be configured correctly', function(done) {
// @ts-ignore
this.timeout(60000); // Increase timeout to 60s for network requests
(async () => {
try {
// Get extension instance
const extension = vscode.extensions.getExtension('RooVeterinaryInc.roo-cline');
if (!extension) {
done(new Error('Extension not found'));
return;
}
// Verify API key is set and valid
const apiKey = process.env.OPEN_ROUTER_API_KEY;
if (!apiKey) {
done(new Error('OPEN_ROUTER_API_KEY environment variable is not set'));
return;
}
if (!apiKey.startsWith('sk-or-v1-')) {
done(new Error('OpenRouter API key should have correct format'));
return;
}
// Activate extension and get provider
const api = await extension.activate();
if (!api) {
done(new Error('Extension API not found'));
return;
}
// Get the provider from the extension's exports
const provider = api.sidebarProvider;
if (!provider) {
done(new Error('Provider not found'));
return;
}
// Set up the API configuration
await provider.updateGlobalState('apiProvider', 'openrouter');
await provider.storeSecret('openRouterApiKey', apiKey);
// Set up timeout to fail test if models don't load
const timeout = setTimeout(() => {
done(new Error('Timeout waiting for models to load'));
}, 30000);
// Wait for models to be loaded
const checkModels = setInterval(async () => {
try {
const models = await provider.readOpenRouterModels();
if (!models) {
return;
}
clearInterval(checkModels);
clearTimeout(timeout);
// Verify expected Claude models are available
const expectedModels = [
'anthropic/claude-3.5-sonnet:beta',
'anthropic/claude-3-sonnet:beta',
'anthropic/claude-3.5-sonnet',
'anthropic/claude-3.5-sonnet-20240620',
'anthropic/claude-3.5-sonnet-20240620:beta',
'anthropic/claude-3.5-haiku:beta'
];
for (const modelId of expectedModels) {
assert.strictEqual(
modelId in models,
true,
`Model ${modelId} should be available`
);
}
done();
} catch (error) {
clearInterval(checkModels);
clearTimeout(timeout);
done(error);
}
}, 1000);
// Trigger model loading
await provider.refreshOpenRouterModels();
} catch (error) {
done(error);
}
})();
});
test('Commands should be registered', async () => {
const commands = await vscode.commands.getCommands(true);
// Test core commands are registered
const expectedCommands = [
'roo-cline.plusButtonClicked',
'roo-cline.mcpButtonClicked',
'roo-cline.historyButtonClicked',
'roo-cline.popoutButtonClicked',
'roo-cline.settingsButtonClicked',
'roo-cline.openInNewTab'
];
for (const cmd of expectedCommands) {
assert.strictEqual(
commands.includes(cmd),
true,
`Command ${cmd} should be registered`
);
}
});
test('Views should be registered', () => {
const view = vscode.window.createWebviewPanel(
'roo-cline.SidebarProvider',
'Roo Cline',
vscode.ViewColumn.One,
{}
);
assert.notStrictEqual(view, undefined);
view.dispose();
});
test('Should handle prompt and response correctly', async function() {
// @ts-ignore
this.timeout(60000); // Increase timeout for API request
const timeout = 30000;
const interval = 1000;
// Get extension instance
const extension = vscode.extensions.getExtension('RooVeterinaryInc.roo-cline');
if (!extension) {
assert.fail('Extension not found');
return;
}
// Activate extension and get API
const api = await extension.activate();
if (!api) {
assert.fail('Extension API not found');
return;
}
// Get provider
const provider = api.sidebarProvider;
if (!provider) {
assert.fail('Provider not found');
return;
}
// Set up API configuration
await provider.updateGlobalState('apiProvider', 'openrouter');
await provider.updateGlobalState('openRouterModelId', 'anthropic/claude-3.5-sonnet');
const apiKey = process.env.OPEN_ROUTER_API_KEY;
if (!apiKey) {
assert.fail('OPEN_ROUTER_API_KEY environment variable is not set');
return;
}
await provider.storeSecret('openRouterApiKey', apiKey);
// Create webview panel with development options
const extensionUri = extension.extensionUri;
const panel = vscode.window.createWebviewPanel(
'roo-cline.SidebarProvider',
'Roo Cline',
vscode.ViewColumn.One,
{
enableScripts: true,
enableCommandUris: true,
retainContextWhenHidden: true,
localResourceRoots: [extensionUri]
}
);
try {
// Initialize webview with development context
panel.webview.options = {
enableScripts: true,
enableCommandUris: true,
localResourceRoots: [extensionUri]
};
// Initialize provider with panel
provider.resolveWebviewView(panel);
// Set up message tracking
let webviewReady = false;
let messagesReceived = false;
const originalPostMessage = provider.postMessageToWebview.bind(provider);
// @ts-ignore
provider.postMessageToWebview = async (message) => {
if (message.type === 'state') {
webviewReady = true;
console.log('Webview state received:', message);
if (message.state?.clineMessages?.length > 0) {
messagesReceived = true;
console.log('Messages in state:', message.state.clineMessages);
}
}
await originalPostMessage(message);
};
// Wait for webview to launch and receive initial state
let startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (webviewReady) {
// Wait an additional second for webview to fully initialize
await new Promise(resolve => setTimeout(resolve, 1000));
break;
}
await new Promise(resolve => setTimeout(resolve, interval));
}
if (!webviewReady) {
throw new Error('Timeout waiting for webview to be ready');
}
// Send webviewDidLaunch to initialize chat
await provider.postMessageToWebview({ type: 'webviewDidLaunch' });
console.log('Sent webviewDidLaunch');
// Wait for webview to fully initialize
await new Promise(resolve => setTimeout(resolve, 2000));
// Restore original postMessage
provider.postMessageToWebview = originalPostMessage;
// Wait for OpenRouter models to be fully loaded
startTime = Date.now();
while (Date.now() - startTime < timeout) {
const models = await provider.readOpenRouterModels();
if (models && Object.keys(models).length > 0) {
console.log('OpenRouter models loaded');
break;
}
await new Promise(resolve => setTimeout(resolve, interval));
}
// Send prompt
const prompt = "Hello world, what is your name?";
console.log('Sending prompt:', prompt);
// Start task
try {
await api.startNewTask(prompt);
console.log('Task started');
} catch (error) {
console.error('Error starting task:', error);
throw error;
}
// Wait for task to appear in history with tokens
startTime = Date.now();
while (Date.now() - startTime < timeout) {
const state = await provider.getState();
const task = state.taskHistory?.[0];
if (task && task.tokensOut > 0) {
console.log('Task completed with tokens:', task);
break;
}
await new Promise(resolve => setTimeout(resolve, interval));
}
// Wait for messages to be processed
startTime = Date.now();
let responseReceived = false;
while (Date.now() - startTime < timeout) {
// Check provider.clineMessages
const messages = provider.clineMessages;
if (messages && messages.length > 0) {
console.log('Provider messages:', JSON.stringify(messages, null, 2));
// @ts-ignore
const hasResponse = messages.some(m =>
m.type === 'say' &&
m.text &&
m.text.toLowerCase().includes('cline')
);
if (hasResponse) {
console.log('Found response containing "Cline" in provider messages');
responseReceived = true;
break;
}
}
// Check provider.cline.clineMessages
const clineMessages = provider.cline?.clineMessages;
if (clineMessages && clineMessages.length > 0) {
console.log('Cline messages:', JSON.stringify(clineMessages, null, 2));
// @ts-ignore
const hasResponse = clineMessages.some(m =>
m.type === 'say' &&
m.text &&
m.text.toLowerCase().includes('cline')
);
if (hasResponse) {
console.log('Found response containing "Cline" in cline messages');
responseReceived = true;
break;
}
}
await new Promise(resolve => setTimeout(resolve, interval));
}
if (!responseReceived) {
console.log('Final provider state:', await provider.getState());
console.log('Final cline messages:', provider.cline?.clineMessages);
throw new Error('Did not receive expected response containing "Cline"');
}
} finally {
panel.dispose();
}
});
});

19
src/test/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2020",
"lib": ["ES2020"],
"sourceMap": true,
"rootDir": "../..",
"strict": false,
"noImplicitAny": false,
"noImplicitThis": false,
"alwaysStrict": false,
"skipLibCheck": true,
"baseUrl": "../..",
"paths": {
"*": ["*", "src/*"]
}
},
"exclude": ["node_modules", ".vscode-test"]
}