From 400a66526b4bb91c9b53bc85211424f0d8eb44b4 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Thu, 11 Jun 2026 08:33:49 -0500 Subject: [PATCH] feat: map more Cursor tools onto opencode native renderers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the edit→diff-viewer mapping into a table-driven adapter so more of Cursor's internal tool activity renders with opencode's native UI in blocks mode instead of generic cursor_* JSON blocks. Native mappings (provider-executed + dynamic, never re-run on disk): - shell→bash, read→read, write→write, glob→glob, grep→grep, ls→list, updateTodos→todowrite, task→task - web search (Cursor runs it as an MCP tool) → websearch Cleaner fallbacks for tools with no opencode counterpart: - readLints → formatted cursor_readLints diagnostics list - delete → one-line cursor_delete confirmation - any MCP tool's content[] flattened to readable text Arg shapes are translated to opencode's (path→filePath, globPattern→ pattern, fileText→content, …). Tools without a mapping, or results with an unexpected shape, still fall back to a safe cursor_* block. --- CHANGELOG.md | 18 ++ README.md | 14 + src/provider/stream-map.ts | 565 +++++++++++++++++++++++++++++++++++-- test/stream-map.test.ts | 457 ++++++++++++++++++++++++++++-- 4 files changed, 1010 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a81c6..88c2cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **More Cursor tools map onto opencode's native tool renderers (blocks mode).** + Following the `edit` → diff-viewer mapping, Cursor's `shell`, `read`, `write`, + `glob`, `grep`, `ls`, `updateTodos`, and `task` tool activity is now surfaced + under opencode's registered `bash`, `read`, `write`, `glob`, `grep`, `list`, + `todowrite`, and `task` tools, and Cursor's web search (which runs as an MCP + tool) maps onto opencode's `websearch` renderer — so opencode renders its + native UI (shell console, file viewer, todo checklist, subagent card, search + results, …) instead of generic `cursor_*` blocks. Cursor's arg shape is + translated to opencode's (e.g. `path` → `filePath`, `globPattern` → `pattern`, + `fileText` → `content`); calls stay provider-executed (display-only, never + re-run on disk). +- **Cleaner fallback blocks for tools without an opencode counterpart.** + `readLints` and `delete` now render as formatted `cursor_*` blocks (a + diagnostics list / a one-line confirmation) instead of raw JSON, and every MCP + tool's `content` array is flattened to readable text. Anything else — or a + result with an unexpected shape — still falls back to a safe `cursor_*` block + with the raw payload. + ## [0.1.0] — 2026-06-10 > Pre-releases: `0.1.0-rc.1` and `0.1.0-rc.2` were published to the npm `next` diff --git a/README.md b/README.md index bf0ba1f..25aedcc 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,20 @@ that activity appears in opencode: and outputs. opencode skips execution for provider-executed calls (they're display-only), so Cursor's tools (`shell`, `mcp`, …) don't trigger an "unavailable tool" error. Requires a V3-native opencode host (1.16+). + + Where a Cursor tool has a natural opencode counterpart, it's surfaced under opencode's + **registered** tool name so its native renderer is used instead of a generic block: `edit` → + opencode's diff viewer (via `metadata.diff`), `shell` → `bash` console, `task` → the subagent + card, web search (which Cursor runs as an MCP tool) → the `websearch` renderer, and + `read`/`write`/`glob`/`grep`/`ls`/`updateTodos` → opencode's + `read`/`write`/`glob`/`grep`/`list`/`todowrite` renderers. Cursor's arg shape is translated to + opencode's (e.g. `path` → `filePath`); the call stays provider-executed, so it's display-only and + never re-run on disk. + + Tools with no opencode counterpart still get cleaned up: `readLints` and `delete` render as + formatted `cursor_*` blocks (a diagnostics list / a one-line confirmation) rather than raw JSON, + and any MCP tool's `content` is flattened to readable text. Anything else — or a result with an + unexpected shape — falls back to a prefixed `cursor_*` block with the raw payload. - **`"reasoning"` (fallback)** — each tool call is shown as a compact reasoning line (`[tool] write {"path":…}`; failures as `[tool] x failed`). Robust on every host: no tool-call parts cross into opencode, so there's no dependency on how the host treats provider-executed diff --git a/src/provider/stream-map.ts b/src/provider/stream-map.ts index ff72020..4899aed 100644 --- a/src/provider/stream-map.ts +++ b/src/provider/stream-map.ts @@ -58,15 +58,21 @@ function blockToolName(name: string): string { type BlockToolPart = LanguageModelV3StreamPart; /** - * Build a provider-executed dynamic `tool-call`. The name is `cursor_`-prefixed - * so it can't collide with a tool opencode has registered; `input` is a - * stringified JSON object per the V3 spec. + * Build a provider-executed dynamic `tool-call` under an EXACT (already-final) + * tool name. `input` is a stringified JSON object per the V3 spec. Both + * `providerExecuted` and `dynamic` are set so ai's `parseToolCall` accepts the + * part without registered-tool validation, and a host that hasn't registered + * the named tool degrades to a generic dynamic block instead of erroring. */ -function toolCallObj(id: string, name: string, input: unknown): BlockToolPart { +function nativeToolCall( + id: string, + toolName: string, + input: unknown, +): BlockToolPart { return { type: "tool-call", toolCallId: id, - toolName: blockToolName(name), + toolName, input: safeJsonString(input), providerExecuted: true, dynamic: true, @@ -74,21 +80,21 @@ function toolCallObj(id: string, name: string, input: unknown): BlockToolPart { } /** - * Build a provider-executed dynamic `tool-result`. Per the V3 spec (and ai v6's - * `runToolsTransformation`, which reads `chunk.result` / `chunk.isError`) the - * payload goes in `result`; `result` is typed `NonNullable` so a - * missing Cursor result is coalesced to `null` and cast. + * Build a provider-executed dynamic `tool-result` under an EXACT tool name. Per + * the V3 spec (and ai v6's `runToolsTransformation`, which reads + * `chunk.result` / `chunk.isError`) the payload goes in `result`; `result` is + * typed `NonNullable` so a missing payload is coalesced to `null`. */ -function toolResultObj( +function nativeToolResult( id: string, - name: string, + toolName: string, result: unknown, isError: boolean, ): BlockToolPart { return { type: "tool-result", toolCallId: id, - toolName: blockToolName(name), + toolName, result: (result ?? null) as never, isError, providerExecuted: true, @@ -96,6 +102,25 @@ function toolResultObj( } as BlockToolPart; } +/** + * Build a generic `tool-call` for a Cursor tool with no native opencode + * counterpart. The name is `cursor_`-prefixed so it can't collide with a tool + * opencode has registered. + */ +function toolCallObj(id: string, name: string, input: unknown): BlockToolPart { + return nativeToolCall(id, blockToolName(name), input); +} + +/** Build a generic (`cursor_`-prefixed) `tool-result`. */ +function toolResultObj( + id: string, + name: string, + result: unknown, + isError: boolean, +): BlockToolPart { + return nativeToolResult(id, blockToolName(name), result, isError); +} + /** Cursor's file-edit tool surfaces with this name (its `toolCall.type`). */ const EDIT_TOOL_NAME = "edit"; @@ -103,6 +128,465 @@ function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null; } +function strField(v: unknown, key: string): string | undefined { + return isRecord(v) && typeof v[key] === "string" + ? (v[key] as string) + : undefined; +} + +function numField(v: unknown, key: string): number | undefined { + return isRecord(v) && typeof v[key] === "number" + ? (v[key] as number) + : undefined; +} + +/** Unwrap a Cursor `{ status:"success", value }` result to its `value`. */ +function successValue(result: unknown): unknown { + return isRecord(result) && result["status"] === "success" + ? result["value"] + : undefined; +} + +/** The `{title, metadata, output}` shape opencode folds into a tool part's state. */ +interface FoldedResult { + title: string; + metadata: Record; + output: string; +} + +/** + * Maps a Cursor tool's call/result onto an opencode tool so opencode renders + * something nicer than a raw-JSON block. Mirrors the `edit` mapping: parts are + * emitted `providerExecuted` + `dynamic`, so the call is never executed on disk + * and a host that hasn't registered the tool degrades to a generic dynamic + * block. + * + * - `tool` is opencode's REGISTERED tool name when there's a native renderer to + * reuse (`bash`, `read`, `websearch`, …). Omit it for "format-only" adapters + * (`readLints`, `delete`): the part stays a `cursor_*` block, but `result` + * still folds the payload into a clean `output` string so opencode's generic + * renderer shows formatted text instead of dumped JSON. + * - `input` translates Cursor's arg keys to the names opencode's renderer reads + * (e.g. Cursor `path` → opencode `filePath`). + * - `result` folds a SUCCESSFUL Cursor result `value` into `{title, metadata, + * output}`; returning `null` falls back to a generic block (unexpected shape). + */ +interface NativeToolAdapter { + tool?: string; + input(args: unknown): Record; + result(value: unknown, args: unknown): FoldedResult | null; +} + +/** + * Flatten a Cursor MCP result `value` (`{ content: [{ text: { text } }, …] }`) + * into plain text. Returns `null` when `value` isn't MCP-shaped, so callers can + * fall back to the raw payload. An MCP `content` array that holds no text + * (e.g. only images) yields a `"[image]"`-style placeholder rather than `null`. + */ +function flattenMcpContent(value: unknown): string | null { + if (!isRecord(value) || !Array.isArray(value["content"])) return null; + const parts = (value["content"] as unknown[]).flatMap((item) => { + const text = isRecord(item) ? strField(item["text"], "text") : undefined; + if (text !== undefined) return [text]; + if (isRecord(item) && isRecord(item["image"])) return ["[image]"]; + return []; + }); + return parts.join("\n"); +} + +/** + * Fold a generic (non-adapter) Cursor result into a clean `output` string IF + * it's an MCP result; otherwise `null` so the raw payload is passed through. + * This stops every MCP tool (web search, custom servers, …) from dumping the + * nested `{content:[{text:{text}}]}` JSON into the block. + */ +function mcpFold(result: unknown): FoldedResult | null { + const text = flattenMcpContent(successValue(result)); + if (text === null) return null; + return { title: "", metadata: {}, output: text }; +} + +/** Cursor MCP call args nest the real tool input under `args.args`. */ +function mcpInputArgs(args: unknown): unknown { + return isRecord(args) ? args["args"] : undefined; +} + +/** Map a Cursor MCP `providerIdentifier` to a websearch provider label key. */ +function webSearchProvider(args: unknown): string | undefined { + const id = (strField(args, "providerIdentifier") ?? "").toLowerCase(); + if (id.includes("exa")) return "exa"; + if (id.includes("parallel")) return "parallel"; + return undefined; +} + +/** A Cursor MCP tool whose name looks like a web search (`web_search`, …). */ +function isWebSearchName(name: string): boolean { + return /web[_-]?search/i.test(name); +} + +/** + * Cursor web search arrives as an MCP tool; map it onto opencode's native + * `websearch` renderer (query subtitle + result body). The query lives in the + * nested MCP input (`args.args.query`); the result is MCP `content`. + */ +const WEBSEARCH_ADAPTER: NativeToolAdapter = { + tool: "websearch", + input: (args) => { + const query = strField(mcpInputArgs(args), "query"); + return query !== undefined ? { query } : {}; + }, + result: (value, args) => { + const provider = webSearchProvider(args); + return { + title: "", + metadata: provider ? { provider } : {}, + output: flattenMcpContent(value) ?? "", + }; + }, +}; + +/** + * Resolve a Cursor tool name (+ call args) to an adapter, or `undefined` for a + * plain `cursor_*` block. `edit` is handled separately (its native call input + * depends on the diff in the result). + */ +function resolveAdapter( + name: string, + _input: unknown, +): NativeToolAdapter | undefined { + const exact = NATIVE_ADAPTERS[name]; + if (exact) return exact; + if (isWebSearchName(name)) return WEBSEARCH_ADAPTER; + return undefined; +} + +/** Cursor todo status (`inProgress`) → opencode todo status (`in_progress`). */ +function mapTodoStatus(status: unknown): string { + if (status === "inProgress") return "in_progress"; + return typeof status === "string" ? status : "pending"; +} + +/** Normalize Cursor `updateTodos` args into opencode `todowrite` todos. */ +function mapTodos(args: unknown): Array<{ content: string; status: string }> { + const todos = + isRecord(args) && Array.isArray(args["todos"]) ? args["todos"] : []; + return (todos as unknown[]).flatMap((t) => + isRecord(t) && typeof t["content"] === "string" + ? [ + { + content: t["content"] as string, + status: mapTodoStatus(t["status"]), + }, + ] + : [], + ); +} + +/** + * Cursor tool name → opencode native tool adapter. Cursor tools without a + * natural opencode counterpart (`delete`, `mcp`, `semSearch`, `readLints`, + * `generateImage`, `createPlan`, `recordScreen`, `task`) are intentionally + * absent and fall through to generic `cursor_*` blocks. `edit` is handled + * separately (its native call input depends on the diff in the result). + */ +const NATIVE_ADAPTERS: Record = { + // Cursor `shell` → opencode `bash` (console renderer). + shell: { + tool: "bash", + input: (args) => ({ command: strField(args, "command") ?? "" }), + result: (value, args) => { + if (!isRecord(value)) return null; + const command = strField(args, "command") ?? ""; + const stdout = strField(value, "stdout") ?? ""; + const stderr = strField(value, "stderr") ?? ""; + const exit = numField(value, "exitCode"); + const body = [stdout, stderr].filter((s) => s.length > 0).join("\n"); + const output = + exit !== undefined && exit !== 0 + ? `${body}${body ? "\n" : ""}(exit ${exit})` + : body; + return { + title: command, + metadata: { command, output, exit: exit ?? 0 }, + output, + }; + }, + }, + // Cursor `read` → opencode `read`. + read: { + tool: "read", + input: (args) => ({ filePath: strField(args, "path") ?? "" }), + result: (value, args) => { + const content = strField(value, "content"); + if (content === undefined) return null; + const filePath = strField(args, "path") ?? ""; + const totalLines = numField(value, "totalLines"); + return { + title: filePath, + metadata: { + preview: content.split("\n").slice(0, 20).join("\n"), + loaded: [] as string[], + ...(totalLines !== undefined ? { totalLines } : {}), + }, + output: content, + }; + }, + }, + // Cursor `write` → opencode `write` (renders input.content as the new file). + write: { + tool: "write", + input: (args) => ({ + filePath: strField(args, "path") ?? "", + content: strField(args, "fileText") ?? "", + }), + result: (value, args) => { + const filePath = strField(args, "path") ?? ""; + const lines = numField(value, "linesCreated"); + const output = + lines !== undefined + ? `Wrote ${lines} line${lines === 1 ? "" : "s"}.` + : "Wrote file successfully."; + return { + title: filePath, + metadata: { diagnostics: {}, filepath: filePath, exists: false }, + output, + }; + }, + }, + // Cursor `glob` → opencode `glob`. + glob: { + tool: "glob", + input: (args) => { + const pattern = strField(args, "globPattern") ?? ""; + const dir = strField(args, "targetDirectory"); + return dir ? { pattern, path: dir } : { pattern }; + }, + result: (value) => { + if (!isRecord(value) || !Array.isArray(value["files"])) return null; + const files = (value["files"] as unknown[]).filter( + (f): f is string => typeof f === "string", + ); + const truncated = + value["clientTruncated"] === true || value["ripgrepTruncated"] === true; + return { + title: "", + metadata: { count: files.length, truncated }, + output: files.length > 0 ? files.join("\n") : "No files found", + }; + }, + }, + // Cursor `grep` → opencode `grep` (flatten matches into ripgrep-style text). + grep: { + tool: "grep", + input: (args) => { + const out: Record = { + pattern: strField(args, "pattern") ?? "", + }; + const p = strField(args, "path"); + if (p) out["path"] = p; + const g = strField(args, "glob"); + if (g) out["include"] = g; + return out; + }, + result: (value) => { + if (!isRecord(value)) return null; + const unions: unknown[] = []; + const ws = value["workspaceResults"]; + if (isRecord(ws)) unions.push(...Object.values(ws)); + if (value["activeEditorResult"] !== undefined) + unions.push(value["activeEditorResult"]); + const lines: string[] = []; + let total = 0; + let current = ""; + for (const u of unions) { + if (!isRecord(u)) continue; + const output = u["output"]; + if ( + u["type"] === "content" && + isRecord(output) && + Array.isArray(output["matches"]) + ) { + for (const m of output["matches"] as unknown[]) { + if (!isRecord(m)) continue; + const file = strField(m, "file") ?? ""; + const line = numField(m, "lineNumber"); + const text = strField(m, "line") ?? ""; + if (current !== file) { + if (current) lines.push(""); + current = file; + lines.push(`${file}:`); + } + lines.push( + line !== undefined ? ` Line ${line}: ${text}` : ` ${text}`, + ); + total++; + } + } else if ( + u["type"] === "files" && + isRecord(output) && + Array.isArray(output["files"]) + ) { + for (const f of output["files"] as unknown[]) { + if (typeof f === "string") { + lines.push(f); + total++; + } + } + } + } + return { + title: "", + metadata: { matches: total, truncated: false }, + output: + total > 0 + ? [ + `Found ${total} match${total === 1 ? "" : "es"}`, + "", + ...lines, + ].join("\n") + : "No matches found", + }; + }, + }, + // Cursor `ls` → opencode `list` (flatten the directory tree into paths). + ls: { + tool: "list", + input: (args) => ({ path: strField(args, "path") ?? "" }), + result: (value) => { + if (!isRecord(value)) return null; + const root = value["directoryTreeRoot"]; + if (!isRecord(root)) return null; + const out: string[] = []; + const walk = (node: Record) => { + const base = strField(node, "absPath") ?? ""; + const files = Array.isArray(node["childrenFiles"]) + ? node["childrenFiles"] + : []; + for (const f of files) { + const name = strField(f, "name"); + if (name) out.push(`${base}/${name}`); + } + const dirs = Array.isArray(node["childrenDirs"]) + ? node["childrenDirs"] + : []; + for (const d of dirs) { + if (!isRecord(d)) continue; + out.push(`${strField(d, "absPath") ?? ""}/`); + walk(d); + } + }; + walk(root); + return { + title: strField(root, "absPath") ?? "", + metadata: {}, + output: out.length > 0 ? out.join("\n") : "(empty)", + }; + }, + }, + // Cursor `updateTodos` → opencode `todowrite` (todo checklist renderer). + updateTodos: { + tool: "todowrite", + input: (args) => ({ todos: mapTodos(args) }), + result: (_value, args) => { + const todos = mapTodos(args); + const done = todos.filter((t) => t.status === "completed").length; + return { + title: `${done}/${todos.length}`, + metadata: { todos }, + output: `Updated ${todos.length} todo${todos.length === 1 ? "" : "s"}.`, + }; + }, + }, + // Cursor `task` (subagent) → opencode `task` (agent card: name + description). + // Non-clickable here — the subagent ran inside Cursor, not as an opencode + // child session — but the native card reads far better than raw JSON. + task: { + tool: "task", + input: (args) => { + const description = strField(args, "description") ?? ""; + const sub = isRecord(args) ? args["subagentType"] : undefined; + const subagent = + strField(sub, "name") ?? strField(sub, "kind") ?? undefined; + return subagent + ? { description, subagent_type: subagent } + : { description }; + }, + result: (value, args) => { + const description = strField(args, "description") ?? ""; + const suffix = strField(value, "resultSuffix"); + const background = isRecord(value) && value["isBackground"] === true; + return { + title: description, + metadata: background ? { background: true } : {}, + output: suffix ?? "Subagent task completed.", + }; + }, + }, + // Cursor `readLints` has no opencode counterpart — format-only: render the + // diagnostics as a readable list instead of dumping the nested JSON. + readLints: { + input: (args) => { + const paths = + isRecord(args) && Array.isArray(args["paths"]) ? args["paths"] : []; + return { paths }; + }, + result: (value) => { + const files = + isRecord(value) && Array.isArray(value["fileDiagnostics"]) + ? (value["fileDiagnostics"] as unknown[]) + : []; + const lines: string[] = []; + let total = 0; + for (const file of files) { + if (!isRecord(file)) continue; + const diags = Array.isArray(file["diagnostics"]) + ? (file["diagnostics"] as unknown[]) + : []; + if (diags.length === 0) continue; + lines.push(`${strField(file, "path") ?? ""}`); + for (const d of diags) { + if (!isRecord(d)) continue; + const severity = strField(d, "severity") ?? "info"; + const start = isRecord(d["range"]) ? d["range"]["start"] : undefined; + const line = numField(start, "line"); + const char = numField(start, "character"); + const loc = + line !== undefined + ? ` L${line + 1}${char !== undefined ? `:${char + 1}` : ""}` + : ""; + lines.push(` ${severity}${loc}: ${strField(d, "message") ?? ""}`); + total++; + } + } + return { + title: + total > 0 + ? `${total} problem${total === 1 ? "" : "s"}` + : "No problems", + metadata: { count: total }, + output: total > 0 ? lines.join("\n") : "No problems found.", + }; + }, + }, + // Cursor `delete` has no opencode counterpart — format-only: a one-line + // confirmation instead of `{"fileSize":N}`. + delete: { + input: (args) => ({ path: strField(args, "path") ?? "" }), + result: (value, args) => { + const path = strField(args, "path") ?? ""; + const size = numField(value, "fileSize"); + return { + title: path, + metadata: {}, + output: + size !== undefined + ? `Deleted ${path} (${size} bytes).` + : `Deleted ${path}.`, + }; + }, + }, +}; + /** Extract the edit target path from Cursor's edit tool-call args (`{ path }`). */ function editFilePath(input: unknown): string { return isRecord(input) && typeof input["path"] === "string" @@ -218,17 +702,26 @@ function editResultFields( /** * Per-turn blocks-mode tool bookkeeping, shared by the streaming and * `doGenerate` paths: - * - `openToolCalls`: non-edit calls awaiting their result (id → original name). + * - `open`: non-edit calls awaiting their result. Stores the FINAL emitted tool + * name (native, e.g. `bash`, or generic `cursor_*`), the native adapter (if + * any) used to fold the result, and the original Cursor args (some adapters + * build their result from the call args, e.g. `write`/`updateTodos`). * - `pendingEdits`: edit calls held until their result, which carries the diff * needed to emit a schema-valid native `edit` call (id → filePath). */ +interface OpenToolCall { + toolName: string; + adapter?: NativeToolAdapter; + args: unknown; +} + interface BlockToolState { - openToolCalls: Map; + open: Map; pendingEdits: Map; } function newBlockToolState(): BlockToolState { - return { openToolCalls: new Map(), pendingEdits: new Map() }; + return { open: new Map(), pendingEdits: new Map() }; } /** Parts to emit for a blocks-mode `tool-call` event (edits are buffered). */ @@ -243,8 +736,19 @@ function blockToolCallParts( state.pendingEdits.set(id, editFilePath(input)); return []; } - state.openToolCalls.set(id, name); - return [toolCallObj(id, name, input)]; + const adapter = resolveAdapter(name, input); + if (adapter) { + // Adapter mapping: native registered tool (`adapter.tool`) when there's a + // renderer to reuse, else a `cursor_*` block whose result is still folded + // into clean output (format-only adapters). + const toolName = adapter.tool ?? blockToolName(name); + state.open.set(id, { toolName, adapter, args: input }); + return [nativeToolCall(id, toolName, adapter.input(input))]; + } + // No adapter: generic prefixed block. + const toolName = blockToolName(name); + state.open.set(id, { toolName, args: input }); + return [nativeToolCall(id, toolName, input)]; } /** Parts to emit for a blocks-mode `tool-result` event. */ @@ -272,8 +776,25 @@ function blockToolResultParts( toolResultObj(id, EDIT_TOOL_NAME, result, isError), ]; } - state.openToolCalls.delete(id); - return [toolResultObj(id, name, result, isError)]; + const open = state.open.get(id); + state.open.delete(id); + const toolName = open?.toolName ?? blockToolName(name); + if (open?.adapter && !isError) { + const value = successValue(result); + if (value !== undefined) { + const folded = open.adapter.result(value, open.args); + if (folded) return [nativeToolResult(id, toolName, folded, false)]; + } + } + if (!isError) { + // No adapter (or it declined): if this is an MCP result, fold its `content` + // into readable text so the block isn't a raw JSON dump. + const folded = mcpFold(result); + if (folded) return [nativeToolResult(id, toolName, folded, false)]; + } + // Generic block / error / unexpected shape: emit the raw Cursor result under + // the (already-resolved) tool name. + return [nativeToolResult(id, toolName, result, isError)]; } /** @@ -283,10 +804,10 @@ function blockToolResultParts( */ function blockDanglingParts(state: BlockToolState): BlockToolPart[] { const parts: BlockToolPart[] = []; - for (const [id, name] of state.openToolCalls) { - parts.push(toolResultObj(id, name, DANGLING_TOOL_RESULT, true)); + for (const [id, open] of state.open) { + parts.push(nativeToolResult(id, open.toolName, DANGLING_TOOL_RESULT, true)); } - state.openToolCalls.clear(); + state.open.clear(); // Edits whose result never arrived: safe generic block + synthetic error // (no diff available to build a native edit). for (const [id, filePath] of state.pendingEdits) { diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index 598d76f..781030d 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -191,13 +191,15 @@ describe("cursorEventsToStream", () => { }); it("emits structured provider-executed tool-call/tool-result parts in 'blocks' mode", async () => { + // Use tools with no native opencode counterpart so they stay generic + // `cursor_*` blocks (mapped tools are covered in "native tool mapping"). const events: CursorEvent[] = [ - { type: "tool-call", id: "c1", name: "shell", input: { command: "ls" } }, + { type: "tool-call", id: "c1", name: "semSearch", input: { query: "x" } }, { type: "tool-result", id: "c1", - name: "shell", - result: { stdout: "a\nb" }, + name: "semSearch", + result: { results: "a\nb" }, isError: false, }, { @@ -232,8 +234,8 @@ describe("cursorEventsToStream", () => { )!; expect(call).toMatchObject({ toolCallId: "c1", - toolName: "cursor_shell", - input: JSON.stringify({ command: "ls" }), + toolName: "cursor_semSearch", + input: JSON.stringify({ query: "x" }), providerExecuted: true, dynamic: true, }); @@ -245,10 +247,10 @@ describe("cursorEventsToStream", () => { >; expect(results[0]).toMatchObject({ toolCallId: "c1", - toolName: "cursor_shell", + toolName: "cursor_semSearch", providerExecuted: true, dynamic: true, - result: { stdout: "a\nb" }, + result: { results: "a\nb" }, isError: false, }); expect(results[1]).toMatchObject({ @@ -272,7 +274,7 @@ describe("cursorEventsToStream", () => { // A run that dies mid-tool emits tool-call-started but never -completed. // Without a matching tool-result, opencode renders "Tool execution aborted". const events: CursorEvent[] = [ - { type: "tool-call", id: "c1", name: "shell", input: { command: "ls" } }, + { type: "tool-call", id: "c1", name: "semSearch", input: { query: "x" } }, { type: "finish" }, ]; const parts = await collect(cursorEventsToStream(gen(events), "blocks")); @@ -283,7 +285,7 @@ describe("cursorEventsToStream", () => { >; expect(result).toMatchObject({ toolCallId: "c1", - toolName: "cursor_shell", + toolName: "cursor_semSearch", isError: true, providerExecuted: true, dynamic: true, @@ -296,7 +298,7 @@ describe("cursorEventsToStream", () => { it("synthesizes error tool-results for dangling calls when the source throws", async () => { const events: CursorEvent[] = [ - { type: "tool-call", id: "c1", name: "read", input: { path: "x" } }, + { type: "tool-call", id: "c1", name: "semSearch", input: { query: "x" } }, ]; const parts = await collect( cursorEventsToStream( @@ -310,7 +312,7 @@ describe("cursorEventsToStream", () => { >; expect(result).toMatchObject({ toolCallId: "c1", - toolName: "cursor_read", + toolName: "cursor_semSearch", isError: true, }); const finish = parts.find((p) => p.type === "finish"); @@ -319,11 +321,11 @@ describe("cursorEventsToStream", () => { it("does not synthesize results for tool calls that completed", async () => { const events: CursorEvent[] = [ - { type: "tool-call", id: "c1", name: "shell", input: {} }, + { type: "tool-call", id: "c1", name: "semSearch", input: {} }, { type: "tool-result", id: "c1", - name: "shell", + name: "semSearch", result: { ok: 1 }, isError: false, }, @@ -415,11 +417,16 @@ describe("cursorEventsToContent (doGenerate)", () => { it("emits tool-call/tool-result content items in 'blocks' mode", async () => { const { content } = await cursorEventsToContent( gen([ - { type: "tool-call", id: "c1", name: "read", input: { path: "x" } }, + { + type: "tool-call", + id: "c1", + name: "semSearch", + input: { query: "x" }, + }, { type: "tool-result", id: "c1", - name: "read", + name: "semSearch", result: { data: "hi" }, isError: false, }, @@ -431,7 +438,7 @@ describe("cursorEventsToContent (doGenerate)", () => { const callItem = content.find((c) => c.type === "tool-call"); expect(callItem).toMatchObject({ toolCallId: "c1", - toolName: "cursor_read", + toolName: "cursor_semSearch", providerExecuted: true, dynamic: true, }); @@ -453,7 +460,7 @@ describe("cursorEventsToContent (doGenerate)", () => { it("synthesizes an error tool-result content item for a dangling call in 'blocks' mode", async () => { const { content } = await cursorEventsToContent( gen([ - { type: "tool-call", id: "c1", name: "shell", input: {} }, + { type: "tool-call", id: "c1", name: "semSearch", input: {} }, { type: "finish" }, ]), "blocks", @@ -464,7 +471,7 @@ describe("cursorEventsToContent (doGenerate)", () => { >; expect(resultItem).toMatchObject({ toolCallId: "c1", - toolName: "cursor_shell", + toolName: "cursor_semSearch", isError: true, }); }); @@ -627,20 +634,20 @@ describe("native edit mapping (blocks)", () => { }); }); - it("leaves non-edit tools as prefixed `cursor_*` blocks", async () => { + it("leaves tools with no native counterpart as prefixed `cursor_*` blocks", async () => { const events: CursorEvent[] = [ - { type: "tool-call", id: "c1", name: "read", input: { path: "/a.ts" } }, + { type: "tool-call", id: "c1", name: "semSearch", input: { query: "x" } }, { type: "tool-result", id: "c1", - name: "read", + name: "semSearch", result: { ok: true }, isError: false, }, { type: "finish" }, ]; const parts = await collect(cursorEventsToStream(gen(events), "blocks")); - expect(toolCalls(parts)[0]).toMatchObject({ toolName: "cursor_read" }); + expect(toolCalls(parts)[0]).toMatchObject({ toolName: "cursor_semSearch" }); }); it("maps edits onto native `edit` content items in doGenerate", async () => { @@ -679,6 +686,412 @@ describe("native edit mapping (blocks)", () => { }); }); +describe("native tool mapping (blocks)", () => { + // Drive a single Cursor tool call+result through the stream and return the + // emitted native tool-call/tool-result parts. + async function mapTool( + name: string, + input: unknown, + result: unknown, + isError = false, + ): Promise<{ call: ToolCallPart; result: ToolResultPart }> { + const parts = await collect( + cursorEventsToStream( + gen([ + { type: "tool-call", id: "t1", name, input }, + { type: "tool-result", id: "t1", name, result, isError }, + { type: "finish" }, + ]), + "blocks", + ), + ); + return { call: toolCalls(parts)[0]!, result: toolResults(parts)[0]! }; + } + + const foldedResult = (part: ToolResultPart) => + (part as unknown as { result: Record }).result; + + it("maps Cursor `shell` onto opencode's `bash` tool", async () => { + const { call, result } = await mapTool( + "shell", + { command: "ls -a", workingDirectory: "/tmp" }, + { + status: "success", + value: { + exitCode: 0, + signal: "", + stdout: "a\nb", + stderr: "", + executionTime: 5, + }, + }, + ); + expect(call).toMatchObject({ + toolName: "bash", + input: JSON.stringify({ command: "ls -a" }), + providerExecuted: true, + dynamic: true, + }); + expect(result).toMatchObject({ toolName: "bash", isError: false }); + expect(foldedResult(result)).toMatchObject({ + title: "ls -a", + metadata: { command: "ls -a", output: "a\nb" }, + output: "a\nb", + }); + }); + + it("includes stderr and a non-zero exit code in bash output", async () => { + const { result } = await mapTool( + "shell", + { command: "false" }, + { + status: "success", + value: { + exitCode: 1, + signal: "", + stdout: "", + stderr: "boom", + executionTime: 1, + }, + }, + ); + expect(foldedResult(result).output).toBe("boom\n(exit 1)"); + }); + + it("maps Cursor `read` onto opencode's `read` tool (path → filePath)", async () => { + const { call, result } = await mapTool( + "read", + { path: "/a.ts" }, + { + status: "success", + value: { content: "l1\nl2", totalLines: 2, fileSize: 6 }, + }, + ); + expect(call).toMatchObject({ + toolName: "read", + input: JSON.stringify({ filePath: "/a.ts" }), + }); + expect(foldedResult(result)).toMatchObject({ + title: "/a.ts", + output: "l1\nl2", + metadata: { preview: "l1\nl2", totalLines: 2 }, + }); + }); + + it("maps Cursor `write` onto opencode's `write` tool (renders the new content)", async () => { + const { call, result } = await mapTool( + "write", + { path: "/a.ts", fileText: "hello\nworld" }, + { + status: "success", + value: { path: "/a.ts", linesCreated: 2, fileSize: 11 }, + }, + ); + expect(call).toMatchObject({ + toolName: "write", + input: JSON.stringify({ filePath: "/a.ts", content: "hello\nworld" }), + }); + expect(foldedResult(result)).toMatchObject({ + title: "/a.ts", + metadata: { filepath: "/a.ts" }, + output: "Wrote 2 lines.", + }); + }); + + it("maps Cursor `glob` onto opencode's `glob` tool", async () => { + const { call, result } = await mapTool( + "glob", + { globPattern: "**/*.ts", targetDirectory: "/src" }, + { + status: "success", + value: { + files: ["/src/a.ts", "/src/b.ts"], + totalFiles: 2, + clientTruncated: false, + ripgrepTruncated: false, + }, + }, + ); + expect(call).toMatchObject({ + toolName: "glob", + input: JSON.stringify({ pattern: "**/*.ts", path: "/src" }), + }); + expect(foldedResult(result)).toMatchObject({ + metadata: { count: 2, truncated: false }, + output: "/src/a.ts\n/src/b.ts", + }); + }); + + it("maps Cursor `grep` onto opencode's `grep` tool (glob → include)", async () => { + const { call, result } = await mapTool( + "grep", + { pattern: "foo", path: "/src", glob: "*.ts" }, + { + status: "success", + value: { + workspaceResults: { + ws: { + type: "content", + output: { + matches: [ + { file: "/src/a.ts", lineNumber: 3, line: "const foo = 1" }, + ], + totalMatches: 1, + }, + }, + }, + }, + }, + ); + expect(call).toMatchObject({ + toolName: "grep", + input: JSON.stringify({ pattern: "foo", path: "/src", include: "*.ts" }), + }); + expect(foldedResult(result)).toMatchObject({ metadata: { matches: 1 } }); + expect(foldedResult(result).output).toContain("/src/a.ts:"); + expect(foldedResult(result).output).toContain("Line 3: const foo = 1"); + }); + + it("maps Cursor `ls` onto opencode's `list` tool (flattens the tree)", async () => { + const { call, result } = await mapTool( + "ls", + { path: "/src" }, + { + status: "success", + value: { + directoryTreeRoot: { + absPath: "/src", + childrenFiles: [{ name: "a.ts" }], + childrenDirs: [ + { + absPath: "/src/sub", + childrenFiles: [{ name: "b.ts" }], + childrenDirs: [], + }, + ], + }, + }, + }, + ); + expect(call).toMatchObject({ + toolName: "list", + input: JSON.stringify({ path: "/src" }), + }); + expect(foldedResult(result).output).toBe( + "/src/a.ts\n/src/sub/\n/src/sub/b.ts", + ); + }); + + it("maps Cursor `updateTodos` onto opencode's `todowrite` (inProgress → in_progress)", async () => { + const { call, result } = await mapTool( + "updateTodos", + { + todos: [ + { content: "a", status: "completed" }, + { content: "b", status: "inProgress" }, + { content: "c", status: "pending" }, + ], + }, + { status: "success", value: {} }, + ); + const todos = [ + { content: "a", status: "completed" }, + { content: "b", status: "in_progress" }, + { content: "c", status: "pending" }, + ]; + expect(call).toMatchObject({ + toolName: "todowrite", + input: JSON.stringify({ todos }), + }); + expect(foldedResult(result)).toMatchObject({ + title: "1/3", + metadata: { todos }, + }); + }); + + it("falls back to a generic `cursor_shell` block when the result shape is unexpected", async () => { + const { call, result } = await mapTool( + "shell", + { command: "ls" }, + { weird: 1 }, + ); + // Call is still emitted natively; only the result folding falls back. + expect(call).toMatchObject({ toolName: "bash" }); + expect(result).toMatchObject({ toolName: "bash", isError: false }); + expect(foldedResult(result)).toEqual({ weird: 1 }); + }); + + it("emits a native error result (matched name) when a mapped tool fails", async () => { + const { call, result } = await mapTool( + "read", + { path: "/missing" }, + { status: "error", error: "ENOENT" }, + true, + ); + // Name stays `read` so the call/result pair never dangles. + expect(call).toMatchObject({ toolName: "read" }); + expect(result).toMatchObject({ toolName: "read", isError: true }); + }); + + it("closes a dangling mapped call under its native name", async () => { + const parts = await collect( + cursorEventsToStream( + gen([ + { + type: "tool-call", + id: "t1", + name: "shell", + input: { command: "ls" }, + }, + { type: "finish" }, + ]), + "blocks", + ), + ); + expect(toolResults(parts)[0]).toMatchObject({ + toolName: "bash", + isError: true, + }); + }); + + it("maps Cursor `task` onto opencode's native `task` agent card", async () => { + const { call, result } = await mapTool( + "task", + { + description: "Investigate flake", + prompt: "find the cause", + subagentType: { kind: "agent", name: "explorer" }, + }, + { + status: "success", + value: { isBackground: false, resultSuffix: "done" }, + }, + ); + expect(call).toMatchObject({ + toolName: "task", + input: JSON.stringify({ + description: "Investigate flake", + subagent_type: "explorer", + }), + }); + expect(foldedResult(result)).toMatchObject({ + title: "Investigate flake", + output: "done", + }); + }); + + it("flags a background `task` in metadata", async () => { + const { result } = await mapTool( + "task", + { description: "bg", prompt: "p" }, + { status: "success", value: { isBackground: true } }, + ); + expect(foldedResult(result)).toMatchObject({ + metadata: { background: true }, + output: "Subagent task completed.", + }); + }); + + it("formats Cursor `readLints` as a `cursor_readLints` diagnostics list", async () => { + const { call, result } = await mapTool( + "readLints", + { paths: ["/a.ts"] }, + { + status: "success", + value: { + fileDiagnostics: [ + { + path: "/a.ts", + diagnostics: [ + { + severity: "error", + range: { start: { line: 4, character: 2 } }, + message: "Unexpected any", + }, + ], + }, + ], + }, + }, + ); + // No native lints tool — stays a prefixed block, but result is formatted. + expect(call).toMatchObject({ toolName: "cursor_readLints" }); + expect(result).toMatchObject({ toolName: "cursor_readLints" }); + expect(foldedResult(result)).toMatchObject({ + title: "1 problem", + metadata: { count: 1 }, + }); + // 1-based line/col, severity + message. + expect(foldedResult(result).output).toBe( + "/a.ts\n error L5:3: Unexpected any", + ); + }); + + it("reports a clean message when `readLints` finds nothing", async () => { + const { result } = await mapTool( + "readLints", + { paths: ["/a.ts"] }, + { status: "success", value: { fileDiagnostics: [] } }, + ); + expect(foldedResult(result)).toMatchObject({ + title: "No problems", + output: "No problems found.", + }); + }); + + it("formats Cursor `delete` as a one-line `cursor_delete` confirmation", async () => { + const { call, result } = await mapTool( + "delete", + { path: "/tmp/x.txt" }, + { status: "success", value: { fileSize: 128 } }, + ); + expect(call).toMatchObject({ toolName: "cursor_delete" }); + expect(foldedResult(result)).toMatchObject({ + title: "/tmp/x.txt", + output: "Deleted /tmp/x.txt (128 bytes).", + }); + }); + + it("maps a Cursor MCP web search onto opencode's native `websearch`", async () => { + const { call, result } = await mapTool( + "exa/web_search_exa", + { + providerIdentifier: "exa", + toolName: "web_search_exa", + args: { query: "opencode plugins" }, + }, + { + status: "success", + value: { + content: [ + { text: { text: "Result one" } }, + { text: { text: "Result two" } }, + ], + }, + }, + ); + expect(call).toMatchObject({ + toolName: "websearch", + input: JSON.stringify({ query: "opencode plugins" }), + }); + expect(foldedResult(result)).toMatchObject({ + metadata: { provider: "exa" }, + output: "Result one\nResult two", + }); + }); + + it("flattens a generic MCP result's `content` instead of dumping JSON", async () => { + const { call, result } = await mapTool( + "notion/search", + { providerIdentifier: "notion", toolName: "search", args: {} }, + { status: "success", value: { content: [{ text: { text: "a page" } }] } }, + ); + // Stays a prefixed block (no native renderer), but output is readable text. + expect(call).toMatchObject({ toolName: "cursor_notion_search" }); + expect(foldedResult(result)).toMatchObject({ output: "a page" }); + }); +}); + describe("mapUsage", () => { it("maps Cursor usage into the V3 nested shape", () => { expect(