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:
7
.changeset/fn-8247-session-advisor-stop-and-icon.md
Normal file
7
.changeset/fn-8247-session-advisor-stop-and-icon.md
Normal 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).
|
||||
@@ -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: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-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-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. -->
|
||||
- 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.
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getErrorMessage,
|
||||
} from "@fusion/core";
|
||||
import { resolveEffectivePlannerOversightLevel } from "../../../core/src/workflow-settings-resolver";
|
||||
import { resolveTaskSessionAdvisorEnabled } from "../../../core/src/session-advisor";
|
||||
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
|
||||
import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert";
|
||||
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`
|
||||
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>>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
if (modalWorkflowOversightEffectiveCache.has(key)) {
|
||||
return modalWorkflowOversightEffectiveCache.get(key);
|
||||
return modalWorkflowOversightEffectiveCache.get(key) ?? { level: undefined, sessionAdvisorEnabled: false };
|
||||
}
|
||||
let inflight = modalWorkflowOversightInflight.get(key);
|
||||
if (!inflight) {
|
||||
inflight = fetchWorkflowSettingValues(workflowId, projectId)
|
||||
.then((payload) => {
|
||||
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(() => {
|
||||
modalWorkflowOversightEffectiveCache.set(key, undefined);
|
||||
modalWorkflowOversightEffectiveCache.set(key, { level: undefined, sessionAdvisorEnabled: false });
|
||||
})
|
||||
.finally(() => {
|
||||
modalWorkflowOversightInflight.delete(key);
|
||||
@@ -476,7 +485,7 @@ async function loadModalWorkflowOversightEffectiveLevel(workflowId: string, proj
|
||||
modalWorkflowOversightInflight.set(key, inflight);
|
||||
}
|
||||
await inflight;
|
||||
return modalWorkflowOversightEffectiveCache.get(key);
|
||||
return modalWorkflowOversightEffectiveCache.get(key) ?? { level: undefined, sessionAdvisorEnabled: false };
|
||||
}
|
||||
|
||||
const OVERSIGHT_LEVEL_LABEL: Record<PlannerOversightLevel, string> = {
|
||||
@@ -957,22 +966,26 @@ export function TaskDetailContent({
|
||||
fetch above); a known per-task override renders synchronously regardless.
|
||||
*/
|
||||
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(() => {
|
||||
if (!workflowIdForOversight) {
|
||||
setWorkflowOversightState({ level: undefined, resolved: true });
|
||||
setWorkflowOversightState({ level: undefined, sessionAdvisorEnabled: false, resolved: true });
|
||||
return;
|
||||
}
|
||||
const workflowId = workflowIdForOversight;
|
||||
const key = getModalWorkflowOversightCacheKey(workflowId, projectId);
|
||||
if (modalWorkflowOversightEffectiveCache.has(key)) {
|
||||
setWorkflowOversightState({ level: modalWorkflowOversightEffectiveCache.get(key), resolved: true });
|
||||
setWorkflowOversightState({ ...modalWorkflowOversightEffectiveCache.get(key)!, resolved: true });
|
||||
return;
|
||||
}
|
||||
setWorkflowOversightState({ level: undefined, resolved: false });
|
||||
setWorkflowOversightState({ level: undefined, sessionAdvisorEnabled: false, resolved: false });
|
||||
let cancelled = false;
|
||||
void loadModalWorkflowOversightEffectiveLevel(workflowId, projectId).then((level) => {
|
||||
if (!cancelled) setWorkflowOversightState({ level, resolved: true });
|
||||
void loadModalWorkflowOversightEffectiveLevel(workflowId, projectId).then((settings) => {
|
||||
if (!cancelled) setWorkflowOversightState({ ...settings, resolved: true });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -2071,29 +2084,44 @@ export function TaskDetailContent({
|
||||
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-14-18:11:
|
||||
Per-task session advisor (LLM overseer agent). Unset inherits project
|
||||
sessionAdvisorEnabledByDefault; explicit boolean forces on/off. Toggle writes
|
||||
an override when it differs from the project default and clears to null when
|
||||
it matches (same inheritance model as Quick Add eye).
|
||||
Per-task session advisor (LLM overseer agent). Unset inherits project and
|
||||
workflow settings; explicit boolean forces on/off. Toggle writes an override
|
||||
when it differs from the full inherited state and clears to null only when it
|
||||
matches that same shared resolver result.
|
||||
*/
|
||||
const projectSessionAdvisorDefault = settings?.sessionAdvisorEnabledByDefault === true;
|
||||
const hasSessionAdvisorOverride = typeof workingTask.sessionAdvisorEnabled === "boolean";
|
||||
const effectiveSessionAdvisorEnabled = hasSessionAdvisorOverride
|
||||
? workingTask.sessionAdvisorEnabled === true
|
||||
: projectSessionAdvisorDefault;
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-18-12:00:
|
||||
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 () => {
|
||||
setIsSavingSessionAdvisor(true);
|
||||
try {
|
||||
const nextEnabled = !effectiveSessionAdvisorEnabled;
|
||||
const nextValue: boolean | null =
|
||||
nextEnabled === projectSessionAdvisorDefault ? null : nextEnabled;
|
||||
nextEnabled === inheritedSessionAdvisorEnabled ? null : nextEnabled;
|
||||
const updatedTask = await updateTask(task.id, { sessionAdvisorEnabled: nextValue }, projectId);
|
||||
onTaskUpdated?.(updatedTask);
|
||||
addToast(
|
||||
nextValue === null
|
||||
? t("taskDetail.sessionAdvisor.reset", "Session advisor follows project default ({{default}})", {
|
||||
default: projectSessionAdvisorDefault
|
||||
? t("taskDetail.sessionAdvisor.reset", "Session advisor follows inherited defaults ({{default}})", {
|
||||
default: inheritedSessionAdvisorEnabled
|
||||
? t("tasks.sessionAdvisorDefaultOn", "on")
|
||||
: t("tasks.sessionAdvisorDefaultOff", "off"),
|
||||
})
|
||||
@@ -2117,7 +2145,7 @@ export function TaskDetailContent({
|
||||
}
|
||||
}, [
|
||||
effectiveSessionAdvisorEnabled,
|
||||
projectSessionAdvisorDefault,
|
||||
inheritedSessionAdvisorEnabled,
|
||||
task.id,
|
||||
projectId,
|
||||
onTaskUpdated,
|
||||
@@ -2190,11 +2218,15 @@ export function TaskDetailContent({
|
||||
FN-7517 stop-oversight control. Disables active oversight for this task
|
||||
(per-task override -> "off") — a lightweight `confirm(...)` guards it since
|
||||
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 shouldStop = await confirm({
|
||||
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;
|
||||
|
||||
@@ -2204,7 +2236,7 @@ export function TaskDetailContent({
|
||||
if (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) {
|
||||
addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
@@ -4347,7 +4379,7 @@ export function TaskDetailContent({
|
||||
)
|
||||
: t(
|
||||
"taskDetail.sessionAdvisor.inheritTitle",
|
||||
"Session advisor {{state}} (follows project default)",
|
||||
"Session advisor {{state}} (follows inherited defaults)",
|
||||
{
|
||||
state: effectiveSessionAdvisorEnabled
|
||||
? t("tasks.sessionAdvisorDefaultOn", "on")
|
||||
@@ -4366,7 +4398,7 @@ export function TaskDetailContent({
|
||||
})}
|
||||
{hasSessionAdvisorOverride
|
||||
? ""
|
||||
: t("taskDetail.sessionAdvisor.inheritSuffix", " (project)")}
|
||||
: t("taskDetail.sessionAdvisor.inheritSuffix", " (inherited)")}
|
||||
</span>
|
||||
</button>
|
||||
{!oversightIsOff && (
|
||||
|
||||
@@ -81,6 +81,110 @@ describe("TaskDetailModal oversight controls", () => {
|
||||
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 () => {
|
||||
const api = await import("../../api");
|
||||
const mockUpdate = vi.mocked(api.updateTask);
|
||||
|
||||
@@ -72,6 +72,68 @@ describe("TaskDetailModal oversight controls — mobile overflow menu", () => {
|
||||
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 () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1554,6 +1554,12 @@ export class ProjectEngine {
|
||||
* recovery-controller ring buffers for this task (mirrors the poll's
|
||||
* leave-in-flight cleanup). This is a user action; it never mutates task
|
||||
* 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 }> {
|
||||
try {
|
||||
@@ -1568,9 +1574,14 @@ export class ProjectEngine {
|
||||
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.plannerRecoveryController?.clear(taskId);
|
||||
this.sessionAdvisor?.clear(taskId);
|
||||
this.sessionAdvisorLogCursor.delete(taskId);
|
||||
// FN-7551: release the observation/escalation emission-dedup state too,
|
||||
// so if oversight is later re-enabled for this task, the first new
|
||||
// observation/escalation emits rather than staying suppressed by stale
|
||||
|
||||
Reference in New Issue
Block a user