FN-8247: stop session advisor with oversight

Stop now persists session-advisor disablement and reflects inherited advisor state in task details.

- Persist an explicit advisor-off override and clear its live runtime when stopping oversight.
- Resolve workflow-level advisor defaults for task-detail icons and toggle behavior.
- Cover stop cleanup and desktop/mobile oversight state transitions with regression tests.
- Document the combined stop contract and add a patch changeset.

Files changed:
 .changeset/fn-8247-session-advisor-stop-and-icon.md       |   7 ++
 docs/dashboard-guide.md                            |   6 +-
 packages/dashboard/app/components/TaskDetailModal.tsx   |  86 +++++++++++------
 packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx    | 104 +++++++++++++++++++++
 packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx      |  62 ++++++++++++
 packages/engine/src/__tests__/project-engine-stop-overseer-session-advisor.test.ts |  62 ++++++++++++
 packages/engine/src/project-engine.ts              |  13 ++-
 7 files changed, 310 insertions(+), 30 deletions(-)

Fusion-Task-Id: FN-8247

Fusion-Task-Lineage: dcb67af7-9a36-4b25-bafa-84722fe158a0

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 16:20:10 -07:00
parent 669cb7cc56
commit 62f121e0c9
7 changed files with 310 additions and 30 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Stop now disables the session advisor, and its on/off state correctly updates the task-detail oversight icon.
category: fix
dev: stopOverseerTask persists sessionAdvisorEnabled:false and clears the advisor runtime; TaskDetailModal effective-state derivation now honors the resolver's workflow-legacy tier (plannerOverseerAdvisorEnabled).

View File

@@ -244,8 +244,10 @@ FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 collapses the desktop inline clu
FNXC:QuickAddActionRow 2026-07-16-16:00: FN-8194 aligns task-detail metadata actions with Quick Add: attach, GitHub tracking, Oversight (Eye), Priority, then Fast. FNXC:QuickAddActionRow 2026-07-16-16:00: FN-8194 aligns task-detail metadata actions with Quick Add: attach, GitHub tracking, Oversight (Eye), Priority, then Fast.
FNXC:PlannerOversight 2026-07-17-13:18: FN-8233 makes the task-detail Oversight trigger communicate the combined effective overseer state: it uses Eye when the oversight level is active or the Session advisor is enabled, and EyeOff only when both are off. --> FNXC:PlannerOversight 2026-07-17-13:18: FN-8233 makes the task-detail Oversight trigger communicate the combined effective overseer state: it uses Eye when the oversight level is active or the Session advisor is enabled, and EyeOff only when both are off.
- The task detail modal's inline meta-controls cluster follows Quick Add order: compact **Attach file** (`data-testid="detail-inline-attach"`), eligible-task **GitHub tracking** toggle (`data-testid="detail-inline-github-toggle"`), **Oversight**, Priority, and Execution mode. Attach opens the existing task attachment picker; GitHub toggles the existing tracking setting and is omitted for GitLab-tracked or non-editable tasks. The Oversight control exposes planner-overseer actions behind a compact **"Oversight" overflow-menu button** (`data-testid="detail-oversight-menu-trigger"`, `Eye` when the oversight level is active or the Session advisor is enabled, otherwise `EyeOff`, `aria-haspopup="menu"`) on every viewport, desktop and mobile alike. Clicking the trigger opens a `role="menu"` popover (mirroring the existing move-action dropdown pattern) containing: a **quick oversight-level select** (`data-testid="detail-oversight-level-select"`) that writes the per-task `plannerOversightLevel` override (Off/Observe/Steer/Autonomous recovery) or clears it back to the inherited workflow/project default via an "Inherit" option; a **manual nudge** button (`data-testid="detail-overseer-nudge"`) that asks the overseer to inject one guidance-only steering comment into the currently watched stage right now (never a merge/PR/destructive action), disabled when the overseer is off/inactive or the task is user-paused/done/archived/`autoMerge:false` in-review; a **stop oversight** button (`data-testid="detail-overseer-stop"`) that disables active oversight for the task (confirmation-gated), hidden once oversight is already off; and an **explain current action** button (`data-testid="detail-overseer-explain"`) that toggles a small read-only panel (`data-testid="detail-overseer-explain-panel"`) showing the overseer's watched stage, reason, last action, and attempt count/limit, with a non-empty-shell inactive state when the overseer is not currently watching. All three action controls call the `POST /tasks/:id/overseer/nudge`, `POST /tasks/:id/overseer/stop`, and `GET /tasks/:id/overseer/explain` routes. The trigger itself is withheld entirely when none of the menu's controls would render (no empty-shell overflow button for the unresolved-default case).
FNXC:PlannerOversight 2026-07-18-12:00: FN-8247 requires Stop to disable both lifecycle oversight and the independently-enabled session advisor, including its live runtime, so project/workflow defaults cannot leave advisor comments running after an operator stops it. -->
- The task detail modal's inline meta-controls cluster follows Quick Add order: compact **Attach file** (`data-testid="detail-inline-attach"`), eligible-task **GitHub tracking** toggle (`data-testid="detail-inline-github-toggle"`), **Oversight**, Priority, and Execution mode. Attach opens the existing task attachment picker; GitHub toggles the existing tracking setting and is omitted for GitLab-tracked or non-editable tasks. The Oversight control exposes planner-overseer actions behind a compact **"Oversight" overflow-menu button** (`data-testid="detail-oversight-menu-trigger"`, `Eye` when the oversight level is active or the Session advisor is enabled, otherwise `EyeOff`, `aria-haspopup="menu"`) on every viewport, desktop and mobile alike. Clicking the trigger opens a `role="menu"` popover (mirroring the existing move-action dropdown pattern) containing: a **quick oversight-level select** (`data-testid="detail-oversight-level-select"`) that writes the per-task `plannerOversightLevel` override (Off/Observe/Steer/Autonomous recovery) or clears it back to the inherited workflow/project default via an "Inherit" option; a **manual nudge** button (`data-testid="detail-overseer-nudge"`) that asks the overseer to inject one guidance-only steering comment into the currently watched stage right now (never a merge/PR/destructive action), disabled when the overseer is off/inactive or the task is user-paused/done/archived/`autoMerge:false` in-review; a **Stop** button (`data-testid="detail-overseer-stop"`) that disables both active lifecycle oversight and the session advisor for the task (confirmation-gated), including its live advisor runtime; it is hidden once oversight is already off; and an **explain current action** button (`data-testid="detail-overseer-explain"`) that toggles a small read-only panel (`data-testid="detail-overseer-explain-panel"`) showing the overseer's watched stage, reason, last action, and attempt count/limit, with a non-empty-shell inactive state when the overseer is not currently watching. All three action controls call the `POST /tasks/:id/overseer/nudge`, `POST /tasks/:id/overseer/stop`, and `GET /tasks/:id/overseer/explain` routes. The trigger itself is withheld entirely when none of the menu's controls would render (no empty-shell overflow button for the unresolved-default case).
<!-- FNXC:PlannerOversight 2026-07-04-20:30: FN-7546 clarifies the cluster above — operators reported the buttons were unlabeled and looked inert, with only a hover title explaining why. Adds a visible group label and an always-visible disabled-reason line, and makes Explain always openable since it never mutates anything. --> <!-- FNXC:PlannerOversight 2026-07-04-20:30: FN-7546 clarifies the cluster above — operators reported the buttons were unlabeled and looked inert, with only a hover title explaining why. Adds a visible group label and an always-visible disabled-reason line, and makes Explain always openable since it never mutates anything. -->
- The controls inside the Oversight menu carry a visible, non-interactive **`"Overseer controls"` group label** (`data-testid="detail-oversight-controls-label"`) so Nudge/Stop/Explain read as an identifiable cluster rather than unlabeled entries; the label is gated by the same `(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff` condition as the buttons, so it never renders when oversight is Off/unresolved (opening the menu in that state shows only the level select). When **Nudge** is disabled, an always-visible helper line (`data-testid="detail-overseer-nudge-disabled-reason"`) states the reason in-DOM (mirroring the existing hover `title`) instead of relying on a mouse-hover tooltip alone. **Explain** is read-only and non-mutating, so it is never disabled purely because the overseer is inactive — clicking it always opens/closes the panel, which shows the overseer's live state when watching or an informative "not currently watching this task" message otherwise. Nudge's mutating enablement rule (`canNudgeOverseer`, including the human-control suppression cases) and Stop's confirmation dialog are unchanged. - The controls inside the Oversight menu carry a visible, non-interactive **`"Overseer controls"` group label** (`data-testid="detail-oversight-controls-label"`) so Nudge/Stop/Explain read as an identifiable cluster rather than unlabeled entries; the label is gated by the same `(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff` condition as the buttons, so it never renders when oversight is Off/unresolved (opening the menu in that state shows only the level select). When **Nudge** is disabled, an always-visible helper line (`data-testid="detail-overseer-nudge-disabled-reason"`) states the reason in-DOM (mirroring the existing hover `title`) instead of relying on a mouse-hover tooltip alone. **Explain** is read-only and non-mutating, so it is never disabled purely because the overseer is inactive — clicking it always opens/closes the panel, which shows the overseer's live state when watching or an informative "not currently watching this task" message otherwise. Nudge's mutating enablement rule (`canNudgeOverseer`, including the human-control suppression cases) and Stop's confirmation dialog are unchanged.
<!-- FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 originally introduced this overflow menu for mobile only, collapsing the level-select/nudge/stop/explain controls behind the trigger below the 768px breakpoint while desktop kept an inline cluster. <!-- FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 originally introduced this overflow menu for mobile only, collapsing the level-select/nudge/stop/explain controls behind the trigger below the 768px breakpoint while desktop kept an inline cluster.

View File

@@ -20,6 +20,7 @@ import {
getErrorMessage, getErrorMessage,
} from "@fusion/core"; } from "@fusion/core";
import { resolveEffectivePlannerOversightLevel } from "../../../core/src/workflow-settings-resolver"; import { resolveEffectivePlannerOversightLevel } from "../../../core/src/workflow-settings-resolver";
import { resolveTaskSessionAdvisorEnabled } from "../../../core/src/session-advisor";
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical"; import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert"; import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert";
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
@@ -444,7 +445,12 @@ in-flight de-dup exactly (see packages/dashboard/app/components/TaskCard.tsx)
rather than re-deriving precedence locally — `resolveEffectivePlannerOversightLevel` rather than re-deriving precedence locally — `resolveEffectivePlannerOversightLevel`
remains the single resolver both surfaces call. remains the single resolver both surfaces call.
*/ */
const modalWorkflowOversightEffectiveCache = new Map<string, PlannerOversightLevel | undefined>(); interface ModalWorkflowOversightSettings {
level: PlannerOversightLevel | undefined;
sessionAdvisorEnabled: boolean;
}
const modalWorkflowOversightEffectiveCache = new Map<string, ModalWorkflowOversightSettings>();
const modalWorkflowOversightInflight = new Map<string, Promise<void>>(); const modalWorkflowOversightInflight = new Map<string, Promise<void>>();
function getModalWorkflowOversightCacheKey(workflowId: string, projectId?: string): string { function getModalWorkflowOversightCacheKey(workflowId: string, projectId?: string): string {
@@ -455,20 +461,23 @@ function isPlannerOversightLevelValue(value: unknown): value is PlannerOversight
return typeof value === "string" && (PLANNER_OVERSIGHT_LEVELS as readonly string[]).includes(value); return typeof value === "string" && (PLANNER_OVERSIGHT_LEVELS as readonly string[]).includes(value);
} }
async function loadModalWorkflowOversightEffectiveLevel(workflowId: string, projectId: string | undefined): Promise<PlannerOversightLevel | undefined> { async function loadModalWorkflowOversightEffectiveLevel(workflowId: string, projectId: string | undefined): Promise<ModalWorkflowOversightSettings> {
const key = getModalWorkflowOversightCacheKey(workflowId, projectId); const key = getModalWorkflowOversightCacheKey(workflowId, projectId);
if (modalWorkflowOversightEffectiveCache.has(key)) { if (modalWorkflowOversightEffectiveCache.has(key)) {
return modalWorkflowOversightEffectiveCache.get(key); return modalWorkflowOversightEffectiveCache.get(key) ?? { level: undefined, sessionAdvisorEnabled: false };
} }
let inflight = modalWorkflowOversightInflight.get(key); let inflight = modalWorkflowOversightInflight.get(key);
if (!inflight) { if (!inflight) {
inflight = fetchWorkflowSettingValues(workflowId, projectId) inflight = fetchWorkflowSettingValues(workflowId, projectId)
.then((payload) => { .then((payload) => {
const raw = payload.effective?.plannerOversightLevel; const raw = payload.effective?.plannerOversightLevel;
modalWorkflowOversightEffectiveCache.set(key, isPlannerOversightLevelValue(raw) ? raw : undefined); modalWorkflowOversightEffectiveCache.set(key, {
level: isPlannerOversightLevelValue(raw) ? raw : undefined,
sessionAdvisorEnabled: payload.effective?.plannerOverseerAdvisorEnabled === true,
});
}) })
.catch(() => { .catch(() => {
modalWorkflowOversightEffectiveCache.set(key, undefined); modalWorkflowOversightEffectiveCache.set(key, { level: undefined, sessionAdvisorEnabled: false });
}) })
.finally(() => { .finally(() => {
modalWorkflowOversightInflight.delete(key); modalWorkflowOversightInflight.delete(key);
@@ -476,7 +485,7 @@ async function loadModalWorkflowOversightEffectiveLevel(workflowId: string, proj
modalWorkflowOversightInflight.set(key, inflight); modalWorkflowOversightInflight.set(key, inflight);
} }
await inflight; await inflight;
return modalWorkflowOversightEffectiveCache.get(key); return modalWorkflowOversightEffectiveCache.get(key) ?? { level: undefined, sessionAdvisorEnabled: false };
} }
const OVERSIGHT_LEVEL_LABEL: Record<PlannerOversightLevel, string> = { const OVERSIGHT_LEVEL_LABEL: Record<PlannerOversightLevel, string> = {
@@ -957,22 +966,26 @@ export function TaskDetailContent({
fetch above); a known per-task override renders synchronously regardless. fetch above); a known per-task override renders synchronously regardless.
*/ */
const workflowIdForOversight = taskWorkflowBadge?.id; const workflowIdForOversight = taskWorkflowBadge?.id;
const [workflowOversightState, setWorkflowOversightState] = useState<{ level: PlannerOversightLevel | undefined; resolved: boolean }>({ level: undefined, resolved: false }); const [workflowOversightState, setWorkflowOversightState] = useState<ModalWorkflowOversightSettings & { resolved: boolean }>({
level: undefined,
sessionAdvisorEnabled: false,
resolved: false,
});
useEffect(() => { useEffect(() => {
if (!workflowIdForOversight) { if (!workflowIdForOversight) {
setWorkflowOversightState({ level: undefined, resolved: true }); setWorkflowOversightState({ level: undefined, sessionAdvisorEnabled: false, resolved: true });
return; return;
} }
const workflowId = workflowIdForOversight; const workflowId = workflowIdForOversight;
const key = getModalWorkflowOversightCacheKey(workflowId, projectId); const key = getModalWorkflowOversightCacheKey(workflowId, projectId);
if (modalWorkflowOversightEffectiveCache.has(key)) { if (modalWorkflowOversightEffectiveCache.has(key)) {
setWorkflowOversightState({ level: modalWorkflowOversightEffectiveCache.get(key), resolved: true }); setWorkflowOversightState({ ...modalWorkflowOversightEffectiveCache.get(key)!, resolved: true });
return; return;
} }
setWorkflowOversightState({ level: undefined, resolved: false }); setWorkflowOversightState({ level: undefined, sessionAdvisorEnabled: false, resolved: false });
let cancelled = false; let cancelled = false;
void loadModalWorkflowOversightEffectiveLevel(workflowId, projectId).then((level) => { void loadModalWorkflowOversightEffectiveLevel(workflowId, projectId).then((settings) => {
if (!cancelled) setWorkflowOversightState({ level, resolved: true }); if (!cancelled) setWorkflowOversightState({ ...settings, resolved: true });
}); });
return () => { return () => {
cancelled = true; cancelled = true;
@@ -2071,29 +2084,44 @@ export function TaskDetailContent({
/* /*
FNXC:PlannerOversight 2026-07-14-18:11: FNXC:PlannerOversight 2026-07-14-18:11:
Per-task session advisor (LLM overseer agent). Unset inherits project Per-task session advisor (LLM overseer agent). Unset inherits project and
sessionAdvisorEnabledByDefault; explicit boolean forces on/off. Toggle writes workflow settings; explicit boolean forces on/off. Toggle writes an override
an override when it differs from the project default and clears to null when when it differs from the full inherited state and clears to null only when it
it matches (same inheritance model as Quick Add eye). matches that same shared resolver result.
*/ */
const projectSessionAdvisorDefault = settings?.sessionAdvisorEnabledByDefault === true; const projectSessionAdvisorDefault = settings?.sessionAdvisorEnabledByDefault === true;
const hasSessionAdvisorOverride = typeof workingTask.sessionAdvisorEnabled === "boolean"; const hasSessionAdvisorOverride = typeof workingTask.sessionAdvisorEnabled === "boolean";
const effectiveSessionAdvisorEnabled = hasSessionAdvisorOverride /*
? workingTask.sessionAdvisorEnabled === true FNXC:PlannerOversight 2026-07-18-12:00:
: projectSessionAdvisorDefault; FN-8247 requires the task-detail Eye/EyeOff affordances to use the shared
session-advisor precedence contract. The workflow legacy setting travels in
the existing workflow-settings fetch, so the UI cannot silently omit it or
retain a divergent local resolver.
*/
const effectiveSessionAdvisorEnabled = resolveTaskSessionAdvisorEnabled(
workingTask,
settings,
workflowOversightState.sessionAdvisorEnabled,
).enabled;
const inheritedSessionAdvisorEnabled = resolveTaskSessionAdvisorEnabled(
{ sessionAdvisorEnabled: undefined },
settings,
workflowOversightState.sessionAdvisorEnabled,
).enabled;
const handleSessionAdvisorToggle = useCallback(async () => { const handleSessionAdvisorToggle = useCallback(async () => {
setIsSavingSessionAdvisor(true); setIsSavingSessionAdvisor(true);
try { try {
const nextEnabled = !effectiveSessionAdvisorEnabled; const nextEnabled = !effectiveSessionAdvisorEnabled;
const nextValue: boolean | null = const nextValue: boolean | null =
nextEnabled === projectSessionAdvisorDefault ? null : nextEnabled; nextEnabled === inheritedSessionAdvisorEnabled ? null : nextEnabled;
const updatedTask = await updateTask(task.id, { sessionAdvisorEnabled: nextValue }, projectId); const updatedTask = await updateTask(task.id, { sessionAdvisorEnabled: nextValue }, projectId);
onTaskUpdated?.(updatedTask); onTaskUpdated?.(updatedTask);
addToast( addToast(
nextValue === null nextValue === null
? t("taskDetail.sessionAdvisor.reset", "Session advisor follows project default ({{default}})", { ? t("taskDetail.sessionAdvisor.reset", "Session advisor follows inherited defaults ({{default}})", {
default: projectSessionAdvisorDefault default: inheritedSessionAdvisorEnabled
? t("tasks.sessionAdvisorDefaultOn", "on") ? t("tasks.sessionAdvisorDefaultOn", "on")
: t("tasks.sessionAdvisorDefaultOff", "off"), : t("tasks.sessionAdvisorDefaultOff", "off"),
}) })
@@ -2117,7 +2145,7 @@ export function TaskDetailContent({
} }
}, [ }, [
effectiveSessionAdvisorEnabled, effectiveSessionAdvisorEnabled,
projectSessionAdvisorDefault, inheritedSessionAdvisorEnabled,
task.id, task.id,
projectId, projectId,
onTaskUpdated, onTaskUpdated,
@@ -2190,11 +2218,15 @@ export function TaskDetailContent({
FN-7517 stop-oversight control. Disables active oversight for this task FN-7517 stop-oversight control. Disables active oversight for this task
(per-task override -> "off") — a lightweight `confirm(...)` guards it since (per-task override -> "off") — a lightweight `confirm(...)` guards it since
it's a disabling action, matching the PROMPT's guidance for this control. it's a disabling action, matching the PROMPT's guidance for this control.
FNXC:PlannerOversight 2026-07-18-12:00:
FN-8247 extends Stop to disable the independently-enabled session advisor,
so confirmation and success copy must tell operators it stops both systems.
*/ */
const handleStopOverseer = useCallback(async () => { const handleStopOverseer = useCallback(async () => {
const shouldStop = await confirm({ const shouldStop = await confirm({
title: t("taskDetail.oversight.stopTitle", "Stop planner oversight?"), title: t("taskDetail.oversight.stopTitle", "Stop planner oversight?"),
message: t("taskDetail.oversight.stopMessage", "This disables active planner oversight for this task (sets oversight level to Off)."), message: t("taskDetail.oversight.stopMessage", "This disables planner oversight and the session advisor for this task."),
}); });
if (!shouldStop) return; if (!shouldStop) return;
@@ -2204,7 +2236,7 @@ export function TaskDetailContent({
if (result.task) { if (result.task) {
onTaskUpdated?.(result.task); onTaskUpdated?.(result.task);
} }
addToast(t("taskDetail.oversight.stopped", "Planner oversight stopped for this task"), "success"); addToast(t("taskDetail.oversight.stopped", "Planner oversight and session advisor stopped for this task"), "success");
} catch (err) { } catch (err) {
addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: getErrorMessage(err) }), "error"); addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: getErrorMessage(err) }), "error");
} finally { } finally {
@@ -4347,7 +4379,7 @@ export function TaskDetailContent({
) )
: t( : t(
"taskDetail.sessionAdvisor.inheritTitle", "taskDetail.sessionAdvisor.inheritTitle",
"Session advisor {{state}} (follows project default)", "Session advisor {{state}} (follows inherited defaults)",
{ {
state: effectiveSessionAdvisorEnabled state: effectiveSessionAdvisorEnabled
? t("tasks.sessionAdvisorDefaultOn", "on") ? t("tasks.sessionAdvisorDefaultOn", "on")
@@ -4366,7 +4398,7 @@ export function TaskDetailContent({
})} })}
{hasSessionAdvisorOverride {hasSessionAdvisorOverride
? "" ? ""
: t("taskDetail.sessionAdvisor.inheritSuffix", " (project)")} : t("taskDetail.sessionAdvisor.inheritSuffix", " (inherited)")}
</span> </span>
</button> </button>
{!oversightIsOff && ( {!oversightIsOff && (

View File

@@ -81,6 +81,110 @@ describe("TaskDetailModal oversight controls", () => {
expect(trigger.querySelector('[data-testid="more-vertical-icon"]')).not.toBeInTheDocument(); expect(trigger.querySelector('[data-testid="more-vertical-icon"]')).not.toBeInTheDocument();
}); });
it("uses the workflow legacy advisor tier for the shared detail trigger and toggle", async () => {
const api = await import("../../api");
vi.mocked(api.fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
sessionAdvisorEnabledByDefault: false,
} as any);
vi.mocked(api.fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "WF-advisor",
workflows: [{ id: "WF-advisor", name: "Advisor workflow" } as any],
taskWorkflowIds: { "FN-8247-workflow": "WF-advisor" },
});
vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValueOnce({
stored: {},
effective: { plannerOversightLevel: "off", plannerOverseerAdvisorEnabled: true },
defaults: {},
});
render(
<TaskDetailModal
task={makeTask({ id: "FN-8247-workflow", column: "in-progress", plannerOversightLevel: "off", sessionAdvisorEnabled: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
await waitFor(() => {
expect(trigger.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
});
await openOversightMenu();
const toggle = await screen.findByTestId("detail-session-advisor-toggle");
expect(toggle).toHaveAttribute("aria-pressed", "true");
expect(toggle.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
});
it("gives an explicit false override precedence and repaints when it is toggled on", async () => {
const api = await import("../../api");
let currentTask = makeTask({
id: "FN-8247-explicit-off",
column: "in-progress",
plannerOversightLevel: "off",
sessionAdvisorEnabled: false,
});
vi.mocked(api.fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
sessionAdvisorEnabledByDefault: false,
} as any);
vi.mocked(api.fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "WF-advisor-explicit-off",
workflows: [{ id: "WF-advisor-explicit-off", name: "Advisor workflow" } as any],
taskWorkflowIds: { [currentTask.id]: "WF-advisor-explicit-off" },
});
vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValueOnce({
stored: {},
effective: { plannerOversightLevel: "off", plannerOverseerAdvisorEnabled: true },
defaults: {},
});
vi.mocked(api.updateTask).mockImplementation(async (_id, patch) => {
currentTask = makeTask({ ...currentTask, ...patch });
return currentTask as any;
});
let rerenderModal: (nextTask: typeof currentTask) => void;
const renderModal = (nextTask: typeof currentTask) => (
<TaskDetailModal
task={nextTask}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={(updatedTask) => rerenderModal(updatedTask as typeof currentTask)}
addToast={noop}
/>
);
const rendered = render(renderModal(currentTask));
rerenderModal = (nextTask) => rendered.rerender(renderModal(nextTask));
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
await waitFor(() => {
expect(trigger.querySelector('[data-testid="eye-off-icon"]')).toBeInTheDocument();
});
await openOversightMenu();
const toggle = await screen.findByTestId("detail-session-advisor-toggle");
expect(toggle).toHaveAttribute("aria-pressed", "false");
fireEvent.click(toggle);
await waitFor(() => {
expect(api.updateTask).toHaveBeenCalledWith(currentTask.id, { sessionAdvisorEnabled: null }, undefined);
expect(trigger.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
expect(screen.getByTestId("detail-session-advisor-toggle")).toHaveAttribute("aria-pressed", "true");
});
});
it("updates the trigger icon for oversight-level and session-advisor predicate changes", async () => { it("updates the trigger icon for oversight-level and session-advisor predicate changes", async () => {
const api = await import("../../api"); const api = await import("../../api");
const mockUpdate = vi.mocked(api.updateTask); const mockUpdate = vi.mocked(api.updateTask);

View File

@@ -72,6 +72,68 @@ describe("TaskDetailModal oversight controls — mobile overflow menu", () => {
setViewportWidth(DESKTOP_WIDTH); setViewportWidth(DESKTOP_WIDTH);
}); });
it("repaints the shared trigger and menu toggle after disabling a workflow-enabled advisor", async () => {
const api = await import("../../api");
let currentTask = makeTask({
id: "FN-8247-mobile",
column: "in-progress",
plannerOversightLevel: "off",
sessionAdvisorEnabled: undefined,
});
vi.mocked(api.fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
sessionAdvisorEnabledByDefault: false,
} as any);
vi.mocked(api.fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "WF-advisor-mobile",
workflows: [{ id: "WF-advisor-mobile", name: "Advisor workflow" } as any],
taskWorkflowIds: { [currentTask.id]: "WF-advisor-mobile" },
});
vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValueOnce({
stored: {},
effective: { plannerOversightLevel: "off", plannerOverseerAdvisorEnabled: true },
defaults: {},
});
vi.mocked(api.updateTask).mockImplementation(async (_id, patch) => {
currentTask = makeTask({ ...currentTask, ...patch });
return currentTask as any;
});
let rerenderModal: (nextTask: typeof currentTask) => void;
const renderModal = (nextTask: typeof currentTask) => (
<TaskDetailModal
task={nextTask}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={(updatedTask) => rerenderModal(updatedTask as typeof currentTask)}
addToast={noop}
/>
);
const rendered = render(renderModal(currentTask));
rerenderModal = (nextTask) => rendered.rerender(renderModal(nextTask));
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
await waitFor(() => {
expect(trigger.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
});
fireEvent.click(trigger);
const toggle = await screen.findByTestId("detail-session-advisor-toggle");
expect(toggle).toHaveAttribute("aria-pressed", "true");
fireEvent.click(toggle);
await waitFor(() => {
expect(api.updateTask).toHaveBeenCalledWith(currentTask.id, { sessionAdvisorEnabled: false }, undefined);
expect(trigger.querySelector('[data-testid="eye-off-icon"]')).toBeInTheDocument();
expect(screen.getByTestId("detail-session-advisor-toggle")).toHaveAttribute("aria-pressed", "false");
});
});
it("renders a single overflow trigger (no inline action buttons) when oversight actions are available", async () => { it("renders a single overflow trigger (no inline action buttons) when oversight actions are available", async () => {
render( render(
<TaskDetailModal <TaskDetailModal

View File

@@ -0,0 +1,62 @@
/*
* FNXC:PlannerOversight 2026-07-18-12:00:
* FN-8247 keeps the Stop contract at the ProjectEngine boundary: lifecycle
* oversight and the session advisor have independent enablement axes, so Stop
* must persist an explicit advisor-off override and clear live advisor state.
*/
import { describe, expect, it, vi } from "vitest";
import { resolveTaskSessionAdvisorEnabled, type Task } from "@fusion/core";
import { ProjectEngine } from "../project-engine.js";
function makeTask(sessionAdvisorEnabled?: boolean): Task {
return {
id: "FN-8247",
title: "Stop session advisor",
description: "",
column: "in-progress",
status: "in-progress",
priority: "normal",
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
sessionAdvisorEnabled,
} as Task;
}
describe("ProjectEngine.stopOverseerTask session advisor cleanup", () => {
it.each([
["inherit", undefined, {}, false],
["project-default-on", undefined, { sessionAdvisorEnabledByDefault: true }, false],
["workflow-advisor-on", undefined, {}, true],
["explicit-on", true, {}, false],
])("forces %s advisor state off and tears down its runtime", async (_source, sessionAdvisorEnabled, settings, workflowAdvisorEnabled) => {
let task = makeTask(sessionAdvisorEnabled);
const clear = vi.fn();
const cursor = new Map([[task.id, 4]]);
const updateTask = vi.fn(async (_id: string, patch: Partial<Task>) => {
task = { ...task, ...patch } as Task;
return task;
});
const engine = Object.create(ProjectEngine.prototype) as ProjectEngine;
Object.assign(engine as object, {
runtime: { getTaskStore: () => ({ getTask: async () => task, updateTask }) },
sessionAdvisor: { clear },
sessionAdvisorLogCursor: cursor,
plannerObservationEmitDedup: new Map(),
plannerEscalationEmitDedup: new Set(),
});
const result = await engine.stopOverseerTask(task.id);
expect(updateTask).toHaveBeenCalledWith(task.id, {
plannerOversightLevel: "off",
sessionAdvisorEnabled: false,
});
expect(result.task?.sessionAdvisorEnabled).toBe(false);
expect(clear).toHaveBeenCalledWith(task.id);
expect(cursor.has(task.id)).toBe(false);
expect(resolveTaskSessionAdvisorEnabled(task, settings, workflowAdvisorEnabled)).toMatchObject({
enabled: false,
source: "task",
});
});
});

View File

@@ -1554,6 +1554,12 @@ export class ProjectEngine {
* recovery-controller ring buffers for this task (mirrors the poll's * recovery-controller ring buffers for this task (mirrors the poll's
* leave-in-flight cleanup). This is a user action; it never mutates task * leave-in-flight cleanup). This is a user action; it never mutates task
* lifecycle/column and never performs a merge/PR/destructive side effect. * lifecycle/column and never performs a merge/PR/destructive side effect.
*
* FNXC:PlannerOversight 2026-07-18-12:00:
* FN-8247 requires Stop to disable BOTH lifecycle oversight and the
* independently-gated session advisor. Persisting explicit false wins over
* project/workflow defaults, then immediate runtime teardown prevents a
* live advisor from spending or injecting after the operator stops it.
*/ */
async stopOverseerTask(taskId: string): Promise<{ applied: boolean; reason: string; task?: Task }> { async stopOverseerTask(taskId: string): Promise<{ applied: boolean; reason: string; task?: Task }> {
try { try {
@@ -1568,9 +1574,14 @@ export class ProjectEngine {
this.plannerRecoveryController?.recordManualAction(taskId, observation.stage, "manual_stop"); this.plannerRecoveryController?.recordManualAction(taskId, observation.stage, "manual_stop");
} }
const updatedTask = await store.updateTask(taskId, { plannerOversightLevel: "off" }); const updatedTask = await store.updateTask(taskId, {
plannerOversightLevel: "off",
sessionAdvisorEnabled: false,
});
this.plannerOverseer?.clear(taskId); this.plannerOverseer?.clear(taskId);
this.plannerRecoveryController?.clear(taskId); this.plannerRecoveryController?.clear(taskId);
this.sessionAdvisor?.clear(taskId);
this.sessionAdvisorLogCursor.delete(taskId);
// FN-7551: release the observation/escalation emission-dedup state too, // FN-7551: release the observation/escalation emission-dedup state too,
// so if oversight is later re-enabled for this task, the first new // so if oversight is later re-enabled for this task, the first new
// observation/escalation emits rather than staying suppressed by stale // observation/escalation emits rather than staying suppressed by stale