From 0d1e6685f678d0f5e37c2ae2a8576f5df4429db2 Mon Sep 17 00:00:00 2001 From: Rafael Calleja Date: Tue, 10 Mar 2026 07:13:47 +0100 Subject: [PATCH 1/3] chore(wip): snapshot working Cursor tool-result flow Save the current functioning state that stops the protobuf wire-type crash and tool-call loop while we do a follow-up cleanup pass. Made-with: Cursor --- bug_index.md | 119 ++++++++ open-sse/executors/cursor.ts | 91 +++++- .../translator/request/openai-to-cursor.ts | 191 +++++++++---- open-sse/utils/cursorProtobuf.ts | 269 +++++++++++++++++- 4 files changed, 596 insertions(+), 74 deletions(-) create mode 100644 bug_index.md diff --git a/bug_index.md b/bug_index.md new file mode 100644 index 0000000000..3200389d1d --- /dev/null +++ b/bug_index.md @@ -0,0 +1,119 @@ +# Bug: Wire Type 4 Protobuf Error on Cursor API + +## Symptom + +- First request to Cursor (3 msgs, 34 tools) → WORKS +- Second request (5 msgs, 34 tools, ~140KB) → FAILS with "wire type 4" protobuf parse error +- Error comes FROM Cursor's server rejecting our encoded protobuf request + +## What "wire type 4" means + +- Valid protobuf wire types: 0=VARINT, 1=FIXED64, 2=LEN, 5=FIXED32 +- Wire type 4 = "end group" (deprecated, never valid in modern protobuf) +- This means **corrupted protobuf data** — Cursor server reads data bytes as tag bytes +- Root cause: a LENGTH-DELIMITED field has wrong length prefix, causing parse misalignment + +## Data Flow + +``` +Client (Claude format, 5 msgs) + → chatCore.translateRequest() + → claude-to-openai.ts (claudeToOpenAIRequest) + → openai-to-cursor.ts (buildCursorRequest) + → cursor.ts transformRequest() + → cursorProtobuf.ts generateCursorBody() → protobuf binary + → HTTP to Cursor API → "wire type 4" error +``` + +## Key Files + +- `open-sse/translator/request/claude-to-openai.ts` — Claude→OpenAI message conversion +- `open-sse/translator/request/openai-to-cursor.ts` — OpenAI→Cursor message conversion +- `open-sse/utils/cursorProtobuf.ts` — Protobuf binary encoder +- `open-sse/executors/cursor.ts` — Cursor executor (HTTP + protobuf framing) + +## Investigation Done (Session 1) + +1. **Content type is NOT the issue** — `encodeField()` handles undefined/null/array gracefully (→ empty `0a 00`) +2. **openai-to-cursor always normalizes content to string** (lines 77-87) +3. **tool_calls on assistant msgs are silently ignored** by protobuf encoder (no encoding logic for them) +4. **Debug logging was added** to `encodeRequest()` and `encodeMessage()` to trace message shapes +5. **All basic varint/field encoding tests pass** — no corruption in primitives + +## Investigation Done (Session 2 — current) + +6. **Round-trip test PASSES** — a synthetic 4-msg + 34-tools + tool_results request encodes and decodes back perfectly +7. **Large payload test PASSES** — 100KB tool result content, frame length matches perfectly (106,810 bytes) +8. **Varint encoding tests PASS** — values up to 268M encode/decode correctly +9. **Null bytes in content work** — no corruption +10. **Missing content on assistant msgs works** — produces valid empty field + +## CRITICAL CONCLUSION + +The encoding logic itself is **correct for all tested patterns**. The bug is NOT in the encoder primitives. +The issue MUST be in one of: + +### HIGH PRIORITY — What to investigate next + +#### H1: The REAL production data has something we haven't tested + +The user mentioned the log was already provided in a previous session. The 140,267 byte request +with 34 tools and 5 messages has specific data that triggers the bug. We need to: + +- **Capture the exact translated messages** before protobuf encoding (add JSON.stringify dump) +- **Capture the hex dump** of the protobuf output +- **Send the hex dump to a protobuf decoder** to find where parsing breaks + +#### H2: assistant message with tool_calls AND content=undefined + +In openai-to-cursor.ts line 96-97, when assistant has tool_calls but empty content: + +```js +const assistantMsg = { role: "assistant" }; // NO content property +if (content) { + assistantMsg.content = content; +} // content="" is falsy → skipped +``` + +Result: `msg.content` is `undefined`. In `encodeMessage`, this produces `encodeField(1, LEN, undefined)` → `0a 00`. +This is technically valid protobuf (empty string field), BUT Cursor's server might require the content field to be present as a non-empty string for assistant messages. The FIRST request (3 msgs) might not have an assistant message at all, while the SECOND (5 msgs) does. + +**FIX TO TRY**: Always set `content: ""` on assistant messages in openai-to-cursor.ts. + +#### H3: tool_calls being passed to protobuf encoder as stray properties + +The Cursor message in the translated body has `{ role, content, tool_calls, tool_results }`. +The protobuf encoder only uses `content`, `role`, and `tool_results`. The `tool_calls` array +is silently ignored. BUT if Cursor's real protocol expects tool calls to be encoded differently +(maybe as part of the content or as a separate protobuf field), we might be sending incomplete data. + +#### H4: Encoding `encodeVarint` with value from `>>>` unsigned shift on large numbers + +`encodeVarint` uses `value >>>= 7` which treats value as unsigned 32-bit. If a tag or length +exceeds 2^31, the unsigned shift behavior could produce wrong bytes. However, tests show this +works correctly up to 268M, and our payloads are ~140KB, so this is unlikely. + +## Recommended Fix (try first — LOW RISK) + +In `openai-to-cursor.ts`, always ensure assistant messages have `content: ""`: + +```js +// Line 96-99, change from: +const assistantMsg = { role: "assistant" }; +if (content) { + assistantMsg.content = content; +} + +// To: +const assistantMsg = { role: "assistant", content: content || "" }; +``` + +## How to debug further + +Add this to `cursor.ts` `transformRequest()` BEFORE `generateCursorBody()`: + +```js +console.log("[CURSOR_DEBUG] Messages to encode:", JSON.stringify(messages, null, 2).slice(0, 2000)); +``` + +Then reproduce the error and check logs for the exact message shapes. diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index cad6ba982d..4f3e619ec7 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -230,10 +230,10 @@ export class CursorExecutor extends BaseExecutor { } transformRequest(model, body, stream, credentials) { - // Call translator to convert OpenAI format to Cursor format - const translatedBody = buildCursorRequest(model, body, stream, credentials); - const messages = translatedBody.messages || []; - const tools = translatedBody.tools || body.tools || []; + // Messages are already translated by chatCore (claude→openai→cursor) + // Do NOT call buildCursorRequest again — double-translation drops tool_results + const messages = body.messages || []; + const tools = body.tools || []; const reasoningEffort = body.reasoning_effort || null; return generateCursorBody(messages, model, tools, reasoningEffort); } @@ -415,9 +415,18 @@ export class CursorExecutor extends BaseExecutor { continue; } + // Check for JSON error frames try { const text = payload.toString("utf-8"); if (text.startsWith("{") && text.includes('"error"')) { + const hasContent = totalContent || toolCallsMap.size > 0; + console.log( + `[CURSOR BUFFER] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}` + ); + // If we already have content, treat error as stream termination (not fatal) + if (hasContent) { + break; + } return createErrorResponse(JSON.parse(text)); } } catch {} @@ -426,6 +435,12 @@ export class CursorExecutor extends BaseExecutor { console.log(`[CURSOR DECODED] Frame ${frameCount}:`, result); if (result.error) { + const hasContent = totalContent || toolCallsMap.size > 0; + console.log(`[CURSOR BUFFER] Decoded error (hasContent=${hasContent}): ${result.error}`); + // If we already have content, treat error as stream termination + if (hasContent) { + break; + } return new Response( JSON.stringify({ error: { @@ -570,9 +585,19 @@ export class CursorExecutor extends BaseExecutor { continue; } + // Check for JSON error frames try { const text = payload.toString("utf-8"); if (text.startsWith("{") && text.includes('"error"')) { + const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0; + // Log the full error for debugging + console.log( + `[CURSOR BUFFER SSE] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}` + ); + // If we already have content, treat error as stream termination (not fatal) + if (hasContent) { + break; + } return createErrorResponse(JSON.parse(text)); } } catch {} @@ -581,6 +606,14 @@ export class CursorExecutor extends BaseExecutor { console.log(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result); if (result.error) { + const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0; + console.log( + `[CURSOR BUFFER SSE] Decoded error (hasContent=${hasContent}): ${result.error}` + ); + // If we already have content, treat error as stream termination + if (hasContent) { + break; + } return new Response( JSON.stringify({ error: { @@ -718,6 +751,56 @@ export class CursorExecutor extends BaseExecutor { `[CURSOR BUFFER SSE] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, toolCalls array: ${toolCalls.length}` ); + // Finalize all remaining tool calls in map (stream may have ended without isLast=true) + for (const [id, tc] of toolCallsMap.entries()) { + if (!toolCalls.find((t) => t.id === id)) { + console.log( + `[CURSOR BUFFER SSE] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}` + ); + const toolCallIndex = toolCalls.length; + toolCalls.push({ + id: tc.id, + type: tc.type, + index: toolCallIndex, + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + }); + + // Emit SSE chunk for the finalized tool call if not already emitted + if (!chunks.some((c) => c.includes(tc.id))) { + chunks.push( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: toolCallIndex, + id: tc.id, + type: "function", + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + }, + ], + }, + finish_reason: null, + }, + ], + })}\n\n` + ); + } + } + } + if (chunks.length === 0 && toolCalls.length === 0) { chunks.push( `data: ${JSON.stringify({ diff --git a/open-sse/translator/request/openai-to-cursor.ts b/open-sse/translator/request/openai-to-cursor.ts index afc0c0b100..345b427f60 100644 --- a/open-sse/translator/request/openai-to-cursor.ts +++ b/open-sse/translator/request/openai-to-cursor.ts @@ -1,19 +1,80 @@ /** * OpenAI to Cursor Request Translator - * Converts OpenAI messages to Cursor simple format + * Converts OpenAI messages to Cursor ask/agent format. + * + * Important: Cursor can loop when tool outputs are sent via protobuf tool_results + * with partial schema mismatches. For stability, tool outputs are represented as + * structured text blocks in user messages. */ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; -/** - * Convert OpenAI messages to Cursor format with native tool_results support - * - system → user with [System Instructions] prefix - * - tool → accumulate into tool_results array for next user/assistant message - * - assistant with tool_calls → keep tool_calls structure (Cursor supports it natively) - */ +type TextPart = { type?: string; text?: string }; +type ToolUsePart = { type?: string; id?: string; name?: string; input?: unknown }; +type ToolResultPart = { type?: string; tool_use_id?: string; content?: unknown }; + +function normalizeToolCallId(id: unknown): string { + return typeof id === "string" ? id.split("\n")[0] : ""; +} + +function extractContent(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((part): part is TextPart => { + if (!part || typeof part !== "object") return false; + const maybe = part as TextPart; + return maybe.type === "text" && typeof maybe.text === "string"; + }) + .map((part) => part.text as string) + .join(""); + } + return ""; +} + +function sanitizeToolResultText(text: string): string { + // Strip non-printable control chars that can produce backend request errors. + return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ""); +} + +function buildToolResultBlock(toolName: string, toolCallId: string, resultText: string): string { + const cleanResult = sanitizeToolResultText(resultText || ""); + return [ + "", + `${toolName || "tool"}`, + `${toolCallId || ""}`, + `${cleanResult}`, + "", + ].join("\n"); +} + function convertMessages(messages) { const result = []; - let pendingToolResults = []; + // Build a map of tool_call_id -> tool name from assistant tool calls. + const toolCallMetaMap = new Map(); + const rememberToolMeta = (toolCallId: string, toolName: string) => { + if (!toolCallId) return; + const name = toolName || "tool"; + toolCallMetaMap.set(toolCallId, { name }); + const normalized = normalizeToolCallId(toolCallId); + if (normalized && normalized !== toolCallId) { + toolCallMetaMap.set(normalized, { name }); + } + }; + + for (const msg of messages) { + if (msg.role === "assistant" && msg.tool_calls) { + for (const tc of msg.tool_calls) { + rememberToolMeta(tc.id || "", tc.function?.name || "tool"); + } + } + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const part of msg.content as ToolUsePart[]) { + if (part?.type !== "tool_use") continue; + rememberToolMeta(part.id || "", part.name || "tool"); + } + } + } for (let i = 0; i < messages.length; i++) { const msg = messages[i]; @@ -21,83 +82,89 @@ function convertMessages(messages) { if (msg.role === "system") { result.push({ role: "user", - content: `[System Instructions]\n${msg.content}`, + content: `[System Instructions]\n${extractContent(msg.content)}`, }); continue; } if (msg.role === "tool") { - let toolContent = ""; - if (typeof msg.content === "string") { - toolContent = msg.content; - } else if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === "text") { - toolContent += part.text; - } - } - } - - const toolName = msg.name || "tool"; + const toolContent = extractContent(msg.content); const toolCallId = msg.tool_call_id || ""; - - // Accumulate tool result - pendingToolResults.push({ - tool_call_id: toolCallId, - name: toolName, - index: pendingToolResults.length, - raw_args: toolContent, + const toolMeta = toolCallMetaMap.get(toolCallId) || {}; + const toolName = msg.name || toolMeta.name || "tool"; + result.push({ + role: "user", + content: buildToolResultBlock(toolName, toolCallId, toolContent), }); continue; } if (msg.role === "user" || msg.role === "assistant") { - let content = ""; - - if (typeof msg.content === "string") { - content = msg.content; - } else if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === "text") { - content += part.text; + if (msg.role === "user" && Array.isArray(msg.content)) { + const parts: string[] = []; + for (const block of msg.content as Array) { + if (!block || typeof block !== "object") continue; + if (block.type === "text") { + if (typeof (block as TextPart).text === "string") { + parts.push((block as TextPart).text || ""); + } + continue; + } + if (block.type === "tool_result") { + const tr = block as ToolResultPart; + const toolCallId = tr.tool_use_id || ""; + const toolMeta = + toolCallMetaMap.get(toolCallId) || + toolCallMetaMap.get(normalizeToolCallId(toolCallId)); + const toolName = toolMeta?.name || "tool"; + const toolContent = extractContent(tr.content); + parts.push(buildToolResultBlock(toolName, toolCallId, toolContent)); } } + const joined = parts.filter(Boolean).join("\n"); + if (joined) result.push({ role: "user", content: joined }); + continue; } - // Keep tool_calls structure for assistant messages + const content = extractContent(msg.content); + if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) { const assistantMsg: { role: string; content?: string; tool_calls?: unknown; - tool_results?: Array>; - } = { role: "assistant" }; - if (content) { - assistantMsg.content = content; - } - assistantMsg.tool_calls = msg.tool_calls; - - // Attach pending tool results to assistant message with tool_calls - if (pendingToolResults.length > 0) { - assistantMsg.tool_results = pendingToolResults; - pendingToolResults = []; - } - + } = { role: "assistant", content: content || "" }; + assistantMsg.tool_calls = msg.tool_calls.map((tc) => { + const { index, ...rest } = tc || {}; + return rest; + }); result.push(assistantMsg); - } else if (content || pendingToolResults.length > 0) { - const msgObj: { - role: string; - content: string; - tool_results?: Array>; - } = { role: msg.role, content: content || "" }; + } else if (msg.role === "assistant" && Array.isArray(msg.content)) { + const extractedToolCalls = (msg.content as ToolUsePart[]) + .filter((b) => b?.type === "tool_use") + .map((b) => ({ + id: b.id || "", + type: "function", + function: { + name: b.name || "tool", + arguments: JSON.stringify(b.input || {}), + }, + })) + .filter((tc) => tc.id); - // Attach pending tool results to this message - if (pendingToolResults.length > 0) { - msgObj.tool_results = pendingToolResults; - pendingToolResults = []; + if (extractedToolCalls.length > 0) { + result.push({ + role: "assistant", + content: content || "", + tool_calls: extractedToolCalls, + }); + } else if (content) { + result.push({ role: "assistant", content }); + } + } else { + if (content) { + result.push({ role: msg.role, content }); } - - result.push(msgObj); } } } diff --git a/open-sse/utils/cursorProtobuf.ts b/open-sse/utils/cursorProtobuf.ts index 39b78e2bf9..f593a6228c 100644 --- a/open-sse/utils/cursorProtobuf.ts +++ b/open-sse/utils/cursorProtobuf.ts @@ -28,6 +28,7 @@ const ROLE = { USER: 1, ASSISTANT: 2 }; const UNIFIED_MODE = { CHAT: 1, AGENT: 2 }; const THINKING_LEVEL = { UNSPECIFIED: 0, MEDIUM: 1, HIGH: 2 }; +const CLIENT_SIDE_TOOL_V2 = { MCP: 19 }; const FIELD = { // StreamUnifiedChatRequestWithTools (top level) @@ -65,6 +66,7 @@ const FIELD = { MSG_ID: 13, MSG_TOOL_RESULTS: 18, MSG_IS_AGENTIC: 29, + MSG_SERVER_BUBBLE_ID: 32, MSG_UNIFIED_MODE: 47, MSG_SUPPORTED_TOOLS: 51, @@ -74,6 +76,28 @@ const FIELD = { TOOL_RESULT_INDEX: 3, TOOL_RESULT_RAW_ARGS: 5, TOOL_RESULT_RESULT: 8, + TOOL_RESULT_TOOL_CALL: 11, + TOOL_RESULT_MODEL_CALL_ID: 12, + + // ClientSideToolV2Result (nested inside ToolResult.result) + CLIENT_RESULT_TOOL: 1, + CLIENT_RESULT_MCP_RESULT: 28, + CLIENT_RESULT_TOOL_CALL_ID: 35, + CLIENT_RESULT_MODEL_CALL_ID: 48, + CLIENT_RESULT_TOOL_INDEX: 49, + + // MCPResult (nested inside ClientSideToolV2Result.mcp_result) + MCP_RESULT_SELECTED_TOOL: 1, + MCP_RESULT_RESULT: 2, + + // ClientSideToolV2Call (nested inside ToolResult.tool_call) + CLIENT_CALL_TOOL: 1, + CLIENT_CALL_MCP_PARAMS: 27, + CLIENT_CALL_TOOL_CALL_ID: 3, + CLIENT_CALL_NAME: 9, + CLIENT_CALL_RAW_ARGS: 10, + CLIENT_CALL_TOOL_INDEX: 48, + CLIENT_CALL_MODEL_CALL_ID: 49, // Model MODEL_NAME: 1, @@ -120,6 +144,7 @@ const FIELD = { TOOL_NAME: 9, TOOL_RAW_ARGS: 10, TOOL_IS_LAST: 11, + TOOL_IS_LAST_ALT: 15, TOOL_MCP_PARAMS: 27, // MCPParams @@ -167,6 +192,13 @@ export function encodeField(fieldNum, wireType, value) { const tagBytes = encodeVarint(tag); if (wireType === WIRE_TYPE.VARINT) { + // Validate: VARINT value must be a number + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + log( + "ENCODE", + `⚠️ VARINT field=${fieldNum} invalid value: type=${typeof value}, value=${value}` + ); + } const valueBytes = encodeVarint(value); return concatArrays(tagBytes, valueBytes); } @@ -202,16 +234,149 @@ function concatArrays(...arrays) { // ==================== MESSAGE ENCODING ==================== export function encodeToolResult(toolResult) { - const toolCallId = toolResult.tool_call_id || ""; - const toolName = toolResult.name || ""; - const toolIndex = toolResult.index || 0; + const { toolCallId, modelCallId } = parseToolCallId(toolResult.tool_call_id || ""); + const rawToolName = toolResult.name || ""; + const toolName = formatCursorToolName(rawToolName); + const { selectedTool, serverName } = parseCursorToolName(toolName); + const toolIndex = toolResult.index > 0 ? toolResult.index : 1; const rawArgs = toolResult.raw_args || "{}"; + const resultContent = toolResult.result || ""; + const encodedResultMessage = encodeClientSideToolResult( + toolCallId, + modelCallId, + selectedTool, + toolIndex, + resultContent + ); + const encodedToolCallMessage = encodeClientSideToolCall( + toolCallId, + modelCallId, + toolName, + selectedTool, + serverName, + rawArgs, + toolIndex + ); return concatArrays( encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId), encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName), encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex), - encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs) + ...(modelCallId + ? [encodeField(FIELD.TOOL_RESULT_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] + : []), + encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs), + ...(encodedResultMessage + ? [encodeField(FIELD.TOOL_RESULT_RESULT, WIRE_TYPE.LEN, encodedResultMessage)] + : []), + encodeField(FIELD.TOOL_RESULT_TOOL_CALL, WIRE_TYPE.LEN, encodedToolCallMessage) + ); +} + +function parseToolCallId(toolCallIdRaw) { + if (typeof toolCallIdRaw !== "string" || toolCallIdRaw.length === 0) { + return { toolCallId: "", modelCallId: null }; + } + const delimiter = "\nmc_"; + const idx = toolCallIdRaw.indexOf(delimiter); + if (idx >= 0) { + return { + toolCallId: toolCallIdRaw.slice(0, idx), + modelCallId: toolCallIdRaw.slice(idx + delimiter.length), + }; + } + return { toolCallId: toolCallIdRaw, modelCallId: null }; +} + +function formatCursorToolName(rawName) { + const base = typeof rawName === "string" && rawName.length > 0 ? rawName : "tool"; + + if (base.startsWith("mcp__")) { + const rest = base.slice("mcp__".length); + const splitIdx = rest.indexOf("__"); + if (splitIdx >= 0) { + const server = rest.slice(0, splitIdx) || "custom"; + const name = rest.slice(splitIdx + 2) || "tool"; + return `mcp_${server}_${name}`; + } + return `mcp_custom_${rest || "tool"}`; + } + + if (base.startsWith("mcp_")) return base; + return `mcp_custom_${base}`; +} + +function parseCursorToolName(formattedName) { + if (typeof formattedName !== "string" || !formattedName.startsWith("mcp_")) { + return { serverName: "custom", selectedTool: formattedName || "tool" }; + } + + const tail = formattedName.slice("mcp_".length); + const splitIdx = tail.indexOf("_"); + if (splitIdx < 0) { + return { serverName: "custom", selectedTool: tail || "tool" }; + } + + return { + serverName: tail.slice(0, splitIdx) || "custom", + selectedTool: tail.slice(splitIdx + 1) || "tool", + }; +} + +function encodeClientSideToolResult(toolCallId, modelCallId, toolName, toolIndex, resultContent) { + const outputText = typeof resultContent === "string" ? resultContent : ""; + const selectedTool = typeof toolName === "string" && toolName.length > 0 ? toolName : "tool"; + + const mcpResult = concatArrays( + encodeField(FIELD.MCP_RESULT_SELECTED_TOOL, WIRE_TYPE.LEN, selectedTool), + encodeField(FIELD.MCP_RESULT_RESULT, WIRE_TYPE.LEN, outputText) + ); + + return concatArrays( + encodeField(FIELD.CLIENT_RESULT_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2.MCP), + encodeField(FIELD.CLIENT_RESULT_MCP_RESULT, WIRE_TYPE.LEN, mcpResult), + ...(toolCallId + ? [encodeField(FIELD.CLIENT_RESULT_TOOL_CALL_ID, WIRE_TYPE.LEN, toolCallId)] + : []), + ...(modelCallId + ? [encodeField(FIELD.CLIENT_RESULT_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] + : []), + encodeField(FIELD.CLIENT_RESULT_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex) + ); +} + +function encodeMcpParamsForCall(toolName, rawArgs, serverName) { + const tool = concatArrays( + encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName || "tool"), + encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, rawArgs || "{}"), + encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, serverName || "custom") + ); + return encodeField(FIELD.MCP_TOOLS_LIST, WIRE_TYPE.LEN, tool); +} + +function encodeClientSideToolCall( + toolCallId, + modelCallId, + toolName, + selectedTool, + serverName, + rawArgs, + toolIndex +) { + return concatArrays( + encodeField(FIELD.CLIENT_CALL_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2.MCP), + encodeField( + FIELD.CLIENT_CALL_MCP_PARAMS, + WIRE_TYPE.LEN, + encodeMcpParamsForCall(selectedTool, rawArgs, serverName) + ), + ...(toolCallId ? [encodeField(FIELD.CLIENT_CALL_TOOL_CALL_ID, WIRE_TYPE.LEN, toolCallId)] : []), + encodeField(FIELD.CLIENT_CALL_NAME, WIRE_TYPE.LEN, toolName || "tool"), + encodeField(FIELD.CLIENT_CALL_RAW_ARGS, WIRE_TYPE.LEN, rawArgs || "{}"), + encodeField(FIELD.CLIENT_CALL_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1), + ...(modelCallId + ? [encodeField(FIELD.CLIENT_CALL_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] + : []) ); } @@ -224,6 +389,20 @@ export function encodeMessage( hasTools = false, toolResults = [] ) { + // Debug: validate content type before encoding + if ( + content !== null && + content !== undefined && + typeof content !== "string" && + !(content instanceof Uint8Array) && + !Buffer.isBuffer(content) + ) { + log( + "ENCODE", + `⚠️ MSG_CONTENT unexpected type: ${typeof content}, isArray=${Array.isArray(content)}, value=${JSON.stringify(content).slice(0, 200)}` + ); + } + return concatArrays( encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content), encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role), @@ -311,13 +490,86 @@ export function encodeRequest(messages, modelName, tools = [], reasoningEffort = const isAgentic = hasTools; const formattedMessages = []; const messageIds = []; + const normalizedMessages = []; - // Prepare messages + // Guardrail: split mixed assistant payload into separate assistant messages. for (let i = 0; i < messages.length; i++) { const msg = messages[i]; + const hasToolCalls = Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0; + const hasToolResults = Array.isArray(msg?.tool_results) && msg.tool_results.length > 0; + + if (msg?.role === "assistant" && hasToolCalls && hasToolResults) { + log( + "ENCODE", + `normalizing mixed assistant tool payload at msg[${i}] (calls=${msg.tool_calls.length}, results=${msg.tool_results.length})` + ); + + // Keep assistant tool call message without embedded results + normalizedMessages.push({ + ...msg, + tool_results: [], + }); + + // Avoid inserting duplicate assistant tool-result message if next one already matches + const nextMsg = messages[i + 1]; + const nextHasToolResults = + nextMsg?.role === "assistant" && + Array.isArray(nextMsg?.tool_results) && + nextMsg.tool_results.length > 0; + const currentIds = new Set( + msg.tool_results.map((tr) => tr?.tool_call_id).filter((id) => typeof id === "string") + ); + const nextIds = new Set( + (nextMsg?.tool_results || []) + .map((tr) => tr?.tool_call_id) + .filter((id) => typeof id === "string") + ); + const sameIds = + currentIds.size > 0 && + currentIds.size === nextIds.size && + [...currentIds].every((id) => nextIds.has(id)); + + if (!(nextHasToolResults && sameIds)) { + normalizedMessages.push({ + role: "assistant", + content: "", + tool_results: msg.tool_results, + }); + } + + continue; + } + + normalizedMessages.push(msg); + } + + // Prepare messages + for (let i = 0; i < normalizedMessages.length; i++) { + const msg = normalizedMessages[i]; const role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT; const msgId = uuidv4(); - const isLast = i === messages.length - 1; + const isLast = i === normalizedMessages.length - 1; + + // Debug: log message shape for diagnosis + const toolResultsCount = Array.isArray(msg.tool_results) ? msg.tool_results.length : 0; + const contentStr = typeof msg.content === "string" ? msg.content : ""; + const contentPreview = contentStr + .slice(0, 120) + .replace(/\r/g, "\\r") + .replace(/\n/g, "\\n") + .replace(/[^\x20-\x7E]/g, "?"); + log( + "ENCODE", + `msg[${i}] role=${msg.role} contentType=${typeof msg.content} contentLen=${contentStr.length} contentPreview="${contentPreview}" contentIsArray=${Array.isArray(msg.content)} hasToolCalls=${Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0} hasToolResults=${toolResultsCount > 0} toolResultsCount=${toolResultsCount}` + ); + if (toolResultsCount > 0) { + for (const tr of msg.tool_results) { + log( + "ENCODE", + ` toolResult: callId=${tr.tool_call_id} name=${tr.name} rawArgsType=${typeof tr.raw_args} rawArgsLen=${tr.raw_args?.length || 0} resultType=${typeof tr.result} resultLen=${tr.result?.length || 0}` + ); + } + } formattedMessages.push({ content: msg.content, @@ -535,8 +787,7 @@ function extractToolCall(toolCallData) { // Extract tool call ID if (toolCall.has(FIELD.TOOL_ID)) { - const fullId = new TextDecoder().decode(toolCall.get(FIELD.TOOL_ID)[0].value); - toolCallId = fullId.split("\n")[0]; // Cursor returns multi-line ID, take first line + toolCallId = new TextDecoder().decode(toolCall.get(FIELD.TOOL_ID)[0].value); } // Extract tool name @@ -547,6 +798,8 @@ function extractToolCall(toolCallData) { // Extract is_last flag if (toolCall.has(FIELD.TOOL_IS_LAST)) { isLast = toolCall.get(FIELD.TOOL_IS_LAST)[0].value !== 0; + } else if (toolCall.has(FIELD.TOOL_IS_LAST_ALT)) { + isLast = toolCall.get(FIELD.TOOL_IS_LAST_ALT)[0].value !== 0; } // Extract MCP params - nested real tool info From 70008e67e46e1810f4796e0305a2b4d2374c7ee2 Mon Sep 17 00:00:00 2001 From: Rafael Calleja Date: Tue, 10 Mar 2026 07:16:13 +0100 Subject: [PATCH 2/3] refactor(cursor): remove debug artifacts after tool-result fix Keep the Cursor tool-result reliability changes while cleaning temporary investigation notes, noisy logs, and unused imports to reduce runtime log spam. Made-with: Cursor --- bug_index.md | 119 ------------------------------- open-sse/executors/cursor.ts | 62 ++++++++-------- open-sse/utils/cursorProtobuf.ts | 45 +----------- 3 files changed, 31 insertions(+), 195 deletions(-) delete mode 100644 bug_index.md diff --git a/bug_index.md b/bug_index.md deleted file mode 100644 index 3200389d1d..0000000000 --- a/bug_index.md +++ /dev/null @@ -1,119 +0,0 @@ -# Bug: Wire Type 4 Protobuf Error on Cursor API - -## Symptom - -- First request to Cursor (3 msgs, 34 tools) → WORKS -- Second request (5 msgs, 34 tools, ~140KB) → FAILS with "wire type 4" protobuf parse error -- Error comes FROM Cursor's server rejecting our encoded protobuf request - -## What "wire type 4" means - -- Valid protobuf wire types: 0=VARINT, 1=FIXED64, 2=LEN, 5=FIXED32 -- Wire type 4 = "end group" (deprecated, never valid in modern protobuf) -- This means **corrupted protobuf data** — Cursor server reads data bytes as tag bytes -- Root cause: a LENGTH-DELIMITED field has wrong length prefix, causing parse misalignment - -## Data Flow - -``` -Client (Claude format, 5 msgs) - → chatCore.translateRequest() - → claude-to-openai.ts (claudeToOpenAIRequest) - → openai-to-cursor.ts (buildCursorRequest) - → cursor.ts transformRequest() - → cursorProtobuf.ts generateCursorBody() → protobuf binary - → HTTP to Cursor API → "wire type 4" error -``` - -## Key Files - -- `open-sse/translator/request/claude-to-openai.ts` — Claude→OpenAI message conversion -- `open-sse/translator/request/openai-to-cursor.ts` — OpenAI→Cursor message conversion -- `open-sse/utils/cursorProtobuf.ts` — Protobuf binary encoder -- `open-sse/executors/cursor.ts` — Cursor executor (HTTP + protobuf framing) - -## Investigation Done (Session 1) - -1. **Content type is NOT the issue** — `encodeField()` handles undefined/null/array gracefully (→ empty `0a 00`) -2. **openai-to-cursor always normalizes content to string** (lines 77-87) -3. **tool_calls on assistant msgs are silently ignored** by protobuf encoder (no encoding logic for them) -4. **Debug logging was added** to `encodeRequest()` and `encodeMessage()` to trace message shapes -5. **All basic varint/field encoding tests pass** — no corruption in primitives - -## Investigation Done (Session 2 — current) - -6. **Round-trip test PASSES** — a synthetic 4-msg + 34-tools + tool_results request encodes and decodes back perfectly -7. **Large payload test PASSES** — 100KB tool result content, frame length matches perfectly (106,810 bytes) -8. **Varint encoding tests PASS** — values up to 268M encode/decode correctly -9. **Null bytes in content work** — no corruption -10. **Missing content on assistant msgs works** — produces valid empty field - -## CRITICAL CONCLUSION - -The encoding logic itself is **correct for all tested patterns**. The bug is NOT in the encoder primitives. -The issue MUST be in one of: - -### HIGH PRIORITY — What to investigate next - -#### H1: The REAL production data has something we haven't tested - -The user mentioned the log was already provided in a previous session. The 140,267 byte request -with 34 tools and 5 messages has specific data that triggers the bug. We need to: - -- **Capture the exact translated messages** before protobuf encoding (add JSON.stringify dump) -- **Capture the hex dump** of the protobuf output -- **Send the hex dump to a protobuf decoder** to find where parsing breaks - -#### H2: assistant message with tool_calls AND content=undefined - -In openai-to-cursor.ts line 96-97, when assistant has tool_calls but empty content: - -```js -const assistantMsg = { role: "assistant" }; // NO content property -if (content) { - assistantMsg.content = content; -} // content="" is falsy → skipped -``` - -Result: `msg.content` is `undefined`. In `encodeMessage`, this produces `encodeField(1, LEN, undefined)` → `0a 00`. -This is technically valid protobuf (empty string field), BUT Cursor's server might require the content field to be present as a non-empty string for assistant messages. The FIRST request (3 msgs) might not have an assistant message at all, while the SECOND (5 msgs) does. - -**FIX TO TRY**: Always set `content: ""` on assistant messages in openai-to-cursor.ts. - -#### H3: tool_calls being passed to protobuf encoder as stray properties - -The Cursor message in the translated body has `{ role, content, tool_calls, tool_results }`. -The protobuf encoder only uses `content`, `role`, and `tool_results`. The `tool_calls` array -is silently ignored. BUT if Cursor's real protocol expects tool calls to be encoded differently -(maybe as part of the content or as a separate protobuf field), we might be sending incomplete data. - -#### H4: Encoding `encodeVarint` with value from `>>>` unsigned shift on large numbers - -`encodeVarint` uses `value >>>= 7` which treats value as unsigned 32-bit. If a tag or length -exceeds 2^31, the unsigned shift behavior could produce wrong bytes. However, tests show this -works correctly up to 268M, and our payloads are ~140KB, so this is unlikely. - -## Recommended Fix (try first — LOW RISK) - -In `openai-to-cursor.ts`, always ensure assistant messages have `content: ""`: - -```js -// Line 96-99, change from: -const assistantMsg = { role: "assistant" }; -if (content) { - assistantMsg.content = content; -} - -// To: -const assistantMsg = { role: "assistant", content: content || "" }; -``` - -## How to debug further - -Add this to `cursor.ts` `transformRequest()` BEFORE `generateCursorBody()`: - -```js -console.log("[CURSOR_DEBUG] Messages to encode:", JSON.stringify(messages, null, 2).slice(0, 2000)); -``` - -Then reproduce the error and check logs for the exact message shapes. diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 4f3e619ec7..0e0c6672f9 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -29,7 +29,6 @@ import { } from "../utils/cursorProtobuf.ts"; import { estimateUsage } from "../utils/usageTracking.ts"; import { FORMATS } from "../translator/formats.ts"; -import { buildCursorRequest } from "../translator/request/openai-to-cursor.ts"; import crypto from "crypto"; import { v5 as uuidv5 } from "uuid"; import zlib from "zlib"; @@ -59,13 +58,18 @@ const COMPRESS_FLAG = { GZIP_BOTH: 0x03, }; +const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1"; +const debugLog = (...args: unknown[]) => { + if (CURSOR_STREAM_DEBUG) console.log(...args); +}; + function decompressPayload(payload, flags) { // Check if payload is JSON error (starts with {"error") if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) { try { const text = payload.toString("utf-8"); if (text.startsWith('{"error"')) { - console.log(`[DECOMPRESS] Detected JSON error, skipping decompression`); + debugLog(`[DECOMPRESS] Detected JSON error, skipping decompression`); return payload; } } catch {} @@ -89,14 +93,14 @@ function decompressPayload(payload, flags) { try { return zlib.inflateRawSync(payload); } catch (rawErr) { - console.log( + debugLog( `[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, gzip=${gzipErr.message}, deflate=${deflateErr.message}, raw=${rawErr.message}` ); - console.log( + debugLog( `[DECOMPRESS ERROR] First 50 bytes (hex):`, payload.slice(0, 50).toString("hex") ); - console.log( + debugLog( `[DECOMPRESS ERROR] First 50 bytes (utf8):`, payload .slice(0, 50) @@ -381,11 +385,11 @@ export class CursorExecutor extends BaseExecutor { const toolCallsMap = new Map(); // Track streaming tool calls by ID let frameCount = 0; - console.log(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`); + debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`); while (offset < buffer.length) { if (offset + 5 > buffer.length) { - console.log( + debugLog( `[CURSOR BUFFER] Reached end, offset=${offset}, remaining=${buffer.length - offset}` ); break; @@ -394,12 +398,12 @@ export class CursorExecutor extends BaseExecutor { const flags = buffer[offset]; const length = buffer.readUInt32BE(offset + 1); - console.log( + debugLog( `[CURSOR BUFFER] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}` ); if (offset + 5 + length > buffer.length) { - console.log( + debugLog( `[CURSOR BUFFER] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}` ); break; @@ -411,7 +415,7 @@ export class CursorExecutor extends BaseExecutor { payload = decompressPayload(payload, flags); if (!payload) { - console.log(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`); + debugLog(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`); continue; } @@ -420,9 +424,7 @@ export class CursorExecutor extends BaseExecutor { const text = payload.toString("utf-8"); if (text.startsWith("{") && text.includes('"error"')) { const hasContent = totalContent || toolCallsMap.size > 0; - console.log( - `[CURSOR BUFFER] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}` - ); + debugLog(`[CURSOR BUFFER] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}`); // If we already have content, treat error as stream termination (not fatal) if (hasContent) { break; @@ -432,11 +434,11 @@ export class CursorExecutor extends BaseExecutor { } catch {} const result = extractTextFromResponse(new Uint8Array(payload)); - console.log(`[CURSOR DECODED] Frame ${frameCount}:`, result); + debugLog(`[CURSOR DECODED] Frame ${frameCount}:`, result); if (result.error) { const hasContent = totalContent || toolCallsMap.size > 0; - console.log(`[CURSOR BUFFER] Decoded error (hasContent=${hasContent}): ${result.error}`); + debugLog(`[CURSOR BUFFER] Decoded error (hasContent=${hasContent}): ${result.error}`); // If we already have content, treat error as stream termination if (hasContent) { break; @@ -486,7 +488,7 @@ export class CursorExecutor extends BaseExecutor { if (result.text) totalContent += result.text; } - console.log( + debugLog( `[CURSOR BUFFER] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, finalized toolCalls: ${toolCalls.length}` ); @@ -494,7 +496,7 @@ export class CursorExecutor extends BaseExecutor { for (const [id, tc] of toolCallsMap.entries()) { // Check if already in final array if (!toolCalls.find((t) => t.id === id)) { - console.log(`[CURSOR BUFFER] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`); + debugLog(`[CURSOR BUFFER] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`); toolCalls.push({ id: tc.id, type: tc.type, @@ -506,7 +508,7 @@ export class CursorExecutor extends BaseExecutor { } } - console.log(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`); + debugLog(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`); const message: Record = { role: "assistant", @@ -551,11 +553,11 @@ export class CursorExecutor extends BaseExecutor { const toolCallsMap = new Map(); // Track streaming tool calls by ID let frameCount = 0; - console.log(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`); + debugLog(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`); while (offset < buffer.length) { if (offset + 5 > buffer.length) { - console.log( + debugLog( `[CURSOR BUFFER SSE] Reached end, offset=${offset}, remaining=${buffer.length - offset}` ); break; @@ -564,12 +566,12 @@ export class CursorExecutor extends BaseExecutor { const flags = buffer[offset]; const length = buffer.readUInt32BE(offset + 1); - console.log( + debugLog( `[CURSOR BUFFER SSE] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}` ); if (offset + 5 + length > buffer.length) { - console.log( + debugLog( `[CURSOR BUFFER SSE] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}` ); break; @@ -581,7 +583,7 @@ export class CursorExecutor extends BaseExecutor { payload = decompressPayload(payload, flags); if (!payload) { - console.log(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`); + debugLog(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`); continue; } @@ -591,7 +593,7 @@ export class CursorExecutor extends BaseExecutor { if (text.startsWith("{") && text.includes('"error"')) { const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0; // Log the full error for debugging - console.log( + debugLog( `[CURSOR BUFFER SSE] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}` ); // If we already have content, treat error as stream termination (not fatal) @@ -603,13 +605,11 @@ export class CursorExecutor extends BaseExecutor { } catch {} const result = extractTextFromResponse(new Uint8Array(payload)); - console.log(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result); + debugLog(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result); if (result.error) { const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0; - console.log( - `[CURSOR BUFFER SSE] Decoded error (hasContent=${hasContent}): ${result.error}` - ); + debugLog(`[CURSOR BUFFER SSE] Decoded error (hasContent=${hasContent}): ${result.error}`); // If we already have content, treat error as stream termination if (hasContent) { break; @@ -747,16 +747,14 @@ export class CursorExecutor extends BaseExecutor { } } - console.log( + debugLog( `[CURSOR BUFFER SSE] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, toolCalls array: ${toolCalls.length}` ); // Finalize all remaining tool calls in map (stream may have ended without isLast=true) for (const [id, tc] of toolCallsMap.entries()) { if (!toolCalls.find((t) => t.id === id)) { - console.log( - `[CURSOR BUFFER SSE] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}` - ); + debugLog(`[CURSOR BUFFER SSE] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`); const toolCallIndex = toolCalls.length; toolCalls.push({ id: tc.id, diff --git a/open-sse/utils/cursorProtobuf.ts b/open-sse/utils/cursorProtobuf.ts index f593a6228c..d8365da646 100644 --- a/open-sse/utils/cursorProtobuf.ts +++ b/open-sse/utils/cursorProtobuf.ts @@ -10,7 +10,7 @@ import { v4 as uuidv4 } from "uuid"; import zlib from "zlib"; -const DEBUG = true; +const DEBUG = process.env.CURSOR_PROTOBUF_DEBUG === "1"; const log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args); /** @@ -66,7 +66,6 @@ const FIELD = { MSG_ID: 13, MSG_TOOL_RESULTS: 18, MSG_IS_AGENTIC: 29, - MSG_SERVER_BUBBLE_ID: 32, MSG_UNIFIED_MODE: 47, MSG_SUPPORTED_TOOLS: 51, @@ -192,13 +191,6 @@ export function encodeField(fieldNum, wireType, value) { const tagBytes = encodeVarint(tag); if (wireType === WIRE_TYPE.VARINT) { - // Validate: VARINT value must be a number - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { - log( - "ENCODE", - `⚠️ VARINT field=${fieldNum} invalid value: type=${typeof value}, value=${value}` - ); - } const valueBytes = encodeVarint(value); return concatArrays(tagBytes, valueBytes); } @@ -389,20 +381,6 @@ export function encodeMessage( hasTools = false, toolResults = [] ) { - // Debug: validate content type before encoding - if ( - content !== null && - content !== undefined && - typeof content !== "string" && - !(content instanceof Uint8Array) && - !Buffer.isBuffer(content) - ) { - log( - "ENCODE", - `⚠️ MSG_CONTENT unexpected type: ${typeof content}, isArray=${Array.isArray(content)}, value=${JSON.stringify(content).slice(0, 200)}` - ); - } - return concatArrays( encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content), encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role), @@ -550,27 +528,6 @@ export function encodeRequest(messages, modelName, tools = [], reasoningEffort = const msgId = uuidv4(); const isLast = i === normalizedMessages.length - 1; - // Debug: log message shape for diagnosis - const toolResultsCount = Array.isArray(msg.tool_results) ? msg.tool_results.length : 0; - const contentStr = typeof msg.content === "string" ? msg.content : ""; - const contentPreview = contentStr - .slice(0, 120) - .replace(/\r/g, "\\r") - .replace(/\n/g, "\\n") - .replace(/[^\x20-\x7E]/g, "?"); - log( - "ENCODE", - `msg[${i}] role=${msg.role} contentType=${typeof msg.content} contentLen=${contentStr.length} contentPreview="${contentPreview}" contentIsArray=${Array.isArray(msg.content)} hasToolCalls=${Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0} hasToolResults=${toolResultsCount > 0} toolResultsCount=${toolResultsCount}` - ); - if (toolResultsCount > 0) { - for (const tr of msg.tool_results) { - log( - "ENCODE", - ` toolResult: callId=${tr.tool_call_id} name=${tr.name} rawArgsType=${typeof tr.raw_args} rawArgsLen=${tr.raw_args?.length || 0} resultType=${typeof tr.result} resultLen=${tr.result?.length || 0}` - ); - } - } - formattedMessages.push({ content: msg.content, role, From 25cd0b361249d5e2facf2f68028e080294b81f3c Mon Sep 17 00:00:00 2001 From: Rafael Calleja Date: Tue, 10 Mar 2026 09:49:51 +0100 Subject: [PATCH 3/3] =?UTF-8?q?refactor(cursor):=20simplify=20review=20?= =?UTF-8?q?=E2=80=94=20Set=20lookups,=20byte=20guards,=20XML=20escaping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace O(n*k) chunks.some(includes) with emittedToolCallIds Set - Replace O(n) toolCalls.find with finalizedIds Set (JSON + SSE paths) - Add byte guard (0x7b check) before payload.toString on every frame - Add escapeXml() to prevent tag injection in tool result blocks - Reuse module-level TextDecoder in cursorProtobuf instead of per-call - Replace [...Set].every() spread with for..of loop --- open-sse/executors/cursor.ts | 105 +++++++++++++----- .../translator/request/openai-to-cursor.ts | 10 +- open-sse/utils/cursorProtobuf.ts | 28 +++-- 3 files changed, 99 insertions(+), 44 deletions(-) diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 0e0c6672f9..b3b135ee80 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -140,6 +140,41 @@ function createErrorResponse(jsonError) { ); } +function parseCursorJsonErrorFrame(text: string) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function isToolBoundaryAbort(jsonError: any, toolCallCount: number) { + if (!jsonError || toolCallCount <= 0) return false; + const code = jsonError?.error?.code || ""; + const debugError = jsonError?.error?.details?.[0]?.debug?.error || ""; + const title = jsonError?.error?.details?.[0]?.debug?.details?.title || ""; + const detail = jsonError?.error?.details?.[0]?.debug?.details?.detail || ""; + const message = `${title} ${detail}`.toLowerCase(); + const isAbortedCode = code === "aborted" || debugError === "ERROR_USER_ABORTED_REQUEST"; + return isAbortedCode && message.includes("tool call ended before result was received"); +} + +function mergeToolCallDelta(existing, incoming) { + const mergedName = incoming?.function?.name || existing?.function?.name || ""; + const existingArgs = existing?.function?.arguments || ""; + const deltaArgs = incoming?.function?.arguments || ""; + return { + id: incoming.id || existing.id, + type: "function", + function: { + name: mergedName, + arguments: `${existingArgs}${deltaArgs}`, + }, + isLast: Boolean(existing?.isLast || incoming?.isLast), + index: existing?.index ?? incoming?.index ?? 0, + }; +} + type CursorHttpResponse = { status: number; headers: Record; @@ -383,6 +418,7 @@ export class CursorExecutor extends BaseExecutor { let totalContent = ""; const toolCalls = []; const toolCallsMap = new Map(); // Track streaming tool calls by ID + const finalizedIds = new Set(); let frameCount = 0; debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`); @@ -419,19 +455,22 @@ export class CursorExecutor extends BaseExecutor { continue; } - // Check for JSON error frames - try { - const text = payload.toString("utf-8"); - if (text.startsWith("{") && text.includes('"error"')) { - const hasContent = totalContent || toolCallsMap.size > 0; - debugLog(`[CURSOR BUFFER] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}`); - // If we already have content, treat error as stream termination (not fatal) - if (hasContent) { - break; + // Check for JSON error frames (byte guard: skip toString on non-JSON frames) + if (payload.length > 0 && payload[0] === 0x7b) { + try { + const text = payload.toString("utf-8"); + if (text.includes('"error"')) { + const hasContent = totalContent || toolCallsMap.size > 0; + debugLog( + `[CURSOR BUFFER] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}` + ); + if (hasContent) { + break; + } + return createErrorResponse(JSON.parse(text)); } - return createErrorResponse(JSON.parse(text)); - } - } catch {} + } catch {} + } const result = extractTextFromResponse(new Uint8Array(payload)); debugLog(`[CURSOR DECODED] Frame ${frameCount}:`, result); @@ -474,6 +513,7 @@ export class CursorExecutor extends BaseExecutor { // Push to final array when isLast is true if (tc.isLast) { const finalToolCall = toolCallsMap.get(tc.id); + finalizedIds.add(tc.id); toolCalls.push({ id: finalToolCall.id, type: finalToolCall.type, @@ -495,7 +535,7 @@ export class CursorExecutor extends BaseExecutor { // Finalize all remaining tool calls in map (in case stream ended without isLast=true) for (const [id, tc] of toolCallsMap.entries()) { // Check if already in final array - if (!toolCalls.find((t) => t.id === id)) { + if (!finalizedIds.has(id)) { debugLog(`[CURSOR BUFFER] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`); toolCalls.push({ id: tc.id, @@ -551,6 +591,8 @@ export class CursorExecutor extends BaseExecutor { let totalContent = ""; const toolCalls = []; const toolCallsMap = new Map(); // Track streaming tool calls by ID + const finalizedIds = new Set(); + const emittedToolCallIds = new Set(); let frameCount = 0; debugLog(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`); @@ -587,22 +629,22 @@ export class CursorExecutor extends BaseExecutor { continue; } - // Check for JSON error frames - try { - const text = payload.toString("utf-8"); - if (text.startsWith("{") && text.includes('"error"')) { - const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0; - // Log the full error for debugging - debugLog( - `[CURSOR BUFFER SSE] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}` - ); - // If we already have content, treat error as stream termination (not fatal) - if (hasContent) { - break; + // Check for JSON error frames (byte-guard: only decode if starts with '{') + if (payload[0] === 0x7b) { + try { + const text = payload.toString("utf-8"); + if (text.includes('"error"')) { + const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0; + debugLog( + `[CURSOR BUFFER SSE] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}` + ); + if (hasContent) { + break; + } + return createErrorResponse(JSON.parse(text)); } - return createErrorResponse(JSON.parse(text)); - } - } catch {} + } catch {} + } const result = extractTextFromResponse(new Uint8Array(payload)); debugLog(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result); @@ -659,6 +701,7 @@ export class CursorExecutor extends BaseExecutor { // Stream the delta arguments if (tc.function.arguments) { + emittedToolCallIds.add(tc.id); chunks.push( `data: ${JSON.stringify({ id: responseId, @@ -690,10 +733,12 @@ export class CursorExecutor extends BaseExecutor { } else { // New tool call - assign index and add to map const toolCallIndex = toolCalls.length; + finalizedIds.add(tc.id); toolCalls.push({ ...tc, index: toolCallIndex }); toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex }); // Stream initial tool call with name + emittedToolCallIds.add(tc.id); chunks.push( `data: ${JSON.stringify({ id: responseId, @@ -753,7 +798,7 @@ export class CursorExecutor extends BaseExecutor { // Finalize all remaining tool calls in map (stream may have ended without isLast=true) for (const [id, tc] of toolCallsMap.entries()) { - if (!toolCalls.find((t) => t.id === id)) { + if (!finalizedIds.has(id)) { debugLog(`[CURSOR BUFFER SSE] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`); const toolCallIndex = toolCalls.length; toolCalls.push({ @@ -767,7 +812,7 @@ export class CursorExecutor extends BaseExecutor { }); // Emit SSE chunk for the finalized tool call if not already emitted - if (!chunks.some((c) => c.includes(tc.id))) { + if (!emittedToolCallIds.has(tc.id)) { chunks.push( `data: ${JSON.stringify({ id: responseId, diff --git a/open-sse/translator/request/openai-to-cursor.ts b/open-sse/translator/request/openai-to-cursor.ts index 345b427f60..16f759a0ec 100644 --- a/open-sse/translator/request/openai-to-cursor.ts +++ b/open-sse/translator/request/openai-to-cursor.ts @@ -37,13 +37,17 @@ function sanitizeToolResultText(text: string): string { return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ""); } +function escapeXml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">"); +} + function buildToolResultBlock(toolName: string, toolCallId: string, resultText: string): string { const cleanResult = sanitizeToolResultText(resultText || ""); return [ "", - `${toolName || "tool"}`, - `${toolCallId || ""}`, - `${cleanResult}`, + `${escapeXml(toolName || "tool")}`, + `${escapeXml(toolCallId || "")}`, + `${escapeXml(cleanResult)}`, "", ].join("\n"); } diff --git a/open-sse/utils/cursorProtobuf.ts b/open-sse/utils/cursorProtobuf.ts index d8365da646..beb3a44e4a 100644 --- a/open-sse/utils/cursorProtobuf.ts +++ b/open-sse/utils/cursorProtobuf.ts @@ -12,6 +12,7 @@ import zlib from "zlib"; const DEBUG = process.env.CURSOR_PROTOBUF_DEBUG === "1"; const log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args); +const textDecoder = new TextDecoder(); /** * Schema version — bump when updating field definitions. @@ -502,10 +503,15 @@ export function encodeRequest(messages, modelName, tools = [], reasoningEffort = .map((tr) => tr?.tool_call_id) .filter((id) => typeof id === "string") ); - const sameIds = - currentIds.size > 0 && - currentIds.size === nextIds.size && - [...currentIds].every((id) => nextIds.has(id)); + let sameIds = currentIds.size > 0 && currentIds.size === nextIds.size; + if (sameIds) { + for (const id of currentIds) { + if (!nextIds.has(id)) { + sameIds = false; + break; + } + } + } if (!(nextHasToolResults && sameIds)) { normalizedMessages.push({ @@ -744,12 +750,12 @@ function extractToolCall(toolCallData) { // Extract tool call ID if (toolCall.has(FIELD.TOOL_ID)) { - toolCallId = new TextDecoder().decode(toolCall.get(FIELD.TOOL_ID)[0].value); + toolCallId = textDecoder.decode(toolCall.get(FIELD.TOOL_ID)[0].value); } // Extract tool name if (toolCall.has(FIELD.TOOL_NAME)) { - toolName = new TextDecoder().decode(toolCall.get(FIELD.TOOL_NAME)[0].value); + toolName = textDecoder.decode(toolCall.get(FIELD.TOOL_NAME)[0].value); } // Extract is_last flag @@ -768,11 +774,11 @@ function extractToolCall(toolCallData) { const tool = decodeMessage(mcpParams.get(FIELD.MCP_TOOLS_LIST)[0].value); if (tool.has(FIELD.MCP_NESTED_NAME)) { - toolName = new TextDecoder().decode(tool.get(FIELD.MCP_NESTED_NAME)[0].value); + toolName = textDecoder.decode(tool.get(FIELD.MCP_NESTED_NAME)[0].value); } if (tool.has(FIELD.MCP_NESTED_PARAMS)) { - rawArgs = new TextDecoder().decode(tool.get(FIELD.MCP_NESTED_PARAMS)[0].value); + rawArgs = textDecoder.decode(tool.get(FIELD.MCP_NESTED_PARAMS)[0].value); } } } catch (err) { @@ -782,7 +788,7 @@ function extractToolCall(toolCallData) { // Fallback to raw_args if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) { - rawArgs = new TextDecoder().decode(toolCall.get(FIELD.TOOL_RAW_ARGS)[0].value); + rawArgs = textDecoder.decode(toolCall.get(FIELD.TOOL_RAW_ARGS)[0].value); } if (toolCallId && toolName) { @@ -807,7 +813,7 @@ function extractTextAndThinking(responseData) { // Extract text if (nested.has(FIELD.RESPONSE_TEXT)) { - text = new TextDecoder().decode(nested.get(FIELD.RESPONSE_TEXT)[0].value); + text = textDecoder.decode(nested.get(FIELD.RESPONSE_TEXT)[0].value); } // Extract thinking @@ -815,7 +821,7 @@ function extractTextAndThinking(responseData) { try { const thinkingMsg = decodeMessage(nested.get(FIELD.THINKING)[0].value); if (thinkingMsg.has(FIELD.THINKING_TEXT)) { - thinking = new TextDecoder().decode(thinkingMsg.get(FIELD.THINKING_TEXT)[0].value); + thinking = textDecoder.decode(thinkingMsg.get(FIELD.THINKING_TEXT)[0].value); } } catch (err) { log("EXTRACT", `Thinking parse error: ${err.message}`);