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
1 change: 1 addition & 0 deletions packages/foundation/schema/src/model/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}),
Expand Down
2 changes: 1 addition & 1 deletion packages/foundation/schema/src/wire/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
25 changes: 25 additions & 0 deletions packages/foundation/schema/tests/contract/wire/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] },
},
});
});

Comment thread
xiaoland marked this conversation as resolved.
it('requires identity on user messages', () => {
expect(
WireMessageSchema.safeParse({
Expand Down
26 changes: 26 additions & 0 deletions packages/host/agent-adapter/src/__tests__/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/host/agent-adapter/src/native/opencode/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
}),
),
Expand Down
9 changes: 4 additions & 5 deletions packages/host/agent-adapter/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<{ data: string; mimeType: string }>>((images, c) => {
if (c.type === 'image') images.push({ data: c.data, mimeType: c.mimeType });
return images;
}, []);
): Array<Extract<ContentBlock, { type: 'image' }>> {
return content.filter(
(block): block is Extract<ContentBlock, { type: 'image' }> => block.type === 'image',
);
}

const PATH_INPUT_KEYS = ['file_path', 'path', 'notebook_path', 'filePath'] as const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ContentBlockView
block={{
type: 'image',
data: 'cG5n',
mimeType: 'image/png',
name: 'architecture.png',
}}
/>,
);

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', () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/presentation/ui/src/chat/content-block-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ export function ContentBlockView({
case 'image':
return (
<img
alt=""
alt={block.name ?? ''}
className="my-2 max-h-80 max-w-full rounded-xl border border-border"
src={block.uri ?? `data:${block.mimeType};base64,${block.data}`}
title={block.name}
/>
);
case 'audio':
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
attachmentFromReadFile,
failedComposerAttachmentFromPath,
pendingComposerAttachment,
} from '../composer-attachments';
Expand All @@ -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',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
9 changes: 7 additions & 2 deletions packages/presentation/ui/src/shell/composer-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
}

Expand Down Expand Up @@ -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 },
};
}

Expand Down
Loading