mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 06:17:09 +00:00
fix(cursor): address fourth-round review feedback for protobuf module
This commit is contained in:
@@ -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' },
|
||||
}
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user