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
37 changes: 34 additions & 3 deletions src/__tests__/api-service.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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));

Expand Down
39 changes: 19 additions & 20 deletions src/hooks/store/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatStore>["setState"],
sessionId: string,
language: string
): Promise<void> {
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(),
Expand Down Expand Up @@ -482,16 +499,7 @@ export const useChatStore = create<ChatStore>((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) => {
Expand Down Expand Up @@ -583,16 +591,7 @@ export const useChatStore = create<ChatStore>((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) => {
Expand Down
23 changes: 17 additions & 6 deletions src/lib/api-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading