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:
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user