fix(cursor): bridge native tools to client calls (#8171)

Co-authored-by: Makcim Ivanov <10184529+makcimbx@users.noreply.github.com>
This commit is contained in:
Makcim Ivanov
2026-07-22 23:38:10 +03:00
committed by GitHub
parent e31c4d31e2
commit ffb37f99a5
6 changed files with 882 additions and 51 deletions

View File

@@ -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<string, { execMsgId: number; execId: string; toolName: string }>;
// 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 `</think>`) 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<CursorClientPlatform>();
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, unknown>
): 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<string, Buffer>;
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<string, Buffer> | undefined,
clientPlatform: CursorClientPlatform | undefined,
signal?: AbortSignal
): Promise<void> {
const ackedExecIds = new Set<string>();
@@ -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);

View File

@@ -0,0 +1,257 @@
import {
decodeProtobufValue,
type ExecServerEvent,
type McpToolDefinition,
} from "../../utils/cursorAgentProtobuf.ts";
export type CursorBuiltinToolBridge = {
toolName: string;
arguments: Record<string, unknown>;
};
export type CursorClientPlatform = "windows" | "posix";
type OpenAIToolChoice =
string | { type?: unknown; function?: { name?: unknown } } | null | undefined;
type JsonSchema = {
type?: unknown;
properties?: Record<string, unknown>;
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<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function hasOnlyKeys(value: Record<string, unknown>, allowed: Set<string>): 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<string, unknown> {
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<string, unknown>): 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<string, unknown>,
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<ExecServerEvent, { kind: "exec_shell" | "exec_shell_stream" }>,
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<string, unknown> = { [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<ExecServerEvent, { kind: "exec_shell" | "exec_shell_stream" | "exec_bg_shell" }>,
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<string, unknown> = {
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<ExecServerEvent, { kind: "exec_read" }>,
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<string, unknown> = { [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);
}

View File

@@ -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<string, unknown>;
};
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",

View File

@@ -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,
});
});

View File

@@ -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");
});

View File

@@ -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> = {}): 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<string, unknown> = {
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
);
});