FN-7040: align task-detail model displays
Unify task-detail model resolution so Chat, Agent Log, and Workflow settings display the same effective model. - Add a shared dashboard resolver for executor, validator, and planning model display precedence. - Reuse the resolver in TaskDetailModal and WorkflowResultsTab instead of duplicating fallback logic. - Cover log marker, assigned runtime model, task override, and settings fallback cases with focused tests. - Add a patch changeset for the published Fusion package. Files changed: .../FN-7040-align-task-detail-model-display.md | 7 + .../dashboard/app/components/TaskDetailModal.tsx | 158 +-------------------- .../app/components/WorkflowResultsTab.tsx | 15 +- .../__tests__/WorkflowResultsTab.test.tsx | 74 +++++++++- .../__tests__/effective-model-resolution.test.ts | 111 +++++++++++++++ .../app/components/effective-model-resolution.ts | 149 +++++++++++++++++++ 6 files changed, 354 insertions(+), 160 deletions(-) Fusion-Task-Id: FN-7040 Fusion-Task-Lineage: 05ec3ae7-5063-4219-a515-da16168e9a19
This commit is contained in:
7
.changeset/FN-7040-align-task-detail-model-display.md
Normal file
7
.changeset/FN-7040-align-task-detail-model-display.md
Normal file
@@ -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.
|
||||||
@@ -10,7 +10,7 @@ import ReactMarkdown from "react-markdown";
|
|||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { sharedRehypePlugins, createMermaidCodeComponent } from "./markdownPipeline";
|
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 {
|
import {
|
||||||
DEFAULT_TASK_PRIORITY,
|
DEFAULT_TASK_PRIORITY,
|
||||||
REPO_OVERRIDE_RE,
|
REPO_OVERRIDE_RE,
|
||||||
@@ -18,9 +18,6 @@ import {
|
|||||||
VALID_TRANSITIONS,
|
VALID_TRANSITIONS,
|
||||||
isColumn,
|
isColumn,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
resolveTaskExecutionModel,
|
|
||||||
resolveTaskPlanningModel,
|
|
||||||
resolveTaskValidatorModel,
|
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
|
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
|
||||||
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
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 { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry";
|
||||||
import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay";
|
import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay";
|
||||||
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
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 STALE_PAUSED_REVIEW_LOG_REGEX = /^Stale paused review surfaced \[([^\]]+)\]/;
|
||||||
const EMPTY_MARKDOWN_CHILD_SEPARATOR = "";
|
const EMPTY_MARKDOWN_CHILD_SEPARATOR = "";
|
||||||
const STRING_OBJECT_TAG = "[object String]";
|
const STRING_OBJECT_TAG = "[object String]";
|
||||||
@@ -107,35 +99,6 @@ const markdownLinkifyComponents: Components = {
|
|||||||
code: createMermaidCodeComponent("task-detail-mermaid-diagram", markdownLinkifyCodeComponent),
|
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 {
|
function hasUsableTrackingTitle(task: { title?: string | null; description?: string | null }): boolean {
|
||||||
if ((task.title ?? "").trim().length > 0) {
|
if ((task.title ?? "").trim().length > 0) {
|
||||||
return true;
|
return true;
|
||||||
@@ -149,121 +112,6 @@ function hasUsableTrackingTitle(task: { title?: string | null; description?: str
|
|||||||
return Boolean(firstMeaningfulLine);
|
return Boolean(firstMeaningfulLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAssignedRuntimeModel(agent: Agent | null | undefined): ModelSelection {
|
|
||||||
const runtimeConfig = (agent?.runtimeConfig ?? undefined) as Record<string, unknown> | 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: <provider>/<modelId>"
|
|
||||||
* 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 {
|
function toTaskChatModelInfo(model: ModelSelection): { provider: string; modelId?: string } | null {
|
||||||
if (!model.provider) return null;
|
if (!model.provider) return null;
|
||||||
return model.modelId ? { provider: model.provider, modelId: model.modelId } : { provider: model.provider };
|
return model.modelId ? { provider: model.provider, modelId: model.modelId } : { provider: model.provider };
|
||||||
@@ -3292,6 +3140,8 @@ export function TaskDetailContent({
|
|||||||
taskStatus={task.status}
|
taskStatus={task.status}
|
||||||
taskPausedReason={task.pausedReason}
|
taskPausedReason={task.pausedReason}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
agentLogEntries={agentLogEntries}
|
||||||
|
assignedAgent={assignedAgent}
|
||||||
onEditWorkflow={onOpenWorkflowEditor}
|
onEditWorkflow={onOpenWorkflowEditor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { Check, ChevronDown, ChevronRight, ChevronUp, Maximize2, Pencil, X } fro
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { ReactFlow, ReactFlowProvider } from "@xyflow/react";
|
import { ReactFlow, ReactFlowProvider } from "@xyflow/react";
|
||||||
import type { AgentLogEntry, Settings, Task, TaskDetail, WorkflowDefinition, WorkflowStep, WorkflowStepResult, ResolvedWorkflowOptionalStep } from "@fusion/core";
|
import type { Agent, AgentLogEntry, Settings, Task, TaskDetail, WorkflowDefinition, WorkflowStep, WorkflowStepResult, ResolvedWorkflowOptionalStep } from "@fusion/core";
|
||||||
import { getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import { approveTaskWorkflowCli, fetchBoardWorkflows, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, fetchWorkflowOptionalSteps, selectTaskWorkflow, submitTaskWorkflowInput } from "../api";
|
import { approveTaskWorkflowCli, fetchBoardWorkflows, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, fetchWorkflowOptionalSteps, selectTaskWorkflow, submitTaskWorkflowInput } from "../api";
|
||||||
import { WorkflowSelector } from "./WorkflowSelector";
|
import { WorkflowSelector } from "./WorkflowSelector";
|
||||||
import { phaseBadge } from "./workflow-phase-badge";
|
import { phaseBadge } from "./workflow-phase-badge";
|
||||||
@@ -22,6 +22,7 @@ import { irToFlow } from "./workflow-flow-mapping";
|
|||||||
import { workflowNodeTypes } from "./nodes/WorkflowNodeTypes";
|
import { workflowNodeTypes } from "./nodes/WorkflowNodeTypes";
|
||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||||
|
import { resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator } from "./effective-model-resolution";
|
||||||
|
|
||||||
// Markdown rendering components for workflow output
|
// Markdown rendering components for workflow output
|
||||||
const markdownComponents: Components = {
|
const markdownComponents: Components = {
|
||||||
@@ -59,6 +60,8 @@ interface WorkflowResultsTabProps {
|
|||||||
taskStatus?: string;
|
taskStatus?: string;
|
||||||
taskPausedReason?: string;
|
taskPausedReason?: string;
|
||||||
settings?: Settings;
|
settings?: Settings;
|
||||||
|
agentLogEntries?: AgentLogEntry[];
|
||||||
|
assignedAgent?: Agent | null;
|
||||||
onEditWorkflow?: () => void;
|
onEditWorkflow?: () => void;
|
||||||
/** U5 (R20): called after a workflow switch affects board placement
|
/** U5 (R20): called after a workflow switch affects board placement
|
||||||
* (any reconciliation result) so the board can refresh before the SSE
|
* (any reconciliation result) so the board can refresh before the SSE
|
||||||
@@ -310,6 +313,8 @@ export function WorkflowResultsTab({
|
|||||||
taskStatus,
|
taskStatus,
|
||||||
taskPausedReason,
|
taskPausedReason,
|
||||||
settings,
|
settings,
|
||||||
|
agentLogEntries = [],
|
||||||
|
assignedAgent = null,
|
||||||
onEditWorkflow,
|
onEditWorkflow,
|
||||||
onWorkflowReconciled,
|
onWorkflowReconciled,
|
||||||
}: WorkflowResultsTabProps) {
|
}: 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 completedStepCount = useMemo(() => results.filter((result) => ["passed", "skipped", "failed", "advisory_failure"].includes(result.status)).length, [results]);
|
||||||
const graphWorkflow = graphCacheKey ? workflowGraphCache[graphCacheKey] : undefined;
|
const graphWorkflow = graphCacheKey ? workflowGraphCache[graphCacheKey] : undefined;
|
||||||
const graphFlow = useMemo(() => (graphWorkflow ? irToFlow(graphWorkflow) : null), [graphWorkflow]);
|
const graphFlow = useMemo(() => (graphWorkflow ? irToFlow(graphWorkflow) : null), [graphWorkflow]);
|
||||||
const effectiveExecutor = useMemo(() => (task ? resolveTaskExecutionModel(task, settings) : undefined), [task, settings]);
|
const effectiveExecutor = useMemo(() => (task ? resolveEffectiveExecutor(task, agentLogEntries, assignedAgent, settings) : undefined), [agentLogEntries, assignedAgent, task, settings]);
|
||||||
const effectiveValidator = useMemo(() => (task ? resolveTaskValidatorModel(task, settings) : undefined), [task, settings]);
|
const effectiveValidator = useMemo(() => (task ? resolveEffectiveValidator(task, agentLogEntries, assignedAgent, settings) : undefined), [agentLogEntries, assignedAgent, task, settings]);
|
||||||
const effectivePlanning = useMemo(() => (task ? resolveTaskPlanningModel(task, settings) : undefined), [task, settings]);
|
const effectivePlanning = useMemo(() => (task ? resolveEffectivePlanning(task, agentLogEntries, settings) : undefined), [agentLogEntries, task, settings]);
|
||||||
|
|
||||||
const renderEditor = () => {
|
const renderEditor = () => {
|
||||||
if (!canEdit || !isEditing || loading) {
|
if (!canEdit || !isEditing || loading) {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { WorkflowResultsTab } from "../WorkflowResultsTab";
|
|||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import { useAgentLogs } from "../../hooks/useAgentLogs";
|
import { useAgentLogs } from "../../hooks/useAgentLogs";
|
||||||
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
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", () => ({
|
vi.mock("@xyflow/react", () => ({
|
||||||
ReactFlow: ({ nodes = [], edges = [] }: { nodes?: unknown[]; edges?: unknown[] }) => (
|
ReactFlow: ({ nodes = [], edges = [] }: { nodes?: unknown[]; edges?: unknown[] }) => (
|
||||||
@@ -442,6 +443,77 @@ describe("WorkflowResultsTab", () => {
|
|||||||
expect(onWorkflowReconciled).toHaveBeenCalledTimes(1);
|
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(
|
||||||
|
<WorkflowResultsTab
|
||||||
|
taskId="FN-001"
|
||||||
|
task={activeTask}
|
||||||
|
settings={mockSettings}
|
||||||
|
results={mockResults}
|
||||||
|
agentLogEntries={agentLogEntries}
|
||||||
|
assignedAgent={assignedAgent}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
it("shows effective model settings and default fallbacks", async () => {
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} />,
|
<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} />,
|
||||||
|
|||||||
@@ -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<string, unknown>): 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
149
packages/dashboard/app/components/effective-model-resolution.ts
Normal file
149
packages/dashboard/app/components/effective-model-resolution.ts
Normal file
@@ -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<string, unknown> | 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: <provider>/<modelId>"
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user