feat(FN-1045): add clickable agent run history with inline log viewer
- Add backend API endpoint for time-range filtered agent log retrieval (GET /api/agents/:id/logs) - Add fetchAgentRunLogs API function in dashboard client - Make RunsTab run cards clickable with inline log viewer accordion - Make AgentRunHistory run rows clickable to expand and view logs - Enhance AgentDetailView with collapsible log sections and time-range filtering - Add comprehensive tests for store, routes, and component click behavior
This commit is contained in:
@@ -70,6 +70,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
|
||||
addSteeringComment: vi.fn(),
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
@@ -7802,3 +7803,128 @@ describe("POST /api/agents/:id/runs", () => {
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/agents/:id/runs/:runId/logs", () => {
|
||||
let tempDir: string;
|
||||
let fusionDir: string;
|
||||
let agentId: string;
|
||||
let taskId: string;
|
||||
let runId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-run-logs-"));
|
||||
fusionDir = join(tempDir, ".fusion");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
|
||||
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;
|
||||
|
||||
// Start a run, complete it, and record its ID
|
||||
const run = await agentStore.startHeartbeatRun(agentId);
|
||||
runId = run.id;
|
||||
|
||||
// End the run with a context snapshot containing a taskId
|
||||
run.endedAt = new Date().toISOString();
|
||||
run.status = "completed";
|
||||
run.contextSnapshot = { taskId: "FN-001", projectId: "test-project" };
|
||||
await agentStore.saveRun(run);
|
||||
|
||||
taskId = "FN-001";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([
|
||||
{
|
||||
timestamp: "2024-01-01T00:01:00.000Z",
|
||||
taskId: "FN-001",
|
||||
text: "Starting task execution",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
timestamp: "2024-01-01T00:02:00.000Z",
|
||||
taskId: "FN-001",
|
||||
text: "Read file: src/index.ts",
|
||||
type: "tool",
|
||||
},
|
||||
]),
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns logs for a valid run with contextSnapshot.taskId", async () => {
|
||||
const res = await REQUEST(buildApp(), "GET", `/api/agents/${agentId}/runs/${runId}/logs`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(2);
|
||||
expect(res.body[0]).toMatchObject({ taskId: "FN-001", type: "text" });
|
||||
expect(res.body[1]).toMatchObject({ taskId: "FN-001", type: "tool" });
|
||||
});
|
||||
|
||||
it("returns empty array for run without contextSnapshot.taskId", async () => {
|
||||
// Create a run without contextSnapshot
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const run = await agentStore.startHeartbeatRun(agentId);
|
||||
run.endedAt = new Date().toISOString();
|
||||
run.status = "completed";
|
||||
// No contextSnapshot
|
||||
await agentStore.saveRun(run);
|
||||
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await REQUEST(app, "GET", `/api/agents/${agentId}/runs/${run.id}/logs`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent run", async () => {
|
||||
const res = await REQUEST(buildApp(), "GET", `/api/agents/${agentId}/runs/run-nonexistent/logs`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("Run not found");
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent agent", async () => {
|
||||
const res = await REQUEST(buildApp(), "GET", `/api/agents/agent-nonexistent/runs/${runId}/logs`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("handles store errors gracefully", async () => {
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue("/nonexistent/path"),
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await REQUEST(app, "GET", `/api/agents/${agentId}/runs/${runId}/logs`);
|
||||
|
||||
// Either 404 or 500 depending on where the error occurs
|
||||
expect([404, 500]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7230,6 +7230,50 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/runs/:runId/logs
|
||||
* Get agent log entries for a specific run's time window.
|
||||
* Uses the run's contextSnapshot.taskId to locate the task's agent log,
|
||||
* then filters entries by the run's startedAt/endedAt timestamps.
|
||||
* Returns an empty array if the run has no associated task.
|
||||
*/
|
||||
router.get("/agents/:id/runs/:runId/logs", 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 run = await agentStore.getRunDetail(req.params.id, req.params.runId);
|
||||
if (!run) {
|
||||
res.status(404).json({ error: "Run not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Only use the run's context snapshot for task ID — do not fall back
|
||||
// to agent.taskId since that represents the agent's *current* task,
|
||||
// not the task active during a historical run.
|
||||
const taskId = run.contextSnapshot?.taskId as string | undefined;
|
||||
if (!taskId) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const logs = await scopedStore.getAgentLogsByTimeRange(
|
||||
taskId,
|
||||
run.startedAt,
|
||||
run.endedAt,
|
||||
);
|
||||
res.json(logs);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/children
|
||||
* Fetch agents that report to a given agent (parent-child hierarchy).
|
||||
|
||||
Reference in New Issue
Block a user