feat(FN-2557): expand task stats tab with execution insights
- Enrich TaskTokenStatsPanel with timing extraction, workflow runtime summaries, and longest-event highlights from task logs and workflow results - Add an execution details section for mode, runtime status, step progress, retries, recovery state, and runtime links - Wire TaskDetailModal to pass the working task into the stats panel so the Stats tab renders execution context with token usage - Extend TaskDetailModal and TaskTokenStatsPanel tests (plus panel CSS) to cover the new stats sections and loading/empty/token states
This commit is contained in:
@@ -1537,6 +1537,7 @@ export function TaskDetailModal({
|
||||
<TaskTokenStatsPanel
|
||||
tokenUsage={workingTask.tokenUsage}
|
||||
loading={detailLoading}
|
||||
task={workingTask}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -8,6 +8,25 @@
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-token-stats-panel h4,
|
||||
.task-token-stats-panel h5 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.task-token-stats-panel h5 {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: calc(var(--space-xs) / 8);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(calc(var(--space-2xl) * 3), 1fr));
|
||||
@@ -67,6 +86,35 @@
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.task-token-stats-panel__details {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(calc(var(--space-2xl) * 5), 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__detail-row {
|
||||
margin: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface) 85%, transparent);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__detail-row dt {
|
||||
margin: 0;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: calc(var(--space-xs) / 8);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__detail-row dd {
|
||||
margin: var(--space-xs) 0 0;
|
||||
color: var(--text);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.task-token-stats-panel__empty,
|
||||
.task-token-stats-panel__loading {
|
||||
border: 1px dashed var(--border);
|
||||
@@ -81,7 +129,8 @@
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-token-stats-panel__timestamps {
|
||||
.task-token-stats-panel__timestamps,
|
||||
.task-token-stats-panel__details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
import type { TaskTokenUsage } from "@fusion/core";
|
||||
import type { Task, TaskLogEntry, TaskTokenUsage, WorkflowStepResult } from "@fusion/core";
|
||||
import "./TaskTokenStatsPanel.css";
|
||||
|
||||
interface TaskTokenStatsPanelProps {
|
||||
tokenUsage?: TaskTokenUsage;
|
||||
loading: boolean;
|
||||
task?: Pick<
|
||||
Task,
|
||||
| "log"
|
||||
| "workflowStepResults"
|
||||
| "executionMode"
|
||||
| "status"
|
||||
| "paused"
|
||||
| "currentStep"
|
||||
| "steps"
|
||||
| "mergeRetries"
|
||||
| "workflowStepRetries"
|
||||
| "stuckKillCount"
|
||||
| "postReviewFixCount"
|
||||
| "recoveryRetryCount"
|
||||
| "taskDoneRetryCount"
|
||||
| "nextRecoveryAt"
|
||||
| "checkedOutBy"
|
||||
| "assignedAgentId"
|
||||
| "blockedBy"
|
||||
| "sessionFile"
|
||||
>;
|
||||
}
|
||||
|
||||
interface TimingEvent {
|
||||
timestamp: string;
|
||||
durationMs?: number;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
interface WorkflowTimingSummary {
|
||||
timedStepCount: number;
|
||||
totalDurationMs: number;
|
||||
longestStep?: { name: string; durationMs: number };
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number): string {
|
||||
@@ -18,64 +51,237 @@ function formatTimestamp(value: string): string {
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
export function TaskTokenStatsPanel({ tokenUsage, loading }: TaskTokenStatsPanelProps) {
|
||||
if (!tokenUsage && loading) {
|
||||
return (
|
||||
<section className="task-token-stats-panel" aria-label="Task token usage">
|
||||
<h4>Token Usage</h4>
|
||||
<div className="task-token-stats-panel__loading" role="status" aria-live="polite">
|
||||
Loading token statistics…
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
function formatDuration(valueMs: number): string {
|
||||
if (valueMs < 1000) {
|
||||
return `${Math.round(valueMs)} ms`;
|
||||
}
|
||||
const valueSeconds = valueMs / 1000;
|
||||
if (valueSeconds < 60) {
|
||||
return `${valueSeconds.toFixed(1)} s`;
|
||||
}
|
||||
const minutes = Math.floor(valueSeconds / 60);
|
||||
const seconds = Math.round(valueSeconds % 60);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
if (!tokenUsage) {
|
||||
return (
|
||||
<section className="task-token-stats-panel" aria-label="Task token usage">
|
||||
<h4>Token Usage</h4>
|
||||
<div className="task-token-stats-panel__empty" role="status">
|
||||
No token usage recorded for this task yet.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function summarizeTimingLabel(entry: TaskLogEntry): string {
|
||||
const timingText = entry.action || entry.outcome || "";
|
||||
const stripped = timingText
|
||||
.replace(/^\[timing\]\s*/i, "")
|
||||
.replace(/^\[[^\]]+\]\s*/i, "")
|
||||
.replace(/\s+in\s+\d+(?:\.\d+)?ms\b/i, "")
|
||||
.replace(/\s+after\s+\d+(?:\.\d+)?ms\b/i, "")
|
||||
.trim();
|
||||
return stripped || "Timing event";
|
||||
}
|
||||
|
||||
function extractTimingEvents(logEntries: TaskLogEntry[]): TimingEvent[] {
|
||||
return logEntries
|
||||
.filter((entry) => {
|
||||
const actionText = typeof entry.action === "string" ? entry.action : "";
|
||||
const outcomeText = typeof entry.outcome === "string" ? entry.outcome : "";
|
||||
return actionText.includes("[timing]") || outcomeText.includes("[timing]");
|
||||
})
|
||||
.map((entry) => {
|
||||
const haystack = `${entry.action ?? ""}\n${entry.outcome ?? ""}`;
|
||||
const durationMatch = haystack.match(/(\d+(?:\.\d+)?)ms\b/i);
|
||||
const durationMs = durationMatch ? Number(durationMatch[1]) : undefined;
|
||||
return {
|
||||
timestamp: entry.timestamp,
|
||||
durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
|
||||
summary: summarizeTimingLabel(entry),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeWorkflowTiming(results: WorkflowStepResult[]): WorkflowTimingSummary {
|
||||
const timedResults = results
|
||||
.map((step) => {
|
||||
if (!step.startedAt || !step.completedAt) {
|
||||
return null;
|
||||
}
|
||||
const startedMs = new Date(step.startedAt).getTime();
|
||||
const completedMs = new Date(step.completedAt).getTime();
|
||||
if (Number.isNaN(startedMs) || Number.isNaN(completedMs) || completedMs < startedMs) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: step.workflowStepName || step.workflowStepId,
|
||||
durationMs: completedMs - startedMs,
|
||||
};
|
||||
})
|
||||
.filter((value): value is { name: string; durationMs: number } => value !== null);
|
||||
|
||||
const totalDurationMs = timedResults.reduce((sum, step) => sum + step.durationMs, 0);
|
||||
const longestStep = timedResults.reduce<{ name: string; durationMs: number } | undefined>((longest, step) => {
|
||||
if (!longest || step.durationMs > longest.durationMs) {
|
||||
return step;
|
||||
}
|
||||
return longest;
|
||||
}, undefined);
|
||||
|
||||
return {
|
||||
timedStepCount: timedResults.length,
|
||||
totalDurationMs,
|
||||
longestStep,
|
||||
};
|
||||
}
|
||||
|
||||
export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStatsPanelProps) {
|
||||
const timingEvents = extractTimingEvents(task?.log ?? []);
|
||||
const timedTimingEvents = timingEvents.filter((event) => typeof event.durationMs === "number");
|
||||
const totalTimingDurationMs = timedTimingEvents.reduce((sum, event) => sum + (event.durationMs ?? 0), 0);
|
||||
const longestTimingEvent = timedTimingEvents.reduce<TimingEvent | undefined>((longest, event) => {
|
||||
if (!longest || (event.durationMs ?? 0) > (longest.durationMs ?? 0)) {
|
||||
return event;
|
||||
}
|
||||
return longest;
|
||||
}, undefined);
|
||||
|
||||
const workflowTiming = summarizeWorkflowTiming(task?.workflowStepResults ?? []);
|
||||
const taskStepCount = task?.steps?.length ?? 0;
|
||||
|
||||
return (
|
||||
<section className="task-token-stats-panel" aria-label="Task token usage">
|
||||
<h4>Token Usage</h4>
|
||||
<div className="task-token-stats-panel__grid" role="list" aria-label="Task token totals">
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Input</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.inputTokens)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Output</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.outputTokens)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Cached</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.cachedTokens)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Total</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.totalTokens)}</span>
|
||||
<section className="task-token-stats-panel" aria-label="Task execution statistics">
|
||||
<h4>Execution & Token Stats</h4>
|
||||
|
||||
<div className="task-token-stats-panel__section">
|
||||
<h5>Execution Timing</h5>
|
||||
<div className="task-token-stats-panel__grid" role="list" aria-label="Execution timing metrics">
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Timing events</span>
|
||||
<span className="task-token-stats-panel__value">{timingEvents.length.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Timed duration</span>
|
||||
<span className="task-token-stats-panel__value">{formatDuration(totalTimingDurationMs)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Workflow timed steps</span>
|
||||
<span className="task-token-stats-panel__value">{workflowTiming.timedStepCount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Workflow runtime</span>
|
||||
<span className="task-token-stats-panel__value">{formatDuration(workflowTiming.totalDurationMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="task-token-stats-panel__timestamps">
|
||||
<div className="task-token-stats-panel__timestamp-row">
|
||||
<dt>Longest timing event</dt>
|
||||
<dd>
|
||||
{longestTimingEvent?.durationMs
|
||||
? `${longestTimingEvent.summary} (${formatDuration(longestTimingEvent.durationMs)})`
|
||||
: "No timed events recorded yet."}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__timestamp-row">
|
||||
<dt>Longest workflow step</dt>
|
||||
<dd>
|
||||
{workflowTiming.longestStep
|
||||
? `${workflowTiming.longestStep.name} (${formatDuration(workflowTiming.longestStep.durationMs)})`
|
||||
: "No completed workflow step timings yet."}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="task-token-stats-panel__section">
|
||||
<h5>Execution Details</h5>
|
||||
<dl className="task-token-stats-panel__details">
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>Execution mode</dt>
|
||||
<dd>{task?.executionMode === "fast" ? "Fast" : "Standard"}</dd>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>Runtime status</dt>
|
||||
<dd>{task?.status ?? "Not set"}</dd>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>Paused</dt>
|
||||
<dd>{task?.paused ? "Yes" : "No"}</dd>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>Step progress</dt>
|
||||
<dd>{taskStepCount > 0 ? `${Math.min((task?.currentStep ?? 0) + 1, taskStepCount)} / ${taskStepCount}` : "No steps"}</dd>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>Retries (recovery / workflow / merge / task_done)</dt>
|
||||
<dd>{`${task?.recoveryRetryCount ?? 0} / ${task?.workflowStepRetries ?? 0} / ${task?.mergeRetries ?? 0} / ${task?.taskDoneRetryCount ?? 0}`}</dd>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>Recovery state</dt>
|
||||
<dd>
|
||||
{task?.nextRecoveryAt
|
||||
? `Next recovery at ${formatTimestamp(task.nextRecoveryAt)}`
|
||||
: "No scheduled recovery"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>Self-heal counters</dt>
|
||||
<dd>{`stuck kills: ${task?.stuckKillCount ?? 0}, post-review fixes: ${task?.postReviewFixCount ?? 0}`}</dd>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>Runtime links</dt>
|
||||
<dd>
|
||||
{[
|
||||
task?.assignedAgentId ? `agent ${task.assignedAgentId}` : null,
|
||||
task?.checkedOutBy ? `checkout ${task.checkedOutBy}` : null,
|
||||
task?.blockedBy ? `blocked by ${task.blockedBy}` : null,
|
||||
task?.sessionFile ? "has session" : null,
|
||||
].filter(Boolean).join(", ") || "No runtime links"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="task-token-stats-panel__section">
|
||||
<h5>Token Usage</h5>
|
||||
{!tokenUsage && loading ? (
|
||||
<div className="task-token-stats-panel__loading" role="status" aria-live="polite">
|
||||
Loading token statistics…
|
||||
</div>
|
||||
) : !tokenUsage ? (
|
||||
<div className="task-token-stats-panel__empty" role="status">
|
||||
No token usage recorded for this task yet.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="task-token-stats-panel__grid" role="list" aria-label="Task token totals">
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Input</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.inputTokens)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Output</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.outputTokens)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Cached</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.cachedTokens)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Total</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.totalTokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="task-token-stats-panel__timestamps">
|
||||
<div className="task-token-stats-panel__timestamp-row">
|
||||
<dt>First used</dt>
|
||||
<dd>
|
||||
<time dateTime={tokenUsage.firstUsedAt}>{formatTimestamp(tokenUsage.firstUsedAt)}</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__timestamp-row">
|
||||
<dt>Last used</dt>
|
||||
<dd>
|
||||
<time dateTime={tokenUsage.lastUsedAt}>{formatTimestamp(tokenUsage.lastUsedAt)}</time>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<dl className="task-token-stats-panel__timestamps">
|
||||
<div className="task-token-stats-panel__timestamp-row">
|
||||
<dt>First used</dt>
|
||||
<dd>
|
||||
<time dateTime={tokenUsage.firstUsedAt}>{formatTimestamp(tokenUsage.firstUsedAt)}</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__timestamp-row">
|
||||
<dt>Last used</dt>
|
||||
<dd>
|
||||
<time dateTime={tokenUsage.lastUsedAt}>{formatTimestamp(tokenUsage.lastUsedAt)}</time>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5710,9 +5710,12 @@ describe("TaskDetailModal", () => {
|
||||
description: "Loading spec test",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
steps: [{ name: "Plan", status: "in-progress" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
log: [{ timestamp: "2026-04-24T09:00:00.000Z", action: "[timing] setup in 120ms" }],
|
||||
executionMode: "fast",
|
||||
status: "executing",
|
||||
assignedAgentId: "agent-loading",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
} as Task;
|
||||
@@ -5733,7 +5736,11 @@ describe("TaskDetailModal", () => {
|
||||
// Token stats now live in their own Stats tab — switch to it before
|
||||
// asserting on token-loading text.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stats" }));
|
||||
expect(screen.getByText("Execution Timing")).toBeInTheDocument();
|
||||
expect(screen.getByText("Execution Details")).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading token statistics…")).toBeDefined();
|
||||
expect(screen.getByText("Fast")).toBeInTheDocument();
|
||||
expect(screen.getByText("executing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows spec content after fetchTaskDetail resolves", async () => {
|
||||
@@ -5755,6 +5762,25 @@ describe("TaskDetailModal", () => {
|
||||
const fullDetail: TaskDetail = {
|
||||
...task,
|
||||
prompt: "# Async Spec\n\nThis is the loaded spec content.",
|
||||
log: [
|
||||
{ timestamp: "2026-04-24T09:00:00.000Z", action: "[timing] prepare env in 120ms" },
|
||||
{ timestamp: "2026-04-24T09:01:00.000Z", action: "[timing] run tests in 3400ms" },
|
||||
],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "WS-101",
|
||||
workflowStepName: "Workflow QA",
|
||||
status: "passed",
|
||||
startedAt: "2026-04-24T09:10:00.000Z",
|
||||
completedAt: "2026-04-24T09:10:07.000Z",
|
||||
},
|
||||
],
|
||||
executionMode: "fast",
|
||||
status: "executing",
|
||||
mergeRetries: 1,
|
||||
workflowStepRetries: 2,
|
||||
recoveryRetryCount: 3,
|
||||
taskDoneRetryCount: 4,
|
||||
tokenUsage: {
|
||||
inputTokens: 1200,
|
||||
outputTokens: 450,
|
||||
@@ -5795,6 +5821,14 @@ describe("TaskDetailModal", () => {
|
||||
// Token stats live behind the Stats tab now.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stats" }));
|
||||
expect(screen.queryByText("Loading token statistics…")).toBeNull();
|
||||
expect(screen.getByText("Execution Timing")).toBeInTheDocument();
|
||||
expect(screen.getByText("Execution Details")).toBeInTheDocument();
|
||||
expect(screen.getByText("Timing events")).toBeInTheDocument();
|
||||
expect(screen.getByText("Workflow runtime")).toBeInTheDocument();
|
||||
expect(screen.getByText("Execution mode")).toBeInTheDocument();
|
||||
expect(screen.getByText("Runtime status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fast")).toBeInTheDocument();
|
||||
expect(screen.getByText("executing")).toBeInTheDocument();
|
||||
expect(screen.getByText((1200).toLocaleString())).toBeInTheDocument();
|
||||
expect(screen.getByText((450).toLocaleString())).toBeInTheDocument();
|
||||
expect(screen.getByText((210).toLocaleString())).toBeInTheDocument();
|
||||
|
||||
@@ -1,26 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TaskTokenStatsPanel } from "../TaskTokenStatsPanel";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-400",
|
||||
description: "Stats panel task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "One", status: "done" }, { name: "Two", status: "in-progress" }],
|
||||
currentStep: 1,
|
||||
log: [
|
||||
{ timestamp: "2026-04-24T09:00:00.000Z", action: "[timing] Worktree init command completed in 120ms" },
|
||||
{ timestamp: "2026-04-24T09:01:00.000Z", action: "Started execution" },
|
||||
{ timestamp: "2026-04-24T09:02:00.000Z", action: "[timing] [verification] test command succeeded (exit 0) in 3400ms" },
|
||||
],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "QA Check",
|
||||
status: "passed",
|
||||
startedAt: "2026-04-24T09:10:00.000Z",
|
||||
completedAt: "2026-04-24T09:10:07.000Z",
|
||||
},
|
||||
],
|
||||
status: "executing",
|
||||
paused: false,
|
||||
executionMode: "fast",
|
||||
recoveryRetryCount: 1,
|
||||
workflowStepRetries: 2,
|
||||
mergeRetries: 3,
|
||||
taskDoneRetryCount: 4,
|
||||
stuckKillCount: 1,
|
||||
postReviewFixCount: 2,
|
||||
assignedAgentId: "agent-1",
|
||||
checkedOutBy: "executor-1",
|
||||
blockedBy: "FN-300",
|
||||
sessionFile: ".fusion/sessions/FN-400.json",
|
||||
createdAt: "2026-04-24T09:00:00.000Z",
|
||||
updatedAt: "2026-04-24T09:30:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TaskTokenStatsPanel", () => {
|
||||
it("renders loading state while task detail token usage is hydrating", () => {
|
||||
render(<TaskTokenStatsPanel loading tokenUsage={undefined} />);
|
||||
render(<TaskTokenStatsPanel loading tokenUsage={undefined} task={makeTask()} />);
|
||||
|
||||
expect(screen.getByText("Execution Timing")).toBeInTheDocument();
|
||||
expect(screen.getByText("Execution Details")).toBeInTheDocument();
|
||||
expect(screen.getByText("Token Usage")).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading token statistics…")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No token usage recorded for this task yet.")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders empty state when detail is loaded without usage", () => {
|
||||
render(<TaskTokenStatsPanel loading={false} tokenUsage={undefined} />);
|
||||
it("renders empty token state when detail is loaded without usage", () => {
|
||||
render(<TaskTokenStatsPanel loading={false} tokenUsage={undefined} task={makeTask()} />);
|
||||
|
||||
expect(screen.getByText("No token usage recorded for this task yet.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading token statistics…")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders all token totals and usage timestamps", () => {
|
||||
it("renders execution timing, details, and token totals", () => {
|
||||
render(
|
||||
<TaskTokenStatsPanel
|
||||
loading={false}
|
||||
task={makeTask()}
|
||||
tokenUsage={{
|
||||
inputTokens: 1200,
|
||||
outputTokens: 450,
|
||||
@@ -32,6 +78,13 @@ describe("TaskTokenStatsPanel", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Timing events")).toBeInTheDocument();
|
||||
expect(screen.getByText("Workflow runtime")).toBeInTheDocument();
|
||||
expect(screen.getByText("Execution mode")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fast")).toBeInTheDocument();
|
||||
expect(screen.getByText("Runtime status")).toBeInTheDocument();
|
||||
expect(screen.getByText("executing")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cached")).toBeInTheDocument();
|
||||
@@ -47,4 +100,20 @@ describe("TaskTokenStatsPanel", () => {
|
||||
expect(firstUsedTime).toBeInTheDocument();
|
||||
expect(lastUsedTime).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gracefully handles logs without timing patterns", () => {
|
||||
render(
|
||||
<TaskTokenStatsPanel
|
||||
loading={false}
|
||||
tokenUsage={undefined}
|
||||
task={makeTask({
|
||||
log: [{ timestamp: "2026-04-24T09:00:00.000Z", action: "Non timing entry" }],
|
||||
workflowStepResults: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No timed events recorded yet.")).toBeInTheDocument();
|
||||
expect(screen.getByText("No completed workflow step timings yet.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user