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