diff --git a/src/__tests__/api-service.test.ts b/src/__tests__/api-service.test.ts index a84ca58..a94c9ce 100644 --- a/src/__tests__/api-service.test.ts +++ b/src/__tests__/api-service.test.ts @@ -1,8 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -vi.mock("@/lib/config/runtime-config", () => ({ - getConfig: () => ({ api: { baseUrl: "/api" }, stubs: { enabled: false } }) -})); +const mockConfig = vi.hoisted(() => ({ api: { baseUrl: "/api" }, stubs: { enabled: false } })); +vi.mock("@/lib/config/runtime-config", () => ({ getConfig: () => mockConfig })); import apiService, { ApiError, type ChatTurn } from "@/lib/api-service"; @@ -79,6 +78,18 @@ describe("sendUserQuery", () => { expect(JSON.parse(second.body as string).location).toEqual({ latitude: 20.0059, longitude: 73.7898 }); }); + it("tolerates a trailing slash on api.baseUrl", async () => { + mockConfig.api.baseUrl = "/experience/api/"; + try { + const fetchMock = fetchOnce(streamResponse(STARTED + COMPLETED)); + await apiService.sendUserQuery(turn); + + expect(fetchMock.mock.calls[0]?.[0]).toBe("/experience/api/v1/chat"); + } finally { + mockConfig.api.baseUrl = "/api"; + } + }); + it("reports started and each delta, then resolves with the completed answer", async () => { fetchOnce(streamResponse(STARTED + DELTAS + COMPLETED)); const onStarted = vi.fn(); @@ -108,6 +119,26 @@ describe("sendUserQuery", () => { expect(answer.error).toMatchObject({ code: "provider_unavailable", retryable: true }); }); + it("fails as upstream_error when started has no ids, rather than passing 'undefined' on", async () => { + fetchOnce(streamResponse('event: started\ndata: {"sequence":1,"sessionId":"s","messageId":"m"}\n\n' + COMPLETED)); + const onStarted = vi.fn(); + + const error = await failure(apiService.sendUserQuery(turn, { onStarted })); + + expect(error).toMatchObject({ code: "upstream_error", retryable: true }); + expect(onStarted).not.toHaveBeenCalled(); + }); + + it("fails as upstream_error when completed is malformed", async () => { + const stringError = COMPLETED.replace('"sources":[]', '"sources":[],"error":"provider down"'); + fetchOnce(streamResponse(STARTED + stringError)); + expect(await failure(apiService.sendUserQuery(turn))).toMatchObject({ code: "upstream_error" }); + + const nothingToShow = COMPLETED.replace(/"content":\[.*?\],"sources"/, '"content":[],"sources"'); + fetchOnce(streamResponse(STARTED + nothingToShow)); + expect(await failure(apiService.sendUserQuery(turn))).toMatchObject({ code: "upstream_error" }); + }); + it("skips events it does not know", async () => { fetchOnce(streamResponse(STARTED + 'event: heartbeat\ndata: {"sequence":2}\n\n' + COMPLETED)); diff --git a/src/hooks/store/chat/index.ts b/src/hooks/store/chat/index.ts index 0363897..9f02d5f 100644 --- a/src/hooks/store/chat/index.ts +++ b/src/hooks/store/chat/index.ts @@ -298,6 +298,23 @@ async function streamTurn( } } +/** After an answered turn, fetch follow-up suggestions, when that surface is on. */ +async function refreshSuggestions( + set: StoreApi["setState"], + sessionId: string, + language: string +): Promise { + if (!FEATURES.suggestions) return; + const suggestions = await apiService.getSuggestions(sessionId, language); + set({ + suggestions: suggestions.map((s) => ({ + id: crypto.randomUUID(), + text: s.question, + label: s.question + })) + }); +} + function makeImageMessage(imageUrl: string, caption?: string): ChatMessage { return { id: crypto.randomUUID(), @@ -482,16 +499,7 @@ export const useChatStore = create((set, get) => ({ } ); - if (answered && FEATURES.suggestions) { - const suggestions = await apiService.getSuggestions(currentSession, language); - set({ - suggestions: suggestions.map((s) => ({ - id: crypto.randomUUID(), - text: s.question, - label: s.question - })) - }); - } + if (answered) await refreshSuggestions(set, currentSession, language); }, sendImage: async (imageFile, language, t) => { @@ -583,16 +591,7 @@ export const useChatStore = create((set, get) => ({ } ); - if (answered && FEATURES.suggestions) { - const suggestions = await apiService.getSuggestions(currentSession, language); - set({ - suggestions: suggestions.map((s) => ({ - id: crypto.randomUUID(), - text: s.question, - label: s.question - })) - }); - } + if (answered) await refreshSuggestions(set, currentSession, language); }, sendAudio: async (blob, sessionId, language) => { diff --git a/src/lib/api-service.ts b/src/lib/api-service.ts index 656759f..1e06a4b 100644 --- a/src/lib/api-service.ts +++ b/src/lib/api-service.ts @@ -163,17 +163,25 @@ const readAnswer = async ( switch (frame.event) { case 'started': - if (typeof data.traceId === 'string') traceId = data.traceId; - handlers.onStarted?.({ - assistantMessageId: String(data.assistantMessageId), - traceId: String(data.traceId), - }); + if (typeof data.assistantMessageId !== 'string' || typeof data.traceId !== 'string') { + throw upstreamError('started carried no assistantMessageId or traceId'); + } + traceId = data.traceId; + handlers.onStarted?.({ assistantMessageId: data.assistantMessageId, traceId: data.traceId }); break; case 'delta': if (typeof data.text === 'string') handlers.onDelta?.(data.text); break; case 'completed': + // Held to ยง5.2 before the store acts on it: content is a list, error + // when present has our shape, and empty content only comes with error. if (!Array.isArray(data.content)) throw upstreamError('completed carried no content array', traceId); + if (data.error !== undefined && !isErrorBody(data.error)) { + throw upstreamError('completed carried an error without the contract shape', traceId); + } + if (data.content.length === 0 && data.error === undefined) { + throw upstreamError('completed carried neither content nor error', traceId); + } return data as unknown as FinalAnswer; case 'error': if (!isErrorBody(data.error)) throw upstreamError('error event carried no error body', traceId); @@ -217,8 +225,11 @@ interface ImageUploadResponse { * * Read on each call rather than at import. Configuration exists only once * loadRuntimeConfig() has resolved, and this module is imported by tests. + * + * A trailing slash is dropped: every path is appended as `/v1/chat`, and + * `/api//v1/chat` is a different, unknown route to the API. */ -const apiBaseUrl = (): string => getConfig().api.baseUrl; +const apiBaseUrl = (): string => getConfig().api.baseUrl.replace(/\/+$/, ''); class ApiService { private locationData: LocationData | null = null; diff --git a/vite.config.ts b/vite.config.ts index 932c539..db350b9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,7 +25,8 @@ const virtualRouteFileChangeReloadPlugin: PluginOption = { * plugin puts it on the web root in both dev and build from that one source. */ const APP_CONFIG_SOURCE = path.resolve(__dirname, "src/config/app-config.json"); -const apiBaseUrl: string = JSON.parse(fs.readFileSync(APP_CONFIG_SOURCE, "utf-8")).api.baseUrl; +// Without its trailing slash, if any, as the client reads it. +const apiBaseUrl: string = JSON.parse(fs.readFileSync(APP_CONFIG_SOURCE, "utf-8")).api.baseUrl.replace(/\/+$/, ""); const appConfigPlugin: PluginOption = { name: "serve-app-config",