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
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,12 @@ npm run build # tsc -b && vite build
docker compose up --build # the deployable image, http://localhost:8080
```

There is no Experience API yet. The client ships with stubs on
(`stubs.enabled` in config), so chat works end to end with canned replies, each
labelled as stubbed.
The client ships with stubs **off** and calls the Experience API at
`api.baseUrl`. In `npm run dev` that path is proxied to a local API
(`DEV_API_URL`, default `http://localhost:8078`). With no API running, set
`stubs.enabled` to `true` in `src/config/app-config.json` for canned replies,
each labelled as stubbed, and do not commit it: the shipped file is the
deployed one.

## Conventions
Naming, git workflow, commit format, PR shape and linting are in
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,15 @@ is `api.baseUrl` in `config.json`, an absolute path on the app's own origin.
The default, `/experience/api`, is the reference deployment's path (see
*Serving from a sub-path*).

There is no Experience API yet, so the client ships with a stub layer turned on
(`stubs.enabled` in `config.json`). With it on, every endpoint returns canned
data, nothing leaves the browser, and the chat works end to end — each reply is
labelled as stubbed so fabricated agricultural advice cannot be mistaken for
real. Set `stubs.enabled` to `false` to talk to a real backend. See
The dev server proxies `api.baseUrl` to an Experience API on
`http://localhost:8078`; set `DEV_API_URL` for another address. Without one,
every question shows an error card.

To work with no API at all, set `stubs.enabled` to `true` in
`src/config/app-config.json`. Every endpoint then returns canned data, nothing
leaves the browser, and the chat works end to end — each reply is labelled as
stubbed so fabricated agricultural advice cannot be mistaken for real. The
shipped file is the deployed one, so do not commit the change. See
[`src/lib/api-stubs.ts`](src/lib/api-stubs.ts).

## Git hooks
Expand Down
2 changes: 1 addition & 1 deletion docs/ADR/0000-fork-oan-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ is doing right now:

| Feature | Notes |
|---|---|
| **Streaming chat** | The client sends a question and reads the answer from the response body as it arrives. Answers are canned until the Experience API exists |
| **Streaming chat** | The client sends the turn to the Experience API as `POST /v1/chat` and reads the answer back as a stream of events. A stub layer (`stubs.enabled`) can stand in for the API during development and speaks the same stream; it is off by default |
| **Markdown answers** | `react-markdown` + `remark-gfm`, with links forced to `target="_blank" rel="noopener noreferrer"` |
| **Copy an answer** | Client-side |
| **Retry a failed answer** | Client-side |
Expand Down
173 changes: 173 additions & 0 deletions src/__tests__/api-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { afterEach, describe, expect, it, vi } from "vitest";

vi.mock("@/lib/config/runtime-config", () => ({
getConfig: () => ({ api: { baseUrl: "/api" }, stubs: { enabled: false } })
}));

import apiService, { ApiError, type ChatTurn } from "@/lib/api-service";

/**
* The chat call is the client's whole coupling to the Experience API. These
* tests hold it to the contract (§4–§6) with a fake fetch: what it sends, how
* it reads the event stream, and that every way a turn can fail comes out as
* one ApiError with a code the rest of the client can branch on.
*/
const turn: ChatTurn = {
sessionId: "68a3872f-3f0d-4cf6-99a3-a350132a0080",
messageId: "1ab38d6c-6fdb-4849-8ea1-da5e80a8687c",
query: "And what about tomorrow?",
history: [{ role: "user", text: "Weather today?" }, { role: "assistant", text: "Clear, 31°C." }],
language: { source: "en", target: "en" }
};

const STARTED = `event: started\ndata: {"sequence":1,"sessionId":"${turn.sessionId}","messageId":"${turn.messageId}","assistantMessageId":"8d2f4b61-93c7-4e0a-b1f5-2a7c9e3d6f10","traceId":"3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b"}\n\n`;
const DELTAS = `event: delta\ndata: {"sequence":2,"text":"Tomorrow in Nashik "}\n\nevent: delta\ndata: {"sequence":3,"text":"expect light rain after 3 pm."}\n\n`;
const COMPLETED = `event: completed\ndata: {"sequence":4,"sessionId":"${turn.sessionId}","messageId":"${turn.messageId}","assistantMessageId":"8d2f4b61-93c7-4e0a-b1f5-2a7c9e3d6f10","traceId":"3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b","outcome":{"status":"answered","cause":null},"content":[{"type":"text","text":"Tomorrow in Nashik expect light rain after 3 pm.","citations":[]}],"sources":[]}\n\n`;

const streamResponse = (text: string, contentType = "text/event-stream; charset=utf-8"): Response =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
controller.close();
}
}),
{ status: 200, headers: { "content-type": contentType } }
);

const jsonResponse = (status: number, body?: unknown): Response =>
new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { "content-type": "application/json" }
});

const fetchOnce = (response: Response) => {
const fetchMock = vi.fn().mockResolvedValue(response);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
};

const failure = async (promise: Promise<unknown>): Promise<ApiError> => {
try {
await promise;
} catch (error) {
if (error instanceof ApiError) return error;
throw error;
}
throw new Error("expected the call to fail");
};

describe("sendUserQuery", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("posts the turn as the contract's body, with location only when known", async () => {
const fetchMock = fetchOnce(streamResponse(STARTED + COMPLETED));
await apiService.sendUserQuery(turn);

const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/api/v1/chat");
expect(init.method).toBe("POST");
expect(init.headers).toMatchObject({ "Content-Type": "application/json", Accept: "text/event-stream" });
expect(JSON.parse(init.body as string)).toEqual(turn);

apiService.setLocationData({ latitude: 20.0059, longitude: 73.7898 });
const again = fetchOnce(streamResponse(STARTED + COMPLETED));
await apiService.sendUserQuery(turn);
const [, second] = again.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(second.body as string).location).toEqual({ latitude: 20.0059, longitude: 73.7898 });
});

it("reports started and each delta, then resolves with the completed answer", async () => {
fetchOnce(streamResponse(STARTED + DELTAS + COMPLETED));
const onStarted = vi.fn();
const onDelta = vi.fn();

const answer = await apiService.sendUserQuery(turn, { onStarted, onDelta });

expect(onStarted).toHaveBeenCalledWith({
assistantMessageId: "8d2f4b61-93c7-4e0a-b1f5-2a7c9e3d6f10",
traceId: "3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b"
});
expect(onDelta.mock.calls.map((c) => c[0])).toEqual(["Tomorrow in Nashik ", "expect light rain after 3 pm."]);
expect(answer.content).toEqual([
{ type: "text", text: "Tomorrow in Nashik expect light rain after 3 pm.", citations: [] }
]);
});

it("returns a completed answer that carries error, rather than failing", async () => {
const completed = COMPLETED.replace(
'"sources":[]',
'"sources":[],"error":{"code":"provider_unavailable","message":"down","retryable":true,"traceId":"t-1"}'
);
fetchOnce(streamResponse(STARTED + completed));

const answer = await apiService.sendUserQuery(turn);

expect(answer.error).toMatchObject({ code: "provider_unavailable", retryable: true });
});

it("skips events it does not know", async () => {
fetchOnce(streamResponse(STARTED + 'event: heartbeat\ndata: {"sequence":2}\n\n' + COMPLETED));

await expect(apiService.sendUserQuery(turn)).resolves.toMatchObject({ outcome: { status: "answered" } });
});

it("fails with the code from an error event", async () => {
fetchOnce(
streamResponse(
STARTED +
'event: error\ndata: {"sequence":2,"error":{"code":"timeout","message":"DSS too slow","retryable":true,"traceId":"3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b"}}\n\n'
)
);

const error = await failure(apiService.sendUserQuery(turn));

expect(error).toMatchObject({ code: "timeout", retryable: true, traceId: "3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b" });
});

it("fails with the code from an error response body", async () => {
fetchOnce(
jsonResponse(503, {
error: { code: "upstream_unavailable", message: "DSS not ready", retryable: true, traceId: "t-503" }
})
);

const error = await failure(apiService.sendUserQuery(turn));

expect(error).toMatchObject({ code: "upstream_unavailable", retryable: true, status: 503, traceId: "t-503" });
});

it("treats a 413 without a contract body as history_too_large", async () => {
fetchOnce(new Response("<html>413 Request Entity Too Large</html>", { status: 413 }));

const error = await failure(apiService.sendUserQuery(turn));

expect(error).toMatchObject({ code: "history_too_large", retryable: false, status: 413 });
});

it("reports any other response without a contract body as unknown, retryable only for 429 and 5xx", async () => {
fetchOnce(jsonResponse(422, { detail: [{ loc: ["body", "sessionId"], msg: "Field required" }] }));
expect(await failure(apiService.sendUserQuery(turn))).toMatchObject({ code: "unknown", retryable: false, status: 422 });

fetchOnce(jsonResponse(502));
expect(await failure(apiService.sendUserQuery(turn))).toMatchObject({ code: "unknown", retryable: true, status: 502 });
});

it("fails as upstream_error when the stream ends with neither completed nor error", async () => {
fetchOnce(streamResponse(STARTED + DELTAS));

const error = await failure(apiService.sendUserQuery(turn));

expect(error).toMatchObject({ code: "upstream_error", retryable: true, traceId: "3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b" });
});

it("fails as upstream_error when the response is not an event stream", async () => {
fetchOnce(streamResponse("<!doctype html><html></html>", "text/html"));

const error = await failure(apiService.sendUserQuery(turn));

expect(error).toMatchObject({ code: "upstream_error", retryable: true });
});
});
111 changes: 81 additions & 30 deletions src/__tests__/api-stubs.test.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,101 @@
import { describe, expect, it, vi } from "vitest";

import { stubGetSuggestions, stubSendUserQuery } from "@/lib/api-stubs";
import type { ChatTurnRequest, FinalAnswer } from "@/lib/api-service";
import { stubChatFrames, stubChatResponse, stubGetSuggestions } from "@/lib/api-stubs";
import { readSseEvents } from "@/lib/sse";

/**
* The stub layer stands in for the Experience API, which does not exist yet.
* These tests cover the two paths the chat screen actually depends on: a reply
* that streams, and a token the auth context will accept.
* The stub stands in for the Experience API, which is not deployed yet. What
* matters is that it speaks the API's own event stream (contract §5.1), so the
* client code that runs on a stubbed reply is the code that will run on a real
* one. These tests read the stub through the real parser to hold it to that.
*/
describe("api stubs", () => {
it("streams a reply in chunks that reassemble into the returned text", async () => {
vi.useFakeTimers();
try {
const chunks: string[] = [];
const pending = stubSendUserQuery("when should I sow wheat?", (chunk) => chunks.push(chunk));
const request: ChatTurnRequest = {
sessionId: "68a3872f-3f0d-4cf6-99a3-a350132a0080",
messageId: "1ab38d6c-6fdb-4849-8ea1-da5e80a8687c",
query: "when should I sow wheat?",
history: [],
language: { source: "en", target: "en" }
};

await vi.runAllTimersAsync();
const result = await pending;
type Parsed = { event: string; data: Record<string, unknown> };

expect(chunks.length).toBeGreaterThan(1);
expect(chunks.join("")).toBe(result.response);
expect(result.status).toBe("success");
expect(result.qid).toMatch(/^stub-qid-/);
} finally {
vi.useRealTimers();
const parse = async (frames: string[]): Promise<Parsed[]> => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const frame of frames) controller.enqueue(encoder.encode(frame));
controller.close();
}
});
const events: Parsed[] = [];
for await (const frame of readSseEvents(body)) {
events.push({ event: frame.event, data: JSON.parse(frame.data) });
}
return events;
};

const completedOf = (events: Parsed[]) => events[events.length - 1]?.data as unknown as FinalAnswer;

it("labels every reply as stubbed, so fabricated advice cannot pass as real", async () => {
describe("chat stub", () => {
it("streams started, deltas and completed in order, numbered from 1", async () => {
const events = await parse(stubChatFrames(request));

expect(events[0]?.event).toBe("started");
expect(events[0]?.data).toMatchObject({ sessionId: request.sessionId, messageId: request.messageId });
expect(events.slice(1, -1).every((e) => e.event === "delta")).toBe(true);
expect(events[events.length - 1]?.event).toBe("completed");
expect(events.map((e) => e.data.sequence)).toEqual(events.map((_, i) => i + 1));
});

it("streams the text items as deltas that join to the completed text", async () => {
const events = await parse(stubChatFrames(request));
const completed = completedOf(events);

const streamed = events.filter((e) => e.event === "delta").map((e) => e.data.text).join("");
const textItems = completed.content.filter((item) => item.type === "text").map((item) => item.text).join("");
expect(streamed).toBe(textItems);
expect(completed.assistantMessageId).toBe(events[0]?.data.assistantMessageId);
});

it("labels every reply as stubbed and cycles through a refusal and a failed turn", async () => {
const answers: FinalAnswer[] = [];
for (let i = 0; i < 5; i += 1) {
answers.push(completedOf(await parse(stubChatFrames(request))));
}

for (const answer of answers) {
for (const item of answer.content) expect(item.text).toContain("stubbed response");
}
expect(answers.some((a) => a.content.some((item) => item.type === "refusal"))).toBe(true);
const failed = answers.find((a) => a.error);
expect(failed?.error).toMatchObject({ retryable: true, traceId: failed?.traceId });
});

it("returns an event-stream Response whose body carries the frames", async () => {
vi.useFakeTimers();
try {
const seen: string[] = [];
// Cycle further than the number of canned replies to cover them all.
for (let i = 0; i < 4; i += 1) {
const pending = stubSendUserQuery(`question ${i}`);
await vi.runAllTimersAsync();
seen.push((await pending).response);
}

for (const reply of seen) {
expect(reply).toContain("stubbed response");
}
const response = await stubChatResponse(request);
expect(response.ok).toBe(true);
expect(response.headers.get("content-type")).toBe("text/event-stream");

const collecting = (async () => {
const seen: string[] = [];
for await (const frame of readSseEvents(response.body as ReadableStream<Uint8Array>)) seen.push(frame.event);
return seen;
})();
await vi.runAllTimersAsync();

const seen = await collecting;
expect(seen[0]).toBe("started");
expect(seen[seen.length - 1]).toBe("completed");
} finally {
vi.useRealTimers();
}
});
});

describe("other stubs", () => {
it("returns suggestions in the shape the chat store expects", async () => {
vi.useFakeTimers();
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export type TextMessage = MessageBase & {

export type CardMessage = MessageBase & {
type: "card";
qid?: string;
/** The API's trace id for the turn, for support. */
traceId?: string;
title?: string;
body: string;
actions?: { id: string; label: string }[];
Expand Down
2 changes: 1 addition & 1 deletion src/config/app-config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"stubs": {
"enabled": true
"enabled": false
},
"api": {
"baseUrl": "/experience/api"
Expand Down
Loading