diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..c5cce43 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,44 @@ +const API_PREFIX = '/api/v1'; + +export class ApiClientError extends Error { + status: number; + code: string; + details: Record; + constructor(status: number, code: string, message: string, details: Record) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } +} + +interface FetchOptions extends RequestInit { + json?: unknown; +} + +export async function apiFetch( + path: string, + options: FetchOptions = {}, +): Promise { + const url = path.startsWith('http') ? path : `${API_PREFIX}${path}`; + const headers = new Headers(options.headers); + const token = localStorage.getItem('asr.token'); + if (token) headers.set('Authorization', `Bearer ${token}`); + if (options.json !== undefined) { + headers.set('Content-Type', 'application/json'); + options.body = JSON.stringify(options.json); + } + const res = await fetch(url, { ...options, headers, method: options.method ?? 'GET' }); + if (!res.ok) { + let body: { error?: { code?: string; message?: string; details?: Record } } = {}; + try { body = await res.clone().json(); } catch { /* not JSON */ } + throw new ApiClientError( + res.status, + body.error?.code ?? 'unknown', + body.error?.message ?? res.statusText, + body.error?.details ?? {}, + ); + } + if (res.status === 204) return undefined as T; + return await res.json() as T; +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..100825e --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,90 @@ +// web/src/api/types.ts +// Hand-authored to match /api/v1/* schemas. v2.1 should generate from OpenAPI. + +export type SessionId = string; // SES-YYYYMMDD-NNN + +export interface Session { + id: SessionId; + status: 'in_progress' | 'awaiting_input' | 'matched' | 'resolved' | 'escalated' | 'stopped' | 'error' | 'new'; + created_at: string; // ISO UTC + updated_at: string; + deleted_at: string | null; + agents_run: AgentRun[]; + tool_calls: ToolCall[]; + findings: Record; + pending_intervention: Record | null; + user_inputs: string[]; + parent_session_id: SessionId | null; + dedup_rationale: string | null; + extra_fields: Record; + version: number; +} + +export interface AgentRun { + agent: string; + started_at: string; + ended_at: string; + summary: string; + token_usage?: { input_tokens: number; output_tokens: number; total_tokens: number }; + confidence: number | null; + confidence_rationale: string | null; + signal: string | null; +} + +export interface ToolCall { + agent: string; + tool: string; + args: Record; + result: unknown; + ts: string; + risk: 'low' | 'medium' | 'high' | null; + status: 'executed' | 'executed_with_notify' | 'pending_approval' | 'approved' | 'rejected' | 'timeout' | 'auto_rejected'; + approver: string | null; + approved_at: string | null; + approval_rationale: string | null; +} + +export interface AgentDefinition { + name: string; + kind: string; // 'responsive' | 'gated' | etc. + model: string; + tools: string[]; + routes: Record; + system_prompt_excerpt: string; +} + +export interface SessionEvent { + seq: number; + kind: string; + payload: Record; + ts: string; + session_id?: string; // present on /sessions/recent/events stream +} + +export interface SessionFullBundle { + session: Session; + agents_run: AgentRun[]; + tool_calls: ToolCall[]; + events: SessionEvent[]; + agent_definitions: Record; + vm_seq: number; +} + +export interface UiHints { + brand_name: string; + brand_logo_url: string | null; + approval_rationale_templates: string[]; + hitl_question_templates: Record; + environments: string[]; +} + +export interface AppView { + id: string; + title: string; + applies_to: string; // 'always' | 'agent:NAME' | 'tool:NAME' + url: string; +} + +export interface ApiError { + error: { code: string; message: string; details: Record }; +} diff --git a/web/tests/unit/client.test.ts b/web/tests/unit/client.test.ts new file mode 100644 index 0000000..f9ed4ef --- /dev/null +++ b/web/tests/unit/client.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { apiFetch, ApiClientError } from '@/api/client'; + +describe('apiFetch', () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('prepends /api/v1 to relative paths', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), + ); + global.fetch = fetchMock; + + await apiFetch('/sessions'); + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/sessions', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('attaches Authorization header when token in localStorage', async () => { + localStorage.setItem('asr.token', 'tk-abc'); + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + global.fetch = fetchMock; + + await apiFetch('/sessions'); + const init = fetchMock.mock.calls[0]![1] as RequestInit; + const headers = new Headers(init.headers); + expect(headers.get('Authorization')).toBe('Bearer tk-abc'); + }); + + it('throws ApiClientError on 4xx with structured error body', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ error: { code: 'not_found', message: 'session not found', details: {} } }), + { status: 404, headers: { 'content-type': 'application/json' } }, + ), + ); + + await expect(apiFetch('/sessions/SES-x')).rejects.toThrow(ApiClientError); + await expect(apiFetch('/sessions/SES-x')).rejects.toMatchObject({ + status: 404, + code: 'not_found', + }); + }); +});