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:
gsxdsm
2026-04-09 11:33:40 -07:00
parent f0aa63bd73
commit 26ff7eb900
5 changed files with 969 additions and 2 deletions

View File

@@ -27,6 +27,9 @@ import type {
MissionEvent,
MissionHealth,
MissionEventType,
AgentRating,
AgentRatingSummary,
AgentRatingInput,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@fusion/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStep } from "@fusion/core";
@@ -3705,3 +3708,41 @@ export function fetchAgentPerformance(agentId: string, windowMs?: number, projec
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<AgentPerformanceSummary>(`/agents/${encodeURIComponent(agentId)}/performance${query}`);
}
/** Fetch ratings for an agent */
export function fetchAgentRatings(
agentId: string,
options?: { limit?: number; category?: string },
projectId?: string,
): Promise<AgentRating[]> {
const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.category) params.set("category", options.category);
if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<AgentRating[]>(`/agents/${encodeURIComponent(agentId)}/ratings${query}`);
}
/** Add a rating for an agent */
export function addAgentRating(
agentId: string,
input: AgentRatingInput,
projectId?: string,
): Promise<AgentRating> {
return api<AgentRating>(withProjectId(`/agents/${encodeURIComponent(agentId)}/ratings`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Fetch rating summary for an agent */
export function fetchAgentRatingSummary(agentId: string, projectId?: string): Promise<AgentRatingSummary> {
return api<AgentRatingSummary>(withProjectId(`/agents/${encodeURIComponent(agentId)}/ratings/summary`, projectId));
}
/** Delete a specific rating */
export function deleteAgentRating(agentId: string, ratingId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/agents/${encodeURIComponent(agentId)}/ratings/${encodeURIComponent(ratingId)}`, projectId), {
method: "DELETE",
});
}

View File

@@ -3,7 +3,7 @@ import {
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
Settings, FileText, ActivitySquare, X, Copy,
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
ChevronDown, ChevronRight, BarChart3
ChevronDown, ChevronRight, BarChart3, Star
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand } from "../api";
@@ -51,7 +51,7 @@ interface AgentDetailViewProps {
onChildClick?: (childId: string) => void;
}
type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "memory" | "reflections";
type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "memory" | "reflections" | "performance";
const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
{ id: "dashboard", label: "Dashboard", icon: ActivitySquare },
@@ -62,6 +62,7 @@ const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
{ id: "soul", label: "Soul", icon: Heart },
{ id: "memory", label: "Memory", icon: FileText },
{ id: "reflections", label: "Reflections", icon: BarChart3 },
{ id: "performance", label: "Performance", icon: Star },
{ id: "config", label: "Settings", icon: Settings },
];
@@ -451,6 +452,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
/>
)}
{activeTab === "performance" && (
<PerformanceTab
agentId={agent.id}
projectId={projectId}
addToast={addToast}
/>
)}
{activeTab === "config" && (
<ConfigTab
agent={agent}
@@ -1610,6 +1619,232 @@ function MemoryTab({
);
}
function PerformanceTab({
agentId,
projectId,
addToast,
}: {
agentId: string;
projectId?: string;
addToast: (msg: string, type?: "success" | "error") => void;
}) {
const [summary, setSummary] = useState<import("@fusion/core").AgentRatingSummary | null>(null);
const [ratings, setRatings] = useState<import("@fusion/core").AgentRating[]>([]);
const [loading, setLoading] = useState(true);
const [newScore, setNewScore] = useState(0);
const [newCategory, setNewCategory] = useState("");
const [newComment, setNewComment] = useState("");
const [submitting, setSubmitting] = useState(false);
const loadData = useCallback(async () => {
try {
const { fetchAgentRatingSummary, fetchAgentRatings } = await import("../api");
const [summaryData, ratingsData] = await Promise.all([
fetchAgentRatingSummary(agentId, projectId),
fetchAgentRatings(agentId, { limit: 50 }, projectId),
]);
setSummary(summaryData);
setRatings(ratingsData);
} catch (err: any) {
addToast(`Failed to load ratings: ${err.message}`, "error");
} finally {
setLoading(false);
}
}, [agentId, projectId, addToast]);
useEffect(() => {
void loadData();
}, [loadData]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (newScore === 0) return;
setSubmitting(true);
try {
const { addAgentRating } = await import("../api");
await addAgentRating(agentId, {
score: newScore,
category: newCategory || undefined,
comment: newComment || undefined,
raterType: "user",
}, projectId);
setNewScore(0);
setNewCategory("");
setNewComment("");
addToast("Rating added", "success");
await loadData();
} catch (err: any) {
addToast(`Failed to add rating: ${err.message}`, "error");
} finally {
setSubmitting(false);
}
};
const handleDelete = async (ratingId: string) => {
try {
const { deleteAgentRating } = await import("../api");
await deleteAgentRating(agentId, ratingId, projectId);
addToast("Rating deleted", "success");
await loadData();
} catch (err: any) {
addToast(`Failed to delete rating: ${err.message}`, "error");
}
};
const getTrendLabel = (trend: string) => {
switch (trend) {
case "improving": return "↑ Improving";
case "declining": return "↓ Declining";
case "stable": return "→ Stable";
default: return "Insufficient data";
}
};
const getTrendClass = (trend: string) => {
switch (trend) {
case "improving": return "trend-improving";
case "declining": return "trend-declining";
case "stable": return "trend-stable";
default: return "trend-insufficient";
}
};
const renderStars = (score: number, maxScore: number = 5) => {
return (
<span className="rating-stars">
{Array.from({ length: maxScore }, (_, i) => (
<Star
key={i}
size={14}
className={i < score ? "star-filled" : "star-empty"}
fill={i < score ? "currentColor" : "none"}
/>
))}
</span>
);
};
if (loading) {
return (
<div className="performance-tab">
<div className="loading-indicator">Loading ratings...</div>
</div>
);
}
return (
<div className="performance-tab">
{/* Summary Card */}
{summary && (
<div className="rating-summary-card">
<div className="rating-score-display">
<span className="rating-average">{summary.averageScore.toFixed(1)}</span>
{renderStars(Math.round(summary.averageScore))}
</div>
<div className="rating-stats">
<span className="rating-count">{summary.totalRatings} ratings</span>
<span className={cn("rating-trend-badge", getTrendClass(summary.trend))}>
{getTrendLabel(summary.trend)}
</span>
</div>
</div>
)}
{/* Category Breakdown */}
{summary && Object.keys(summary.categoryAverages).length > 0 && (
<div className="category-breakdown">
<h4>Category Averages</h4>
{Object.entries(summary.categoryAverages).map(([category, avg]) => (
<div key={category} className="category-item">
<span className="category-name">{category}</span>
<span className="category-score">{avg.toFixed(1)}</span>
</div>
))}
</div>
)}
{/* Add Rating Form */}
<form className="add-rating-form" onSubmit={handleSubmit}>
<h4>Add Rating</h4>
<div className="star-selector">
{[1, 2, 3, 4, 5].map((score) => (
<button
key={score}
type="button"
className="star-btn"
onClick={() => setNewScore(score)}
title={`${score} star${score > 1 ? "s" : ""}`}
>
<Star
size={24}
fill={score <= newScore ? "currentColor" : "none"}
className={score <= newScore ? "star-filled" : "star-empty"}
/>
</button>
))}
</div>
<select
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
className="form-select"
>
<option value="">Select category...</option>
<option value="quality">Quality</option>
<option value="speed">Speed</option>
<option value="communication">Communication</option>
<option value="reliability">Reliability</option>
<option value="other">Other</option>
</select>
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Optional comment..."
className="form-textarea"
rows={3}
/>
<button
type="submit"
className="btn-primary"
disabled={newScore === 0 || submitting}
>
{submitting ? "Submitting..." : "Submit Rating"}
</button>
</form>
{/* Rating History */}
<div className="rating-history">
<h4>Rating History</h4>
{ratings.length === 0 ? (
<p className="no-ratings">No ratings yet</p>
) : (
ratings.map((rating) => (
<div key={rating.id} className="rating-history-item">
<div className="rating-item-header">
{renderStars(rating.score)}
{rating.category && (
<span className="rating-category-badge">{rating.category}</span>
)}
<span className="rating-time">{relativeTime(rating.createdAt)}</span>
<button
className="rating-delete-btn"
onClick={() => handleDelete(rating.id)}
title="Delete rating"
>
<Trash2 size={14} />
</button>
</div>
{rating.comment && (
<p className="rating-comment">{rating.comment}</p>
)}
</div>
))
)}
</div>
</div>
);
}
function ConfigTab({
agent,
projectId,

View File

@@ -24571,6 +24571,257 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
color: var(--color-success);
}
/* === Performance Tab === */
.performance-tab {
display: flex;
flex-direction: column;
gap: 24px;
padding: 16px;
}
.performance-tab .loading-indicator {
text-align: center;
color: var(--text-muted);
padding: 40px;
}
.rating-summary-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.rating-score-display {
display: flex;
align-items: center;
gap: 12px;
}
.rating-average {
font-size: 32px;
font-weight: 700;
line-height: 1;
}
.rating-stats {
display: flex;
align-items: center;
gap: 12px;
}
.rating-count {
font-size: 14px;
color: var(--text-muted);
}
.rating-trend-badge {
font-size: 12px;
padding: 4px 10px;
border-radius: 12px;
font-weight: 500;
}
.trend-improving {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.trend-declining {
background: color-mix(in srgb, var(--color-error) 15%, transparent);
color: var(--color-error);
}
.trend-stable {
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
}
.trend-insufficient {
background: var(--bg-tertiary);
color: var(--text-muted);
}
.category-breakdown {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
}
.category-breakdown h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
}
.category-breakdown .category-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.category-breakdown .category-item:last-child {
border-bottom: none;
}
.category-name {
text-transform: capitalize;
font-size: 14px;
}
.category-score {
font-weight: 600;
font-size: 14px;
}
.add-rating-form {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.add-rating-form h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.star-selector {
display: flex;
gap: 4px;
}
.star-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
border-radius: var(--radius-sm);
transition: background-color 0.15s;
}
.star-btn:hover {
background: var(--bg-tertiary);
}
.star-btn:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.rating-stars {
display: inline-flex;
gap: 2px;
}
.rating-stars .star-filled {
color: var(--color-warning);
}
.rating-stars .star-empty {
color: var(--text-muted);
}
.add-rating-form .form-select,
.add-rating-form .form-textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
}
.add-rating-form .form-textarea {
resize: vertical;
min-height: 60px;
}
.rating-history {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
max-height: 300px;
overflow-y: auto;
}
.rating-history h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
}
.rating-history .no-ratings {
color: var(--text-muted);
font-size: 14px;
text-align: center;
padding: 20px;
}
.rating-history-item {
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.rating-history-item:last-child {
border-bottom: none;
}
.rating-item-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.rating-category-badge {
font-size: 11px;
padding: 2px 8px;
background: var(--bg-tertiary);
border-radius: 10px;
text-transform: capitalize;
}
.rating-time {
font-size: 12px;
color: var(--text-muted);
margin-left: auto;
}
.rating-delete-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: var(--text-muted);
border-radius: var(--radius-sm);
transition: color 0.15s, background-color 0.15s;
}
.rating-delete-btn:hover {
color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
}
.rating-comment {
margin: 8px 0 0 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
/* === Agent Detail View Mobile Responsive === */
@media (max-width: 768px) {
.agent-detail-overlay {
@@ -24857,6 +25108,27 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
min-width: 36px;
min-height: 36px;
}
/* Performance Tab mobile */
.performance-tab {
gap: 16px;
}
.rating-summary-card {
padding: 16px;
}
.rating-average {
font-size: 28px;
}
.category-breakdown {
grid-template-columns: 1fr;
}
.rating-history {
max-height: 250px;
}
}
/* === Active Agents Panel === */

View 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);
});
});
});

View File

@@ -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 ──────────────────────────────────────────────
/**