Skip to content

Commit c7c496b

Browse files
committed
Add createPlan mapping to emit markdown as plain text
1 parent a91a2bd commit c7c496b

2 files changed

Lines changed: 158 additions & 4 deletions

File tree

src/provider/stream-map.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,17 @@ function toolResultObj(
124124
/** Cursor's file-edit tool surfaces with this name (its `toolCall.type`). */
125125
const EDIT_TOOL_NAME = "edit";
126126

127+
/** Cursor plan-mode tool; markdown lives in call args, result is empty. */
128+
const CREATE_PLAN_TOOL_NAME = "createPlan";
129+
130+
function isCreatePlanTool(name: string): boolean {
131+
return name === CREATE_PLAN_TOOL_NAME;
132+
}
133+
134+
function createPlanContent(input: unknown): string | undefined {
135+
return strField(input, "plan");
136+
}
137+
127138
function isRecord(v: unknown): v is Record<string, unknown> {
128139
return typeof v === "object" && v !== null;
129140
}
@@ -285,9 +296,10 @@ function mapTodos(args: unknown): Array<{ content: string; status: string }> {
285296
/**
286297
* Cursor tool name → opencode native tool adapter. Cursor tools without a
287298
* natural opencode counterpart (`delete`, `mcp`, `semSearch`, `readLints`,
288-
* `generateImage`, `createPlan`, `recordScreen`, `task`) are intentionally
289-
* absent and fall through to generic `cursor_*` blocks. `edit` is handled
290-
* separately (its native call input depends on the diff in the result).
299+
* `generateImage`, `recordScreen`) are intentionally absent and fall through
300+
* to generic `cursor_*` blocks. `edit` and `createPlan` are handled separately
301+
* (edit: native call input depends on the diff in the result; createPlan:
302+
* emitted as assistant markdown text so opencode renders it like a normal plan).
291303
*/
292304
const NATIVE_ADAPTERS: Record<string, NativeToolAdapter> = {
293305
// Cursor `shell` → opencode `bash` (console renderer).
@@ -743,10 +755,11 @@ interface OpenToolCall {
743755
interface BlockToolState {
744756
open: Map<string, OpenToolCall>;
745757
pendingEdits: Map<string, string>;
758+
dropped: Set<string>;
746759
}
747760

748761
function newBlockToolState(): BlockToolState {
749-
return { open: new Map(), pendingEdits: new Map() };
762+
return { open: new Map(), pendingEdits: new Map(), dropped: new Set() };
750763
}
751764

752765
/** Parts to emit for a blocks-mode `tool-call` event (edits are buffered). */
@@ -991,6 +1004,20 @@ export function cursorEventsToStream(
9911004
reasoningLine(event.text);
9921005
break;
9931006
case "tool-call":
1007+
if (isCreatePlanTool(event.name)) {
1008+
const plan = createPlanContent(event.input);
1009+
if (plan) {
1010+
closeReasoning();
1011+
streamedText = true;
1012+
controller.enqueue({
1013+
type: "text-delta",
1014+
id: ensureText(),
1015+
delta: plan,
1016+
});
1017+
}
1018+
toolState.dropped.add(event.id);
1019+
break;
1020+
}
9941021
if (toolDisplay === "blocks") {
9951022
const parts = blockToolCallParts(
9961023
event.id,
@@ -1015,6 +1042,7 @@ export function cursorEventsToStream(
10151042
}
10161043
break;
10171044
case "tool-result":
1045+
if (toolState.dropped.delete(event.id)) break;
10181046
if (toolDisplay === "blocks") {
10191047
const parts = blockToolResultParts(
10201048
event.id,
@@ -1109,6 +1137,12 @@ export async function cursorEventsToContent(
11091137
reasoning += event.text;
11101138
break;
11111139
case "tool-call":
1140+
if (isCreatePlanTool(event.name)) {
1141+
const plan = createPlanContent(event.input);
1142+
if (plan) text += plan;
1143+
toolState.dropped.add(event.id);
1144+
break;
1145+
}
11121146
if (toolDisplay === "blocks") {
11131147
for (const part of blockToolCallParts(
11141148
event.id,
@@ -1123,6 +1157,7 @@ export async function cursorEventsToContent(
11231157
}
11241158
break;
11251159
case "tool-result":
1160+
if (toolState.dropped.delete(event.id)) break;
11261161
if (toolDisplay === "blocks") {
11271162
for (const part of blockToolResultParts(
11281163
event.id,

test/stream-map.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,6 +1239,125 @@ describe("native tool mapping (blocks)", () => {
12391239
});
12401240
});
12411241

1242+
describe("createPlan mapping", () => {
1243+
const PLAN = "# Plan\n\n- step one\n- step two";
1244+
1245+
it("emits createPlan markdown as assistant text, not a tool block", async () => {
1246+
const events: CursorEvent[] = [
1247+
{
1248+
type: "tool-call",
1249+
id: "p1",
1250+
name: "createPlan",
1251+
input: { plan: PLAN },
1252+
},
1253+
{
1254+
type: "tool-result",
1255+
id: "p1",
1256+
name: "createPlan",
1257+
result: { status: "success", value: {} },
1258+
isError: false,
1259+
},
1260+
{ type: "finish" },
1261+
];
1262+
const parts = await collect(cursorEventsToStream(gen(events), "blocks"));
1263+
1264+
expect(types(parts)).not.toContain("tool-call");
1265+
expect(types(parts)).not.toContain("tool-result");
1266+
const text = parts
1267+
.filter(
1268+
(p): p is Extract<LanguageModelV3StreamPart, { type: "text-delta" }> =>
1269+
p.type === "text-delta",
1270+
)
1271+
.map((p) => p.delta)
1272+
.join("");
1273+
expect(text).toBe(PLAN);
1274+
});
1275+
1276+
it("closes reasoning before emitting the plan as text", async () => {
1277+
const events: CursorEvent[] = [
1278+
{ type: "reasoning-delta", text: "thinking" },
1279+
{
1280+
type: "tool-call",
1281+
id: "p1",
1282+
name: "createPlan",
1283+
input: { plan: PLAN },
1284+
},
1285+
{
1286+
type: "tool-result",
1287+
id: "p1",
1288+
name: "createPlan",
1289+
result: { status: "success", value: {} },
1290+
isError: false,
1291+
},
1292+
{ type: "finish" },
1293+
];
1294+
const parts = await collect(cursorEventsToStream(gen(events), "blocks"));
1295+
expect(types(parts)).toEqual([
1296+
"stream-start",
1297+
"reasoning-start",
1298+
"reasoning-delta",
1299+
"reasoning-end",
1300+
"text-start",
1301+
"text-delta",
1302+
"text-end",
1303+
"finish",
1304+
]);
1305+
});
1306+
1307+
it("does not duplicate finish.text when the plan was already streamed", async () => {
1308+
const events: CursorEvent[] = [
1309+
{
1310+
type: "tool-call",
1311+
id: "p1",
1312+
name: "createPlan",
1313+
input: { plan: PLAN },
1314+
},
1315+
{
1316+
type: "tool-result",
1317+
id: "p1",
1318+
name: "createPlan",
1319+
result: { status: "success", value: {} },
1320+
isError: false,
1321+
},
1322+
{ type: "finish", text: PLAN },
1323+
];
1324+
const parts = await collect(cursorEventsToStream(gen(events), "blocks"));
1325+
const text = parts
1326+
.filter(
1327+
(p): p is Extract<LanguageModelV3StreamPart, { type: "text-delta" }> =>
1328+
p.type === "text-delta",
1329+
)
1330+
.map((p) => p.delta)
1331+
.join("");
1332+
expect(text).toBe(PLAN);
1333+
});
1334+
1335+
it("maps createPlan to text content in doGenerate", async () => {
1336+
const { content } = await cursorEventsToContent(
1337+
gen([
1338+
{
1339+
type: "tool-call",
1340+
id: "p1",
1341+
name: "createPlan",
1342+
input: { plan: PLAN },
1343+
},
1344+
{
1345+
type: "tool-result",
1346+
id: "p1",
1347+
name: "createPlan",
1348+
result: { status: "success", value: {} },
1349+
isError: false,
1350+
},
1351+
{ type: "finish" },
1352+
]),
1353+
"blocks",
1354+
);
1355+
expect(content.find((c) => c.type === "tool-call")).toBeUndefined();
1356+
expect(content.find((c) => c.type === "tool-result")).toBeUndefined();
1357+
expect(content).toMatchObject([{ type: "text", text: PLAN }]);
1358+
});
1359+
});
1360+
12421361
describe("mapUsage", () => {
12431362
it("maps Cursor usage into the V3 nested shape", () => {
12441363
expect(

0 commit comments

Comments
 (0)