1be025bb72
Cursor exec bridges derived failure state only from thrown exceptions, so structured AgentToolResult.isError failures were emitted as successes. Propagate the returned flag through standard and streaming shell execution, with regression coverage for both paths.
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import * as fs from "node:fs";
|
|
import type {
|
|
AgentEvent,
|
|
AgentTool,
|
|
AgentToolContext,
|
|
AgentToolResult,
|
|
AgentToolUpdateCallback,
|
|
} from "@oh-my-pi/pi-agent-core";
|
|
import type {
|
|
CursorMcpCall,
|
|
CursorShellStreamCallbacks,
|
|
CursorExecHandlers as ICursorExecHandlers,
|
|
ToolResultMessage,
|
|
} from "@oh-my-pi/pi-ai";
|
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
import { resolveToCwd } from "./tools/path-utils";
|
|
|
|
interface CursorExecBridgeOptions {
|
|
cwd: string;
|
|
tools: Map<string, AgentTool>;
|
|
getToolContext?: () => AgentToolContext | undefined;
|
|
emitEvent?: (event: AgentEvent) => void;
|
|
}
|
|
|
|
function createToolResultMessage(
|
|
toolCallId: string,
|
|
toolName: string,
|
|
result: AgentToolResult<unknown>,
|
|
isError: boolean,
|
|
): ToolResultMessage {
|
|
return {
|
|
role: "toolResult",
|
|
toolCallId,
|
|
toolName,
|
|
content: result.content,
|
|
details: result.details,
|
|
isError,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
function buildToolErrorResult(message: string): AgentToolResult<unknown> {
|
|
return {
|
|
content: [{ type: "text", text: message }],
|
|
details: {},
|
|
};
|
|
}
|
|
|
|
async function executeTool(
|
|
options: CursorExecBridgeOptions,
|
|
toolName: string,
|
|
toolCallId: string,
|
|
args: Record<string, unknown>,
|
|
): Promise<ToolResultMessage> {
|
|
const tool = options.tools.get(toolName);
|
|
if (!tool) {
|
|
const result = buildToolErrorResult(`Tool "${toolName}" not available`);
|
|
return createToolResultMessage(toolCallId, toolName, result, true);
|
|
}
|
|
|
|
options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args });
|
|
|
|
let result: AgentToolResult<unknown>;
|
|
let isError = false;
|
|
|
|
const onUpdate: AgentToolUpdateCallback<unknown> | undefined = options.emitEvent
|
|
? partialResult => {
|
|
const sanitizedResult: AgentToolResult<unknown> = {
|
|
content: partialResult.content.map(c => (c.type === "text" ? { ...c, text: sanitizeText(c.text) } : c)),
|
|
details: partialResult.details,
|
|
};
|
|
options.emitEvent?.({
|
|
type: "tool_execution_update",
|
|
toolCallId,
|
|
toolName,
|
|
args,
|
|
partialResult: sanitizedResult,
|
|
});
|
|
}
|
|
: undefined;
|
|
|
|
try {
|
|
result = await tool.execute(
|
|
toolCallId,
|
|
args as Record<string, unknown>,
|
|
undefined,
|
|
onUpdate,
|
|
options.getToolContext?.(),
|
|
);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
result = buildToolErrorResult(message);
|
|
isError = true;
|
|
}
|
|
isError ||= result.isError === true;
|
|
|
|
const sanitizedFinalResult: AgentToolResult<unknown> = {
|
|
content: result.content.map(c => (c.type === "text" ? { ...c, text: sanitizeText(c.text) } : c)),
|
|
details: result.details,
|
|
};
|
|
options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result: sanitizedFinalResult, isError });
|
|
|
|
return createToolResultMessage(toolCallId, toolName, result, isError);
|
|
}
|
|
|
|
async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, toolCallId: string) {
|
|
const toolName = "delete";
|
|
options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: { path: pathArg } });
|
|
|
|
const absolutePath = resolveToCwd(pathArg, options.cwd);
|
|
let isError = false;
|
|
let result: AgentToolResult<unknown>;
|
|
|
|
try {
|
|
let fileStat: fs.Stats | undefined;
|
|
try {
|
|
fileStat = fs.statSync(absolutePath);
|
|
} catch {
|
|
throw new Error(`File not found: ${pathArg}`);
|
|
}
|
|
if (!fileStat.isFile()) {
|
|
throw new Error(`Path is not a file: ${pathArg}`);
|
|
}
|
|
|
|
fs.rmSync(absolutePath);
|
|
|
|
const sizeText = fileStat.size ? ` (${fileStat.size} bytes)` : "";
|
|
const message = `Deleted ${pathArg}${sizeText}`;
|
|
result = { content: [{ type: "text", text: message }], details: {} };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
result = buildToolErrorResult(message);
|
|
isError = true;
|
|
}
|
|
|
|
options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError });
|
|
return createToolResultMessage(toolCallId, toolName, result, isError);
|
|
}
|
|
|
|
function decodeToolCallId(toolCallId?: string): string {
|
|
return toolCallId && toolCallId.length > 0 ? toolCallId : randomUUID();
|
|
}
|
|
|
|
function decodeMcpArgs(rawArgs: Record<string, Uint8Array>): Record<string, unknown> {
|
|
const decoded: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(rawArgs)) {
|
|
const text = new TextDecoder().decode(value);
|
|
try {
|
|
decoded[key] = JSON.parse(text);
|
|
} catch {
|
|
decoded[key] = text;
|
|
}
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
function formatMcpToolErrorMessage(toolName: string, availableTools: string[]): string {
|
|
const list = availableTools.length > 0 ? availableTools.join(", ") : "none";
|
|
return `MCP tool "${toolName}" not found. Available tools: ${list}`;
|
|
}
|
|
|
|
export class CursorExecHandlers implements ICursorExecHandlers {
|
|
constructor(private options: CursorExecBridgeOptions) {}
|
|
|
|
async read(args: Parameters<NonNullable<ICursorExecHandlers["read"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const toolResultMessage = await executeTool(this.options, "read", toolCallId, { path: args.path });
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async ls(args: Parameters<NonNullable<ICursorExecHandlers["ls"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
// Redirect ls to read tool, which handles directories
|
|
const toolResultMessage = await executeTool(this.options, "read", toolCallId, { path: args.path });
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async grep(args: Parameters<NonNullable<ICursorExecHandlers["grep"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
|
|
const toolResultMessage = await executeTool(this.options, "grep", toolCallId, {
|
|
pattern: args.pattern,
|
|
path: searchPath,
|
|
case: args.caseInsensitive === true ? false : undefined,
|
|
});
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async write(args: Parameters<NonNullable<ICursorExecHandlers["write"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const content = args.fileText ?? new TextDecoder().decode(args.fileBytes ?? new Uint8Array());
|
|
const toolResultMessage = await executeTool(this.options, "write", toolCallId, {
|
|
path: args.path,
|
|
content,
|
|
});
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async delete(args: Parameters<NonNullable<ICursorExecHandlers["delete"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const toolResultMessage = await executeDelete(this.options, args.path, toolCallId);
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async shell(args: Parameters<NonNullable<ICursorExecHandlers["shell"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const timeoutSeconds = args.timeout && args.timeout > 0 ? args.timeout : undefined;
|
|
const toolResultMessage = await executeTool(this.options, "bash", toolCallId, {
|
|
command: args.command,
|
|
cwd: args.workingDirectory || undefined,
|
|
timeout: timeoutSeconds,
|
|
});
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async shellStream(
|
|
args: Parameters<NonNullable<ICursorExecHandlers["shellStream"]>>[0],
|
|
callbacks: CursorShellStreamCallbacks,
|
|
) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const toolName = "bash";
|
|
const tool = this.options.tools.get(toolName);
|
|
if (!tool) {
|
|
const result = buildToolErrorResult(`Tool "${toolName}" not available`);
|
|
return createToolResultMessage(toolCallId, toolName, result, true);
|
|
}
|
|
|
|
const timeoutSeconds = args.timeout && args.timeout > 0 ? args.timeout : undefined;
|
|
const toolArgs: Record<string, unknown> = {
|
|
command: args.command,
|
|
cwd: args.workingDirectory || undefined,
|
|
timeout: timeoutSeconds,
|
|
};
|
|
|
|
this.options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: toolArgs });
|
|
|
|
let result: AgentToolResult<unknown>;
|
|
let isError = false;
|
|
|
|
let rawText = "";
|
|
let sanitizedRawText = "";
|
|
let streamedSanitizedText = "";
|
|
let canStreamSanitizedDelta = true;
|
|
const onUpdate: AgentToolUpdateCallback<unknown> = partialResult => {
|
|
const newRawText = partialResult.content.map(c => (c.type === "text" ? c.text : "")).join("");
|
|
if (newRawText === rawText) {
|
|
return;
|
|
}
|
|
rawText = newRawText;
|
|
sanitizedRawText = sanitizeText(newRawText);
|
|
const sanitizedPartialResult: AgentToolResult<unknown> = {
|
|
content: [{ type: "text" as const, text: sanitizedRawText }],
|
|
details: partialResult.details,
|
|
};
|
|
this.options.emitEvent?.({
|
|
type: "tool_execution_update",
|
|
toolCallId,
|
|
toolName,
|
|
args: toolArgs,
|
|
partialResult: sanitizedPartialResult,
|
|
});
|
|
if (!canStreamSanitizedDelta) {
|
|
return;
|
|
}
|
|
if (sanitizedRawText.startsWith(streamedSanitizedText)) {
|
|
const sanitizedDelta = sanitizedRawText.slice(streamedSanitizedText.length);
|
|
streamedSanitizedText = sanitizedRawText;
|
|
if (sanitizedDelta) {
|
|
callbacks.onStdout(sanitizedDelta);
|
|
}
|
|
return;
|
|
}
|
|
// Cursor's shell-stream callback is append-only. Once the sanitized snapshot
|
|
// stops being a prefix extension, we can no longer repair the stream safely.
|
|
// Keep emitting full snapshots via tool_execution_update, but stop stdout deltas.
|
|
canStreamSanitizedDelta = false;
|
|
};
|
|
|
|
try {
|
|
result = await tool.execute(toolCallId, toolArgs, undefined, onUpdate, this.options.getToolContext?.());
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
result = buildToolErrorResult(message);
|
|
isError = true;
|
|
}
|
|
isError ||= result.isError === true;
|
|
|
|
// onUpdate may not fire for every chunk — flush any remaining output
|
|
// from the final result that wasn't already streamed.
|
|
const finalRawText = result.content.map(c => (c.type === "text" ? c.text : "")).join("");
|
|
if (finalRawText !== rawText) {
|
|
rawText = finalRawText;
|
|
sanitizedRawText = sanitizeText(finalRawText);
|
|
}
|
|
if (canStreamSanitizedDelta && sanitizedRawText.startsWith(streamedSanitizedText)) {
|
|
const finalDelta = sanitizedRawText.slice(streamedSanitizedText.length);
|
|
streamedSanitizedText = sanitizedRawText;
|
|
if (finalDelta) {
|
|
callbacks.onStdout(finalDelta);
|
|
}
|
|
}
|
|
|
|
const sanitizedFinalResult: AgentToolResult<unknown> = {
|
|
content: result.content.map(c => (c.type === "text" ? { ...c, text: sanitizeText(c.text) } : c)),
|
|
details: result.details,
|
|
};
|
|
this.options.emitEvent?.({
|
|
type: "tool_execution_end",
|
|
toolCallId,
|
|
toolName,
|
|
result: sanitizedFinalResult,
|
|
isError,
|
|
});
|
|
return createToolResultMessage(toolCallId, toolName, result, isError);
|
|
}
|
|
|
|
async diagnostics(args: Parameters<NonNullable<ICursorExecHandlers["diagnostics"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const toolResultMessage = await executeTool(this.options, "lsp", toolCallId, {
|
|
action: "diagnostics",
|
|
file: args.path,
|
|
});
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async mcp(call: CursorMcpCall) {
|
|
const toolName = call.toolName || call.name;
|
|
const toolCallId = decodeToolCallId(call.toolCallId);
|
|
const tool = this.options.tools.get(toolName);
|
|
if (!tool) {
|
|
const availableTools = Array.from(this.options.tools.keys()).filter(name => name.startsWith("mcp__"));
|
|
const message = formatMcpToolErrorMessage(toolName, availableTools);
|
|
const result = buildToolErrorResult(message);
|
|
return createToolResultMessage(toolCallId, toolName, result, true);
|
|
}
|
|
|
|
const args = Object.keys(call.args ?? {}).length > 0 ? call.args : decodeMcpArgs(call.rawArgs ?? {});
|
|
const toolResultMessage = await executeTool(this.options, toolName, toolCallId, args);
|
|
return toolResultMessage;
|
|
}
|
|
}
|