From 4558dbcf11584388cedcf5b13ab1856fb9d9b0a0 Mon Sep 17 00:00:00 2001 From: Katya Date: Fri, 9 Oct 2026 23:09:27 +0200 Subject: [PATCH 1/5] feat: render sources, checks and tool lines for an assistant answer --- .../components/assistant/AssistantMessage.tsx | 170 +++++++ .../components/assistant/AssistantSources.tsx | 95 ++++ .../src/components/assistant/message-shape.ts | 52 +++ frontend/src/i18n/locales/cs/assistant.json | 31 +- frontend/src/i18n/locales/en/assistant.json | 31 +- .../assistant/AssistantMessage.test.tsx | 427 ++++++++++++++++++ .../assistant/message-shape.test.ts | 103 +++++ 7 files changed, 907 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/assistant/AssistantMessage.tsx create mode 100644 frontend/src/components/assistant/AssistantSources.tsx create mode 100644 frontend/tests/components/assistant/AssistantMessage.test.tsx create mode 100644 frontend/tests/components/assistant/message-shape.test.ts diff --git a/frontend/src/components/assistant/AssistantMessage.tsx b/frontend/src/components/assistant/AssistantMessage.tsx new file mode 100644 index 00000000..3b44b671 --- /dev/null +++ b/frontend/src/components/assistant/AssistantMessage.tsx @@ -0,0 +1,170 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertTriangle, Info, OctagonX } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { AnswerChecks } from "@/types/api"; +import { AssistantMarkdown } from "./AssistantMarkdown"; +import { AssistantSources } from "./AssistantSources"; +import { cleanChecks, cleanSources, cleanToolsUsed, sourceAnchorId } from "./message-shape"; +import type { AssistantMessage as AssistantMessageData, AssistantMessageError } from "./useAssistantChat"; + +const KNOWN_TOOLS = [ + "search_docs", + "list_my_projects", + "get_project", + "get_project_result", + "get_project_opinions", +]; + +interface FailureBlockProps { + readonly cut: boolean; + readonly error?: AssistantMessageError; + readonly onRetry?: () => void; +} + +/** + * The red block of a failed or interrupted turn. The text is chosen by the status code + * from fixed copy: `error.detail` is whatever the server or the network said, and is + * never shown. + */ +function FailureBlock({ cut, error, onRetry }: FailureBlockProps) { + const { t } = useTranslation("assistant"); + let title = t("message.errors.generic"); + let hint: string | null = null; + + if (cut) { + title = t("message.errors.cut"); + hint = t("message.errors.cutHint"); + } else if (error?.code === 429) { + const seconds = error.retryAfter; + title = + seconds !== undefined && Number.isFinite(seconds) && seconds > 0 + ? t("message.errors.rateLimited", { minutes: Math.ceil(seconds / 60) }) + : t("message.errors.rateLimitedGeneric"); + } else if (error?.code === 503) { + title = t("message.errors.unavailable"); + hint = t("message.errors.unavailableHint"); + } else if (error?.code === 404) { + title = t("message.errors.notFound"); + hint = t("message.errors.notFoundHint"); + } + + return ( +
+
+ ); +} + +function ChecksLines({ checks }: { readonly checks: AnswerChecks }) { + const { t } = useTranslation("assistant"); + const lines: string[] = []; + if (!checks.numbers_grounded) { + lines.push(t("message.numbersNotConfirmed", { numbers: checks.ungrounded_numbers.join(", ") })); + } + if (!checks.citations_valid) { + lines.push(t("message.citationInvalid")); + } + + return lines.map((line) => ( +

+

+ )); +} + +function ToolsLine({ tools }: { readonly tools: readonly string[] }) { + const { t } = useTranslation("assistant"); + const labels = tools.map((tool) => (KNOWN_TOOLS.includes(tool) ? t(`message.tools.${tool}`) : tool)); + + return ( +

+

+ ); +} + +export interface AssistantMessageProps { + readonly message: AssistantMessageData; + readonly onRetry?: () => void; +} + +function AssistantAnswer({ message, onRetry }: AssistantMessageProps) { + const { t } = useTranslation("assistant"); + const [openN, setOpenN] = useState(null); + const sources = useMemo(() => cleanSources(message.sources), [message.sources]); + const sourceNumbers = useMemo(() => sources.map((source) => source.n), [sources]); + const tools = cleanToolsUsed(message.toolsUsed); + const checks = cleanChecks(message.checks); + + const { status } = message; + const partial = status === "error" || status === "cut" || status === "cancelled"; + + function handleCite(n: number) { + setOpenN(n); + document.getElementById(sourceAnchorId(message.id, n))?.scrollIntoView?.({ block: "nearest" }); + } + + function handleToggle(n: number) { + setOpenN((current) => (current === n ? null : n)); + } + + return ( +
+ {message.content.trim() !== "" && ( +
+ +
+ )} + {status === "cancelled" && ( +

+

+ )} + {(status === "error" || status === "cut") && ( + + )} + {checks && } + {sources.length > 0 && ( + + )} + {tools.length > 0 && } +
+ ); +} + +/** One turn of the conversation. The user's text is never read as markdown. */ +export function AssistantMessage({ message, onRetry }: AssistantMessageProps) { + if (message.role === "user") { + return ( +
+ {message.content} +
+ ); + } + return ; +} diff --git a/frontend/src/components/assistant/AssistantSources.tsx b/frontend/src/components/assistant/AssistantSources.tsx new file mode 100644 index 00000000..5f0cf6b6 --- /dev/null +++ b/frontend/src/components/assistant/AssistantSources.tsx @@ -0,0 +1,95 @@ +import { useTranslation } from "react-i18next"; +import type { SourceRef } from "@/types/api"; +import { sourceAnchorId } from "./message-shape"; +import { isAllowedAssistantUrl } from "./url-allowlist"; + +interface SourceRowProps { + readonly messageId: string; + readonly source: SourceRef; + readonly open: boolean; + readonly onToggle: (n: number) => void; +} + +function SourceRow({ messageId, source, open, onToggle }: SourceRowProps) { + const { t } = useTranslation("assistant"); + const rowId = sourceAnchorId(messageId, source.n); + const detailId = `${rowId}-detail`; + const isLocal = source.layer === "local"; + // A local source never links, whatever its url says. + const href = !isLocal && isAllowedAssistantUrl(source.url) ? source.url : null; + + return ( +
+
+ + {isLocal && ( + + {t("message.localCopy")} + + )} +
+ {open && ( +
+ {source.snippet !== "" && ( +

+ {source.snippet} +

+ )} + {isLocal ?

{t("message.localHint")}

:

{t("message.publicLayer")}

} + {href && ( + + {t("message.openSource")} + + )} +
+ )} +
+ ); +} + +export interface AssistantSourcesProps { + readonly messageId: string; + readonly sources: readonly SourceRef[]; + readonly openN: number | null; + readonly onToggle: (n: number) => void; +} + +/** Compact source rows; one row at a time shows its quote and, for a public page, a link. */ +export function AssistantSources({ messageId, sources, openN, onToggle }: AssistantSourcesProps) { + const { t } = useTranslation("assistant"); + + return ( +
+

{t("message.sources")}

+ {sources.map((source) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/assistant/message-shape.ts b/frontend/src/components/assistant/message-shape.ts index 0b41cdff..e6cbcfd6 100644 --- a/frontend/src/components/assistant/message-shape.ts +++ b/frontend/src/components/assistant/message-shape.ts @@ -1,4 +1,56 @@ +import type { AnswerChecks, SourceRef } from "@/types/api"; + /** The id of a source row, which a `[n]` citation in the same message points at. */ export function sourceAnchorId(messageId: string, n: number): string { return `assistant-src-${messageId}-${n}`; } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toSource(value: unknown): SourceRef | null { + if (!isRecord(value)) return null; + const { n, title, section, snippet, url, layer } = value; + if (typeof n !== "number" || !Number.isInteger(n) || n < 1) return null; + if (typeof title !== "string" || typeof section !== "string") return null; + if (layer !== "public" && layer !== "local") return null; + if (url !== null && typeof url !== "string") return null; + // A stored entry from before snippets existed has none; it shows an empty quote. + return { n, title, section, snippet: typeof snippet === "string" ? snippet : "", url, layer }; +} + +/** + * Storage only checks that `sources` is an array and a stored message can be edited + * by hand, so each entry is checked here: malformed ones and repeated numbers drop out. + */ +export function cleanSources(value: unknown): SourceRef[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const sources: SourceRef[] = []; + for (const entry of value) { + const source = toSource(entry); + if (source && !seen.has(source.n)) { + seen.add(source.n); + sources.push(source); + } + } + return sources; +} + +export function cleanToolsUsed(value: unknown): string[] { + return Array.isArray(value) ? value.filter((tool): tool is string => typeof tool === "string") : []; +} + +/** Only a complete set of checks counts; anything else is treated as "no checks". */ +export function cleanChecks(value: unknown): AnswerChecks | null { + if (!isRecord(value)) return null; + const { citations_valid, numbers_grounded, ungrounded_numbers } = value; + if (typeof citations_valid !== "boolean" || typeof numbers_grounded !== "boolean") return null; + if (!Array.isArray(ungrounded_numbers)) return null; + return { + citations_valid, + numbers_grounded, + ungrounded_numbers: ungrounded_numbers.filter((n): n is string => typeof n === "string"), + }; +} diff --git a/frontend/src/i18n/locales/cs/assistant.json b/frontend/src/i18n/locales/cs/assistant.json index 579ddb3f..000c1e4d 100644 --- a/frontend/src/i18n/locales/cs/assistant.json +++ b/frontend/src/i18n/locales/cs/assistant.json @@ -31,6 +31,35 @@ "hint": "Enter odešle · Shift+Enter nový řádek" }, "message": { - "noSuchSource": "Zdroj neexistuje" + "sources": "Zdroje", + "localCopy": "Místní kopie", + "localHint": "Z neveřejného korpusu (diplomová práce, články). Veřejná stránka není.", + "publicLayer": "Dokumentace", + "openSource": "Otevřít stránku", + "noSuchSource": "Zdroj neexistuje", + "numbersNotConfirmed": "Čísla, která zdroje nepotvrzují: {{numbers}}", + "citationInvalid": "Citace odkazuje na neexistující zdroj.", + "read": "Načteno: {{tools}}", + "tools": { + "search_docs": "dokumentace", + "list_my_projects": "projekty", + "get_project": "projekt", + "get_project_result": "výsledek", + "get_project_opinions": "názory" + }, + "stopped": "Zastaveno", + "stoppedHint": "Částečná odpověď. S další otázkou se neodesílá.", + "retry": "Zkusit znovu", + "errors": { + "rateLimited": "Limit zpráv vyčerpán. Zkuste to znovu za {{minutes}} min.", + "rateLimitedGeneric": "Limit zpráv vyčerpán. Zkuste to znovu později.", + "unavailable": "Asistent je dočasně nedostupný.", + "unavailableHint": "Model neodpovídá.", + "notFound": "Tento projekt není dostupný.", + "notFoundHint": "Mohl být smazán nebo už nejste jeho členem.", + "generic": "Něco se pokazilo. Zkuste to prosím znovu.", + "cut": "Spojení se přerušilo před dokončením odpovědi.", + "cutHint": "Co dorazilo, je výše. S další otázkou se neodesílá." + } } } diff --git a/frontend/src/i18n/locales/en/assistant.json b/frontend/src/i18n/locales/en/assistant.json index f5371510..d2e29b22 100644 --- a/frontend/src/i18n/locales/en/assistant.json +++ b/frontend/src/i18n/locales/en/assistant.json @@ -31,6 +31,35 @@ "hint": "Enter to send · Shift+Enter for a new line" }, "message": { - "noSuchSource": "No such source" + "sources": "Sources", + "localCopy": "Local copy", + "localHint": "From the private corpus (thesis, articles). No public page.", + "publicLayer": "Documentation", + "openSource": "Open the page", + "noSuchSource": "No such source", + "numbersNotConfirmed": "Numbers not confirmed by the sources: {{numbers}}", + "citationInvalid": "A citation points to no source.", + "read": "Read: {{tools}}", + "tools": { + "search_docs": "documentation", + "list_my_projects": "projects", + "get_project": "project", + "get_project_result": "result", + "get_project_opinions": "opinions" + }, + "stopped": "Stopped", + "stoppedHint": "Partial answer. Not sent with your next question.", + "retry": "Try again", + "errors": { + "rateLimited": "Message limit reached. Try again in {{minutes}} min.", + "rateLimitedGeneric": "Message limit reached. Try again later.", + "unavailable": "The assistant is temporarily unavailable.", + "unavailableHint": "The model is not responding.", + "notFound": "This project is not available.", + "notFoundHint": "It may have been deleted or you are no longer a member.", + "generic": "Something went wrong. Please try again.", + "cut": "The connection dropped before the answer finished.", + "cutHint": "What arrived is shown above. It is not sent with your next question." + } } } diff --git a/frontend/tests/components/assistant/AssistantMessage.test.tsx b/frontend/tests/components/assistant/AssistantMessage.test.tsx new file mode 100644 index 00000000..a2946b61 --- /dev/null +++ b/frontend/tests/components/assistant/AssistantMessage.test.tsx @@ -0,0 +1,427 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, within } from '@tests/utils'; +import userEvent from '@testing-library/user-event'; +import i18n from '@/i18n'; +import { AssistantMessage } from '@/components/assistant/AssistantMessage'; +import type { AssistantMessage as Message } from '@/components/assistant/useAssistantChat'; +import '@/components/assistant/i18n'; + +const base: Message = { + id: 'm1', + role: 'assistant', + content: 'The answer is 42 [1].', + status: 'done', + createdAt: 1, +}; + +const publicSource = { + n: 1, + title: 'Reading the result', + section: 'Confidence', + snippet: 'Quote from **the page**', + url: 'https://docs.becomify.app/user/reading-the-result', + layer: 'public' as const, +}; +const localSource = { + n: 2, + title: 'BeCoMe article', + section: 'Introduction', + snippet: 'Quote from the article', + url: null, + layer: 'local' as const, +}; + +function renderMessage(message: Partial, onRetry?: () => void) { + return render(); +} + +const rowButton = (name: RegExp) => screen.getByRole('button', { name }); +const originalScrollIntoView = Element.prototype.scrollIntoView; + +describe('AssistantMessage', () => { + afterEach(async () => { + Element.prototype.scrollIntoView = originalScrollIntoView; + await i18n.changeLanguage('en'); + }); + + describe('text', () => { + it("renders the user's own message as plain text, not through markdown", () => { + const { container } = renderMessage({ role: 'user', content: '**not bold**\n# not a heading' }); + + expect(container.querySelector('strong')).toBeNull(); + expect(container.querySelector('h1')).toBeNull(); + expect(container.firstElementChild).toHaveClass('whitespace-pre-wrap'); + expect(container).toHaveTextContent('**not bold**'); + }); + + it('renders an assistant answer through markdown', () => { + renderMessage({ content: 'It is **bold**.' }); + + expect(screen.getByText('bold').tagName).toBe('STRONG'); + }); + + it('renders the content so far for a pending message', () => { + renderMessage({ status: 'pending', content: 'partial words' }); + + expect(screen.getByText('partial words')).toBeInTheDocument(); + }); + }); + + describe('sources', () => { + it('renders compact rows with number, title and section, each with an id', () => { + const { container } = renderMessage({ sources: [publicSource, localSource] }); + + expect(rowButton(/\[1\].*Reading the result.*Confidence/)).toHaveAttribute('aria-expanded', 'false'); + expect(rowButton(/\[2\].*BeCoMe article.*Introduction/)).toBeInTheDocument(); + expect(container.querySelector('#assistant-src-m1-1')).not.toBeNull(); + expect(container.querySelector('#assistant-src-m1-2')).not.toBeNull(); + expect(screen.queryByText(/Quote from/)).not.toBeInTheDocument(); + }); + + it('expands a row on click and collapses it on the second click', async () => { + renderMessage({ sources: [publicSource] }); + const button = rowButton(/Reading the result/); + + await userEvent.click(button); + expect(button).toHaveAttribute('aria-expanded', 'true'); + expect(document.getElementById(button.getAttribute('aria-controls') as string)).toHaveTextContent('Quote from'); + + await userEvent.click(button); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText(/Quote from/)).not.toBeInTheDocument(); + }); + + it('keeps only one row open: opening another closes the first', async () => { + renderMessage({ sources: [publicSource, localSource] }); + + await userEvent.click(rowButton(/Reading the result/)); + await userEvent.click(rowButton(/BeCoMe article/)); + + expect(rowButton(/Reading the result/)).toHaveAttribute('aria-expanded', 'false'); + expect(rowButton(/BeCoMe article/)).toHaveAttribute('aria-expanded', 'true'); + expect(screen.queryByText(/Quote from \*\*the page\*\*/)).not.toBeInTheDocument(); + expect(screen.getByText(/Quote from the article/)).toBeInTheDocument(); + }); + + it('shows the snippet as a plain text node, never as markdown', async () => { + const { container } = renderMessage({ sources: [publicSource] }); + + await userEvent.click(rowButton(/Reading the result/)); + + expect(screen.getByText('Quote from **the page**')).toBeInTheDocument(); + expect(container.querySelector('strong')).toBeNull(); + }); + + it('gives a public https source a safe link when expanded', async () => { + renderMessage({ sources: [publicSource] }); + expect(screen.queryByRole('link', { name: 'Open the page' })).not.toBeInTheDocument(); + + await userEvent.click(rowButton(/Reading the result/)); + + const link = screen.getByRole('link', { name: 'Open the page' }); + expect(link).toHaveAttribute('href', publicSource.url); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + expect(screen.getByText('Documentation')).toBeInTheDocument(); + }); + + it.each([ + ['http:', 'http://docs.becomify.app/user'], + ['javascript:', 'javascript:alert(1)'], + ['a relative path', '/user/reading'], + ['null', null], + ])('renders a public source with a %s url as text, with no link', async (_label, url) => { + renderMessage({ sources: [{ ...publicSource, url }] }); + + await userEvent.click(rowButton(/Reading the result/)); + + expect(screen.queryByRole('link', { name: 'Open the page' })).not.toBeInTheDocument(); + expect(screen.getByText(/Quote from/)).toBeInTheDocument(); + }); + + it('never links a local source, even when it carries a url, and shows the badge and hint', async () => { + renderMessage({ sources: [{ ...localSource, url: 'https://example.com/leak' }] }); + + expect(screen.getByText('Local copy')).toBeInTheDocument(); + await userEvent.click(rowButton(/BeCoMe article/)); + + expect(screen.queryByRole('link', { name: 'Open the page' })).not.toBeInTheDocument(); + expect(document.body.innerHTML).not.toContain('example.com/leak'); + expect(screen.getAllByText(/private corpus/).length).toBeGreaterThan(0); + }); + + it('shows no Local copy badge for a public source', () => { + renderMessage({ sources: [publicSource] }); + + expect(screen.queryByText('Local copy')).not.toBeInTheDocument(); + }); + + it('expands the source and scrolls its row into view when a citation is clicked', async () => { + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + const { container } = renderMessage({ sources: [publicSource] }); + + await userEvent.click(screen.getByRole('link', { name: '[1]' })); + + expect(rowButton(/Reading the result/)).toHaveAttribute('aria-expanded', 'true'); + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }); + expect(scrollIntoView.mock.contexts[0]).toBe(container.querySelector('#assistant-src-m1-1')); + expect(window.location.hash).toBe(''); + }); + + it('still expands the source where scrollIntoView does not exist', async () => { + // Some environments lack the method; the click must neither throw nor stop the expansion. + Object.defineProperty(Element.prototype, 'scrollIntoView', { value: undefined, configurable: true, writable: true }); + const onError = vi.fn((event: ErrorEvent) => event.preventDefault()); + window.addEventListener('error', onError); + renderMessage({ sources: [publicSource] }); + + await userEvent.click(screen.getByRole('link', { name: '[1]' })); + window.removeEventListener('error', onError); + + expect(onError).not.toHaveBeenCalled(); + expect(rowButton(/Reading the result/)).toHaveAttribute('aria-expanded', 'true'); + }); + + it('strikes through a citation that no source backs', () => { + renderMessage({ content: 'Claim [4].', sources: [publicSource] }); + + expect(screen.queryByRole('link', { name: '[4]' })).not.toBeInTheDocument(); + expect(screen.getByText('[4]').closest('s')).not.toBeNull(); + }); + + it('drops malformed sources without throwing and keeps the good ones', () => { + const sources = [ + { ...publicSource, title: undefined }, + { ...publicSource, n: '1' }, + { ...publicSource, n: 5, layer: 'evil' }, + null, + 'x', + localSource, + { ...localSource, title: 'Duplicate of 2' }, + ] as unknown as Message['sources']; + + renderMessage({ sources, content: 'text' }); + + expect(screen.getAllByRole('button')).toHaveLength(1); + expect(rowButton(/BeCoMe article/)).toBeInTheDocument(); + expect(screen.queryByText('Duplicate of 2')).not.toBeInTheDocument(); + }); + + it('treats a source stored without a snippet as an empty quote', async () => { + const older: Record = { ...publicSource }; + delete older.snippet; + renderMessage({ sources: [older as unknown as typeof publicSource] }); + + await userEvent.click(rowButton(/Reading the result/)); + + expect(screen.getByRole('link', { name: 'Open the page' })).toBeInTheDocument(); + }); + + it('renders no sources block for a non-array value', () => { + renderMessage({ sources: 'nope' as unknown as Message['sources'] }); + + expect(screen.queryByText('Sources')).not.toBeInTheDocument(); + }); + + it('renders no sources block when there are none', () => { + renderMessage({ sources: [] }); + + expect(screen.queryByText('Sources')).not.toBeInTheDocument(); + }); + }); + + describe('checks', () => { + it('shows one line with the numbers when numbers are not grounded', () => { + renderMessage({ checks: { citations_valid: true, numbers_grounded: false, ungrounded_numbers: ['17', '4.5'] } }); + + expect(screen.getByText('Numbers not confirmed by the sources: 17, 4.5')).toBeInTheDocument(); + expect(screen.queryByText(/points to no source/)).not.toBeInTheDocument(); + }); + + it('shows the citation line when a citation is invalid', () => { + renderMessage({ checks: { citations_valid: false, numbers_grounded: true, ungrounded_numbers: [] } }); + + expect(screen.getByText('A citation points to no source.')).toBeInTheDocument(); + expect(screen.queryByText(/Numbers not confirmed/)).not.toBeInTheDocument(); + }); + + it('shows both lines when both checks fail', () => { + renderMessage({ checks: { citations_valid: false, numbers_grounded: false, ungrounded_numbers: ['1'] } }); + + expect(screen.getByText(/Numbers not confirmed/)).toBeInTheDocument(); + expect(screen.getByText(/points to no source/)).toBeInTheDocument(); + }); + + it('shows nothing when both checks pass', () => { + renderMessage({ checks: { citations_valid: true, numbers_grounded: true, ungrounded_numbers: [] } }); + + expect(screen.queryByText(/Numbers not confirmed/)).not.toBeInTheDocument(); + expect(screen.queryByText(/points to no source/)).not.toBeInTheDocument(); + }); + + it('ignores checks whose flags are not booleans', () => { + renderMessage({ + checks: { citations_valid: 'false', numbers_grounded: 'false', ungrounded_numbers: [] } as unknown as Message['checks'], + }); + + expect(screen.queryByText(/Numbers not confirmed/)).not.toBeInTheDocument(); + expect(screen.queryByText(/points to no source/)).not.toBeInTheDocument(); + }); + }); + + describe('tools line', () => { + it('names the known tools with short labels', () => { + renderMessage({ toolsUsed: ['get_project', 'get_project_result', 'get_project_opinions'] }); + + expect(screen.getByText('Read: project, result, opinions')).toBeInTheDocument(); + }); + + it('labels the other known tools', () => { + renderMessage({ toolsUsed: ['search_docs', 'list_my_projects'] }); + + expect(screen.getByText('Read: documentation, projects')).toBeInTheDocument(); + }); + + it('shows an unknown tool by its raw name', () => { + renderMessage({ toolsUsed: ['made_up_tool', 'search_docs'] }); + + expect(screen.getByText('Read: made_up_tool, documentation')).toBeInTheDocument(); + }); + + it('keeps string tool names only', () => { + renderMessage({ toolsUsed: ['search_docs', 7, null] as unknown as string[] }); + + expect(screen.getByText('Read: documentation')).toBeInTheDocument(); + }); + + it('shows no line for a non-array value or an empty list', () => { + const { unmount } = renderMessage({ toolsUsed: 5 as unknown as string[] }); + expect(screen.queryByText(/^Read:/)).not.toBeInTheDocument(); + unmount(); + + renderMessage({ toolsUsed: [] }); + expect(screen.queryByText(/^Read:/)).not.toBeInTheDocument(); + }); + }); + + describe('failed turns', () => { + const SECRET = 'Traceback: db password hunter2 at /srv/app.py'; + + it.each([ + [429, 120, 'Message limit reached. Try again in 2 min.'], + [429, 61, 'Message limit reached. Try again in 2 min.'], + [429, 30, 'Message limit reached. Try again in 1 min.'], + [429, undefined, 'Message limit reached. Try again later.'], + [429, 0, 'Message limit reached. Try again later.'], + [429, -5, 'Message limit reached. Try again later.'], + [429, Number.NaN, 'Message limit reached. Try again later.'], + [429, Number.POSITIVE_INFINITY, 'Message limit reached. Try again later.'], + [503, undefined, 'The assistant is temporarily unavailable.'], + [404, undefined, 'This project is not available.'], + [0, undefined, 'Something went wrong. Please try again.'], + [500, undefined, 'Something went wrong. Please try again.'], + ])('code %s with retryAfter %s shows a fixed message and never the detail', (code, retryAfter, text) => { + renderMessage({ status: 'error', content: '', error: { code, detail: SECRET, retryAfter } }); + + expect(screen.getByText(text)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('hunter2'); + expect(document.body).not.toHaveTextContent('Traceback'); + }); + + it('adds a hint under the 503 and 404 messages', () => { + const { unmount } = renderMessage({ status: 'error', content: '', error: { code: 503, detail: 'x' } }); + expect(screen.getByText('The model is not responding.')).toBeInTheDocument(); + unmount(); + + renderMessage({ status: 'error', content: '', error: { code: 404, detail: 'x' } }); + expect(screen.getByText('It may have been deleted or you are no longer a member.')).toBeInTheDocument(); + }); + + it('falls back to the generic message when an error message has no error object', () => { + renderMessage({ status: 'error', content: '' }); + + expect(screen.getByText('Something went wrong. Please try again.')).toBeInTheDocument(); + }); + + it('keeps the partial text of a cut answer, muted, with the interrupted block under it', () => { + const { container } = renderMessage({ status: 'cut', content: 'The best compromise is' }); + + expect(screen.getByText('The best compromise is')).toBeInTheDocument(); + expect(container.querySelector('.opacity-75')).toContainElement(screen.getByText('The best compromise is')); + expect(screen.getByText('The connection dropped before the answer finished.')).toBeInTheDocument(); + }); + + it('renders a cut answer with no content like a failed turn: the block and no empty bubble', () => { + const { container } = renderMessage({ status: 'cut', content: '' }); + + expect(screen.getByText('The connection dropped before the answer finished.')).toBeInTheDocument(); + expect(container.querySelector('.opacity-75')).toBeNull(); + expect(container.querySelector('p')?.textContent).toBe('The connection dropped before the answer finished.'); + }); + + it('offers a retry button on an error and on a cut answer when onRetry is passed', async () => { + const onRetry = vi.fn(); + const { unmount } = renderMessage({ status: 'error', content: '', error: { code: 503, detail: 'x' } }, onRetry); + + await userEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(onRetry).toHaveBeenCalledTimes(1); + unmount(); + + renderMessage({ status: 'cut', content: 'partial' }, onRetry); + await userEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(onRetry).toHaveBeenCalledTimes(2); + }); + + it('shows no retry button without onRetry', () => { + renderMessage({ status: 'error', content: '', error: { code: 503, detail: 'x' } }); + + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + }); + }); + + describe('cancelled', () => { + it('shows the partial text muted with a Stopped note and no red block', () => { + const { container } = renderMessage({ status: 'cancelled', content: 'half an answ' }); + + expect(container.querySelector('.opacity-75')).toContainElement(screen.getByText('half an answ')); + const note = screen.getByText('Stopped'); + expect(note).toBeInTheDocument(); + expect(within(note.parentElement as HTMLElement).getByText(/Not sent with your next question/)).toBeInTheDocument(); + expect(screen.queryByText(/connection dropped/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + }); + + it('shows the note even when nothing arrived before the stop', () => { + renderMessage({ status: 'cancelled', content: '' }); + + expect(screen.getByText('Stopped')).toBeInTheDocument(); + }); + }); + + describe('Czech', () => { + beforeEach(async () => { + await i18n.changeLanguage('cs'); + }); + + it('speaks Czech in the sources, checks, tools and error copy', async () => { + renderMessage({ + sources: [localSource], + checks: { citations_valid: false, numbers_grounded: false, ungrounded_numbers: ['17'] }, + toolsUsed: ['get_project_result'], + }); + + expect(screen.getByText('Zdroje')).toBeInTheDocument(); + expect(screen.getByText('Místní kopie')).toBeInTheDocument(); + expect(screen.getByText('Čísla, která zdroje nepotvrzují: 17')).toBeInTheDocument(); + expect(screen.getByText('Citace odkazuje na neexistující zdroj.')).toBeInTheDocument(); + expect(screen.getByText('Načteno: výsledek')).toBeInTheDocument(); + }); + + it('shows the Czech rate-limit message with the minutes', () => { + renderMessage({ status: 'error', content: '', error: { code: 429, detail: 'x', retryAfter: 600 } }); + + expect(screen.getByText('Limit zpráv vyčerpán. Zkuste to znovu za 10 min.')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/tests/components/assistant/message-shape.test.ts b/frontend/tests/components/assistant/message-shape.test.ts new file mode 100644 index 00000000..393d4d53 --- /dev/null +++ b/frontend/tests/components/assistant/message-shape.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { + cleanChecks, + cleanSources, + cleanToolsUsed, + sourceAnchorId, +} from '@/components/assistant/message-shape'; + +const good = { + n: 1, + title: 'Reading the result', + section: 'Confidence', + snippet: 'Quote', + url: 'https://docs.becomify.app/user/reading-the-result', + layer: 'public', +}; + +describe('sourceAnchorId', () => { + it('names the row after the message and the source number', () => { + expect(sourceAnchorId('m1', 3)).toBe('assistant-src-m1-3'); + }); +}); + +describe('cleanSources', () => { + it('keeps a well-formed source as it is', () => { + expect(cleanSources([good])).toEqual([good]); + }); + + it('keeps a local source with a null url', () => { + const local = { ...good, n: 2, url: null, layer: 'local' }; + + expect(cleanSources([local])).toEqual([local]); + }); + + it('treats a missing snippet as empty', () => { + const older: Record = { ...good }; + delete older.snippet; + + expect(cleanSources([older])).toEqual([{ ...good, snippet: '' }]); + }); + + it.each([ + ['not an array', 'nope'], + ['null', null], + ['an object', { 0: good }], + ])('returns nothing for %s', (_label, value) => { + expect(cleanSources(value)).toEqual([]); + }); + + it.each([ + ['null entry', null], + ['string entry', 'x'], + ['array entry', [good]], + ['n as a string', { ...good, n: '1' }], + ['n zero', { ...good, n: 0 }], + ['n negative', { ...good, n: -2 }], + ['n fractional', { ...good, n: 1.5 }], + ['n NaN', { ...good, n: Number.NaN }], + ['missing title', { ...good, title: undefined }], + ['numeric section', { ...good, section: 4 }], + ['unknown layer', { ...good, layer: 'evil' }], + ['numeric url', { ...good, url: 5 }], + ['undefined url', { ...good, url: undefined }], + ])('drops an entry with %s', (_label, entry) => { + expect(cleanSources([entry, { ...good, n: 9 }])).toEqual([{ ...good, n: 9 }]); + }); + + it('drops a second source with a number already taken', () => { + const second = { ...good, title: 'Second' }; + + expect(cleanSources([good, second])).toEqual([good]); + }); +}); + +describe('cleanToolsUsed', () => { + it('keeps string names only', () => { + expect(cleanToolsUsed(['search_docs', 3, null, 'get_project'])).toEqual(['search_docs', 'get_project']); + }); + + it.each([undefined, null, 'search_docs', 7, {}])('returns nothing for %s', (value) => { + expect(cleanToolsUsed(value)).toEqual([]); + }); +}); + +describe('cleanChecks', () => { + it('keeps complete checks and the string numbers', () => { + expect( + cleanChecks({ citations_valid: false, numbers_grounded: false, ungrounded_numbers: ['17', 4, '9'] }) + ).toEqual({ citations_valid: false, numbers_grounded: false, ungrounded_numbers: ['17', '9'] }); + }); + + it.each([ + ['undefined', undefined], + ['an array', []], + ['string flags', { citations_valid: 'true', numbers_grounded: 'false', ungrounded_numbers: [] }], + ['one flag missing', { numbers_grounded: true, ungrounded_numbers: [] }], + ['the other flag missing', { citations_valid: true, ungrounded_numbers: [] }], + ['numbers not an array', { citations_valid: true, numbers_grounded: true, ungrounded_numbers: '17' }], + ['numbers missing', { citations_valid: true, numbers_grounded: true }], + ])('ignores checks with %s', (_label, value) => { + expect(cleanChecks(value)).toBeNull(); + }); +}); From af610b08e09a75d3e655f02437e93a2e84d0a156 Mon Sep 17 00:00:00 2001 From: Katya Date: Fri, 9 Oct 2026 23:24:43 +0200 Subject: [PATCH 2/5] fix: tighten the assistant message failure and source states --- .../components/assistant/AssistantMessage.tsx | 30 +++++++---- .../assistant/AssistantMessage.test.tsx | 54 +++++++++++++++++-- 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/assistant/AssistantMessage.tsx b/frontend/src/components/assistant/AssistantMessage.tsx index 3b44b671..56f3f8b9 100644 --- a/frontend/src/components/assistant/AssistantMessage.tsx +++ b/frontend/src/components/assistant/AssistantMessage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertTriangle, Info, OctagonX } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -19,6 +19,8 @@ const KNOWN_TOOLS = [ interface FailureBlockProps { readonly cut: boolean; + /** Whether some of the answer is on screen above the block. */ + readonly hasText: boolean; readonly error?: AssistantMessageError; readonly onRetry?: () => void; } @@ -28,14 +30,14 @@ interface FailureBlockProps { * from fixed copy: `error.detail` is whatever the server or the network said, and is * never shown. */ -function FailureBlock({ cut, error, onRetry }: FailureBlockProps) { +function FailureBlock({ cut, hasText, error, onRetry }: FailureBlockProps) { const { t } = useTranslation("assistant"); let title = t("message.errors.generic"); let hint: string | null = null; if (cut) { title = t("message.errors.cut"); - hint = t("message.errors.cutHint"); + hint = hasText ? t("message.errors.cutHint") : null; } else if (error?.code === 429) { const seconds = error.retryAfter; title = @@ -104,6 +106,8 @@ export interface AssistantMessageProps { function AssistantAnswer({ message, onRetry }: AssistantMessageProps) { const { t } = useTranslation("assistant"); const [openN, setOpenN] = useState(null); + // A fresh object per citation click, so citing the row that is already open scrolls again. + const [scrollRequest, setScrollRequest] = useState<{ readonly n: number } | null>(null); const sources = useMemo(() => cleanSources(message.sources), [message.sources]); const sourceNumbers = useMemo(() => sources.map((source) => source.n), [sources]); const tools = cleanToolsUsed(message.toolsUsed); @@ -111,11 +115,19 @@ function AssistantAnswer({ message, onRetry }: AssistantMessageProps) { const { status } = message; const partial = status === "error" || status === "cut" || status === "cancelled"; + const hasText = message.content.trim() !== ""; - function handleCite(n: number) { + const messageId = message.id; + const handleCite = useCallback((n: number) => { setOpenN(n); - document.getElementById(sourceAnchorId(message.id, n))?.scrollIntoView?.({ block: "nearest" }); - } + setScrollRequest({ n }); + }, []); + + // After the render that opened the row, so its detail exists when the scroll measures it. + useEffect(() => { + if (scrollRequest === null) return; + document.getElementById(sourceAnchorId(messageId, scrollRequest.n))?.scrollIntoView?.({ block: "nearest" }); + }, [scrollRequest, messageId]); function handleToggle(n: number) { setOpenN((current) => (current === n ? null : n)); @@ -123,10 +135,10 @@ function AssistantAnswer({ message, onRetry }: AssistantMessageProps) { return (
- {message.content.trim() !== "" && ( + {hasText && (
@@ -146,7 +158,7 @@ function AssistantAnswer({ message, onRetry }: AssistantMessageProps) {

)} {(status === "error" || status === "cut") && ( - + )} {checks && } {sources.length > 0 && ( diff --git a/frontend/tests/components/assistant/AssistantMessage.test.tsx b/frontend/tests/components/assistant/AssistantMessage.test.tsx index a2946b61..92851a0a 100644 --- a/frontend/tests/components/assistant/AssistantMessage.test.tsx +++ b/frontend/tests/components/assistant/AssistantMessage.test.tsx @@ -60,6 +60,14 @@ describe('AssistantMessage', () => { expect(screen.getByText('bold').tagName).toBe('STRONG'); }); + it('keeps the line breaks of a fenced code block', () => { + const { container } = renderMessage({ content: '```\nline1\nline2\n```' }); + + const code = container.querySelector('code'); + expect(code?.textContent).toBe('line1\nline2\n'); + expect(code?.closest('[class*="[&_code]:whitespace-pre-wrap"]')).not.toBeNull(); + }); + it('renders the content so far for a pending message', () => { renderMessage({ status: 'pending', content: 'partial words' }); @@ -112,6 +120,16 @@ describe('AssistantMessage', () => { expect(container.querySelector('strong')).toBeNull(); }); + it('shows a snippet with HTML as literal text, not as an element', async () => { + const snippet = ''; + const { container } = renderMessage({ sources: [{ ...publicSource, snippet }] }); + + await userEvent.click(rowButton(/Reading the result/)); + + expect(screen.getByText(snippet)).toBeInTheDocument(); + expect(container.querySelector('img')).toBeNull(); + }); + it('gives a public https source a safe link when expanded', async () => { renderMessage({ sources: [publicSource] }); expect(screen.queryByRole('link', { name: 'Open the page' })).not.toBeInTheDocument(); @@ -169,6 +187,19 @@ describe('AssistantMessage', () => { expect(window.location.hash).toBe(''); }); + it('scrolls only once the detail exists, and again when the open row is cited again', async () => { + const detailAtCall: boolean[] = []; + Element.prototype.scrollIntoView = vi.fn(() => { + detailAtCall.push(document.getElementById('assistant-src-m1-1-detail') !== null); + }); + renderMessage({ sources: [publicSource] }); + + await userEvent.click(screen.getByRole('link', { name: '[1]' })); + await userEvent.click(screen.getByRole('link', { name: '[1]' })); + + expect(detailAtCall).toEqual([true, true]); + }); + it('still expands the source where scrollIntoView does not exist', async () => { // Some environments lack the method; the click must neither throw nor stop the expansion. Object.defineProperty(Element.prototype, 'scrollIntoView', { value: undefined, configurable: true, writable: true }); @@ -352,14 +383,28 @@ describe('AssistantMessage', () => { expect(screen.getByText('The connection dropped before the answer finished.')).toBeInTheDocument(); }); - it('renders a cut answer with no content like a failed turn: the block and no empty bubble', () => { - const { container } = renderMessage({ status: 'cut', content: '' }); + it.each(['', ' \n'])('renders a cut answer with content %j as the block alone: no wrapper, no hint', (content) => { + const { container } = renderMessage({ status: 'cut', content }); expect(screen.getByText('The connection dropped before the answer finished.')).toBeInTheDocument(); + expect(screen.queryByText(/What arrived is shown above/)).not.toBeInTheDocument(); expect(container.querySelector('.opacity-75')).toBeNull(); expect(container.querySelector('p')?.textContent).toBe('The connection dropped before the answer finished.'); }); + it('shows the "what arrived" hint only when a cut answer has text', () => { + renderMessage({ status: 'cut', content: 'partial' }); + + expect(screen.getByText(/What arrived is shown above/)).toBeInTheDocument(); + }); + + it('keeps the partial text of an error turn, muted, above the block', () => { + const { container } = renderMessage({ status: 'error', content: 'half', error: { code: 503, detail: 'x' } }); + + expect(container.querySelector('.opacity-75')).toContainElement(screen.getByText('half')); + expect(screen.getByText('The assistant is temporarily unavailable.')).toBeInTheDocument(); + }); + it('offers a retry button on an error and on a cut answer when onRetry is passed', async () => { const onRetry = vi.fn(); const { unmount } = renderMessage({ status: 'error', content: '', error: { code: 503, detail: 'x' } }, onRetry); @@ -392,10 +437,11 @@ describe('AssistantMessage', () => { expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); }); - it('shows the note even when nothing arrived before the stop', () => { - renderMessage({ status: 'cancelled', content: '' }); + it.each(['', ' \n'])('shows the note and no wrapper when the content is %j', (content) => { + const { container } = renderMessage({ status: 'cancelled', content }); expect(screen.getByText('Stopped')).toBeInTheDocument(); + expect(container.querySelector('.opacity-75')).toBeNull(); }); }); From 5e5939fa1465589322f9816280a1456280233250 Mon Sep 17 00:00:00 2001 From: Katya Date: Fri, 9 Oct 2026 23:43:20 +0200 Subject: [PATCH 3/5] fix: polish the assistant message empty and pending states --- .../components/assistant/AssistantMessage.tsx | 10 ++- .../components/assistant/AssistantSources.tsx | 2 +- .../src/components/assistant/message-shape.ts | 2 +- frontend/src/i18n/locales/cs/assistant.json | 1 + frontend/src/i18n/locales/en/assistant.json | 1 + .../assistant/AssistantMessage.test.tsx | 90 ++++++++++--------- .../assistant/message-shape.test.ts | 1 + 7 files changed, 58 insertions(+), 49 deletions(-) diff --git a/frontend/src/components/assistant/AssistantMessage.tsx b/frontend/src/components/assistant/AssistantMessage.tsx index 56f3f8b9..a4596f63 100644 --- a/frontend/src/components/assistant/AssistantMessage.tsx +++ b/frontend/src/components/assistant/AssistantMessage.tsx @@ -72,7 +72,11 @@ function ChecksLines({ checks }: { readonly checks: AnswerChecks }) { const { t } = useTranslation("assistant"); const lines: string[] = []; if (!checks.numbers_grounded) { - lines.push(t("message.numbersNotConfirmed", { numbers: checks.ungrounded_numbers.join(", ") })); + lines.push( + checks.ungrounded_numbers.length > 0 + ? t("message.numbersNotConfirmed", { numbers: checks.ungrounded_numbers.join(", ") }) + : t("message.numbersNotConfirmedGeneric"), + ); } if (!checks.citations_valid) { lines.push(t("message.citationInvalid")); @@ -145,7 +149,7 @@ function AssistantAnswer({ message, onRetry }: AssistantMessageProps) {
@@ -154,7 +158,7 @@ function AssistantAnswer({ message, onRetry }: AssistantMessageProps) {

)} {(status === "error" || status === "cut") && ( diff --git a/frontend/src/components/assistant/AssistantSources.tsx b/frontend/src/components/assistant/AssistantSources.tsx index 5f0cf6b6..f63ee463 100644 --- a/frontend/src/components/assistant/AssistantSources.tsx +++ b/frontend/src/components/assistant/AssistantSources.tsx @@ -31,7 +31,7 @@ function SourceRow({ messageId, source, open, onToggle }: SourceRowProps) { [{source.n}] {source.title} - · {source.section} + {source.section !== "" && · {source.section}} {isLocal && ( diff --git a/frontend/src/components/assistant/message-shape.ts b/frontend/src/components/assistant/message-shape.ts index e6cbcfd6..18600f0f 100644 --- a/frontend/src/components/assistant/message-shape.ts +++ b/frontend/src/components/assistant/message-shape.ts @@ -12,7 +12,7 @@ function isRecord(value: unknown): value is Record { function toSource(value: unknown): SourceRef | null { if (!isRecord(value)) return null; const { n, title, section, snippet, url, layer } = value; - if (typeof n !== "number" || !Number.isInteger(n) || n < 1) return null; + if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) return null; if (typeof title !== "string" || typeof section !== "string") return null; if (layer !== "public" && layer !== "local") return null; if (url !== null && typeof url !== "string") return null; diff --git a/frontend/src/i18n/locales/cs/assistant.json b/frontend/src/i18n/locales/cs/assistant.json index 000c1e4d..0d898278 100644 --- a/frontend/src/i18n/locales/cs/assistant.json +++ b/frontend/src/i18n/locales/cs/assistant.json @@ -38,6 +38,7 @@ "openSource": "Otevřít stránku", "noSuchSource": "Zdroj neexistuje", "numbersNotConfirmed": "Čísla, která zdroje nepotvrzují: {{numbers}}", + "numbersNotConfirmedGeneric": "Některá čísla zdroje nepotvrzují.", "citationInvalid": "Citace odkazuje na neexistující zdroj.", "read": "Načteno: {{tools}}", "tools": { diff --git a/frontend/src/i18n/locales/en/assistant.json b/frontend/src/i18n/locales/en/assistant.json index d2e29b22..83e95696 100644 --- a/frontend/src/i18n/locales/en/assistant.json +++ b/frontend/src/i18n/locales/en/assistant.json @@ -38,6 +38,7 @@ "openSource": "Open the page", "noSuchSource": "No such source", "numbersNotConfirmed": "Numbers not confirmed by the sources: {{numbers}}", + "numbersNotConfirmedGeneric": "Some numbers are not confirmed by the sources.", "citationInvalid": "A citation points to no source.", "read": "Read: {{tools}}", "tools": { diff --git a/frontend/tests/components/assistant/AssistantMessage.test.tsx b/frontend/tests/components/assistant/AssistantMessage.test.tsx index 92851a0a..846e8cfa 100644 --- a/frontend/tests/components/assistant/AssistantMessage.test.tsx +++ b/frontend/tests/components/assistant/AssistantMessage.test.tsx @@ -111,23 +111,16 @@ describe('AssistantMessage', () => { expect(screen.getByText(/Quote from the article/)).toBeInTheDocument(); }); - it('shows the snippet as a plain text node, never as markdown', async () => { - const { container } = renderMessage({ sources: [publicSource] }); - - await userEvent.click(rowButton(/Reading the result/)); - - expect(screen.getByText('Quote from **the page**')).toBeInTheDocument(); - expect(container.querySelector('strong')).toBeNull(); - }); - - it('shows a snippet with HTML as literal text, not as an element', async () => { - const snippet = ''; + it.each([ + ['markdown', 'Quote from **the page**', 'strong'], + ['HTML', '', 'img'], + ])('shows a snippet with %s as literal text, never as an element', async (_label, snippet, tag) => { const { container } = renderMessage({ sources: [{ ...publicSource, snippet }] }); await userEvent.click(rowButton(/Reading the result/)); expect(screen.getByText(snippet)).toBeInTheDocument(); - expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector(tag)).toBeNull(); }); it('gives a public https source a safe link when expanded', async () => { @@ -221,6 +214,24 @@ describe('AssistantMessage', () => { expect(screen.getByText('[4]').closest('s')).not.toBeNull(); }); + it.each(['pending', 'cut', 'cancelled', 'error'] as const)( + 'leaves a [1] in a %s answer as plain text, since its sources are not known', + (status) => { + const { container } = renderMessage({ status, content: 'See [1].' }); + + expect(container.querySelector('s')).toBeNull(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('See [1].'); + expect(container).not.toHaveTextContent('No such source'); + }, + ); + + it('shows a source row without a dangling separator when its section is empty', () => { + renderMessage({ sources: [{ ...publicSource, section: '' }] }); + + expect(rowButton(/Reading the result/).textContent).toBe('[1]Reading the result'); + }); + it('drops malformed sources without throwing and keeps the good ones', () => { const sources = [ { ...publicSource, title: undefined }, @@ -242,21 +253,16 @@ describe('AssistantMessage', () => { it('treats a source stored without a snippet as an empty quote', async () => { const older: Record = { ...publicSource }; delete older.snippet; - renderMessage({ sources: [older as unknown as typeof publicSource] }); + const { container } = renderMessage({ sources: [older as unknown as typeof publicSource] }); await userEvent.click(rowButton(/Reading the result/)); expect(screen.getByRole('link', { name: 'Open the page' })).toBeInTheDocument(); + expect(container.querySelector('q')).toBeNull(); }); - it('renders no sources block for a non-array value', () => { - renderMessage({ sources: 'nope' as unknown as Message['sources'] }); - - expect(screen.queryByText('Sources')).not.toBeInTheDocument(); - }); - - it('renders no sources block when there are none', () => { - renderMessage({ sources: [] }); + it.each(['nope', []])('renders no sources block for %j', (sources) => { + renderMessage({ sources: sources as Message['sources'] }); expect(screen.queryByText('Sources')).not.toBeInTheDocument(); }); @@ -284,34 +290,34 @@ describe('AssistantMessage', () => { expect(screen.getByText(/points to no source/)).toBeInTheDocument(); }); - it('shows nothing when both checks pass', () => { - renderMessage({ checks: { citations_valid: true, numbers_grounded: true, ungrounded_numbers: [] } }); + it.each([ + ['both checks pass', { citations_valid: true, numbers_grounded: true, ungrounded_numbers: [] }], + ['the flags are not booleans', { citations_valid: 'false', numbers_grounded: 'false', ungrounded_numbers: [] }], + ])('shows nothing when %s', (_label, checks) => { + renderMessage({ checks: checks as unknown as Message['checks'] }); expect(screen.queryByText(/Numbers not confirmed/)).not.toBeInTheDocument(); expect(screen.queryByText(/points to no source/)).not.toBeInTheDocument(); }); - it('ignores checks whose flags are not booleans', () => { - renderMessage({ - checks: { citations_valid: 'false', numbers_grounded: 'false', ungrounded_numbers: [] } as unknown as Message['checks'], - }); + it.each([ + ['en', 'Some numbers are not confirmed by the sources.'], + ['cs', 'Některá čísla zdroje nepotvrzují.'], + ])('shows a line without a list when numbers are not grounded but none are named (%s)', async (lang, text) => { + await i18n.changeLanguage(lang); + renderMessage({ checks: { citations_valid: true, numbers_grounded: false, ungrounded_numbers: [] } }); - expect(screen.queryByText(/Numbers not confirmed/)).not.toBeInTheDocument(); - expect(screen.queryByText(/points to no source/)).not.toBeInTheDocument(); + expect(screen.getByText(text)).toBeInTheDocument(); }); }); describe('tools line', () => { it('names the known tools with short labels', () => { - renderMessage({ toolsUsed: ['get_project', 'get_project_result', 'get_project_opinions'] }); - - expect(screen.getByText('Read: project, result, opinions')).toBeInTheDocument(); - }); - - it('labels the other known tools', () => { - renderMessage({ toolsUsed: ['search_docs', 'list_my_projects'] }); + renderMessage({ + toolsUsed: ['search_docs', 'list_my_projects', 'get_project', 'get_project_result', 'get_project_opinions'], + }); - expect(screen.getByText('Read: documentation, projects')).toBeInTheDocument(); + expect(screen.getByText('Read: documentation, projects, project, result, opinions')).toBeInTheDocument(); }); it('shows an unknown tool by its raw name', () => { @@ -381,6 +387,7 @@ describe('AssistantMessage', () => { expect(screen.getByText('The best compromise is')).toBeInTheDocument(); expect(container.querySelector('.opacity-75')).toContainElement(screen.getByText('The best compromise is')); expect(screen.getByText('The connection dropped before the answer finished.')).toBeInTheDocument(); + expect(screen.getByText(/What arrived is shown above/)).toBeInTheDocument(); }); it.each(['', ' \n'])('renders a cut answer with content %j as the block alone: no wrapper, no hint', (content) => { @@ -392,12 +399,6 @@ describe('AssistantMessage', () => { expect(container.querySelector('p')?.textContent).toBe('The connection dropped before the answer finished.'); }); - it('shows the "what arrived" hint only when a cut answer has text', () => { - renderMessage({ status: 'cut', content: 'partial' }); - - expect(screen.getByText(/What arrived is shown above/)).toBeInTheDocument(); - }); - it('keeps the partial text of an error turn, muted, above the block', () => { const { container } = renderMessage({ status: 'error', content: 'half', error: { code: 503, detail: 'x' } }); @@ -437,10 +438,11 @@ describe('AssistantMessage', () => { expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); }); - it.each(['', ' \n'])('shows the note and no wrapper when the content is %j', (content) => { + it.each(['', ' \n'])('shows Stopped without the partial-answer hint or a wrapper when the content is %j', (content) => { const { container } = renderMessage({ status: 'cancelled', content }); expect(screen.getByText('Stopped')).toBeInTheDocument(); + expect(screen.queryByText(/Partial answer/)).not.toBeInTheDocument(); expect(container.querySelector('.opacity-75')).toBeNull(); }); }); diff --git a/frontend/tests/components/assistant/message-shape.test.ts b/frontend/tests/components/assistant/message-shape.test.ts index 393d4d53..e84b292f 100644 --- a/frontend/tests/components/assistant/message-shape.test.ts +++ b/frontend/tests/components/assistant/message-shape.test.ts @@ -55,6 +55,7 @@ describe('cleanSources', () => { ['n zero', { ...good, n: 0 }], ['n negative', { ...good, n: -2 }], ['n fractional', { ...good, n: 1.5 }], + ['n beyond the safe integers', { ...good, n: 1e21 }], ['n NaN', { ...good, n: Number.NaN }], ['missing title', { ...good, title: undefined }], ['numeric section', { ...good, section: 4 }], From 24b07eb892ec2fe6bcdf14cf8374c62a48c2909d Mon Sep 17 00:00:00 2001 From: Katya Date: Sat, 10 Oct 2026 00:33:00 +0200 Subject: [PATCH 4/5] test: harden the assistant message citation and detail tests --- .../assistant/AssistantMarkdown.test.tsx | 18 ++++++++++++++++++ .../assistant/AssistantMessage.test.tsx | 8 ++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/tests/components/assistant/AssistantMarkdown.test.tsx b/frontend/tests/components/assistant/AssistantMarkdown.test.tsx index 88d2d396..bdede4f1 100644 --- a/frontend/tests/components/assistant/AssistantMarkdown.test.tsx +++ b/frontend/tests/components/assistant/AssistantMarkdown.test.tsx @@ -269,6 +269,24 @@ describe('AssistantMarkdown', () => { expect(container.textContent).toBe('a [1] b [2] c'); }); + it.each([ + ['See [1][2].', ['[1]', '[2]']], + ['[1] opens the line', ['[1]']], + ])('turns the citations in %j into anchors in order', (content, names) => { + renderMarkdown(content, { sourceNumbers: [1, 2] }); + + expect(screen.getAllByRole('link').map((a) => a.textContent)).toEqual(names); + }); + + it('keeps the citation anchor node while the content grows', () => { + const { rerender } = renderMarkdown('A [1].', { sourceNumbers: [1] }); + const cite = screen.getByRole('link', { name: '[1]' }); + + rerender(); + + expect(screen.getByRole('link', { name: '[1]' })).toBe(cite); + }); + it('leaves [1] literal inside inline code', () => { renderMarkdown('use `arr[1]` here', { sourceNumbers: [1] }); diff --git a/frontend/tests/components/assistant/AssistantMessage.test.tsx b/frontend/tests/components/assistant/AssistantMessage.test.tsx index 846e8cfa..73560446 100644 --- a/frontend/tests/components/assistant/AssistantMessage.test.tsx +++ b/frontend/tests/components/assistant/AssistantMessage.test.tsx @@ -217,10 +217,10 @@ describe('AssistantMessage', () => { it.each(['pending', 'cut', 'cancelled', 'error'] as const)( 'leaves a [1] in a %s answer as plain text, since its sources are not known', (status) => { - const { container } = renderMessage({ status, content: 'See [1].' }); + const { container } = renderMessage({ status, content: 'See [1].', sources: [publicSource] }); expect(container.querySelector('s')).toBeNull(); - expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: '[1]' })).not.toBeInTheDocument(); expect(container).toHaveTextContent('See [1].'); expect(container).not.toHaveTextContent('No such source'); }, @@ -343,7 +343,7 @@ describe('AssistantMessage', () => { }); describe('failed turns', () => { - const SECRET = 'Traceback: db password hunter2 at /srv/app.py'; + const DETAIL = 'Traceback: db password hunter2 at /srv/app.py'; it.each([ [429, 120, 'Message limit reached. Try again in 2 min.'], @@ -359,7 +359,7 @@ describe('AssistantMessage', () => { [0, undefined, 'Something went wrong. Please try again.'], [500, undefined, 'Something went wrong. Please try again.'], ])('code %s with retryAfter %s shows a fixed message and never the detail', (code, retryAfter, text) => { - renderMessage({ status: 'error', content: '', error: { code, detail: SECRET, retryAfter } }); + renderMessage({ status: 'error', content: '', error: { code, detail: DETAIL, retryAfter } }); expect(screen.getByText(text)).toBeInTheDocument(); expect(document.body).not.toHaveTextContent('hunter2'); From 14be2a2a3cd7293110b847f35e449a4fc6d976f8 Mon Sep 17 00:00:00 2001 From: Katya Date: Sat, 10 Oct 2026 00:54:15 +0200 Subject: [PATCH 5/5] fix: keep the known assistant tool names in a set --- frontend/src/components/assistant/AssistantMessage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/assistant/AssistantMessage.tsx b/frontend/src/components/assistant/AssistantMessage.tsx index a4596f63..cce08257 100644 --- a/frontend/src/components/assistant/AssistantMessage.tsx +++ b/frontend/src/components/assistant/AssistantMessage.tsx @@ -9,13 +9,13 @@ import { AssistantSources } from "./AssistantSources"; import { cleanChecks, cleanSources, cleanToolsUsed, sourceAnchorId } from "./message-shape"; import type { AssistantMessage as AssistantMessageData, AssistantMessageError } from "./useAssistantChat"; -const KNOWN_TOOLS = [ +const KNOWN_TOOLS = new Set([ "search_docs", "list_my_projects", "get_project", "get_project_result", "get_project_opinions", -]; +]); interface FailureBlockProps { readonly cut: boolean; @@ -92,7 +92,7 @@ function ChecksLines({ checks }: { readonly checks: AnswerChecks }) { function ToolsLine({ tools }: { readonly tools: readonly string[] }) { const { t } = useTranslation("assistant"); - const labels = tools.map((tool) => (KNOWN_TOOLS.includes(tool) ? t(`message.tools.${tool}`) : tool)); + const labels = tools.map((tool) => (KNOWN_TOOLS.has(tool) ? t(`message.tools.${tool}`) : tool)); return (