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