feat(FN-1186): add Performance tab to agent detail with ratings
- Add agent rating API routes (GET/PATCH /api/agents/:id/ratings) - Add API helper functions for fetching and updating agent ratings - Add Performance tab UI to AgentDetailView with star rating display - Add CSS styling for the Performance tab and star rating components - Add comprehensive route tests for agent ratings endpoints
This commit is contained in:
285
packages/dashboard/src/__tests__/routes-agent-ratings.test.ts
Normal file
285
packages/dashboard/src/__tests__/routes-agent-ratings.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { request } from "../test-request.js";
|
||||
|
||||
// ── Mock @fusion/core for agent ratings ─────────────────────────────────
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAddRating = vi.fn();
|
||||
const mockGetRatings = vi.fn();
|
||||
const mockGetRatingSummary = vi.fn();
|
||||
const mockDeleteRating = vi.fn();
|
||||
|
||||
vi.mock("@fusion/core", () => {
|
||||
return {
|
||||
AgentStore: class MockAgentStore {
|
||||
init = mockInit;
|
||||
addRating = mockAddRating;
|
||||
getRatings = mockGetRatings;
|
||||
getRatingSummary = mockGetRatingSummary;
|
||||
deleteRating = mockDeleteRating;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ── Mock Store ────────────────────────────────────────────────────────
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-1186-test";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-1186-test/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
get: vi.fn(),
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function createMockRating(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "rating-001",
|
||||
agentId: "agent-001",
|
||||
score: 5,
|
||||
category: "quality",
|
||||
comment: "Great work!",
|
||||
raterType: "user",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockSummary(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
agentId: "agent-001",
|
||||
averageScore: 4.5,
|
||||
totalRatings: 10,
|
||||
categoryAverages: {
|
||||
quality: 4.8,
|
||||
speed: 4.2,
|
||||
},
|
||||
recentRatings: [],
|
||||
trend: "improving" as const,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Agent ratings routes", () => {
|
||||
let store: MockStore;
|
||||
let app: ReturnType<typeof import("../server.js").createServer>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
|
||||
store = new MockStore();
|
||||
const { createServer } = await import("../server.js");
|
||||
app = createServer(store as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/agents/:id/ratings", () => {
|
||||
it("returns ratings array with 200", async () => {
|
||||
const mockRatings = [createMockRating(), createMockRating({ id: "rating-002", score: 4 })];
|
||||
mockGetRatings.mockResolvedValue(mockRatings);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/agent-001/ratings");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRatings);
|
||||
expect(mockGetRatings).toHaveBeenCalledWith("agent-001", { limit: 50, category: undefined });
|
||||
});
|
||||
|
||||
it("passes limit and category query params to store", async () => {
|
||||
mockGetRatings.mockResolvedValue([]);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/agent-001/ratings?limit=10&category=quality");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetRatings).toHaveBeenCalledWith("agent-001", { limit: 10, category: "quality" });
|
||||
});
|
||||
|
||||
it("returns empty array when no ratings exist", async () => {
|
||||
mockGetRatings.mockResolvedValue([]);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/agent-001/ratings");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/ratings", () => {
|
||||
it("creates rating with 201, returns the created rating", async () => {
|
||||
const mockRating = createMockRating();
|
||||
mockAddRating.mockResolvedValue(mockRating);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/ratings",
|
||||
JSON.stringify({ score: 5, category: "quality", comment: "Great work!" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockRating);
|
||||
expect(mockAddRating).toHaveBeenCalledWith("agent-001", {
|
||||
score: 5,
|
||||
category: "quality",
|
||||
comment: "Great work!",
|
||||
runId: undefined,
|
||||
taskId: undefined,
|
||||
raterType: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults raterType to 'user' when not provided in body", async () => {
|
||||
const mockRating = createMockRating({ raterType: "user" });
|
||||
mockAddRating.mockResolvedValue(mockRating);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/ratings",
|
||||
JSON.stringify({ score: 4 }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockAddRating).toHaveBeenCalledWith("agent-001", {
|
||||
score: 4,
|
||||
category: undefined,
|
||||
comment: undefined,
|
||||
runId: undefined,
|
||||
taskId: undefined,
|
||||
raterType: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when score is missing", async () => {
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/ratings",
|
||||
JSON.stringify({ category: "quality" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((response.body as any).error).toContain("score is required");
|
||||
});
|
||||
|
||||
it("returns 400 when score is not a number between 1 and 5", async () => {
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/ratings",
|
||||
JSON.stringify({ score: 6 }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((response.body as any).error).toContain("score must be a number between 1 and 5");
|
||||
});
|
||||
|
||||
it("returns 400 when score is below 1", async () => {
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/ratings",
|
||||
JSON.stringify({ score: 0 }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((response.body as any).error).toContain("score must be a number between 1 and 5");
|
||||
});
|
||||
|
||||
it("passes all optional fields through (category, comment, runId, taskId, raterType)", async () => {
|
||||
const mockRating = createMockRating();
|
||||
mockAddRating.mockResolvedValue(mockRating);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/agents/agent-001/ratings",
|
||||
JSON.stringify({
|
||||
score: 5,
|
||||
category: "speed",
|
||||
comment: "Very fast!",
|
||||
runId: "run-123",
|
||||
taskId: "task-456",
|
||||
raterType: "system",
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockAddRating).toHaveBeenCalledWith("agent-001", {
|
||||
score: 5,
|
||||
category: "speed",
|
||||
comment: "Very fast!",
|
||||
runId: "run-123",
|
||||
taskId: "task-456",
|
||||
raterType: "system",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/agents/:id/ratings/summary", () => {
|
||||
it("returns summary object with 200", async () => {
|
||||
const mockSummary = createMockSummary();
|
||||
mockGetRatingSummary.mockResolvedValue(mockSummary);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/agent-001/ratings/summary");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockSummary);
|
||||
expect(mockGetRatingSummary).toHaveBeenCalledWith("agent-001");
|
||||
});
|
||||
|
||||
it("returns 500 when store throws an error", async () => {
|
||||
mockGetRatingSummary.mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/agent-001/ratings/summary");
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/agents/:id/ratings/:ratingId", () => {
|
||||
it("returns 204 on successful deletion", async () => {
|
||||
mockDeleteRating.mockResolvedValue(undefined);
|
||||
|
||||
const response = await request(app, "DELETE", "/api/agents/agent-001/ratings/rating-001");
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockDeleteRating).toHaveBeenCalledWith("rating-001");
|
||||
});
|
||||
|
||||
it("returns 500 when store throws an error", async () => {
|
||||
mockDeleteRating.mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const response = await request(app, "DELETE", "/api/agents/agent-001/ratings/rating-001");
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9454,6 +9454,140 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Agent Rating Routes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/ratings
|
||||
* Fetch ratings for an agent.
|
||||
* Query params: limit (number, default 50), category (string, optional)
|
||||
* Response 200: AgentRating[]
|
||||
*/
|
||||
router.get("/agents/:id/ratings", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50;
|
||||
const category = typeof req.query.category === "string" ? req.query.category : undefined;
|
||||
|
||||
const ratings = await agentStore.getRatings(req.params.id, { limit, category });
|
||||
res.json(ratings);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err.message?.includes("not found")) {
|
||||
throw notFound(err.message);
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/:id/ratings
|
||||
* Add a rating for an agent.
|
||||
* Body: { score: number, category?: string, comment?: string, runId?: string, taskId?: string, raterType?: string }
|
||||
* Response 201: AgentRating — The created rating
|
||||
* Response 400: { error: "score is required" } — When score is missing
|
||||
* { error: "score must be a number between 1 and 5" } — When score is invalid
|
||||
*/
|
||||
router.post("/agents/:id/ratings", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const { score, category, comment, runId, taskId, raterType } = req.body || {};
|
||||
|
||||
// Validate score
|
||||
if (score === undefined || score === null) {
|
||||
throw badRequest("score is required");
|
||||
}
|
||||
if (typeof score !== "number" || !Number.isFinite(score) || score < 1 || score > 5) {
|
||||
throw badRequest("score must be a number between 1 and 5");
|
||||
}
|
||||
|
||||
// Default raterType to "user" if not provided
|
||||
const resolvedRaterType = raterType || "user";
|
||||
|
||||
const rating = await agentStore.addRating(req.params.id, {
|
||||
score,
|
||||
category,
|
||||
comment,
|
||||
runId,
|
||||
taskId,
|
||||
raterType: resolvedRaterType,
|
||||
});
|
||||
|
||||
res.status(201).json(rating);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err.message?.includes("not found")) {
|
||||
throw notFound(err.message);
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/ratings/summary
|
||||
* Fetch rating summary for an agent.
|
||||
* Response 200: AgentRatingSummary
|
||||
*/
|
||||
router.get("/agents/:id/ratings/summary", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const summary = await agentStore.getRatingSummary(req.params.id);
|
||||
res.json(summary);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err.message?.includes("not found")) {
|
||||
throw notFound(err.message);
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/agents/:id/ratings/:ratingId
|
||||
* Delete a specific rating.
|
||||
* Response 204: No Content
|
||||
*/
|
||||
router.delete("/agents/:id/ratings/:ratingId", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
await agentStore.deleteRating(req.params.ratingId);
|
||||
res.status(204).send();
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err.message?.includes("not found")) {
|
||||
throw notFound(err.message);
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Agent Generation Routes ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user