feat(FN-1183): add agent reflections tab to dashboard

- Add API routes for agent reflections (GET, POST, DELETE)
- Create AgentReflectionsTab component with reflection list and form
- Integrate reflections tab into AgentDetailView
- Add CSS styles for the reflections tab UI
- Write tests for API routes and component
This commit is contained in:
gsxdsm
2026-04-09 11:15:57 -07:00
parent f0619ed678
commit ddc574bdd7
7 changed files with 1316 additions and 3 deletions

View File

@@ -9835,3 +9835,138 @@ describe("GET /api/agents/:id/runs/:runId/logs", () => {
expect([404, 500]).toContain(res.status);
});
});
describe("Agent Reflection routes", () => {
let tempDir: string;
let fusionDir: string;
let agentId: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-reflection-"));
fusionDir = join(tempDir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
// Create a real agent in the temp directory
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const agent = await agentStore.createAgent({
name: "Test Agent",
role: "executor",
});
agentId = agent.id;
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function buildApp() {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
describe("GET /api/agents/:id/reflections", () => {
it("returns 200 for valid agent (uses real stores)", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/reflections`);
// The route uses real stores, so it should return an empty array (no reflections created yet)
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it("returns 404 for non-existent agent", async () => {
const res = await GET(buildApp(), "/api/agents/nonexistent-agent/reflections");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("accepts limit query param", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/reflections?limit=10`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
});
describe("GET /api/agents/:id/reflections/latest", () => {
it("returns 404 when no reflections exist", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/reflections/latest`);
expect(res.status).toBe(404);
expect(res.body.error).toContain("No reflections found");
});
it("returns 404 for non-existent agent", async () => {
const res = await GET(buildApp(), "/api/agents/nonexistent-agent/reflections/latest");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
});
describe("POST /api/agents/:id/reflections", () => {
it("returns 500 or 503 when reflection service is not available", async () => {
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/reflections`);
// The reflection service requires the engine to be initialized
// Without proper setup, it may return 500 or 503
expect([500, 503]).toContain(res.status);
});
it("returns 404 or 500 for non-existent agent", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/agents/nonexistent-agent/reflections");
// The route either returns 404 (agent not found) or 500 (reflection service error)
expect([404, 500]).toContain(res.status);
});
});
describe("GET /api/agents/:id/performance", () => {
it("returns 200 with performance summary for valid agent", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/performance`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("agentId");
expect(res.body).toHaveProperty("totalTasksCompleted");
expect(res.body).toHaveProperty("successRate");
});
it("accepts windowMs query param", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/performance?windowMs=604800000`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("agentId");
});
it("returns 404 for non-existent agent", async () => {
const res = await GET(buildApp(), "/api/agents/nonexistent-agent/performance");
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
});
describe("GET /api/agents/:id/reflection-context", () => {
it("returns 500 or 503 when reflection service is not available", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/reflection-context`);
// The reflection service requires the engine to be initialized
// Without proper setup, it may return 500 or 503
expect([500, 503]).toContain(res.status);
});
it("returns 404 or 500 for non-existent agent", async () => {
const res = await GET(buildApp(), "/api/agents/nonexistent-agent/reflection-context");
// The route either returns 404 (agent not found) or 500 (reflection service error)
expect([404, 500]).toContain(res.status);
});
});
});

View File

@@ -9226,6 +9226,234 @@ Output ONLY the prompt text (no markdown, no explanations).`;
*/
router.get("/agents/:id/employees", getAgentEmployeesHandler);
// ── Agent Reflection Routes ──────────────────────────────────────────────
/**
* GET /api/agents/:id/reflections/latest
* Fetch the most recent reflection for an agent.
* Must be registered before /agents/:id/reflections to avoid matching "latest" as a limit.
* Response 200: AgentReflection | null — The most recent reflection or null
* Response 404: { error: "Agent not found" } — When agent doesn't exist
* { error: "No reflections found" } — When agent has no reflections
*/
router.get("/agents/:id/reflections/latest", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore, ReflectionStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
const reflectionStore = new ReflectionStore({ 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");
}
// Validate the agent exists
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound("Agent not found");
}
const reflection = await reflectionStore.getLatestReflection(agentId);
if (!reflection) {
throw notFound("No reflections found");
}
res.json(reflection);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/agents/:id/reflections
* List reflection history for an agent.
* Query params: limit (optional, default 50)
* Response 200: AgentReflection[] — Array of reflections
* Response 404: { error: "Agent not found" } — When agent doesn't exist
*/
router.get("/agents/:id/reflections", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore, ReflectionStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
const reflectionStore = new ReflectionStore({ 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");
}
// Validate the agent exists
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound("Agent not found");
}
// Parse limit from query params (default 50)
const limitParam = req.query.limit;
const limit = limitParam ? parseInt(String(limitParam), 10) : 50;
const reflections = await reflectionStore.getReflections(agentId, limit);
res.json(reflections);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/agents/:id/reflections
* Trigger a manual reflection for an agent.
* Response 201: AgentReflection — The created reflection
* Response 404: { error: "Agent not found" } — When agent doesn't exist
* Response 500: { error: message } — When reflection generation fails
*/
router.post("/agents/:id/reflections", async (req, res) => {
try {
const taskStore = await getScopedStore(req);
const { AgentStore, ReflectionStore } = await import("@fusion/core");
const { AgentReflectionService } = await import("@fusion/engine");
const agentStore = new AgentStore({ rootDir: taskStore.getRootDir() });
const reflectionStore = new ReflectionStore({ rootDir: taskStore.getRootDir() });
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");
}
// Validate the agent exists
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound("Agent not found");
}
// Create the reflection service and generate a reflection
const reflectionService = new AgentReflectionService({
agentStore,
taskStore,
reflectionStore,
rootDir: taskStore.getRootDir(),
});
const reflection = await reflectionService.generateReflection(agentId, "manual");
res.status(201).json(reflection);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/agents/:id/performance
* Get aggregated performance summary for an agent.
* Query params: windowMs (optional, default 7 days)
* Response 200: AgentPerformanceSummary
* Response 404: { error: "Agent not found" } — When agent doesn't exist
*/
router.get("/agents/:id/performance", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore, ReflectionStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
const reflectionStore = new ReflectionStore({ 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");
}
// Validate the agent exists
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound("Agent not found");
}
// Parse windowMs from query params (default 7 days)
const windowMsParam = req.query.windowMs;
const windowMs = windowMsParam ? parseInt(String(windowMsParam), 10) : undefined;
const summary = await reflectionStore.getPerformanceSummary(agentId, { windowMs });
res.json(summary);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/agents/:id/reflection-context
* Get raw context for debugging agent reflections.
* Response 200: { context: object } — The built reflection context
* Response 404: { error: "Agent not found" } — When agent doesn't exist
* Response 503: { error: "Reflection service not available" } — When engine not initialized
*/
router.get("/agents/:id/reflection-context", async (req, res) => {
try {
const taskStore = await getScopedStore(req);
const { AgentStore, ReflectionStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: taskStore.getRootDir() });
const reflectionStore = new ReflectionStore({ rootDir: taskStore.getRootDir() });
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");
}
// Validate the agent exists
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound("Agent not found");
}
// Check if AgentReflectionService is available
let AgentReflectionService: any;
try {
const engine = await import("@fusion/engine");
AgentReflectionService = engine.AgentReflectionService;
} catch {
res.status(503).json({ error: "Reflection service not available" });
return;
}
if (!AgentReflectionService) {
res.status(503).json({ error: "Reflection service not available" });
return;
}
// Create the service and build the context
const reflectionService = new AgentReflectionService({
agentStore,
taskStore,
reflectionStore,
rootDir: taskStore.getRootDir(),
});
const context = await reflectionService.buildReflectionContext(agentId);
res.json({ context });
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
// ── Agent Generation Routes ──────────────────────────────────────────────
/**