diff --git a/README.md b/README.md index b424b89..d155be7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,21 @@ No prompt limits. No broken streams. Full thinking + tool support in Opencode. Y curl -fsSL https://raw.githubusercontent.com/Nomadcxx/opencode-cursor/main/install.sh | bash ``` -**Option B: TUI Installer** +**Option B: npm Package (Recommended for updates)** + +```bash +npm install -g open-cursor +open-cursor install +``` + +Upgrade later with: + +```bash +npm update -g open-cursor +open-cursor sync-models +``` + +**Option C: TUI Installer** ```bash git clone https://github.com/Nomadcxx/opencode-cursor.git @@ -23,7 +37,7 @@ cd opencode-cursor go build -o ./installer ./cmd/installer && ./installer ``` -**Option C: Let an LLM do it** +**Option D: Let an LLM do it** Paste this into any LLM agent (Claude Code, OpenCode, Cursor, etc.): @@ -51,7 +65,7 @@ Install the cursor-acp plugin for OpenCode: 5. Verify: opencode models | grep cursor ``` -**Option D: Manual Install** +**Option E: Manual Install** ```bash bun install && bun run build @@ -142,6 +156,13 @@ opencode run "your prompt" --model cursor-acp/auto opencode run "your prompt" --model cursor-acp/sonnet-4.5 ``` +If installed via npm, manage setup with: + +```bash +open-cursor status +open-cursor sync-models +``` + ## Models Models are pulled from `cursor-agent models` and written to your config during installation. If Cursor adds new models later, re-run: @@ -272,6 +293,7 @@ CI runs split suites in `.github/workflows/ci.yml`: Integration CI defaults to OpenCode-owned loop mode: - `CURSOR_ACP_TOOL_LOOP_MODE=opencode` +- `CURSOR_ACP_PROVIDER_BOUNDARY=v1` - `CURSOR_ACP_ENABLE_OPENCODE_TOOLS=true` - `CURSOR_ACP_FORWARD_TOOL_CALLS=false` - `CURSOR_ACP_EMIT_TOOL_UPDATES=false` @@ -302,6 +324,10 @@ Set the log level via environment variable: - `CURSOR_ACP_LOG_LEVEL=warn` - Warnings and errors only - `CURSOR_ACP_LOG_LEVEL=error` - Errors only +Provider-boundary rollout: +- `CURSOR_ACP_PROVIDER_BOUNDARY=legacy` - Original provider/runtime boundary behavior +- `CURSOR_ACP_PROVIDER_BOUNDARY=v1` - New shared boundary/interception path (recommended) + Disable log output entirely: ```bash CURSOR_ACP_LOG_SILENT=true opencode run "your prompt" diff --git a/src/plugin.ts b/src/plugin.ts index 6a1e7b9..7533d5f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -14,10 +14,7 @@ import { createLogger } from "./utils/logger"; import { parseAgentError, formatErrorForUser, stripAnsi } from "./utils/errors"; import { buildPromptFromMessages } from "./proxy/prompt-builder.js"; import { - createToolCallCompletionResponse, - createToolCallStreamChunks, extractAllowedToolNames, - extractOpenAiToolCall, type OpenAiToolCall, } from "./proxy/tool-loop.js"; import { OpenCodeToolDiscovery } from "./tools/discovery.js"; @@ -33,6 +30,13 @@ import { McpExecutor } from "./tools/executors/mcp.js"; import { executeWithChain } from "./tools/core/executor.js"; import { registerDefaultTools } from "./tools/defaults.js"; import type { IToolExecutor } from "./tools/core/types.js"; +import { + createProviderBoundary, + parseProviderBoundaryMode, + type ToolLoopMode, + type ToolOptionResolution, +} from "./provider/boundary.js"; +import { handleToolLoopEvent } from "./provider/runtime-interception.js"; const log = createLogger("plugin"); @@ -50,6 +54,7 @@ const CURSOR_PROVIDER_ID = "cursor-acp"; const CURSOR_PROXY_HOST = "127.0.0.1"; const CURSOR_PROXY_DEFAULT_PORT = 32124; const CURSOR_PROXY_DEFAULT_BASE_URL = `http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/v1`; +const REUSE_EXISTING_PROXY = process.env.CURSOR_ACP_REUSE_EXISTING_PROXY !== "false"; function getGlobalKey(): string { return "__opencode_cursor_proxy_server__"; @@ -58,7 +63,6 @@ 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 !== "false"; -type ToolLoopMode = "opencode" | "proxy-exec" | "off"; function parseToolLoopMode(value: string | undefined): { mode: ToolLoopMode; valid: boolean } { const normalized = (value ?? "opencode").trim().toLowerCase(); @@ -70,38 +74,28 @@ function parseToolLoopMode(value: string | undefined): { mode: ToolLoopMode; val const TOOL_LOOP_MODE_RAW = process.env.CURSOR_ACP_TOOL_LOOP_MODE; const { mode: TOOL_LOOP_MODE, valid: TOOL_LOOP_MODE_VALID } = parseToolLoopMode(TOOL_LOOP_MODE_RAW); -const PROXY_EXECUTE_TOOL_CALLS = TOOL_LOOP_MODE === "proxy-exec" && FORWARD_TOOL_CALLS; -const SUPPRESS_CONVERTER_TOOL_EVENTS = TOOL_LOOP_MODE === "proxy-exec" && !FORWARD_TOOL_CALLS; -const SHOULD_EMIT_TOOL_UPDATES = EMIT_TOOL_UPDATES && TOOL_LOOP_MODE === "proxy-exec"; - -type ToolOptionResolution = { - tools: unknown; - action: "preserve" | "fallback" | "override" | "none"; -}; +const PROVIDER_BOUNDARY_MODE_RAW = process.env.CURSOR_ACP_PROVIDER_BOUNDARY; +const { + mode: PROVIDER_BOUNDARY_MODE, + valid: PROVIDER_BOUNDARY_MODE_VALID, +} = parseProviderBoundaryMode(PROVIDER_BOUNDARY_MODE_RAW); +const PROVIDER_BOUNDARY = createProviderBoundary(PROVIDER_BOUNDARY_MODE, CURSOR_PROVIDER_ID); +const { + proxyExecuteToolCalls: PROXY_EXECUTE_TOOL_CALLS, + suppressConverterToolEvents: SUPPRESS_CONVERTER_TOOL_EVENTS, + shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES, +} = PROVIDER_BOUNDARY.computeToolLoopFlags( + TOOL_LOOP_MODE, + FORWARD_TOOL_CALLS, + EMIT_TOOL_UPDATES, +); export function resolveChatParamTools( mode: ToolLoopMode, existingTools: unknown, refreshedTools: Array, ): ToolOptionResolution { - if (mode === "proxy-exec") { - if (refreshedTools.length > 0) { - return { tools: refreshedTools, action: "override" }; - } - return { tools: existingTools, action: "none" }; - } - - if (mode === "opencode") { - if (existingTools != null) { - return { tools: existingTools, action: "preserve" }; - } - if (refreshedTools.length > 0) { - return { tools: refreshedTools, action: "fallback" }; - } - return { tools: existingTools, action: "none" }; - } - - return { tools: existingTools, action: "none" }; + return PROVIDER_BOUNDARY.resolveChatParamTools(mode, existingTools, refreshedTools); } function createChatCompletionResponse(model: string, content: string, reasoningContent?: string) { @@ -188,6 +182,7 @@ function formatToolUpdateEvent(update: ToolUpdate): string { function findFirstAllowedToolCallInOutput( output: string, allowedToolNames: Set, + toolLoopMode: ToolLoopMode, ): OpenAiToolCall | null { if (allowedToolNames.size === 0 || !output) { return null; @@ -199,7 +194,11 @@ function findFirstAllowedToolCallInOutput( continue; } - const toolCall = extractOpenAiToolCall(event as any, allowedToolNames); + const toolCall = PROVIDER_BOUNDARY.maybeExtractToolCall( + event as any, + allowedToolNames, + toolLoopMode, + ); if (toolCall) { return toolCall; } @@ -284,7 +283,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const allowedToolNames = extractAllowedToolNames(tools); const prompt = buildPromptFromMessages(messages, tools); - const model = typeof body?.model === "string" ? body.model : "auto"; + const model = PROVIDER_BOUNDARY.normalizeRuntimeModel(body?.model); const bunAny = globalThis as any; if (!bunAny.Bun?.spawn) { @@ -329,24 +328,26 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const stdout = (stdoutText || "").trim(); const stderr = (stderrText || "").trim(); - if (TOOL_LOOP_MODE === "opencode") { - const toolCall = findFirstAllowedToolCallInOutput(stdout, allowedToolNames); - if (toolCall) { - log.debug("Intercepted OpenCode tool call (non-stream)", { - name: toolCall.function.name, - callId: toolCall.id, - }); - const meta = { - id: `cursor-acp-${Date.now()}`, - created: Math.floor(Date.now() / 1000), - model, - }; - const payload = createToolCallCompletionResponse(meta, toolCall); - return new Response(JSON.stringify(payload), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } + const toolCall = findFirstAllowedToolCallInOutput( + stdout, + allowedToolNames, + TOOL_LOOP_MODE, + ); + if (toolCall) { + log.debug("Intercepted OpenCode tool call (non-stream)", { + name: toolCall.function.name, + callId: toolCall.id, + }); + const meta = { + id: `cursor-acp-${Date.now()}`, + created: Math.floor(Date.now() / 1000), + model, + }; + const payload = PROVIDER_BOUNDARY.createNonStreamToolCallResponse(meta, toolCall); + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); } // cursor-agent sometimes returns non-zero even with usable stdout. @@ -394,7 +395,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: name: toolCall.function.name, callId: toolCall.id, }); - for (const chunk of createToolCallStreamChunks({ id, created, model }, toolCall)) { + for (const chunk of PROVIDER_BOUNDARY.createStreamToolCallChunks( + { id, created, model }, + toolCall, + )) { controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); } controller.enqueue(encoder.encode(formatSseDone())); @@ -420,33 +424,32 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: } if (event.type === "tool_call") { - const updates = await toolMapper.mapCursorEventToAcp( - event, - event.session_id ?? toolSessionId, - ); - if (SHOULD_EMIT_TOOL_UPDATES) { - for (const update of updates) { + const result = await handleToolLoopEvent({ + event: event as any, + boundary: PROVIDER_BOUNDARY, + toolLoopMode: TOOL_LOOP_MODE, + allowedToolNames, + toolMapper, + toolSessionId, + shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES, + proxyExecuteToolCalls: PROXY_EXECUTE_TOOL_CALLS, + suppressConverterToolEvents: SUPPRESS_CONVERTER_TOOL_EVENTS, + toolRouter, + responseMeta: { id, created, model }, + onToolUpdate: (update) => { controller.enqueue(encoder.encode(formatToolUpdateEvent(update))); - } - } - - if (TOOL_LOOP_MODE === "opencode") { - const toolCall = extractOpenAiToolCall(event as any, allowedToolNames); - if (toolCall) { - emitToolCallAndTerminate(toolCall); - break; - } - } - - // Handle OpenCode tools - if (toolRouter && PROXY_EXECUTE_TOOL_CALLS) { - const toolResult = await toolRouter.handleToolCall(event as any, { id, created, model }); - if (toolResult) { + }, + onToolResult: (toolResult) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(toolResult)}\n\n`)); - } + }, + onInterceptedToolCall: (toolCall) => { + emitToolCallAndTerminate(toolCall); + }, + }); + if (result.intercepted) { + break; } - - if (SUPPRESS_CONVERTER_TOOL_EVENTS) { + if (result.skipConverter) { continue; } } @@ -467,32 +470,32 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: continue; } if (event.type === "tool_call") { - const updates = await toolMapper.mapCursorEventToAcp( - event, - event.session_id ?? toolSessionId, - ); - if (SHOULD_EMIT_TOOL_UPDATES) { - for (const update of updates) { + const result = await handleToolLoopEvent({ + event: event as any, + boundary: PROVIDER_BOUNDARY, + toolLoopMode: TOOL_LOOP_MODE, + allowedToolNames, + toolMapper, + toolSessionId, + shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES, + proxyExecuteToolCalls: PROXY_EXECUTE_TOOL_CALLS, + suppressConverterToolEvents: SUPPRESS_CONVERTER_TOOL_EVENTS, + toolRouter, + responseMeta: { id, created, model }, + onToolUpdate: (update) => { controller.enqueue(encoder.encode(formatToolUpdateEvent(update))); - } - } - - if (TOOL_LOOP_MODE === "opencode") { - const toolCall = extractOpenAiToolCall(event as any, allowedToolNames); - if (toolCall) { - emitToolCallAndTerminate(toolCall); - break; - } - } - - if (toolRouter && PROXY_EXECUTE_TOOL_CALLS) { - const toolResult = await toolRouter.handleToolCall(event as any, { id, created, model }); - if (toolResult) { + }, + onToolResult: (toolResult) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(toolResult)}\n\n`)); - } + }, + onInterceptedToolCall: (toolCall) => { + emitToolCallAndTerminate(toolCall); + }, + }); + if (result.intercepted) { + break; } - - if (SUPPRESS_CONVERTER_TOOL_EVENTS) { + if (result.skipConverter) { continue; } } @@ -541,15 +544,17 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: } }; - // Check if another process already started a proxy on the default port - try { - const res = await fetch(`http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/health`).catch(() => null); - if (res && res.ok) { - g[key].baseURL = CURSOR_PROXY_DEFAULT_BASE_URL; - return CURSOR_PROXY_DEFAULT_BASE_URL; + if (REUSE_EXISTING_PROXY) { + // Check if another process already started a proxy on the default port + try { + const res = await fetch(`http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/health`).catch(() => null); + if (res && res.ok) { + g[key].baseURL = CURSOR_PROXY_DEFAULT_BASE_URL; + return CURSOR_PROXY_DEFAULT_BASE_URL; + } + } catch { + // ignore } - } catch { - // ignore } // Use Node.js http server (works in both Node and Bun) @@ -612,7 +617,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const allowedToolNames = extractAllowedToolNames(tools); const prompt = buildPromptFromMessages(messages, tools); - const model = typeof bodyData?.model === "string" ? bodyData.model : "auto"; + const model = PROVIDER_BOUNDARY.normalizeRuntimeModel(bodyData?.model); const cmd = [ "cursor-agent", @@ -645,23 +650,25 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: child.on("close", async (code) => { const stdout = Buffer.concat(stdoutChunks).toString().trim(); const stderr = Buffer.concat(stderrChunks).toString().trim(); - if (TOOL_LOOP_MODE === "opencode") { - const toolCall = findFirstAllowedToolCallInOutput(stdout, allowedToolNames); - if (toolCall) { - log.debug("Intercepted OpenCode tool call (non-stream)", { - name: toolCall.function.name, - callId: toolCall.id, - }); - const meta = { - id: `cursor-acp-${Date.now()}`, - created: Math.floor(Date.now() / 1000), - model, - }; - const payload = createToolCallCompletionResponse(meta, toolCall); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(payload)); - return; - } + const toolCall = findFirstAllowedToolCallInOutput( + stdout, + allowedToolNames, + TOOL_LOOP_MODE, + ); + if (toolCall) { + log.debug("Intercepted OpenCode tool call (non-stream)", { + name: toolCall.function.name, + callId: toolCall.id, + }); + const meta = { + id: `cursor-acp-${Date.now()}`, + created: Math.floor(Date.now() / 1000), + model, + }; + const payload = PROVIDER_BOUNDARY.createNonStreamToolCallResponse(meta, toolCall); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); + return; } const completion = extractCompletionFromStream(stdout); @@ -710,7 +717,10 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: name: toolCall.function.name, callId: toolCall.id, }); - for (const chunk of createToolCallStreamChunks({ id, created, model }, toolCall)) { + for (const chunk of PROVIDER_BOUNDARY.createStreamToolCallChunks( + { id, created, model }, + toolCall, + )) { res.write(`data: ${JSON.stringify(chunk)}\n\n`); } res.write(formatSseDone()); @@ -737,32 +747,32 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: } if (event.type === "tool_call") { - const updates = await toolMapper.mapCursorEventToAcp( - event, - event.session_id ?? toolSessionId, - ); - if (SHOULD_EMIT_TOOL_UPDATES) { - for (const update of updates) { + const result = await handleToolLoopEvent({ + event: event as any, + boundary: PROVIDER_BOUNDARY, + toolLoopMode: TOOL_LOOP_MODE, + allowedToolNames, + toolMapper, + toolSessionId, + shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES, + proxyExecuteToolCalls: PROXY_EXECUTE_TOOL_CALLS, + suppressConverterToolEvents: SUPPRESS_CONVERTER_TOOL_EVENTS, + toolRouter, + responseMeta: { id, created, model }, + onToolUpdate: (update) => { res.write(formatToolUpdateEvent(update)); - } - } - - if (TOOL_LOOP_MODE === "opencode") { - const toolCall = extractOpenAiToolCall(event as any, allowedToolNames); - if (toolCall) { - emitToolCallAndTerminate(toolCall); - break; - } - } - - if (toolRouter && PROXY_EXECUTE_TOOL_CALLS) { - const toolResult = await toolRouter.handleToolCall(event as any, { id, created, model }); - if (toolResult) { + }, + onToolResult: (toolResult) => { res.write(`data: ${JSON.stringify(toolResult)}\n\n`); - } + }, + onInterceptedToolCall: (toolCall) => { + emitToolCallAndTerminate(toolCall); + }, + }); + if (result.intercepted) { + break; } - - if (SUPPRESS_CONVERTER_TOOL_EVENTS) { + if (result.skipConverter) { continue; } } @@ -790,32 +800,32 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: } if (event.type === "tool_call") { - const updates = await toolMapper.mapCursorEventToAcp( - event, - event.session_id ?? toolSessionId, - ); - if (SHOULD_EMIT_TOOL_UPDATES) { - for (const update of updates) { + const result = await handleToolLoopEvent({ + event: event as any, + boundary: PROVIDER_BOUNDARY, + toolLoopMode: TOOL_LOOP_MODE, + allowedToolNames, + toolMapper, + toolSessionId, + shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES, + proxyExecuteToolCalls: PROXY_EXECUTE_TOOL_CALLS, + suppressConverterToolEvents: SUPPRESS_CONVERTER_TOOL_EVENTS, + toolRouter, + responseMeta: { id, created, model }, + onToolUpdate: (update) => { res.write(formatToolUpdateEvent(update)); - } - } - - if (TOOL_LOOP_MODE === "opencode") { - const toolCall = extractOpenAiToolCall(event as any, allowedToolNames); - if (toolCall) { - emitToolCallAndTerminate(toolCall); - break; - } - } - - if (toolRouter && PROXY_EXECUTE_TOOL_CALLS) { - const toolResult = await toolRouter.handleToolCall(event as any, { id, created, model }); - if (toolResult) { + }, + onToolResult: (toolResult) => { res.write(`data: ${JSON.stringify(toolResult)}\n\n`); - } + }, + onInterceptedToolCall: (toolCall) => { + emitToolCallAndTerminate(toolCall); + }, + }); + if (result.intercepted) { + break; } - - if (SUPPRESS_CONVERTER_TOOL_EVENTS) { + if (result.skipConverter) { continue; } } @@ -895,15 +905,17 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: throw error; } - // Port in use - check if it's our proxy - try { - const res = await fetch(`http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/health`).catch(() => null); - if (res && res.ok) { - g[key].baseURL = CURSOR_PROXY_DEFAULT_BASE_URL; - return CURSOR_PROXY_DEFAULT_BASE_URL; + if (REUSE_EXISTING_PROXY) { + // Port in use - check if it's our proxy + try { + const res = await fetch(`http://${CURSOR_PROXY_HOST}:${CURSOR_PROXY_DEFAULT_PORT}/health`).catch(() => null); + if (res && res.ok) { + g[key].baseURL = CURSOR_PROXY_DEFAULT_BASE_URL; + return CURSOR_PROXY_DEFAULT_BASE_URL; + } + } catch { + // ignore } - } catch { - // ignore } // Start on random port @@ -1019,8 +1031,14 @@ export const CursorPlugin: Plugin = async ({ $, directory, client, serverUrl }: if (!TOOL_LOOP_MODE_VALID) { log.warn("Invalid CURSOR_ACP_TOOL_LOOP_MODE; defaulting to opencode", { value: TOOL_LOOP_MODE_RAW }); } + if (!PROVIDER_BOUNDARY_MODE_VALID) { + log.warn("Invalid CURSOR_ACP_PROVIDER_BOUNDARY; defaulting to legacy", { + value: PROVIDER_BOUNDARY_MODE_RAW, + }); + } log.info("Tool loop mode configured", { mode: TOOL_LOOP_MODE, + providerBoundary: PROVIDER_BOUNDARY.mode, proxyExecToolCalls: PROXY_EXECUTE_TOOL_CALLS, }); await ensurePluginDirectory(); @@ -1176,14 +1194,16 @@ export const CursorPlugin: Plugin = async ({ $, directory, client, serverUrl }: }, async "chat.params"(input: any, output: any) { - if (input.model.providerID !== CURSOR_PROVIDER_ID) { + if (!PROVIDER_BOUNDARY.matchesProvider(input.model)) { return; } - // Always point to the actual proxy base URL (may be dynamically allocated). - output.options = output.options || {}; - output.options.baseURL = proxyBaseURL || CURSOR_PROXY_DEFAULT_BASE_URL; - output.options.apiKey = output.options.apiKey || "cursor-agent"; + PROVIDER_BOUNDARY.applyChatParamDefaults( + output, + proxyBaseURL, + CURSOR_PROXY_DEFAULT_BASE_URL, + "cursor-agent", + ); // Tool definitions handling: // - proxy-exec mode: provider injects tool definitions directly. diff --git a/src/provider/boundary.ts b/src/provider/boundary.ts new file mode 100644 index 0000000..04bfc48 --- /dev/null +++ b/src/provider/boundary.ts @@ -0,0 +1,161 @@ +import type { OpenAiToolCall, ToolLoopMeta } from "../proxy/tool-loop.js"; +import { + createToolCallCompletionResponse, + createToolCallStreamChunks, + extractOpenAiToolCall, +} from "../proxy/tool-loop.js"; +import type { StreamJsonToolCallEvent } from "../streaming/types.js"; + +export type ToolLoopMode = "opencode" | "proxy-exec" | "off"; + +export type ProviderBoundaryMode = "legacy" | "v1"; + +export type ToolOptionResolution = { + tools: unknown; + action: "preserve" | "fallback" | "override" | "none"; +}; + +export interface ToolLoopFlags { + proxyExecuteToolCalls: boolean; + suppressConverterToolEvents: boolean; + shouldEmitToolUpdates: boolean; +} + +export interface ProviderBoundary { + readonly mode: ProviderBoundaryMode; + readonly providerId: string; + resolveChatParamTools( + toolLoopMode: ToolLoopMode, + existingTools: unknown, + refreshedTools: Array, + ): ToolOptionResolution; + computeToolLoopFlags( + toolLoopMode: ToolLoopMode, + forwardToolCalls: boolean, + emitToolUpdates: boolean, + ): ToolLoopFlags; + matchesProvider(inputModel: any): boolean; + normalizeRuntimeModel(model: unknown): string; + applyChatParamDefaults( + output: any, + proxyBaseURL: string | undefined, + defaultBaseURL: string, + defaultApiKey: string, + ): void; + maybeExtractToolCall( + event: StreamJsonToolCallEvent, + allowedToolNames: Set, + toolLoopMode: ToolLoopMode, + ): OpenAiToolCall | null; + createNonStreamToolCallResponse(meta: ToolLoopMeta, toolCall: OpenAiToolCall): any; + createStreamToolCallChunks(meta: ToolLoopMeta, toolCall: OpenAiToolCall): Array; +} + +export function parseProviderBoundaryMode( + value: string | undefined, +): { mode: ProviderBoundaryMode; valid: boolean } { + const normalized = (value ?? "legacy").trim().toLowerCase(); + if (normalized === "legacy" || normalized === "v1") { + return { mode: normalized, valid: true }; + } + return { mode: "legacy", valid: false }; +} + +export function createProviderBoundary( + mode: ProviderBoundaryMode, + providerId: string, +): ProviderBoundary { + const shared = createSharedBoundary(providerId); + if (mode === "v1") { + return { ...shared, mode: "v1" }; + } + return { ...shared, mode: "legacy" }; +} + +function createSharedBoundary( + providerId: string, +): Omit { + return { + providerId, + + resolveChatParamTools(toolLoopMode, existingTools, refreshedTools) { + if (toolLoopMode === "proxy-exec") { + if (refreshedTools.length > 0) { + return { tools: refreshedTools, action: "override" }; + } + return { tools: existingTools, action: "none" }; + } + + if (toolLoopMode === "opencode") { + if (existingTools != null) { + return { tools: existingTools, action: "preserve" }; + } + if (refreshedTools.length > 0) { + return { tools: refreshedTools, action: "fallback" }; + } + return { tools: existingTools, action: "none" }; + } + + return { tools: existingTools, action: "none" }; + }, + + computeToolLoopFlags(toolLoopMode, forwardToolCalls, emitToolUpdates) { + const proxyExec = toolLoopMode === "proxy-exec"; + return { + proxyExecuteToolCalls: proxyExec && forwardToolCalls, + suppressConverterToolEvents: proxyExec && !forwardToolCalls, + shouldEmitToolUpdates: proxyExec && emitToolUpdates, + }; + }, + + matchesProvider(inputModel: any) { + if (!inputModel || typeof inputModel !== "object") { + return false; + } + + const modelProviderId = + (typeof inputModel.providerID === "string" && inputModel.providerID) + || (typeof inputModel.providerId === "string" && inputModel.providerId) + || (typeof inputModel.provider === "string" && inputModel.provider) + || ""; + + return modelProviderId === providerId; + }, + + normalizeRuntimeModel(model) { + const raw = typeof model === "string" ? model.trim() : ""; + if (raw.length === 0) { + return "auto"; + } + + const prefix = `${providerId}/`; + if (raw.startsWith(prefix)) { + const stripped = raw.slice(prefix.length).trim(); + return stripped.length > 0 ? stripped : "auto"; + } + + return raw; + }, + + applyChatParamDefaults(output, proxyBaseURL, defaultBaseURL, defaultApiKey) { + output.options = output.options || {}; + output.options.baseURL = proxyBaseURL || defaultBaseURL; + output.options.apiKey = output.options.apiKey || defaultApiKey; + }, + + maybeExtractToolCall(event, allowedToolNames, toolLoopMode) { + if (toolLoopMode !== "opencode") { + return null; + } + return extractOpenAiToolCall(event, allowedToolNames); + }, + + createNonStreamToolCallResponse(meta, toolCall) { + return createToolCallCompletionResponse(meta, toolCall); + }, + + createStreamToolCallChunks(meta, toolCall) { + return createToolCallStreamChunks(meta, toolCall); + }, + }; +} diff --git a/src/provider/runtime-interception.ts b/src/provider/runtime-interception.ts new file mode 100644 index 0000000..08b81dc --- /dev/null +++ b/src/provider/runtime-interception.ts @@ -0,0 +1,82 @@ +import type { ToolUpdate, ToolMapper } from "../acp/tools.js"; +import type { OpenAiToolCall } from "../proxy/tool-loop.js"; +import type { StreamJsonToolCallEvent } from "../streaming/types.js"; +import type { ToolRouter } from "../tools/router.js"; +import type { ToolLoopMode } from "./boundary.js"; +import type { ProviderBoundary } from "./boundary.js"; + +export interface HandleToolLoopEventOptions { + event: StreamJsonToolCallEvent; + boundary: ProviderBoundary; + toolLoopMode: ToolLoopMode; + allowedToolNames: Set; + toolMapper: ToolMapper; + toolSessionId: string; + shouldEmitToolUpdates: boolean; + proxyExecuteToolCalls: boolean; + suppressConverterToolEvents: boolean; + toolRouter?: ToolRouter; + responseMeta: { id: string; created: number; model: string }; + onToolUpdate: (update: ToolUpdate) => Promise | void; + onToolResult: (toolResult: any) => Promise | void; + onInterceptedToolCall: (toolCall: OpenAiToolCall) => Promise | void; +} + +export interface HandleToolLoopEventResult { + intercepted: boolean; + skipConverter: boolean; +} + +export async function handleToolLoopEvent( + options: HandleToolLoopEventOptions, +): Promise { + const { + event, + boundary, + toolLoopMode, + allowedToolNames, + toolMapper, + toolSessionId, + shouldEmitToolUpdates, + proxyExecuteToolCalls, + suppressConverterToolEvents, + toolRouter, + responseMeta, + onToolUpdate, + onToolResult, + onInterceptedToolCall, + } = options; + + const updates = await toolMapper.mapCursorEventToAcp( + event, + event.session_id ?? toolSessionId, + ); + + if (shouldEmitToolUpdates) { + for (const update of updates) { + await onToolUpdate(update); + } + } + + const interceptedToolCall = boundary.maybeExtractToolCall( + event, + allowedToolNames, + toolLoopMode, + ); + if (interceptedToolCall) { + await onInterceptedToolCall(interceptedToolCall); + return { intercepted: true, skipConverter: true }; + } + + if (toolRouter && proxyExecuteToolCalls) { + const toolResult = await toolRouter.handleToolCall(event as any, responseMeta); + if (toolResult) { + await onToolResult(toolResult); + } + } + + return { + intercepted: false, + skipConverter: suppressConverterToolEvents, + }; +} diff --git a/tests/integration/opencode-loop.integration.test.ts b/tests/integration/opencode-loop.integration.test.ts index 343e06b..aab991a 100644 --- a/tests/integration/opencode-loop.integration.test.ts +++ b/tests/integration/opencode-loop.integration.test.ts @@ -18,6 +18,21 @@ const READ_TOOL = { }, }; +const TODO_WRITE_TOOL = { + type: "function", + function: { + name: "todowrite", + description: "Create or update todos", + parameters: { + type: "object", + properties: { + todos: { type: "array" }, + }, + required: ["todos"], + }, + }, +}; + const MOCK_CURSOR_AGENT = `#!/usr/bin/env node const fs = require("fs"); @@ -29,6 +44,7 @@ if (args[0] === "models") { const scenario = process.env.MOCK_CURSOR_SCENARIO || "assistant-text"; const promptFile = process.env.MOCK_CURSOR_PROMPT_FILE; +const argsFile = process.env.MOCK_CURSOR_ARGS_FILE; let prompt = ""; process.stdin.setEncoding("utf8"); @@ -37,6 +53,9 @@ process.stdin.on("data", (chunk) => { }); process.stdin.on("end", () => { + if (argsFile) { + fs.writeFileSync(argsFile, JSON.stringify(args)); + } if (promptFile) { fs.writeFileSync(promptFile, prompt); } @@ -83,6 +102,29 @@ process.stdin.on("end", () => { }, }, ]; + } else if (scenario === "tool-updateTodos-then-text") { + events = [ + { + type: "tool_call", + call_id: "c1", + name: "updateTodos", + tool_call: { + updateTodos: { + args: { + todos: [{ content: "Book flights", status: "pending" }], + }, + }, + }, + }, + { + type: "assistant", + timestamp_ms: now + 1, + message: { + role: "assistant", + content: [{ type: "text", text: "todo alias passthrough text" }], + }, + }, + ]; } else { events = [ { @@ -145,16 +187,22 @@ describe("OpenCode-owned tool loop integration", () => { let originalPath = ""; let originalToolLoopMode: string | undefined; let originalToolsEnabled: string | undefined; + let originalReuseExistingProxy: string | undefined; + let originalProviderBoundary: string | undefined; let mockDir = ""; let promptFile = ""; + let argsFile = ""; let baseURL = ""; beforeAll(async () => { originalPath = process.env.PATH || ""; originalToolLoopMode = process.env.CURSOR_ACP_TOOL_LOOP_MODE; originalToolsEnabled = process.env.CURSOR_ACP_ENABLE_OPENCODE_TOOLS; + originalReuseExistingProxy = process.env.CURSOR_ACP_REUSE_EXISTING_PROXY; + originalProviderBoundary = process.env.CURSOR_ACP_PROVIDER_BOUNDARY; mockDir = mkdtempSync(join(tmpdir(), "cursor-agent-mock-")); promptFile = join(mockDir, "prompt.txt"); + argsFile = join(mockDir, "args.json"); const mockCursorPath = join(mockDir, "cursor-agent"); writeFileSync(mockCursorPath, MOCK_CURSOR_AGENT, "utf8"); @@ -163,7 +211,10 @@ describe("OpenCode-owned tool loop integration", () => { process.env.PATH = `${mockDir}:${originalPath}`; process.env.CURSOR_ACP_TOOL_LOOP_MODE = "opencode"; process.env.CURSOR_ACP_ENABLE_OPENCODE_TOOLS = "true"; + process.env.CURSOR_ACP_REUSE_EXISTING_PROXY = "false"; + process.env.CURSOR_ACP_PROVIDER_BOUNDARY = "v1"; process.env.MOCK_CURSOR_PROMPT_FILE = ""; + process.env.MOCK_CURSOR_ARGS_FILE = ""; process.env.MOCK_CURSOR_SCENARIO = "assistant-text"; const { CursorPlugin } = await import("../../src/plugin"); @@ -202,7 +253,18 @@ describe("OpenCode-owned tool loop integration", () => { } else { process.env.CURSOR_ACP_ENABLE_OPENCODE_TOOLS = originalToolsEnabled; } + if (originalReuseExistingProxy === undefined) { + delete process.env.CURSOR_ACP_REUSE_EXISTING_PROXY; + } else { + process.env.CURSOR_ACP_REUSE_EXISTING_PROXY = originalReuseExistingProxy; + } + if (originalProviderBoundary === undefined) { + delete process.env.CURSOR_ACP_PROVIDER_BOUNDARY; + } else { + process.env.CURSOR_ACP_PROVIDER_BOUNDARY = originalProviderBoundary; + } delete process.env.MOCK_CURSOR_PROMPT_FILE; + delete process.env.MOCK_CURSOR_ARGS_FILE; delete process.env.MOCK_CURSOR_SCENARIO; rmSync(mockDir, { recursive: true, force: true }); }); @@ -253,6 +315,22 @@ describe("OpenCode-owned tool loop integration", () => { expect(json.choices?.[0]?.message?.content).toBeNull(); }); + it("maps updateTodos alias to allowed todowrite in non-stream mode", async () => { + process.env.MOCK_CURSOR_SCENARIO = "tool-updateTodos-then-text"; + process.env.MOCK_CURSOR_PROMPT_FILE = ""; + + const response = await requestCompletion(baseURL, { + model: "auto", + stream: false, + tools: [TODO_WRITE_TOOL], + messages: [{ role: "user", content: "Create a todo list" }], + }); + + const json: any = await response.json(); + expect(json.choices?.[0]?.message?.tool_calls?.[0]?.function?.name).toBe("todowrite"); + expect(json.choices?.[0]?.finish_reason).toBe("tool_calls"); + }); + it("continues on second turn with role tool result and includes TOOL_RESULT in prompt", async () => { process.env.MOCK_CURSOR_SCENARIO = "assistant-text"; process.env.MOCK_CURSOR_PROMPT_FILE = promptFile; @@ -299,6 +377,26 @@ describe("OpenCode-owned tool loop integration", () => { expect(promptText).toContain("TOOL_RESULT (call_id: c1): {\"content\":\"file contents here\"}"); }); + it("normalizes provider-prefixed model ids before invoking cursor-agent", async () => { + process.env.MOCK_CURSOR_SCENARIO = "assistant-text"; + process.env.MOCK_CURSOR_PROMPT_FILE = ""; + process.env.MOCK_CURSOR_ARGS_FILE = argsFile; + + const response = await requestCompletion(baseURL, { + model: "cursor-acp/auto", + stream: false, + messages: [{ role: "user", content: "Say hello" }], + }); + + const json: any = await response.json(); + expect(json.choices?.[0]?.message?.content).toContain("The file contains..."); + + const argv = JSON.parse(readFileSync(argsFile, "utf8")) as string[]; + const modelIndex = argv.indexOf("--model"); + expect(modelIndex).toBeGreaterThan(-1); + expect(argv[modelIndex + 1]).toBe("auto"); + }); + it("does not intercept non-allowed tools", async () => { process.env.MOCK_CURSOR_SCENARIO = "tool-bash-then-text"; process.env.MOCK_CURSOR_PROMPT_FILE = ""; diff --git a/tests/unit/provider-boundary.test.ts b/tests/unit/provider-boundary.test.ts new file mode 100644 index 0000000..7d37a56 --- /dev/null +++ b/tests/unit/provider-boundary.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "bun:test"; +import { + createProviderBoundary, + parseProviderBoundaryMode, + type ToolLoopMode, +} from "../../src/provider/boundary"; + +describe("provider boundary", () => { + it("parses provider boundary mode with legacy default", () => { + expect(parseProviderBoundaryMode("legacy")).toEqual({ mode: "legacy", valid: true }); + expect(parseProviderBoundaryMode("v1")).toEqual({ mode: "v1", valid: true }); + expect(parseProviderBoundaryMode(undefined)).toEqual({ mode: "legacy", valid: true }); + expect(parseProviderBoundaryMode("invalid")).toEqual({ mode: "legacy", valid: false }); + }); + + it("keeps legacy and v1 resolveChatParamTools behavior identical", () => { + const legacy = createProviderBoundary("legacy", "cursor-acp"); + const v1 = createProviderBoundary("v1", "cursor-acp"); + + const cases: Array<{ + mode: ToolLoopMode; + existing: unknown; + refreshed: Array; + }> = [ + { mode: "opencode", existing: [{ function: { name: "external" } }], refreshed: [] }, + { mode: "opencode", existing: undefined, refreshed: [{ function: { name: "oc_bash" } }] }, + { mode: "proxy-exec", existing: [{ function: { name: "legacy" } }], refreshed: [{ function: { name: "new" } }] }, + { mode: "off", existing: [{ function: { name: "keep" } }], refreshed: [{ function: { name: "ignored" } }] }, + { mode: "proxy-exec", existing: undefined, refreshed: [] }, + ]; + + for (const testCase of cases) { + const lhs = legacy.resolveChatParamTools(testCase.mode, testCase.existing, testCase.refreshed); + const rhs = v1.resolveChatParamTools(testCase.mode, testCase.existing, testCase.refreshed); + expect(lhs).toEqual(rhs); + } + }); + + it("computes loop flags based on tool loop mode and env toggles", () => { + const boundary = createProviderBoundary("v1", "cursor-acp"); + + expect(boundary.computeToolLoopFlags("opencode", true, true)).toEqual({ + proxyExecuteToolCalls: false, + suppressConverterToolEvents: false, + shouldEmitToolUpdates: false, + }); + + expect(boundary.computeToolLoopFlags("proxy-exec", true, false)).toEqual({ + proxyExecuteToolCalls: true, + suppressConverterToolEvents: false, + shouldEmitToolUpdates: false, + }); + + expect(boundary.computeToolLoopFlags("proxy-exec", false, true)).toEqual({ + proxyExecuteToolCalls: false, + suppressConverterToolEvents: true, + shouldEmitToolUpdates: true, + }); + }); + + it("normalizes provider-prefixed model names", () => { + const boundary = createProviderBoundary("v1", "cursor-acp"); + expect(boundary.normalizeRuntimeModel("cursor-acp/auto")).toBe("auto"); + expect(boundary.normalizeRuntimeModel("cursor-acp/gpt-5.3-codex")).toBe("gpt-5.3-codex"); + expect(boundary.normalizeRuntimeModel("auto")).toBe("auto"); + expect(boundary.normalizeRuntimeModel(undefined)).toBe("auto"); + expect(boundary.normalizeRuntimeModel(" ")).toBe("auto"); + }); + + it("matches provider across providerID/providerId/provider keys", () => { + const boundary = createProviderBoundary("v1", "cursor-acp"); + expect(boundary.matchesProvider({ providerID: "cursor-acp" })).toBe(true); + expect(boundary.matchesProvider({ providerId: "cursor-acp" })).toBe(true); + expect(boundary.matchesProvider({ provider: "cursor-acp" })).toBe(true); + expect(boundary.matchesProvider({ providerID: "other" })).toBe(false); + expect(boundary.matchesProvider(undefined)).toBe(false); + }); + + it("applies chat param defaults without clobbering existing api key", () => { + const boundary = createProviderBoundary("v1", "cursor-acp"); + const output: any = { options: { apiKey: "existing-key" } }; + boundary.applyChatParamDefaults(output, "http://127.0.0.1:32124/v1", "http://fallback/v1", "cursor-agent"); + expect(output.options.baseURL).toBe("http://127.0.0.1:32124/v1"); + expect(output.options.apiKey).toBe("existing-key"); + }); + + it("extracts tool calls only for opencode mode and returns formatted responses", () => { + const boundary = createProviderBoundary("v1", "cursor-acp"); + const event: any = { + type: "tool_call", + call_id: "c1", + name: "updateTodos", + tool_call: { + updateTodos: { + args: { todos: [{ content: "Book flights", status: "pending" }] }, + }, + }, + }; + + const call = boundary.maybeExtractToolCall(event, new Set(["todowrite"]), "opencode"); + expect(call?.function.name).toBe("todowrite"); + + const skipped = boundary.maybeExtractToolCall(event, new Set(["todowrite"]), "proxy-exec"); + expect(skipped).toBeNull(); + + const meta = { id: "resp-1", created: 123, model: "auto" }; + const nonStream = boundary.createNonStreamToolCallResponse(meta, call!); + expect(nonStream.choices[0].finish_reason).toBe("tool_calls"); + + const stream = boundary.createStreamToolCallChunks(meta, call!); + expect(stream).toHaveLength(2); + expect(stream[1].choices[0].finish_reason).toBe("tool_calls"); + }); +});