FN-5858: fix multipart chat attachment sends

Handle multipart chat message requests so uploaded attachments reach the chat manager.

- parse multipart bodies on the chat message SSE endpoint with attachment count and size handling
- persist uploaded message attachments into session storage and forward generated metadata to sendMessage
- add multipart route coverage for successful sends, missing content validation, and the published package changeset

Files changed:
 .changeset/fn-5858-chat-multipart-fix.md           |  7 ++
 .../src/__tests__/chat-attachment-routes.test.ts   | 33 ++++++++
 .../dashboard/src/__tests__/chat-routes.test.ts    | 79 +++++++++++++++++++
 .../dashboard/src/routes/register-chat-routes.ts   | 92 +++++++++++++++-------
 4 files changed, 183 insertions(+), 28 deletions(-)

Fusion-Task-Id: FN-5858

Fusion-Task-Lineage: 3b1320a7-14ba-4699-8bfd-eba193a5de7e
This commit is contained in:
gsxdsm
2026-06-02 00:07:07 -07:00
parent de23db3789
commit f3732afcd0
4 changed files with 183 additions and 28 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Fix chat message sends with file attachments by parsing multipart form bodies on the chat messages SSE endpoint.
Uploaded message attachments are now validated, persisted to the session attachment directory, converted into chat attachment metadata, and forwarded to the chat manager while JSON-only message sends continue to work unchanged.

View File

@@ -108,6 +108,18 @@ function makeMultipart(fieldName: string, filename: string, contentType: string,
return { payload: Buffer.concat([head, body, tail]), boundary }; return { payload: Buffer.concat([head, body, tail]), boundary };
} }
function makeMultipartMessageRequest(content: string | undefined, filename: string, contentType: string, body: Buffer): { payload: Buffer; boundary: string } {
const boundary = `----fn-msg-${Date.now()}`;
const parts: Buffer[] = [];
if (content !== undefined) {
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="content"\r\n\r\n${content}\r\n`));
}
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="attachments"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`));
parts.push(body);
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
return { payload: Buffer.concat(parts), boundary };
}
describe("chat attachment routes", () => { describe("chat attachment routes", () => {
let app: (req: any, res: any) => void; let app: (req: any, res: any) => void;
let rootDir: string; let rootDir: string;
@@ -192,6 +204,27 @@ describe("chat attachment routes", () => {
expect(mockSendMessage).toHaveBeenCalledWith(session.id, "hello", undefined, undefined, attachments, { generationId: 1 }); expect(mockSendMessage).toHaveBeenCalledWith(session.id, "hello", undefined, undefined, attachments, { generationId: 1 });
}); });
it("passes multipart file attachments on message send", async () => {
const { payload, boundary } = makeMultipartMessageRequest("hello", "x.txt", "text/plain", Buffer.from("x"));
const response = await request(app, "POST", `/api/chat/sessions/${session.id}/messages`, payload, { "content-type": `multipart/form-data; boundary=${boundary}` }, payload);
expect(response.status).toBe(200);
expect(mockSendMessage).toHaveBeenCalledWith(
session.id,
"hello",
undefined,
undefined,
[expect.objectContaining({ originalName: "x.txt", mimeType: "text/plain", size: 1 })],
{ generationId: 1 },
);
});
it("returns 400 for multipart message send without content", async () => {
const { payload, boundary } = makeMultipartMessageRequest(undefined, "x.txt", "text/plain", Buffer.from("x"));
const response = await request(app, "POST", `/api/chat/sessions/${session.id}/messages`, payload, { "content-type": `multipart/form-data; boundary=${boundary}` }, payload);
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("content is required");
});
afterEach(() => { afterEach(() => {
if (rootDir) rmSync(rootDir, { recursive: true, force: true }); if (rootDir) rmSync(rootDir, { recursive: true, force: true });
}); });

View File

@@ -288,6 +288,27 @@ function createMockChatManager() {
}; };
} }
function makeMultipartMessageRequest(options: {
fields?: Record<string, string>;
files?: Array<{ fieldName: string; filename: string; contentType: string; body: Buffer }>;
}): { payload: Buffer; boundary: string } {
const boundary = `----fn-chat-${Date.now()}`;
const parts: Buffer[] = [];
for (const [fieldName, value] of Object.entries(options.fields ?? {})) {
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"\r\n\r\n${value}\r\n`));
}
for (const file of options.files ?? []) {
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${file.fieldName}"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`));
parts.push(file.body);
parts.push(Buffer.from("\r\n"));
}
parts.push(Buffer.from(`--${boundary}--\r\n`));
return { payload: Buffer.concat(parts), boundary };
}
// ── Tests ─────────────────────────────────────────────────────────────────── // ── Tests ───────────────────────────────────────────────────────────────────
describe("Chat API Routes", () => { describe("Chat API Routes", () => {
@@ -1124,6 +1145,64 @@ describe("Chat API Routes", () => {
expect((response.body as any).error).toContain("content is required"); expect((response.body as any).error).toContain("content is required");
}); });
it("accepts multipart content with attachments without crashing", async () => {
mockGetSession.mockReturnValue(sampleSession);
mockSendMessage.mockImplementation(async (sessionId: string) => {
mockChatStreamManager.broadcast(sessionId, {
type: "done",
data: { messageId: "msg-multipart" },
});
});
const { payload, boundary } = makeMultipartMessageRequest({
fields: { content: "Hello with attachment" },
files: [{
fieldName: "attachments",
filename: "note.txt",
contentType: "text/plain",
body: Buffer.from("hello"),
}],
});
const response = await request(
app,
"POST",
"/api/chat/sessions/chat-abc123/messages",
payload,
{ "content-type": `multipart/form-data; boundary=${boundary}` },
payload,
);
expect(response.status).toBe(200);
expect(String(response.body)).not.toContain("Cannot destructure property 'content'");
});
it("returns 400 for multipart requests missing content instead of crashing", async () => {
mockGetSession.mockReturnValue(sampleSession);
const { payload, boundary } = makeMultipartMessageRequest({
files: [{
fieldName: "attachments",
filename: "note.txt",
contentType: "text/plain",
body: Buffer.from("hello"),
}],
});
const response = await request(
app,
"POST",
"/api/chat/sessions/chat-abc123/messages",
payload,
{ "content-type": `multipart/form-data; boundary=${boundary}` },
payload,
);
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("content is required");
expect(mockSendMessage).not.toHaveBeenCalled();
});
describe("SSE stream lifecycle", () => { describe("SSE stream lifecycle", () => {
/** /**
* Helper to invoke the chat SSE route handler directly. * Helper to invoke the chat SSE route handler directly.

View File

@@ -19,6 +19,8 @@ interface ChatRouteDeps {
upload: import("multer").Multer; upload: import("multer").Multer;
} }
const CHAT_MESSAGE_MAX_ATTACHMENTS = 10;
function resolveAttachmentPath(rootDir: string, sessionId: string, filename: string): { sessionDir: string; filePath: string } { function resolveAttachmentPath(rootDir: string, sessionId: string, filename: string): { sessionDir: string; filePath: string } {
const sessionDir = resolve(rootDir, ".fusion", "chat-attachments", sessionId); const sessionDir = resolve(rootDir, ".fusion", "chat-attachments", sessionId);
const safeName = basename(filename); const safeName = basename(filename);
@@ -48,6 +50,52 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
}); });
}; };
const uploadChatMessageAttachments: import("express").RequestHandler = (req, res, next) => {
upload.array("attachments", CHAT_MESSAGE_MAX_ATTACHMENTS)(req, res, (err?: unknown) => {
if (!err) {
next();
return;
}
const multerError = err as { code?: string; message?: string };
if (multerError?.code === "LIMIT_FILE_SIZE") {
next(badRequest(`File too large. Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`));
return;
}
next(err as Error);
});
};
const persistChatAttachment = async (
file: { originalname: string; mimetype: string; size: number; buffer: Buffer },
rootDir: string,
sessionId: string,
): Promise<ChatAttachment> => {
if (!CHAT_ALLOWED_MIME_TYPES.has(file.mimetype)) {
throw badRequest(`Invalid mime type '${file.mimetype}'`);
}
if (file.size > CHAT_MAX_ATTACHMENT_SIZE) {
throw badRequest(`File too large (${file.size} bytes). Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`);
}
const sessionDir = resolve(rootDir, ".fusion", "chat-attachments", sessionId);
await mkdir(sessionDir, { recursive: true });
const sanitizedFilename = (file.originalname || "attachment").replace(/[^a-zA-Z0-9._-]/g, "_");
const filename = `${Date.now()}-${sanitizedFilename}`;
const filePath = join(sessionDir, filename);
await writeFile(filePath, file.buffer);
return {
id: `att-${randomUUID().slice(0, 8)}`,
filename,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
createdAt: new Date().toISOString(),
};
};
// ── Per-project store / manager resolution ─────────────────────────────────── // ── Per-project store / manager resolution ───────────────────────────────────
async function resolveScopedChatStore(projectId: string | undefined) { async function resolveScopedChatStore(projectId: string | undefined) {
@@ -389,32 +437,8 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
throw badRequest("file is required"); throw badRequest("file is required");
} }
if (!CHAT_ALLOWED_MIME_TYPES.has(file.mimetype)) {
throw badRequest(`Invalid mime type '${file.mimetype}'`);
}
if (file.size > CHAT_MAX_ATTACHMENT_SIZE) {
throw badRequest(`File too large (${file.size} bytes). Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`);
}
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir(); const attachment = await persistChatAttachment(file, scopedStore.getRootDir(), sessionId);
const sessionDir = resolve(rootDir, ".fusion", "chat-attachments", sessionId);
await mkdir(sessionDir, { recursive: true });
const sanitizedFilename = (file.originalname || "attachment").replace(/[^a-zA-Z0-9._-]/g, "_");
const filename = `${Date.now()}-${sanitizedFilename}`;
const filePath = join(sessionDir, filename);
await writeFile(filePath, file.buffer);
const attachment: ChatAttachment = {
id: `att-${randomUUID().slice(0, 8)}`,
filename,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
createdAt: new Date().toISOString(),
};
res.status(201).json({ attachment }); res.status(201).json({ attachment });
} catch (err: unknown) { } catch (err: unknown) {
@@ -550,16 +574,17 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
* - done: Message sent successfully with messageId + persisted assistant message snapshot * - done: Message sent successfully with messageId + persisted assistant message snapshot
* - error: Error message * - error: Error message
*/ */
router.post("/chat/sessions/:id/messages", rateLimit(RATE_LIMITS.sse), async (req, res) => { router.post("/chat/sessions/:id/messages", rateLimit(RATE_LIMITS.sse), uploadChatMessageAttachments, async (req, res) => {
try { try {
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined); const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
const { content, modelProvider, modelId, attachments } = req.body as { const body = (req.body ?? {}) as {
content?: string; content?: string;
modelProvider?: string; modelProvider?: string;
modelId?: string; modelId?: string;
attachments?: ChatAttachment[]; attachments?: ChatAttachment[];
}; };
const { content, modelProvider, modelId, attachments } = body;
const sessionId = String(req.params.id); const sessionId = String(req.params.id);
if (!content || typeof content !== "string" || !content.trim()) { if (!content || typeof content !== "string" || !content.trim()) {
@@ -572,6 +597,17 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
throw notFound(`Chat session ${sessionId} not found`); throw notFound(`Chat session ${sessionId} not found`);
} }
const uploadedFiles = Array.isArray(req.files) ? (req.files as Express.Multer.File[]) : [];
const { store: scopedStore } = await getProjectContext(req);
const uploadedAttachments = uploadedFiles.length > 0
? await Promise.all(uploadedFiles.map((file) => persistChatAttachment(file, scopedStore.getRootDir(), sessionId)))
: undefined;
const messageAttachments = uploadedAttachments && uploadedAttachments.length > 0
? uploadedAttachments
: Array.isArray(attachments)
? attachments
: undefined;
// Resolve per-project ChatManager before opening the SSE stream so // Resolve per-project ChatManager before opening the SSE stream so
// failures (e.g. project DB cannot be opened) produce a proper HTTP error. // failures (e.g. project DB cannot be opened) produce a proper HTTP error.
const chatManager = await resolveScopedChatManager(req.query.projectId as string | undefined); const chatManager = await resolveScopedChatManager(req.query.projectId as string | undefined);
@@ -671,7 +707,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
content.trim(), content.trim(),
normalizedProvider, normalizedProvider,
normalizedModelId, normalizedModelId,
Array.isArray(attachments) ? attachments : undefined, messageAttachments,
{ generationId }, { generationId },
).catch((err: Error) => { ).catch((err: Error) => {
chatLogger.error("Error in sendMessage", { chatLogger.error("Error in sendMessage", {