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}`);