feat(FN-3466): add mutation coverage for agent view refreshes
Adds deterministic split-pane refresh behavior to the Agents views (AgentsView and AgentDetailView), with comprehensive test coverage for the mutation refreshes in the detail pane and the agents hook. Fusion-Task-Id: FN-3466
This commit is contained in:
@@ -68,6 +68,7 @@ interface AgentDetailViewProps {
|
||||
initialTab?: TabId;
|
||||
initialRunId?: string | null;
|
||||
preferActiveRun?: boolean;
|
||||
onMutationSuccess?: (context: { agentId: string; deleted?: boolean }) => void | Promise<void>;
|
||||
}
|
||||
|
||||
type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "instructions" | "memory" | "reflections";
|
||||
@@ -126,7 +127,7 @@ function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string
|
||||
?? "";
|
||||
}
|
||||
|
||||
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false, showInlineBackButton = false, initialTab, initialRunId, preferActiveRun = false }: AgentDetailViewProps) {
|
||||
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false, showInlineBackButton = false, initialTab, initialRunId, preferActiveRun = false, onMutationSuccess }: AgentDetailViewProps) {
|
||||
const [agent, setAgent] = useState<AgentDetail | null>(null);
|
||||
const { confirm } = useConfirm();
|
||||
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
|
||||
@@ -220,6 +221,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
hasConfigChangesRef.current = hasChanges;
|
||||
}, []);
|
||||
|
||||
const notifyMutationSuccess = useCallback(async (deleted = false) => {
|
||||
await onMutationSuccess?.({ agentId, deleted });
|
||||
}, [agentId, onMutationSuccess]);
|
||||
|
||||
const handleSavedMutation = useCallback(async () => {
|
||||
await loadAgent();
|
||||
await notifyMutationSuccess(false);
|
||||
}, [loadAgent, notifyMutationSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAgent();
|
||||
}, [loadAgent]);
|
||||
@@ -396,7 +406,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
try {
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
addToast(`Agent state updated to ${newState}`, "success");
|
||||
void loadAgent();
|
||||
await handleSavedMutation();
|
||||
} catch (err) {
|
||||
setAgent((prev) => (prev ? { ...prev, state: previousState } : prev));
|
||||
addToast(`Failed to update state: ${getErrorMessage(err)}`, "error");
|
||||
@@ -416,6 +426,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
try {
|
||||
await deleteAgent(agentId, projectId);
|
||||
addToast(`Agent "${agent.name}" deleted`, "success");
|
||||
await notifyMutationSuccess(true);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error");
|
||||
@@ -681,7 +692,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
agent={agent}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
onSaved={loadAgent}
|
||||
onSaved={handleSavedMutation}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -690,7 +701,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
agent={agent}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
onSaved={loadAgent}
|
||||
onSaved={handleSavedMutation}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -699,7 +710,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
agent={agent}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
onSaved={loadAgent}
|
||||
onSaved={handleSavedMutation}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -717,7 +728,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
agent={agent}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
onSaved={loadAgent}
|
||||
onSaved={handleSavedMutation}
|
||||
onHasChangesChange={handleConfigChangesState}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
@@ -2061,6 +2072,7 @@ function MemoryTab({
|
||||
setFileSwitchHint("");
|
||||
await loadMemoryFiles(selectedFilePath);
|
||||
addToast("Agent memory file saved", "success");
|
||||
await onSaved();
|
||||
} catch (err) {
|
||||
addToast(`Failed to save agent memory file: ${getErrorMessage(err)}`, "error");
|
||||
} finally {
|
||||
@@ -2398,6 +2410,7 @@ function InstructionsTab({
|
||||
clearTimeout(justSavedFileTimeoutRef.current);
|
||||
}
|
||||
justSavedFileTimeoutRef.current = setTimeout(() => setJustSavedFile(false), 3000);
|
||||
await onSaved();
|
||||
} catch (err) {
|
||||
addToast(`Failed to save instructions file: ${getErrorMessage(err)}`, "error");
|
||||
} finally {
|
||||
@@ -2734,6 +2747,7 @@ function HeartbeatProcedureSection({
|
||||
clearTimeout(justSavedFileTimeoutRef.current);
|
||||
}
|
||||
justSavedFileTimeoutRef.current = setTimeout(() => setJustSavedFile(false), 3000);
|
||||
await onSaved();
|
||||
} catch (err) {
|
||||
addToast(`Failed to save heartbeat procedure file: ${getErrorMessage(err)}`, "error");
|
||||
} finally {
|
||||
|
||||
@@ -226,7 +226,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobileViewport = viewportMode === "mobile";
|
||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||
const { agents, stats, isLoading, loadAgents } = useAgents(projectId, {
|
||||
const { agents, stats, isLoading, loadAgents, refreshAgents } = useAgents(projectId, {
|
||||
filterState,
|
||||
showSystemAgents,
|
||||
});
|
||||
@@ -486,7 +486,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
try {
|
||||
await deleteAgent(agentId, projectId);
|
||||
addToast(`Agent "${agentName}" deleted`, "success");
|
||||
void loadAgents();
|
||||
await loadAgents();
|
||||
} catch (err) {
|
||||
addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error");
|
||||
}
|
||||
@@ -675,6 +675,13 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
openAgentDetail(agentId);
|
||||
}, [openAgentDetail]);
|
||||
|
||||
const handleDetailMutationSuccess = useCallback(async ({ agentId, deleted }: { agentId: string; deleted?: boolean }) => {
|
||||
await refreshAgents();
|
||||
if (deleted && selectedAgentId === agentId) {
|
||||
handleCloseDetail();
|
||||
}
|
||||
}, [refreshAgents, selectedAgentId, handleCloseDetail]);
|
||||
|
||||
const handleOverviewAgentSelect = useCallback((agentId: string) => {
|
||||
openAgentDetail(agentId);
|
||||
if (isMobileViewport) {
|
||||
@@ -1442,6 +1449,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
initialTab={selectedAgentInitialTab}
|
||||
initialRunId={selectedAgentInitialRunId}
|
||||
preferActiveRun={selectedAgentPreferActiveRun}
|
||||
onMutationSuccess={handleDetailMutationSuccess}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
|
||||
@@ -776,6 +776,25 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies parent mutation callback after successful state change", async () => {
|
||||
const onMutationSuccess = vi.fn();
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
onMutationSuccess={onMutationSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(await screen.findByText("Pause"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onMutationSuccess).toHaveBeenCalledWith({ agentId: "agent-001", deleted: false });
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Resume button for paused agent", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "paused" }));
|
||||
|
||||
@@ -1538,6 +1557,7 @@ describe("AgentDetailView", () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "idle" }));
|
||||
const addToast = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const onMutationSuccess = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
@@ -1546,6 +1566,7 @@ describe("AgentDetailView", () => {
|
||||
projectId="proj_123"
|
||||
onClose={onClose}
|
||||
addToast={addToast}
|
||||
onMutationSuccess={onMutationSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1560,6 +1581,7 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-001", "proj_123");
|
||||
expect(addToast).toHaveBeenCalledWith('Agent "Test Agent" deleted', "success");
|
||||
expect(onMutationSuccess).toHaveBeenCalledWith({ agentId: "agent-001", deleted: true });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -3563,6 +3585,7 @@ describe("AgentDetailView", () => {
|
||||
|
||||
it("calls saveWorkspaceFileContent when saving file content", async () => {
|
||||
const addToast = vi.fn();
|
||||
const onMutationSuccess = vi.fn();
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
instructionsPath: ".fusion/agents/test.md",
|
||||
}));
|
||||
@@ -3578,6 +3601,7 @@ describe("AgentDetailView", () => {
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={addToast}
|
||||
onMutationSuccess={onMutationSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3602,6 +3626,7 @@ describe("AgentDetailView", () => {
|
||||
".fusion/agents/test.md",
|
||||
"Updated content",
|
||||
);
|
||||
expect(onMutationSuccess).toHaveBeenCalledWith({ agentId: "agent-001", deleted: false });
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Instructions file saved", "success");
|
||||
});
|
||||
|
||||
@@ -31,11 +31,12 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
});
|
||||
|
||||
vi.mock("../AgentDetailView", () => ({
|
||||
AgentDetailView: ({ agentId, inline, onClose, showInlineBackButton, initialTab, initialRunId, preferActiveRun }: { agentId: string; inline?: boolean; onClose?: () => void; showInlineBackButton?: boolean; initialTab?: string; initialRunId?: string | null; preferActiveRun?: boolean }) => (
|
||||
AgentDetailView: ({ agentId, inline, onClose, showInlineBackButton, initialTab, initialRunId, preferActiveRun, onMutationSuccess }: { agentId: string; inline?: boolean; onClose?: () => void; showInlineBackButton?: boolean; initialTab?: string; initialRunId?: string | null; preferActiveRun?: boolean; onMutationSuccess?: (context: { agentId: string; deleted?: boolean }) => void | Promise<void> }) => (
|
||||
<div data-testid="agent-detail-view" data-inline={inline ? "true" : "false"} data-initial-tab={initialTab ?? "dashboard"} data-initial-run-id={initialRunId ?? ""} data-prefer-active-run={preferActiveRun ? "true" : "false"}>
|
||||
{showInlineBackButton ? (
|
||||
<button type="button" aria-label="Back to agents" onClick={onClose}>Agents</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => void onMutationSuccess?.({ agentId })}>Trigger detail mutation success</button>
|
||||
Agent detail: {agentId}
|
||||
</div>
|
||||
),
|
||||
@@ -629,6 +630,29 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes left-pane list immediately when detail pane reports a successful mutation", async () => {
|
||||
mockFetchAgents
|
||||
.mockResolvedValueOnce(mockAgents)
|
||||
.mockResolvedValueOnce([
|
||||
{ ...mockAgents[0], name: "Renamed Agent" },
|
||||
...mockAgents.slice(1),
|
||||
]);
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Test Agent 1").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "View details for Test Agent 1" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Trigger detail mutation success" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getAllByText("Renamed Agent").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps clickable identity area behavior for opening detail view", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
|
||||
@@ -118,6 +118,25 @@ describe("useAgents", () => {
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "active", role: "executor", includeEphemeral: false }, undefined);
|
||||
});
|
||||
|
||||
it("refreshAgents reloads both list and stats in one call", async () => {
|
||||
const { result } = renderHook(() => useAgents());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalled();
|
||||
expect(mockFetchAgentStats).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
mockFetchAgents.mockClear();
|
||||
mockFetchAgentStats.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshAgents();
|
||||
});
|
||||
|
||||
expect(mockFetchAgents).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchAgentStats).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles fetchAgents rejection gracefully", async () => {
|
||||
mockFetchAgents.mockRejectedValueOnce(new Error("agents failed"));
|
||||
|
||||
|
||||
@@ -79,6 +79,10 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
|
||||
});
|
||||
}, [projectId, loadAgents, loadStats]);
|
||||
|
||||
const refreshAgents = useCallback(async () => {
|
||||
await Promise.all([loadAgents(), loadStats()]);
|
||||
}, [loadAgents, loadStats]);
|
||||
|
||||
const showSystemAgents = options?.showSystemAgents ?? false;
|
||||
const activeAgents = agents.filter((agent) => {
|
||||
if (agent.state !== "active" && agent.state !== "running") {
|
||||
@@ -87,5 +91,5 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
|
||||
return showSystemAgents || !isEphemeralAgent(agent);
|
||||
});
|
||||
|
||||
return { agents, activeAgents, stats, isLoading, loadAgents, loadStats };
|
||||
return { agents, activeAgents, stats, isLoading, loadAgents, loadStats, refreshAgents };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user