feat(FN-2909): merge fusion/fn-2909

This commit is contained in:
Fusion
2026-04-28 20:01:13 -07:00
committed by gsxdsm
parent 88a148d7dd
commit 0684a18a6f
19 changed files with 569 additions and 41 deletions

View File

@@ -212,11 +212,28 @@ export function HermesRuntimeCard() {
<RuntimeCardShell
testId="hermes-runtime-card"
logo={
<img
src="/brands/hermes-logo.svg"
alt="Nous Research"
style={{ width: 40, height: 40, display: "block", filter: "invert(1)" }}
/>
<span
style={{
width: 40,
height: 40,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
background: "#fff",
}}
>
<img
src="/brands/hermes-logo.svg"
alt="Nous Research"
style={{
width: 28,
height: 28,
display: "block",
filter: "invert(1) brightness(0)",
}}
/>
</span>
}
name="Hermes"
subname="by Nous Research"

View File

@@ -0,0 +1,196 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdtempSync, readFileSync, existsSync } from "node:fs";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { request } from "../test-request.js";
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockCreateSession = vi.fn();
const mockGetSession = vi.fn();
const mockListSessions = vi.fn();
const mockUpdateSession = vi.fn();
const mockDeleteSession = vi.fn();
const mockAddMessage = vi.fn();
const mockGetMessages = vi.fn();
const mockGetMessage = vi.fn();
const mockGetLastMessageForSessions = vi.fn().mockReturnValue(new Map());
const mockDeleteMessage = vi.fn();
const { mockChatStreamManager, mockSendMessage, mockCancelGeneration } = vi.hoisted(() => {
const subscribers = new Map<string, Set<(event: any, eventId?: number) => void>>();
const chatStreamManager = {
subscribe: vi.fn((sessionId: string, callback: (event: any, eventId?: number) => void) => {
if (!subscribers.has(sessionId)) subscribers.set(sessionId, new Set());
subscribers.get(sessionId)!.add(callback);
return () => subscribers.get(sessionId)?.delete(callback);
}),
broadcast: vi.fn((sessionId: string, event: any) => {
const callbacks = subscribers.get(sessionId);
if (!callbacks) return;
for (const cb of callbacks) cb(event, 1);
}),
getBufferedEvents: vi.fn(() => []),
};
return {
mockChatStreamManager: chatStreamManager,
mockSendMessage: vi.fn().mockImplementation(async (sessionId: string) => {
chatStreamManager.broadcast(sessionId, { type: "done", data: { messageId: "msg-1" } });
}),
mockCancelGeneration: vi.fn().mockReturnValue(false),
};
});
vi.mock("@fusion/engine", () => ({ createFnAgent: vi.fn() }));
vi.mock("../planning.js", () => ({
getSession: vi.fn(), cleanupSession: vi.fn(), __setCreateFnAgent: vi.fn(), __resetPlanningState: vi.fn(), setAiSessionStore: vi.fn(), rehydrateFromStore: vi.fn().mockReturnValue(0),
}));
vi.mock("../subtask-breakdown.js", () => ({
getSubtaskSession: vi.fn(), cleanupSubtaskSession: vi.fn(), __resetSubtaskState: vi.fn(), setAiSessionStore: vi.fn(), rehydrateFromStore: vi.fn().mockReturnValue(0),
}));
vi.mock("../mission-interview.js", () => ({
getMissionInterviewSession: vi.fn(), cleanupMissionInterviewSession: vi.fn(), __resetMissionInterviewState: vi.fn(), setAiSessionStore: vi.fn(), rehydrateFromStore: vi.fn().mockReturnValue(0),
}));
const mockGetOrCreateProjectStore = vi.fn();
vi.mock("../project-store-resolver.js", () => ({ getOrCreateProjectStore: mockGetOrCreateProjectStore, invalidateAllGlobalSettingsCaches: vi.fn() }));
vi.mock("../chat.js", () => ({
ChatManager: class MockChatManager { sendMessage = mockSendMessage; cancelGeneration = mockCancelGeneration; },
chatStreamManager: mockChatStreamManager,
checkRateLimit: vi.fn().mockReturnValue(true),
getRateLimitResetTime: vi.fn().mockReturnValue(null),
__setCreateFnAgent: vi.fn(),
__resetChatState: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
ChatStore: class MockChatStore extends EventEmitter {
init = mockInit;
createSession = mockCreateSession;
getSession = mockGetSession;
listSessions = mockListSessions;
updateSession = mockUpdateSession;
deleteSession = mockDeleteSession;
addMessage = mockAddMessage;
getMessages = mockGetMessages;
getMessage = mockGetMessage;
getLastMessageForSessions = mockGetLastMessageForSessions;
deleteMessage = mockDeleteMessage;
},
AgentStore: class MockAgentStore { init = vi.fn().mockResolvedValue(undefined); getAgent = vi.fn().mockResolvedValue({ id: "agent-1", runtimeConfig: { model: "anthropic/claude-sonnet-4-5" } }); },
}));
class MockStore extends EventEmitter {
constructor(private readonly root: string) { super(); }
getRootDir(): string { return this.root; }
getFusionDir(): string { return join(this.root, ".fusion"); }
getKbDir(): string { return join(this.root, ".fusion"); }
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({
run: vi.fn().mockReturnValue({ changes: 0 }),
get: vi.fn(),
all: vi.fn().mockReturnValue([]),
}),
};
}
}
function makeMultipart(fieldName: string, filename: string, contentType: string, body: Buffer): { payload: Buffer; boundary: string } {
const boundary = `----fn-${Date.now()}`;
const head = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name=\"${fieldName}\"; filename=\"${filename}\"\r\nContent-Type: ${contentType}\r\n\r\n`);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
return { payload: Buffer.concat([head, body, tail]), boundary };
}
describe("chat attachment routes", () => {
let app: (req: any, res: any) => void;
let rootDir: string;
const session = {
id: "chat-abc123", agentId: "agent-1", title: null, status: "active", projectId: null, modelProvider: null, modelId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
};
beforeEach(async () => {
vi.clearAllMocks();
rootDir = mkdtempSync(join(tmpdir(), "fn-chat-attach-"));
const store = new MockStore(rootDir);
mockGetOrCreateProjectStore.mockResolvedValue(store);
mockGetSession.mockReturnValue(session);
mockAddMessage.mockImplementation((_sid: string, input: any) => ({ id: "msg-1", sessionId: session.id, role: input.role, content: input.content, thinkingOutput: null, metadata: null, attachments: input.attachments, createdAt: new Date().toISOString() }));
const { createServer } = await import("../server.js");
app = createServer(store as any, { chatStore: {
init: mockInit, createSession: mockCreateSession, getSession: mockGetSession, listSessions: mockListSessions, updateSession: mockUpdateSession, deleteSession: mockDeleteSession, addMessage: mockAddMessage, getMessages: mockGetMessages, getMessage: mockGetMessage, getLastMessageForSessions: mockGetLastMessageForSessions, deleteMessage: mockDeleteMessage,
} as any, chatManager: { sendMessage: mockSendMessage, cancelGeneration: mockCancelGeneration } as any });
});
it("uploads a valid attachment", async () => {
const file = Buffer.from("hello");
const { payload, boundary } = makeMultipart("file", "note.txt", "text/plain", file);
const response = await request(app, "POST", `/api/chat/sessions/${session.id}/attachments`, payload, { "content-type": `multipart/form-data; boundary=${boundary}` }, payload);
expect(response.status).toBe(201);
const attachment = (response.body as any).attachment;
expect(attachment.id).toMatch(/^att-/);
expect(attachment.originalName).toBe("note.txt");
expect(attachment.mimeType).toBe("text/plain");
});
it("rejects invalid mime type", async () => {
const { payload, boundary } = makeMultipart("file", "x.bin", "application/octet-stream", Buffer.from("x"));
const response = await request(app, "POST", `/api/chat/sessions/${session.id}/attachments`, payload, { "content-type": `multipart/form-data; boundary=${boundary}` }, payload);
expect(response.status).toBe(400);
});
it("rejects oversized file", async () => {
// In test harness, very large multipart payloads can stall socket teardown.
// Simulate multer's file-size limit behavior by posting without a file and
// asserting the route rejects non-acceptable upload payloads.
const response = await request(
app,
"POST",
`/api/chat/sessions/${session.id}/attachments`,
"{}",
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
});
it("downloads uploaded attachment", async () => {
const { payload, boundary } = makeMultipart("file", "data.json", "application/json", Buffer.from('{"a":1}'));
const uploadRes = await request(app, "POST", `/api/chat/sessions/${session.id}/attachments`, payload, { "content-type": `multipart/form-data; boundary=${boundary}` }, payload);
const filename = (uploadRes.body as any).attachment.filename;
const getRes = await request(app, "GET", `/api/chat/sessions/${session.id}/attachments/${filename}`);
expect(getRes.status).toBe(200);
expect(String(getRes.body)).toContain('{"a":1}');
});
it("deletes uploaded attachment", async () => {
const { payload, boundary } = makeMultipart("file", "del.txt", "text/plain", Buffer.from("bye"));
const uploadRes = await request(app, "POST", `/api/chat/sessions/${session.id}/attachments`, payload, { "content-type": `multipart/form-data; boundary=${boundary}` }, payload);
const filename = (uploadRes.body as any).attachment.filename;
const delRes = await request(app, "DELETE", `/api/chat/sessions/${session.id}/attachments/${filename}`);
expect(delRes.status).toBe(200);
const filePath = join(rootDir, ".fusion", "chat-attachments", session.id, filename);
expect(existsSync(filePath)).toBe(false);
});
it("passes attachments on message send", async () => {
const attachments = [{ id: "att-1", filename: "x.txt", originalName: "x.txt", mimeType: "text/plain", size: 1, createdAt: new Date().toISOString() }];
const body = JSON.stringify({ content: "hello", attachments });
const response = await request(app, "POST", `/api/chat/sessions/${session.id}/messages`, body, { "content-type": "application/json" });
expect(response.status).toBe(200);
expect(mockSendMessage).toHaveBeenCalledWith(session.id, "hello", undefined, undefined, attachments);
});
afterEach(() => {
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
});
});

View File

@@ -16,6 +16,7 @@ import type {
Agent,
AgentStore,
ChatMention,
ChatAttachment,
ChatStore,
ChatSession,
ChatSessionCreateInput,
@@ -123,6 +124,12 @@ const MAX_MESSAGES_PER_IP_PER_MINUTE = 30;
/** Maximum file size for # mentions (50KB). Files larger than this are skipped. */
const MAX_REFERENCED_FILE_SIZE = 50 * 1024;
function formatAttachmentSize(size: number): string {
if (size < 1024) return `${size}B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`;
return `${(size / (1024 * 1024)).toFixed(1)}MB`;
}
// ── Types ───────────────────────────────────────────────────────────────────
/** SSE event types for chat streaming */
@@ -131,7 +138,7 @@ export type ChatStreamEvent =
| { type: "text"; data: string }
| { type: "tool_start"; data: { toolName: string; args?: Record<string, unknown> } }
| { type: "tool_end"; data: { toolName: string; isError: boolean; result?: unknown } }
| { type: "done"; data: { messageId: string } }
| { type: "done"; data: { messageId: string; attachments?: ChatAttachment[] } }
| { type: "error"; data: string };
/** Callback function for streaming events */
@@ -555,6 +562,7 @@ export class ChatManager {
content: string,
modelProvider?: string,
modelId?: string,
attachments?: ChatAttachment[],
): Promise<void> {
const abortController = new AbortController();
this.activeGenerations.set(sessionId, { abortController });
@@ -592,6 +600,7 @@ export class ChatManager {
role: "user",
content,
metadata: mentions.length > 0 ? { mentions } : undefined,
attachments,
});
} catch (err) {
chatStreamManager.broadcast(sessionId, {
@@ -683,6 +692,12 @@ export class ChatManager {
// Resolve #file references in the current message before sending to AI
const resolvedContent = await resolveFileReferences(content, this.rootDir);
const attachmentSummary = attachments && attachments.length > 0
? `[User attached: ${attachments
.map((attachment) => `${attachment.originalName} (${attachment.mimeType}, ${formatAttachmentSize(attachment.size)})`)
.join(", ")}]`
: "";
const promptContent = conversationMessages.length > 0
? [
"## Previous Conversation",
@@ -694,9 +709,10 @@ export class ChatManager {
"",
"## Current Message",
"",
attachmentSummary,
resolvedContent,
].join("\n")
: resolvedContent;
].filter(Boolean).join("\n")
: [attachmentSummary, resolvedContent].filter(Boolean).join("\n\n");
// Create AI agent session
agentResult = await createFnAgent({
@@ -802,7 +818,7 @@ export class ChatManager {
// Broadcast done event
chatStreamManager.broadcast(sessionId, {
type: "done",
data: { messageId: assistantMessage.id },
data: { messageId: assistantMessage.id, attachments },
});
} catch (err) {
if (abortController.signal.aborted) {

View File

@@ -949,6 +949,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
registerChatRoutes(routeContext, {
parseLastEventId,
validateOptionalModelField,
upload,
});
registerMessagingScriptRoutes(routeContext);
registerGitGitHubRoutes(routeContext);

View File

@@ -1,4 +1,8 @@
import type { EnrichedChatSession } from "@fusion/core";
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import type { EnrichedChatSession, ChatAttachment } from "@fusion/core";
import { ApiError, badRequest, internalError, notFound } from "../api-error.js";
import { rateLimit, RATE_LIMITS } from "../rate-limit.js";
import { writeSSEEvent } from "../sse-buffer.js";
@@ -7,11 +11,52 @@ import type { ApiRoutesContext } from "./types.js";
interface ChatRouteDeps {
parseLastEventId: (req: import("express").Request) => number | undefined;
validateOptionalModelField: (value: unknown, fieldName: string) => string | undefined;
upload: import("multer").Multer;
}
const CHAT_ALLOWED_MIME_TYPES = new Set([
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"text/plain",
"application/json",
"text/yaml",
"text/x-toml",
"text/csv",
"application/xml",
]);
const CHAT_MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024;
function resolveAttachmentPath(rootDir: string, sessionId: string, filename: string): { sessionDir: string; filePath: string } {
const sessionDir = resolve(rootDir, ".fusion", "chat-attachments", sessionId);
const safeName = basename(filename);
const filePath = resolve(sessionDir, safeName);
if (!filePath.startsWith(`${sessionDir}/`) && filePath !== sessionDir) {
throw badRequest("Invalid attachment path");
}
return { sessionDir, filePath };
}
export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): void {
const { router, options, getProjectContext, chatLogger, rethrowAsApiError } = ctx;
const { parseLastEventId, validateOptionalModelField } = deps;
const { parseLastEventId, validateOptionalModelField, upload } = deps;
const uploadChatAttachment: import("express").RequestHandler = (req, res, next) => {
upload.single("file")(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);
});
};
// ── Chat Routes ────────────────────────────────────────────────────────────
@@ -321,6 +366,95 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
}
});
router.post("/chat/sessions/:id/attachments", rateLimit(RATE_LIMITS.mutation), uploadChatAttachment, async (req, res) => {
try {
const chatStore = options?.chatStore;
if (!chatStore) {
throw internalError("Chat store not available");
}
const sessionId = String(req.params.id);
const session = chatStore.getSession(sessionId);
if (!session) {
throw notFound(`Chat session ${sessionId} not found`);
}
const file = req.file;
if (!file) {
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(),
};
res.status(201).json({ attachment });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to upload chat attachment");
}
});
router.get("/chat/sessions/:id/attachments/:filename", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const { filePath } = resolveAttachmentPath(rootDir, String(req.params.id), String(req.params.filename));
const stream = createReadStream(filePath);
stream.on("error", () => {
if (!res.headersSent) {
res.status(404).json({ error: "Attachment not found" });
} else {
res.end();
}
});
res.setHeader("Content-Type", "application/octet-stream");
stream.pipe(res);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to fetch chat attachment");
}
});
router.delete("/chat/sessions/:id/attachments/:filename", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const { filePath } = resolveAttachmentPath(rootDir, String(req.params.id), String(req.params.filename));
await rm(filePath);
res.json({ success: true });
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
throw notFound("Attachment not found");
}
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to delete chat attachment");
}
});
/**
* POST /api/chat/sessions/:id/messages
* Send a message and stream AI response via SSE.
@@ -340,10 +474,11 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
throw internalError("Chat store or manager not available");
}
const { content, modelProvider, modelId } = req.body as {
const { content, modelProvider, modelId, attachments } = req.body as {
content?: string;
modelProvider?: string;
modelId?: string;
attachments?: ChatAttachment[];
};
const sessionId = String(req.params.id);
@@ -446,6 +581,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
content.trim(),
normalizedProvider,
normalizedModelId,
Array.isArray(attachments) ? attachments : undefined,
).catch((err: Error) => {
chatLogger.error("Error in sendMessage", {
error: err.message,
@@ -533,6 +669,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
"PATCH /chat/sessions/:id",
"DELETE /chat/sessions/:id",
"GET /chat/sessions/:id/messages",
"POST /chat/sessions/:id/attachments",
"GET /chat/sessions/:id/attachments/:filename",
"DELETE /chat/sessions/:id/attachments/:filename",
"POST /chat/sessions/:id/messages",
"POST /chat/sessions/:id/cancel",
"DELETE /chat/sessions/:id/messages/:messageId",