FN-7563: explain the planner-overseer awaiting-confirmation badge

Replace the raw kebab-case planner-overseer badge state and bare tooltip with a human-readable label and an explanatory tooltip built from the existing runtime snapshot.

- Add packages/dashboard/app/components/plannerOverseerBadge.ts: pure, type-only helper exposing plannerOverseerStateLabel() and plannerOverseerBadgeTooltip(), composing the tooltip from reason/watchedStage/signal/pendingConfirmation with graceful fallbacks
- Re-export PlannerOverseerState and PlannerOverseerRuntimeSnapshot as type-only from packages/core/src/types.ts so the dashboard's @fusion/core vite alias can resolve them
- Update TaskCard.tsx to render the new label/tooltip instead of the raw state string
- Add unit tests for the new badge helper and extend TaskCard tests for the updated label/tooltip behavior
- Add a patch changeset documenting the operator-facing fix

Files changed:
 .changeset/fn-7563-overseer-badge-explanation.md   |   7 ++
 packages/core/src/types.ts                         |   7 ++
 packages/dashboard/app/components/TaskCard.tsx     |   7 +-
 .../app/components/__tests__/TaskCard.test.tsx     |  72 ++++++++++++++
 .../__tests__/plannerOverseerBadge.test.ts         | 103 ++++++++++++++++++++
 .../app/components/plannerOverseerBadge.ts         | 104 +++++++++++++++++++++
 6 files changed, 296 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-7563

Fusion-Task-Lineage: 64ed011b-5486-4272-abd1-a6123c60785f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 22:05:49 -07:00
parent 9d4a45b0f9
commit c08498efc6
6 changed files with 296 additions and 4 deletions

View File

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

View File

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

View File

@@ -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" && (
<span
className="card-status-badge card-planner-overseer-state"
title={t("tasks.plannerOverseerStateTitle", "Planner overseer: {{state}}", {
state: task.plannerOverseerState.state,
})}
title={plannerOverseerBadgeTooltip(task.plannerOverseerState, t)}
data-testid="planner-overseer-state-badge"
data-planner-overseer-state={task.plannerOverseerState.state}
>
{task.plannerOverseerState.state}
{plannerOverseerStateLabel(task.plannerOverseerState.state, t)}
</span>
)}
{showStalledReview && stalledReview && (

View File

@@ -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(<TaskCard task={task} onOpenDetail={noop} addToast={noop} />);
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(<TaskCard task={watchingTask} onOpenDetail={noop} addToast={noop} />);
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(<TaskCard task={recoveringTask} onOpenDetail={noop} addToast={noop} />);
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(

View File

@@ -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<PlannerOverseerRuntimeSnapshot> = {},
): Pick<PlannerOverseerRuntimeSnapshot, "state" | "reason" | "watchedStage" | "signal" | "pendingConfirmation"> {
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");
});
});

View File

@@ -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, unknown>) => 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<PlannerOverseerRuntimeSnapshot, "state" | "reason" | "watchedStage" | "signal" | "pendingConfirmation">,
t?: TFunction<"app">,
): string {
const translate: (key: string, fallback: string, opts?: Record<string, unknown>) => 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(" ");
}