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

@@ -108,6 +108,18 @@ function makeMultipart(fieldName: string, filename: string, contentType: string,
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", () => {
let app: (req: any, res: any) => void;
let rootDir: string;
@@ -192,6 +204,27 @@ describe("chat attachment routes", () => {
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(() => {
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 ───────────────────────────────────────────────────────────────────
describe("Chat API Routes", () => {
@@ -1124,6 +1145,64 @@ describe("Chat API Routes", () => {
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", () => {
/**
* Helper to invoke the chat SSE route handler directly.

View File

@@ -19,6 +19,8 @@ interface ChatRouteDeps {
upload: import("multer").Multer;
}
const CHAT_MESSAGE_MAX_ATTACHMENTS = 10;
function resolveAttachmentPath(rootDir: string, sessionId: string, filename: string): { sessionDir: string; filePath: string } {
const sessionDir = resolve(rootDir, ".fusion", "chat-attachments", sessionId);
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 ───────────────────────────────────
async function resolveScopedChatStore(projectId: string | undefined) {
@@ -389,32 +437,8 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
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 rootDir = scopedStore.getRootDir();
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(),
};
const attachment = await persistChatAttachment(file, scopedStore.getRootDir(), sessionId);
res.status(201).json({ attachment });
} catch (err: unknown) {
@@ -550,16 +574,17 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
* - done: Message sent successfully with messageId + persisted assistant message snapshot
* - 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 {
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;
modelProvider?: string;
modelId?: string;
attachments?: ChatAttachment[];
};
const { content, modelProvider, modelId, attachments } = body;
const sessionId = String(req.params.id);
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`);
}
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
// failures (e.g. project DB cannot be opened) produce a proper HTTP error.
const chatManager = await resolveScopedChatManager(req.query.projectId as string | undefined);
@@ -671,7 +707,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
content.trim(),
normalizedProvider,
normalizedModelId,
Array.isArray(attachments) ? attachments : undefined,
messageAttachments,
{ generationId },
).catch((err: Error) => {
chatLogger.error("Error in sendMessage", {