From 9ba8a2e5751014545dc8528c8c534326111c66a9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 13 Jul 2026 00:44:59 -0700 Subject: [PATCH] FN-7932: add per-lane Reviewer and Planning thinking-level overrides Adds validatorThinkingLevel and planningThinkingLevel task fields so the Reviewer and Planning AI lanes can override reasoning effort independently of the shared task thinkingLevel, with dashboard UI, storage, and runtime fallback wiring. - Add validatorThinkingLevel and planningThinkingLevel to Task/TaskCreateInput types (packages/core/src/types.ts) - Persist the new fields in the SQLite schema and store read/write/replication paths (packages/core/src/db.ts, store.ts, mesh-task-replication.ts) - Wire executor and triage lanes to fall back per-lane thinking level -> task.thinkingLevel -> existing settings/lane fallback (packages/engine/src/executor.ts, triage.ts) - Add per-lane thinking-level selectors to the ModelSelectorTab UI, alongside the existing thinking-level control (packages/dashboard/app/components/ModelSelectorTab.tsx) - Expose the new fields through the legacy task API and task-workflow routes (packages/dashboard/app/api/legacy.ts, packages/dashboard/src/routes/register-task-workflow-routes.ts) - Document the new settings in dashboard-guide.md and settings-reference.md - Add a minor changeset and unit/integration test coverage for store persistence, routes, UI, and agent-session helpers Files changed: .changeset/per-lane-task-thinking.md | 7 ++ docs/dashboard-guide.md | 2 + docs/settings-reference.md | 2 +- .../src/__tests__/store-thinking-levels.test.ts | 43 +++++++ packages/core/src/db.ts | 15 ++- packages/core/src/mesh-task-replication.ts | 4 + packages/core/src/store.ts | 24 +++- packages/core/src/types.ts | 12 ++ packages/dashboard/app/api/legacy.ts | 2 + .../dashboard/app/components/ModelSelectorTab.tsx | 126 ++++++++++++++++++++- .../components/__tests__/ModelSelectorTab.test.tsx | 50 +++++++- .../src/__tests__/routes-tasks-ops.test.ts | 74 ++++++++++++ .../src/routes/register-task-workflow-routes.ts | 19 +++- .../src/__tests__/agent-session-helpers.test.ts | 15 +++ packages/engine/src/executor.ts | 16 ++- packages/engine/src/triage.ts | 8 +- 16 files changed, 395 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-7932 Fusion-Task-Lineage: 4202f774-aab9-41d2-86a0-f5277dd0f848 Co-authored-by: Fusion (runfusion.ai) --- .changeset/per-lane-task-thinking.md | 7 + docs/dashboard-guide.md | 2 + docs/settings-reference.md | 2 +- .../__tests__/store-thinking-levels.test.ts | 43 ++++++ packages/core/src/db.ts | 15 ++- packages/core/src/mesh-task-replication.ts | 4 + packages/core/src/store.ts | 24 +++- packages/core/src/types.ts | 12 ++ packages/dashboard/app/api/legacy.ts | 2 + .../app/components/ModelSelectorTab.tsx | 126 +++++++++++++++++- .../__tests__/ModelSelectorTab.test.tsx | 50 ++++++- .../src/__tests__/routes-tasks-ops.test.ts | 74 ++++++++++ .../routes/register-task-workflow-routes.ts | 19 ++- .../__tests__/agent-session-helpers.test.ts | 15 +++ packages/engine/src/executor.ts | 16 ++- packages/engine/src/triage.ts | 8 +- 16 files changed, 395 insertions(+), 24 deletions(-) create mode 100644 .changeset/per-lane-task-thinking.md create mode 100644 packages/core/src/__tests__/store-thinking-levels.test.ts diff --git a/.changeset/per-lane-task-thinking.md b/.changeset/per-lane-task-thinking.md new file mode 100644 index 0000000000..bc62481442 --- /dev/null +++ b/.changeset/per-lane-task-thinking.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add separate Reviewer and Planning thinking-level selectors on task details. +category: feature +dev: Adds validatorThinkingLevel and planningThinkingLevel task fields with runtime lane fallback to task.thinkingLevel. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 81b31dff20..633e696823 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1279,6 +1279,8 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - The **Activity → Live**, **Feed**, and **Raw Logs** segments remain immediately after **Chat** and share an expand/collapse control that lets the active Activity segment fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed. - The **Summary** tab appears for `done` tasks and remains their default landing tab. It shows the recorded completion summary, changed-file/merge stats when available, completed steps, workflow results, retry counts, and a token usage & cost section broken down by model from the already-loaded task detail; unpriced models show cost as unavailable rather than `$0`. - The **Cost** tab is available for tasks in every column and sits immediately after **Comments → Terminal** in the tab strip. It shows the read-time derived per-model cost breakdown (input, output, cached, cache-write, total tokens, derived USD) and a task total; no token usage shows an explicit empty state, while unpriced or zero-usage rows use `—` instead of a guessed `$0`. + +- The **Models** tab exposes inline **Thinking Level** selectors for **Executor Model**, **Reviewer Model**, and **Planning Model**. Executor saves the shared task thinking level, while Reviewer and Planning save independent per-lane overrides; leaving either lane on **Default** inherits the shared task thinking level and then the configured workflow/project defaults. - Task-detail Activity steering comments are persisted as user comments/steering guidance and surfaced to every relevant agent lane: live executor sessions receive steering injection, while planner, reviewer (spec/plan/code), and merger agents (standard and clean-room AI merge/review) receive the latest user comments in their next prompt/pass. - The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 174927ab5e..f1ead2c5a6 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -987,7 +987,7 @@ Fusion resolves task models through workflow-backed lane values first, then glob Direct-chat defaults are project-scoped and independent of task workflow lanes. Configure them in **Settings -> Project Models -> Chat**. `chatDefaultKind: "agent"` resolves only when `chatDefaultAgentId` is set; `chatDefaultKind: "model"` resolves only when both `chatDefaultModelProvider` and `chatDefaultModelId` are set, with optional `chatDefaultThinkingLevel`. If `chatNewSessionMode` is `"always-default"` and that target resolves, every New Chat entry point creates the session directly. If the target is incomplete, or the mode is unset/`"prompt"`, Fusion opens the New Chat dialog instead and preselects the resolved default when one exists. Chat Rooms additionally support a per-room `thinkingLevel` default that applies to every room responder; clearing it inherits the resolved project/global default. -Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Primary workflow lanes declare `executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel` per `(workflow, project)`; planning/reviewer fallback lanes declare `planningFallbackThinkingLevel` and `validatorFallbackThinkingLevel`; global fallback uses `fallbackThinkingLevel`; and project title summarization fallback uses `titleSummarizerFallbackThinkingLevel`. Empty thinking values inherit through the lane/global/default chain and explicit values are cleared by the lane reset action. Runtime thinking precedence for task/workflow execution is node/step `config.thinkingLevel` > task `thinkingLevel` > workflow lane thinking override > global lane thinking override > project default thinking override > global `defaultThinkingLevel`. Model-mode Chat sessions use the same executor-lane resolver with session `thinkingLevel` in the task slot, so an empty chat-session value inherits project/global defaults while a concrete New Chat selection wins for that session. The resolved value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters). +Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Primary workflow lanes declare `executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel` per `(workflow, project)`; planning/reviewer fallback lanes declare `planningFallbackThinkingLevel` and `validatorFallbackThinkingLevel`; global fallback uses `fallbackThinkingLevel`; and project title summarization fallback uses `titleSummarizerFallbackThinkingLevel`. Empty thinking values inherit through the lane/global/default chain and explicit values are cleared by the lane reset action. Runtime thinking precedence for task/workflow execution is node/step `config.thinkingLevel` > lane-specific task override (`planningThinkingLevel` or `validatorThinkingLevel`) > shared task `thinkingLevel` > workflow lane thinking override > global lane thinking override > project default thinking override > global `defaultThinkingLevel`; executor sessions continue to use shared task `thinkingLevel` directly. Model-mode Chat sessions use the same executor-lane resolver with session `thinkingLevel` in the task slot, so an empty chat-session value inherits project/global defaults while a concrete New Chat selection wins for that session. The resolved value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters). When the planning lane has neither `planningFallback*` nor a global `fallback*` pair configured, triage now derives an **implicit fallback** from the resolved project/global default (execution) model (FN-7719). This lets a retryable primary planner-model failure (e.g. a provider 404/429) recover via one distinct swap instead of permanently failing triage with "no fallback configured" — the operator's chosen primary planner lane is unchanged, and the implicit fallback is skipped when it would equal the primary model or when test mode is active. diff --git a/packages/core/src/__tests__/store-thinking-levels.test.ts b/packages/core/src/__tests__/store-thinking-levels.test.ts new file mode 100644 index 0000000000..94c7f0d620 --- /dev/null +++ b/packages/core/src/__tests__/store-thinking-levels.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +describe("TaskStore task thinking levels", () => { + const harness = createTaskStoreTestHarness(); + + beforeEach(harness.beforeEach); + afterEach(harness.afterEach); + + it("round-trips per-lane thinking levels through create, update, omit, and null clear", async () => { + const store = harness.store(); + const created = await store.createTask({ + description: "per-lane thinking fields", + validatorThinkingLevel: "high", + planningThinkingLevel: "low", + }); + + expect(created.validatorThinkingLevel).toBe("high"); + expect(created.planningThinkingLevel).toBe("low"); + expect((await store.getTask(created.id)).validatorThinkingLevel).toBe("high"); + expect((await store.getTask(created.id)).planningThinkingLevel).toBe("low"); + + const updated = await store.updateTask(created.id, { + validatorThinkingLevel: "medium", + planningThinkingLevel: "minimal", + }); + expect(updated.validatorThinkingLevel).toBe("medium"); + expect(updated.planningThinkingLevel).toBe("minimal"); + + const omitted = await store.updateTask(created.id, { title: "untouched thinking" }); + expect(omitted.validatorThinkingLevel).toBe("medium"); + expect(omitted.planningThinkingLevel).toBe("minimal"); + + const cleared = await store.updateTask(created.id, { + validatorThinkingLevel: null, + planningThinkingLevel: null, + }); + expect(cleared.validatorThinkingLevel).toBeUndefined(); + expect(cleared.planningThinkingLevel).toBeUndefined(); + expect((await store.getTask(created.id)).validatorThinkingLevel).toBeUndefined(); + expect((await store.getTask(created.id)).planningThinkingLevel).toBeUndefined(); + }); +}); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 5f439e00e3..9204cc6274 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -184,7 +184,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 144; +const SCHEMA_VERSION = 145; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -301,6 +301,8 @@ CREATE TABLE IF NOT EXISTS tasks ( error TEXT, summary TEXT, thinkingLevel TEXT, + validatorThinkingLevel TEXT, + planningThinkingLevel TEXT, executionMode TEXT DEFAULT 'standard', plannerOversightLevel TEXT, awaitingApprovalReason TEXT, @@ -5714,6 +5716,17 @@ export class Database { }); } + if (version < 145) { + /* + * FNXC:Settings-ThinkingLevel 2026-07-13-00:27: + * Tasks persist optional validator/planning reasoning-effort overrides separately from shared `thinkingLevel`; rerun these additive task columns under a fresh schema version so upgraded databases converge safely. + */ + this.applyMigration(145, () => { + this.addColumnIfMissing("tasks", "validatorThinkingLevel", "TEXT"); + this.addColumnIfMissing("tasks", "planningThinkingLevel", "TEXT"); + }); + } + } /** diff --git a/packages/core/src/mesh-task-replication.ts b/packages/core/src/mesh-task-replication.ts index 7db85d2a04..de82aa0ea1 100644 --- a/packages/core/src/mesh-task-replication.ts +++ b/packages/core/src/mesh-task-replication.ts @@ -127,6 +127,8 @@ export function taskMatchesReplicatedCreate(existing: TaskDetail, payload: MeshR planningModelProvider: existing.planningModelProvider, planningModelId: existing.planningModelId, thinkingLevel: existing.thinkingLevel, + validatorThinkingLevel: existing.validatorThinkingLevel, + planningThinkingLevel: existing.planningThinkingLevel, missionId: existing.missionId, sliceId: existing.sliceId, assignedAgentId: existing.assignedAgentId, @@ -179,6 +181,8 @@ export function toReplicatedCreateInput(task: Task): TaskCreateInput { planningModelProvider: task.planningModelProvider, planningModelId: task.planningModelId, thinkingLevel: task.thinkingLevel, + validatorThinkingLevel: task.validatorThinkingLevel, + planningThinkingLevel: task.planningThinkingLevel, missionId: task.missionId, sliceId: task.sliceId, assignedAgentId: task.assignedAgentId, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index de476f1060..b537f312e3 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -273,6 +273,8 @@ interface TaskRow { error: string | null; summary: string | null; thinkingLevel: string | null; + validatorThinkingLevel: string | null; + planningThinkingLevel: string | null; executionMode: string | null; plannerOversightLevel: string | null; awaitingApprovalReason: string | null; @@ -443,6 +445,8 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("error", (task) => task.error ?? null), defineTaskColumn("summary", (task) => task.summary ?? null), defineTaskColumn("thinkingLevel", (task) => task.thinkingLevel ?? null), + defineTaskColumn("validatorThinkingLevel", (task) => task.validatorThinkingLevel ?? null), + defineTaskColumn("planningThinkingLevel", (task) => task.planningThinkingLevel ?? null), defineTaskColumn("executionMode", (task) => task.executionMode ?? null), defineTaskColumn("plannerOversightLevel", (task) => task.plannerOversightLevel ?? null), /* @@ -2214,6 +2218,8 @@ export class TaskStore extends EventEmitter { error: row.error || undefined, summary: row.summary || undefined, thinkingLevel: (row.thinkingLevel || undefined) as Task["thinkingLevel"], + validatorThinkingLevel: (row.validatorThinkingLevel || undefined) as Task["validatorThinkingLevel"], + planningThinkingLevel: (row.planningThinkingLevel || undefined) as Task["planningThinkingLevel"], executionMode: (row.executionMode || undefined) as Task["executionMode"], plannerOversightLevel: (row.plannerOversightLevel || undefined) as Task["plannerOversightLevel"], awaitingApprovalReason: (row.awaitingApprovalReason || undefined) as Task["awaitingApprovalReason"], @@ -2782,7 +2788,7 @@ export class TaskStore extends EventEmitter { "validatorModelProvider", "validatorModelId", "planningModelProvider", "planningModelId", "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", - "error", "summary", "thinkingLevel", "executionMode", "plannerOversightLevel", "awaitingApprovalReason", "approvedPlanFingerprint", + "error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "executionMode", "plannerOversightLevel", "awaitingApprovalReason", "approvedPlanFingerprint", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "columnDwellMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments", @@ -2878,7 +2884,7 @@ export class TaskStore extends EventEmitter { "validatorModelProvider", "validatorModelId", "planningModelProvider", "planningModelId", "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", - "error", "summary", "thinkingLevel", "executionMode", "plannerOversightLevel", "awaitingApprovalReason", "approvedPlanFingerprint", + "error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "executionMode", "plannerOversightLevel", "awaitingApprovalReason", "approvedPlanFingerprint", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "columnDwellMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "attachments", "steeringComments", @@ -5146,6 +5152,8 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} planningModelProvider: input.planningModelProvider, planningModelId: input.planningModelId, thinkingLevel: input.thinkingLevel, + validatorThinkingLevel: input.validatorThinkingLevel, + planningThinkingLevel: input.planningThinkingLevel, reviewLevel: input.reviewLevel, executionMode: input.executionMode, plannerOversightLevel: input.plannerOversightLevel, @@ -8656,7 +8664,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} async updateTask( id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; plannerOversightLevel?: import("./types.js").PlannerOversightLevel | null; awaitingApprovalReason?: import("./types.js").Task["awaitingApprovalReason"] | null; approvedPlanFingerprint?: string | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; gitlabTracking?: (Omit & { item?: import("./types.js").TaskGitLabTrackedItem | null }) | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; workflowTransitionNotification?: import("./types.js").Task["workflowTransitionNotification"] | null; missionId?: string | null; sliceId?: string | null }, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; plannerOversightLevel?: import("./types.js").PlannerOversightLevel | null; awaitingApprovalReason?: import("./types.js").Task["awaitingApprovalReason"] | null; approvedPlanFingerprint?: string | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; gitlabTracking?: (Omit & { item?: import("./types.js").TaskGitLabTrackedItem | null }) | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; workflowTransitionNotification?: import("./types.js").Task["workflowTransitionNotification"] | null; missionId?: string | null; sliceId?: string | null }, runContext?: RunMutationContext, ): Promise { /* @@ -9541,6 +9549,16 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } else if (updates.thinkingLevel !== undefined) { task.thinkingLevel = updates.thinkingLevel as import("./types.js").ThinkingLevel; } + if (updates.validatorThinkingLevel === null) { + task.validatorThinkingLevel = undefined; + } else if (updates.validatorThinkingLevel !== undefined) { + task.validatorThinkingLevel = updates.validatorThinkingLevel as import("./types.js").ThinkingLevel; + } + if (updates.planningThinkingLevel === null) { + task.planningThinkingLevel = undefined; + } else if (updates.planningThinkingLevel !== undefined) { + task.planningThinkingLevel = updates.planningThinkingLevel as import("./types.js").ThinkingLevel; + } if (updates.executionMode === null) { task.executionMode = undefined; } else if (updates.executionMode !== undefined) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6ff937b5ef..6c57205081 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2576,6 +2576,12 @@ export interface Task { approvedPlanFingerprint?: string; /** Thinking level for AI agent sessions — controls reasoning effort (off/minimal/low/medium/high) */ thinkingLevel?: ThinkingLevel; + /** + * FNXC:Settings-ThinkingLevel 2026-07-13-00:27: + * Validator and planning task fields are optional per-lane reasoning-effort overrides. When unset, those lanes inherit the shared task `thinkingLevel`, then existing settings and lane fallbacks. + */ + validatorThinkingLevel?: ThinkingLevel; + planningThinkingLevel?: ThinkingLevel; /** Execution mode for task implementation. * - "standard": Full execution with complete review workflow (default) * - "fast": Expedited execution with minimal overhead for simple tasks @@ -2847,6 +2853,12 @@ export interface TaskCreateInput { planningModelId?: string; /** Thinking level for AI agent sessions — controls reasoning effort (off/minimal/low/medium/high) */ thinkingLevel?: ThinkingLevel; + /** + * FNXC:Settings-ThinkingLevel 2026-07-13-00:27: + * Validator and planning task fields are optional per-lane reasoning-effort overrides. When unset, those lanes inherit the shared task `thinkingLevel`, then existing settings and lane fallbacks. + */ + validatorThinkingLevel?: ThinkingLevel; + planningThinkingLevel?: ThinkingLevel; /** When true, trigger AI title summarization if description is long and no title provided */ summarize?: boolean; /** Mission ID to link this task to (for mission hierarchy) */ diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 4809d6a2a5..30f237d00c 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -569,6 +569,8 @@ export function updateTask( planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; + validatorThinkingLevel?: string | null; + planningThinkingLevel?: string | null; plannerOversightLevel?: "off" | "observe" | "steer" | "autonomous" | null; reviewLevel?: number | null; executionMode?: "standard" | "fast" | null; diff --git a/packages/dashboard/app/components/ModelSelectorTab.tsx b/packages/dashboard/app/components/ModelSelectorTab.tsx index 538bdc02a3..dca1087606 100644 --- a/packages/dashboard/app/components/ModelSelectorTab.tsx +++ b/packages/dashboard/app/components/ModelSelectorTab.tsx @@ -140,6 +140,10 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj const [savedPlanning, setSavedPlanning] = useState(() => getPlanningSelection(task)); const [selectedThinking, setSelectedThinking] = useState(() => task.thinkingLevel ?? null); const [savedThinking, setSavedThinking] = useState(() => task.thinkingLevel ?? null); + const [selectedValidatorThinking, setSelectedValidatorThinking] = useState(() => task.validatorThinkingLevel ?? null); + const [savedValidatorThinking, setSavedValidatorThinking] = useState(() => task.validatorThinkingLevel ?? null); + const [selectedPlanningThinking, setSelectedPlanningThinking] = useState(() => task.planningThinkingLevel ?? null); + const [savedPlanningThinking, setSavedPlanningThinking] = useState(() => task.planningThinkingLevel ?? null); const [savingTarget, setSavingTarget] = useState<"executor" | "validator" | "planning" | "thinking" | null>(null); const activeTaskIdRef = useRef(task.id); @@ -174,10 +178,16 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj setSelectedPlanning(nextPlanning); setSavedPlanning(nextPlanning); const nextThinking = task.thinkingLevel ?? null; + const nextValidatorThinking = task.validatorThinkingLevel ?? null; + const nextPlanningThinking = task.planningThinkingLevel ?? null; setSelectedThinking(nextThinking); setSavedThinking(nextThinking); + setSelectedValidatorThinking(nextValidatorThinking); + setSavedValidatorThinking(nextValidatorThinking); + setSelectedPlanningThinking(nextPlanningThinking); + setSavedPlanningThinking(nextPlanningThinking); setSavingTarget(null); - }, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId, task.planningModelProvider, task.planningModelId, task.thinkingLevel]); + }, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId, task.planningModelProvider, task.planningModelId, task.thinkingLevel, task.validatorThinkingLevel, task.planningThinkingLevel]); const executorValue = useMemo(() => getDropdownValue(selectedExecutor), [selectedExecutor]); const validatorValue = useMemo(() => getDropdownValue(selectedValidator), [selectedValidator]); @@ -370,6 +380,112 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj [task.id, savedThinking, settings, addToast, onTaskUpdated, projectId, t], ); + const handleValidatorThinkingChange = useCallback( + async (value: string) => { + const requestTaskId = task.id; + const previousThinking = savedValidatorThinking; + const nextValue = value === "" ? null : value; + + setSelectedValidatorThinking(nextValue); + setSavingTarget("thinking"); + + try { + const updatedTask = await updateTask(requestTaskId, { + validatorThinkingLevel: nextValue, + }, projectId); + + if (activeTaskIdRef.current !== requestTaskId) { + return; + } + + const nextThinking = updatedTask.validatorThinkingLevel ?? null; + setSavedValidatorThinking(nextThinking); + setSelectedValidatorThinking(nextThinking); + onTaskUpdated?.(updatedTask); + + const effectiveDefault = settings?.defaultThinkingLevel ?? "off"; + if (nextThinking === null) { + addToast( + t("models.messages.thinkingLevelSetDefault", "Thinking level set to default ({{level}})", { level: effectiveDefault }), + "success", + ); + } else { + addToast( + t("models.messages.thinkingLevelSet", "Thinking level set to {{level}}", { level: nextThinking }), + "success", + ); + } + } catch (err) { + if (activeTaskIdRef.current !== requestTaskId) { + return; + } + + setSelectedValidatorThinking(previousThinking); + addToast(getErrorMessage(err) || t("models.errors.failedSaveThinking", "Failed to save thinking level"), "error"); + } finally { + if (activeTaskIdRef.current === requestTaskId) { + setSavingTarget(null); + } + } + }, + [task.id, savedValidatorThinking, settings, addToast, onTaskUpdated, projectId, t], + ); + + const handlePlanningThinkingChange = useCallback( + async (value: string) => { + const requestTaskId = task.id; + const previousThinking = savedPlanningThinking; + const nextValue = value === "" ? null : value; + + setSelectedPlanningThinking(nextValue); + setSavingTarget("thinking"); + + try { + const updatedTask = await updateTask(requestTaskId, { + planningThinkingLevel: nextValue, + }, projectId); + + if (activeTaskIdRef.current !== requestTaskId) { + return; + } + + const nextThinking = updatedTask.planningThinkingLevel ?? null; + setSavedPlanningThinking(nextThinking); + setSelectedPlanningThinking(nextThinking); + onTaskUpdated?.(updatedTask); + + const effectiveDefault = settings?.defaultThinkingLevel ?? "off"; + if (nextThinking === null) { + addToast( + t("models.messages.thinkingLevelSetDefault", "Thinking level set to default ({{level}})", { level: effectiveDefault }), + "success", + ); + } else { + addToast( + t("models.messages.thinkingLevelSet", "Thinking level set to {{level}}", { level: nextThinking }), + "success", + ); + } + } catch (err) { + if (activeTaskIdRef.current !== requestTaskId) { + return; + } + + setSelectedPlanningThinking(previousThinking); + addToast(getErrorMessage(err) || t("models.errors.failedSaveThinking", "Failed to save thinking level"), "error"); + } finally { + if (activeTaskIdRef.current === requestTaskId) { + setSavingTarget(null); + } + } + }, + [task.id, savedPlanningThinking, settings, addToast, onTaskUpdated, projectId, t], + ); + + /* + * FNXC:Settings-ThinkingLevel 2026-07-13-00:27: + * Reviewer and Planning task-detail model boxes carry independent per-lane reasoning-effort overrides persisted to task.validatorThinkingLevel and task.planningThinkingLevel while the Executor box continues to use task.thinkingLevel. + */ const executorUsingDefault = !savedExecutor.provider && !savedExecutor.modelId; const validatorUsingDefault = !savedValidator.provider && !savedValidator.modelId; const planningUsingDefault = !savedPlanning.provider && !savedPlanning.modelId; @@ -448,6 +564,9 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} + thinkingLevel={selectedValidatorThinking ?? ""} + onThinkingLevelChange={handleValidatorThinkingChange} + defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"} /> {t("models.descriptions.reviewer", "The AI model used to review code and plans for this task.")} @@ -478,13 +597,16 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} + thinkingLevel={selectedPlanningThinking ?? ""} + onThinkingLevelChange={handlePlanningThinkingChange} + defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"} /> {t("models.descriptions.planning", "The AI model used for task specification (triage phase).")}
- {executorUsingDefault && validatorUsingDefault && planningUsingDefault && savedThinking === null + {executorUsingDefault && validatorUsingDefault && planningUsingDefault && savedThinking === null && savedValidatorThinking === null && savedPlanningThinking === null ? t("models.messages.usingDefaults", "Using project or global default models.") : t("models.messages.upToDate", "Model settings are up to date.")}
diff --git a/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx b/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx index 7917c1ac45..c56c2c0938 100644 --- a/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx @@ -131,6 +131,14 @@ describe("ModelSelectorTab", () => { .mockResolvedValueOnce({ ...task, thinkingLevel: "high", + }) + .mockResolvedValueOnce({ + ...task, + validatorThinkingLevel: "high", + }) + .mockResolvedValueOnce({ + ...task, + planningThinkingLevel: "high", }); render( @@ -168,13 +176,29 @@ describe("ModelSelectorTab", () => { }, "project-alpha"); }); - await user.click(screen.getByRole("button", { name: "Executor Model" })); + await user.click(screen.getByRole("button", { name: /Executor Model/ })); await user.selectOptions(await screen.findByTestId("custom-model-dropdown-thinking"), "high"); await waitFor(() => { expect(mockUpdateTask).toHaveBeenNthCalledWith(4, "FN-7398", { thinkingLevel: "high", }, "project-alpha"); }); + + await user.click(screen.getByRole("button", { name: /Reviewer Model/ })); + await user.selectOptions(await screen.findByTestId("custom-model-dropdown-thinking"), "high"); + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenNthCalledWith(5, "FN-7398", { + validatorThinkingLevel: "high", + }, "project-alpha"); + }); + + await user.click(screen.getByRole("button", { name: /Planning Model/ })); + await user.selectOptions(await screen.findByTestId("custom-model-dropdown-thinking"), "high"); + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenNthCalledWith(6, "FN-7398", { + planningThinkingLevel: "high", + }, "project-alpha"); + }); expect(addToast).toHaveBeenCalledWith(expect.stringContaining("set to"), "success"); }); @@ -210,6 +234,30 @@ describe("ModelSelectorTab", () => { }); }); + it("renders reviewer and planning thinking badges with default fallback and concrete overrides", async () => { + const task = makeTask({ + thinkingLevel: "medium", + validatorThinkingLevel: "high", + planningThinkingLevel: undefined, + }); + + render( + , + ); + + await waitFor(() => expect(screen.getByLabelText("Executor Model")).toBeInTheDocument()); + + const badges = screen.getAllByTestId("custom-model-dropdown-thinking-badge"); + expect(badges).toHaveLength(3); + expect(badges[0]).toHaveTextContent("Medium"); + expect(badges[1]).toHaveTextContent("High"); + expect(badges[2]).toHaveTextContent("Default (low)"); + }); + it("updates from a cached empty catalog to populated Claude CLI rows without remounting", async () => { localStorage.setItem( SWR_CACHE_KEYS.MODELS, diff --git a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts index d1d3c0139f..afdbed4432 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts @@ -3901,6 +3901,80 @@ describe("PATCH /tasks/:id", () => { expect(res.body.error).toContain("thinkingLevel must be one of"); }); + it("forwards valid per-lane thinking levels to store.updateTask", async () => { + (store.updateTask as ReturnType).mockResolvedValue({ + ...FAKE_TASK_DETAIL, + validatorThinkingLevel: "high", + planningThinkingLevel: "minimal", + }); + + const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ + validatorThinkingLevel: "high", + planningThinkingLevel: "minimal", + }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(store.updateTask).toHaveBeenCalledWith("KB-001", { + validatorThinkingLevel: "high", + planningThinkingLevel: "minimal", + }); + }); + + it("returns 400 for invalid per-lane thinking level values via PATCH", async () => { + const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ + validatorThinkingLevel: "maximum", + }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("validatorThinkingLevel must be one of"); + expect(store.updateTask).not.toHaveBeenCalled(); + }); + + it("accepts null to clear per-lane thinking levels via PATCH", async () => { + (store.updateTask as ReturnType).mockResolvedValue({ + ...FAKE_TASK_DETAIL, + validatorThinkingLevel: undefined, + planningThinkingLevel: undefined, + }); + + const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ + validatorThinkingLevel: null, + planningThinkingLevel: null, + }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(store.updateTask).toHaveBeenCalledWith("KB-001", { + validatorThinkingLevel: null, + planningThinkingLevel: null, + }); + }); + + it("omits per-lane thinking updates when fields are absent via PATCH", async () => { + (store.updateTask as ReturnType).mockResolvedValue({ + ...FAKE_TASK_DETAIL, + title: "No lane thinking patch", + validatorThinkingLevel: "high", + planningThinkingLevel: "low", + }); + + const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ + title: "No lane thinking patch", + }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(store.updateTask).toHaveBeenCalledWith("KB-001", { + title: "No lane thinking patch", + }); + }); + it("forwards reviewLevel to store.updateTask", async () => { (store.updateTask as ReturnType).mockResolvedValue({ ...FAKE_TASK_DETAIL, diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 47d456ab95..ecfba79599 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -4278,7 +4278,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork router.patch("/tasks/:id", async (req, res) => { try { const { store: scopedStore } = await getProjectContext(req); - const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking, gitlabTracking, noCommitsExpected, autoMerge, overlapBlockedBy, status, dismissNearDuplicate } = req.body; + const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, validatorThinkingLevel, planningThinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking, gitlabTracking, noCommitsExpected, autoMerge, overlapBlockedBy, status, dismissNearDuplicate } = req.body; const hasBodyField = (field: string) => Object.prototype.hasOwnProperty.call(req.body, field); // Validate model fields are strings or undefined/null @@ -4299,11 +4299,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork const validatedPlanningModelId = validateModelField(planningModelId, "planningModelId"); const validatedAssigneeUserId = validateModelField(assigneeUserId, "assigneeUserId"); - // Validate thinkingLevel if provided + // Validate thinking level fields if provided const validThinkingLevels = [...THINKING_LEVELS]; - if (thinkingLevel !== undefined && thinkingLevel !== null && !validThinkingLevels.includes(thinkingLevel)) { - throw new Error(`thinkingLevel must be one of: ${validThinkingLevels.join(", ")}`); - } + const validateThinkingLevel = (value: unknown, name: string): void => { + if (value !== undefined && value !== null && !validThinkingLevels.includes(value as (typeof validThinkingLevels)[number])) { + throw new Error(`${name} must be one of: ${validThinkingLevels.join(", ")}`); + } + }; + validateThinkingLevel(thinkingLevel, "thinkingLevel"); + validateThinkingLevel(validatorThinkingLevel, "validatorThinkingLevel"); + validateThinkingLevel(planningThinkingLevel, "planningThinkingLevel"); // Validate reviewLevel if provided (must be integer 0-3) if (reviewLevel !== undefined && reviewLevel !== null) { @@ -4586,6 +4591,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork if (hasBodyField("planningModelProvider")) updates.planningModelProvider = validatedPlanningModelProvider; if (hasBodyField("planningModelId")) updates.planningModelId = validatedPlanningModelId; if (hasBodyField("thinkingLevel")) updates.thinkingLevel = thinkingLevel === null ? null : thinkingLevel; + if (hasBodyField("validatorThinkingLevel")) updates.validatorThinkingLevel = validatorThinkingLevel === null ? null : validatorThinkingLevel; + if (hasBodyField("planningThinkingLevel")) updates.planningThinkingLevel = planningThinkingLevel === null ? null : planningThinkingLevel; if (hasBodyField("assigneeUserId")) updates.assigneeUserId = validatedAssigneeUserId; if (hasBodyField("reviewLevel")) updates.reviewLevel = reviewLevel; if (hasBodyField("executionMode")) updates.executionMode = executionMode === null ? null : executionMode; @@ -4658,7 +4665,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork if (err instanceof ApiError) { throw err; } - const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be a non-empty string") || (err instanceof Error ? err.message : String(err)).includes("must be a string or null") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") || (err instanceof Error ? err.message : String(err)).includes("must be a boolean") || (err instanceof Error ? err.message : String(err)).includes("thinkingLevel must be one of") || (err instanceof Error ? err.message : String(err)).includes("reviewLevel must be an integer") || (err instanceof Error ? err.message : String(err)).includes("executionMode must be one of") || (err instanceof Error ? err.message : String(err)).includes("priority must be one of") || (err instanceof Error ? err.message : String(err)).includes("sourceIssue") || (err instanceof Error ? err.message : String(err)).includes("gitlabTracking") || (err instanceof Error ? err.message : String(err)).includes("status may only be cleared") ? 400 : 500; + const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be a non-empty string") || (err instanceof Error ? err.message : String(err)).includes("must be a string or null") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") || (err instanceof Error ? err.message : String(err)).includes("must be a boolean") || (err instanceof Error ? err.message : String(err)).includes("thinkingLevel must be one of") || (err instanceof Error ? err.message : String(err)).includes("validatorThinkingLevel must be one of") || (err instanceof Error ? err.message : String(err)).includes("planningThinkingLevel must be one of") || (err instanceof Error ? err.message : String(err)).includes("reviewLevel must be an integer") || (err instanceof Error ? err.message : String(err)).includes("executionMode must be one of") || (err instanceof Error ? err.message : String(err)).includes("priority must be one of") || (err instanceof Error ? err.message : String(err)).includes("sourceIssue") || (err instanceof Error ? err.message : String(err)).includes("gitlabTracking") || (err instanceof Error ? err.message : String(err)).includes("status may only be cleared") ? 400 : 500; throw new ApiError(status, err instanceof Error ? err.message : String(err)); } }); diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index cd9a6f990c..59a2bba773 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -65,6 +65,21 @@ describe("resolve model-lane thinking levels", () => { })).toBe("medium"); }); + it("documents caller precedence for per-task planning and validator thinking overrides", () => { + const settings = { planningThinkingLevel: "minimal", validatorThinkingLevel: "low", defaultThinkingLevel: "off" } as const; + const task = { thinkingLevel: "medium", planningThinkingLevel: "high", validatorThinkingLevel: "xhigh" } as const; + + expect(resolvePlanningThinkingLevel(settings, task.planningThinkingLevel ?? task.thinkingLevel)).toBe("high"); + expect(resolveValidatorThinkingLevel(task.validatorThinkingLevel ?? task.thinkingLevel, settings)).toBe("xhigh"); + + const legacyTask = { thinkingLevel: "medium", planningThinkingLevel: undefined, validatorThinkingLevel: undefined } as const; + expect(resolvePlanningThinkingLevel(settings, legacyTask.planningThinkingLevel ?? legacyTask.thinkingLevel)).toBe("medium"); + expect(resolveValidatorThinkingLevel(legacyTask.validatorThinkingLevel ?? legacyTask.thinkingLevel, settings)).toBe("medium"); + + const nodeThinkingLevel = "minimal" as const; + expect(resolveValidatorThinkingLevel(nodeThinkingLevel ?? task.validatorThinkingLevel ?? task.thinkingLevel, settings)).toBe("minimal"); + }); + it("resolves fallback thinking through fallback key then executor lane then defaults", () => { expect(resolveExecutorFallbackThinkingLevel("task", { fallbackThinkingLevel: "high", executionThinkingLevel: "low" })).toBe("high"); expect(resolveExecutorFallbackThinkingLevel(undefined, { executionThinkingLevel: "minimal", defaultThinkingLevel: "low" })).toBe("minimal"); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 693cded9fb..e99742aee9 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -6897,19 +6897,19 @@ export class TaskExecutor { fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, /* - * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: - * Step-review model sessions honor per-node `config.thinkingLevel` before task, validator workflow lane, global lane, and default thinking settings. + * FNXC:Settings-ThinkingLevel 2026-07-13-00:27: + * Step-review model sessions honor per-node `config.thinkingLevel` before the task validator override, then shared task thinking, validator workflow lane, global lane, and default thinking settings. */ defaultThinkingLevel: resolveValidatorThinkingLevel( typeof config.thinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(config.thinkingLevel) ? (config.thinkingLevel as ThinkingLevel) - : detail.thinkingLevel, + : detail.validatorThinkingLevel ?? detail.thinkingLevel, settings, ), fallbackThinkingLevel: resolveValidatorFallbackThinkingLevel( typeof config.thinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(config.thinkingLevel) ? (config.thinkingLevel as ThinkingLevel) - : detail.thinkingLevel, + : detail.validatorThinkingLevel ?? detail.thinkingLevel, settings, ), taskValidatorProvider: detail.validatorModelProvider, @@ -13978,8 +13978,12 @@ export class TaskExecutor { defaultModelId: settings.defaultModelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, - fallbackThinkingLevel: resolveValidatorFallbackThinkingLevel(latestDetailForReview.thinkingLevel, settings), - defaultThinkingLevel: resolveValidatorThinkingLevel(latestDetailForReview.thinkingLevel, settings), + /* + * FNXC:Settings-ThinkingLevel 2026-07-13-00:27: + * Pre-merge review sessions honor the per-task validator override before shared task thinking, preserving shared-task fallback for legacy tasks. + */ + fallbackThinkingLevel: resolveValidatorFallbackThinkingLevel(latestDetailForReview.validatorThinkingLevel ?? latestDetailForReview.thinkingLevel, settings), + defaultThinkingLevel: resolveValidatorThinkingLevel(latestDetailForReview.validatorThinkingLevel ?? latestDetailForReview.thinkingLevel, settings), // Task-level validator override (from task) taskValidatorProvider: latestDetailForReview.validatorModelProvider, taskValidatorModelId: latestDetailForReview.validatorModelId, diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 5cc74ef8af..c948a0621c 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -1171,10 +1171,10 @@ export class TriageProcessor { ? settings.planningFallbackModelId : (hasExplicitGlobalFallback ? settings.fallbackModelId : implicitPlanningFallback.modelId), /* - * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: - * Planning sessions carry task thinking first, then the workflow-declared planning lane, global planning lane, and default thinking settings into pi.ts' existing thinking fallback path. + * FNXC:Settings-ThinkingLevel 2026-07-13-00:27: + * Planning sessions honor the per-task planning override before the shared task thinking level, then the workflow-declared planning lane, global lane, and default thinking settings. */ - defaultThinkingLevel: resolvePlanningThinkingLevel(settings, task.thinkingLevel), + defaultThinkingLevel: resolvePlanningThinkingLevel(settings, task.planningThinkingLevel ?? task.thinkingLevel), runAuditor, settings, // FNXC:McpConfig 2026-06-25-23:17: Primary triage planning is an AI lane, so it receives the store-resolved MCP set while the pi runtime-support guard decides whether to forward it without logging secret material. @@ -1193,7 +1193,7 @@ export class TriageProcessor { }), }); - const modelDesc = formatModelMarkerDetails(describeModel(session), resolvePlanningThinkingLevel(settings, task.thinkingLevel)); + const modelDesc = formatModelMarkerDetails(describeModel(session), resolvePlanningThinkingLevel(settings, task.planningThinkingLevel ?? task.thinkingLevel)); planLog.log(`${task.id}: using model ${modelDesc}`); await this.store.logEntry(task.id, `Triage using model: ${modelDesc}`); await this.store.appendAgentLog(