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:
gsxdsm
2026-04-07 12:55:05 -07:00
parent 52e5ec8793
commit 28bc4a3bfa
8 changed files with 635 additions and 43 deletions

View File

@@ -1836,6 +1836,11 @@ export function fetchAgentRunDetail(agentId: string, runId: string, projectId?:
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`, projectId));
}
/** Fetch agent logs for a specific run's time window */
export function fetchAgentRunLogs(agentId: string, runId: string, projectId?: string): Promise<AgentLogEntry[]> {
return api<AgentLogEntry[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/logs`, projectId));
}
/** Manually start a heartbeat run for an agent */
export function startAgentRun(agentId: string, projectId?: string): Promise<AgentHeartbeatRun> {
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs`, projectId), {

View File

@@ -2,12 +2,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import {
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
Settings, FileText, ActivitySquare, X, Copy,
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch,
ChevronDown, ChevronRight
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentChildren } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
/**
* Simple className utility - joins class names conditionally
@@ -377,6 +379,8 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
runs={runs}
activeRun={activeRun}
addToast={addToast}
agentId={agent.id}
projectId={projectId}
/>
)}
@@ -673,12 +677,40 @@ function LogEntry({ entry, showTimestamp }: { entry: AgentLogEntry; showTimestam
function RunsTab({
runs,
activeRun,
addToast
addToast,
agentId,
projectId,
}: {
runs: AgentHeartbeatRun[];
activeRun?: AgentHeartbeatRun;
addToast: (msg: string, type?: "success" | "error") => void;
agentId: string;
projectId?: string;
}) {
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [runLogs, setRunLogs] = useState<AgentLogEntry[]>([]);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const handleRunClick = useCallback(async (runId: string) => {
if (selectedRunId === runId) {
setSelectedRunId(null);
setRunLogs([]);
return;
}
setSelectedRunId(runId);
setIsLoadingLogs(true);
setRunLogs([]);
try {
const logs = await fetchAgentRunLogs(agentId, runId, projectId);
setRunLogs(logs);
} catch (err: any) {
addToast(`Failed to load run logs: ${err.message}`, "error");
setRunLogs([]);
} finally {
setIsLoadingLogs(false);
}
}, [selectedRunId, agentId, projectId, addToast]);
if (runs.length === 0 && !activeRun) {
return (
<div className="runs-empty">
@@ -693,50 +725,86 @@ function RunsTab({
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
return (
<div className="runs-tab">
{activeRun && (
<div className="run-card run-card--active">
const renderRunCard = (run: AgentHeartbeatRun, index: number, isActive: boolean) => {
const statusInfo = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.completed;
const StatusIcon = statusInfo.icon;
const duration = run.endedAt
? formatDuration(new Date(run.startedAt), new Date(run.endedAt))
: "In progress";
const isSelected = selectedRunId === run.id;
return (
<div key={run.id}>
<div
className={cn("run-card", isActive && "run-card--active", isSelected && "run-card--selected")}
onClick={() => void handleRunClick(run.id)}
style={{ cursor: "pointer" }}
role="button"
tabIndex={0}
aria-expanded={isSelected}
aria-label={`${isActive ? "Active" : ""} run ${run.id.slice(0, 8)}, ${run.status}`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void handleRunClick(run.id);
}
}}
>
<div className="run-header">
<span className="run-live-indicator">
<span className="live-dot" />
Live Run
</span>
<span className="run-status active">
<Loader2 size={14} className="animate-spin" />
Active
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
{isSelected ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{isActive ? (
<span className="run-live-indicator">
<span className="live-dot" />
Live Run
</span>
) : (
<span className="run-id">#{index + 1} {run.id.slice(0, 8)}</span>
)}
</div>
<span className={cn("run-status", run.status)}>
<StatusIcon size={14} className={statusInfo.color} style={run.status === "active" ? { color: statusInfo.color } : undefined} />
{run.status}
</span>
</div>
<div className="run-details">
<span>Started {relativeTime(activeRun.startedAt)}</span>
<span>Started {relativeTime(run.startedAt)}</span>
<span></span>
<span>{duration}</span>
</div>
</div>
)}
{sortedRuns.map((run, i) => {
const statusInfo = RUN_STATUS_ICONS[run.status] || RUN_STATUS_ICONS.completed;
const StatusIcon = statusInfo.icon;
const duration = run.endedAt
? formatDuration(new Date(run.startedAt), new Date(run.endedAt))
: "In progress";
return (
<div key={run.id} className="run-card">
<div className="run-header">
<span className="run-id">#{i + 1} {run.id.slice(0, 8)}</span>
<span className={cn("run-status", run.status)}>
<StatusIcon size={14} className={statusInfo.color} />
{run.status}
</span>
</div>
<div className="run-details">
<span>Started {relativeTime(run.startedAt)}</span>
<span></span>
<span>{duration}</span>
</div>
{isSelected && (
<div
className="run-logs-container"
style={{
padding: "12px",
background: "var(--bg-secondary)",
borderBottom: "1px solid var(--border-color)",
borderTop: "1px solid var(--border-color)",
}}
>
{isLoadingLogs ? (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "12px 0" }}>
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading logs...</span>
</div>
) : runLogs.length === 0 ? (
<div className="text-muted" style={{ padding: "12px 0", fontStyle: "italic" }}>
No logs available for this run
</div>
) : (
<AgentLogViewer entries={runLogs} loading={false} />
)}
</div>
);
})}
)}
</div>
);
};
return (
<div className="runs-tab">
{activeRun && renderRunCard(activeRun, 0, true)}
{sortedRuns.map((run, i) => renderRunCard(run, activeRun ? i + 1 : i, false))}
</div>
);
}

View File

@@ -6,6 +6,8 @@ import { fetchAgentRuns } from "../api";
interface AgentRunHistoryProps {
agentId: string;
projectId?: string;
/** Optional callback when a run row is clicked */
onRunClick?: (runId: string) => void;
}
const STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
@@ -15,7 +17,7 @@ const STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }>
terminated: { icon: Square, color: "var(--text-muted, #8b949e)" },
};
export function AgentRunHistory({ agentId, projectId }: AgentRunHistoryProps) {
export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHistoryProps) {
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -46,7 +48,20 @@ export function AgentRunHistory({ agentId, projectId }: AgentRunHistoryProps) {
const usage = run.usageJson;
return (
<div key={run.id} className="agent-run-row">
<div
key={run.id}
className={onRunClick ? "agent-run-row agent-run-row--clickable" : "agent-run-row"}
onClick={onRunClick ? () => onRunClick(run.id) : undefined}
role={onRunClick ? "button" : undefined}
tabIndex={onRunClick ? 0 : undefined}
aria-label={onRunClick ? `Run ${run.id.slice(0, 8)}, ${run.status}` : undefined}
onKeyDown={onRunClick ? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRunClick(run.id);
}
} : undefined}
>
<StatusIcon size={16} style={{ color: statusInfo.color }} className={run.status === "active" ? "animate-spin" : ""} />
<div className="agent-run-info">
<span className="agent-run-id">{run.id}</span>

View File

@@ -12,13 +12,23 @@ vi.mock("../../api", () => ({
updateAgentState: vi.fn(),
deleteAgent: vi.fn(),
fetchAgentLogs: vi.fn(),
fetchAgentRunLogs: vi.fn(),
}));
import { fetchAgent, updateAgent, updateAgentState } from "../../api";
vi.mock("../AgentLogViewer", () => ({
AgentLogViewer: ({ entries }: { entries: Array<{ text: string }> }) => (
<div data-testid="agent-log-viewer">
{entries.map((e, i) => <span key={i}>{e.text}</span>)}
</div>
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
const mockUpdateAgentState = vi.mocked(updateAgentState);
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
describe("AgentDetailView", () => {
const createMockAgent = (overrides: Partial<{
@@ -882,4 +892,221 @@ describe("AgentDetailView", () => {
});
});
});
// ── Runs Tab — Click to show logs ──────────────────────────────────
describe("Runs Tab — click to show logs", () => {
const navigateToRuns = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
expect(screen.getByText("Runs")).toBeInTheDocument();
});
await user.click(screen.getByText("Runs"));
};
it("shows run cards as clickable with chevron indicators", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToRuns(user);
await waitFor(() => {
// Completed run card should be clickable (has role="button")
const buttons = screen.getAllByRole("button");
const runButtons = buttons.filter(btn => btn.getAttribute("aria-label")?.includes("run"));
expect(runButtons.length).toBeGreaterThan(0);
});
});
it("fetches and displays logs when clicking a completed run", async () => {
const mockLogs = [
{ 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" },
];
mockFetchAgentRunLogs.mockResolvedValue(mockLogs);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToRuns(user);
// Wait for run cards to render
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
// Click the completed run
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
await user.click(completedRunButton);
// Verify fetchAgentRunLogs was called
await waitFor(() => {
expect(mockFetchAgentRunLogs).toHaveBeenCalled();
});
// Verify logs appear
await waitFor(() => {
expect(screen.getByText("Starting task execution")).toBeInTheDocument();
});
});
it("shows loading state while fetching run logs", async () => {
// Create a promise that won't resolve immediately
let resolveLogs: (value: any) => void;
mockFetchAgentRunLogs.mockImplementation(() => new Promise(r => { resolveLogs = r; }));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToRuns(user);
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
await user.click(completedRunButton);
// Should show loading state
await waitFor(() => {
expect(screen.getByText("Loading logs...")).toBeInTheDocument();
});
// Resolve to clean up
resolveLogs!([]);
});
it("shows empty message when no logs available for a run", async () => {
mockFetchAgentRunLogs.mockResolvedValue([]);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToRuns(user);
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
await user.click(completedRunButton);
await waitFor(() => {
expect(screen.getByText("No logs available for this run")).toBeInTheDocument();
});
});
it("collapses log viewer when clicking the same run again", async () => {
mockFetchAgentRunLogs.mockResolvedValue([
{ timestamp: "2024-01-01T00:01:00.000Z", taskId: "FN-001", text: "Test log entry", type: "text" },
]);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToRuns(user);
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
// Click to expand
await user.click(completedRunButton);
await waitFor(() => {
expect(screen.getByText("Test log entry")).toBeInTheDocument();
});
// Click to collapse
await user.click(completedRunButton);
await waitFor(() => {
expect(screen.queryByText("Test log entry")).not.toBeInTheDocument();
});
});
it("shows toast on fetch error", async () => {
const addToast = vi.fn();
mockFetchAgentRunLogs.mockRejectedValue(new Error("Network error"));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
await navigateToRuns(user);
await waitFor(() => {
const runButtons = screen.getAllByRole("button").filter(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
);
expect(runButtons.length).toBeGreaterThan(0);
});
const completedRunButton = screen.getAllByRole("button").find(
btn => btn.getAttribute("aria-label")?.includes("run") && btn.getAttribute("aria-label")?.includes("completed")
)!;
await user.click(completedRunButton);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to load run logs"),
"error",
);
});
});
});
});

View File

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

View File

@@ -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).