Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions agentex-ui/components/agentex-ui-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -19,18 +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,
isFetching: isAgentByNameFetching,
isError: isAgentByNameError,
} = useAgentByName(agentexClient, agentName);
const [localAgentName, setLocalAgentName] = useLocalStorageState<
string | undefined
>('lastSelectedAgent', undefined);

// 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) return;
if (isAgentsFetching || isAgentByNameFetching) 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 agentInList = agents.find(agent => agent.name === agentName);
const selectedAgent = agentInList ?? agentByName ?? undefined;
const isAgentValid = selectedAgent && selectedAgent.status === 'Ready';

if (!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);
}
Expand All @@ -39,7 +61,7 @@ export function AgentexUIRoot() {
updateParams({ [SearchParamKey.AGENT_NAME]: localAgentName });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading]);
}, [isAgentsFetching, isAgentByNameFetching, isAgentByNameError, agentName]);

const handleSelectTask = useCallback(
(taskId: string | null) => {
Expand Down
2 changes: 1 addition & 1 deletion agentex-ui/components/agents-list/agents-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function AgentsList({ agents, isLoading = false }: AgentsListProps) {
return (
<TooltipProvider delayDuration={200}>
<motion.div
className="mb-2 flex max-w-4xl flex-wrap items-center justify-center gap-2"
className="mb-2 flex max-h-[60vh] max-w-4xl flex-wrap items-center justify-center gap-2 overflow-y-auto"
layout={hasMounted.current}
>
{displayedAgents.length > 0 ? (
Expand Down
101 changes: 101 additions & 0 deletions agentex-ui/hooks/use-agent-by-name.test.tsx
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();
});
});
48 changes: 48 additions & 0 deletions agentex-ui/hooks/use-agent-by-name.ts
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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
},
enabled: !!agentName,
refetchOnWindowFocus: false,
});
}
146 changes: 146 additions & 0 deletions agentex-ui/hooks/use-agents.test.tsx
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);
});
});
Loading
Loading