diff --git a/web/src/state/useAgentDefinitions.ts b/web/src/state/useAgentDefinitions.ts new file mode 100644 index 0000000..a8eea29 --- /dev/null +++ b/web/src/state/useAgentDefinitions.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { apiFetch } from '@/api/client'; +import type { AgentDefinition } from '@/api/types'; + +export interface AgentDefinitions { + list: AgentDefinition[]; + byName: Record; +} + +export function useAgentDefinitions() { + return useQuery({ + queryKey: ['agent-definitions'], + queryFn: async () => { + const list = await apiFetch('/agents'); + const byName: Record = {}; + for (const a of list) { + byName[a.name] = a; + } + return { list, byName }; + }, + staleTime: Infinity, + gcTime: Infinity, + }); +} diff --git a/web/src/state/useApprovalsQueue.ts b/web/src/state/useApprovalsQueue.ts new file mode 100644 index 0000000..789ed21 --- /dev/null +++ b/web/src/state/useApprovalsQueue.ts @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; +import { useSessionList, type SessionSummary } from './useSessionList'; +import type { ApiClientError } from '@/api/client'; + +export interface UseApprovalsQueueResult { + queue: SessionSummary[]; + count: number; + isLoading: boolean; + error: ApiClientError | null; +} + +export function useApprovalsQueue(): UseApprovalsQueueResult { + const { sessions, isLoading, error } = useSessionList(); + const queue = useMemo( + () => + sessions + .filter((s) => s.status === 'awaiting_input') + .sort((a, b) => a.updated_at.localeCompare(b.updated_at)), + [sessions], + ); + return { queue, count: queue.length, isLoading, error }; +} diff --git a/web/tests/unit/useAgentDefinitions.test.tsx b/web/tests/unit/useAgentDefinitions.test.tsx new file mode 100644 index 0000000..3e97279 --- /dev/null +++ b/web/tests/unit/useAgentDefinitions.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useAgentDefinitions } from '@/state/useAgentDefinitions'; + +const agents = [ + { name: 'intake', kind: 'responsive', model: 'gpt', tools: [], routes: {}, system_prompt_excerpt: '...' }, + { name: 'triage', kind: 'gated', model: 'claude', tools: ['obs:get_logs'], routes: {}, system_prompt_excerpt: '...' }, +]; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return {children}; +} + +describe('useAgentDefinitions', () => { + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(agents), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }); + + it('fetches /api/v1/agents and exposes the list', async () => { + const { result } = renderHook(() => useAgentDefinitions(), { wrapper }); + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data?.list).toHaveLength(2); + expect(result.current.data?.list[0]?.name).toBe('intake'); + }); + + it('exposes a byName map keyed by agent name', async () => { + const { result } = renderHook(() => useAgentDefinitions(), { wrapper }); + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data?.byName.triage?.kind).toBe('gated'); + expect(result.current.data?.byName.intake?.tools).toEqual([]); + }); + + it('hits /api/v1/agents', async () => { + renderHook(() => useAgentDefinitions(), { wrapper }); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + expect(global.fetch).toHaveBeenCalledWith( + '/api/v1/agents', + expect.objectContaining({ method: 'GET' }), + ); + }); +}); diff --git a/web/tests/unit/useApprovalsQueue.test.ts b/web/tests/unit/useApprovalsQueue.test.ts new file mode 100644 index 0000000..b232165 --- /dev/null +++ b/web/tests/unit/useApprovalsQueue.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useApprovalsQueue } from '@/state/useApprovalsQueue'; +import { MockEventSource } from '../_helpers/MockEventSource'; + +const sessions = [ + { id: 'SES-1', status: 'in_progress', label: 'foo', created_at: 't0', updated_at: 't0' }, + { id: 'SES-2', status: 'awaiting_input', label: 'bar', created_at: 't0', updated_at: 't2' }, + { id: 'SES-3', status: 'awaiting_input', label: 'baz', created_at: 't0', updated_at: 't1' }, + { id: 'SES-4', status: 'resolved', label: 'qux', created_at: 't0', updated_at: 't0' }, +]; + +describe('useApprovalsQueue', () => { + beforeEach(() => { + MockEventSource.reset(); + // @ts-expect-error global override + global.EventSource = MockEventSource; + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(sessions), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }); + + it('returns only sessions with status="awaiting_input"', async () => { + const { result } = renderHook(() => useApprovalsQueue()); + await waitFor(() => expect(result.current.queue.length).toBeGreaterThan(0)); + expect(result.current.queue.every((s) => s.status === 'awaiting_input')).toBe(true); + expect(result.current.queue).toHaveLength(2); + }); + + it('sorts oldest-waiting-first by updated_at ascending', async () => { + const { result } = renderHook(() => useApprovalsQueue()); + await waitFor(() => expect(result.current.queue).toHaveLength(2)); + expect(result.current.queue[0]?.id).toBe('SES-3'); // updated_at = t1 + expect(result.current.queue[1]?.id).toBe('SES-2'); // updated_at = t2 + }); + + it('exposes total count via result.count', async () => { + const { result } = renderHook(() => useApprovalsQueue()); + await waitFor(() => expect(result.current.count).toBe(2)); + }); +});