From 32e8bbe459f371904f017c77aacc82fedb8f8153 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 21:11:29 -0700 Subject: [PATCH] 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) --- .../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(-) create mode 100644 packages/dashboard/app/components/__tests__/step-number-alignment.test.tsx create mode 100644 packages/dashboard/app/lib/step-display.ts diff --git a/packages/dashboard/app/components/ActiveAgentsPanel.tsx b/packages/dashboard/app/components/ActiveAgentsPanel.tsx index 852c727ce8..7abcc59532 100644 --- a/packages/dashboard/app/components/ActiveAgentsPanel.tsx +++ b/packages/dashboard/app/components/ActiveAgentsPanel.tsx @@ -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 = () => { diff --git a/packages/dashboard/app/components/TaskTokenStatsPanel.tsx b/packages/dashboard/app/components/TaskTokenStatsPanel.tsx index 88d0a1aa9c..d033530f9a 100644 --- a/packages/dashboard/app/components/TaskTokenStatsPanel.tsx +++ b/packages/dashboard/app/components/TaskTokenStatsPanel.tsx @@ -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 (
@@ -231,7 +234,7 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat
{t("taskDetail.stepProgress", "Step progress")}
-
{taskStepCount > 0 ? `${Math.min((task?.currentStep ?? 0) + 1, taskStepCount)} / ${taskStepCount}` : t("taskDetail.noSteps", "No steps")}
+
{taskStepCount > 0 ? `${canonicalStepNumber} / ${taskStepCount}` : t("taskDetail.noSteps", "No steps")}
{t("taskDetail.retriesLabel", "Retries (recovery / workflow / merge / task_done)")}
diff --git a/packages/dashboard/app/components/__tests__/step-number-alignment.test.tsx b/packages/dashboard/app/components/__tests__/step-number-alignment.test.tsx new file mode 100644 index 0000000000..d458b7abcc --- /dev/null +++ b/packages/dashboard/app/components/__tests__/step-number-alignment.test.tsx @@ -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): 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 { + 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( + , + ); + 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(); + + 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(); + expect(screen.getByText("No steps")).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/lib/step-display.ts b/packages/dashboard/app/lib/step-display.ts new file mode 100644 index 0000000000..448d04ccee --- /dev/null +++ b/packages/dashboard/app/lib/step-display.ts @@ -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 }; +}