/**
* Tests for handleListMessages attachment handling.
*
* Verifies that:
* - User message image attachments include base64 data for client thumbnail generation
* - User message non-image attachments stay metadata-only (no base64 blob)
* - Assistant message image attachments include base64 data (same as user messages)
* - Stored HEIF/HEIC rows are hydrated as JPEG display data (Chromium cannot
* decode HEIF); undecodable content falls back to the stored bytes
*/
import { beforeEach, describe, expect, test } from "bun:test";
import { setConfig } from "./helpers/set-config.js";
// Keep the memory system off so addMessage skips indexing side effects.
setConfig("memory", { enabled: false });
import { randomUUID } from "node:crypto";
import {
linkAttachmentToMessage,
uploadAttachment,
} from "../persistence/attachments-store.js";
import {
addMessage,
createConversation,
} from "../persistence/conversation-crud.js";
import { getDb } from "../persistence/db-connection.js";
import { initializeDb } from "../persistence/db-init.js";
import { rawRun } from "../persistence/raw-query.js";
import { handleListMessages } from "../runtime/routes/conversation-routes.js";
import { fakeHeifHeaderBytes, makeHeicFixtureBytes } from "./heic-fixture.js";
await initializeDb();
function resetTables() {
const db = getDb();
db.run("DELETE FROM message_attachments");
db.run("DELETE FROM attachments");
db.run("DELETE FROM messages");
db.run("DELETE FROM conversations");
}
function createTestArgs(conversationId: string) {
return {
queryParams: { conversationId },
};
}
interface AttachmentPayload {
data?: string;
filename?: string;
mimeType: string;
kind?: string;
thumbnailData?: string;
}
/**
* Insert an attachment row directly, bypassing upload-time HEIF
* normalization — simulates rows stored before normalization existed (or
* where conversion was unavailable).
*/
function insertLegacyAttachmentRow(
messageId: string,
filename: string,
mimeType: string,
dataBase64: string,
kind = "image",
): string {
const id = randomUUID();
rawRun(
"test:insertAttachment",
`INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id,
filename,
mimeType,
Buffer.from(dataBase64, "base64").length,
kind,
dataBase64,
Date.now(),
);
linkAttachmentToMessage(messageId, id, 0);
return id;
}
interface MessagePayload {
attachments?: AttachmentPayload[];
}
const IMAGE_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk";
const DOC_BASE64 = "JVBERi0xLjQKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwo";
describe("handleListMessages attachments", () => {
beforeEach(resetTables);
test("user message image attachments include base64 data", async () => {
const conv = createConversation();
const msg = await addMessage(
conv.id,
"user",
JSON.stringify([{ type: "text", text: "check this image" }]),
);
const stored = await uploadAttachment(
"photo.png",
"image/png",
IMAGE_BASE64,
);
linkAttachmentToMessage(msg.id, stored.id, 0);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as { messages: MessagePayload[] };
expect(body.messages).toHaveLength(1);
const attachments = body.messages[0].attachments;
expect(attachments).toBeDefined();
expect(attachments).toHaveLength(1);
expect(attachments![0].mimeType).toBe("image/png");
expect(attachments![0].data).toBe(IMAGE_BASE64);
});
test("user message non-image attachments stay metadata-only", async () => {
const conv = createConversation();
const msg = await addMessage(
conv.id,
"user",
JSON.stringify([{ type: "text", text: "check this doc" }]),
);
const stored = await uploadAttachment(
"report.pdf",
"application/pdf",
DOC_BASE64,
);
linkAttachmentToMessage(msg.id, stored.id, 0);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as { messages: MessagePayload[] };
expect(body.messages).toHaveLength(1);
const attachments = body.messages[0].attachments;
expect(attachments).toBeDefined();
expect(attachments).toHaveLength(1);
expect(attachments![0].mimeType).toBe("application/pdf");
// Non-image attachments should NOT include base64 data
expect(attachments![0].data).toBeUndefined();
});
test("assistant message image attachments include base64 data", async () => {
const conv = createConversation();
const msg = await addMessage(
conv.id,
"assistant",
JSON.stringify([{ type: "text", text: "here is an image" }]),
);
const stored = await uploadAttachment(
"result.png",
"image/png",
IMAGE_BASE64,
);
linkAttachmentToMessage(msg.id, stored.id, 0);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as { messages: MessagePayload[] };
expect(body.messages).toHaveLength(1);
const attachments = body.messages[0].attachments;
expect(attachments).toBeDefined();
expect(attachments).toHaveLength(1);
expect(attachments![0].mimeType).toBe("image/png");
// Assistant image attachments include base64 data for inline rendering
expect(attachments![0].data).toBe(IMAGE_BASE64);
});
test("user message with mixed attachments only inlines images", async () => {
const conv = createConversation();
const msg = await addMessage(
conv.id,
"user",
JSON.stringify([{ type: "text", text: "here are files" }]),
);
const imgStored = await uploadAttachment(
"photo.png",
"image/png",
IMAGE_BASE64,
);
const docStored = await uploadAttachment(
"doc.pdf",
"application/pdf",
DOC_BASE64,
);
linkAttachmentToMessage(msg.id, imgStored.id, 0);
linkAttachmentToMessage(msg.id, docStored.id, 1);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as { messages: MessagePayload[] };
const attachments = body.messages[0].attachments!;
expect(attachments).toHaveLength(2);
const imgAtt = attachments.find((a) => a.mimeType === "image/png");
const docAtt = attachments.find((a) => a.mimeType === "application/pdf");
expect(imgAtt!.data).toBe(IMAGE_BASE64);
expect(docAtt!.data).toBeUndefined();
});
test("contentBlocks attachment blocks are metadata-only references", async () => {
// The flat `attachments` array is the sole payload carrier. An inline
// file-block ref places an `attachment` block in `contentBlocks`, but
// that block must reference the row by id without repeating its base64:
// duplicating it doubles every image in the response and in client
// memory.
const conv = createConversation();
const stored = await uploadAttachment(
"photo.png",
"image/png",
IMAGE_BASE64,
);
const msg = await addMessage(
conv.id,
"user",
JSON.stringify([
{ type: "text", text: "check this image" },
{
type: "file",
source: { type: "workspace_ref", attachmentId: stored.id },
filename: "photo.png",
media_type: "image/png",
},
]),
);
linkAttachmentToMessage(msg.id, stored.id, 0);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as {
messages: {
attachments?: AttachmentPayload[];
contentBlocks?: Array<{
type: string;
attachment?: {
id: string;
data?: string;
thumbnailData?: string;
};
}>;
}[];
};
expect(body.messages).toHaveLength(1);
const row = body.messages[0];
// Payload ships on the flat array.
expect(row.attachments).toHaveLength(1);
expect(row.attachments![0].data).toBe(IMAGE_BASE64);
// The inline block references the same row without the payload.
const blocks = (row.contentBlocks ?? []).filter(
(b) => b.type === "attachment",
);
expect(blocks).toHaveLength(1);
expect(blocks[0].attachment!.id).toBe(stored.id);
expect(blocks[0].attachment!.data).toBeUndefined();
expect(blocks[0].attachment!.thumbnailData).toBeUndefined();
});
test("synthesized attachment blocks are metadata-only references", async () => {
// Directive attachments take the appendix path (no file-block ref), which
// must strip the payload the same way the inline path does.
const conv = createConversation();
const msg = await addMessage(
conv.id,
"assistant",
JSON.stringify([{ type: "text", text: "" }]),
);
const stored = await uploadAttachment(
"output.png",
"image/png",
IMAGE_BASE64,
);
linkAttachmentToMessage(msg.id, stored.id, 0);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as {
messages: {
attachments?: AttachmentPayload[];
contentBlocks?: Array<{
type: string;
attachment?: { id: string; data?: string };
}>;
}[];
};
const row = body.messages[0];
expect(row.attachments![0].data).toBe(IMAGE_BASE64);
const blocks = (row.contentBlocks ?? []).filter(
(b) => b.type === "attachment",
);
expect(blocks).toHaveLength(1);
expect(blocks[0].attachment!.data).toBeUndefined();
});
test("attachment-only assistant message synthesizes contentBlocks", async () => {
// When the assistant's entire response was a tag,
// parseDirectives strips it → cleanText is empty → renderHistoryContent
// drops the empty text block → contentBlocks is []. The serializer must
// synthesize attachment blocks from msgAttachments so the client has a
// block to anchor the attachment chip.
const conv = createConversation();
// Persist the post-strip content: an empty text block (what
// cleanAssistantContent leaves after stripping the directive tag).
const msg = await addMessage(
conv.id,
"assistant",
JSON.stringify([{ type: "text", text: "" }]),
);
const stored = await uploadAttachment(
"output.png",
"image/png",
IMAGE_BASE64,
);
linkAttachmentToMessage(msg.id, stored.id, 0);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as {
messages: {
attachments?: AttachmentPayload[];
contentBlocks?: Array<{
type: string;
attachment?: { id: string; filename: string };
}>;
}[];
};
expect(body.messages).toHaveLength(1);
// Attachments are always on the wire
expect(body.messages[0].attachments).toBeDefined();
expect(body.messages[0].attachments).toHaveLength(1);
// contentBlocks must be synthesized — not omitted
expect(body.messages[0].contentBlocks).toBeDefined();
expect(body.messages[0].contentBlocks).toHaveLength(1);
expect(body.messages[0].contentBlocks![0].type).toBe("attachment");
expect(body.messages[0].contentBlocks![0].attachment!.filename).toBe(
"output.png",
);
});
});
describe("handleListMessages HEIC display normalization", () => {
beforeEach(resetTables);
test("undecodable HEIC data is served unchanged (conversion fallback)", async () => {
const conv = createConversation();
const msg = await addMessage(
conv.id,
"user",
JSON.stringify([{ type: "text", text: "photo" }]),
);
const heicB64 = fakeHeifHeaderBytes().toString("base64");
insertLegacyAttachmentRow(msg.id, "IMG_1.HEIC", "image/heic", heicB64);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as { messages: MessagePayload[] };
const attachments = body.messages[0].attachments!;
expect(attachments).toHaveLength(1);
expect(attachments[0].mimeType).toBe("image/heic");
expect(attachments[0].data).toBe(heicB64);
});
test("octet-stream HEIC stays metadata-only when conversion is unavailable", async () => {
const conv = createConversation();
const msg = await addMessage(
conv.id,
"user",
JSON.stringify([{ type: "text", text: "photo" }]),
);
// Web-uploader fallback for an empty File.type: HEIC bytes land under
// application/octet-stream and classify as a document. Fake header bytes
// never convert, so the row must stay metadata-only rather than ship
// unrenderable bytes as display data.
const heicB64 = fakeHeifHeaderBytes().toString("base64");
insertLegacyAttachmentRow(
msg.id,
"IMG_3.HEIC",
"application/octet-stream",
heicB64,
"document",
);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as { messages: MessagePayload[] };
const attachments = body.messages[0].attachments!;
expect(attachments).toHaveLength(1);
expect(attachments[0].mimeType).toBe("application/octet-stream");
expect(attachments[0].kind).toBe("document");
expect(attachments[0].data).toBeUndefined();
});
describe.skipIf(process.platform !== "darwin")(
"real conversion (sips)",
() => {
test("legacy HEIC rows are hydrated as JPEG display data", async () => {
const heic = makeHeicFixtureBytes();
expect(heic).not.toBeNull();
const conv = createConversation();
const msg = await addMessage(
conv.id,
"user",
JSON.stringify([{ type: "text", text: "photo" }]),
);
insertLegacyAttachmentRow(
msg.id,
"IMG_2.HEIC",
"image/heic",
heic!.toString("base64"),
);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as { messages: MessagePayload[] };
const attachments = body.messages[0].attachments!;
expect(attachments).toHaveLength(1);
expect(attachments[0].mimeType).toBe("image/jpeg");
// JPEG SOI marker (FF D8 FF) base64-encodes to "/9j/".
expect(attachments[0].data!.startsWith("/9j/")).toBe(true);
// Metadata keeps describing the stored original, which the content
// endpoint serves verbatim for downloads.
expect(attachments[0].filename).toBe("IMG_2.HEIC");
});
test("legacy octet-stream HEIC rows hydrate as JPEG display data", async () => {
const heic = makeHeicFixtureBytes();
expect(heic).not.toBeNull();
const conv = createConversation();
const msg = await addMessage(
conv.id,
"user",
JSON.stringify([{ type: "text", text: "photo" }]),
);
// Real HEIC bytes stored under application/octet-stream (empty
// File.type fallback) and classified as a document — the row is
// detected by its filename extension and converted for display.
insertLegacyAttachmentRow(
msg.id,
"IMG_4.HEIC",
"application/octet-stream",
heic!.toString("base64"),
"document",
);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as { messages: MessagePayload[] };
const attachments = body.messages[0].attachments!;
expect(attachments).toHaveLength(1);
expect(attachments[0].mimeType).toBe("image/jpeg");
expect(attachments[0].data!.startsWith("/9j/")).toBe(true);
// The converted row presents as an image so clients render it inline.
expect(attachments[0].kind).toBe("image");
// The stored original filename is preserved for verbatim download.
expect(attachments[0].filename).toBe("IMG_4.HEIC");
});
},
);
});
describe("handleListMessages no_response filtering", () => {
beforeEach(resetTables);
test("strips from assistant message content", async () => {
const conv = createConversation();
await addMessage(
conv.id,
"assistant",
JSON.stringify([{ type: "text", text: "" }]),
);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as {
messages: { textSegments?: string[] }[];
};
expect(body.messages).toHaveLength(1);
// textSegments is omitted from payload when empty
expect(body.messages[0].textSegments).toBeUndefined();
});
test("strips but keeps other text segments", async () => {
const conv = createConversation();
await addMessage(
conv.id,
"assistant",
JSON.stringify([
{ type: "text", text: "" },
{ type: "text", text: "Real reply." },
]),
);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as {
messages: { textSegments?: string[] }[];
};
expect(body.messages).toHaveLength(1);
expect(body.messages[0].textSegments).toEqual(["Real reply."]);
});
test("remaps contentOrder when segment is removed", async () => {
const conv = createConversation();
// Simulate: text("") -> tool_use -> tool_result -> text("Answer")
await addMessage(
conv.id,
"assistant",
JSON.stringify([
{ type: "text", text: "" },
{
type: "tool_use",
id: "tu1",
name: "search",
input: { q: "test" },
},
{ type: "tool_result", tool_use_id: "tu1", content: "result" },
{ type: "text", text: "Answer" },
]),
);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as {
messages: {
textSegments: string[];
contentOrder: string[];
}[];
};
expect(body.messages).toHaveLength(1);
expect(body.messages[0].textSegments).toEqual(["Answer"]);
// text:0 (no_response) should be removed, text:1 remapped to text:0
expect(body.messages[0].contentOrder).toContain("text:0");
expect(body.messages[0].contentOrder).not.toContain("text:1");
});
test("does not strip from user messages", async () => {
const conv = createConversation();
await addMessage(
conv.id,
"user",
JSON.stringify([{ type: "text", text: "What does do?" }]),
);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as {
messages: { textSegments?: string[] }[];
};
expect(body.messages).toHaveLength(1);
expect(body.messages[0].textSegments).toEqual([
"What does do?",
]);
});
});
// ---------------------------------------------------------------------------
// Pagination
// ---------------------------------------------------------------------------
interface PaginatedResponse {
messages: { id: string; timestamp: string }[];
hasMore?: boolean;
oldestTimestamp?: number;
oldestMessageId?: string;
}
function createPaginatedArgs(
conversationId: string,
params?: { limit?: string; beforeTimestamp?: string },
) {
const queryParams: Record = { conversationId };
if (params?.limit !== undefined) {
queryParams.limit = params.limit;
}
if (params?.beforeTimestamp !== undefined) {
queryParams.beforeTimestamp = params.beforeTimestamp;
}
return { queryParams };
}
/** Helper: insert N messages with distinct, increasing timestamps and return them in insertion order. */
async function insertMessages(
conversationId: string,
count: number,
): Promise<{ id: string; createdAt: number }[]> {
const msgs: { id: string; createdAt: number }[] = [];
for (let i = 0; i < count; i++) {
const msg = await addMessage(
conversationId,
i % 2 === 0 ? "user" : "assistant",
JSON.stringify([{ type: "text", text: `msg-${i}` }]),
);
msgs.push({ id: msg.id, createdAt: msg.createdAt });
}
return msgs;
}
describe("handleListMessages pagination", () => {
beforeEach(resetTables);
test("no params → all messages, no hasMore field", async () => {
const conv = createConversation();
await insertMessages(conv.id, 5);
const response = await handleListMessages(createTestArgs(conv.id));
const body = response as unknown as PaginatedResponse;
expect(body.messages).toHaveLength(5);
expect(body.hasMore).toBeUndefined();
expect(body.oldestTimestamp).toBeUndefined();
expect(body.oldestMessageId).toBeUndefined();
});
test("limit only (no beforeTimestamp) → all messages, no hasMore", async () => {
const conv = createConversation();
await insertMessages(conv.id, 5);
const args = createPaginatedArgs(conv.id, { limit: "3" });
const response = await handleListMessages(args);
const body = response as unknown as PaginatedResponse;
// Option A: without beforeTimestamp, all messages are returned regardless of limit
expect(body.messages).toHaveLength(5);
expect(body.hasMore).toBeUndefined();
});
test("beforeTimestamp + limit → correct page with hasMore: true", async () => {
const conv = createConversation();
const msgs = await insertMessages(conv.id, 10);
// Cursor is message[7]'s timestamp; limit=3 → should return messages [4,5,6]
const args = createPaginatedArgs(conv.id, {
beforeTimestamp: String(msgs[7].createdAt),
limit: "3",
});
const response = await handleListMessages(args);
const body = response as unknown as PaginatedResponse;
expect(body.messages).toHaveLength(3);
expect(body.messages.map((m) => m.id)).toEqual([
msgs[4].id,
msgs[5].id,
msgs[6].id,
]);
expect(body.hasMore).toBe(true);
});
test("beforeTimestamp is strictly exclusive", async () => {
const conv = createConversation();
const msgs = await insertMessages(conv.id, 3);
// Use message[1]'s exact timestamp as cursor — message[1] should NOT appear
const args = createPaginatedArgs(conv.id, {
beforeTimestamp: String(msgs[1].createdAt),
limit: "10",
});
const response = await handleListMessages(args);
const body = response as unknown as PaginatedResponse;
const ids = body.messages.map((m) => m.id);
expect(ids).toContain(msgs[0].id);
expect(ids).not.toContain(msgs[1].id);
expect(ids).not.toContain(msgs[2].id);
});
test("hasMore: false when all older messages fit", async () => {
const conv = createConversation();
const msgs = await insertMessages(conv.id, 5);
// beforeTimestamp beyond the last message, limit larger than total count
const args = createPaginatedArgs(conv.id, {
beforeTimestamp: String(msgs[4].createdAt + 1),
limit: "10",
});
const response = await handleListMessages(args);
const body = response as unknown as PaginatedResponse;
expect(body.messages).toHaveLength(5);
expect(body.hasMore).toBe(false);
});
test("oldestTimestamp and oldestMessageId match oldest returned message", async () => {
const conv = createConversation();
const msgs = await insertMessages(conv.id, 5);
// Fetch last 3 messages before a cursor past the end
const args = createPaginatedArgs(conv.id, {
beforeTimestamp: String(msgs[4].createdAt + 1),
limit: "3",
});
const response = await handleListMessages(args);
const body = response as unknown as PaginatedResponse;
expect(body.messages).toHaveLength(3);
// Oldest returned message is msgs[2] (messages [2,3,4])
expect(body.oldestTimestamp).toBe(msgs[2].createdAt);
expect(body.oldestMessageId).toBe(msgs[2].id);
});
test("empty / nonexistent conversation → empty messages, no pagination metadata", async () => {
const args = createPaginatedArgs("nonexistent-conv-id");
const response = await handleListMessages(args);
const body = response as unknown as PaginatedResponse;
expect(body.messages).toEqual([]);
expect(body.hasMore).toBeUndefined();
expect(body.oldestTimestamp).toBeUndefined();
expect(body.oldestMessageId).toBeUndefined();
});
test("invalid limit (NaN) → 400", async () => {
const conv = createConversation();
const args = createPaginatedArgs(conv.id, { limit: "abc" });
await expect(handleListMessages(args)).rejects.toThrow(
"limit must be a valid number",
);
});
test("invalid beforeTimestamp (NaN) → 400", async () => {
const conv = createConversation();
const args = createPaginatedArgs(conv.id, { beforeTimestamp: "abc" });
await expect(handleListMessages(args)).rejects.toThrow(
"beforeTimestamp must be a valid number",
);
});
});