feat(FN-5227): add bulk controls for project agents
- Add project-scoped Pause All Agents and Resume All Agents actions to the AgentsView controls menu - Load bulk-action eligibility on menu open and skip ephemeral or ineligible agents - Surface bulk action confirmations, success/error toasts, and refresh agent state after updates - Add dashboard tests and agent docs coverage for the new bulk controls Fusion-Task-Id: FN-5227
This commit is contained in:
committed by
gsxdsm
parent
46fb3f0ef7
commit
1237168b71
@@ -346,7 +346,7 @@ The agents surface provides:
|
|||||||
- Org Chart is a full-view mode that takes over the full Agents content area; selecting a node opens detail in that same full-width region with back navigation to the chart
|
- Org Chart is a full-view mode that takes over the full Agents content area; selecting a node opens detail in that same full-width region with back navigation to the chart
|
||||||
- Org chart nodes intentionally stay compact (role/state/health hierarchy signal only) and do not enumerate per-agent skill badges; detailed skills remain in list/board/detail surfaces
|
- Org chart nodes intentionally stay compact (role/state/health hierarchy signal only) and do not enumerate per-agent skill badges; detailed skills remain in list/board/detail surfaces
|
||||||
- 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, project-scoped bulk pause/resume, Import, and global Heartbeat Speed)
|
||||||
- Agent import can also be launched from the selected **Agent Detail** header; this entry opens the import modal directly in the companies.sh browse flow so operators can discover and import packages without leaving the detail context
|
- Agent import can also be launched from the selected **Agent Detail** header; this entry opens the import modal directly in the companies.sh browse flow so operators can discover and import packages without leaving the detail context
|
||||||
- Detail/config panels
|
- Detail/config panels
|
||||||
- Agent Detail includes a **Mail** tab for inspecting that agent’s inbox/outbox; selecting a message opens full details, and selecting an unread inbox message marks it read
|
- Agent Detail includes a **Mail** tab for inspecting that agent’s inbox/outbox; selecting a message opens full details, and selecting an unread inbox message marks it read
|
||||||
|
|||||||
@@ -146,6 +146,53 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-controls-bulk-actions {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: var(--space-xs);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-controls-bulk-actions__label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-detail-bulk-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(var(--space-xs) * 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-detail-bulk-menu-item:hover:not(:disabled),
|
||||||
|
.agent-detail-bulk-menu-item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: var(--card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-detail-bulk-menu-item:disabled {
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-detail-bulk-menu-item-hint {
|
||||||
|
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.agents-org-full-view {
|
.agents-org-full-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1409,6 +1456,10 @@
|
|||||||
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-controls-bulk-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.agents-org-full-view {
|
.agents-org-full-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import "./AgentsView.css";
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo, useId, useLayoutEffect, lazy, Suspense, type CSSProperties, type ReactNode, type MutableRefObject, type RefObject, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo, useId, useLayoutEffect, lazy, Suspense, type CSSProperties, type ReactNode, type MutableRefObject, type RefObject, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||||
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal, ZoomIn, ZoomOut, Minimize2, Move, Info } from "lucide-react";
|
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal, ZoomIn, ZoomOut, Minimize2, Move, Info } from "lucide-react";
|
||||||
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
|
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
|
||||||
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
||||||
|
|
||||||
const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView })));
|
const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView })));
|
||||||
import { AgentTokenStatsPanel } from "./AgentTokenStatsPanel";
|
import { AgentTokenStatsPanel } from "./AgentTokenStatsPanel";
|
||||||
@@ -294,6 +294,10 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
const [orgChartViewportWidth, setOrgChartViewportWidth] = useState(0);
|
const [orgChartViewportWidth, setOrgChartViewportWidth] = useState(0);
|
||||||
const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false);
|
const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false);
|
||||||
const [isOverviewOpen, setIsOverviewOpen] = useState(false);
|
const [isOverviewOpen, setIsOverviewOpen] = useState(false);
|
||||||
|
const [isBulkActionRunning, setIsBulkActionRunning] = useState(false);
|
||||||
|
const [isBulkEligibilityLoading, setIsBulkEligibilityLoading] = useState(false);
|
||||||
|
const [bulkPauseEligibleCount, setBulkPauseEligibleCount] = useState(0);
|
||||||
|
const [bulkResumeEligibleCount, setBulkResumeEligibleCount] = useState(0);
|
||||||
const [orgChartTransform, setOrgChartTransform] = useState<OrgChartTransform>({ scale: 1, x: 0, y: 0 });
|
const [orgChartTransform, setOrgChartTransform] = useState<OrgChartTransform>({ scale: 1, x: 0, y: 0 });
|
||||||
const [isOrgChartPanning, setIsOrgChartPanning] = useState(false);
|
const [isOrgChartPanning, setIsOrgChartPanning] = useState(false);
|
||||||
const controlsPanelRef = useRef<HTMLDivElement>(null);
|
const controlsPanelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -499,6 +503,29 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isControlsPanelOpen) return;
|
if (!isControlsPanelOpen) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setIsBulkEligibilityLoading(true);
|
||||||
|
void fetchAgents(undefined, projectId)
|
||||||
|
.then((projectAgents) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const nonEphemeralAgents = projectAgents.filter((projectAgent) => !isEphemeralAgent(projectAgent));
|
||||||
|
setBulkPauseEligibleCount(
|
||||||
|
nonEphemeralAgents.filter((projectAgent) => projectAgent.state === "active" || projectAgent.state === "running").length,
|
||||||
|
);
|
||||||
|
setBulkResumeEligibleCount(nonEphemeralAgents.filter((projectAgent) => projectAgent.state === "paused").length);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setBulkPauseEligibleCount(0);
|
||||||
|
setBulkResumeEligibleCount(0);
|
||||||
|
addToast(`Failed to load bulk agent actions: ${getErrorMessage(err)}`, "error");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsBulkEligibilityLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||||
const target = event.target as Node | null;
|
const target = event.target as Node | null;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -518,11 +545,66 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
document.removeEventListener("mousedown", handlePointerDown);
|
document.removeEventListener("mousedown", handlePointerDown);
|
||||||
document.removeEventListener("touchstart", handlePointerDown);
|
document.removeEventListener("touchstart", handlePointerDown);
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [isControlsPanelOpen]);
|
}, [addToast, isControlsPanelOpen, projectId]);
|
||||||
|
|
||||||
|
const handleBulkStateChange = async (targetState: "paused" | "active") => {
|
||||||
|
if (isBulkActionRunning) return;
|
||||||
|
setIsBulkActionRunning(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectAgents = await fetchAgents(undefined, projectId);
|
||||||
|
const nonEphemeralAgents = projectAgents.filter((projectAgent) => !isEphemeralAgent(projectAgent));
|
||||||
|
const eligibleAgents = nonEphemeralAgents.filter((projectAgent) => (
|
||||||
|
targetState === "paused"
|
||||||
|
? projectAgent.state === "active" || projectAgent.state === "running"
|
||||||
|
: projectAgent.state === "paused"
|
||||||
|
));
|
||||||
|
const skippedCount = nonEphemeralAgents.length - eligibleAgents.length;
|
||||||
|
|
||||||
|
if (eligibleAgents.length === 0) {
|
||||||
|
addToast(`No agents eligible to ${targetState === "paused" ? "pause" : "resume"}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: targetState === "paused" ? "Pause All Agents" : "Resume All Agents",
|
||||||
|
message: `${targetState === "paused" ? "Pause" : "Resume"} ${eligibleAgents.length} agent${eligibleAgents.length === 1 ? "" : "s"} in this project?`,
|
||||||
|
danger: targetState === "paused",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
eligibleAgents.map((projectAgent) => updateAgentState(projectAgent.id, targetState, projectId)),
|
||||||
|
);
|
||||||
|
const failedResults = results
|
||||||
|
.map((result, index) => ({ result, agent: eligibleAgents[index] }))
|
||||||
|
.filter((entry): entry is { result: PromiseRejectedResult; agent: Agent } => entry.result.status === "rejected");
|
||||||
|
const successCount = results.length - failedResults.length;
|
||||||
|
const failureCount = failedResults.length;
|
||||||
|
const baseSummary = `${targetState === "paused" ? "Paused" : "Resumed"} ${successCount} agent${successCount === 1 ? "" : "s"}; skipped ${skippedCount}`;
|
||||||
|
|
||||||
|
if (failureCount > 0) {
|
||||||
|
const failureSummary = failedResults
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(({ agent, result }) => `${agent.name || agent.id}: ${getErrorMessage(result.reason)}`)
|
||||||
|
.join("; ");
|
||||||
|
addToast(`${baseSummary}; failed ${failureCount}${failureSummary ? ` (${failureSummary})` : ""}`, "error");
|
||||||
|
} else {
|
||||||
|
addToast(baseSummary, "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadAgents();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to ${targetState === "paused" ? "pause" : "resume"} agents: ${getErrorMessage(err)}`, "error");
|
||||||
|
} finally {
|
||||||
|
setIsBulkActionRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||||
if (transitioningAgentIds.has(agentId)) return;
|
if (transitioningAgentIds.has(agentId)) return;
|
||||||
@@ -1034,6 +1116,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
return getAgentHealthStatus(agent);
|
return getAgentHealthStatus(agent);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPauseAllDisabled = isBulkEligibilityLoading || isBulkActionRunning || bulkPauseEligibleCount === 0;
|
||||||
|
const isResumeAllDisabled = isBulkEligibilityLoading || isBulkActionRunning || bulkResumeEligibleCount === 0;
|
||||||
const showInitialAgentsLoading = isLoading && agents.length === 0;
|
const showInitialAgentsLoading = isLoading && agents.length === 0;
|
||||||
|
|
||||||
const handleOpenNewAgent = useCallback(() => {
|
const handleOpenNewAgent = useCallback(() => {
|
||||||
@@ -1195,6 +1279,53 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="agent-controls-bulk-actions" role="menu" aria-label="Bulk agent actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="agent-detail-bulk-menu-item"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={isPauseAllDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
setIsControlsPanelOpen(false);
|
||||||
|
void handleBulkStateChange("paused");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="agent-controls-bulk-actions__label">
|
||||||
|
<Pause />
|
||||||
|
<span>Pause All Agents</span>
|
||||||
|
</span>
|
||||||
|
<span className="agent-detail-bulk-menu-item-hint">
|
||||||
|
{isBulkEligibilityLoading
|
||||||
|
? "Loading eligibility…"
|
||||||
|
: bulkPauseEligibleCount === 0
|
||||||
|
? "No active or running project agents to pause"
|
||||||
|
: `Pause ${bulkPauseEligibleCount} active/running agent${bulkPauseEligibleCount === 1 ? "" : "s"}`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="agent-detail-bulk-menu-item"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={isResumeAllDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
setIsControlsPanelOpen(false);
|
||||||
|
void handleBulkStateChange("active");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="agent-controls-bulk-actions__label">
|
||||||
|
<Play />
|
||||||
|
<span>Resume All Agents</span>
|
||||||
|
</span>
|
||||||
|
<span className="agent-detail-bulk-menu-item-hint">
|
||||||
|
{isBulkEligibilityLoading
|
||||||
|
? "Loading eligibility…"
|
||||||
|
: bulkResumeEligibleCount === 0
|
||||||
|
? "No paused project agents to resume"
|
||||||
|
: `Resume ${bulkResumeEligibleCount} paused agent${bulkResumeEligibleCount === 1 ? "" : "s"}`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="agent-global-controls agent-controls-actions">
|
<div className="agent-global-controls agent-controls-actions">
|
||||||
<div className="heartbeat-multiplier-group">
|
<div className="heartbeat-multiplier-group">
|
||||||
<div className="heartbeat-multiplier-controls">
|
<div className="heartbeat-multiplier-controls">
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { useChat, type ChatMessageInfo, type FailureInfo, type ToolCallInfo } fr
|
|||||||
import { useChatRooms } from "../hooks/useChatRooms";
|
import { useChatRooms } from "../hooks/useChatRooms";
|
||||||
import { useChatUnread } from "../hooks/useChatUnread";
|
import { useChatUnread } from "../hooks/useChatUnread";
|
||||||
import { useViewportMode } from "./Header";
|
import { useViewportMode } from "./Header";
|
||||||
import { updateGlobalSettings } from "../api";
|
import { updateGlobalSettings, type DiscoveredSkill } from "../api";
|
||||||
import type { Agent } from "@fusion/core";
|
import type { Agent } from "@fusion/core";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
|
|||||||
@@ -2353,6 +2353,149 @@ describe("AgentsView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("bulk agent controls", () => {
|
||||||
|
const bulkAgents: Agent[] = [
|
||||||
|
{
|
||||||
|
id: "bulk-active",
|
||||||
|
name: "Active Agent",
|
||||||
|
role: "executor" as AgentCapability,
|
||||||
|
state: "active" as AgentState,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bulk-running",
|
||||||
|
name: "Running Agent",
|
||||||
|
role: "reviewer" as AgentCapability,
|
||||||
|
state: "running" as AgentState,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bulk-paused",
|
||||||
|
name: "Paused Agent",
|
||||||
|
role: "triage" as AgentCapability,
|
||||||
|
state: "paused" as AgentState,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bulk-idle",
|
||||||
|
name: "Idle Agent",
|
||||||
|
role: "engineer" as AgentCapability,
|
||||||
|
state: "idle" as AgentState,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bulk-system",
|
||||||
|
name: "System Worker",
|
||||||
|
role: "executor" as AgentCapability,
|
||||||
|
state: "active" as AgentState,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
metadata: { agentKind: "task-worker" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("loads bulk eligibility when controls open and shows count hints", async () => {
|
||||||
|
mockFetchAgents.mockResolvedValue(bulkAgents);
|
||||||
|
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||||
|
|
||||||
|
const initialFetchCount = mockFetchAgents.mock.calls.length;
|
||||||
|
await openControlsPanel();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchAgents.mock.calls.length).toBeGreaterThan(initialFetchCount);
|
||||||
|
expect(screen.getByText("Pause 2 active/running agents")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Resume 1 paused agent")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables pause and resume actions when no agents are eligible", async () => {
|
||||||
|
mockFetchAgents.mockResolvedValue([
|
||||||
|
{ ...bulkAgents[3] },
|
||||||
|
{ ...bulkAgents[4], state: "paused" as AgentState },
|
||||||
|
]);
|
||||||
|
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||||
|
await openControlsPanel();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("menuitem", { name: /Pause All Agents/i })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("menuitem", { name: /Resume All Agents/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("No active or running project agents to pause")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("No paused project agents to resume")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauses eligible non-ephemeral agents after confirmation", async () => {
|
||||||
|
mockFetchAgents.mockResolvedValue(bulkAgents);
|
||||||
|
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||||
|
await openControlsPanel();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /Pause All Agents/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
title: "Pause All Agents",
|
||||||
|
danger: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateAgentState).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
expect(mockUpdateAgentState).toHaveBeenCalledWith("bulk-active", "paused", projectId);
|
||||||
|
expect(mockUpdateAgentState).toHaveBeenCalledWith("bulk-running", "paused", projectId);
|
||||||
|
expect(mockUpdateAgentState).not.toHaveBeenCalledWith("bulk-system", "paused", projectId);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAddToast).toHaveBeenCalledWith("Paused 2 agents; skipped 2", "success");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes paused agents only", async () => {
|
||||||
|
mockFetchAgents.mockResolvedValue(bulkAgents);
|
||||||
|
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||||
|
await openControlsPanel();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /Resume All Agents/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateAgentState).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(mockUpdateAgentState).toHaveBeenCalledWith("bulk-paused", "active", projectId);
|
||||||
|
expect(mockUpdateAgentState).not.toHaveBeenCalledWith("bulk-active", "active", projectId);
|
||||||
|
expect(mockUpdateAgentState).not.toHaveBeenCalledWith("bulk-system", "active", projectId);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAddToast).toHaveBeenCalledWith("Resumed 1 agent; skipped 3", "success");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows aggregate failure details when a bulk action partially fails", async () => {
|
||||||
|
mockFetchAgents.mockResolvedValue(bulkAgents);
|
||||||
|
mockUpdateAgentState.mockImplementation(async (agentId, newState) => {
|
||||||
|
if (agentId === "bulk-running") {
|
||||||
|
throw new Error("network boom");
|
||||||
|
}
|
||||||
|
return { ...bulkAgents[0], id: agentId, state: newState };
|
||||||
|
});
|
||||||
|
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||||
|
await openControlsPanel();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /Pause All Agents/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAddToast).toHaveBeenCalledWith(
|
||||||
|
"Paused 1 agent; skipped 2; failed 1 (Running Agent: network boom)",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("global heartbeat multiplier", () => {
|
describe("global heartbeat multiplier", () => {
|
||||||
it("renders the global heartbeat speed control", async () => {
|
it("renders the global heartbeat speed control", async () => {
|
||||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
||||||
|
|||||||
Reference in New Issue
Block a user