diff --git a/.changeset/file-attachment-support.md b/.changeset/file-attachment-support.md new file mode 100644 index 00000000000..d5df0e2ac5d --- /dev/null +++ b/.changeset/file-attachment-support.md @@ -0,0 +1,26 @@ +--- +'@mastra/core': patch +'mastracode': patch +--- + +**`sendMessage` now accepts `files` instead of `images`**, supporting any file type with optional `filename`. + +**Breaking change:** Rename `images` to `files` when calling `harness.sendMessage()`: + +```ts +// Before +await harness.sendMessage({ + content: "Analyze this", + images: [{ data: base64Data, mimeType: "image/png" }], +}); + +// After +await harness.sendMessage({ + content: "Analyze this", + files: [{ data: base64Data, mediaType: "image/png", filename: "screenshot.png" }], +}); +``` + +- `files` accepts `{ data, mediaType, filename? }` — filenames are now preserved through storage and message history +- Text-based files (`text/*`, `application/json`) are automatically decoded to readable text content instead of being sent as binary, which models could not process +- `HarnessMessageContent` now includes a `file` type, so file parts round-trip correctly through message history diff --git a/mastracode/src/tui/mastra-tui.ts b/mastracode/src/tui/mastra-tui.ts index 45d1f19a2cb..de9d5d74e4f 100644 --- a/mastracode/src/tui/mastra-tui.ts +++ b/mastracode/src/tui/mastra-tui.ts @@ -215,7 +215,8 @@ export class MastraTUI { * Errors are handled via harness events. */ private fireMessage(content: string, images?: Array<{ data: string; mimeType: string }>): void { - this.state.harness.sendMessage({ content, images: images ? images : undefined }).catch(error => { + const files = images?.map(img => ({ data: img.data, mediaType: img.mimeType })); + this.state.harness.sendMessage({ content, files }).catch(error => { showError(this.state, error instanceof Error ? error.message : 'Unknown error'); }); } diff --git a/packages/core/src/agent/message-list/adapters/AIV4Adapter.ts b/packages/core/src/agent/message-list/adapters/AIV4Adapter.ts index 2d7a8201dab..32c86f13fca 100644 --- a/packages/core/src/agent/message-list/adapters/AIV4Adapter.ts +++ b/packages/core/src/agent/message-list/adapters/AIV4Adapter.ts @@ -429,6 +429,9 @@ export class AIV4Adapter { if (aiV4Part.providerOptions) { part.providerMetadata = aiV4Part.providerOptions; } + if (aiV4Part.filename) { + (part as Record).filename = aiV4Part.filename; + } parts.push(part); } else if (typeof aiV4Part.data === 'string') { const categorized = categorizeFileData(aiV4Part.data, aiV4Part.mimeType); @@ -442,6 +445,9 @@ export class AIV4Adapter { if (aiV4Part.providerOptions) { part.providerMetadata = aiV4Part.providerOptions; } + if (aiV4Part.filename) { + (part as Record).filename = aiV4Part.filename; + } parts.push(part); } else { try { @@ -453,6 +459,9 @@ export class AIV4Adapter { if (aiV4Part.providerOptions) { part.providerMetadata = aiV4Part.providerOptions; } + if (aiV4Part.filename) { + (part as Record).filename = aiV4Part.filename; + } parts.push(part); } catch (error) { console.error(`Failed to convert binary data to base64 in CoreMessage file part: ${error}`, error); @@ -468,6 +477,9 @@ export class AIV4Adapter { if (aiV4Part.providerOptions) { part.providerMetadata = aiV4Part.providerOptions; } + if (aiV4Part.filename) { + (part as Record).filename = aiV4Part.filename; + } parts.push(part); } catch (error) { console.error(`Failed to convert binary data to base64 in CoreMessage file part: ${error}`, error); diff --git a/packages/core/src/agent/message-list/adapters/AIV5Adapter.ts b/packages/core/src/agent/message-list/adapters/AIV5Adapter.ts index 50363d6fb1b..ed8f77e1c39 100644 --- a/packages/core/src/agent/message-list/adapters/AIV5Adapter.ts +++ b/packages/core/src/agent/message-list/adapters/AIV5Adapter.ts @@ -428,6 +428,7 @@ export class AIV5Adapter { mimeType: p.mediaType, data: p.url || '', providerMetadata: p.providerMetadata, + ...((p as { filename?: string }).filename ? { filename: (p as { filename?: string }).filename } : {}), }; } @@ -671,6 +672,9 @@ export class AIV5Adapter { if (part.providerOptions) { v2FilePart.providerMetadata = part.providerOptions; } + if ((filePart as { filename?: string }).filename) { + (v2FilePart as Record).filename = (filePart as { filename?: string }).filename; + } mastraDBParts.push(v2FilePart); experimental_attachments.push({ url: fileData, diff --git a/packages/core/src/harness/harness.ts b/packages/core/src/harness/harness.ts index 366fcd5e9c7..d89ecbf5976 100644 --- a/packages/core/src/harness/harness.ts +++ b/packages/core/src/harness/harness.ts @@ -1059,12 +1059,12 @@ export class Harness { */ async sendMessage({ content, - images, + files, tracingContext, tracingOptions, }: { content: string; - images?: Array<{ data: string; mimeType: string }>; + files?: Array<{ data: string; mediaType: string; filename?: string }>; tracingContext?: TracingContext; tracingOptions?: TracingOptions; }): Promise { @@ -1098,17 +1098,33 @@ export class Harness { streamOptions.toolsets = await this.buildToolsets(requestContext); let messageInput: string | Record = content; - if (images?.length) { + if (files?.length) { + const fileParts = files.map(f => { + const isText = f.mediaType.startsWith('text/') || f.mediaType === 'application/json'; + if (isText) { + let textContent = f.data; + // Decode data URI to plain text + const base64Match = f.data.match(/^data:[^;]*;base64,(.*)$/); + if (base64Match) { + try { + textContent = Buffer.from(base64Match[1]!, 'base64').toString('utf-8'); + } catch { + // Fall through with raw data + } + } + const label = f.filename ? `[File: ${f.filename}]` : '[Attached file]'; + return { type: 'text' as const, text: `${label}\n\`\`\`\n${textContent}\n\`\`\`` }; + } + return { + type: 'file' as const, + data: f.data, + mimeType: f.mediaType, + filename: f.filename, + }; + }); messageInput = { role: 'user', - content: [ - { type: 'text', text: content }, - ...images.map((img: { data: string; mimeType: string }) => ({ - type: 'file', - data: img.data, - mediaType: img.mimeType, - })), - ], + content: [{ type: 'text', text: content }, ...fileParts], }; } @@ -1297,6 +1313,32 @@ export class Harness { }); break; } + case 'file': + content.push({ + type: 'file', + data: typeof part.data === 'string' ? part.data : '', + mediaType: + (part as { mediaType?: string }).mediaType ?? + (part as { mimeType?: string }).mimeType ?? + 'application/octet-stream', + ...((part as { filename?: string }).filename ? { filename: (part as { filename?: string }).filename } : {}), + }); + break; + case 'image': { + const imgData = + typeof part.data === 'string' + ? part.data + : typeof (part as { image?: string }).image === 'string' + ? (part as { image?: string }).image! + : ''; + content.push({ + type: 'file', + data: imgData, + mediaType: + (part as { mimeType?: string }).mimeType ?? (part as { mediaType?: string }).mediaType ?? 'image/png', + }); + break; + } // Skip other part types (step-start, data-om-status, etc.) } } diff --git a/packages/core/src/harness/types.ts b/packages/core/src/harness/types.ts index d2f9523824c..fb5a53d5cb5 100644 --- a/packages/core/src/harness/types.ts +++ b/packages/core/src/harness/types.ts @@ -764,6 +764,7 @@ export type HarnessMessageContent = | { type: 'tool_call'; id: string; name: string; args: unknown } | { type: 'tool_result'; id: string; name: string; result: unknown; isError: boolean } | { type: 'image'; data: string; mimeType: string } + | { type: 'file'; data: string; mediaType: string; filename?: string } | { type: 'om_observation_start'; tokensToObserve: number;