diff --git a/e2e/documents-chat.spec.ts b/e2e/documents-chat.spec.ts new file mode 100644 index 000000000..87e253f22 --- /dev/null +++ b/e2e/documents-chat.spec.ts @@ -0,0 +1,294 @@ +/** + * Document vault — the P4 "chat about this document" acceptance suite. + * + * Pins the scoped chat that lives in the detail sheet's AI area: the entry is + * offered only when a provider is available AND the document is content-indexed; + * a non-indexed document shows a calm read-it-first hint (never an error); a + * conversation streams the assistant reply and settles it as plain text with the + * always-on "not medical advice" safety note; and with NO provider the AI area + * collapses to the settings pointer with no chat surface at all. + * + * v1.27.31 lesson: the panel depends on new endpoints (the capability probe, the + * chat history GET, the streaming chat POST), so every one is mocked here — + * otherwise the panel never renders and the specs time out waiting for elements. + * The provider round-trips are mocked, so the SSE reply is deterministic. The + * seeded demo user has no AI provider, so the no-provider state is the default. + */ +import { expect, test, type Page } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + ensureVaultFixture, + ensureVaultAiFixture, + AI_PROBE_DOC_ID, + CONTENT_DOC_ID, +} from "./setup/vault-fixture"; + +/** Fulfil the envelope shape the client unwraps (`(await res.json()).data`). */ +async function fulfilJson( + route: Parameters[1]>[0], + body: unknown, +) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +/** The vault usage DTO the sheet reads to gate the AI affordances. */ +function usageBody() { + return { + data: { + usedBytes: 4096, + quotaBytes: 1_073_741_824, + maxFileBytes: 26_214_400, + acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"], + linkedEpisodes: [], + assistAvailable: true, + contentIndex: { enabled: true, indexedCount: 1, totalCount: 2 }, + }, + error: null, + }; +} + +/** Serialise SSE frames the way the chat route emits them. */ +function sse(frames: Array>): string { + return frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join(""); +} + +/** + * Mock the AI-availability surface (usage + the document-scoped capability + * probe) so the sheet offers the AI area and the chat entry. `egress` drives the + * vendor-blind notice; default external. + */ +async function mockAiEnabled( + page: Page, + opts: { egress?: "local" | "external" } = {}, +): Promise { + await page.route("**/api/documents/inbound/usage", (route) => + fulfilJson(route, usageBody()), + ); + await page.route("**/api/documents/inbound/capability", (route) => + fulfilJson(route, { + data: { + available: true, + mode: "vision", + reason: null, + pdfSupported: true, + egress: opts.egress ?? "external", + }, + error: null, + }), + ); +} + +test.describe("document vault — chat about a document", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeAll(async () => { + await ensureVaultFixture(); + await ensureVaultAiFixture(); + }); + + // The chat drives a mocked provider round-trip; on a loaded CI runner it races + // the default 30s, so the whole group earns the tripled timeout. + test.beforeEach(() => { + test.slow(); + }); + + // ── (a) Indexed + available: the entry opens an empty, safe chat surface ── + + test("offers the chat entry and opens an empty, safety-noted surface", async ({ + page, + }) => { + await mockAiEnabled(page); + // The document has no thread yet → the history list is empty. + await page.route( + `**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`, + (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return fulfilJson(route, { + data: { conversations: [], nextCursor: null }, + error: null, + }); + }, + ); + + await page.goto(`/documents?doc=${CONTENT_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + const open = sheet.locator('[data-slot="document-chat-open"]'); + await expect(open).toBeVisible(); + await open.click(); + + await expect( + sheet.locator('[data-slot="document-chat-log"]'), + ).toBeVisible(); + await expect( + sheet.locator('[data-slot="document-chat-empty"]'), + ).toBeVisible(); + await expect( + sheet.locator('[data-slot="document-chat-input"]'), + ).toBeVisible(); + // The always-on safety note is present. + await expect( + sheet.locator('[data-slot="document-chat-safety"]'), + ).toContainText("not medical advice"); + }); + + // ── (b) A turn streams a grounded reply that settles as plain text ── + + test("streams the assistant reply and settles the conversation", async ({ + page, + }) => { + await mockAiEnabled(page); + + const CONV_ID = "e2edocchatconv0000000001"; + const QUESTION = "What does the Impression say?"; + const REPLY = "Per the report's Impression, the findings are unremarkable."; + let posted = false; + + await page.route( + `**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`, + async (route) => { + const req = route.request(); + if (req.method() === "POST") { + posted = true; + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + body: sse([ + { type: "token", token: REPLY }, + { + type: "done", + conversationId: CONV_ID, + messageId: "e2edocchatmsg0000000001", + }, + ]), + }); + return; + } + const url = new URL(req.url()); + if (url.searchParams.get("conversationId")) { + // The persisted thread the post-`done` refetch reads back. + await fulfilJson(route, { + data: { + id: CONV_ID, + title: QUESTION, + createdAt: "2026-07-07T10:00:00.000Z", + updatedAt: "2026-07-07T10:00:02.000Z", + messageCount: 2, + messages: [ + { + id: "e2edocchatu0000000001", + role: "user", + content: QUESTION, + createdAt: "2026-07-07T10:00:00.000Z", + metricSource: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }, + { + id: "e2edocchatmsg0000000001", + role: "assistant", + content: REPLY, + createdAt: "2026-07-07T10:00:02.000Z", + metricSource: null, + providerType: "mock", + promptVersion: null, + tokensUsed: 42, + model: "mock-1", + }, + ], + }, + error: null, + }); + return; + } + // The thread list: empty until the turn is posted, then the new thread. + await fulfilJson(route, { + data: { + conversations: posted + ? [ + { + id: CONV_ID, + title: QUESTION, + createdAt: "2026-07-07T10:00:00.000Z", + updatedAt: "2026-07-07T10:00:02.000Z", + messageCount: 2, + }, + ] + : [], + nextCursor: null, + }, + error: null, + }); + }, + ); + + await page.goto(`/documents?doc=${CONTENT_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + await sheet.locator('[data-slot="document-chat-open"]').click(); + const input = sheet.locator('[data-slot="document-chat-input"]'); + await input.fill(QUESTION); + await sheet.locator('[data-slot="document-chat-send"]').click(); + + // The user's question and the streamed/settled reply both land in the log. + const log = sheet.locator('[data-slot="document-chat-log"]'); + await expect(log.getByText(QUESTION)).toBeVisible(); + await expect(log.getByText(/findings are unremarkable/)).toBeVisible(); + // The reply settled to a single assistant bubble (no duplicate). + await expect( + log.locator('[data-slot="document-chat-message"][data-role="assistant"]'), + ).toHaveCount(1); + }); + + // ── (c) Not indexed: a calm read-it-first hint, never an error ── + + test("shows a calm hint when the document is not indexed", async ({ + page, + }) => { + // AI_PROBE_DOC_ID carries no content index (hasContentIndex === false). + await mockAiEnabled(page); + await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + await expect( + sheet.locator('[data-slot="document-chat-not-indexed"]'), + ).toBeVisible(); + // No chat entry, and no error styling. + await expect(sheet.locator('[data-slot="document-chat-open"]')).toHaveCount( + 0, + ); + await expect( + sheet.locator('[data-slot="document-chat"] [role="alert"]'), + ).toHaveCount(0); + }); + + // ── (d) No provider: the calm settings pointer, no chat surface at all ── + + test("with no provider there is no chat surface, only the settings pointer", async ({ + page, + }) => { + // No mocks — the seeded demo user has no AI provider configured, so the AI + // area collapses to the calm pointer and the chat slot never renders. + await page.goto(`/documents?doc=${CONTENT_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + await expect( + sheet.locator('[data-slot="assist-unavailable"]'), + ).toBeVisible(); + await expect( + sheet.getByRole("link", { name: "Open AI settings" }), + ).toBeVisible(); + await expect(sheet.locator('[data-slot="document-chat"]')).toHaveCount(0); + }); +}); diff --git a/src/components/documents/use-document-chat.ts b/src/components/documents/use-document-chat.ts index f5da98510..62668d6e5 100644 --- a/src/components/documents/use-document-chat.ts +++ b/src/components/documents/use-document-chat.ts @@ -275,10 +275,14 @@ export function useSendDocumentChatMessage(documentId: string) { if (resolvedConversationId) { // Reload the encrypted-on-disk thread (canonical ids + any outbound - // editorialisation). The persisted twin replaces the optimistic bubble. + // editorialisation), then clear the live tail + optimistic bubble so the + // persisted turns are the single source — the refetch is awaited, so the + // canonical messages are in cache before the streamed copy is dropped + // (no gap, no duplicate assistant bubble). await queryClient.invalidateQueries({ queryKey: queryKeys.inboundDocumentChat(documentId), }); + setStreaming(EMPTY_STREAMING); setOptimisticUser(null); } return resolvedConversationId;