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:
gsxdsm
2026-07-05 21:11:29 -07:00
parent 44442622c5
commit 32e8bbe459
4 changed files with 229 additions and 5 deletions

View File

@@ -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 = () => {

View File

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

View File

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

View 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 };
}