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:
Fusion
2026-05-05 03:20:19 -07:00
committed by gsxdsm
parent 6e6413263f
commit c5e680db76
7 changed files with 105 additions and 10 deletions

View File

@@ -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 {

View File

@@ -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>
) : (

View File

@@ -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");
});

View File

@@ -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} />);

View File

@@ -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"));

View File

@@ -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 };
}