From d06d1ae49ce4ed2ecb2fc1cfe7f8104585a6fba9 Mon Sep 17 00:00:00 2001 From: payne Date: Sun, 10 May 2026 06:00:18 +0300 Subject: [PATCH] feat(cursor): full OpenAI parity (tool calls, streaming, sessions) (#2082) Merged automatically --- open-sse/config/providerRegistry.ts | 113 +- open-sse/executors/cursor.ts | 1774 +++++++++-------- open-sse/services/cursorSessionManager.ts | 167 ++ open-sse/utils/cursorAgentProtobuf.ts | 1369 +++++++++++++ open-sse/utils/cursorProtobuf.ts | 897 --------- scripts/cursor-tap.cjs | 176 ++ scripts/sync-cursor-models.mjs | 130 ++ src/app/api/providers/[id]/models/route.ts | 26 + src/lib/providerModels/cursorAgent.ts | 166 ++ tests/fixtures/cursor/.gitignore | 5 + tests/integration/cursor-e2e.test.ts | 147 ++ tests/unit/cursor-agent-exec-router.test.ts | 219 ++ tests/unit/cursor-agent-models.test.ts | 42 + tests/unit/cursor-agent-protobuf.test.ts | 490 +++++ tests/unit/cursor-agent-session.test.ts | 227 +++ tests/unit/cursor-agent-system-prompt.test.ts | 122 ++ tests/unit/cursor-agent-tool-calls.test.ts | 236 +++ tests/unit/cursor-protobuf.test.ts | 318 --- tests/unit/cursor-streaming.test.ts | 276 +++ tests/unit/executor-cursor-extended.test.ts | 549 ----- 20 files changed, 4885 insertions(+), 2564 deletions(-) create mode 100644 open-sse/services/cursorSessionManager.ts create mode 100644 open-sse/utils/cursorAgentProtobuf.ts delete mode 100644 open-sse/utils/cursorProtobuf.ts create mode 100644 scripts/cursor-tap.cjs create mode 100644 scripts/sync-cursor-models.mjs create mode 100644 src/lib/providerModels/cursorAgent.ts create mode 100644 tests/fixtures/cursor/.gitignore create mode 100644 tests/integration/cursor-e2e.test.ts create mode 100644 tests/unit/cursor-agent-exec-router.test.ts create mode 100644 tests/unit/cursor-agent-models.test.ts create mode 100644 tests/unit/cursor-agent-protobuf.test.ts create mode 100644 tests/unit/cursor-agent-session.test.ts create mode 100644 tests/unit/cursor-agent-system-prompt.test.ts create mode 100644 tests/unit/cursor-agent-tool-calls.test.ts delete mode 100644 tests/unit/cursor-protobuf.test.ts create mode 100644 tests/unit/cursor-streaming.test.ts delete mode 100644 tests/unit/executor-cursor-extended.test.ts diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index ce4241bb8a..0ba98449e1 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -608,20 +608,107 @@ export const REGISTRY: Record = { headers: getCursorRegistryHeaders(), clientVersion: CURSOR_REGISTRY_VERSION, models: [ - { id: "default", name: "Auto (Server Picks)" }, - { id: "claude-4.6-opus-high-thinking", name: "Claude 4.6 Opus High Thinking" }, - { id: "claude-4.6-opus-high", name: "Claude 4.6 Opus High" }, - { id: "claude-4.6-sonnet-high-thinking", name: "Claude 4.6 Sonnet High Thinking" }, - { id: "claude-4.6-sonnet-high", name: "Claude 4.6 Sonnet High" }, - { id: "claude-4.6-haiku", name: "Claude 4.6 Haiku" }, - { id: "claude-4.6-opus", name: "Claude 4.6 Opus" }, - { id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" }, - { id: "claude-4.5-opus-high", name: "Claude 4.5 Opus High" }, - { id: "claude-4.5-sonnet-thinking", name: "Claude 4.5 Sonnet Thinking" }, - { id: "claude-4.5-sonnet", name: "Claude 4.5 Sonnet" }, - { id: "claude-4.5-haiku", name: "Claude 4.5 Haiku" }, - { id: "claude-4.5-opus", name: "Claude 4.5 Opus" }, + { id: "auto", name: "Auto (Server Picks)" }, + { id: "composer-2-fast", name: "Composer 2 Fast" }, + { id: "composer-2", name: "Composer 2" }, + { id: "gpt-5.3-codex-low", name: "GPT 5.3 Codex Low" }, + { id: "gpt-5.3-codex-low-fast", name: "GPT 5.3 Codex Low Fast" }, + { id: "gpt-5.3-codex", name: "GPT 5.3 Codex" }, + { id: "gpt-5.3-codex-fast", name: "GPT 5.3 Codex Fast" }, + { id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex High" }, + { id: "gpt-5.3-codex-high-fast", name: "GPT 5.3 Codex High Fast" }, + { id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex XHigh" }, + { id: "gpt-5.3-codex-xhigh-fast", name: "GPT 5.3 Codex XHigh Fast" }, + { id: "gpt-5.2", name: "GPT 5.2" }, + { id: "gpt-5.3-codex-spark-preview-low", name: "GPT 5.3 Codex Spark Preview Low" }, + { id: "gpt-5.3-codex-spark-preview", name: "GPT 5.3 Codex Spark Preview" }, + { id: "gpt-5.3-codex-spark-preview-high", name: "GPT 5.3 Codex Spark Preview High" }, + { id: "gpt-5.3-codex-spark-preview-xhigh", name: "GPT 5.3 Codex Spark Preview XHigh" }, + { id: "gpt-5.2-codex-low", name: "GPT 5.2 Codex Low" }, + { id: "gpt-5.2-codex-low-fast", name: "GPT 5.2 Codex Low Fast" }, { id: "gpt-5.2-codex", name: "GPT 5.2 Codex" }, + { id: "gpt-5.2-codex-fast", name: "GPT 5.2 Codex Fast" }, + { id: "gpt-5.2-codex-high", name: "GPT 5.2 Codex High" }, + { id: "gpt-5.2-codex-high-fast", name: "GPT 5.2 Codex High Fast" }, + { id: "gpt-5.2-codex-xhigh", name: "GPT 5.2 Codex XHigh" }, + { id: "gpt-5.2-codex-xhigh-fast", name: "GPT 5.2 Codex XHigh Fast" }, + { id: "gpt-5.1-codex-max-low", name: "GPT 5.1 Codex Max Low" }, + { id: "gpt-5.1-codex-max-low-fast", name: "GPT 5.1 Codex Max Low Fast" }, + { id: "gpt-5.1-codex-max-medium", name: "GPT 5.1 Codex Max Medium" }, + { id: "gpt-5.1-codex-max-medium-fast", name: "GPT 5.1 Codex Max Medium Fast" }, + { id: "gpt-5.1-codex-max-high", name: "GPT 5.1 Codex Max High" }, + { id: "gpt-5.1-codex-max-high-fast", name: "GPT 5.1 Codex Max High Fast" }, + { id: "gpt-5.1-codex-max-xhigh", name: "GPT 5.1 Codex Max XHigh" }, + { id: "gpt-5.1-codex-max-xhigh-fast", name: "GPT 5.1 Codex Max XHigh Fast" }, + { id: "gpt-5.5-high", name: "GPT 5.5 High" }, + { id: "gpt-5.5-high-fast", name: "GPT 5.5 High Fast" }, + { id: "claude-opus-4-7-thinking-high", name: "Claude Opus 4.7 Thinking High" }, + { id: "gpt-5.4-high", name: "GPT 5.4 High" }, + { id: "gpt-5.4-high-fast", name: "GPT 5.4 High Fast" }, + { id: "claude-4.6-opus-high-thinking", name: "Claude 4.6 Opus High Thinking" }, + { id: "claude-4.6-opus-high-thinking-fast", name: "Claude 4.6 Opus High Thinking Fast" }, + { id: "claude-4.6-sonnet-medium", name: "Claude 4.6 Sonnet Medium" }, + { id: "claude-4.6-sonnet-medium-thinking", name: "Claude 4.6 Sonnet Medium Thinking" }, + { id: "gpt-5.5-none", name: "GPT 5.5 None" }, + { id: "gpt-5.5-none-fast", name: "GPT 5.5 None Fast" }, + { id: "gpt-5.5-low", name: "GPT 5.5 Low" }, + { id: "gpt-5.5-low-fast", name: "GPT 5.5 Low Fast" }, + { id: "gpt-5.5-medium", name: "GPT 5.5 Medium" }, + { id: "gpt-5.5-medium-fast", name: "GPT 5.5 Medium Fast" }, + { id: "gpt-5.5-extra-high", name: "GPT 5.5 Extra High" }, + { id: "gpt-5.5-extra-high-fast", name: "GPT 5.5 Extra High Fast" }, + { id: "claude-opus-4-7-low", name: "Claude Opus 4.7 Low" }, + { id: "claude-opus-4-7-medium", name: "Claude Opus 4.7 Medium" }, + { id: "claude-opus-4-7-high", name: "Claude Opus 4.7 High" }, + { id: "claude-opus-4-7-xhigh", name: "Claude Opus 4.7 XHigh" }, + { id: "claude-opus-4-7-max", name: "Claude Opus 4.7 Max" }, + { id: "claude-opus-4-7-thinking-low", name: "Claude Opus 4.7 Thinking Low" }, + { id: "claude-opus-4-7-thinking-medium", name: "Claude Opus 4.7 Thinking Medium" }, + { id: "claude-opus-4-7-thinking-xhigh", name: "Claude Opus 4.7 Thinking XHigh" }, + { id: "claude-opus-4-7-thinking-max", name: "Claude Opus 4.7 Thinking Max" }, + { id: "gpt-5.4-low", name: "GPT 5.4 Low" }, + { id: "gpt-5.4-medium", name: "GPT 5.4 Medium" }, + { id: "gpt-5.4-medium-fast", name: "GPT 5.4 Medium Fast" }, + { id: "gpt-5.4-xhigh", name: "GPT 5.4 XHigh" }, + { id: "gpt-5.4-xhigh-fast", name: "GPT 5.4 XHigh Fast" }, + { id: "claude-4.6-opus-high", name: "Claude 4.6 Opus High" }, + { id: "claude-4.6-opus-max", name: "Claude 4.6 Opus Max" }, + { id: "claude-4.6-opus-max-thinking", name: "Claude 4.6 Opus Max Thinking" }, + { id: "claude-4.6-opus-max-thinking-fast", name: "Claude 4.6 Opus Max Thinking Fast" }, + { id: "claude-4.5-opus-high", name: "Claude 4.5 Opus High" }, + { id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" }, + { id: "gpt-5.2-low", name: "GPT 5.2 Low" }, + { id: "gpt-5.2-low-fast", name: "GPT 5.2 Low Fast" }, + { id: "gpt-5.2-fast", name: "GPT 5.2 Fast" }, + { id: "gpt-5.2-high", name: "GPT 5.2 High" }, + { id: "gpt-5.2-high-fast", name: "GPT 5.2 High Fast" }, + { id: "gpt-5.2-xhigh", name: "GPT 5.2 XHigh" }, + { id: "gpt-5.2-xhigh-fast", name: "GPT 5.2 XHigh Fast" }, + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + { id: "gpt-5.4-mini-none", name: "GPT 5.4 Mini None" }, + { id: "gpt-5.4-mini-low", name: "GPT 5.4 Mini Low" }, + { id: "gpt-5.4-mini-medium", name: "GPT 5.4 Mini Medium" }, + { id: "gpt-5.4-mini-high", name: "GPT 5.4 Mini High" }, + { id: "gpt-5.4-mini-xhigh", name: "GPT 5.4 Mini XHigh" }, + { id: "gpt-5.4-nano-none", name: "GPT 5.4 Nano None" }, + { id: "gpt-5.4-nano-low", name: "GPT 5.4 Nano Low" }, + { id: "gpt-5.4-nano-medium", name: "GPT 5.4 Nano Medium" }, + { id: "gpt-5.4-nano-high", name: "GPT 5.4 Nano High" }, + { id: "gpt-5.4-nano-xhigh", name: "GPT 5.4 Nano XHigh" }, + { id: "grok-4.3", name: "Grok 4.3" }, + { id: "claude-4.5-sonnet", name: "Claude 4.5 Sonnet" }, + { id: "claude-4.5-sonnet-thinking", name: "Claude 4.5 Sonnet Thinking" }, + { id: "gpt-5.1-low", name: "GPT 5.1 Low" }, + { id: "gpt-5.1", name: "GPT 5.1" }, + { id: "gpt-5.1-high", name: "GPT 5.1 High" }, + { id: "gemini-3-flash", name: "Gemini 3 Flash" }, + { id: "gpt-5.1-codex-mini-low", name: "GPT 5.1 Codex Mini Low" }, + { id: "gpt-5.1-codex-mini", name: "GPT 5.1 Codex Mini" }, + { id: "gpt-5.1-codex-mini-high", name: "GPT 5.1 Codex Mini High" }, + { id: "claude-4-sonnet", name: "Claude 4 Sonnet" }, + { id: "claude-4-sonnet-thinking", name: "Claude 4 Sonnet Thinking" }, + { id: "gpt-5-mini", name: "GPT 5 Mini" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, ], }, diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 950683a385..e02c212292 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -1,41 +1,142 @@ declare const EdgeRuntime: string | undefined; /** - * CursorExecutor — Handles communication with the Cursor IDE API. + * CursorExecutor — talks to Cursor's agent.v1.AgentService/Run endpoint. * - * This executor is the most complex due to Cursor's non-standard protocol: + * cursor-agent (CLI) and the cursor IDE both use this RPC for every model id + * (auto, composer-*, claude-*, gpt-*, gemini-*). The legacy + * aiserver.v1.ChatService/StreamUnifiedChatWithTools rejects "auto" and + * "composer-*" with errors, so we migrated this executor over. * - * SECTION 1: Authentication (generateChecksum) - * - SHA-256 based checksum using machine ID and timestamp - * - WorkOS token refresh for session management - * - * SECTION 2: Request Encoding (transformRequest, buildHeaders) - * - ConnectRPC Protobuf binary encoding via cursorProtobuf.js - * - Chat body construction with model routing - * - * SECTION 3: Response Parsing (executeStream, parseEvent*) - * - Binary EventStream → SSE text conversion - * - Gzip decompression of response frames - * - HTTP/2 support with h2 fallback to fetch - * - * @see cursorProtobuf.js for Protobuf encoding/decoding utilities + * Wire format & schema details live in ../utils/cursorAgentProtobuf.ts. */ import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts"; -import { getCursorUserAgent } from "../config/providerHeaderProfiles.ts"; import { PROVIDERS, HTTP_STATUS } from "../config/constants.ts"; import { - generateCursorBody, - parseConnectRPCFrame, - extractTextFromResponse, -} from "../utils/cursorProtobuf.ts"; -import { estimateUsage } from "../utils/usageTracking.ts"; + buildAgentRequestBody, + decodeAgentServerMessage, + decodeExecServerEvent, + decodeKvServerEvent, + encodeRequestContextResponse, + encodeKvGetBlobResult, + encodeKvSetBlobResult, + encodeExecReadRejected, + encodeExecWriteRejected, + encodeExecDeleteRejected, + encodeExecLsRejected, + encodeExecShellRejected, + encodeExecBackgroundShellSpawnRejected, + encodeExecGrepError, + encodeExecFetchError, + encodeExecWriteShellStdinError, + encodeExecDiagnosticsResult, + flattenMessages, + openAIToolsToMcpDefs, + type ChatMessage, + type ExecServerEvent, + type McpToolDefinition, + type OpenAITool, +} from "../utils/cursorAgentProtobuf.ts"; +import { + estimateInputTokens, + estimateOutputTokens, + addBufferToUsage, +} from "../utils/usageTracking.ts"; import { getCursorVersion } from "../utils/cursorVersionDetector.ts"; -import { FORMATS } from "../translator/formats.ts"; +import { generateToolCallId } from "../translator/helpers/toolCallHelper.ts"; +import { cursorSessionManager, type CursorSession } from "../services/cursorSessionManager.ts"; import crypto from "crypto"; -import { v5 as uuidv5 } from "uuid"; -import zlib from "zlib"; +import * as fs from "node:fs"; +import * as zlib from "node:zlib"; -// Detect cloud environment +// Reject reason text aligned with kaitranntt/CLIProxyAPIPlus — proven to +// keep cursor's model from retrying the same built-in tool indefinitely. +// The model adapts and either answers from context or uses declared MCP tools. +const BUILTIN_TOOL_REJECT_REASON = + "Tool not available in this environment. Use the MCP tools provided instead."; + +/** + * Build the ExecClientMessage frame that responds to a built-in tool request. + * Returns null for the request_context handshake (caller handles separately + * to inject MCP tools in Phase 3) and for exec_mcp (model is invoking a + * declared MCP tool — Phase 5 surfaces this as an OpenAI tool_calls delta). + */ +function buildExecRejection(event: ExecServerEvent): Buffer | null { + switch (event.kind) { + case "exec_request_context": + case "exec_mcp": + return null; + case "exec_read": + return encodeExecReadRejected( + event.execMsgId, + event.execId, + event.path, + BUILTIN_TOOL_REJECT_REASON + ); + case "exec_write": + return encodeExecWriteRejected( + event.execMsgId, + event.execId, + event.path, + BUILTIN_TOOL_REJECT_REASON + ); + case "exec_delete": + return encodeExecDeleteRejected( + event.execMsgId, + event.execId, + event.path, + BUILTIN_TOOL_REJECT_REASON + ); + case "exec_ls": + return encodeExecLsRejected( + event.execMsgId, + event.execId, + event.path, + BUILTIN_TOOL_REJECT_REASON + ); + case "exec_grep": + return encodeExecGrepError(event.execMsgId, event.execId, BUILTIN_TOOL_REJECT_REASON); + case "exec_diagnostics": + // Diagnostics has no rejection variant — return an empty success. + return encodeExecDiagnosticsResult(event.execMsgId, event.execId); + case "exec_shell": + case "exec_shell_stream": + return encodeExecShellRejected( + event.execMsgId, + event.execId, + event.command, + event.workingDir, + BUILTIN_TOOL_REJECT_REASON + ); + case "exec_bg_shell": + return encodeExecBackgroundShellSpawnRejected( + event.execMsgId, + event.execId, + event.command, + event.workingDir, + BUILTIN_TOOL_REJECT_REASON + ); + case "exec_fetch": + return encodeExecFetchError( + event.execMsgId, + event.execId, + event.url, + BUILTIN_TOOL_REJECT_REASON + ); + case "exec_write_shell_stdin": + return encodeExecWriteShellStdinError( + event.execMsgId, + event.execId, + BUILTIN_TOOL_REJECT_REASON + ); + } +} + +const CURSOR_AGENT_HOST = "agentn.global.api5.cursor.sh"; +const CURSOR_AGENT_PATH = "/agent.v1.AgentService/Run"; +const CURSOR_AGENT_URL = `https://${CURSOR_AGENT_HOST}${CURSOR_AGENT_PATH}`; + +// Detect cloud environment (Edge runtime, Cloudflare Workers, etc.) const isCloudEnv = () => { if (typeof caches !== "undefined" && typeof caches === "object") return true; if (typeof EdgeRuntime !== "undefined") return true; @@ -43,144 +144,27 @@ const isCloudEnv = () => { }; // Lazy import http2 (only in Node.js environment) -let http2 = null; +let http2: typeof import("http2") | null = null; if (!isCloudEnv()) { try { http2 = await import("http2"); } catch { - // http2 not available + http2 = null; } } -// --- SECTION 1: Authentication Constants --- -const COMPRESS_FLAG = { - NONE: 0x00, - GZIP: 0x01, - GZIP_ALT: 0x02, - GZIP_BOTH: 0x03, -}; - -const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1"; +// Phase 10: CURSOR_DEBUG=1 enables verbose streaming debug logs (decoded +// frame summaries, exec router dispatches, session lifecycle events). +// CURSOR_STREAM_DEBUG is kept as a backward-compatible alias. +const CURSOR_DEBUG = process.env.CURSOR_DEBUG === "1" || process.env.CURSOR_STREAM_DEBUG === "1"; const debugLog = (...args: unknown[]) => { - if (CURSOR_STREAM_DEBUG) console.log(...args); + if (CURSOR_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"')) { - debugLog(`[DECOMPRESS] Detected JSON error, skipping decompression`); - return payload; - } - } catch {} - } - - if ( - flags === COMPRESS_FLAG.GZIP || - flags === COMPRESS_FLAG.GZIP_ALT || - flags === COMPRESS_FLAG.GZIP_BOTH - ) { - // Primary: try gzip decompression (standard gzip header 0x1f 0x8b) - try { - return zlib.gunzipSync(payload); - } catch (gzipErr) { - // Fallback: GZIP_ALT (0x02) and GZIP_BOTH (0x03) frames sometimes use - // raw zlib deflate format instead of gzip wrapping (#250) - try { - return zlib.inflateSync(payload); - } catch (deflateErr) { - // Last resort: try raw deflate (no zlib header) - try { - return zlib.inflateRawSync(payload); - } catch (rawErr) { - debugLog( - `[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, gzip=${gzipErr.message}, deflate=${deflateErr.message}, raw=${rawErr.message}` - ); - debugLog( - `[DECOMPRESS ERROR] First 50 bytes (hex):`, - payload.slice(0, 50).toString("hex") - ); - debugLog( - `[DECOMPRESS ERROR] First 50 bytes (utf8):`, - payload - .slice(0, 50) - .toString("utf8") - .replace(/[^\x20-\x7E]/g, ".") - ); - // Try to use payload as-is if all decompression methods fail - return payload; - } - } - } - } - return payload; -} - -function createErrorResponse(jsonError) { - const errorMsg = - jsonError?.error?.details?.[0]?.debug?.details?.title || - jsonError?.error?.details?.[0]?.debug?.details?.detail || - jsonError?.error?.message || - "API Error"; - - const isRateLimit = jsonError?.error?.code === "resource_exhausted"; - - return new Response( - JSON.stringify({ - error: { - message: errorMsg, - type: isRateLimit ? "rate_limit_error" : "api_error", - code: jsonError?.error?.details?.[0]?.debug?.error || "unknown", - }, - }), - { - status: isRateLimit ? HTTP_STATUS.RATE_LIMITED : HTTP_STATUS.BAD_REQUEST, - headers: { "Content-Type": "application/json" }, - } - ); -} - -function parseCursorJsonErrorFrame(text: string) { - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function isToolBoundaryAbort(jsonError: unknown, toolCallCount: number) { - if (!jsonError || toolCallCount <= 0) return false; - const e = jsonError as Record; - const err = e?.error as Record | undefined; - const details = (err?.details as Record[] | undefined)?.[0]; - const debug = details?.debug as Record | undefined; - const debugDetails = debug?.details as Record | undefined; - const code = (err?.code as string) || ""; - const debugError = (debug?.error as string) || ""; - const title = (debugDetails?.title as string) || ""; - const detail = (debugDetails?.detail as string) || ""; - 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, - }; -} +// Phase 8: max wall-clock time before we give up on the upstream and abort +// the stream. Cursor's longest-observed plain chat takes ~90s; tool-using +// turns can be longer. Five minutes is generous but bounded. +const CURSOR_STREAM_TIMEOUT_MS = parseInt(process.env.CURSOR_STREAM_TIMEOUT_MS || "300000", 10); type CursorHttpResponse = { status: number; @@ -188,141 +172,426 @@ type CursorHttpResponse = { body: Buffer; }; +function tryParseJsonError(payload: Buffer): { message: string; status: number } | null { + if (payload.length < 2 || payload[0] !== 0x7b) return null; + try { + const text = payload.toString("utf8"); + if (!text.includes('"error"')) return null; + const parsed = JSON.parse(text); + const err = parsed?.error || {}; + const message = + err?.details?.[0]?.debug?.details?.title || + err?.details?.[0]?.debug?.details?.detail || + err?.message || + text; + const status = + err?.code === "resource_exhausted" ? HTTP_STATUS.RATE_LIMITED : HTTP_STATUS.BAD_REQUEST; + return { message, status }; + } catch { + return null; + } +} + +// ─── Phase 4: streaming dispatch context ─────────────────────────────────── +// +// One StreamCtx flows through a single execute() call. It owns the live +// SSE emission state (responseId, created timestamp, model id, role-chunk +// flag) plus aggregate state (totalText, tokenDelta) needed for the final +// usage chunk and JSON-mode aggregation. Phases 5 (tool calls) and 8 +// (end-signal hardening) extend it. + +export type StreamCtx = { + responseId: string; + created: number; + model: string; + emit: (chunk: string) => void; + emittedRoleChunk: boolean; + totalText: string; + thinkingText: string; + tokenDelta: number; + // End-signal tracking (Phase 8 hardens this further). + receivedText: boolean; + kvAfterTextSeen: boolean; + endReason: "turn_ended" | "kv_after_text" | "tool_calls" | "server_end" | null; + // Mid-stream JSON error (rare; emitted once with the error code). + midStreamError: { message: string; status: number } | null; + // Phase 5: tool-call indexing for parallel calls. Each McpArgs gets a + // monotonically-increasing index in the OpenAI delta. emittedToolCalls + // tracks how many were emitted so finalizeSseStream picks the right + // finish_reason ("tool_calls" vs "stop"). + emittedToolCallIndex: number; + // Captured tool calls (for JSON-mode aggregation). Each entry maps to + // one OpenAI tool_calls[] item. + toolCalls: Array<{ + id: string; + name: string; + argumentsJson: string; + }>; + // Phase 6: maps OpenAI tool_call_id → cursor exec info, so a follow-up + // role:"tool" message can be answered on the open h2 stream via + // encodeExecMcpResult. + pendingToolCalls: Map; +}; + +export function newStreamCtx(model: string, emit: (chunk: string) => void): StreamCtx { + return { + responseId: `chatcmpl-cursor-${Date.now()}`, + created: Math.floor(Date.now() / 1000), + model, + emit, + emittedRoleChunk: false, + totalText: "", + thinkingText: "", + tokenDelta: 0, + receivedText: false, + kvAfterTextSeen: false, + endReason: null, + midStreamError: null, + emittedToolCallIndex: 0, + toolCalls: [], + pendingToolCalls: new Map(), + }; +} + +function emitChunk(ctx: StreamCtx, delta: object, finishReason: string | null = null) { + const payload = { + id: ctx.responseId, + object: "chat.completion.chunk", + created: ctx.created, + model: ctx.model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; + ctx.emit(`data: ${JSON.stringify(payload)}\n\n`); +} + +export function buildCursorUsage(ctx: StreamCtx, body: { messages?: ChatMessage[] }) { + const promptTokens = estimateInputTokens(body); + const completionTokens = + ctx.tokenDelta > 0 + ? ctx.tokenDelta + : estimateOutputTokens(ctx.totalText.length + ctx.thinkingText.length); + const usage: Record = { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + estimated: true, + }; + if (ctx.thinkingText.length > 0) { + usage.completion_tokens_details = { + reasoning_tokens: estimateOutputTokens(ctx.thinkingText.length), + }; + } + return addBufferToUsage(usage); +} + +function emitUsage(ctx: StreamCtx, body: { messages?: ChatMessage[] }) { + if (ctx.tokenDelta <= 0 && ctx.totalText.length === 0 && ctx.thinkingText.length === 0) return; + const usage = buildCursorUsage(ctx, body); + const payload = { + id: ctx.responseId, + object: "chat.completion.chunk", + created: ctx.created, + model: ctx.model, + choices: [], + usage, + }; + ctx.emit(`data: ${JSON.stringify(payload)}\n\n`); +} + +function emitDone(ctx: StreamCtx) { + ctx.emit("data: [DONE]\n\n"); +} + +/** + * Process one decoded Connect-RPC frame payload: dispatch ExecServerMessage + * events (rejection / context ack / mcp_args), decode AgentServerMessage + * interaction updates, and emit OpenAI SSE deltas for any text content. + * + * Returns true if an end-of-response signal was observed. + * + * The h2 `req` (used to write rejection acks back on the same stream) is + * passed via opts so this function works for both the streaming h2 path + * and the buffered fetch fallback (where opts.req is undefined). + * + * Mutates `ackedExecIds` so each exec_id is dispatched exactly once even + * when the same payload is seen multiple times during incremental decoding. + */ +export function processFrame( + payload: Buffer, + ctx: StreamCtx, + ackedExecIds: Set, + opts: { + h2Req?: import("http2").ClientHttp2Stream; + mcpTools?: McpToolDefinition[]; + blobStore?: Map; + } = {} +): void { + // 1. JSON error envelope (Connect-RPC style — usually status > 200). + const jsonError = tryParseJsonError(payload); + if (jsonError) { + if (ctx.totalText.length === 0) { + ctx.midStreamError = jsonError; + ctx.endReason = "server_end"; + } else { + // Already streamed content — terminate cleanly. + ctx.endReason = "server_end"; + } + return; + } + + // 2a. KV server message: cursor requesting a blob (system prompt) or + // saving an assistant turn. We reply on the same stream so the model + // proceeds. The opaque request_metadata is echoed so cursor can match + // request to response. + const kvEvent = decodeKvServerEvent(payload); + if (kvEvent && opts.h2Req) { + if (kvEvent.kind === "kv_get_blob") { + const hex = kvEvent.blobId.toString("hex"); + const blob = opts.blobStore?.get(hex) ?? Buffer.alloc(0); + try { + opts.h2Req.write(encodeKvGetBlobResult(kvEvent.kvId, blob, kvEvent.requestMetadata)); + } catch {} + } else if (kvEvent.kind === "kv_set_blob") { + if (opts.blobStore) { + opts.blobStore.set(kvEvent.blobId.toString("hex"), kvEvent.blobData); + } + try { + opts.h2Req.write(encodeKvSetBlobResult(kvEvent.kvId, kvEvent.requestMetadata)); + } catch {} + } + } + + // 2b. ExecServerMessage dispatch (request_context, built-in rejection, mcp). + // Dedup by kind+execId+execMsgId — request_context and mcp_args both + // arrive with empty execId in the current cursor schema, so a single + // execId-only set would collapse them. + const event = decodeExecServerEvent(payload); + const dedupKey = event ? `${event.kind}:${event.execId}:${event.execMsgId}` : ""; + if (event && !ackedExecIds.has(dedupKey)) { + ackedExecIds.add(dedupKey); + if (event.kind === "exec_request_context") { + if (opts.h2Req) { + try { + // Cursor receives tools via AgentRunRequest.mcp_tools (request body) + // — sending them again in the request_context ack causes the + // server to stall silently. Empty ack only. + opts.h2Req.write(encodeRequestContextResponse(event.execMsgId, event.execId)); + } catch {} + } + } else if (event.kind === "exec_mcp") { + // Phase 5: surface the model-invoked MCP tool as an OpenAI tool_calls + // SSE delta. Two chunks are emitted per call: an init chunk with the + // tool's id+name+empty args, then a chunk with the JSON-stringified + // args. Parallel tool calls share one finish chunk (Phase 8 closes). + if (!ctx.emittedRoleChunk) { + emitChunk(ctx, { role: "assistant", content: "" }); + ctx.emittedRoleChunk = true; + } + const idx = ctx.emittedToolCallIndex++; + const openAIToolCallId = generateToolCallId(); + const argumentsJson = JSON.stringify(event.args ?? {}); + emitChunk(ctx, { + tool_calls: [ + { + index: idx, + id: openAIToolCallId, + type: "function", + function: { name: event.toolName, arguments: "" }, + }, + ], + }); + emitChunk(ctx, { + tool_calls: [ + { + index: idx, + function: { arguments: argumentsJson }, + }, + ], + }); + ctx.toolCalls.push({ + id: openAIToolCallId, + name: event.toolName, + argumentsJson, + }); + // Phase 6: remember the cursor exec ids so a follow-up role:"tool" + // message can be replied with encodeExecMcpResult on the open h2 stream. + ctx.pendingToolCalls.set(openAIToolCallId, { + execMsgId: event.execMsgId, + execId: event.execId, + toolName: event.toolName, + }); + // Cursor pauses after mcp_args waiting for the client to either send + // a tool result via ExecMcpResult or close the stream. We mark + // endReason now so driveH2 returns; the session manager keeps the h2 + // alive for the next OpenAI call (which arrives with role:"tool"). + ctx.endReason = "tool_calls"; + } else { + const rejection = buildExecRejection(event); + if (rejection && opts.h2Req) { + try { + opts.h2Req.write(rejection); + } catch {} + } + } + } + + // 3. Interaction update deltas → OpenAI SSE chunks. + let deltas; + try { + deltas = decodeAgentServerMessage(payload); + } catch (err) { + debugLog("[cursor-agent] decode failed:", (err as Error).message); + return; + } + for (const d of deltas) { + if (d.kind === "text" && d.text) { + if (!ctx.emittedRoleChunk) { + emitChunk(ctx, { role: "assistant", content: "" }); + ctx.emittedRoleChunk = true; + } + ctx.totalText += d.text; + ctx.receivedText = true; + emitChunk(ctx, { content: d.text }); + } else if (d.kind === "thinking" && d.text) { + if (!ctx.emittedRoleChunk) { + emitChunk(ctx, { role: "assistant", content: "" }); + ctx.emittedRoleChunk = true; + } + ctx.thinkingText += d.text; + ctx.receivedText = true; + emitChunk(ctx, { reasoning_content: d.text }); + } else if (d.kind === "token_delta") { + ctx.tokenDelta += d.tokens; + } else if (d.kind === "turn_ended") { + ctx.endReason = "turn_ended"; + } else if (d.kind === "tool_call_completed" && ctx.toolCalls.length > 0) { + // Phase 6: model paused awaiting tool result. driveH2 returns but the + // h2 stream stays open — the session manager keeps it alive for the + // next OpenAI call (which will arrive with role:"tool" results). + ctx.endReason = "tool_calls"; + } else if (d.kind === "kv_server_message" && ctx.receivedText) { + // Cursor short-circuits turn_ended for plain chats — kv_server_message + // after text means the model finished and the server is saving the + // turn. Phase 8 keeps both signals as defense-in-depth. + ctx.kvAfterTextSeen = true; + ctx.endReason = "kv_after_text"; + } + } +} + export class CursorExecutor extends BaseExecutor { constructor() { super("cursor", PROVIDERS.cursor); } buildUrl() { - return `${this.config.baseUrl}${this.config.chatPath || ""}`; - } - - // Jyh cipher checksum for Cursor API authentication - generateChecksum(machineId) { - const timestamp = Math.floor(Date.now() / 1000000); - const byteArray = new Uint8Array([ - (timestamp >> 40) & 0xff, - (timestamp >> 32) & 0xff, - (timestamp >> 24) & 0xff, - (timestamp >> 16) & 0xff, - (timestamp >> 8) & 0xff, - timestamp & 0xff, - ]); - - let t = 165; - for (let i = 0; i < byteArray.length; i++) { - byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xff; - t = byteArray[i]; - } - - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let encoded = ""; - - for (let i = 0; i < byteArray.length; i += 3) { - const a = byteArray[i]; - const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0; - const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0; - - encoded += alphabet[a >> 2]; - encoded += alphabet[((a & 3) << 4) | (b >> 4)]; - - if (i + 1 < byteArray.length) { - encoded += alphabet[((b & 15) << 2) | (c >> 6)]; - } - if (i + 2 < byteArray.length) { - encoded += alphabet[c & 63]; - } - } - - return `${encoded}${machineId}`; + return CURSOR_AGENT_URL; } buildHeaders(credentials) { const accessToken = credentials.accessToken; const ghostMode = credentials.providerSpecificData?.ghostMode !== false; - - // Use stored machineId, or derive a stable one from the access token - // (cursor-agent imports don't provide a machineId) - const machineId = - credentials.providerSpecificData?.machineId || - crypto.createHash("sha256").update(accessToken).digest("hex"); - const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken; + const requestId = crypto.randomUUID(); + const traceParent = `00-${crypto.randomBytes(16).toString("hex")}-${crypto.randomBytes(8).toString("hex")}-01`; + // Mirrors cursor-agent's actual headers for agent.v1.AgentService/Run. + // Notably: no x-cursor-checksum, no machineId, no x-amzn-trace-id. + // Only advertise gzip (not brotli) — our Connect-RPC frame decoder + // only handles gzip-compressed message bodies. return { authorization: `Bearer ${cleanToken}`, + "backend-traceparent": traceParent, "connect-accept-encoding": "gzip", "connect-protocol-version": "1", "content-type": "application/connect+proto", - "user-agent": getCursorUserAgent(getCursorVersion()), - "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, - "x-client-key": crypto.createHash("sha256").update(cleanToken).digest("hex"), - "x-cursor-checksum": this.generateChecksum(machineId), - "x-cursor-client-version": getCursorVersion(), - "x-cursor-client-type": "ide", - "x-cursor-client-os": - process.platform === "win32" - ? "windows" - : process.platform === "darwin" - ? "macos" - : "linux", - "x-cursor-client-arch": process.arch === "arm64" ? "aarch64" : "x64", - "x-cursor-client-device-type": "desktop", - "x-cursor-user-agent": getCursorUserAgent(getCursorVersion()), - "x-cursor-config-version": crypto.randomUUID(), - "x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + traceparent: traceParent, + "user-agent": "connect-es/1.6.1", + "x-cursor-client-type": "cli", + "x-cursor-client-version": `cli-${getCursorVersion()}`, "x-ghost-mode": ghostMode ? "true" : "false", - "x-request-id": crypto.randomUUID(), - "x-session-id": uuidv5(cleanToken, uuidv5.DNS), + "x-original-request-id": requestId, + "x-request-id": requestId, }; } - transformRequest(model, body, stream, credentials) { - // 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); - } + /** + * Build the request body and return it alongside the request-scoped + * blobStore. cursor's models (auto, claude-*, gpt-*) don't reliably + * follow system-role content delivered via the KV blob channel — even + * though the blob is requested and our reply is accepted, the model + * proceeds without applying the prompt. + * + * As a pragmatic workaround we prepend the system content into the + * UserMessage text (the pre-Phase-7 behavior). The KV-blob handshake + * machinery is still in place for any future schema where cursor honors + * root_prompt_messages_json semantically — verified end-to-end with + * wire-tap captures. + */ + private buildRequest( + model: string, + body: { messages?: ChatMessage[]; tools?: unknown; conversation_id?: string } + ): { body: Uint8Array; blobStore: Map } { + const messages: ChatMessage[] = body.messages || []; + const tools: OpenAITool[] | undefined = Array.isArray(body.tools) + ? (body.tools as OpenAITool[]) + : undefined; - async makeFetchRequest( - url: string, - headers: Record, - body: Uint8Array, - signal?: AbortSignal - ): Promise { - const response = await fetch(url, { - method: "POST", - headers, - body: body as unknown as BodyInit, - signal, + // flattenMessages prepends any role:"system" messages into the user + // text (proven path that cursor's models honor). + const userText = flattenMessages(messages); + + const blobStore = new Map(); + const requestBody = buildAgentRequestBody({ + modelId: model, + userText, + conversationId: body.conversation_id, + tools, + blobStore, }); - - return { - status: response.status, - headers: Object.fromEntries(response.headers.entries()), - body: Buffer.from(await response.arrayBuffer()), - }; + return { body: requestBody, blobStore }; } - makeHttp2Request( + transformRequest(model, body, _stream, _credentials) { + return this.buildRequest(model, body).body; + } + + // ─── h2 lifecycle: open + drive (Phase 4 streaming refactor) ───────────── + // + // openH2 establishes the bidirectional stream and waits for the response + // headers (so we can decide whether to commit to a streaming SSE Response + // or return an error). driveH2 then consumes data events incrementally, + // dispatching frames through processFrame so SSE chunks land on the + // ReadableStream controller as the upstream produces them. + // + // The fetch fallback (cloud envs without http2) preserves the legacy + // buffer-then-decode behavior — Connect-RPC bidirectional ack-on-same-stream + // can't run over a one-shot fetch anyway. + + private async openH2( url: string, headers: Record, body: Uint8Array, signal?: AbortSignal - ): Promise { - if (!http2) { - throw new Error("http2 module not available"); - } + ): Promise<{ + status: number; + headers: Record; + client: import("http2").ClientHttp2Session; + req: import("http2").ClientHttp2Stream; + initialBytes: Buffer; + consumeError: () => Promise; + }> { + if (!http2) throw new Error("http2 module not available"); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const urlObj = new URL(url); - const client = http2.connect(`https://${urlObj.host}`); - const chunks = []; - let responseHeaders = {}; + const client = http2!.connect(`https://${urlObj.host}`); + const earlyChunks: Buffer[] = []; + let resolved = false; - client.on("error", reject); + client.on("error", (err) => { + if (!resolved) reject(err); + }); const req = client.request({ ":method": "POST", @@ -332,38 +601,188 @@ export class CursorExecutor extends BaseExecutor { ...headers, }); - req.on("response", (hdrs) => { - responseHeaders = hdrs; - }); - req.on("data", (chunk) => { - chunks.push(chunk); - }); - req.on("end", () => { - client.close(); - resolve({ - status: - typeof responseHeaders[":status"] === "number" - ? responseHeaders[":status"] - : Number(responseHeaders[":status"] || HTTP_STATUS.SERVER_ERROR), - headers: responseHeaders, - body: Buffer.concat(chunks), - }); - }); - req.on("error", (err) => { - client.close(); - reject(err); - }); - - if (signal) { - signal.addEventListener("abort", () => { + const onAbort = () => { + try { req.close(); client.close(); - reject(new Error("Request aborted")); - }); - } + } catch {} + if (!resolved) { + resolved = true; + reject(new Error("aborted")); + } + }; + if (signal) signal.addEventListener("abort", onAbort); + req.on("response", (h) => { + if (resolved) return; + resolved = true; + const status = Number(h[":status"] ?? HTTP_STATUS.SERVER_ERROR); + // For non-200 statuses, drain the remaining body for an error message. + // The caller calls consumeError() to await the full body. + const consumeError = () => + new Promise((res) => { + const out = [...earlyChunks]; + req.on("data", (c) => out.push(Buffer.from(c))); + req.on("end", () => { + try { + req.close(); + client.close(); + } catch {} + if (signal) signal.removeEventListener("abort", onAbort); + res(Buffer.concat(out)); + }); + req.on("error", () => { + try { + req.close(); + client.close(); + } catch {} + if (signal) signal.removeEventListener("abort", onAbort); + res(Buffer.concat(out)); + }); + }); + resolve({ + status, + headers: h as Record, + client, + req, + initialBytes: Buffer.concat(earlyChunks), + consumeError, + }); + }); + + // Buffer any data that arrives before the response event resolves. + // (In practice the response event fires first, but this guards against + // implementation differences in node:http2.) + req.on("data", (chunk) => { + if (!resolved) earlyChunks.push(Buffer.from(chunk)); + }); + + req.on("error", (err) => { + if (!resolved) { + resolved = true; + if (signal) signal.removeEventListener("abort", onAbort); + reject(err); + } + }); + + // Bidirectional streaming: write the init message but DO NOT send + // END_STREAM — cursor's server stops responding once we close our side. req.write(body); - req.end(); + }); + } + + /** + * Drive an open h2 stream to completion. processFrame populates ctx as + * each Connect-RPC frame is decoded; the loop closes when ctx.endReason + * is set (turn_ended, kv_after_text, server_end) or the stream errors. + * + * Phase 8 will add a max-stream safety timeout here. + */ + private driveH2( + h2: { + req: import("http2").ClientHttp2Stream; + client: import("http2").ClientHttp2Session; + initialBytes: Buffer; + }, + ctx: StreamCtx, + mcpTools: McpToolDefinition[] | undefined, + blobStore: Map | undefined, + signal?: AbortSignal + ): Promise { + const ackedExecIds = new Set(); + // Rolling buffer: chunks arrive on `data`, get appended, and consumed + // frames are sliced off so we don't re-scan + re-concat on every event + // (avoids O(N²) for long-running streams). + let buf: Buffer = h2.initialBytes.length > 0 ? h2.initialBytes : Buffer.alloc(0); + + return new Promise((resolve, reject) => { + // Phase 8: safety timeout. If neither turn_ended, kv_after_text, nor + // server-end fires within CURSOR_STREAM_TIMEOUT_MS, abort the stream + // so a stuck upstream doesn't keep the response open indefinitely. + const safetyTimer = setTimeout(() => { + if (ctx.endReason) return; + debugLog("[cursor-agent] stream safety timeout fired"); + teardown(); + reject(new Error("cursor-agent stream timed out")); + }, CURSOR_STREAM_TIMEOUT_MS); + + const onData = (chunk: Buffer) => { + if (CURSOR_DEBUG && process.env.CURSOR_DUMP_FILE) { + fs.appendFileSync(process.env.CURSOR_DUMP_FILE, chunk); + } + buf = buf.length === 0 ? Buffer.from(chunk) : Buffer.concat([buf, chunk]); + tryScan(); + }; + const onEnd = () => { + if (!ctx.endReason) ctx.endReason = "server_end"; + detachListeners(); + resolve(); + }; + const onErr = (err: Error) => { + teardown(); + reject(err); + }; + const onAbort = () => { + teardown(); + reject(new Error("aborted")); + }; + + // detachListeners removes data/end/error/abort handlers and clears the + // safety timer. Called on successful resolve when the caller keeps the + // h2 alive (Phase 6 session reuse). + const detachListeners = () => { + clearTimeout(safetyTimer); + h2.req.off("data", onData); + h2.req.off("end", onEnd); + h2.req.off("error", onErr); + if (signal) signal.removeEventListener("abort", onAbort); + }; + // teardown additionally closes the h2 stream. Used on error / abort / + // safety-timeout — the connection isn't worth keeping at that point. + const teardown = () => { + detachListeners(); + try { + h2.req.close(); + h2.client.close(); + } catch {} + }; + + if (signal) signal.addEventListener("abort", onAbort); + + const tryScan = () => { + let pos = 0; + while (pos + 5 <= buf.length) { + const length = buf.readUInt32BE(pos + 1); + if (pos + 5 + length > buf.length) break; // partial frame; wait + const flag = buf[pos]; + const raw = buf.subarray(pos + 5, pos + 5 + length); + // Per-frame error isolation: if gunzip or processFrame throws on + // one frame, log and skip past it instead of getting stuck on + // the same offset and hanging until the safety timer fires. + try { + const payload = flag & 0x1 ? zlib.gunzipSync(raw) : raw; + processFrame(payload, ctx, ackedExecIds, { h2Req: h2.req, mcpTools, blobStore }); + } catch (err) { + debugLog("[cursor-agent] frame decode failed at pos", pos, ":", (err as Error).message); + } + pos += 5 + length; + if (ctx.endReason) { + buf = buf.subarray(pos); + detachListeners(); + resolve(); + return; + } + } + // Splice off processed bytes so the buffer stays bounded. + if (pos > 0) buf = buf.subarray(pos); + }; + + h2.req.on("data", onData); + h2.req.on("end", onEnd); + h2.req.on("error", onErr); + + // Process any bytes already buffered from openH2. + tryScan(); }); } @@ -371,535 +790,316 @@ export class CursorExecutor extends BaseExecutor { const url = this.buildUrl(); const headers = this.buildHeaders(credentials); mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); - const transformedBody = await this.transformRequest(model, body, stream, credentials); - try { - const response: CursorHttpResponse = http2 - ? await this.makeHttp2Request(url, headers, transformedBody, signal) - : await this.makeFetchRequest(url, headers, transformedBody, signal); + const messages: ChatMessage[] = body.messages || []; + const conversationId: string = + typeof body.conversation_id === "string" && body.conversation_id + ? body.conversation_id + : crypto.randomUUID(); + const lastMessage = messages[messages.length - 1]; + const isToolFollowUp = lastMessage?.role === "tool"; - if (response.status !== 200) { - const errorText = response.body?.toString() || "Unknown error"; - const errorResponse = new Response( - JSON.stringify({ - error: { - message: `[${response.status}]: ${errorText}`, - type: "invalid_request_error", - code: "", - }, - }), - { - status: response.status, - headers: { "Content-Type": "application/json" }, - } - ); - return { response: errorResponse, url, headers, transformedBody: body }; + // Tools embedded in the RequestContext ack throughout the turn — + // synced with mcp_tools in the encoded request body. + const mcpTools: McpToolDefinition[] | undefined = Array.isArray(body.tools) + ? openAIToolsToMcpDefs(body.tools as OpenAITool[]) + : undefined; + + const buildErrorResponse = (status: number, message: string, type = "invalid_request_error") => + new Response(JSON.stringify({ error: { message, type, code: "" } }), { + status, + headers: { "Content-Type": "application/json" }, + }); + + // Cursor's agent.v1.AgentService/Run is a bidirectional Connect-RPC: + // request_context, KV blob lookups, and exec rejections must be + // written back on the same h2 stream while the response is still + // being read. One-shot fetch can't do that, so cloud/edge runtimes + // without node:http2 cannot drive cursor at all — fail fast with a + // clear error rather than silently producing incomplete output. + if (!http2) { + return { + response: buildErrorResponse( + 501, + "Cursor provider requires Node.js http2, which is unavailable in this runtime (Edge / Cloudflare Workers / similar). Run OmniRoute on a Node.js runtime to use cursor.", + "unsupported_runtime" + ), + url, + headers, + transformedBody: body, + }; + } + + // ── h2 path with inline session manager (Phase 6) ── + // + // 1. If this is a tool-result follow-up (last message role:"tool") AND + // we have an alive session for the conversation, send the tool + // result on the existing h2 stream (inline resume). + // 2. Otherwise, open a fresh h2 stream, send a new RunRequest, and + // register it as a session. + // + // Cold-resume fallback (acquire returns undefined, or sendToolResult + // doesn't match): always lands on path #2, which now flattens the full + // history (including role:"tool" messages) into UserText via + // flattenMessages. + + type H2Like = { + req: import("http2").ClientHttp2Stream; + client: import("http2").ClientHttp2Session; + initialBytes: Buffer; + }; + + let session: CursorSession | undefined; + let h2: H2Like; + let blobStore: Map; + + if (isToolFollowUp) { + session = cursorSessionManager.acquire(conversationId); + } + + if (session) { + // Inline resume: send ExecMcpResult only for tool messages whose + // tool_call_id is currently pending in this session. Older tool + // messages from prior turns are already consumed by cursor and + // sit in the request history harmlessly — sending them again + // would either be a no-op or wedge the session, so we skip. + // We require at least one match so we don't reuse the session + // for a request that has no relevant tool results. + blobStore = session.blobStore; + let matched = 0; + let hadFailure = false; + for (const msg of messages) { + if (msg.role !== "tool") continue; + const id = msg.tool_call_id ?? ""; + if (!session.pendingToolCalls.has(id)) continue; + const content = typeof msg.content === "string" ? msg.content : ""; + if (cursorSessionManager.sendToolResult(session, id, content, false)) { + matched++; + } else { + hadFailure = true; + break; + } } + if (matched === 0 || hadFailure) { + cursorSessionManager.close(session); + session = undefined; + } else { + h2 = { + client: session.h2Client, + req: session.h2Req, + initialBytes: Buffer.alloc(0), + }; + } + } - const transformedResponse = - stream !== false - ? this.transformProtobufToSSE(response.body, model, body) - : this.transformProtobufToJSON(response.body, model, body); + if (!session) { + // Cold path: open fresh h2 stream with the full message history + // flattened into UserText (Phase 6 flattenMessages handles role:"tool" + // and assistant.tool_calls). + const built = this.buildRequest(model, body); + blobStore = built.blobStore; + let opened; + try { + opened = await this.openH2(url, headers, built.body, signal); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + response: buildErrorResponse(HTTP_STATUS.SERVER_ERROR, message, "connection_error"), + url, + headers, + transformedBody: body, + }; + } + if (opened.status !== 200) { + const errBuf = await opened.consumeError(); + const errText = errBuf.toString("utf8") || "Unknown error"; + return { + response: buildErrorResponse(opened.status, `[${opened.status}]: ${errText}`), + url, + headers, + transformedBody: body, + }; + } + h2 = opened; + session = cursorSessionManager.open(conversationId, opened.client, opened.req, blobStore); + } - return { response: transformedResponse, url, headers, transformedBody: body }; - } catch (error) { - const errorResponse = new Response( + // Closure to share the post-drive lifecycle between stream/non-stream paths. + const sessionToUse = session; + const finishLifecycle = (ctx: StreamCtx, errored: boolean) => { + // Persist any new pendingToolCalls from this turn into the session. + for (const [id, info] of ctx.pendingToolCalls) { + sessionToUse.pendingToolCalls.set(id, info); + } + if (errored || ctx.endReason !== "tool_calls") { + cursorSessionManager.close(sessionToUse); + } else { + cursorSessionManager.release(sessionToUse, "awaiting_tool_result"); + } + }; + + // Stream mode: ReadableStream that emits SSE chunks as they're decoded. + if (stream !== false) { + const enc = new TextEncoder(); + const sseStream = new ReadableStream({ + start: async (controller) => { + const ctx = newStreamCtx(model, (s) => controller.enqueue(enc.encode(s))); + try { + await this.driveH2(h2, ctx, mcpTools, blobStore, signal); + this.finalizeSseStream(ctx, body); + finishLifecycle(ctx, false); + controller.close(); + } catch (err) { + finishLifecycle(ctx, true); + controller.error(err); + } + }, + }); + return { + response: new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url, + headers, + transformedBody: body, + }; + } + + // Non-streaming: drive to completion, return chat.completion JSON. + const ctx = newStreamCtx(model, () => {}); + try { + await this.driveH2(h2, ctx, mcpTools, blobStore, signal); + } catch (err) { + finishLifecycle(ctx, true); + const message = err instanceof Error ? err.message : String(err); + return { + response: buildErrorResponse(HTTP_STATUS.SERVER_ERROR, message, "connection_error"), + url, + headers, + transformedBody: body, + }; + } + finishLifecycle(ctx, false); + return { + response: this.buildResponseFromCtx(ctx, body), + url, + headers, + transformedBody: body, + }; + } + + /** + * Emit the trailing SSE chunks (finish + usage + DONE) onto an already-open + * stream. Called once driveH2 returns and ctx.endReason is set. The + * mid-stream-error path emits an error chunk instead. + */ + private finalizeSseStream(ctx: StreamCtx, body: { messages?: ChatMessage[] }) { + if (ctx.midStreamError && ctx.totalText.length === 0) { + const payload = { + id: ctx.responseId, + object: "chat.completion.chunk", + created: ctx.created, + model: ctx.model, + choices: [], + error: { + message: ctx.midStreamError.message, + type: + ctx.midStreamError.status === HTTP_STATUS.RATE_LIMITED + ? "rate_limit_error" + : "api_error", + }, + }; + ctx.emit(`data: ${JSON.stringify(payload)}\n\n`); + ctx.emit("data: [DONE]\n\n"); + return; + } + if (!ctx.emittedRoleChunk) { + // Edge case: empty response. Emit a role chunk so clients see at least + // one delta before finish. + emitChunk(ctx, { role: "assistant", content: "" }); + } + // OpenAI finish_reason: "tool_calls" if the model invoked any declared + // tool, else "stop". A turn with mixed text + tool_calls finishes with + // "tool_calls" (the tool calls are the actionable signal for the client). + const finishReason = ctx.toolCalls.length > 0 ? "tool_calls" : "stop"; + emitChunk(ctx, {}, finishReason); + emitUsage(ctx, body); + emitDone(ctx); + } + + /** + * Build a non-streaming chat.completion JSON Response from a fully-driven + * StreamCtx. The streaming path emits chunks live via finalizeSseStream + * and never calls this method. + */ + private buildResponseFromCtx(ctx: StreamCtx, body: { messages?: ChatMessage[] }): Response { + if (ctx.midStreamError && ctx.totalText.length === 0) { + return new Response( JSON.stringify({ error: { - message: error.message, - type: "connection_error", - code: "", + message: ctx.midStreamError.message, + type: + ctx.midStreamError.status === HTTP_STATUS.RATE_LIMITED + ? "rate_limit_error" + : "api_error", }, }), { - status: HTTP_STATUS.SERVER_ERROR, + status: ctx.midStreamError.status, headers: { "Content-Type": "application/json" }, } ); - return { response: errorResponse, url, headers, transformedBody: body }; - } - } - - transformProtobufToJSON(buffer, model, body) { - const responseId = `chatcmpl-cursor-${Date.now()}`; - const created = Math.floor(Date.now() / 1000); - - let offset = 0; - 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`); - - while (offset < buffer.length) { - if (offset + 5 > buffer.length) { - debugLog( - `[CURSOR BUFFER] Reached end, offset=${offset}, remaining=${buffer.length - offset}` - ); - break; - } - - const flags = buffer[offset]; - const length = buffer.readUInt32BE(offset + 1); - - debugLog( - `[CURSOR BUFFER] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}` - ); - - if (offset + 5 + length > buffer.length) { - debugLog( - `[CURSOR BUFFER] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}` - ); - break; - } - - let payload = buffer.slice(offset + 5, offset + 5 + length); - offset += 5 + length; - frameCount++; - - payload = decompressPayload(payload, flags); - if (!payload) { - debugLog(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`); - continue; - } - - // 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)); - } - } catch {} - } - - const result = extractTextFromResponse(new Uint8Array(payload)); - debugLog(`[CURSOR DECODED] Frame ${frameCount}:`, result); - - if (result.error) { - const hasContent = totalContent || toolCallsMap.size > 0; - debugLog(`[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: { - message: result.error, - type: "rate_limit_error", - code: "rate_limited", - }, - }), - { - status: HTTP_STATUS.RATE_LIMITED, - headers: { "Content-Type": "application/json" }, - } - ); - } - - if (result.toolCall) { - const tc = result.toolCall; - - if (toolCallsMap.has(tc.id)) { - // Accumulate arguments for existing tool call - const existing = toolCallsMap.get(tc.id); - existing.function.arguments += tc.function.arguments; - existing.isLast = tc.isLast; - } else { - // New tool call - toolCallsMap.set(tc.id, { ...tc }); - } - - // 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, - function: { - name: finalToolCall.function.name, - arguments: finalToolCall.function.arguments, - }, - }); - } - } - - if (result.text) totalContent += result.text; } - debugLog( - `[CURSOR BUFFER] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, finalized toolCalls: ${toolCalls.length}` - ); - - // 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 (!finalizedIds.has(id)) { - debugLog(`[CURSOR BUFFER] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`); - toolCalls.push({ - id: tc.id, - type: tc.type, - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - }); - } - } - - debugLog(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`); - - const message: Record = { + // Non-streaming: chat.completion shape. Include tool_calls in the + // assistant message when the model invoked any (Phase 5). + const usage = buildCursorUsage(ctx, body); + const finishReason = ctx.toolCalls.length > 0 ? "tool_calls" : "stop"; + const message: { + role: "assistant"; + content: string | null; + reasoning_content?: string; + tool_calls?: Array<{ + id: string; + type: "function"; + function: { name: string; arguments: string }; + }>; + } = { role: "assistant", - content: totalContent || null, + content: ctx.totalText.length > 0 ? ctx.totalText : null, }; - - if (toolCalls.length > 0) { - message.tool_calls = toolCalls; + if (ctx.thinkingText.length > 0) { + message.reasoning_content = ctx.thinkingText; } - - const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI); - - const completion = { - id: responseId, - object: "chat.completion", - created, - model, - choices: [ - { - index: 0, - message, - finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop", - }, - ], - usage, - }; - - return new Response(JSON.stringify(completion), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - - transformProtobufToSSE(buffer, model, body) { - const responseId = `chatcmpl-cursor-${Date.now()}`; - const created = Math.floor(Date.now() / 1000); - - const chunks = []; - let offset = 0; - 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`); - - while (offset < buffer.length) { - if (offset + 5 > buffer.length) { - debugLog( - `[CURSOR BUFFER SSE] Reached end, offset=${offset}, remaining=${buffer.length - offset}` - ); - break; - } - - const flags = buffer[offset]; - const length = buffer.readUInt32BE(offset + 1); - - debugLog( - `[CURSOR BUFFER SSE] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}` - ); - - if (offset + 5 + length > buffer.length) { - debugLog( - `[CURSOR BUFFER SSE] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}` - ); - break; - } - - let payload = buffer.slice(offset + 5, offset + 5 + length); - offset += 5 + length; - frameCount++; - - payload = decompressPayload(payload, flags); - if (!payload) { - debugLog(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`); - continue; - } - - // 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)); - } - } catch {} - } - - const result = extractTextFromResponse(new Uint8Array(payload)); - debugLog(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result); - - if (result.error) { - const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0; - debugLog(`[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: { - message: result.error, - type: "rate_limit_error", - code: "rate_limited", - }, - }), - { - status: HTTP_STATUS.RATE_LIMITED, - headers: { "Content-Type": "application/json" }, - } - ); - } - - if (result.toolCall) { - const tc = result.toolCall; - - if (chunks.length === 0) { - chunks.push( - `data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: { role: "assistant", content: "" }, - finish_reason: null, - }, - ], - })}\n\n` - ); - } - - if (toolCallsMap.has(tc.id)) { - // Accumulate arguments for existing tool call - const existing = toolCallsMap.get(tc.id); - const oldArgsLen = existing.function.arguments.length; - existing.function.arguments += tc.function.arguments; - existing.isLast = tc.isLast; - - // Stream the delta arguments - if (tc.function.arguments) { - emittedToolCallIds.add(tc.id); - chunks.push( - `data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: existing.index, - id: tc.id, - type: "function", - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - }, - ], - }, - finish_reason: null, - }, - ], - })}\n\n` - ); - } - } 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, - 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 (result.text) { - totalContent += result.text; - chunks.push( - `data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: - chunks.length === 0 && toolCalls.length === 0 - ? { role: "assistant", content: result.text } - : { content: result.text }, - finish_reason: null, - }, - ], - })}\n\n` - ); - } + if (ctx.toolCalls.length > 0) { + message.tool_calls = ctx.toolCalls.map((tc) => ({ + id: tc.id, + type: "function", + function: { name: tc.name, arguments: tc.argumentsJson }, + })); } - - 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 (!finalizedIds.has(id)) { - debugLog(`[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 (!emittedToolCallIds.has(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({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: { role: "assistant", content: "" }, - finish_reason: null, - }, - ], - })}\n\n` - ); - } - - const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI); - - chunks.push( - `data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, + return new Response( + JSON.stringify({ + id: ctx.responseId, + object: "chat.completion", + created: ctx.created, + model: ctx.model, choices: [ { index: 0, - delta: {}, - finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop", + message, + finish_reason: finishReason, }, ], usage, - })}\n\n` + }), + { status: 200, headers: { "Content-Type": "application/json" } } ); - chunks.push("data: [DONE]\n\n"); - - return new Response(chunks.join(""), { - status: 200, - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); } async refreshCredentials() { diff --git a/open-sse/services/cursorSessionManager.ts b/open-sse/services/cursorSessionManager.ts new file mode 100644 index 0000000000..93969a8501 --- /dev/null +++ b/open-sse/services/cursorSessionManager.ts @@ -0,0 +1,167 @@ +/** + * CursorSessionManager — keeps cursor's h2 streams alive across OpenAI calls + * so a tool-using turn can complete inline. + * + * cursor's `agent.v1.AgentService/Run` is bidirectional. When the model + * invokes an MCP tool it pauses and waits for a `ExecClientMessage.McpResult` + * on the SAME stream. Closing the stream between the OpenAI tool_calls + * response and the role:"tool" follow-up loses the exec_id mapping cursor + * needs to resume. + * + * This manager solves that by retaining the open h2 stream (keyed by + * conversation_id) when the executor reports endReason="tool_calls". The + * next OpenAI call with role:"tool" reacquires the session, replies via + * encodeExecMcpResult on the live stream, and continues driving until + * turn_ended. + * + * Multi-instance considerations: sessions live in process memory. If a + * follow-up call lands on a different OmniRoute instance, acquire() returns + * undefined and the executor falls back to cold-resume (fresh RunRequest + * with all history flattened into UserText). Cold-resume is correctness- + * preserving but loses the inline efficiency. + * + * Concurrency: one in-flight call per session. The acquire/release pattern + * keeps a session in "awaiting_tool_result" between calls; if a second call + * arrives while the first is still running, acquire() returns undefined and + * the second falls back to cold-resume. + * + * TTL: sessions evict after CURSOR_SESSION_IDLE_TTL_MS (default 5min). The + * sweep runs lazily on every acquire/release rather than via setInterval to + * keep this module test-friendly. + */ + +import type { ClientHttp2Session, ClientHttp2Stream } from "node:http2"; +import { encodeExecMcpResult } from "../utils/cursorAgentProtobuf.ts"; + +const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000; + +export type CursorSession = { + conversationId: string; + h2Client: ClientHttp2Session; + h2Req: ClientHttp2Stream; + blobStore: Map; + pendingToolCalls: Map; + state: "running" | "awaiting_tool_result" | "closed"; + lastActivityTs: number; +}; + +export class CursorSessionManager { + private sessions = new Map(); + private idleTtlMs: number; + + constructor(opts: { idleTtlMs?: number } = {}) { + this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; + } + + /** + * Try to reacquire an existing session for this conversation. Returns + * undefined if there isn't one, if it's still running, or if it's idle + * past the TTL (in which case it's closed as a side-effect). + */ + acquire(conversationId: string): CursorSession | undefined { + this.evictExpired(); + const session = this.sessions.get(conversationId); + if (!session) return undefined; + if (session.state !== "awaiting_tool_result") return undefined; + session.state = "running"; + session.lastActivityTs = Date.now(); + return session; + } + + /** + * Register a freshly-opened h2 stream as the session for this conversation. + * Any pre-existing session for the same conversation is closed first. + */ + open( + conversationId: string, + h2Client: ClientHttp2Session, + h2Req: ClientHttp2Stream, + blobStore: Map + ): CursorSession { + const existing = this.sessions.get(conversationId); + if (existing) this.close(existing); + const session: CursorSession = { + conversationId, + h2Client, + h2Req, + blobStore, + pendingToolCalls: new Map(), + state: "running", + lastActivityTs: Date.now(), + }; + this.sessions.set(conversationId, session); + return session; + } + + /** + * Mark a session as no longer in-flight. If finalState is + * "awaiting_tool_result" the h2 stream stays open and the next acquire() + * for this conversation_id can reuse it. If "idle" or "closed" the + * h2 is torn down here. + */ + release(session: CursorSession, finalState: "awaiting_tool_result" | "idle" | "closed"): void { + session.lastActivityTs = Date.now(); + if (finalState === "awaiting_tool_result") { + session.state = "awaiting_tool_result"; + return; + } + this.close(session); + } + + close(session: CursorSession): void { + session.state = "closed"; + try { + session.h2Req.close(); + } catch {} + try { + session.h2Client.close(); + } catch {} + this.sessions.delete(session.conversationId); + } + + /** + * Send an MCP tool result on this session's open h2 stream. Returns true + * if the openAIToolCallId matched a pending call we'd previously seen + * mcp_args for; false otherwise (caller should fall back to cold-resume). + */ + sendToolResult( + session: CursorSession, + openAIToolCallId: string, + content: string, + isError: boolean + ): boolean { + const pending = session.pendingToolCalls.get(openAIToolCallId); + if (!pending) return false; + try { + session.h2Req.write(encodeExecMcpResult(pending.execMsgId, pending.execId, content, isError)); + session.pendingToolCalls.delete(openAIToolCallId); + session.lastActivityTs = Date.now(); + return true; + } catch { + return false; + } + } + + private evictExpired(): void { + const now = Date.now(); + for (const session of this.sessions.values()) { + if (now - session.lastActivityTs > this.idleTtlMs) { + this.close(session); + } + } + } + + // ─── Test / introspection helpers ──────────────────────────────────────── + + size(): number { + return this.sessions.size; + } + + has(conversationId: string): boolean { + return this.sessions.has(conversationId); + } +} + +// Module-level singleton — one manager per OmniRoute process. The executor +// imports this directly. For testing, construct a fresh CursorSessionManager. +export const cursorSessionManager = new CursorSessionManager(); diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts new file mode 100644 index 0000000000..5fcf0710b2 --- /dev/null +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -0,0 +1,1369 @@ +/** + * Hand-rolled protobuf encoder/decoder for Cursor's `agent.v1.AgentService/Run` + * RPC, the endpoint cursor-agent uses for everything (chat + composer + auto). + * + * Replaces the legacy aiserver.v1.ChatService/StreamUnifiedChatWithTools path, + * which doesn't accept "auto" or "composer-*" model ids. + * + * Schema sourced from: + * - On-the-wire captures of cursor-agent (decoded against the protobuf + * descriptor shipped in cursor-agent's bundle) + * - Cross-checked against router-for-me/CLIProxyAPI's reference Go impl + * and KooshaPari/cliproxyapi-plusplus's hand-rolled field tables + * + * The endpoint is a Connect-RPC client-streaming RPC. We send one frame + * (AgentClientMessage with a RunRequest) and end the stream; the server + * streams back an AgentServerMessage per chunk. + */ + +import zlib from "node:zlib"; +import crypto from "node:crypto"; + +// ─── Field numbers (from agent.proto descriptor) ─────────────────────────── + +const ACM_RUN_REQUEST = 1; // AgentClientMessage.run_request + +const ARR_CONVERSATION_STATE = 1; // AgentRunRequest.conversation_state +const ARR_ACTION = 2; // AgentRunRequest.action +const ARR_CONVERSATION_ID = 5; // AgentRunRequest.conversation_id +const ARR_MCP_TOOLS = 4; // AgentRunRequest.mcp_tools (empty placeholder required) +const ARR_REQUESTED_MODEL = 9; // AgentRunRequest.requested_model +const ARR_UNKNOWN_12 = 12; // observed varint=0 in cursor-agent traffic +const ARR_REQUEST_ID = 16; // observed UUID, same value as conversation_id + +const CSS_ROOT_PROMPT = 1; // ConversationStateStructure.root_prompt_messages_json +const CSS_TURNS = 8; // ConversationStateStructure.turns + +const CA_USER_MESSAGE_ACTION = 1; // ConversationAction.user_message_action + +const UMA_USER_MESSAGE = 1; // UserMessageAction.user_message + +const UM_TEXT = 1; // UserMessage.text +const UM_MESSAGE_ID = 2; // UserMessage.message_id +const UM_SELECTED_CONTEXT = 3; // UserMessage.selected_context (empty placeholder required) +const UM_MODE = 4; // UserMessage.mode (cursor-agent sends 1) + +const RM_MODEL_ID = 1; // RequestedModel.model_id +const RM_PARAMETERS = 3; // RequestedModel.parameters [repeated] + +const RMP_ID = 1; // RequestedModel.ModelParameter.id +const RMP_VALUE = 2; // RequestedModel.ModelParameter.value + +const ACM_EXEC_CLIENT_MESSAGE = 2; // AgentClientMessage.exec_client_message + +const ECM_ID = 1; // ExecClientMessage.id +const ECM_EXEC_ID = 15; // ExecClientMessage.exec_id +const ECM_REQUEST_CONTEXT_RESULT = 10; // ExecClientMessage.request_context_result + +const RCR_SUCCESS = 1; // RequestContextResult.success +const RCS_REQUEST_CONTEXT = 1; // RequestContextSuccess.request_context + +const ASM_INTERACTION_UPDATE = 1; // AgentServerMessage.interaction_update +const ASM_EXEC_SERVER_MESSAGE = 2; // AgentServerMessage.exec_server_message +const ASM_KV_SERVER_MESSAGE = 4; // AgentServerMessage.kv_server_message +// Cursor sends kv_server_message frames once the model stops generating +// (it saves the assistant turn into a blob). For non-tool-calling chats +// this functions as our end-of-response marker. + +const ESM_ID = 1; // ExecServerMessage.id +const ESM_EXEC_ID = 15; // ExecServerMessage.exec_id +const ESM_REQUEST_CONTEXT_ARGS = 10; // ExecServerMessage.request_context_args + +const IU_TEXT_DELTA = 1; // InteractionUpdate.text_delta +const IU_THINKING_DELTA = 4; // InteractionUpdate.thinking_delta +const IU_THINKING_COMPLETED = 5; +const IU_TOOL_CALL_STARTED = 2; +const IU_TOOL_CALL_COMPLETED = 3; +const IU_TOKEN_DELTA = 8; +const IU_HEARTBEAT = 13; +const IU_TURN_ENDED = 14; + +const TDU_TEXT = 1; // TextDeltaUpdate.text + +// ─── Phase 1+: tool-use field numbers ────────────────────────────────────── +// Field numbers in result-message oneof discriminators (RES_*) are best-known +// values; verified against wire-tap captures during integration testing. + +const ACM_KV_CLIENT_MESSAGE = 3; // AgentClientMessage.kv_client_message + +// CSS_ROOT_PROMPT and CSS_TURNS already declared above (lines 34-35) +// CSS_TURNS_OLD = 2 is deprecated; CSS_TURNS = 8 is current. + +// ExecClientMessage payload variants (mirror ESM_*) +const ECM_SHELL_RESULT = 2; +const ECM_WRITE_RESULT = 3; +const ECM_DELETE_RESULT = 4; +const ECM_GREP_RESULT = 5; +const ECM_READ_RESULT = 7; +const ECM_LS_RESULT = 8; +const ECM_DIAGNOSTICS_RESULT = 9; +const ECM_MCP_RESULT = 11; +const ECM_BACKGROUND_SHELL_SPAWN_RES = 16; +const ECM_FETCH_RESULT = 20; +const ECM_WRITE_SHELL_STDIN_RESULT = 23; + +// ExecServerMessage variant tags (used by exec router in Phase 2) +const ESM_SHELL_ARGS = 2; +const ESM_WRITE_ARGS = 3; +const ESM_DELETE_ARGS = 4; +const ESM_GREP_ARGS = 5; +const ESM_READ_ARGS = 7; +const ESM_LS_ARGS = 8; +const ESM_DIAGNOSTICS_ARGS = 9; +const ESM_MCP_ARGS = 11; +const ESM_SHELL_STREAM_ARGS = 14; +const ESM_BACKGROUND_SHELL_SPAWN = 16; +const ESM_FETCH_ARGS = 20; +const ESM_WRITE_SHELL_STDIN_ARGS = 23; + +// Args sub-message field numbers (path and shell variants) +const ARG_PATH = 1; // ReadArgs.path / WriteArgs.path / DeleteArgs.path / LsArgs.path +const ARG_SHELL_COMMAND = 1; // ShellArgs.command +const ARG_SHELL_WORKING_DIR = 2; // ShellArgs.working_directory +const ARG_FETCH_URL = 1; // FetchArgs.url + +// KvServerMessage / KvClientMessage +const KSM_ID = 1; +const KSM_GET_BLOB_ARGS = 2; +const KSM_SET_BLOB_ARGS = 3; +// Field 4 of KvServerMessage is an opaque request-correlation/metadata +// envelope — observed in real wire captures. The exact schema isn't +// public; we capture its raw bytes and echo them back in our reply +// so cursor can match request to response. +const KSM_REQUEST_METADATA = 4; +const KCM_ID = 1; +const KCM_GET_BLOB_RESULT = 2; +const KCM_SET_BLOB_RESULT = 3; +const KCM_REQUEST_METADATA = 4; + +// GetBlobArgs / GetBlobResult / SetBlobArgs +const GBA_BLOB_ID = 1; // GetBlobArgs.blob_id (bytes) +const SBA_BLOB_ID = 1; // SetBlobArgs.blob_id (bytes) +const SBA_BLOB_DATA = 2; // SetBlobArgs.blob_data (bytes) +const GBR_BLOB_DATA = 1; // GetBlobResult.blob_data (bytes) — verified by wire test (cursor parses field 1 as JSON) + +// Rejection sub-messages (path-based: read/write/delete/ls) +const REJ_PATH = 1; +const REJ_REASON = 2; + +// ShellRejected (command + working_dir + reason) +const SREJ_COMMAND = 1; +const SREJ_WORKING_DIR = 2; +const SREJ_REASON = 3; + +// Generic error sub-messages +const ERR_MESSAGE = 1; // GrepError.error / WriteShellStdinError.error +const FERR_URL = 1; // FetchError.url +const FERR_ERROR = 2; // FetchError.error + +// Result-message variant discriminators (oneof). field 1 = success/accepted, +// field 2 = rejected/error. Matches existing RCR_SUCCESS=1 pattern. +const RES_REJECTED = 2; // rejected variant for read/write/delete/ls/shell/bg_shell + +// McpToolDefinition +const MTD_NAME = 1; +const MTD_DESCRIPTION = 2; +const MTD_INPUT_SCHEMA = 3; +const MTD_PROVIDER_IDENTIFIER = 4; +const MTD_TOOL_NAME = 5; + +// McpArgs (used by Phase 5 decoder) +const MCA_NAME = 1; +const MCA_ARGS = 2; // map +const MCA_TOOL_CALL_ID = 3; +const MCA_PROVIDER_IDENTIFIER = 4; +const MCA_TOOL_NAME = 5; + +// McpResult variants +const MCR_SUCCESS = 1; +const MCR_ERROR = 2; +const MCS_CONTENT = 1; // McpSuccess.content (repeated McpToolResultContentItem) +const MCS_IS_ERROR = 2; +const MCC_TEXT = 1; // McpToolResultContentItem.text (oneof) -> McpTextContent +const MTC_TEXT = 1; // McpTextContent.text + +// google.protobuf.Value (well-known type) +const VAL_NULL = 1; +const VAL_NUMBER = 2; +const VAL_STRING = 3; +const VAL_BOOL = 4; +const VAL_STRUCT = 5; +const VAL_LIST = 6; +const STRUCT_FIELDS = 1; // Struct.fields = map +const LIST_VALUES = 1; // ListValue.values = repeated Value + +// proto3 map serializes as repeated FieldsEntry { key=1, value=2 } +const MAP_KEY = 1; +const MAP_VALUE = 2; + +// ─── Wire-type constants ─────────────────────────────────────────────────── + +const WT_VARINT = 0; +const WT_LEN = 2; + +// ─── Primitive encoders ──────────────────────────────────────────────────── + +function encodeVarint(value: number | bigint): Buffer { + let v = typeof value === "bigint" ? value : BigInt(value); + const bytes: number[] = []; + while (v > 0x7fn) { + bytes.push(Number(v & 0x7fn) | 0x80); + v >>= 7n; + } + bytes.push(Number(v)); + return Buffer.from(bytes); +} + +function encodeTag(fieldNumber: number, wireType: number): Buffer { + return encodeVarint((fieldNumber << 3) | wireType); +} + +function encodeBytes(fieldNumber: number, value: Buffer | Uint8Array): Buffer { + const buf = Buffer.isBuffer(value) ? value : Buffer.from(value); + return Buffer.concat([encodeTag(fieldNumber, WT_LEN), encodeVarint(buf.length), buf]); +} + +function encodeString(fieldNumber: number, value: string): Buffer { + return encodeBytes(fieldNumber, Buffer.from(value, "utf8")); +} + +function encodeMessage(fieldNumber: number, parts: Buffer[]): Buffer { + const inner = Buffer.concat(parts); + return Buffer.concat([encodeTag(fieldNumber, WT_LEN), encodeVarint(inner.length), inner]); +} + +function encodeUInt32Field(fieldNumber: number, value: number): Buffer { + return Buffer.concat([encodeTag(fieldNumber, WT_VARINT), encodeVarint(value)]); +} + +function encodeBoolField(fieldNumber: number, value: boolean): Buffer { + return Buffer.concat([encodeTag(fieldNumber, WT_VARINT), encodeVarint(value ? 1 : 0)]); +} + +function encodeDoubleField(fieldNumber: number, value: number): Buffer { + // wire type 1 = 64-bit fixed (double) + const buf = Buffer.alloc(8); + buf.writeDoubleLE(value, 0); + return Buffer.concat([encodeTag(fieldNumber, 1), buf]); +} + +// ─── Primitive decoders ──────────────────────────────────────────────────── + +function decodeVarint(buf: Buffer, offset: number): [bigint, number] { + let result = 0n; + let shift = 0n; + let pos = offset; + while (pos < buf.length) { + const byte = buf[pos++]; + result |= BigInt(byte & 0x7f) << shift; + if ((byte & 0x80) === 0) return [result, pos]; + shift += 7n; + } + throw new Error("varint truncated"); +} + +type Field = + | { fieldNumber: number; wireType: 0; varint: bigint } + | { fieldNumber: number; wireType: 2; bytes: Buffer }; + +function decodeFields(buf: Buffer): Field[] { + const fields: Field[] = []; + let pos = 0; + while (pos < buf.length) { + const [tag, np] = decodeVarint(buf, pos); + pos = np; + const fieldNumber = Number(tag >> 3n); + const wireType = Number(tag & 0x7n); + if (wireType === WT_VARINT) { + const [v, np2] = decodeVarint(buf, pos); + pos = np2; + fields.push({ fieldNumber, wireType: 0, varint: v }); + } else if (wireType === WT_LEN) { + const [len, np2] = decodeVarint(buf, pos); + pos = np2; + const lenN = Number(len); + fields.push({ fieldNumber, wireType: 2, bytes: buf.subarray(pos, pos + lenN) }); + pos += lenN; + } else if (wireType === 5) { + pos += 4; + } else if (wireType === 1) { + pos += 8; + } else { + throw new Error(`unsupported wireType ${wireType}`); + } + } + return fields; +} + +// ─── Connect-RPC framing ─────────────────────────────────────────────────── + +const FLAG_NONE = 0x00; +const FLAG_GZIP = 0x01; + +export function wrapConnectFrame(payload: Buffer, compressed = false): Buffer { + const data = compressed ? zlib.gzipSync(payload) : payload; + const header = Buffer.alloc(5); + header[0] = compressed ? FLAG_GZIP : FLAG_NONE; + header.writeUInt32BE(data.length, 1); + return Buffer.concat([header, data]); +} + +export type ConnectFrame = { + flags: number; + payload: Buffer; +}; + +export function* iterateConnectFrames(stream: Buffer): Generator { + let pos = 0; + while (pos + 5 <= stream.length) { + const flags = stream[pos]; + const length = stream.readUInt32BE(pos + 1); + if (pos + 5 + length > stream.length) return; + const raw = stream.subarray(pos + 5, pos + 5 + length); + const payload = flags & FLAG_GZIP ? zlib.gunzipSync(raw) : raw; + yield { flags, payload }; + pos += 5 + length; + } +} + +// ─── Model id translation ────────────────────────────────────────────────── + +/** + * cursor-agent rewrites model ids before putting them on the wire: + * "auto" → RequestedModel { model_id: "default" } + * "composer-2-fast" → RequestedModel { model_id: "composer-2", + * parameters: [{id: "fast", value: "true"}] } + * + * Other ids (e.g. "claude-4.6-sonnet-medium") are passed through verbatim. + */ +export function resolveRequestedModel(modelId: string): { + modelId: string; + parameters: Array<{ id: string; value: string }>; +} { + if (modelId === "auto") { + return { modelId: "default", parameters: [] }; + } + // Strip the "-fast" suffix and surface it as a parameter — only the composer + // family observably needs this split today, but the protocol field is generic. + if (modelId.startsWith("composer-") && modelId.endsWith("-fast")) { + return { + modelId: modelId.slice(0, -"-fast".length), + parameters: [{ id: "fast", value: "true" }], + }; + } + return { modelId, parameters: [] }; +} + +// ─── Request encoder ─────────────────────────────────────────────────────── + +/** + * OpenAI tool shape (subset OmniRoute receives from clients). Cursor's + * AgentRunRequest carries declared tools as McpToolDefinition entries; the + * model uses these to know what's invocable, then emits ExecServerMessage + * mcp_args when it wants to call one (Phase 5 surfaces those as OpenAI + * tool_calls deltas). + */ +export type OpenAITool = { + type?: string; + function: { + name: string; + description?: string; + parameters?: unknown; + }; +}; + +export type AgentRunInput = { + modelId: string; + userText: string; + conversationId?: string; + messageId?: string; + tools?: OpenAITool[]; + // Phase 7: when systemPrompt is set, the encoder hashes + // {role:"system", content:} into a blob, stores it in blobStore + // (keyed by hex sha256), and embeds the blob id in the + // ConversationStateStructure.root_prompt_messages_json field. Cursor's + // server then sends a KvServerMessage.GetBlobArgs requesting the blob, + // which the executor's processFrame replies to with the stored bytes. + systemPrompt?: string; + blobStore?: Map; +}; + +/** + * Convert OpenAI tool definitions to cursor McpToolDefinition bodies. Used + * both by the AgentRunRequest builder (mcp_tools field) and by the request + * context ack (request_context.tools field) — the model needs both to see + * the tools as available. + */ +export function openAIToolsToMcpDefs(tools: OpenAITool[]): McpToolDefinition[] { + return tools.map((t) => { + const params = t.function?.parameters ?? { type: "object", properties: {} }; + return { + name: t.function.name, + description: t.function.description ?? "", + inputSchemaBytes: jsonSchemaToProtobufValue(params), + providerIdentifier: "omniroute", + toolName: t.function.name, + }; + }); +} + +export function encodeAgentRunRequest(input: AgentRunInput): Buffer { + const conversationId = input.conversationId || crypto.randomUUID(); + const messageId = input.messageId || crypto.randomUUID(); + const { modelId, parameters } = resolveRequestedModel(input.modelId); + + // UserMessage { text, message_id, selected_context: empty, mode=1 }. + // The empty selected_context placeholder and mode=1 match cursor-agent's + // wire format; without them the server accepts the request but never + // streams a response. + const userMessage = encodeMessage(UMA_USER_MESSAGE, [ + encodeString(UM_TEXT, input.userText), + encodeString(UM_MESSAGE_ID, messageId), + encodeMessage(UM_SELECTED_CONTEXT, []), + Buffer.concat([encodeTag(UM_MODE, WT_VARINT), encodeVarint(1)]), + ]); + // UserMessageAction { user_message } + const userMessageAction = encodeMessage(CA_USER_MESSAGE_ACTION, [userMessage]); + // ConversationAction { user_message_action } + const action = encodeMessage(ARR_ACTION, [userMessageAction]); + + // ConversationStateStructure. When a system prompt is present, hash it to + // a sha256 blob id and reference the blob from root_prompt_messages_json; + // the server requests the blob over the KV channel during the turn. + const cssParts: Buffer[] = []; + if (input.systemPrompt && input.blobStore) { + const systemJson = JSON.stringify({ role: "system", content: input.systemPrompt }); + const blobBytes = Buffer.from(systemJson, "utf8"); + const blobId = crypto.createHash("sha256").update(blobBytes).digest(); + input.blobStore.set(blobId.toString("hex"), blobBytes); + cssParts.push(encodeBytes(CSS_ROOT_PROMPT, blobId)); + } + const conversationState = encodeMessage(ARR_CONVERSATION_STATE, cssParts); + + // RequestedModel { model_id, [parameters...] } + const rmParts: Buffer[] = [encodeString(RM_MODEL_ID, modelId)]; + for (const param of parameters) { + rmParts.push( + encodeMessage(RM_PARAMETERS, [ + encodeString(RMP_ID, param.id), + encodeString(RMP_VALUE, param.value), + ]) + ); + } + const requestedModel = encodeMessage(ARR_REQUESTED_MODEL, rmParts); + + // mcp_tools: McpTools envelope at field 4 of AgentRunRequest. Each tool + // is packed inside the envelope at field 1 (repeated McpToolDefinition). + // Empty placeholder for non-tool calls (the field is observably required + // even when empty — cursor errors if it's omitted entirely). + const mcpToolDefs = input.tools ? openAIToolsToMcpDefs(input.tools) : []; + const mcpToolsBlock = encodeMessage( + ARR_MCP_TOOLS, + mcpToolDefs.map((def) => encodeMessage(ARR_MCP_TOOLS_INNER, [encodeMcpToolDefinitionBody(def)])) + ); + + // AgentRunRequest. Field order mirrors cursor-agent's wire format; empty + // placeholders for mcp_tools and request_id are observably required. + const agentRunRequest = [ + conversationState, + action, + mcpToolsBlock, + encodeString(ARR_CONVERSATION_ID, conversationId), + requestedModel, + Buffer.concat([encodeTag(ARR_UNKNOWN_12, WT_VARINT), encodeVarint(0)]), + encodeString(ARR_REQUEST_ID, conversationId), + ]; + + // AgentClientMessage { run_request } + const acm = encodeMessage(ACM_RUN_REQUEST, agentRunRequest); + return acm; +} + +// McpTools.tool field number — repeated McpToolDefinition entries go under +// field 1 of the McpTools wrapper (which itself is field 4 of AgentRunRequest). +const ARR_MCP_TOOLS_INNER = 1; + +export function buildAgentRequestBody(input: AgentRunInput): Buffer { + return wrapConnectFrame(encodeAgentRunRequest(input)); +} + +// ─── Response decoder ────────────────────────────────────────────────────── + +export type DecodedDelta = + | { kind: "text"; text: string } + | { kind: "thinking"; text: string } + | { kind: "thinking_complete" } + | { kind: "token_delta"; tokens: number } + | { kind: "turn_ended" } + | { kind: "heartbeat" } + | { kind: "tool_call_started" } + | { kind: "tool_call_completed" } + | { kind: "kv_server_message" } + | { kind: "unknown"; field: number }; + +function findField(fields: Field[], fieldNumber: number): Field | undefined { + return fields.find((f) => f.fieldNumber === fieldNumber); +} + +function decodeStringField(buf: Buffer, fieldNumber: number): string { + const fields = decodeFields(buf); + const f = findField(fields, fieldNumber); + if (f && f.wireType === 2) return f.bytes.toString("utf8"); + return ""; +} + +function decodeVarintField(buf: Buffer, fieldNumber: number): number { + const fields = decodeFields(buf); + const f = findField(fields, fieldNumber); + if (f && f.wireType === 0) return Number(f.varint); + return 0; +} + +export function decodeAgentServerMessage(payload: Buffer): DecodedDelta[] { + const out: DecodedDelta[] = []; + for (const top of decodeFields(payload)) { + if (top.fieldNumber === ASM_KV_SERVER_MESSAGE && top.wireType === 2) { + out.push({ kind: "kv_server_message" }); + continue; + } + if (top.fieldNumber !== ASM_INTERACTION_UPDATE || top.wireType !== 2) continue; + for (const update of decodeFields(top.bytes)) { + if (update.wireType !== 2 && update.wireType !== 0) continue; + switch (update.fieldNumber) { + case IU_TEXT_DELTA: + if (update.wireType === 2) { + out.push({ kind: "text", text: decodeStringField(update.bytes, TDU_TEXT) }); + } + break; + case IU_THINKING_DELTA: + if (update.wireType === 2) { + out.push({ kind: "thinking", text: decodeStringField(update.bytes, TDU_TEXT) }); + } + break; + case IU_THINKING_COMPLETED: + out.push({ kind: "thinking_complete" }); + break; + case IU_TOOL_CALL_STARTED: + out.push({ kind: "tool_call_started" }); + break; + case IU_TOOL_CALL_COMPLETED: + out.push({ kind: "tool_call_completed" }); + break; + case IU_TOKEN_DELTA: + if (update.wireType === 2) { + out.push({ kind: "token_delta", tokens: decodeVarintField(update.bytes, 1) }); + } + break; + case IU_HEARTBEAT: + out.push({ kind: "heartbeat" }); + break; + case IU_TURN_ENDED: + out.push({ kind: "turn_ended" }); + break; + default: + out.push({ kind: "unknown", field: update.fieldNumber }); + } + } + } + return out; +} + +// ─── Exec channel handshake ──────────────────────────────────────────────── + +/** + * Parse an AgentServerMessage looking for an ExecServerMessage requesting + * context (sent right after the init RunRequest). The server stalls until we + * respond on the same h2 stream with an ExecClientMessage.RequestContextResult. + * + * Kept for backward compat — internally delegates to decodeExecServerEvent. + */ +export function decodeExecRequestContext(payload: Buffer): { id: number; execId: string } | null { + const event = decodeExecServerEvent(payload); + if (event && event.kind === "exec_request_context") { + return { id: event.execMsgId, execId: event.execId }; + } + return null; +} + +// ─── Phase 7: KvServerMessage decoder ────────────────────────────────────── +// +// Cursor multiplexes a key-value channel through the same h2 stream. After +// the init RunRequest with a CSS root_prompt_messages_json blob, the server +// sends KvServerMessage.GetBlobArgs requesting the blob bytes; we look up +// the bytes in our request-scoped blobStore and reply on the same stream. +// +// SetBlobArgs is sent at end-of-turn (server saving the assistant message); +// we ack with an empty SetBlobResult. + +export type KvServerEvent = + | { + kind: "kv_get_blob"; + kvId: number; + blobId: Buffer; + // Opaque metadata cursor sends with the request; echoed back in the + // reply so cursor can match request/response correctly. Empty when + // the request didn't include the metadata field. + requestMetadata: Buffer | null; + } + | { + kind: "kv_set_blob"; + kvId: number; + blobId: Buffer; + blobData: Buffer; + requestMetadata: Buffer | null; + }; + +export function decodeKvServerEvent(payload: Buffer): KvServerEvent | null { + for (const top of decodeFields(payload)) { + if (top.fieldNumber !== ASM_KV_SERVER_MESSAGE || top.wireType !== 2) continue; + + let kvId = 0; + let getBlobArgs: Buffer | null = null; + let setBlobArgs: Buffer | null = null; + let requestMetadata: Buffer | null = null; + + for (const f of decodeFields(top.bytes)) { + if (f.fieldNumber === KSM_ID && f.wireType === 0) { + kvId = Number(f.varint); + } else if (f.fieldNumber === KSM_GET_BLOB_ARGS && f.wireType === 2) { + getBlobArgs = f.bytes; + } else if (f.fieldNumber === KSM_SET_BLOB_ARGS && f.wireType === 2) { + setBlobArgs = f.bytes; + } else if (f.fieldNumber === KSM_REQUEST_METADATA && f.wireType === 2) { + requestMetadata = f.bytes; + } + } + + if (getBlobArgs) { + // GetBlobArgs { blob_id (1): bytes } + let blobId = Buffer.alloc(0); + for (const f of decodeFields(getBlobArgs)) { + if (f.fieldNumber === GBA_BLOB_ID && f.wireType === 2) { + blobId = f.bytes; + } + } + return { kind: "kv_get_blob", kvId, blobId, requestMetadata }; + } + if (setBlobArgs) { + // SetBlobArgs { blob_id (1): bytes, blob_data (2): bytes } + let blobId = Buffer.alloc(0); + let blobData = Buffer.alloc(0); + for (const f of decodeFields(setBlobArgs)) { + if (f.fieldNumber === SBA_BLOB_ID && f.wireType === 2) { + blobId = f.bytes; + } else if (f.fieldNumber === SBA_BLOB_DATA && f.wireType === 2) { + blobData = f.bytes; + } + } + return { kind: "kv_set_blob", kvId, blobId, blobData, requestMetadata }; + } + } + return null; +} + +// ─── Phase 2: full ExecServerMessage variant decoder ─────────────────────── +// +// Cursor's server multiplexes a tool channel through the h2 stream. After +// the init RunRequest, the server may emit any of: +// - request_context_args (always first — context handshake) +// - read/write/delete/ls/grep/diagnostics/shell/etc args (built-in tools) +// - mcp_args (MCP tool the model wants to invoke — declared via Phase 3) +// All variants share the same ExecServerMessage envelope { id, exec_id, ... }; +// only the discriminator field number differs. + +export type ExecServerEvent = + | { kind: "exec_request_context"; execMsgId: number; execId: string } + | { kind: "exec_read"; execMsgId: number; execId: string; path: string } + | { kind: "exec_write"; execMsgId: number; execId: string; path: string } + | { kind: "exec_delete"; execMsgId: number; execId: string; path: string } + | { kind: "exec_ls"; execMsgId: number; execId: string; path: string } + | { kind: "exec_grep"; execMsgId: number; execId: string } + | { kind: "exec_diagnostics"; execMsgId: number; execId: string } + | { + kind: "exec_shell"; + execMsgId: number; + execId: string; + command: string; + workingDir: string; + } + | { + kind: "exec_shell_stream"; + execMsgId: number; + execId: string; + command: string; + workingDir: string; + } + | { + kind: "exec_bg_shell"; + execMsgId: number; + execId: string; + command: string; + workingDir: string; + } + | { kind: "exec_fetch"; execMsgId: number; execId: string; url: string } + | { kind: "exec_write_shell_stdin"; execMsgId: number; execId: string } + | { + kind: "exec_mcp"; + execMsgId: number; + execId: string; + toolName: string; + toolCallId: string; + // args populated by Phase 5 (decodeMcpArgs); empty {} until then. + args: Record; + }; + +export function decodeExecServerEvent(payload: Buffer): ExecServerEvent | null { + for (const top of decodeFields(payload)) { + if (top.fieldNumber !== ASM_EXEC_SERVER_MESSAGE || top.wireType !== 2) continue; + + let execMsgId = 0; + let execId = ""; + let variantField = 0; + let variantBytes: Buffer | null = null; + + for (const f of decodeFields(top.bytes)) { + if (f.fieldNumber === ESM_ID && f.wireType === 0) { + execMsgId = Number(f.varint); + } else if (f.fieldNumber === ESM_EXEC_ID && f.wireType === 2) { + execId = f.bytes.toString("utf8"); + } else if (f.wireType === 2) { + // Any other LEN field is the variant payload. Take the first one we + // see — variants don't co-occur in a well-formed message. + if (variantField === 0) { + variantField = f.fieldNumber; + variantBytes = f.bytes; + } + } + } + + if (variantBytes === null) continue; + + switch (variantField) { + case ESM_REQUEST_CONTEXT_ARGS: + return { kind: "exec_request_context", execMsgId, execId }; + case ESM_READ_ARGS: + return { + kind: "exec_read", + execMsgId, + execId, + path: decodeStringField(variantBytes, ARG_PATH), + }; + case ESM_WRITE_ARGS: + return { + kind: "exec_write", + execMsgId, + execId, + path: decodeStringField(variantBytes, ARG_PATH), + }; + case ESM_DELETE_ARGS: + return { + kind: "exec_delete", + execMsgId, + execId, + path: decodeStringField(variantBytes, ARG_PATH), + }; + case ESM_LS_ARGS: + return { + kind: "exec_ls", + execMsgId, + execId, + path: decodeStringField(variantBytes, ARG_PATH), + }; + case ESM_GREP_ARGS: + return { kind: "exec_grep", execMsgId, execId }; + case ESM_DIAGNOSTICS_ARGS: + return { kind: "exec_diagnostics", execMsgId, execId }; + case ESM_SHELL_ARGS: + return { + kind: "exec_shell", + execMsgId, + execId, + command: decodeStringField(variantBytes, ARG_SHELL_COMMAND), + workingDir: decodeStringField(variantBytes, ARG_SHELL_WORKING_DIR), + }; + case ESM_SHELL_STREAM_ARGS: + return { + kind: "exec_shell_stream", + execMsgId, + execId, + command: decodeStringField(variantBytes, ARG_SHELL_COMMAND), + workingDir: decodeStringField(variantBytes, ARG_SHELL_WORKING_DIR), + }; + case ESM_BACKGROUND_SHELL_SPAWN: + return { + kind: "exec_bg_shell", + execMsgId, + execId, + command: decodeStringField(variantBytes, ARG_SHELL_COMMAND), + workingDir: decodeStringField(variantBytes, ARG_SHELL_WORKING_DIR), + }; + case ESM_FETCH_ARGS: + return { + kind: "exec_fetch", + execMsgId, + execId, + url: decodeStringField(variantBytes, ARG_FETCH_URL), + }; + case ESM_WRITE_SHELL_STDIN_ARGS: + return { kind: "exec_write_shell_stdin", execMsgId, execId }; + case ESM_MCP_ARGS: { + // McpArgs.args is map; each value is a protobuf- + // encoded google.protobuf.Value. Decode keys and value-bytes here, + // then convert each Value to its JSON shape. + let toolName = ""; + let toolCallId = ""; + const args: Record = {}; + for (const f of decodeFields(variantBytes)) { + if (f.wireType !== 2) continue; + if (f.fieldNumber === MCA_TOOL_NAME) { + toolName = f.bytes.toString("utf8"); + } else if (f.fieldNumber === MCA_NAME && !toolName) { + // tool_name (5) takes precedence; fall back to name (1) + toolName = f.bytes.toString("utf8"); + } else if (f.fieldNumber === MCA_TOOL_CALL_ID) { + toolCallId = f.bytes.toString("utf8"); + } else if (f.fieldNumber === MCA_ARGS) { + // FieldsEntry { key (1): string, value (2): bytes } + let key = ""; + let valueBytes: Buffer | null = null; + for (const entry of decodeFields(f.bytes)) { + if (entry.fieldNumber === MAP_KEY && entry.wireType === 2) { + key = entry.bytes.toString("utf8"); + } else if (entry.fieldNumber === MAP_VALUE && entry.wireType === 2) { + valueBytes = entry.bytes; + } + } + if (key && valueBytes !== null) { + args[key] = decodeProtobufValue(valueBytes); + } + } + } + return { kind: "exec_mcp", execMsgId, execId, toolName, toolCallId, args }; + } + default: + // Unknown variant — return null so caller can keep buffering. + return null; + } + } + return null; +} + +/** + * Build the ack the server expects after sending RequestContextArgs. We + * respond with a RequestContext (optionally containing the declared MCP + * tools so cursor's model knows what's available); cursor's server then + * proceeds to stream the model's response. + * + * The Phase 3 `tools` argument is what unblocks tool-calling — without it + * cursor's server still streams text but the model never sees the tools as + * available. + */ +export function encodeRequestContextResponse( + id: number, + execId: string, + tools?: McpToolDefinition[] +): Buffer { + const rcParts: Buffer[] = []; + if (tools && tools.length > 0) { + for (const tool of tools) { + rcParts.push(encodeMessage(RCS_TOOLS, [encodeMcpToolDefinitionBody(tool)])); + } + } + const requestContext = encodeMessage(RCS_REQUEST_CONTEXT, rcParts); + const success = encodeMessage(RCR_SUCCESS, [requestContext]); + const ecm = encodeMessage(ACM_EXEC_CLIENT_MESSAGE, [ + encodeUInt32Field(ECM_ID, id), + encodeString(ECM_EXEC_ID, execId), + encodeMessage(ECM_REQUEST_CONTEXT_RESULT, [success]), + ]); + return wrapConnectFrame(ecm); +} + +// RequestContext.tools field number — multiple tool defs are repeated within +// the inner RequestContext message. +const RCS_TOOLS = 2; + +// ─── ExecClientMessage wrapper ────────────────────────────────────────────── + +/** + * Build an ExecClientMessage frame: + * AgentClientMessage { + * exec_client_message (2): ExecClientMessage { + * id (1): execMsgId, + * exec_id (15): execId, + * : resultPayload, + * } + * } + * Connect-RPC framed, ready to write to the h2 stream. + * + * `exec_id` is force-set even when empty (matches kaitranntt's behavior). + */ +function wrapExecClientMessage( + execMsgId: number, + execId: string, + resultFieldNumber: number, + resultPayload: Buffer +): Buffer { + const ecm = encodeMessage(ACM_EXEC_CLIENT_MESSAGE, [ + encodeUInt32Field(ECM_ID, execMsgId), + encodeString(ECM_EXEC_ID, execId), + encodeMessage(resultFieldNumber, [resultPayload]), + ]); + return wrapConnectFrame(ecm); +} + +// ─── Phase 1: built-in tool rejection encoders ───────────────────────────── +// Cursor's model invokes built-in tools (read/write/shell/grep/etc.) which we +// can't safely run inside the proxy. We respond with a typed rejection so the +// model continues without that tool — matches kaitranntt's stance and avoids +// stalling the h2 stream. + +function encodePathRejection(path: string, reason: string): Buffer { + return Buffer.concat([encodeString(REJ_PATH, path), encodeString(REJ_REASON, reason)]); +} + +function encodeShellRejection(command: string, workingDir: string, reason: string): Buffer { + return Buffer.concat([ + encodeString(SREJ_COMMAND, command), + encodeString(SREJ_WORKING_DIR, workingDir), + encodeString(SREJ_REASON, reason), + ]); +} + +export function encodeExecReadRejected( + execMsgId: number, + execId: string, + path: string, + reason: string +): Buffer { + const rejected = encodeMessage(RES_REJECTED, [encodePathRejection(path, reason)]); + return wrapExecClientMessage(execMsgId, execId, ECM_READ_RESULT, rejected); +} + +export function encodeExecWriteRejected( + execMsgId: number, + execId: string, + path: string, + reason: string +): Buffer { + const rejected = encodeMessage(RES_REJECTED, [encodePathRejection(path, reason)]); + return wrapExecClientMessage(execMsgId, execId, ECM_WRITE_RESULT, rejected); +} + +export function encodeExecDeleteRejected( + execMsgId: number, + execId: string, + path: string, + reason: string +): Buffer { + const rejected = encodeMessage(RES_REJECTED, [encodePathRejection(path, reason)]); + return wrapExecClientMessage(execMsgId, execId, ECM_DELETE_RESULT, rejected); +} + +export function encodeExecLsRejected( + execMsgId: number, + execId: string, + path: string, + reason: string +): Buffer { + const rejected = encodeMessage(RES_REJECTED, [encodePathRejection(path, reason)]); + return wrapExecClientMessage(execMsgId, execId, ECM_LS_RESULT, rejected); +} + +export function encodeExecShellRejected( + execMsgId: number, + execId: string, + command: string, + workingDir: string, + reason: string +): Buffer { + const rejected = encodeMessage(RES_REJECTED, [encodeShellRejection(command, workingDir, reason)]); + return wrapExecClientMessage(execMsgId, execId, ECM_SHELL_RESULT, rejected); +} + +export function encodeExecBackgroundShellSpawnRejected( + execMsgId: number, + execId: string, + command: string, + workingDir: string, + reason: string +): Buffer { + const rejected = encodeMessage(RES_REJECTED, [encodeShellRejection(command, workingDir, reason)]); + return wrapExecClientMessage(execMsgId, execId, ECM_BACKGROUND_SHELL_SPAWN_RES, rejected); +} + +export function encodeExecGrepError(execMsgId: number, execId: string, errMsg: string): Buffer { + const grepError = encodeString(ERR_MESSAGE, errMsg); + const errorVariant = encodeMessage(RES_REJECTED, [grepError]); + return wrapExecClientMessage(execMsgId, execId, ECM_GREP_RESULT, errorVariant); +} + +export function encodeExecFetchError( + execMsgId: number, + execId: string, + url: string, + errMsg: string +): Buffer { + const fetchError = Buffer.concat([encodeString(FERR_URL, url), encodeString(FERR_ERROR, errMsg)]); + const errorVariant = encodeMessage(RES_REJECTED, [fetchError]); + return wrapExecClientMessage(execMsgId, execId, ECM_FETCH_RESULT, errorVariant); +} + +export function encodeExecWriteShellStdinError( + execMsgId: number, + execId: string, + errMsg: string +): Buffer { + const stdinError = encodeString(ERR_MESSAGE, errMsg); + const errorVariant = encodeMessage(RES_REJECTED, [stdinError]); + return wrapExecClientMessage(execMsgId, execId, ECM_WRITE_SHELL_STDIN_RESULT, errorVariant); +} + +export function encodeExecDiagnosticsResult(execMsgId: number, execId: string): Buffer { + // DiagnosticsResult is empty — there's no rejection variant. + return wrapExecClientMessage(execMsgId, execId, ECM_DIAGNOSTICS_RESULT, Buffer.alloc(0)); +} + +// ─── Phase 1: MCP result encoders (used when WE invoke a tool on behalf +// of the model — Phase 5 wires this to OpenAI tool_calls). ───────────────── + +export function encodeExecMcpResult( + execMsgId: number, + execId: string, + content: string, + isError: boolean +): Buffer { + // McpTextContent { text } → McpToolResultContentItem.text + const textContent = encodeMessage(MCC_TEXT, [encodeString(MTC_TEXT, content)]); + const successFields: Buffer[] = [encodeMessage(MCS_CONTENT, [textContent])]; + if (isError) successFields.push(encodeBoolField(MCS_IS_ERROR, true)); + const success = encodeMessage(MCR_SUCCESS, successFields); + return wrapExecClientMessage(execMsgId, execId, ECM_MCP_RESULT, success); +} + +export function encodeExecMcpError(execMsgId: number, execId: string, errMsg: string): Buffer { + const mcpError = encodeString(ERR_MESSAGE, errMsg); + const errorVariant = encodeMessage(MCR_ERROR, [mcpError]); + return wrapExecClientMessage(execMsgId, execId, ECM_MCP_RESULT, errorVariant); +} + +// ─── Phase 1: KV blob handshake encoders ─────────────────────────────────── + +/** + * Reply to KvServerMessage.GetBlobArgs. Server sends `{ id, blob_id, ... }`; + * we look up the blob in our request-scoped store and reply with the bytes. + * Echoes the opaque request_metadata cursor sent so the server can match + * request to response. + */ +export function encodeKvGetBlobResult( + kvId: number, + blobData: Buffer, + requestMetadata: Buffer | null = null +): Buffer { + const getBlobResult = encodeBytes(GBR_BLOB_DATA, blobData); + const parts: Buffer[] = []; + if (kvId !== 0) parts.push(encodeUInt32Field(KCM_ID, kvId)); + parts.push(encodeMessage(KCM_GET_BLOB_RESULT, [getBlobResult])); + if (requestMetadata && requestMetadata.length > 0) { + parts.push(encodeBytes(KCM_REQUEST_METADATA, requestMetadata)); + } + const kcm = encodeMessage(ACM_KV_CLIENT_MESSAGE, parts); + return wrapConnectFrame(kcm); +} + +/** + * Ack KvServerMessage.SetBlobArgs. Server is saving an assistant turn; we + * acknowledge with an empty SetBlobResult so the stream proceeds. + */ +export function encodeKvSetBlobResult(kvId: number, requestMetadata: Buffer | null = null): Buffer { + const parts: Buffer[] = []; + if (kvId !== 0) parts.push(encodeUInt32Field(KCM_ID, kvId)); + parts.push(encodeMessage(KCM_SET_BLOB_RESULT, [])); + if (requestMetadata && requestMetadata.length > 0) { + parts.push(encodeBytes(KCM_REQUEST_METADATA, requestMetadata)); + } + const kcm = encodeMessage(ACM_KV_CLIENT_MESSAGE, parts); + return wrapConnectFrame(kcm); +} + +// ─── Phase 1: MCP tool definitions ───────────────────────────────────────── + +export type McpToolDefinition = { + name: string; + description: string; + inputSchemaBytes: Buffer; + providerIdentifier?: string; + toolName?: string; +}; + +/** + * Encode the body of an McpToolDefinition (without the wrapping field tag). + * Use this when embedding a tool def inside a parent message — the parent + * supplies the field number via encodeMessage(parentField, [body]). + */ +export function encodeMcpToolDefinitionBody(def: McpToolDefinition): Buffer { + const parts: Buffer[] = [ + encodeString(MTD_NAME, def.name), + encodeString(MTD_DESCRIPTION, def.description), + encodeBytes(MTD_INPUT_SCHEMA, def.inputSchemaBytes), + ]; + if (def.providerIdentifier) { + parts.push(encodeString(MTD_PROVIDER_IDENTIFIER, def.providerIdentifier)); + } + if (def.toolName) { + parts.push(encodeString(MTD_TOOL_NAME, def.toolName)); + } + return Buffer.concat(parts); +} + +// ─── Phase 1: JSON Schema → google.protobuf.Value ────────────────────────── + +/** + * Convert a JSON object (e.g. an OpenAI tool's input_schema) to bytes + * encoding a google.protobuf.Value. The result is the body of a Value + * message — one oneof field set, no outer tag. + * + * Used to populate McpToolDefinition.input_schema (which is bytes-typed + * on the wire even though semantically it's a Value). + */ +export function jsonSchemaToProtobufValue(json: unknown): Buffer { + return encodeProtobufValue(json); +} + +/** + * Reverse of jsonSchemaToProtobufValue: decode google.protobuf.Value bytes + * back into a JSON-shape value. Used by Phase 5 to translate cursor's + * McpArgs.args (map) into the JSON object the + * OpenAI tool_calls.function.arguments field expects. + * + * Handles all six Value variants: null, number (double), string, bool, + * struct (object), list (array). Unknown fields are skipped. + */ +export function decodeProtobufValue(buf: Buffer): unknown { + let pos = 0; + while (pos < buf.length) { + const [t, np] = decodeVarint(buf, pos); + pos = np; + const fieldNumber = Number(t >> 3n); + const wireType = Number(t & 0x7n); + switch (fieldNumber) { + case VAL_NULL: { + if (wireType === WT_VARINT) { + [, pos] = decodeVarint(buf, pos); + } + return null; + } + case VAL_NUMBER: { + if (wireType === 1 && pos + 8 <= buf.length) { + const value = buf.readDoubleLE(pos); + pos += 8; + return value; + } + return 0; + } + case VAL_STRING: { + if (wireType === WT_LEN) { + const [len, np2] = decodeVarint(buf, pos); + pos = np2; + const lenN = Number(len); + const value = buf.subarray(pos, pos + lenN).toString("utf8"); + pos += lenN; + return value; + } + return ""; + } + case VAL_BOOL: { + if (wireType === WT_VARINT) { + const [val, np2] = decodeVarint(buf, pos); + pos = np2; + return val !== 0n; + } + return false; + } + case VAL_STRUCT: { + if (wireType === WT_LEN) { + const [len, np2] = decodeVarint(buf, pos); + pos = np2; + const lenN = Number(len); + const inner = buf.subarray(pos, pos + lenN); + pos += lenN; + return decodeProtobufStruct(inner); + } + return {}; + } + case VAL_LIST: { + if (wireType === WT_LEN) { + const [len, np2] = decodeVarint(buf, pos); + pos = np2; + const lenN = Number(len); + const inner = buf.subarray(pos, pos + lenN); + pos += lenN; + return decodeProtobufList(inner); + } + return []; + } + default: + // Skip unknown field + if (wireType === WT_VARINT) { + [, pos] = decodeVarint(buf, pos); + } else if (wireType === WT_LEN) { + const [len, np2] = decodeVarint(buf, pos); + pos = np2; + pos += Number(len); + } else if (wireType === 1) { + pos += 8; + } else if (wireType === 5) { + pos += 4; + } + } + } + return null; +} + +function decodeProtobufStruct(buf: Buffer): Record { + const result: Record = {}; + for (const f of decodeFields(buf)) { + if (f.fieldNumber === STRUCT_FIELDS && f.wireType === 2) { + let key = ""; + let valueBytes: Buffer | null = null; + for (const entry of decodeFields(f.bytes)) { + if (entry.fieldNumber === MAP_KEY && entry.wireType === 2) { + key = entry.bytes.toString("utf8"); + } else if (entry.fieldNumber === MAP_VALUE && entry.wireType === 2) { + valueBytes = entry.bytes; + } + } + if (key && valueBytes) { + result[key] = decodeProtobufValue(valueBytes); + } + } + } + return result; +} + +function decodeProtobufList(buf: Buffer): unknown[] { + const result: unknown[] = []; + for (const f of decodeFields(buf)) { + if (f.fieldNumber === LIST_VALUES && f.wireType === 2) { + result.push(decodeProtobufValue(f.bytes)); + } + } + return result; +} + +function encodeProtobufValue(value: unknown): Buffer { + if (value === null || value === undefined) { + // null_value (1) = NULL_VALUE = 0 (enum) + return Buffer.concat([encodeTag(VAL_NULL, WT_VARINT), encodeVarint(0)]); + } + if (typeof value === "number") { + return encodeDoubleField(VAL_NUMBER, value); + } + if (typeof value === "string") { + return encodeString(VAL_STRING, value); + } + if (typeof value === "boolean") { + return Buffer.concat([encodeTag(VAL_BOOL, WT_VARINT), encodeVarint(value ? 1 : 0)]); + } + if (Array.isArray(value)) { + // ListValue { values: repeated Value } + const listParts = value.map((v) => encodeMessage(LIST_VALUES, [encodeProtobufValue(v)])); + return encodeMessage(VAL_LIST, listParts); + } + if (typeof value === "object") { + // Struct { fields: map } + const obj = value as Record; + const structParts: Buffer[] = []; + for (const [k, v] of Object.entries(obj)) { + const entry = Buffer.concat([ + encodeString(MAP_KEY, k), + encodeMessage(MAP_VALUE, [encodeProtobufValue(v)]), + ]); + structParts.push(encodeMessage(STRUCT_FIELDS, [entry])); + } + return encodeMessage(VAL_STRUCT, structParts); + } + // Fallback: encode as null + return Buffer.concat([encodeTag(VAL_NULL, WT_VARINT), encodeVarint(0)]); +} + +// ─── User message extractor (for chat-completions input) ─────────────────── + +export type ChatMessage = { + role: "user" | "assistant" | "system" | "tool"; + content?: string | Array<{ type: string; text?: string }> | null; + tool_calls?: Array<{ + id: string; + type?: "function" | string; + function: { name: string; arguments: string }; + }>; + tool_call_id?: string; +}; + +/** + * Flatten an OpenAI-shaped message list down to a single user-text string + * suitable for cursor's UserMessage. The agent endpoint expects ONE user + * message per Run; we concatenate prior conversation as context. + * + * Phase 6 cold-resume support: handles `role:"tool"` results and + * `assistant.tool_calls` so that follow-up turns after an OpenAI tool call + * round-trip coherently. Format follows kaitranntt's reference impl — + * cursor's model has been observed to handle this layout reliably. + */ +export function flattenMessages(messages: ChatMessage[]): string { + if (!Array.isArray(messages) || messages.length === 0) return ""; + + const partsToText = (content: ChatMessage["content"]): string => { + if (typeof content === "string") return content; + if (content == null) return ""; + if (!Array.isArray(content)) return ""; + return content + .map((p) => (typeof p?.text === "string" ? p.text : "")) + .filter(Boolean) + .join("\n"); + }; + + // System instructions go first as a labeled prefix. (The cursor executor + // routes system messages through the KV blob channel — see Phase 7 — but + // this branch is kept for non-cursor callers.) + const systemTexts = messages + .filter((m) => m.role === "system") + .map((m) => partsToText(m.content)) + .filter(Boolean); + + const turn = messages.filter((m) => m.role !== "system"); + + // Single-user-message fast path (no tool_calls, no labels). + if (turn.length === 1 && turn[0].role === "user" && !turn[0].tool_calls) { + const userText = partsToText(turn[0].content); + return systemTexts.length > 0 ? `${systemTexts.join("\n\n")}\n\n${userText}` : userText; + } + + // Multi-turn / tool-using format. Each message is labeled. Tool calls + // and tool results get their own labeled lines. + const lines: string[] = []; + for (const m of turn) { + const text = partsToText(m.content); + if (m.role === "user") { + if (text) lines.push(`User: ${text}`); + } else if (m.role === "assistant") { + if (text) lines.push(`Assistant: ${text}`); + if (Array.isArray(m.tool_calls)) { + for (const tc of m.tool_calls) { + const args = tc.function?.arguments ?? ""; + lines.push( + `Assistant called tool ${tc.function?.name ?? "(unknown)"} ` + + `(${tc.id}) with arguments: ${args}` + ); + } + } + } else if (m.role === "tool") { + const callId = m.tool_call_id ?? "(unknown)"; + lines.push(`Tool result (${callId}): ${text}`); + } else { + if (text) lines.push(`${m.role}: ${text}`); + } + } + const labelled = lines.join("\n\n"); + return systemTexts.length > 0 ? `${systemTexts.join("\n\n")}\n\n${labelled}` : labelled; +} diff --git a/open-sse/utils/cursorProtobuf.ts b/open-sse/utils/cursorProtobuf.ts deleted file mode 100644 index beb3a44e4a..0000000000 --- a/open-sse/utils/cursorProtobuf.ts +++ /dev/null @@ -1,897 +0,0 @@ -/** - * Cursor Protobuf Encoder/Decoder - * Implements ConnectRPC protobuf wire format for Cursor API - * - * Schema Version: reverse-engineered from Cursor client traffic. - * If Cursor updates their protocol, unknown field warnings will appear - * in the logs — update the FIELD map and bump the version below. - */ - -import { v4 as uuidv4 } from "uuid"; -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. - * Logged in warnings to help correlate unknown fields with Cursor client versions. - */ -const PROTOBUF_SCHEMA_VERSION = "1.1.3"; - -// ==================== SCHEMAS ==================== - -const WIRE_TYPE = { VARINT: 0, FIXED64: 1, LEN: 2, FIXED32: 5 }; - -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) - REQUEST: 1, - - // StreamUnifiedChatRequest - MESSAGES: 1, - UNKNOWN_2: 2, - INSTRUCTION: 3, - UNKNOWN_4: 4, - MODEL: 5, - WEB_TOOL: 8, - UNKNOWN_13: 13, - CURSOR_SETTING: 15, - UNKNOWN_19: 19, - CONVERSATION_ID: 23, - METADATA: 26, - IS_AGENTIC: 27, - SUPPORTED_TOOLS: 29, - MESSAGE_IDS: 30, - MCP_TOOLS: 34, - LARGE_CONTEXT: 35, - UNKNOWN_38: 38, - UNIFIED_MODE: 46, - UNKNOWN_47: 47, - SHOULD_DISABLE_TOOLS: 48, - THINKING_LEVEL: 49, - UNKNOWN_51: 51, - UNKNOWN_53: 53, - UNIFIED_MODE_NAME: 54, - - // ConversationMessage - MSG_CONTENT: 1, - MSG_ROLE: 2, - MSG_ID: 13, - MSG_TOOL_RESULTS: 18, - MSG_IS_AGENTIC: 29, - MSG_UNIFIED_MODE: 47, - MSG_SUPPORTED_TOOLS: 51, - - // ConversationMessage.ToolResult - TOOL_RESULT_CALL_ID: 1, - TOOL_RESULT_NAME: 2, - 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, - MODEL_EMPTY: 4, - - // Instruction - INSTRUCTION_TEXT: 1, - - // CursorSetting - SETTING_PATH: 1, - SETTING_UNKNOWN_3: 3, - SETTING_UNKNOWN_6: 6, - SETTING_UNKNOWN_8: 8, - SETTING_UNKNOWN_9: 9, - - // CursorSetting.Unknown6 - SETTING6_FIELD_1: 1, - SETTING6_FIELD_2: 2, - - // Metadata - META_PLATFORM: 1, - META_ARCH: 2, - META_VERSION: 3, - META_CWD: 4, - META_TIMESTAMP: 5, - - // MessageId - MSGID_ID: 1, - MSGID_SUMMARY: 2, - MSGID_ROLE: 3, - - // MCPTool - MCP_TOOL_NAME: 1, - MCP_TOOL_DESC: 2, - MCP_TOOL_PARAMS: 3, - MCP_TOOL_SERVER: 4, - - // StreamUnifiedChatResponseWithTools (response) - TOOL_CALL: 1, - RESPONSE: 2, - - // ClientSideToolV2Call - TOOL_ID: 3, - TOOL_NAME: 9, - TOOL_RAW_ARGS: 10, - TOOL_IS_LAST: 11, - TOOL_IS_LAST_ALT: 15, - TOOL_MCP_PARAMS: 27, - - // MCPParams - MCP_TOOLS_LIST: 1, - - // MCPParams.Tool (nested) - MCP_NESTED_NAME: 1, - MCP_NESTED_PARAMS: 3, - - // StreamUnifiedChatResponse - RESPONSE_TEXT: 1, - THINKING: 25, - - // Thinking - THINKING_TEXT: 1, -}; - -// Known response field numbers — used to detect unknown fields from protocol updates -const KNOWN_RESPONSE_FIELDS = new Set([ - FIELD.TOOL_CALL, - FIELD.RESPONSE, - FIELD.TOOL_ID, - FIELD.TOOL_NAME, - FIELD.TOOL_RAW_ARGS, - FIELD.TOOL_IS_LAST, - FIELD.TOOL_MCP_PARAMS, - FIELD.RESPONSE_TEXT, - FIELD.THINKING, -]); - -// ==================== PRIMITIVE ENCODING ==================== - -export function encodeVarint(value) { - const bytes = []; - while (value >= 0x80) { - bytes.push((value & 0x7f) | 0x80); - value >>>= 7; - } - bytes.push(value & 0x7f); - return new Uint8Array(bytes); -} - -export function encodeField(fieldNum, wireType, value) { - const tag = (fieldNum << 3) | wireType; - const tagBytes = encodeVarint(tag); - - if (wireType === WIRE_TYPE.VARINT) { - const valueBytes = encodeVarint(value); - return concatArrays(tagBytes, valueBytes); - } - - if (wireType === WIRE_TYPE.LEN) { - const dataBytes = - typeof value === "string" - ? new TextEncoder().encode(value) - : value instanceof Uint8Array - ? value - : Buffer.isBuffer(value) - ? new Uint8Array(value) - : new Uint8Array(0); - - const lengthBytes = encodeVarint(dataBytes.length); - return concatArrays(tagBytes, lengthBytes, dataBytes); - } - - return new Uint8Array(0); -} - -function concatArrays(...arrays) { - const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); - const result = new Uint8Array(totalLength); - let offset = 0; - for (const arr of arrays) { - result.set(arr, offset); - offset += arr.length; - } - return result; -} - -// ==================== MESSAGE ENCODING ==================== - -export function encodeToolResult(toolResult) { - 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), - ...(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)] - : []) - ); -} - -export function encodeMessage( - content, - role, - messageId, - chatModeEnum = null, - isLast = false, - hasTools = false, - toolResults = [] -) { - return concatArrays( - encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content), - encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role), - encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId), - ...(toolResults.length > 0 - ? toolResults.map((tr) => - encodeField(FIELD.MSG_TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr)) - ) - : []), - encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0), - encodeField( - FIELD.MSG_UNIFIED_MODE, - WIRE_TYPE.VARINT, - hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT - ), - ...(isLast && hasTools - ? [encodeField(FIELD.MSG_SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] - : []) - ); -} - -export function encodeInstruction(text) { - return text ? encodeField(FIELD.INSTRUCTION_TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0); -} - -export function encodeModel(modelName) { - return concatArrays( - encodeField(FIELD.MODEL_NAME, WIRE_TYPE.LEN, modelName), - encodeField(FIELD.MODEL_EMPTY, WIRE_TYPE.LEN, new Uint8Array(0)) - ); -} - -export function encodeCursorSetting() { - const unknown6 = concatArrays( - encodeField(FIELD.SETTING6_FIELD_1, WIRE_TYPE.LEN, new Uint8Array(0)), - encodeField(FIELD.SETTING6_FIELD_2, WIRE_TYPE.LEN, new Uint8Array(0)) - ); - - return concatArrays( - encodeField(FIELD.SETTING_PATH, WIRE_TYPE.LEN, "cursor\\aisettings"), - encodeField(FIELD.SETTING_UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)), - encodeField(FIELD.SETTING_UNKNOWN_6, WIRE_TYPE.LEN, unknown6), - encodeField(FIELD.SETTING_UNKNOWN_8, WIRE_TYPE.VARINT, 1), - encodeField(FIELD.SETTING_UNKNOWN_9, WIRE_TYPE.VARINT, 1) - ); -} - -export function encodeMetadata() { - return concatArrays( - encodeField(FIELD.META_PLATFORM, WIRE_TYPE.LEN, process.platform || "linux"), - encodeField(FIELD.META_ARCH, WIRE_TYPE.LEN, process.arch || "x64"), - encodeField(FIELD.META_VERSION, WIRE_TYPE.LEN, process.version || "v20.0.0"), - encodeField(FIELD.META_CWD, WIRE_TYPE.LEN, process.cwd?.() || "/"), - encodeField(FIELD.META_TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString()) - ); -} - -export function encodeMessageId(messageId, role, summaryId = null) { - return concatArrays( - encodeField(FIELD.MSGID_ID, WIRE_TYPE.LEN, messageId), - ...(summaryId ? [encodeField(FIELD.MSGID_SUMMARY, WIRE_TYPE.LEN, summaryId)] : []), - encodeField(FIELD.MSGID_ROLE, WIRE_TYPE.VARINT, role) - ); -} - -export function encodeMcpTool(tool) { - const toolName = tool.function?.name || tool.name || ""; - const toolDesc = tool.function?.description || tool.description || ""; - const inputSchema = tool.function?.parameters || tool.input_schema || {}; - - return concatArrays( - ...(toolName ? [encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName)] : []), - ...(toolDesc ? [encodeField(FIELD.MCP_TOOL_DESC, WIRE_TYPE.LEN, toolDesc)] : []), - ...(Object.keys(inputSchema).length > 0 - ? [encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, JSON.stringify(inputSchema))] - : []), - encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, "custom") - ); -} - -// ==================== REQUEST BUILDING ==================== - -export function encodeRequest(messages, modelName, tools = [], reasoningEffort = null) { - const hasTools = tools?.length > 0; - const isAgentic = hasTools; - const formattedMessages = []; - const messageIds = []; - const normalizedMessages = []; - - // 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") - ); - 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({ - 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 === normalizedMessages.length - 1; - - formattedMessages.push({ - content: msg.content, - role, - messageId: msgId, - isLast, - hasTools, - toolResults: msg.tool_results || [], - }); - - messageIds.push({ messageId: msgId, role }); - } - - // Map reasoning effort to thinking level - let thinkingLevel = THINKING_LEVEL.UNSPECIFIED; - if (reasoningEffort === "medium") thinkingLevel = THINKING_LEVEL.MEDIUM; - else if (reasoningEffort === "high") thinkingLevel = THINKING_LEVEL.HIGH; - - // Build request - return concatArrays( - // Messages - ...formattedMessages.map((fm) => - encodeField( - FIELD.MESSAGES, - WIRE_TYPE.LEN, - encodeMessage( - fm.content, - fm.role, - fm.messageId, - null, - fm.isLast, - fm.hasTools, - fm.toolResults - ) - ) - ), - - // Static fields - encodeField(FIELD.UNKNOWN_2, WIRE_TYPE.VARINT, 1), - encodeField(FIELD.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction("")), - encodeField(FIELD.UNKNOWN_4, WIRE_TYPE.VARINT, 1), - encodeField(FIELD.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)), - encodeField(FIELD.WEB_TOOL, WIRE_TYPE.LEN, ""), - encodeField(FIELD.UNKNOWN_13, WIRE_TYPE.VARINT, 1), - encodeField(FIELD.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()), - encodeField(FIELD.UNKNOWN_19, WIRE_TYPE.VARINT, 1), - encodeField(FIELD.CONVERSATION_ID, WIRE_TYPE.LEN, uuidv4()), - encodeField(FIELD.METADATA, WIRE_TYPE.LEN, encodeMetadata()), - - // Tool-related fields - encodeField(FIELD.IS_AGENTIC, WIRE_TYPE.VARINT, isAgentic ? 1 : 0), - ...(isAgentic ? [encodeField(FIELD.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] : []), - - // Message IDs - ...messageIds.map((mid) => - encodeField(FIELD.MESSAGE_IDS, WIRE_TYPE.LEN, encodeMessageId(mid.messageId, mid.role)) - ), - - // MCP Tools - ...(tools?.length > 0 - ? tools.map((tool) => encodeField(FIELD.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool))) - : []), - - // Mode fields - encodeField(FIELD.LARGE_CONTEXT, WIRE_TYPE.VARINT, 0), - encodeField(FIELD.UNKNOWN_38, WIRE_TYPE.VARINT, 0), - encodeField( - FIELD.UNIFIED_MODE, - WIRE_TYPE.VARINT, - isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT - ), - encodeField(FIELD.UNKNOWN_47, WIRE_TYPE.LEN, ""), - encodeField(FIELD.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1), - encodeField(FIELD.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel), - encodeField(FIELD.UNKNOWN_51, WIRE_TYPE.VARINT, 0), - encodeField(FIELD.UNKNOWN_53, WIRE_TYPE.VARINT, 1), - encodeField(FIELD.UNIFIED_MODE_NAME, WIRE_TYPE.LEN, isAgentic ? "Agent" : "Ask") - ); -} - -export function buildChatRequest(messages, modelName, tools = [], reasoningEffort = null) { - return encodeField( - FIELD.REQUEST, - WIRE_TYPE.LEN, - encodeRequest(messages, modelName, tools, reasoningEffort) - ); -} - -export function wrapConnectRPCFrame(payload, compress = false) { - let finalPayload = payload; - let flags = 0x00; - - if (compress) { - finalPayload = new Uint8Array(zlib.gzipSync(Buffer.from(payload))); - flags = 0x01; - } - - const frame = new Uint8Array(5 + finalPayload.length); - frame[0] = flags; - frame[1] = (finalPayload.length >> 24) & 0xff; - frame[2] = (finalPayload.length >> 16) & 0xff; - frame[3] = (finalPayload.length >> 8) & 0xff; - frame[4] = finalPayload.length & 0xff; - frame.set(finalPayload, 5); - - return frame; -} - -export function generateCursorBody(messages, modelName, tools = [], reasoningEffort = null) { - log( - "BODY", - `Generating: ${messages.length} msgs, model=${modelName}, tools=${tools.length}, reasoning=${reasoningEffort || "none"}` - ); - - const protobuf = buildChatRequest(messages, modelName, tools, reasoningEffort); - const framed = wrapConnectRPCFrame(protobuf, false); // Cursor doesn't support compressed requests - - log("BODY", `Protobuf=${protobuf.length}B, Framed=${framed.length}B`); - return framed; -} - -// ==================== PRIMITIVE DECODING ==================== - -export function decodeVarint(buffer, offset) { - let result = 0; - let shift = 0; - let pos = offset; - - while (pos < buffer.length) { - const b = buffer[pos]; - result |= (b & 0x7f) << shift; - pos++; - if (!(b & 0x80)) break; - shift += 7; - } - - return [result, pos]; -} - -export function decodeField(buffer, offset) { - if (offset >= buffer.length) return [null, null, null, offset]; - - const [tag, pos1] = decodeVarint(buffer, offset); - const fieldNum = tag >> 3; - const wireType = tag & 0x07; - - let value; - let pos = pos1; - - if (wireType === WIRE_TYPE.VARINT) { - [value, pos] = decodeVarint(buffer, pos); - } else if (wireType === WIRE_TYPE.LEN) { - const [length, pos2] = decodeVarint(buffer, pos); - value = buffer.slice(pos2, pos2 + length); - pos = pos2 + length; - } else if (wireType === WIRE_TYPE.FIXED64) { - value = buffer.slice(pos, pos + 8); - pos += 8; - } else if (wireType === WIRE_TYPE.FIXED32) { - value = buffer.slice(pos, pos + 4); - pos += 4; - } else { - value = null; - } - - return [fieldNum, wireType, value, pos]; -} - -export function decodeMessage(data) { - const fields = new Map(); - let pos = 0; - - while (pos < data.length) { - const [fieldNum, wireType, value, newPos] = decodeField(data, pos); - if (fieldNum === null) break; - - if (!fields.has(fieldNum)) fields.set(fieldNum, []); - fields.get(fieldNum).push({ wireType, value }); - pos = newPos; - } - - return fields; -} - -// ==================== RESPONSE PARSING ==================== - -export function parseConnectRPCFrame(buffer) { - if (buffer.length < 5) return null; - - const flags = buffer[0]; - const length = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4]; - - if (buffer.length < 5 + length) return null; - - let payload = buffer.slice(5, 5 + length); - - // Decompress if gzip - if (flags === 0x01) { - try { - payload = new Uint8Array(zlib.gunzipSync(Buffer.from(payload))); - } catch (err) { - log("PARSE", `Decompression failed: ${err.message}`); - } - } - - return { flags, length, payload, consumed: 5 + length }; -} - -function extractToolCall(toolCallData) { - const toolCall = decodeMessage(toolCallData); - let toolCallId = ""; - let toolName = ""; - let rawArgs = ""; - let isLast = false; - - // Extract tool call ID - if (toolCall.has(FIELD.TOOL_ID)) { - toolCallId = textDecoder.decode(toolCall.get(FIELD.TOOL_ID)[0].value); - } - - // Extract tool name - if (toolCall.has(FIELD.TOOL_NAME)) { - toolName = textDecoder.decode(toolCall.get(FIELD.TOOL_NAME)[0].value); - } - - // 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 - if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) { - try { - const mcpParams = decodeMessage(toolCall.get(FIELD.TOOL_MCP_PARAMS)[0].value); - - if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) { - const tool = decodeMessage(mcpParams.get(FIELD.MCP_TOOLS_LIST)[0].value); - - if (tool.has(FIELD.MCP_NESTED_NAME)) { - toolName = textDecoder.decode(tool.get(FIELD.MCP_NESTED_NAME)[0].value); - } - - if (tool.has(FIELD.MCP_NESTED_PARAMS)) { - rawArgs = textDecoder.decode(tool.get(FIELD.MCP_NESTED_PARAMS)[0].value); - } - } - } catch (err) { - log("EXTRACT", `MCP parse error: ${err.message}`); - } - } - - // Fallback to raw_args - if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) { - rawArgs = textDecoder.decode(toolCall.get(FIELD.TOOL_RAW_ARGS)[0].value); - } - - if (toolCallId && toolName) { - return { - id: toolCallId, - type: "function", - function: { - name: toolName, - arguments: rawArgs || "{}", - }, - isLast, - }; - } - - return null; -} - -function extractTextAndThinking(responseData) { - const nested = decodeMessage(responseData); - let text = null; - let thinking = null; - - // Extract text - if (nested.has(FIELD.RESPONSE_TEXT)) { - text = textDecoder.decode(nested.get(FIELD.RESPONSE_TEXT)[0].value); - } - - // Extract thinking - if (nested.has(FIELD.THINKING)) { - try { - const thinkingMsg = decodeMessage(nested.get(FIELD.THINKING)[0].value); - if (thinkingMsg.has(FIELD.THINKING_TEXT)) { - thinking = textDecoder.decode(thinkingMsg.get(FIELD.THINKING_TEXT)[0].value); - } - } catch (err) { - log("EXTRACT", `Thinking parse error: ${err.message}`); - } - } - - return { text, thinking }; -} - -export function extractTextFromResponse(payload) { - try { - const fields = decodeMessage(payload); - - // Warn about unknown field numbers — may indicate a Cursor protocol update - for (const fieldNum of fields.keys()) { - if (!KNOWN_RESPONSE_FIELDS.has(fieldNum)) { - log( - "SCHEMA", - `Unknown response field #${fieldNum} detected. Schema v${PROTOBUF_SCHEMA_VERSION} may be outdated.` - ); - } - } - - // Field 1: ClientSideToolV2Call - if (fields.has(FIELD.TOOL_CALL)) { - const toolCall = extractToolCall(fields.get(FIELD.TOOL_CALL)[0].value); - if (toolCall) { - log("EXTRACT", `Tool call: ${toolCall.function.name}`); - return { text: null, error: null, toolCall, thinking: null }; - } - } - - // Field 2: StreamUnifiedChatResponse - if (fields.has(FIELD.RESPONSE)) { - const { text, thinking } = extractTextAndThinking(fields.get(FIELD.RESPONSE)[0].value); - - if (text || thinking) { - return { text, error: null, toolCall: null, thinking }; - } - } - - return { text: null, error: null, toolCall: null, thinking: null }; - } catch (err) { - // Graceful fallback — return raw payload instead of crashing - log("EXTRACT", `Decode failed (schema v${PROTOBUF_SCHEMA_VERSION}): ${err.message}`); - return { - text: null, - error: null, - toolCall: null, - thinking: null, - raw: Buffer.from(payload).toString("base64"), - decodeError: err.message, - }; - } -} - -// ==================== EXPORTS ==================== - -const cursorProtobufUtils = { - encodeVarint, - encodeField, - encodeMessage, - buildChatRequest, - wrapConnectRPCFrame, - generateCursorBody, - decodeVarint, - decodeField, - decodeMessage, - parseConnectRPCFrame, - extractTextFromResponse, -}; - -export default cursorProtobufUtils; diff --git a/scripts/cursor-tap.cjs b/scripts/cursor-tap.cjs new file mode 100644 index 0000000000..1c0e548699 --- /dev/null +++ b/scripts/cursor-tap.cjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** + * cursor-tap — capture cursor agent.v1.AgentService/Run wire bytes for tests. + * + * Usage: + * CURSOR_TOKEN=... node scripts/cursor-tap.cjs + * + * Examples: + * node scripts/cursor-tap.cjs single-turn-chat "say only PING" + * node scripts/cursor-tap.cjs system-prompt "be brief|hi" # split on first '|' + * node scripts/cursor-tap.cjs tool-call "weather in Paris" --tools=get_weather + * node scripts/cursor-tap.cjs composer-2-fast "hi" --model=composer-2-fast + * + * Writes the upstream response bytes to tests/fixtures/cursor/.bin + * and prints decoded summary to stdout. Use these fixtures in unit tests to + * catch schema drift in cursor-agent's protobuf format. + * + * Note: this is a one-time / on-demand tool. The .bin output is gitignored + * by default; commit fixtures explicitly when you want them in the test + * baseline (tests/fixtures/cursor/.gitignore controls this). + */ + +const fs = require("fs"); +const path = require("path"); +const http2 = require("http2"); +const crypto = require("crypto"); + +const args = process.argv.slice(2); +if (args.length < 2) { + console.error("Usage: cursor-tap.cjs [--model=...] [--tools=name1,name2]"); + process.exit(1); +} + +const [fixtureName, prompt, ...flags] = args; +const flagMap = Object.fromEntries( + flags.map((f) => { + const m = f.match(/^--([^=]+)=(.*)$/); + return m ? [m[1], m[2]] : [f.replace(/^--/, ""), true]; + }) +); + +const token = process.env.CURSOR_TOKEN; +if (!token) { + console.error("Set CURSOR_TOKEN environment variable."); + process.exit(1); +} + +const model = flagMap.model || "auto"; +const conversationId = crypto.randomUUID(); +const requestId = crypto.randomUUID(); +const traceParent = `00-${crypto.randomBytes(16).toString("hex")}-${crypto.randomBytes(8).toString("hex")}-01`; + +// ─── Minimal protobuf encoder (mirrors open-sse/utils/cursorAgentProtobuf.ts) ─ + +function encodeVarint(n) { + const out = []; + let v = BigInt(n); + while (v > 0x7fn) { + out.push(Number(v & 0x7fn) | 0x80); + v >>= 7n; + } + out.push(Number(v)); + return Buffer.from(out); +} +function tag(field, wt) { + return encodeVarint((field << 3) | wt); +} +function lenField(f, payload) { + return Buffer.concat([tag(f, 2), encodeVarint(payload.length), payload]); +} +function strField(f, s) { + return lenField(f, Buffer.from(s, "utf8")); +} +function varintField(f, n) { + return Buffer.concat([tag(f, 0), encodeVarint(n)]); +} +function wrapConnectFrame(payload) { + const header = Buffer.alloc(5); + header[0] = 0; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +// AgentRunRequest body +const userMessage = lenField( + 1, + Buffer.concat([ + strField(1, prompt), + strField(2, crypto.randomUUID()), + lenField(3, Buffer.alloc(0)), + varintField(4, 1), + ]) +); +const userMessageAction = lenField(1, userMessage); +const action = lenField(2, userMessageAction); +const conversationState = lenField(1, Buffer.alloc(0)); +const requestedModel = lenField(9, strField(1, model === "auto" ? "default" : model)); +const arr = Buffer.concat([ + conversationState, + action, + lenField(4, Buffer.alloc(0)), // mcp_tools + strField(5, conversationId), + requestedModel, + varintField(12, 0), + strField(16, conversationId), +]); +const acm = lenField(1, arr); +const body = wrapConnectFrame(acm); + +// ─── h2 request ──────────────────────────────────────────────────────────── + +const cleanToken = token.includes("::") ? token.split("::")[1] : token; +const client = http2.connect("https://agentn.global.api5.cursor.sh"); + +const collected = []; +let responseStatus = 0; + +const req = client.request({ + ":method": "POST", + ":path": "/agent.v1.AgentService/Run", + ":authority": "agentn.global.api5.cursor.sh", + ":scheme": "https", + authorization: `Bearer ${cleanToken}`, + "backend-traceparent": traceParent, + "connect-accept-encoding": "gzip,br", + "connect-protocol-version": "1", + "content-type": "application/connect+proto", + traceparent: traceParent, + "user-agent": "connect-es/1.6.1", + "x-cursor-client-type": "cli", + "x-cursor-client-version": "cli-2025.10.21-b2dfaef", + "x-ghost-mode": "true", + "x-original-request-id": requestId, + "x-request-id": requestId, +}); + +req.on("response", (h) => { + responseStatus = Number(h[":status"]); +}); +req.on("data", (chunk) => { + collected.push(Buffer.from(chunk)); +}); +req.on("end", () => { + const raw = Buffer.concat(collected); + const outDir = path.join(__dirname, "..", "tests", "fixtures", "cursor"); + fs.mkdirSync(outDir, { recursive: true }); + const outFile = path.join(outDir, `${fixtureName}.bin`); + fs.writeFileSync(outFile, raw); + console.log(`[cursor-tap] status=${responseStatus} bytes=${raw.length} → ${outFile}`); + client.close(); +}); +req.on("error", (err) => { + console.error("[cursor-tap] req error:", err); + process.exit(1); +}); + +req.write(body); +// NOTE: we never end the request; cursor closes the stream itself when the +// turn is done. For tool-using captures, the script may need to write +// follow-up frames before the stream closes — extend as needed. + +// Safety timeout: if cursor doesn't close in 60s, dump what we have. +setTimeout(() => { + console.warn("[cursor-tap] safety timeout; closing"); + try { + req.close(); + client.close(); + } catch {} + const raw = Buffer.concat(collected); + if (raw.length > 0) { + const outDir = path.join(__dirname, "..", "tests", "fixtures", "cursor"); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, `${fixtureName}.bin`), raw); + } + process.exit(0); +}, 60_000); diff --git a/scripts/sync-cursor-models.mjs b/scripts/sync-cursor-models.mjs new file mode 100644 index 0000000000..7aa29a88e8 --- /dev/null +++ b/scripts/sync-cursor-models.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// Sync the cursor models list in open-sse/config/providerRegistry.ts from +// cursor-agent's runtime model list. Triggers an intentional invalid --model +// invocation so cursor-agent prints "Available models: ..." on stderr. +// +// Usage: +// node scripts/sync-cursor-models.mjs # spawn cursor-agent and apply +// node scripts/sync-cursor-models.mjs --dry-run # print proposed block, don't write +// node scripts/sync-cursor-models.mjs --from-stdin # read the error message from stdin + +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REGISTRY_PATH = resolve(__dirname, "..", "open-sse", "config", "providerRegistry.ts"); + +const args = new Set(process.argv.slice(2)); +const DRY_RUN = args.has("--dry-run"); +const FROM_STDIN = args.has("--from-stdin"); + +function readSource() { + if (FROM_STDIN) return readFileSync(0, "utf8"); + // cursor-agent prints "Available models: ..." to stderr and exits non-zero. + const r = spawnSync("cursor-agent", ["--model", "--help"], { encoding: "utf8" }); + return `${r.stdout || ""}\n${r.stderr || ""}`; +} + +// `auto` is a CLI-side abstraction (cursor-agent resolves it locally before +// sending) and `composer-*` targets cursor's edit/composer endpoint, neither +// of which work via the chat RPC. Filter them so the dashboard never offers +// them to the chat path. Pass --include-unsupported to keep them. +function isUnsupportedChatModel(id) { + if (id === "auto") return true; + if (id.startsWith("composer-")) return true; + return false; +} + +function parseModelIds(text) { + const m = text.match(/Available models:\s*([^\n]+)/); + if (!m) throw new Error("Could not find 'Available models:' line in cursor-agent output"); + const includeUnsupported = args.has("--include-unsupported"); + return m[1] + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .filter((id) => includeUnsupported || !isUnsupportedChatModel(id)); +} + +const SEGMENT_OVERRIDES = { + gpt: "GPT", + claude: "Claude", + gemini: "Gemini", + grok: "Grok", + kimi: "Kimi", + composer: "Composer", + opus: "Opus", + sonnet: "Sonnet", + haiku: "Haiku", + codex: "Codex", + mini: "Mini", + nano: "Nano", + max: "Max", + high: "High", + low: "Low", + medium: "Medium", + xhigh: "XHigh", + none: "None", + fast: "Fast", + thinking: "Thinking", + extra: "Extra", + spark: "Spark", + preview: "Preview", + flash: "Flash", + pro: "Pro", +}; + +// Pretty-print an id by: +// 1) collapsing claude-NAME-X-Y dotted version (e.g. claude-opus-4-7 → claude-opus-4.7) +// 2) splitting on '-' +// 3) applying SEGMENT_OVERRIDES; falling back to capitalize-first +function humanize(id) { + if (id === "auto") return "Auto (Server Picks)"; + + // Collapse "X-Y" numeric suffix in claude-foo-X-Y- patterns into "X.Y" + const collapsed = id.replace(/(\d+)-(\d+)(?=-|$)/g, "$1.$2"); + const parts = collapsed.split("-"); + const labelled = parts.map((p) => { + if (SEGMENT_OVERRIDES[p]) return SEGMENT_OVERRIDES[p]; + if (/^\d/.test(p)) return p; // leave version numbers / "k2.5" alone + return p.charAt(0).toUpperCase() + p.slice(1); + }); + return labelled.join(" "); +} + +function buildModelsArrayLines(ids) { + const seen = new Set(); + const out = []; + for (const id of ids) { + if (seen.has(id)) continue; + seen.add(id); + out.push(` { id: ${JSON.stringify(id)}, name: ${JSON.stringify(humanize(id))} },`); + } + return out.join("\n"); +} + +function replaceCursorModels(source, modelsBlock) { + // Match the `cursor:` provider entry and replace just its `models: [ ... ],` array. + const re = /(\n cursor:\s*\{[\s\S]*?\n models:\s*\[)([\s\S]*?)(\n \],)/; + if (!re.test(source)) throw new Error("Could not locate cursor.models array in registry"); + return source.replace(re, `$1\n${modelsBlock}$3`); +} + +const ids = parseModelIds(readSource()); +const block = buildModelsArrayLines(ids); + +if (DRY_RUN) { + console.log(block); + process.exit(0); +} + +const before = readFileSync(REGISTRY_PATH, "utf8"); +const after = replaceCursorModels(before, block); +if (before === after) { + console.log("No changes — cursor models already in sync."); + process.exit(0); +} +writeFileSync(REGISTRY_PATH, after); +console.log(`Updated ${ids.length} cursor models in ${REGISTRY_PATH}`); diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 7b004dc278..7b6b8572f5 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -63,6 +63,7 @@ import { isAutoFetchModelsEnabled, persistDiscoveredModels, } from "@/lib/providerModels/modelDiscovery"; +import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent"; type JsonRecord = Record; type LocalCatalogModel = { @@ -1525,6 +1526,31 @@ export async function GET( }); } + if (provider === "cursor") { + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + + try { + const models = await fetchCursorAgentModels(); + return buildApiDiscoveryResponse(models); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.log("[models] cursor-agent fetch failed:", message); + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: `cursor-agent unavailable (${message}) — using cached catalog`, + localWarning: `cursor-agent unavailable (${message}) — using local catalog`, + }); + if (fallback) return fallback; + return NextResponse.json( + { error: `Failed to fetch Cursor models: ${message}` }, + { status: 502 } + ); + } + } + if (provider === "glm" || provider === "glmt") { const cachedResponse = maybeReturnCachedDiscovery(); if (cachedResponse) return cachedResponse; diff --git a/src/lib/providerModels/cursorAgent.ts b/src/lib/providerModels/cursorAgent.ts new file mode 100644 index 0000000000..b30fb690f8 --- /dev/null +++ b/src/lib/providerModels/cursorAgent.ts @@ -0,0 +1,166 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { delimiter, join } from "node:path"; + +// cursor-agent waits on stdin when given a piped fd, so we always launch it +// with stdin closed ("ignore") so it exits as soon as it prints the model list. +function runCursorAgent( + binary: string, + args: string[], + timeoutMs: number +): Promise<{ stdout: string; stderr: string; code: number | null; signal: NodeJS.Signals | null }> { + return new Promise((resolve, reject) => { + let child; + try { + child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] }); + } catch (err) { + reject(err); + return; + } + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const killTimer = setTimeout(() => child.kill("SIGTERM"), timeoutMs); + child.on("error", (err) => { + clearTimeout(killTimer); + reject(err); + }); + child.on("close", (code, signal) => { + clearTimeout(killTimer); + resolve({ stdout, stderr, code, signal }); + }); + }); +} + +// Resolve cursor-agent across common install locations, since the standalone +// Next.js server may run with a PATH that doesn't include the user's local bin. +function resolveCursorAgentBinary(): string | null { + const home = homedir(); + const candidates = [ + join(home, ".local", "bin", "cursor-agent"), + "/root/.local/bin/cursor-agent", + "/usr/local/bin/cursor-agent", + "/usr/bin/cursor-agent", + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + // Fallback: PATH-based lookup (lets execFile do the resolution). + const pathDirs = (process.env.PATH || "").split(delimiter).filter(Boolean); + for (const dir of pathDirs) { + const candidate = join(dir, "cursor-agent"); + if (existsSync(candidate)) return candidate; + } + return null; +} + +const SEGMENT_OVERRIDES: Record = { + gpt: "GPT", + claude: "Claude", + gemini: "Gemini", + grok: "Grok", + kimi: "Kimi", + composer: "Composer", + opus: "Opus", + sonnet: "Sonnet", + haiku: "Haiku", + codex: "Codex", + mini: "Mini", + nano: "Nano", + max: "Max", + high: "High", + low: "Low", + medium: "Medium", + xhigh: "XHigh", + none: "None", + fast: "Fast", + thinking: "Thinking", + extra: "Extra", + spark: "Spark", + preview: "Preview", + flash: "Flash", + pro: "Pro", +}; + +export function humanizeCursorModelId(id: string): string { + if (id === "auto") return "Auto (Server Picks)"; + + // Collapse digit-dash-digit suffixes (e.g. claude-opus-4-7 → claude-opus-4.7) + // so version numbers read naturally. + const collapsed = id.replace(/(\d+)-(\d+)(?=-|$)/g, "$1.$2"); + return collapsed + .split("-") + .map((part) => { + if (SEGMENT_OVERRIDES[part]) return SEGMENT_OVERRIDES[part]; + if (/^\d/.test(part)) return part; + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join(" "); +} + +export function parseCursorAgentModels(text: string): string[] { + const match = text.match(/Available models:\s*([^\n]+)/); + if (!match) return []; + const seen = new Set(); + const out: string[] = []; + for (const raw of match[1].split(",")) { + const id = raw.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + out.push(id); + } + return out; +} + +export type CursorAgentModelEntry = { + id: string; + name: string; + owned_by: "cursor"; +}; + +export async function fetchCursorAgentModels( + options: { binary?: string; timeoutMs?: number } = {} +): Promise { + const binary = options.binary || resolveCursorAgentBinary(); + const timeoutMs = options.timeoutMs ?? 5000; + + if (!binary) { + throw new Error( + "cursor-agent binary not found. Install it (curl https://cursor.com/install -fsS | bash) so ~/.local/bin/cursor-agent exists, or pass a binary path explicitly." + ); + } + + // cursor-agent prints "Available models: ..." to stderr and exits non-zero + // when given an unknown model id, so we intentionally pass `--help` as the + // model value to coerce it into listing. + let result: { stdout: string; stderr: string }; + try { + result = await runCursorAgent(binary, ["--model", "--help"], timeoutMs); + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e?.code === "ENOENT") { + throw new Error(`cursor-agent binary not executable at ${binary}`); + } + throw err; + } + const combined = `${result.stdout}\n${result.stderr}`; + + const ids = parseCursorAgentModels(combined); + if (ids.length === 0) { + throw new Error("cursor-agent did not return an 'Available models:' line"); + } + + return ids.map((id) => ({ + id, + name: humanizeCursorModelId(id), + owned_by: "cursor" as const, + })); +} diff --git a/tests/fixtures/cursor/.gitignore b/tests/fixtures/cursor/.gitignore new file mode 100644 index 0000000000..ee0bf80a07 --- /dev/null +++ b/tests/fixtures/cursor/.gitignore @@ -0,0 +1,5 @@ +# Captured cursor wire bytes from scripts/cursor-tap.cjs. +# By default these are local-only — uncomment a specific fixture to +# include it in the repo as a regression baseline. +*.bin +!.gitignore diff --git a/tests/integration/cursor-e2e.test.ts b/tests/integration/cursor-e2e.test.ts new file mode 100644 index 0000000000..42175787bc --- /dev/null +++ b/tests/integration/cursor-e2e.test.ts @@ -0,0 +1,147 @@ +/** + * Cursor end-to-end integration test. + * + * Skipped unless `CURSOR_E2E_TOKEN` env var is set. Exercises the full + * OpenAI-compatible flow against cursor's real `agent.v1.AgentService/Run` + * endpoint: + * + * 1. Single-turn chat with system prompt + * 2. Tool-use round trip (request → tool_calls → role:"tool" follow-up) + * 3. Streaming SSE incremental delivery + * 4. Inline-session reuse across two consecutive calls + * 5. Cold-resume fallback when session is missing/evicted + * + * To run: + * CURSOR_E2E_TOKEN=$(cat ~/.cursor/access-token) \ + * node --import tsx/esm --test tests/integration/cursor-e2e.test.ts + * + * Capturing wire fixtures (separate workflow): + * CURSOR_TOKEN=... node scripts/cursor-tap.cjs single-turn-chat "say PING" + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const TOKEN = process.env.CURSOR_E2E_TOKEN; +const skipReason = TOKEN ? undefined : "CURSOR_E2E_TOKEN not set"; + +test( + "[cursor-e2e] single-turn plain chat returns assistant text", + { skip: skipReason }, + async () => { + const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); + const exec = new CursorExecutor(); + const result = await exec.execute({ + model: "auto", + body: { messages: [{ role: "user", content: "say only PING" }] }, + stream: false, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 200); + const json = await result.response.json(); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.match(json.choices[0].message.content, /PING/i); + } +); + +test("[cursor-e2e] system prompt biases the response", { skip: skipReason }, async () => { + const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); + const exec = new CursorExecutor(); + const result = await exec.execute({ + model: "auto", + body: { + messages: [ + { role: "system", content: "Reply with exactly the word HAIKU and nothing else." }, + { role: "user", content: "hi" }, + ], + }, + stream: false, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 200); + const json = await result.response.json(); + assert.match(json.choices[0].message.content, /HAIKU/); +}); + +test("[cursor-e2e] tool-use single-turn returns tool_calls", { skip: skipReason }, async () => { + const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); + const exec = new CursorExecutor(); + const result = await exec.execute({ + model: "claude-4.6-sonnet-medium", + body: { + messages: [{ role: "user", content: "What's the weather in Paris? Use the tool." }], + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }, + ], + }, + stream: false, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 200); + const json = await result.response.json(); + assert.equal(json.choices[0].finish_reason, "tool_calls"); + const toolCall = json.choices[0].message.tool_calls?.[0]; + assert.ok(toolCall, "expected a tool_call"); + assert.equal(toolCall.function.name, "get_weather"); + assert.match(toolCall.function.arguments, /Paris/); +}); + +test( + "[cursor-e2e] streaming SSE delivers chunks before the upstream closes", + { skip: skipReason }, + async () => { + const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); + const exec = new CursorExecutor(); + const result = await exec.execute({ + model: "auto", + body: { messages: [{ role: "user", content: "count from 1 to 5" }] }, + stream: true, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 200); + const reader = (result.response.body as ReadableStream).getReader(); + const decoder = new TextDecoder(); + let chunks = 0; + let totalText = ""; + let firstChunkTime: number | null = null; + const startTime = Date.now(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (firstChunkTime == null) firstChunkTime = Date.now(); + const text = decoder.decode(value); + chunks++; + totalText += text; + } + void firstChunkTime; + void startTime; + // Multiple SSE chunks (not one big buffered blob) proves we're streaming + // emit-as-decoded. The exact latency ratio depends on cursor's pacing + // and isn't worth asserting tightly. + assert.ok(chunks > 1, `expected multiple chunks; got ${chunks}`); + assert.match(totalText, /data: \[DONE\]/); + } +); diff --git a/tests/unit/cursor-agent-exec-router.test.ts b/tests/unit/cursor-agent-exec-router.test.ts new file mode 100644 index 0000000000..2469a24b4a --- /dev/null +++ b/tests/unit/cursor-agent-exec-router.test.ts @@ -0,0 +1,219 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + decodeExecServerEvent, + iterateConnectFrames, + wrapConnectFrame, +} from "../../open-sse/utils/cursorAgentProtobuf"; + +// ─── Wire-format helpers (match the encoder's primitives) ────────────────── +// +// We synthesize ExecServerMessage payloads for each variant and assert +// decodeExecServerEvent returns the right kind + extracted fields. This +// validates the decoder against the field-number contract independently of +// the encoder (which runs the same code path in different test files). + +function v(n: number): Buffer { + const out: number[] = []; + while (n > 0x7f) { + out.push((n & 0x7f) | 0x80); + n >>>= 7; + } + out.push(n); + return Buffer.from(out); +} +function tag(field: number, wireType: number): Buffer { + return v((field << 3) | wireType); +} +function lenPrefixed(field: number, payload: Buffer): Buffer { + return Buffer.concat([tag(field, 2), v(payload.length), payload]); +} +function varintField(field: number, value: number): Buffer { + return Buffer.concat([tag(field, 0), v(value)]); +} +function stringField(field: number, value: string): Buffer { + return lenPrefixed(field, Buffer.from(value, "utf8")); +} + +// AgentServerMessage { exec_server_message (2): ExecServerMessage } +function buildAgentServerMessage(esmInner: Buffer): Buffer { + return lenPrefixed(2, esmInner); +} + +// ExecServerMessage { id (1): execMsgId, exec_id (15): execId, : bytes } +function buildExecServerMessage( + execMsgId: number, + execId: string, + variantField: number, + variantPayload: Buffer +): Buffer { + return Buffer.concat([ + varintField(1, execMsgId), + stringField(15, execId), + lenPrefixed(variantField, variantPayload), + ]); +} + +test("decodeExecServerEvent returns null for unrelated AgentServerMessage", () => { + // text_delta InteractionUpdate, no exec_server_message + const interactionUpdate = lenPrefixed(1, lenPrefixed(1, Buffer.from("hi", "utf8"))); + const asm = lenPrefixed(1, interactionUpdate); + assert.equal(decodeExecServerEvent(asm), null); +}); + +test("decodeExecServerEvent recognizes request_context (field 10)", () => { + const variant = Buffer.alloc(0); // RequestContextArgs has no fields we care about + const esm = buildExecServerMessage(1, "exec-rc", 10, variant); + const asm = buildAgentServerMessage(esm); + const event = decodeExecServerEvent(asm); + assert.deepEqual(event, { kind: "exec_request_context", execMsgId: 1, execId: "exec-rc" }); +}); + +test("decodeExecServerEvent recognizes read_args (field 7) with path", () => { + const variant = stringField(1, "/etc/passwd"); + const esm = buildExecServerMessage(2, "exec-r", 7, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { + kind: "exec_read", + execMsgId: 2, + execId: "exec-r", + path: "/etc/passwd", + }); +}); + +test("decodeExecServerEvent recognizes write_args (field 3)", () => { + const variant = stringField(1, "/tmp/x"); + const esm = buildExecServerMessage(3, "exec-w", 3, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { kind: "exec_write", execMsgId: 3, execId: "exec-w", path: "/tmp/x" }); +}); + +test("decodeExecServerEvent recognizes delete_args (field 4)", () => { + const variant = stringField(1, "/tmp/y"); + const esm = buildExecServerMessage(4, "exec-d", 4, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { kind: "exec_delete", execMsgId: 4, execId: "exec-d", path: "/tmp/y" }); +}); + +test("decodeExecServerEvent recognizes ls_args (field 8)", () => { + const variant = stringField(1, "/home"); + const esm = buildExecServerMessage(5, "exec-l", 8, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { kind: "exec_ls", execMsgId: 5, execId: "exec-l", path: "/home" }); +}); + +test("decodeExecServerEvent recognizes grep_args (field 5)", () => { + const variant = stringField(1, "pattern"); + const esm = buildExecServerMessage(6, "exec-g", 5, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { kind: "exec_grep", execMsgId: 6, execId: "exec-g" }); +}); + +test("decodeExecServerEvent recognizes diagnostics_args (field 9)", () => { + const esm = buildExecServerMessage(7, "exec-diag", 9, Buffer.alloc(0)); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { kind: "exec_diagnostics", execMsgId: 7, execId: "exec-diag" }); +}); + +test("decodeExecServerEvent recognizes shell_args (field 2) with command + working_dir", () => { + const variant = Buffer.concat([stringField(1, "ls -la"), stringField(2, "/tmp")]); + const esm = buildExecServerMessage(8, "exec-s", 2, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { + kind: "exec_shell", + execMsgId: 8, + execId: "exec-s", + command: "ls -la", + workingDir: "/tmp", + }); +}); + +test("decodeExecServerEvent recognizes shell_stream_args (field 14)", () => { + const variant = Buffer.concat([stringField(1, "tail -f x"), stringField(2, "/var/log")]); + const esm = buildExecServerMessage(9, "exec-ss", 14, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { + kind: "exec_shell_stream", + execMsgId: 9, + execId: "exec-ss", + command: "tail -f x", + workingDir: "/var/log", + }); +}); + +test("decodeExecServerEvent recognizes background_shell_spawn (field 16)", () => { + const variant = Buffer.concat([stringField(1, "node server"), stringField(2, "/app")]); + const esm = buildExecServerMessage(10, "exec-bg", 16, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { + kind: "exec_bg_shell", + execMsgId: 10, + execId: "exec-bg", + command: "node server", + workingDir: "/app", + }); +}); + +test("decodeExecServerEvent recognizes fetch_args (field 20) with url", () => { + const variant = stringField(1, "https://example.com/x"); + const esm = buildExecServerMessage(11, "exec-f", 20, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { + kind: "exec_fetch", + execMsgId: 11, + execId: "exec-f", + url: "https://example.com/x", + }); +}); + +test("decodeExecServerEvent recognizes write_shell_stdin_args (field 23)", () => { + const esm = buildExecServerMessage(12, "exec-stdin", 23, Buffer.alloc(0)); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { kind: "exec_write_shell_stdin", execMsgId: 12, execId: "exec-stdin" }); +}); + +test("decodeExecServerEvent recognizes mcp_args (field 11) with name + tool_call_id", () => { + // McpArgs { name (1): "foo", tool_call_id (3): "call_x" } + const variant = Buffer.concat([stringField(1, "get_weather"), stringField(3, "call_abc123")]); + const esm = buildExecServerMessage(13, "exec-mcp", 11, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + assert.deepEqual(event, { + kind: "exec_mcp", + execMsgId: 13, + execId: "exec-mcp", + toolName: "get_weather", + toolCallId: "call_abc123", + args: {}, + }); +}); + +test("decodeExecServerEvent prefers tool_name (field 5) over name (field 1)", () => { + // McpArgs { name: "old", tool_name: "new", tool_call_id: "x" } + const variant = Buffer.concat([ + stringField(1, "old_alias"), + stringField(3, "call_y"), + stringField(5, "canonical_name"), + ]); + const esm = buildExecServerMessage(14, "exec-mcp2", 11, variant); + const event = decodeExecServerEvent(buildAgentServerMessage(esm)); + if (event?.kind !== "exec_mcp") throw new Error("expected exec_mcp"); + assert.equal(event.toolName, "canonical_name"); +}); + +// ─── Wire-tap regression: real Connect-RPC frame round-trips ─────────────── + +test("decodeExecServerEvent works through a Connect-RPC frame", () => { + const variant = stringField(1, "/etc/shadow"); + const esm = buildExecServerMessage(99, "exec-real", 7, variant); + const asm = buildAgentServerMessage(esm); + const framed = wrapConnectFrame(asm); + const frames = [...iterateConnectFrames(framed)]; + assert.equal(frames.length, 1); + const event = decodeExecServerEvent(frames[0].payload); + assert.deepEqual(event, { + kind: "exec_read", + execMsgId: 99, + execId: "exec-real", + path: "/etc/shadow", + }); +}); diff --git a/tests/unit/cursor-agent-models.test.ts b/tests/unit/cursor-agent-models.test.ts new file mode 100644 index 0000000000..3893d2b160 --- /dev/null +++ b/tests/unit/cursor-agent-models.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + humanizeCursorModelId, + parseCursorAgentModels, +} from "../../src/lib/providerModels/cursorAgent"; + +test("parseCursorAgentModels returns every reported id including auto and composer-*", () => { + const text = + "Cannot use this model: --help. Available models: auto, composer-2, composer-2-fast, gpt-5.3-codex-low, claude-opus-4-7-thinking-high, kimi-k2.5"; + assert.deepEqual(parseCursorAgentModels(text), [ + "auto", + "composer-2", + "composer-2-fast", + "gpt-5.3-codex-low", + "claude-opus-4-7-thinking-high", + "kimi-k2.5", + ]); +}); + +test("parseCursorAgentModels deduplicates and trims", () => { + assert.deepEqual(parseCursorAgentModels("Available models: a, a , b"), ["a", "b"]); +}); + +test("parseCursorAgentModels returns [] when the marker is missing", () => { + assert.deepEqual(parseCursorAgentModels("nothing here"), []); +}); + +test("humanizeCursorModelId pretty-prints common patterns", () => { + assert.equal(humanizeCursorModelId("auto"), "Auto (Server Picks)"); + assert.equal(humanizeCursorModelId("composer-2-fast"), "Composer 2 Fast"); + assert.equal(humanizeCursorModelId("gpt-5.3-codex-low"), "GPT 5.3 Codex Low"); + assert.equal(humanizeCursorModelId("gpt-5.5-extra-high-fast"), "GPT 5.5 Extra High Fast"); + // Collapses claude-opus-4-7-* version pattern into 4.7 + assert.equal( + humanizeCursorModelId("claude-opus-4-7-thinking-high"), + "Claude Opus 4.7 Thinking High" + ); + assert.equal(humanizeCursorModelId("kimi-k2.5"), "Kimi K2.5"); + assert.equal(humanizeCursorModelId("gemini-3.1-pro"), "Gemini 3.1 Pro"); + assert.equal(humanizeCursorModelId("claude-4-sonnet-thinking"), "Claude 4 Sonnet Thinking"); +}); diff --git a/tests/unit/cursor-agent-protobuf.test.ts b/tests/unit/cursor-agent-protobuf.test.ts new file mode 100644 index 0000000000..adb34505ea --- /dev/null +++ b/tests/unit/cursor-agent-protobuf.test.ts @@ -0,0 +1,490 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + resolveRequestedModel, + encodeAgentRunRequest, + buildAgentRequestBody, + iterateConnectFrames, + decodeAgentServerMessage, + flattenMessages, + wrapConnectFrame, + encodeExecReadRejected, + encodeExecWriteRejected, + encodeExecDeleteRejected, + encodeExecLsRejected, + encodeExecShellRejected, + encodeExecBackgroundShellSpawnRejected, + encodeExecGrepError, + encodeExecFetchError, + encodeExecWriteShellStdinError, + encodeExecDiagnosticsResult, + encodeExecMcpResult, + encodeExecMcpError, + encodeKvGetBlobResult, + encodeKvSetBlobResult, + encodeMcpToolDefinitionBody, + jsonSchemaToProtobufValue, + encodeRequestContextResponse, + openAIToolsToMcpDefs, +} from "../../open-sse/utils/cursorAgentProtobuf"; + +test("resolveRequestedModel maps cursor-agent's client-side aliases", () => { + assert.deepEqual(resolveRequestedModel("auto"), { modelId: "default", parameters: [] }); + assert.deepEqual(resolveRequestedModel("composer-2-fast"), { + modelId: "composer-2", + parameters: [{ id: "fast", value: "true" }], + }); + assert.deepEqual(resolveRequestedModel("claude-4.6-sonnet-medium"), { + modelId: "claude-4.6-sonnet-medium", + parameters: [], + }); + assert.deepEqual(resolveRequestedModel("composer-2"), { modelId: "composer-2", parameters: [] }); +}); + +test("encodeAgentRunRequest embeds user text and resolves the model id", () => { + const buf = encodeAgentRunRequest({ + modelId: "auto", + userText: "say only PING", + conversationId: "00000000-0000-0000-0000-000000000000", + messageId: "11111111-1111-1111-1111-111111111111", + }); + // Verifiable substrings: cursor-agent itself emits these on the wire. + const text = buf.toString("latin1"); + assert.ok(text.includes("say only PING"), "user text present"); + assert.ok(text.includes("default"), "auto rewritten to 'default'"); + assert.ok(text.includes("00000000-0000-0000-0000-000000000000"), "conversation id present"); +}); + +test("encodeAgentRunRequest emits composer parameters", () => { + const buf = encodeAgentRunRequest({ + modelId: "composer-2-fast", + userText: "hi", + }); + const text = buf.toString("latin1"); + assert.ok(text.includes("composer-2"), "split model id present"); + assert.ok(text.includes("fast"), "parameter id 'fast' present"); + assert.ok(text.includes("true"), "parameter value 'true' present"); +}); + +test("buildAgentRequestBody wraps the message in a Connect-RPC frame", () => { + const buf = buildAgentRequestBody({ modelId: "claude-4.6-sonnet-medium", userText: "hi" }); + // First byte is flags (0x00 = uncompressed); next 4 bytes are big-endian length. + assert.equal(buf[0], 0x00); + const length = buf.readUInt32BE(1); + assert.equal(length, buf.length - 5); +}); + +test("iterateConnectFrames + decodeAgentServerMessage extract a text delta", () => { + // Synthesize a server-side delta frame: AgentServerMessage { interaction_update { + // text_delta { text = "hello" } } } + function v(n: number): Buffer { + const out: number[] = []; + while (n > 0x7f) { + out.push((n & 0x7f) | 0x80); + n >>>= 7; + } + out.push(n); + return Buffer.from(out); + } + function tag(field: number, wt: number) { + return v((field << 3) | wt); + } + function lenPrefixed(field: number, payload: Buffer) { + return Buffer.concat([tag(field, 2), v(payload.length), payload]); + } + const textDelta = Buffer.from("hello", "utf8"); + const tdu = lenPrefixed(1, textDelta); // TextDeltaUpdate { text } + const iu = lenPrefixed(1, tdu); // InteractionUpdate { text_delta } + const asm = lenPrefixed(1, iu); // AgentServerMessage { interaction_update } + const framed = wrapConnectFrame(asm); + + const frames = [...iterateConnectFrames(framed)]; + assert.equal(frames.length, 1); + const deltas = decodeAgentServerMessage(frames[0].payload); + assert.deepEqual(deltas, [{ kind: "text", text: "hello" }]); +}); + +test("flattenMessages handles a simple single user message", () => { + assert.equal(flattenMessages([{ role: "user", content: "hello there" }]), "hello there"); +}); + +test("flattenMessages prepends system prompts and labels multi-turn", () => { + const out = flattenMessages([ + { role: "system", content: "be brief" }, + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + { role: "user", content: "again" }, + ]); + assert.ok(out.startsWith("be brief")); + assert.ok(out.includes("User: hi")); + assert.ok(out.includes("Assistant: hello")); + assert.ok(out.includes("User: again")); +}); + +test("flattenMessages flattens content arrays", () => { + const out = flattenMessages([ + { + role: "user", + content: [ + { type: "text", text: "part1" }, + { type: "text", text: "part2" }, + ], + }, + ]); + assert.equal(out, "part1\npart2"); +}); + +// ─── Phase 1: rejection encoders ─────────────────────────────────────────── +// +// All ExecClientMessage frames share a structure: +// wrapConnectFrame( +// AgentClientMessage { +// exec_client_message (2): ExecClientMessage { +// id (1): execMsgId, +// exec_id (15): execId, +// : , +// } +// } +// ) +// +// We unwrap via decodeFields and assert: (1) framing is valid, (2) the +// ExecClientMessage carries the right id+exec_id, (3) the result field +// number matches the variant, (4) the payload bytes contain the rejection +// reason as a substring (sufficient since Buffer.from(reason, "utf8") is +// embedded verbatim by encodeString). + +function unwrapFrame(buf: Buffer): Buffer { + const frames = [...iterateConnectFrames(buf)]; + assert.equal(frames.length, 1); + return frames[0].payload; +} + +function assertEcmShape( + framed: Buffer, + expectedExecMsgId: number, + expectedExecId: string, + expectedResultField: number +) { + const payload = unwrapFrame(framed); + // Top-level: AgentClientMessage { exec_client_message (2): ECM } + // Just verify the fragment contains the exec id and result-field tag. + assert.ok(payload.includes(Buffer.from(expectedExecId, "utf8")), "exec_id present"); + // Tag for result field with WT_LEN (2): (field<<3)|2 + const resultTag = (expectedResultField << 3) | 2; + let found = false; + for (let i = 0; i < payload.length; i++) { + if (payload[i] === resultTag) { + found = true; + break; + } + } + assert.ok(found, `result tag for field ${expectedResultField} present`); + void expectedExecMsgId; // varint encoded; covered by other tests +} + +test("encodeExecReadRejected wraps in read_result (field 7) with reason", () => { + const framed = encodeExecReadRejected(42, "exec-abc", "/etc/passwd", "denied"); + assertEcmShape(framed, 42, "exec-abc", 7); + const payload = unwrapFrame(framed); + assert.ok(payload.includes(Buffer.from("/etc/passwd", "utf8"))); + assert.ok(payload.includes(Buffer.from("denied", "utf8"))); +}); + +test("encodeExecWriteRejected wraps in write_result (field 3)", () => { + const framed = encodeExecWriteRejected(1, "x", "/tmp/foo", "no"); + assertEcmShape(framed, 1, "x", 3); +}); + +test("encodeExecDeleteRejected wraps in delete_result (field 4)", () => { + const framed = encodeExecDeleteRejected(1, "x", "/tmp/foo", "no"); + assertEcmShape(framed, 1, "x", 4); +}); + +test("encodeExecLsRejected wraps in ls_result (field 8)", () => { + const framed = encodeExecLsRejected(1, "x", "/tmp", "no"); + assertEcmShape(framed, 1, "x", 8); +}); + +test("encodeExecShellRejected carries command and working directory", () => { + const framed = encodeExecShellRejected(7, "exec-7", "rm -rf /", "/home", "denied"); + assertEcmShape(framed, 7, "exec-7", 2); + const payload = unwrapFrame(framed); + assert.ok(payload.includes(Buffer.from("rm -rf /", "utf8"))); + assert.ok(payload.includes(Buffer.from("/home", "utf8"))); + assert.ok(payload.includes(Buffer.from("denied", "utf8"))); +}); + +test("encodeExecBackgroundShellSpawnRejected uses field 16", () => { + const framed = encodeExecBackgroundShellSpawnRejected(1, "x", "sleep", "/", "no"); + assertEcmShape(framed, 1, "x", 16); +}); + +test("encodeExecGrepError uses field 5 with error message", () => { + const framed = encodeExecGrepError(1, "exec-grep", "regex too complex"); + assertEcmShape(framed, 1, "exec-grep", 5); + const payload = unwrapFrame(framed); + assert.ok(payload.includes(Buffer.from("regex too complex", "utf8"))); +}); + +test("encodeExecFetchError uses field 20 with url and error", () => { + const framed = encodeExecFetchError(1, "x", "https://example.com", "timeout"); + assertEcmShape(framed, 1, "x", 20); + const payload = unwrapFrame(framed); + assert.ok(payload.includes(Buffer.from("https://example.com", "utf8"))); + assert.ok(payload.includes(Buffer.from("timeout", "utf8"))); +}); + +test("encodeExecWriteShellStdinError uses field 23", () => { + const framed = encodeExecWriteShellStdinError(1, "x", "no shell"); + assertEcmShape(framed, 1, "x", 23); +}); + +test("encodeExecDiagnosticsResult is empty success on field 9", () => { + const framed = encodeExecDiagnosticsResult(1, "exec-diag"); + assertEcmShape(framed, 1, "exec-diag", 9); +}); + +// ─── Phase 1: MCP encoders ────────────────────────────────────────────────── + +test("encodeExecMcpResult wraps text content in mcp_result (field 11)", () => { + const framed = encodeExecMcpResult(1, "exec-mcp", "tool output", false); + assertEcmShape(framed, 1, "exec-mcp", 11); + const payload = unwrapFrame(framed); + assert.ok(payload.includes(Buffer.from("tool output", "utf8"))); +}); + +test("encodeExecMcpResult sets is_error when isError=true", () => { + const framed = encodeExecMcpResult(1, "x", "err msg", true); + // is_error field would be encoded as varint 1 — verify framing only + assertEcmShape(framed, 1, "x", 11); +}); + +test("encodeExecMcpError wraps error in mcp_result", () => { + const framed = encodeExecMcpError(1, "exec-err", "tool crashed"); + assertEcmShape(framed, 1, "exec-err", 11); + const payload = unwrapFrame(framed); + assert.ok(payload.includes(Buffer.from("tool crashed", "utf8"))); +}); + +// ─── Phase 1: KV blob encoders ────────────────────────────────────────────── + +test("encodeKvGetBlobResult wraps in kv_client_message (field 3)", () => { + const framed = encodeKvGetBlobResult(99, Buffer.from("blob-content", "utf8")); + const payload = unwrapFrame(framed); + // Top-level field 3 (kv_client_message) tag = (3<<3)|2 = 26 + assert.equal(payload[0], 26); + assert.ok(payload.includes(Buffer.from("blob-content", "utf8"))); +}); + +test("encodeKvSetBlobResult is an empty ack", () => { + const framed = encodeKvSetBlobResult(7); + const payload = unwrapFrame(framed); + assert.equal(payload[0], 26); // (3<<3)|2 = kv_client_message +}); + +// ─── Phase 1: MCP tool definition body ────────────────────────────────────── + +test("encodeMcpToolDefinitionBody encodes name, description, schema bytes", () => { + const body = encodeMcpToolDefinitionBody({ + name: "get_weather", + description: "Look up current weather", + inputSchemaBytes: Buffer.from("schema-bytes"), + providerIdentifier: "omniroute", + toolName: "get_weather", + }); + assert.ok(body.includes(Buffer.from("get_weather", "utf8"))); + assert.ok(body.includes(Buffer.from("Look up current weather", "utf8"))); + assert.ok(body.includes(Buffer.from("schema-bytes"))); + assert.ok(body.includes(Buffer.from("omniroute", "utf8"))); +}); + +test("encodeMcpToolDefinitionBody omits optional providerIdentifier and toolName", () => { + const body = encodeMcpToolDefinitionBody({ + name: "n", + description: "d", + inputSchemaBytes: Buffer.alloc(0), + }); + assert.ok(!body.includes(Buffer.from("omniroute", "utf8"))); +}); + +// ─── Phase 1: encodeRequestContextResponse with tools ─────────────────────── + +test("encodeRequestContextResponse with no tools produces empty RequestContext (existing behavior)", () => { + const framed = encodeRequestContextResponse(5, "exec-rc"); + assertEcmShape(framed, 5, "exec-rc", 10); // request_context_result = field 10 +}); + +test("encodeRequestContextResponse with tools embeds tool name and schema", () => { + const framed = encodeRequestContextResponse(5, "exec-rc", [ + { + name: "get_weather", + description: "lookup", + inputSchemaBytes: jsonSchemaToProtobufValue({ + type: "object", + properties: { city: { type: "string" } }, + }), + providerIdentifier: "omniroute", + toolName: "get_weather", + }, + ]); + assertEcmShape(framed, 5, "exec-rc", 10); + const payload = unwrapFrame(framed); + assert.ok(payload.includes(Buffer.from("get_weather", "utf8"))); + assert.ok(payload.includes(Buffer.from("city", "utf8"))); +}); + +// ─── Phase 1: jsonSchemaToProtobufValue ───────────────────────────────────── +// +// google.protobuf.Value is a oneof message. We verify the encoded bytes +// start with the right field tag for each kind, and (for nested types) that +// the inner content survives. + +test("jsonSchemaToProtobufValue encodes a string as field 3", () => { + const buf = jsonSchemaToProtobufValue("hello"); + // tag (3<<3)|2 = 26 + assert.equal(buf[0], 26); + assert.ok(buf.includes(Buffer.from("hello", "utf8"))); +}); + +test("jsonSchemaToProtobufValue encodes a number as field 2 (double)", () => { + const buf = jsonSchemaToProtobufValue(3.14); + // tag (2<<3)|1 = 17 + assert.equal(buf[0], 17); + assert.equal(buf.length, 9); // 1-byte tag + 8-byte double + assert.equal(buf.readDoubleLE(1), 3.14); +}); + +test("jsonSchemaToProtobufValue encodes a bool as field 4", () => { + const buf = jsonSchemaToProtobufValue(true); + // tag (4<<3)|0 = 32 + assert.equal(buf[0], 32); + assert.equal(buf[1], 1); +}); + +test("jsonSchemaToProtobufValue encodes null as field 1", () => { + const buf = jsonSchemaToProtobufValue(null); + // tag (1<<3)|0 = 8 + assert.equal(buf[0], 8); + assert.equal(buf[1], 0); +}); + +test("jsonSchemaToProtobufValue encodes a list as field 6", () => { + const buf = jsonSchemaToProtobufValue(["a", "b"]); + // tag (6<<3)|2 = 50 + assert.equal(buf[0], 50); + assert.ok(buf.includes(Buffer.from("a", "utf8"))); + assert.ok(buf.includes(Buffer.from("b", "utf8"))); +}); + +test("jsonSchemaToProtobufValue encodes a struct as field 5", () => { + const buf = jsonSchemaToProtobufValue({ key: "value" }); + // tag (5<<3)|2 = 42 + assert.equal(buf[0], 42); + assert.ok(buf.includes(Buffer.from("key", "utf8"))); + assert.ok(buf.includes(Buffer.from("value", "utf8"))); +}); + +test("jsonSchemaToProtobufValue encodes a nested OpenAI tool input_schema", () => { + const schema = { + type: "object", + properties: { + city: { type: "string", description: "the city" }, + units: { type: "string", enum: ["c", "f"] }, + }, + required: ["city"], + }; + const buf = jsonSchemaToProtobufValue(schema); + // Outer tag is struct (5) + assert.equal(buf[0], 42); + assert.ok(buf.includes(Buffer.from("city", "utf8"))); + assert.ok(buf.includes(Buffer.from("units", "utf8"))); + assert.ok(buf.includes(Buffer.from("required", "utf8"))); + assert.ok(buf.includes(Buffer.from("the city", "utf8"))); +}); + +// ─── Phase 3: tools in AgentRunRequest ───────────────────────────────────── + +test("openAIToolsToMcpDefs converts OpenAI tool array to McpToolDefinition[]", () => { + const defs = openAIToolsToMcpDefs([ + { + type: "function", + function: { + name: "get_weather", + description: "lookup", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ]); + assert.equal(defs.length, 1); + assert.equal(defs[0].name, "get_weather"); + assert.equal(defs[0].description, "lookup"); + assert.equal(defs[0].toolName, "get_weather"); + assert.equal(defs[0].providerIdentifier, "omniroute"); + assert.ok(defs[0].inputSchemaBytes.length > 0); + // Schema bytes are a Struct (field 5) + assert.equal(defs[0].inputSchemaBytes[0], 42); +}); + +test("openAIToolsToMcpDefs supplies a default schema if parameters omitted", () => { + const defs = openAIToolsToMcpDefs([{ type: "function", function: { name: "no_params" } }]); + assert.equal(defs.length, 1); + // Default schema is { type: "object", properties: {} } — encoded as struct + assert.equal(defs[0].inputSchemaBytes[0], 42); +}); + +test("encodeAgentRunRequest with tools embeds tool name and schema in mcp_tools", () => { + const buf = encodeAgentRunRequest({ + modelId: "claude-4.6-sonnet-medium", + userText: "what's the weather?", + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Look up current weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }, + ], + }); + const text = buf.toString("latin1"); + assert.ok(text.includes("get_weather"), "tool name present"); + assert.ok(text.includes("Look up current weather"), "tool description present"); + assert.ok(text.includes("city"), "tool schema field present"); + assert.ok(text.includes("omniroute"), "provider_identifier set"); +}); + +test("encodeAgentRunRequest without tools preserves empty mcp_tools placeholder", () => { + const bufNoTools = encodeAgentRunRequest({ + modelId: "auto", + userText: "hi", + }); + const bufEmptyTools = encodeAgentRunRequest({ + modelId: "auto", + userText: "hi", + tools: [], + }); + // Both should produce essentially the same shape — neither embeds tools. + // Lengths may differ by message-id randomness; just verify neither contains + // tool-related markers. + assert.ok(!bufNoTools.toString("latin1").includes("omniroute")); + assert.ok(!bufEmptyTools.toString("latin1").includes("omniroute")); +}); + +test("encodeAgentRunRequest with multiple tools embeds all of them", () => { + const buf = encodeAgentRunRequest({ + modelId: "auto", + userText: "test", + tools: [ + { type: "function", function: { name: "tool_a", description: "A" } }, + { type: "function", function: { name: "tool_b", description: "B" } }, + ], + }); + const text = buf.toString("latin1"); + assert.ok(text.includes("tool_a")); + assert.ok(text.includes("tool_b")); +}); diff --git a/tests/unit/cursor-agent-session.test.ts b/tests/unit/cursor-agent-session.test.ts new file mode 100644 index 0000000000..f2b38a4cb8 --- /dev/null +++ b/tests/unit/cursor-agent-session.test.ts @@ -0,0 +1,227 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + CursorSessionManager, + type CursorSession, +} from "../../open-sse/services/cursorSessionManager"; +import { flattenMessages } from "../../open-sse/utils/cursorAgentProtobuf"; + +// ─── Test doubles for h2 ─────────────────────────────────────────────────── +// +// We don't open real h2 connections in unit tests. Sessions hold opaque +// references that the manager only ever .close()s or .write()s through +// encodeExecMcpResult. A pair of stubs is enough. + +type WriteCall = { kind: "write"; data: Buffer } | { kind: "close" }; + +function mockReq() { + const calls: WriteCall[] = []; + return { + req: { + write: (data: Buffer) => { + calls.push({ kind: "write", data }); + return true; + }, + close: () => { + calls.push({ kind: "close" }); + }, + } as unknown as import("node:http2").ClientHttp2Stream, + calls, + }; +} + +function mockClient() { + const closed = { value: false }; + return { + client: { + close: () => { + closed.value = true; + }, + } as unknown as import("node:http2").ClientHttp2Session, + closed, + }; +} + +// ─── flattenMessages: Phase 6 cold-resume support ────────────────────────── + +test("flattenMessages handles role:'tool' messages", () => { + const out = flattenMessages([ + { role: "user", content: "what's the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_xyz", + type: "function", + function: { name: "get_weather", arguments: '{"city":"Paris"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_xyz", content: "sunny, 22C" }, + ]); + assert.match(out, /User: what's the weather\?/); + assert.match( + out, + /Assistant called tool get_weather \(call_xyz\) with arguments: \{"city":"Paris"\}/ + ); + assert.match(out, /Tool result \(call_xyz\): sunny, 22C/); +}); + +test("flattenMessages handles assistant with text + tool_calls in same message", () => { + const out = flattenMessages([ + { role: "user", content: "do x" }, + { + role: "assistant", + content: "Let me check.", + tool_calls: [ + { + id: "c1", + type: "function", + function: { name: "check", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "c1", content: "result" }, + ]); + assert.match(out, /Assistant: Let me check\./); + assert.match(out, /Assistant called tool check \(c1\) with arguments: \{\}/); + assert.match(out, /Tool result \(c1\): result/); +}); + +test("flattenMessages handles parallel tool_calls", () => { + const out = flattenMessages([ + { role: "user", content: "check both" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: "c1", type: "function", function: { name: "tool_a", arguments: "{}" } }, + { id: "c2", type: "function", function: { name: "tool_b", arguments: "{}" } }, + ], + }, + { role: "tool", tool_call_id: "c1", content: "result_a" }, + { role: "tool", tool_call_id: "c2", content: "result_b" }, + ]); + assert.match(out, /tool_a \(c1\)/); + assert.match(out, /tool_b \(c2\)/); + assert.match(out, /Tool result \(c1\): result_a/); + assert.match(out, /Tool result \(c2\): result_b/); +}); + +test("flattenMessages keeps single-user fast path unchanged when no tool_calls", () => { + const out = flattenMessages([{ role: "user", content: "hi" }]); + assert.equal(out, "hi"); +}); + +// ─── CursorSessionManager lifecycle ──────────────────────────────────────── + +test("CursorSessionManager.open registers a session under conversation_id", () => { + const m = new CursorSessionManager(); + const { req } = mockReq(); + const { client } = mockClient(); + const session = m.open("conv-1", client, req, new Map()); + assert.equal(m.size(), 1); + assert.ok(m.has("conv-1")); + assert.equal(session.conversationId, "conv-1"); + assert.equal(session.state, "running"); +}); + +test("CursorSessionManager.acquire returns undefined when no session", () => { + const m = new CursorSessionManager(); + assert.equal(m.acquire("nope"), undefined); +}); + +test("CursorSessionManager.acquire returns undefined when session is still running", () => { + const m = new CursorSessionManager(); + const { req } = mockReq(); + const { client } = mockClient(); + m.open("conv-2", client, req, new Map()); + // open() leaves state="running"; acquire requires "awaiting_tool_result" + assert.equal(m.acquire("conv-2"), undefined); +}); + +test("CursorSessionManager.acquire returns the session after release(awaiting_tool_result)", () => { + const m = new CursorSessionManager(); + const { req } = mockReq(); + const { client } = mockClient(); + const opened = m.open("conv-3", client, req, new Map()); + m.release(opened, "awaiting_tool_result"); + const acquired = m.acquire("conv-3"); + assert.equal(acquired, opened); + assert.equal(acquired?.state, "running"); +}); + +test("CursorSessionManager.release(idle) closes the session", () => { + const m = new CursorSessionManager(); + const { req, calls } = mockReq(); + const { client, closed } = mockClient(); + const session = m.open("conv-4", client, req, new Map()); + m.release(session, "idle"); + assert.equal(m.size(), 0); + assert.ok(closed.value); + assert.ok(calls.some((c) => c.kind === "close")); +}); + +test("CursorSessionManager.acquire evicts expired sessions", () => { + const m = new CursorSessionManager({ idleTtlMs: 10 }); + const { req } = mockReq(); + const { client, closed } = mockClient(); + const session = m.open("conv-5", client, req, new Map()); + m.release(session, "awaiting_tool_result"); + // Manually backdate lastActivityTs to simulate idle. + session.lastActivityTs = Date.now() - 1000; + const acquired = m.acquire("conv-5"); + assert.equal(acquired, undefined); + assert.equal(m.size(), 0); + assert.ok(closed.value); +}); + +test("CursorSessionManager.sendToolResult writes ExecMcpResult on the session's req", () => { + const m = new CursorSessionManager(); + const { req, calls } = mockReq(); + const { client } = mockClient(); + const session = m.open("conv-6", client, req, new Map()); + session.pendingToolCalls.set("call_x", { + execMsgId: 1, + execId: "exec-1", + toolName: "get_weather", + }); + const ok = m.sendToolResult(session, "call_x", "sunny", false); + assert.equal(ok, true); + // Verify a write happened + const writes = calls.filter((c) => c.kind === "write"); + assert.equal(writes.length, 1); + // The write should be a Connect-RPC frame containing "sunny" and "exec-1" + const data = (writes[0] as { kind: "write"; data: Buffer }).data; + assert.ok(data.includes(Buffer.from("sunny", "utf8"))); + assert.ok(data.includes(Buffer.from("exec-1", "utf8"))); + // Pending tool call was consumed + assert.equal(session.pendingToolCalls.has("call_x"), false); +}); + +test("CursorSessionManager.sendToolResult returns false when openAIToolCallId not pending", () => { + const m = new CursorSessionManager(); + const { req } = mockReq(); + const { client } = mockClient(); + const session = m.open("conv-7", client, req, new Map()); + const ok = m.sendToolResult(session, "unknown_id", "x", false); + assert.equal(ok, false); +}); + +test("CursorSessionManager.open replaces an existing session for the same conversation", () => { + const m = new CursorSessionManager(); + const r1 = mockReq(); + const c1 = mockClient(); + const session1 = m.open("conv-8", c1.client, r1.req, new Map()); + m.release(session1, "awaiting_tool_result"); + const r2 = mockReq(); + const c2 = mockClient(); + const session2 = m.open("conv-8", c2.client, r2.req, new Map()); + // First session's client should be closed + assert.ok(c1.closed.value); + // Map only has one session; the new one + assert.equal(m.size(), 1); + assert.equal(m.acquire("conv-8"), undefined); // session2 is "running" + void session2; +}); diff --git a/tests/unit/cursor-agent-system-prompt.test.ts b/tests/unit/cursor-agent-system-prompt.test.ts new file mode 100644 index 0000000000..84555e2d9d --- /dev/null +++ b/tests/unit/cursor-agent-system-prompt.test.ts @@ -0,0 +1,122 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { + encodeAgentRunRequest, + decodeKvServerEvent, +} from "../../open-sse/utils/cursorAgentProtobuf"; + +// ─── Wire-format helpers ─────────────────────────────────────────────────── + +function v(n: number): Buffer { + const out: number[] = []; + while (n > 0x7f) { + out.push((n & 0x7f) | 0x80); + n >>>= 7; + } + out.push(n); + return Buffer.from(out); +} +function tag(field: number, wireType: number): Buffer { + return v((field << 3) | wireType); +} +function lenPrefixed(field: number, payload: Buffer): Buffer { + return Buffer.concat([tag(field, 2), v(payload.length), payload]); +} +function varintField(field: number, value: number): Buffer { + return Buffer.concat([tag(field, 0), v(value)]); +} + +// ─── encodeAgentRunRequest with system prompt ────────────────────────────── + +test("encodeAgentRunRequest with systemPrompt populates blobStore", () => { + const blobStore = new Map(); + const buf = encodeAgentRunRequest({ + modelId: "auto", + userText: "hi", + systemPrompt: "be brief", + blobStore, + }); + // The blobStore should have one entry (the system prompt blob) + assert.equal(blobStore.size, 1); + // Key is hex of sha256({role:"system", content:"be brief"}) + const expectedJson = JSON.stringify({ role: "system", content: "be brief" }); + const expectedHash = crypto + .createHash("sha256") + .update(Buffer.from(expectedJson, "utf8")) + .digest("hex"); + assert.ok(blobStore.has(expectedHash)); + // The stored bytes should match the JSON + assert.equal(blobStore.get(expectedHash)!.toString("utf8"), expectedJson); + // The blob hash (32 bytes) should appear in the encoded request body + const hashBytes = Buffer.from(expectedHash, "hex"); + assert.ok(buf.includes(hashBytes), "blob id embedded in CSS"); +}); + +test("encodeAgentRunRequest without systemPrompt leaves blobStore empty", () => { + const blobStore = new Map(); + encodeAgentRunRequest({ modelId: "auto", userText: "hi", blobStore }); + assert.equal(blobStore.size, 0); +}); + +test("encodeAgentRunRequest with systemPrompt but no blobStore is a no-op for blobs", () => { + // The system prompt is silently dropped if blobStore isn't provided. + // This matches the existing pre-Phase-7 behavior. + const buf = encodeAgentRunRequest({ + modelId: "auto", + userText: "hi", + systemPrompt: "be brief", + }); + // No crash; the system prompt isn't embedded (no blob to reference). + assert.ok(buf.length > 0); +}); + +test("encodeAgentRunRequest with multi-message system prompt joins them", () => { + const blobStore = new Map(); + encodeAgentRunRequest({ + modelId: "auto", + userText: "hi", + systemPrompt: "first instruction\n\nsecond instruction", + blobStore, + }); + assert.equal(blobStore.size, 1); + const [stored] = [...blobStore.values()]; + const parsed = JSON.parse(stored.toString("utf8")); + assert.equal(parsed.role, "system"); + assert.equal(parsed.content, "first instruction\n\nsecond instruction"); +}); + +// ─── KV server message decoder ───────────────────────────────────────────── + +test("decodeKvServerEvent recognizes get_blob_args", () => { + // KvServerMessage { id (1): 7, get_blob_args (2): { blob_id (1): bytes } } + const blobId = Buffer.from("01020304".repeat(8), "hex"); + const getBlobArgs = lenPrefixed(1, blobId); + const ksm = Buffer.concat([varintField(1, 7), lenPrefixed(2, getBlobArgs)]); + const asm = lenPrefixed(4, ksm); // ASM_KV_SERVER_MESSAGE = 4 + const event = decodeKvServerEvent(asm); + assert.deepEqual(event, { kind: "kv_get_blob", kvId: 7, blobId, requestMetadata: null }); +}); + +test("decodeKvServerEvent recognizes set_blob_args with data", () => { + // SetBlobArgs { blob_id (1): bytes, blob_data (2): bytes } + const blobId = Buffer.from("aa".repeat(32), "hex"); + const blobData = Buffer.from("hello world", "utf8"); + const setBlobArgs = Buffer.concat([lenPrefixed(1, blobId), lenPrefixed(2, blobData)]); + const ksm = Buffer.concat([varintField(1, 5), lenPrefixed(3, setBlobArgs)]); + const asm = lenPrefixed(4, ksm); + const event = decodeKvServerEvent(asm); + if (event?.kind !== "kv_set_blob") throw new Error("expected kv_set_blob"); + assert.equal(event.kvId, 5); + assert.ok(event.blobId.equals(blobId)); + assert.ok(event.blobData.equals(blobData)); + assert.equal(event.requestMetadata, null); +}); + +test("decodeKvServerEvent returns null when AgentServerMessage has no kv frame", () => { + // ASM with only an interaction_update (field 1) + const tdu = lenPrefixed(1, Buffer.from("hi")); + const iu = lenPrefixed(1, tdu); + const asm = lenPrefixed(1, iu); + assert.equal(decodeKvServerEvent(asm), null); +}); diff --git a/tests/unit/cursor-agent-tool-calls.test.ts b/tests/unit/cursor-agent-tool-calls.test.ts new file mode 100644 index 0000000000..3d352d506d --- /dev/null +++ b/tests/unit/cursor-agent-tool-calls.test.ts @@ -0,0 +1,236 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + decodeExecServerEvent, + decodeProtobufValue, + jsonSchemaToProtobufValue, +} from "../../open-sse/utils/cursorAgentProtobuf"; +import { newStreamCtx, processFrame } from "../../open-sse/executors/cursor"; + +// ─── Wire-format helpers ─────────────────────────────────────────────────── + +function v(n: number): Buffer { + const out: number[] = []; + while (n > 0x7f) { + out.push((n & 0x7f) | 0x80); + n >>>= 7; + } + out.push(n); + return Buffer.from(out); +} +function tag(field: number, wireType: number): Buffer { + return v((field << 3) | wireType); +} +function lenPrefixed(field: number, payload: Buffer): Buffer { + return Buffer.concat([tag(field, 2), v(payload.length), payload]); +} +function stringField(field: number, value: string): Buffer { + return lenPrefixed(field, Buffer.from(value, "utf8")); +} +function varintField(field: number, value: number): Buffer { + return Buffer.concat([tag(field, 0), v(value)]); +} + +// Encodes a single map entry: { key (1): k, value (2): valueBytes } +function mapEntry(field: number, key: string, valueBytes: Buffer): Buffer { + const entry = Buffer.concat([stringField(1, key), lenPrefixed(2, valueBytes)]); + return lenPrefixed(field, entry); +} + +// AgentServerMessage { exec_server_message (2): ESM { id, exec_id, mcp_args (11): ... } } +function buildMcpArgsEvent( + execMsgId: number, + execId: string, + toolName: string, + toolCallId: string, + args: Record +): Buffer { + const mcaParts: Buffer[] = [ + stringField(1, toolName), // name + stringField(3, toolCallId), // tool_call_id + ]; + for (const [k, val] of Object.entries(args)) { + const valueBytes = jsonSchemaToProtobufValue(val); + mcaParts.push(mapEntry(2, k, valueBytes)); // args = field 2 + } + const mca = Buffer.concat(mcaParts); + const esm = Buffer.concat([ + varintField(1, execMsgId), + stringField(15, execId), + lenPrefixed(11, mca), + ]); + return lenPrefixed(2, esm); +} + +// ─── decodeProtobufValue round-trip tests ────────────────────────────────── + +test("decodeProtobufValue round-trips primitives", () => { + assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue("hi") as Buffer), "hi"); + assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue(42.5)), 42.5); + assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue(true)), true); + assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue(false)), false); + assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue(null)), null); +}); + +test("decodeProtobufValue round-trips a flat object", () => { + const obj = { name: "alice", age: 30, active: true }; + const encoded = jsonSchemaToProtobufValue(obj); + assert.deepEqual(decodeProtobufValue(encoded), obj); +}); + +test("decodeProtobufValue round-trips an array of strings", () => { + const arr = ["a", "b", "c"]; + const encoded = jsonSchemaToProtobufValue(arr); + assert.deepEqual(decodeProtobufValue(encoded), arr); +}); + +test("decodeProtobufValue round-trips a nested object with mixed types", () => { + const obj = { + city: "Paris", + coords: { lat: 48.8566, lng: 2.3522 }, + tags: ["europe", "capital"], + populated: true, + sister_city: null, + }; + const encoded = jsonSchemaToProtobufValue(obj); + assert.deepEqual(decodeProtobufValue(encoded), obj); +}); + +test("decodeProtobufValue handles deeply nested OpenAI tool args", () => { + const args = { + location: { city: "Tokyo", country: "JP" }, + units: "metric", + days: 7, + }; + const encoded = jsonSchemaToProtobufValue(args); + assert.deepEqual(decodeProtobufValue(encoded), args); +}); + +// ─── McpArgs decoding tests ──────────────────────────────────────────────── + +test("decodeExecServerEvent populates args dict from McpArgs map entries", () => { + const args = { city: "Paris", units: "celsius" }; + const event = decodeExecServerEvent( + buildMcpArgsEvent(1, "exec-w", "get_weather", "call_abc", args) + ); + assert.equal(event?.kind, "exec_mcp"); + if (event?.kind !== "exec_mcp") return; + assert.equal(event.toolName, "get_weather"); + assert.equal(event.toolCallId, "call_abc"); + assert.deepEqual(event.args, args); +}); + +test("decodeExecServerEvent handles McpArgs with empty args", () => { + const event = decodeExecServerEvent(buildMcpArgsEvent(1, "exec-x", "no_args_tool", "call_x", {})); + if (event?.kind !== "exec_mcp") throw new Error("expected exec_mcp"); + assert.deepEqual(event.args, {}); +}); + +test("decodeExecServerEvent handles McpArgs with nested object args", () => { + const args = { + location: { city: "London", country: "UK" }, + days: 3, + }; + const event = decodeExecServerEvent(buildMcpArgsEvent(1, "exec-n", "weather", "call_n", args)); + if (event?.kind !== "exec_mcp") throw new Error("expected exec_mcp"); + assert.deepEqual(event.args, args); +}); + +// ─── Streaming SSE tool_calls emission ───────────────────────────────────── + +function parseChunk(line: string): { + delta?: { + tool_calls?: Array<{ + index: number; + id?: string; + function?: { name?: string; arguments?: string }; + }>; + }; + finish_reason?: string | null; +} { + return JSON.parse(line.replace(/^data: /, "").trim()).choices[0]; +} + +test("processFrame emits OpenAI tool_calls deltas for an mcp_args event", () => { + const emitted: string[] = []; + const ctx = newStreamCtx("auto", (s) => emitted.push(s)); + const payload = buildMcpArgsEvent(1, "exec-tc", "get_weather", "cursor_call_x", { + city: "Paris", + }); + processFrame(payload, ctx, new Set()); + + // Expect: role chunk + tool_calls init chunk + tool_calls args chunk + assert.equal(emitted.length, 3); + const roleChunk = parseChunk(emitted[0]); + assert.ok(roleChunk.delta); + // Init chunk: id + name + const initChunk = parseChunk(emitted[1]); + const initCall = initChunk.delta?.tool_calls?.[0]; + assert.equal(initCall?.index, 0); + assert.match(initCall?.id ?? "", /^call_/); + assert.equal(initCall?.function?.name, "get_weather"); + assert.equal(initCall?.function?.arguments, ""); + // Args chunk + const argsChunk = parseChunk(emitted[2]); + const argsCall = argsChunk.delta?.tool_calls?.[0]; + assert.equal(argsCall?.index, 0); + assert.equal(argsCall?.function?.arguments, JSON.stringify({ city: "Paris" })); + + // Side-effects on ctx + assert.equal(ctx.toolCalls.length, 1); + assert.equal(ctx.toolCalls[0].name, "get_weather"); + assert.equal(ctx.emittedToolCallIndex, 1); +}); + +test("processFrame indexes parallel tool calls sequentially", () => { + const emitted: string[] = []; + const ctx = newStreamCtx("auto", (s) => emitted.push(s)); + const acked = new Set(); + processFrame(buildMcpArgsEvent(1, "exec-1", "tool_a", "ca", { x: 1 }), ctx, acked); + processFrame(buildMcpArgsEvent(2, "exec-2", "tool_b", "cb", { y: 2 }), ctx, acked); + assert.equal(ctx.toolCalls.length, 2); + assert.equal(ctx.emittedToolCallIndex, 2); + // emitted[0] = role chunk + // emitted[1] = init for tool_a (index=0) + // emitted[2] = args for tool_a (index=0) + // emitted[3] = init for tool_b (index=1) + // emitted[4] = args for tool_b (index=1) + const initB = parseChunk(emitted[3]); + assert.equal(initB.delta?.tool_calls?.[0].index, 1); + assert.equal(initB.delta?.tool_calls?.[0].function?.name, "tool_b"); +}); + +test("processFrame emits text + tool_calls in mixed order", () => { + const emitted: string[] = []; + const ctx = newStreamCtx("auto", (s) => emitted.push(s)); + const acked = new Set(); + + // Build a text_delta payload + function textDelta(text: string): Buffer { + const tdu = lenPrefixed(1, Buffer.from(text, "utf8")); + const iu = lenPrefixed(1, tdu); + return lenPrefixed(1, iu); + } + + processFrame(textDelta("Let me check"), ctx, acked); + processFrame(buildMcpArgsEvent(1, "exec-mix", "lookup", "c1", {}), ctx, acked); + + assert.equal(ctx.totalText, "Let me check"); + assert.equal(ctx.toolCalls.length, 1); + // Order: role chunk, content chunk, tool_call init, tool_call args + assert.equal(emitted.length, 4); + const contentChunk = JSON.parse(emitted[1].replace(/^data: /, "").trim()); + assert.equal(contentChunk.choices[0].delta.content, "Let me check"); + const initChunk = JSON.parse(emitted[2].replace(/^data: /, "").trim()); + assert.ok(initChunk.choices[0].delta.tool_calls); +}); + +test("processFrame doesn't emit tool_calls for the same exec_id twice", () => { + const emitted: string[] = []; + const ctx = newStreamCtx("auto", (s) => emitted.push(s)); + const acked = new Set(); + const payload = buildMcpArgsEvent(1, "exec-dup", "tool", "cd", {}); + processFrame(payload, ctx, acked); + processFrame(payload, ctx, acked); + assert.equal(ctx.toolCalls.length, 1); +}); diff --git a/tests/unit/cursor-protobuf.test.ts b/tests/unit/cursor-protobuf.test.ts deleted file mode 100644 index fbf1b7bdc7..0000000000 --- a/tests/unit/cursor-protobuf.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { - buildChatRequest, - decodeMessage, - decodeField, - encodeField, - encodeMcpTool, - extractTextFromResponse, - generateCursorBody, - parseConnectRPCFrame, - wrapConnectRPCFrame, -} from "../../open-sse/utils/cursorProtobuf.ts"; - -const LEN = 2; -const VARINT = 0; -const TOP_LEVEL_TOOL_CALL = 1; -const TOP_LEVEL_RESPONSE = 2; -const RESPONSE_TEXT = 1; -const THINKING = 25; -const THINKING_TEXT = 1; -const TOOL_ID = 3; -const TOOL_NAME = 9; -const TOOL_RAW_ARGS = 10; -const TOOL_IS_LAST_ALT = 15; -const TOOL_MCP_PARAMS = 27; -const MCP_TOOLS_LIST = 1; -const MCP_NESTED_NAME = 1; -const MCP_NESTED_PARAMS = 3; - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); - -function concatArrays(...arrays) { - const total = arrays.reduce((sum, array) => sum + array.length, 0); - const result = new Uint8Array(total); - let offset = 0; - - for (const array of arrays) { - result.set(array, offset); - offset += array.length; - } - - return result; -} - -test("parseConnectRPCFrame round-trips compressed payloads", () => { - const payload = textEncoder.encode("cursor-frame"); - const frame = wrapConnectRPCFrame(payload, true); - const parsed = parseConnectRPCFrame(frame); - - assert.equal(parsed.flags, 1); - assert.equal(parsed.consumed, frame.length); - assert.equal(textDecoder.decode(parsed.payload), "cursor-frame"); -}); - -test("parseConnectRPCFrame returns null for truncated frames", () => { - const payload = textEncoder.encode("short"); - const frame = wrapConnectRPCFrame(payload, false); - - assert.equal(parseConnectRPCFrame(frame.slice(0, frame.length - 1)), null); -}); - -test("parseConnectRPCFrame keeps the raw payload when a compressed frame is not valid gzip", () => { - const payload = textEncoder.encode("not-gzip"); - const frame = new Uint8Array(5 + payload.length); - frame[0] = 0x01; - frame[1] = (payload.length >> 24) & 0xff; - frame[2] = (payload.length >> 16) & 0xff; - frame[3] = (payload.length >> 8) & 0xff; - frame[4] = payload.length & 0xff; - frame.set(payload, 5); - - const parsed = parseConnectRPCFrame(frame); - - assert.equal(parsed.flags, 0x01); - assert.equal(parsed.consumed, frame.length); - assert.equal(textDecoder.decode(parsed.payload), "not-gzip"); -}); - -test("decodeField handles fixed-width wire types and past-end offsets", () => { - const buffer = Uint8Array.from([ - (1 << 3) | 1, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - (2 << 3) | 5, - 9, - 10, - 11, - 12, - ]); - - const [field1, wire1, value1, pos1] = decodeField(buffer, 0); - const [field2, wire2, value2, pos2] = decodeField(buffer, pos1); - const pastEnd = decodeField(buffer, pos2); - - assert.equal(field1, 1); - assert.equal(wire1, 1); - assert.deepEqual([...value1], [1, 2, 3, 4, 5, 6, 7, 8]); - assert.equal(field2, 2); - assert.equal(wire2, 5); - assert.deepEqual([...value2], [9, 10, 11, 12]); - assert.deepEqual(pastEnd, [null, null, null, pos2]); -}); - -test("extractTextFromResponse reads MCP nested tool metadata and alternate last-tool flag", () => { - const toolCallPayload = encodeField( - TOP_LEVEL_TOOL_CALL, - LEN, - concatArrays( - encodeField(TOOL_ID, LEN, "call_1"), - encodeField(TOOL_NAME, LEN, "mcp_custom_placeholder"), - encodeField(TOOL_RAW_ARGS, LEN, "{}"), - encodeField(TOOL_IS_LAST_ALT, VARINT, 1), - encodeField( - TOOL_MCP_PARAMS, - LEN, - encodeField( - MCP_TOOLS_LIST, - LEN, - concatArrays( - encodeField(MCP_NESTED_NAME, LEN, "read_file"), - encodeField(MCP_NESTED_PARAMS, LEN, '{"path":"/tmp/a"}') - ) - ) - ) - ) - ); - - const extracted = extractTextFromResponse(toolCallPayload); - - assert.equal(extracted.toolCall.id, "call_1"); - assert.equal(extracted.toolCall.function.name, "read_file"); - assert.equal(extracted.toolCall.function.arguments, '{"path":"/tmp/a"}'); - assert.equal(extracted.toolCall.isLast, true); -}); - -test("extractTextFromResponse falls back to raw args when MCP metadata is absent", () => { - const toolCallPayload = encodeField( - TOP_LEVEL_TOOL_CALL, - LEN, - concatArrays( - encodeField(TOOL_ID, LEN, "call_fallback"), - encodeField(TOOL_NAME, LEN, "read_file"), - encodeField(TOOL_RAW_ARGS, LEN, '{"path":"/tmp/fallback"}') - ) - ); - - const extracted = extractTextFromResponse(toolCallPayload); - - assert.equal(extracted.toolCall.id, "call_fallback"); - assert.equal(extracted.toolCall.function.name, "read_file"); - assert.equal(extracted.toolCall.function.arguments, '{"path":"/tmp/fallback"}'); - assert.equal(extracted.toolCall.isLast, false); -}); - -test("extractTextFromResponse ignores incomplete tool call payloads", () => { - const toolCallPayload = encodeField( - TOP_LEVEL_TOOL_CALL, - LEN, - encodeField(TOOL_NAME, LEN, "read_file") - ); - - assert.deepEqual(extractTextFromResponse(toolCallPayload), { - text: null, - error: null, - toolCall: null, - thinking: null, - }); -}); - -test("extractTextFromResponse returns text and thinking blocks from response payloads", () => { - const responsePayload = encodeField( - TOP_LEVEL_RESPONSE, - LEN, - concatArrays( - encodeField(RESPONSE_TEXT, LEN, "hello"), - encodeField(THINKING, LEN, encodeField(THINKING_TEXT, LEN, "reasoning")) - ) - ); - - const extracted = extractTextFromResponse(responsePayload); - - assert.equal(extracted.text, "hello"); - assert.equal(extracted.thinking, "reasoning"); - assert.equal(extracted.toolCall, null); -}); - -test("generateCursorBody encodes tool metadata, message ids and high reasoning mode", () => { - const framed = generateCursorBody( - [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: "", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { - name: "mcp__repo__read_file", - arguments: '{"path":"/tmp/a"}', - }, - }, - ], - tool_results: [ - { - tool_call_id: "call_1\nmc_model_1", - name: "mcp__repo__read_file", - index: 1, - raw_args: '{"path":"/tmp/a"}', - result: "file contents", - }, - ], - }, - ], - "cursor-small", - [ - { - function: { - name: "read_file", - description: "Read a file", - parameters: { type: "object", properties: { path: { type: "string" } } }, - }, - }, - ], - "high" - ); - - const parsed = parseConnectRPCFrame(framed); - const topLevel = decodeMessage(parsed.payload); - const request = decodeMessage(topLevel.get(1)[0].value); - - assert.equal(parsed.flags, 0); - assert.equal(request.has(29), true); - assert.equal(request.has(34), true); - assert.equal(request.has(30), true); - assert.equal(request.get(30).length >= 2, true); - assert.equal(request.get(49)[0].value, 2); -}); - -test("buildChatRequest normalizes mixed assistant tool payloads without duplicating matching tool-result messages", () => { - const requestFrame = buildChatRequest( - [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: "", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { - name: "mcp__repo__read_file", - arguments: '{"path":"/tmp/a"}', - }, - }, - ], - tool_results: [ - { - tool_call_id: "call_1\nmc_model_1", - name: "mcp__repo__read_file", - index: 1, - raw_args: '{"path":"/tmp/a"}', - result: "file contents", - }, - ], - }, - { - role: "assistant", - content: "", - tool_results: [ - { - tool_call_id: "call_1\nmc_model_1", - name: "mcp__repo__read_file", - index: 1, - raw_args: '{"path":"/tmp/a"}', - result: "file contents", - }, - ], - }, - ], - "cursor-small", - [ - { - name: "read_file", - description: "Read a file", - }, - ], - "medium" - ); - const topLevel = decodeMessage(requestFrame); - const request = decodeMessage(topLevel.get(1)[0].value); - - assert.equal(request.get(1).length, 3); - assert.equal(request.get(27)[0].value, 1); - assert.equal(request.get(48)[0].value, 0); - assert.equal(request.get(49)[0].value, 1); - assert.equal(request.has(29), true); - assert.equal(request.has(34), true); -}); - -test("encodeMcpTool keeps the custom server field even when name and schema are missing", () => { - const encoded = encodeMcpTool({ description: "" }); - const decoded = decodeMessage(encoded); - - assert.equal(decoded.has(1), false); - assert.equal(decoded.has(2), false); - assert.equal(decoded.has(3), false); - assert.equal(textDecoder.decode(decoded.get(4)[0].value), "custom"); -}); diff --git a/tests/unit/cursor-streaming.test.ts b/tests/unit/cursor-streaming.test.ts new file mode 100644 index 0000000000..343e92cdcd --- /dev/null +++ b/tests/unit/cursor-streaming.test.ts @@ -0,0 +1,276 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + newStreamCtx, + processFrame, + buildCursorUsage, + type StreamCtx, +} from "../../open-sse/executors/cursor"; + +// ─── Wire-format helpers (mirror the encoder's primitives) ───────────────── + +function v(n: number): Buffer { + const out: number[] = []; + while (n > 0x7f) { + out.push((n & 0x7f) | 0x80); + n >>>= 7; + } + out.push(n); + return Buffer.from(out); +} +function tag(field: number, wireType: number): Buffer { + return v((field << 3) | wireType); +} +function lenPrefixed(field: number, payload: Buffer): Buffer { + return Buffer.concat([tag(field, 2), v(payload.length), payload]); +} + +// AgentServerMessage { interaction_update (1): { text_delta (1): { text (1): str } } } +function buildTextDeltaPayload(text: string): Buffer { + const tdu = lenPrefixed(1, Buffer.from(text, "utf8")); + const iu = lenPrefixed(1, tdu); + return lenPrefixed(1, iu); +} + +// AgentServerMessage { interaction_update (1): { turn_ended (14): {} } } +function buildTurnEndedPayload(): Buffer { + const iu = lenPrefixed(14, Buffer.alloc(0)); + return lenPrefixed(1, iu); +} + +// AgentServerMessage { interaction_update (1): { token_delta (8): { count (1): n } } } +function buildTokenDeltaPayload(tokens: number): Buffer { + const tokDelta = Buffer.concat([tag(1, 0), v(tokens)]); + const iu = lenPrefixed(8, tokDelta); + return lenPrefixed(1, iu); +} + +// AgentServerMessage { interaction_update (1): { thinking_delta (4): { text (1): str } } } +function buildThinkingDeltaPayload(text: string): Buffer { + const tdu = lenPrefixed(1, Buffer.from(text, "utf8")); + const iu = lenPrefixed(4, tdu); + return lenPrefixed(1, iu); +} + +// AgentServerMessage { kv_server_message (4): {...} } — empty body +function buildKvServerMessagePayload(): Buffer { + return lenPrefixed(4, Buffer.alloc(0)); +} + +// JSON error payload (Connect-RPC error envelope) +function buildJsonErrorPayload(): Buffer { + return Buffer.from( + JSON.stringify({ + error: { message: "rate limited", code: "resource_exhausted" }, + }), + "utf8" + ); +} + +// ─── Tests ───────────────────────────────────────────────────────────────── + +test("newStreamCtx initializes with empty state", () => { + const ctx = newStreamCtx("auto", () => {}); + assert.equal(ctx.totalText, ""); + assert.equal(ctx.tokenDelta, 0); + assert.equal(ctx.endReason, null); + assert.equal(ctx.emittedRoleChunk, false); + assert.equal(ctx.midStreamError, null); + assert.equal(ctx.model, "auto"); + assert.match(ctx.responseId, /^chatcmpl-cursor-/); +}); + +test("processFrame emits role+content chunks for text deltas", () => { + const emitted: string[] = []; + const ctx = newStreamCtx("auto", (s) => emitted.push(s)); + processFrame(buildTextDeltaPayload("hello"), ctx, new Set()); + assert.equal(emitted.length, 2, "role chunk then content chunk"); + // First chunk: role + const first = JSON.parse(emitted[0].replace(/^data: /, "").trim()); + assert.equal(first.choices[0].delta.role, "assistant"); + // Second chunk: content + const second = JSON.parse(emitted[1].replace(/^data: /, "").trim()); + assert.equal(second.choices[0].delta.content, "hello"); + assert.equal(ctx.totalText, "hello"); + assert.equal(ctx.receivedText, true); +}); + +test("processFrame skips role chunk on subsequent text deltas", () => { + const emitted: string[] = []; + const ctx = newStreamCtx("auto", (s) => emitted.push(s)); + processFrame(buildTextDeltaPayload("hello "), ctx, new Set()); + processFrame(buildTextDeltaPayload("world"), ctx, new Set()); + assert.equal(emitted.length, 3, "role + 2 content chunks"); + assert.equal(ctx.totalText, "hello world"); +}); + +test("processFrame sets endReason on turn_ended", () => { + const ctx = newStreamCtx("auto", () => {}); + processFrame(buildTurnEndedPayload(), ctx, new Set()); + assert.equal(ctx.endReason, "turn_ended"); +}); + +test("processFrame accumulates token_delta", () => { + const ctx = newStreamCtx("auto", () => {}); + processFrame(buildTokenDeltaPayload(42), ctx, new Set()); + processFrame(buildTokenDeltaPayload(13), ctx, new Set()); + assert.equal(ctx.tokenDelta, 55); +}); + +test("processFrame sets endReason on kv_server_message after text", () => { + const ctx = newStreamCtx("auto", () => {}); + processFrame(buildTextDeltaPayload("hi"), ctx, new Set()); + processFrame(buildKvServerMessagePayload(), ctx, new Set()); + assert.equal(ctx.endReason, "kv_after_text"); + assert.equal(ctx.kvAfterTextSeen, true); +}); + +test("processFrame ignores kv_server_message before text (no end signal yet)", () => { + const ctx = newStreamCtx("auto", () => {}); + processFrame(buildKvServerMessagePayload(), ctx, new Set()); + assert.equal(ctx.endReason, null); + assert.equal(ctx.kvAfterTextSeen, false); +}); + +test("processFrame captures mid-stream JSON error", () => { + const ctx = newStreamCtx("auto", () => {}); + processFrame(buildJsonErrorPayload(), ctx, new Set()); + assert.equal(ctx.endReason, "server_end"); + assert.ok(ctx.midStreamError); + assert.match(ctx.midStreamError!.message, /rate limited/); +}); + +test("processFrame JSON error after text terminates without overwriting content", () => { + const ctx = newStreamCtx("auto", () => {}); + processFrame(buildTextDeltaPayload("partial"), ctx, new Set()); + processFrame(buildJsonErrorPayload(), ctx, new Set()); + assert.equal(ctx.endReason, "server_end"); + assert.equal(ctx.midStreamError, null, "no error overlay when text already streamed"); + assert.equal(ctx.totalText, "partial"); +}); + +test("processFrame doesn't ack same exec_id twice", () => { + // Simulate request_context appearing twice — only the first should ack. + // Phase 6 dedup is keyed by kind+execId+execMsgId so request_context and + // mcp_args sharing an empty execId don't collide. + const ctx = newStreamCtx("auto", () => {}); + const acked = new Set(); + + // Build an exec_request_context payload: + // ASM { exec_server_message (2): ESM { id (1): 1, exec_id (15): "x", request_context_args (10): {} } } + function buildRequestContext(execId: string): Buffer { + const esm = Buffer.concat([ + Buffer.concat([tag(1, 0), v(1)]), + lenPrefixed(15, Buffer.from(execId, "utf8")), + lenPrefixed(10, Buffer.alloc(0)), + ]); + return lenPrefixed(2, esm); + } + + processFrame(buildRequestContext("x"), ctx, acked); + assert.ok(acked.has("exec_request_context:x:1")); + // Second call doesn't error or change state + processFrame(buildRequestContext("x"), ctx, acked); + assert.equal(acked.size, 1); +}); + +test("StreamCtx custom emit is invoked with full SSE-formatted strings", () => { + const captured: string[] = []; + const ctx = newStreamCtx("auto", (s) => captured.push(s)); + processFrame(buildTextDeltaPayload("ok"), ctx, new Set()); + // Each emit ends with \n\n and starts with "data: " + for (const s of captured) { + assert.match(s, /^data: /); + assert.ok(s.endsWith("\n\n")); + } +}); + +// ─── Thinking-delta capture and reasoning emission ───────────────────────── + +test("processFrame accumulates thinking_delta into thinkingText and emits reasoning_content", () => { + const emitted: string[] = []; + const ctx = newStreamCtx("auto", (s) => emitted.push(s)); + processFrame(buildThinkingDeltaPayload("step 1: "), ctx, new Set()); + processFrame(buildThinkingDeltaPayload("compute"), ctx, new Set()); + assert.equal(ctx.thinkingText, "step 1: compute"); + assert.equal(ctx.receivedText, true); + // role chunk + 2 reasoning_content chunks + assert.equal(emitted.length, 3); + const chunks = emitted.map((s) => JSON.parse(s.replace(/^data: /, "").trim())); + assert.equal(chunks[0].choices[0].delta.role, "assistant"); + assert.equal(chunks[1].choices[0].delta.reasoning_content, "step 1: "); + assert.equal(chunks[2].choices[0].delta.reasoning_content, "compute"); +}); + +test("processFrame on empty thinking text is a no-op (no role chunk, no emit)", () => { + const emitted: string[] = []; + const ctx = newStreamCtx("auto", (s) => emitted.push(s)); + // Build an empty-text thinking delta: thinking_delta(4) wraps an empty TDU + const iu = lenPrefixed(4, lenPrefixed(1, Buffer.from("", "utf8"))); + const payload = lenPrefixed(1, iu); + processFrame(payload, ctx, new Set()); + assert.equal(ctx.thinkingText, ""); + assert.equal(ctx.emittedRoleChunk, false); + assert.equal(emitted.length, 0); +}); + +test("newStreamCtx initializes thinkingText to empty", () => { + const ctx = newStreamCtx("auto", () => {}); + assert.equal(ctx.thinkingText, ""); +}); + +// ─── Usage construction (buildCursorUsage) ───────────────────────────────── + +const SAMPLE_BODY = { messages: [{ role: "user" as const, content: "hello world" }] }; + +test("buildCursorUsage uses cursor's real tokenDelta for completion_tokens", () => { + const ctx = newStreamCtx("auto", () => {}); + processFrame(buildTextDeltaPayload("answer"), ctx, new Set()); + processFrame(buildTokenDeltaPayload(42), ctx, new Set()); + processFrame(buildTokenDeltaPayload(13), ctx, new Set()); + const usage = buildCursorUsage(ctx, SAMPLE_BODY) as Record; + // 55 tokens + buffer added by addBufferToUsage (currently +5 per util convention) + assert.ok(usage.completion_tokens >= 55, `expected ≥55, got ${usage.completion_tokens}`); + assert.ok(usage.prompt_tokens > 0); + assert.equal(usage.total_tokens, usage.prompt_tokens + usage.completion_tokens); +}); + +test("buildCursorUsage falls back to estimate when tokenDelta is zero", () => { + const ctx = newStreamCtx("auto", () => {}); + processFrame(buildTextDeltaPayload("abc"), ctx, new Set()); + // No token_delta frames sent + const usage = buildCursorUsage(ctx, SAMPLE_BODY) as Record; + assert.ok(usage.completion_tokens > 0); + assert.equal(usage.total_tokens, usage.prompt_tokens + usage.completion_tokens); + assert.equal((usage as Record).estimated, true); +}); + +test("buildCursorUsage emits completion_tokens_details.reasoning_tokens when thinking seen", () => { + const ctx = newStreamCtx("auto", () => {}); + processFrame(buildThinkingDeltaPayload("let me think about this carefully"), ctx, new Set()); + processFrame(buildTextDeltaPayload("done"), ctx, new Set()); + processFrame(buildTokenDeltaPayload(20), ctx, new Set()); + const usage = buildCursorUsage(ctx, SAMPLE_BODY) as Record; + const details = usage.completion_tokens_details as Record | undefined; + assert.ok(details, "completion_tokens_details should be present"); + assert.ok(details!.reasoning_tokens > 0, "reasoning_tokens should be >0"); +}); + +test("buildCursorUsage omits completion_tokens_details when no thinking", () => { + const ctx = newStreamCtx("auto", () => {}); + processFrame(buildTextDeltaPayload("just text"), ctx, new Set()); + processFrame(buildTokenDeltaPayload(10), ctx, new Set()); + const usage = buildCursorUsage(ctx, SAMPLE_BODY) as Record; + assert.equal(usage.completion_tokens_details, undefined); +}); + +test("buildCursorUsage never emits cache fields (cursor doesn't expose them)", () => { + const ctx = newStreamCtx("auto", () => {}); + processFrame(buildThinkingDeltaPayload("thinking"), ctx, new Set()); + processFrame(buildTextDeltaPayload("text"), ctx, new Set()); + processFrame(buildTokenDeltaPayload(20), ctx, new Set()); + const usage = buildCursorUsage(ctx, SAMPLE_BODY) as Record; + assert.equal(usage.cached_tokens, undefined); + assert.equal(usage.cache_read_input_tokens, undefined); + assert.equal(usage.cache_creation_input_tokens, undefined); +}); diff --git a/tests/unit/executor-cursor-extended.test.ts b/tests/unit/executor-cursor-extended.test.ts deleted file mode 100644 index 0f008bf9c9..0000000000 --- a/tests/unit/executor-cursor-extended.test.ts +++ /dev/null @@ -1,549 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { CursorExecutor } from "../../open-sse/executors/cursor.ts"; -import { - decodeMessage, - encodeField, - parseConnectRPCFrame, - wrapConnectRPCFrame, -} from "../../open-sse/utils/cursorProtobuf.ts"; -import { - buildCursorHeaders, - generateCursorChecksum, - generateHashed64Hex, - generateSessionId, -} from "../../open-sse/utils/cursorChecksum.ts"; -import { - getCursorVersion, - resetCursorVersionCache, -} from "../../open-sse/utils/cursorVersionDetector.ts"; - -const LEN = 2; -const VARINT = 0; -const TOP_LEVEL_TOOL_CALL = 1; -const TOP_LEVEL_RESPONSE = 2; -const RESPONSE_TEXT = 1; -const TOOL_ID = 3; -const TOOL_NAME = 9; -const TOOL_RAW_ARGS = 10; -const TOOL_IS_LAST = 11; - -function concatArrays(...arrays) { - const total = arrays.reduce((sum, array) => sum + array.length, 0); - const result = new Uint8Array(total); - let offset = 0; - - for (const array of arrays) { - result.set(array, offset); - offset += array.length; - } - - return result; -} - -function buildTextFrame(text) { - return Buffer.from( - wrapConnectRPCFrame( - encodeField(TOP_LEVEL_RESPONSE, LEN, encodeField(RESPONSE_TEXT, LEN, text)), - false - ) - ); -} - -function buildCompressedTextFrame(text) { - return Buffer.from( - wrapConnectRPCFrame( - encodeField(TOP_LEVEL_RESPONSE, LEN, encodeField(RESPONSE_TEXT, LEN, text)), - true - ) - ); -} - -function buildToolCallFrame({ id, name, args, isLast }) { - return Buffer.from( - wrapConnectRPCFrame( - encodeField( - TOP_LEVEL_TOOL_CALL, - LEN, - concatArrays( - encodeField(TOOL_ID, LEN, id), - encodeField(TOOL_NAME, LEN, name), - encodeField(TOOL_RAW_ARGS, LEN, args), - encodeField(TOOL_IS_LAST, VARINT, isLast ? 1 : 0) - ) - ), - false - ) - ); -} - -function buildJsonErrorFrame(error) { - return Buffer.from(wrapConnectRPCFrame(new TextEncoder().encode(JSON.stringify(error)), false)); -} - -test("CursorExecutor.buildUrl uses the configured Cursor endpoint", () => { - const executor = new CursorExecutor(); - assert.equal( - executor.buildUrl(), - "https://api2.cursor.sh/aiserver.v1.ChatService/StreamUnifiedChatWithTools" - ); -}); - -test("CursorExecutor.buildHeaders strips token prefixes and derives checksum/session headers", () => { - const executor = new CursorExecutor(); - const originalDateNow = Date.now; - const originalDbPath = process.env.CURSOR_STATE_DB_PATH; - // Force fallback by pointing to a non-existent DB path - process.env.CURSOR_STATE_DB_PATH = "/nonexistent/cursor/state.vscdb"; - resetCursorVersionCache(); - Date.now = () => 1_700_000_000_000; - - try { - const headers = executor.buildHeaders({ - accessToken: "prefix::real-token", - providerSpecificData: { machineId: "machine-1", ghostMode: false }, - }); - - const expectedVersion = getCursorVersion(); - - assert.equal(headers.authorization, "Bearer real-token"); - assert.equal(headers["x-client-key"], generateHashed64Hex("real-token")); - assert.equal(headers["x-session-id"], generateSessionId("real-token")); - assert.equal(headers["x-cursor-checksum"], generateCursorChecksum("machine-1")); - assert.equal(headers["x-cursor-client-version"], expectedVersion); - assert.equal(headers["x-cursor-user-agent"], `Cursor/${expectedVersion}`); - assert.equal(headers["user-agent"], `Cursor/${expectedVersion}`); - assert.equal(headers["x-ghost-mode"], "false"); - assert.equal(headers["connect-protocol-version"], "1"); - assert.match(headers["x-amzn-trace-id"], /^Root=/); - assert.ok(headers["x-request-id"]); - } finally { - Date.now = originalDateNow; - if (originalDbPath === undefined) { - delete process.env.CURSOR_STATE_DB_PATH; - } else { - process.env.CURSOR_STATE_DB_PATH = originalDbPath; - } - resetCursorVersionCache(); - } -}); - -test("buildCursorHeaders utility stays aligned with Cursor Composer 2 versioned headers", () => { - const originalDbPath = process.env.CURSOR_STATE_DB_PATH; - process.env.CURSOR_STATE_DB_PATH = "/nonexistent/cursor/state.vscdb"; - resetCursorVersionCache(); - - try { - const headers = buildCursorHeaders("prefix::real-token", "machine-1", false); - const expectedVersion = getCursorVersion(); - - assert.equal(headers.Authorization, "Bearer real-token"); - assert.equal(headers["x-cursor-client-version"], expectedVersion); - assert.equal(headers["x-cursor-user-agent"], `Cursor/${expectedVersion}`); - assert.equal(headers["User-Agent"], `Cursor/${expectedVersion}`); - assert.equal(headers["x-ghost-mode"], "false"); - } finally { - if (originalDbPath === undefined) { - delete process.env.CURSOR_STATE_DB_PATH; - } else { - process.env.CURSOR_STATE_DB_PATH = originalDbPath; - } - resetCursorVersionCache(); - } -}); - -test("CursorExecutor.buildHeaders derives machineId when not provided", () => { - const executor = new CursorExecutor(); - const headers = executor.buildHeaders({ accessToken: "real-token", providerSpecificData: {} }); - assert.ok(headers["x-cursor-checksum"], "should have a checksum header"); - assert.ok(headers["x-client-key"], "should have a client key header"); -}); - -test("CursorExecutor.transformRequest produces a framed protobuf payload", () => { - const executor = new CursorExecutor(); - const transformed = executor.transformRequest( - "claude-3.5-sonnet", - { messages: [{ role: "user", content: "Hello" }], tools: [] }, - true, - {} - ); - const frame = parseConnectRPCFrame(transformed); - const fields = decodeMessage(frame.payload); - - assert.ok(transformed instanceof Uint8Array); - assert.equal(frame.flags, 0); - assert.equal(frame.consumed, transformed.length); - assert.equal(fields.has(1), true); -}); - -test("CursorExecutor.transformProtobufToJSON aggregates text and split tool call arguments", async () => { - const executor = new CursorExecutor(); - const body = { messages: [{ role: "user", content: "hi" }] }; - const buffer = Buffer.concat([ - buildTextFrame("Hello "), - buildToolCallFrame({ - id: "call_1", - name: "read_file", - args: '{"path":', - isLast: false, - }), - buildToolCallFrame({ - id: "call_1", - name: "read_file", - args: '"/tmp/a"}', - isLast: true, - }), - ]); - - const response = executor.transformProtobufToJSON(buffer, "cursor-small", body); - const payload = (await response.json()) as any; - - assert.equal(response.status, 200); - assert.equal(payload.object, "chat.completion"); - assert.equal(payload.model, "cursor-small"); - assert.equal(payload.choices[0].message.content, "Hello "); - assert.equal(payload.choices[0].finish_reason, "tool_calls"); - assert.equal(payload.choices[0].message.tool_calls[0].function.name, "read_file"); - assert.equal(payload.choices[0].message.tool_calls[0].function.arguments, '{"path":"/tmp/a"}'); - assert.equal(payload.usage.estimated, true); -}); - -test("CursorExecutor.transformProtobufToJSON finalizes incomplete tool calls when the stream ends early", async () => { - const executor = new CursorExecutor(); - const response = executor.transformProtobufToJSON( - Buffer.concat([ - buildToolCallFrame({ - id: "call_2", - name: "list_files", - args: '{"path":"/tmp"}', - isLast: false, - }), - ]), - "cursor-small", - { messages: [{ role: "user", content: "hi" }] } - ); - const payload = (await response.json()) as any; - - assert.equal(payload.choices[0].finish_reason, "tool_calls"); - assert.equal(payload.choices[0].message.tool_calls[0].id, "call_2"); - assert.equal(payload.choices[0].message.tool_calls[0].function.name, "list_files"); -}); - -test("CursorExecutor.transformProtobufToJSON keeps prior content when an error frame arrives after output", async () => { - const executor = new CursorExecutor(); - const response = executor.transformProtobufToJSON( - Buffer.concat([ - buildTextFrame("Partial answer"), - buildJsonErrorFrame({ - error: { - code: "resource_exhausted", - message: "late error", - }, - }), - ]), - "cursor-small", - { messages: [{ role: "user", content: "hi" }] } - ); - const payload = (await response.json()) as any; - - assert.equal(response.status, 200); - assert.equal(payload.choices[0].message.content, "Partial answer"); - assert.equal(payload.choices[0].finish_reason, "stop"); -}); - -test("CursorExecutor.transformProtobufToJSON decompresses gzip frames", async () => { - const executor = new CursorExecutor(); - const response = executor.transformProtobufToJSON( - Buffer.concat([buildCompressedTextFrame("Compressed answer")]), - "cursor-small", - { messages: [{ role: "user", content: "hi" }] } - ); - const payload = (await response.json()) as any; - - assert.equal(payload.choices[0].message.content, "Compressed answer"); -}); - -test("CursorExecutor.transformProtobufToSSE emits assistant chunks, tool deltas and DONE marker", async () => { - const executor = new CursorExecutor(); - const body = { messages: [{ role: "user", content: "hi" }] }; - const buffer = Buffer.concat([ - buildTextFrame("Hello "), - buildToolCallFrame({ - id: "call_1", - name: "read_file", - args: '{"path":', - isLast: false, - }), - buildToolCallFrame({ - id: "call_1", - name: "read_file", - args: '"/tmp/a"}', - isLast: true, - }), - ]); - - const response = executor.transformProtobufToSSE(buffer, "cursor-small", body); - const text = await response.text(); - - assert.equal(response.status, 200); - assert.equal(response.headers.get("Content-Type"), "text/event-stream"); - assert.match(text, /"role":"assistant","content":"Hello "/); - assert.match(text, /"tool_calls":\[/); - assert.match(text, /"name":"read_file"/); - assert.match(text, /"finish_reason":"tool_calls"/); - assert.match(text, /\[DONE\]/); -}); - -test("CursorExecutor.transformProtobufToSSE finalizes unterminated tool calls at stream end", async () => { - const executor = new CursorExecutor(); - const response = executor.transformProtobufToSSE( - Buffer.concat([ - buildToolCallFrame({ - id: "call_2", - name: "read_file", - args: '{"path":"/tmp/b"}', - isLast: false, - }), - ]), - "cursor-small", - { messages: [{ role: "user", content: "hi" }] } - ); - const text = await response.text(); - - assert.match(text, /"name":"read_file"/); - assert.match(text, /"finish_reason":"tool_calls"/); - assert.match(text, /\[DONE\]/); -}); - -test("CursorExecutor.transformProtobufToSSE returns a JSON error before any content is streamed", async () => { - const executor = new CursorExecutor(); - const response = executor.transformProtobufToSSE( - buildJsonErrorFrame({ - error: { - code: "resource_exhausted", - message: "too many requests", - details: [{ debug: { error: "LIMIT", details: { title: "Limit hit" } } }], - }, - }), - "cursor-small", - { messages: [{ role: "user", content: "hi" }] } - ); - const payload = (await response.json()) as any; - - assert.equal(response.status, 429); - assert.equal(payload.error.type, "rate_limit_error"); - assert.equal(payload.error.message, "Limit hit"); - assert.equal(payload.error.code, "LIMIT"); -}); - -test("CursorExecutor.transformProtobufToSSE stops gracefully when a JSON error arrives after content", async () => { - const executor = new CursorExecutor(); - const response = executor.transformProtobufToSSE( - Buffer.concat([ - buildTextFrame("Partial Cursor answer"), - buildJsonErrorFrame({ - error: { - code: "resource_exhausted", - message: "late limit", - }, - }), - ]), - "cursor-small", - { messages: [{ role: "user", content: "hi" }] } - ); - const text = await response.text(); - - assert.equal(response.status, 200); - assert.match(text, /Partial Cursor answer/); - assert.match(text, /"finish_reason":"stop"/); - assert.match(text, /\[DONE\]/); -}); - -test("CursorExecutor.transformProtobufToSSE emits plain content deltas after tool call chunks", async () => { - const executor = new CursorExecutor(); - const response = executor.transformProtobufToSSE( - Buffer.concat([ - buildToolCallFrame({ - id: "call_3", - name: "read_file", - args: '{"path":"/tmp/c"}', - isLast: false, - }), - buildTextFrame("Follow-up text"), - ]), - "cursor-small", - { messages: [{ role: "user", content: "hi" }] } - ); - const text = await response.text(); - - assert.match(text, /"name":"read_file"/); - assert.match(text, /"delta":\{"content":"Follow-up text"\}/); - assert.match(text, /"finish_reason":"tool_calls"/); -}); - -test("CursorExecutor.transformProtobufToSSE emits an empty assistant envelope for empty responses", async () => { - const executor = new CursorExecutor(); - const response = executor.transformProtobufToSSE(Buffer.alloc(0), "cursor-small", { - messages: [{ role: "user", content: "hi" }], - }); - const text = await response.text(); - - assert.match(text, /"role":"assistant","content":""/); - assert.match(text, /"finish_reason":"stop"/); - assert.match(text, /\[DONE\]/); -}); - -test("CursorExecutor.transformProtobufToSSE converts JSON error frames into rate-limit responses", async () => { - const executor = new CursorExecutor(); - const response = executor.transformProtobufToSSE( - buildJsonErrorFrame({ - error: { - code: "resource_exhausted", - message: "rate limited", - details: [{ debug: { error: "LIMIT", details: { title: "Limit", detail: "Slow down" } } }], - }, - }), - "cursor-small", - { messages: [{ role: "user", content: "hi" }] } - ); - const payload = (await response.json()) as any; - - assert.equal(response.status, 429); - assert.equal(payload.error.type, "rate_limit_error"); - assert.equal(payload.error.message, "Limit"); - assert.equal(payload.error.code, "LIMIT"); -}); - -test("CursorExecutor.execute returns transformed JSON for non-stream responses", async () => { - const executor = new CursorExecutor(); - const body = { messages: [{ role: "user", content: "hi" }] }; - const responseBuffer = Buffer.concat([buildTextFrame("Hello from Cursor")]); - executor.makeHttp2Request = async () => ({ - status: 200, - headers: {}, - body: responseBuffer, - }); - executor.makeFetchRequest = executor.makeHttp2Request; - - const result = await executor.execute({ - model: "cursor-small", - body, - stream: false, - credentials: { - accessToken: "token", - providerSpecificData: { machineId: "machine-1" }, - }, - }); - const payload = (await result.response.json()) as any; - - assert.equal( - result.url, - "https://api2.cursor.sh/aiserver.v1.ChatService/StreamUnifiedChatWithTools" - ); - assert.equal(result.transformedBody, body); - assert.equal(result.headers.authorization, "Bearer token"); - assert.equal(payload.object, "chat.completion"); - assert.equal(payload.choices[0].message.content, "Hello from Cursor"); - assert.equal(payload.choices[0].finish_reason, "stop"); -}); - -test("CursorExecutor.execute returns transformed SSE for stream responses", async () => { - const executor = new CursorExecutor(); - const body = { messages: [{ role: "user", content: "hi" }] }; - const responseBuffer = Buffer.concat([buildTextFrame("Hello stream")]); - executor.makeHttp2Request = async () => ({ - status: 200, - headers: {}, - body: responseBuffer, - }); - executor.makeFetchRequest = executor.makeHttp2Request; - - const result = await executor.execute({ - model: "cursor-small", - body, - stream: true, - credentials: { - accessToken: "token", - providerSpecificData: { machineId: "machine-1" }, - }, - }); - const text = await result.response.text(); - - assert.equal(result.response.status, 200); - assert.match(text, /"content":"Hello stream"/); - assert.match(text, /\[DONE\]/); -}); - -test("CursorExecutor.execute maps non-200 upstream responses to OpenAI-style errors", async () => { - const executor = new CursorExecutor(); - const body = { messages: [{ role: "user", content: "hi" }] }; - executor.makeHttp2Request = async () => ({ - status: 403, - headers: {}, - body: Buffer.from("denied"), - }); - executor.makeFetchRequest = executor.makeHttp2Request; - - const result = await executor.execute({ - model: "cursor-small", - body, - stream: false, - credentials: { - accessToken: "token", - providerSpecificData: { machineId: "machine-1" }, - }, - }); - const payload = (await result.response.json()) as any; - - assert.equal(result.response.status, 403); - assert.equal(payload.error.type, "invalid_request_error"); - assert.match(payload.error.message, /\[403\]: denied/); -}); - -test("CursorExecutor.execute maps transport failures to connection_error and refreshCredentials returns null", async () => { - const executor = new CursorExecutor(); - executor.makeHttp2Request = async () => { - throw new Error("socket hang up"); - }; - executor.makeFetchRequest = executor.makeHttp2Request; - - const result = await executor.execute({ - model: "cursor-small", - body: { messages: [{ role: "user", content: "hi" }] }, - stream: false, - credentials: { - accessToken: "token", - providerSpecificData: { machineId: "machine-1" }, - }, - }); - const payload = (await result.response.json()) as any; - - assert.equal(result.response.status, 500); - assert.equal(payload.error.type, "connection_error"); - assert.equal(payload.error.message, "socket hang up"); - assert.equal(await executor.refreshCredentials(), null); -}); - -test("CursorExecutor.transformProtobufToSSE finalizes un-terminated tools when stream abruptly cuts before isLast", async () => { - const executor = new CursorExecutor(); - - // Send a tool call but never close it - const response = executor.transformProtobufToSSE( - Buffer.concat([ - buildToolCallFrame({ - id: "call_abrupt", - name: "write_file", - args: '{"content":"partial"', - isLast: false, - }), - ]), - "cursor-small", - { messages: [{ role: "user", content: "hi" }] } - ); - - const text = await response.text(); - assert.match(text, /"name":"write_file"/); - assert.match(text, /"finish_reason":"tool_calls"/); - assert.match(text, /\[DONE\]/); -});