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 aea1396d34
commit e8f147eeb2
7 changed files with 105 additions and 10 deletions

View File

@@ -107,6 +107,7 @@ The agents surface provides:
- A cross-pane **Overview** strip above the split layout with summary metrics and a disclosure to expand active/running live cards - A cross-pane **Overview** strip above the split layout with summary metrics and a disclosure to expand active/running live cards
- A compact **Controls** popup for secondary actions (state filter, Show system agents toggle, Import, and global Heartbeat Speed) - A compact **Controls** popup for secondary actions (state filter, Show system agents toggle, Import, and global Heartbeat Speed)
- Detail/config panels - Detail/config panels
- Split-view synchronization: successful saves and lifecycle actions from the right-side Agent Detail pane immediately refresh the left-side list/selection state (no wait for background polling)
- A per-agent **Token Usage** panel that summarizes cumulative token consumption for the currently displayed agents - A per-agent **Token Usage** panel that summarizes cumulative token consumption for the currently displayed agents
- Run history - Run history
- Task assignment context - Task assignment context

View File

@@ -68,6 +68,7 @@ interface AgentDetailViewProps {
initialTab?: TabId; initialTab?: TabId;
initialRunId?: string | null; initialRunId?: string | null;
preferActiveRun?: boolean; preferActiveRun?: boolean;
onMutationSuccess?: (context: { agentId: string; deleted?: boolean }) => void | Promise<void>;
} }
type TabId = "dashboard" | "logs" | "config" | "runs" | "tasks" | "employees" | "soul" | "instructions" | "memory" | "reflections"; 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 [agent, setAgent] = useState<AgentDetail | null>(null);
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const [logs, setLogs] = useState<AgentLogEntry[]>([]); const [logs, setLogs] = useState<AgentLogEntry[]>([]);
@@ -220,6 +221,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
hasConfigChangesRef.current = hasChanges; 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(() => { useEffect(() => {
void loadAgent(); void loadAgent();
}, [loadAgent]); }, [loadAgent]);
@@ -396,7 +406,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
try { try {
await updateAgentState(agentId, newState, projectId); await updateAgentState(agentId, newState, projectId);
addToast(`Agent state updated to ${newState}`, "success"); addToast(`Agent state updated to ${newState}`, "success");
void loadAgent(); await handleSavedMutation();
} catch (err) { } catch (err) {
setAgent((prev) => (prev ? { ...prev, state: previousState } : prev)); setAgent((prev) => (prev ? { ...prev, state: previousState } : prev));
addToast(`Failed to update state: ${getErrorMessage(err)}`, "error"); addToast(`Failed to update state: ${getErrorMessage(err)}`, "error");
@@ -416,6 +426,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
try { try {
await deleteAgent(agentId, projectId); await deleteAgent(agentId, projectId);
addToast(`Agent "${agent.name}" deleted`, "success"); addToast(`Agent "${agent.name}" deleted`, "success");
await notifyMutationSuccess(true);
onClose(); onClose();
} catch (err) { } catch (err) {
addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error"); addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error");
@@ -681,7 +692,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
agent={agent} agent={agent}
projectId={projectId} projectId={projectId}
addToast={addToast} addToast={addToast}
onSaved={loadAgent} onSaved={handleSavedMutation}
/> />
)} )}
@@ -690,7 +701,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
agent={agent} agent={agent}
projectId={projectId} projectId={projectId}
addToast={addToast} addToast={addToast}
onSaved={loadAgent} onSaved={handleSavedMutation}
/> />
)} )}
@@ -699,7 +710,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
agent={agent} agent={agent}
projectId={projectId} projectId={projectId}
addToast={addToast} addToast={addToast}
onSaved={loadAgent} onSaved={handleSavedMutation}
/> />
)} )}
@@ -717,7 +728,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
agent={agent} agent={agent}
projectId={projectId} projectId={projectId}
addToast={addToast} addToast={addToast}
onSaved={loadAgent} onSaved={handleSavedMutation}
onHasChangesChange={handleConfigChangesState} onHasChangesChange={handleConfigChangesState}
onDelete={handleDelete} onDelete={handleDelete}
/> />
@@ -2061,6 +2072,7 @@ function MemoryTab({
setFileSwitchHint(""); setFileSwitchHint("");
await loadMemoryFiles(selectedFilePath); await loadMemoryFiles(selectedFilePath);
addToast("Agent memory file saved", "success"); addToast("Agent memory file saved", "success");
await onSaved();
} catch (err) { } catch (err) {
addToast(`Failed to save agent memory file: ${getErrorMessage(err)}`, "error"); addToast(`Failed to save agent memory file: ${getErrorMessage(err)}`, "error");
} finally { } finally {
@@ -2398,6 +2410,7 @@ function InstructionsTab({
clearTimeout(justSavedFileTimeoutRef.current); clearTimeout(justSavedFileTimeoutRef.current);
} }
justSavedFileTimeoutRef.current = setTimeout(() => setJustSavedFile(false), 3000); justSavedFileTimeoutRef.current = setTimeout(() => setJustSavedFile(false), 3000);
await onSaved();
} catch (err) { } catch (err) {
addToast(`Failed to save instructions file: ${getErrorMessage(err)}`, "error"); addToast(`Failed to save instructions file: ${getErrorMessage(err)}`, "error");
} finally { } finally {
@@ -2734,6 +2747,7 @@ function HeartbeatProcedureSection({
clearTimeout(justSavedFileTimeoutRef.current); clearTimeout(justSavedFileTimeoutRef.current);
} }
justSavedFileTimeoutRef.current = setTimeout(() => setJustSavedFile(false), 3000); justSavedFileTimeoutRef.current = setTimeout(() => setJustSavedFile(false), 3000);
await onSaved();
} catch (err) { } catch (err) {
addToast(`Failed to save heartbeat procedure file: ${getErrorMessage(err)}`, "error"); addToast(`Failed to save heartbeat procedure file: ${getErrorMessage(err)}`, "error");
} finally { } finally {

View File

@@ -226,7 +226,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const viewportMode = useViewportMode(); const viewportMode = useViewportMode();
const isMobileViewport = viewportMode === "mobile"; const isMobileViewport = viewportMode === "mobile";
const [filterState, setFilterState] = useState<AgentState | "all">("all"); const [filterState, setFilterState] = useState<AgentState | "all">("all");
const { agents, stats, isLoading, loadAgents } = useAgents(projectId, { const { agents, stats, isLoading, loadAgents, refreshAgents } = useAgents(projectId, {
filterState, filterState,
showSystemAgents, showSystemAgents,
}); });
@@ -486,7 +486,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
try { try {
await deleteAgent(agentId, projectId); await deleteAgent(agentId, projectId);
addToast(`Agent "${agentName}" deleted`, "success"); addToast(`Agent "${agentName}" deleted`, "success");
void loadAgents(); await loadAgents();
} catch (err) { } catch (err) {
addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error"); addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error");
} }
@@ -675,6 +675,13 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
openAgentDetail(agentId); openAgentDetail(agentId);
}, [openAgentDetail]); }, [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) => { const handleOverviewAgentSelect = useCallback((agentId: string) => {
openAgentDetail(agentId); openAgentDetail(agentId);
if (isMobileViewport) { if (isMobileViewport) {
@@ -1442,6 +1449,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
initialTab={selectedAgentInitialTab} initialTab={selectedAgentInitialTab}
initialRunId={selectedAgentInitialRunId} initialRunId={selectedAgentInitialRunId}
preferActiveRun={selectedAgentPreferActiveRun} preferActiveRun={selectedAgentPreferActiveRun}
onMutationSuccess={handleDetailMutationSuccess}
/> />
</Suspense> </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 () => { it("shows Resume button for paused agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "paused" })); mockFetchAgent.mockResolvedValue(createMockAgent({ state: "paused" }));
@@ -1538,6 +1557,7 @@ describe("AgentDetailView", () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "idle" })); mockFetchAgent.mockResolvedValue(createMockAgent({ state: "idle" }));
const addToast = vi.fn(); const addToast = vi.fn();
const onClose = vi.fn(); const onClose = vi.fn();
const onMutationSuccess = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -1546,6 +1566,7 @@ describe("AgentDetailView", () => {
projectId="proj_123" projectId="proj_123"
onClose={onClose} onClose={onClose}
addToast={addToast} addToast={addToast}
onMutationSuccess={onMutationSuccess}
/>, />,
); );
@@ -1560,6 +1581,7 @@ describe("AgentDetailView", () => {
}); });
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-001", "proj_123"); expect(mockDeleteAgent).toHaveBeenCalledWith("agent-001", "proj_123");
expect(addToast).toHaveBeenCalledWith('Agent "Test Agent" deleted', "success"); expect(addToast).toHaveBeenCalledWith('Agent "Test Agent" deleted', "success");
expect(onMutationSuccess).toHaveBeenCalledWith({ agentId: "agent-001", deleted: true });
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -3563,6 +3585,7 @@ describe("AgentDetailView", () => {
it("calls saveWorkspaceFileContent when saving file content", async () => { it("calls saveWorkspaceFileContent when saving file content", async () => {
const addToast = vi.fn(); const addToast = vi.fn();
const onMutationSuccess = vi.fn();
mockFetchAgent.mockResolvedValue(createMockAgent({ mockFetchAgent.mockResolvedValue(createMockAgent({
instructionsPath: ".fusion/agents/test.md", instructionsPath: ".fusion/agents/test.md",
})); }));
@@ -3578,6 +3601,7 @@ describe("AgentDetailView", () => {
agentId="agent-001" agentId="agent-001"
onClose={vi.fn()} onClose={vi.fn()}
addToast={addToast} addToast={addToast}
onMutationSuccess={onMutationSuccess}
/> />
); );
@@ -3602,6 +3626,7 @@ describe("AgentDetailView", () => {
".fusion/agents/test.md", ".fusion/agents/test.md",
"Updated content", "Updated content",
); );
expect(onMutationSuccess).toHaveBeenCalledWith({ agentId: "agent-001", deleted: false });
}); });
expect(addToast).toHaveBeenCalledWith("Instructions file saved", "success"); expect(addToast).toHaveBeenCalledWith("Instructions file saved", "success");
}); });

View File

@@ -31,11 +31,12 @@ vi.mock("../../api", async (importOriginal) => {
}); });
vi.mock("../AgentDetailView", () => ({ 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"}> <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 ? ( {showInlineBackButton ? (
<button type="button" aria-label="Back to agents" onClick={onClose}>Agents</button> <button type="button" aria-label="Back to agents" onClick={onClose}>Agents</button>
) : null} ) : null}
<button type="button" onClick={() => void onMutationSuccess?.({ agentId })}>Trigger detail mutation success</button>
Agent detail: {agentId} Agent detail: {agentId}
</div> </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 () => { it("keeps clickable identity area behavior for opening detail view", async () => {
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);

View File

@@ -118,6 +118,25 @@ describe("useAgents", () => {
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "active", role: "executor", includeEphemeral: false }, undefined); 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 () => { it("handles fetchAgents rejection gracefully", async () => {
mockFetchAgents.mockRejectedValueOnce(new Error("agents failed")); mockFetchAgents.mockRejectedValueOnce(new Error("agents failed"));

View File

@@ -79,6 +79,10 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
}); });
}, [projectId, loadAgents, loadStats]); }, [projectId, loadAgents, loadStats]);
const refreshAgents = useCallback(async () => {
await Promise.all([loadAgents(), loadStats()]);
}, [loadAgents, loadStats]);
const showSystemAgents = options?.showSystemAgents ?? false; const showSystemAgents = options?.showSystemAgents ?? false;
const activeAgents = agents.filter((agent) => { const activeAgents = agents.filter((agent) => {
if (agent.state !== "active" && agent.state !== "running") { if (agent.state !== "active" && agent.state !== "running") {
@@ -87,5 +91,5 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
return showSystemAgents || !isEphemeralAgent(agent); return showSystemAgents || !isEphemeralAgent(agent);
}); });
return { agents, activeAgents, stats, isLoading, loadAgents, loadStats }; return { agents, activeAgents, stats, isLoading, loadAgents, loadStats, refreshAgents };
} }