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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions src/provider/stream-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return typeof v === "object" && v !== null;
}
Expand Down Expand Up @@ -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<string, NativeToolAdapter> = {
// Cursor `shell` → opencode `bash` (console renderer).
Expand Down Expand Up @@ -743,10 +755,11 @@ interface OpenToolCall {
interface BlockToolState {
open: Map<string, OpenToolCall>;
pendingEdits: Map<string, string>;
dropped: Set<string>;
}

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). */
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
119 changes: 119 additions & 0 deletions test/stream-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LanguageModelV3StreamPart, { type: "text-delta" }> =>
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<LanguageModelV3StreamPart, { type: "text-delta" }> =>
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(
Expand Down