Skip to content

Commit 40a6d28

Browse files
committed
fix(provider): note file/dir @-mentions as text instead of failing the turn
The local Cursor SDK agent (the only backend the chat path uses) cannot accept attachments in any form: `{url}` images throw "URL images are only supported for cloud SDK agents", and inline `{data,mimeType}` base64 images end the run with an empty `status:"error"` — surfacing to users as "Cursor run ended with status error" on an `@image` or `@directory` mention. Stop attaching file parts via SDKUserMessage.images. Route every non-text file part (images, PDFs, directories, other media) through a text note so the run always completes and the agent still learns a file was referenced. Applies to both promptToCursorMessage and latestUserMessage (fresh + resumed turns). Text/plain and directory mentions that opencode inlines upstream are unaffected.
1 parent 5152b63 commit 40a6d28

3 files changed

Lines changed: 188 additions & 53 deletions

File tree

src/provider/language-model.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ export class CursorLanguageModel implements LanguageModelV3 {
7676
readonly specificationVersion = "v3" as const;
7777
readonly modelId: string;
7878
readonly provider: string;
79-
// Images are passed inline as base64 data, so no URLs are fetched natively.
79+
// The local Cursor agent has no attachment channel, so file parts (images,
80+
// directories, other media) are noted as text in message-map rather than
81+
// fetched or attached; no URLs are resolved natively.
8082
readonly supportedUrls: Record<string, RegExp[]> = {};
8183

8284
constructor(

src/provider/message-map.ts

Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import type { LanguageModelV3Prompt } from "@ai-sdk/provider";
2-
import type { SDKImage, SDKUserMessage } from "@cursor/sdk";
1+
import type {
2+
LanguageModelV3FilePart,
3+
LanguageModelV3Prompt,
4+
} from "@ai-sdk/provider";
5+
import type { SDKUserMessage } from "@cursor/sdk";
36

47
/**
58
* Caps on inlined tool payloads in the flattened transcript. Tool outputs are
@@ -32,17 +35,19 @@ function truncate(text: string, cap: number): string {
3235
* The Cursor agent keeps its own per-agent conversation memory, but opencode
3336
* re-sends the whole history each turn. To stay correct without double-counting
3437
* context, we create a fresh agent per turn (see language-model.ts) and flatten
35-
* the entire prompt into one transcript message. Images from the final user
36-
* turn are attached natively so multimodal models can see them.
38+
* the entire prompt into one transcript message. File attachments (images and
39+
* other files alike) are noted as text rather than attached natively: the
40+
* Cursor LOCAL SDK agent — the only backend this chat path uses — cannot accept
41+
* images in any form (URL images throw "URL images are only supported for cloud
42+
* SDK agents"; inline base64 images fail the run with an empty `status:"error"`,
43+
* surfacing as "Cursor run ended with status error" on an `@image` mention).
3744
*/
3845
export function promptToCursorMessage(
3946
prompt: LanguageModelV3Prompt,
4047
): SDKUserMessage {
4148
const lines: string[] = [];
42-
const images: SDKImage[] = [];
4349

44-
prompt.forEach((message, index) => {
45-
const isLast = index === prompt.length - 1;
50+
prompt.forEach((message) => {
4651
switch (message.role) {
4752
case "system":
4853
lines.push(`# System\n${message.content}`);
@@ -51,16 +56,10 @@ export function promptToCursorMessage(
5156
const text: string[] = [];
5257
for (const part of message.content) {
5358
if (part.type === "text") text.push(part.text);
54-
else if (
55-
part.type === "file" &&
56-
part.mediaType.startsWith("image/")
57-
) {
58-
const image = fileToImage(part.data, part.mediaType);
59-
// Only attach images natively for the final user turn; earlier ones
60-
// are referenced by transcript order.
61-
if (isLast && image) images.push(image);
62-
text.push("[image attached]");
63-
}
59+
// File parts (images and other files) can't be forwarded to the
60+
// local Cursor agent, so note them as text instead of dropping
61+
// them — the agent still learns a file was referenced.
62+
else if (part.type === "file") text.push(fileNote(part));
6463
}
6564
lines.push(`# User\n${text.join("\n")}`);
6665
break;
@@ -96,24 +95,33 @@ export function promptToCursorMessage(
9695
}
9796
});
9897

99-
const out: SDKUserMessage = { text: lines.join("\n\n") };
100-
if (images.length > 0) out.images = images;
101-
return out;
98+
return { text: lines.join("\n\n") };
10299
}
103100

104-
function fileToImage(
105-
data: string | Uint8Array | URL,
106-
mediaType: string,
107-
): SDKImage | undefined {
108-
if (data instanceof URL) return { url: data.toString() };
109-
if (typeof data === "string") {
110-
// Either a URL or already-base64 encoded data.
111-
if (/^https?:\/\//i.test(data)) return { url: data };
112-
return { data, mimeType: mediaType };
113-
}
114-
if (data instanceof Uint8Array) {
115-
return { data: Buffer.from(data).toString("base64"), mimeType: mediaType };
116-
}
101+
/**
102+
* A short text note standing in for a file attachment that can't be forwarded
103+
* to the local Cursor agent.
104+
*
105+
* opencode hands `@`-mentions to the provider as file parts; `text/plain` and
106+
* directory mentions are already inlined as text upstream, so what reaches the
107+
* provider here is images and other media/binaries. The Cursor LOCAL SDK agent
108+
* (the only backend this chat path uses) cannot accept any of them:
109+
* - `{ url }` images throw `ConfigurationError: URL images are only supported
110+
* for cloud SDK agents`,
111+
* - `{ data, mimeType }` inline-base64 images fail the run with an empty
112+
* `status:"error"` (the "Cursor run ended with status error" a user hits on
113+
* an `@image` mention — regardless of model).
114+
* So rather than attaching — and failing the whole turn — we note the file as
115+
* text. The run completes and the agent still learns a file was referenced.
116+
*/
117+
function fileNote(part: LanguageModelV3FilePart): string {
118+
const name = part.filename ?? describeSource(part.data) ?? "file";
119+
return `[attached file: ${name} (${part.mediaType}) — not forwarded to Cursor]`;
120+
}
121+
122+
function describeSource(data: string | Uint8Array | URL): string | undefined {
123+
if (data instanceof URL) return data.href;
124+
if (typeof data === "string" && !data.startsWith("data:")) return data;
117125
return undefined;
118126
}
119127

@@ -130,17 +138,10 @@ export function latestUserMessage(
130138
if (!last || last.role !== "user") return undefined;
131139

132140
const text: string[] = [];
133-
const images: SDKImage[] = [];
134141
for (const part of last.content) {
135142
if (part.type === "text") text.push(part.text);
136-
else if (part.type === "file" && part.mediaType.startsWith("image/")) {
137-
const image = fileToImage(part.data, part.mediaType);
138-
if (image) images.push(image);
139-
text.push("[image attached]");
140-
}
143+
else if (part.type === "file") text.push(fileNote(part));
141144
}
142145

143-
const out: SDKUserMessage = { text: text.join("\n") };
144-
if (images.length > 0) out.images = images;
145-
return out;
146+
return { text: text.join("\n") };
146147
}

test/message-map.test.ts

Lines changed: 142 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
11
import { describe, expect, it } from "vitest";
2+
import { mkdtempSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { pathToFileURL } from "node:url";
26
import type { LanguageModelV3Prompt } from "@ai-sdk/provider";
37
import {
48
latestUserMessage,
59
promptToCursorMessage,
610
} from "../src/provider/message-map.js";
711

12+
/** Write a temp file and return its absolute path + file:// URL string. */
13+
function tempFile(name: string, bytes: Buffer): { path: string; url: string } {
14+
const dir = mkdtempSync(join(tmpdir(), "cursor-msgmap-"));
15+
const path = join(dir, name);
16+
writeFileSync(path, bytes);
17+
return { path, url: pathToFileURL(path).href };
18+
}
19+
20+
const PNG_BYTES = Buffer.from(
21+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
22+
"base64",
23+
);
24+
825
describe("promptToCursorMessage", () => {
926
it("flattens a multi-role conversation into a transcript", () => {
1027
const prompt: LanguageModelV3Prompt = [
@@ -21,24 +38,32 @@ describe("promptToCursorMessage", () => {
2138
expect(msg.images).toBeUndefined();
2239
});
2340

24-
it("attaches images from the final user turn as base64 data", () => {
41+
// Cursor's LOCAL SDK agent (the only backend the chat path uses) cannot
42+
// accept images in any form: `{url}` throws "URL images are only supported
43+
// for cloud SDK agents", and `{data,mimeType}` base64 fails the run with an
44+
// empty `status:"error"` (the "Cursor run ended with status error" a user
45+
// hits on an `@image` mention). So we never populate `images`; instead every
46+
// file part is surfaced as a text note so the run always completes.
47+
it("never attaches images; notes an image file part as text", () => {
2548
const bytes = new Uint8Array([1, 2, 3, 4]);
2649
const prompt: LanguageModelV3Prompt = [
2750
{
2851
role: "user",
2952
content: [
3053
{ type: "text", text: "Describe this" },
31-
{ type: "file", data: bytes, mediaType: "image/png" },
54+
{
55+
type: "file",
56+
data: bytes,
57+
mediaType: "image/png",
58+
filename: "shot.png",
59+
},
3260
],
3361
},
3462
];
3563
const msg = promptToCursorMessage(prompt);
36-
expect(msg.images).toHaveLength(1);
37-
expect(msg.images![0]).toEqual({
38-
data: Buffer.from(bytes).toString("base64"),
39-
mimeType: "image/png",
40-
});
41-
expect(msg.text).toContain("[image attached]");
64+
expect(msg.images).toBeUndefined();
65+
expect(msg.text).toContain("shot.png");
66+
expect(msg.text).toContain("image/png");
4267
});
4368

4469
it("includes tool outputs (truncated) instead of dropping them", () => {
@@ -90,7 +115,7 @@ describe("promptToCursorMessage", () => {
90115
expect(msg.text.length).toBeLessThan(3000);
91116
});
92117

93-
it("passes through image URLs", () => {
118+
it("notes an http(s) image URL as text without attaching it", () => {
94119
const prompt: LanguageModelV3Prompt = [
95120
{
96121
role: "user",
@@ -99,12 +124,77 @@ describe("promptToCursorMessage", () => {
99124
type: "file",
100125
data: "https://example.com/a.png",
101126
mediaType: "image/png",
127+
filename: "a.png",
102128
},
103129
],
104130
},
105131
];
106132
const msg = promptToCursorMessage(prompt);
107-
expect(msg.images![0]).toEqual({ url: "https://example.com/a.png" });
133+
expect(msg.images).toBeUndefined();
134+
expect(msg.text).toContain("a.png");
135+
});
136+
137+
it("notes a file:// image URL (URL object) as text, never as an image", () => {
138+
const { url } = tempFile("px.png", PNG_BYTES);
139+
const prompt: LanguageModelV3Prompt = [
140+
{
141+
role: "user",
142+
content: [
143+
{ type: "text", text: "look" },
144+
{
145+
type: "file",
146+
data: new URL(url),
147+
mediaType: "image/png",
148+
filename: "px.png",
149+
},
150+
],
151+
},
152+
];
153+
const msg = promptToCursorMessage(prompt);
154+
expect(msg.images).toBeUndefined();
155+
expect(msg.text).toContain("px.png");
156+
});
157+
158+
it("notes a data: URI image as text, never as an image", () => {
159+
const b64 = PNG_BYTES.toString("base64");
160+
const prompt: LanguageModelV3Prompt = [
161+
{
162+
role: "user",
163+
content: [
164+
{
165+
type: "file",
166+
data: `data:image/png;base64,${b64}`,
167+
mediaType: "image/png",
168+
filename: "inline.png",
169+
},
170+
],
171+
},
172+
];
173+
const msg = promptToCursorMessage(prompt);
174+
expect(msg.images).toBeUndefined();
175+
expect(msg.text).toContain("inline.png");
176+
});
177+
178+
it("notes a non-image file attachment as text instead of dropping it", () => {
179+
const { url } = tempFile("doc.pdf", Buffer.from("%PDF-1.4\n"));
180+
const prompt: LanguageModelV3Prompt = [
181+
{
182+
role: "user",
183+
content: [
184+
{ type: "text", text: "summarize" },
185+
{
186+
type: "file",
187+
data: new URL(url),
188+
mediaType: "application/pdf",
189+
filename: "doc.pdf",
190+
},
191+
],
192+
},
193+
];
194+
const msg = promptToCursorMessage(prompt);
195+
expect(msg.images).toBeUndefined();
196+
expect(msg.text).toContain("doc.pdf");
197+
expect(msg.text).toContain("application/pdf");
108198
});
109199
});
110200

@@ -126,4 +216,46 @@ describe("latestUserMessage", () => {
126216
];
127217
expect(latestUserMessage(prompt)).toBeUndefined();
128218
});
219+
220+
it("notes an image in the final user turn as text, never as an image", () => {
221+
const { url } = tempFile("px.png", PNG_BYTES);
222+
const prompt: LanguageModelV3Prompt = [
223+
{
224+
role: "user",
225+
content: [
226+
{ type: "text", text: "look" },
227+
{
228+
type: "file",
229+
data: new URL(url),
230+
mediaType: "image/png",
231+
filename: "px.png",
232+
},
233+
],
234+
},
235+
];
236+
const msg = latestUserMessage(prompt);
237+
expect(msg?.images).toBeUndefined();
238+
expect(msg?.text).toContain("px.png");
239+
});
240+
241+
it("notes a non-image attachment in the final user turn as text", () => {
242+
const { url } = tempFile("doc.pdf", Buffer.from("%PDF-1.4\n"));
243+
const prompt: LanguageModelV3Prompt = [
244+
{
245+
role: "user",
246+
content: [
247+
{ type: "text", text: "summarize" },
248+
{
249+
type: "file",
250+
data: new URL(url),
251+
mediaType: "application/pdf",
252+
filename: "doc.pdf",
253+
},
254+
],
255+
},
256+
];
257+
const msg = latestUserMessage(prompt);
258+
expect(msg?.images).toBeUndefined();
259+
expect(msg?.text).toContain("doc.pdf");
260+
});
129261
});

0 commit comments

Comments
 (0)