feat(FN-3118): document avatar storage in agents and architecture

Added avatar storage and field documentation to the agents guide, with a minor reference added to the architecture docs.

Fusion-Task-Id: FN-3118
This commit is contained in:
Fusion
2026-05-05 15:55:43 -07:00
committed by gsxdsm
parent 21bedd89a0
commit e30fe5d245
19 changed files with 621 additions and 19 deletions

View File

@@ -0,0 +1,179 @@
// @vitest-environment node
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import express from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApiRoutes } from "../../routes.js";
import { request } from "../../test-request.js";
const state = {
agents: new Map<string, { id: string; name: string; imageUrl?: string; updatedAt: string; createdAt: string; role: string; state: string; metadata: Record<string, unknown> }>(),
};
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
class MockAgentStore {
async init() {}
async getAgent(id: string) {
return state.agents.get(id) ?? null;
}
async getAgentDetail(id: string) {
return state.agents.get(id) ?? null;
}
async updateAgent(id: string, updates: { imageUrl?: string }) {
const existing = state.agents.get(id);
if (!existing) {
throw new Error("not found");
}
const updated = {
...existing,
imageUrl: updates.imageUrl,
updatedAt: new Date().toISOString(),
};
state.agents.set(id, updated);
return updated;
}
async listAgents() { return []; }
async getRecentRuns() { return []; }
}
return {
...actual,
AgentStore: MockAgentStore,
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
isQmdAvailable: vi.fn().mockResolvedValue(false),
};
});
vi.mock("@fusion/engine", () => ({
createFnAgent: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() } })),
promptWithFallback: vi.fn(),
}));
function buildMultipartBody(fileName: string, mimeType: string, buffer: Buffer): { body: Buffer; contentType: string } {
const boundary = "----fusion-test-boundary";
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"${fileName}\"\r\nContent-Type: ${mimeType}\r\n\r\n`,
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
return {
body: Buffer.concat([head, buffer, tail]),
contentType: `multipart/form-data; boundary=${boundary}`,
};
}
function createMockStore(fusionDir: string) {
return {
getRootDir: vi.fn().mockReturnValue(path.dirname(fusionDir)),
getFusionDir: vi.fn().mockReturnValue(fusionDir),
listTasks: vi.fn().mockResolvedValue([]),
searchTasks: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({}),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn(),
getAgentLogs: vi.fn().mockResolvedValue([]),
getAgentLogCount: vi.fn().mockResolvedValue(0),
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
getTaskDocuments: vi.fn().mockResolvedValue([]),
getTaskDocument: vi.fn().mockResolvedValue(null),
getTaskDocumentRevisions: vi.fn().mockResolvedValue([]),
getAllDocuments: vi.fn().mockResolvedValue([]),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
getMissionStore: vi.fn(),
} as any;
}
describe("agent avatar routes", () => {
let fusionDir: string;
let app: express.Express;
beforeEach(async () => {
state.agents.clear();
const rootDir = await mkdtemp(path.join(os.tmpdir(), "fn-3118-"));
fusionDir = path.join(rootDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
state.agents.set("agent-1", {
id: "agent-1",
name: "Agent One",
role: "engineer",
state: "idle",
metadata: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
app = express();
app.use(express.json());
app.use("/api", createApiRoutes(createMockStore(fusionDir)));
});
afterEach(() => {
vi.clearAllMocks();
});
it("uploads valid png and sets imageUrl", async () => {
const { body, contentType } = buildMultipartBody("avatar.png", "image/png", Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const res = await request(app, "POST", "/api/agents/agent-1/avatar", body, { "content-type": contentType });
expect(res.status).toBe(200);
expect((res.body as any).imageUrl).toBe("/api/agents/agent-1/avatar");
});
it("rejects non-image mime type", async () => {
const { body, contentType } = buildMultipartBody("note.txt", "text/plain", Buffer.from("hello"));
const res = await request(app, "POST", "/api/agents/agent-1/avatar", body, { "content-type": contentType });
expect(res.status).toBe(400);
});
it("rejects oversized file", async () => {
const oversize = Buffer.alloc(2 * 1024 * 1024 + 1, 1);
const { body, contentType } = buildMultipartBody("avatar.png", "image/png", oversize);
const res = await request(app, "POST", "/api/agents/agent-1/avatar", body, { "content-type": contentType });
expect(res.status).toBe(400);
});
it("serves stored avatar with content type", async () => {
const dir = path.join(fusionDir, "agents", "agent-1");
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "avatar.png"), Buffer.from([1, 2, 3, 4]));
const res = await request(app, "GET", "/api/agents/agent-1/avatar");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("image/png");
expect(res.body).toBe("\u0001\u0002\u0003\u0004");
});
it("returns 404 when agent has no avatar", async () => {
const res = await request(app, "GET", "/api/agents/agent-1/avatar");
expect(res.status).toBe(404);
});
it("deletes avatar file and clears imageUrl", async () => {
state.agents.set("agent-1", { ...state.agents.get("agent-1")!, imageUrl: "/api/agents/agent-1/avatar" });
const dir = path.join(fusionDir, "agents", "agent-1");
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "avatar.png"), Buffer.from([1, 2, 3, 4]));
const res = await request(app, "DELETE", "/api/agents/agent-1/avatar");
expect(res.status).toBe(200);
expect((res.body as any).imageUrl).toBeUndefined();
await expect(readFile(path.join(dir, "avatar.png"))).rejects.toThrow();
});
it("returns 404 for non-existent agent on all avatar endpoints", async () => {
const { body, contentType } = buildMultipartBody("avatar.png", "image/png", Buffer.from([1]));
const postRes = await request(app, "POST", "/api/agents/nope/avatar", body, { "content-type": contentType });
const getRes = await request(app, "GET", "/api/agents/nope/avatar");
const delRes = await request(app, "DELETE", "/api/agents/nope/avatar");
expect(postRes.status).toBe(404);
expect(getRes.status).toBe(404);
expect(delRes.status).toBe(404);
});
});

View File

@@ -1,3 +1,5 @@
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Request, Response } from "express";
import type { Agent, AgentCapability, AgentUpdateInput, TaskStore } from "@fusion/core";
import { getDefaultHeartbeatProcedurePath } from "@fusion/core";
@@ -8,8 +10,17 @@ import { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } from "@fusio
interface AgentCoreRouteDeps {
sanitizeAgentTaskLinks: (agents: Agent[], scopedStore: TaskStore) => Promise<Agent[]>;
validateAgentInstructionsPayload: (instructionsPath: unknown, instructionsText: unknown) => boolean;
upload: import("multer").Multer;
}
const AVATAR_MIME_TO_EXT: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
};
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
function isCompatibleDefaultHeartbeatPath(path: string | undefined, agent: Agent): boolean {
const trimmed = path?.trim();
if (!trimmed) {
@@ -210,7 +221,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRouteDeps): void {
const { router, getProjectContext, rethrowAsApiError } = ctx;
const { sanitizeAgentTaskLinks, validateAgentInstructionsPayload } = deps;
const { sanitizeAgentTaskLinks, validateAgentInstructionsPayload, upload } = deps;
/**
* GET /api/agents/stats
@@ -327,6 +338,142 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
}
});
/**
* POST /api/agents/:id/avatar
* Upload agent avatar image.
*/
router.post("/agents/:id/avatar", upload.single("file") as import("express").RequestHandler, async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
if (!agentId) {
throw badRequest("Agent id is required");
}
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound("Agent not found");
}
if (!req.file) {
throw badRequest("No file provided");
}
if (req.file.size > MAX_AVATAR_BYTES) {
throw badRequest("File too large (max 2MB)");
}
const ext = AVATAR_MIME_TO_EXT[req.file.mimetype];
if (!ext) {
throw badRequest("Invalid mime type");
}
const agentDir = path.join(scopedStore.getFusionDir(), "agents", agent.id);
await mkdir(agentDir, { recursive: true });
const entries = await readdir(agentDir);
await Promise.all(entries.filter((entry) => entry.startsWith("avatar.")).map((entry) => rm(path.join(agentDir, entry), { force: true })));
await writeFile(path.join(agentDir, `avatar.${ext}`), req.file.buffer);
const updated = await agentStore.updateAgent(agent.id, { imageUrl: `/api/agents/${agent.id}/avatar` });
res.setHeader("Cache-Control", "no-store");
res.json(updated);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
throw notFound(err instanceof Error ? err.message : String(err));
}
rethrowAsApiError(err);
}
});
/**
* GET /api/agents/:id/avatar
* Serve agent avatar image.
*/
router.get("/agents/:id/avatar", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
if (!agentId) {
throw badRequest("Agent id is required");
}
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound("Agent not found");
}
const agentDir = path.join(scopedStore.getFusionDir(), "agents", agent.id);
const entries = await readdir(agentDir).catch(() => [] as string[]);
const avatarFile = entries.find((entry) => entry.startsWith("avatar."));
if (!avatarFile) {
throw notFound("Avatar not found");
}
const ext = avatarFile.split(".").pop() ?? "";
const mimeType = Object.entries(AVATAR_MIME_TO_EXT).find(([, value]) => value === ext)?.[0];
if (!mimeType) {
throw notFound("Avatar not found");
}
const fileBuffer = await readFile(path.join(agentDir, avatarFile));
res.setHeader("Content-Type", mimeType);
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(fileBuffer);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
throw notFound(err instanceof Error ? err.message : String(err));
}
rethrowAsApiError(err);
}
});
/**
* DELETE /api/agents/:id/avatar
* Remove agent avatar image.
*/
router.delete("/agents/:id/avatar", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
if (!agentId) {
throw badRequest("Agent id is required");
}
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound("Agent not found");
}
const agentDir = path.join(scopedStore.getFusionDir(), "agents", agent.id);
const entries = await readdir(agentDir).catch(() => [] as string[]);
await Promise.all(entries.filter((entry) => entry.startsWith("avatar.")).map((entry) => rm(path.join(agentDir, entry), { force: true })));
const updated = await agentStore.updateAgent(agent.id, { imageUrl: undefined });
res.setHeader("Cache-Control", "no-store");
res.json(updated);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
throw notFound(err instanceof Error ? err.message : String(err));
}
rethrowAsApiError(err);
}
});
/**
* PATCH /api/agents/:id
* Update agent fields.
@@ -371,6 +518,13 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
updates.icon = body.icon ?? undefined;
}
if ("imageUrl" in body) {
if (body.imageUrl !== null && typeof body.imageUrl !== "string") {
throw badRequest("imageUrl must be a string");
}
updates.imageUrl = body.imageUrl ?? undefined;
}
if ("reportsTo" in body) {
if (body.reportsTo !== null && typeof body.reportsTo !== "string") {
throw badRequest("reportsTo must be a string");