diff --git a/.changeset/FN-7040-align-task-detail-model-display.md b/.changeset/FN-7040-align-task-detail-model-display.md new file mode 100644 index 0000000000..f7b6e2a07b --- /dev/null +++ b/.changeset/FN-7040-align-task-detail-model-display.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep task-detail Chat and Workflow tabs aligned on displayed model names. +category: fix +dev: Extracts dashboard effective model display resolution for shared Chat, Agent Log, and Workflow tab use. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 3319f42f75..33b1f44322 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -10,7 +10,7 @@ import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { sharedRehypePlugins, createMermaidCodeComponent } from "./markdownPipeline"; -import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core"; +import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, REPO_OVERRIDE_RE, @@ -18,9 +18,6 @@ import { VALID_TRANSITIONS, isColumn, getErrorMessage, - resolveTaskExecutionModel, - resolveTaskPlanningModel, - resolveTaskValidatorModel, } from "@fusion/core"; import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; @@ -66,13 +63,8 @@ import { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy"; import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry"; import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; +import { ACTIVE_STATUSES, resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator, type ModelSelection } from "./effective-model-resolution"; -interface ModelSelection { - provider?: string; - modelId?: string; -} - -const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]); const STALE_PAUSED_REVIEW_LOG_REGEX = /^Stale paused review surfaced \[([^\]]+)\]/; const EMPTY_MARKDOWN_CHILD_SEPARATOR = ""; const STRING_OBJECT_TAG = "[object String]"; @@ -107,35 +99,6 @@ const markdownLinkifyComponents: Components = { code: createMermaidCodeComponent("task-detail-mermaid-diagram", markdownLinkifyCodeComponent), }; -/** - * Resolve the effective executor model following the engine's resolution order: - * 1. Per-task modelProvider/modelId (both must be set) - * 2. Project/global execution lane fallback - */ -function extractExecutorModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null { - let result: { provider: string; modelId: string } | null = null; - entries.forEach((entry) => { - if (entry.agent !== "executor" || entry.type !== "text") return; - const match = entry.text.match(/^Executor using model: (.+?)\/(.+)$/); - if (match) { - result = { provider: match[1], modelId: match[2] }; - } - }); - return result; -} - -function extractReviewerModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null { - let result: { provider: string; modelId: string } | null = null; - entries.forEach((entry) => { - if (entry.agent !== "reviewer" || entry.type !== "text") return; - const match = entry.text.match(/^Reviewer using model: (.+?)\/(.+)$/); - if (match) { - result = { provider: match[1], modelId: match[2] }; - } - }); - return result; -} - function hasUsableTrackingTitle(task: { title?: string | null; description?: string | null }): boolean { if ((task.title ?? "").trim().length > 0) { return true; @@ -149,121 +112,6 @@ function hasUsableTrackingTitle(task: { title?: string | null; description?: str return Boolean(firstMeaningfulLine); } -function extractAssignedRuntimeModel(agent: Agent | null | undefined): ModelSelection { - const runtimeConfig = (agent?.runtimeConfig ?? undefined) as Record | undefined; - const model = isStringValue(runtimeConfig?.model) ? runtimeConfig.model.trim() : ""; - if (model) { - const slashIdx = model.indexOf("/"); - if (slashIdx > 0 && slashIdx < model.length - 1) { - return { - provider: model.slice(0, slashIdx), - modelId: model.slice(slashIdx + 1), - }; - } - } - - const provider = isStringValue(runtimeConfig?.modelProvider) ? runtimeConfig.modelProvider.trim() : ""; - const modelId = isStringValue(runtimeConfig?.modelId) ? runtimeConfig.modelId.trim() : ""; - return { - provider: provider || undefined, - modelId: modelId || undefined, - }; -} - -/** - * Resolve the effective executor model following the engine's resolution order: - * 1. Runtime executor model from agent log marker - * 2. Assigned agent runtime model (active runs only) - * 3. Per-task modelProvider/modelId override - * 4. Project/global execution lane fallback - */ -function resolveEffectiveExecutor( - task: Task | TaskDetail, - logEntries: AgentLogEntry[], - assignedAgent: Agent | null, - settings?: Settings, -): ModelSelection { - const fromLog = extractExecutorModelFromLog(logEntries); - if (fromLog) return fromLog; - - if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") { - const assignedModel = extractAssignedRuntimeModel(assignedAgent); - if (assignedModel.provider && assignedModel.modelId) { - return assignedModel; - } - } - - return resolveTaskExecutionModel(task, settings); -} - -/** - * Resolve the effective validator model following the engine's resolution order: - * 1. Runtime reviewer model from agent log marker - * 2. Assigned agent runtime model (active runs only) - * 3. Per-task validatorModelProvider/validatorModelId override - * 4. Project/global validator lane fallback - */ -function resolveEffectiveValidator( - task: Task | TaskDetail, - logEntries: AgentLogEntry[], - assignedAgent: Agent | null, - settings?: Settings, -): ModelSelection { - const fromLog = extractReviewerModelFromLog(logEntries); - if (fromLog) return fromLog; - - if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") { - const assignedModel = extractAssignedRuntimeModel(assignedAgent); - if (assignedModel.provider && assignedModel.modelId) { - return assignedModel; - } - } - - return resolveTaskValidatorModel(task, settings); -} - -/** - * Extract planning model from agent log entries. - * Looks for text entries with agent role "triage" matching the pattern: - * "Triage using model: /" - * Returns the latest match, or null if none found. - */ -function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null { - // Iterate in chronological order; last match wins - let result: { provider: string; modelId: string } | null = null; - entries.forEach((entry) => { - if (entry.agent !== "triage" || entry.type !== "text") return; - const match = entry.text.match(/^Triage using model: (.+?)\/(.+)$/); - if (match) { - result = { provider: match[1], modelId: match[2] }; - } - }); - return result; -} - -/** - * Resolve the effective planning model following the resolution order: - * 1. Per-task planningModelProvider/planningModelId override - * 2. Runtime triage model from agent log marker (if present) - * 3. Project/global planning lane fallback - */ -function resolveEffectivePlanning( - task: Task | TaskDetail, - logEntries: AgentLogEntry[], - settings?: Settings, -): ModelSelection { - // 1. Per-task override takes precedence - if (task.planningModelProvider && task.planningModelId) { - return { provider: task.planningModelProvider, modelId: task.planningModelId }; - } - // 2. Runtime triage model from agent log marker - const fromLog = extractPlanningModelFromLog(logEntries); - if (fromLog) { - return fromLog; - } - return resolveTaskPlanningModel(task, settings); -} - function toTaskChatModelInfo(model: ModelSelection): { provider: string; modelId?: string } | null { if (!model.provider) return null; return model.modelId ? { provider: model.provider, modelId: model.modelId } : { provider: model.provider }; @@ -3292,6 +3140,8 @@ export function TaskDetailContent({ taskStatus={task.status} taskPausedReason={task.pausedReason} settings={settings} + agentLogEntries={agentLogEntries} + assignedAgent={assignedAgent} onEditWorkflow={onOpenWorkflowEditor} /> diff --git a/packages/dashboard/app/components/WorkflowResultsTab.tsx b/packages/dashboard/app/components/WorkflowResultsTab.tsx index 6d7a05aa14..3e711c57c1 100644 --- a/packages/dashboard/app/components/WorkflowResultsTab.tsx +++ b/packages/dashboard/app/components/WorkflowResultsTab.tsx @@ -11,8 +11,8 @@ import { Check, ChevronDown, ChevronRight, ChevronUp, Maximize2, Pencil, X } fro import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { ReactFlow, ReactFlowProvider } from "@xyflow/react"; -import type { AgentLogEntry, Settings, Task, TaskDetail, WorkflowDefinition, WorkflowStep, WorkflowStepResult, ResolvedWorkflowOptionalStep } from "@fusion/core"; -import { getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core"; +import type { Agent, AgentLogEntry, Settings, Task, TaskDetail, WorkflowDefinition, WorkflowStep, WorkflowStepResult, ResolvedWorkflowOptionalStep } from "@fusion/core"; +import { getErrorMessage } from "@fusion/core"; import { approveTaskWorkflowCli, fetchBoardWorkflows, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, fetchWorkflowOptionalSteps, selectTaskWorkflow, submitTaskWorkflowInput } from "../api"; import { WorkflowSelector } from "./WorkflowSelector"; import { phaseBadge } from "./workflow-phase-badge"; @@ -22,6 +22,7 @@ import { irToFlow } from "./workflow-flow-mapping"; import { workflowNodeTypes } from "./nodes/WorkflowNodeTypes"; import type { Components } from "react-markdown"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; +import { resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator } from "./effective-model-resolution"; // Markdown rendering components for workflow output const markdownComponents: Components = { @@ -59,6 +60,8 @@ interface WorkflowResultsTabProps { taskStatus?: string; taskPausedReason?: string; settings?: Settings; + agentLogEntries?: AgentLogEntry[]; + assignedAgent?: Agent | null; onEditWorkflow?: () => void; /** U5 (R20): called after a workflow switch affects board placement * (any reconciliation result) so the board can refresh before the SSE @@ -310,6 +313,8 @@ export function WorkflowResultsTab({ taskStatus, taskPausedReason, settings, + agentLogEntries = [], + assignedAgent = null, onEditWorkflow, onWorkflowReconciled, }: WorkflowResultsTabProps) { @@ -608,9 +613,9 @@ export function WorkflowResultsTab({ const completedStepCount = useMemo(() => results.filter((result) => ["passed", "skipped", "failed", "advisory_failure"].includes(result.status)).length, [results]); const graphWorkflow = graphCacheKey ? workflowGraphCache[graphCacheKey] : undefined; const graphFlow = useMemo(() => (graphWorkflow ? irToFlow(graphWorkflow) : null), [graphWorkflow]); - const effectiveExecutor = useMemo(() => (task ? resolveTaskExecutionModel(task, settings) : undefined), [task, settings]); - const effectiveValidator = useMemo(() => (task ? resolveTaskValidatorModel(task, settings) : undefined), [task, settings]); - const effectivePlanning = useMemo(() => (task ? resolveTaskPlanningModel(task, settings) : undefined), [task, settings]); + const effectiveExecutor = useMemo(() => (task ? resolveEffectiveExecutor(task, agentLogEntries, assignedAgent, settings) : undefined), [agentLogEntries, assignedAgent, task, settings]); + const effectiveValidator = useMemo(() => (task ? resolveEffectiveValidator(task, agentLogEntries, assignedAgent, settings) : undefined), [agentLogEntries, assignedAgent, task, settings]); + const effectivePlanning = useMemo(() => (task ? resolveEffectivePlanning(task, agentLogEntries, settings) : undefined), [agentLogEntries, task, settings]); const renderEditor = () => { if (!canEdit || !isEditing || loading) { diff --git a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx index 6c525a1ba4..2f7371135d 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx @@ -4,7 +4,8 @@ import { WorkflowResultsTab } from "../WorkflowResultsTab"; import * as api from "../../api"; import { useAgentLogs } from "../../hooks/useAgentLogs"; import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture"; -import type { AgentLogEntry, Settings, Task, WorkflowDefinition, WorkflowStep, WorkflowStepResult } from "@fusion/core"; +import type { Agent, AgentLogEntry, Settings, Task, WorkflowDefinition, WorkflowStep, WorkflowStepResult } from "@fusion/core"; +import { resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator } from "../effective-model-resolution"; vi.mock("@xyflow/react", () => ({ ReactFlow: ({ nodes = [], edges = [] }: { nodes?: unknown[]; edges?: unknown[] }) => ( @@ -442,6 +443,77 @@ describe("WorkflowResultsTab", () => { expect(onWorkflowReconciled).toHaveBeenCalledTimes(1); }); + it("shows Workflow model settings that match the Chat effective model resolver for runtime markers", async () => { + const activeTask = { + ...baseTask, + status: "executing", + column: "in-progress", + modelProvider: "configured-executor", + modelId: "configured-executor-model", + validatorModelProvider: "configured-reviewer", + validatorModelId: "configured-reviewer-model", + planningModelProvider: null, + planningModelId: null, + } as Task; + const agentLogEntries: AgentLogEntry[] = [ + { + timestamp: "2026-06-25T00:00:00Z", + taskId: "FN-001", + agent: "executor", + type: "text", + text: "Executor using model: runtime-executor/runtime-executor-model", + }, + { + timestamp: "2026-06-25T00:00:01Z", + taskId: "FN-001", + agent: "reviewer", + type: "text", + text: "Reviewer using model: runtime-reviewer/runtime-reviewer-model", + }, + { + timestamp: "2026-06-25T00:00:02Z", + taskId: "FN-001", + agent: "triage", + type: "text", + text: "Triage using model: runtime-planning/runtime-planning-model", + }, + ]; + const assignedAgent = { + id: "agent-runtime", + name: "Runtime Agent", + role: "executor", + state: "running", + createdAt: "2026-06-25T00:00:00Z", + updatedAt: "2026-06-25T00:00:00Z", + metadata: {}, + runtimeConfig: { model: "assigned-provider/assigned-model" }, + } as Agent; + + render( + , + ); + + await screen.findByTestId("workflow-state-summary-name"); + fireEvent.click(screen.getByTestId("workflow-model-settings-toggle")); + + const chatExecutor = resolveEffectiveExecutor(activeTask, agentLogEntries, assignedAgent, mockSettings); + const chatReviewer = resolveEffectiveValidator(activeTask, agentLogEntries, assignedAgent, mockSettings); + const chatPlanning = resolveEffectivePlanning(activeTask, agentLogEntries, mockSettings); + + await waitFor(() => expect(screen.getByTestId("workflow-model-setting-executor")).toHaveTextContent(`${chatExecutor.provider}/${chatExecutor.modelId}`)); + expect(screen.getByTestId("workflow-model-setting-reviewer")).toHaveTextContent(`${chatReviewer.provider}/${chatReviewer.modelId}`); + expect(screen.getByTestId("workflow-model-setting-planning")).toHaveTextContent(`${chatPlanning.provider}/${chatPlanning.modelId}`); + expect(screen.getByTestId("workflow-model-setting-executor")).not.toHaveTextContent("configured-executor/configured-executor-model"); + expect(screen.getByTestId("workflow-model-setting-reviewer")).not.toHaveTextContent("configured-reviewer/configured-reviewer-model"); + }); + it("shows effective model settings and default fallbacks", async () => { const { rerender } = render( , diff --git a/packages/dashboard/app/components/__tests__/effective-model-resolution.test.ts b/packages/dashboard/app/components/__tests__/effective-model-resolution.test.ts new file mode 100644 index 0000000000..6ec7d6f4a4 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/effective-model-resolution.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import type { Agent, AgentLogEntry, Settings, Task } from "@fusion/core"; +import { + extractAssignedRuntimeModel, + extractExecutorModelFromLog, + extractPlanningModelFromLog, + extractReviewerModelFromLog, + resolveEffectiveExecutor, + resolveEffectivePlanning, + resolveEffectiveValidator, +} from "../effective-model-resolution"; + +const baseTask: Task = { + id: "FN-7040", + title: "Align models", + status: "todo", + column: "todo", + createdAt: "2026-06-25T00:00:00Z", + updatedAt: "2026-06-25T00:00:00Z", + dependencies: [], + outputBranch: null, + prompt: "", + baseBranch: null, + assignee: null, + labels: [], + priority: "normal", + autoMerge: false, + autoMergeMode: "squash", + paused: false, + userPaused: false, +} as Task; + +const settings: Settings = { + executionProvider: "settings-executor", + executionModelId: "settings-executor-model", + validatorProvider: "settings-reviewer", + validatorModelId: "settings-reviewer-model", + planningProvider: "settings-planning", + planningModelId: "settings-planning-model", +} as Settings; + +function log(agent: AgentLogEntry["agent"], text: string): AgentLogEntry { + return { + timestamp: "2026-06-25T00:00:00Z", + taskId: "FN-7040", + agent, + type: "text", + text, + }; +} + +function runtimeAgent(runtimeConfig?: Record): Agent { + return { + id: "agent-1", + name: "Executor", + role: "executor", + state: "running", + createdAt: "2026-06-25T00:00:00Z", + updatedAt: "2026-06-25T00:00:00Z", + metadata: {}, + runtimeConfig, + } as Agent; +} + +describe("effective model resolution", () => { + it("extracts the latest role-specific model marker from agent logs", () => { + const entries = [ + log("executor", "Executor using model: old-provider/old-model"), + log("reviewer", "Reviewer using model: reviewer-provider/reviewer-model"), + log("triage", "Triage using model: triage-provider/triage-model"), + log("executor", "Executor using model: new-provider/new-model"), + ]; + + expect(extractExecutorModelFromLog(entries)).toEqual({ provider: "new-provider", modelId: "new-model" }); + expect(extractReviewerModelFromLog(entries)).toEqual({ provider: "reviewer-provider", modelId: "reviewer-model" }); + expect(extractPlanningModelFromLog(entries)).toEqual({ provider: "triage-provider", modelId: "triage-model" }); + }); + + it("parses assigned-agent runtime models from combined or split fields", () => { + expect(extractAssignedRuntimeModel(runtimeAgent({ model: "runtime-provider/runtime-model" }))).toEqual({ provider: "runtime-provider", modelId: "runtime-model" }); + expect(extractAssignedRuntimeModel(runtimeAgent({ modelProvider: "split-provider", modelId: "split-model" }))).toEqual({ provider: "split-provider", modelId: "split-model" }); + expect(extractAssignedRuntimeModel(runtimeAgent({ model: "malformed" }))).toEqual({ provider: undefined, modelId: undefined }); + expect(extractAssignedRuntimeModel(null)).toEqual({ provider: undefined, modelId: undefined }); + }); + + it("resolves executor from log marker before assigned runtime, task override, and settings fallback", () => { + const task = { ...baseTask, status: "executing", column: "in-progress", modelProvider: "task-provider", modelId: "task-model" } as Task; + + expect(resolveEffectiveExecutor(task, [log("executor", "Executor using model: log-provider/log-model")], runtimeAgent({ model: "runtime-provider/runtime-model" }), settings)).toEqual({ provider: "log-provider", modelId: "log-model" }); + expect(resolveEffectiveExecutor(task, [], runtimeAgent({ model: "runtime-provider/runtime-model" }), settings)).toEqual({ provider: "runtime-provider", modelId: "runtime-model" }); + expect(resolveEffectiveExecutor({ ...task, status: "todo", column: "todo" } as Task, [], runtimeAgent({ model: "runtime-provider/runtime-model" }), settings)).toEqual({ provider: "task-provider", modelId: "task-model" }); + expect(resolveEffectiveExecutor({ ...baseTask, modelProvider: null, modelId: null } as Task, [], null, settings)).toEqual({ provider: "settings-executor", modelId: "settings-executor-model" }); + }); + + it("resolves validator from reviewer log marker before assigned runtime, task override, and settings fallback", () => { + const task = { ...baseTask, status: "executing", column: "in-progress", validatorModelProvider: "task-reviewer", validatorModelId: "task-reviewer-model" } as Task; + + expect(resolveEffectiveValidator(task, [log("reviewer", "Reviewer using model: log-reviewer/log-reviewer-model")], runtimeAgent({ model: "runtime-provider/runtime-model" }), settings)).toEqual({ provider: "log-reviewer", modelId: "log-reviewer-model" }); + expect(resolveEffectiveValidator(task, [], runtimeAgent({ model: "runtime-provider/runtime-model" }), settings)).toEqual({ provider: "runtime-provider", modelId: "runtime-model" }); + expect(resolveEffectiveValidator({ ...task, status: "done", column: "done" } as Task, [], runtimeAgent({ model: "runtime-provider/runtime-model" }), settings)).toEqual({ provider: "task-reviewer", modelId: "task-reviewer-model" }); + expect(resolveEffectiveValidator({ ...baseTask, validatorModelProvider: null, validatorModelId: null } as Task, [], null, settings)).toEqual({ provider: "settings-reviewer", modelId: "settings-reviewer-model" }); + }); + + it("resolves planning from task override before triage log marker and settings fallback", () => { + const task = { ...baseTask, planningModelProvider: "task-planning", planningModelId: "task-planning-model" } as Task; + + expect(resolveEffectivePlanning(task, [log("triage", "Triage using model: log-planning/log-planning-model")], settings)).toEqual({ provider: "task-planning", modelId: "task-planning-model" }); + expect(resolveEffectivePlanning({ ...baseTask, planningModelProvider: null, planningModelId: null } as Task, [log("triage", "Triage using model: log-planning/log-planning-model")], settings)).toEqual({ provider: "log-planning", modelId: "log-planning-model" }); + expect(resolveEffectivePlanning({ ...baseTask, planningModelProvider: null, planningModelId: null } as Task, [], settings)).toEqual({ provider: "settings-planning", modelId: "settings-planning-model" }); + }); +}); diff --git a/packages/dashboard/app/components/effective-model-resolution.ts b/packages/dashboard/app/components/effective-model-resolution.ts new file mode 100644 index 0000000000..53510c6b1a --- /dev/null +++ b/packages/dashboard/app/components/effective-model-resolution.ts @@ -0,0 +1,149 @@ +import type { Agent, AgentLogEntry, ResolvedModelSelection, Settings, Task, TaskDetail } from "@fusion/core"; +import { resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core"; + +export type ModelSelection = ResolvedModelSelection; + +export const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]); + +const STRING_OBJECT_TAG = "[object String]"; + +function isStringValue(value: unknown): value is string { + return Object.prototype.toString.call(value) === STRING_OBJECT_TAG; +} + +/* +FNXC:ModelResolution 2026-06-25-00:00: +FN-7040 requires the Chat tab, Agent Log header, and Workflow tab Model settings to share one effective model resolver so runtime log markers, active assigned-agent runtime models, task overrides, and settings fallbacks never diverge between task-detail surfaces. +*/ +export function extractExecutorModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null { + let result: { provider: string; modelId: string } | null = null; + entries.forEach((entry) => { + if (entry.agent !== "executor" || entry.type !== "text") return; + const match = entry.text.match(/^Executor using model: (.+?)\/(.+)$/); + if (match) { + result = { provider: match[1], modelId: match[2] }; + } + }); + return result; +} + +export function extractReviewerModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null { + let result: { provider: string; modelId: string } | null = null; + entries.forEach((entry) => { + if (entry.agent !== "reviewer" || entry.type !== "text") return; + const match = entry.text.match(/^Reviewer using model: (.+?)\/(.+)$/); + if (match) { + result = { provider: match[1], modelId: match[2] }; + } + }); + return result; +} + +export function extractAssignedRuntimeModel(agent: Agent | null | undefined): ModelSelection { + const runtimeConfig = (agent?.runtimeConfig ?? undefined) as Record | undefined; + const model = isStringValue(runtimeConfig?.model) ? runtimeConfig.model.trim() : ""; + if (model) { + const slashIdx = model.indexOf("/"); + if (slashIdx > 0 && slashIdx < model.length - 1) { + return { + provider: model.slice(0, slashIdx), + modelId: model.slice(slashIdx + 1), + }; + } + } + + const provider = isStringValue(runtimeConfig?.modelProvider) ? runtimeConfig.modelProvider.trim() : ""; + const modelId = isStringValue(runtimeConfig?.modelId) ? runtimeConfig.modelId.trim() : ""; + return { + provider: provider || undefined, + modelId: modelId || undefined, + }; +} + +/** + * Resolve the effective executor model following the dashboard display resolution order: + * 1. Runtime executor model from agent log marker + * 2. Assigned agent runtime model (active runs only) + * 3. Per-task modelProvider/modelId override + * 4. Project/global execution lane fallback + */ +export function resolveEffectiveExecutor( + task: Task | TaskDetail, + logEntries: AgentLogEntry[], + assignedAgent: Agent | null, + settings?: Settings, +): ModelSelection { + const fromLog = extractExecutorModelFromLog(logEntries); + if (fromLog) return fromLog; + + if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") { + const assignedModel = extractAssignedRuntimeModel(assignedAgent); + if (assignedModel.provider && assignedModel.modelId) { + return assignedModel; + } + } + + return resolveTaskExecutionModel(task, settings); +} + +/** + * Resolve the effective validator model following the dashboard display resolution order. + * Merger display intentionally reuses this reviewer/validator lane in TaskDetailModal. + */ +export function resolveEffectiveValidator( + task: Task | TaskDetail, + logEntries: AgentLogEntry[], + assignedAgent: Agent | null, + settings?: Settings, +): ModelSelection { + const fromLog = extractReviewerModelFromLog(logEntries); + if (fromLog) return fromLog; + + if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") { + const assignedModel = extractAssignedRuntimeModel(assignedAgent); + if (assignedModel.provider && assignedModel.modelId) { + return assignedModel; + } + } + + return resolveTaskValidatorModel(task, settings); +} + +/** + * Extract planning model from agent log entries. + * Looks for text entries with agent role "triage" matching the pattern: + * "Triage using model: /" + * Returns the latest match, or null if none found. + */ +export function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null { + let result: { provider: string; modelId: string } | null = null; + entries.forEach((entry) => { + if (entry.agent !== "triage" || entry.type !== "text") return; + const match = entry.text.match(/^Triage using model: (.+?)\/(.+)$/); + if (match) { + result = { provider: match[1], modelId: match[2] }; + } + }); + return result; +} + +/** + * Resolve the effective planning model following the preserved dashboard order: + * 1. Per-task planningModelProvider/planningModelId override + * 2. Runtime triage model from agent log marker + * 3. Project/global planning lane fallback + */ +export function resolveEffectivePlanning( + task: Task | TaskDetail, + logEntries: AgentLogEntry[], + settings?: Settings, +): ModelSelection { + if (task.planningModelProvider && task.planningModelId) { + return { provider: task.planningModelProvider, modelId: task.planningModelId }; + } + const fromLog = extractPlanningModelFromLog(logEntries); + if (fromLog) { + return fromLog; + } + return resolveTaskPlanningModel(task, settings); +}