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(