From 6c2f2f27909b0f86205aaa62c6ec582d2893856a Mon Sep 17 00:00:00 2001 From: Vineet Vora Date: Thu, 2 Jul 2026 14:59:05 -0400 Subject: [PATCH 1/3] fix(ui): load all agents in picker via pagination + validate deep links by name The agent picker fetched agents with an unpaginated list() call, so on accounts with more than one page of agents only the first page was shown. Those agents were invisible in the picker and, because the same list backed deep-link validation, deep-linking to one cleared the agent_name param and bounced back to the home grid. - useAgents now pages through the full list (limit 100, loop until a short page) with a safety bound + warning to avoid silent truncation. - Add useAgentByName to validate a deep-linked agent directly via retrieveByName, so a valid agent opens even if it's outside the loaded list. - agentex-ui-root only clears agent_name when it's present and invalid, and accepts an agent found either in the list or by name. - Add unit tests for the pagination loop and the by-name lookup. Co-Authored-By: Claude Opus 4.8 (1M context) --- agentex-ui/components/agentex-ui-root.tsx | 25 +++- agentex-ui/hooks/use-agent-by-name.test.tsx | 77 +++++++++++ agentex-ui/hooks/use-agent-by-name.ts | 39 ++++++ agentex-ui/hooks/use-agents.test.tsx | 146 ++++++++++++++++++++ agentex-ui/hooks/use-agents.ts | 48 ++++++- 5 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 agentex-ui/hooks/use-agent-by-name.test.tsx create mode 100644 agentex-ui/hooks/use-agent-by-name.ts create mode 100644 agentex-ui/hooks/use-agents.test.tsx diff --git a/agentex-ui/components/agentex-ui-root.tsx b/agentex-ui/components/agentex-ui-root.tsx index 3466f2de..6cd5ec01 100644 --- a/agentex-ui/components/agentex-ui-root.tsx +++ b/agentex-ui/components/agentex-ui-root.tsx @@ -8,6 +8,7 @@ import { PrimaryContent } from '@/components/primary-content/primary-content'; import { useAgentexClient } from '@/components/providers'; import { TaskSidebar } from '@/components/task-sidebar/task-sidebar'; import { TracesSidebar } from '@/components/traces-sidebar/traces-sidebar'; +import { useAgentByName } from '@/hooks/use-agent-by-name'; import { useAgents } from '@/hooks/use-agents'; import { useLocalStorageState } from '@/hooks/use-local-storage-state'; import { @@ -20,17 +21,33 @@ export function AgentexUIRoot() { const [isTracesSidebarOpen, setIsTracesSidebarOpen] = useState(false); const { agentexClient } = useAgentexClient(); const { data: agents = [], isLoading } = useAgents(agentexClient); + // Validate the deep-linked agent directly against the backend so a valid agent opens + // even if it sits outside the loaded list (e.g. on accounts with many agents). + const { data: agentByName, isLoading: isAgentByNameLoading } = useAgentByName( + agentexClient, + agentName + ); const [localAgentName, setLocalAgentName] = useLocalStorageState< string | undefined >('lastSelectedAgent', undefined); + // Gate on `isLoading` (not `isPending`): a disabled by-name query stays `pending` forever, + // but `isLoading` is `pending && fetching`, so it's false while disabled — letting the + // localStorage-restore branch run when no agent_name is present. Deps are intentionally + // narrowed to the load-settled flags + agentName so we re-validate on load completion and + // on agent_name changes, not on every `agents`/`agentByName` identity change. useEffect(() => { - if (isLoading) return; + if (isLoading || isAgentByNameLoading) return; - const selectedAgent = agents.find(agent => agent.name === agentName); + // Accept an agent found in the (paginated) list OR resolved directly by name, so a valid + // deep-linked agent opens even if it falls outside the loaded list. + const selectedAgent = + agents.find(agent => agent.name === agentName) ?? + agentByName ?? + undefined; const isAgentValid = selectedAgent && selectedAgent.status === 'Ready'; - if (!isAgentValid) { + if (agentName && !isAgentValid) { updateParams({ [SearchParamKey.AGENT_NAME]: null }); setLocalAgentName(undefined); } @@ -39,7 +56,7 @@ export function AgentexUIRoot() { updateParams({ [SearchParamKey.AGENT_NAME]: localAgentName }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoading]); + }, [isLoading, isAgentByNameLoading, agentName]); const handleSelectTask = useCallback( (taskId: string | null) => { diff --git a/agentex-ui/hooks/use-agent-by-name.test.tsx b/agentex-ui/hooks/use-agent-by-name.test.tsx new file mode 100644 index 00000000..3724e641 --- /dev/null +++ b/agentex-ui/hooks/use-agent-by-name.test.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useAgentByName } from './use-agent-by-name'; + +import type AgentexSDK from 'agentex'; +import type { Agent } from 'agentex/resources'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +function clientWith(retrieveByName: ReturnType) { + return { agents: { retrieveByName } } as unknown as AgentexSDK; +} + +describe('useAgentByName', () => { + it('does not fetch when no agent name is provided', () => { + const retrieveByName = vi.fn(); + + const { result } = renderHook( + () => useAgentByName(clientWith(retrieveByName), null), + { + wrapper: createWrapper(), + } + ); + + expect(retrieveByName).not.toHaveBeenCalled(); + expect(result.current.fetchStatus).toBe('idle'); + expect(result.current.data).toBeUndefined(); + }); + + it('returns the agent when the lookup succeeds', async () => { + const agent = { + id: 'a1', + name: 'interview-agent', + status: 'Ready', + } as Agent; + const retrieveByName = vi.fn().mockResolvedValueOnce(agent); + + const { result } = renderHook( + () => useAgentByName(clientWith(retrieveByName), 'interview-agent'), + { wrapper: createWrapper() } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(agent); + expect(retrieveByName).toHaveBeenCalledWith('interview-agent'); + }); + + it('resolves to null (not an error) when the lookup fails', async () => { + const retrieveByName = vi + .fn() + .mockRejectedValueOnce(new Error('404 not found')); + + const { result } = renderHook( + () => useAgentByName(clientWith(retrieveByName), 'missing-agent'), + { wrapper: createWrapper() } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toBeNull(); + expect(result.current.isError).toBe(false); + }); +}); diff --git a/agentex-ui/hooks/use-agent-by-name.ts b/agentex-ui/hooks/use-agent-by-name.ts new file mode 100644 index 00000000..a70189a6 --- /dev/null +++ b/agentex-ui/hooks/use-agent-by-name.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query'; + +import type AgentexSDK from 'agentex'; +import type { Agent } from 'agentex/resources'; + +export const agentByNameKeys = { + byName: (name: string) => ['agents', 'by-name', name] as const, +}; + +/** + * Fetches a single agent by its unique name. + * + * Used to validate a deep-linked `agent_name` directly against the backend so that opening + * an agent does not depend on the entire (paginated) agent list having been loaded first. + * The query is disabled when no name is provided, and a missing/unknown agent resolves to + * `null` rather than throwing so callers can treat "not found" as a normal outcome. + * + * @param agentexClient - AgentexSDK - The SDK client used to communicate with the Agentex API + * @param agentName - The agent name to look up; query is disabled when absent (null/undefined) + * @returns UseQueryResult - React Query result containing the agent, or null if not found + */ +export function useAgentByName( + agentexClient: AgentexSDK, + agentName: string | null | undefined +) { + return useQuery({ + queryKey: agentByNameKeys.byName(agentName ?? ''), + queryFn: async (): Promise => { + try { + return await agentexClient.agents.retrieveByName(agentName as string); + } catch { + // A 404 (or any lookup failure) means the name isn't a valid, reachable agent. + return null; + } + }, + enabled: !!agentName, + refetchOnWindowFocus: false, + }); +} diff --git a/agentex-ui/hooks/use-agents.test.tsx b/agentex-ui/hooks/use-agents.test.tsx new file mode 100644 index 00000000..3fb7a90f --- /dev/null +++ b/agentex-ui/hooks/use-agents.test.tsx @@ -0,0 +1,146 @@ +import type { ReactNode } from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useAgents } from './use-agents'; + +import type AgentexSDK from 'agentex'; +import type { Agent } from 'agentex/resources'; + +// Keep in sync with AGENTS_PAGE_SIZE in use-agents.ts — the page size the hook requests. +const PAGE_SIZE = 100; + +function makeAgents(count: number, prefix: string): Agent[] { + return Array.from( + { length: count }, + (_, i) => + ({ + id: `${prefix}-${i}`, + name: `${prefix}-${i}`, + status: 'Ready', + }) as Agent + ); +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +function clientWith(list: ReturnType) { + return { agents: { list } } as unknown as AgentexSDK; +} + +describe('useAgents', () => { + it('pages through every agent until a short page is returned', async () => { + const list = vi + .fn() + .mockResolvedValueOnce(makeAgents(PAGE_SIZE, 'p1')) // full page -> keep going + .mockResolvedValueOnce(makeAgents(24, 'p2')); // short page -> stop + + const { result } = renderHook(() => useAgents(clientWith(list)), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toHaveLength(PAGE_SIZE + 24); + expect(list).toHaveBeenCalledTimes(2); + expect(list).toHaveBeenNthCalledWith(1, { + limit: PAGE_SIZE, + page_number: 1, + }); + expect(list).toHaveBeenNthCalledWith(2, { + limit: PAGE_SIZE, + page_number: 2, + }); + }); + + it('makes a single request when the first page is not full', async () => { + const list = vi.fn().mockResolvedValueOnce(makeAgents(10, 'only')); + + const { result } = renderHook(() => useAgents(clientWith(list)), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toHaveLength(10); + expect(list).toHaveBeenCalledTimes(1); + }); + + it('returns an empty list without paging when there are no agents', async () => { + const list = vi.fn().mockResolvedValueOnce([]); + + const { result } = renderHook(() => useAgents(clientWith(list)), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual([]); + expect(list).toHaveBeenCalledTimes(1); + }); + + it('stops at the exact-multiple boundary without looping forever', async () => { + // Total is an exact multiple of the page size: a full page followed by an empty page. + const list = vi + .fn() + .mockResolvedValueOnce(makeAgents(PAGE_SIZE, 'p1')) + .mockResolvedValueOnce([]); + + const { result } = renderHook(() => useAgents(clientWith(list)), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toHaveLength(PAGE_SIZE); + expect(list).toHaveBeenCalledTimes(2); + }); + + it('stops at the safety bound and warns when every page stays full', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Always return a full page so the loop can only stop at MAX_AGENT_PAGES. + const list = vi.fn().mockResolvedValue(makeAgents(PAGE_SIZE, 'full')); + + const { result } = renderHook(() => useAgents(clientWith(list)), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // MAX_AGENT_PAGES is 100 in use-agents.ts. + expect(list).toHaveBeenCalledTimes(100); + expect(result.current.data).toHaveLength(PAGE_SIZE * 100); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('MAX_AGENT_PAGES') + ); + + warn.mockRestore(); + }); + + it('surfaces an error instead of a partial list when a page fails mid-loop', async () => { + const list = vi + .fn() + .mockResolvedValueOnce(makeAgents(PAGE_SIZE, 'p1')) + .mockRejectedValueOnce(new Error('boom')); + + const { result } = renderHook(() => useAgents(clientWith(list)), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.data).toBeUndefined(); + expect(list).toHaveBeenCalledTimes(2); + }); +}); diff --git a/agentex-ui/hooks/use-agents.ts b/agentex-ui/hooks/use-agents.ts index be5efde4..e8c8c727 100644 --- a/agentex-ui/hooks/use-agents.ts +++ b/agentex-ui/hooks/use-agents.ts @@ -7,11 +7,29 @@ export const agentsKeys = { all: ['agents'] as const, }; +/** + * Page size used when paging through the full agent list. + * + * The `/agents` endpoint is offset-paginated (1-indexed `page_number`) and defaults to + * 50 agents per page, so a single unpaginated call only ever returns the first page. We + * request a larger page and keep fetching until a page comes back short. + */ +const AGENTS_PAGE_SIZE = 100; + +/** + * Upper bound on the number of pages we fetch, as a safety valve against an unbounded + * loop if the backend ever stops short-paging. At {@link AGENTS_PAGE_SIZE} per page this + * covers far more agents than any real account. + */ +const MAX_AGENT_PAGES = 100; + /** * Fetches the complete list of agents available in the system. * - * This hook retrieves all agent definitions that can execute tasks. Refetch on window focus - * is disabled to prevent unnecessary API calls when switching browser tabs. + * The list endpoint is paginated, so this pages through every result — accumulating until + * a page returns fewer than {@link AGENTS_PAGE_SIZE} items — rather than returning only the + * default first page. Refetch on window focus is disabled to prevent unnecessary API calls + * when switching browser tabs. * * @param agentexClient - AgentexSDK - The SDK client used to communicate with the Agentex API * @returns UseQueryResult - React Query result containing the array of agent definitions @@ -20,7 +38,31 @@ export function useAgents(agentexClient: AgentexSDK) { return useQuery({ queryKey: agentsKeys.all, queryFn: async (): Promise => { - return agentexClient.agents.list(); + const allAgents: Agent[] = []; + + for (let pageNumber = 1; pageNumber <= MAX_AGENT_PAGES; pageNumber++) { + const page = await agentexClient.agents.list({ + limit: AGENTS_PAGE_SIZE, + page_number: pageNumber, + }); + + allAgents.push(...page); + + // A short page means we've reached the end of the list. + if (page.length < AGENTS_PAGE_SIZE) break; + + // Still a full page on the last allowed iteration: we've hit the safety bound + // with more agents likely remaining. Warn loudly rather than silently truncating + // (a silent truncation would reintroduce the "missing agents" bug this fix closes). + if (pageNumber === MAX_AGENT_PAGES) { + console.warn( + `useAgents: reached MAX_AGENT_PAGES (${MAX_AGENT_PAGES}) with a full final page; ` + + `the agent list may be truncated at ${allAgents.length} agents.` + ); + } + } + + return allAgents; }, refetchOnWindowFocus: false, }); From 8c6c9f5ff01611dd70c4c9f887439fd0cc02e049 Mon Sep 17 00:00:00 2001 From: Vineet Vora Date: Tue, 7 Jul 2026 11:17:35 -0400 Subject: [PATCH 2/3] fix(ui): make the agent picker scrollable when the list is long With the full agent list now loading, accounts with many agents produced a picker taller than the viewport. Because the home view is vertically centered with no overflow handling, the grid spilled off-screen top and bottom, hiding the title and the prompt input with no way to scroll. Cap the chip grid height and let it scroll internally so the title and input stay visible. Co-Authored-By: Claude Opus 4.8 (1M context) --- agentex-ui/components/agents-list/agents-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentex-ui/components/agents-list/agents-list.tsx b/agentex-ui/components/agents-list/agents-list.tsx index 49c450c4..1c6d3c6e 100644 --- a/agentex-ui/components/agents-list/agents-list.tsx +++ b/agentex-ui/components/agents-list/agents-list.tsx @@ -50,7 +50,7 @@ export function AgentsList({ agents, isLoading = false }: AgentsListProps) { return ( {displayedAgents.length > 0 ? ( From 10b28036ff957baf8edb25f61cf509c58b1a793c Mon Sep 17 00:00:00 2001 From: Vineet Vora Date: Tue, 7 Jul 2026 13:12:18 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(ui):=20address=20review=20=E2=80=94=20n?= =?UTF-8?q?arrow=20by-name=20lookup=20to=20404=20and=20gate=20validation?= =?UTF-8?q?=20on=20isFetching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useAgentByName resolves to null only on a 404 (APIError, status 404) and re-throws everything else, so a transient network/5xx/auth failure surfaces as a query error instead of masquerading as "not found". - agentex-ui-root: gate the deep-link validation on isFetching (not isLoading, which is false once a query has cached data) so it never validates against stale data mid-refetch; and only clear agent_name when the agent is genuinely invalid — skip the clear when the by-name lookup errored and the agent isn't in the loaded list (unknown != invalid). - Export AGENTS_PAGE_SIZE / MAX_AGENT_PAGES and import them in the test instead of duplicating the values. - Tests updated: 404 -> null, non-404 -> surfaced error. Co-Authored-By: Claude Opus 4.8 (1M context) --- agentex-ui/components/agentex-ui-root.tsx | 39 ++++++++++++--------- agentex-ui/hooks/use-agent-by-name.test.tsx | 32 ++++++++++++++--- agentex-ui/hooks/use-agent-by-name.ts | 19 +++++++--- agentex-ui/hooks/use-agents.test.tsx | 14 ++++---- agentex-ui/hooks/use-agents.ts | 4 +-- 5 files changed, 73 insertions(+), 35 deletions(-) diff --git a/agentex-ui/components/agentex-ui-root.tsx b/agentex-ui/components/agentex-ui-root.tsx index 6cd5ec01..e027b974 100644 --- a/agentex-ui/components/agentex-ui-root.tsx +++ b/agentex-ui/components/agentex-ui-root.tsx @@ -20,34 +20,39 @@ export function AgentexUIRoot() { const { agentName, taskID, updateParams } = useSafeSearchParams(); const [isTracesSidebarOpen, setIsTracesSidebarOpen] = useState(false); const { agentexClient } = useAgentexClient(); - const { data: agents = [], isLoading } = useAgents(agentexClient); + const { data: agents = [], isFetching: isAgentsFetching } = + useAgents(agentexClient); // Validate the deep-linked agent directly against the backend so a valid agent opens // even if it sits outside the loaded list (e.g. on accounts with many agents). - const { data: agentByName, isLoading: isAgentByNameLoading } = useAgentByName( - agentexClient, - agentName - ); + const { + data: agentByName, + isFetching: isAgentByNameFetching, + isError: isAgentByNameError, + } = useAgentByName(agentexClient, agentName); const [localAgentName, setLocalAgentName] = useLocalStorageState< string | undefined >('lastSelectedAgent', undefined); - // Gate on `isLoading` (not `isPending`): a disabled by-name query stays `pending` forever, - // but `isLoading` is `pending && fetching`, so it's false while disabled — letting the - // localStorage-restore branch run when no agent_name is present. Deps are intentionally - // narrowed to the load-settled flags + agentName so we re-validate on load completion and - // on agent_name changes, not on every `agents`/`agentByName` identity change. + // Wait until neither query is fetching before validating. We gate on `isFetching` (not + // `isLoading`, which is false once a query has cached data) so we never validate against + // stale data mid-refetch. A disabled by-name query reports `isFetching: false`, so it + // doesn't block the localStorage-restore path when there's no agent_name. Deps are + // intentionally narrowed so we re-validate on fetch settle / agent_name change. useEffect(() => { - if (isLoading || isAgentByNameLoading) return; + if (isAgentsFetching || isAgentByNameFetching) return; // Accept an agent found in the (paginated) list OR resolved directly by name, so a valid // deep-linked agent opens even if it falls outside the loaded list. - const selectedAgent = - agents.find(agent => agent.name === agentName) ?? - agentByName ?? - undefined; + const agentInList = agents.find(agent => agent.name === agentName); + const selectedAgent = agentInList ?? agentByName ?? undefined; const isAgentValid = selectedAgent && selectedAgent.status === 'Ready'; - if (agentName && !isAgentValid) { + // If the agent isn't in the loaded list and the by-name lookup errored (a transient + // failure, not a 404), we can't tell whether it's valid — leave the URL alone rather + // than bouncing a possibly-valid deep link to the home grid. + const couldNotDetermine = !agentInList && isAgentByNameError; + + if (agentName && !isAgentValid && !couldNotDetermine) { updateParams({ [SearchParamKey.AGENT_NAME]: null }); setLocalAgentName(undefined); } @@ -56,7 +61,7 @@ export function AgentexUIRoot() { updateParams({ [SearchParamKey.AGENT_NAME]: localAgentName }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoading, isAgentByNameLoading, agentName]); + }, [isAgentsFetching, isAgentByNameFetching, isAgentByNameError, agentName]); const handleSelectTask = useCallback( (taskId: string | null) => { diff --git a/agentex-ui/hooks/use-agent-by-name.test.tsx b/agentex-ui/hooks/use-agent-by-name.test.tsx index 3724e641..f0da260d 100644 --- a/agentex-ui/hooks/use-agent-by-name.test.tsx +++ b/agentex-ui/hooks/use-agent-by-name.test.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; +import { APIError, NotFoundError } from 'agentex'; import { describe, expect, it, vi } from 'vitest'; import { useAgentByName } from './use-agent-by-name'; @@ -59,10 +60,14 @@ describe('useAgentByName', () => { expect(retrieveByName).toHaveBeenCalledWith('interview-agent'); }); - it('resolves to null (not an error) when the lookup fails', async () => { - const retrieveByName = vi - .fn() - .mockRejectedValueOnce(new Error('404 not found')); + it('resolves to null (not an error) on a 404', async () => { + const notFound = new NotFoundError( + 404, + undefined, + 'Not found', + new Headers() + ); + const retrieveByName = vi.fn().mockRejectedValueOnce(notFound); const { result } = renderHook( () => useAgentByName(clientWith(retrieveByName), 'missing-agent'), @@ -74,4 +79,23 @@ describe('useAgentByName', () => { expect(result.current.data).toBeNull(); expect(result.current.isError).toBe(false); }); + + it('surfaces a non-404 error instead of masquerading as not-found', async () => { + const serverError = new APIError( + 500, + undefined, + 'Server error', + new Headers() + ); + const retrieveByName = vi.fn().mockRejectedValueOnce(serverError); + + const { result } = renderHook( + () => useAgentByName(clientWith(retrieveByName), 'some-agent'), + { wrapper: createWrapper() } + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.data).toBeUndefined(); + }); }); diff --git a/agentex-ui/hooks/use-agent-by-name.ts b/agentex-ui/hooks/use-agent-by-name.ts index a70189a6..46225972 100644 --- a/agentex-ui/hooks/use-agent-by-name.ts +++ b/agentex-ui/hooks/use-agent-by-name.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { APIError } from 'agentex'; import type AgentexSDK from 'agentex'; import type { Agent } from 'agentex/resources'; @@ -12,8 +13,10 @@ export const agentByNameKeys = { * * Used to validate a deep-linked `agent_name` directly against the backend so that opening * an agent does not depend on the entire (paginated) agent list having been loaded first. - * The query is disabled when no name is provided, and a missing/unknown agent resolves to - * `null` rather than throwing so callers can treat "not found" as a normal outcome. + * The query is disabled when no name is provided. A 404 resolves to `null` ("no such agent" + * — a normal outcome), while any other failure (network / 5xx / auth) is re-thrown so React + * Query surfaces it as an error rather than masquerading as "not found" (which could + * otherwise clear a valid deep link). * * @param agentexClient - AgentexSDK - The SDK client used to communicate with the Agentex API * @param agentName - The agent name to look up; query is disabled when absent (null/undefined) @@ -28,9 +31,15 @@ export function useAgentByName( queryFn: async (): Promise => { try { return await agentexClient.agents.retrieveByName(agentName as string); - } catch { - // A 404 (or any lookup failure) means the name isn't a valid, reachable agent. - return null; + } catch (error) { + // A 404 means the name isn't a real, reachable agent — a normal "not found" + // outcome, so resolve to null. Re-throw anything else (network / 5xx / auth) so a + // transient failure surfaces as a query error instead of masquerading as + // "not found", which could otherwise clear a valid deep link. + if (error instanceof APIError && error.status === 404) { + return null; + } + throw error; } }, enabled: !!agentName, diff --git a/agentex-ui/hooks/use-agents.test.tsx b/agentex-ui/hooks/use-agents.test.tsx index 3fb7a90f..f902212f 100644 --- a/agentex-ui/hooks/use-agents.test.tsx +++ b/agentex-ui/hooks/use-agents.test.tsx @@ -4,14 +4,15 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { useAgents } from './use-agents'; +import { + AGENTS_PAGE_SIZE as PAGE_SIZE, + MAX_AGENT_PAGES, + useAgents, +} from './use-agents'; import type AgentexSDK from 'agentex'; import type { Agent } from 'agentex/resources'; -// Keep in sync with AGENTS_PAGE_SIZE in use-agents.ts — the page size the hook requests. -const PAGE_SIZE = 100; - function makeAgents(count: number, prefix: string): Agent[] { return Array.from( { length: count }, @@ -118,9 +119,8 @@ describe('useAgents', () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); - // MAX_AGENT_PAGES is 100 in use-agents.ts. - expect(list).toHaveBeenCalledTimes(100); - expect(result.current.data).toHaveLength(PAGE_SIZE * 100); + expect(list).toHaveBeenCalledTimes(MAX_AGENT_PAGES); + expect(result.current.data).toHaveLength(PAGE_SIZE * MAX_AGENT_PAGES); expect(warn).toHaveBeenCalledWith( expect.stringContaining('MAX_AGENT_PAGES') ); diff --git a/agentex-ui/hooks/use-agents.ts b/agentex-ui/hooks/use-agents.ts index e8c8c727..05a19bb1 100644 --- a/agentex-ui/hooks/use-agents.ts +++ b/agentex-ui/hooks/use-agents.ts @@ -14,14 +14,14 @@ export const agentsKeys = { * 50 agents per page, so a single unpaginated call only ever returns the first page. We * request a larger page and keep fetching until a page comes back short. */ -const AGENTS_PAGE_SIZE = 100; +export const AGENTS_PAGE_SIZE = 100; /** * Upper bound on the number of pages we fetch, as a safety valve against an unbounded * loop if the backend ever stops short-paging. At {@link AGENTS_PAGE_SIZE} per page this * covers far more agents than any real account. */ -const MAX_AGENT_PAGES = 100; +export const MAX_AGENT_PAGES = 100; /** * Fetches the complete list of agents available in the system.