diff --git a/.changeset/fn-7600-oversight-nudge-detail-snapshot.md b/.changeset/fn-7600-oversight-nudge-detail-snapshot.md new file mode 100644 index 0000000000..2690189613 --- /dev/null +++ b/.changeset/fn-7600-oversight-nudge-detail-snapshot.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the task-detail Nudge control staying disabled when the overseer is actively watching. +category: fix +dev: GET /api/tasks/:id now attaches the transient plannerOverseerState snapshot (mirrors the list route); TaskDetailModal reads the snapshot from workingTask so detail refetches no longer drop it. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 010c127e3b..8f2b73d279 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -3183,7 +3183,19 @@ export function TaskDetailContent({ copy-only, selected via the already-computed `overseerHumanControlSuppressed` / `overseerActive` booleans below. */ - const overseerSnapshot = task.plannerOverseerState ?? null; + /* + FNXC:PlannerOversight 2026-07-05-00:00: + FN-7600: this used to read `task.plannerOverseerState` — the transient + snapshot enrichment from `GET /api/tasks` (list) — but the modal is + frequently opened via `fetchTaskDetail` (dependency chips, Documents view, + logs, or the post-open detail refetch) where the parent `task` prop never + carries the snapshot, so `overseerActive`/`canNudgeOverseer` were almost + always false and Nudge showed the periodic-observation copy even while the + overseer was actively watching. `GET /api/tasks/:id` now attaches the same + snapshot (mirrors the list route), so read it from `workingTask` — the + full-detail-backed merged object — instead of the raw prop. + */ + const overseerSnapshot = workingTask.plannerOverseerState ?? null; const overseerActive = Boolean(overseerSnapshot); const isDoneOrArchivedColumn = task.column === "done" || task.column === "archived"; const isOverseerHumanReviewTerminal = task.column === "in-review" && !effectiveAutoMerge; diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx index 768ed722a7..18bf22f1ad 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx @@ -407,6 +407,137 @@ describe("TaskDetailModal oversight controls", () => { }); }); +/* +FNXC:PlannerOversight 2026-07-05-00:00: +FN-7600 regression coverage: the modal previously read `overseerSnapshot` from +the raw `task` prop, which loses the snapshot whenever the modal is opened via +`fetchTaskDetail` (dependency chips, Documents view, logs) because those call +sites pass a slim `Task` (no `prompt` key) that never carries +`plannerOverseerState` — only the full-detail fetch response does. These +tests reproduce that exact path: a slim task prop with NO snapshot, plus a +mocked `fetchTaskDetail` resolving a full TaskDetail WITH an active snapshot, +and assert Nudge enables (helper absent) once the fetched detail lands — at +both the desktop inline site and the mobile overflow-menu site. +*/ +describe("TaskDetailModal oversight controls — snapshot delivered via fetched full detail (FN-7600)", () => { + const originalInnerWidth = window.innerWidth; + + beforeEach(async () => { + vi.clearAllMocks(); + mockConfirm.mockResolvedValue(true); + const api = await import("../../api"); + vi.mocked(api.fetchBoardWorkflows).mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }); + vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, defaults: {} }); + vi.mocked(api.nudgeOverseer).mockResolvedValue({ applied: false, reason: "oversight-off" }); + vi.mocked(api.stopOverseer).mockResolvedValue({ applied: true, reason: "stopped" }); + vi.mocked(api.explainOverseer).mockResolvedValue({ snapshot: null }); + }); + + afterEach(() => { + Object.defineProperty(window, "innerWidth", { value: originalInnerWidth, configurable: true }); + }); + + function makeSlimTaskWithoutSnapshot(overrides: Record = {}) { + // Omit `prompt`/`log`/`steps` so the modal treats this as a slim `Task` + // (not a `TaskDetail`) and triggers the `fetchTaskDetail` fetch-on-open + // path instead of using the prop directly as `fullDetail`. + const { prompt: _prompt, log: _log, steps: _steps, plannerOverseerState: _snap, ...task } = makeTask({ + id: "FN-220", + column: "in-progress", + plannerOversightLevel: "autonomous", + ...overrides, + }); + return task; + } + + it("desktop: enables Nudge and hides the disabled-reason helper once the fetched full detail carries an active snapshot", async () => { + const api = await import("../../api"); + vi.mocked(api.fetchTaskDetail).mockResolvedValueOnce(makeTask({ + id: "FN-220", + column: "in-progress", + plannerOversightLevel: "autonomous", + plannerOverseerState: activeSnapshot, + })); + + render( + , + ); + + const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); + await waitFor(() => { + expect(nudgeBtn).not.toBeDisabled(); + }); + expect(screen.queryByTestId("detail-overseer-nudge-disabled-reason")).not.toBeInTheDocument(); + }); + + it("desktop: still shows the periodic-observation copy while the fetched full detail carries no snapshot", async () => { + const api = await import("../../api"); + vi.mocked(api.fetchTaskDetail).mockResolvedValueOnce(makeTask({ + id: "FN-221", + column: "in-progress", + plannerOversightLevel: "autonomous", + })); + + render( + , + ); + + const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); + expect(nudgeBtn).toBeDisabled(); + const reason = await screen.findByTestId("detail-overseer-nudge-disabled-reason"); + expect(reason).toHaveTextContent("Nudge becomes available once the overseer is observing this task's current stage"); + }); + + it("mobile: enables Nudge and hides the disabled-reason helper behind the overflow menu once the fetched full detail carries an active snapshot", async () => { + Object.defineProperty(window, "innerWidth", { value: 375, configurable: true }); + + const api = await import("../../api"); + vi.mocked(api.fetchTaskDetail).mockResolvedValueOnce(makeTask({ + id: "FN-222", + column: "in-progress", + plannerOversightLevel: "autonomous", + plannerOverseerState: activeSnapshot, + })); + + render( + , + ); + + const trigger = await screen.findByTestId("detail-oversight-menu-trigger"); + fireEvent.click(trigger); + + const nudgeBtn = await screen.findByTestId("detail-overseer-nudge"); + await waitFor(() => { + expect(nudgeBtn).not.toBeDisabled(); + }); + expect(screen.queryByTestId("detail-overseer-nudge-disabled-reason")).not.toBeInTheDocument(); + }); +}); + /* * FNXC:PlannerOversight 2026-07-04-20:30 (FN-7558): * FN-7521's original mobile suite asserted the oversight quick-controls diff --git a/packages/dashboard/src/routes/__tests__/tasks-planner-overseer-state.test.ts b/packages/dashboard/src/routes/__tests__/tasks-planner-overseer-state.test.ts index f401b54d33..468a590fca 100644 --- a/packages/dashboard/src/routes/__tests__/tasks-planner-overseer-state.test.ts +++ b/packages/dashboard/src/routes/__tests__/tasks-planner-overseer-state.test.ts @@ -112,3 +112,98 @@ describe("GET /tasks — plannerOverseerState enrichment", () => { expect(found && "plannerOverseerState" in found).toBe(false); }); }); + +// FN-7600: GET /tasks/:id (detail route) must attach the same transient +// `plannerOverseerState` snapshot as the list route above — the Task Detail +// modal's Overseer/Nudge controls read the snapshot from the full-detail +// payload, not the list payload, so the detail route previously never +// carried it and Nudge always showed the periodic-observation disabled copy. +describe("GET /tasks/:id — plannerOverseerState enrichment", () => { + let store: TaskStore; + let rootDir: string; + let globalDir: string; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "planner-overseer-state-detail-root-")); + globalDir = mkdtempSync(join(tmpdir(), "planner-overseer-state-detail-global-")); + store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + await store.init(); + }); + + afterEach(() => { + store.close(); + rmSync(rootDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); + }); + + function buildApp(engine: Partial | undefined): express.Express { + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store, engine ? { engine: engine as unknown as ProjectEngine } : undefined)); + return app; + } + + it("attaches plannerOverseerState when the engine snapshot accessor returns a snapshot", async () => { + const task = await store.createTask({ description: "watched task" }); + + const snapshot = { + state: "watching" as const, + oversightLevel: "autonomous" as const, + watchedStage: "executor", + signal: "progressing", + attemptCount: 0, + attemptLimit: 3, + pendingConfirmation: false, + observedAt: 1700000000000, + }; + + const engineStub: Partial = { + getTaskStore: () => store, + getPlannerOverseerRuntimeSnapshot: (taskId: string) => (taskId === task.id ? snapshot : null), + }; + + const app = buildApp(engineStub); + const res = await REQUEST(app, "GET", `/api/tasks/${task.id}`); + expect(res.status).toBe(200); + expect((res.body as Record).plannerOverseerState).toEqual(snapshot); + }); + + it("omits plannerOverseerState entirely (no key) when the accessor returns null", async () => { + const task = await store.createTask({ description: "idle task" }); + + const engineStub: Partial = { + getTaskStore: () => store, + getPlannerOverseerRuntimeSnapshot: () => null, + }; + + const app = buildApp(engineStub); + const res = await REQUEST(app, "GET", `/api/tasks/${task.id}`); + expect(res.status).toBe(200); + expect("plannerOverseerState" in (res.body as Record)).toBe(false); + }); + + it("returns 200 with the un-enriched task when the accessor throws (detail load never fails)", async () => { + const task = await store.createTask({ description: "throwing task" }); + + const engineStub: Partial = { + getTaskStore: () => store, + getPlannerOverseerRuntimeSnapshot: () => { + throw new Error("boom"); + }, + }; + + const app = buildApp(engineStub); + const res = await REQUEST(app, "GET", `/api/tasks/${task.id}`); + expect(res.status).toBe(200); + expect("plannerOverseerState" in (res.body as Record)).toBe(false); + }); + + it("returns 200 with the un-enriched task when no engine is present at all", async () => { + const task = await store.createTask({ description: "no engine task" }); + + const app = buildApp(undefined); + const res = await REQUEST(app, "GET", `/api/tasks/${task.id}`); + expect(res.status).toBe(200); + expect("plannerOverseerState" in (res.body as Record)).toBe(false); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 6884cd5130..e4b93c4206 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -2933,11 +2933,32 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork // Get single task with prompt content router.get("/tasks/:id", async (req, res) => { try { - const { store: scopedStore } = await getProjectContext(req); + const { store: scopedStore, engine } = await getProjectContext(req); const task = await scopedStore.getTask(req.params.id, { activityLogLimit: TASK_DETAIL_ACTIVITY_LOG_LIMIT, }); - res.json(trimTaskDetailActivityLog(task)); + let enrichedTask = task; + // FNXC:PlannerOversight 2026-07-05-00:00: + // FN-7600: the Task Detail modal's Overseer/Nudge controls read + // `plannerOverseerState` from the merged full-detail object, but this + // detail route previously never attached it (only the list route did, + // per FN-7531 above) — so opening the modal via fetchTaskDetail + // (dependency chips, Documents view, logs, or the post-open detail + // refetch) always lost the snapshot and the Nudge button showed the + // periodic-observation disabled copy even when the overseer was + // actively watching. Mirror the list-route contract exactly: best- + // effort, never throws, and omits the key (not `null`) when the + // accessor returns no active observation. + try { + const plannerOverseerState = engine?.getPlannerOverseerRuntimeSnapshot(task.id); + if (plannerOverseerState) { + enrichedTask = { ...task, plannerOverseerState }; + } + } catch { + // Planner-overseer-state enrichment is best-effort and must never + // fail the task-detail load — fall through with the un-enriched task. + } + res.json(trimTaskDetailActivityLog(enrichedTask)); } catch (err: unknown) { if (err instanceof ApiError) { throw err;