FN-7612: fix off-by-one step number mismatch between task dialog and Activity tab
Introduces a single canonical step-number helper so the task-dialog step indicators agree with the Activity tab for the same underlying step. - Add `getCanonicalStepNumber()` in `packages/dashboard/app/lib/step-display.ts`, returning the raw 0-based, PROMPT.md-numbered step index (Step 0 = Preflight), clamped to a valid range. - Update `ActiveAgentsPanel` to derive its step/total-steps display from the new helper instead of adding its own +1 to `task.currentStep`. - Update `TaskTokenStatsPanel`'s step-progress row to use the same canonical helper instead of its own +1 math. - Add regression tests (`step-number-alignment.test.tsx`) asserting the task-dialog and Activity-tab step numbers stay in sync across surfaces. Files changed: .../dashboard/app/components/ActiveAgentsPanel.tsx | 8 +- .../app/components/TaskTokenStatsPanel.tsx | 7 +- .../__tests__/step-number-alignment.test.tsx | 162 +++++++++++++++++++++ packages/dashboard/app/lib/step-display.ts | 57 ++++++++ 4 files changed, 229 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-7612 Fusion-Task-Lineage: a33703f5-32cb-4d10-9153-399821517ea3 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -8,6 +8,7 @@ import "./ActiveAgentsPanel.css";
|
||||
import { useLiveTranscript } from "../hooks/useLiveTranscript";
|
||||
import { resolveHeartbeatIntervalMs } from "../utils/heartbeatIntervals";
|
||||
import { AgentTaskBadge } from "./AgentTaskBadge";
|
||||
import { getCanonicalStepNumber } from "../lib/step-display";
|
||||
|
||||
interface LiveAgentCardProps {
|
||||
agent: Agent;
|
||||
@@ -71,9 +72,10 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
|
||||
return t("agents.nextHeartbeat", "Next heartbeat in {{elapsed}}", { elapsed: formatElapsed(deltaSec) });
|
||||
})();
|
||||
|
||||
const currentStep = task?.steps?.[task.currentStep ?? 0];
|
||||
const totalSteps = task?.steps?.length ?? 0;
|
||||
const stepNumber = (task?.currentStep ?? 0) + 1;
|
||||
// FNXC:TaskStepNumbering 2026-07-05-00:00: use the canonical (0-based, PROMPT-numbered) step
|
||||
// number so this indicator agrees with the Activity tab for the same underlying step (FN-7612).
|
||||
const { stepNumber, totalSteps } = getCanonicalStepNumber(task);
|
||||
const currentStep = task?.steps?.[stepNumber];
|
||||
const executorModel = task?.modelId;
|
||||
|
||||
const handleSelect = () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Task, TaskTokenUsage, WorkflowStepResult } from "@fusion/core";
|
||||
import { extractTimingEvents, getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWallClockSinceFirstExecutionMs, getWorkflowRuntimeMs, type TimingEvent } from "../utils/taskTiming";
|
||||
import { getCanonicalStepNumber } from "../lib/step-display";
|
||||
import "./TaskTokenStatsPanel.css";
|
||||
|
||||
interface TaskTokenStatsPanelProps {
|
||||
@@ -153,7 +154,9 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat
|
||||
const showWallClockSinceFirstExecution =
|
||||
wallClockSinceFirstExecutionMs != null
|
||||
&& wallClockSinceFirstExecutionMs !== totalExecutionMs;
|
||||
const taskStepCount = task?.steps?.length ?? 0;
|
||||
// FNXC:TaskStepNumbering 2026-07-05-00:00: use the canonical (0-based, PROMPT-numbered) step
|
||||
// number so this indicator agrees with the Activity tab for the same underlying step (FN-7612).
|
||||
const { stepNumber: canonicalStepNumber, totalSteps: taskStepCount } = getCanonicalStepNumber(task);
|
||||
|
||||
return (
|
||||
<section className="task-token-stats-panel" aria-label={t("taskDetail.executionStatsAria", "Task execution statistics")}>
|
||||
@@ -231,7 +234,7 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat
|
||||
</div>
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>{t("taskDetail.stepProgress", "Step progress")}</dt>
|
||||
<dd>{taskStepCount > 0 ? `${Math.min((task?.currentStep ?? 0) + 1, taskStepCount)} / ${taskStepCount}` : t("taskDetail.noSteps", "No steps")}</dd>
|
||||
<dd>{taskStepCount > 0 ? `${canonicalStepNumber} / ${taskStepCount}` : t("taskDetail.noSteps", "No steps")}</dd>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__detail-row">
|
||||
<dt>{t("taskDetail.retriesLabel", "Retries (recovery / workflow / merge / task_done)")}</dt>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
FNXC:TaskStepNumbering 2026-07-05-00:00:
|
||||
Regression test for FN-7612 (Runfusion/Fusion#1921): the task-dialog step
|
||||
indicators (ActiveAgentsPanel "Step N/Total", TaskTokenStatsPanel "N / Total")
|
||||
must show the SAME step number as the Activity surface for the same
|
||||
underlying step. The Activity surface's convention is the engine's raw
|
||||
0-based `stepIndex` (Step 0 = Preflight), as literally embedded in task log
|
||||
lines such as "code review requested for Step N" (see executor.ts's
|
||||
`detectPendingReviewBlock`) and in `createWorkflowStepActivityRun`'s
|
||||
`stepIndex` context field (step-session-executor.ts). This test builds ONE
|
||||
shared TaskDetail fixture per data state, derives the "Activity" step number
|
||||
independently from a log-line convention (not from the shared display
|
||||
helper, so the assertion isn't tautological), then renders both task-dialog
|
||||
surfaces from that SAME fixture and asserts their displayed numbers match it.
|
||||
|
||||
Before the fix, ActiveAgentsPanel/TaskTokenStatsPanel added `+ 1` to
|
||||
`task.currentStep`, so this test would FAIL (their number was one higher
|
||||
than the Activity number) for every non-empty-steps case below. After the
|
||||
fix (both surfaces derive their number from `getCanonicalStepNumber`, which
|
||||
returns the raw, clamped `task.currentStep`), the test PASSES.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { ActiveAgentsPanel } from "../ActiveAgentsPanel";
|
||||
import { TaskTokenStatsPanel } from "../TaskTokenStatsPanel";
|
||||
import { getCanonicalStepNumber } from "../../lib/step-display";
|
||||
import type { Agent } from "../../api";
|
||||
|
||||
const fetchTaskDetailMock = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTaskDetail: (...args: unknown[]) => fetchTaskDetailMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useLiveTranscript", () => ({
|
||||
useLiveTranscript: () => ({ entries: [], isConnected: true }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Derives the Activity-tab's step number the way an operator would read it —
|
||||
* from the raw 0-based `stepIndex` embedded in a task log line, mirroring
|
||||
* `executor.ts`'s "code review requested for Step N" / verdict-prefix
|
||||
* convention. Intentionally independent of `getCanonicalStepNumber` so the
|
||||
* test does not just assert the helper agrees with itself.
|
||||
*/
|
||||
function activityStepNumberFromLog(task: Pick<Task, "log">): number {
|
||||
for (const entry of task.log ?? []) {
|
||||
const match = entry.action?.match(/Step (\d+)/);
|
||||
if (match) return Number(match[1]);
|
||||
}
|
||||
throw new Error("fixture is missing an Activity-convention log line");
|
||||
}
|
||||
|
||||
function makeStep(name: string, status: Task["steps"][number]["status"] = "pending") {
|
||||
return { name, status };
|
||||
}
|
||||
|
||||
function makeTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
return {
|
||||
id: "FN-7612-T",
|
||||
prompt: "",
|
||||
description: "Step numbering fixture",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [
|
||||
makeStep("Preflight", "done"),
|
||||
makeStep("Implement", "in-progress"),
|
||||
makeStep("Test", "pending"),
|
||||
],
|
||||
currentStep: 1,
|
||||
log: [{ timestamp: "2026-07-05T00:00:00.000Z", action: "code review requested for Step 1" }],
|
||||
status: "executing",
|
||||
paused: false,
|
||||
executionMode: "standard",
|
||||
createdAt: "2026-07-05T00:00:00.000Z",
|
||||
updatedAt: "2026-07-05T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as TaskDetail;
|
||||
}
|
||||
|
||||
describe("step number alignment between task-dialog and Activity surfaces (FN-7612)", () => {
|
||||
const cases: Array<{ name: string; task: TaskDetail }> = [
|
||||
{
|
||||
name: "current step is Preflight (index 0)",
|
||||
task: makeTaskDetail({
|
||||
steps: [makeStep("Preflight", "in-progress"), makeStep("Implement", "pending"), makeStep("Test", "pending")],
|
||||
currentStep: 0,
|
||||
log: [{ timestamp: "2026-07-05T00:00:00.000Z", action: "code review requested for Step 0" }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "mid-task in-progress step",
|
||||
task: makeTaskDetail({
|
||||
steps: [makeStep("Preflight", "done"), makeStep("Implement", "in-progress"), makeStep("Test", "pending"), makeStep("Docs", "pending")],
|
||||
currentStep: 1,
|
||||
log: [{ timestamp: "2026-07-05T00:00:00.000Z", action: "code review requested for Step 1" }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "current step is the last step",
|
||||
task: makeTaskDetail({
|
||||
steps: [makeStep("Preflight", "done"), makeStep("Implement", "done"), makeStep("Docs", "in-progress")],
|
||||
currentStep: 2,
|
||||
log: [{ timestamp: "2026-07-05T00:00:00.000Z", action: "code review Step 2: APPROVE" }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "currentStep overflow is clamped (stale step index beyond steps.length)",
|
||||
task: makeTaskDetail({
|
||||
steps: [makeStep("Preflight", "done"), makeStep("Implement", "done")],
|
||||
currentStep: 5,
|
||||
// The Activity log line reflects the last real step that ran (index 1);
|
||||
// both surfaces must clamp to that same bound, never rendering "Step 6/2".
|
||||
log: [{ timestamp: "2026-07-05T00:00:00.000Z", action: "code review Step 1: APPROVE" }],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, task } of cases) {
|
||||
it(`renders the same step number in ActiveAgentsPanel and TaskTokenStatsPanel as Activity (${name})`, async () => {
|
||||
const expectedStepNumber = activityStepNumberFromLog(task);
|
||||
expect(getCanonicalStepNumber(task).stepNumber).toBe(expectedStepNumber);
|
||||
|
||||
// --- TaskTokenStatsPanel ("N / Total") ---
|
||||
const { unmount: unmountStats } = render(
|
||||
<TaskTokenStatsPanel loading={false} tokenUsage={undefined} task={task} />,
|
||||
);
|
||||
const totalSteps = task.steps.length;
|
||||
expect(screen.getByText(`${expectedStepNumber} / ${totalSteps}`)).toBeInTheDocument();
|
||||
unmountStats();
|
||||
|
||||
// --- ActiveAgentsPanel ("Step N/Total: Name") ---
|
||||
fetchTaskDetailMock.mockResolvedValueOnce(task);
|
||||
const agent: Agent = {
|
||||
id: "agent-fn7612",
|
||||
name: "Executor",
|
||||
role: "executor",
|
||||
state: "running",
|
||||
taskId: task.id,
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
} as Agent;
|
||||
|
||||
render(<ActiveAgentsPanel agents={[agent]} />);
|
||||
|
||||
const expectedStepName = task.steps[expectedStepNumber]?.name;
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(`Step ${expectedStepNumber}/${totalSteps}: ${expectedStepName}`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("shows 'No steps' rather than a bogus number when task.steps is empty", () => {
|
||||
const task = makeTaskDetail({ steps: [], currentStep: 0, log: [] });
|
||||
expect(getCanonicalStepNumber(task)).toEqual({ stepNumber: 0, totalSteps: 0, hasSteps: false });
|
||||
|
||||
render(<TaskTokenStatsPanel loading={false} tokenUsage={undefined} task={task} />);
|
||||
expect(screen.getByText("No steps")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
57
packages/dashboard/app/lib/step-display.ts
Normal file
57
packages/dashboard/app/lib/step-display.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
FNXC:TaskStepNumbering 2026-07-05-00:00:
|
||||
Canonical step-number convention for the dashboard. Fusion's engine, executor tool
|
||||
contracts (`fn_task_update`'s `step` arg, `fn_task_log`'s implicit step context, review
|
||||
verdict/request log lines such as "code review requested for Step N", and
|
||||
`step-session-executor.ts`'s `createWorkflowStepActivityRun` `stepIndex`) all use a
|
||||
0-based step number that is IDENTICAL to the literal `### Step N:` numbering in
|
||||
PROMPT.md, where Step 0 is Preflight. `task.currentStep` and `task.steps[i]` already
|
||||
live in that same 0-based index space (task.steps[0] IS the Preflight step).
|
||||
|
||||
Before this fix, the task-dialog indicators (`ActiveAgentsPanel`, `TaskTokenStatsPanel`)
|
||||
independently added `+ 1` to `task.currentStep` to render a "1-based" step number, while
|
||||
the Activity tab / workflow-step activity runs / task log lines rendered the raw 0-based
|
||||
`stepIndex`/`currentStep`. That meant the SAME underlying step showed as e.g. "Step 1"
|
||||
in the task dialog and "Step 0" in Activity — an off-by-one that made operators unable
|
||||
to tell which step was actually running (Runfusion/Fusion#1921).
|
||||
|
||||
Fix: every surface that displays a step number must derive it from this single helper,
|
||||
which returns the raw (PROMPT-numbered, 0-based) step index — clamped into
|
||||
`[0, totalSteps - 1]` so an out-of-range `currentStep` never renders as "Step 6/5" —
|
||||
instead of re-deriving its own +1/-1 math. Do NOT reintroduce a per-surface `+ 1`;
|
||||
if a "1-based ordinal" is ever desired for a NEW surface, it must be computed FROM this
|
||||
helper's canonical number (`+ 1` at render time, clearly labeled), never from raw
|
||||
`task.currentStep` directly, so it stays traceable back to the one convention.
|
||||
*/
|
||||
|
||||
/** Minimal shape needed to compute the canonical step-number display. */
|
||||
export interface StepNumberDisplayTask {
|
||||
currentStep?: number | null;
|
||||
steps?: unknown[] | null;
|
||||
}
|
||||
|
||||
export interface StepNumberDisplayInfo {
|
||||
/** Canonical 0-based step number (PROMPT.md convention — Step 0 is Preflight), clamped to a valid index when steps exist. */
|
||||
stepNumber: number;
|
||||
/** Total number of steps (`task.steps.length`), 0 when there are no steps. */
|
||||
totalSteps: number;
|
||||
/** False when the task has no steps at all (nothing to display). */
|
||||
hasSteps: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the canonical step number for display, matching the PROMPT.md /
|
||||
* engine convention (0-based, Step 0 = Preflight) used by the Activity tab,
|
||||
* workflow-step activity runs, and task log lines. Every surface that shows a
|
||||
* numeric "Step N" for a task must call this instead of doing its own math on
|
||||
* `task.currentStep`, so the number is guaranteed to agree everywhere.
|
||||
*/
|
||||
export function getCanonicalStepNumber(task: StepNumberDisplayTask | null | undefined): StepNumberDisplayInfo {
|
||||
const totalSteps = task?.steps?.length ?? 0;
|
||||
if (totalSteps === 0) {
|
||||
return { stepNumber: 0, totalSteps: 0, hasSteps: false };
|
||||
}
|
||||
const raw = task?.currentStep ?? 0;
|
||||
const stepNumber = Math.min(Math.max(raw, 0), totalSteps - 1);
|
||||
return { stepNumber, totalSteps, hasSteps: true };
|
||||
}
|
||||
Reference in New Issue
Block a user