diff --git a/packages/foundation/schema/src/model/content.ts b/packages/foundation/schema/src/model/content.ts index ee28e4c77..7b0dcb5bd 100644 --- a/packages/foundation/schema/src/model/content.ts +++ b/packages/foundation/schema/src/model/content.ts @@ -28,6 +28,7 @@ export const ContentBlockSchema = z.discriminatedUnion('type', [ type: z.literal('image'), data: z.string(), // base64 mimeType: z.string(), + name: z.string().optional(), uri: z.string().optional(), annotations: AnnotationsSchema.optional(), }), diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 62d543f87..35c1b5ec4 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 77 as const; +export const WIRE_PROTOCOL_VERSION = 78 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ diff --git a/packages/foundation/schema/tests/contract/wire/agent.test.ts b/packages/foundation/schema/tests/contract/wire/agent.test.ts index e4aa13d17..53e162943 100644 --- a/packages/foundation/schema/tests/contract/wire/agent.test.ts +++ b/packages/foundation/schema/tests/contract/wire/agent.test.ts @@ -98,6 +98,31 @@ describe('agent wire variants', () => { ).toBe(false); }); + it('preserves image attachment names through the wire schema', () => { + const parsed = WireMessageSchema.parse({ + v: WIRE_PROTOCOL_VERSION, + id: 'message-1', + ts: 0, + payload: { + kind: 'agent.input', + clientReqId: 'request-1', + sessionId: 'session-1', + input: { + type: 'prompt', + content: [ + { type: 'image', data: 'cG5n', mimeType: 'image/png', name: 'architecture.png' }, + ], + }, + }, + }); + + expect(parsed).toMatchObject({ + payload: { + input: { content: [{ name: 'architecture.png' }] }, + }, + }); + }); + it('requires identity on user messages', () => { expect( WireMessageSchema.safeParse({ diff --git a/packages/host/agent-adapter/src/__tests__/opencode.test.ts b/packages/host/agent-adapter/src/__tests__/opencode.test.ts index 263f3bd4f..5fe9a70be 100644 --- a/packages/host/agent-adapter/src/__tests__/opencode.test.ts +++ b/packages/host/agent-adapter/src/__tests__/opencode.test.ts @@ -710,6 +710,32 @@ describe('OpenCodeAdapter prompt dispatch', () => { parts: [{ type: 'text', text: 'hi' }], }); }); + + it('forwards image attachment names as file part filenames', async () => { + const { adapter } = await makeAdapter(); + + await adapter.send({ + type: 'prompt', + content: [ + { type: 'text', text: 'review this' }, + { type: 'image', data: 'cG5n', mimeType: 'image/png', name: 'architecture.png' }, + ], + }); + + expect(client.session.promptAsync).toHaveBeenCalledWith( + expect.objectContaining({ + parts: [ + { type: 'text', text: 'review this' }, + { + type: 'file', + mime: 'image/png', + filename: 'architecture.png', + url: 'data:image/png;base64,cG5n', + }, + ], + }), + ); + }); }); describe('OpenCodeAdapter permission round-trip', () => { diff --git a/packages/host/agent-adapter/src/native/opencode/adapter.ts b/packages/host/agent-adapter/src/native/opencode/adapter.ts index 2a59487bd..c654b4ce4 100644 --- a/packages/host/agent-adapter/src/native/opencode/adapter.ts +++ b/packages/host/agent-adapter/src/native/opencode/adapter.ts @@ -492,6 +492,7 @@ export class OpenCodeAdapter extends BaseAgentAdapter { (image): FilePartInput => ({ type: 'file', mime: image.mimeType, + filename: image.name, url: `data:${image.mimeType};base64,${image.data}`, }), ), diff --git a/packages/host/agent-adapter/src/util.ts b/packages/host/agent-adapter/src/util.ts index caf7da2f1..724fbce77 100644 --- a/packages/host/agent-adapter/src/util.ts +++ b/packages/host/agent-adapter/src/util.ts @@ -23,11 +23,10 @@ export function contentToText(content: ContentBlock[]): string { * from — the target shape differs per vendor, so only this extraction step is shared. */ export function imageBlocksFrom( content: ContentBlock[], -): Array<{ data: string; mimeType: string }> { - return content.reduce>((images, c) => { - if (c.type === 'image') images.push({ data: c.data, mimeType: c.mimeType }); - return images; - }, []); +): Array> { + return content.filter( + (block): block is Extract => block.type === 'image', + ); } const PATH_INPUT_KEYS = ['file_path', 'path', 'notebook_path', 'filePath'] as const; diff --git a/packages/presentation/ui/src/chat/__tests__/content-block-view.test.tsx b/packages/presentation/ui/src/chat/__tests__/content-block-view.test.tsx index 1be4ae562..e3b40d4b8 100644 --- a/packages/presentation/ui/src/chat/__tests__/content-block-view.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/content-block-view.test.tsx @@ -14,6 +14,23 @@ function resourceLink(uri: string): ContentBlock { return { type: 'resource_link', uri, name: 'ARCHITECTURE.md' }; } +it('uses the preserved attachment name for an image preview', () => { + const { getByRole } = render( + , + ); + + const image = getByRole('img', { name: 'architecture.png' }); + expect(image.getAttribute('title')).toBe('architecture.png'); + expect(image.getAttribute('src')).toBe('data:image/png;base64,cG5n'); +}); + // The pre-chip renderer emitted a target=_blank anchor whose file:// href was blocked from // http(s) origins — a dead click. File uris must route the artifact host actions instead. it('opens file resource links through the artifact host actions', () => { diff --git a/packages/presentation/ui/src/chat/content-block-view.tsx b/packages/presentation/ui/src/chat/content-block-view.tsx index a24e90f2e..8996f493a 100644 --- a/packages/presentation/ui/src/chat/content-block-view.tsx +++ b/packages/presentation/ui/src/chat/content-block-view.tsx @@ -36,9 +36,10 @@ export function ContentBlockView({ case 'image': return ( ); case 'audio': diff --git a/packages/presentation/ui/src/shell/__tests__/composer-attachments.test.ts b/packages/presentation/ui/src/shell/__tests__/composer-attachments.test.ts index f3632d63e..391b846c1 100644 --- a/packages/presentation/ui/src/shell/__tests__/composer-attachments.test.ts +++ b/packages/presentation/ui/src/shell/__tests__/composer-attachments.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + attachmentFromReadFile, failedComposerAttachmentFromPath, pendingComposerAttachment, } from '../composer-attachments'; @@ -25,4 +26,24 @@ describe('composer attachment presentation kinds', () => { it('uses the file extension when a failed native read has no MIME type', () => { expect(failedComposerAttachmentFromPath('/tmp/guide.pdf', 'Failed').kind).toBe('pdf'); }); + + it('keeps the basename on image blocks created from native file reads', () => { + const attachment = attachmentFromReadFile( + { + path: String.raw`C:\screenshots\architecture.png`, + content: 'cG5n', + encoding: 'base64', + mimeType: 'image/png', + size: 3, + }, + { unsupportedType: 'Unsupported', tooLarge: 'Too large' }, + ); + + expect(attachment.block).toEqual({ + type: 'image', + data: 'cG5n', + mimeType: 'image/png', + name: 'architecture.png', + }); + }); }); diff --git a/packages/presentation/ui/src/shell/__tests__/composer-command-menu.test.tsx b/packages/presentation/ui/src/shell/__tests__/composer-command-menu.test.tsx index 81643941c..b2ae4cb5e 100644 --- a/packages/presentation/ui/src/shell/__tests__/composer-command-menu.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/composer-command-menu.test.tsx @@ -391,7 +391,12 @@ describe('Composer command menu', () => { expect(onSend).toHaveBeenCalledExactlyOnceWith([ { type: 'text', text: 'ship it' }, - { type: 'image', data: expect.any(String) as string, mimeType: 'image/png' }, + { + type: 'image', + data: expect.any(String) as string, + mimeType: 'image/png', + name: 'probe.png', + }, ]); expect(screen.queryByRole('img', { name: 'probe.png' })).toBeNull(); }); diff --git a/packages/presentation/ui/src/shell/composer-attachments.ts b/packages/presentation/ui/src/shell/composer-attachments.ts index 339f73e1b..0587ccfdd 100644 --- a/packages/presentation/ui/src/shell/composer-attachments.ts +++ b/packages/presentation/ui/src/shell/composer-attachments.ts @@ -87,7 +87,12 @@ export async function readImageFileAsComposerAttachment( ...pending, status: 'ready', url: dataUrl, - block: { type: 'image', data: dataUrl.slice(dataUrl.indexOf(',') + 1), mimeType: file.type }, + block: { + type: 'image', + data: dataUrl.slice(dataUrl.indexOf(',') + 1), + mimeType: file.type, + name: file.name, + }, }; } @@ -155,7 +160,7 @@ export function attachmentFromReadFile( sizeBytes: file.size, status: 'ready', url: `data:${mimeType};base64,${file.content}`, - block: { type: 'image', data: file.content, mimeType }, + block: { type: 'image', data: file.content, mimeType, name }, }; }