From c16cc9e08a431e4080f50a43925e1a08b42f8287 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 14:26:46 -0700 Subject: [PATCH] FN-7515: expose planner oversight configuration Expose planner oversight as workflow-native configuration across task and workflow surfaces. - Add a shared TaskForm selector for per-task planner oversight overrides with inherit semantics. - Thread plannerOversightLevel through new task creation, task detail edits, and legacy dashboard API payloads. - Add a Workflow Editor Values display group and documentation for configuring workflow defaults. - Cover create, edit, form, and workflow settings behavior with dashboard tests. Files changed: .changeset/fn-7515-planner-oversight-config-exposure.md | 7 ++ docs/dashboard-guide.md | 1 + docs/settings-reference.md | 2 +- packages/dashboard/app/api/legacy.ts | 3 + packages/dashboard/app/components/NewTaskModal.tsx | 12 +++- packages/dashboard/app/components/TaskDetailModal.tsx | 11 +++- packages/dashboard/app/components/TaskForm.tsx | 34 ++++++++++ packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx | 31 +++++++++ packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx | 76 ++++++++++++++++++++++ packages/dashboard/app/components/__tests__/TaskForm.test.tsx | 28 ++++++++ packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx | 34 ++++++++++ packages/dashboard/app/components/workflow-setting-display.ts | 17 ++++- 12 files changed, 251 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-7515 Fusion-Task-Lineage: aded67c0-835c-4046-b691-04dc2bb2d314 Co-authored-by: Fusion (runfusion.ai) --- ...-7515-planner-oversight-config-exposure.md | 7 ++ docs/dashboard-guide.md | 1 + docs/settings-reference.md | 2 +- packages/dashboard/app/api/legacy.ts | 3 + .../dashboard/app/components/NewTaskModal.tsx | 12 ++- .../app/components/TaskDetailModal.tsx | 11 ++- .../dashboard/app/components/TaskForm.tsx | 34 +++++++++ .../__tests__/NewTaskModal.test.tsx | 31 ++++++++ ...l.inline-editing-and-integrations.test.tsx | 76 +++++++++++++++++++ .../components/__tests__/TaskForm.test.tsx | 28 +++++++ .../__tests__/WorkflowSettingsPanel.test.tsx | 34 +++++++++ .../components/workflow-setting-display.ts | 17 ++++- 12 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 .changeset/fn-7515-planner-oversight-config-exposure.md diff --git a/.changeset/fn-7515-planner-oversight-config-exposure.md b/.changeset/fn-7515-planner-oversight-config-exposure.md new file mode 100644 index 0000000000..8fa1d7229b --- /dev/null +++ b/.changeset/fn-7515-planner-oversight-config-exposure.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Configure planner oversight level per task and per project in the workflow editor and task create/detail. +category: feature +dev: Per-task `plannerOversightLevel` override exposed via TaskForm (Inherit/off/observe/steer/autonomous), threaded through createTask/updateTask; workflow-editor Values tab gets a first-class display entry. Workflow-native setting; not a project setting. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7c7ff6347c..306c98afde 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -431,6 +431,7 @@ Rules: - `auto-new` creates a branch after task creation using `fusion/{task-id}-{short-name}` (for example `fusion/fn-5671-branch-strategy-dropdown`). - `Merge target / base branch` stays optional for all modes and uses the same branch-dropdown + `Custom…` fallback behavior as Planning Mode. - In **More options → Model Configuration**, **Auto-merge** is a per-task override with three states: **Default** (follow project setting), **Enabled**, or **Disabled**. +- In **More options → Model Configuration**, **Planner oversight** is a per-task override of the workflow-native `plannerOversightLevel` setting (FN-7508): **Inherit from workflow** (default) plus **Off**, **Observe**, **Steer**, and **Autonomous recovery**. This selector appears in both the New Task dialog and the Task Detail edit form (same shared control). Selecting **Inherit from workflow** clears the per-task override (sent as `null` on edit, omitted on create) so the task falls back to the effective `plannerOversightLevel` configured on its workflow — set project/global defaults for this in the **Workflow Editor → Values** tab, not in Project Settings; it is workflow-native, not a project setting. The dialog also exposes AI handoffs that quick-add no longer shows: **Plan** opens Planning Mode with the current description, and **Subtask** opens Subtask Breakdown with the current description when **Settings → Experimental Features → Subtask Breakdown** is enabled. The Subtask handoff is hidden by default; visible handoff buttons remain disabled until the description has content, matching the quick-add row behavior for Subtask. **Execution mode** and optional workflow-step selection are available in the New Task dialog as well as quick entry, so users can choose Fast or standard execution and opt into workflow-specific creation-time steps before creating a task from either surface. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 2e1b9ff8ed..b504685296 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -359,7 +359,7 @@ The built-in workflows also declare triage/spec policy settings that were **not* | `autoApproveSpec` | `false` | Legacy compatibility setting. Workflow Plan Review now owns optional pre-execution AI plan approval. | | `planReviewMaxRevisions` | unset | Workflow-native Plan Review/spec revision cap. Unset/empty means unbounded automatic replans; a non-negative integer caps attempts; `0` disables automatic Plan Review revision. | | `codeReviewMaxRevisions` | unset | Workflow-native Code Review remediation cap. Unset/empty means unbounded automatic code-fix passes; a non-negative integer caps attempts; `0` disables automatic Code Review remediation. | -| `plannerOversightLevel` | `autonomous` | Workflow-native planner oversight mode. `off` disables oversight; `observe` watches only; `steer` injects guidance or suggests revisions; `autonomous` enables bounded retry and targeted-fix recovery — but merge/PR progression and any destructive or external-service side effect ALWAYS require an explicit, recorded human confirmation before they run, even at `autonomous` (FN-7513's confirmation gate; see `docs/architecture.md` → "Planner overseer confirmation gate"). Tasks may set a nullable `Task.plannerOversightLevel` override (same four values) that wins over this workflow value when present; `null`/unset means "inherit the workflow value". `resolveEffectivePlannerOversightLevel` in `@fusion/core` computes the effective level (task override → workflow effective → `autonomous`). Dashboard UI/API threading for the per-task override and engine read-site behavior are follow-up work (FN-7515, FN-7510+). | +| `plannerOversightLevel` | `autonomous` | Workflow-native planner oversight mode. `off` disables oversight; `observe` watches only; `steer` injects guidance or suggests revisions; `autonomous` enables bounded retry and targeted-fix recovery — but merge/PR progression and any destructive or external-service side effect ALWAYS require an explicit, recorded human confirmation before they run, even at `autonomous` (FN-7513's confirmation gate; see `docs/architecture.md` → "Planner overseer confirmation gate"). Tasks may set a nullable `Task.plannerOversightLevel` override (same four values) that wins over this workflow value when present; `null`/unset means "inherit the workflow value". `resolveEffectivePlannerOversightLevel` in `@fusion/core` computes the effective level (task override → workflow effective → `autonomous`). The per-task override is exposed in the dashboard as a "Planner oversight" selector (Inherit from workflow / Off / Observe / Steer / Autonomous recovery) in both the New Task dialog and Task Detail edit form, threaded through `createTask`/`updateTask` (FN-7515); the project/global default is set via the **Workflow Editor → Values** tab on the default workflow's `plannerOversightLevel` value, not in Project Settings. Engine read-site behavior beyond the FN-7513 confirmation gate remains follow-up work (FN-7510+). | When `triageProactiveSubtaskSplittingEnabled` is `true` (the default), triage may proactively replace a large task with 2-5 child tasks when the size, step-count, package breadth, file-scope, or remediation-batch signals justify the coordination overhead. When it is `false`, those automatic oversized-task signals are advisory only for writing a realistic single-task spec; triage must not split solely because the task is large. The per-task `breakIntoSubtasks: true` flag is separate and remains mandatory: if a user explicitly asks for subtask breakdown, triage still evaluates and creates child tasks when the work is meaningfully decomposable. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index fc8941a5fb..bcfe99f6f1 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -420,6 +420,7 @@ export async function createTask( planningModelProvider, planningModelId, thinkingLevel, + plannerOversightLevel, summarize, reviewLevel, executionMode, @@ -457,6 +458,7 @@ export async function createTask( planningModelProvider, planningModelId, thinkingLevel, + plannerOversightLevel, summarize, reviewLevel, executionMode, @@ -523,6 +525,7 @@ export function updateTask( planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; + plannerOversightLevel?: "off" | "observe" | "steer" | "autonomous" | null; reviewLevel?: number | null; executionMode?: "standard" | "fast" | null; noCommitsExpected?: boolean; diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index c85e63dd8d..ab0251100f 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -569,6 +569,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, const [validatorModel, setValidatorModel] = useState(""); const [planningModel, setPlanningModel] = useState(""); const [thinkingLevel, setThinkingLevel] = useState(""); + // FNXC:PlannerOversight 2026-07-04-00:00: Per-task override of the workflow-native plannerOversightLevel setting (FN-7508). "" means inherit from workflow. + const [plannerOversightLevel, setPlannerOversightLevel] = useState(""); const [selectedPresetId, setSelectedPresetId] = useState(""); const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default"); const [hasDirtyState, setHasDirtyState] = useState(false); @@ -713,6 +715,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, validatorModel !== "" || planningModel !== "" || thinkingLevel !== "" || + plannerOversightLevel !== "" || selectedAgentId !== null || reviewLevel !== undefined || autoMerge !== undefined || @@ -725,7 +728,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, githubTrackingEnabled || githubRepoOverrideTrimmed !== ""; setHasDirtyState(isDirty); - }, [description, dependencies, pendingImages, selectedWorkflowId, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, executorModel, validatorModel, planningModel, thinkingLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]); + }, [description, dependencies, pendingImages, selectedWorkflowId, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, executorModel, validatorModel, planningModel, thinkingLevel, plannerOversightLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]); const resetForm = useCallback(() => { // Clean up object URLs @@ -738,6 +741,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, setValidatorModel(""); setPlanningModel(""); setThinkingLevel(""); + setPlannerOversightLevel(""); setSelectedPresetId(""); setPresetMode("default"); setSelectedWorkflowId(undefined); @@ -808,6 +812,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, planningModelProvider: planningModel && planningSlashIdx !== -1 ? planningModel.slice(0, planningSlashIdx) : undefined, planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined, thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" | "xhigh" : undefined, + // FNXC:PlannerOversight 2026-07-04-00:00: omit when "Inherit from workflow" ("") is selected so the task falls back to the workflow's effective plannerOversightLevel. + ...(plannerOversightLevel !== "" ? { plannerOversightLevel: plannerOversightLevel as "off" | "observe" | "steer" | "autonomous" } : {}), reviewLevel, ...(autoMerge !== undefined ? { autoMerge } : {}), priority, @@ -852,7 +858,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, resetForm(); addToast(t("newTaskModal.taskCreated", "Created {{taskId}}", { taskId: task.id }), "success"); onClose(); - }, [executorModel, validatorModel, planningModel, thinkingLevel, dependencies, selectedWorkflowId, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, selectedAgentId, presetMode, selectedPresetId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, onCreateTask, pendingImages, resetForm, addToast, t, onClose, projectId]); + }, [executorModel, validatorModel, planningModel, thinkingLevel, plannerOversightLevel, dependencies, selectedWorkflowId, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, selectedAgentId, presetMode, selectedPresetId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, onCreateTask, pendingImages, resetForm, addToast, t, onClose, projectId]); const handleSubmit = useCallback(async () => { const trimmedDesc = description.trim(); @@ -1176,6 +1182,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, planningModel={planningModel} onPlanningModelChange={setPlanningModel} thinkingLevel={thinkingLevel} + plannerOversightLevel={plannerOversightLevel} + onPlannerOversightLevelChange={setPlannerOversightLevel} onThinkingLevelChange={setThinkingLevel} reviewLevel={reviewLevel} onReviewLevelChange={setReviewLevel} diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 9aa3f55928..43a58f7ceb 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -919,6 +919,8 @@ export function TaskDetailContent({ const [editValidatorModel, setEditValidatorModel] = useState(""); const [editPlanningModel, setEditPlanningModel] = useState(""); const [editThinkingLevel, setEditThinkingLevel] = useState(""); + // FNXC:PlannerOversight 2026-07-04-00:00: Per-task override of the workflow-native plannerOversightLevel setting (FN-7508). "" means "inherit from workflow" (clear-to-default). + const [editPlannerOversightLevel, setEditPlannerOversightLevel] = useState(""); const [editPresetMode, setEditPresetMode] = useState<"default" | "preset" | "custom">("default"); const [editReviewLevel, setEditReviewLevel] = useState(undefined); const [editPriority, setEditPriority] = useState(DEFAULT_TASK_PRIORITY); @@ -1546,6 +1548,7 @@ export function TaskDetailContent({ setEditValidatorModel(valModel); setEditPlanningModel(planModel); setEditThinkingLevel(task.thinkingLevel ?? ""); + setEditPlannerOversightLevel(task.plannerOversightLevel ?? ""); setEditNodeId(task.nodeId); setEditPresetMode(execModel || valModel || planModel ? "custom" : "default"); setEditSelectedPresetId(""); @@ -1624,6 +1627,9 @@ export function TaskDetailContent({ const currentThinkingLevel = task.thinkingLevel ?? ""; if (editThinkingLevel !== currentThinkingLevel) updates.thinkingLevel = editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high" | "xhigh") : null; + // FNXC:PlannerOversight 2026-07-04-00:00: "" (Inherit from workflow) clears the per-task override to null so the workflow's effective plannerOversightLevel applies. + const currentPlannerOversightLevel = task.plannerOversightLevel ?? ""; + if (editPlannerOversightLevel !== currentPlannerOversightLevel) updates.plannerOversightLevel = editPlannerOversightLevel !== "" ? (editPlannerOversightLevel as "off" | "observe" | "steer" | "autonomous") : null; if ((task.nodeId ?? undefined) !== editNodeId) updates.nodeId = editNodeId ?? null; if (editReviewLevel !== task.reviewLevel) updates.reviewLevel = editReviewLevel; if (editPriority !== normalizeTaskPriorityValue(task.priority)) updates.priority = editPriority; @@ -1664,7 +1670,7 @@ export function TaskDetailContent({ } return { updates, error: null as string | null }; - }, [editBaseBranch, editBranch, editDependencies, editDescription, editExecutionMode, editExecutorModel, editNodeId, editPlanningModel, editPriority, editReviewLevel, editSelectedWorkflowSteps, editSourceIssueExternalId, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueUrl, editThinkingLevel, editTitle, editValidatorModel, task]); + }, [editBaseBranch, editBranch, editDependencies, editDescription, editExecutionMode, editExecutorModel, editNodeId, editPlanningModel, editPriority, editReviewLevel, editSelectedWorkflowSteps, editSourceIssueExternalId, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueUrl, editThinkingLevel, editPlannerOversightLevel, editTitle, editValidatorModel, task]); const persistEditChanges = useCallback(async (includeDescription: boolean) => { const { updates, error } = buildEditUpdates(includeDescription); @@ -1768,6 +1774,7 @@ export function TaskDetailContent({ editValidatorModel, editPlanningModel, editThinkingLevel, + editPlannerOversightLevel, editNodeId, editReviewLevel, editPriority, @@ -3266,6 +3273,8 @@ export function TaskDetailContent({ onPlanningModelChange={setEditPlanningModel} thinkingLevel={editThinkingLevel} onThinkingLevelChange={setEditThinkingLevel} + plannerOversightLevel={editPlannerOversightLevel} + onPlannerOversightLevelChange={setEditPlannerOversightLevel} presetMode={editPresetMode} onPresetModeChange={setEditPresetMode} selectedPresetId={editSelectedPresetId} diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx index 992d3f1ea3..31c613a701 100644 --- a/packages/dashboard/app/components/TaskForm.tsx +++ b/packages/dashboard/app/components/TaskForm.tsx @@ -92,6 +92,17 @@ export interface TaskFormProps { onPlanningModelChange?: (value: string) => void; thinkingLevel?: string; onThinkingLevelChange?: (value: string) => void; + /* + * FNXC:PlannerOversight 2026-07-04-00:00: + * Per-task override selector for the workflow-native `plannerOversightLevel` setting + * (FN-7508). Empty string ("") means "inherit from workflow" — the task uses the + * default workflow's effective `plannerOversightLevel` value. The four non-empty + * values (off/observe/steer/autonomous) mirror `BUILTIN_OVERSIGHT_SETTINGS` verbatim. + * This control is configuration only; runtime oversight controls (quick change, + * manual nudge, stop, explain-current-action) are FN-7517, not this task. + */ + plannerOversightLevel?: string; + onPlannerOversightLevelChange?: (value: string) => void; presetMode: "default" | "preset" | "custom"; onPresetModeChange: (mode: "default" | "preset" | "custom") => void; selectedPresetId: string; @@ -196,6 +207,8 @@ export function TaskForm({ onPlanningModelChange, thinkingLevel, onThinkingLevelChange, + plannerOversightLevel, + onPlannerOversightLevelChange, presetMode, onPresetModeChange, selectedPresetId, @@ -244,6 +257,7 @@ export function TaskForm({ validatorModel !== "" || (planningModel || "") !== "" || (thinkingLevel || "") !== "" || + (plannerOversightLevel || "") !== "" || reviewLevel !== undefined || autoMerge !== undefined || executionMode === "fast" || @@ -419,6 +433,7 @@ export function TaskForm({ validatorModel !== "" || (planningModel || "") !== "" || (thinkingLevel || "") !== "" || + (plannerOversightLevel || "") !== "" || reviewLevel !== undefined || autoMerge !== undefined || executionMode === "fast" || @@ -1574,6 +1589,25 @@ export function TaskForm({ )} + {onPlannerOversightLevelChange && ( +
+ {/* FNXC:PlannerOversight 2026-07-04-00:00: Per-task override for the workflow-native plannerOversightLevel setting (FN-7508). Empty value inherits the workflow's effective value; the four levels mirror BUILTIN_OVERSIGHT_SETTINGS verbatim. Configuration only — runtime controls are FN-7517. */} + + +
+ )} {onReviewLevelChange && (
diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index 823af8edb3..6b043fef58 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -640,6 +640,37 @@ describe("NewTaskModal", () => { expect(payload).not.toHaveProperty("executionMode"); }); + // FNXC:PlannerOversight 2026-07-04-00:00: leaving the selector on "Inherit from workflow" ("") must omit plannerOversightLevel from the create payload so the task falls back to the workflow's effective value. + it("omits plannerOversightLevel from the create payload when left on Inherit", async () => { + const { props } = renderNewTaskModal(); + + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Inherit oversight task" } }); + fireEvent.click(screen.getByRole("button", { name: "Create Task" })); + + await waitFor(() => { + expect(props.onCreateTask).toHaveBeenCalledTimes(1); + }); + const payload = vi.mocked(props.onCreateTask).mock.calls[0][0] as Record; + expect(payload).not.toHaveProperty("plannerOversightLevel"); + }); + + it("includes the selected plannerOversightLevel in the create payload", async () => { + const { props } = renderNewTaskModal(); + + fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); + const select = await screen.findByTestId("planner-oversight-level-select"); + fireEvent.change(select, { target: { value: "steer" } }); + + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Steer oversight task" } }); + fireEvent.click(screen.getByRole("button", { name: "Create Task" })); + + await waitFor(() => { + expect(props.onCreateTask).toHaveBeenCalledWith( + expect.objectContaining({ plannerOversightLevel: "steer" }), + ); + }); + }); + it("resets executionMode to standard after canceling and discarding changes", async () => { const { props, rerender } = renderNewTaskModal(); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx index 13add5729a..4ede1e7e02 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx @@ -963,6 +963,82 @@ describe("TaskDetailModal", () => { }); }); + // FNXC:PlannerOversight 2026-07-04-00:00: the edit path clear-to-default contract — selecting a level sends the value, returning to Inherit sends null, no change emits nothing. + it("emits plannerOversightLevel when changed from Inherit to a level", async () => { + const { updateTask, fetchModels } = await import("../../api"); + const mockUpdate = vi.mocked(updateTask); + mockUpdate.mockResolvedValue({ id: "FN-001" } as Task); + vi.mocked(fetchModels).mockResolvedValue({ + models: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }], + favoriteProviders: [], + favoriteModels: [], + }); + + const { container } = render( + , + ); + + // No change → no updateTask call. + fireEvent.click(container.querySelector(".modal-edit-btn")!); + fireEvent.click(screen.getByText("Save")); + await waitFor(() => { + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + // Selecting a level sends that value. + fireEvent.click(container.querySelector(".modal-edit-btn")!); + fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); + const select = await screen.findByTestId("planner-oversight-level-select"); + fireEvent.change(select, { target: { value: "observe" } }); + fireEvent.click(screen.getByText("Save")); + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith("FN-001", { plannerOversightLevel: "observe" }, undefined); + }); + }); + + it("emits plannerOversightLevel: null when changed from a level back to Inherit (clear-to-default)", async () => { + const { updateTask, fetchModels } = await import("../../api"); + const mockUpdate = vi.mocked(updateTask); + mockUpdate.mockResolvedValue({ id: "FN-001" } as Task); + vi.mocked(fetchModels).mockResolvedValue({ + models: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }], + favoriteProviders: [], + favoriteModels: [], + }); + + const { container } = render( + , + ); + + fireEvent.click(container.querySelector(".modal-edit-btn")!); + fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); + const select = await screen.findByTestId("planner-oversight-level-select"); + expect(select).toHaveValue("observe"); + fireEvent.change(select, { target: { value: "" } }); + fireEvent.click(screen.getByText("Save")); + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith("FN-001", { plannerOversightLevel: null }, undefined); + }); + }); + it("sends executionMode: \"fast\" when changed from standard to fast", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); diff --git a/packages/dashboard/app/components/__tests__/TaskForm.test.tsx b/packages/dashboard/app/components/__tests__/TaskForm.test.tsx index dbce77e296..ad1b7d78cd 100644 --- a/packages/dashboard/app/components/__tests__/TaskForm.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskForm.test.tsx @@ -160,6 +160,34 @@ describe("TaskForm", () => { expect(screen.getByRole("option", { name: /Very High/i })).toHaveAttribute("value", "xhigh"); }); + // FNXC:PlannerOversight 2026-07-04-00:00: per-task override selector must render Inherit + the four BUILTIN_OVERSIGHT_SETTINGS levels and fire onPlannerOversightLevelChange on selection. + it("renders the planner oversight selector with Inherit + four levels and fires onPlannerOversightLevelChange", async () => { + const onPlannerOversightLevelChange = vi.fn(); + renderTaskForm({ + plannerOversightLevel: "", + onPlannerOversightLevelChange, + }); + + fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); + + const select = await screen.findByTestId("planner-oversight-level-select"); + expect(select).toHaveValue(""); + expect(screen.getByRole("option", { name: /Inherit from workflow/i })).toHaveAttribute("value", ""); + expect(screen.getByRole("option", { name: /^Off$/i })).toHaveAttribute("value", "off"); + expect(screen.getByRole("option", { name: /^Observe$/i })).toHaveAttribute("value", "observe"); + expect(screen.getByRole("option", { name: /^Steer$/i })).toHaveAttribute("value", "steer"); + expect(screen.getByRole("option", { name: /Autonomous recovery/i })).toHaveAttribute("value", "autonomous"); + + fireEvent.change(select, { target: { value: "steer" } }); + expect(onPlannerOversightLevelChange).toHaveBeenCalledWith("steer"); + }); + + it("does not render the planner oversight selector when onPlannerOversightLevelChange is not provided", () => { + renderTaskForm({}); + fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); + expect(screen.queryByTestId("planner-oversight-level-select")).toBeNull(); + }); + it("renders description field with AI refine button when text is present", () => { renderTaskForm({ description: "Some text" }); diff --git a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx index cb689af9c7..f0e6d206fa 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx @@ -216,6 +216,40 @@ describe("WorkflowSettingsPanel — Values tab", () => { expect(screen.getByText(/Leave empty for unbounded automatic Code Review remediation/i)).toBeInTheDocument(); }); + // FNXC:PlannerOversight 2026-07-04-00:00: the Values tab is the project/global default + // surface for the workflow-native plannerOversightLevel setting (FN-7508/FN-7515) — + // confirm it renders with its own first-class group/label and all four options. + it("renders plannerOversightLevel in the Values tab with its four options and a first-class oversight group", async () => { + mockFetchValues.mockResolvedValue(payload({ effective: { plannerOversightLevel: "autonomous" } })); + render( + , + ); + + await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1")); + expect(within(screen.getByTestId("wf-settings-group-oversight")).getByText("Planner Oversight")).toBeInTheDocument(); + const select = screen.getByLabelText("Planner oversight level") as HTMLSelectElement; + expect(select).toBeInTheDocument(); + expect(select).toHaveValue("autonomous"); + const optionValues = Array.from(select.options).map((o) => o.value); + expect(optionValues).toEqual(expect.arrayContaining(["off", "observe", "steer", "autonomous"])); + }); + it("renders and saves the automatic large-task splitting workflow toggle once", async () => { const triageToggle: WorkflowSettingDefinition[] = [ { diff --git a/packages/dashboard/app/components/workflow-setting-display.ts b/packages/dashboard/app/components/workflow-setting-display.ts index 719b62f91e..296352ef85 100644 --- a/packages/dashboard/app/components/workflow-setting-display.ts +++ b/packages/dashboard/app/components/workflow-setting-display.ts @@ -1,6 +1,6 @@ import type { WorkflowSettingDefinition } from "../api"; -export type WorkflowSettingGroup = "models" | "review" | "steps" | "advanced"; +export type WorkflowSettingGroup = "models" | "review" | "oversight" | "steps" | "advanced"; export interface WorkflowSettingDisplay { group: WorkflowSettingGroup; @@ -151,11 +151,25 @@ const DISPLAY: Record = { group: "steps", label: "Post-review fix passes", }, + /* + * FNXC:PlannerOversight 2026-07-04-00:00: + * First-class Values-tab display entry for the workflow-native plannerOversightLevel + * setting (FN-7508). This is the project/global default surface: the default + * workflow's stored value here is the effective oversight level for every task + * under it that does not set a per-task override (FN-7515, TaskForm selector). + */ + plannerOversightLevel: { + group: "oversight", + label: "Planner oversight level", + description: + "Off disables oversight; Observe watches only; Steer injects guidance or suggests revisions; Autonomous recovery enables bounded retry and targeted-fix recovery. Per-task overrides are set on individual tasks and win over this project/global default.", + }, }; export const WORKFLOW_SETTING_GROUP_ORDER: WorkflowSettingGroup[] = [ "models", "review", + "oversight", "steps", "advanced", ]; @@ -163,6 +177,7 @@ export const WORKFLOW_SETTING_GROUP_ORDER: WorkflowSettingGroup[] = [ export const WORKFLOW_SETTING_GROUP_LABELS: Record = { models: "Models", review: "Review & Approval", + oversight: "Planner Oversight", steps: "Step Execution", advanced: "Advanced", };