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:
@@ -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">
|
||||
|
||||
@@ -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: {} }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user