mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 10:17:05 +00:00
HIGH PRIORITY FIXES: - Extract shared buffer parsing logic into parseProtobufFrames generator method (DRY violation fix) - both JSON and SSE transformers now use common frame parsing loop, eliminating ~60% code duplication - Use COMPRESS_FLAG constants instead of hardcoded 0x01/0x02/0x03 in parseConnectRPCFrame for better maintainability MEDIUM PRIORITY FIXES: - Return empty buffer on gzip decompression failure (prevents silent data corruption) - ALREADY FIXED - Add debug warning for unknown message roles in convertMessages - Create GitHub issue #535 for FIELD namespace refactoring follow-up - Add test coverage: transformProtobufToSSE, error paths, unknown roles LOW PRIORITY FIXES: - Rename checksum test to clarify timestamp granularity (~16 min) - Fix debug log function name in SSE transformer - FIXED BY REFACTOR - Add comment to TOOL_RESULT_RESULT field documenting future use All tests pass (1593 pass, 0 fail) All validation checks pass (typecheck + lint + format + tests)
337 lines
9.2 KiB
TypeScript
337 lines
9.2 KiB
TypeScript
/**
|
|
* Cursor Protobuf Decoder
|
|
* Implements ConnectRPC protobuf wire format decoding
|
|
*/
|
|
|
|
import * as zlib from 'zlib';
|
|
import { WIRE_TYPE, FIELD, COMPRESS_FLAG, type WireType } from './cursor-protobuf-schema.js';
|
|
|
|
/**
|
|
* Decode a varint from buffer
|
|
* Returns [value, newOffset]
|
|
*/
|
|
export function decodeVarint(buffer: Uint8Array, offset: number): [number, number] {
|
|
let result = 0;
|
|
let shift = 0;
|
|
let pos = offset;
|
|
const maxBytes = 5;
|
|
|
|
while (pos < buffer.length && pos - offset < maxBytes) {
|
|
const b = buffer[pos];
|
|
result |= (b & 0x7f) << shift;
|
|
pos++;
|
|
if (!(b & 0x80)) break;
|
|
shift += 7;
|
|
}
|
|
|
|
return [result >>> 0, pos]; // Ensure unsigned
|
|
}
|
|
|
|
/**
|
|
* Decode a single protobuf field
|
|
* Returns [fieldNum, wireType, value, newOffset]
|
|
*/
|
|
export function decodeField(
|
|
buffer: Uint8Array,
|
|
offset: number
|
|
): [number | null, WireType | null, Uint8Array | number | null, number] {
|
|
if (offset >= buffer.length) {
|
|
return [null, null, null, offset];
|
|
}
|
|
|
|
const [tag, pos1] = decodeVarint(buffer, offset);
|
|
const fieldNum = tag >> 3;
|
|
const wireType = (tag & 0x07) as WireType;
|
|
|
|
let value: Uint8Array | number | null;
|
|
let pos = pos1;
|
|
|
|
if (wireType === WIRE_TYPE.VARINT) {
|
|
[value, pos] = decodeVarint(buffer, pos);
|
|
} else if (wireType === WIRE_TYPE.LEN) {
|
|
const [length, pos2] = decodeVarint(buffer, pos);
|
|
if (pos2 + length > buffer.length) {
|
|
return [null, null, null, buffer.length];
|
|
}
|
|
value = buffer.slice(pos2, pos2 + length);
|
|
pos = pos2 + length;
|
|
} else if (wireType === WIRE_TYPE.FIXED64) {
|
|
if (pos + 8 > buffer.length) {
|
|
return [null, null, null, buffer.length];
|
|
}
|
|
value = buffer.slice(pos, pos + 8);
|
|
pos += 8;
|
|
} else if (wireType === WIRE_TYPE.FIXED32) {
|
|
if (pos + 4 > buffer.length) {
|
|
return [null, null, null, buffer.length];
|
|
}
|
|
value = buffer.slice(pos, pos + 4);
|
|
pos += 4;
|
|
} else {
|
|
value = null;
|
|
}
|
|
|
|
return [fieldNum, wireType, value, pos];
|
|
}
|
|
|
|
/**
|
|
* Decode a protobuf message into a map of fields
|
|
*/
|
|
export function decodeMessage(
|
|
data: Uint8Array
|
|
): Map<number, Array<{ wireType: WireType; value: Uint8Array | number }>> {
|
|
const fields = new Map<number, Array<{ wireType: WireType; value: Uint8Array | number }>>();
|
|
let pos = 0;
|
|
|
|
// NOTE: If two fields share the same field number but different wire types, later values overwrite earlier ones.
|
|
while (pos < data.length) {
|
|
const [fieldNum, wireType, value, newPos] = decodeField(data, pos);
|
|
if (fieldNum === null || wireType === null || value === null) break;
|
|
|
|
if (!fields.has(fieldNum)) {
|
|
fields.set(fieldNum, []);
|
|
}
|
|
const fieldArray = fields.get(fieldNum);
|
|
if (fieldArray) {
|
|
fieldArray.push({ wireType, value: value as Uint8Array | number });
|
|
}
|
|
pos = newPos;
|
|
}
|
|
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* Parse ConnectRPC frame from buffer
|
|
* Returns frame data or null if incomplete
|
|
*/
|
|
export function parseConnectRPCFrame(buffer: Buffer): {
|
|
flags: number;
|
|
length: number;
|
|
payload: Uint8Array;
|
|
consumed: number;
|
|
} | null {
|
|
if (buffer.length < 5) return null;
|
|
|
|
const flags = buffer[0];
|
|
const length = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4];
|
|
|
|
if (buffer.length < 5 + length) return null;
|
|
|
|
let payload = buffer.slice(5, 5 + length);
|
|
|
|
// Decompress if gzip
|
|
if (
|
|
flags === COMPRESS_FLAG.GZIP ||
|
|
flags === COMPRESS_FLAG.GZIP_ALT ||
|
|
flags === COMPRESS_FLAG.GZIP_BOTH
|
|
) {
|
|
try {
|
|
payload = Buffer.from(zlib.gunzipSync(payload));
|
|
} catch (err) {
|
|
if (process.env.CCS_DEBUG) {
|
|
console.error('[cursor] parseConnectRPCFrame decompression failed:', err);
|
|
}
|
|
// Decompression failed, use raw payload
|
|
}
|
|
}
|
|
|
|
return {
|
|
flags,
|
|
length,
|
|
payload: new Uint8Array(payload),
|
|
consumed: 5 + length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract tool call from protobuf data
|
|
*/
|
|
function extractToolCall(toolCallData: Uint8Array): {
|
|
id: string;
|
|
type: string;
|
|
function: { name: string; arguments: string };
|
|
isLast: boolean;
|
|
} | null {
|
|
const toolCall = decodeMessage(toolCallData);
|
|
let toolCallId = '';
|
|
let toolName = '';
|
|
let rawArgs = '';
|
|
let isLast = false;
|
|
|
|
// Extract tool call ID
|
|
if (toolCall.has(FIELD.TOOL_ID)) {
|
|
const idField = toolCall.get(FIELD.TOOL_ID);
|
|
if (idField && idField[0]) {
|
|
const fullId = new TextDecoder().decode(idField[0].value as Uint8Array);
|
|
toolCallId = fullId.split('\n')[0]; // Take first line
|
|
}
|
|
}
|
|
|
|
// Extract tool name
|
|
if (toolCall.has(FIELD.TOOL_NAME)) {
|
|
const nameField = toolCall.get(FIELD.TOOL_NAME);
|
|
if (nameField && nameField[0]) {
|
|
toolName = new TextDecoder().decode(nameField[0].value as Uint8Array);
|
|
}
|
|
}
|
|
|
|
// Extract is_last flag
|
|
if (toolCall.has(FIELD.TOOL_IS_LAST)) {
|
|
const lastField = toolCall.get(FIELD.TOOL_IS_LAST);
|
|
if (lastField && lastField[0]) {
|
|
isLast = (lastField[0].value as number) !== 0;
|
|
}
|
|
}
|
|
|
|
// Extract MCP params - nested real tool info
|
|
if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) {
|
|
try {
|
|
const mcpField = toolCall.get(FIELD.TOOL_MCP_PARAMS);
|
|
if (!mcpField || !mcpField[0]) return null;
|
|
|
|
const mcpParams = decodeMessage(mcpField[0].value as Uint8Array);
|
|
|
|
if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) {
|
|
const toolsList = mcpParams.get(FIELD.MCP_TOOLS_LIST);
|
|
if (!toolsList || !toolsList[0]) return null;
|
|
|
|
const tool = decodeMessage(toolsList[0].value as Uint8Array);
|
|
|
|
if (tool.has(FIELD.MCP_NESTED_NAME)) {
|
|
const nestedName = tool.get(FIELD.MCP_NESTED_NAME);
|
|
if (nestedName && nestedName[0]) {
|
|
toolName = new TextDecoder().decode(nestedName[0].value as Uint8Array);
|
|
}
|
|
}
|
|
|
|
if (tool.has(FIELD.MCP_NESTED_PARAMS)) {
|
|
const nestedParams = tool.get(FIELD.MCP_NESTED_PARAMS);
|
|
if (nestedParams && nestedParams[0]) {
|
|
rawArgs = new TextDecoder().decode(nestedParams[0].value as Uint8Array);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (process.env.CCS_DEBUG) {
|
|
console.error('[cursor] extractToolCall MCP parsing failed:', err);
|
|
}
|
|
// MCP parse error, continue
|
|
}
|
|
}
|
|
|
|
// Fallback to raw_args
|
|
if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) {
|
|
const rawArgsField = toolCall.get(FIELD.TOOL_RAW_ARGS);
|
|
if (rawArgsField && rawArgsField[0]) {
|
|
rawArgs = new TextDecoder().decode(rawArgsField[0].value as Uint8Array);
|
|
}
|
|
}
|
|
|
|
if (toolCallId && toolName) {
|
|
return {
|
|
id: toolCallId,
|
|
type: 'function',
|
|
function: {
|
|
name: toolName,
|
|
arguments: rawArgs || '{}',
|
|
},
|
|
isLast,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract text and thinking from response data
|
|
*/
|
|
function extractTextAndThinking(responseData: Uint8Array): {
|
|
text: string | null;
|
|
thinking: string | null;
|
|
} {
|
|
const nested = decodeMessage(responseData);
|
|
let text: string | null = null;
|
|
let thinking: string | null = null;
|
|
|
|
// Extract text
|
|
if (nested.has(FIELD.RESPONSE_TEXT)) {
|
|
const textField = nested.get(FIELD.RESPONSE_TEXT);
|
|
if (textField && textField[0]) {
|
|
text = new TextDecoder().decode(textField[0].value as Uint8Array);
|
|
}
|
|
}
|
|
|
|
// Extract thinking
|
|
if (nested.has(FIELD.THINKING)) {
|
|
try {
|
|
const thinkingField = nested.get(FIELD.THINKING);
|
|
if (thinkingField && thinkingField[0]) {
|
|
const thinkingMsg = decodeMessage(thinkingField[0].value as Uint8Array);
|
|
if (thinkingMsg.has(FIELD.THINKING_TEXT)) {
|
|
const thinkingTextField = thinkingMsg.get(FIELD.THINKING_TEXT);
|
|
if (thinkingTextField && thinkingTextField[0]) {
|
|
thinking = new TextDecoder().decode(thinkingTextField[0].value as Uint8Array);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (process.env.CCS_DEBUG) {
|
|
console.error('[cursor] extractTextAndThinking parsing failed:', err);
|
|
}
|
|
// Thinking parse error, continue
|
|
}
|
|
}
|
|
|
|
return { text, thinking };
|
|
}
|
|
|
|
/**
|
|
* Extract text and tool calls from response payload
|
|
*/
|
|
export function extractTextFromResponse(payload: Uint8Array): {
|
|
text: string | null;
|
|
error: string | null;
|
|
toolCall: {
|
|
id: string;
|
|
type: string;
|
|
function: { name: string; arguments: string };
|
|
isLast: boolean;
|
|
} | null;
|
|
thinking: string | null;
|
|
} {
|
|
try {
|
|
const fields = decodeMessage(payload);
|
|
|
|
// Field 1: ClientSideToolV2Call
|
|
if (fields.has(FIELD.TOOL_CALL)) {
|
|
const toolCallField = fields.get(FIELD.TOOL_CALL);
|
|
if (toolCallField && toolCallField[0]) {
|
|
const toolCall = extractToolCall(toolCallField[0].value as Uint8Array);
|
|
if (toolCall) {
|
|
return { text: null, error: null, toolCall, thinking: null };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Field 2: StreamUnifiedChatResponse
|
|
if (fields.has(FIELD.RESPONSE)) {
|
|
const responseField = fields.get(FIELD.RESPONSE);
|
|
if (responseField && responseField[0]) {
|
|
const { text, thinking } = extractTextAndThinking(responseField[0].value as Uint8Array);
|
|
|
|
if (text || thinking) {
|
|
return { text, error: null, toolCall: null, thinking };
|
|
}
|
|
}
|
|
}
|
|
|
|
return { text: null, error: null, toolCall: null, thinking: null };
|
|
} catch (err) {
|
|
if (process.env.CCS_DEBUG) {
|
|
console.error('[cursor] extractTextFromResponse parsing failed:', err);
|
|
}
|
|
return { text: null, error: null, toolCall: null, thinking: null };
|
|
}
|
|
}
|