mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
1733 lines
56 KiB
TypeScript
1733 lines
56 KiB
TypeScript
/**
|
|
* 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,
|
|
decodeMessage,
|
|
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';
|
|
import { StreamingFrameParser, decompressPayload } from '../../../src/cursor/cursor-stream-parser';
|
|
|
|
const MAX_TOOL_RESULT_CHARS = 12_000;
|
|
|
|
function computeExpectedToolResultOmittedChars(textLength: number): number {
|
|
if (textLength <= MAX_TOOL_RESULT_CHARS) {
|
|
return 0;
|
|
}
|
|
|
|
let omittedChars = textLength - MAX_TOOL_RESULT_CHARS;
|
|
while (true) {
|
|
const suffix = `\n[truncated ${omittedChars} chars]`;
|
|
const keepLength = Math.max(MAX_TOOL_RESULT_CHARS - suffix.length, 0);
|
|
const nextOmittedChars = textLength - keepLength;
|
|
if (nextOmittedChars === omittedChars) {
|
|
return omittedChars;
|
|
}
|
|
omittedChars = nextOmittedChars;
|
|
}
|
|
}
|
|
|
|
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 flatten tool results into user tool_result blocks', () => {
|
|
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(3);
|
|
expect(result.messages[1].role).toBe('user');
|
|
expect(result.messages[1].content).toContain('<tool_result>');
|
|
expect(result.messages[1].content).toContain('<tool_name>get_weather</tool_name>');
|
|
expect(result.messages[1].content).toContain('<tool_call_id>call_123</tool_call_id>');
|
|
expect(result.messages[2]).toEqual({ role: 'user', content: 'What is the weather?' });
|
|
});
|
|
|
|
it('should preserve consecutive tool messages as separate user turns', () => {
|
|
const result = buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_one',
|
|
type: 'function',
|
|
function: { name: 'search_docs', arguments: '{"q":"cursor"}' },
|
|
},
|
|
{
|
|
id: 'call_two',
|
|
type: 'function',
|
|
function: { name: 'read_file', arguments: '{"path":"README.md"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: 'first result',
|
|
tool_call_id: 'call_one',
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: 'second result',
|
|
tool_call_id: 'call_two',
|
|
},
|
|
{ role: 'user', content: 'Summarize both results.' },
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
);
|
|
|
|
expect(result.messages).toHaveLength(4);
|
|
expect(result.messages[1]).toEqual({
|
|
role: 'user',
|
|
content: expect.stringContaining('<tool_call_id>call_one</tool_call_id>'),
|
|
});
|
|
expect(result.messages[2]).toEqual({
|
|
role: 'user',
|
|
content: expect.stringContaining('<tool_call_id>call_two</tool_call_id>'),
|
|
});
|
|
expect(result.messages[3]).toEqual({ role: 'user', content: 'Summarize both results.' });
|
|
});
|
|
|
|
it('should recover tool names for tool results without a name field', () => {
|
|
const result = buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_456',
|
|
type: 'function',
|
|
function: { name: 'search_docs', arguments: '{"q":"cursor"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: 'done',
|
|
tool_call_id: 'call_456',
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
);
|
|
|
|
expect(result.messages).toHaveLength(2);
|
|
expect(result.messages[1].content).toContain('<tool_name>search_docs</tool_name>');
|
|
expect(result.messages[1].tool_results).toBeUndefined();
|
|
});
|
|
|
|
it('should flatten user content arrays that include tool_result blocks', () => {
|
|
const result = buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_789',
|
|
type: 'function',
|
|
function: { name: 'search_docs', arguments: '{"q":"cursor"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'Tool finished.' },
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'call_789',
|
|
content: { answer: 'Cursor integration ready' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
);
|
|
|
|
expect(result.messages).toHaveLength(2);
|
|
expect(result.messages[1].role).toBe('user');
|
|
expect(result.messages[1].content).toContain('Tool finished.');
|
|
expect(result.messages[1].content).toContain('<tool_name>search_docs</tool_name>');
|
|
expect(result.messages[1].content).toContain('{"answer":"Cursor integration ready"}');
|
|
});
|
|
|
|
it('should preserve line breaks for multipart tool_result text content', () => {
|
|
const result = buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_lines',
|
|
type: 'function',
|
|
function: { name: 'read_file', arguments: '{"path":"notes.txt"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'call_lines',
|
|
content: [
|
|
{ type: 'text', text: 'first line' },
|
|
{ type: 'text', text: 'second line' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
);
|
|
|
|
expect(result.messages).toHaveLength(2);
|
|
expect(result.messages[1].content).toContain('<result>first line\nsecond line</result>');
|
|
});
|
|
|
|
it('should convert assistant tool_use content blocks into tool_calls', () => {
|
|
const result = buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'text', text: 'Inspecting the workspace.' },
|
|
{
|
|
type: 'tool_use',
|
|
id: 'call_999',
|
|
name: 'list_files',
|
|
input: { path: '/tmp' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
);
|
|
|
|
expect(result.messages).toHaveLength(1);
|
|
expect(result.messages[0].role).toBe('assistant');
|
|
expect(result.messages[0].content).toBe('Inspecting the workspace.');
|
|
expect(result.messages[0].tool_calls).toEqual([
|
|
{
|
|
id: 'call_999',
|
|
type: 'function',
|
|
function: { name: 'list_files', arguments: '{"path":"/tmp"}' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should synthesize fallback ids for tool_use blocks that omit them', () => {
|
|
const result = buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
name: 'search_docs',
|
|
input: { q: 'cursor' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
);
|
|
|
|
expect(result.messages).toHaveLength(1);
|
|
expect(result.messages[0].tool_calls).toHaveLength(1);
|
|
expect(result.messages[0].tool_calls![0].id).toBe('toolu_cursor_fallback_0_0');
|
|
});
|
|
|
|
it('should normalize invalid assistant tool call ids before emitting tool results', () => {
|
|
const result = buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_bad\nline two',
|
|
type: 'function',
|
|
function: { name: 'search_docs', arguments: '{"q":"cursor"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: 'done',
|
|
tool_call_id: 'call_bad\nline two',
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
);
|
|
|
|
expect(result.messages).toHaveLength(2);
|
|
expect(result.messages[0].tool_calls![0].id).toBe('call_bad');
|
|
expect(result.messages[1].content).toContain('<tool_call_id>call_bad</tool_call_id>');
|
|
});
|
|
|
|
it('should dedupe tool calls when assistant messages contain both tool_calls and tool_use blocks', () => {
|
|
const result = buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: 'call_dupe',
|
|
name: 'search_docs',
|
|
input: { q: 'cursor' },
|
|
},
|
|
],
|
|
tool_calls: [
|
|
{
|
|
id: 'call_dupe',
|
|
type: 'function',
|
|
function: { name: 'search_docs', arguments: '{"q":"cursor"}' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
);
|
|
|
|
expect(result.messages).toHaveLength(1);
|
|
expect(result.messages[0].tool_calls).toEqual([
|
|
{
|
|
id: 'call_dupe',
|
|
type: 'function',
|
|
function: { name: 'search_docs', arguments: '{"q":"cursor"}' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should truncate oversized tool result payloads', () => {
|
|
const oversizedResult = '&'.repeat(12_050);
|
|
const omittedChars = computeExpectedToolResultOmittedChars(oversizedResult.length);
|
|
const preservedChars = oversizedResult.length - omittedChars;
|
|
const result = buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_big',
|
|
type: 'function',
|
|
function: { name: 'read_file', arguments: '{"path":"big.txt"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: oversizedResult,
|
|
tool_call_id: 'call_big',
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
);
|
|
|
|
expect(result.messages).toHaveLength(2);
|
|
expect(result.messages[1].content).toContain('[truncated ');
|
|
expect(result.messages[1].content).toContain(`[truncated ${omittedChars} chars]`);
|
|
|
|
const resultMatch = result.messages[1].content.match(/<result>([\s\S]*)<\/result>/);
|
|
expect(resultMatch).not.toBeNull();
|
|
expect((resultMatch?.[1].match(/&/g) ?? []).length).toBe(preservedChars);
|
|
});
|
|
|
|
it('should mark unserializable structured tool results explicitly', () => {
|
|
const circular: Record<string, unknown> = {};
|
|
circular.self = circular;
|
|
|
|
const result = buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_bad',
|
|
type: 'function',
|
|
function: { name: 'read_json', arguments: '{"path":"bad.json"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'call_bad',
|
|
content: circular,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
);
|
|
|
|
expect(result.messages).toHaveLength(2);
|
|
expect(result.messages[1].content).toContain('[unserializable content]');
|
|
});
|
|
|
|
it('should reject tool_result blocks without a valid tool_use_id', () => {
|
|
expect(() =>
|
|
buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
content: 'done',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
)
|
|
).toThrow('messages[0].content[0] must include a valid tool result id');
|
|
});
|
|
|
|
it('should reject tool role messages without a valid tool_call_id', () => {
|
|
expect(() =>
|
|
buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'tool',
|
|
content: 'done',
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
)
|
|
).toThrow('messages[0].tool_call_id must include a valid tool result id');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
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 a raw top-level request protobuf for basic text messages', () => {
|
|
const result = generateCursorBody([{ role: 'user', content: 'Hello' }], 'gpt-4', [], null);
|
|
const topLevel = decodeMessage(result);
|
|
const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array;
|
|
const chatRequest = decodeMessage(requestPayload);
|
|
const encodedMessages = (chatRequest.get(FIELD.Chat.MESSAGES) || []).map((entry) =>
|
|
decodeMessage(entry.value as Uint8Array)
|
|
);
|
|
const decoder = new TextDecoder();
|
|
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
expect(result.length).toBeGreaterThan(0);
|
|
expect(Array.from(result.slice(0, 5))).not.toEqual([0, 0, 0, 1, 107]);
|
|
expect(topLevel.has(FIELD.Request.REQUEST)).toBe(true);
|
|
expect(encodedMessages).toHaveLength(1);
|
|
expect(decoder.decode(encodedMessages[0].get(FIELD.Message.CONTENT)?.[0]?.value as Uint8Array)).toBe(
|
|
'Hello'
|
|
);
|
|
});
|
|
|
|
it('should encode message with tools into the raw request payload', () => {
|
|
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
|
|
);
|
|
const topLevel = decodeMessage(result);
|
|
const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array;
|
|
const chatRequest = decodeMessage(requestPayload);
|
|
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
expect(result.length).toBeGreaterThan(0);
|
|
expect(topLevel.has(FIELD.Request.REQUEST)).toBe(true);
|
|
expect((chatRequest.get(FIELD.Chat.MCP_TOOLS) || []).length).toBe(1);
|
|
});
|
|
|
|
it('should preserve flattened tool_result blocks through protobuf encoding', () => {
|
|
const translated = buildCursorRequest(
|
|
'gpt-4',
|
|
{
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_wire',
|
|
type: 'function',
|
|
function: { name: 'read_file', arguments: '{"path":"notes.txt"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: 'workspace snapshot',
|
|
tool_call_id: 'call_wire',
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{}
|
|
);
|
|
|
|
const body = generateCursorBody(translated.messages, 'gpt-4', [], null);
|
|
const topLevel = decodeMessage(body);
|
|
const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array;
|
|
const chatRequest = decodeMessage(requestPayload);
|
|
const encodedMessages = (chatRequest.get(FIELD.Chat.MESSAGES) || []).map((entry) =>
|
|
decodeMessage(entry.value as Uint8Array)
|
|
);
|
|
const decoder = new TextDecoder();
|
|
const contents = encodedMessages.map((message) =>
|
|
decoder.decode(message.get(FIELD.Message.CONTENT)?.[0]?.value as Uint8Array)
|
|
);
|
|
|
|
expect(contents.some((content) => content.includes('<tool_result>'))).toBe(true);
|
|
expect(contents.some((content) => content.includes('<tool_name>read_file</tool_name>'))).toBe(
|
|
true
|
|
);
|
|
expect(
|
|
contents.some((content) => content.includes('<tool_call_id>call_wire</tool_call_id>'))
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Edge cases', () => {
|
|
it('should reject malformed frame headers', async () => {
|
|
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: [],
|
|
});
|
|
|
|
expect(result.status).toBe(502);
|
|
const body = JSON.parse(await result.text());
|
|
expect(body.error.type).toBe('server_error');
|
|
expect(body.error.message).toContain('Truncated Cursor ConnectRPC frame');
|
|
});
|
|
|
|
it('should reject truncated payloads', async () => {
|
|
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: [],
|
|
});
|
|
|
|
expect(result.status).toBe(502);
|
|
const body = JSON.parse(await result.text());
|
|
expect(body.error.type).toBe('server_error');
|
|
expect(body.error.message).toContain('Truncated Cursor ConnectRPC frame');
|
|
});
|
|
|
|
it('should handle multi-frame buffer', () => {
|
|
const executor = new CursorExecutor();
|
|
|
|
// Build two valid response frames (top-level Response.RESPONSE wrapper).
|
|
const frame1 = buildTextFrame('Frame 1');
|
|
const frame2 = buildTextFrame(' Frame 2');
|
|
|
|
// Concatenate them
|
|
const multiFrame = Buffer.concat([frame1, frame2]);
|
|
|
|
const result = executor.transformProtobufToJSON(multiFrame, 'gpt-4', {
|
|
messages: [],
|
|
});
|
|
|
|
expect(result.status).toBe(200);
|
|
});
|
|
});
|
|
});
|
|
|
|
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 valid checksums at different call times', async () => {
|
|
const machineId = 'test-machine-id';
|
|
const checksum1 = executor.generateChecksum(machineId);
|
|
|
|
// Wait to ensure timestamp may change (though timestamp granularity is ~16 min)
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
const checksum2 = executor.generateChecksum(machineId);
|
|
|
|
// Verify both checksums are valid (may be same due to timestamp granularity)
|
|
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 throw when token becomes empty after delimiter parsing', () => {
|
|
const credentials = {
|
|
accessToken: 'prefix::',
|
|
machineId: 'test-machine-id',
|
|
};
|
|
|
|
expect(() => executor.buildHeaders(credentials)).toThrow('Access token is empty');
|
|
});
|
|
|
|
it('should include normalized platform and timezone headers', () => {
|
|
const credentials = {
|
|
accessToken: 'test-token',
|
|
machineId: 'test-machine-id',
|
|
};
|
|
|
|
const headers = executor.buildHeaders(credentials);
|
|
|
|
expect(['windows', 'macos', 'linux']).toContain(headers['x-cursor-client-os']);
|
|
expect(['aarch64', 'x64']).toContain(headers['x-cursor-client-arch']);
|
|
expect(typeof headers['x-cursor-timezone']).toBe('string');
|
|
expect(headers['x-cursor-timezone'].length).toBeGreaterThan(0);
|
|
});
|
|
|
|
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.ChatResponse.TEXT, WIRE_TYPE.LEN, textContent);
|
|
const responseMsg = encodeField(FIELD.Response.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');
|
|
});
|
|
|
|
it('should handle JSON error response', async () => {
|
|
const errorJson = JSON.stringify({
|
|
error: {
|
|
code: 'resource_exhausted',
|
|
message: 'Rate limit exceeded',
|
|
},
|
|
});
|
|
const frame = wrapConnectRPCFrame(new TextEncoder().encode(errorJson), false);
|
|
|
|
const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', {
|
|
messages: [],
|
|
});
|
|
|
|
expect(result.status).toBe(429);
|
|
const bodyText = await result.text();
|
|
const body = JSON.parse(bodyText);
|
|
expect(body.error.type).toBe('rate_limit_error');
|
|
});
|
|
|
|
it('should map unavailable end-stream errors to 503', async () => {
|
|
const unavailableFrame = buildFrame(
|
|
new TextEncoder().encode(
|
|
JSON.stringify({
|
|
error: {
|
|
code: 'unavailable',
|
|
message: 'upstream down',
|
|
},
|
|
})
|
|
),
|
|
0x02
|
|
);
|
|
|
|
const result = executor.transformProtobufToJSON(unavailableFrame, 'gpt-4', {
|
|
messages: [],
|
|
});
|
|
|
|
expect(result.status).toBe(503);
|
|
const body = JSON.parse(await result.text());
|
|
expect(body.error.type).toBe('api_error');
|
|
expect(body.error.message).toContain('upstream down');
|
|
});
|
|
|
|
it('should surface reasoning_content when thinking payload is present', async () => {
|
|
const textContent = 'Final answer';
|
|
const thinkingContent = 'Internal reasoning trail';
|
|
const thinkingField = encodeField(FIELD.Thinking.TEXT, WIRE_TYPE.LEN, thinkingContent);
|
|
const chatResponse = concatArrays(
|
|
encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, textContent),
|
|
encodeField(FIELD.ChatResponse.THINKING, WIRE_TYPE.LEN, thinkingField)
|
|
);
|
|
const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, chatResponse);
|
|
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].message.reasoning_content).toBe(thinkingContent);
|
|
});
|
|
|
|
it('should merge fragmented tool call arguments and set tool_calls finish reason', async () => {
|
|
const frame1 = buildToolCallFrame({
|
|
id: 'call_123',
|
|
name: 'search_docs',
|
|
args: '{"q":"hel',
|
|
isLast: false,
|
|
});
|
|
const frame2 = buildToolCallFrame({
|
|
id: 'call_123',
|
|
name: 'search_docs',
|
|
args: 'lo"}',
|
|
isLast: true,
|
|
});
|
|
const combined = Buffer.concat([frame1, frame2]);
|
|
|
|
const result = executor.transformProtobufToJSON(combined, 'gpt-4', {
|
|
messages: [],
|
|
});
|
|
|
|
expect(result.status).toBe(200);
|
|
const body = JSON.parse(await result.text());
|
|
expect(body.choices[0].finish_reason).toBe('tool_calls');
|
|
expect(body.choices[0].message.tool_calls[0].id).toBe('call_123');
|
|
expect(body.choices[0].message.tool_calls[0].function.name).toBe('search_docs');
|
|
expect(body.choices[0].message.tool_calls[0].function.arguments).toBe('{"q":"hello"}');
|
|
});
|
|
});
|
|
|
|
describe('transformProtobufToSSE', () => {
|
|
it('should output SSE format', async () => {
|
|
// Create minimal protobuf response with text
|
|
const textContent = 'Hello';
|
|
const responseField = encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, textContent);
|
|
const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField);
|
|
const frame = wrapConnectRPCFrame(responseMsg, false);
|
|
|
|
const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', {
|
|
messages: [],
|
|
});
|
|
|
|
expect(result.status).toBe(200);
|
|
expect(result.headers.get('content-type')).toBe('text/event-stream');
|
|
|
|
const bodyText = await result.text();
|
|
expect(bodyText).toContain('data: ');
|
|
expect(bodyText).toContain('data: [DONE]');
|
|
expect(bodyText).toContain(textContent);
|
|
});
|
|
|
|
it('should handle JSON error response', async () => {
|
|
const errorJson = JSON.stringify({
|
|
error: {
|
|
code: 'resource_exhausted',
|
|
message: 'Rate limit exceeded',
|
|
},
|
|
});
|
|
const frame = wrapConnectRPCFrame(new TextEncoder().encode(errorJson), false);
|
|
|
|
const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', {
|
|
messages: [],
|
|
});
|
|
|
|
expect(result.status).toBe(429);
|
|
const bodyText = await result.text();
|
|
const body = JSON.parse(bodyText);
|
|
expect(body.error.type).toBe('rate_limit_error');
|
|
});
|
|
|
|
it('should emit reasoning_content deltas for thinking payloads', async () => {
|
|
const thinkingContent = 'Deliberate reasoning';
|
|
const thinkingField = encodeField(FIELD.Thinking.TEXT, WIRE_TYPE.LEN, thinkingContent);
|
|
const chatResponse = encodeField(FIELD.ChatResponse.THINKING, WIRE_TYPE.LEN, thinkingField);
|
|
const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, chatResponse);
|
|
const frame = wrapConnectRPCFrame(responseMsg, false);
|
|
|
|
const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', {
|
|
messages: [],
|
|
});
|
|
|
|
expect(result.status).toBe(200);
|
|
const bodyText = await result.text();
|
|
expect(bodyText).toContain('reasoning_content');
|
|
expect(bodyText).toContain(thinkingContent);
|
|
});
|
|
|
|
it('should emit tool call deltas and end with finish_reason tool_calls', async () => {
|
|
const frame1 = buildToolCallFrame({
|
|
id: 'call_abc',
|
|
name: 'search_docs',
|
|
args: '{"q":"foo',
|
|
isLast: false,
|
|
});
|
|
const frame2 = buildToolCallFrame({
|
|
id: 'call_abc',
|
|
name: 'search_docs',
|
|
args: '"}',
|
|
isLast: true,
|
|
});
|
|
const combined = Buffer.concat([frame1, frame2]);
|
|
|
|
const result = executor.transformProtobufToSSE(combined, 'gpt-4', {
|
|
messages: [],
|
|
});
|
|
|
|
expect(result.status).toBe(200);
|
|
const bodyText = await result.text();
|
|
expect(bodyText).toContain('tool_calls');
|
|
expect(bodyText).toContain('search_docs');
|
|
expect(bodyText).toContain('"finish_reason":"tool_calls"');
|
|
});
|
|
});
|
|
|
|
describe('decompressPayload error handling', () => {
|
|
it('returns an explicit executor error on decompression failure', async () => {
|
|
// Create invalid gzip data
|
|
const invalidGzip = Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0xff, 0xff]);
|
|
const frame = new Uint8Array(5 + invalidGzip.length);
|
|
frame[0] = 0x01; // GZIP flag
|
|
frame[1] = 0;
|
|
frame[2] = 0;
|
|
frame[3] = 0;
|
|
frame[4] = invalidGzip.length;
|
|
frame.set(invalidGzip, 5);
|
|
|
|
const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', {
|
|
messages: [],
|
|
});
|
|
|
|
expect(result.status).toBe(502);
|
|
const body = JSON.parse(await result.text());
|
|
expect(body.error.type).toBe('server_error');
|
|
expect(body.error.message).toContain('decompress');
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('returns an explicit error for invalid compressed payloads', async () => {
|
|
const executor = new CursorExecutor();
|
|
|
|
// Invalid compressed payload (not actually gzipped)
|
|
const invalidGzipPayload = new Uint8Array([1, 2, 3, 4, 5]);
|
|
const flags = 0x01; // GZIP flag
|
|
|
|
// Wrap with ConnectRPC frame header (flags + length)
|
|
const length = invalidGzipPayload.length;
|
|
const frame = new Uint8Array(5 + length);
|
|
frame[0] = flags;
|
|
frame[1] = (length >> 24) & 0xff;
|
|
frame[2] = (length >> 16) & 0xff;
|
|
frame[3] = (length >> 8) & 0xff;
|
|
frame[4] = length & 0xff;
|
|
frame.set(invalidGzipPayload, 5);
|
|
|
|
const buffer = Buffer.from(frame);
|
|
|
|
const result = executor.transformProtobufToJSON(buffer, 'test-model', {
|
|
messages: [],
|
|
stream: false,
|
|
});
|
|
|
|
expect(result.status).toBe(502);
|
|
const body = JSON.parse(await result.text());
|
|
expect(body.error.type).toBe('server_error');
|
|
expect(body.error.message).toContain('decompress');
|
|
});
|
|
|
|
it('surfaces first-frame protocol errors in SSE mode without pretending success', async () => {
|
|
const executor = new CursorExecutor();
|
|
const invalidGzipPayload = new Uint8Array([1, 2, 3, 4, 5]);
|
|
const frame = buildFrame(invalidGzipPayload, 0x01);
|
|
|
|
const result = executor.transformProtobufToSSE(frame, 'test-model', {
|
|
messages: [],
|
|
stream: true,
|
|
});
|
|
|
|
expect(result.status).toBe(502);
|
|
const body = JSON.parse(await result.text());
|
|
expect(body.error.type).toBe('server_error');
|
|
expect(body.error.message).toContain('decompress');
|
|
});
|
|
|
|
it('emits an SSE error event without [DONE] when a later frame fails', async () => {
|
|
const executor = new CursorExecutor();
|
|
const combined = Buffer.concat([
|
|
buildTextFrame('Before failure'),
|
|
buildFrame(new Uint8Array([1, 2, 3, 4, 5]), 0x01),
|
|
]);
|
|
|
|
const result = executor.transformProtobufToSSE(combined, 'test-model', {
|
|
messages: [],
|
|
stream: true,
|
|
});
|
|
|
|
expect(result.status).toBe(200);
|
|
const body = await result.text();
|
|
expect(body).toContain('Before failure');
|
|
expect(body).toContain('event: error');
|
|
expect(body).toContain('"type":"server_error"');
|
|
expect(body).not.toContain('data: [DONE]');
|
|
});
|
|
|
|
it('emits an SSE error event when trailing bytes leave a truncated frame', async () => {
|
|
const executor = new CursorExecutor();
|
|
const combined = Buffer.concat([buildTextFrame('Partial success'), Buffer.from([0x00, 0x00, 0x00])]);
|
|
|
|
const result = executor.transformProtobufToSSE(combined, 'test-model', {
|
|
messages: [],
|
|
stream: true,
|
|
});
|
|
|
|
expect(result.status).toBe(200);
|
|
const body = await result.text();
|
|
expect(body).toContain('Partial success');
|
|
expect(body).toContain('event: error');
|
|
expect(body).toContain('Truncated Cursor ConnectRPC frame');
|
|
expect(body).not.toContain('data: [DONE]');
|
|
});
|
|
|
|
it('returns an explicit error for unknown ConnectRPC frame flags', async () => {
|
|
const executor = new CursorExecutor();
|
|
const result = executor.transformProtobufToJSON(buildFrame(new Uint8Array([0x01]), 0x04), 'gpt-4', {
|
|
messages: [],
|
|
});
|
|
|
|
expect(result.status).toBe(502);
|
|
const body = JSON.parse(await result.text());
|
|
expect(body.error.type).toBe('server_error');
|
|
expect(body.error.message).toContain('0x04');
|
|
});
|
|
|
|
it('should log unknown message roles in debug mode', () => {
|
|
const originalDebug = process.env.CCS_DEBUG;
|
|
process.env.CCS_DEBUG = '1';
|
|
|
|
const consoleSpy: string[] = [];
|
|
const originalError = console.error;
|
|
console.error = (...args: unknown[]) => {
|
|
const msg = args.map((a) => String(a)).join(' ');
|
|
consoleSpy.push(msg);
|
|
};
|
|
|
|
try {
|
|
const messages = [
|
|
{
|
|
role: 'unknown_role' as 'user', // Type assertion to bypass TS
|
|
content: 'test',
|
|
},
|
|
];
|
|
|
|
// buildCursorRequest expects (model, body, stream, credentials)
|
|
buildCursorRequest('test-model', { messages }, false, {
|
|
machineId: '12345',
|
|
accessToken: 'test',
|
|
});
|
|
|
|
// Should have logged warning
|
|
const hasWarning = consoleSpy.some((log) => log.includes('Unknown message role'));
|
|
expect(hasWarning).toBe(true);
|
|
} finally {
|
|
console.error = originalError;
|
|
process.env.CCS_DEBUG = originalDebug;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Helper: build a ConnectRPC frame from raw payload bytes
|
|
*/
|
|
function buildFrame(payload: Uint8Array, flags = 0): Buffer {
|
|
const header = Buffer.alloc(5);
|
|
header[0] = flags;
|
|
header.writeUInt32BE(payload.length, 1);
|
|
return Buffer.concat([header, Buffer.from(payload)]);
|
|
}
|
|
|
|
/**
|
|
* Helper: build a protobuf text response frame
|
|
*/
|
|
function buildTextFrame(text: string): Buffer {
|
|
const responseField = encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, text);
|
|
const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField);
|
|
return buildFrame(responseMsg);
|
|
}
|
|
|
|
/**
|
|
* Helper: build a protobuf thinking response frame
|
|
*/
|
|
function buildThinkingFrame(thinking: string): Buffer {
|
|
const thinkingField = encodeField(FIELD.Thinking.TEXT, WIRE_TYPE.LEN, thinking);
|
|
const responseField = encodeField(FIELD.ChatResponse.THINKING, WIRE_TYPE.LEN, thinkingField);
|
|
const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField);
|
|
return buildFrame(responseMsg);
|
|
}
|
|
|
|
/**
|
|
* Helper: build a protobuf tool call response frame
|
|
*/
|
|
function buildToolCallFrame(options: {
|
|
id: string;
|
|
name: string;
|
|
args: string;
|
|
isLast: boolean;
|
|
}): Buffer {
|
|
const toolCallPayload = concatArrays(
|
|
encodeField(FIELD.ToolCall.ID, WIRE_TYPE.LEN, options.id),
|
|
encodeField(FIELD.ToolCall.NAME, WIRE_TYPE.LEN, options.name),
|
|
encodeField(FIELD.ToolCall.RAW_ARGS, WIRE_TYPE.LEN, options.args),
|
|
encodeField(FIELD.ToolCall.IS_LAST, WIRE_TYPE.VARINT, options.isLast ? 1 : 0)
|
|
);
|
|
const responseMsg = encodeField(FIELD.Response.TOOL_CALL, WIRE_TYPE.LEN, toolCallPayload);
|
|
return buildFrame(responseMsg);
|
|
}
|
|
|
|
describe('StreamingFrameParser', () => {
|
|
it('should parse a complete single frame', () => {
|
|
const parser = new StreamingFrameParser();
|
|
const frame = buildTextFrame('Hello');
|
|
const results = parser.push(frame);
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].type).toBe('text');
|
|
if (results[0].type === 'text') {
|
|
expect(results[0].text).toBe('Hello');
|
|
}
|
|
expect(parser.hasPartial()).toBe(false);
|
|
});
|
|
|
|
it('should buffer partial frame header (< 5 bytes)', () => {
|
|
const parser = new StreamingFrameParser();
|
|
const frame = buildTextFrame('Hi');
|
|
|
|
// Send only first 3 bytes (partial header)
|
|
const results1 = parser.push(frame.slice(0, 3));
|
|
expect(results1.length).toBe(0);
|
|
expect(parser.hasPartial()).toBe(true);
|
|
|
|
// Send rest of the frame
|
|
const results2 = parser.push(frame.slice(3));
|
|
expect(results2.length).toBe(1);
|
|
expect(results2[0].type).toBe('text');
|
|
expect(parser.hasPartial()).toBe(false);
|
|
});
|
|
|
|
it('should buffer partial frame payload', () => {
|
|
const parser = new StreamingFrameParser();
|
|
const frame = buildTextFrame('Hello world');
|
|
|
|
// Send header + partial payload
|
|
const splitPoint = 8; // past the 5-byte header but not full payload
|
|
const results1 = parser.push(frame.slice(0, splitPoint));
|
|
expect(results1.length).toBe(0);
|
|
expect(parser.hasPartial()).toBe(true);
|
|
|
|
// Complete the frame
|
|
const results2 = parser.push(frame.slice(splitPoint));
|
|
expect(results2.length).toBe(1);
|
|
expect(results2[0].type).toBe('text');
|
|
if (results2[0].type === 'text') {
|
|
expect(results2[0].text).toBe('Hello world');
|
|
}
|
|
});
|
|
|
|
it('should parse multiple frames from single chunk', () => {
|
|
const parser = new StreamingFrameParser();
|
|
const frame1 = buildTextFrame('First');
|
|
const frame2 = buildTextFrame('Second');
|
|
const combined = Buffer.concat([frame1, frame2]);
|
|
|
|
const results = parser.push(combined);
|
|
expect(results.length).toBe(2);
|
|
expect(results[0].type).toBe('text');
|
|
expect(results[1].type).toBe('text');
|
|
if (results[0].type === 'text' && results[1].type === 'text') {
|
|
expect(results[0].text).toBe('First');
|
|
expect(results[1].text).toBe('Second');
|
|
}
|
|
});
|
|
|
|
it('should handle frame split across two chunks', () => {
|
|
const parser = new StreamingFrameParser();
|
|
const frame1 = buildTextFrame('AAA');
|
|
const frame2 = buildTextFrame('BBB');
|
|
const combined = Buffer.concat([frame1, frame2]);
|
|
|
|
// Split in the middle of frame2
|
|
const splitPoint = frame1.length + 3;
|
|
const results1 = parser.push(combined.slice(0, splitPoint));
|
|
expect(results1.length).toBe(1); // frame1 complete
|
|
expect(results1[0].type).toBe('text');
|
|
|
|
const results2 = parser.push(combined.slice(splitPoint));
|
|
expect(results2.length).toBe(1); // frame2 complete
|
|
expect(results2[0].type).toBe('text');
|
|
if (results2[0].type === 'text') {
|
|
expect(results2[0].text).toBe('BBB');
|
|
}
|
|
});
|
|
|
|
it('should detect JSON error mid-stream', () => {
|
|
const parser = new StreamingFrameParser();
|
|
|
|
// First: a valid text frame
|
|
const textFrame = buildTextFrame('Before error');
|
|
const results1 = parser.push(textFrame);
|
|
expect(results1.length).toBe(1);
|
|
|
|
// Then: a JSON error frame
|
|
const errorJson = JSON.stringify({
|
|
error: { code: 'resource_exhausted', message: 'Rate limit' },
|
|
});
|
|
const errorFrame = buildFrame(new TextEncoder().encode(errorJson));
|
|
const results2 = parser.push(errorFrame);
|
|
|
|
expect(results2.length).toBe(1);
|
|
expect(results2[0].type).toBe('error');
|
|
if (results2[0].type === 'error') {
|
|
expect(results2[0].status).toBe(429);
|
|
expect(results2[0].errorType).toBe('rate_limit_error');
|
|
}
|
|
});
|
|
|
|
it('should surface end-stream JSON errors instead of treating them as gzip', () => {
|
|
const parser = new StreamingFrameParser();
|
|
const endStreamError = buildFrame(
|
|
new TextEncoder().encode(
|
|
JSON.stringify({
|
|
error: {
|
|
code: 'invalid_argument',
|
|
message: 'parse binary: illegal tag: field no 0 wire type 0',
|
|
},
|
|
})
|
|
),
|
|
0x02
|
|
);
|
|
|
|
const results = parser.push(endStreamError);
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].type).toBe('error');
|
|
if (results[0].type === 'error') {
|
|
expect(results[0].status).toBe(400);
|
|
expect(results[0].errorType).toBe('api_error');
|
|
expect(results[0].message).toContain('illegal tag');
|
|
}
|
|
});
|
|
|
|
it('should map unavailable end-stream errors to 503 in the parser', () => {
|
|
const parser = new StreamingFrameParser();
|
|
const endStreamError = buildFrame(
|
|
new TextEncoder().encode(
|
|
JSON.stringify({
|
|
error: {
|
|
code: 'unavailable',
|
|
message: 'upstream down',
|
|
},
|
|
})
|
|
),
|
|
0x02
|
|
);
|
|
|
|
const results = parser.push(endStreamError);
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].type).toBe('error');
|
|
if (results[0].type === 'error') {
|
|
expect(results[0].status).toBe(503);
|
|
expect(results[0].errorType).toBe('api_error');
|
|
}
|
|
});
|
|
|
|
it('should parse thinking frames', () => {
|
|
const parser = new StreamingFrameParser();
|
|
const frame = buildThinkingFrame('Think step by step');
|
|
const results = parser.push(frame);
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].type).toBe('thinking');
|
|
if (results[0].type === 'thinking') {
|
|
expect(results[0].text).toBe('Think step by step');
|
|
}
|
|
});
|
|
|
|
it('should parse tool call frames', () => {
|
|
const parser = new StreamingFrameParser();
|
|
const frame = buildToolCallFrame({
|
|
id: 'call_parser',
|
|
name: 'search_docs',
|
|
args: '{"q":"docs"}',
|
|
isLast: true,
|
|
});
|
|
const results = parser.push(frame);
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].type).toBe('toolCall');
|
|
if (results[0].type === 'toolCall') {
|
|
expect(results[0].toolCall.id).toBe('call_parser');
|
|
expect(results[0].toolCall.function.name).toBe('search_docs');
|
|
expect(results[0].toolCall.function.arguments).toBe('{"q":"docs"}');
|
|
expect(results[0].toolCall.isLast).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should classify malformed protobuf payload as server error', () => {
|
|
const parser = new StreamingFrameParser();
|
|
const malformedFrame = buildFrame(new Uint8Array([0xff, 0xff, 0xff]));
|
|
const results = parser.push(malformedFrame);
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].type).toBe('error');
|
|
if (results[0].type === 'error') {
|
|
expect(results[0].status).toBe(502);
|
|
expect(results[0].errorType).toBe('server_error');
|
|
expect(results[0].message).toContain('Malformed protobuf response');
|
|
}
|
|
});
|
|
|
|
it('should reject invalid gzip-compressed frames explicitly', () => {
|
|
const parser = new StreamingFrameParser();
|
|
const invalidCompressedFrame = buildFrame(new Uint8Array([1, 2, 3, 4, 5]), 0x01);
|
|
const results = parser.push(invalidCompressedFrame);
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].type).toBe('error');
|
|
if (results[0].type === 'error') {
|
|
expect(results[0].status).toBe(502);
|
|
expect(results[0].errorType).toBe('server_error');
|
|
expect(results[0].message).toContain('decompress');
|
|
}
|
|
});
|
|
|
|
it('should reject unknown ConnectRPC frame flag bits', () => {
|
|
const parser = new StreamingFrameParser();
|
|
const results = parser.push(buildFrame(new Uint8Array([0x01]), 0x04));
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].type).toBe('error');
|
|
if (results[0].type === 'error') {
|
|
expect(results[0].status).toBe(502);
|
|
expect(results[0].errorType).toBe('server_error');
|
|
expect(results[0].message).toContain('0x04');
|
|
}
|
|
});
|
|
|
|
it('should report hasPartial() correctly', () => {
|
|
const parser = new StreamingFrameParser();
|
|
expect(parser.hasPartial()).toBe(false);
|
|
|
|
// Push partial data
|
|
parser.push(Buffer.from([0x00, 0x00]));
|
|
expect(parser.hasPartial()).toBe(true);
|
|
|
|
// Push enough to complete the frame (empty payload)
|
|
// header: flags=0, length=0 → 5 bytes total
|
|
const emptyFrame = Buffer.alloc(5);
|
|
emptyFrame[0] = 0;
|
|
emptyFrame.writeUInt32BE(0, 1);
|
|
|
|
const parser2 = new StreamingFrameParser();
|
|
parser2.push(emptyFrame);
|
|
expect(parser2.hasPartial()).toBe(false);
|
|
});
|
|
|
|
it('should surface truncated trailing bytes when the stream finishes', () => {
|
|
const parser = new StreamingFrameParser();
|
|
parser.push(Buffer.from([0x00, 0x00, 0x00]));
|
|
|
|
const results = parser.finish();
|
|
|
|
expect(results.length).toBe(1);
|
|
expect(results[0].type).toBe('error');
|
|
if (results[0].type === 'error') {
|
|
expect(results[0].status).toBe(502);
|
|
expect(results[0].message).toContain('Truncated Cursor ConnectRPC frame');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('decompressPayload', () => {
|
|
it('should pass through uncompressed payload', () => {
|
|
const payload = Buffer.from('raw protobuf data');
|
|
const result = decompressPayload(payload, 0x00);
|
|
expect(result).toEqual(payload);
|
|
});
|
|
|
|
it('should skip decompression for JSON error payloads', () => {
|
|
const errorPayload = Buffer.from('{"error":"something went wrong"}');
|
|
const result = decompressPayload(errorPayload, 0x01); // GZIP flag but JSON error
|
|
expect(result).toEqual(errorPayload);
|
|
});
|
|
|
|
it('should throw on invalid gzip data', () => {
|
|
const invalidGzip = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05]);
|
|
expect(() => decompressPayload(invalidGzip, 0x01)).toThrow(
|
|
'Failed to decompress Cursor ConnectRPC frame.'
|
|
);
|
|
});
|
|
|
|
it('should throw on unknown ConnectRPC frame flags', () => {
|
|
const payload = Buffer.from('payload');
|
|
expect(() => decompressPayload(payload, 0x04)).toThrow(
|
|
'Unsupported ConnectRPC frame flags: 0x04'
|
|
);
|
|
});
|
|
|
|
it('should decompress valid gzip payload', () => {
|
|
const zlib = require('zlib');
|
|
const original = Buffer.from('Hello compressed world');
|
|
const compressed = zlib.gzipSync(original);
|
|
|
|
const result = decompressPayload(compressed, 0x01);
|
|
expect(result.toString()).toBe('Hello compressed world');
|
|
});
|
|
|
|
it('should not decompress plain end-stream trailers and should handle compressed end-stream trailers', () => {
|
|
const zlib = require('zlib');
|
|
const original = Buffer.from('test data');
|
|
const compressed = zlib.gzipSync(original);
|
|
|
|
expect(decompressPayload(original, 0x02)).toEqual(original);
|
|
expect(decompressPayload(compressed, 0x03).toString()).toBe('test data');
|
|
});
|
|
});
|