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:
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 === */
|
||||
|
||||
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