Skip to content

Commit 0bca2ca

Browse files
fix(provider): note file/dir @-mentions as text instead of failing the turn (#58)
* 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. * test(provider): cover directory @-mentions and no-filename fallback Add explicit unit tests for application/x-directory file parts (the case that regressed) in both promptToCursorMessage and latestUserMessage, and a test asserting the note falls back to the source file URL when a file part has no filename (exercises describeSource). * fix(provider): guard file notes against raw base64 data describeSource returned any non-data: string verbatim, so a raw base64 file part with no filename inlined the whole blob into the note text. Now only URL-shaped strings are used as names; everything else falls back to "file". file:// sources are emitted as filesystem paths (via fileURLToPath, href fallback on invalid URLs) since the local agent's workspace tools act on paths.
1 parent 5152b63 commit 0bca2ca

3 files changed

Lines changed: 319 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: 64 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import type { LanguageModelV3Prompt } from "@ai-sdk/provider";
2-
import type { SDKImage, SDKUserMessage } from "@cursor/sdk";
1+
import { fileURLToPath } from "node:url";
2+
import type {
3+
LanguageModelV3FilePart,
4+
LanguageModelV3Prompt,
5+
} from "@ai-sdk/provider";
6+
import type { SDKUserMessage } from "@cursor/sdk";
37

48
/**
59
* Caps on inlined tool payloads in the flattened transcript. Tool outputs are
@@ -32,17 +36,19 @@ function truncate(text: string, cap: number): string {
3236
* The Cursor agent keeps its own per-agent conversation memory, but opencode
3337
* re-sends the whole history each turn. To stay correct without double-counting
3438
* 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.
39+
* the entire prompt into one transcript message. File attachments (images and
40+
* other files alike) are noted as text rather than attached natively: the
41+
* Cursor LOCAL SDK agent — the only backend this chat path uses — cannot accept
42+
* images in any form (URL images throw "URL images are only supported for cloud
43+
* SDK agents"; inline base64 images fail the run with an empty `status:"error"`,
44+
* surfacing as "Cursor run ended with status error" on an `@image` mention).
3745
*/
3846
export function promptToCursorMessage(
3947
prompt: LanguageModelV3Prompt,
4048
): SDKUserMessage {
4149
const lines: string[] = [];
42-
const images: SDKImage[] = [];
4350

44-
prompt.forEach((message, index) => {
45-
const isLast = index === prompt.length - 1;
51+
prompt.forEach((message) => {
4652
switch (message.role) {
4753
case "system":
4854
lines.push(`# System\n${message.content}`);
@@ -51,16 +57,10 @@ export function promptToCursorMessage(
5157
const text: string[] = [];
5258
for (const part of message.content) {
5359
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-
}
60+
// File parts (images and other files) can't be forwarded to the
61+
// local Cursor agent, so note them as text instead of dropping
62+
// them — the agent still learns a file was referenced.
63+
else if (part.type === "file") text.push(fileNote(part));
6464
}
6565
lines.push(`# User\n${text.join("\n")}`);
6666
break;
@@ -96,27 +96,56 @@ export function promptToCursorMessage(
9696
}
9797
});
9898

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

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-
}
102+
/**
103+
* A short text note standing in for a file attachment that can't be forwarded
104+
* to the local Cursor agent.
105+
*
106+
* opencode hands `@`-mentions to the provider as file parts; `text/plain` and
107+
* directory mentions are already inlined as text upstream, so what reaches the
108+
* provider here is images and other media/binaries. The Cursor LOCAL SDK agent
109+
* (the only backend this chat path uses) cannot accept any of them:
110+
* - `{ url }` images throw `ConfigurationError: URL images are only supported
111+
* for cloud SDK agents`,
112+
* - `{ data, mimeType }` inline-base64 images fail the run with an empty
113+
* `status:"error"` (the "Cursor run ended with status error" a user hits on
114+
* an `@image` mention — regardless of model).
115+
* So rather than attaching — and failing the whole turn — we note the file as
116+
* text. The run completes and the agent still learns a file was referenced.
117+
*/
118+
function fileNote(part: LanguageModelV3FilePart): string {
119+
const name = part.filename ?? describeSource(part.data) ?? "file";
120+
return `[attached file: ${name} (${part.mediaType}) — not forwarded to Cursor]`;
121+
}
122+
123+
/** Matches strings with a URL scheme followed by `://` (http, https, file, …). */
124+
const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
125+
126+
function describeSource(data: string | Uint8Array | URL): string | undefined {
127+
// file:// sources become filesystem paths: the agent runs locally with
128+
// workspace tools that operate on paths, so a path is actionable where a
129+
// file:// href is not.
130+
if (data instanceof URL)
131+
return data.protocol === "file:" ? fileUrlToPath(data) : data.href;
132+
// Only accept strings that look like a URL. AI-SDK file parts may carry
133+
// raw base64 in `data` (no `data:` prefix); returning that verbatim would
134+
// inline the entire blob into the note text.
135+
if (typeof data === "string" && URL_SCHEME.test(data))
136+
return data.startsWith("file://") ? fileUrlToPath(data) : data;
117137
return undefined;
118138
}
119139

140+
/** Convert a file:// URL to a filesystem path, falling back to the href when invalid. */
141+
function fileUrlToPath(url: string | URL): string {
142+
try {
143+
return fileURLToPath(url);
144+
} catch {
145+
return typeof url === "string" ? url : url.href;
146+
}
147+
}
148+
120149
/**
121150
* Extract only the final user turn as a Cursor message. Used when resuming a
122151
* pooled agent that already remembers the prior conversation, so we send just
@@ -130,17 +159,10 @@ export function latestUserMessage(
130159
if (!last || last.role !== "user") return undefined;
131160

132161
const text: string[] = [];
133-
const images: SDKImage[] = [];
134162
for (const part of last.content) {
135163
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-
}
164+
else if (part.type === "file") text.push(fileNote(part));
141165
}
142166

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

0 commit comments

Comments
 (0)