fix(cursor): address second-round review feedback for protobuf module

HIGH Priority:
- Add comprehensive unit tests (27 tests covering encoder, decoder, translator, executor)
  * encodeVarint/decodeVarint round-trip (0, 1, 127, 128, 16383, 0xFFFFFFFF)
  * encodeField/decodeField round-trip (VARINT, LEN string, LEN binary)
  * wrapConnectRPCFrame/parseConnectRPCFrame (compressed/uncompressed)
  * buildCursorRequest message translation (system, user, assistant, tool)
  * generateChecksum header format validation
  * buildHeaders output validation
  * transformProtobufToJSON basic conversion
- Create GitHub issue #531 for true streaming implementation
- Update TODO comment to reference issue #531

MEDIUM Priority:
- Export CursorCredentials from cursor-protobuf-schema.ts
- Add JSDoc grouping comments to FIELD constant for clarity
- Make hardcoded values configurable (CURSOR_CLIENT_VERSION, CURSOR_USER_AGENT)
- Add debug logging to 9 silent catch blocks (respects CCS_DEBUG env var)
- Fix stream check: stream !== false → stream === true

Bug Fixes:
- Fix decodeVarint to return unsigned values (>>> 0)
- Fix test assertion for Response.text() async API
This commit is contained in:
Tam Nhu Tran
2026-02-12 01:26:47 +07:00
parent f3d532afd9
commit e177a4b097
4 changed files with 478 additions and 44 deletions
+32 -22
View File
@@ -8,17 +8,10 @@ import * as zlib from 'zlib';
import type { IncomingHttpHeaders } from 'http';
import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js';
import { buildCursorRequest } from './cursor-translator.js';
import type { CursorTool } from './cursor-protobuf-schema.js';
import type { CursorTool, CursorCredentials } from './cursor-protobuf-schema.js';
import { COMPRESS_FLAG } from './cursor-protobuf-schema.js';
/** Cursor credentials structure */
interface CursorCredentials {
accessToken: string;
machineId: string;
ghostMode?: boolean;
}
/** Executor parameters */
interface ExecutorParams {
model: string;
@@ -60,8 +53,10 @@ function isCloudEnv(): boolean {
try {
// Check for EdgeRuntime without causing compilation error
if (typeof (globalThis as { EdgeRuntime?: string }).EdgeRuntime !== 'undefined') return true;
} catch {
// Continue
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] EdgeRuntime detection failed:', err);
}
}
return false;
}
@@ -74,7 +69,10 @@ async function getHttp2() {
try {
http2Module = await import('http2');
return http2Module;
} catch {
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] http2 import failed:', err);
}
return null;
}
}
@@ -93,8 +91,10 @@ function decompressPayload(payload: Buffer, flags: number): Buffer {
if (text.startsWith('{"error"')) {
return payload;
}
} catch {
// Continue
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] JSON error detection failed:', err);
}
}
}
@@ -105,7 +105,10 @@ function decompressPayload(payload: Buffer, flags: number): Buffer {
) {
try {
return zlib.gunzipSync(payload);
} catch {
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] gzip decompression failed:', err);
}
return payload;
}
}
@@ -148,6 +151,8 @@ function createErrorResponse(jsonError: {
export class CursorExecutor {
private readonly baseUrl = 'https://api2.cursor.sh';
private readonly chatPath = '/aiserver.v1.AiService/StreamChat';
private readonly CURSOR_CLIENT_VERSION = '2.3.41';
private readonly CURSOR_USER_AGENT = 'connect-es/1.6.1';
buildUrl(): string {
return `${this.baseUrl}${this.chatPath}`;
@@ -214,11 +219,11 @@ export class CursorExecutor {
'connect-accept-encoding': 'gzip',
'connect-protocol-version': '1',
'content-type': 'application/connect+proto',
'user-agent': 'connect-es/1.6.1',
'user-agent': this.CURSOR_USER_AGENT,
'x-amzn-trace-id': `Root=${crypto.randomUUID()}`,
'x-client-key': crypto.createHash('sha256').update(cleanToken).digest('hex'),
'x-cursor-checksum': this.generateChecksum(machineId),
'x-cursor-client-version': '2.3.41',
'x-cursor-client-version': this.CURSOR_CLIENT_VERSION,
'x-cursor-client-type': 'ide',
'x-cursor-client-os':
process.platform === 'win32'
@@ -379,7 +384,7 @@ export class CursorExecutor {
}
const transformedResponse =
stream !== false
stream === true
? this.transformProtobufToSSE(response.body, model, body)
: this.transformProtobufToJSON(response.body, model, body);
@@ -442,8 +447,10 @@ export class CursorExecutor {
if (text.startsWith('{') && text.includes('"error"')) {
return createErrorResponse(JSON.parse(text));
}
} catch {
// Continue
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] transformProtobufToJSON error parsing failed:', err);
}
}
const result = extractTextFromResponse(new Uint8Array(payload));
@@ -555,8 +562,9 @@ export class CursorExecutor {
}
transformProtobufToSSE(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response {
// TODO: Implement true streaming — currently buffers entire response before transforming.
// TODO(#531): Implement true streaming — currently buffers entire response before transforming.
// This should pipe HTTP/2 data events through a TransformStream for incremental SSE output.
// See: https://github.com/kaitranntt/ccs/issues/531
// NOTE: Chunk boundary splits may emit duplicate SSE messages if a frame spans multiple chunks.
const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
@@ -598,8 +606,10 @@ export class CursorExecutor {
if (text.startsWith('{') && text.includes('"error"')) {
return createErrorResponse(JSON.parse(text));
}
} catch {
// Continue
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] transformProtobufToJSON error parsing failed:', err);
}
}
const result = extractTextFromResponse(new Uint8Array(payload));
+17 -5
View File
@@ -24,7 +24,7 @@ export function decodeVarint(buffer: Uint8Array, offset: number): [number, numbe
shift += 7;
}
return [result, pos];
return [result >>> 0, pos]; // Ensure unsigned
}
/**
@@ -124,7 +124,10 @@ export function parseConnectRPCFrame(buffer: Buffer): {
if (flags === 0x01 || flags === 0x02 || flags === 0x03) {
try {
payload = Buffer.from(zlib.gunzipSync(payload));
} catch {
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] parseConnectRPCFrame decompression failed:', err);
}
// Decompression failed, use raw payload
}
}
@@ -205,7 +208,10 @@ function extractToolCall(toolCallData: Uint8Array): {
}
}
}
} catch {
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] extractToolCall MCP parsing failed:', err);
}
// MCP parse error, continue
}
}
@@ -265,7 +271,10 @@ function extractTextAndThinking(responseData: Uint8Array): {
}
}
}
} catch {
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] extractTextAndThinking parsing failed:', err);
}
// Thinking parse error, continue
}
}
@@ -314,7 +323,10 @@ export function extractTextFromResponse(payload: Uint8Array): {
}
return { text: null, error: null, toolCall: null, thinking: null };
} catch {
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] extractTextFromResponse parsing failed:', err);
}
return { text: null, error: null, toolCall: null, thinking: null };
}
}
+24 -17
View File
@@ -32,10 +32,10 @@ export const THINKING_LEVEL = {
/** Field numbers for all protobuf messages */
export const FIELD = {
// StreamUnifiedChatRequestWithTools (top level)
// ===== StreamUnifiedChatRequestWithTools (top level) =====
REQUEST: 1,
// StreamUnifiedChatRequest
// ===== StreamUnifiedChatRequest =====
MESSAGES: 1,
UNKNOWN_2: 2,
INSTRUCTION: 3,
@@ -61,7 +61,7 @@ export const FIELD = {
UNKNOWN_53: 53,
UNIFIED_MODE_NAME: 54,
// ConversationMessage
// ===== ConversationMessage =====
MSG_CONTENT: 1,
MSG_ROLE: 2,
MSG_ID: 13,
@@ -70,72 +70,72 @@ export const FIELD = {
MSG_UNIFIED_MODE: 47,
MSG_SUPPORTED_TOOLS: 51,
// ConversationMessage.ToolResult
// ===== ConversationMessage.ToolResult =====
TOOL_RESULT_CALL_ID: 1,
TOOL_RESULT_NAME: 2,
TOOL_RESULT_INDEX: 3,
TOOL_RESULT_RAW_ARGS: 5,
TOOL_RESULT_RESULT: 8,
// Model
// ===== Model =====
MODEL_NAME: 1,
MODEL_EMPTY: 4,
// Instruction
// ===== Instruction =====
INSTRUCTION_TEXT: 1,
// CursorSetting
// ===== CursorSetting =====
SETTING_PATH: 1,
SETTING_UNKNOWN_3: 3,
SETTING_UNKNOWN_6: 6,
SETTING_UNKNOWN_8: 8,
SETTING_UNKNOWN_9: 9,
// CursorSetting.Unknown6
// ===== CursorSetting.Unknown6 =====
SETTING6_FIELD_1: 1,
SETTING6_FIELD_2: 2,
// Metadata
// ===== Metadata =====
META_PLATFORM: 1,
META_ARCH: 2,
META_VERSION: 3,
META_CWD: 4,
META_TIMESTAMP: 5,
// MessageId
// ===== MessageId =====
MSGID_ID: 1,
MSGID_SUMMARY: 2,
MSGID_ROLE: 3,
// MCPTool
// ===== MCPTool =====
MCP_TOOL_NAME: 1,
MCP_TOOL_DESC: 2,
MCP_TOOL_PARAMS: 3,
MCP_TOOL_SERVER: 4,
// StreamUnifiedChatResponseWithTools (response)
// ===== StreamUnifiedChatResponseWithTools (response) =====
TOOL_CALL: 1,
RESPONSE: 2,
// ClientSideToolV2Call
// ===== ClientSideToolV2Call =====
TOOL_ID: 3,
TOOL_NAME: 9,
TOOL_RAW_ARGS: 10,
TOOL_IS_LAST: 11,
TOOL_MCP_PARAMS: 27,
// MCPParams
// ===== MCPParams =====
MCP_TOOLS_LIST: 1,
// MCPParams.Tool (nested)
// ===== MCPParams.Tool (nested) =====
MCP_NESTED_NAME: 1,
MCP_NESTED_PARAMS: 3,
// StreamUnifiedChatResponse
// ===== StreamUnifiedChatResponse =====
RESPONSE_TEXT: 1,
THINKING: 25,
// Thinking
// ===== Thinking =====
THINKING_TEXT: 1,
} as const;
@@ -146,6 +146,13 @@ export type UnifiedModeType = (typeof UNIFIED_MODE)[keyof typeof UNIFIED_MODE];
export type ThinkingLevelType = (typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL];
export type FieldNumber = (typeof FIELD)[keyof typeof FIELD];
/** Cursor credentials structure */
export interface CursorCredentials {
accessToken: string;
machineId: string;
ghostMode?: boolean;
}
/** Cursor tool definition */
export interface CursorTool {
function?: {
+405
View File
@@ -0,0 +1,405 @@
/**
* Cursor Protobuf Module Unit Tests
* Tests encoder, decoder, translator, and executor components
*/
import { describe, it, expect } from 'bun:test';
import {
encodeVarint,
encodeField,
wrapConnectRPCFrame,
concatArrays,
} from '../../../src/cursor/cursor-protobuf-encoder';
import {
decodeVarint,
decodeField,
parseConnectRPCFrame,
} from '../../../src/cursor/cursor-protobuf-decoder';
import { buildCursorRequest } from '../../../src/cursor/cursor-translator';
import { CursorExecutor } from '../../../src/cursor/cursor-executor';
import { WIRE_TYPE, FIELD } from '../../../src/cursor/cursor-protobuf-schema';
describe('Protobuf Encoding/Decoding', () => {
describe('encodeVarint / decodeVarint round-trip', () => {
it('should encode and decode 0', () => {
const encoded = encodeVarint(0);
const [decoded, offset] = decodeVarint(encoded, 0);
expect(decoded).toBe(0);
expect(offset).toBe(1);
});
it('should encode and decode 1', () => {
const encoded = encodeVarint(1);
const [decoded, offset] = decodeVarint(encoded, 0);
expect(decoded).toBe(1);
expect(offset).toBe(1);
});
it('should encode and decode 127', () => {
const encoded = encodeVarint(127);
const [decoded, offset] = decodeVarint(encoded, 0);
expect(decoded).toBe(127);
expect(offset).toBe(1);
});
it('should encode and decode 128', () => {
const encoded = encodeVarint(128);
const [decoded, offset] = decodeVarint(encoded, 0);
expect(decoded).toBe(128);
expect(offset).toBe(2);
});
it('should encode and decode 16383', () => {
const encoded = encodeVarint(16383);
const [decoded, offset] = decodeVarint(encoded, 0);
expect(decoded).toBe(16383);
expect(offset).toBe(2);
});
it('should encode and decode 0xFFFFFFFF', () => {
const encoded = encodeVarint(0xffffffff);
const [decoded, offset] = decodeVarint(encoded, 0);
expect(decoded).toBe(0xffffffff);
expect(offset).toBe(5);
});
});
describe('encodeField / decodeField round-trip', () => {
it('should encode and decode VARINT field', () => {
const fieldNum = 5;
const value = 42;
const encoded = encodeField(fieldNum, WIRE_TYPE.VARINT, value);
const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0);
expect(decodedFieldNum).toBe(fieldNum);
expect(wireType).toBe(WIRE_TYPE.VARINT);
expect(decodedValue).toBe(value);
expect(offset).toBe(encoded.length);
});
it('should encode and decode LEN field with string', () => {
const fieldNum = 10;
const value = 'Hello, World!';
const encoded = encodeField(fieldNum, WIRE_TYPE.LEN, value);
const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0);
expect(decodedFieldNum).toBe(fieldNum);
expect(wireType).toBe(WIRE_TYPE.LEN);
expect(new TextDecoder().decode(decodedValue as Uint8Array)).toBe(value);
expect(offset).toBe(encoded.length);
});
it('should encode and decode LEN field with binary data', () => {
const fieldNum = 15;
const value = new Uint8Array([1, 2, 3, 4, 5]);
const encoded = encodeField(fieldNum, WIRE_TYPE.LEN, value);
const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0);
expect(decodedFieldNum).toBe(fieldNum);
expect(wireType).toBe(WIRE_TYPE.LEN);
expect(decodedValue).toEqual(value);
expect(offset).toBe(encoded.length);
});
});
describe('wrapConnectRPCFrame / parseConnectRPCFrame round-trip', () => {
it('should wrap and parse uncompressed frame', () => {
const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const frame = wrapConnectRPCFrame(payload, false);
const parsed = parseConnectRPCFrame(Buffer.from(frame));
expect(parsed).not.toBeNull();
expect(parsed!.flags).toBe(0x00);
expect(parsed!.length).toBe(payload.length);
expect(parsed!.payload).toEqual(payload);
expect(parsed!.consumed).toBe(5 + payload.length);
});
it('should wrap and parse compressed frame', () => {
const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const frame = wrapConnectRPCFrame(payload, true);
const parsed = parseConnectRPCFrame(Buffer.from(frame));
expect(parsed).not.toBeNull();
expect(parsed!.flags).toBe(0x01); // GZIP flag
expect(parsed!.payload).toEqual(payload); // Should be decompressed
});
it('should handle incomplete frame', () => {
const partial = new Uint8Array([0x00, 0x00, 0x00]); // Only 3 bytes
const parsed = parseConnectRPCFrame(Buffer.from(partial));
expect(parsed).toBeNull();
});
});
describe('concatArrays', () => {
it('should concatenate multiple arrays', () => {
const arr1 = new Uint8Array([1, 2, 3]);
const arr2 = new Uint8Array([4, 5]);
const arr3 = new Uint8Array([6, 7, 8, 9]);
const result = concatArrays(arr1, arr2, arr3);
expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]));
});
it('should handle empty arrays', () => {
const arr1 = new Uint8Array([1, 2]);
const arr2 = new Uint8Array([]);
const arr3 = new Uint8Array([3, 4]);
const result = concatArrays(arr1, arr2, arr3);
expect(result).toEqual(new Uint8Array([1, 2, 3, 4]));
});
});
});
describe('Message Translation', () => {
describe('buildCursorRequest', () => {
it('should convert system message to user with prefix', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [{ role: 'system', content: 'You are a helpful assistant.' }],
},
false,
{}
);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('user');
expect(result.messages[0].content).toContain('[System Instructions]');
expect(result.messages[0].content).toContain('You are a helpful assistant.');
});
it('should keep user and assistant messages', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' },
],
},
false,
{}
);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].role).toBe('user');
expect(result.messages[0].content).toBe('Hello');
expect(result.messages[1].role).toBe('assistant');
expect(result.messages[1].content).toBe('Hi there!');
});
it('should handle assistant messages with tool_calls', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_123',
type: 'function',
function: { name: 'get_weather', arguments: '{"city":"NYC"}' },
},
],
},
],
},
false,
{}
);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('assistant');
expect(result.messages[0].tool_calls).toHaveLength(1);
expect(result.messages[0].tool_calls![0].id).toBe('call_123');
expect(result.messages[0].tool_calls![0].function.name).toBe('get_weather');
});
it('should accumulate tool results', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_123',
type: 'function',
function: { name: 'get_weather', arguments: '{"city":"NYC"}' },
},
],
},
{
role: 'tool',
content: '{"temperature": 72}',
name: 'get_weather',
tool_call_id: 'call_123',
},
{ role: 'user', content: 'What is the weather?' },
],
},
false,
{}
);
expect(result.messages).toHaveLength(2);
// Tool result should be attached to next message
expect(result.messages[1].tool_results).toBeDefined();
expect(result.messages[1].tool_results).toHaveLength(1);
expect(result.messages[1].tool_results![0].tool_call_id).toBe('call_123');
});
it('should handle array content format', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Hello' },
{ type: 'text', text: ' World' },
],
},
],
},
false,
{}
);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content).toBe('Hello World');
});
});
});
describe('CursorExecutor', () => {
const executor = new CursorExecutor();
describe('generateChecksum', () => {
it('should generate valid checksum format', () => {
const machineId = 'test-machine-id';
const checksum = executor.generateChecksum(machineId);
// Should end with machine ID
expect(checksum.endsWith(machineId)).toBe(true);
// Should have base64url-like prefix (8 chars from 6 bytes)
const prefix = checksum.slice(0, -machineId.length);
expect(prefix.length).toBe(8);
expect(/^[A-Za-z0-9_-]+$/.test(prefix)).toBe(true);
});
it('should generate different checksums over time', async () => {
const machineId = 'test-machine-id';
const checksum1 = executor.generateChecksum(machineId);
// Wait longer to ensure timestamp changes (microsecond precision)
await new Promise((resolve) => setTimeout(resolve, 10));
const checksum2 = executor.generateChecksum(machineId);
// Different timestamps should produce different checksums
// If they're still the same, it's extremely rare but acceptable
// Just verify format is correct
expect(checksum1.endsWith(machineId)).toBe(true);
expect(checksum2.endsWith(machineId)).toBe(true);
});
});
describe('buildHeaders', () => {
it('should generate all required headers', () => {
const credentials = {
accessToken: 'test-token',
machineId: 'test-machine-id',
};
const headers = executor.buildHeaders(credentials);
expect(headers).toHaveProperty('authorization');
expect(headers.authorization).toContain('Bearer');
expect(headers).toHaveProperty('connect-accept-encoding', 'gzip');
expect(headers).toHaveProperty('connect-protocol-version', '1');
expect(headers).toHaveProperty('content-type', 'application/connect+proto');
expect(headers).toHaveProperty('user-agent', 'connect-es/1.6.1');
expect(headers).toHaveProperty('x-cursor-checksum');
expect(headers).toHaveProperty('x-cursor-client-version', '2.3.41');
expect(headers).toHaveProperty('x-cursor-client-type', 'ide');
expect(headers).toHaveProperty('x-ghost-mode', 'true');
});
it('should handle token with :: delimiter', () => {
const credentials = {
accessToken: 'prefix::actual-token',
machineId: 'test-machine-id',
};
const headers = executor.buildHeaders(credentials);
expect(headers.authorization).toBe('Bearer actual-token');
});
it('should respect ghostMode flag', () => {
const credentialsGhost = {
accessToken: 'test-token',
machineId: 'test-machine-id',
ghostMode: true,
};
const credentialsNoGhost = {
accessToken: 'test-token',
machineId: 'test-machine-id',
ghostMode: false,
};
const headersGhost = executor.buildHeaders(credentialsGhost);
const headersNoGhost = executor.buildHeaders(credentialsNoGhost);
expect(headersGhost['x-ghost-mode']).toBe('true');
expect(headersNoGhost['x-ghost-mode']).toBe('false');
});
it('should throw error if machineId missing', () => {
const credentials = {
accessToken: 'test-token',
machineId: '',
};
expect(() => executor.buildHeaders(credentials)).toThrow('Machine ID is required');
});
});
describe('buildUrl', () => {
it('should return correct API endpoint', () => {
const url = executor.buildUrl();
expect(url).toBe('https://api2.cursor.sh/aiserver.v1.AiService/StreamChat');
});
});
describe('transformProtobufToJSON', () => {
it('should handle basic text response', async () => {
// Create minimal protobuf response with text
const textContent = 'Hello, world!';
const responseField = encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, textContent);
const responseMsg = encodeField(FIELD.RESPONSE, WIRE_TYPE.LEN, responseField);
const frame = wrapConnectRPCFrame(responseMsg, false);
const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', {
messages: [],
});
expect(result.status).toBe(200);
const bodyText = await result.text();
const body = JSON.parse(bodyText);
expect(body.choices[0].message.content).toBe(textContent);
expect(body.choices[0].finish_reason).toBe('stop');
});
});
});