diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index d05d3b5ee5..57c810470a 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -62,6 +62,11 @@ import * as fs from "node:fs"; import * as zlib from "node:zlib"; import { promisify } from "node:util"; import { toolChoiceDirectiveLine, buildCursorOutputConstraints } from "./cursor/prompt.ts"; +import { + bridgeCursorBuiltinTool, + selectCursorBridgeTools, + type CursorClientPlatform, +} from "./cursor/builtinToolBridge.ts"; import { isComposerModel, visibleComposerContentFromThinking, @@ -289,6 +294,10 @@ export type StreamCtx = { // role:"tool" message can be answered on the open h2 stream via // encodeExecMcpResult. pendingToolCalls: Map; + // Built-in Cursor tools are bridged to external OpenAI tool calls by first + // rejecting the native request. Their result therefore cannot resume on the + // same h2 stream and must use the existing full-history cold-resume path. + requiresColdResume: boolean; // Composer thinking-as-content (decolua/9router#1310): tracks how much of // the visible suffix (after the last ``) has already been streamed // out as `content` deltas, so we only emit the incremental tail per frame. @@ -320,6 +329,7 @@ export function newStreamCtx(model: string, emit: (chunk: string) => void): Stre emittedToolCallIndex: 0, toolCalls: [], pendingToolCalls: new Map(), + requiresColdResume: false, composerVisibleEmittedLength: 0, composerToolParserState: isComposerModel(model) ? createStreamingState() : null, composerInlineToolCallsEmitted: false, @@ -380,6 +390,56 @@ function emitDone(ctx: StreamCtx) { ctx.emit("data: [DONE]\n\n"); } +export function inferCursorClientPlatform( + messages: ChatMessage[] +): CursorClientPlatform | undefined { + const systemMessages = messages.filter((message) => message.role === "system"); + if (systemMessages.length === 0) return undefined; + const text = flattenMessages(systemMessages); + const platforms = new Set(); + const metadataPattern = + /\b(?:client\s+)?(?:platform|os|operating\s+system)\s*[:=]\s*["']?(win32|windows|linux|darwin|macos|posix)\b/gi; + for (const match of text.matchAll(metadataPattern)) { + platforms.add(/^(?:win32|windows)$/i.test(match[1]) ? "windows" : "posix"); + } + return platforms.size === 1 ? [...platforms][0] : undefined; +} + +/** Emit one complete OpenAI-compatible structured tool call. */ +function emitStructuredToolCall( + ctx: StreamCtx, + toolName: string, + args: Record +): string { + if (!ctx.emittedRoleChunk) { + emitChunk(ctx, { role: "assistant", content: "" }); + ctx.emittedRoleChunk = true; + } + const idx = ctx.emittedToolCallIndex++; + const openAIToolCallId = generateToolCallId(); + const argumentsJson = JSON.stringify(args); + emitChunk(ctx, { + tool_calls: [ + { + index: idx, + id: openAIToolCallId, + type: "function", + function: { name: toolName, arguments: "" }, + }, + ], + }); + emitChunk(ctx, { + tool_calls: [ + { + index: idx, + function: { arguments: argumentsJson }, + }, + ], + }); + ctx.toolCalls.push({ id: openAIToolCallId, name: toolName, argumentsJson }); + return openAIToolCallId; +} + /** * Process one decoded Connect-RPC frame payload: dispatch ExecServerMessage * events (rejection / context ack / mcp_args), decode AgentServerMessage @@ -402,6 +462,7 @@ export function processFrame( h2Req?: import("http2").ClientHttp2Stream; mcpTools?: McpToolDefinition[]; blobStore?: Map; + clientPlatform?: CursorClientPlatform; } = {} ): void { // 1. JSON error envelope (Connect-RPC style — usually status > 200). @@ -461,36 +522,7 @@ export function processFrame( // 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, - }); + const openAIToolCallId = emitStructuredToolCall(ctx, event.toolName, event.args ?? {}); // 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, { @@ -504,12 +536,23 @@ export function processFrame( // alive for the next OpenAI call (which arrives with role:"tool"). ctx.endReason = "tool_calls"; } else { + // Cursor/Fable frequently chooses its native Shell tool even when the + // OpenAI client declared external tools. If a schema-compatible shell + // tool exists, surface the native request as a structured OpenAI call. + // We still send the typed rejection upstream, then close this h2 stream; + // the role:"tool" follow-up is resumed cold from the full history. + const bridge = bridgeCursorBuiltinTool(event, opts.mcpTools ?? [], opts.clientPlatform); const rejection = buildExecRejection(event); if (rejection && opts.h2Req) { try { opts.h2Req.write(rejection); } catch {} } + if (bridge) { + emitStructuredToolCall(ctx, bridge.toolName, bridge.arguments); + ctx.requiresColdResume = true; + ctx.endReason = "tool_calls"; + } } } @@ -599,13 +642,17 @@ export function processFrame( } else if (d.kind === "token_delta") { ctx.tokenDelta += d.tokens; } else if (d.kind === "turn_ended") { - ctx.endReason = "turn_ended"; + if (ctx.endReason !== "tool_calls") 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) { + } else if ( + d.kind === "kv_server_message" && + ctx.receivedText && + ctx.endReason !== "tool_calls" + ) { // 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. @@ -923,6 +970,7 @@ export class CursorExecutor extends BaseExecutor { ctx: StreamCtx, mcpTools: McpToolDefinition[] | undefined, blobStore: Map | undefined, + clientPlatform: CursorClientPlatform | undefined, signal?: AbortSignal ): Promise { const ackedExecIds = new Set(); @@ -1019,7 +1067,12 @@ export class CursorExecutor extends BaseExecutor { try { const payload = flag & 0x1 ? await gunzipAsync(raw) : raw; if (settled) return; - processFrame(payload, ctx, ackedExecIds, { h2Req: h2.req, mcpTools, blobStore }); + processFrame(payload, ctx, ackedExecIds, { + h2Req: h2.req, + mcpTools, + blobStore, + clientPlatform, + }); } catch (err) { debugLog( "[cursor-agent] frame decode failed at pos", @@ -1072,9 +1125,11 @@ export class CursorExecutor extends BaseExecutor { // 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) + const declaredMcpTools: McpToolDefinition[] | undefined = Array.isArray(body.tools) ? openAIToolsToMcpDefs(body.tools as OpenAITool[]) : undefined; + const mcpTools = selectCursorBridgeTools(declaredMcpTools, body.tool_choice); + const clientPlatform = inferCursorClientPlatform(messages); // Sanitize error messages: strip stack traces and absolute paths to // prevent information exposure. Shared helper in utils/error.ts. @@ -1226,7 +1281,7 @@ export class CursorExecutor extends BaseExecutor { for (const [id, info] of ctx.pendingToolCalls) { sessionToUse.pendingToolCalls.set(id, info); } - if (errored || ctx.endReason !== "tool_calls") { + if (errored || ctx.endReason !== "tool_calls" || ctx.requiresColdResume) { cursorSessionManager.close(sessionToUse); } else { cursorSessionManager.release(sessionToUse, "awaiting_tool_result"); @@ -1241,7 +1296,7 @@ export class CursorExecutor extends BaseExecutor { start: async (controller) => { const ctx = newStreamCtx(model, (s) => controller.enqueue(enc.encode(s))); try { - await this.driveH2(h2, ctx, mcpTools, blobStore, signal); + await this.driveH2(h2, ctx, mcpTools, blobStore, clientPlatform, signal); this.finalizeSseStream(ctx, body); finishLifecycle(ctx, false); controller.close(); @@ -1271,7 +1326,7 @@ export class CursorExecutor extends BaseExecutor { // Non-streaming: drive to completion, return chat.completion JSON. const ctx = newStreamCtx(model, () => {}); try { - await this.driveH2(h2, ctx, mcpTools, blobStore, signal); + await this.driveH2(h2, ctx, mcpTools, blobStore, clientPlatform, signal); } catch (err) { finishLifecycle(ctx, true); const message = err instanceof Error ? err.message : String(err); diff --git a/open-sse/executors/cursor/builtinToolBridge.ts b/open-sse/executors/cursor/builtinToolBridge.ts new file mode 100644 index 0000000000..121d80fd8c --- /dev/null +++ b/open-sse/executors/cursor/builtinToolBridge.ts @@ -0,0 +1,257 @@ +import { + decodeProtobufValue, + type ExecServerEvent, + type McpToolDefinition, +} from "../../utils/cursorAgentProtobuf.ts"; + +export type CursorBuiltinToolBridge = { + toolName: string; + arguments: Record; +}; + +export type CursorClientPlatform = "windows" | "posix"; + +type OpenAIToolChoice = + string | { type?: unknown; function?: { name?: unknown } } | null | undefined; + +type JsonSchema = { + type?: unknown; + properties?: Record; + required?: unknown; + additionalProperties?: unknown; +}; + +const DIRECT_SHELL_TOOL_NAMES = ["bash", "shell", "run_terminal_cmd"]; +const BRIDGE_DESCRIPTION = "Run Cursor-requested shell command"; +const ROOT_SCHEMA_KEYS = new Set([ + "$schema", + "$id", + "$comment", + "title", + "description", + "type", + "properties", + "required", + "additionalProperties", +]); +const PROPERTY_ANNOTATION_KEYS = [ + "$comment", + "title", + "description", + "default", + "examples", + "deprecated", + "readOnly", + "writeOnly", +] as const; +const SCALAR_PROPERTY_KEYS = new Set(["type", ...PROPERTY_ANNOTATION_KEYS]); +const ARRAY_PROPERTY_KEYS = new Set(["type", "items", ...PROPERTY_ANNOTATION_KEYS]); + +/** Restrict bridge candidates to the caller's OpenAI tool_choice contract. */ +export function selectCursorBridgeTools( + tools: McpToolDefinition[] | undefined, + toolChoice: OpenAIToolChoice +): McpToolDefinition[] | undefined { + if (toolChoice === "none") return undefined; + const specificName = + toolChoice && + typeof toolChoice === "object" && + toolChoice.type === "function" && + typeof toolChoice.function?.name === "string" + ? toolChoice.function.name + : undefined; + return specificName ? tools?.filter((tool) => tool.name === specificName) : tools; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function hasOnlyKeys(value: Record, allowed: Set): boolean { + return Object.keys(value).every((key) => allowed.has(key)); +} + +function schemaFor(tool: McpToolDefinition): JsonSchema | null { + try { + const decoded = decodeProtobufValue(tool.inputSchemaBytes); + if (!isRecord(decoded) || decoded.type !== "object") return null; + if (!hasOnlyKeys(decoded, ROOT_SCHEMA_KEYS)) return null; + if (decoded.properties !== undefined && !isRecord(decoded.properties)) return null; + if ( + decoded.required !== undefined && + (!Array.isArray(decoded.required) || + !decoded.required.every((key) => typeof key === "string")) + ) { + return null; + } + if ( + decoded.additionalProperties !== undefined && + typeof decoded.additionalProperties !== "boolean" + ) { + return null; + } + return decoded as JsonSchema; + } catch { + return null; + } +} + +function schemaProperties(schema: JsonSchema): Record { + return isRecord(schema.properties) ? schema.properties : {}; +} + +function requiredKeys(schema: JsonSchema): string[] { + return Array.isArray(schema.required) + ? schema.required.filter((key): key is string => typeof key === "string") + : []; +} + +function hasAllRequired(schema: JsonSchema, args: Record): boolean { + return requiredKeys(schema).every((key) => Object.prototype.hasOwnProperty.call(args, key)); +} + +/** + * Accept only the small schema subset for which generated values are proven + * valid. Any validation keyword we do not implement (pattern, format, length, + * conditionals, refs, dependentRequired, and so on) fails closed. + */ +function propertySupports(value: unknown, expected: "string" | "boolean" | "string[]"): boolean { + if (!isRecord(value)) return false; + if (expected === "string[]") { + if (!hasOnlyKeys(value, ARRAY_PROPERTY_KEYS)) return false; + if (value.type !== "array" || !isRecord(value.items)) return false; + return hasOnlyKeys(value.items, SCALAR_PROPERTY_KEYS) && value.items.type === "string"; + } + return hasOnlyKeys(value, SCALAR_PROPERTY_KEYS) && value.type === expected; +} + +function namedTools(tools: McpToolDefinition[], names: string[]): McpToolDefinition[] { + const out: McpToolDefinition[] = []; + for (const name of names) { + out.push(...tools.filter((tool) => tool.name.toLowerCase() === name)); + } + return out; +} + +function selectProperty( + schema: JsonSchema, + properties: Record, + names: string[], + expected: "string" | "boolean" | "string[]" +): string | undefined { + const required = new Set(requiredKeys(schema)); + return ( + names.find((name) => required.has(name) && propertySupports(properties[name], expected)) ?? + names.find((name) => propertySupports(properties[name], expected)) + ); +} + +function directShellBridge( + event: Extract, + tools: McpToolDefinition[] +): CursorBuiltinToolBridge | null { + for (const tool of namedTools(tools, DIRECT_SHELL_TOOL_NAMES)) { + const schema = schemaFor(tool); + if (!schema) continue; + const properties = schemaProperties(schema); + const commandKey = selectProperty(schema, properties, ["command", "cmd"], "string"); + if (!commandKey) continue; + + const args: Record = { [commandKey]: event.command }; + const cwdKey = selectProperty( + schema, + properties, + ["workdir", "cwd", "workingDirectory", "working_directory"], + "string" + ); + if (cwdKey && event.workingDir) args[cwdKey] = event.workingDir; + if (propertySupports(properties.description, "string")) { + args.description = BRIDGE_DESCRIPTION; + } + if (hasAllRequired(schema, args)) return { toolName: tool.name, arguments: args }; + } + return null; +} + +function ptySpawnBridge( + event: Extract, + tools: McpToolDefinition[], + platform: CursorClientPlatform | undefined +): CursorBuiltinToolBridge | null { + if (!platform) return null; + for (const tool of namedTools(tools, ["pty_spawn"])) { + const schema = schemaFor(tool); + if (!schema) continue; + const properties = schemaProperties(schema); + if ( + !propertySupports(properties.command, "string") || + !propertySupports(properties.args, "string[]") || + !propertySupports(properties.description, "string") + ) { + continue; + } + + const windows = platform === "windows"; + const args: Record = { + command: windows ? "powershell.exe" : "/bin/sh", + args: windows + ? ["-NoProfile", "-NonInteractive", "-Command", event.command] + : ["-lc", event.command], + description: BRIDGE_DESCRIPTION, + }; + const cwdKey = selectProperty( + schema, + properties, + ["workdir", "cwd", "workingDirectory", "working_directory"], + "string" + ); + if (cwdKey && event.workingDir) args[cwdKey] = event.workingDir; + if (propertySupports(properties.notifyOnExit, "boolean")) args.notifyOnExit = true; + if (hasAllRequired(schema, args)) return { toolName: tool.name, arguments: args }; + } + return null; +} + +function readBridge( + event: Extract, + tools: McpToolDefinition[] +): CursorBuiltinToolBridge | null { + for (const tool of namedTools(tools, ["read", "read_file"])) { + const schema = schemaFor(tool); + if (!schema) continue; + const properties = schemaProperties(schema); + const pathKey = selectProperty(schema, properties, ["filePath", "path", "file_path"], "string"); + if (!pathKey) continue; + const args: Record = { [pathKey]: event.path }; + if (hasAllRequired(schema, args)) return { toolName: tool.name, arguments: args }; + } + return null; +} + +/** + * Convert a Cursor-native built-in request into a declared external tool call. + * Only event variants whose complete arguments are decoded are supported. + * Unknown or constrained schemas fail closed and retain typed rejection. + */ +export function bridgeCursorBuiltinTool( + event: ExecServerEvent, + tools: McpToolDefinition[], + platform?: CursorClientPlatform +): CursorBuiltinToolBridge | null { + if (event.kind === "exec_read") return readBridge(event, tools); + if ( + event.kind !== "exec_shell" && + event.kind !== "exec_shell_stream" && + event.kind !== "exec_bg_shell" + ) { + return null; + } + if (!event.command.trim()) return null; + // The external schemas supported here do not expose Cursor's timeout or + // hard-timeout semantics. Dropping either limit could broaden execution, so + // preserve the native typed rejection instead of emitting an unsafe call. + if (event.timeout > 0 || event.hardTimeout > 0) return null; + const background = event.kind === "exec_bg_shell" || event.isBackground; + if (background) return ptySpawnBridge(event, tools, platform); + return directShellBridge(event, tools) ?? ptySpawnBridge(event, tools, platform); +} diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index 502f475711..b78a497444 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -168,6 +168,9 @@ const ESM_WRITE_SHELL_STDIN_ARGS = 23; 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_SHELL_TIMEOUT = 3; // ShellArgs.timeout +const ARG_SHELL_IS_BACKGROUND = 11; // ShellArgs.is_background +const ARG_SHELL_HARD_TIMEOUT = 14; // ShellArgs.hard_timeout const ARG_FETCH_URL = 1; // FetchArgs.url // KvServerMessage / KvClientMessage @@ -764,6 +767,9 @@ export type ExecServerEvent = execId: string; command: string; workingDir: string; + timeout: number; + isBackground: boolean; + hardTimeout: number; } | { kind: "exec_shell_stream"; @@ -771,6 +777,9 @@ export type ExecServerEvent = execId: string; command: string; workingDir: string; + timeout: number; + isBackground: boolean; + hardTimeout: number; } | { kind: "exec_bg_shell"; @@ -778,6 +787,9 @@ export type ExecServerEvent = execId: string; command: string; workingDir: string; + timeout: number; + isBackground: boolean; + hardTimeout: number; } | { kind: "exec_fetch"; execMsgId: number; execId: string; url: string } | { kind: "exec_write_shell_stdin"; execMsgId: number; execId: string } @@ -791,6 +803,34 @@ export type ExecServerEvent = args: Record; }; +type DecodedShellArgs = { + command: string; + workingDir: string; + timeout: number; + isBackground: boolean; + hardTimeout: number; +}; + +function decodeShellArgs(payload: Buffer): DecodedShellArgs { + const decoded: DecodedShellArgs = { + command: decodeStringField(payload, ARG_SHELL_COMMAND), + workingDir: decodeStringField(payload, ARG_SHELL_WORKING_DIR), + timeout: 0, + isBackground: false, + hardTimeout: 0, + }; + for (const field of decodeFields(payload)) { + if (field.wireType !== 0) continue; + if (field.fieldNumber === ARG_SHELL_TIMEOUT) decoded.timeout = Number(field.varint); + else if (field.fieldNumber === ARG_SHELL_IS_BACKGROUND) { + decoded.isBackground = field.varint !== 0n; + } else if (field.fieldNumber === ARG_SHELL_HARD_TIMEOUT) { + decoded.hardTimeout = Number(field.varint); + } + } + return decoded; +} + export function decodeExecServerEvent(payload: Buffer): ExecServerEvent | null { for (const top of decodeFields(payload)) { if (top.fieldNumber !== ASM_EXEC_SERVER_MESSAGE || top.wireType !== 2) continue; @@ -852,30 +892,33 @@ export function decodeExecServerEvent(payload: Buffer): ExecServerEvent | null { return { kind: "exec_grep", execMsgId, execId }; case ESM_DIAGNOSTICS_ARGS: return { kind: "exec_diagnostics", execMsgId, execId }; - case ESM_SHELL_ARGS: + case ESM_SHELL_ARGS: { + const shell = decodeShellArgs(variantBytes); return { kind: "exec_shell", execMsgId, execId, - command: decodeStringField(variantBytes, ARG_SHELL_COMMAND), - workingDir: decodeStringField(variantBytes, ARG_SHELL_WORKING_DIR), + ...shell, }; - case ESM_SHELL_STREAM_ARGS: + } + case ESM_SHELL_STREAM_ARGS: { + const shell = decodeShellArgs(variantBytes); return { kind: "exec_shell_stream", execMsgId, execId, - command: decodeStringField(variantBytes, ARG_SHELL_COMMAND), - workingDir: decodeStringField(variantBytes, ARG_SHELL_WORKING_DIR), + ...shell, }; - case ESM_BACKGROUND_SHELL_SPAWN: + } + case ESM_BACKGROUND_SHELL_SPAWN: { + const shell = decodeShellArgs(variantBytes); return { kind: "exec_bg_shell", execMsgId, execId, - command: decodeStringField(variantBytes, ARG_SHELL_COMMAND), - workingDir: decodeStringField(variantBytes, ARG_SHELL_WORKING_DIR), + ...shell, }; + } case ESM_FETCH_ARGS: return { kind: "exec_fetch", diff --git a/tests/unit/cursor-agent-exec-router.test.ts b/tests/unit/cursor-agent-exec-router.test.ts index 2469a24b4a..e3d22b8722 100644 --- a/tests/unit/cursor-agent-exec-router.test.ts +++ b/tests/unit/cursor-agent-exec-router.test.ts @@ -125,11 +125,20 @@ test("decodeExecServerEvent recognizes shell_args (field 2) with command + worki execId: "exec-s", command: "ls -la", workingDir: "/tmp", + timeout: 0, + isBackground: false, + hardTimeout: 0, }); }); -test("decodeExecServerEvent recognizes shell_stream_args (field 14)", () => { - const variant = Buffer.concat([stringField(1, "tail -f x"), stringField(2, "/var/log")]); +test("decodeExecServerEvent preserves shell_stream timeout and background semantics", () => { + const variant = Buffer.concat([ + stringField(1, "tail -f x"), + stringField(2, "/var/log"), + varintField(3, 5_000), + varintField(11, 1), + varintField(14, 7_000), + ]); const esm = buildExecServerMessage(9, "exec-ss", 14, variant); const event = decodeExecServerEvent(buildAgentServerMessage(esm)); assert.deepEqual(event, { @@ -138,6 +147,9 @@ test("decodeExecServerEvent recognizes shell_stream_args (field 14)", () => { execId: "exec-ss", command: "tail -f x", workingDir: "/var/log", + timeout: 5_000, + isBackground: true, + hardTimeout: 7_000, }); }); @@ -151,6 +163,9 @@ test("decodeExecServerEvent recognizes background_shell_spawn (field 16)", () => execId: "exec-bg", command: "node server", workingDir: "/app", + timeout: 0, + isBackground: false, + hardTimeout: 0, }); }); diff --git a/tests/unit/cursor-agent-tool-calls.test.ts b/tests/unit/cursor-agent-tool-calls.test.ts index a91d3df3c3..abfbcff163 100644 --- a/tests/unit/cursor-agent-tool-calls.test.ts +++ b/tests/unit/cursor-agent-tool-calls.test.ts @@ -4,8 +4,14 @@ import { decodeExecServerEvent, decodeProtobufValue, jsonSchemaToProtobufValue, + openAIToolsToMcpDefs, } from "../../open-sse/utils/cursorAgentProtobuf"; -import { newStreamCtx, processFrame, CursorExecutor } from "../../open-sse/executors/cursor"; +import { + newStreamCtx, + processFrame, + CursorExecutor, + inferCursorClientPlatform, +} from "../../open-sse/executors/cursor"; // ─── Wire-format helpers ─────────────────────────────────────────────────── @@ -62,6 +68,22 @@ function buildMcpArgsEvent( return lenPrefixed(2, esm); } +// AgentServerMessage { exec_server_message (2): ESM { shell_stream_args (14) } } +function buildShellStreamEvent( + execMsgId: number, + execId: string, + command: string, + workingDir: string +): Buffer { + const shellArgs = Buffer.concat([stringField(1, command), stringField(2, workingDir)]); + const esm = Buffer.concat([ + varintField(1, execMsgId), + stringField(15, execId), + lenPrefixed(14, shellArgs), + ]); + return lenPrefixed(2, esm); +} + // ─── decodeProtobufValue round-trip tests ────────────────────────────────── test("decodeProtobufValue round-trips primitives", () => { @@ -235,6 +257,101 @@ test("processFrame doesn't emit tool_calls for the same exec_id twice", () => { assert.equal(ctx.toolCalls.length, 1); }); +test("processFrame bridges Cursor shell_stream to an external tool call and cold resume", () => { + const emitted: string[] = []; + const writes: Buffer[] = []; + const ctx = newStreamCtx("claude-fable-5-thinking-xhigh", (s) => emitted.push(s)); + const mcpTools = openAIToolsToMcpDefs([ + { + type: "function", + function: { + name: "pty_spawn", + description: "Spawn a PTY", + parameters: { + type: "object", + properties: { + command: { type: "string" }, + args: { type: "array", items: { type: "string" } }, + workdir: { type: "string" }, + description: { type: "string" }, + notifyOnExit: { type: "boolean" }, + }, + required: ["command", "args", "description"], + }, + }, + }, + ]); + const h2Req = { + write(data: Buffer) { + writes.push(Buffer.from(data)); + return true; + }, + } as unknown as import("http2").ClientHttp2Stream; + + processFrame( + buildShellStreamEvent(1, "exec-shell", "mktemp -d /tmp/probe-XXXXXX", "/tmp"), + ctx, + new Set(), + { h2Req, mcpTools, clientPlatform: "posix" } + ); + + assert.equal(ctx.endReason, "tool_calls"); + assert.equal(ctx.requiresColdResume, true); + assert.equal(ctx.pendingToolCalls.size, 0, "bridged calls must not attempt ExecMcpResult resume"); + assert.equal(ctx.toolCalls.length, 1); + assert.equal(ctx.toolCalls[0].name, "pty_spawn"); + assert.deepEqual(JSON.parse(ctx.toolCalls[0].argumentsJson), { + command: "/bin/sh", + args: ["-lc", "mktemp -d /tmp/probe-XXXXXX"], + workdir: "/tmp", + description: "Run Cursor-requested shell command", + notifyOnExit: true, + }); + assert.equal(emitted.length, 3, "role + tool init + tool args chunks"); + assert.equal(writes.length, 1, "native Cursor shell request must receive a typed rejection"); + assert.ok(writes[0].includes(Buffer.from("Tool not available in this environment", "utf8"))); +}); + +test("infers the client platform from explicit environment metadata, not command text", () => { + assert.equal( + inferCursorClientPlatform([{ role: "system", content: "Client platform: win32" }]), + "windows" + ); + assert.equal( + inferCursorClientPlatform([{ role: "system", content: "Client platform: linux" }]), + "posix" + ); + assert.equal( + inferCursorClientPlatform([{ role: "user", content: "Run echo C:\\temp" }]), + undefined, + "user command text must not influence platform selection" + ); + assert.equal( + inferCursorClientPlatform([ + { + role: "system", + content: "Build software for Windows and Linux. Client platform: linux", + }, + ]), + "posix", + "unlabelled platform words must not override explicit metadata" + ); + assert.equal( + inferCursorClientPlatform([ + { role: "system", content: "Document Windows paths, but execute commands carefully." }, + ]), + undefined + ); + assert.equal( + inferCursorClientPlatform([ + { role: "system", content: "Client platform: windows\nPlatform: linux" }, + ]), + undefined, + "conflicting metadata must fail closed" + ); + assert.equal(inferCursorClientPlatform([{ role: "user", content: "Run echo hello" }]), undefined); +}); + // ─── Tool-commit directive (ported from composer-api) ────────────────────── // // transformRequest() returns the encoded Connect-RPC body; the UserMessage.text @@ -287,7 +404,10 @@ test("transformRequest honors CURSOR_TOOL_DIRECTIVE=0 opt-out", () => { {} ) as Uint8Array; const text = Buffer.from(body).toString("utf8"); - assert.ok(!text.includes("you MUST issue the actual tool call"), "directive suppressed by opt-out"); + assert.ok( + !text.includes("you MUST issue the actual tool call"), + "directive suppressed by opt-out" + ); } finally { if (prev === undefined) delete process.env.CURSOR_TOOL_DIRECTIVE; else process.env.CURSOR_TOOL_DIRECTIVE = prev; @@ -308,7 +428,10 @@ test("tool_choice:'none' drops tools and the directive", () => { tools: [weatherTool], tool_choice: "none", }); - assert.ok(!text.includes("you MUST issue the actual tool call"), "no directive when tool_choice none"); + assert.ok( + !text.includes("you MUST issue the actual tool call"), + "no directive when tool_choice none" + ); assert.ok(!text.includes("web_search"), "tool not advertised when tool_choice none"); }); diff --git a/tests/unit/cursor-builtin-tool-bridge.test.ts b/tests/unit/cursor-builtin-tool-bridge.test.ts new file mode 100644 index 0000000000..be8384ff83 --- /dev/null +++ b/tests/unit/cursor-builtin-tool-bridge.test.ts @@ -0,0 +1,338 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + bridgeCursorBuiltinTool, + selectCursorBridgeTools, +} from "../../open-sse/executors/cursor/builtinToolBridge.ts"; +import { + openAIToolsToMcpDefs, + type ExecServerEvent, + type OpenAITool, +} from "../../open-sse/utils/cursorAgentProtobuf.ts"; + +function defs(tools: OpenAITool[]) { + return openAIToolsToMcpDefs(tools); +} + +const ptySpawn: OpenAITool = { + type: "function", + function: { + name: "pty_spawn", + description: "Spawn a PTY", + parameters: { + type: "object", + properties: { + command: { type: "string" }, + args: { type: "array", items: { type: "string" } }, + workdir: { type: "string" }, + description: { type: "string" }, + notifyOnExit: { type: "boolean" }, + }, + required: ["command", "args", "description"], + additionalProperties: false, + }, + }, +}; + +const readTool: OpenAITool = { + type: "function", + function: { + name: "read", + description: "Read a file", + parameters: { + type: "object", + properties: { + filePath: { type: "string" }, + offset: { type: "number" }, + limit: { type: "number" }, + }, + required: ["filePath"], + additionalProperties: false, + }, + }, +}; + +function shellEvent(overrides: Partial = {}): ExecServerEvent { + return { + kind: "exec_shell_stream", + execMsgId: 1, + execId: "exec-shell", + command: "mktemp -d /tmp/file-tools-test-XXXXXX", + workingDir: "/tmp", + timeout: 0, + isBackground: false, + hardTimeout: 0, + ...overrides, + } as ExecServerEvent; +} + +function bashTool( + parameters: Record = { + type: "object", + properties: { command: { type: "string" }, workdir: { type: "string" } }, + required: ["command"], + additionalProperties: false, + } +): OpenAITool { + return { type: "function", function: { name: "bash", parameters } }; +} + +test("bridges Cursor shell_stream to pty_spawn with an explicit POSIX platform", () => { + const result = bridgeCursorBuiltinTool(shellEvent(), defs([ptySpawn]), "posix"); + assert.deepEqual(result, { + toolName: "pty_spawn", + arguments: { + command: "/bin/sh", + args: ["-lc", "mktemp -d /tmp/file-tools-test-XXXXXX"], + workdir: "/tmp", + description: "Run Cursor-requested shell command", + notifyOnExit: true, + }, + }); +}); + +test("restricts bridge candidates according to tool_choice", () => { + const tools = defs([ptySpawn, readTool]); + assert.equal(selectCursorBridgeTools(tools, "none"), undefined); + assert.deepEqual( + selectCursorBridgeTools(tools, { + type: "function", + function: { name: "read" }, + })?.map((tool) => tool.name), + ["read"] + ); + assert.deepEqual( + selectCursorBridgeTools(tools, "auto")?.map((tool) => tool.name), + ["pty_spawn", "read"] + ); +}); + +test("bridges Windows Cursor shell requests using the explicit client platform", () => { + const command = "New-Item -ItemType Directory -Path $env:TEMP\\cursor-probe"; + const result = bridgeCursorBuiltinTool( + shellEvent({ command, workingDir: "C:\\Users\\max\\project" }), + defs([ptySpawn]), + "windows" + ); + assert.deepEqual(result, { + toolName: "pty_spawn", + arguments: { + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + workdir: "C:\\Users\\max\\project", + description: "Run Cursor-requested shell command", + notifyOnExit: true, + }, + }); +}); + +test("does not infer the interpreter from command text", () => { + const result = bridgeCursorBuiltinTool( + shellEvent({ command: "echo C:\\temp", workingDir: "/tmp" }), + defs([ptySpawn]), + "posix" + ); + assert.equal(result?.arguments.command, "/bin/sh"); +}); + +test("fails closed for pty_spawn when client platform is unknown", () => { + assert.equal(bridgeCursorBuiltinTool(shellEvent(), defs([ptySpawn])), null); +}); + +test("prefers a synchronous bash-compatible tool for foreground shell requests", () => { + const result = bridgeCursorBuiltinTool(shellEvent(), defs([ptySpawn, bashTool()])); + assert.deepEqual(result, { + toolName: "bash", + arguments: { + command: "mktemp -d /tmp/file-tools-test-XXXXXX", + workdir: "/tmp", + }, + }); +}); + +test("uses a required compatible alias instead of the first optional alias", () => { + const tool = bashTool({ + type: "object", + properties: { command: { type: "string" }, cmd: { type: "string" } }, + required: ["cmd"], + }); + assert.deepEqual(bridgeCursorBuiltinTool(shellEvent(), defs([tool])), { + toolName: "bash", + arguments: { cmd: "mktemp -d /tmp/file-tools-test-XXXXXX" }, + }); +}); + +test("background shell requests never downgrade to a synchronous bash tool", () => { + const event = shellEvent({ kind: "exec_bg_shell", command: "node server.js" }); + const result = bridgeCursorBuiltinTool(event, defs([bashTool(), ptySpawn]), "posix"); + assert.equal(result?.toolName, "pty_spawn"); +}); + +test("background-marked shell_stream requests never use a synchronous shell tool", () => { + const event = shellEvent({ isBackground: true, command: "node server.js" }); + const result = bridgeCursorBuiltinTool(event, defs([bashTool(), ptySpawn]), "posix"); + assert.equal(result?.toolName, "pty_spawn"); +}); + +test("fails closed rather than dropping Cursor timeout semantics", () => { + assert.equal( + bridgeCursorBuiltinTool(shellEvent({ timeout: 5_000 }), defs([bashTool(), ptySpawn]), "posix"), + null + ); + assert.equal( + bridgeCursorBuiltinTool( + shellEvent({ hardTimeout: 7_000 }), + defs([bashTool(), ptySpawn]), + "posix" + ), + null + ); +}); + +test("bridges exec_read to a schema-compatible read tool", () => { + const event: ExecServerEvent = { + kind: "exec_read", + execMsgId: 2, + execId: "read", + path: "/tmp/test.txt", + }; + assert.deepEqual(bridgeCursorBuiltinTool(event, defs([readTool])), { + toolName: "read", + arguments: { filePath: "/tmp/test.txt" }, + }); +}); + +test("rejects type-incompatible and constrained schemas", () => { + const wrongCommand = bashTool({ + type: "object", + properties: { command: { type: "number" } }, + required: ["command"], + }); + const patternedCommand = bashTool({ + type: "object", + properties: { command: { type: "string", pattern: "^safe$" } }, + required: ["command"], + }); + const dependentCommand = bashTool({ + type: "object", + properties: { command: { type: "string" }, confirmation: { type: "string" } }, + required: ["command"], + dependentRequired: { command: ["confirmation"] }, + }); + const constrainedRead: OpenAITool = { + ...readTool, + function: { + ...readTool.function, + parameters: { + type: "object", + properties: { filePath: { type: "string", minLength: 100 } }, + required: ["filePath"], + }, + }, + }; + const wrongPtyArgs: OpenAITool = { + ...ptySpawn, + function: { + ...ptySpawn.function, + parameters: { + type: "object", + properties: { + command: { type: "string" }, + args: { type: "array", items: { type: "string" }, minItems: 8 }, + description: { type: "string" }, + }, + required: ["command", "args", "description"], + }, + }, + }; + const readEvent: ExecServerEvent = { + kind: "exec_read", + execMsgId: 2, + execId: "read-constrained", + path: "/x", + }; + assert.equal(bridgeCursorBuiltinTool(shellEvent(), defs([wrongCommand])), null); + assert.equal(bridgeCursorBuiltinTool(shellEvent(), defs([patternedCommand])), null); + assert.equal(bridgeCursorBuiltinTool(shellEvent(), defs([dependentCommand])), null); + assert.equal(bridgeCursorBuiltinTool(readEvent, defs([constrainedRead])), null); + assert.equal(bridgeCursorBuiltinTool(shellEvent(), defs([wrongPtyArgs]), "posix"), null); +}); + +test("tries later aliases when an earlier name has an incompatible schema", () => { + const incompatibleBash = bashTool({ + type: "object", + properties: { command: { type: "number" } }, + required: ["command"], + }); + const compatibleShell: OpenAITool = { + type: "function", + function: { + name: "shell", + parameters: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }, + }, + }; + assert.equal( + bridgeCursorBuiltinTool(shellEvent(), defs([incompatibleBash, compatibleShell]))?.toolName, + "shell" + ); + + const incompatibleRead: OpenAITool = { + ...readTool, + function: { + ...readTool.function, + parameters: { + type: "object", + properties: { filePath: { type: "number" } }, + required: ["filePath"], + }, + }, + }; + const compatibleReadFile: OpenAITool = { + type: "function", + function: { + name: "read_file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }; + const readEvent: ExecServerEvent = { + kind: "exec_read", + execMsgId: 3, + execId: "read-alias", + path: "/tmp/a", + }; + assert.equal( + bridgeCursorBuiltinTool(readEvent, defs([incompatibleRead, compatibleReadFile]))?.toolName, + "read_file" + ); +}); + +test("fails closed when no declared tool matches the built-in event", () => { + const incompatible: OpenAITool = { + type: "function", + function: { + name: "execute", + parameters: { + type: "object", + properties: { code: { type: "string" } }, + required: ["code"], + }, + }, + }; + assert.equal(bridgeCursorBuiltinTool(shellEvent(), defs([incompatible]), "posix"), null); + assert.equal( + bridgeCursorBuiltinTool( + { kind: "exec_write", execMsgId: 2, execId: "write", path: "/tmp/a" }, + defs([readTool]), + "posix" + ), + null + ); +});