Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- run: bun install

- run: bun run build

- name: Run tests
run: |
bun test \
tests/tools/defaults.test.ts \
tests/tools/executor-chain.test.ts \
tests/tools/sdk-executor.test.ts \
tests/tools/mcp-executor.test.ts \
tests/tools/skills.test.ts \
tests/tools/registry.test.ts \
tests/integration/comprehensive.test.ts \
tests/integration/tools-router.integration.test.ts \
tests/unit/proxy/prompt-builder.test.ts \
tests/unit/plugin.test.ts \
tests/unit/plugin-tools-hook.test.ts \
tests/unit/plugin-config.test.ts \
tests/unit/auth.test.ts \
tests/unit/streaming/line-buffer.test.ts \
tests/unit/streaming/parser.test.ts \
tests/unit/streaming/types.test.ts \
tests/unit/streaming/delta-tracker.test.ts \
tests/competitive/edge.test.ts
68 changes: 14 additions & 54 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { parseStreamJsonLine } from "./streaming/parser.js";
import { extractText, extractThinking, isAssistantText, isThinking } from "./streaming/types.js";
import { createLogger } from "./utils/logger";
import { parseAgentError, formatErrorForUser, stripAnsi } from "./utils/errors";
import { buildPromptFromMessages } from "./proxy/prompt-builder.js";
import { OpenCodeToolDiscovery } from "./tools/discovery.js";
import { toOpenAiParameters, describeTool } from "./tools/schema.js";
import { ToolRouter } from "./tools/router.js";
Expand Down Expand Up @@ -49,7 +50,7 @@ function getGlobalKey(): string {

const FORCE_TOOL_MODE = process.env.CURSOR_ACP_FORCE !== "false";
const EMIT_TOOL_UPDATES = process.env.CURSOR_ACP_EMIT_TOOL_UPDATES === "true";
const FORWARD_TOOL_CALLS = process.env.CURSOR_ACP_FORWARD_TOOL_CALLS === "true";
const FORWARD_TOOL_CALLS = process.env.CURSOR_ACP_FORWARD_TOOL_CALLS !== "false";

function createChatCompletionResponse(model: string, content: string, reasoningContent?: string) {
const message: { role: "assistant"; content: string; reasoning_content?: string } = {
Expand Down Expand Up @@ -206,29 +207,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
const stream = body?.stream === true;
const tools = Array.isArray(body?.tools) ? body.tools : [];

// Convert messages to prompt
const lines: string[] = [];
for (const message of messages) {
const role = typeof message.role === "string" ? message.role : "user";
const content = message.content;

if (typeof content === "string") {
lines.push(`${role.toUpperCase()}: ${content}`);
} else if (Array.isArray(content)) {
const textParts = content
.map((part: any) => {
if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") {
return part.text;
}
return "";
})
.filter(Boolean);
if (textParts.length) {
lines.push(`${role.toUpperCase()}: ${textParts.join("\n")}`);
}
}
}
const prompt = lines.join("\n\n");
const prompt = buildPromptFromMessages(messages, tools);
const model = typeof body?.model === "string" ? body.model : "auto";

const bunAny = globalThis as any;
Expand Down Expand Up @@ -339,7 +318,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
}

// Handle OpenCode tools
if (toolRouter && forwardToolCalls) {
if (toolRouter && FORWARD_TOOL_CALLS) {
const toolResult = await toolRouter.handleToolCall(event as any, { id, created, model });
if (toolResult) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(toolResult)}\n\n`));
Expand Down Expand Up @@ -487,30 +466,9 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
const bodyData: any = JSON.parse(body || "{}");
const messages: Array<any> = Array.isArray(bodyData?.messages) ? bodyData.messages : [];
const stream = bodyData?.stream === true;
const tools = Array.isArray(bodyData?.tools) ? bodyData.tools : [];

// Convert messages to prompt
const lines: string[] = [];
for (const message of messages) {
const role = typeof message.role === "string" ? message.role : "user";
const content = message.content;

if (typeof content === "string") {
lines.push(`${role.toUpperCase()}: ${content}`);
} else if (Array.isArray(content)) {
const textParts = content
.map((part: any) => {
if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") {
return part.text;
}
return "";
})
.filter(Boolean);
if (textParts.length) {
lines.push(`${role.toUpperCase()}: ${textParts.join("\n")}`);
}
}
}
const prompt = lines.join("\n\n");
const prompt = buildPromptFromMessages(messages, tools);
const model = typeof bodyData?.model === "string" ? bodyData.model : "auto";

const cmd = [
Expand Down Expand Up @@ -600,7 +558,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
}
}

if (toolRouter && forwardToolCalls) {
if (toolRouter && FORWARD_TOOL_CALLS) {
const toolResult = await toolRouter.handleToolCall(event as any, { id, created, model });
if (toolResult) {
res.write(`data: ${JSON.stringify(toolResult)}\n\n`);
Expand Down Expand Up @@ -636,7 +594,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
}
}

if (toolRouter && forwardToolCalls) {
if (toolRouter && FORWARD_TOOL_CALLS) {
const toolResult = await toolRouter.handleToolCall(event as any, { id, created, model });
if (toolResult) {
res.write(`data: ${JSON.stringify(toolResult)}\n\n`);
Expand Down Expand Up @@ -838,7 +796,7 @@ export const CursorPlugin: Plugin = async ({ $, directory, client, serverUrl }:

// Tools (skills) discovery/execution wiring
const toolsEnabled = process.env.CURSOR_ACP_ENABLE_OPENCODE_TOOLS !== "false"; // default ON
const forwardToolCalls = process.env.CURSOR_ACP_FORWARD_TOOL_CALLS !== "false"; // default ON
// forwardToolCalls uses the module-level FORWARD_TOOL_CALLS constant (line 53)
// Build a client with serverUrl so SDK tool.list works even if the injected client isn't fully configured.
const serverClient = toolsEnabled
? createOpencodeClient({ serverUrl: serverUrl.toString(), directory })
Expand Down Expand Up @@ -882,10 +840,12 @@ export const CursorPlugin: Plugin = async ({ $, directory, client, serverUrl }:
const skills = skillLoader.load(list);
skillResolver = new SkillResolver(skills);

// Populate MCP executor with discovered SDK tool IDs
// Populate executors with their respective tool IDs
if (sdkExec) {
sdkExec.setToolIds(list.filter((t) => t.source === "sdk").map((t) => t.id));
}
if (mcpExec) {
const sdkToolIds = list.filter((t) => t.source === "sdk").map((t) => t.id);
mcpExec.setToolIds(sdkToolIds);
mcpExec.setToolIds(list.filter((t) => t.source === "mcp").map((t) => t.id));
}

const toolEntries: any[] = [];
Expand Down
74 changes: 74 additions & 0 deletions src/proxy/prompt-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Build a text prompt from OpenAI chat messages + tool definitions.
* Handles role:"tool" result messages and assistant tool_calls that
* plain text flattening would silently drop.
*/
export function buildPromptFromMessages(messages: Array<any>, tools: Array<any>): string {
const lines: string[] = [];

if (tools.length > 0) {
const toolDescs = tools
.map((t: any) => {
const fn = t.function || t;
const name = fn.name || "unknown";
const desc = fn.description || "";
const params = fn.parameters;
const paramStr = params ? JSON.stringify(params) : "{}";
return `- ${name}: ${desc}\n Parameters: ${paramStr}`;
})
.join("\n");
lines.push(
`SYSTEM: You have access to the following tools. When you need to use one, respond with a tool_call in the standard OpenAI format.\n\nAvailable tools:\n${toolDescs}`,
);
}

for (const message of messages) {
const role = typeof message.role === "string" ? message.role : "user";

// tool result messages (from multi-turn tool execution loop)
if (role === "tool") {
const callId = message.tool_call_id || "unknown";
const body =
typeof message.content === "string"
? message.content
: JSON.stringify(message.content ?? "");
lines.push(`TOOL_RESULT (call_id: ${callId}): ${body}`);
continue;
}

// assistant messages that contain tool_calls (previous turn's tool invocations)
if (
role === "assistant" &&
Array.isArray(message.tool_calls) &&
message.tool_calls.length > 0
) {
const tcTexts = message.tool_calls.map((tc: any) => {
const fn = tc.function || {};
return `tool_call(id: ${tc.id || "?"}, name: ${fn.name || "?"}, args: ${fn.arguments || "{}"})`;
});
const text = typeof message.content === "string" ? message.content : "";
lines.push(`ASSISTANT: ${text ? text + "\n" : ""}${tcTexts.join("\n")}`);
continue;
}

// standard text messages
const content = message.content;
if (typeof content === "string") {
lines.push(`${role.toUpperCase()}: ${content}`);
} else if (Array.isArray(content)) {
const textParts = content
.map((part: any) => {
if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") {
return part.text;
}
return "";
})
.filter(Boolean);
if (textParts.length) {
lines.push(`${role.toUpperCase()}: ${textParts.join("\n")}`);
}
}
}

return lines.join("\n\n");
}
48 changes: 28 additions & 20 deletions src/tools/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,22 +201,28 @@ export function registerDefaultTools(registry: ToolRegistry): void {
},
source: "local" as const
}, async (args) => {
const { exec } = await import("child_process");
const { execFile } = await import("child_process");
const { promisify } = await import("util");
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

try {
const pattern = args.pattern as string;
const path = args.path as string;
const include = args.include as string | undefined;
const includeFlag = include ? `--include="${include}"` : "";
const { stdout } = await execAsync(
`grep -r ${includeFlag} -n "${pattern}" "${path}" 2>/dev/null || true`,
{ timeout: 30000 }
);
const pattern = args.pattern as string;
const path = args.path as string;
const include = args.include as string | undefined;

const grepArgs = ["-r", "-n"];
if (include) {
grepArgs.push(`--include=${include}`);
}
grepArgs.push(pattern, path);

try {
const { stdout } = await execFileAsync("grep", grepArgs, { timeout: 30000 });
return stdout || "No matches found";
} catch (error: any) {
// grep exits with code 1 when no matches found — not an error
if (error.code === 1) {
return "No matches found";
}
throw error;
}
});
Expand Down Expand Up @@ -278,20 +284,22 @@ export function registerDefaultTools(registry: ToolRegistry): void {
},
source: "local" as const
}, async (args) => {
const { exec } = await import("child_process");
const { execFile } = await import("child_process");
const { promisify } = await import("util");
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

const pattern = args.pattern as string;
const path = args.path as string | undefined;
const cwd = path || ".";

try {
const pattern = args.pattern as string;
const path = args.path as string | undefined;
const cwd = path || ".";
const { stdout } = await execAsync(
`find "${cwd}" -type f -name "${pattern}" 2>/dev/null | head -50`,
const { stdout } = await execFileAsync(
"find", [cwd, "-type", "f", "-name", pattern],
{ timeout: 30000 }
);

return stdout || "No files found";
// Limit output to 50 lines (replaces piped `| head -50`)
const lines = (stdout || "").split("\n").filter(Boolean);
return lines.slice(0, 50).join("\n") || "No files found";
} catch (error: any) {
throw error;
}
Expand Down
12 changes: 9 additions & 3 deletions src/tools/executors/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@ import { createLogger } from "../../utils/logger.js";
const log = createLogger("tools:executor:sdk");

export class SdkExecutor implements IToolExecutor {
private toolIds = new Set<string>();

constructor(private client: any, private timeoutMs: number) {}

canExecute(): boolean {
return Boolean(this.client?.tool?.invoke);
setToolIds(ids: Iterable<string>): void {
this.toolIds = new Set(ids);
}

canExecute(toolId: string): boolean {
return this.toolIds.has(toolId) && Boolean(this.client?.tool?.invoke);
}

async execute(toolId: string, args: Record<string, unknown>): Promise<ExecutionResult> {
if (!this.canExecute()) return { status: "error", error: "SDK invoke unavailable" };
if (!this.canExecute(toolId)) return { status: "error", error: "SDK invoke unavailable" };
try {
const p = this.client.tool.invoke(toolId, args);
const res = await this.runWithTimeout(p);
Expand Down
Loading
Loading