feat(cursor): namespace FIELD constants and implement true streaming SSE (#531, #535)

This commit is contained in:
Tam Nhu Tran
2026-02-13 04:12:29 +07:00
parent 52e04f5cf3
commit 4e5b502fc9
7 changed files with 773 additions and 229 deletions
+281 -47
View File
@@ -4,13 +4,12 @@
*/ */
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import * as zlib from 'zlib';
import type { IncomingHttpHeaders } from 'http'; import type { IncomingHttpHeaders } from 'http';
import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js'; import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js';
import { buildCursorRequest } from './cursor-translator.js'; import { buildCursorRequest } from './cursor-translator.js';
import type { CursorTool, CursorCredentials } from './cursor-protobuf-schema.js'; import type { CursorTool, CursorCredentials } from './cursor-protobuf-schema.js';
import { COMPRESS_FLAG } from './cursor-protobuf-schema.js'; import { StreamingFrameParser, decompressPayload } from './cursor-stream-parser.js';
/** Executor parameters */ /** Executor parameters */
interface ExecutorParams { interface ExecutorParams {
@@ -57,42 +56,6 @@ async function getHttp2() {
} }
} }
/**
* Decompress payload if needed
* NOTE: Uses synchronous gzip for single-request CLI tool. Async not warranted for small payloads.
*/
function decompressPayload(payload: Buffer, flags: number): Buffer {
// Check if payload is JSON error
if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) {
try {
const text = payload.toString('utf-8');
if (text.startsWith('{"error"')) {
return payload;
}
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] JSON error detection failed:', err);
}
}
}
if (
flags === COMPRESS_FLAG.GZIP ||
flags === COMPRESS_FLAG.GZIP_ALT ||
flags === COMPRESS_FLAG.GZIP_BOTH
) {
try {
return zlib.gunzipSync(payload);
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] gzip decompression failed:', err);
}
return Buffer.alloc(0);
}
}
return payload;
}
/** /**
* Create error response from JSON error * Create error response from JSON error
*/ */
@@ -342,6 +305,20 @@ export class CursorExecutor {
const transformedBody = this.transformRequest(model, body, stream, credentials); const transformedBody = this.transformRequest(model, body, stream, credentials);
try { try {
// Streaming requests use incremental HTTP/2 → SSE pipeline
if (stream) {
const response = await this.executeStreaming(
url,
headers,
transformedBody,
model,
body,
signal
);
return { response, url, headers, transformedBody: body };
}
// Non-streaming: buffer entire response then transform to JSON
const http2 = await getHttp2(); const http2 = await getHttp2();
const response = http2 const response = http2
? await this.makeHttp2Request(url, headers, transformedBody, signal) ? await this.makeHttp2Request(url, headers, transformedBody, signal)
@@ -365,11 +342,7 @@ export class CursorExecutor {
return { response: errorResponse, url, headers, transformedBody: body }; return { response: errorResponse, url, headers, transformedBody: body };
} }
const transformedResponse = const transformedResponse = this.transformProtobufToJSON(response.body, model, body);
stream === true
? this.transformProtobufToSSE(response.body, model, body)
: this.transformProtobufToJSON(response.body, model, body);
return { response: transformedResponse, url, headers, transformedBody: body }; return { response: transformedResponse, url, headers, transformedBody: body };
} catch (error) { } catch (error) {
const errorResponse = new Response( const errorResponse = new Response(
@@ -389,6 +362,270 @@ export class CursorExecutor {
} }
} }
/**
* Execute a streaming request, piping HTTP/2 data events through
* StreamingFrameParser for incremental SSE output.
* Falls back to buffered transformProtobufToSSE when http2 is unavailable.
*/
private async executeStreaming(
url: string,
headers: Record<string, string>,
body: Uint8Array,
model: string,
requestBody: ExecutorParams['body'],
signal?: AbortSignal
): Promise<Response> {
const http2 = await getHttp2();
if (!http2) {
const response = await this.makeFetchRequest(url, headers, body, signal);
return this.transformProtobufToSSE(response.body, model, requestBody);
}
const responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const client = http2.connect(`https://${urlObj.host}`);
client.on('error', (err) => {
client.close();
reject(err);
});
const req = client.request({
':method': 'POST',
':path': urlObj.pathname,
':authority': urlObj.host,
':scheme': 'https',
...headers,
});
req.on('response', (hdrs) => {
const status = Number(hdrs[':status']) || 500;
if (status !== 200) {
const errorChunks: Buffer[] = [];
req.on('data', (c: Buffer) => errorChunks.push(c));
req.on('end', () => {
client.close();
const errorText = Buffer.concat(errorChunks).toString();
resolve(
new Response(
JSON.stringify({
error: {
message: `[${status}]: ${errorText}`,
type: 'invalid_request_error',
code: '',
},
}),
{ status, headers: { 'Content-Type': 'application/json' } }
)
);
});
return;
}
// Status 200: set up incremental streaming pipeline
const parser = new StreamingFrameParser();
const enc = new TextEncoder();
const toolCallsMap = new Map<
string,
{
id: string;
type: string;
function: { name: string; arguments: string };
isLast: boolean;
index: number;
}
>();
let chunkCount = 0;
let toolCallCount = 0;
let streamClosed = false;
const readable = new ReadableStream<Uint8Array>({
start(controller) {
const emitSSE = (data: string) => {
if (streamClosed) return;
controller.enqueue(enc.encode(`data: ${data}\n\n`));
};
const closeStream = () => {
if (streamClosed) return;
streamClosed = true;
try {
controller.close();
} catch {
/* already closed */
}
client.close();
};
const buildChunk = (delta: Record<string, unknown>, finishReason: string | null) =>
JSON.stringify({
id: responseId,
object: 'chat.completion.chunk',
created,
model,
choices: [{ index: 0, delta, finish_reason: finishReason }],
});
req.on('data', (chunk: Buffer) => {
if (streamClosed) return;
for (const frame of parser.push(chunk)) {
if (frame.type === 'error') {
emitSSE(
JSON.stringify({
error: { message: frame.message, type: frame.errorType, code: '' },
})
);
emitSSE('[DONE]');
closeStream();
return;
}
if (frame.type === 'toolCall') {
const tc = frame.toolCall;
// Emit role chunk on first content
if (chunkCount === 0) {
emitSSE(buildChunk({ role: 'assistant', content: '' }, null));
chunkCount++;
}
if (toolCallsMap.has(tc.id)) {
const existing = toolCallsMap.get(tc.id);
if (!existing) continue;
existing.function.arguments += tc.function.arguments;
existing.isLast = tc.isLast;
if (tc.function.arguments) {
emitSSE(
buildChunk(
{
tool_calls: [
{
index: existing.index,
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
},
],
},
null
)
);
chunkCount++;
}
} else {
const idx = toolCallCount++;
toolCallsMap.set(tc.id, { ...tc, index: idx });
emitSSE(
buildChunk(
{
tool_calls: [
{
index: idx,
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
},
],
},
null
)
);
chunkCount++;
}
}
if (frame.type === 'text') {
const delta =
chunkCount === 0 && toolCallCount === 0
? { role: 'assistant', content: frame.text }
: { content: frame.text };
emitSSE(buildChunk(delta, null));
chunkCount++;
}
}
});
req.on('end', () => {
if (streamClosed) return;
if (chunkCount === 0 && toolCallCount === 0) {
emitSSE(buildChunk({ role: 'assistant', content: '' }, null));
}
emitSSE(
JSON.stringify({
id: responseId,
object: 'chat.completion.chunk',
created,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: toolCallCount > 0 ? 'tool_calls' : 'stop',
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
})
);
emitSSE('[DONE]');
closeStream();
});
req.on('error', (err) => {
if (!streamClosed) {
try {
controller.error(err);
} catch {
/* already closed */
}
}
client.close();
});
if (signal) {
const onAbort = () => {
req.close();
closeStream();
};
signal.addEventListener('abort', onAbort, { once: true });
const cleanup = () => signal.removeEventListener('abort', onAbort);
req.on('end', cleanup);
req.on('error', cleanup);
}
},
});
resolve(
new Response(readable, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
);
});
req.on('error', (err) => {
client.close();
reject(err);
});
req.write(body);
req.end();
});
}
/** /**
* Parse protobuf buffer into frames and extract text/toolcalls. * Parse protobuf buffer into frames and extract text/toolcalls.
* Shared logic between JSON and SSE transformers. * Shared logic between JSON and SSE transformers.
@@ -590,11 +827,8 @@ export class CursorExecutor {
}); });
} }
/** @deprecated Use executeStreaming() for real-time SSE. Retained for fetch fallback. */
transformProtobufToSSE(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response { transformProtobufToSSE(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response {
// 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 responseId = `chatcmpl-cursor-${Date.now()}`;
const created = Math.floor(Date.now() / 1000); const created = Math.floor(Date.now() / 1000);
+26 -26
View File
@@ -160,8 +160,8 @@ function extractToolCall(toolCallData: Uint8Array): {
let isLast = false; let isLast = false;
// Extract tool call ID // Extract tool call ID
if (toolCall.has(FIELD.TOOL_ID)) { if (toolCall.has(FIELD.ToolCall.ID)) {
const idField = toolCall.get(FIELD.TOOL_ID); const idField = toolCall.get(FIELD.ToolCall.ID);
if (idField && idField[0]) { if (idField && idField[0]) {
const fullId = new TextDecoder().decode(idField[0].value as Uint8Array); const fullId = new TextDecoder().decode(idField[0].value as Uint8Array);
toolCallId = fullId.split('\n')[0]; // Take first line toolCallId = fullId.split('\n')[0]; // Take first line
@@ -169,44 +169,44 @@ function extractToolCall(toolCallData: Uint8Array): {
} }
// Extract tool name // Extract tool name
if (toolCall.has(FIELD.TOOL_NAME)) { if (toolCall.has(FIELD.ToolCall.NAME)) {
const nameField = toolCall.get(FIELD.TOOL_NAME); const nameField = toolCall.get(FIELD.ToolCall.NAME);
if (nameField && nameField[0]) { if (nameField && nameField[0]) {
toolName = new TextDecoder().decode(nameField[0].value as Uint8Array); toolName = new TextDecoder().decode(nameField[0].value as Uint8Array);
} }
} }
// Extract is_last flag // Extract is_last flag
if (toolCall.has(FIELD.TOOL_IS_LAST)) { if (toolCall.has(FIELD.ToolCall.IS_LAST)) {
const lastField = toolCall.get(FIELD.TOOL_IS_LAST); const lastField = toolCall.get(FIELD.ToolCall.IS_LAST);
if (lastField && lastField[0]) { if (lastField && lastField[0]) {
isLast = (lastField[0].value as number) !== 0; isLast = (lastField[0].value as number) !== 0;
} }
} }
// Extract MCP params - nested real tool info // Extract MCP params - nested real tool info
if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) { if (toolCall.has(FIELD.ToolCall.MCP_PARAMS)) {
try { try {
const mcpField = toolCall.get(FIELD.TOOL_MCP_PARAMS); const mcpField = toolCall.get(FIELD.ToolCall.MCP_PARAMS);
if (!mcpField || !mcpField[0]) return null; if (!mcpField || !mcpField[0]) return null;
const mcpParams = decodeMessage(mcpField[0].value as Uint8Array); const mcpParams = decodeMessage(mcpField[0].value as Uint8Array);
if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) { if (mcpParams.has(FIELD.McpParams.TOOLS_LIST)) {
const toolsList = mcpParams.get(FIELD.MCP_TOOLS_LIST); const toolsList = mcpParams.get(FIELD.McpParams.TOOLS_LIST);
if (!toolsList || !toolsList[0]) return null; if (!toolsList || !toolsList[0]) return null;
const tool = decodeMessage(toolsList[0].value as Uint8Array); const tool = decodeMessage(toolsList[0].value as Uint8Array);
if (tool.has(FIELD.MCP_NESTED_NAME)) { if (tool.has(FIELD.McpNested.NAME)) {
const nestedName = tool.get(FIELD.MCP_NESTED_NAME); const nestedName = tool.get(FIELD.McpNested.NAME);
if (nestedName && nestedName[0]) { if (nestedName && nestedName[0]) {
toolName = new TextDecoder().decode(nestedName[0].value as Uint8Array); toolName = new TextDecoder().decode(nestedName[0].value as Uint8Array);
} }
} }
if (tool.has(FIELD.MCP_NESTED_PARAMS)) { if (tool.has(FIELD.McpNested.PARAMS)) {
const nestedParams = tool.get(FIELD.MCP_NESTED_PARAMS); const nestedParams = tool.get(FIELD.McpNested.PARAMS);
if (nestedParams && nestedParams[0]) { if (nestedParams && nestedParams[0]) {
rawArgs = new TextDecoder().decode(nestedParams[0].value as Uint8Array); rawArgs = new TextDecoder().decode(nestedParams[0].value as Uint8Array);
} }
@@ -221,8 +221,8 @@ function extractToolCall(toolCallData: Uint8Array): {
} }
// Fallback to raw_args // Fallback to raw_args
if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) { if (!rawArgs && toolCall.has(FIELD.ToolCall.RAW_ARGS)) {
const rawArgsField = toolCall.get(FIELD.TOOL_RAW_ARGS); const rawArgsField = toolCall.get(FIELD.ToolCall.RAW_ARGS);
if (rawArgsField && rawArgsField[0]) { if (rawArgsField && rawArgsField[0]) {
rawArgs = new TextDecoder().decode(rawArgsField[0].value as Uint8Array); rawArgs = new TextDecoder().decode(rawArgsField[0].value as Uint8Array);
} }
@@ -255,21 +255,21 @@ function extractTextAndThinking(responseData: Uint8Array): {
let thinking: string | null = null; let thinking: string | null = null;
// Extract text // Extract text
if (nested.has(FIELD.RESPONSE_TEXT)) { if (nested.has(FIELD.ChatResponse.TEXT)) {
const textField = nested.get(FIELD.RESPONSE_TEXT); const textField = nested.get(FIELD.ChatResponse.TEXT);
if (textField && textField[0]) { if (textField && textField[0]) {
text = new TextDecoder().decode(textField[0].value as Uint8Array); text = new TextDecoder().decode(textField[0].value as Uint8Array);
} }
} }
// Extract thinking // Extract thinking
if (nested.has(FIELD.THINKING)) { if (nested.has(FIELD.ChatResponse.THINKING)) {
try { try {
const thinkingField = nested.get(FIELD.THINKING); const thinkingField = nested.get(FIELD.ChatResponse.THINKING);
if (thinkingField && thinkingField[0]) { if (thinkingField && thinkingField[0]) {
const thinkingMsg = decodeMessage(thinkingField[0].value as Uint8Array); const thinkingMsg = decodeMessage(thinkingField[0].value as Uint8Array);
if (thinkingMsg.has(FIELD.THINKING_TEXT)) { if (thinkingMsg.has(FIELD.Thinking.TEXT)) {
const thinkingTextField = thinkingMsg.get(FIELD.THINKING_TEXT); const thinkingTextField = thinkingMsg.get(FIELD.Thinking.TEXT);
if (thinkingTextField && thinkingTextField[0]) { if (thinkingTextField && thinkingTextField[0]) {
thinking = new TextDecoder().decode(thinkingTextField[0].value as Uint8Array); thinking = new TextDecoder().decode(thinkingTextField[0].value as Uint8Array);
} }
@@ -304,8 +304,8 @@ export function extractTextFromResponse(payload: Uint8Array): {
const fields = decodeMessage(payload); const fields = decodeMessage(payload);
// Field 1: ClientSideToolV2Call // Field 1: ClientSideToolV2Call
if (fields.has(FIELD.TOOL_CALL)) { if (fields.has(FIELD.Response.TOOL_CALL)) {
const toolCallField = fields.get(FIELD.TOOL_CALL); const toolCallField = fields.get(FIELD.Response.TOOL_CALL);
if (toolCallField && toolCallField[0]) { if (toolCallField && toolCallField[0]) {
const toolCall = extractToolCall(toolCallField[0].value as Uint8Array); const toolCall = extractToolCall(toolCallField[0].value as Uint8Array);
if (toolCall) { if (toolCall) {
@@ -315,8 +315,8 @@ export function extractTextFromResponse(payload: Uint8Array): {
} }
// Field 2: StreamUnifiedChatResponse // Field 2: StreamUnifiedChatResponse
if (fields.has(FIELD.RESPONSE)) { if (fields.has(FIELD.Response.RESPONSE)) {
const responseField = fields.get(FIELD.RESPONSE); const responseField = fields.get(FIELD.Response.RESPONSE);
if (responseField && responseField[0]) { if (responseField && responseField[0]) {
const { text, thinking } = extractTextAndThinking(responseField[0].value as Uint8Array); const { text, thinking } = extractTextAndThinking(responseField[0].value as Uint8Array);
+33 -33
View File
@@ -84,10 +84,10 @@ export function encodeToolResult(toolResult: CursorToolResult): Uint8Array {
const rawArgs = toolResult.raw_args || '{}'; const rawArgs = toolResult.raw_args || '{}';
return concatArrays( return concatArrays(
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId), encodeField(FIELD.ToolResult.CALL_ID, WIRE_TYPE.LEN, toolCallId),
encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName), encodeField(FIELD.ToolResult.NAME, WIRE_TYPE.LEN, toolName),
encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex), encodeField(FIELD.ToolResult.INDEX, WIRE_TYPE.VARINT, toolIndex),
encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs) encodeField(FIELD.ToolResult.RAW_ARGS, WIRE_TYPE.LEN, rawArgs)
); );
} }
@@ -103,22 +103,22 @@ export function encodeMessage(
toolResults: CursorToolResult[] toolResults: CursorToolResult[]
): Uint8Array { ): Uint8Array {
return concatArrays( return concatArrays(
encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content), encodeField(FIELD.Message.CONTENT, WIRE_TYPE.LEN, content),
encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role), encodeField(FIELD.Message.ROLE, WIRE_TYPE.VARINT, role),
encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId), encodeField(FIELD.Message.ID, WIRE_TYPE.LEN, messageId),
...(toolResults.length > 0 ...(toolResults.length > 0
? toolResults.map((tr) => ? toolResults.map((tr) =>
encodeField(FIELD.MSG_TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr)) encodeField(FIELD.Message.TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr))
) )
: []), : []),
encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0), encodeField(FIELD.Message.IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0),
encodeField( encodeField(
FIELD.MSG_UNIFIED_MODE, FIELD.Message.UNIFIED_MODE,
WIRE_TYPE.VARINT, WIRE_TYPE.VARINT,
hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
), ),
...(isLast && hasTools ...(isLast && hasTools
? [encodeField(FIELD.MSG_SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] ? [encodeField(FIELD.Message.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))]
: []) : [])
); );
} }
@@ -127,7 +127,7 @@ export function encodeMessage(
* Encode instruction text * Encode instruction text
*/ */
export function encodeInstruction(text: string): Uint8Array { export function encodeInstruction(text: string): Uint8Array {
return text ? encodeField(FIELD.INSTRUCTION_TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0); return text ? encodeField(FIELD.Instruction.TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0);
} }
/** /**
@@ -135,8 +135,8 @@ export function encodeInstruction(text: string): Uint8Array {
*/ */
export function encodeModel(modelName: string): Uint8Array { export function encodeModel(modelName: string): Uint8Array {
return concatArrays( return concatArrays(
encodeField(FIELD.MODEL_NAME, WIRE_TYPE.LEN, modelName), encodeField(FIELD.Model.NAME, WIRE_TYPE.LEN, modelName),
encodeField(FIELD.MODEL_EMPTY, WIRE_TYPE.LEN, new Uint8Array(0)) encodeField(FIELD.Model.EMPTY, WIRE_TYPE.LEN, new Uint8Array(0))
); );
} }
@@ -145,16 +145,16 @@ export function encodeModel(modelName: string): Uint8Array {
*/ */
export function encodeCursorSetting(): Uint8Array { export function encodeCursorSetting(): Uint8Array {
const unknown6 = concatArrays( const unknown6 = concatArrays(
encodeField(FIELD.SETTING6_FIELD_1, WIRE_TYPE.LEN, new Uint8Array(0)), encodeField(FIELD.Setting.U6_FIELD_1, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.SETTING6_FIELD_2, WIRE_TYPE.LEN, new Uint8Array(0)) encodeField(FIELD.Setting.U6_FIELD_2, WIRE_TYPE.LEN, new Uint8Array(0))
); );
return concatArrays( return concatArrays(
encodeField(FIELD.SETTING_PATH, WIRE_TYPE.LEN, 'cursor\\aisettings'), encodeField(FIELD.Setting.PATH, WIRE_TYPE.LEN, 'cursor\\aisettings'),
encodeField(FIELD.SETTING_UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)), encodeField(FIELD.Setting.UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)),
encodeField(FIELD.SETTING_UNKNOWN_6, WIRE_TYPE.LEN, unknown6), encodeField(FIELD.Setting.UNKNOWN_6, WIRE_TYPE.LEN, unknown6),
encodeField(FIELD.SETTING_UNKNOWN_8, WIRE_TYPE.VARINT, 1), encodeField(FIELD.Setting.UNKNOWN_8, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.SETTING_UNKNOWN_9, WIRE_TYPE.VARINT, 1) encodeField(FIELD.Setting.UNKNOWN_9, WIRE_TYPE.VARINT, 1)
); );
} }
@@ -163,11 +163,11 @@ export function encodeCursorSetting(): Uint8Array {
*/ */
export function encodeMetadata(): Uint8Array { export function encodeMetadata(): Uint8Array {
return concatArrays( return concatArrays(
encodeField(FIELD.META_PLATFORM, WIRE_TYPE.LEN, process.platform || 'linux'), encodeField(FIELD.Metadata.PLATFORM, WIRE_TYPE.LEN, process.platform || 'linux'),
encodeField(FIELD.META_ARCH, WIRE_TYPE.LEN, process.arch || 'x64'), encodeField(FIELD.Metadata.ARCH, WIRE_TYPE.LEN, process.arch || 'x64'),
encodeField(FIELD.META_VERSION, WIRE_TYPE.LEN, process.version || 'v20.0.0'), encodeField(FIELD.Metadata.VERSION, WIRE_TYPE.LEN, process.version || 'v20.0.0'),
encodeField(FIELD.META_CWD, WIRE_TYPE.LEN, process.cwd() || '/'), encodeField(FIELD.Metadata.CWD, WIRE_TYPE.LEN, process.cwd() || '/'),
encodeField(FIELD.META_TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString()) encodeField(FIELD.Metadata.TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString())
); );
} }
@@ -176,9 +176,9 @@ export function encodeMetadata(): Uint8Array {
*/ */
export function encodeMessageId(messageId: string, role: RoleType, summaryId?: string): Uint8Array { export function encodeMessageId(messageId: string, role: RoleType, summaryId?: string): Uint8Array {
return concatArrays( return concatArrays(
encodeField(FIELD.MSGID_ID, WIRE_TYPE.LEN, messageId), encodeField(FIELD.MessageId.ID, WIRE_TYPE.LEN, messageId),
...(summaryId ? [encodeField(FIELD.MSGID_SUMMARY, WIRE_TYPE.LEN, summaryId)] : []), ...(summaryId ? [encodeField(FIELD.MessageId.SUMMARY, WIRE_TYPE.LEN, summaryId)] : []),
encodeField(FIELD.MSGID_ROLE, WIRE_TYPE.VARINT, role) encodeField(FIELD.MessageId.ROLE, WIRE_TYPE.VARINT, role)
); );
} }
@@ -191,12 +191,12 @@ export function encodeMcpTool(tool: CursorTool): Uint8Array {
const inputSchema = tool.function?.parameters || tool.input_schema || {}; const inputSchema = tool.function?.parameters || tool.input_schema || {};
return concatArrays( return concatArrays(
...(toolName ? [encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName)] : []), ...(toolName ? [encodeField(FIELD.McpTool.NAME, WIRE_TYPE.LEN, toolName)] : []),
...(toolDesc ? [encodeField(FIELD.MCP_TOOL_DESC, WIRE_TYPE.LEN, toolDesc)] : []), ...(toolDesc ? [encodeField(FIELD.McpTool.DESC, WIRE_TYPE.LEN, toolDesc)] : []),
...(Object.keys(inputSchema).length > 0 ...(Object.keys(inputSchema).length > 0
? [encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, JSON.stringify(inputSchema))] ? [encodeField(FIELD.McpTool.PARAMS, WIRE_TYPE.LEN, JSON.stringify(inputSchema))]
: []), : []),
encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, 'custom') encodeField(FIELD.McpTool.SERVER, WIRE_TYPE.LEN, 'custom')
); );
} }
+80 -92
View File
@@ -30,113 +30,102 @@ export const THINKING_LEVEL = {
HIGH: 2, HIGH: 2,
} as const; } as const;
/** Field numbers for all protobuf messages */ /** Field numbers namespaced by protobuf message type */
export const FIELD = { export const FIELD = {
// ===== StreamUnifiedChatRequestWithTools (top level) ===== /** StreamUnifiedChatRequestWithTools (top level) */
REQUEST: 1, Request: { REQUEST: 1 },
// ===== StreamUnifiedChatRequest ===== /** StreamUnifiedChatRequest */
MESSAGES: 1, Chat: {
UNKNOWN_2: 2, MESSAGES: 1,
INSTRUCTION: 3, UNKNOWN_2: 2,
UNKNOWN_4: 4, INSTRUCTION: 3,
MODEL: 5, UNKNOWN_4: 4,
WEB_TOOL: 8, MODEL: 5,
UNKNOWN_13: 13, WEB_TOOL: 8,
CURSOR_SETTING: 15, UNKNOWN_13: 13,
UNKNOWN_19: 19, CURSOR_SETTING: 15,
CONVERSATION_ID: 23, UNKNOWN_19: 19,
METADATA: 26, CONVERSATION_ID: 23,
IS_AGENTIC: 27, METADATA: 26,
SUPPORTED_TOOLS: 29, IS_AGENTIC: 27,
MESSAGE_IDS: 30, SUPPORTED_TOOLS: 29,
MCP_TOOLS: 34, MESSAGE_IDS: 30,
LARGE_CONTEXT: 35, MCP_TOOLS: 34,
UNKNOWN_38: 38, LARGE_CONTEXT: 35,
UNIFIED_MODE: 46, UNKNOWN_38: 38,
UNKNOWN_47: 47, UNIFIED_MODE: 46,
SHOULD_DISABLE_TOOLS: 48, UNKNOWN_47: 47,
THINKING_LEVEL: 49, SHOULD_DISABLE_TOOLS: 48,
UNKNOWN_51: 51, THINKING_LEVEL: 49,
UNKNOWN_53: 53, UNKNOWN_51: 51,
UNIFIED_MODE_NAME: 54, UNKNOWN_53: 53,
UNIFIED_MODE_NAME: 54,
},
// ===== ConversationMessage ===== /** ConversationMessage */
MSG_CONTENT: 1, Message: {
MSG_ROLE: 2, CONTENT: 1,
MSG_ID: 13, ROLE: 2,
MSG_TOOL_RESULTS: 18, ID: 13,
MSG_IS_AGENTIC: 29, TOOL_RESULTS: 18,
MSG_UNIFIED_MODE: 47, IS_AGENTIC: 29,
MSG_SUPPORTED_TOOLS: 51, UNIFIED_MODE: 47,
SUPPORTED_TOOLS: 51,
},
// ===== ConversationMessage.ToolResult ===== /** ConversationMessage.ToolResult */
TOOL_RESULT_CALL_ID: 1, ToolResult: {
TOOL_RESULT_NAME: 2, CALL_ID: 1,
TOOL_RESULT_INDEX: 3, NAME: 2,
TOOL_RESULT_RAW_ARGS: 5, INDEX: 3,
TOOL_RESULT_RESULT: 8, // Reserved for future tool result parsing RAW_ARGS: 5,
RESULT: 8,
},
// ===== Model ===== /** Model */
MODEL_NAME: 1, Model: { NAME: 1, EMPTY: 4 },
MODEL_EMPTY: 4,
// ===== Instruction ===== /** Instruction */
INSTRUCTION_TEXT: 1, Instruction: { TEXT: 1 },
// ===== CursorSetting ===== /** CursorSetting (U6_FIELD_* are sub-fields of Unknown6 nested message) */
SETTING_PATH: 1, Setting: {
SETTING_UNKNOWN_3: 3, PATH: 1,
SETTING_UNKNOWN_6: 6, UNKNOWN_3: 3,
SETTING_UNKNOWN_8: 8, UNKNOWN_6: 6,
SETTING_UNKNOWN_9: 9, UNKNOWN_8: 8,
UNKNOWN_9: 9,
U6_FIELD_1: 1,
U6_FIELD_2: 2,
},
// ===== CursorSetting.Unknown6 ===== /** Metadata */
SETTING6_FIELD_1: 1, Metadata: { PLATFORM: 1, ARCH: 2, VERSION: 3, CWD: 4, TIMESTAMP: 5 },
SETTING6_FIELD_2: 2,
// ===== Metadata ===== /** MessageId */
META_PLATFORM: 1, MessageId: { ID: 1, SUMMARY: 2, ROLE: 3 },
META_ARCH: 2,
META_VERSION: 3,
META_CWD: 4,
META_TIMESTAMP: 5,
// ===== MessageId ===== /** MCPTool */
MSGID_ID: 1, McpTool: { NAME: 1, DESC: 2, PARAMS: 3, SERVER: 4 },
MSGID_SUMMARY: 2,
MSGID_ROLE: 3,
// ===== MCPTool ===== /** StreamUnifiedChatResponseWithTools (response top-level) */
MCP_TOOL_NAME: 1, Response: { TOOL_CALL: 1, RESPONSE: 2 },
MCP_TOOL_DESC: 2,
MCP_TOOL_PARAMS: 3,
MCP_TOOL_SERVER: 4,
// ===== StreamUnifiedChatResponseWithTools (response) ===== /** ClientSideToolV2Call */
TOOL_CALL: 1, ToolCall: { ID: 3, NAME: 9, RAW_ARGS: 10, IS_LAST: 11, MCP_PARAMS: 27 },
RESPONSE: 2,
// ===== ClientSideToolV2Call ===== /** MCPParams */
TOOL_ID: 3, McpParams: { TOOLS_LIST: 1 },
TOOL_NAME: 9,
TOOL_RAW_ARGS: 10,
TOOL_IS_LAST: 11,
TOOL_MCP_PARAMS: 27,
// ===== MCPParams ===== /** MCPParams.Tool (nested) */
MCP_TOOLS_LIST: 1, McpNested: { NAME: 1, PARAMS: 3 },
// ===== MCPParams.Tool (nested) ===== /** StreamUnifiedChatResponse */
MCP_NESTED_NAME: 1, ChatResponse: { TEXT: 1, THINKING: 25 },
MCP_NESTED_PARAMS: 3,
// ===== StreamUnifiedChatResponse ===== /** Thinking */
RESPONSE_TEXT: 1, Thinking: { TEXT: 1 },
THINKING: 25,
// ===== Thinking =====
THINKING_TEXT: 1,
} as const; } as const;
/** Type definitions */ /** Type definitions */
@@ -144,7 +133,6 @@ export type WireType = (typeof WIRE_TYPE)[keyof typeof WIRE_TYPE];
export type RoleType = (typeof ROLE)[keyof typeof ROLE]; export type RoleType = (typeof ROLE)[keyof typeof ROLE];
export type UnifiedModeType = (typeof UNIFIED_MODE)[keyof typeof UNIFIED_MODE]; export type UnifiedModeType = (typeof UNIFIED_MODE)[keyof typeof UNIFIED_MODE];
export type ThinkingLevelType = (typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL]; export type ThinkingLevelType = (typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL];
export type FieldNumber = (typeof FIELD)[keyof typeof FIELD];
/** Cursor credentials structure */ /** Cursor credentials structure */
export interface CursorCredentials { export interface CursorCredentials {
+25 -25
View File
@@ -82,55 +82,55 @@ export function encodeRequest(
// Build arrays for messages and tools // Build arrays for messages and tools
const messageFields = formattedMessages.map((fm) => const messageFields = formattedMessages.map((fm) =>
encodeField( encodeField(
FIELD.MESSAGES, FIELD.Chat.MESSAGES,
WIRE_TYPE.LEN, WIRE_TYPE.LEN,
encodeMessage(fm.content, fm.role, fm.messageId, fm.isLast, fm.hasTools, fm.toolResults) encodeMessage(fm.content, fm.role, fm.messageId, fm.isLast, fm.hasTools, fm.toolResults)
) )
); );
const messageIdFields = messageIds.map((mid) => const messageIdFields = messageIds.map((mid) =>
encodeField(FIELD.MESSAGE_IDS, WIRE_TYPE.LEN, encodeMessageId(mid.messageId, mid.role)) encodeField(FIELD.Chat.MESSAGE_IDS, WIRE_TYPE.LEN, encodeMessageId(mid.messageId, mid.role))
); );
const toolFields = const toolFields =
tools?.length > 0 tools?.length > 0
? tools.map((tool) => encodeField(FIELD.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool))) ? tools.map((tool) => encodeField(FIELD.Chat.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool)))
: []; : [];
const supportedToolsField = isAgentic const supportedToolsField = isAgentic
? [encodeField(FIELD.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] ? [encodeField(FIELD.Chat.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))]
: []; : [];
// Concatenate all parts // Concatenate all parts
const parts: Uint8Array[] = [ const parts: Uint8Array[] = [
...messageFields, ...messageFields,
encodeField(FIELD.UNKNOWN_2, WIRE_TYPE.VARINT, 1), encodeField(FIELD.Chat.UNKNOWN_2, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction('')), encodeField(FIELD.Chat.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction('')),
encodeField(FIELD.UNKNOWN_4, WIRE_TYPE.VARINT, 1), encodeField(FIELD.Chat.UNKNOWN_4, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)), encodeField(FIELD.Chat.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)),
encodeField(FIELD.WEB_TOOL, WIRE_TYPE.LEN, ''), encodeField(FIELD.Chat.WEB_TOOL, WIRE_TYPE.LEN, ''),
encodeField(FIELD.UNKNOWN_13, WIRE_TYPE.VARINT, 1), encodeField(FIELD.Chat.UNKNOWN_13, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()), encodeField(FIELD.Chat.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()),
encodeField(FIELD.UNKNOWN_19, WIRE_TYPE.VARINT, 1), encodeField(FIELD.Chat.UNKNOWN_19, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.CONVERSATION_ID, WIRE_TYPE.LEN, randomUUID()), encodeField(FIELD.Chat.CONVERSATION_ID, WIRE_TYPE.LEN, randomUUID()),
encodeField(FIELD.METADATA, WIRE_TYPE.LEN, encodeMetadata()), encodeField(FIELD.Chat.METADATA, WIRE_TYPE.LEN, encodeMetadata()),
encodeField(FIELD.IS_AGENTIC, WIRE_TYPE.VARINT, isAgentic ? 1 : 0), encodeField(FIELD.Chat.IS_AGENTIC, WIRE_TYPE.VARINT, isAgentic ? 1 : 0),
...supportedToolsField, ...supportedToolsField,
...messageIdFields, ...messageIdFields,
...toolFields, ...toolFields,
encodeField(FIELD.LARGE_CONTEXT, WIRE_TYPE.VARINT, 0), encodeField(FIELD.Chat.LARGE_CONTEXT, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.UNKNOWN_38, WIRE_TYPE.VARINT, 0), encodeField(FIELD.Chat.UNKNOWN_38, WIRE_TYPE.VARINT, 0),
encodeField( encodeField(
FIELD.UNIFIED_MODE, FIELD.Chat.UNIFIED_MODE,
WIRE_TYPE.VARINT, WIRE_TYPE.VARINT,
isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT
), ),
encodeField(FIELD.UNKNOWN_47, WIRE_TYPE.LEN, ''), encodeField(FIELD.Chat.UNKNOWN_47, WIRE_TYPE.LEN, ''),
encodeField(FIELD.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1), encodeField(FIELD.Chat.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1),
encodeField(FIELD.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel), encodeField(FIELD.Chat.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel),
encodeField(FIELD.UNKNOWN_51, WIRE_TYPE.VARINT, 0), encodeField(FIELD.Chat.UNKNOWN_51, WIRE_TYPE.VARINT, 0),
encodeField(FIELD.UNKNOWN_53, WIRE_TYPE.VARINT, 1), encodeField(FIELD.Chat.UNKNOWN_53, WIRE_TYPE.VARINT, 1),
encodeField(FIELD.UNIFIED_MODE_NAME, WIRE_TYPE.LEN, isAgentic ? 'Agent' : 'Ask'), encodeField(FIELD.Chat.UNIFIED_MODE_NAME, WIRE_TYPE.LEN, isAgentic ? 'Agent' : 'Ask'),
]; ];
return concatArrays(...parts); return concatArrays(...parts);
@@ -146,7 +146,7 @@ export function buildChatRequest(
reasoningEffort: string | null = null reasoningEffort: string | null = null
): Uint8Array { ): Uint8Array {
return encodeField( return encodeField(
FIELD.REQUEST, FIELD.Request.REQUEST,
WIRE_TYPE.LEN, WIRE_TYPE.LEN,
encodeRequest(messages, modelName, tools, reasoningEffort) encodeRequest(messages, modelName, tools, reasoningEffort)
); );
+136
View File
@@ -0,0 +1,136 @@
/**
* Cursor Streaming Frame Parser
* Incrementally parses ConnectRPC frames from arbitrary chunk boundaries
*/
import * as zlib from 'zlib';
import { COMPRESS_FLAG } from './cursor-protobuf-schema.js';
import { extractTextFromResponse } from './cursor-protobuf-decoder.js';
/** Frame parsing result types */
export type FrameResult =
| { type: 'error'; message: string; status: number; errorType: string }
| { type: 'text'; text: string }
| { type: 'thinking'; text: string }
| {
type: 'toolCall';
toolCall: {
id: string;
type: string;
function: { name: string; arguments: string };
isLast: boolean;
};
};
/**
* Decompress payload if gzip-compressed.
* Skips decompression for JSON error payloads.
* NOTE: Uses synchronous gzip for single-request CLI tool. Async not warranted for small payloads.
*/
export function decompressPayload(payload: Buffer, flags: number): Buffer {
if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) {
try {
const text = payload.toString('utf-8');
if (text.startsWith('{"error"')) return payload;
} catch {
// Not JSON, continue
}
}
if (
flags === COMPRESS_FLAG.GZIP ||
flags === COMPRESS_FLAG.GZIP_ALT ||
flags === COMPRESS_FLAG.GZIP_BOTH
) {
try {
return zlib.gunzipSync(payload);
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[cursor] gzip decompression failed:', err);
}
return Buffer.alloc(0);
}
}
return payload;
}
/**
* Incrementally parses ConnectRPC frames from arbitrary chunk boundaries.
*
* Usage:
* const parser = new StreamingFrameParser();
* req.on('data', (chunk) => {
* for (const frame of parser.push(chunk)) { handle(frame); }
* });
*/
export class StreamingFrameParser {
private buffer: Buffer = Buffer.alloc(0);
/** Feed a new chunk. Returns zero or more parsed frames. */
push(chunk: Buffer): FrameResult[] {
this.buffer = Buffer.concat([this.buffer, chunk]);
const results: FrameResult[] = [];
while (this.buffer.length >= 5) {
const length = this.buffer.readUInt32BE(1);
const frameSize = 5 + length;
if (this.buffer.length < frameSize) break;
const flags = this.buffer[0];
let payload = this.buffer.slice(5, frameSize);
this.buffer = this.buffer.slice(frameSize);
payload = decompressPayload(payload, flags);
// Check for JSON error
try {
const text = payload.toString('utf-8');
if (text.startsWith('{') && text.includes('"error"')) {
const json = JSON.parse(text);
const msg =
json?.error?.details?.[0]?.debug?.details?.title ||
json?.error?.details?.[0]?.debug?.details?.detail ||
json?.error?.message ||
'API Error';
const isRateLimit = json?.error?.code === 'resource_exhausted';
results.push({
type: 'error',
message: msg,
status: isRateLimit ? 429 : 400,
errorType: isRateLimit ? 'rate_limit_error' : 'api_error',
});
return results;
}
} catch {
// Not JSON, continue to protobuf parsing
}
const result = extractTextFromResponse(new Uint8Array(payload));
if (result.error) {
const errorLower = result.error.toLowerCase();
const isRateLimit =
errorLower.includes('rate limit') ||
errorLower.includes('resource_exhausted') ||
errorLower.includes('too many requests');
results.push({
type: 'error',
message: result.error,
status: isRateLimit ? 429 : 400,
errorType: isRateLimit ? 'rate_limit_error' : 'server_error',
});
return results;
}
if (result.toolCall) results.push({ type: 'toolCall', toolCall: result.toolCall });
if (result.text) results.push({ type: 'text', text: result.text });
if (result.thinking) results.push({ type: 'thinking', text: result.thinking });
}
return results;
}
hasPartial(): boolean {
return this.buffer.length > 0;
}
}
+192 -6
View File
@@ -19,6 +19,7 @@ import { buildCursorRequest } from '../../../src/cursor/cursor-translator';
import { generateCursorBody } from '../../../src/cursor/cursor-protobuf'; import { generateCursorBody } from '../../../src/cursor/cursor-protobuf';
import { CursorExecutor } from '../../../src/cursor/cursor-executor'; import { CursorExecutor } from '../../../src/cursor/cursor-executor';
import { WIRE_TYPE, FIELD } from '../../../src/cursor/cursor-protobuf-schema'; import { WIRE_TYPE, FIELD } from '../../../src/cursor/cursor-protobuf-schema';
import { StreamingFrameParser, decompressPayload } from '../../../src/cursor/cursor-stream-parser';
describe('Protobuf Encoding/Decoding', () => { describe('Protobuf Encoding/Decoding', () => {
describe('encodeVarint / decodeVarint round-trip', () => { describe('encodeVarint / decodeVarint round-trip', () => {
@@ -372,11 +373,11 @@ describe('Request Encoding', () => {
// Create two simple frames // Create two simple frames
const frame1 = wrapConnectRPCFrame( const frame1 = wrapConnectRPCFrame(
encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, 'Frame 1'), encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, 'Frame 1'),
false false
); );
const frame2 = wrapConnectRPCFrame( const frame2 = wrapConnectRPCFrame(
encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, ' Frame 2'), encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, ' Frame 2'),
false false
); );
@@ -497,8 +498,8 @@ describe('CursorExecutor', () => {
it('should handle basic text response', async () => { it('should handle basic text response', async () => {
// Create minimal protobuf response with text // Create minimal protobuf response with text
const textContent = 'Hello, world!'; const textContent = 'Hello, world!';
const responseField = encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, textContent); const responseField = encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, textContent);
const responseMsg = encodeField(FIELD.RESPONSE, WIRE_TYPE.LEN, responseField); const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField);
const frame = wrapConnectRPCFrame(responseMsg, false); const frame = wrapConnectRPCFrame(responseMsg, false);
const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', { const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', {
@@ -536,8 +537,8 @@ describe('CursorExecutor', () => {
it('should output SSE format', async () => { it('should output SSE format', async () => {
// Create minimal protobuf response with text // Create minimal protobuf response with text
const textContent = 'Hello'; const textContent = 'Hello';
const responseField = encodeField(FIELD.RESPONSE_TEXT, WIRE_TYPE.LEN, textContent); const responseField = encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, textContent);
const responseMsg = encodeField(FIELD.RESPONSE, WIRE_TYPE.LEN, responseField); const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField);
const frame = wrapConnectRPCFrame(responseMsg, false); const frame = wrapConnectRPCFrame(responseMsg, false);
const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', { const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', {
@@ -655,3 +656,188 @@ describe('CursorExecutor', () => {
}); });
}); });
}); });
/**
* 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);
}
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 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);
});
});
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 return empty buffer on invalid gzip data', () => {
const invalidGzip = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05]);
const result = decompressPayload(invalidGzip, 0x01);
expect(result.length).toBe(0);
});
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 handle GZIP_ALT and GZIP_BOTH flags', () => {
const zlib = require('zlib');
const original = Buffer.from('test data');
const compressed = zlib.gzipSync(original);
expect(decompressPayload(compressed, 0x02).toString()).toBe('test data');
expect(decompressPayload(compressed, 0x03).toString()).toBe('test data');
});
});