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
44 changes: 44 additions & 0 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const API_PREFIX = '/api/v1';

export class ApiClientError extends Error {
status: number;
code: string;
details: Record<string, unknown>;
constructor(status: number, code: string, message: string, details: Record<string, unknown>) {
super(message);
this.status = status;
this.code = code;
this.details = details;
}
}

interface FetchOptions extends RequestInit {
json?: unknown;
}

export async function apiFetch<T = unknown>(
path: string,
options: FetchOptions = {},
): Promise<T> {
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<string, unknown> } } = {};
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;
}
90 changes: 90 additions & 0 deletions web/src/api/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
pending_intervention: Record<string, unknown> | null;
user_inputs: string[];
parent_session_id: SessionId | null;
dedup_rationale: string | null;
extra_fields: Record<string, unknown>;
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<string, unknown>;
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<string, string>;
system_prompt_excerpt: string;
}

export interface SessionEvent {
seq: number;
kind: string;
payload: Record<string, unknown>;
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<string, AgentDefinition>;
vm_seq: number;
}

export interface UiHints {
brand_name: string;
brand_logo_url: string | null;
approval_rationale_templates: string[];
hitl_question_templates: Record<string, string>;
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<string, unknown> };
}
48 changes: 48 additions & 0 deletions web/tests/unit/client.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
Loading