FN-103: support large request payloads

Allow large chat logs and workspace file-editor saves to pass through the dashboard without payload-too-large failures.

- Increase request-body handling capacity for large text endpoints.
- Document the payload limits and add integration coverage for chat and workspace-file APIs.
- Add the FN-103 release changeset.

Files changed:
 .changeset/fn-103-large-text-payloads.md           |   7 +
 packages/dashboard/README.md                       |  15 +-
 .../app/api/__tests__/chat-rooms-api.test.ts       |  11 ++
 .../app/api/__tests__/legacy-chat-stream.test.ts   |  19 ++
 .../app/api/__tests__/workspace-files-api.test.ts  |  30 ++++
 .../large-text-body-parser-integration.test.ts     | 195 +++++++++++++++++++++
 packages/dashboard/src/routes/README.md            |  21 +++
 packages/dashboard/src/server.ts                   |  29 ++-
 8 files changed, 325 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-103

Fusion-Task-Lineage: 05cb2642-1f55-4939-8a28-78badf9b7caa

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-21 05:08:44 +00:00
parent 9e0f64af9a
commit 2bdc4448ef
8 changed files with 325 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Allow larger chat logs and supported file-editor saves without payload errors.
category: fix
dev: Adds finite route-scoped 2 MiB chat and escaped-file JSON parsers while retaining default limits.

View File

@@ -459,12 +459,25 @@ Browse and edit task worktree files directly from the task detail modal:
- **Safety Features**:
- Path traversal prevention (blocks `..` patterns)
- Binary file detection (prevents editing images, executables, etc.)
- 1MB file size limit
- 1MB UTF-8 file-content size limit
- File-save JSON requests allow up to 6,292,480 bytes so a supported 1MB control-character file
can survive JSON escaping; this transport envelope does not increase the file-content limit
- Unsaved change indicators
- **Keyboard Shortcuts**:
- `Ctrl/Cmd+S` to save
- `Escape` to close
### Large chat and file requests
Pasted Direct, Planner, and Room Chat text sent as JSON can be up to 2 MiB per request. The limit
is a transport limit, not a model-context promise: provider context also includes conversation
history, instructions, tool input, reasoning, and output. Chat attachment uploads remain multipart
with their existing file count and size limits.
File-editor saves have an approximately 6 MiB JSON transport envelope only to accommodate JSON
escaping of the unchanged 1 MiB UTF-8 file-content maximum. File-browser operations such as mkdir,
copy, move, delete, and rename, plus unrelated JSON endpoints, retain the 100 KiB request limit.
### Activity Log
View a centralized timeline of all task lifecycle events. Click the history icon in the header to open the Activity Log modal.

View File

@@ -71,6 +71,17 @@ describe("chat room legacy API client", () => {
expect((fetchMock.mock.calls[2] as [string])[0]).toContain("/api/chat/rooms/room-1/members/agent-2?projectId=proj-2");
});
it("serializes a large room log byte-for-byte", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ message: {} }));
const log = "2026-08-21T04:35:00Z INFO repeated room line\n".repeat(3_000);
await postChatRoomMessage("room-1", { content: log }, "proj-3");
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe(JSON.stringify({ content: log }));
expect(JSON.parse(String(init.body)).content).toBe(log);
});
it("builds message endpoints", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ success: true }));

View File

@@ -51,6 +51,25 @@ describe("streamChatResponse SSE parser", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("preserves a large no-attachment log as JSON while attachments remain multipart", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(createChunkedStream(["event: done\ndata: {\"messageId\":\"m-large\"}\n\n"]), { status: 200 }),
);
const log = "2026-08-21T04:35:00Z INFO repeated log line\n".repeat(3_000);
streamChatResponse("s-1", log, { onDone: vi.fn(), onError: vi.fn() });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const jsonRequest = fetchMock.mock.calls[0]?.[1];
expect(jsonRequest?.body).toBe(JSON.stringify({ content: log }));
expect(JSON.parse(String(jsonRequest?.body)).content).toBe(log);
streamChatResponse("s-1", log, { onDone: vi.fn(), onError: vi.fn() }, [new File(["file"], "note.txt")]);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
const multipartBody = fetchMock.mock.calls[1]?.[1]?.body as FormData;
expect(multipartBody).toBeInstanceOf(FormData);
expect(multipartBody.get("content")).toBe(log);
});
it("serializes replacement identity in JSON and multipart requests", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(createChunkedStream(["event: done\ndata: {\"messageId\":\"m-1\"}\n\n"]), { status: 200 }),

View File

@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { saveFileContent, saveWorkspaceFileContent } from "../projects/workspace-files";
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
/**
* FNXC:LargeTextPayloads 2026-08-21-04:35:
* File-editor clients must keep canonical JSON serialization so the server's finite escaped-file
* envelope covers every shared editor without introducing client-specific truncation.
*/
describe("workspace file save API", () => {
afterEach(() => vi.restoreAllMocks());
it("serializes large workspace and compatibility task-file content exactly", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ success: true, mtime: "now", size: 1 }));
const content = "2026-08-21T04:35:00Z INFO repeated file log\n".repeat(3_000);
await saveWorkspaceFileContent("project", "logs/large.log", content, "project-1");
await saveFileContent("task-1", "logs/large.log", content, "project-1");
const [workspaceUrl, workspaceInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(workspaceUrl).toContain("/api/files/logs%2Flarge.log?workspace=project&projectId=project-1");
expect(workspaceInit.body).toBe(JSON.stringify({ content }));
const [taskUrl, taskInit] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(taskUrl).toContain("/api/tasks/task-1/files/logs%2Flarge.log?projectId=project-1");
expect(taskInit.body).toBe(JSON.stringify({ content }));
});
});

View File

@@ -0,0 +1,195 @@
// @vitest-environment node
import { EventEmitter } from "node:events";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Settings, TaskStore } from "@fusion/core";
const scopedChatManager = vi.hoisted(() => ({ current: undefined as never }));
vi.mock("../chat-project-services.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../chat-project-services.js")>();
return {
...actual,
getOrCreateScopedChatManager: () => scopedChatManager.current,
createProjectScopedChatManager: async () => scopedChatManager.current,
};
});
import { createServer } from "../server.js";
import { MAX_FILE_SIZE } from "../file-service.js";
import { chatStreamManager } from "../chat.js";
import { request } from "../test-request.js";
const LARGE_LOG = "2026-08-21T04:35:00Z INFO repeated log line\n".repeat(3_000).trimEnd();
const largeJson = (content = LARGE_LOG) => JSON.stringify({ content });
/** Minimal production-stack store: route tests intentionally use createServer, not a router. */
class LargeTextParserStore extends EventEmitter {
constructor(private readonly rootDir: string) { super(); }
getRootDir() { return this.rootDir; }
getFusionDir() { return join(this.rootDir, ".fusion"); }
getSettings = vi.fn(async (): Promise<Settings> => ({} as Settings));
getSettingsFast = this.getSettings;
getGlobalSettingsStore = () => ({ getSettings: async () => ({}) });
getAsyncLayer = vi.fn(() => ({ db: { update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn(() => ({ returning: vi.fn(async () => []) })) })) })) } }));
getProjectScopedPluginMcpServers = vi.fn().mockResolvedValue([]);
getTask = vi.fn(async () => ({ worktree: this.rootDir }));
getTaskWorkflowSelection = vi.fn();
getWorkflowDefinition = vi.fn(async () => undefined);
getWorkflowSettingValues = vi.fn(() => ({}));
getWorkflowSettingsProjectId = vi.fn(() => "default");
}
const roots: string[] = [];
afterEach(async () => {
chatStreamManager.reset();
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function app() {
const root = await mkdtemp(join(tmpdir(), "fusion-large-text-"));
roots.push(root);
const store = new LargeTextParserStore(root);
const chatStore = {
getSession: vi.fn(async (id: string) => {
if (id === "session-1") return { id, agentId: "__fn_agent__", projectId: null };
if (id === "planner-session") return { id, agentId: "task-planner:task-1", projectId: null };
return undefined;
}),
getRoom: vi.fn(async (id: string) => id === "room-1" ? { id, projectId: null } : undefined),
};
const chatManager = {
prepareReplacement: vi.fn(async () => ({ generationId: 1 })),
beginGeneration: vi.fn(() => ({ generationId: 2, abortController: new AbortController() })),
isGenerating: vi.fn(() => false),
sendMessage: vi.fn(async (sessionId: string, content: string, _provider: unknown, _model: unknown, _attachments: unknown, options: { generationId?: number }) => {
chatStreamManager.broadcast(sessionId, { type: "done", data: { messageId: "assistant-1" } }, { generationId: options.generationId });
}),
sendRoomMessage: vi.fn(async (_roomId: string, content: string) => ({ userMessage: { id: "room-message-1", content } })),
};
scopedChatManager.current = chatManager as never;
return {
root,
chatManager,
app: createServer(store as unknown as TaskStore, {
noAuth: true,
chatStore: chatStore as never,
chatManager: chatManager as never,
}),
};
}
/**
* FNXC:LargeTextPayloads 2026-08-21-04:35:
* These requests boot the complete server middleware stack because registrar tests cannot prove
* that the central 100 KiB parser admits only the finite chat and file-save envelopes.
*/
describe("large-text body parser integration", () => {
it("delivers an exact over-100-KiB log once through direct, planner, and room chat", async () => {
const server = await app();
const body = largeJson();
expect(Buffer.byteLength(body)).toBeGreaterThan(100 * 1024);
expect(Buffer.byteLength(body)).toBeLessThanOrEqual(2 * 1024 * 1024);
const direct = await request(server.app, "POST", "/api/chat/sessions/session-1/messages", body, { "content-type": "application/json" });
const planner = await request(server.app, "POST", "/api/chat/sessions/planner-session/messages", JSON.stringify({ content: LARGE_LOG, taskId: "task-1" }), { "content-type": "application/json" });
const room = await request(server.app, "POST", "/api/chat/rooms/room-1/messages/", body, { "content-type": "application/json" });
expect(direct.status).toBe(200);
expect(planner.status).toBe(200);
expect(room.status).toBe(201);
expect(server.chatManager.sendMessage).toHaveBeenCalledTimes(2);
expect(server.chatManager.sendMessage).toHaveBeenNthCalledWith(1, "session-1", LARGE_LOG, undefined, undefined, undefined, { generationId: 2 });
expect(server.chatManager.sendMessage).toHaveBeenNthCalledWith(2, "planner-session", LARGE_LOG, undefined, undefined, undefined, { generationId: 2 });
expect(server.chatManager.sendRoomMessage).toHaveBeenCalledTimes(1);
expect(server.chatManager.sendRoomMessage).toHaveBeenCalledWith("room-1", LARGE_LOG, undefined);
expect((room.body as { message: { content: string } }).message.content).toBe(LARGE_LOG);
});
it("persists over-100-KiB workspace saves exactly, including encoded paths and operation-named files", async () => {
const server = await app();
const body = largeJson();
await mkdir(join(server.root, "src"));
const encodedPath = await request(server.app, "POST", "/api/files/src%2Fcopy?workspace=project", body, { "content-type": "application/json" });
const operationNamedFile = await request(server.app, "POST", "/api/files/copy?workspace=project", body, { "content-type": "application/json" });
expect(encodedPath.status).toBe(200);
expect(operationNamedFile.status).toBe(200);
expect(await readFile(join(server.root, "src", "copy"), "utf8")).toBe(LARGE_LOG);
expect(await readFile(join(server.root, "copy"), "utf8")).toBe(LARGE_LOG);
});
it("persists the worst-case escaped 1 MiB content through workspace and task saves", async () => {
const server = await app();
const content = "\0".repeat(MAX_FILE_SIZE);
const body = JSON.stringify({ content });
const fileLimit = 6 * MAX_FILE_SIZE + 1024;
expect(Buffer.byteLength(content, "utf8")).toBe(MAX_FILE_SIZE);
expect(Buffer.byteLength(body)).toBeGreaterThan(2 * 1024 * 1024);
expect(Buffer.byteLength(body)).toBeLessThanOrEqual(fileLimit);
const workspace = await request(server.app, "POST", "/api/files/escaped.txt?workspace=project", body, { "content-type": "application/json" });
const taskFile = await request(server.app, "POST", "/api/tasks/task-1/files/escaped-task.txt", body, { "content-type": "application/json" });
expect(workspace.status).toBe(200);
expect(taskFile.status).toBe(200);
expect(await readFile(join(server.root, "escaped.txt"))).toEqual(Buffer.from(content));
expect(await readFile(join(server.root, "escaped-task.txt"))).toEqual(Buffer.from(content));
});
it("keeps the 1 MiB file-service cap and finite transport envelope separate", async () => {
const server = await app();
const applicationTooLarge = "x".repeat(MAX_FILE_SIZE + 1);
const applicationBody = JSON.stringify({ content: applicationTooLarge });
const transportLimit = 6 * MAX_FILE_SIZE + 1024;
expect(Buffer.byteLength(applicationBody)).toBeLessThanOrEqual(transportLimit);
const applicationResponse = await request(server.app, "POST", "/api/files/too-large.txt?workspace=project", applicationBody, { "content-type": "application/json" });
expect(applicationResponse.status).toBe(413);
const transportBody = JSON.stringify({ content: "", ignoredPadding: "x".repeat(transportLimit) });
expect(Buffer.byteLength(transportBody)).toBeGreaterThan(transportLimit);
const transportResponse = await request(server.app, "POST", "/api/files/transport-too-large.txt?workspace=project", transportBody, { "content-type": "application/json" });
expect(transportResponse.status).toBe(413);
expect(transportResponse.body).toEqual({ error: "payload-too-large" });
});
it.each([
"/api/files/mkdir",
"/api/files/mkdir/",
"/api/files/src%2Findex.ts/copy",
"/api/files/src%2Findex.ts/move/",
"/api/files/src%2Findex.ts/delete",
"/api/files/src%2Findex.ts/rename/",
"/api/files",
"/api/files-extra/save",
"/api/chat/sessions/session-1/messages-extra",
])("keeps %s on the default 100-KiB parser", async (path) => {
const server = await app();
const response = await request(server.app, "POST", path, largeJson(), { "content-type": "application/json" });
expect(response.status).toBe(413);
expect(response.body).toEqual({ error: "payload-too-large" });
});
it("keeps non-POST chat and file lookalikes on the default parser", async () => {
const server = await app();
for (const path of ["/api/chat/sessions/session-1/messages", "/api/files/large.txt"]) {
const response = await request(server.app, "PUT", path, largeJson(), { "content-type": "application/json" });
expect(response.status).toBe(413);
expect(response.body).toEqual({ error: "payload-too-large" });
}
});
it("rejects oversized replacement chat bodies before generation or rewind work", async () => {
const server = await app();
const body = JSON.stringify({ content: "x".repeat(2 * 1024 * 1024), replacementMessageId: "message-1" });
expect(Buffer.byteLength(body)).toBeGreaterThan(2 * 1024 * 1024);
const response = await request(server.app, "POST", "/api/chat/sessions/session-1/messages", body, { "content-type": "application/json" });
expect(response.status).toBe(413);
expect(response.body).toEqual({ error: "payload-too-large" });
expect(server.chatManager.prepareReplacement).not.toHaveBeenCalled();
expect(server.chatManager.beginGeneration).not.toHaveBeenCalled();
expect(server.chatManager.sendMessage).not.toHaveBeenCalled();
});
});

View File

@@ -180,3 +180,24 @@ return 404. Download returns 202 with queued/downloading state; poll status for
`server.ts` excludes only `/api/voice/transcribe` from its global 100 KiB JSON parser so the
route's 2 MiB parser can return JSON 413/400 errors; other routes retain raw-body HMAC capture.
## Large text JSON parsing
`server.ts` preserves raw bodies and selects a finite parser before metrics, authentication, and
all API registrars. The default remains 100 KiB. Only `POST /api/chat/sessions/:id/messages` and
`POST /api/chat/rooms/:id/messages` (including optional trailing slashes) receive a 2 MiB JSON
limit. Multipart requests on those paths are not parsed by `express.json()` and continue to Multer.
Only non-empty `POST /api/tasks/:id/files/{*filepath}` and generic
`POST /api/files/{*filepath}` saves receive `6 * MAX_FILE_SIZE + 1024` bytes (6,292,480 bytes).
A supported 1 MiB UTF-8 string can expand to six bytes per control character in canonical JSON;
the extra 1 KiB covers object framing. `/api/files/mkdir` and literal terminal `/copy`, `/move`,
`/delete`, and `/rename` operation paths retain 100 KiB, including optional trailing slashes. The
selector uses query-free, undecoded `req.path`, so an encoded filepath such as `src%2Fcopy` remains
a generic save rather than an operation.
The roughly 6 MiB file envelope is a deliberate exact-route trade-off: Express warns that larger
bodies consume memory and can add latency. It is not derived from model context windows, because
HTTP parsing precedes model selection, bytes are not tokens, and model context also includes
history, system/tool input, reasoning, and output. GitHub raw webhook parsing stays first;
Voice keeps its route-owned 2 MiB parser and Planning keeps its route-owned 5 MiB parser.

View File

@@ -33,6 +33,7 @@ import {
setOnProjectFirstCreated,
} from "./project-store-resolver.js";
import { getOrCreateScopedChatStore } from "./chat-project-services.js";
import { MAX_FILE_SIZE } from "./file-service.js";
import { TerminalViewportRegistry } from "./terminal-viewport.js";
import { getTerminalService, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
import { WebSocketServer, type WebSocket } from "ws";
@@ -1018,13 +1019,39 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
};
const jsonParser = express.json({ verify: preserveRawBody });
const planningImageCaptureParser = express.json({ limit: "5mb", verify: preserveRawBody });
const chatMessageParser = express.json({ limit: 2 * 1024 * 1024, verify: preserveRawBody });
const fileSaveParser = express.json({ limit: 6 * MAX_FILE_SIZE + 1024, verify: preserveRawBody });
/*
FNXC:LargeTextPayloads 2026-08-21-04:35:
Large pasted logs must reach only production chat-message endpoints within a finite 2 MiB JSON
envelope, while generic workspace and task-file saves receive 6 * MAX_FILE_SIZE + 1 KiB. A
supported 1 MiB UTF-8 file can serialize each control byte as six JSON bytes; 1 KiB covers the
canonical object framing. Express warns that large bodies increase memory and latency, so the
approximately 6 MiB parser is limited to exact save routes: mkdir and literal copy/move/delete/
rename operations retain the 100 KiB default. Model context windows cannot define HTTP bytes:
parsing precedes model resolution, bytes are not tokens, and context is shared with history,
system/tool input, reasoning, and output.
*/
const isChatMessagePath = (path: string): boolean =>
/^\/api\/chat\/(?:sessions|rooms)\/[^/]+\/messages\/?$/.test(path);
const isTaskFileSavePath = (path: string): boolean =>
/^\/api\/tasks\/[^/]+\/files\/.+\/?$/.test(path);
const isWorkspaceFileSavePath = (path: string): boolean => {
if (!/^\/api\/files\/.+/.test(path) || /^\/api\/files\/mkdir\/?$/.test(path)) return false;
return !/^\/api\/files\/.+\/(?:copy|move|delete|rename)\/?$/.test(path);
};
app.use((req, res, next) => {
// Express treats trailing slashes as equivalent, so parser boundaries must do the same;
// no broader prefix is exempted from the global rawBody-preserving parser.
if (req.path === "/api/voice/transcribe" || req.path === "/api/voice/transcribe/") return next();
const parser = req.path === "/api/planning/start-streaming" || req.path === "/api/planning/start-streaming/"
? planningImageCaptureParser
: jsonParser;
: req.method === "POST" && isChatMessagePath(req.path)
? chatMessageParser
: req.method === "POST" && (isTaskFileSavePath(req.path) || isWorkspaceFileSavePath(req.path))
? fileSaveParser
: jsonParser;
return parser(req, res, (error) => {
// Keep the established global and route-specific size rejections observable as 413 instead
// of allowing Express's parser error to fall through to the generic 500 handler.