feat(FN-768): resolve effective executor/validator models in Agent Log header
- Display resolved executor and validator model names in the Agent Log header of TaskDetailModal - Add comprehensive UI tests for model resolution in TaskDetailModal - Add missing fetchSettings mock to PlanningModeModal test - Remove unused project-store-resolver module, UsageIndicator tests, and usage tests - Clean up unused styles and API references
This commit is contained in:
@@ -46,7 +46,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level
|
||||
- **Model Selection at Creation**: Choose executor and validator AI models while creating tasks from the board or list view, or leave them unset to use the global defaults.
|
||||
- **Layered Model Dropdowns**: Shared model combobox menus render in a top-level portal attached to `document.body`, so they stay above board columns and scrollable modal content instead of being clipped behind surrounding dashboard surfaces.
|
||||
- **Bulk Model Editing**: Update AI model configuration for multiple tasks at once in the list view. Select tasks via checkboxes (archived tasks excluded), then use the "Bulk Edit Models" toolbar to apply executor and/or validator model changes to all selected tasks. Selection persists in localStorage across page reloads.
|
||||
- **Task Details**: View full task specifications, agent logs, and attachments
|
||||
- **Task Details**: View full task specifications, agent logs, and attachments. The Agent Log tab header shows the effective executor and validator model names resolved from task-level overrides or project/global settings fallbacks, matching the same resolution order the engine uses at runtime.
|
||||
- **Changed Files Viewer**: Click a task card's "files changed" button to open a dedicated diff viewer showing only files changed in that task worktree, with per-file statuses and sidebar navigation
|
||||
- **GitHub Import**: Import issues directly from GitHub repositories
|
||||
- **PR Management**: Create, monitor, and merge pull requests for in-review tasks
|
||||
|
||||
@@ -39,6 +39,13 @@ vi.mock("../api", () => ({
|
||||
approvePlan: (...args: any[]) => mockApprovePlan(...args),
|
||||
rejectPlan: (...args: any[]) => mockRejectPlan(...args),
|
||||
refineTask: (...args: any[]) => mockRefineTask(...args),
|
||||
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
duplicateTask: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo } from "@fusion/core";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo, Settings } from "@fusion/core";
|
||||
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@fusion/core";
|
||||
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, requestSpecRevision, approvePlan, rejectPlan, refineTask } from "../api";
|
||||
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, requestSpecRevision, approvePlan, rejectPlan, refineTask } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
@@ -39,6 +39,46 @@ function getValidatorSelection(task: Task | TaskDetail): ModelSelection {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective executor model following the engine's resolution order:
|
||||
* 1. Per-task modelProvider/modelId (both must be set)
|
||||
* 2. Global settings defaultProvider/defaultModelId
|
||||
*/
|
||||
function resolveEffectiveExecutor(
|
||||
task: Task | TaskDetail,
|
||||
settings?: Settings,
|
||||
): ModelSelection {
|
||||
if (task.modelProvider && task.modelId) {
|
||||
return { provider: task.modelProvider, modelId: task.modelId };
|
||||
}
|
||||
if (settings?.defaultProvider && settings.defaultModelId) {
|
||||
return { provider: settings.defaultProvider, modelId: settings.defaultModelId };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective validator model following the engine's resolution order:
|
||||
* 1. Per-task validatorModelProvider/validatorModelId (both must be set)
|
||||
* 2. Project settings validatorProvider/validatorModelId
|
||||
* 3. Global settings defaultProvider/defaultModelId
|
||||
*/
|
||||
function resolveEffectiveValidator(
|
||||
task: Task | TaskDetail,
|
||||
settings?: Settings,
|
||||
): ModelSelection {
|
||||
if (task.validatorModelProvider && task.validatorModelId) {
|
||||
return { provider: task.validatorModelProvider, modelId: task.validatorModelId };
|
||||
}
|
||||
if (settings?.validatorProvider && settings.validatorModelId) {
|
||||
return { provider: settings.validatorProvider, modelId: settings.validatorModelId };
|
||||
}
|
||||
if (settings?.defaultProvider && settings.defaultModelId) {
|
||||
return { provider: settings.defaultProvider, modelId: settings.defaultModelId };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getStepStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "done":
|
||||
@@ -137,6 +177,9 @@ export function TaskDetailModal({
|
||||
const [editPendingImages, setEditPendingImages] = useState<PendingImage[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Merged project settings for effective model resolution in Agent Log header
|
||||
const [settings, setSettings] = useState<Settings | undefined>(undefined);
|
||||
|
||||
// Reset edit state when task changes
|
||||
useEffect(() => {
|
||||
setEditTitle(task.title || "");
|
||||
@@ -144,6 +187,19 @@ export function TaskDetailModal({
|
||||
setIsEditing(false);
|
||||
}, [task.id, task.title, task.description]);
|
||||
|
||||
// Load merged settings for effective model resolution
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchSettings(projectId)
|
||||
.then((s) => {
|
||||
if (!cancelled) setSettings(s);
|
||||
})
|
||||
.catch(() => {
|
||||
// Settings fetch failure is non-blocking; fallback to "Using default"
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [projectId]);
|
||||
|
||||
// Reset dependency search when dropdown closes
|
||||
useEffect(() => {
|
||||
if (!showDepDropdown) {
|
||||
@@ -747,8 +803,8 @@ export function TaskDetailModal({
|
||||
<AgentLogViewer
|
||||
entries={agentLogEntries}
|
||||
loading={agentLogLoading}
|
||||
executorModel={getExecutorSelection(task)}
|
||||
validatorModel={getValidatorSelection(task)}
|
||||
executorModel={resolveEffectiveExecutor(task, settings)}
|
||||
validatorModel={resolveEffectiveValidator(task, settings)}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "changes" ? (
|
||||
|
||||
@@ -900,6 +900,266 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent Log model resolution", () => {
|
||||
// AgentLogViewer only renders the model header when entries.length > 0,
|
||||
// so we mock useAgentLogs to return at least one entry.
|
||||
const mockLogEntry = { timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const };
|
||||
|
||||
async function setupModelTest(settingsOverrides: Record<string, any> = {}) {
|
||||
const { fetchSettings } = await import("../../api");
|
||||
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
||||
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
...settingsOverrides,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useAgentLogs).mockReturnValue({
|
||||
entries: [mockLogEntry],
|
||||
loading: false,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
|
||||
return render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async function setupModelTestWithTask(taskOverrides: Partial<TaskDetail>, settingsOverrides: Record<string, any> = {}) {
|
||||
const { fetchSettings } = await import("../../api");
|
||||
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
||||
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
...settingsOverrides,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useAgentLogs).mockReturnValue({
|
||||
entries: [mockLogEntry],
|
||||
loading: false,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
|
||||
return render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ prompt: "# Hello\n\nContent", ...taskOverrides })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("shows resolved executor from settings when task has no explicit executor override", async () => {
|
||||
const { container } = await setupModelTest({
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Agent Log"));
|
||||
|
||||
await waitFor(() => {
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
||||
expect(header).toBeTruthy();
|
||||
expect(header!.textContent).toContain("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
// Validator should also fall back to the default
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
||||
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("shows resolved validator from project validator settings when task has no validator override", async () => {
|
||||
const { container } = await setupModelTest({
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Agent Log"));
|
||||
|
||||
await waitFor(() => {
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
||||
expect(header).toBeTruthy();
|
||||
expect(header!.textContent).toContain("openai/gpt-4o");
|
||||
});
|
||||
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
||||
// Executor falls back to default
|
||||
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
|
||||
// Validator uses the validator-specific setting
|
||||
expect(header.textContent).toContain("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("falls back to default settings for validator when no validator-specific setting exists", async () => {
|
||||
const { container } = await setupModelTest({
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
// No validatorProvider or validatorModelId
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Agent Log"));
|
||||
|
||||
await waitFor(() => {
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
||||
expect(header).toBeTruthy();
|
||||
// Both executor and validator should resolve to the default
|
||||
expect(header!.textContent).toContain("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
// Count occurrences - should appear twice (once for executor, once for validator)
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
||||
const matches = header.textContent!.match(/anthropic\/claude-sonnet-4-5/g);
|
||||
expect(matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows task executor override even when settings provide a default", async () => {
|
||||
const { container } = await setupModelTestWithTask(
|
||||
{ modelProvider: "openai", modelId: "gpt-4o" },
|
||||
{ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Agent Log"));
|
||||
|
||||
await waitFor(() => {
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
||||
expect(header).toBeTruthy();
|
||||
// Task override should win
|
||||
expect(header!.textContent).toContain("openai/gpt-4o");
|
||||
});
|
||||
|
||||
// Default model should not appear for executor
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
||||
expect(header.textContent).toContain("openai/gpt-4o");
|
||||
// Validator falls back to default
|
||||
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("shows task validator override even when settings provide a validator default", async () => {
|
||||
const { container } = await setupModelTestWithTask(
|
||||
{ validatorModelProvider: "google", validatorModelId: "gemini-pro" },
|
||||
{ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Agent Log"));
|
||||
|
||||
await waitFor(() => {
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
||||
expect(header).toBeTruthy();
|
||||
// Task validator override should win
|
||||
expect(header!.textContent).toContain("google/gemini-pro");
|
||||
});
|
||||
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
||||
// Executor falls back to default
|
||||
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
|
||||
// Settings validator should not appear (task override wins)
|
||||
expect(header.textContent).not.toContain("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("shows 'Using default' for both when no models can be resolved", async () => {
|
||||
const { container } = await setupModelTest({
|
||||
// No defaultProvider/defaultModelId
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Agent Log"));
|
||||
|
||||
await waitFor(() => {
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
||||
expect(header).toBeTruthy();
|
||||
});
|
||||
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
||||
expect(header.textContent).toContain("Using default");
|
||||
// Should show "Using default" for both executor and validator
|
||||
const defaultBadges = header.querySelectorAll(".model-badge-default");
|
||||
expect(defaultBadges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows 'Using default' for both when settings fetch fails", async () => {
|
||||
const { fetchSettings } = await import("../../api");
|
||||
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
||||
|
||||
vi.mocked(fetchSettings).mockRejectedValueOnce(new Error("Network error"));
|
||||
vi.mocked(useAgentLogs).mockReturnValue({
|
||||
entries: [mockLogEntry],
|
||||
loading: false,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
|
||||
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("Agent Log"));
|
||||
|
||||
// Wait for the failed fetch to settle
|
||||
await waitFor(() => {
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
||||
expect(header).toBeTruthy();
|
||||
});
|
||||
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
||||
expect(header.textContent).toContain("Using default");
|
||||
const defaultBadges = header.querySelectorAll(".model-badge-default");
|
||||
expect(defaultBadges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows partial override: task executor with settings-based validator", async () => {
|
||||
const { container } = await setupModelTestWithTask(
|
||||
{
|
||||
modelProvider: "google",
|
||||
modelId: "gemini-pro",
|
||||
// No validator override — should use settings validator
|
||||
},
|
||||
{
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Agent Log"));
|
||||
|
||||
await waitFor(() => {
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
||||
expect(header).toBeTruthy();
|
||||
expect(header!.textContent).toContain("google/gemini-pro");
|
||||
});
|
||||
|
||||
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
||||
// Executor uses task override
|
||||
expect(header.textContent).toContain("google/gemini-pro");
|
||||
// Validator uses settings-specific validator
|
||||
expect(header.textContent).toContain("openai/gpt-4o");
|
||||
});
|
||||
});
|
||||
|
||||
describe("step progress", () => {
|
||||
it("renders step progress section when steps exist", () => {
|
||||
const { container } = render(
|
||||
|
||||
Reference in New Issue
Block a user