diff --git a/src/cursor/cursor-executor.ts b/src/cursor/cursor-executor.ts index 06b9e267..cf231387 100644 --- a/src/cursor/cursor-executor.ts +++ b/src/cursor/cursor-executor.ts @@ -42,41 +42,19 @@ interface Http2Response { body: Buffer; } -/** Detect cloud environment */ -function isCloudEnv(): boolean { - if ( - typeof globalThis !== 'undefined' && - 'caches' in globalThis && - typeof (globalThis as { caches?: unknown }).caches === 'object' - ) - return true; - try { - // Check for EdgeRuntime without causing compilation error - if (typeof (globalThis as { EdgeRuntime?: string }).EdgeRuntime !== 'undefined') return true; - } catch (err) { - if (process.env.CCS_DEBUG) { - console.error('[cursor] EdgeRuntime detection failed:', err); - } - } - return false; -} - /** Lazy import http2 */ let http2Module: typeof import('http2') | null = null; async function getHttp2() { if (http2Module) return http2Module; - if (!isCloudEnv()) { - try { - http2Module = await import('http2'); - return http2Module; - } catch (err) { - if (process.env.CCS_DEBUG) { - console.error('[cursor] http2 import failed:', err); - } - return null; + try { + http2Module = await import('http2'); + return http2Module; + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] http2 module not available, falling back to fetch:', err); } + return null; } - return null; } /** @@ -214,6 +192,10 @@ export class CursorExecutor { const delimIdx = accessToken.indexOf('::'); const cleanToken = delimIdx !== -1 ? accessToken.slice(delimIdx + 2) : accessToken; + if (!cleanToken) { + throw new Error('Access token is empty after parsing'); + } + return { authorization: `Bearer ${cleanToken}`, 'connect-accept-encoding': 'gzip', @@ -318,7 +300,7 @@ export class CursorExecutor { req.on('end', () => { client.close(); resolve({ - status: Number(responseHeaders[':status']), + status: Number(responseHeaders[':status']) || 500, headers: responseHeaders, body: Buffer.concat(chunks), }); @@ -456,18 +438,21 @@ export class CursorExecutor { // Check for protobuf-decoded error if (result.error) { + const isRateLimit = + result.error.toLowerCase().includes('rate') || + result.error.toLowerCase().includes('limit'); yield { type: 'error', response: new Response( JSON.stringify({ error: { message: result.error, - type: 'rate_limit_error', - code: 'rate_limited', + type: isRateLimit ? 'rate_limit_error' : 'server_error', + code: isRateLimit ? 'rate_limited' : 'cursor_error', }, }), { - status: 429, + status: isRateLimit ? 429 : 400, headers: { 'Content-Type': 'application/json' }, } ), diff --git a/src/cursor/cursor-translator.ts b/src/cursor/cursor-translator.ts index 451c089c..f709ab43 100644 --- a/src/cursor/cursor-translator.ts +++ b/src/cursor/cursor-translator.ts @@ -39,9 +39,17 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] { const msg = messages[i]; if (msg.role === 'system') { + let content = ''; + if (typeof msg.content === 'string') { + content = msg.content; + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === 'text' && part.text) content += part.text; + } + } result.push({ role: 'user', - content: `[System Instructions]\n${msg.content}`, + content: `[System Instructions]\n${content}`, }); continue; } diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index 85bc2e3c..7d74495d 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -16,6 +16,7 @@ import { parseConnectRPCFrame, } from '../../../src/cursor/cursor-protobuf-decoder'; import { buildCursorRequest } from '../../../src/cursor/cursor-translator'; +import { generateCursorBody } from '../../../src/cursor/cursor-protobuf'; import { CursorExecutor } from '../../../src/cursor/cursor-executor'; import { WIRE_TYPE, FIELD } from '../../../src/cursor/cursor-protobuf-schema'; @@ -277,6 +278,117 @@ describe('Message Translation', () => { expect(result.messages).toHaveLength(1); expect(result.messages[0].content).toBe('Hello World'); }); + + it('should handle system message with array content format', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'system', + content: [ + { type: 'text', text: 'System instruction part 1' }, + { type: 'text', text: ' part 2' }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content).toBe('[System Instructions]\nSystem instruction part 1 part 2'); + }); + }); +}); + +describe('Request Encoding', () => { + describe('generateCursorBody', () => { + it('should encode basic text message', () => { + const result = generateCursorBody([{ role: 'user', content: 'Hello' }], 'gpt-4', [], null); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBeGreaterThan(0); + }); + + it('should encode message with tools', () => { + const tools = [ + { + type: 'function' as const, + function: { + name: 'get_weather', + description: 'Get weather data', + parameters: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + required: ['city'], + }, + }, + }, + ]; + + const result = generateCursorBody([{ role: 'user', content: 'What is the weather?' }], 'gpt-4', tools, null); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('Edge cases', () => { + it('should handle malformed frame gracefully', () => { + const executor = new CursorExecutor(); + + // Incomplete frame header (only 3 bytes instead of 5) + const incompleteFrame = Buffer.from([0x00, 0x00, 0x00]); + + const result = executor.transformProtobufToJSON(incompleteFrame, 'gpt-4', { + messages: [], + }); + + // Should return valid response even with malformed input + expect(result.status).toBe(200); + }); + + it('should handle truncated payload', () => { + const executor = new CursorExecutor(); + + // Frame header says payload is 100 bytes but only 5 bytes follow + const truncatedFrame = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x64, 0x01, 0x02, 0x03, 0x04, 0x05]); + + const result = executor.transformProtobufToJSON(truncatedFrame, 'gpt-4', { + messages: [], + }); + + // Should handle gracefully + expect(result.status).toBe(200); + }); + + it('should handle multi-frame buffer', () => { + const executor = new CursorExecutor(); + + // Create two simple frames + const frame1 = wrapConnectRPCFrame( + encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, 'Frame 1'), + false + ); + const frame2 = wrapConnectRPCFrame( + encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, ' Frame 2'), + false + ); + + // Concatenate them + const multiFrame = Buffer.concat([Buffer.from(frame1), Buffer.from(frame2)]); + + const result = executor.transformProtobufToJSON(multiFrame, 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(200); + }); }); }); @@ -482,63 +594,6 @@ describe('CursorExecutor', () => { }); }); - describe('transformProtobufToSSE', () => { - it('should output SSE format for simple text response', async () => { - const executor = new CursorExecutor(); - - // Minimal protobuf frame with text content - const textPayload = new Uint8Array([ - (FIELD.MSG_CONTENT << 3) | WIRE_TYPE.LEN, - 4, - ...[116, 101, 115, 116], // "test" - ]); - - const buffer = Buffer.from( - wrapConnectRPCFrame(textPayload, { - compress: false, - }) - ); - - const result = executor.transformProtobufToSSE(buffer, 'test-model', { - messages: [], - stream: true, - }); - - expect(result.status).toBe(200); - expect(result.headers.get('Content-Type')).toBe('text/event-stream'); - - const body = await result.text(); - expect(body).toContain('data: '); - expect(body).toContain('"object":"chat.completion.chunk"'); - expect(body).toContain('data: [DONE]'); - }); - - it('should handle error responses in SSE format', () => { - const executor = new CursorExecutor(); - - // Protobuf frame with error - const errorPayload = new Uint8Array([ - (FIELD.MSG_CONTENT << 3) | WIRE_TYPE.LEN, - 17, - ...[101, 114, 114, 111, 114, 58, 32, 116, 101, 115, 116, 32, 101, 114, 114, 111, 114], // "error: test error" - ]); - - const buffer = Buffer.from( - wrapConnectRPCFrame(errorPayload, { - compress: false, - }) - ); - - const result = executor.transformProtobufToSSE(buffer, 'test-model', { - messages: [], - stream: true, - }); - - // Error responses should still be valid - expect(result.status).toBeGreaterThanOrEqual(200); - }); - }); - describe('error handling', () => { it('should return empty buffer on decompression failure', () => { const executor = new CursorExecutor();