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 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<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 {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
<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 () => {
|
||||
const { rerender } = render(
|
||||
<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