mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-22 05:11:06 -05:00
merge: resolve conflicts after upstream merge
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
164
src/core/config/ConfigManager.ts
Normal file
164
src/core/config/ConfigManager.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
384
src/core/config/__tests__/ConfigManager.test.ts
Normal file
384
src/core/config/__tests__/ConfigManager.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user