feat(FN-2328): add manager dropdown for agent reports-to setting

- Replace free-text Reports To input with a manager select populated from fetched agents
- Exclude the current agent from manager candidates and preserve unknown manager IDs as a fallback option
- Save selected manager IDs through updateAgent, including clearing reportsTo when No manager is selected
- Expand AgentDetailView settings tests for manager selection behavior and increase task document cascade test timeout stability
This commit is contained in:
Fusion
2026-04-23 12:23:56 -07:00
committed by gsxdsm
parent bc0f776359
commit 445bdb2eea
3 changed files with 175 additions and 8 deletions

View File

@@ -259,7 +259,7 @@ describe("TaskStore task documents", () => {
const document = await store.getTaskDocument(task.id, "plan");
expect(document).toBeNull();
});
}, 15_000);
it("accepts valid key edge cases and rejects invalid ones", async () => {
const task = await store.createTask({ description: "Key edge case task" });

View File

@@ -8,7 +8,7 @@ import {
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchAgents } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry, Task } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
@@ -2705,6 +2705,8 @@ function ConfigTab({
const [titleValue, setTitleValue] = useState(agent.title ?? "");
const [iconValue, setIconValue] = useState(agent.icon ?? "");
const [reportsToValue, setReportsToValue] = useState(agent.reportsTo ?? "");
const [managerOptions, setManagerOptions] = useState<Agent[]>([]);
const [isLoadingManagers, setIsLoadingManagers] = useState(false);
// Local form state initialised from agent.metadata
const [formValues, setFormValues] = useState<Record<string, string>>(() => {
@@ -2758,6 +2760,40 @@ function ConfigTab({
})();
const [modelValue, setModelValue] = useState(initialModelValue);
const managerSelection = reportsToValue.trim();
const availableManagers = useMemo(
() => managerOptions.filter((candidate) => candidate.id !== agent.id),
[managerOptions, agent.id],
);
const hasMissingManagerSelection = !!managerSelection
&& !availableManagers.some((candidate) => candidate.id === managerSelection);
// Load candidate managers for reports-to dropdown
useEffect(() => {
let cancelled = false;
setIsLoadingManagers(true);
fetchAgents(undefined, projectId)
.then((agents) => {
if (cancelled) return;
setManagerOptions(agents);
})
.catch(() => {
if (!cancelled) {
setManagerOptions([]);
}
})
.finally(() => {
if (!cancelled) {
setIsLoadingManagers(false);
}
});
return () => {
cancelled = true;
};
}, [projectId]);
// Load available models on mount
useEffect(() => {
setModelsLoading(true);
@@ -3210,14 +3246,23 @@ function ConfigTab({
<div className="config-field">
<label htmlFor="agent-reports-to">Reports To</label>
<input
<select
id="agent-reports-to"
type="text"
className="input"
placeholder="e.g. agent-001"
className="select"
value={reportsToValue}
onChange={(e) => setReportsToValue(e.target.value)}
/>
disabled={isLoadingManagers}
>
<option value="">No manager</option>
{hasMissingManagerSelection && (
<option value={managerSelection}>Unknown manager ({managerSelection})</option>
)}
{availableManagers.map((manager) => (
<option key={manager.id} value={manager.id}>
{manager.name} ({manager.id})
</option>
))}
</select>
</div>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import { DEFAULT_HEARTBEAT_INTERVAL_MS } from "../../utils/heartbeatIntervals";
// Mock the API functions
vi.mock("../../api", () => ({
fetchAgent: vi.fn(),
fetchAgents: vi.fn(),
updateAgent: vi.fn(),
updateAgentState: vi.fn(),
deleteAgent: vi.fn(),
@@ -87,9 +88,10 @@ vi.mock("../SkillMultiselect", () => ({
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchModels, fetchAgentLogsWithMeta } from "../../api";
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchModels, fetchAgentLogsWithMeta } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockFetchAgents = vi.mocked(fetchAgents);
const mockUpdateAgent = vi.mocked(updateAgent);
const mockUpdateAgentState = vi.mocked(updateAgentState);
const mockFetchAgentChildren = vi.mocked(fetchAgentChildren);
@@ -157,6 +159,11 @@ describe("AgentDetailView", () => {
vi.clearAllMocks();
const mockAgent = createMockAgent();
mockFetchAgent.mockResolvedValue(mockAgent);
mockFetchAgents.mockResolvedValue([
{ id: "agent-001", name: "Test Agent", role: "executor", state: "active", metadata: {} },
{ id: "agent-002", name: "Manager Agent", role: "reviewer", state: "active", metadata: {} },
{ id: "agent-003", name: "Director Agent", role: "triage", state: "active", metadata: {} },
] as any);
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "paused" }));
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
// Default: return runs from mock agent
@@ -1000,6 +1007,121 @@ describe("AgentDetailView", () => {
});
});
it("renders Reports To as a manager dropdown sourced from fetched agents", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await navigateToSettings(user);
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, undefined);
});
const reportsToSelect = await screen.findByLabelText("Reports To") as HTMLSelectElement;
expect(reportsToSelect.tagName).toBe("SELECT");
const optionValues = Array.from(reportsToSelect.options).map((option) => option.value);
expect(optionValues).toContain("");
expect(optionValues).toContain("agent-002");
expect(optionValues).toContain("agent-003");
expect(optionValues).not.toContain("agent-001");
});
it("shows existing reportsTo value as selected manager", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ reportsTo: "agent-003" } as any));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await navigateToSettings(user);
const reportsToSelect = await screen.findByLabelText("Reports To") as HTMLSelectElement;
expect(reportsToSelect.value).toBe("agent-003");
});
it("preserves unknown reportsTo ids in dropdown until changed", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ reportsTo: "agent-missing" } as any));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await navigateToSettings(user);
const reportsToSelect = await screen.findByLabelText("Reports To") as HTMLSelectElement;
expect(reportsToSelect.value).toBe("agent-missing");
expect(screen.getByRole("option", { name: "Unknown manager (agent-missing)" })).toBeInTheDocument();
});
it("saves selected manager id via updateAgent reportsTo", async () => {
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await navigateToSettings(user);
const reportsToSelect = await screen.findByLabelText("Reports To");
await user.selectOptions(reportsToSelect, "agent-003");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({ reportsTo: "agent-003" }),
undefined,
);
});
});
it("clears reportsTo when selecting No manager", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ reportsTo: "agent-002" } as any));
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await navigateToSettings(user);
const reportsToSelect = await screen.findByLabelText("Reports To");
await user.selectOptions(reportsToSelect, "");
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({ reportsTo: undefined }),
undefined,
);
});
});
it("renders model settings section and pre-fills dropdown from runtimeConfig", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
runtimeConfig: {