mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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);
|
||||
|
||||
257
open-sse/executors/cursor/builtinToolBridge.ts
Normal file
257
open-sse/executors/cursor/builtinToolBridge.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
338
tests/unit/cursor-builtin-tool-bridge.test.ts
Normal file
338
tests/unit/cursor-builtin-tool-bridge.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user