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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user