mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
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:
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user