-
Notifications
You must be signed in to change notification settings - Fork 51
fix(ui): load all agents so the picker and deep-links work past the first page #347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
vineetvora-scale
merged 4 commits into
main
from
vineetvora/agx1-361-fix-agentex-ui-agent-picker-pagination-only-first-50-agents
Jul 8, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
6c2f2f2
fix(ui): load all agents in picker via pagination + validate deep lin…
vineetvora-scale 8c6c9f5
fix(ui): make the agent picker scrollable when the list is long
vineetvora-scale 10b2803
fix(ui): address review — narrow by-name lookup to 404 and gate valid…
vineetvora-scale 433e6a2
Merge origin/main into vineetvora/agx1-361-fix-agentex-ui-agent-picke…
vineetvora-scale File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| 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'; | ||
|
|
||
| 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 ( | ||
| <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> | ||
| ); | ||
| }; | ||
| } | ||
|
|
||
| function clientWith(retrieveByName: ReturnType<typeof vi.fn>) { | ||
| 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) 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'), | ||
| { wrapper: createWrapper() } | ||
| ); | ||
|
|
||
| await waitFor(() => expect(result.current.isSuccess).toBe(true)); | ||
|
|
||
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { useQuery } from '@tanstack/react-query'; | ||
| import { APIError } from 'agentex'; | ||
|
|
||
| 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. 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) | ||
| * @returns UseQueryResult<Agent | null> - 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<Agent | null> => { | ||
| try { | ||
| return await agentexClient.agents.retrieveByName(agentName as string); | ||
| } 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, | ||
| refetchOnWindowFocus: false, | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
| AGENTS_PAGE_SIZE as PAGE_SIZE, | ||
| MAX_AGENT_PAGES, | ||
| useAgents, | ||
| } from './use-agents'; | ||
|
|
||
| import type AgentexSDK from 'agentex'; | ||
| import type { Agent } from 'agentex/resources'; | ||
|
|
||
| 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 ( | ||
| <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> | ||
| ); | ||
| }; | ||
| } | ||
|
|
||
| function clientWith(list: ReturnType<typeof vi.fn>) { | ||
| 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)); | ||
|
|
||
| expect(list).toHaveBeenCalledTimes(MAX_AGENT_PAGES); | ||
| expect(result.current.data).toHaveLength(PAGE_SIZE * MAX_AGENT_PAGES); | ||
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.