diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 0f46823b..7b48176e 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -1,3 +1,42 @@ +import type { + HarnessAuthState, + HarnessModelAvailability, + HarnessSelection, +} from '@mosaicstack/types'; + +// The exact harness/provider/model tuple and its closed enum companions are the +// shared domain types — re-exported here so web consumers (and the runtime +// guards) import one shape, never a divergent local redefinition. +export type { HarnessSelection, HarnessAuthState, HarnessModelAvailability }; + +/** Harness summary row from `GET /api/harnesses` (the `HarnessSummaryDto`). The + * harness id is kept distinct from any provider id — they are never merged. */ +export interface HarnessSummary { + id: string; + displayName: string; + capabilities: string[]; +} + +/** One selectable model in a harness catalog. Extends the `{harnessId, + * providerId, modelId}` tuple with the display/availability metadata the UI + * needs; `inputTypes` is kept as a plain `string[]` on the client boundary + * because it arrives from untrusted JSON and is only ever displayed. */ +export interface HarnessCatalogEntry extends HarnessSelection { + displayName: string; + reasoningCapability: boolean; + inputTypes: string[]; + authState: HarnessAuthState; + availability: HarnessModelAvailability; +} + +/** Harness-scoped catalog from `GET /api/harnesses/:harnessId/catalog`. */ +export interface HarnessCatalog { + harnessId: string; + version: string; + fingerprint: string; + models: HarnessCatalogEntry[]; +} + /** Conversation returned by the gateway API. */ export interface Conversation { id: string; diff --git a/apps/web/src/spa/chat/chat-api.spec.ts b/apps/web/src/spa/chat/chat-api.spec.ts new file mode 100644 index 00000000..3d02c01a --- /dev/null +++ b/apps/web/src/spa/chat/chat-api.spec.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + fetchCatalog, + fetchHarnesses, + fetchPersistedSelection, + persistSelection, +} from './chat-api'; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function stubFetch(): ReturnType { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +/** Every URL the client actually requested, across all calls. */ +function requestedUrls(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls.map((call) => String(call[0])); +} + +describe('chat-api', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fetchHarnesses GETs /api/harnesses and returns typed summaries (harness id separate from provider)', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValue( + json([ + { id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] }, + { id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] }, + ]), + ); + + const harnesses = await fetchHarnesses(); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses'); + expect(harnesses).toEqual([ + { id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] }, + { id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] }, + ]); + }); + + it('fetchCatalog GETs the harness-scoped catalog and returns only its model entries', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValue( + json({ + harnessId: 'pi', + version: '2026-08-11', + fingerprint: 'abc123', + models: [ + { + harnessId: 'pi', + providerId: 'openai', + modelId: 'gpt-5', + displayName: 'GPT-5', + reasoningCapability: true, + inputTypes: ['text'], + authState: 'ready', + availability: 'available', + }, + ], + }), + ); + + const result = await fetchCatalog('pi'); + + expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses/pi/catalog'); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok catalog'); + expect(result.catalog.harnessId).toBe('pi'); + expect(result.catalog.models).toHaveLength(1); + expect(result.catalog.models[0]).toMatchObject({ + harnessId: 'pi', + providerId: 'openai', + modelId: 'gpt-5', + availability: 'available', + }); + }); + + it('normalizes a catalog 404 into a typed catalog_unavailable result without surfacing the raw body', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValue( + json( + { + code: 'adapter_unavailable', + message: 'raw gateway detail that must not leak verbatim', + harnessId: 'attacker-echo', + extra: { hostile: 'blob' }, + }, + 404, + ), + ); + + const result = await fetchCatalog('ghost'); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected unavailable result'); + expect(result.code).toBe('catalog_unavailable'); + // harnessId comes from the request, never the (untrusted) response body. + expect(result.harnessId).toBe('ghost'); + expect(typeof result.message).toBe('string'); + // The raw response body is never rendered/returned verbatim. + expect(JSON.stringify(result)).not.toContain('hostile'); + expect(JSON.stringify(result)).not.toContain('attacker-echo'); + }); + + it('fetchPersistedSelection returns the stored tuple, or null when unset', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce( + json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }), + ); + await expect(fetchPersistedSelection()).resolves.toEqual({ + harnessId: 'pi', + providerId: 'openai', + modelId: 'gpt-5', + }); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/chat/preferences/selection'); + + fetchMock.mockResolvedValueOnce(json({ selection: null })); + await expect(fetchPersistedSelection()).resolves.toBeNull(); + }); + + it('persistSelection PUTs the structured tuple (not free text) and returns the confirmed selection', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValue( + json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }), + ); + + const result = await persistSelection({ + harnessId: 'pi', + providerId: 'openai', + modelId: 'gpt-5', + }); + + expect(result.ok).toBe(true); + const call = fetchMock.mock.calls[0]; + expect(String(call?.[0])).toBe('/api/chat/preferences/selection'); + const init = call?.[1] as RequestInit; + expect(String(init.method).toUpperCase()).toBe('PUT'); + // The body is exactly the structured tuple — harness/provider/model kept distinct. + expect(JSON.parse(String(init.body))).toEqual({ + harnessId: 'pi', + providerId: 'openai', + modelId: 'gpt-5', + }); + }); + + it('normalizes a selection 422 into a typed error preserving the requested tuple exactly', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValue( + json( + { + code: 'model_unavailable', + message: 'raw detail that must not leak', + selection: { harnessId: 'x', providerId: 'y', modelId: 'z' }, + }, + 422, + ), + ); + + const requested = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' }; + const result = await persistSelection(requested); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected failed persist'); + expect(['selection_invalid', 'model_unavailable']).toContain(result.code); + // The requested tuple is preserved unchanged — not replaced by the body's echo. + expect(result.requested).toEqual(requested); + expect(JSON.stringify(result)).not.toContain('raw detail'); + }); + + it('never requests any /api/providers* endpoint', async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValue(json([])); + await fetchHarnesses(); + fetchMock.mockResolvedValue( + json({ harnessId: 'pi', version: '1', fingerprint: 'f', models: [] }), + ); + await fetchCatalog('pi'); + fetchMock.mockResolvedValue(json({ selection: null })); + await fetchPersistedSelection(); + + for (const url of requestedUrls(fetchMock)) { + expect(url).not.toContain('/api/providers'); + } + }); +}); diff --git a/apps/web/src/spa/chat/chat-api.ts b/apps/web/src/spa/chat/chat-api.ts new file mode 100644 index 00000000..ba8e20fd --- /dev/null +++ b/apps/web/src/spa/chat/chat-api.ts @@ -0,0 +1,132 @@ +/** + * Typed fetch wrappers for the Task-3 harness HTTP contract the chat selection + * UI depends on. Every response body is untrusted and is normalized through the + * runtime guards before it reaches state — a 404 (catalog) and a 422 (selection) + * are mapped to typed, body-free error results so a raw gateway body is never + * rendered, and the caller's requested tuple is preserved verbatim on failure. + * + * This module talks ONLY to the harness/chat-preferences endpoints. It never + * calls `/api/providers*` — provider identity lives inside the harness catalog. + */ +import { asHarnessCatalog, asHarnessSelection, asHarnessSummaries } from './runtime-guards'; +import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types'; + +/** A catalog fetch either yields the typed catalog or a typed unavailability — + * never a thrown raw body. */ +export type CatalogResult = + | { ok: true; catalog: HarnessCatalog } + | { ok: false; code: 'catalog_unavailable'; harnessId: string; message: string }; + +export type SelectionErrorCode = 'selection_invalid' | 'model_unavailable'; + +/** A persist either confirms the stored tuple or reports a typed domain failure + * that echoes back the exact tuple the caller requested. */ +export type SelectionPersistResult = + | { ok: true; selection: HarnessSelection } + | { ok: false; code: SelectionErrorCode; message: string; requested: HarnessSelection }; + +/** A safe, generic message for an unavailable catalog — the raw 404 body is + * never surfaced. */ +const CATALOG_UNAVAILABLE_MESSAGE = 'This harness catalog is currently unavailable.'; + +/** A safe, generic message for a rejected selection. The untrusted 422 body's + * own `message` is deliberately NEVER surfaced — only this fixed copy — so a + * raw gateway detail can never leak into the UI. Only the closed `code` enum is + * read from the body. */ +const SELECTION_REJECTED_MESSAGE = 'This selection was rejected.'; + +async function readJson(response: Response): Promise { + return response.json().catch(() => null); +} + +function safeSelectionCode(body: unknown): SelectionErrorCode { + if (typeof body === 'object' && body !== null && 'code' in body) { + const code = (body as { code: unknown }).code; + if (code === 'selection_invalid' || code === 'model_unavailable') return code; + } + // Default to the more conservative "invalid" classification for anything + // unrecognized rather than guessing "model_unavailable". + return 'selection_invalid'; +} + +/** `GET /api/harnesses` → the list of harness summaries. A non-OK response + * normalizes to an empty list (the UI then has no harness to select). */ +export async function fetchHarnesses(): Promise { + const response = await fetch('/api/harnesses', { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) return []; + return asHarnessSummaries(await readJson(response)); +} + +/** `GET /api/harnesses/:harnessId/catalog` → the harness-scoped catalog. A 404 + * (or any non-OK) becomes a typed `catalog_unavailable` result rather than a + * fallback catalog or a rendered raw body. */ +export async function fetchCatalog(harnessId: string): Promise { + const response = await fetch(`/api/harnesses/${encodeURIComponent(harnessId)}/catalog`, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return { + ok: false, + code: 'catalog_unavailable', + // Scoped to the requested harness id, never the untrusted body's echo. + harnessId, + message: CATALOG_UNAVAILABLE_MESSAGE, + }; + } + return { ok: true, catalog: asHarnessCatalog(await readJson(response), harnessId) }; +} + +/** `GET /api/chat/preferences/selection` → the persisted tuple, or null when + * unset or malformed. */ +export async function fetchPersistedSelection(): Promise { + const response = await fetch('/api/chat/preferences/selection', { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) return null; + const body = await readJson(response); + if (typeof body !== 'object' || body === null) return null; + return asHarnessSelection((body as { selection?: unknown }).selection); +} + +/** `PUT /api/chat/preferences/selection` with the structured tuple as the body. + * On success returns the confirmed selection; on a typed domain failure (422) + * or validation error, returns a typed result carrying the EXACT requested + * tuple — never the body's echo — and never the raw body text. */ +export async function persistSelection( + selection: HarnessSelection, +): Promise { + const requested: HarnessSelection = { + harnessId: selection.harnessId, + providerId: selection.providerId, + modelId: selection.modelId, + }; + const response = await fetch('/api/chat/preferences/selection', { + method: 'PUT', + credentials: 'include', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(requested), + }); + if (!response.ok) { + const body = await readJson(response); + return { + ok: false, + code: safeSelectionCode(body), + // Fixed copy only — the untrusted body's message is never surfaced. + message: SELECTION_REJECTED_MESSAGE, + requested, + }; + } + const body = await readJson(response); + const confirmed = + typeof body === 'object' && body !== null + ? asHarnessSelection((body as { selection?: unknown }).selection) + : null; + // A malformed 2xx body is treated as a confirmation of exactly what we sent — + // the server accepted the tuple, so the requested tuple is the source of truth. + return { ok: true, selection: confirmed ?? requested }; +} diff --git a/apps/web/src/spa/chat/composer.tsx b/apps/web/src/spa/chat/composer.tsx index 7bda88ef..696e8d1d 100644 --- a/apps/web/src/spa/chat/composer.tsx +++ b/apps/web/src/spa/chat/composer.tsx @@ -1,4 +1,5 @@ import { useState, type KeyboardEvent, type ReactElement } from 'react'; +import type { HarnessSelectionValue } from './use-harness-selection'; interface ComposerProps { onSend: (input: { content: string; provider?: string; modelId?: string }) => void; @@ -9,6 +10,23 @@ interface ComposerProps { * pre-ack window where a second send could otherwise slip through. */ sending: boolean; hasConversation: boolean; + /** Structured harness/provider/model selection state. The composer never + * accepts free-text provider/model — every sendable tuple is a validated, + * persisted catalog entry, and the send projection is derived from it. */ + harness: HarnessSelectionValue; +} + +/** The distinct provider ids present in the current catalog, in first-seen + * order — the provider select is catalog-derived, never a hardcoded list. */ +function providerOptions(harness: HarnessSelectionValue): string[] { + const seen = new Set(); + const out: string[] = []; + for (const model of harness.catalog?.models ?? []) { + if (seen.has(model.providerId)) continue; + seen.add(model.providerId); + out.push(model.providerId); + } + return out; } export function Composer({ @@ -17,21 +35,19 @@ export function Composer({ streaming, sending, hasConversation, + harness, }: ComposerProps): ReactElement { const [content, setContent] = useState(''); - const [provider, setProvider] = useState(''); - const [modelId, setModelId] = useState(''); const busy = streaming || sending; function submit(): void { if (busy) return; + // Send is gated on a validated, persisted catalog tuple — a draft or unset + // selection can never emit, so provider/model never travel as free text. + if (!harness.canSend) return; const trimmed = content.trim(); if (!trimmed) return; - onSend({ - content: trimmed, - provider: provider.trim() || undefined, - modelId: modelId.trim() || undefined, - }); + onSend({ content: trimmed, ...harness.projection }); setContent(''); } @@ -42,6 +58,8 @@ export function Composer({ } } + const models = harness.catalog?.models ?? []; + return (
{ @@ -51,21 +69,63 @@ export function Composer({ className="flex flex-col gap-2 border-t p-4" >
- harness.selectHarness(event.target.value)} + className="rounded border px-2 py-1 text-xs" + > + + {harness.harnesses.map((item) => ( + + ))} + + + + {providerOptions(harness).map((providerId) => ( + + ))} + +
+ {harness.catalogUnavailable ? ( +

+ This harness catalog is currently unavailable. +

+ ) : null} + {harness.isStale ? ( +

+ The saved model is no longer available — pick another to continue. +

+ ) : null} + {harness.persistError ? ( +

+ {harness.persistError.message} +

+ ) : null}