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 { InReviewStallSignal } from "./in-review-stall.js";
|
||||||
import type { PlannerOverseerRuntimeSnapshot } from "./planner-overseer-state.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 { ModelPricing } from "./model-pricing.js";
|
||||||
import type { InReviewStalledSignal } from "./in-review-stalled.js";
|
import type { InReviewStalledSignal } from "./in-review-stalled.js";
|
||||||
import type { StalePausedReviewSignal } from "./stale-paused-review.js";
|
import type { StalePausedReviewSignal } from "./stale-paused-review.js";
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { ProviderIcon } from "./ProviderIcon";
|
|||||||
import { PluginSlot } from "./PluginSlot";
|
import { PluginSlot } from "./PluginSlot";
|
||||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||||
import { useCoarsePointer } from "../hooks/useCoarsePointer";
|
import { useCoarsePointer } from "../hooks/useCoarsePointer";
|
||||||
|
import { plannerOverseerBadgeTooltip, plannerOverseerStateLabel } from "./plannerOverseerBadge";
|
||||||
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
||||||
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
|
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
|
||||||
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
||||||
@@ -2774,13 +2775,11 @@ function TaskCardComponent({
|
|||||||
{task.plannerOverseerState && task.plannerOverseerState.state !== "idle" && (
|
{task.plannerOverseerState && task.plannerOverseerState.state !== "idle" && (
|
||||||
<span
|
<span
|
||||||
className="card-status-badge card-planner-overseer-state"
|
className="card-status-badge card-planner-overseer-state"
|
||||||
title={t("tasks.plannerOverseerStateTitle", "Planner overseer: {{state}}", {
|
title={plannerOverseerBadgeTooltip(task.plannerOverseerState, t)}
|
||||||
state: task.plannerOverseerState.state,
|
|
||||||
})}
|
|
||||||
data-testid="planner-overseer-state-badge"
|
data-testid="planner-overseer-state-badge"
|
||||||
data-planner-overseer-state={task.plannerOverseerState.state}
|
data-planner-overseer-state={task.plannerOverseerState.state}
|
||||||
>
|
>
|
||||||
{task.plannerOverseerState.state}
|
{plannerOverseerStateLabel(task.plannerOverseerState.state, t)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{showStalledReview && stalledReview && (
|
{showStalledReview && stalledReview && (
|
||||||
|
|||||||
@@ -295,6 +295,78 @@ describe("TaskCard", () => {
|
|||||||
expect(screen.getByTestId("planner-overseer-state-badge")).toBeInTheDocument();
|
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 () => {
|
it("shows an Answer-questions button when awaiting user input and opens the workflow tab", async () => {
|
||||||
const onOpenDetailWithTab = vi.fn();
|
const onOpenDetailWithTab = vi.fn();
|
||||||
render(
|
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