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:
Fusion
2026-05-07 06:39:55 -07:00
committed by gsxdsm
parent be54c30980
commit 5ffd1485c3
11 changed files with 417 additions and 114 deletions

View File

@@ -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}

View File

@@ -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(