From c7c496b668bdf212d0eaaf3ac4b6065917662f19 Mon Sep 17 00:00:00 2001 From: Lexi Emory Date: Thu, 18 Jun 2026 09:53:31 -0400 Subject: [PATCH] Add `createPlan` mapping to emit markdown as plain text --- src/provider/stream-map.ts | 43 ++++++++++++-- test/stream-map.test.ts | 119 +++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/src/provider/stream-map.ts b/src/provider/stream-map.ts index 8f0b488..888ef28 100644 --- a/src/provider/stream-map.ts +++ b/src/provider/stream-map.ts @@ -124,6 +124,17 @@ function toolResultObj( /** Cursor's file-edit tool surfaces with this name (its `toolCall.type`). */ const EDIT_TOOL_NAME = "edit"; +/** Cursor plan-mode tool; markdown lives in call args, result is empty. */ +const CREATE_PLAN_TOOL_NAME = "createPlan"; + +function isCreatePlanTool(name: string): boolean { + return name === CREATE_PLAN_TOOL_NAME; +} + +function createPlanContent(input: unknown): string | undefined { + return strField(input, "plan"); +} + function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null; } @@ -285,9 +296,10 @@ function mapTodos(args: unknown): Array<{ content: string; status: string }> { /** * 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). + * `generateImage`, `recordScreen`) are intentionally absent and fall through + * to generic `cursor_*` blocks. `edit` and `createPlan` are handled separately + * (edit: native call input depends on the diff in the result; createPlan: + * emitted as assistant markdown text so opencode renders it like a normal plan). */ const NATIVE_ADAPTERS: Record = { // Cursor `shell` → opencode `bash` (console renderer). @@ -743,10 +755,11 @@ interface OpenToolCall { interface BlockToolState { open: Map; pendingEdits: Map; + dropped: Set; } function newBlockToolState(): BlockToolState { - return { open: new Map(), pendingEdits: new Map() }; + return { open: new Map(), pendingEdits: new Map(), dropped: new Set() }; } /** Parts to emit for a blocks-mode `tool-call` event (edits are buffered). */ @@ -991,6 +1004,20 @@ export function cursorEventsToStream( reasoningLine(event.text); break; case "tool-call": + if (isCreatePlanTool(event.name)) { + const plan = createPlanContent(event.input); + if (plan) { + closeReasoning(); + streamedText = true; + controller.enqueue({ + type: "text-delta", + id: ensureText(), + delta: plan, + }); + } + toolState.dropped.add(event.id); + break; + } if (toolDisplay === "blocks") { const parts = blockToolCallParts( event.id, @@ -1015,6 +1042,7 @@ export function cursorEventsToStream( } break; case "tool-result": + if (toolState.dropped.delete(event.id)) break; if (toolDisplay === "blocks") { const parts = blockToolResultParts( event.id, @@ -1109,6 +1137,12 @@ export async function cursorEventsToContent( reasoning += event.text; break; case "tool-call": + if (isCreatePlanTool(event.name)) { + const plan = createPlanContent(event.input); + if (plan) text += plan; + toolState.dropped.add(event.id); + break; + } if (toolDisplay === "blocks") { for (const part of blockToolCallParts( event.id, @@ -1123,6 +1157,7 @@ export async function cursorEventsToContent( } break; case "tool-result": + if (toolState.dropped.delete(event.id)) break; if (toolDisplay === "blocks") { for (const part of blockToolResultParts( event.id, diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index d18ef36..e798153 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -1239,6 +1239,125 @@ describe("native tool mapping (blocks)", () => { }); }); +describe("createPlan mapping", () => { + const PLAN = "# Plan\n\n- step one\n- step two"; + + it("emits createPlan markdown as assistant text, not a tool block", async () => { + const events: CursorEvent[] = [ + { + type: "tool-call", + id: "p1", + name: "createPlan", + input: { plan: PLAN }, + }, + { + type: "tool-result", + id: "p1", + name: "createPlan", + result: { status: "success", value: {} }, + isError: false, + }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "blocks")); + + expect(types(parts)).not.toContain("tool-call"); + expect(types(parts)).not.toContain("tool-result"); + const text = parts + .filter( + (p): p is Extract => + p.type === "text-delta", + ) + .map((p) => p.delta) + .join(""); + expect(text).toBe(PLAN); + }); + + it("closes reasoning before emitting the plan as text", async () => { + const events: CursorEvent[] = [ + { type: "reasoning-delta", text: "thinking" }, + { + type: "tool-call", + id: "p1", + name: "createPlan", + input: { plan: PLAN }, + }, + { + type: "tool-result", + id: "p1", + name: "createPlan", + result: { status: "success", value: {} }, + isError: false, + }, + { type: "finish" }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "blocks")); + expect(types(parts)).toEqual([ + "stream-start", + "reasoning-start", + "reasoning-delta", + "reasoning-end", + "text-start", + "text-delta", + "text-end", + "finish", + ]); + }); + + it("does not duplicate finish.text when the plan was already streamed", async () => { + const events: CursorEvent[] = [ + { + type: "tool-call", + id: "p1", + name: "createPlan", + input: { plan: PLAN }, + }, + { + type: "tool-result", + id: "p1", + name: "createPlan", + result: { status: "success", value: {} }, + isError: false, + }, + { type: "finish", text: PLAN }, + ]; + const parts = await collect(cursorEventsToStream(gen(events), "blocks")); + const text = parts + .filter( + (p): p is Extract => + p.type === "text-delta", + ) + .map((p) => p.delta) + .join(""); + expect(text).toBe(PLAN); + }); + + it("maps createPlan to text content in doGenerate", async () => { + const { content } = await cursorEventsToContent( + gen([ + { + type: "tool-call", + id: "p1", + name: "createPlan", + input: { plan: PLAN }, + }, + { + type: "tool-result", + id: "p1", + name: "createPlan", + result: { status: "success", value: {} }, + isError: false, + }, + { type: "finish" }, + ]), + "blocks", + ); + expect(content.find((c) => c.type === "tool-call")).toBeUndefined(); + expect(content.find((c) => c.type === "tool-result")).toBeUndefined(); + expect(content).toMatchObject([{ type: "text", text: PLAN }]); + }); +}); + describe("mapUsage", () => { it("maps Cursor usage into the V3 nested shape", () => { expect(