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:
7
.changeset/fn-7563-overseer-badge-explanation.md
Normal file
7
.changeset/fn-7563-overseer-badge-explanation.md
Normal 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.
|
||||
@@ -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";
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
104
packages/dashboard/app/components/plannerOverseerBadge.ts
Normal file
104
packages/dashboard/app/components/plannerOverseerBadge.ts
Normal 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(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user