From a1b4a51b8e41184d7b1ab92a6c5065ac25270624 Mon Sep 17 00:00:00 2001 From: Maaz Ghani Date: Sat, 23 May 2026 14:34:59 -0700 Subject: [PATCH 1/4] implement UI namespace filtering Signed-off-by: Maaz Ghani --- ui/src/app/actions/agents.ts | 3 +- ui/src/app/agents/new/page.tsx | 11 +- ui/src/components/AgentCard.tsx | 4 +- ui/src/components/AgentGrid.tsx | 7 +- ui/src/components/AgentList.tsx | 169 +++++++++++++----- ui/src/components/AgentListView.tsx | 10 +- ui/src/components/AgentsProvider.tsx | 17 +- ui/src/components/DeleteAgentButton.tsx | 7 +- ui/src/components/NamespaceCombobox.tsx | 41 ++++- .../__tests__/AgentList.namespace.test.tsx | 139 ++++++++++++++ .../AgentsProvider.namespace.test.tsx | 46 +++++ .../CreateAgentPage.namespace.test.tsx | 78 ++++++++ ui/src/lib/__tests__/agentsActions.test.ts | 31 ++++ 13 files changed, 483 insertions(+), 80 deletions(-) create mode 100644 ui/src/lib/__tests__/AgentList.namespace.test.tsx create mode 100644 ui/src/lib/__tests__/AgentsProvider.namespace.test.tsx create mode 100644 ui/src/lib/__tests__/CreateAgentPage.namespace.test.tsx create mode 100644 ui/src/lib/__tests__/agentsActions.test.ts diff --git a/ui/src/app/actions/agents.ts b/ui/src/app/actions/agents.ts index bdf6209d9c..978183573f 100644 --- a/ui/src/app/actions/agents.ts +++ b/ui/src/app/actions/agents.ts @@ -570,8 +570,9 @@ export async function getAgents(opts: { namespace?: string } = {}): Promise>(path); + const agents = Array.isArray(data) ? data : []; - const sortedData = data?.sort((a, b) => { + const sortedData = agents.sort((a, b) => { const aRef = k8sRefUtils.toRef(a.agent.metadata.namespace || "", a.agent.metadata.name); const bRef = k8sRefUtils.toRef(b.agent.metadata.namespace || "", b.agent.metadata.name); return aRef.localeCompare(bRef); diff --git a/ui/src/app/agents/new/page.tsx b/ui/src/app/agents/new/page.tsx index 3c9c1549fa..b63516904f 100644 --- a/ui/src/app/agents/new/page.tsx +++ b/ui/src/app/agents/new/page.tsx @@ -62,6 +62,7 @@ const DEFAULT_SYSTEM_PROMPT = `You're a helpful agent, made by the kagent team. function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageContentProps) { const router = useRouter(); const { models, loading, error, createNewAgent, updateAgent, getAgent, validateAgentData } = useAgents(); + const initialNamespace = !isEditMode && agentNamespace ? agentNamespace : "default"; type SelectedModelType = ModelConfig; @@ -100,7 +101,7 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo const [state, setState] = useState({ name: "", - namespace: "default", + namespace: initialNamespace, description: "", agentType: "Declarative", systemPrompt: isEditMode ? "" : DEFAULT_SYSTEM_PROMPT, @@ -486,7 +487,11 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo } setFormDirty(false); - router.push(`/agents`); + const returnPath = + !isEditMode && agentNamespace + ? `/agents?namespace=${encodeURIComponent(state.namespace)}` + : "/agents"; + router.push(returnPath); } catch (e) { console.error(`Error ${isEditMode ? "updating" : "creating"} agent:`, e); const errorMessage = @@ -881,7 +886,7 @@ export default function AgentPage() { const isEditMode = searchParams.get("edit") === "true"; const agentName = searchParams.get("name"); const agentNamespace = searchParams.get("namespace"); - const formKey = isEditMode ? `edit-${agentName}-${agentNamespace}` : "create"; + const formKey = isEditMode ? `edit-${agentName}-${agentNamespace}` : `create-${agentNamespace || "default"}`; return ( }> diff --git a/ui/src/components/AgentCard.tsx b/ui/src/components/AgentCard.tsx index cb99f87575..e81245d9a2 100644 --- a/ui/src/components/AgentCard.tsx +++ b/ui/src/components/AgentCard.tsx @@ -24,9 +24,10 @@ import { cn } from "@/lib/utils"; interface AgentCardProps { agentResponse: AgentResponse; + onAgentsChanged?: () => Promise | void; } -export function AgentCard({ agentResponse }: AgentCardProps) { +export function AgentCard({ agentResponse, onAgentsChanged }: AgentCardProps) { const { agent, model, modelProvider, deploymentReady, accepted } = agentResponse; const router = useRouter(); const [memoriesOpen, setMemoriesOpen] = useState(false); @@ -192,6 +193,7 @@ export function AgentCard({ agentResponse }: AgentCardProps) { diff --git a/ui/src/components/AgentGrid.tsx b/ui/src/components/AgentGrid.tsx index 2a5e119d3c..4790eb9a3c 100644 --- a/ui/src/components/AgentGrid.tsx +++ b/ui/src/components/AgentGrid.tsx @@ -4,9 +4,10 @@ import { k8sRefUtils } from "@/lib/k8sUtils"; interface AgentGridProps { agentResponse: AgentResponse[]; + onAgentsChanged?: () => Promise | void; } -export function AgentGrid({ agentResponse }: AgentGridProps) { +export function AgentGrid({ agentResponse, onAgentsChanged }: AgentGridProps) { return (
@@ -15,8 +16,8 @@ export function AgentGrid({ agentResponse }: AgentGridProps) { item.agent.metadata.namespace || '', item.agent.metadata.name || ''); - return + return })}
); -} \ No newline at end of file +} diff --git a/ui/src/components/AgentList.tsx b/ui/src/components/AgentList.tsx index 45d13786f5..cb196fa787 100644 --- a/ui/src/components/AgentList.tsx +++ b/ui/src/components/AgentList.tsx @@ -5,13 +5,16 @@ import { AgentListView } from "@/components/AgentListView"; import { Plus, LayoutGrid, List } from "lucide-react"; import KagentLogo from "@/components/kagent-logo"; import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; import { ErrorState } from "./ErrorState"; import { Button } from "./ui/button"; import { LoadingState } from "./LoadingState"; -import { useAgents } from "./AgentsProvider"; +import { getAgents } from "@/app/actions/agents"; import { AppPageFrame } from "@/components/layout/AppPageFrame"; import { PageHeader } from "@/components/layout/PageHeader"; import { cn } from "@/lib/utils"; +import { NamespaceCombobox } from "@/components/NamespaceCombobox"; +import type { AgentResponse } from "@/types"; const AGENTS_VIEW_KEY = "kagent-agents-view"; type AgentsView = "grid" | "list"; @@ -25,9 +28,30 @@ function readStoredView(): AgentsView { } export default function AgentList() { - const { agents , loading, error } = useAgents(); + const router = useRouter(); + const searchParams = useSearchParams(); + const selectedNamespace = searchParams.get("namespace")?.trim() || ""; + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); const [view, setView] = useState("grid"); + const fetchAgents = useCallback(async () => { + try { + setLoading(true); + const result = await getAgents(selectedNamespace ? { namespace: selectedNamespace } : {}); + if (result.error) { + throw new Error(result.error || "Failed to fetch agents"); + } + setAgents(result.data || []); + setError(""); + } catch (err) { + setError(err instanceof Error ? err.message : "An unexpected error occurred"); + } finally { + setLoading(false); + } + }, [selectedNamespace]); + useEffect(() => { const id = requestAnimationFrame(() => { setView(readStoredView()); @@ -35,6 +59,10 @@ export default function AgentList() { return () => cancelAnimationFrame(id); }, []); + useEffect(() => { + void fetchAgents(); + }, [fetchAgents]); + const setViewAndPersist = useCallback((next: AgentsView) => { setView(next); try { @@ -44,6 +72,22 @@ export default function AgentList() { } }, []); + const handleNamespaceChange = useCallback( + (nextNamespace: string) => { + const namespace = nextNamespace.trim(); + if (!namespace) { + router.push("/agents"); + return; + } + router.push(`/agents?namespace=${encodeURIComponent(namespace)}`); + }, + [router], + ); + + const createHref = selectedNamespace + ? `/agents/new?namespace=${encodeURIComponent(selectedNamespace)}` + : "/agents/new"; + if (error) { return ; } @@ -57,69 +101,98 @@ export default function AgentList() { + Showing agents in namespace {selectedNamespace}. + + ) : ( + "Showing agents across all namespaces." + ) + } className="mb-8" end={ - agents && agents.length > 0 ? ( -
- - +
+
+
- ) : null + {agents && agents.length > 0 ? ( +
+ + +
+ ) : null} +
} /> {agents?.length === 0 ? (
-

No agents yet

-

Create an agent to run it in your cluster and wire models and tools in one place.

+

+ {selectedNamespace + ? `No agents found in namespace "${selectedNamespace}".` + : "No agents yet"} +

+

+ {selectedNamespace + ? "Create an agent in this namespace or switch namespaces." + : "Create an agent to run it in your cluster and wire models and tools in one place."} +

) : view === "list" ? ( - + ) : ( - + )} ); diff --git a/ui/src/components/AgentListView.tsx b/ui/src/components/AgentListView.tsx index 08f08e0d1c..67e9370e94 100644 --- a/ui/src/components/AgentListView.tsx +++ b/ui/src/components/AgentListView.tsx @@ -23,6 +23,7 @@ import { isOpenshellSandboxRow, openshellTerminalHref } from "@/lib/openshellSan interface AgentListViewProps { agentResponse: AgentResponse[]; + onAgentsChanged?: () => Promise | void; } type SortKey = "name" | "type" | "providerModel" | "toolCount" | "skillsCount" | "state"; @@ -209,7 +210,7 @@ function SortableTh({ col, label, className, textAlign = "left", sort, onSort }: ); } -function AgentListRow({ item }: { item: AgentResponse }) { +function AgentListRow({ item, onAgentsChanged }: { item: AgentResponse; onAgentsChanged?: () => Promise | void }) { const { agent, deploymentReady, accepted } = item; const router = useRouter(); const [memoriesOpen, setMemoriesOpen] = useState(false); @@ -379,6 +380,7 @@ function AgentListRow({ item }: { item: AgentResponse }) { @@ -396,7 +398,7 @@ function AgentListRow({ item }: { item: AgentResponse }) { return trBody; } -export function AgentListView({ agentResponse }: AgentListViewProps) { +export function AgentListView({ agentResponse, onAgentsChanged }: AgentListViewProps) { const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: "name", dir: "asc" }); const onSort = useCallback((col: SortKey) => { @@ -443,10 +445,10 @@ export function AgentListView({ agentResponse }: AgentListViewProps) { item.agent.metadata.namespace || "", item.agent.metadata.name || "", ); - return ; + return ; })}
); -} \ No newline at end of file +} diff --git a/ui/src/components/AgentsProvider.tsx b/ui/src/components/AgentsProvider.tsx index eb90f86be9..c63c5ec136 100644 --- a/ui/src/components/AgentsProvider.tsx +++ b/ui/src/components/AgentsProvider.tsx @@ -278,11 +278,6 @@ export function AgentsProvider({ children }: AgentsProviderProps) { const result = await createAgent(agentData); - if (!result.error) { - // Refresh agents to get the newly created one - await fetchAgents(); - } - return result; } catch (error) { console.error("Error creating agent:", error); @@ -291,7 +286,7 @@ export function AgentsProvider({ children }: AgentsProviderProps) { error: error instanceof Error ? error.message : "Failed to create agent", }; } - }, [fetchAgents, validateAgentData]); + }, [validateAgentData]); // Update existing agent const updateAgent = useCallback(async (agentData: AgentFormData): Promise> => { @@ -306,11 +301,6 @@ export function AgentsProvider({ children }: AgentsProviderProps) { // Use the same createAgent endpoint for updates const result = await createAgent(agentData, true); - if (!result.error) { - // Refresh agents to get the updated one - await fetchAgents(); - } - return result; } catch (error) { console.error("Error updating agent:", error); @@ -319,14 +309,13 @@ export function AgentsProvider({ children }: AgentsProviderProps) { error: error instanceof Error ? error.message : "Failed to update agent", }; } - }, [fetchAgents, validateAgentData]); + }, [validateAgentData]); // Initial fetches useEffect(() => { - fetchAgents(); fetchTools(); fetchModels(); - }, [fetchAgents, fetchTools, fetchModels]); + }, [fetchTools, fetchModels]); const value = { agents, diff --git a/ui/src/components/DeleteAgentButton.tsx b/ui/src/components/DeleteAgentButton.tsx index 8f9479fc9b..540e002d03 100644 --- a/ui/src/components/DeleteAgentButton.tsx +++ b/ui/src/components/DeleteAgentButton.tsx @@ -5,21 +5,20 @@ import { Loader2, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { deleteAgent } from "@/app/actions/agents"; -import { useAgents } from "./AgentsProvider"; interface DeleteButtonProps { agentName: string; namespace: string; disabled?: boolean; + onDeleted?: () => Promise | void; /** When provided, the button is hidden and the dialog is controlled externally. */ externalOpen?: boolean; onExternalOpenChange?: (open: boolean) => void; } -export function DeleteButton({ agentName, namespace, disabled = false, externalOpen, onExternalOpenChange }: DeleteButtonProps) { +export function DeleteButton({ agentName, namespace, disabled = false, onDeleted, externalOpen, onExternalOpenChange }: DeleteButtonProps) { const [internalOpen, setInternalOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const { refreshAgents } = useAgents(); const isControlled = externalOpen !== undefined; const isOpen = isControlled ? externalOpen : internalOpen; @@ -35,7 +34,7 @@ export function DeleteButton({ agentName, namespace, disabled = false, externalO setIsDeleting(true); await deleteAgent(agentName, namespace); - await refreshAgents(); + await onDeleted?.(); } catch (error) { console.error("Error deleting agent:", error); } finally { diff --git a/ui/src/components/NamespaceCombobox.tsx b/ui/src/components/NamespaceCombobox.tsx index 7cadc89e67..a468fc18fa 100644 --- a/ui/src/components/NamespaceCombobox.tsx +++ b/ui/src/components/NamespaceCombobox.tsx @@ -24,6 +24,10 @@ interface NamespaceComboboxProps { onValueChange: (value: string) => void; placeholder?: string; disabled?: boolean; + includeAllNamespaces?: boolean; + allNamespacesLabel?: string; + autoSelectDefault?: boolean; + ariaLabel?: string; /** `id` on the trigger control (for labels and focus management). */ id?: string; // callback to handle errors in case the parent component wants to handle an error @@ -35,6 +39,10 @@ export function NamespaceCombobox({ onValueChange, placeholder = "Select namespace…", disabled = false, + includeAllNamespaces = false, + allNamespacesLabel = "All namespaces", + autoSelectDefault = true, + ariaLabel, id: triggerId, onError, }: NamespaceComboboxProps) { @@ -59,7 +67,7 @@ export function NamespaceCombobox({ onError?.(null); // Set a default namespace if none is currently selected - if (!value) { + if (autoSelectDefault && !value) { const names = sorted.map((ns) => ns.name); let defaultNamespace: string | undefined; if (names.includes("kagent")) { @@ -93,6 +101,7 @@ export function NamespaceCombobox({ }, [onError]); const selectedNamespace = namespaces.find((ns) => ns.name === value); + const showingAllNamespaces = includeAllNamespaces && !value; return ( @@ -102,6 +111,7 @@ export function NamespaceCombobox({ type="button" variant="outline" role="combobox" + aria-label={ariaLabel} aria-expanded={open} className={cn( "w-full justify-between", @@ -114,6 +124,10 @@ export function NamespaceCombobox({ Loading namespaces... + ) : showingAllNamespaces ? ( +
+ {allNamespacesLabel} +
) : selectedNamespace ? (
{selectedNamespace.name} @@ -141,6 +155,29 @@ export function NamespaceCombobox({ {loading ? "Loading..." : "No namespaces found."} + {includeAllNamespaces && ( + { + onValueChange(""); + setOpen(false); + }} + > + +
+ {allNamespacesLabel} + + Show agents across namespaces + +
+
+ )} {namespaces.map((namespace) => ( ); -} \ No newline at end of file +} diff --git a/ui/src/lib/__tests__/AgentList.namespace.test.tsx b/ui/src/lib/__tests__/AgentList.namespace.test.tsx new file mode 100644 index 0000000000..4c70ab8953 --- /dev/null +++ b/ui/src/lib/__tests__/AgentList.namespace.test.tsx @@ -0,0 +1,139 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRouter, useSearchParams } from "next/navigation"; +import AgentList from "@/components/AgentList"; +import { getAgents } from "@/app/actions/agents"; +import type { AgentResponse } from "@/types"; + +jest.mock("@/app/actions/agents", () => ({ + getAgents: jest.fn(), +})); + +jest.mock("@/components/NamespaceCombobox", () => ({ + NamespaceCombobox: ({ + value, + onValueChange, + }: { + value?: string; + onValueChange: (value: string) => void; + }) => ( + + ), +})); + +jest.mock("next/navigation", () => ({ + useRouter: jest.fn(), + useSearchParams: jest.fn(), +})); + +const mockGetAgents = getAgents as jest.MockedFunction; +const mockUseRouter = useRouter as jest.Mock; +const mockUseSearchParams = useSearchParams as jest.Mock; + +function agent(namespace: string, name: string): AgentResponse { + return { + id: `${namespace}/${name}`, + agent: { + metadata: { namespace, name }, + spec: { + type: "Declarative", + description: `${name} description`, + }, + }, + model: "gpt-4.1-mini", + modelProvider: "OpenAI", + modelConfigRef: `${namespace}/model`, + tools: [], + deploymentReady: true, + accepted: true, + }; +} + +function setup(search = "") { + const push = jest.fn(); + mockUseRouter.mockReturnValue({ push }); + mockUseSearchParams.mockReturnValue(new URLSearchParams(search)); + return { push }; +} + +describe("AgentList namespace filtering", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetAgents.mockResolvedValue({ + message: "Successfully fetched agents", + data: [agent("kagent", "k8s-agent")], + }); + }); + + it("fetches unscoped agents and renders all-namespace copy on /agents", async () => { + setup(); + + render(); + + await waitFor(() => expect(mockGetAgents).toHaveBeenCalledWith({})); + expect( + await screen.findByText("Showing agents across all namespaces."), + ).toBeInTheDocument(); + }); + + it("fetches namespace-scoped agents from the namespace URL query", async () => { + setup("namespace=kagent"); + + render(); + + await waitFor(() => + expect(mockGetAgents).toHaveBeenCalledWith({ namespace: "kagent" }), + ); + expect( + await screen.findByText(/Showing agents in namespace/i), + ).toBeInTheDocument(); + }); + + it("updates the URL when the namespace selector changes", async () => { + const user = userEvent.setup(); + const { push } = setup(); + + render(); + + await user.selectOptions(await screen.findByLabelText("Namespace"), "kagent"); + + expect(push).toHaveBeenCalledWith("/agents?namespace=kagent"); + }); + + it("clears the namespace query when All namespaces is selected", async () => { + const user = userEvent.setup(); + const { push } = setup("namespace=kagent"); + + render(); + + await user.selectOptions(await screen.findByLabelText("Namespace"), ""); + + expect(push).toHaveBeenCalledWith("/agents"); + }); + + it("renders scoped empty state with a namespace-aware create link", async () => { + setup("namespace=kube-system"); + mockGetAgents.mockResolvedValueOnce({ + message: "Successfully fetched agents", + data: [], + }); + + render(); + + expect( + await screen.findByText('No agents found in namespace "kube-system".'), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /new agent/i })).toHaveAttribute( + "href", + "/agents/new?namespace=kube-system", + ); + }); +}); diff --git a/ui/src/lib/__tests__/AgentsProvider.namespace.test.tsx b/ui/src/lib/__tests__/AgentsProvider.namespace.test.tsx new file mode 100644 index 0000000000..3f68df9aaa --- /dev/null +++ b/ui/src/lib/__tests__/AgentsProvider.namespace.test.tsx @@ -0,0 +1,46 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { AgentsProvider } from "@/components/AgentsProvider"; +import { getAgents } from "@/app/actions/agents"; +import { getTools } from "@/app/actions/tools"; +import { getModelConfigs } from "@/app/actions/modelConfigs"; + +jest.mock("@/app/actions/agents", () => ({ + getAgent: jest.fn(), + createAgent: jest.fn(), + getAgents: jest.fn(), +})); + +jest.mock("@/app/actions/tools", () => ({ + getTools: jest.fn(), +})); + +jest.mock("@/app/actions/modelConfigs", () => ({ + getModelConfigs: jest.fn(), +})); + +const mockGetAgents = getAgents as jest.MockedFunction; +const mockGetTools = getTools as jest.MockedFunction; +const mockGetModelConfigs = getModelConfigs as jest.MockedFunction; + +describe("AgentsProvider list fetching", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetTools.mockResolvedValue([]); + mockGetModelConfigs.mockResolvedValue({ + message: "Successfully fetched models", + data: [], + }); + }); + + it("does not fetch all agents on mount", async () => { + render( + +
provider child
+
, + ); + + expect(screen.getByText("provider child")).toBeInTheDocument(); + await waitFor(() => expect(mockGetTools).toHaveBeenCalled()); + expect(mockGetAgents).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/lib/__tests__/CreateAgentPage.namespace.test.tsx b/ui/src/lib/__tests__/CreateAgentPage.namespace.test.tsx new file mode 100644 index 0000000000..e82afdff3a --- /dev/null +++ b/ui/src/lib/__tests__/CreateAgentPage.namespace.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import { useRouter, useSearchParams } from "next/navigation"; +import AgentPage from "@/app/agents/new/page"; + +jest.mock("next/navigation", () => ({ + useRouter: jest.fn(), + useSearchParams: jest.fn(), +})); + +jest.mock("@/components/AgentsProvider", () => ({ + useAgents: () => ({ + models: [], + loading: false, + error: "", + createNewAgent: jest.fn(), + updateAgent: jest.fn(), + getAgent: jest.fn(), + validateAgentData: jest.fn(() => ({})), + }), +})); + +jest.mock("@/components/NamespaceCombobox", () => ({ + NamespaceCombobox: ({ value }: { value?: string }) => ( +
{value}
+ ), +})); + +jest.mock("@/components/create/SystemPromptSection", () => ({ + SystemPromptSection: () => null, +})); + +jest.mock("@/components/create/ModelSelectionSection", () => ({ + ModelSelectionSection: () => null, +})); + +jest.mock("@/components/create/ToolsSection", () => ({ + ToolsSection: () => null, +})); + +jest.mock("@/components/create/MemorySection", () => ({ + MemorySection: () => null, +})); + +jest.mock("@/components/create/ContextSection", () => ({ + ContextSection: () => null, +})); + +jest.mock("@/components/agent-form/AgentSkillsFormSection", () => ({ + AgentSkillsFormSection: () => null, +})); + +jest.mock("@/components/agent-form/ServiceAccountNameField", () => ({ + ServiceAccountNameField: () => null, +})); + +jest.mock("@/components/agent-form/DeclarativeRuntimeField", () => ({ + DeclarativeRuntimeField: () => null, +})); + +const mockUseRouter = useRouter as jest.Mock; +const mockUseSearchParams = useSearchParams as jest.Mock; + +describe("new agent namespace query", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRouter.mockReturnValue({ push: jest.fn() }); + }); + + it("initializes the editable namespace from ?namespace= in create mode", async () => { + mockUseSearchParams.mockReturnValue(new URLSearchParams("namespace=kagent")); + + render(); + + expect(await screen.findByTestId("namespace-value")).toHaveTextContent( + "kagent", + ); + }); +}); diff --git a/ui/src/lib/__tests__/agentsActions.test.ts b/ui/src/lib/__tests__/agentsActions.test.ts new file mode 100644 index 0000000000..e810430502 --- /dev/null +++ b/ui/src/lib/__tests__/agentsActions.test.ts @@ -0,0 +1,31 @@ +import { getAgents } from "@/app/actions/agents"; +import { fetchApi } from "@/app/actions/utils"; + +jest.mock("next/cache", () => ({ + revalidatePath: jest.fn(), +})); + +jest.mock("@/app/actions/utils", () => ({ + fetchApi: jest.fn(), + createErrorResponse: jest.fn((error: unknown, defaultMessage: string) => ({ + message: error instanceof Error ? error.message : defaultMessage, + error: error instanceof Error ? error.message : defaultMessage, + })), +})); + +const mockFetchApi = fetchApi as jest.MockedFunction; + +describe("getAgents", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("normalizes a successful response without data to an empty list", async () => { + mockFetchApi.mockResolvedValueOnce({ message: "Successfully fetched agents" }); + + const result = await getAgents(); + + expect(result.error).toBeUndefined(); + expect(result.data).toEqual([]); + }); +}); From 2e947c731b536817c589803e44148a925ff781ab Mon Sep 17 00:00:00 2001 From: Maaz Ghani Date: Tue, 26 May 2026 10:54:53 -0700 Subject: [PATCH 2/4] input sanitization for agentNamespace() Signed-off-by: Maaz Ghani --- ui/src/app/agents/new/page.tsx | 2 +- ui/src/lib/__tests__/CreateAgentPage.namespace.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/app/agents/new/page.tsx b/ui/src/app/agents/new/page.tsx index b63516904f..0d3f161ee6 100644 --- a/ui/src/app/agents/new/page.tsx +++ b/ui/src/app/agents/new/page.tsx @@ -62,7 +62,7 @@ const DEFAULT_SYSTEM_PROMPT = `You're a helpful agent, made by the kagent team. function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageContentProps) { const router = useRouter(); const { models, loading, error, createNewAgent, updateAgent, getAgent, validateAgentData } = useAgents(); - const initialNamespace = !isEditMode && agentNamespace ? agentNamespace : "default"; + const initialNamespace = !isEditMode && agentNamespace?.trim() ? agentNamespace.trim() : "default"; type SelectedModelType = ModelConfig; diff --git a/ui/src/lib/__tests__/CreateAgentPage.namespace.test.tsx b/ui/src/lib/__tests__/CreateAgentPage.namespace.test.tsx index e82afdff3a..52200217f3 100644 --- a/ui/src/lib/__tests__/CreateAgentPage.namespace.test.tsx +++ b/ui/src/lib/__tests__/CreateAgentPage.namespace.test.tsx @@ -67,7 +67,7 @@ describe("new agent namespace query", () => { }); it("initializes the editable namespace from ?namespace= in create mode", async () => { - mockUseSearchParams.mockReturnValue(new URLSearchParams("namespace=kagent")); + mockUseSearchParams.mockReturnValue(new URLSearchParams("namespace=%20kagent%20")); render(); From 3a91c75c245367812c8ce51cd5fe78f0e019a4f5 Mon Sep 17 00:00:00 2001 From: Maaz Ghani Date: Tue, 26 May 2026 11:11:17 -0700 Subject: [PATCH 3/4] agent delete refresh behavior cleanup with tests Signed-off-by: Maaz Ghani --- ui/src/components/DeleteAgentButton.tsx | 5 +- .../__tests__/DeleteAgentButton.test.tsx | 71 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 ui/src/components/__tests__/DeleteAgentButton.test.tsx diff --git a/ui/src/components/DeleteAgentButton.tsx b/ui/src/components/DeleteAgentButton.tsx index 540e002d03..479894c9d1 100644 --- a/ui/src/components/DeleteAgentButton.tsx +++ b/ui/src/components/DeleteAgentButton.tsx @@ -32,7 +32,10 @@ export function DeleteButton({ agentName, namespace, disabled = false, onDeleted try { setIsDeleting(true); - await deleteAgent(agentName, namespace); + const result = await deleteAgent(agentName, namespace); + if (result.error) { + throw new Error(result.error); + } await onDeleted?.(); } catch (error) { diff --git a/ui/src/components/__tests__/DeleteAgentButton.test.tsx b/ui/src/components/__tests__/DeleteAgentButton.test.tsx new file mode 100644 index 0000000000..d49b2d77e8 --- /dev/null +++ b/ui/src/components/__tests__/DeleteAgentButton.test.tsx @@ -0,0 +1,71 @@ +/** + * @jest-environment jsdom + */ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { deleteAgent } from "@/app/actions/agents"; +import { DeleteButton } from "@/components/DeleteAgentButton"; + +jest.mock("@/app/actions/agents", () => ({ + deleteAgent: jest.fn(), +})); + +const mockDeleteAgent = deleteAgent as jest.MockedFunction; + +describe("DeleteButton", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("invokes onDeleted after a successful delete", async () => { + const user = userEvent.setup(); + const onDeleted = jest.fn(); + mockDeleteAgent.mockResolvedValue({ message: "Successfully deleted agent" }); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => { + expect(mockDeleteAgent).toHaveBeenCalledWith("test-agent", "kagent"); + }); + await waitFor(() => expect(onDeleted).toHaveBeenCalledTimes(1)); + }); + + it("does not invoke onDeleted when deleteAgent returns an error response", async () => { + const user = userEvent.setup(); + const onDeleted = jest.fn(); + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + mockDeleteAgent.mockResolvedValue({ message: "boom", error: "boom" }); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => { + expect(mockDeleteAgent).toHaveBeenCalledWith("test-agent", "kagent"); + }); + await waitFor(() => expect(consoleError).toHaveBeenCalled()); + expect(onDeleted).not.toHaveBeenCalled(); + }); +}); From 3d4d210532d3675547224ea285ae1821b7b1818c Mon Sep 17 00:00:00 2001 From: Maaz Ghani Date: Tue, 26 May 2026 11:28:58 -0700 Subject: [PATCH 4/4] stop old agent fetches from overwriting newer namespace results Signed-off-by: Maaz Ghani --- ui/src/components/AgentList.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/ui/src/components/AgentList.tsx b/ui/src/components/AgentList.tsx index cb196fa787..98a891f4b5 100644 --- a/ui/src/components/AgentList.tsx +++ b/ui/src/components/AgentList.tsx @@ -1,5 +1,5 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { AgentGrid } from "@/components/AgentGrid"; import { AgentListView } from "@/components/AgentListView"; import { Plus, LayoutGrid, List } from "lucide-react"; @@ -35,20 +35,32 @@ export default function AgentList() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [view, setView] = useState("grid"); + const latestFetchRequestId = useRef(0); const fetchAgents = useCallback(async () => { + const requestId = latestFetchRequestId.current + 1; + latestFetchRequestId.current = requestId; + try { setLoading(true); const result = await getAgents(selectedNamespace ? { namespace: selectedNamespace } : {}); + if (requestId !== latestFetchRequestId.current) { + return; + } if (result.error) { throw new Error(result.error || "Failed to fetch agents"); } setAgents(result.data || []); setError(""); } catch (err) { + if (requestId !== latestFetchRequestId.current) { + return; + } setError(err instanceof Error ? err.message : "An unexpected error occurred"); } finally { - setLoading(false); + if (requestId === latestFetchRequestId.current) { + setLoading(false); + } } }, [selectedNamespace]);