Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/message-received-file-parts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"eve": patch
---

The `message.received` stream event now includes structured `parts` (text plus
file/image attachment metadata) alongside the flattened `message` summary. The
default message reducer projects attachments as `file` message parts, so chat
UIs can render user-attached files and images instead of parsing the
`[file: …]` placeholder text. Attachment bytes and internal sandbox paths are
never projected; a `url` is included only when the attachment is a
client-resolvable `http(s)`/`data:` URL.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { defineEval } from "eve/evals";
* Multimodal turn: a local PNG inlined as a data: URL FilePart must reach
* the model. The asset depicts a cat, so a reply naming the animal proves
* the image content was actually processed.
*
* Also asserts the transcript projection: `message.received` must carry a
* structured `file` part (metadata only) so clients can render the attachment
* instead of the flattened `[file: …]` text summary.
*/
export default defineEval({
description: "Session runtime smoke: attachments.",
Expand All @@ -16,11 +20,24 @@ export default defineEval({
// Eval modules execute from a build cache, so assets resolve against
// the app root (`eve eval` runs with the app as cwd), not import.meta.
const filePath = join(process.cwd(), "evals/assets/cat-image.png");
await t.sendFile(
const turn = await t.sendFile(
"What animal is in this image? Answer in one short sentence.",
filePath,
"image/png",
);
turn.expectOk();

const received = turn.events.find(
(event): event is Extract<typeof event, { type: "message.received" }> =>
event.type === "message.received",
);
const fileParts = received?.data.parts?.filter((part) => part.type === "file") ?? [];
if (fileParts.length === 0 || fileParts[0]?.mediaType !== "image/png") {
throw new Error(
"message.received did not project a structured image/png file part. " +
`Saw parts: ${JSON.stringify(received?.data.parts)}`,
);
}

t.succeeded();
t.messageIncludes(/cat/i);
Expand Down
15 changes: 12 additions & 3 deletions packages/eve/src/client/message-reducer-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ export interface EveMessageMetadata {
* One renderable part of an {@link EveMessage}, discriminated by `type`.
*
* `text` and `reasoning` store streamed content with a `state` of `"streaming"`
* or `"done"`; `step-start` marks the boundary of an agent step; and
* `dynamic-tool` ({@link EveDynamicToolPart}) holds the tool call and its
* lifecycle state. `stepIndex` ties a part to the agent step that produced it.
* or `"done"`; `file` carries user-attachment metadata (and a `url` when the
* attachment is client-resolvable); `step-start` marks the boundary of an agent
* step; and `dynamic-tool` ({@link EveDynamicToolPart}) holds the tool call and
* its lifecycle state. `stepIndex` ties a part to the agent step that produced it.
*/
export type EveMessagePart =
| {
Expand All @@ -58,6 +59,14 @@ export type EveMessagePart =
readonly text: string;
readonly type: "reasoning";
}
| {
readonly filename?: string;
readonly mediaType: string;
readonly size?: number;
readonly stepIndex?: number;
readonly type: "file";
readonly url?: string;
}
| {
readonly type: "step-start";
}
Expand Down
43 changes: 43 additions & 0 deletions packages/eve/src/client/message-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,4 +691,47 @@ describe("defaultMessageReducer", () => {
{ type: "step-start" },
]);
});

it("projects structured file parts from message.received onto the user message", () => {
const reducer = defaultMessageReducer();
let data = reducer.initial();

data = reducer.reduce(data, {
data: {
message: "describe this\n[file: report.pdf (application/pdf)]",
parts: [
{ text: "describe this", type: "text" },
{ filename: "report.pdf", mediaType: "application/pdf", size: 4, type: "file" },
],
sequence: 1,
turnId: "turn_1",
},
type: "message.received",
});

const userMessage = data.messages.find((message) => message.role === "user");
expect(userMessage?.parts).toEqual([
{ state: "done", text: "describe this", type: "text" },
{
filename: "report.pdf",
mediaType: "application/pdf",
size: 4,
type: "file",
url: undefined,
},
]);
});

it("falls back to a single text part when message.received omits parts", () => {
const reducer = defaultMessageReducer();
let data = reducer.initial();

data = reducer.reduce(data, {
data: { message: "hello there", sequence: 1, turnId: "turn_1" },
type: "message.received",
});

const userMessage = data.messages.find((message) => message.role === "user");
expect(userMessage?.parts).toEqual([{ state: "done", text: "hello there", type: "text" }]);
});
});
32 changes: 31 additions & 1 deletion packages/eve/src/client/message-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
EveMessagePart,
EveMessageToolMetadata,
} from "#client/message-reducer-types.js";
import type { MessageReceivedPart } from "#protocol/message.js";
import type { RuntimeActionRequest, RuntimeActionResult } from "#runtime/actions/types.js";
import type { InputRequest, InputResponse } from "#runtime/input/types.js";
import type { AuthorizationCompletedStreamEvent } from "#protocol/message.js";
Expand Down Expand Up @@ -96,7 +97,7 @@ function reduceMessageData(data: EveMessageData, event: EveAgentReducerEvent): E
status: "complete",
turnId: event.data.turnId,
},
parts: [{ type: "text", text: event.data.message, state: "done" }],
parts: projectReceivedParts(event.data.parts, event.data.message),
role: "user",
});

Expand Down Expand Up @@ -496,12 +497,41 @@ function findToolPartByApprovalId(
return undefined;
}

/**
* Projects a received user message into renderable parts. Prefers the
* structured `parts` from the stream event (so attachments render) and falls
* back to a single text part for events from servers that predate `parts`.
*/
function projectReceivedParts(
parts: readonly MessageReceivedPart[] | undefined,
message: string,
): readonly EveMessagePart[] {
// `parts` is absent only for servers that predate the field; fall back to a
// single text part. An empty array means "structured, but no parts" and maps
// to `[]` — so the nullish coalesce, not a length check, is what we want.
return (
parts?.map((part) =>
part.type === "text"
? { type: "text", text: part.text, state: "done" }
: {
filename: part.filename,
mediaType: part.mediaType,
size: part.size,
type: "file",
url: part.url,
},
) ?? [{ type: "text", text: message, state: "done" }]
);
}

function partKey(part: EveMessagePart): string {
switch (part.type) {
case "text":
return `text:${part.stepIndex ?? 0}`;
case "reasoning":
return `reasoning:${part.stepIndex ?? 0}`;
case "file":
return `file:${part.stepIndex ?? 0}:${part.filename ?? part.url ?? part.mediaType}`;
case "step-start":
return "step-start";
case "authorization":
Expand Down
127 changes: 126 additions & 1 deletion packages/eve/src/protocol/message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@ import {
createActionResultEvent,
createAuthorizationCompletedEvent,
createAuthorizationRequiredEvent,
createMessageReceivedEvent,
createResultCompletedEvent,
createStepStartedEvent,
encodeMessageStreamEvent,
timestampHandleMessageStreamEvent,
} from "#protocol/message.js";
import { createEveConnectionCallbackRoutePath } from "#protocol/routes.js";
import { encodeSandboxRef } from "#internal/attachments/sandbox-refs.js";
import { serializeUrlFilePart } from "#internal/attachments/url-refs.js";

describe("message stream protocol", () => {
it("pins the stream version for timed session events", () => {
expect(EVE_MESSAGE_STREAM_VERSION).toBe("16");
expect(EVE_MESSAGE_STREAM_VERSION).toBe("17");
});

it("creates result.completed events", () => {
Expand Down Expand Up @@ -193,3 +196,125 @@ describe("message stream protocol", () => {
});
});
});

describe("createMessageReceivedEvent parts projection", () => {
function projectParts(message: Parameters<typeof createMessageReceivedEvent>[0]["message"]) {
return createMessageReceivedEvent({ message, sequence: 1, turnId: "turn_1" }).data.parts;
}

it("projects a plain string message as a single text part", () => {
expect(projectParts("hello")).toEqual([{ text: "hello", type: "text" }]);
});

it("projects text parts verbatim alongside the flattened summary", () => {
const event = createMessageReceivedEvent({
message: [{ text: "describe this", type: "text" }],
sequence: 1,
turnId: "turn_1",
});
expect(event.data.parts).toEqual([{ text: "describe this", type: "text" }]);
expect(event.data.message).toBe("describe this");
});

it("projects inline file bytes as metadata only, never the bytes or a url", () => {
const bytes = new Uint8Array([1, 2, 3, 4]);
const parts = projectParts([
{ data: bytes, filename: "report.pdf", mediaType: "application/pdf", type: "file" },
]);
expect(parts).toEqual([
{ filename: "report.pdf", mediaType: "application/pdf", size: 4, type: "file" },
]);
expect(parts?.[0]).not.toHaveProperty("url");
});

it("exposes a client-resolvable url for http(s) URL file parts", () => {
const parts = projectParts([
{
data: new URL("https://example.com/a.png"),
filename: "a.png",
mediaType: "image/png",
type: "file",
},
]);
expect(parts).toEqual([
{
filename: "a.png",
mediaType: "image/png",
type: "file",
url: "https://example.com/a.png",
},
]);
});

it("reconstitutes eve-url: serialized file parts into a url", () => {
const parts = projectParts([
{
data: serializeUrlFilePart(new URL("https://files.example.com/x.pdf")),
filename: "x.pdf",
mediaType: "application/pdf",
type: "file",
},
]);
expect(parts?.[0]).toMatchObject({ url: "https://files.example.com/x.pdf" });
});

it("passes through a data: URL string as a url", () => {
const data = "data:text/plain;base64,aGVsbG8=";
const parts = projectParts([{ data, mediaType: "text/plain", type: "file" }]);
expect(parts?.[0]).toMatchObject({ url: data });
});

it("exposes a plain http(s) URL string as a url", () => {
const parts = projectParts([
{ data: "https://files.example.com/y.pdf", mediaType: "application/pdf", type: "file" },
]);
expect(parts?.[0]).toMatchObject({ url: "https://files.example.com/y.pdf" });
});

it("never exposes an internal eve-sandbox: ref string as a url", () => {
const parts = projectParts([
{
data: "eve-sandbox:?path=%2Fworkspace%2Fa.png&size=10&type=image%2Fpng",
mediaType: "image/png",
type: "file",
},
]);
expect(parts).toEqual([{ mediaType: "image/png", type: "file" }]);
expect(parts?.[0]).not.toHaveProperty("url");
});

it("surfaces opaque base64 string data as metadata only", () => {
const parts = projectParts([
{ data: "aGVsbG8=", filename: "note.txt", mediaType: "text/plain", type: "file" },
]);
expect(parts).toEqual([{ filename: "note.txt", mediaType: "text/plain", type: "file" }]);
expect(parts?.[0]).not.toHaveProperty("url");
});

it("projects sandbox refs as metadata without leaking the internal path", () => {
const ref = encodeSandboxRef({
mediaType: "image/png",
path: "/workspace/attachments/abc/photo.png",
size: 2048,
});
const parts = projectParts([{ data: ref, mediaType: "image/png", type: "file" }]);
expect(parts).toEqual([
{ filename: "photo.png", mediaType: "image/png", size: 2048, type: "file" },
]);
expect(JSON.stringify(parts)).not.toContain("/workspace/attachments");
});

it("normalizes image parts into file parts", () => {
const parts = projectParts([
{ image: new URL("https://example.com/p.jpg"), mediaType: "image/jpeg", type: "image" },
]);
expect(parts).toEqual([
{
filename: undefined,
mediaType: "image/jpeg",
type: "file",
url: "https://example.com/p.jpg",
},
]);
});
});
Loading