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