feat(FN-3581): honor runtime model precedence for assigned agents
This merge delivers five major feature clusters: a fully rebuilt dependency graph plugin with draggable nodes, position persistence, modular architecture, highlighting and selection states, toolbar navigation, and keyboard controls; a new roadmap plugin with domain store, ordering logic, and compreh Fusion-Task-Id: FN-3581
This commit is contained in:
@@ -53,22 +53,100 @@ const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finali
|
||||
* 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;
|
||||
for (const entry of entries) {
|
||||
if (entry.agent !== "executor" || entry.type !== "text") continue;
|
||||
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;
|
||||
for (const entry of entries) {
|
||||
if (entry.agent !== "reviewer" || entry.type !== "text") continue;
|
||||
const match = entry.text.match(/^Reviewer using model: (.+?)\/(.+)$/);
|
||||
if (match) {
|
||||
result = { provider: match[1], modelId: match[2] };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractAssignedRuntimeModel(agent: Agent | null | undefined): ModelSelection {
|
||||
const runtimeConfig = (agent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined;
|
||||
const model = typeof runtimeConfig?.model === "string" ? 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 = typeof runtimeConfig?.modelProvider === "string" ? runtimeConfig.modelProvider.trim() : "";
|
||||
const modelId = typeof runtimeConfig?.modelId === "string" ? 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. Per-task validatorModelProvider/validatorModelId (both must be set)
|
||||
* 2. Project/global validator lane fallback
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -1952,8 +2030,8 @@ export function TaskDetailContent({
|
||||
<AgentLogViewer
|
||||
entries={agentLogEntries}
|
||||
loading={agentLogLoading}
|
||||
executorModel={resolveEffectiveExecutor(task, settings)}
|
||||
validatorModel={resolveEffectiveValidator(task, settings)}
|
||||
executorModel={resolveEffectiveExecutor(task, agentLogEntries, assignedAgent, settings)}
|
||||
validatorModel={resolveEffectiveValidator(task, agentLogEntries, assignedAgent, settings)}
|
||||
planningModel={resolveEffectivePlanning(task, agentLogEntries, settings)}
|
||||
hasMore={agentLogHasMore}
|
||||
onLoadMore={loadMoreAgentLogs}
|
||||
|
||||
@@ -478,6 +478,113 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows executor/reviewer models from runtime agent-log markers", async () => {
|
||||
const { fetchSettings } = await import("../../api");
|
||||
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
||||
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
} as any);
|
||||
|
||||
vi.mocked(useAgentLogs).mockReturnValue({
|
||||
entries: [
|
||||
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Executor using model: openai/gpt-4o", type: "text" as const, agent: "executor" },
|
||||
{ timestamp: "2026-01-01T00:00:02Z", taskId: "FN-099", text: "Reviewer using model: google/gemini-2.5-pro", type: "text" as const, agent: "reviewer" },
|
||||
],
|
||||
loading: false,
|
||||
clear: vi.fn(),
|
||||
loadMore: vi.fn(async () => {}),
|
||||
hasMore: false,
|
||||
total: null,
|
||||
loadingMore: false,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Logs"));
|
||||
fireEvent.click(screen.getByText("Agent Log"));
|
||||
await waitFor(() => {
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
||||
expect(header).toBeTruthy();
|
||||
});
|
||||
const expandButton = screen.getByTestId("agent-log-model-expand") as HTMLButtonElement;
|
||||
if (expandButton.getAttribute("aria-expanded") !== "true") {
|
||||
fireEvent.click(expandButton);
|
||||
}
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement;
|
||||
expect(header.textContent).toContain("openai/gpt-4o");
|
||||
expect(header.textContent).toContain("google/gemini-2.5-pro");
|
||||
});
|
||||
|
||||
it("falls back to assigned-agent runtime model when no runtime marker exists", async () => {
|
||||
const { fetchSettings, fetchAgent } = await import("../../api");
|
||||
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
||||
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
} as any);
|
||||
vi.mocked(fetchAgent).mockResolvedValueOnce({
|
||||
id: "agent-1",
|
||||
name: "Agent One",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
runtimeConfig: { model: "openai/gpt-4.1" },
|
||||
} as any);
|
||||
|
||||
vi.mocked(useAgentLogs).mockReturnValue({
|
||||
entries: [{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const }],
|
||||
loading: false,
|
||||
clear: vi.fn(),
|
||||
loadMore: vi.fn(async () => {}),
|
||||
hasMore: false,
|
||||
total: null,
|
||||
loadingMore: false,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ prompt: "# Hello\n\nContent", assignedAgentId: "agent-1", status: "executing", column: "in-progress" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Logs"));
|
||||
fireEvent.click(screen.getByText("Agent Log"));
|
||||
await waitFor(() => {
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
||||
expect(header).toBeTruthy();
|
||||
});
|
||||
const expandButton = screen.getByTestId("agent-log-model-expand") as HTMLButtonElement;
|
||||
if (expandButton.getAttribute("aria-expanded") !== "true") {
|
||||
fireEvent.click(expandButton);
|
||||
}
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement;
|
||||
expect(header.textContent).toContain("openai/gpt-4.1");
|
||||
});
|
||||
|
||||
describe("step progress", () => {
|
||||
it("renders step progress section when steps exist", () => {
|
||||
const { container } = render(
|
||||
|
||||
Reference in New Issue
Block a user