feat(FN-1965): add per-agent model override controls in dashboard

- Show the active model override in Agent Information, including support for legacy runtimeConfig.model values
- Add a Model section to agent settings with CustomModelDropdown and available model loading via fetchModels
- Persist model overrides as modelProvider/modelId/model and clear those fields when reverting to global default
- Expand AgentDetailView tests to cover model display, prefill behavior, save payloads, and clearing overrides
- Add a changeset for @gsxdsm/fusion describing the new agent model selection capability
This commit is contained in:
Fusion
2026-04-16 15:17:24 -07:00
committed by gsxdsm
parent 78a7c0a829
commit c8c2bba8f5
3 changed files with 286 additions and 3 deletions

View File

@@ -5,14 +5,15 @@ import {
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
ChevronDown, ChevronRight, BarChart3, Star, BookOpen
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../api";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry, Task } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
import { AgentReflectionsTab } from "./AgentReflectionsTab";
import { getAgentHealthStatus } from "../utils/agentHealth";
import { SkillMultiselect } from "./SkillMultiselect";
import { CustomModelDropdown } from "./CustomModelDropdown";
/**
* Simple className utility - joins class names conditionally
@@ -574,6 +575,18 @@ function DashboardTab({
const [isLoadingChainOfCommand, setIsLoadingChainOfCommand] = useState(true);
const [budgetStatus, setBudgetStatus] = useState<AgentBudgetStatus | null>(null);
const modelDisplay = (() => {
const rc = agent.runtimeConfig ?? {};
if (rc.modelProvider && rc.modelId) {
return `${rc.modelProvider}/${rc.modelId}`;
}
if (typeof rc.model === "string" && rc.model.includes("/")) {
const slashIdx = rc.model.indexOf("/");
return rc.model.slice(slashIdx + 1);
}
return null;
})();
// Fetch budget status on mount
useEffect(() => {
fetchAgentBudgetStatus(agent.id, projectId)
@@ -685,6 +698,12 @@ function DashboardTab({
{health.label}
</span>
</div>
{modelDisplay && (
<div className="info-item">
<span className="info-label">Model</span>
<span className="info-value">{modelDisplay}</span>
</div>
)}
{budgetStatus?.budgetLimit != null && (
<div className="info-item">
<span className="info-label">Budget</span>
@@ -2305,6 +2324,33 @@ function ConfigTab({
Array.isArray(agent.metadata?.skills) ? agent.metadata.skills as string[] : []
);
// Model dropdown state
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const initialModelValue = (() => {
const rc = agent.runtimeConfig ?? {};
if (rc.modelProvider && rc.modelId) {
return `${rc.modelProvider}/${rc.modelId}`;
}
if (typeof rc.model === "string" && rc.model.includes("/")) {
return rc.model;
}
return "";
})();
const [modelValue, setModelValue] = useState(initialModelValue);
// Load available models on mount
useEffect(() => {
setModelsLoading(true);
fetchModels()
.then((response) => setAvailableModels(response.models))
.catch(() => {
// Gracefully handle unavailable models endpoint
})
.finally(() => setModelsLoading(false));
}, []);
// Budget status for progress bar display
const [budgetStatus, setBudgetStatus] = useState<AgentBudgetStatus | null>(null);
const [isResettingBudget, setIsResettingBudget] = useState(false);
@@ -2384,6 +2430,9 @@ function ConfigTab({
const persistedSkills = Array.isArray(agent.metadata?.skills) ? agent.metadata.skills as string[] : [];
if (JSON.stringify(selectedSkills) !== JSON.stringify(persistedSkills)) return true;
// Check model override
if (modelValue !== initialModelValue) return true;
return false;
})();
@@ -2537,6 +2586,20 @@ function ConfigTab({
newRuntimeConfig.messageResponseMode = messageResponseMode;
}
// Model override: parse "provider/modelId" into separate fields
if (modelValue.trim()) {
const slashIdx = modelValue.indexOf("/");
if (slashIdx !== -1) {
newRuntimeConfig.modelProvider = modelValue.slice(0, slashIdx);
newRuntimeConfig.modelId = modelValue.slice(slashIdx + 1);
newRuntimeConfig.model = modelValue.trim();
}
} else {
delete newRuntimeConfig.modelProvider;
delete newRuntimeConfig.modelId;
delete newRuntimeConfig.model;
}
// Build budgetConfig payload — only include non-empty values
const newBudgetConfig: Record<string, unknown> = {};
const tokenBudget = budgetValues.tokenBudget?.trim();
@@ -2696,6 +2759,26 @@ function ConfigTab({
</div>
</div>
<div className="config-section">
<h3>Model</h3>
<p className="config-description">
Override the AI model used by this agent. Leave empty to use the global default model.
</p>
<div className="config-fields">
<div className="config-field">
<CustomModelDropdown
models={availableModels}
value={modelValue}
onChange={setModelValue}
placeholder="Use global default"
label="Agent Model"
disabled={modelsLoading}
/>
</div>
</div>
</div>
<div className="config-section">
<h3>Heartbeat Settings</h3>
<p className="config-description">

View File

@@ -12,6 +12,7 @@ vi.mock("../../api", () => ({
updateAgentState: vi.fn(),
deleteAgent: vi.fn(),
fetchAgentLogs: vi.fn(),
fetchAgentLogsWithMeta: vi.fn(),
fetchAgentRunLogs: vi.fn(),
fetchAgentChildren: vi.fn(),
fetchAgentRuns: vi.fn(),
@@ -27,6 +28,7 @@ vi.mock("../../api", () => ({
fetchWorkspaceFileContent: vi.fn(),
saveWorkspaceFileContent: vi.fn(),
fetchDiscoveredSkills: vi.fn(),
fetchModels: vi.fn(),
}));
vi.mock("../AgentLogViewer", () => ({
@@ -37,6 +39,42 @@ vi.mock("../AgentLogViewer", () => ({
),
}));
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ models, value, onChange, disabled, label, placeholder, id }: {
models: Array<{ provider: string; id: string }> ;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
label: string;
placeholder?: string;
id?: string;
}) => {
const selectId = id ?? "custom-model-dropdown";
return (
<div data-testid="custom-model-dropdown">
<label htmlFor={selectId}>{label}</label>
<select
id={selectId}
aria-label={label}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{placeholder ?? "Use default"}</option>
{models.map((model) => {
const modelValue = `${model.provider}/${model.id}`;
return (
<option key={modelValue} value={modelValue}>
{modelValue}
</option>
);
})}
</select>
</div>
);
},
}));
// Mock SkillMultiselect
vi.mock("../SkillMultiselect", () => ({
SkillMultiselect: ({ value, onChange, id }: { value: string[]; onChange: (v: string[]) => void; id?: string }) => (
@@ -48,7 +86,7 @@ vi.mock("../SkillMultiselect", () => ({
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills } from "../../api";
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchModels, fetchAgentLogsWithMeta } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
@@ -65,6 +103,8 @@ const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions);
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
const mockFetchDiscoveredSkills = vi.mocked(fetchDiscoveredSkills);
const mockFetchModels = vi.mocked(fetchModels);
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
const MOCK_SKILLS = [
{ id: "skill-1", name: "Skill One", path: "/path/skill-1", relativePath: "skills/skill-1", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
@@ -125,6 +165,7 @@ describe("AgentDetailView", () => {
mockFetchAgentChildren.mockResolvedValue([]);
mockFetchAgentTasks.mockResolvedValue([]);
mockFetchChainOfCommand.mockResolvedValue([mockAgent]);
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], truncated: false, hasMore: false });
// Default: no budget limit configured
mockFetchAgentBudgetStatus.mockResolvedValue({
agentId: "agent-001",
@@ -144,6 +185,14 @@ describe("AgentDetailView", () => {
mockUpdateAgentInstructions.mockResolvedValue({} as any);
// Default: return skills
mockFetchDiscoveredSkills.mockResolvedValue(MOCK_SKILLS);
mockFetchModels.mockResolvedValue({
models: [
{ provider: "openai", id: "gpt-4o", name: "gpt-4o", reasoning: false, contextWindow: 128000 },
{ provider: "anthropic", id: "claude-3-7-sonnet", name: "claude-3-7-sonnet", reasoning: true, contextWindow: 200000 },
],
favoriteProviders: [],
favoriteModels: [],
});
});
it("shows loading state initially", () => {
@@ -633,6 +682,49 @@ describe("AgentDetailView", () => {
});
});
it("shows model override in Agent Information when runtimeConfig modelProvider/modelId is set", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
modelProvider: "openai",
modelId: "gpt-4o",
},
}));
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText("Model")).toBeInTheDocument();
expect(screen.getByText("openai/gpt-4o")).toBeInTheDocument();
});
});
it("shows legacy model override using model id when runtimeConfig.model is set", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
model: "anthropic/claude-3-7-sonnet",
},
}));
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText("Model")).toBeInTheDocument();
expect(screen.getByText("claude-3-7-sonnet")).toBeInTheDocument();
});
});
describe("Chain of Command", () => {
it("renders chain-of-command section and displays agents in order", async () => {
mockFetchChainOfCommand.mockResolvedValue([
@@ -905,6 +997,109 @@ describe("AgentDetailView", () => {
});
});
it("renders model settings section and pre-fills dropdown from runtimeConfig", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
modelProvider: "openai",
modelId: "gpt-4o",
},
}));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByText("Model")).toBeInTheDocument();
});
const modelSelect = await screen.findByLabelText("Agent Model") as HTMLSelectElement;
expect(modelSelect.value).toBe("openai/gpt-4o");
});
it("saves selected model override as modelProvider/modelId/model in runtimeConfig", async () => {
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
const modelSelect = await screen.findByLabelText("Agent Model");
await user.selectOptions(modelSelect, "anthropic/claude-3-7-sonnet");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
runtimeConfig: expect.objectContaining({
modelProvider: "anthropic",
modelId: "claude-3-7-sonnet",
model: "anthropic/claude-3-7-sonnet",
}),
}),
undefined,
);
});
});
it("clears model override from runtimeConfig when selecting global default", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {
modelProvider: "openai",
modelId: "gpt-4o",
model: "openai/gpt-4o",
heartbeatIntervalMs: 30000,
},
}));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
const modelSelect = await screen.findByLabelText("Agent Model");
await user.selectOptions(modelSelect, "");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
runtimeConfig: expect.not.objectContaining({
modelProvider: expect.anything(),
modelId: expect.anything(),
model: expect.anything(),
}),
}),
undefined,
);
});
});
it("shows empty fields when metadata and runtimeConfig are empty", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));