feat(FN-2635): add optimistic agent state updates across dashboard

- Apply optimistic start/stop state transitions in AgentsView and AgentDetailView action handlers
- Add optimistic behavior to AgentListModal while preserving rollback on API failure
- Expand unit test coverage for optimistic transitions and failure recovery in all three agent views
- Update dashboard TUI test expectation to align with the new agent state update flow
This commit is contained in:
Fusion
2026-04-26 22:59:45 -07:00
committed by gsxdsm
parent 567e869f7b
commit ddf5b13c00
7 changed files with 435 additions and 21 deletions

View File

@@ -154,7 +154,7 @@ afterEach(() => {
vi.useRealTimers();
});
async function waitForFrameContains(lastFrame: () => string | undefined, text: string, timeoutMs = 1200) {
async function waitForFrameContains(lastFrame: () => string | undefined, text: string, timeoutMs = 3000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if ((lastFrame() ?? "").includes(text)) return;

View File

@@ -122,6 +122,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabId>("dashboard");
const [isStreaming, setIsStreaming] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
const logContainerRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
const addToastRef = useRef(addToast);
@@ -307,12 +308,23 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
}, [agent?.taskId, activeTab, projectId]);
const handleStateChange = async (newState: AgentState) => {
if (isTransitioning || !agentRef.current) return;
const previousState = agentRef.current.state;
if (previousState === newState) return;
setIsTransitioning(true);
setAgent((prev) => (prev ? { ...prev, state: newState } : prev));
try {
await updateAgentState(agentId, newState, projectId);
addToast(`Agent state updated to ${newState}`, "success");
void loadAgent();
} catch (err) {
setAgent((prev) => (prev ? { ...prev, state: previousState } : prev));
addToast(`Failed to update state: ${getErrorMessage(err)}`, "error");
} finally {
setIsTransitioning(false);
}
};
@@ -400,7 +412,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
{/* State-dependent action buttons */}
{agent.state === "idle" && (
<>
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<Play size={14} />
Start
</button>
@@ -411,24 +423,24 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
</>
)}
{agent.state === "active" && (
<button className="btn btn--compact" onClick={() => void handleStateChange("paused")}>
<button className="btn btn--compact" onClick={() => void handleStateChange("paused")} disabled={isTransitioning}>
<Pause size={14} />
Pause
</button>
)}
{agent.state === "paused" && (
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<Play size={14} />
Resume
</button>
)}
{agent.state === "running" && (
<>
<button className="btn btn--compact" onClick={() => void handleStateChange("paused")}>
<button className="btn btn--compact" onClick={() => void handleStateChange("paused")} disabled={isTransitioning}>
<Pause size={14} />
Pause
</button>
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")} disabled={isTransitioning}>
<Square size={14} />
Stop
</button>
@@ -436,11 +448,11 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
)}
{agent.state === "error" && (
<>
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<Play size={14} />
Retry
</button>
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")}>
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")} disabled={isTransitioning}>
<Square size={14} />
Stop
</button>
@@ -448,7 +460,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
)}
{agent.state === "terminated" && (
<>
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")}>
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
<Play size={14} />
Start
</button>

View File

@@ -58,15 +58,28 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null);
const roleSelectRef = useRef<HTMLSelectElement>(null);
const [transitioningAgentIds, setTransitioningAgentIds] = useState<Set<string>>(new Set());
const [optimisticStateOverrides, setOptimisticStateOverrides] = useState<Map<string, AgentState>>(new Map());
const optimisticAgents = useMemo(() => {
if (optimisticStateOverrides.size === 0) {
return agents;
}
return agents.map((agent) => {
const optimisticState = optimisticStateOverrides.get(agent.id);
return optimisticState ? { ...agent, state: optimisticState } : agent;
});
}, [agents, optimisticStateOverrides]);
// Filter agents for display: hide terminated agents in default "All States" view
// but show them when the user explicitly filters to "terminated"
const displayAgents = useMemo(() => {
if (filterState === "all") {
return agents.filter(a => a.state !== "terminated");
return optimisticAgents.filter((a) => a.state !== "terminated");
}
return agents;
}, [agents, filterState]);
return optimisticAgents;
}, [optimisticAgents, filterState]);
const loadAgents = useCallback(async () => {
setIsLoading(true);
@@ -115,12 +128,37 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
};
const handleStateChange = async (agentId: string, newState: AgentState) => {
if (transitioningAgentIds.has(agentId)) return;
setTransitioningAgentIds((prev) => new Set(prev).add(agentId));
setOptimisticStateOverrides((prev) => {
const next = new Map(prev);
next.set(agentId, newState);
return next;
});
try {
await updateAgentState(agentId, newState, projectId);
addToast(`Agent state updated to ${newState}`, "success");
void loadAgents();
await loadAgents();
setOptimisticStateOverrides((prev) => {
const next = new Map(prev);
next.delete(agentId);
return next;
});
} catch (err) {
setOptimisticStateOverrides((prev) => {
const next = new Map(prev);
next.delete(agentId);
return next;
});
addToast(`Failed to update state: ${getErrorMessage(err)}`, "error");
} finally {
setTransitioningAgentIds((prev) => {
const next = new Set(prev);
next.delete(agentId);
return next;
});
}
};
@@ -318,6 +356,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Activate"
>
<Play size={14} />
@@ -336,6 +375,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Pause"
>
<Pause size={14} />
@@ -343,6 +383,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
>
<Square size={14} />
@@ -354,6 +395,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Resume"
>
<Play size={14} />
@@ -361,6 +403,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
>
<Square size={14} />
@@ -372,6 +415,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Pause"
>
<Pause size={14} />
@@ -379,6 +423,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
>
<Square size={14} />
@@ -390,6 +435,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Retry"
>
<Play size={14} />
@@ -397,6 +443,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
>
<Square size={14} />
@@ -499,6 +546,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Activate"
>
<Play size={14} /> Start
@@ -517,6 +565,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Pause"
>
<Pause size={14} /> Pause
@@ -524,6 +573,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
>
<Square size={14} /> Stop
@@ -535,6 +585,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Resume"
>
<Play size={14} /> Resume
@@ -542,6 +593,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
>
<Square size={14} /> Stop
@@ -553,6 +605,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Pause"
>
<Pause size={14} /> Pause
@@ -560,6 +613,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
>
<Square size={14} /> Stop
@@ -571,6 +625,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Retry"
>
<Play size={14} /> Retry
@@ -578,6 +633,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
>
<Square size={14} /> Stop
@@ -589,6 +645,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn btn--sm btn--primary"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Start"
>
<Play size={14} /> Start

View File

@@ -254,7 +254,7 @@ function OrgChartNode({
export function AgentsView({ addToast, projectId }: AgentsViewProps) {
const [showSystemAgents, setShowSystemAgents] = useState(false);
const [filterState, setFilterState] = useState<AgentState | "all">("all");
const { agents, activeAgents, stats, isLoading, loadAgents } = useAgents(projectId, {
const { agents, stats, isLoading, loadAgents } = useAgents(projectId, {
filterState,
showSystemAgents,
});
@@ -300,6 +300,8 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
const [isSavingMultiplier, setIsSavingMultiplier] = useState(false);
/** Agent IDs with an in-flight state transition (for optimistic update guard) */
const [transitioningAgentIds, setTransitioningAgentIds] = useState<Set<string>>(new Set());
/** Optimistic state overrides keyed by agent ID while pause/resume/start API call is in-flight */
const [optimisticStateOverrides, setOptimisticStateOverrides] = useState<Map<string, AgentState>>(new Map());
const isMountedRef = useRef(true);
useEffect(() => {
@@ -338,14 +340,34 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
}
}, [projectId, addToast]);
const hierarchy = useAgentHierarchy(agents, projectId);
const optimisticAgents = useMemo(() => {
if (optimisticStateOverrides.size === 0) {
return agents;
}
return agents.map((agent) => {
const optimisticState = optimisticStateOverrides.get(agent.id);
return optimisticState ? { ...agent, state: optimisticState } : agent;
});
}, [agents, optimisticStateOverrides]);
const hierarchy = useAgentHierarchy(optimisticAgents, projectId);
// Filter agents for display. "All States" means all non-ephemeral agents,
// including disabled/terminated agents that still carry configuration.
// When "Show system agents" is enabled, include ephemeral/internal agents.
const displayAgents = useMemo(() => {
return agents.filter((agent) => showSystemAgents || !isEphemeralAgent(agent));
}, [agents, showSystemAgents]);
return optimisticAgents.filter((agent) => showSystemAgents || !isEphemeralAgent(agent));
}, [optimisticAgents, showSystemAgents]);
const displayActiveAgents = useMemo(() => {
return optimisticAgents.filter((agent) => {
if (agent.state !== "active" && agent.state !== "running") {
return false;
}
return showSystemAgents || !isEphemeralAgent(agent);
});
}, [optimisticAgents, showSystemAgents]);
// Filter org tree to exclude ephemeral agents in default view.
const displayOrgTree = useMemo(() => {
@@ -440,15 +462,36 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
const handleStateChange = async (agentId: string, newState: AgentState) => {
if (transitioningAgentIds.has(agentId)) return;
setTransitioningAgentIds(prev => new Set(prev).add(agentId));
setTransitioningAgentIds((prev) => new Set(prev).add(agentId));
setOptimisticStateOverrides((prev) => {
const next = new Map(prev);
next.set(agentId, newState);
return next;
});
try {
await updateAgentState(agentId, newState, projectId);
addToast(`Agent state updated to ${newState}`, "success");
void loadAgents();
await loadAgents();
setOptimisticStateOverrides((prev) => {
const next = new Map(prev);
next.delete(agentId);
return next;
});
} catch (err) {
setOptimisticStateOverrides((prev) => {
const next = new Map(prev);
next.delete(agentId);
return next;
});
addToast(`Failed to update state: ${getErrorMessage(err)}`, "error");
} finally {
setTransitioningAgentIds(prev => { const next = new Set(prev); next.delete(agentId); return next; });
setTransitioningAgentIds((prev) => {
const next = new Set(prev);
next.delete(agentId);
return next;
});
}
};
@@ -1263,7 +1306,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
)}
{/* Secondary sections after the main collection */}
<ActiveAgentsPanel agents={activeAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} />
<ActiveAgentsPanel agents={displayActiveAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} />
</div>
{/* Agent Detail Modal */}

View File

@@ -536,6 +536,93 @@ describe("AgentDetailView", () => {
});
});
it("optimistically updates the detail header state before API resolves", async () => {
let resolveTransition: (() => void) | null = null;
const transitionPromise = new Promise<AgentDetail>((resolve) => {
resolveTransition = () => resolve(createMockAgent({ state: "paused" }));
});
mockUpdateAgentState.mockImplementationOnce(() => transitionPromise);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
const pauseButton = await screen.findByText("Pause");
await userEvent.click(pauseButton);
await waitFor(() => {
expect(screen.getAllByText("paused").length).toBeGreaterThan(0);
expect(screen.getByText("Resume")).toBeInTheDocument();
});
resolveTransition?.();
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "paused", undefined);
});
});
it("rolls back optimistic detail state when API call fails", async () => {
let rejectTransition: ((error: Error) => void) | null = null;
const transitionPromise = new Promise<AgentDetail>((_, reject) => {
rejectTransition = reject;
});
mockUpdateAgentState.mockImplementationOnce(() => transitionPromise);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await userEvent.click(await screen.findByText("Pause"));
await waitFor(() => {
expect(screen.getByText("Resume")).toBeInTheDocument();
});
rejectTransition?.(new Error("State change failed"));
await waitFor(() => {
expect(screen.getByText("Pause")).toBeInTheDocument();
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
});
});
it("disables lifecycle transition buttons while state transition is in-flight", async () => {
let resolveTransition: (() => void) | null = null;
const transitionPromise = new Promise<AgentDetail>((resolve) => {
resolveTransition = () => resolve(createMockAgent({ state: "paused" }));
});
mockUpdateAgentState.mockImplementationOnce(() => transitionPromise);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await userEvent.click(await screen.findByText("Pause"));
await waitFor(() => {
const resumeButton = screen.getByText("Resume").closest("button") as HTMLButtonElement | null;
expect(resumeButton).toBeTruthy();
expect(resumeButton?.disabled).toBe(true);
});
resolveTransition?.();
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "paused", undefined);
});
});
it("shows Resume button for paused agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "paused" }));

View File

@@ -634,6 +634,120 @@ describe("AgentListModal", () => {
});
});
it("optimistically updates list card state before API resolves", async () => {
let resolveTransition: (() => void) | null = null;
const transitionPromise = new Promise<Agent>((resolve) => {
resolveTransition = () => resolve({ ...mockAgents[0], state: "active" });
});
mockUpdateAgentState.mockImplementationOnce(() => transitionPromise);
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Activate"));
await waitFor(() => {
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
expect(targetCard?.textContent).toContain("active");
expect(targetCard?.querySelector('[title="Pause"]')).toBeTruthy();
});
resolveTransition?.();
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined);
});
});
it("rolls back optimistic list state when API call fails", async () => {
let rejectTransition: ((error: Error) => void) | null = null;
const transitionPromise = new Promise<Agent>((_, reject) => {
rejectTransition = reject;
});
mockUpdateAgentState.mockImplementationOnce(() => transitionPromise);
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Activate"));
await waitFor(() => {
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
expect(targetCard?.textContent).toContain("active");
});
rejectTransition?.(new Error("Invalid transition"));
await waitFor(() => {
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
expect(targetCard?.textContent).toContain("idle");
});
});
it("prevents concurrent state changes while transition is in-flight", async () => {
let resolveTransition: (() => void) | null = null;
const transitionPromise = new Promise<Agent>((resolve) => {
resolveTransition = () => resolve({ ...mockAgents[0], state: "active" });
});
mockUpdateAgentState.mockImplementationOnce(() => transitionPromise);
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Activate"));
await waitFor(() => {
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
const pauseButton = targetCard?.querySelector('[title="Pause"]') as HTMLButtonElement | null;
expect(pauseButton).toBeTruthy();
expect(pauseButton?.disabled).toBe(true);
});
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
const pauseButton = targetCard?.querySelector('[title="Pause"]') as HTMLButtonElement | null;
if (pauseButton) {
fireEvent.click(pauseButton);
}
expect(mockUpdateAgentState).toHaveBeenCalledTimes(1);
resolveTransition?.();
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined);
});
});
it("handles state change errors gracefully", async () => {
mockUpdateAgentState.mockRejectedValue(new Error("Invalid transition"));

View File

@@ -1130,6 +1130,107 @@ describe("AgentsView", () => {
expect(mockStartAgentRun).not.toHaveBeenCalled();
});
it("optimistically updates the card state before state API resolves", async () => {
let resolveTransition: (() => void) | null = null;
const transitionPromise = new Promise<Agent>((resolve) => {
resolveTransition = () => resolve({ ...mockAgents[0], state: "active" });
});
mockUpdateAgentState.mockImplementationOnce(() => transitionPromise);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Activate"));
await waitFor(() => {
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
expect(targetCard).toBeTruthy();
expect(targetCard?.textContent).toContain("active");
});
resolveTransition?.();
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined);
});
});
it("rolls back optimistic state when the state API fails", async () => {
let rejectTransition: ((error: Error) => void) | null = null;
const transitionPromise = new Promise<Agent>((_, reject) => {
rejectTransition = reject;
});
mockUpdateAgentState.mockImplementationOnce(() => transitionPromise);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Activate"));
await waitFor(() => {
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
expect(targetCard?.textContent).toContain("active");
});
rejectTransition?.(new Error("State change failed"));
await waitFor(() => {
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
expect(targetCard?.textContent).toContain("idle");
});
expect(mockAddToast).toHaveBeenCalledWith(
expect.stringContaining("State change failed"),
"error"
);
});
it("prevents concurrent state transitions for the same agent", async () => {
let resolveTransition: (() => void) | null = null;
const transitionPromise = new Promise<Agent>((resolve) => {
resolveTransition = () => resolve({ ...mockAgents[0], state: "active" });
});
mockUpdateAgentState.mockImplementationOnce(() => transitionPromise);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTitle("Activate")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Activate"));
await waitFor(() => {
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
const pauseButton = targetCard?.querySelector('[title="Pause"]') as HTMLButtonElement | null;
expect(pauseButton).toBeTruthy();
expect(pauseButton?.disabled).toBe(true);
});
const agentCards = Array.from(document.querySelectorAll(".agent-card"));
const targetCard = agentCards.find((card) => card.textContent?.includes("agent-001"));
const pauseButton = targetCard?.querySelector('[title="Pause"]') as HTMLButtonElement | null;
if (pauseButton) {
fireEvent.click(pauseButton);
}
expect(mockUpdateAgentState).toHaveBeenCalledTimes(1);
resolveTransition?.();
await waitFor(() => {
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "active", undefined);
});
});
it("handles state change error gracefully", async () => {
mockUpdateAgentState.mockRejectedValue(new Error("State change failed"));