diff --git a/.changeset/fn-7563-overseer-badge-explanation.md b/.changeset/fn-7563-overseer-badge-explanation.md new file mode 100644 index 0000000000..31b019ecfe --- /dev/null +++ b/.changeset/fn-7563-overseer-badge-explanation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Planner-overseer task badge now shows a readable label and explains what it is waiting on. +category: fix +dev: TaskCard badge renders plannerOverseerStateLabel + plannerOverseerBadgeTooltip built from the existing PlannerOverseerRuntimeSnapshot (reason/watchedStage/signal/pendingConfirmation); presentation-only, no engine changes. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ce51edb2a7..2b5a47a26f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,5 +1,12 @@ import type { InReviewStallSignal } from "./in-review-stall.js"; import type { PlannerOverseerRuntimeSnapshot } from "./planner-overseer-state.js"; +// FNXC:PlannerOversight 2026-07-04-18:00: FN-7563 needs `PlannerOverseerState`/ +// `PlannerOverseerRuntimeSnapshot` as TYPE-ONLY imports in the dashboard's pure +// `plannerOverseerBadge.ts` helper. The dashboard's vite alias for "@fusion/core" +// resolves only to this file (types.ts), not the package barrel, so the types must +// be re-exported here (type-only — no engine/runtime code crosses into the browser +// bundle) rather than requiring dashboard code to import the source module path. +export type { PlannerOverseerState, PlannerOverseerRuntimeSnapshot } from "./planner-overseer-state.js"; import type { ModelPricing } from "./model-pricing.js"; import type { InReviewStalledSignal } from "./in-review-stalled.js"; import type { StalePausedReviewSignal } from "./stale-paused-review.js"; diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index aca0b43de0..03d0e9be82 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -28,6 +28,7 @@ import { ProviderIcon } from "./ProviderIcon"; import { PluginSlot } from "./PluginSlot"; import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket"; import { useCoarsePointer } from "../hooks/useCoarsePointer"; +import { plannerOverseerBadgeTooltip, plannerOverseerStateLabel } from "./plannerOverseerBadge"; import { getFreshBatchData } from "../hooks/useBatchBadgeFetch"; import { useTaskDiffStats } from "../hooks/useTaskDiffStats"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; @@ -2774,13 +2775,11 @@ function TaskCardComponent({ {task.plannerOverseerState && task.plannerOverseerState.state !== "idle" && ( - {task.plannerOverseerState.state} + {plannerOverseerStateLabel(task.plannerOverseerState.state, t)} )} {showStalledReview && stalledReview && ( diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index a485b3d23b..01e6b965cd 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -295,6 +295,78 @@ describe("TaskCard", () => { expect(screen.getByTestId("planner-overseer-state-badge")).toBeInTheDocument(); }); + // FN-7563: the badge used to print the raw kebab-case state (e.g. + // "awaiting-confirmation") with a bare "Planner overseer: awaiting-confirmation" + // tooltip. This reproduces the reported in-review symptom and asserts the badge + // is now human-readable and self-explanatory. + it("explains an in-review awaiting-confirmation badge with a readable label and a reason-bearing tooltip", () => { + const task = makeTask({ + column: "in-review", + plannerOverseerState: { + state: "awaiting-confirmation", + oversightLevel: "autonomous", + watchedStage: "executor", + signal: "stalled", + attemptCount: 2, + attemptLimit: 3, + pendingConfirmation: true, + observedAt: 1700000000000, + reason: "Retry limit reached; waiting for an operator decision", + }, + }); + + render(); + + const badge = screen.getByTestId("planner-overseer-state-badge"); + expect(badge.textContent).not.toBe("awaiting-confirmation"); + expect(badge.textContent).toBe("Awaiting confirmation"); + + const title = badge.getAttribute("title") ?? ""; + expect(title).not.toBe("Planner overseer: awaiting-confirmation"); + expect(title).toContain("Retry limit reached; waiting for an operator decision"); + expect(title).toMatch(/human decision/i); + expect(title).not.toMatch(/undefined/); + }); + + it("renders readable labels for in-progress watching and recovering overseer states", () => { + const watchingTask = makeTask({ + column: "in-progress", + plannerOverseerState: { + state: "watching", + oversightLevel: "autonomous", + watchedStage: "executor", + signal: "progressing", + attemptCount: 0, + attemptLimit: 3, + pendingConfirmation: false, + observedAt: 1700000000000, + }, + }); + const { unmount } = render(); + let badge = screen.getByTestId("planner-overseer-state-badge"); + expect(badge.textContent).toBe("Overseer watching"); + expect(badge.getAttribute("title")).not.toMatch(/undefined/); + unmount(); + + const recoveringTask = makeTask({ + column: "in-progress", + plannerOverseerState: { + state: "recovering", + oversightLevel: "autonomous", + attemptCount: 1, + attemptLimit: 3, + pendingConfirmation: false, + observedAt: 1700000000000, + }, + }); + render(); + badge = screen.getByTestId("planner-overseer-state-badge"); + expect(badge.textContent).toBe("Overseer recovering"); + const title = badge.getAttribute("title") ?? ""; + expect(title).not.toMatch(/undefined/); + expect(title.length).toBeGreaterThan(0); + }); + it("shows an Answer-questions button when awaiting user input and opens the workflow tab", async () => { const onOpenDetailWithTab = vi.fn(); render( diff --git a/packages/dashboard/app/components/__tests__/plannerOverseerBadge.test.ts b/packages/dashboard/app/components/__tests__/plannerOverseerBadge.test.ts new file mode 100644 index 0000000000..43deee4999 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/plannerOverseerBadge.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { plannerOverseerBadgeTooltip, plannerOverseerStateLabel } from "../plannerOverseerBadge"; +import type { PlannerOverseerRuntimeSnapshot, PlannerOverseerState } from "@fusion/core"; + +const NON_IDLE_STATES: PlannerOverseerState[] = [ + "watching", + "steering", + "recovering", + "awaiting-confirmation", +]; + +function makeSnapshot( + overrides: Partial = {}, +): Pick { + return { + state: "watching", + reason: undefined, + watchedStage: undefined, + signal: undefined, + pendingConfirmation: undefined, + ...overrides, + }; +} + +describe("plannerOverseerStateLabel", () => { + for (const state of NON_IDLE_STATES) { + it(`renders a human-readable label for "${state}" (no raw kebab-case)`, () => { + const label = plannerOverseerStateLabel(state); + expect(label).not.toBe(state); + expect(label).not.toMatch(/-/); + }); + } + + it("renders a distinct readable label for awaiting-confirmation", () => { + expect(plannerOverseerStateLabel("awaiting-confirmation")).toBe("Awaiting confirmation"); + }); +}); + +describe("plannerOverseerBadgeTooltip", () => { + it("uses the verbatim reason when present", () => { + const tooltip = plannerOverseerBadgeTooltip( + makeSnapshot({ state: "steering", reason: "Executor is stalled on step 3" }), + ); + expect(tooltip).toContain("Executor is stalled on step 3"); + }); + + it("falls back gracefully (no literal undefined) when reason is absent", () => { + const tooltip = plannerOverseerBadgeTooltip(makeSnapshot({ state: "watching", reason: undefined })); + expect(tooltip).not.toMatch(/undefined/); + expect(tooltip.length).toBeGreaterThan(0); + }); + + it("appends watched stage and signal when both present", () => { + const tooltip = plannerOverseerBadgeTooltip( + makeSnapshot({ state: "recovering", watchedStage: "executor", signal: "progressing" }), + ); + expect(tooltip).toContain("executor"); + expect(tooltip).toContain("progressing"); + expect(tooltip).not.toMatch(/undefined/); + }); + + it("appends watched stage without a signal clause when signal is absent", () => { + const tooltip = plannerOverseerBadgeTooltip(makeSnapshot({ state: "steering", watchedStage: "executor" })); + expect(tooltip).toContain("executor"); + expect(tooltip).not.toMatch(/undefined/); + }); + + it("omits stage/signal wording entirely when both are absent", () => { + const tooltip = plannerOverseerBadgeTooltip(makeSnapshot({ state: "steering", reason: "Applying a targeted fix" })); + expect(tooltip).not.toMatch(/undefined/); + }); + + it("explains a pending human decision for awaiting-confirmation regardless of the pendingConfirmation flag value", () => { + const tooltip = plannerOverseerBadgeTooltip( + makeSnapshot({ state: "awaiting-confirmation", reason: "Retry limit reached", pendingConfirmation: true }), + ); + expect(tooltip).toMatch(/human decision/i); + }); + + it("explains a pending human decision when pendingConfirmation is true even outside the awaiting-confirmation state", () => { + const tooltip = plannerOverseerBadgeTooltip( + makeSnapshot({ state: "watching", reason: "Observing", pendingConfirmation: true }), + ); + expect(tooltip).toMatch(/human decision/i); + }); + + it("does not mention a pending decision when pendingConfirmation is false and state is not awaiting-confirmation", () => { + const tooltip = plannerOverseerBadgeTooltip( + makeSnapshot({ state: "watching", reason: "Observing", pendingConfirmation: false }), + ); + expect(tooltip).not.toMatch(/human decision/i); + }); + + it("never emits the raw kebab-case state string when reason is absent (uses the readable label instead)", () => { + // Only "awaiting-confirmation" is kebab-case; the others contain no hyphen + // so a substring check would be trivially true. Assert the hyphenated raw + // form specifically never leaks into the tooltip. + const tooltip = plannerOverseerBadgeTooltip( + makeSnapshot({ state: "awaiting-confirmation", reason: undefined }), + ); + expect(tooltip).not.toContain("awaiting-confirmation"); + }); +}); diff --git a/packages/dashboard/app/components/plannerOverseerBadge.ts b/packages/dashboard/app/components/plannerOverseerBadge.ts new file mode 100644 index 0000000000..7b9ab45dfd --- /dev/null +++ b/packages/dashboard/app/components/plannerOverseerBadge.ts @@ -0,0 +1,104 @@ +import type { TFunction } from "i18next"; +import type { PlannerOverseerState, PlannerOverseerRuntimeSnapshot } from "@fusion/core"; + +/** + * FNXC:PlannerOversight 2026-07-04-18:05: + * FN-7563: the `TaskCard` planner-overseer badge previously printed the raw + * kebab-case runtime state (e.g. `awaiting-confirmation`) with a bare + * `Planner overseer: awaiting-confirmation` tooltip — an operator on an + * `in-review` task had no explanation of what was being confirmed or why. + * This module is the single source of truth for (a) a human-readable label + * per non-idle `PlannerOverseerState`, and (b) an explanatory tooltip string + * composed from the transient `PlannerOverseerRuntimeSnapshot` fields the + * engine already populates (`reason`, `watchedStage`, `signal`, + * `pendingConfirmation`). It is presentation-only: a pure function of the + * snapshot it is given. It must NOT re-derive, cache, or mutate overseer + * state — the snapshot itself remains owned by FN-7511/FN-7512/FN-7531. + * + * Deliberately free of React/engine imports (only TYPE imports from + * `@fusion/core`) so it stays trivially unit-testable and safe under the + * dashboard's `@fusion/core` -> `packages/core/src/types.ts` vite alias. + */ + +/** Human-readable label for a non-idle planner-overseer state. Falls back to the raw state for future/unknown enum values so a new state never silently disappears. */ +export function plannerOverseerStateLabel( + state: PlannerOverseerState, + t?: TFunction<"app">, +): string { + const translate: (key: string, fallback: string, opts?: Record) => string = + t ?? ((_key, fallback) => fallback); + switch (state) { + case "watching": + return translate("tasks.plannerOverseerState.watching", "Overseer watching"); + case "steering": + return translate("tasks.plannerOverseerState.steering", "Overseer steering"); + case "recovering": + return translate("tasks.plannerOverseerState.recovering", "Overseer recovering"); + case "awaiting-confirmation": + return translate("tasks.plannerOverseerState.awaitingConfirmation", "Awaiting confirmation"); + case "idle": + return translate("tasks.plannerOverseerState.idle", "Overseer idle"); + default: + return state; + } +} + +/** + * Composes the badge tooltip: leads with the human-readable `reason` when + * present (verbatim, mirroring the `taskDetail.oversight.explainReason` + * wording), appends the watched stage/signal when known, and — for + * `awaiting-confirmation` — appends a sentence stating a human decision is + * pending. Never emits the literal `undefined`; every optional field has a + * graceful fallback clause instead. + */ +export function plannerOverseerBadgeTooltip( + snapshot: Pick, + t?: TFunction<"app">, +): string { + const translate: (key: string, fallback: string, opts?: Record) => string = + t ?? + ((_key, fallback, opts) => { + if (!opts) return fallback; + return Object.entries(opts).reduce( + (acc, [key, value]) => acc.replaceAll(`{{${key}}}`, String(value)), + fallback, + ); + }); + + const parts: string[] = []; + + if (snapshot.reason) { + parts.push(snapshot.reason); + } else { + parts.push( + translate("tasks.plannerOverseerStateTooltip.stateOnly", "Planner overseer: {{label}}", { + label: plannerOverseerStateLabel(snapshot.state, t), + }), + ); + } + + if (snapshot.watchedStage) { + parts.push( + snapshot.signal + ? translate( + "tasks.plannerOverseerStateTooltip.watchingStageSignal", + "Watching {{stage}} ({{signal}}).", + { stage: snapshot.watchedStage, signal: snapshot.signal }, + ) + : translate("tasks.plannerOverseerStateTooltip.watchingStage", "Watching {{stage}}.", { + stage: snapshot.watchedStage, + }), + ); + } + + if (snapshot.state === "awaiting-confirmation" || snapshot.pendingConfirmation) { + parts.push( + translate( + "tasks.plannerOverseerStateTooltip.pendingConfirmation", + "A human decision is required before the overseer can continue.", + ), + ); + } + + return parts.join(" "); +}