From 5fbfe9b77553dcbbfc3a0161e66acbc7176d11c8 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 01:52:42 -0500 Subject: [PATCH 01/68] Add test coverage --- .gitignore | 5 +- src/api/providers/__tests__/anthropic.test.ts | 168 +++++++++ src/api/providers/__tests__/bedrock.test.ts | 299 +++++++-------- src/api/providers/__tests__/deepseek.test.ts | 324 +++++++++------- src/api/providers/__tests__/gemini.test.ts | 154 ++++++++ src/api/providers/__tests__/lmstudio.test.ts | 148 ++++++++ src/api/providers/__tests__/ollama.test.ts | 148 ++++++++ .../providers/__tests__/openai-native.test.ts | 230 ++++++++++++ src/api/providers/__tests__/openai.test.ts | 352 +++++++++--------- src/api/providers/__tests__/vertex.test.ts | 218 +++++++++++ .../transform/__tests__/openai-format.test.ts | 257 +++++++++++++ src/api/transform/__tests__/stream.test.ts | 114 ++++++ src/core/__tests__/Cline.test.ts | 3 +- .../__tests__/TerminalProcess.test.ts | 229 ++++++++++++ .../tree-sitter/__tests__/index.test.ts | 254 +++++++++++++ .../__tests__/languageParser.test.ts | 128 +++++++ src/utils/__tests__/cost.test.ts | 97 +++++ src/utils/__tests__/git.test.ts | 336 +++++++++++++++++ src/utils/__tests__/path.test.ts | 135 +++++++ 19 files changed, 3106 insertions(+), 493 deletions(-) create mode 100644 src/api/providers/__tests__/anthropic.test.ts create mode 100644 src/api/providers/__tests__/gemini.test.ts create mode 100644 src/api/providers/__tests__/lmstudio.test.ts create mode 100644 src/api/providers/__tests__/ollama.test.ts create mode 100644 src/api/providers/__tests__/openai-native.test.ts create mode 100644 src/api/providers/__tests__/vertex.test.ts create mode 100644 src/api/transform/__tests__/openai-format.test.ts create mode 100644 src/api/transform/__tests__/stream.test.ts create mode 100644 src/integrations/terminal/__tests__/TerminalProcess.test.ts create mode 100644 src/services/tree-sitter/__tests__/index.test.ts create mode 100644 src/services/tree-sitter/__tests__/languageParser.test.ts create mode 100644 src/utils/__tests__/cost.test.ts create mode 100644 src/utils/__tests__/git.test.ts create mode 100644 src/utils/__tests__/path.test.ts diff --git a/.gitignore b/.gitignore index 4914bf2..b508d9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ out dist node_modules -.vscode-test/ +coverage/ .DS_Store @@ -13,4 +13,5 @@ roo-cline-*.vsix /local-prompts # Test environment -.test_env \ No newline at end of file +.test_env +.vscode-test/ diff --git a/src/api/providers/__tests__/anthropic.test.ts b/src/api/providers/__tests__/anthropic.test.ts new file mode 100644 index 0000000..f730f78 --- /dev/null +++ b/src/api/providers/__tests__/anthropic.test.ts @@ -0,0 +1,168 @@ +import { AnthropicHandler } from '../anthropic'; +import { ApiHandlerOptions } from '../../../shared/api'; +import { ApiStream } from '../../transform/stream'; +import { Anthropic } from '@anthropic-ai/sdk'; + +// Mock Anthropic client +const mockBetaCreate = jest.fn(); +const mockCreate = jest.fn(); +jest.mock('@anthropic-ai/sdk', () => { + return { + Anthropic: jest.fn().mockImplementation(() => ({ + beta: { + promptCaching: { + messages: { + create: mockBetaCreate.mockImplementation(async () => ({ + async *[Symbol.asyncIterator]() { + yield { + type: 'message_start', + message: { + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 10 + } + } + }; + yield { + type: 'content_block_start', + index: 0, + content_block: { + type: 'text', + text: 'Hello' + } + }; + yield { + type: 'content_block_delta', + delta: { + type: 'text_delta', + text: ' world' + } + }; + } + })) + } + } + }, + messages: { + create: mockCreate + } + })) + }; +}); + +describe('AnthropicHandler', () => { + let handler: AnthropicHandler; + let mockOptions: ApiHandlerOptions; + + beforeEach(() => { + mockOptions = { + apiKey: 'test-api-key', + apiModelId: 'claude-3-5-sonnet-20241022' + }; + handler = new AnthropicHandler(mockOptions); + mockBetaCreate.mockClear(); + mockCreate.mockClear(); + }); + + describe('constructor', () => { + it('should initialize with provided options', () => { + expect(handler).toBeInstanceOf(AnthropicHandler); + expect(handler.getModel().id).toBe(mockOptions.apiModelId); + }); + + it('should initialize with undefined API key', () => { + // The SDK will handle API key validation, so we just verify it initializes + const handlerWithoutKey = new AnthropicHandler({ + ...mockOptions, + apiKey: undefined + }); + expect(handlerWithoutKey).toBeInstanceOf(AnthropicHandler); + }); + + it('should use custom base URL if provided', () => { + const customBaseUrl = 'https://custom.anthropic.com'; + const handlerWithCustomUrl = new AnthropicHandler({ + ...mockOptions, + anthropicBaseUrl: customBaseUrl + }); + expect(handlerWithCustomUrl).toBeInstanceOf(AnthropicHandler); + }); + }); + + describe('createMessage', () => { + const systemPrompt = 'You are a helpful assistant.'; + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: 'user', + content: [{ + type: 'text' as const, + text: 'Hello!' + }] + } + ]; + + it('should handle prompt caching for supported models', async () => { + const stream = handler.createMessage(systemPrompt, [ + { + role: 'user', + content: [{ type: 'text' as const, text: 'First message' }] + }, + { + role: 'assistant', + content: [{ type: 'text' as const, text: 'Response' }] + }, + { + role: 'user', + content: [{ type: 'text' as const, text: 'Second message' }] + } + ]); + + const chunks: any[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + // Verify usage information + const usageChunk = chunks.find(chunk => chunk.type === 'usage'); + expect(usageChunk).toBeDefined(); + expect(usageChunk?.inputTokens).toBe(100); + expect(usageChunk?.outputTokens).toBe(50); + expect(usageChunk?.cacheWriteTokens).toBe(20); + expect(usageChunk?.cacheReadTokens).toBe(10); + + // Verify text content + const textChunks = chunks.filter(chunk => chunk.type === 'text'); + expect(textChunks).toHaveLength(2); + expect(textChunks[0].text).toBe('Hello'); + expect(textChunks[1].text).toBe(' world'); + + // Verify beta API was used + expect(mockBetaCreate).toHaveBeenCalled(); + expect(mockCreate).not.toHaveBeenCalled(); + }); + }); + + describe('getModel', () => { + it('should return default model if no model ID is provided', () => { + const handlerWithoutModel = new AnthropicHandler({ + ...mockOptions, + apiModelId: undefined + }); + const model = handlerWithoutModel.getModel(); + expect(model.id).toBeDefined(); + expect(model.info).toBeDefined(); + }); + + it('should return specified model if valid model ID is provided', () => { + const model = handler.getModel(); + expect(model.id).toBe(mockOptions.apiModelId); + expect(model.info).toBeDefined(); + expect(model.info.maxTokens).toBe(8192); + expect(model.info.contextWindow).toBe(200_000); + expect(model.info.supportsImages).toBe(true); + expect(model.info.supportsPromptCache).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/src/api/providers/__tests__/bedrock.test.ts b/src/api/providers/__tests__/bedrock.test.ts index a95aa7b..36cccc1 100644 --- a/src/api/providers/__tests__/bedrock.test.ts +++ b/src/api/providers/__tests__/bedrock.test.ts @@ -1,191 +1,144 @@ -import { AwsBedrockHandler } from '../bedrock' -import { ApiHandlerOptions, ModelInfo } from '../../../shared/api' -import { Anthropic } from '@anthropic-ai/sdk' -import { StreamEvent } from '../bedrock' - -// Simplified mock for BedrockRuntimeClient -class MockBedrockRuntimeClient { - private _region: string - private mockStream: StreamEvent[] = [] - - constructor(config: { region: string }) { - this._region = config.region - } - - async send(command: any): Promise<{ stream: AsyncIterableIterator }> { - return { - stream: this.createMockStream() - } - } - - private createMockStream(): AsyncIterableIterator { - const self = this; - return { - async *[Symbol.asyncIterator]() { - for (const event of self.mockStream) { - yield event; - } - }, - next: async () => { - const value = this.mockStream.shift(); - return value ? { value, done: false } : { value: undefined, done: true }; - }, - return: async () => ({ value: undefined, done: true }), - throw: async (e) => { throw e; } - }; - } - - setMockStream(stream: StreamEvent[]) { - this.mockStream = stream; - } - - get config() { - return { region: this._region }; - } -} +import { AwsBedrockHandler } from '../bedrock'; +import { MessageContent } from '../../../shared/api'; +import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'; +import { Anthropic } from '@anthropic-ai/sdk'; describe('AwsBedrockHandler', () => { - const mockOptions: ApiHandlerOptions = { - awsRegion: 'us-east-1', - awsAccessKey: 'mock-access-key', - awsSecretKey: 'mock-secret-key', - apiModelId: 'anthropic.claude-v2', - } + let handler: AwsBedrockHandler; - // Override the BedrockRuntimeClient creation in the constructor - class TestAwsBedrockHandler extends AwsBedrockHandler { - constructor(options: ApiHandlerOptions, mockClient?: MockBedrockRuntimeClient) { - super(options) - if (mockClient) { - // Force type casting to bypass strict type checking - (this as any)['client'] = mockClient - } - } - } + beforeEach(() => { + handler = new AwsBedrockHandler({ + apiModelId: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + awsAccessKey: 'test-access-key', + awsSecretKey: 'test-secret-key', + awsRegion: 'us-east-1' + }); + }); - test('constructor initializes with correct AWS credentials', () => { - const mockClient = new MockBedrockRuntimeClient({ - region: 'us-east-1' - }) + describe('constructor', () => { + it('should initialize with provided config', () => { + expect(handler['options'].awsAccessKey).toBe('test-access-key'); + expect(handler['options'].awsSecretKey).toBe('test-secret-key'); + expect(handler['options'].awsRegion).toBe('us-east-1'); + expect(handler['options'].apiModelId).toBe('anthropic.claude-3-5-sonnet-20241022-v2:0'); + }); - const handler = new TestAwsBedrockHandler(mockOptions, mockClient) - - // Verify that the client is created with the correct configuration - expect(handler['client']).toBeDefined() - expect(handler['client'].config.region).toBe('us-east-1') - }) + it('should initialize with missing AWS credentials', () => { + const handlerWithoutCreds = new AwsBedrockHandler({ + apiModelId: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + awsRegion: 'us-east-1' + }); + expect(handlerWithoutCreds).toBeInstanceOf(AwsBedrockHandler); + }); + }); - test('getModel returns correct model info', () => { - const mockClient = new MockBedrockRuntimeClient({ - region: 'us-east-1' - }) - - const handler = new TestAwsBedrockHandler(mockOptions, mockClient) - const result = handler.getModel() - - expect(result).toEqual({ - id: 'anthropic.claude-v2', - info: { - maxTokens: 5000, - contextWindow: 128_000, - supportsPromptCache: false - } - }) - }) - - test('createMessage handles successful stream events', async () => { - const mockClient = new MockBedrockRuntimeClient({ - region: 'us-east-1' - }) - - // Mock stream events - const mockStreamEvents: StreamEvent[] = [ + describe('createMessage', () => { + const mockMessages: Anthropic.Messages.MessageParam[] = [ { - metadata: { - usage: { - inputTokens: 50, - outputTokens: 100 - } - } + role: 'user', + content: 'Hello' }, { - contentBlockStart: { - start: { - text: 'Hello' - } - } - }, - { - contentBlockDelta: { - delta: { - text: ' world' - } - } - }, - { - messageStop: { - stopReason: 'end_turn' - } + role: 'assistant', + content: 'Hi there!' } - ] + ]; - mockClient.setMockStream(mockStreamEvents) + const systemPrompt = 'You are a helpful assistant'; - const handler = new TestAwsBedrockHandler(mockOptions, mockClient) + it('should handle text messages correctly', async () => { + const mockResponse = { + messages: [{ + role: 'assistant', + content: [{ type: 'text', text: 'Hello! How can I help you?' }] + }], + usage: { + input_tokens: 10, + output_tokens: 5 + } + }; - const systemPrompt = 'You are a helpful assistant' - const messages: Anthropic.Messages.MessageParam[] = [ - { role: 'user', content: 'Say hello' } - ] + // Mock AWS SDK invoke + const mockStream = { + [Symbol.asyncIterator]: async function* () { + yield { + metadata: { + usage: { + inputTokens: 10, + outputTokens: 5 + } + } + }; + } + }; - const generator = handler.createMessage(systemPrompt, messages) - const chunks = [] + const mockInvoke = jest.fn().mockResolvedValue({ + stream: mockStream + }); - for await (const chunk of generator) { - chunks.push(chunk) - } + handler['client'] = { + send: mockInvoke + } as unknown as BedrockRuntimeClient; - // Verify the chunks match expected stream events - expect(chunks).toHaveLength(3) - expect(chunks[0]).toEqual({ - type: 'usage', - inputTokens: 50, - outputTokens: 100 - }) - expect(chunks[1]).toEqual({ - type: 'text', - text: 'Hello' - }) - expect(chunks[2]).toEqual({ - type: 'text', - text: ' world' - }) - }) - - test('createMessage handles error scenarios', async () => { - const mockClient = new MockBedrockRuntimeClient({ - region: 'us-east-1' - }) - - // Simulate an error by overriding the send method - mockClient.send = () => { - throw new Error('API request failed') - } - - const handler = new TestAwsBedrockHandler(mockOptions, mockClient) - - const systemPrompt = 'You are a helpful assistant' - const messages: Anthropic.Messages.MessageParam[] = [ - { role: 'user', content: 'Cause an error' } - ] - - await expect(async () => { - const generator = handler.createMessage(systemPrompt, messages) - const chunks = [] + const stream = handler.createMessage(systemPrompt, mockMessages); + const chunks = []; - for await (const chunk of generator) { - chunks.push(chunk) + for await (const chunk of stream) { + chunks.push(chunk); } - }).rejects.toThrow('API request failed') - }) -}) + + expect(chunks.length).toBeGreaterThan(0); + expect(chunks[0]).toEqual({ + type: 'usage', + inputTokens: 10, + outputTokens: 5 + }); + + expect(mockInvoke).toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ + modelId: 'anthropic.claude-3-5-sonnet-20241022-v2:0' + }) + })); + }); + + it('should handle API errors', async () => { + // Mock AWS SDK invoke with error + const mockInvoke = jest.fn().mockRejectedValue(new Error('AWS Bedrock error')); + + handler['client'] = { + send: mockInvoke + } as unknown as BedrockRuntimeClient; + + const stream = handler.createMessage(systemPrompt, mockMessages); + + await expect(async () => { + for await (const chunk of stream) { + // Should throw before yielding any chunks + } + }).rejects.toThrow('AWS Bedrock error'); + }); + }); + + describe('getModel', () => { + it('should return correct model info in test environment', () => { + const modelInfo = handler.getModel(); + expect(modelInfo.id).toBe('anthropic.claude-3-5-sonnet-20241022-v2:0'); + expect(modelInfo.info).toBeDefined(); + expect(modelInfo.info.maxTokens).toBe(5000); // Test environment value + expect(modelInfo.info.contextWindow).toBe(128_000); // Test environment value + }); + + it('should return test model info for invalid model in test environment', () => { + const invalidHandler = new AwsBedrockHandler({ + apiModelId: 'invalid-model', + awsAccessKey: 'test-access-key', + awsSecretKey: 'test-secret-key', + awsRegion: 'us-east-1' + }); + const modelInfo = invalidHandler.getModel(); + expect(modelInfo.id).toBe('invalid-model'); // In test env, returns whatever is passed + expect(modelInfo.info.maxTokens).toBe(5000); + expect(modelInfo.info.contextWindow).toBe(128_000); + }); + }); +}); diff --git a/src/api/providers/__tests__/deepseek.test.ts b/src/api/providers/__tests__/deepseek.test.ts index 85918be..edf6598 100644 --- a/src/api/providers/__tests__/deepseek.test.ts +++ b/src/api/providers/__tests__/deepseek.test.ts @@ -1,167 +1,203 @@ -import { DeepSeekHandler } from '../deepseek' -import { ApiHandlerOptions } from '../../../shared/api' -import OpenAI from 'openai' -import { Anthropic } from '@anthropic-ai/sdk' +import { DeepSeekHandler } from '../deepseek'; +import { ApiHandlerOptions, deepSeekDefaultModelId } from '../../../shared/api'; +import OpenAI from 'openai'; +import { Anthropic } from '@anthropic-ai/sdk'; -// Mock dependencies -jest.mock('openai') +// Mock OpenAI client +const mockCreate = jest.fn(); +jest.mock('openai', () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: mockCreate.mockImplementation(async (options) => { + if (!options.stream) { + return { + id: 'test-completion', + choices: [{ + message: { role: 'assistant', content: 'Test response', refusal: null }, + finish_reason: 'stop', + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + } + }; + } + + // Return async iterator for streaming + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ + delta: { content: 'Test response' }, + index: 0 + }], + usage: null + }; + yield { + choices: [{ + delta: {}, + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + } + }; + } + }; + }) + } + } + })) + }; +}); describe('DeepSeekHandler', () => { - - const mockOptions: ApiHandlerOptions = { - deepSeekApiKey: 'test-key', - deepSeekModelId: 'deepseek-chat', - } + let handler: DeepSeekHandler; + let mockOptions: ApiHandlerOptions; beforeEach(() => { - jest.clearAllMocks() - }) + mockOptions = { + deepSeekApiKey: 'test-api-key', + deepSeekModelId: 'deepseek-chat', + deepSeekBaseUrl: 'https://api.deepseek.com/v1' + }; + handler = new DeepSeekHandler(mockOptions); + mockCreate.mockClear(); + }); - test('constructor initializes with correct options', () => { - const handler = new DeepSeekHandler(mockOptions) - expect(handler).toBeInstanceOf(DeepSeekHandler) - expect(OpenAI).toHaveBeenCalledWith({ - baseURL: 'https://api.deepseek.com/v1', - apiKey: mockOptions.deepSeekApiKey, - }) - }) + describe('constructor', () => { + it('should initialize with provided options', () => { + expect(handler).toBeInstanceOf(DeepSeekHandler); + expect(handler.getModel().id).toBe(mockOptions.deepSeekModelId); + }); - test('getModel returns correct model info', () => { - const handler = new DeepSeekHandler(mockOptions) - const result = handler.getModel() - - expect(result).toEqual({ - id: mockOptions.deepSeekModelId, - info: expect.objectContaining({ - maxTokens: 8192, - contextWindow: 64000, - supportsPromptCache: false, - supportsImages: false, - inputPrice: 0.014, - outputPrice: 0.28, - }) - }) - }) + it('should throw error if API key is missing', () => { + expect(() => { + new DeepSeekHandler({ + ...mockOptions, + deepSeekApiKey: undefined + }); + }).toThrow('DeepSeek API key is required'); + }); - test('getModel returns default model info when no model specified', () => { - const handler = new DeepSeekHandler({ deepSeekApiKey: 'test-key' }) - const result = handler.getModel() - - expect(result.id).toBe('deepseek-chat') - expect(result.info.maxTokens).toBe(8192) - }) + it('should use default model ID if not provided', () => { + const handlerWithoutModel = new DeepSeekHandler({ + ...mockOptions, + deepSeekModelId: undefined + }); + expect(handlerWithoutModel.getModel().id).toBe(deepSeekDefaultModelId); + }); - test('createMessage handles string content correctly', async () => { - const handler = new DeepSeekHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ - delta: { - content: 'test response' - } - }] - } - } - } + it('should use default base URL if not provided', () => { + const handlerWithoutBaseUrl = new DeepSeekHandler({ + ...mockOptions, + deepSeekBaseUrl: undefined + }); + expect(handlerWithoutBaseUrl).toBeInstanceOf(DeepSeekHandler); + // The base URL is passed to OpenAI client internally + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ + baseURL: 'https://api.deepseek.com/v1' + })); + }); - const mockCreate = jest.fn().mockResolvedValue(mockStream) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate } - } as any + it('should use custom base URL if provided', () => { + const customBaseUrl = 'https://custom.deepseek.com/v1'; + const handlerWithCustomUrl = new DeepSeekHandler({ + ...mockOptions, + deepSeekBaseUrl: customBaseUrl + }); + expect(handlerWithCustomUrl).toBeInstanceOf(DeepSeekHandler); + // The custom base URL is passed to OpenAI client + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ + baseURL: customBaseUrl + })); + }); - const systemPrompt = 'test system prompt' - const messages: Anthropic.Messages.MessageParam[] = [ - { role: 'user', content: 'test message' } - ] + it('should set includeMaxTokens to true', () => { + // Create a new handler and verify OpenAI client was called with includeMaxTokens + new DeepSeekHandler(mockOptions); + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ + apiKey: mockOptions.deepSeekApiKey + })); + }); + }); - const generator = handler.createMessage(systemPrompt, messages) - const chunks = [] - - for await (const chunk of generator) { - chunks.push(chunk) - } + describe('getModel', () => { + it('should return model info for valid model ID', () => { + const model = handler.getModel(); + expect(model.id).toBe(mockOptions.deepSeekModelId); + expect(model.info).toBeDefined(); + expect(model.info.maxTokens).toBe(8192); + expect(model.info.contextWindow).toBe(64_000); + expect(model.info.supportsImages).toBe(false); + expect(model.info.supportsPromptCache).toBe(false); + }); - expect(chunks).toHaveLength(1) - expect(chunks[0]).toEqual({ - type: 'text', - text: 'test response' - }) + it('should return provided model ID with default model info if model does not exist', () => { + const handlerWithInvalidModel = new DeepSeekHandler({ + ...mockOptions, + deepSeekModelId: 'invalid-model' + }); + const model = handlerWithInvalidModel.getModel(); + expect(model.id).toBe('invalid-model'); // Returns provided ID + expect(model.info).toBeDefined(); + expect(model.info).toBe(handler.getModel().info); // But uses default model info + }); - expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ - model: mockOptions.deepSeekModelId, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: 'test message' } - ], - temperature: 0, - stream: true, - max_tokens: 8192, - stream_options: { include_usage: true } - })) - }) + it('should return default model if no model ID is provided', () => { + const handlerWithoutModel = new DeepSeekHandler({ + ...mockOptions, + deepSeekModelId: undefined + }); + const model = handlerWithoutModel.getModel(); + expect(model.id).toBe(deepSeekDefaultModelId); + expect(model.info).toBeDefined(); + }); + }); - test('createMessage handles complex content correctly', async () => { - const handler = new DeepSeekHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ - delta: { - content: 'test response' - } - }] - } - } - } - - const mockCreate = jest.fn().mockResolvedValue(mockStream) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate } - } as any - - const systemPrompt = 'test system prompt' + describe('createMessage', () => { + const systemPrompt = 'You are a helpful assistant.'; const messages: Anthropic.Messages.MessageParam[] = [ { role: 'user', - content: [ - { type: 'text', text: 'part 1' }, - { type: 'text', text: 'part 2' } - ] + content: [{ + type: 'text' as const, + text: 'Hello!' + }] } - ] + ]; - const generator = handler.createMessage(systemPrompt, messages) - await generator.next() - - expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ - messages: [ - { role: 'system', content: systemPrompt }, - { - role: 'user', - content: [ - { type: 'text', text: 'part 1' }, - { type: 'text', text: 'part 2' } - ] - } - ] - })) - }) - - test('createMessage handles API errors', async () => { - const handler = new DeepSeekHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - throw new Error('API Error') + it('should handle streaming responses', async () => { + const stream = handler.createMessage(systemPrompt, messages); + const chunks: any[] = []; + for await (const chunk of stream) { + chunks.push(chunk); } - } - const mockCreate = jest.fn().mockResolvedValue(mockStream) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate } - } as any + expect(chunks.length).toBeGreaterThan(0); + const textChunks = chunks.filter(chunk => chunk.type === 'text'); + expect(textChunks).toHaveLength(1); + expect(textChunks[0].text).toBe('Test response'); + }); - const generator = handler.createMessage('test', []) - await expect(generator.next()).rejects.toThrow('API Error') - }) -}) \ No newline at end of file + it('should include usage information', async () => { + const stream = handler.createMessage(systemPrompt, messages); + const chunks: any[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const usageChunks = chunks.filter(chunk => chunk.type === 'usage'); + expect(usageChunks.length).toBeGreaterThan(0); + expect(usageChunks[0].inputTokens).toBe(10); + expect(usageChunks[0].outputTokens).toBe(5); + }); + }); +}); \ No newline at end of file diff --git a/src/api/providers/__tests__/gemini.test.ts b/src/api/providers/__tests__/gemini.test.ts new file mode 100644 index 0000000..b979714 --- /dev/null +++ b/src/api/providers/__tests__/gemini.test.ts @@ -0,0 +1,154 @@ +import { GeminiHandler } from '../gemini'; +import { Anthropic } from '@anthropic-ai/sdk'; +import { GoogleGenerativeAI } from '@google/generative-ai'; + +// Mock the Google Generative AI SDK +jest.mock('@google/generative-ai', () => ({ + GoogleGenerativeAI: jest.fn().mockImplementation(() => ({ + getGenerativeModel: jest.fn().mockReturnValue({ + generateContentStream: jest.fn() + }) + })) +})); + +describe('GeminiHandler', () => { + let handler: GeminiHandler; + + beforeEach(() => { + handler = new GeminiHandler({ + apiKey: 'test-key', + apiModelId: 'gemini-2.0-flash-thinking-exp-1219', + geminiApiKey: 'test-key' + }); + }); + + describe('constructor', () => { + it('should initialize with provided config', () => { + expect(handler['options'].geminiApiKey).toBe('test-key'); + expect(handler['options'].apiModelId).toBe('gemini-2.0-flash-thinking-exp-1219'); + }); + + it('should throw if API key is missing', () => { + expect(() => { + new GeminiHandler({ + apiModelId: 'gemini-2.0-flash-thinking-exp-1219', + geminiApiKey: '' + }); + }).toThrow('API key is required for Google Gemini'); + }); + }); + + describe('createMessage', () => { + const mockMessages: Anthropic.Messages.MessageParam[] = [ + { + role: 'user', + content: 'Hello' + }, + { + role: 'assistant', + content: 'Hi there!' + } + ]; + + const systemPrompt = 'You are a helpful assistant'; + + it('should handle text messages correctly', async () => { + // Mock the stream response + const mockStream = { + stream: [ + { text: () => 'Hello' }, + { text: () => ' world!' } + ], + response: { + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5 + } + } + }; + + // Setup the mock implementation + const mockGenerateContentStream = jest.fn().mockResolvedValue(mockStream); + const mockGetGenerativeModel = jest.fn().mockReturnValue({ + generateContentStream: mockGenerateContentStream + }); + + (handler['client'] as any).getGenerativeModel = mockGetGenerativeModel; + + const stream = handler.createMessage(systemPrompt, mockMessages); + const chunks = []; + + for await (const chunk of stream) { + chunks.push(chunk); + } + + // Should have 3 chunks: 'Hello', ' world!', and usage info + expect(chunks.length).toBe(3); + expect(chunks[0]).toEqual({ + type: 'text', + text: 'Hello' + }); + expect(chunks[1]).toEqual({ + type: 'text', + text: ' world!' + }); + expect(chunks[2]).toEqual({ + type: 'usage', + inputTokens: 10, + outputTokens: 5 + }); + + // Verify the model configuration + expect(mockGetGenerativeModel).toHaveBeenCalledWith({ + model: 'gemini-2.0-flash-thinking-exp-1219', + systemInstruction: systemPrompt + }); + + // Verify generation config + expect(mockGenerateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + generationConfig: { + temperature: 0 + } + }) + ); + }); + + it('should handle API errors', async () => { + const mockError = new Error('Gemini API error'); + const mockGenerateContentStream = jest.fn().mockRejectedValue(mockError); + const mockGetGenerativeModel = jest.fn().mockReturnValue({ + generateContentStream: mockGenerateContentStream + }); + + (handler['client'] as any).getGenerativeModel = mockGetGenerativeModel; + + const stream = handler.createMessage(systemPrompt, mockMessages); + + await expect(async () => { + for await (const chunk of stream) { + // Should throw before yielding any chunks + } + }).rejects.toThrow('Gemini API error'); + }); + }); + + describe('getModel', () => { + it('should return correct model info', () => { + const modelInfo = handler.getModel(); + expect(modelInfo.id).toBe('gemini-2.0-flash-thinking-exp-1219'); + expect(modelInfo.info).toBeDefined(); + expect(modelInfo.info.maxTokens).toBe(8192); + expect(modelInfo.info.contextWindow).toBe(32_767); + }); + + it('should return default model if invalid model specified', () => { + const invalidHandler = new GeminiHandler({ + apiModelId: 'invalid-model', + geminiApiKey: 'test-key' + }); + const modelInfo = invalidHandler.getModel(); + expect(modelInfo.id).toBe('gemini-2.0-flash-thinking-exp-1219'); // Default model + }); + }); +}); \ No newline at end of file diff --git a/src/api/providers/__tests__/lmstudio.test.ts b/src/api/providers/__tests__/lmstudio.test.ts new file mode 100644 index 0000000..9e24053 --- /dev/null +++ b/src/api/providers/__tests__/lmstudio.test.ts @@ -0,0 +1,148 @@ +import { LmStudioHandler } from '../lmstudio'; +import { Anthropic } from '@anthropic-ai/sdk'; +import OpenAI from 'openai'; + +// Mock OpenAI SDK +jest.mock('openai', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: jest.fn() + } + } + })) +})); + +describe('LmStudioHandler', () => { + let handler: LmStudioHandler; + + beforeEach(() => { + handler = new LmStudioHandler({ + lmStudioModelId: 'mistral-7b', + lmStudioBaseUrl: 'http://localhost:1234' + }); + }); + + describe('constructor', () => { + it('should initialize with provided config', () => { + expect(OpenAI).toHaveBeenCalledWith({ + baseURL: 'http://localhost:1234/v1', + apiKey: 'noop' + }); + }); + + it('should use default base URL if not provided', () => { + const defaultHandler = new LmStudioHandler({ + lmStudioModelId: 'mistral-7b' + }); + + expect(OpenAI).toHaveBeenCalledWith({ + baseURL: 'http://localhost:1234/v1', + apiKey: 'noop' + }); + }); + }); + + describe('createMessage', () => { + const mockMessages: Anthropic.Messages.MessageParam[] = [ + { + role: 'user', + content: 'Hello' + }, + { + role: 'assistant', + content: 'Hi there!' + } + ]; + + const systemPrompt = 'You are a helpful assistant'; + + it('should handle streaming responses correctly', async () => { + const mockStream = [ + { + choices: [{ + delta: { content: 'Hello' } + }] + }, + { + choices: [{ + delta: { content: ' world!' } + }] + } + ]; + + // Setup async iterator for mock stream + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk; + } + } + }; + + const mockCreate = jest.fn().mockResolvedValue(asyncIterator); + (handler['client'].chat.completions as any).create = mockCreate; + + const stream = handler.createMessage(systemPrompt, mockMessages); + const chunks = []; + + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(2); + expect(chunks[0]).toEqual({ + type: 'text', + text: 'Hello' + }); + expect(chunks[1]).toEqual({ + type: 'text', + text: ' world!' + }); + + expect(mockCreate).toHaveBeenCalledWith({ + model: 'mistral-7b', + messages: expect.arrayContaining([ + { + role: 'system', + content: systemPrompt + } + ]), + temperature: 0, + stream: true + }); + }); + + it('should handle API errors with custom message', async () => { + const mockError = new Error('LM Studio API error'); + const mockCreate = jest.fn().mockRejectedValue(mockError); + (handler['client'].chat.completions as any).create = mockCreate; + + const stream = handler.createMessage(systemPrompt, mockMessages); + + await expect(async () => { + for await (const chunk of stream) { + // Should throw before yielding any chunks + } + }).rejects.toThrow('Please check the LM Studio developer logs to debug what went wrong'); + }); + }); + + describe('getModel', () => { + it('should return model info with sane defaults', () => { + const modelInfo = handler.getModel(); + expect(modelInfo.id).toBe('mistral-7b'); + expect(modelInfo.info).toBeDefined(); + expect(modelInfo.info.maxTokens).toBe(-1); + expect(modelInfo.info.contextWindow).toBe(128_000); + }); + + it('should return empty string as model ID if not provided', () => { + const noModelHandler = new LmStudioHandler({}); + const modelInfo = noModelHandler.getModel(); + expect(modelInfo.id).toBe(''); + expect(modelInfo.info).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/api/providers/__tests__/ollama.test.ts b/src/api/providers/__tests__/ollama.test.ts new file mode 100644 index 0000000..3d74e88 --- /dev/null +++ b/src/api/providers/__tests__/ollama.test.ts @@ -0,0 +1,148 @@ +import { OllamaHandler } from '../ollama'; +import { Anthropic } from '@anthropic-ai/sdk'; +import OpenAI from 'openai'; + +// Mock OpenAI SDK +jest.mock('openai', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: jest.fn() + } + } + })) +})); + +describe('OllamaHandler', () => { + let handler: OllamaHandler; + + beforeEach(() => { + handler = new OllamaHandler({ + ollamaModelId: 'llama2', + ollamaBaseUrl: 'http://localhost:11434' + }); + }); + + describe('constructor', () => { + it('should initialize with provided config', () => { + expect(OpenAI).toHaveBeenCalledWith({ + baseURL: 'http://localhost:11434/v1', + apiKey: 'ollama' + }); + }); + + it('should use default base URL if not provided', () => { + const defaultHandler = new OllamaHandler({ + ollamaModelId: 'llama2' + }); + + expect(OpenAI).toHaveBeenCalledWith({ + baseURL: 'http://localhost:11434/v1', + apiKey: 'ollama' + }); + }); + }); + + describe('createMessage', () => { + const mockMessages: Anthropic.Messages.MessageParam[] = [ + { + role: 'user', + content: 'Hello' + }, + { + role: 'assistant', + content: 'Hi there!' + } + ]; + + const systemPrompt = 'You are a helpful assistant'; + + it('should handle streaming responses correctly', async () => { + const mockStream = [ + { + choices: [{ + delta: { content: 'Hello' } + }] + }, + { + choices: [{ + delta: { content: ' world!' } + }] + } + ]; + + // Setup async iterator for mock stream + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk; + } + } + }; + + const mockCreate = jest.fn().mockResolvedValue(asyncIterator); + (handler['client'].chat.completions as any).create = mockCreate; + + const stream = handler.createMessage(systemPrompt, mockMessages); + const chunks = []; + + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(2); + expect(chunks[0]).toEqual({ + type: 'text', + text: 'Hello' + }); + expect(chunks[1]).toEqual({ + type: 'text', + text: ' world!' + }); + + expect(mockCreate).toHaveBeenCalledWith({ + model: 'llama2', + messages: expect.arrayContaining([ + { + role: 'system', + content: systemPrompt + } + ]), + temperature: 0, + stream: true + }); + }); + + it('should handle API errors', async () => { + const mockError = new Error('Ollama API error'); + const mockCreate = jest.fn().mockRejectedValue(mockError); + (handler['client'].chat.completions as any).create = mockCreate; + + const stream = handler.createMessage(systemPrompt, mockMessages); + + await expect(async () => { + for await (const chunk of stream) { + // Should throw before yielding any chunks + } + }).rejects.toThrow('Ollama API error'); + }); + }); + + describe('getModel', () => { + it('should return model info with sane defaults', () => { + const modelInfo = handler.getModel(); + expect(modelInfo.id).toBe('llama2'); + expect(modelInfo.info).toBeDefined(); + expect(modelInfo.info.maxTokens).toBe(-1); + expect(modelInfo.info.contextWindow).toBe(128_000); + }); + + it('should return empty string as model ID if not provided', () => { + const noModelHandler = new OllamaHandler({}); + const modelInfo = noModelHandler.getModel(); + expect(modelInfo.id).toBe(''); + expect(modelInfo.info).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/api/providers/__tests__/openai-native.test.ts b/src/api/providers/__tests__/openai-native.test.ts new file mode 100644 index 0000000..ece832a --- /dev/null +++ b/src/api/providers/__tests__/openai-native.test.ts @@ -0,0 +1,230 @@ +import { OpenAiNativeHandler } from "../openai-native" +import OpenAI from "openai" +import { ApiHandlerOptions, openAiNativeDefaultModelId } from "../../../shared/api" +import { Anthropic } from "@anthropic-ai/sdk" + +// Mock OpenAI +jest.mock("openai") + +describe("OpenAiNativeHandler", () => { + let handler: OpenAiNativeHandler + let mockOptions: ApiHandlerOptions + let mockOpenAIClient: jest.Mocked + let mockCreate: jest.Mock + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks() + + // Setup mock options + mockOptions = { + openAiNativeApiKey: "test-api-key", + apiModelId: "gpt-4o", // Use the correct model ID from shared/api.ts + } + + // Setup mock create function + mockCreate = jest.fn() + + // Setup mock OpenAI client + mockOpenAIClient = { + chat: { + completions: { + create: mockCreate, + }, + }, + } as unknown as jest.Mocked + + // Mock OpenAI constructor + ;(OpenAI as jest.MockedClass).mockImplementation(() => mockOpenAIClient) + + // Create handler instance + handler = new OpenAiNativeHandler(mockOptions) + }) + + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(OpenAI).toHaveBeenCalledWith({ + apiKey: mockOptions.openAiNativeApiKey, + }) + }) + }) + + describe("getModel", () => { + it("should return specified model when valid", () => { + const result = handler.getModel() + expect(result.id).toBe("gpt-4o") // Use the correct model ID + }) + + it("should return default model when model ID is invalid", () => { + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "invalid-model" as any, + }) + const result = handler.getModel() + expect(result.id).toBe(openAiNativeDefaultModelId) + }) + + it("should return default model when model ID is not provided", () => { + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: undefined, + }) + const result = handler.getModel() + expect(result.id).toBe(openAiNativeDefaultModelId) + }) + }) + + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant" + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Hello" }, + ] + + describe("o1 models", () => { + beforeEach(() => { + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "o1-preview", + }) + }) + + it("should handle non-streaming response for o1 models", async () => { + const mockResponse = { + choices: [{ message: { content: "Hello there!" } }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + }, + } + + mockCreate.mockResolvedValueOnce(mockResponse) + + const generator = handler.createMessage(systemPrompt, messages) + const results = [] + for await (const result of generator) { + results.push(result) + } + + expect(results).toEqual([ + { type: "text", text: "Hello there!" }, + { type: "usage", inputTokens: 10, outputTokens: 5 }, + ]) + + expect(mockCreate).toHaveBeenCalledWith({ + model: "o1-preview", + messages: [ + { role: "user", content: systemPrompt }, + { role: "user", content: "Hello" }, + ], + }) + }) + + it("should handle missing content in response", async () => { + const mockResponse = { + choices: [{ message: { content: null } }], + usage: null, + } + + mockCreate.mockResolvedValueOnce(mockResponse) + + const generator = handler.createMessage(systemPrompt, messages) + const results = [] + for await (const result of generator) { + results.push(result) + } + + expect(results).toEqual([ + { type: "text", text: "" }, + { type: "usage", inputTokens: 0, outputTokens: 0 }, + ]) + }) + }) + + describe("streaming models", () => { + beforeEach(() => { + handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-4o", + }) + }) + + it("should handle streaming response", async () => { + const mockStream = [ + { choices: [{ delta: { content: "Hello" } }], usage: null }, + { choices: [{ delta: { content: " there" } }], usage: null }, + { choices: [{ delta: { content: "!" } }], usage: { prompt_tokens: 10, completion_tokens: 5 } }, + ] + + mockCreate.mockResolvedValueOnce( + (async function* () { + for (const chunk of mockStream) { + yield chunk + } + })() + ) + + const generator = handler.createMessage(systemPrompt, messages) + const results = [] + for await (const result of generator) { + results.push(result) + } + + expect(results).toEqual([ + { type: "text", text: "Hello" }, + { type: "text", text: " there" }, + { type: "text", text: "!" }, + { type: "usage", inputTokens: 10, outputTokens: 5 }, + ]) + + expect(mockCreate).toHaveBeenCalledWith({ + model: "gpt-4o", + temperature: 0, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: "Hello" }, + ], + stream: true, + stream_options: { include_usage: true }, + }) + }) + + it("should handle empty delta content", async () => { + const mockStream = [ + { choices: [{ delta: {} }], usage: null }, + { choices: [{ delta: { content: null } }], usage: null }, + { choices: [{ delta: { content: "Hello" } }], usage: { prompt_tokens: 10, completion_tokens: 5 } }, + ] + + mockCreate.mockResolvedValueOnce( + (async function* () { + for (const chunk of mockStream) { + yield chunk + } + })() + ) + + const generator = handler.createMessage(systemPrompt, messages) + const results = [] + for await (const result of generator) { + results.push(result) + } + + expect(results).toEqual([ + { type: "text", text: "Hello" }, + { type: "usage", inputTokens: 10, outputTokens: 5 }, + ]) + }) + }) + + it("should handle API errors", async () => { + mockCreate.mockRejectedValueOnce(new Error("API Error")) + + const generator = handler.createMessage(systemPrompt, messages) + await expect(async () => { + for await (const _ of generator) { + // consume generator + } + }).rejects.toThrow("API Error") + }) + }) +}) \ No newline at end of file diff --git a/src/api/providers/__tests__/openai.test.ts b/src/api/providers/__tests__/openai.test.ts index 0a88068..edd6460 100644 --- a/src/api/providers/__tests__/openai.test.ts +++ b/src/api/providers/__tests__/openai.test.ts @@ -1,192 +1,198 @@ -import { OpenAiHandler } from '../openai' -import { ApiHandlerOptions, openAiModelInfoSaneDefaults } from '../../../shared/api' -import OpenAI, { AzureOpenAI } from 'openai' -import { Anthropic } from '@anthropic-ai/sdk' +import { OpenAiHandler } from '../openai'; +import { ApiHandlerOptions } from '../../../shared/api'; +import { ApiStream } from '../../transform/stream'; +import OpenAI from 'openai'; +import { Anthropic } from '@anthropic-ai/sdk'; -// Mock dependencies -jest.mock('openai') +// Mock OpenAI client +const mockCreate = jest.fn(); +jest.mock('openai', () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: mockCreate.mockImplementation(async (options) => { + if (!options.stream) { + return { + id: 'test-completion', + choices: [{ + message: { role: 'assistant', content: 'Test response', refusal: null }, + finish_reason: 'stop', + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + } + }; + } + + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ + delta: { content: 'Test response' }, + index: 0 + }], + usage: null + }; + yield { + choices: [{ + delta: {}, + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + } + }; + } + }; + }) + } + } + })) + }; +}); describe('OpenAiHandler', () => { - const mockOptions: ApiHandlerOptions = { - openAiApiKey: 'test-key', - openAiModelId: 'gpt-4', - openAiStreamingEnabled: true, - openAiBaseUrl: 'https://api.openai.com/v1' - } + let handler: OpenAiHandler; + let mockOptions: ApiHandlerOptions; beforeEach(() => { - jest.clearAllMocks() - }) + mockOptions = { + openAiApiKey: 'test-api-key', + openAiModelId: 'gpt-4', + openAiBaseUrl: 'https://api.openai.com/v1' + }; + handler = new OpenAiHandler(mockOptions); + mockCreate.mockClear(); + }); - test('constructor initializes with correct options', () => { - const handler = new OpenAiHandler(mockOptions) - expect(handler).toBeInstanceOf(OpenAiHandler) - expect(OpenAI).toHaveBeenCalledWith({ - apiKey: mockOptions.openAiApiKey, - baseURL: mockOptions.openAiBaseUrl - }) - }) + describe('constructor', () => { + it('should initialize with provided options', () => { + expect(handler).toBeInstanceOf(OpenAiHandler); + expect(handler.getModel().id).toBe(mockOptions.openAiModelId); + }); - test('constructor initializes Azure client when Azure URL is provided', () => { - const azureOptions: ApiHandlerOptions = { - ...mockOptions, - openAiBaseUrl: 'https://example.azure.com', - azureApiVersion: '2023-05-15' - } - const handler = new OpenAiHandler(azureOptions) - expect(handler).toBeInstanceOf(OpenAiHandler) - expect(AzureOpenAI).toHaveBeenCalledWith({ - baseURL: azureOptions.openAiBaseUrl, - apiKey: azureOptions.openAiApiKey, - apiVersion: azureOptions.azureApiVersion - }) - }) + it('should use custom base URL if provided', () => { + const customBaseUrl = 'https://custom.openai.com/v1'; + const handlerWithCustomUrl = new OpenAiHandler({ + ...mockOptions, + openAiBaseUrl: customBaseUrl + }); + expect(handlerWithCustomUrl).toBeInstanceOf(OpenAiHandler); + }); + }); - test('getModel returns correct model info', () => { - const handler = new OpenAiHandler(mockOptions) - const result = handler.getModel() - - expect(result).toEqual({ - id: mockOptions.openAiModelId, - info: openAiModelInfoSaneDefaults - }) - }) - - test('createMessage handles streaming correctly when enabled', async () => { - const handler = new OpenAiHandler({ - ...mockOptions, - openAiStreamingEnabled: true, - includeMaxTokens: true - }) - - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ - delta: { - content: 'test response' - } - }], - usage: { - prompt_tokens: 10, - completion_tokens: 5 - } - } - } - } - - const mockCreate = jest.fn().mockResolvedValue(mockStream) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate } - } as any - - const systemPrompt = 'test system prompt' + describe('createMessage', () => { + const systemPrompt = 'You are a helpful assistant.'; const messages: Anthropic.Messages.MessageParam[] = [ - { role: 'user', content: 'test message' } - ] - - const generator = handler.createMessage(systemPrompt, messages) - const chunks = [] - - for await (const chunk of generator) { - chunks.push(chunk) - } - - expect(chunks).toEqual([ { - type: 'text', - text: 'test response' - }, - { - type: 'usage', - inputTokens: 10, - outputTokens: 5 + role: 'user', + content: [{ + type: 'text' as const, + text: 'Hello!' + }] } - ]) + ]; - expect(mockCreate).toHaveBeenCalledWith({ - model: mockOptions.openAiModelId, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: 'test message' } - ], - temperature: 0, - stream: true, - stream_options: { include_usage: true }, - max_tokens: openAiModelInfoSaneDefaults.maxTokens - }) - }) + it('should handle non-streaming mode', async () => { + const handler = new OpenAiHandler({ + ...mockOptions, + openAiStreamingEnabled: false + }); - test('createMessage handles non-streaming correctly when disabled', async () => { - const handler = new OpenAiHandler({ - ...mockOptions, - openAiStreamingEnabled: false - }) - - const mockResponse = { - choices: [{ - message: { - content: 'test response' + const stream = handler.createMessage(systemPrompt, messages); + const chunks: any[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks.length).toBeGreaterThan(0); + const textChunk = chunks.find(chunk => chunk.type === 'text'); + const usageChunk = chunks.find(chunk => chunk.type === 'usage'); + + expect(textChunk).toBeDefined(); + expect(textChunk?.text).toBe('Test response'); + expect(usageChunk).toBeDefined(); + expect(usageChunk?.inputTokens).toBe(10); + expect(usageChunk?.outputTokens).toBe(5); + }); + + it('should handle streaming responses', async () => { + const stream = handler.createMessage(systemPrompt, messages); + const chunks: any[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks.length).toBeGreaterThan(0); + const textChunks = chunks.filter(chunk => chunk.type === 'text'); + expect(textChunks).toHaveLength(1); + expect(textChunks[0].text).toBe('Test response'); + }); + }); + + describe('error handling', () => { + const testMessages: Anthropic.Messages.MessageParam[] = [ + { + role: 'user', + content: [{ + type: 'text' as const, + text: 'Hello' + }] + } + ]; + + it('should handle API errors', async () => { + mockCreate.mockRejectedValueOnce(new Error('API Error')); + + const stream = handler.createMessage('system prompt', testMessages); + + await expect(async () => { + for await (const chunk of stream) { + // Should not reach here } - }], - usage: { - prompt_tokens: 10, - completion_tokens: 5 - } - } + }).rejects.toThrow('API Error'); + }); - const mockCreate = jest.fn().mockResolvedValue(mockResponse) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate } - } as any + it('should handle rate limiting', async () => { + const rateLimitError = new Error('Rate limit exceeded'); + rateLimitError.name = 'Error'; + (rateLimitError as any).status = 429; + mockCreate.mockRejectedValueOnce(rateLimitError); - const systemPrompt = 'test system prompt' - const messages: Anthropic.Messages.MessageParam[] = [ - { role: 'user', content: 'test message' } - ] + const stream = handler.createMessage('system prompt', testMessages); - const generator = handler.createMessage(systemPrompt, messages) - const chunks = [] - - for await (const chunk of generator) { - chunks.push(chunk) - } + await expect(async () => { + for await (const chunk of stream) { + // Should not reach here + } + }).rejects.toThrow('Rate limit exceeded'); + }); + }); - expect(chunks).toEqual([ - { - type: 'text', - text: 'test response' - }, - { - type: 'usage', - inputTokens: 10, - outputTokens: 5 - } - ]) + describe('getModel', () => { + it('should return model info with sane defaults', () => { + const model = handler.getModel(); + expect(model.id).toBe(mockOptions.openAiModelId); + expect(model.info).toBeDefined(); + expect(model.info.contextWindow).toBe(128_000); + expect(model.info.supportsImages).toBe(true); + }); - expect(mockCreate).toHaveBeenCalledWith({ - model: mockOptions.openAiModelId, - messages: [ - { role: 'user', content: systemPrompt }, - { role: 'user', content: 'test message' } - ] - }) - }) - - test('createMessage handles API errors', async () => { - const handler = new OpenAiHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - throw new Error('API Error') - } - } - - const mockCreate = jest.fn().mockResolvedValue(mockStream) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate } - } as any - - const generator = handler.createMessage('test', []) - await expect(generator.next()).rejects.toThrow('API Error') - }) -}) \ No newline at end of file + it('should handle undefined model ID', () => { + const handlerWithoutModel = new OpenAiHandler({ + ...mockOptions, + openAiModelId: undefined + }); + const model = handlerWithoutModel.getModel(); + expect(model.id).toBe(''); + expect(model.info).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/api/providers/__tests__/vertex.test.ts b/src/api/providers/__tests__/vertex.test.ts new file mode 100644 index 0000000..71aa810 --- /dev/null +++ b/src/api/providers/__tests__/vertex.test.ts @@ -0,0 +1,218 @@ +import { VertexHandler } from '../vertex'; +import { Anthropic } from '@anthropic-ai/sdk'; +import { AnthropicVertex } from '@anthropic-ai/vertex-sdk'; + +// Mock Vertex SDK +jest.mock('@anthropic-ai/vertex-sdk', () => ({ + AnthropicVertex: jest.fn().mockImplementation(() => ({ + messages: { + create: jest.fn() + } + })) +})); + +describe('VertexHandler', () => { + let handler: VertexHandler; + + beforeEach(() => { + handler = new VertexHandler({ + apiModelId: 'claude-3-5-sonnet-v2@20241022', + vertexProjectId: 'test-project', + vertexRegion: 'us-central1' + }); + }); + + describe('constructor', () => { + it('should initialize with provided config', () => { + expect(AnthropicVertex).toHaveBeenCalledWith({ + projectId: 'test-project', + region: 'us-central1' + }); + }); + }); + + describe('createMessage', () => { + const mockMessages: Anthropic.Messages.MessageParam[] = [ + { + role: 'user', + content: 'Hello' + }, + { + role: 'assistant', + content: 'Hi there!' + } + ]; + + const systemPrompt = 'You are a helpful assistant'; + + it('should handle streaming responses correctly', async () => { + const mockStream = [ + { + type: 'message_start', + message: { + usage: { + input_tokens: 10, + output_tokens: 0 + } + } + }, + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'text', + text: 'Hello' + } + }, + { + type: 'content_block_delta', + delta: { + type: 'text_delta', + text: ' world!' + } + }, + { + type: 'message_delta', + usage: { + output_tokens: 5 + } + } + ]; + + // Setup async iterator for mock stream + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk; + } + } + }; + + const mockCreate = jest.fn().mockResolvedValue(asyncIterator); + (handler['client'].messages as any).create = mockCreate; + + const stream = handler.createMessage(systemPrompt, mockMessages); + const chunks = []; + + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(4); + expect(chunks[0]).toEqual({ + type: 'usage', + inputTokens: 10, + outputTokens: 0 + }); + expect(chunks[1]).toEqual({ + type: 'text', + text: 'Hello' + }); + expect(chunks[2]).toEqual({ + type: 'text', + text: ' world!' + }); + expect(chunks[3]).toEqual({ + type: 'usage', + inputTokens: 0, + outputTokens: 5 + }); + + expect(mockCreate).toHaveBeenCalledWith({ + model: 'claude-3-5-sonnet-v2@20241022', + max_tokens: 8192, + temperature: 0, + system: systemPrompt, + messages: mockMessages, + stream: true + }); + }); + + it('should handle multiple content blocks with line breaks', async () => { + const mockStream = [ + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'text', + text: 'First line' + } + }, + { + type: 'content_block_start', + index: 1, + content_block: { + type: 'text', + text: 'Second line' + } + } + ]; + + const asyncIterator = { + async *[Symbol.asyncIterator]() { + for (const chunk of mockStream) { + yield chunk; + } + } + }; + + const mockCreate = jest.fn().mockResolvedValue(asyncIterator); + (handler['client'].messages as any).create = mockCreate; + + const stream = handler.createMessage(systemPrompt, mockMessages); + const chunks = []; + + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(3); + expect(chunks[0]).toEqual({ + type: 'text', + text: 'First line' + }); + expect(chunks[1]).toEqual({ + type: 'text', + text: '\n' + }); + expect(chunks[2]).toEqual({ + type: 'text', + text: 'Second line' + }); + }); + + it('should handle API errors', async () => { + const mockError = new Error('Vertex API error'); + const mockCreate = jest.fn().mockRejectedValue(mockError); + (handler['client'].messages as any).create = mockCreate; + + const stream = handler.createMessage(systemPrompt, mockMessages); + + await expect(async () => { + for await (const chunk of stream) { + // Should throw before yielding any chunks + } + }).rejects.toThrow('Vertex API error'); + }); + }); + + describe('getModel', () => { + it('should return correct model info', () => { + const modelInfo = handler.getModel(); + expect(modelInfo.id).toBe('claude-3-5-sonnet-v2@20241022'); + expect(modelInfo.info).toBeDefined(); + expect(modelInfo.info.maxTokens).toBe(8192); + expect(modelInfo.info.contextWindow).toBe(200_000); + }); + + it('should return default model if invalid model specified', () => { + const invalidHandler = new VertexHandler({ + apiModelId: 'invalid-model', + vertexProjectId: 'test-project', + vertexRegion: 'us-central1' + }); + const modelInfo = invalidHandler.getModel(); + expect(modelInfo.id).toBe('claude-3-5-sonnet-v2@20241022'); // Default model + }); + }); +}); \ No newline at end of file diff --git a/src/api/transform/__tests__/openai-format.test.ts b/src/api/transform/__tests__/openai-format.test.ts new file mode 100644 index 0000000..32673dc --- /dev/null +++ b/src/api/transform/__tests__/openai-format.test.ts @@ -0,0 +1,257 @@ +import { convertToOpenAiMessages, convertToAnthropicMessage } from '../openai-format'; +import { Anthropic } from '@anthropic-ai/sdk'; +import OpenAI from 'openai'; + +type PartialChatCompletion = Omit & { + choices: Array & { + message: OpenAI.Chat.Completions.ChatCompletion.Choice['message']; + finish_reason: string; + index: number; + }>; +}; + +describe('OpenAI Format Transformations', () => { + describe('convertToOpenAiMessages', () => { + it('should convert simple text messages', () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: 'user', + content: 'Hello' + }, + { + role: 'assistant', + content: 'Hi there!' + } + ]; + + const openAiMessages = convertToOpenAiMessages(anthropicMessages); + expect(openAiMessages).toHaveLength(2); + expect(openAiMessages[0]).toEqual({ + role: 'user', + content: 'Hello' + }); + expect(openAiMessages[1]).toEqual({ + role: 'assistant', + content: 'Hi there!' + }); + }); + + it('should handle messages with image content', () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'What is in this image?' + }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/jpeg', + data: 'base64data' + } + } + ] + } + ]; + + const openAiMessages = convertToOpenAiMessages(anthropicMessages); + expect(openAiMessages).toHaveLength(1); + expect(openAiMessages[0].role).toBe('user'); + + const content = openAiMessages[0].content as Array<{ + type: string; + text?: string; + image_url?: { url: string }; + }>; + + expect(Array.isArray(content)).toBe(true); + expect(content).toHaveLength(2); + expect(content[0]).toEqual({ type: 'text', text: 'What is in this image?' }); + expect(content[1]).toEqual({ + type: 'image_url', + image_url: { url: 'data:image/jpeg;base64,base64data' } + }); + }); + + it('should handle assistant messages with tool use', () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Let me check the weather.' + }, + { + type: 'tool_use', + id: 'weather-123', + name: 'get_weather', + input: { city: 'London' } + } + ] + } + ]; + + const openAiMessages = convertToOpenAiMessages(anthropicMessages); + expect(openAiMessages).toHaveLength(1); + + const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam; + expect(assistantMessage.role).toBe('assistant'); + expect(assistantMessage.content).toBe('Let me check the weather.'); + expect(assistantMessage.tool_calls).toHaveLength(1); + expect(assistantMessage.tool_calls![0]).toEqual({ + id: 'weather-123', + type: 'function', + function: { + name: 'get_weather', + arguments: JSON.stringify({ city: 'London' }) + } + }); + }); + + it('should handle user messages with tool results', () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'weather-123', + content: 'Current temperature in London: 20°C' + } + ] + } + ]; + + const openAiMessages = convertToOpenAiMessages(anthropicMessages); + expect(openAiMessages).toHaveLength(1); + + const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam; + expect(toolMessage.role).toBe('tool'); + expect(toolMessage.tool_call_id).toBe('weather-123'); + expect(toolMessage.content).toBe('Current temperature in London: 20°C'); + }); + }); + + describe('convertToAnthropicMessage', () => { + it('should convert simple completion', () => { + const openAiCompletion: PartialChatCompletion = { + id: 'completion-123', + model: 'gpt-4', + choices: [{ + message: { + role: 'assistant', + content: 'Hello there!', + refusal: null + }, + finish_reason: 'stop', + index: 0 + }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15 + }, + created: 123456789, + object: 'chat.completion' + }; + + const anthropicMessage = convertToAnthropicMessage(openAiCompletion as OpenAI.Chat.Completions.ChatCompletion); + expect(anthropicMessage.id).toBe('completion-123'); + expect(anthropicMessage.role).toBe('assistant'); + expect(anthropicMessage.content).toHaveLength(1); + expect(anthropicMessage.content[0]).toEqual({ + type: 'text', + text: 'Hello there!' + }); + expect(anthropicMessage.stop_reason).toBe('end_turn'); + expect(anthropicMessage.usage).toEqual({ + input_tokens: 10, + output_tokens: 5 + }); + }); + + it('should handle tool calls in completion', () => { + const openAiCompletion: PartialChatCompletion = { + id: 'completion-123', + model: 'gpt-4', + choices: [{ + message: { + role: 'assistant', + content: 'Let me check the weather.', + tool_calls: [{ + id: 'weather-123', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"city":"London"}' + } + }], + refusal: null + }, + finish_reason: 'tool_calls', + index: 0 + }], + usage: { + prompt_tokens: 15, + completion_tokens: 8, + total_tokens: 23 + }, + created: 123456789, + object: 'chat.completion' + }; + + const anthropicMessage = convertToAnthropicMessage(openAiCompletion as OpenAI.Chat.Completions.ChatCompletion); + expect(anthropicMessage.content).toHaveLength(2); + expect(anthropicMessage.content[0]).toEqual({ + type: 'text', + text: 'Let me check the weather.' + }); + expect(anthropicMessage.content[1]).toEqual({ + type: 'tool_use', + id: 'weather-123', + name: 'get_weather', + input: { city: 'London' } + }); + expect(anthropicMessage.stop_reason).toBe('tool_use'); + }); + + it('should handle invalid tool call arguments', () => { + const openAiCompletion: PartialChatCompletion = { + id: 'completion-123', + model: 'gpt-4', + choices: [{ + message: { + role: 'assistant', + content: 'Testing invalid arguments', + tool_calls: [{ + id: 'test-123', + type: 'function', + function: { + name: 'test_function', + arguments: 'invalid json' + } + }], + refusal: null + }, + finish_reason: 'tool_calls', + index: 0 + }], + created: 123456789, + object: 'chat.completion' + }; + + const anthropicMessage = convertToAnthropicMessage(openAiCompletion as OpenAI.Chat.Completions.ChatCompletion); + expect(anthropicMessage.content).toHaveLength(2); + expect(anthropicMessage.content[1]).toEqual({ + type: 'tool_use', + id: 'test-123', + name: 'test_function', + input: {} // Should default to empty object for invalid JSON + }); + }); + }); +}); \ No newline at end of file diff --git a/src/api/transform/__tests__/stream.test.ts b/src/api/transform/__tests__/stream.test.ts new file mode 100644 index 0000000..32efd50 --- /dev/null +++ b/src/api/transform/__tests__/stream.test.ts @@ -0,0 +1,114 @@ +import { ApiStreamChunk } from '../stream'; + +describe('API Stream Types', () => { + describe('ApiStreamChunk', () => { + it('should correctly handle text chunks', () => { + const textChunk: ApiStreamChunk = { + type: 'text', + text: 'Hello world' + }; + + expect(textChunk.type).toBe('text'); + expect(textChunk.text).toBe('Hello world'); + }); + + it('should correctly handle usage chunks with cache information', () => { + const usageChunk: ApiStreamChunk = { + type: 'usage', + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 20, + cacheReadTokens: 10 + }; + + expect(usageChunk.type).toBe('usage'); + expect(usageChunk.inputTokens).toBe(100); + expect(usageChunk.outputTokens).toBe(50); + expect(usageChunk.cacheWriteTokens).toBe(20); + expect(usageChunk.cacheReadTokens).toBe(10); + }); + + it('should handle usage chunks without cache tokens', () => { + const usageChunk: ApiStreamChunk = { + type: 'usage', + inputTokens: 100, + outputTokens: 50 + }; + + expect(usageChunk.type).toBe('usage'); + expect(usageChunk.inputTokens).toBe(100); + expect(usageChunk.outputTokens).toBe(50); + expect(usageChunk.cacheWriteTokens).toBeUndefined(); + expect(usageChunk.cacheReadTokens).toBeUndefined(); + }); + + it('should handle text chunks with empty strings', () => { + const emptyTextChunk: ApiStreamChunk = { + type: 'text', + text: '' + }; + + expect(emptyTextChunk.type).toBe('text'); + expect(emptyTextChunk.text).toBe(''); + }); + + it('should handle usage chunks with zero tokens', () => { + const zeroUsageChunk: ApiStreamChunk = { + type: 'usage', + inputTokens: 0, + outputTokens: 0 + }; + + expect(zeroUsageChunk.type).toBe('usage'); + expect(zeroUsageChunk.inputTokens).toBe(0); + expect(zeroUsageChunk.outputTokens).toBe(0); + }); + + it('should handle usage chunks with large token counts', () => { + const largeUsageChunk: ApiStreamChunk = { + type: 'usage', + inputTokens: 1000000, + outputTokens: 500000, + cacheWriteTokens: 200000, + cacheReadTokens: 100000 + }; + + expect(largeUsageChunk.type).toBe('usage'); + expect(largeUsageChunk.inputTokens).toBe(1000000); + expect(largeUsageChunk.outputTokens).toBe(500000); + expect(largeUsageChunk.cacheWriteTokens).toBe(200000); + expect(largeUsageChunk.cacheReadTokens).toBe(100000); + }); + + it('should handle text chunks with special characters', () => { + const specialCharsChunk: ApiStreamChunk = { + type: 'text', + text: '!@#$%^&*()_+-=[]{}|;:,.<>?`~' + }; + + expect(specialCharsChunk.type).toBe('text'); + expect(specialCharsChunk.text).toBe('!@#$%^&*()_+-=[]{}|;:,.<>?`~'); + }); + + it('should handle text chunks with unicode characters', () => { + const unicodeChunk: ApiStreamChunk = { + type: 'text', + text: '你好世界👋🌍' + }; + + expect(unicodeChunk.type).toBe('text'); + expect(unicodeChunk.text).toBe('你好世界👋🌍'); + }); + + it('should handle text chunks with multiline content', () => { + const multilineChunk: ApiStreamChunk = { + type: 'text', + text: 'Line 1\nLine 2\nLine 3' + }; + + expect(multilineChunk.type).toBe('text'); + expect(multilineChunk.text).toBe('Line 1\nLine 2\nLine 3'); + expect(multilineChunk.text.split('\n')).toHaveLength(3); + }); + }); +}); \ No newline at end of file diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 4e9b98b..107c1c0 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -252,7 +252,8 @@ describe('Cline', () => { // Setup mock API configuration mockApiConfig = { apiProvider: 'anthropic', - apiModelId: 'claude-3-5-sonnet-20241022' + apiModelId: 'claude-3-5-sonnet-20241022', + apiKey: 'test-api-key' // Add API key to mock config }; // Mock provider methods diff --git a/src/integrations/terminal/__tests__/TerminalProcess.test.ts b/src/integrations/terminal/__tests__/TerminalProcess.test.ts new file mode 100644 index 0000000..6294780 --- /dev/null +++ b/src/integrations/terminal/__tests__/TerminalProcess.test.ts @@ -0,0 +1,229 @@ +import { TerminalProcess, mergePromise } from "../TerminalProcess" +import * as vscode from "vscode" +import { EventEmitter } from "events" + +// Mock vscode +jest.mock("vscode") + +describe("TerminalProcess", () => { + let terminalProcess: TerminalProcess + let mockTerminal: jest.Mocked + let mockExecution: any + let mockStream: AsyncIterableIterator + + beforeEach(() => { + terminalProcess = new TerminalProcess() + + // Create properly typed mock terminal + mockTerminal = { + shellIntegration: { + executeCommand: jest.fn() + }, + name: "Mock Terminal", + processId: Promise.resolve(123), + creationOptions: {}, + exitStatus: undefined, + state: { isInteractedWith: true }, + dispose: jest.fn(), + hide: jest.fn(), + show: jest.fn(), + sendText: jest.fn() + } as unknown as jest.Mocked + + // Reset event listeners + terminalProcess.removeAllListeners() + }) + + describe("run", () => { + it("handles shell integration commands correctly", async () => { + const lines: string[] = [] + terminalProcess.on("line", (line) => { + // Skip empty lines used for loading spinner + if (line !== "") { + lines.push(line) + } + }) + + // Mock stream data with shell integration sequences + mockStream = (async function* () { + // The first chunk contains the command start sequence + yield "Initial output\n" + yield "More output\n" + // The last chunk contains the command end sequence + yield "Final output" + })() + + mockExecution = { + read: jest.fn().mockReturnValue(mockStream) + } + + mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution) + + const completedPromise = new Promise((resolve) => { + terminalProcess.once("completed", resolve) + }) + + await terminalProcess.run(mockTerminal, "test command") + await completedPromise + + expect(lines).toEqual(["Initial output", "More output", "Final output"]) + expect(terminalProcess.isHot).toBe(false) + }) + + it("handles terminals without shell integration", async () => { + const noShellTerminal = { + sendText: jest.fn(), + shellIntegration: undefined + } as unknown as vscode.Terminal + + const noShellPromise = new Promise((resolve) => { + terminalProcess.once("no_shell_integration", resolve) + }) + + await terminalProcess.run(noShellTerminal, "test command") + await noShellPromise + + expect(noShellTerminal.sendText).toHaveBeenCalledWith("test command", true) + }) + + it("sets hot state for compiling commands", async () => { + const lines: string[] = [] + terminalProcess.on("line", (line) => { + if (line !== "") { + lines.push(line) + } + }) + + // Create a promise that resolves when the first chunk is processed + const firstChunkProcessed = new Promise(resolve => { + terminalProcess.on("line", () => resolve()) + }) + + mockStream = (async function* () { + yield "compiling...\n" + // Wait to ensure hot state check happens after first chunk + await new Promise(resolve => setTimeout(resolve, 10)) + yield "still compiling...\n" + yield "done" + })() + + mockExecution = { + read: jest.fn().mockReturnValue(mockStream) + } + + mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution) + + // Start the command execution + const runPromise = terminalProcess.run(mockTerminal, "npm run build") + + // Wait for the first chunk to be processed + await firstChunkProcessed + + // Hot state should be true while compiling + expect(terminalProcess.isHot).toBe(true) + + // Complete the execution + const completedPromise = new Promise((resolve) => { + terminalProcess.once("completed", resolve) + }) + + await runPromise + await completedPromise + + expect(lines).toEqual(["compiling...", "still compiling...", "done"]) + }) + }) + + describe("buffer processing", () => { + it("correctly processes and emits lines", () => { + const lines: string[] = [] + terminalProcess.on("line", (line) => lines.push(line)) + + // Simulate incoming chunks + terminalProcess["emitIfEol"]("first line\n") + terminalProcess["emitIfEol"]("second") + terminalProcess["emitIfEol"](" line\n") + terminalProcess["emitIfEol"]("third line") + + expect(lines).toEqual(["first line", "second line"]) + + // Process remaining buffer + terminalProcess["emitRemainingBufferIfListening"]() + expect(lines).toEqual(["first line", "second line", "third line"]) + }) + + it("handles Windows-style line endings", () => { + const lines: string[] = [] + terminalProcess.on("line", (line) => lines.push(line)) + + terminalProcess["emitIfEol"]("line1\r\nline2\r\n") + + expect(lines).toEqual(["line1", "line2"]) + }) + }) + + describe("removeLastLineArtifacts", () => { + it("removes terminal artifacts from output", () => { + const cases = [ + ["output%", "output"], + ["output$ ", "output"], + ["output#", "output"], + ["output> ", "output"], + ["multi\nline%", "multi\nline"], + ["no artifacts", "no artifacts"] + ] + + for (const [input, expected] of cases) { + expect(terminalProcess["removeLastLineArtifacts"](input)).toBe(expected) + } + }) + }) + + describe("continue", () => { + it("stops listening and emits continue event", () => { + const continueSpy = jest.fn() + terminalProcess.on("continue", continueSpy) + + terminalProcess.continue() + + expect(continueSpy).toHaveBeenCalled() + expect(terminalProcess["isListening"]).toBe(false) + }) + }) + + describe("getUnretrievedOutput", () => { + it("returns and clears unretrieved output", () => { + terminalProcess["fullOutput"] = "previous\nnew output" + terminalProcess["lastRetrievedIndex"] = 9 // After "previous\n" + + const unretrieved = terminalProcess.getUnretrievedOutput() + + expect(unretrieved).toBe("new output") + expect(terminalProcess["lastRetrievedIndex"]).toBe(terminalProcess["fullOutput"].length) + }) + }) + + describe("mergePromise", () => { + it("merges promise methods with terminal process", async () => { + const process = new TerminalProcess() + const promise = Promise.resolve() + + const merged = mergePromise(process, promise) + + expect(merged).toHaveProperty("then") + expect(merged).toHaveProperty("catch") + expect(merged).toHaveProperty("finally") + expect(merged instanceof TerminalProcess).toBe(true) + + await expect(merged).resolves.toBeUndefined() + }) + }) +}) \ No newline at end of file diff --git a/src/services/tree-sitter/__tests__/index.test.ts b/src/services/tree-sitter/__tests__/index.test.ts new file mode 100644 index 0000000..614cccf --- /dev/null +++ b/src/services/tree-sitter/__tests__/index.test.ts @@ -0,0 +1,254 @@ +import { parseSourceCodeForDefinitionsTopLevel } from '../index'; +import { listFiles } from '../../glob/list-files'; +import { loadRequiredLanguageParsers } from '../languageParser'; +import { fileExistsAtPath } from '../../../utils/fs'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +// Mock dependencies +jest.mock('../../glob/list-files'); +jest.mock('../languageParser'); +jest.mock('../../../utils/fs'); +jest.mock('fs/promises'); + +describe('Tree-sitter Service', () => { + beforeEach(() => { + jest.clearAllMocks(); + (fileExistsAtPath as jest.Mock).mockResolvedValue(true); + }); + + describe('parseSourceCodeForDefinitionsTopLevel', () => { + it('should handle non-existent directory', async () => { + (fileExistsAtPath as jest.Mock).mockResolvedValue(false); + + const result = await parseSourceCodeForDefinitionsTopLevel('/non/existent/path'); + expect(result).toBe('This directory does not exist or you do not have permission to access it.'); + }); + + it('should handle empty directory', async () => { + (listFiles as jest.Mock).mockResolvedValue([[], new Set()]); + + const result = await parseSourceCodeForDefinitionsTopLevel('/test/path'); + expect(result).toBe('No source code definitions found.'); + }); + + it('should parse TypeScript files correctly', async () => { + const mockFiles = [ + '/test/path/file1.ts', + '/test/path/file2.tsx', + '/test/path/readme.md' + ]; + + (listFiles as jest.Mock).mockResolvedValue([mockFiles, new Set()]); + + const mockParser = { + parse: jest.fn().mockReturnValue({ + rootNode: 'mockNode' + }) + }; + + const mockQuery = { + captures: jest.fn().mockReturnValue([ + { + node: { + startPosition: { row: 0 }, + endPosition: { row: 0 } + }, + name: 'name.definition' + } + ]) + }; + + (loadRequiredLanguageParsers as jest.Mock).mockResolvedValue({ + ts: { parser: mockParser, query: mockQuery }, + tsx: { parser: mockParser, query: mockQuery } + }); + + (fs.readFile as jest.Mock).mockResolvedValue( + 'export class TestClass {\n constructor() {}\n}' + ); + + const result = await parseSourceCodeForDefinitionsTopLevel('/test/path'); + + expect(result).toContain('file1.ts'); + expect(result).toContain('file2.tsx'); + expect(result).not.toContain('readme.md'); + expect(result).toContain('export class TestClass'); + }); + + it('should handle multiple definition types', async () => { + const mockFiles = ['/test/path/file.ts']; + (listFiles as jest.Mock).mockResolvedValue([mockFiles, new Set()]); + + const mockParser = { + parse: jest.fn().mockReturnValue({ + rootNode: 'mockNode' + }) + }; + + const mockQuery = { + captures: jest.fn().mockReturnValue([ + { + node: { + startPosition: { row: 0 }, + endPosition: { row: 0 } + }, + name: 'name.definition.class' + }, + { + node: { + startPosition: { row: 2 }, + endPosition: { row: 2 } + }, + name: 'name.definition.function' + } + ]) + }; + + (loadRequiredLanguageParsers as jest.Mock).mockResolvedValue({ + ts: { parser: mockParser, query: mockQuery } + }); + + const fileContent = + 'class TestClass {\n' + + ' constructor() {}\n' + + ' testMethod() {}\n' + + '}'; + + (fs.readFile as jest.Mock).mockResolvedValue(fileContent); + + const result = await parseSourceCodeForDefinitionsTopLevel('/test/path'); + + expect(result).toContain('class TestClass'); + expect(result).toContain('testMethod()'); + expect(result).toContain('|----'); + }); + + it('should handle parsing errors gracefully', async () => { + const mockFiles = ['/test/path/file.ts']; + (listFiles as jest.Mock).mockResolvedValue([mockFiles, new Set()]); + + const mockParser = { + parse: jest.fn().mockImplementation(() => { + throw new Error('Parsing error'); + }) + }; + + const mockQuery = { + captures: jest.fn() + }; + + (loadRequiredLanguageParsers as jest.Mock).mockResolvedValue({ + ts: { parser: mockParser, query: mockQuery } + }); + + (fs.readFile as jest.Mock).mockResolvedValue('invalid code'); + + const result = await parseSourceCodeForDefinitionsTopLevel('/test/path'); + expect(result).toBe('No source code definitions found.'); + }); + + it('should respect file limit', async () => { + const mockFiles = Array(100).fill(0).map((_, i) => `/test/path/file${i}.ts`); + (listFiles as jest.Mock).mockResolvedValue([mockFiles, new Set()]); + + const mockParser = { + parse: jest.fn().mockReturnValue({ + rootNode: 'mockNode' + }) + }; + + const mockQuery = { + captures: jest.fn().mockReturnValue([]) + }; + + (loadRequiredLanguageParsers as jest.Mock).mockResolvedValue({ + ts: { parser: mockParser, query: mockQuery } + }); + + await parseSourceCodeForDefinitionsTopLevel('/test/path'); + + // Should only process first 50 files + expect(mockParser.parse).toHaveBeenCalledTimes(50); + }); + + it('should handle various supported file extensions', async () => { + const mockFiles = [ + '/test/path/script.js', + '/test/path/app.py', + '/test/path/main.rs', + '/test/path/program.cpp', + '/test/path/code.go' + ]; + + (listFiles as jest.Mock).mockResolvedValue([mockFiles, new Set()]); + + const mockParser = { + parse: jest.fn().mockReturnValue({ + rootNode: 'mockNode' + }) + }; + + const mockQuery = { + captures: jest.fn().mockReturnValue([{ + node: { + startPosition: { row: 0 }, + endPosition: { row: 0 } + }, + name: 'name' + }]) + }; + + (loadRequiredLanguageParsers as jest.Mock).mockResolvedValue({ + js: { parser: mockParser, query: mockQuery }, + py: { parser: mockParser, query: mockQuery }, + rs: { parser: mockParser, query: mockQuery }, + cpp: { parser: mockParser, query: mockQuery }, + go: { parser: mockParser, query: mockQuery } + }); + + (fs.readFile as jest.Mock).mockResolvedValue('function test() {}'); + + const result = await parseSourceCodeForDefinitionsTopLevel('/test/path'); + + expect(result).toContain('script.js'); + expect(result).toContain('app.py'); + expect(result).toContain('main.rs'); + expect(result).toContain('program.cpp'); + expect(result).toContain('code.go'); + }); + + it('should normalize paths in output', async () => { + const mockFiles = ['/test/path/dir\\file.ts']; + (listFiles as jest.Mock).mockResolvedValue([mockFiles, new Set()]); + + const mockParser = { + parse: jest.fn().mockReturnValue({ + rootNode: 'mockNode' + }) + }; + + const mockQuery = { + captures: jest.fn().mockReturnValue([{ + node: { + startPosition: { row: 0 }, + endPosition: { row: 0 } + }, + name: 'name' + }]) + }; + + (loadRequiredLanguageParsers as jest.Mock).mockResolvedValue({ + ts: { parser: mockParser, query: mockQuery } + }); + + (fs.readFile as jest.Mock).mockResolvedValue('class Test {}'); + + const result = await parseSourceCodeForDefinitionsTopLevel('/test/path'); + + // Should use forward slashes regardless of platform + expect(result).toContain('dir/file.ts'); + expect(result).not.toContain('dir\\file.ts'); + }); + }); +}); \ No newline at end of file diff --git a/src/services/tree-sitter/__tests__/languageParser.test.ts b/src/services/tree-sitter/__tests__/languageParser.test.ts new file mode 100644 index 0000000..538a2eb --- /dev/null +++ b/src/services/tree-sitter/__tests__/languageParser.test.ts @@ -0,0 +1,128 @@ +import { loadRequiredLanguageParsers } from '../languageParser'; +import Parser from 'web-tree-sitter'; + +// Mock web-tree-sitter +const mockSetLanguage = jest.fn(); +jest.mock('web-tree-sitter', () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + setLanguage: mockSetLanguage + })) + }; +}); + +// Add static methods to Parser mock +const ParserMock = Parser as jest.MockedClass; +ParserMock.init = jest.fn().mockResolvedValue(undefined); +ParserMock.Language = { + load: jest.fn().mockResolvedValue({ + query: jest.fn().mockReturnValue('mockQuery') + }), + prototype: {} // Add required prototype property +} as unknown as typeof Parser.Language; + +describe('Language Parser', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('loadRequiredLanguageParsers', () => { + it('should initialize parser only once', async () => { + const files = ['test.js', 'test2.js']; + await loadRequiredLanguageParsers(files); + await loadRequiredLanguageParsers(files); + + expect(ParserMock.init).toHaveBeenCalledTimes(1); + }); + + it('should load JavaScript parser for .js and .jsx files', async () => { + const files = ['test.js', 'test.jsx']; + const parsers = await loadRequiredLanguageParsers(files); + + expect(ParserMock.Language.load).toHaveBeenCalledWith( + expect.stringContaining('tree-sitter-javascript.wasm') + ); + expect(parsers.js).toBeDefined(); + expect(parsers.jsx).toBeDefined(); + expect(parsers.js.query).toBeDefined(); + expect(parsers.jsx.query).toBeDefined(); + }); + + it('should load TypeScript parser for .ts and .tsx files', async () => { + const files = ['test.ts', 'test.tsx']; + const parsers = await loadRequiredLanguageParsers(files); + + expect(ParserMock.Language.load).toHaveBeenCalledWith( + expect.stringContaining('tree-sitter-typescript.wasm') + ); + expect(ParserMock.Language.load).toHaveBeenCalledWith( + expect.stringContaining('tree-sitter-tsx.wasm') + ); + expect(parsers.ts).toBeDefined(); + expect(parsers.tsx).toBeDefined(); + }); + + it('should load Python parser for .py files', async () => { + const files = ['test.py']; + const parsers = await loadRequiredLanguageParsers(files); + + expect(ParserMock.Language.load).toHaveBeenCalledWith( + expect.stringContaining('tree-sitter-python.wasm') + ); + expect(parsers.py).toBeDefined(); + }); + + it('should load multiple language parsers as needed', async () => { + const files = ['test.js', 'test.py', 'test.rs', 'test.go']; + const parsers = await loadRequiredLanguageParsers(files); + + expect(ParserMock.Language.load).toHaveBeenCalledTimes(4); + expect(parsers.js).toBeDefined(); + expect(parsers.py).toBeDefined(); + expect(parsers.rs).toBeDefined(); + expect(parsers.go).toBeDefined(); + }); + + it('should handle C/C++ files correctly', async () => { + const files = ['test.c', 'test.h', 'test.cpp', 'test.hpp']; + const parsers = await loadRequiredLanguageParsers(files); + + expect(ParserMock.Language.load).toHaveBeenCalledWith( + expect.stringContaining('tree-sitter-c.wasm') + ); + expect(ParserMock.Language.load).toHaveBeenCalledWith( + expect.stringContaining('tree-sitter-cpp.wasm') + ); + expect(parsers.c).toBeDefined(); + expect(parsers.h).toBeDefined(); + expect(parsers.cpp).toBeDefined(); + expect(parsers.hpp).toBeDefined(); + }); + + it('should throw error for unsupported file extensions', async () => { + const files = ['test.unsupported']; + + await expect(loadRequiredLanguageParsers(files)).rejects.toThrow( + 'Unsupported language: unsupported' + ); + }); + + it('should load each language only once for multiple files', async () => { + const files = ['test1.js', 'test2.js', 'test3.js']; + await loadRequiredLanguageParsers(files); + + expect(ParserMock.Language.load).toHaveBeenCalledTimes(1); + expect(ParserMock.Language.load).toHaveBeenCalledWith( + expect.stringContaining('tree-sitter-javascript.wasm') + ); + }); + + it('should set language for each parser instance', async () => { + const files = ['test.js', 'test.py']; + await loadRequiredLanguageParsers(files); + + expect(mockSetLanguage).toHaveBeenCalledTimes(2); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/cost.test.ts b/src/utils/__tests__/cost.test.ts new file mode 100644 index 0000000..b1a44aa --- /dev/null +++ b/src/utils/__tests__/cost.test.ts @@ -0,0 +1,97 @@ +import { calculateApiCost } from '../cost'; +import { ModelInfo } from '../../shared/api'; + +describe('Cost Utility', () => { + describe('calculateApiCost', () => { + const mockModelInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 200_000, + supportsPromptCache: true, + inputPrice: 3.0, // $3 per million tokens + outputPrice: 15.0, // $15 per million tokens + cacheWritesPrice: 3.75, // $3.75 per million tokens + cacheReadsPrice: 0.3, // $0.30 per million tokens + }; + + it('should calculate basic input/output costs correctly', () => { + const cost = calculateApiCost(mockModelInfo, 1000, 500); + + // Input cost: (3.0 / 1_000_000) * 1000 = 0.003 + // Output cost: (15.0 / 1_000_000) * 500 = 0.0075 + // Total: 0.003 + 0.0075 = 0.0105 + expect(cost).toBe(0.0105); + }); + + it('should handle cache writes cost', () => { + const cost = calculateApiCost(mockModelInfo, 1000, 500, 2000); + + // Input cost: (3.0 / 1_000_000) * 1000 = 0.003 + // Output cost: (15.0 / 1_000_000) * 500 = 0.0075 + // Cache writes: (3.75 / 1_000_000) * 2000 = 0.0075 + // Total: 0.003 + 0.0075 + 0.0075 = 0.018 + expect(cost).toBeCloseTo(0.018, 6); + }); + + it('should handle cache reads cost', () => { + const cost = calculateApiCost(mockModelInfo, 1000, 500, undefined, 3000); + + // Input cost: (3.0 / 1_000_000) * 1000 = 0.003 + // Output cost: (15.0 / 1_000_000) * 500 = 0.0075 + // Cache reads: (0.3 / 1_000_000) * 3000 = 0.0009 + // Total: 0.003 + 0.0075 + 0.0009 = 0.0114 + expect(cost).toBe(0.0114); + }); + + it('should handle all cost components together', () => { + const cost = calculateApiCost(mockModelInfo, 1000, 500, 2000, 3000); + + // Input cost: (3.0 / 1_000_000) * 1000 = 0.003 + // Output cost: (15.0 / 1_000_000) * 500 = 0.0075 + // Cache writes: (3.75 / 1_000_000) * 2000 = 0.0075 + // Cache reads: (0.3 / 1_000_000) * 3000 = 0.0009 + // Total: 0.003 + 0.0075 + 0.0075 + 0.0009 = 0.0189 + expect(cost).toBe(0.0189); + }); + + it('should handle missing prices gracefully', () => { + const modelWithoutPrices: ModelInfo = { + maxTokens: 8192, + contextWindow: 200_000, + supportsPromptCache: true + }; + + const cost = calculateApiCost(modelWithoutPrices, 1000, 500, 2000, 3000); + expect(cost).toBe(0); + }); + + it('should handle zero tokens', () => { + const cost = calculateApiCost(mockModelInfo, 0, 0, 0, 0); + expect(cost).toBe(0); + }); + + it('should handle undefined cache values', () => { + const cost = calculateApiCost(mockModelInfo, 1000, 500); + + // Input cost: (3.0 / 1_000_000) * 1000 = 0.003 + // Output cost: (15.0 / 1_000_000) * 500 = 0.0075 + // Total: 0.003 + 0.0075 = 0.0105 + expect(cost).toBe(0.0105); + }); + + it('should handle missing cache prices', () => { + const modelWithoutCachePrices: ModelInfo = { + ...mockModelInfo, + cacheWritesPrice: undefined, + cacheReadsPrice: undefined + }; + + const cost = calculateApiCost(modelWithoutCachePrices, 1000, 500, 2000, 3000); + + // Should only include input and output costs + // Input cost: (3.0 / 1_000_000) * 1000 = 0.003 + // Output cost: (15.0 / 1_000_000) * 500 = 0.0075 + // Total: 0.003 + 0.0075 = 0.0105 + expect(cost).toBe(0.0105); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts new file mode 100644 index 0000000..6e2355d --- /dev/null +++ b/src/utils/__tests__/git.test.ts @@ -0,0 +1,336 @@ +import { jest } from '@jest/globals' +import { searchCommits, getCommitInfo, getWorkingState, GitCommit } from '../git' +import { ExecException } from 'child_process' + +type ExecFunction = ( + command: string, + options: { cwd?: string }, + callback: (error: ExecException | null, result?: { stdout: string; stderr: string }) => void +) => void + +type PromisifiedExec = (command: string, options?: { cwd?: string }) => Promise<{ stdout: string; stderr: string }> + +// Mock child_process.exec +jest.mock('child_process', () => ({ + exec: jest.fn() +})) + +// Mock util.promisify to return our own mock function +jest.mock('util', () => ({ + promisify: jest.fn((fn: ExecFunction): PromisifiedExec => { + return async (command: string, options?: { cwd?: string }) => { + // Call the original mock to maintain the mock implementation + return new Promise((resolve, reject) => { + fn(command, options || {}, (error: ExecException | null, result?: { stdout: string; stderr: string }) => { + if (error) { + reject(error) + } else { + resolve(result!) + } + }) + }) + } + }) +})) + +// Mock extract-text +jest.mock('../../integrations/misc/extract-text', () => ({ + truncateOutput: jest.fn(text => text) +})) + +describe('git utils', () => { + // Get the mock with proper typing + const { exec } = jest.requireMock('child_process') as { exec: jest.MockedFunction } + const cwd = '/test/path' + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('searchCommits', () => { + const mockCommitData = [ + 'abc123def456', + 'abc123', + 'fix: test commit', + 'John Doe', + '2024-01-06', + 'def456abc789', + 'def456', + 'feat: new feature', + 'Jane Smith', + '2024-01-05' + ].join('\n') + + it('should return commits when git is installed and repo exists', async () => { + // Set up mock responses + const responses = new Map([ + ['git --version', { stdout: 'git version 2.39.2', stderr: '' }], + ['git rev-parse --git-dir', { stdout: '.git', stderr: '' }], + ['git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short --grep="test" --regexp-ignore-case', { stdout: mockCommitData, stderr: '' }] + ]) + + exec.mockImplementation((command: string, options: { cwd?: string }, callback: Function) => { + // Find matching response + for (const [cmd, response] of responses) { + if (command === cmd) { + callback(null, response) + return + } + } + callback(new Error(`Unexpected command: ${command}`)) + }) + + const result = await searchCommits('test', cwd) + + // First verify the result is correct + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + hash: 'abc123def456', + shortHash: 'abc123', + subject: 'fix: test commit', + author: 'John Doe', + date: '2024-01-06' + }) + + // Then verify all commands were called correctly + expect(exec).toHaveBeenCalledWith( + 'git --version', + {}, + expect.any(Function) + ) + expect(exec).toHaveBeenCalledWith( + 'git rev-parse --git-dir', + { cwd }, + expect.any(Function) + ) + expect(exec).toHaveBeenCalledWith( + 'git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short --grep="test" --regexp-ignore-case', + { cwd }, + expect.any(Function) + ) + }, 20000) + + it('should return empty array when git is not installed', async () => { + exec.mockImplementation((command: string, options: { cwd?: string }, callback: Function) => { + if (command === 'git --version') { + callback(new Error('git not found')) + return + } + callback(new Error('Unexpected command')) + }) + + const result = await searchCommits('test', cwd) + expect(result).toEqual([]) + expect(exec).toHaveBeenCalledWith('git --version', {}, expect.any(Function)) + }) + + it('should return empty array when not in a git repository', async () => { + const responses = new Map([ + ['git --version', { stdout: 'git version 2.39.2', stderr: '' }], + ['git rev-parse --git-dir', null] // null indicates error should be called + ]) + + exec.mockImplementation((command: string, options: { cwd?: string }, callback: Function) => { + const response = responses.get(command) + if (response === null) { + callback(new Error('not a git repository')) + } else if (response) { + callback(null, response) + } else { + callback(new Error('Unexpected command')) + } + }) + + const result = await searchCommits('test', cwd) + expect(result).toEqual([]) + expect(exec).toHaveBeenCalledWith('git --version', {}, expect.any(Function)) + expect(exec).toHaveBeenCalledWith('git rev-parse --git-dir', { cwd }, expect.any(Function)) + }) + + it('should handle hash search when grep search returns no results', async () => { + const responses = new Map([ + ['git --version', { stdout: 'git version 2.39.2', stderr: '' }], + ['git rev-parse --git-dir', { stdout: '.git', stderr: '' }], + ['git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short --grep="abc123" --regexp-ignore-case', { stdout: '', stderr: '' }], + ['git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short --author-date-order abc123', { stdout: mockCommitData, stderr: '' }] + ]) + + exec.mockImplementation((command: string, options: { cwd?: string }, callback: Function) => { + for (const [cmd, response] of responses) { + if (command === cmd) { + callback(null, response) + return + } + } + callback(new Error('Unexpected command')) + }) + + const result = await searchCommits('abc123', cwd) + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + hash: 'abc123def456', + shortHash: 'abc123', + subject: 'fix: test commit', + author: 'John Doe', + date: '2024-01-06' + }) + }) + }) + + describe('getCommitInfo', () => { + const mockCommitInfo = [ + 'abc123def456', + 'abc123', + 'fix: test commit', + 'John Doe', + '2024-01-06', + 'Detailed description' + ].join('\n') + const mockStats = '1 file changed, 2 insertions(+), 1 deletion(-)' + const mockDiff = '@@ -1,1 +1,2 @@\n-old line\n+new line' + + it('should return formatted commit info', async () => { + const responses = new Map([ + ['git --version', { stdout: 'git version 2.39.2', stderr: '' }], + ['git rev-parse --git-dir', { stdout: '.git', stderr: '' }], + ['git show --format="%H%n%h%n%s%n%an%n%ad%n%b" --no-patch abc123', { stdout: mockCommitInfo, stderr: '' }], + ['git show --stat --format="" abc123', { stdout: mockStats, stderr: '' }], + ['git show --format="" abc123', { stdout: mockDiff, stderr: '' }] + ]) + + exec.mockImplementation((command: string, options: { cwd?: string }, callback: Function) => { + for (const [cmd, response] of responses) { + if (command.startsWith(cmd)) { + callback(null, response) + return + } + } + callback(new Error('Unexpected command')) + }) + + const result = await getCommitInfo('abc123', cwd) + expect(result).toContain('Commit: abc123') + expect(result).toContain('Author: John Doe') + expect(result).toContain('Files Changed:') + expect(result).toContain('Full Changes:') + }) + + it('should return error message when git is not installed', async () => { + exec.mockImplementation((command: string, options: { cwd?: string }, callback: Function) => { + if (command === 'git --version') { + callback(new Error('git not found')) + return + } + callback(new Error('Unexpected command')) + }) + + const result = await getCommitInfo('abc123', cwd) + expect(result).toBe('Git is not installed') + }) + + it('should return error message when not in a git repository', async () => { + const responses = new Map([ + ['git --version', { stdout: 'git version 2.39.2', stderr: '' }], + ['git rev-parse --git-dir', null] // null indicates error should be called + ]) + + exec.mockImplementation((command: string, options: { cwd?: string }, callback: Function) => { + const response = responses.get(command) + if (response === null) { + callback(new Error('not a git repository')) + } else if (response) { + callback(null, response) + } else { + callback(new Error('Unexpected command')) + } + }) + + const result = await getCommitInfo('abc123', cwd) + expect(result).toBe('Not a git repository') + }) + }) + + describe('getWorkingState', () => { + const mockStatus = ' M src/file1.ts\n?? src/file2.ts' + const mockDiff = '@@ -1,1 +1,2 @@\n-old line\n+new line' + + it('should return working directory changes', async () => { + const responses = new Map([ + ['git --version', { stdout: 'git version 2.39.2', stderr: '' }], + ['git rev-parse --git-dir', { stdout: '.git', stderr: '' }], + ['git status --short', { stdout: mockStatus, stderr: '' }], + ['git diff HEAD', { stdout: mockDiff, stderr: '' }] + ]) + + exec.mockImplementation((command: string, options: { cwd?: string }, callback: Function) => { + for (const [cmd, response] of responses) { + if (command === cmd) { + callback(null, response) + return + } + } + callback(new Error('Unexpected command')) + }) + + const result = await getWorkingState(cwd) + expect(result).toContain('Working directory changes:') + expect(result).toContain('src/file1.ts') + expect(result).toContain('src/file2.ts') + }) + + it('should return message when working directory is clean', async () => { + const responses = new Map([ + ['git --version', { stdout: 'git version 2.39.2', stderr: '' }], + ['git rev-parse --git-dir', { stdout: '.git', stderr: '' }], + ['git status --short', { stdout: '', stderr: '' }] + ]) + + exec.mockImplementation((command: string, options: { cwd?: string }, callback: Function) => { + for (const [cmd, response] of responses) { + if (command === cmd) { + callback(null, response) + return + } + } + callback(new Error('Unexpected command')) + }) + + const result = await getWorkingState(cwd) + expect(result).toBe('No changes in working directory') + }) + + it('should return error message when git is not installed', async () => { + exec.mockImplementation((command: string, options: { cwd?: string }, callback: Function) => { + if (command === 'git --version') { + callback(new Error('git not found')) + return + } + callback(new Error('Unexpected command')) + }) + + const result = await getWorkingState(cwd) + expect(result).toBe('Git is not installed') + }) + + it('should return error message when not in a git repository', async () => { + const responses = new Map([ + ['git --version', { stdout: 'git version 2.39.2', stderr: '' }], + ['git rev-parse --git-dir', null] // null indicates error should be called + ]) + + exec.mockImplementation((command: string, options: { cwd?: string }, callback: Function) => { + const response = responses.get(command) + if (response === null) { + callback(new Error('not a git repository')) + } else if (response) { + callback(null, response) + } else { + callback(new Error('Unexpected command')) + } + }) + + const result = await getWorkingState(cwd) + expect(result).toBe('Not a git repository') + }) + }) +}) \ No newline at end of file diff --git a/src/utils/__tests__/path.test.ts b/src/utils/__tests__/path.test.ts new file mode 100644 index 0000000..5b35c05 --- /dev/null +++ b/src/utils/__tests__/path.test.ts @@ -0,0 +1,135 @@ +import { arePathsEqual, getReadablePath } from '../path'; +import * as path from 'path'; +import os from 'os'; + +describe('Path Utilities', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform + }); + }); + + describe('String.prototype.toPosix', () => { + it('should convert backslashes to forward slashes', () => { + const windowsPath = 'C:\\Users\\test\\file.txt'; + expect(windowsPath.toPosix()).toBe('C:/Users/test/file.txt'); + }); + + it('should not modify paths with forward slashes', () => { + const unixPath = '/home/user/file.txt'; + expect(unixPath.toPosix()).toBe('/home/user/file.txt'); + }); + + it('should preserve extended-length Windows paths', () => { + const extendedPath = '\\\\?\\C:\\Very\\Long\\Path'; + expect(extendedPath.toPosix()).toBe('\\\\?\\C:\\Very\\Long\\Path'); + }); + }); + + describe('arePathsEqual', () => { + describe('on Windows', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'win32' + }); + }); + + it('should compare paths case-insensitively', () => { + expect(arePathsEqual('C:\\Users\\Test', 'c:\\users\\test')).toBe(true); + }); + + it('should handle different path separators', () => { + // Convert both paths to use forward slashes after normalization + const path1 = path.normalize('C:\\Users\\Test').replace(/\\/g, '/'); + const path2 = path.normalize('C:/Users/Test').replace(/\\/g, '/'); + expect(arePathsEqual(path1, path2)).toBe(true); + }); + + it('should normalize paths with ../', () => { + // Convert both paths to use forward slashes after normalization + const path1 = path.normalize('C:\\Users\\Test\\..\\Test').replace(/\\/g, '/'); + const path2 = path.normalize('C:\\Users\\Test').replace(/\\/g, '/'); + expect(arePathsEqual(path1, path2)).toBe(true); + }); + }); + + describe('on POSIX', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'darwin' + }); + }); + + it('should compare paths case-sensitively', () => { + expect(arePathsEqual('/Users/Test', '/Users/test')).toBe(false); + }); + + it('should normalize paths', () => { + expect(arePathsEqual('/Users/./Test', '/Users/Test')).toBe(true); + }); + + it('should handle trailing slashes', () => { + expect(arePathsEqual('/Users/Test/', '/Users/Test')).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle undefined paths', () => { + expect(arePathsEqual(undefined, undefined)).toBe(true); + expect(arePathsEqual('/test', undefined)).toBe(false); + expect(arePathsEqual(undefined, '/test')).toBe(false); + }); + + it('should handle root paths with trailing slashes', () => { + expect(arePathsEqual('/', '/')).toBe(true); + expect(arePathsEqual('C:\\', 'C:\\')).toBe(true); + }); + }); + }); + + describe('getReadablePath', () => { + const homeDir = os.homedir(); + const desktop = path.join(homeDir, 'Desktop'); + + it('should return basename when path equals cwd', () => { + const cwd = '/Users/test/project'; + expect(getReadablePath(cwd, cwd)).toBe('project'); + }); + + it('should return relative path when inside cwd', () => { + const cwd = '/Users/test/project'; + const filePath = '/Users/test/project/src/file.txt'; + expect(getReadablePath(cwd, filePath)).toBe('src/file.txt'); + }); + + it('should return absolute path when outside cwd', () => { + const cwd = '/Users/test/project'; + const filePath = '/Users/test/other/file.txt'; + expect(getReadablePath(cwd, filePath)).toBe('/Users/test/other/file.txt'); + }); + + it('should handle Desktop as cwd', () => { + const filePath = path.join(desktop, 'file.txt'); + expect(getReadablePath(desktop, filePath)).toBe(filePath.toPosix()); + }); + + it('should handle undefined relative path', () => { + const cwd = '/Users/test/project'; + expect(getReadablePath(cwd)).toBe('project'); + }); + + it('should handle parent directory traversal', () => { + const cwd = '/Users/test/project'; + const filePath = '../../other/file.txt'; + expect(getReadablePath(cwd, filePath)).toBe('/Users/other/file.txt'); + }); + + it('should normalize paths with redundant segments', () => { + const cwd = '/Users/test/project'; + const filePath = '/Users/test/project/./src/../src/file.txt'; + expect(getReadablePath(cwd, filePath)).toBe('src/file.txt'); + }); + }); +}); \ No newline at end of file From 305b1342ede53d2f3e227709e3489e94ee13c329 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 16:35:45 -0500 Subject: [PATCH 02/68] Use jest-simple-dot-reporter to cut down on test noise --- jest.config.js | 3 +++ package-lock.json | 7 +++++++ package.json | 1 + 3 files changed, 11 insertions(+) diff --git a/jest.config.js b/jest.config.js index 1ef4d79..549b518 100644 --- a/jest.config.js +++ b/jest.config.js @@ -32,6 +32,9 @@ module.exports = { modulePathIgnorePatterns: [ '.vscode-test' ], + reporters: [ + ["jest-simple-dot-reporter", {}] + ], setupFiles: [], globals: { 'ts-jest': { diff --git a/package-lock.json b/package-lock.json index 8c88b6c..3200254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "eslint": "^8.57.0", "husky": "^9.1.7", "jest": "^29.7.0", + "jest-simple-dot-reporter": "^1.0.5", "lint-staged": "^15.2.11", "npm-run-all": "^4.1.5", "ts-jest": "^29.2.5", @@ -10893,6 +10894,12 @@ "node": ">=8" } }, + "node_modules/jest-simple-dot-reporter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/jest-simple-dot-reporter/-/jest-simple-dot-reporter-1.0.5.tgz", + "integrity": "sha512-cZLFG/C7k0+WYoIGGuGXKm0vmJiXlWG/m3uCZ4RaMPYxt8lxjdXMLHYkxXaQ7gVWaSPe7uAPCEUcRxthC5xskg==", + "dev": true + }, "node_modules/jest-snapshot": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", diff --git a/package.json b/package.json index ff3bb8a..b49d4b5 100644 --- a/package.json +++ b/package.json @@ -187,6 +187,7 @@ "eslint": "^8.57.0", "husky": "^9.1.7", "jest": "^29.7.0", + "jest-simple-dot-reporter": "^1.0.5", "lint-staged": "^15.2.11", "npm-run-all": "^4.1.5", "ts-jest": "^29.2.5", From 352f34d8ce39b12b4645f0d223b5545758779d7d Mon Sep 17 00:00:00 2001 From: sam hoang Date: Sun, 5 Jan 2025 00:52:00 +0700 Subject: [PATCH 03/68] feat: config manager using secret store --- src/core/config/ConfigManager.ts | 153 ++++++++ .../config/__tests__/ConfigManager.test.ts | 348 ++++++++++++++++++ src/core/webview/ClineProvider.ts | 308 ++++++++++++---- src/shared/ExtensionMessage.ts | 11 +- src/shared/WebviewMessage.ts | 7 + src/shared/checkExistApiConfig.ts | 19 + .../components/settings/ApiConfigManager.tsx | 165 +++++++++ .../src/components/settings/SettingsView.tsx | 49 +++ .../settings/__tests__/SettingsView.test.tsx | 22 ++ .../src/context/ExtensionStateContext.tsx | 40 +- 10 files changed, 1026 insertions(+), 96 deletions(-) create mode 100644 src/core/config/ConfigManager.ts create mode 100644 src/core/config/__tests__/ConfigManager.test.ts create mode 100644 src/shared/checkExistApiConfig.ts create mode 100644 webview-ui/src/components/settings/ApiConfigManager.tsx diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts new file mode 100644 index 0000000..9082cf7 --- /dev/null +++ b/src/core/config/ConfigManager.ts @@ -0,0 +1,153 @@ +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 = "cline_config_" + private readonly context: ExtensionContext + + constructor(context: ExtensionContext) { + this.context = context + } + + /** + * Initialize config if it doesn't exist + */ + async initConfig(): Promise { + try { + const config = await this.readConfig() + console.log("config", config) + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`) + } + } + + private async readConfig(): Promise { + 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 { + 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}`) + } + } +} \ No newline at end of file diff --git a/src/core/config/__tests__/ConfigManager.test.ts b/src/core/config/__tests__/ConfigManager.test.ts new file mode 100644 index 0000000..a6527ab --- /dev/null +++ b/src/core/config/__tests__/ConfigManager.test.ts @@ -0,0 +1,348 @@ +import { ExtensionContext } from 'vscode' +import { ConfigManager } from '../ConfigManager' +import { ApiConfiguration } from '../../../shared/api' +import { ApiConfigData } from '../ConfigManager' + +// 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( + '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( + '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( + '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( + '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( + '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' + ) + }) + }) +}) \ No newline at end of file diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 579a12e..97d3521 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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 @@ -43,6 +45,7 @@ type SecretKey = | "geminiApiKey" | "openAiNativeApiKey" | "deepSeekApiKey" + | "apiConfigPassword" type GlobalStateKey = | "apiProvider" | "apiModelId" @@ -85,6 +88,9 @@ type GlobalStateKey = | "mcpEnabled" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "currentApiConfigName" + | "listApiConfigMeta" + export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", uiMessages: "ui_messages.json", @@ -103,6 +109,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, @@ -112,6 +119,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) } /* @@ -235,7 +243,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold } = await this.getState() - + this.cline = new Cline( this, apiConfiguration, @@ -255,7 +263,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { diffEnabled, fuzzyMatchThreshold } = await this.getState() - + this.cline = new Cline( this, apiConfiguration, @@ -321,15 +329,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { // Use a nonce to only allow a specific script to be run. /* - content security policy of your webview to only allow scripts that have a specific nonce - create a content security policy meta tag so that only loading scripts with a nonce is allowed - As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g. - + content security policy of your webview to only allow scripts that have a specific nonce + create a content security policy meta tag so that only loading scripts with a nonce is allowed + As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g. + - 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection - since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:; - in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial. - */ + in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial. + */ const nonce = getNonce() // Tip: Install the es6-string-html VS Code extension to enable code highlighting below @@ -410,6 +418,33 @@ 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]) && listApiConfig[0].name === "default") { + const { + apiConfiguration, + } = await this.getState() + await this.configManager.SaveConfig("default", apiConfiguration) + listApiConfig[0].apiProvider = apiConfiguration.apiProvider + } + } + + 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 @@ -424,70 +459,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { break case "apiConfiguration": if (message.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, - azureApiVersion, - openAiStreamingEnabled, - openRouterModelId, - openRouterModelInfo, - openRouterUseMiddleOutTransform, - } = message.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", message.apiConfiguration.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(message.apiConfiguration) - } + await this.updateApiConfiguration(message.apiConfiguration) } await this.postStateToWebview() break @@ -566,7 +538,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (message?.values?.baseUrl && message?.values?.apiKey) { const openAiModels = await this.getOpenAiModels(message?.values?.baseUrl, message?.values?.apiKey) this.postMessageToWebview({ type: "openAiModels", openAiModels }) - } + } break case "openImage": openImage(message.text!) @@ -805,6 +777,106 @@ 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.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) { + try { + await this.configManager.DeleteConfig(message.text); + let currentApiConfigName = (await this.getGlobalState("currentApiConfigName") as string) ?? "default" + + if (message.text === currentApiConfigName) { + await this.updateGlobalState("currentApiConfigName", "default") + } + + let listApiConfig = await this.configManager.ListConfig(); + await this.updateGlobalState("listApiConfigMeta", listApiConfig) + this.postMessageToWebview({ type: "listApiConfig", listApiConfig }) + + } 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 + case "setApiConfigPassword": + if (message.text) { + try { + await this.storeSecret("apiConfigPassword", message.text !== "" ? message.text : undefined) + } catch (error) { + console.error("Error set apiKey password:", error) + vscode.window.showErrorMessage("Failed to set apiKey password") + } + } + break } }, null, @@ -812,6 +884,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) @@ -1256,8 +1396,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + currentApiConfigName, + listApiConfigMeta, + apiKeyPassword } = await this.getState() - + const allowedCommands = vscode.workspace .getConfiguration('roo-cline') .get('allowedCommands') || [] @@ -1290,6 +1433,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, + currentApiConfigName: currentApiConfigName ?? "default", + listApiConfigMeta: listApiConfigMeta ?? [], + apiKeyPassword: apiKeyPassword ?? "" } } @@ -1397,6 +1543,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + currentApiConfigName, + listApiConfigMeta, + apiKeyPassword, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1449,6 +1598,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("mcpEnabled") as Promise, this.getGlobalState("alwaysApproveResubmit") as Promise, this.getGlobalState("requestDelaySeconds") as Promise, + this.getGlobalState("currentApiConfigName") as Promise, + this.getGlobalState("listApiConfigMeta") as Promise, + this.getSecret("apiConfigPassword") as Promise, ]) let apiProvider: ApiProvider @@ -1545,6 +1697,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 5, + currentApiConfigName: currentApiConfigName ?? "default", + listApiConfigMeta: listApiConfigMeta ?? [], + apiKeyPassword: apiKeyPassword ?? "" } } @@ -1622,6 +1777,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { "geminiApiKey", "openAiNativeApiKey", "deepSeekApiKey", + "apiConfigPassword" ] for (const key of secretKeys) { await this.storeSecret(key, undefined) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 6b877a0..8972958 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -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" @@ -23,6 +23,7 @@ export interface ExtensionMessage { | "mcpServers" | "enhancedPrompt" | "commitSearchResults" + | "listApiConfig" text?: string action?: | "chatButtonClicked" @@ -42,6 +43,12 @@ export interface ExtensionMessage { openAiModels?: string[] mcpServers?: McpServer[] commits?: GitCommit[] + listApiConfig?: ApiConfigMeta[] +} + +export interface ApiConfigMeta { + name: string + apiProvider?: ApiProvider } export interface ExtensionState { @@ -50,6 +57,8 @@ export interface ExtensionState { taskHistory: HistoryItem[] shouldShowAnnouncement: boolean apiConfiguration?: ApiConfiguration + currentApiConfigName?: string + listApiConfigMeta?: ApiConfigMeta[] customInstructions?: string alwaysAllowReadOnly?: boolean alwaysAllowWrite?: boolean diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 0ca7cb3..4072526 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -5,6 +5,12 @@ export type AudioType = "notification" | "celebration" | "progress_loop" export interface WebviewMessage { type: | "apiConfiguration" + | "currentApiConfigName" + | "upsertApiConfiguration" + | "deleteApiConfiguration" + | "loadApiConfiguration" + | "renameApiConfiguration" + | "getListApiConfiguration" | "customInstructions" | "allowedCommands" | "alwaysAllowReadOnly" @@ -54,6 +60,7 @@ export interface WebviewMessage { | "searchCommits" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "setApiConfigPassword" text?: string disabled?: boolean askResponse?: ClineAskResponse diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts new file mode 100644 index 0000000..b347ccf --- /dev/null +++ b/src/shared/checkExistApiConfig.ts @@ -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; +} diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx new file mode 100644 index 0000000..2464840 --- /dev/null +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -0,0 +1,165 @@ +import { VSCodeButton, VSCodeDivider, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { memo, useState } from "react" +import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage" + +interface ApiConfigManagerProps { + currentApiConfigName?: string + listApiConfigMeta?: ApiConfigMeta[] + onSelectConfig: (configName: string) => void + onDeleteConfig: (configName: string) => void + onRenameConfig: (oldName: string, newName: string) => void + onUpsertConfig: (configName: string) => void + // setDraftNewConfig: (mode: boolean) => void +} + +const ApiConfigManager = ({ + currentApiConfigName, + listApiConfigMeta, + onSelectConfig, + onDeleteConfig, + onRenameConfig, + onUpsertConfig, + // setDraftNewConfig, +}: ApiConfigManagerProps) => { + const [isNewMode, setIsNewMode] = useState(false); + const [isRenameMode, setIsRenameMode] = useState(false); + const [newConfigName, setNewConfigName] = useState(""); + const [renamedConfigName, setRenamedConfigName] = useState(""); + + const handleNewConfig = () => { + setIsNewMode(true); + setNewConfigName(""); + // setDraftNewConfig(true) + }; + + const handleSaveNewConfig = () => { + if (newConfigName.trim()) { + onUpsertConfig(newConfigName.trim()); + setIsNewMode(false); + setNewConfigName(""); + // setDraftNewConfig(false) + } + }; + + const handleCancelNewConfig = () => { + setIsNewMode(false); + setNewConfigName(""); + // setDraftNewConfig(false) + }; + + const handleStartRename = () => { + setIsRenameMode(true); + setRenamedConfigName(currentApiConfigName || ""); + }; + + const handleSaveRename = () => { + if (renamedConfigName.trim() && currentApiConfigName) { + onRenameConfig(currentApiConfigName, renamedConfigName.trim()); + setIsRenameMode(false); + setRenamedConfigName(""); + } + }; + + const handleCancelRename = () => { + setIsRenameMode(false); + setRenamedConfigName(""); + }; + + return ( +
+ +
+ {isNewMode ? ( + <> + setNewConfigName(e.target.value)} + placeholder="Enter configuration name" + style={{ flexGrow: 1 }} + /> + + Save + + + Cancel + + + ) : isRenameMode ? ( + <> + setRenamedConfigName(e.target.value)} + placeholder="Enter new name" + style={{ flexGrow: 1 }} + /> + + Save + + + Cancel + + + ) : ( + <> + + + New + + + Rename + + onDeleteConfig(currentApiConfigName!)} + > + Delete + + + )} +
+ +
+ ) +} + +export default memo(ApiConfigManager) \ No newline at end of file diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 956b76b..8940833 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -5,6 +5,7 @@ import { validateApiConfiguration, validateModelId } from "../../utils/validate" import { vscode } from "../../utils/vscode" import ApiOptions from "./ApiOptions" import McpEnabledToggle from "../mcp/McpEnabledToggle" +import ApiConfigManager from "./ApiConfigManager" const IS_DEV = false // FIXME: use flags when packaging @@ -55,10 +56,15 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setAlwaysApproveResubmit, requestDelaySeconds, setRequestDelaySeconds, + currentApiConfigName, + listApiConfigMeta, } = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) const [commandInput, setCommandInput] = useState("") + // const [draftNewMode, setDraftNewMode] = useState(false) + + const handleSubmit = () => { const apiValidationResult = validateApiConfiguration(apiConfiguration) const modelIdValidationResult = validateModelId(apiConfiguration, glamaModels, openRouterModels) @@ -89,6 +95,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled }) vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit }) vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds }) + vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName }) + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration + }) + onDone() } } @@ -150,6 +163,42 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
+
+ { + vscode.postMessage({ + type: "loadApiConfiguration", + text: configName + }) + }} + onDeleteConfig={(configName: string) => { + vscode.postMessage({ + type: "deleteApiConfiguration", + text: configName + }) + }} + onRenameConfig={(oldName: string, newName: string) => { + vscode.postMessage({ + type: "renameApiConfiguration", + values: {oldName, newName}, + apiConfiguration + }) + }} + onUpsertConfig={(configName: string) => { + vscode.postMessage({ + type: "upsertApiConfiguration", + text: configName, + apiConfiguration + }) + }} + // setDraftNewConfig={(mode: boolean) => { + // setDraftNewMode(mode) + // }} + /> +
+

Provider Settings

({ }, })) +// Mock ApiConfigManager component +jest.mock('../ApiConfigManager', () => ({ + __esModule: true, + default: ({ currentApiConfigName, listApiConfigMeta, onSelectConfig, onDeleteConfig, onRenameConfig, onUpsertConfig }: any) => ( +
+ Current config: {currentApiConfigName} +
+ ) +})) + // Mock VSCode components jest.mock('@vscode/webview-ui-toolkit/react', () => ({ VSCodeButton: ({ children, onClick, appearance }: any) => ( @@ -185,6 +195,18 @@ describe('SettingsView - Sound Settings', () => { }) }) +describe('SettingsView - API Configuration', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders ApiConfigManagement with correct props', () => { + renderSettingsView() + + expect(screen.getByTestId('api-config-management')).toBeInTheDocument() + }) +}) + describe('SettingsView - Allowed Commands', () => { beforeEach(() => { jest.clearAllMocks() diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 131364b..4aa874e 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useCallback, useContext, useEffect, useState } from "react" import { useEvent } from "react-use" -import { ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage" +import { ApiConfigMeta, ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage" import { ApiConfiguration, ModelInfo, @@ -13,6 +13,9 @@ import { vscode } from "../utils/vscode" import { convertTextMateToHljs } from "../utils/textMateToHljs" import { findLastIndex } from "../../../src/shared/array" import { McpServer } from "../../../src/shared/mcp" +import { + checkExistKey +} from "../../../src/shared/checkExistApiConfig" export interface ExtensionStateContextType extends ExtensionState { didHydrateState: boolean @@ -50,6 +53,8 @@ export interface ExtensionStateContextType extends ExtensionState { setAlwaysApproveResubmit: (value: boolean) => void requestDelaySeconds: number setRequestDelaySeconds: (value: number) => void + setCurrentApiConfigName: (value: string) => void + setListApiConfigMeta: (value: ApiConfigMeta[]) => void } export const ExtensionStateContext = createContext(undefined) @@ -72,7 +77,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode terminalOutputLineLimit: 500, mcpEnabled: true, alwaysApproveResubmit: false, - requestDelaySeconds: 5 + requestDelaySeconds: 5, + currentApiConfigName: 'default', + listApiConfigMeta: [], }) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -88,27 +95,16 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [openAiModels, setOpenAiModels] = useState([]) const [mcpServers, setMcpServers] = useState([]) + + const setListApiConfigMeta = useCallback((value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), [setState]) + const handleMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data switch (message.type) { case "state": { setState(message.state!) const config = message.state?.apiConfiguration - const hasKey = 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 + const hasKey = checkExistKey(config) setShowWelcome(!hasKey) setDidHydrateState(true) break @@ -162,8 +158,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setMcpServers(message.mcpServers ?? []) break } + case "listApiConfig": { + setListApiConfigMeta(message.listApiConfig ?? []) + break + } } - }, []) + }, [setListApiConfigMeta]) useEvent("message", handleMessage) @@ -208,7 +208,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setTerminalOutputLineLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })), setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })), - setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })) + setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })), + setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), + setListApiConfigMeta } return {children} From 840276b2976e5c29a4a040b2be2e8ac43c5ca224 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Sun, 5 Jan 2025 11:09:16 +0700 Subject: [PATCH 04/68] chore: remove verbose log --- src/core/config/ConfigManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 9082cf7..960babe 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -29,7 +29,6 @@ export class ConfigManager { async initConfig(): Promise { try { const config = await this.readConfig() - console.log("config", config) if (!config) { await this.writeConfig(this.defaultConfig) } From c3fa10b367f81638f848a4077c0623e62cf14fa6 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 04:21:21 -0500 Subject: [PATCH 05/68] UI cleanup --- .../src/components/chat/ChatTextArea.tsx | 62 +++- .../components/settings/ApiConfigManager.tsx | 286 +++++++++++------- .../src/components/settings/SettingsView.tsx | 8 +- 3 files changed, 235 insertions(+), 121 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 2078013..8762466 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -44,9 +44,21 @@ const ChatTextArea = forwardRef( }, ref, ) => { - const { filePaths, apiConfiguration } = useExtensionState() + const { filePaths, apiConfiguration, currentApiConfigName, listApiConfigMeta } = useExtensionState() const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [gitCommits, setGitCommits] = useState([]) + const [showDropdown, setShowDropdown] = useState(false) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (showDropdown) { + setShowDropdown(false) + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [showDropdown]) // Handle enhanced prompt response useEffect(() => { @@ -656,6 +668,54 @@ const ChatTextArea = forwardRef( }} /> )} + {(listApiConfigMeta || []).length > 1 && ( +
+ +
+ )}
{apiConfiguration?.apiProvider === "openrouter" && ( diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index 2464840..b6cf5dd 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -1,5 +1,5 @@ -import { VSCodeButton, VSCodeDivider, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { memo, useState } from "react" +import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { memo, useEffect, useRef, useState } from "react" import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage" interface ApiConfigManagerProps { @@ -9,7 +9,6 @@ interface ApiConfigManagerProps { onDeleteConfig: (configName: string) => void onRenameConfig: (oldName: string, newName: string) => void onUpsertConfig: (configName: string) => void - // setDraftNewConfig: (mode: boolean) => void } const ApiConfigManager = ({ @@ -19,145 +18,206 @@ const ApiConfigManager = ({ onDeleteConfig, onRenameConfig, onUpsertConfig, - // setDraftNewConfig, }: ApiConfigManagerProps) => { - const [isNewMode, setIsNewMode] = useState(false); - const [isRenameMode, setIsRenameMode] = useState(false); - const [newConfigName, setNewConfigName] = useState(""); - const [renamedConfigName, setRenamedConfigName] = useState(""); + const [editState, setEditState] = useState<'new' | 'rename' | null>(null); + const [inputValue, setInputValue] = useState(""); + const inputRef = useRef(); - const handleNewConfig = () => { - setIsNewMode(true); - setNewConfigName(""); - // setDraftNewConfig(true) - }; - - const handleSaveNewConfig = () => { - if (newConfigName.trim()) { - onUpsertConfig(newConfigName.trim()); - setIsNewMode(false); - setNewConfigName(""); - // setDraftNewConfig(false) + // Focus input when entering edit mode + useEffect(() => { + if (editState) { + setTimeout(() => inputRef.current?.focus(), 0); } - }; + }, [editState]); - const handleCancelNewConfig = () => { - setIsNewMode(false); - setNewConfigName(""); - // setDraftNewConfig(false) + // Reset edit state when current profile changes + useEffect(() => { + setEditState(null); + setInputValue(""); + }, [currentApiConfigName]); + + const handleStartNew = () => { + setEditState('new'); + setInputValue(""); }; const handleStartRename = () => { - setIsRenameMode(true); - setRenamedConfigName(currentApiConfigName || ""); + setEditState('rename'); + setInputValue(currentApiConfigName || ""); }; - const handleSaveRename = () => { - if (renamedConfigName.trim() && currentApiConfigName) { - onRenameConfig(currentApiConfigName, renamedConfigName.trim()); - setIsRenameMode(false); - setRenamedConfigName(""); + const handleCancel = () => { + setEditState(null); + setInputValue(""); + }; + + const handleSave = () => { + const trimmedValue = inputValue.trim(); + if (!trimmedValue) return; + + if (editState === 'new') { + onUpsertConfig(trimmedValue); + } else if (editState === 'rename' && currentApiConfigName) { + onRenameConfig(currentApiConfigName, trimmedValue); } + + setEditState(null); + setInputValue(""); }; - const handleCancelRename = () => { - setIsRenameMode(false); - setRenamedConfigName(""); + const handleDelete = () => { + if (!currentApiConfigName || !listApiConfigMeta || listApiConfigMeta.length <= 1) return; + + // Let the extension handle both deletion and selection + onDeleteConfig(currentApiConfigName); }; + const isOnlyProfile = listApiConfigMeta?.length === 1; + return ( -
- -
- {isNewMode ? ( - <> +
+
+ + + {editState ? ( +
setNewConfigName(e.target.value)} - placeholder="Enter configuration name" + ref={inputRef as any} + value={inputValue} + onInput={(e: any) => setInputValue(e.target.value)} + placeholder={editState === 'new' ? "Enter profile name" : "Enter new name"} style={{ flexGrow: 1 }} + onKeyDown={(e: any) => { + if (e.key === 'Enter' && inputValue.trim()) { + handleSave(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }} /> - Save + - Cancel + - - ) : isRenameMode ? ( - <> - setRenamedConfigName(e.target.value)} - placeholder="Enter new name" - style={{ flexGrow: 1 }} - /> - - Save - - - Cancel - - +
) : ( <> - - - New - - - Rename - - onDeleteConfig(currentApiConfigName!)} - > - Delete - +
+ + + + + {currentApiConfigName && ( + <> + + + + + + + + )} +
+

+ Save different API configurations to quickly switch between providers and settings +

)}
-
) } diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 8940833..6a0883c 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -164,6 +164,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
+

Provider Settings

{ apiConfiguration }) }} - // setDraftNewConfig={(mode: boolean) => { - // setDraftNewMode(mode) - // }} /> -
- -
-

Provider Settings

Date: Tue, 7 Jan 2025 20:16:44 +0700 Subject: [PATCH 06/68] fix: change provider not update without done, update chatbox change provider from MrUbens --- src/core/config/ConfigManager.ts | 12 +++ .../config/__tests__/ConfigManager.test.ts | 40 +++++++++- src/core/webview/ClineProvider.ts | 76 +++++++++++++------ .../components/settings/ApiConfigManager.tsx | 4 +- .../src/components/settings/ApiOptions.tsx | 8 +- .../src/components/settings/SettingsView.tsx | 12 ++- .../src/components/welcome/WelcomeView.tsx | 2 +- 7 files changed, 121 insertions(+), 33 deletions(-) diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 960babe..562ecb3 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -125,6 +125,18 @@ export class ConfigManager { } } + /** + * Check if a config exists by name + */ + async HasConfig(name: string): Promise { + 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 { try { const configKey = `${this.SCOPE_PREFIX}api_config` diff --git a/src/core/config/__tests__/ConfigManager.test.ts b/src/core/config/__tests__/ConfigManager.test.ts index a6527ab..f185ede 100644 --- a/src/core/config/__tests__/ConfigManager.test.ts +++ b/src/core/config/__tests__/ConfigManager.test.ts @@ -1,7 +1,6 @@ import { ExtensionContext } from 'vscode' -import { ConfigManager } from '../ConfigManager' +import { ConfigManager, ApiConfigData } from '../ConfigManager' import { ApiConfiguration } from '../../../shared/api' -import { ApiConfigData } from '../ConfigManager' // Mock VSCode ExtensionContext const mockSecrets = { @@ -345,4 +344,41 @@ describe('ConfigManager', () => { ) }) }) + + 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' + ) + }) + }) }) \ No newline at end of file diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 97d3521..94b8ea5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -45,7 +45,6 @@ type SecretKey = | "geminiApiKey" | "openAiNativeApiKey" | "deepSeekApiKey" - | "apiConfigPassword" type GlobalStateKey = | "apiProvider" | "apiModelId" @@ -428,15 +427,37 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (listApiConfig.length === 1) { // check if first time init then sync with exist config - if (!checkExistKey(listApiConfig[0]) && listApiConfig[0].name === "default") { + if (!checkExistKey(listApiConfig[0])) { const { apiConfiguration, } = await this.getState() - await this.configManager.SaveConfig("default", apiConfiguration) + 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), @@ -785,6 +806,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { let listApiConfig = await this.configManager.ListConfig(); await Promise.all([ + this.updateApiConfiguration(message.apiConfiguration), this.updateGlobalState("currentApiConfigName", message.text), this.updateGlobalState("listApiConfigMeta", listApiConfig), ]) @@ -800,7 +822,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (message.values && message.apiConfiguration) { try { - const {oldName, newName} = message.values + const { oldName, newName } = message.values await this.configManager.SaveConfig(newName, message.apiConfiguration); @@ -839,17 +861,37 @@ export class ClineProvider implements vscode.WebviewViewProvider { break case "deleteApiConfiguration": if (message.text) { + + const answer = await vscode.window.showInformationMessage( + "What would you like to delete this api config?", + { modal: true }, + "Yes", + "No", + ) + + if (answer === "No" || answer === undefined) { + break + } + try { await this.configManager.DeleteConfig(message.text); - let currentApiConfigName = (await this.getGlobalState("currentApiConfigName") as string) ?? "default" + let listApiConfig = await this.configManager.ListConfig() + let currentApiConfigName = await this.getGlobalState("currentApiConfigName") if (message.text === currentApiConfigName) { - await this.updateGlobalState("currentApiConfigName", "default") + 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() + } } - let listApiConfig = await this.configManager.ListConfig(); - await this.updateGlobalState("listApiConfigMeta", listApiConfig) - this.postMessageToWebview({ type: "listApiConfig", listApiConfig }) + // this.postMessageToWebview({ type: "listApiConfig", listApiConfig }) } catch (error) { console.error("Error delete api configuration:", error) @@ -867,16 +909,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { vscode.window.showErrorMessage("Failed to get list api configuration") } break - case "setApiConfigPassword": - if (message.text) { - try { - await this.storeSecret("apiConfigPassword", message.text !== "" ? message.text : undefined) - } catch (error) { - console.error("Error set apiKey password:", error) - vscode.window.showErrorMessage("Failed to set apiKey password") - } - } - break } }, null, @@ -1398,7 +1430,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds, currentApiConfigName, listApiConfigMeta, - apiKeyPassword } = await this.getState() const allowedCommands = vscode.workspace @@ -1435,7 +1466,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds: requestDelaySeconds ?? 5, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], - apiKeyPassword: apiKeyPassword ?? "" } } @@ -1545,7 +1575,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds, currentApiConfigName, listApiConfigMeta, - apiKeyPassword, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1600,7 +1629,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("requestDelaySeconds") as Promise, this.getGlobalState("currentApiConfigName") as Promise, this.getGlobalState("listApiConfigMeta") as Promise, - this.getSecret("apiConfigPassword") as Promise, ]) let apiProvider: ApiProvider @@ -1699,7 +1727,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { requestDelaySeconds: requestDelaySeconds ?? 5, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], - apiKeyPassword: apiKeyPassword ?? "" } } @@ -1777,7 +1804,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { "geminiApiKey", "openAiNativeApiKey", "deepSeekApiKey", - "apiConfigPassword" ] for (const key of secretKeys) { await this.storeSecret(key, undefined) diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index b6cf5dd..e69865d 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -12,8 +12,8 @@ interface ApiConfigManagerProps { } const ApiConfigManager = ({ - currentApiConfigName, - listApiConfigMeta, + currentApiConfigName = "", + listApiConfigMeta = [], onSelectConfig, onDeleteConfig, onRenameConfig, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index f38a0a3..3ddb55f 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -46,9 +46,10 @@ interface ApiOptionsProps { showModelOptions: boolean apiErrorMessage?: string modelIdErrorMessage?: string + onSelectProvider: (apiProvider: any) => void } -const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { +const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, onSelectProvider }: ApiOptionsProps) => { const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) @@ -130,7 +131,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: { + onSelectProvider(event.target.value); + handleInputChange("apiProvider")(event); + }} style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}> OpenRouter Anthropic diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 6a0883c..0567d92 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -183,7 +183,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { onRenameConfig={(oldName: string, newName: string) => { vscode.postMessage({ type: "renameApiConfiguration", - values: {oldName, newName}, + values: { oldName, newName }, apiConfiguration }) }} @@ -199,6 +199,16 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { showModelOptions={true} apiErrorMessage={apiErrorMessage} modelIdErrorMessage={modelIdErrorMessage} + onSelectProvider={(apiProvider: any) => { + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration: { + ...apiConfiguration, + apiProvider: apiProvider, + } + }) + }} />
diff --git a/webview-ui/src/components/welcome/WelcomeView.tsx b/webview-ui/src/components/welcome/WelcomeView.tsx index ef15a4e..fdaedb9 100644 --- a/webview-ui/src/components/welcome/WelcomeView.tsx +++ b/webview-ui/src/components/welcome/WelcomeView.tsx @@ -38,7 +38,7 @@ const WelcomeView = () => { To get started, this extension needs an API provider for Claude 3.5 Sonnet.
- + {}} /> Let's go! From 20322af5df0bffcaa8b13d7abd1f4181dc3f98d8 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 09:28:45 -0500 Subject: [PATCH 07/68] Delete confirmation tweak --- src/core/webview/ClineProvider.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 94b8ea5..2e64379 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -863,13 +863,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (message.text) { const answer = await vscode.window.showInformationMessage( - "What would you like to delete this api config?", + "Are you sure you want to delete this configuration profile?", { modal: true }, "Yes", - "No", ) - if (answer === "No" || answer === undefined) { + if (answer !== "Yes") { break } From 921f8844ebb9455dc3a677b0024228c02c29719d Mon Sep 17 00:00:00 2001 From: sam hoang Date: Wed, 8 Jan 2025 00:29:52 +0700 Subject: [PATCH 08/68] fix: config manager not update when model, key, another optionn... --- webview-ui/src/components/settings/ApiOptions.tsx | 14 ++++++-------- .../src/components/settings/GlamaModelPicker.tsx | 9 ++++++--- .../src/components/settings/OpenAiModelPicker.tsx | 9 ++++++--- .../components/settings/OpenRouterModelPicker.tsx | 9 ++++++--- .../src/components/settings/SettingsView.tsx | 10 ---------- webview-ui/src/components/welcome/WelcomeView.tsx | 2 +- webview-ui/src/context/ExtensionStateContext.tsx | 12 +++++++++++- 7 files changed, 36 insertions(+), 29 deletions(-) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 3ddb55f..ff1535d 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -46,11 +46,10 @@ interface ApiOptionsProps { showModelOptions: boolean apiErrorMessage?: string modelIdErrorMessage?: string - onSelectProvider: (apiProvider: any) => void } -const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, onSelectProvider }: ApiOptionsProps) => { - const { apiConfiguration, setApiConfiguration, uriScheme } = useExtensionState() +const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { + const { apiConfiguration, setApiConfiguration, uriScheme, onUpdateApiConfig } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) @@ -58,7 +57,9 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, on const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => { - setApiConfiguration({ ...apiConfiguration, [field]: event.target.value }) + const apiConfig = { ...apiConfiguration, [field]: event.target.value } + onUpdateApiConfig(apiConfig) + setApiConfiguration(apiConfig) } const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => { @@ -131,10 +132,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, on { - onSelectProvider(event.target.value); - handleInputChange("apiProvider")(event); - }} + onChange={handleInputChange("apiProvider")} style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}> OpenRouter Anthropic diff --git a/webview-ui/src/components/settings/GlamaModelPicker.tsx b/webview-ui/src/components/settings/GlamaModelPicker.tsx index 6823cc0..1b6164d 100644 --- a/webview-ui/src/components/settings/GlamaModelPicker.tsx +++ b/webview-ui/src/components/settings/GlamaModelPicker.tsx @@ -11,7 +11,7 @@ import { highlight } from "../history/HistoryView" import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" const GlamaModelPicker: React.FC = () => { - const { apiConfiguration, setApiConfiguration, glamaModels } = useExtensionState() + const { apiConfiguration, setApiConfiguration, glamaModels, onUpdateApiConfig } = useExtensionState() const [searchTerm, setSearchTerm] = useState(apiConfiguration?.glamaModelId || glamaDefaultModelId) const [isDropdownVisible, setIsDropdownVisible] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) @@ -22,11 +22,14 @@ const GlamaModelPicker: React.FC = () => { const handleModelChange = (newModelId: string) => { // could be setting invalid model id/undefined info but validation will catch it - setApiConfiguration({ + const apiConfig = { ...apiConfiguration, glamaModelId: newModelId, glamaModelInfo: glamaModels[newModelId], - }) + } + setApiConfiguration(apiConfig) + onUpdateApiConfig(apiConfig) + setSearchTerm(newModelId) } diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx index 31cbddc..7979244 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -8,7 +8,7 @@ import { vscode } from "../../utils/vscode" import { highlight } from "../history/HistoryView" const OpenAiModelPicker: React.FC = () => { - const { apiConfiguration, setApiConfiguration, openAiModels } = useExtensionState() + const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState() const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openAiModelId || "") const [isDropdownVisible, setIsDropdownVisible] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) @@ -18,10 +18,13 @@ const OpenAiModelPicker: React.FC = () => { const handleModelChange = (newModelId: string) => { // could be setting invalid model id/undefined info but validation will catch it - setApiConfiguration({ + const apiConfig = { ...apiConfiguration, openAiModelId: newModelId, - }) + } + setApiConfiguration(apiConfig) + onUpdateApiConfig(apiConfig) + setSearchTerm(newModelId) } diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx index bd4efd8..10086e7 100644 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx @@ -11,7 +11,7 @@ import { highlight } from "../history/HistoryView" import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" const OpenRouterModelPicker: React.FC = () => { - const { apiConfiguration, setApiConfiguration, openRouterModels } = useExtensionState() + const { apiConfiguration, setApiConfiguration, openRouterModels, onUpdateApiConfig } = useExtensionState() const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId) const [isDropdownVisible, setIsDropdownVisible] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) @@ -22,11 +22,14 @@ const OpenRouterModelPicker: React.FC = () => { const handleModelChange = (newModelId: string) => { // could be setting invalid model id/undefined info but validation will catch it - setApiConfiguration({ + const apiConfig = { ...apiConfiguration, openRouterModelId: newModelId, openRouterModelInfo: openRouterModels[newModelId], - }) + } + + setApiConfiguration(apiConfig) + onUpdateApiConfig(apiConfig) setSearchTerm(newModelId) } diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 0567d92..2d2a04e 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -199,16 +199,6 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { showModelOptions={true} apiErrorMessage={apiErrorMessage} modelIdErrorMessage={modelIdErrorMessage} - onSelectProvider={(apiProvider: any) => { - vscode.postMessage({ - type: "upsertApiConfiguration", - text: currentApiConfigName, - apiConfiguration: { - ...apiConfiguration, - apiProvider: apiProvider, - } - }) - }} />
diff --git a/webview-ui/src/components/welcome/WelcomeView.tsx b/webview-ui/src/components/welcome/WelcomeView.tsx index fdaedb9..ef15a4e 100644 --- a/webview-ui/src/components/welcome/WelcomeView.tsx +++ b/webview-ui/src/components/welcome/WelcomeView.tsx @@ -38,7 +38,7 @@ const WelcomeView = () => { To get started, this extension needs an API provider for Claude 3.5 Sonnet.
- {}} /> + Let's go! diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 4aa874e..48a0757 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -55,6 +55,7 @@ export interface ExtensionStateContextType extends ExtensionState { setRequestDelaySeconds: (value: number) => void setCurrentApiConfigName: (value: string) => void setListApiConfigMeta: (value: ApiConfigMeta[]) => void + onUpdateApiConfig: (apiConfig: ApiConfiguration) => void } export const ExtensionStateContext = createContext(undefined) @@ -98,6 +99,14 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const setListApiConfigMeta = useCallback((value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), [setState]) + const onUpdateApiConfig = useCallback((apiConfig: ApiConfiguration) => { + vscode.postMessage({ + type: "upsertApiConfiguration", + text: state.currentApiConfigName, + apiConfiguration: apiConfig, + }) + }, [state]) + const handleMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data switch (message.type) { @@ -210,7 +219,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })), setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })), setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), - setListApiConfigMeta + setListApiConfigMeta, + onUpdateApiConfig } return {children} From f39eaa14ffb774259d2bf1e8129ffc7cb05cf70b Mon Sep 17 00:00:00 2001 From: sam hoang Date: Wed, 8 Jan 2025 07:53:56 +0700 Subject: [PATCH 09/68] fix: sync model picker search terms with selected models Added useEffect hooks to GlamaModelPicker, OpenAiModelPicker, and OpenRouterModelPicker components to ensure the search term stays synchronized with the selected model ID from apiConfiguration. This prevents the search term from getting out of sync when the model is changed. --- webview-ui/src/components/settings/GlamaModelPicker.tsx | 9 +++++++++ webview-ui/src/components/settings/OpenAiModelPicker.tsx | 8 ++++++++ .../src/components/settings/OpenRouterModelPicker.tsx | 8 ++++++++ 3 files changed, 25 insertions(+) diff --git a/webview-ui/src/components/settings/GlamaModelPicker.tsx b/webview-ui/src/components/settings/GlamaModelPicker.tsx index 1b6164d..2df9984 100644 --- a/webview-ui/src/components/settings/GlamaModelPicker.tsx +++ b/webview-ui/src/components/settings/GlamaModelPicker.tsx @@ -37,6 +37,15 @@ const GlamaModelPicker: React.FC = () => { return normalizeApiConfiguration(apiConfiguration) }, [apiConfiguration]) + + useEffect(() => { + if (apiConfiguration?.glamaModelId) { + if (apiConfiguration?.glamaModelId !== searchTerm) { + setSearchTerm(apiConfiguration?.glamaModelId) + } + } + }, [apiConfiguration, searchTerm]) + useMount(() => { vscode.postMessage({ type: "refreshGlamaModels" }) }) diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx index 7979244..cb8a6a4 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -28,6 +28,14 @@ const OpenAiModelPicker: React.FC = () => { setSearchTerm(newModelId) } + useEffect(() => { + if (apiConfiguration?.openAiModelId) { + if (apiConfiguration?.openAiModelId !== searchTerm) { + setSearchTerm(apiConfiguration?.openAiModelId) + } + } + }, [apiConfiguration, searchTerm]) + useEffect(() => { if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) { return diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx index 10086e7..df2883c 100644 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx @@ -37,6 +37,14 @@ const OpenRouterModelPicker: React.FC = () => { return normalizeApiConfiguration(apiConfiguration) }, [apiConfiguration]) + useEffect(() => { + if (apiConfiguration?.openRouterModelId) { + if (apiConfiguration?.openRouterModelId !== searchTerm) { + setSearchTerm(apiConfiguration?.openRouterModelId) + } + } + }, [apiConfiguration, searchTerm]) + useMount(() => { vscode.postMessage({ type: "refreshOpenRouterModels" }) }) From bb774e17eb8ce1c11f73f30361f330037973ff1c Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 20:14:12 -0500 Subject: [PATCH 10/68] Release --- .changeset/shiny-seahorses-peel.md | 5 +++++ README.md | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/shiny-seahorses-peel.md diff --git a/.changeset/shiny-seahorses-peel.md b/.changeset/shiny-seahorses-peel.md new file mode 100644 index 0000000..60f9108 --- /dev/null +++ b/.changeset/shiny-seahorses-peel.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Save different API configurations to quickly switch between providers and settings diff --git a/README.md b/README.md index d067902..07f0e7f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f - Drag and drop images into chats - Delete messages from chats - @-mention Git commits to include their context in the chat +- Save different API configurations to quickly switch between providers and settings - "Enhance prompt" button (OpenRouter models only for now) - Sound effects for feedback - Option to use browsers of different sizes and adjust screenshot quality From 2cffbc860bf1f28253a46bcd2c94d7d4d7bc9200 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 20:30:23 -0500 Subject: [PATCH 11/68] Tweak thumbnail display --- webview-ui/src/components/chat/ChatTextArea.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 8762466..ec94cba 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -661,7 +661,7 @@ const ChatTextArea = forwardRef( style={{ position: "absolute", paddingTop: 4, - bottom: 14, + bottom: 32, left: 22, right: 67, zIndex: 2, From 525b7424fe1fa38098419cddac4bef132d2f7823 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 7 Jan 2025 22:30:21 -0500 Subject: [PATCH 12/68] Cleanup the welcome screen to be less Claude focused --- src/core/config/ConfigManager.ts | 2 +- src/core/config/__tests__/ConfigManager.test.ts | 10 +++++----- webview-ui/src/components/chat/ChatView.tsx | 9 ++------- webview-ui/src/components/settings/ApiOptions.tsx | 10 ++++------ webview-ui/src/components/settings/SettingsView.tsx | 3 --- webview-ui/src/components/welcome/WelcomeView.tsx | 13 ++++--------- 6 files changed, 16 insertions(+), 31 deletions(-) diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 562ecb3..7e4393d 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -16,7 +16,7 @@ export class ConfigManager { default: {} } } - private readonly SCOPE_PREFIX = "cline_config_" + private readonly SCOPE_PREFIX = "roo_cline_config_" private readonly context: ExtensionContext constructor(context: ExtensionContext) { diff --git a/src/core/config/__tests__/ConfigManager.test.ts b/src/core/config/__tests__/ConfigManager.test.ts index f185ede..b8170ee 100644 --- a/src/core/config/__tests__/ConfigManager.test.ts +++ b/src/core/config/__tests__/ConfigManager.test.ts @@ -121,7 +121,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) @@ -154,7 +154,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) @@ -196,7 +196,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) @@ -256,7 +256,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) @@ -314,7 +314,7 @@ describe('ConfigManager', () => { } expect(mockSecrets.store).toHaveBeenCalledWith( - 'cline_config_api_config', + 'roo_cline_config_api_config', JSON.stringify(expectedConfig, null, 2) ) }) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index db11547..12a7e93 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1,4 +1,4 @@ -import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react" +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import debounce from "debounce" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useDeepCompareEffect, useEvent, useMount } from "react-use" @@ -868,12 +868,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie

What can I do for you?

- Thanks to{" "} - - Claude 3.5 Sonnet's agentic coding capabilities, - {" "} + Thanks to the latest breakthroughs in agentic coding capabilities, I can handle complex software development tasks step-by-step. With tools that let me create & edit files, explore complex projects, use the browser, and execute terminal commands (after you grant permission), I can assist you in ways that go beyond code completion or diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index ff1535d..cc30ae9 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -43,12 +43,11 @@ import OpenAiModelPicker from "./OpenAiModelPicker" import GlamaModelPicker from "./GlamaModelPicker" interface ApiOptionsProps { - showModelOptions: boolean apiErrorMessage?: string modelIdErrorMessage?: string } -const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { +const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) => { const { apiConfiguration, setApiConfiguration, uriScheme, onUpdateApiConfig } = useExtensionState() const [ollamaModels, setOllamaModels] = useState([]) const [lmStudioModels, setLmStudioModels] = useState([]) @@ -695,16 +694,15 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:

)} - {selectedProvider === "glama" && showModelOptions && } + {selectedProvider === "glama" && } - {selectedProvider === "openrouter" && showModelOptions && } + {selectedProvider === "openrouter" && } {selectedProvider !== "glama" && selectedProvider !== "openrouter" && selectedProvider !== "openai" && selectedProvider !== "ollama" && - selectedProvider !== "lmstudio" && - showModelOptions && ( + selectedProvider !== "lmstudio" && ( <>