From 1e3bfa09d4e31e02d109ef2f4b41895dc89a78fe Mon Sep 17 00:00:00 2001 From: lihongyuan99 <64824864+lihongyuan99@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:26:48 +0800 Subject: [PATCH] fix(frontend): read web_fetch titles that start with blank lines or indented headings (#5560) * fix(frontend): read web_fetch titles that start with blank lines or indented headings * docs(frontend): describe the indented-code guard as it actually behaves * fix(frontend): reject mixed code indentation in web-fetch titles --------- Co-authored-by: Willem Jiang --- README.md | 3 + frontend/AGENTS.md | 5 ++ frontend/src/core/utils/markdown.ts | 17 +++--- .../tests/unit/core/utils/markdown.test.ts | 60 +++++++++++++++++++ 4 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 frontend/tests/unit/core/utils/markdown.test.ts diff --git a/README.md b/README.md index 8b2bd309f..4733d174e 100644 --- a/README.md +++ b/README.md @@ -1128,6 +1128,9 @@ empty list is forwarded and imposes no restriction of that kind. See the When using Tavily for `web_fetch`, extracted pages without a title use their URL as the heading; their content remains available to the agent. +Chat tool-step titles accept leading blank lines and up to three spaces before +a page's first H1 heading. Indented code, including mixed spaces and tabs, is +not used as a title; the tool step falls back to the URL. Tavily search and fetch each read `api_key` from their own tool entry in `config.yaml`, falling back to `TAVILY_API_KEY` when omitted. Fetch does not reuse the search entry's key, so search can use a different provider. If you diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 2d6f92529..8cd922271 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -83,6 +83,11 @@ More specific `AGENTS.md` files under `src/` contain the frontend sections split ## Code Style +`core/utils/markdown.ts` reads web-fetch titles from the first nonblank line. +Match zero to three literal spaces before `# ` without trimming indentation; +mixed space/tab code blocks must fall back to the URL. Keep this local to title +extraction rather than changing the shared streamdown fence parser. + Custom Agent `display_name` is an optional Unicode UI label, edited in `AgentSettingsDialog`. Use it with a fallback to `name` for gallery/chat text; keep `name` for React identity, URLs, requests, and runtime `agent_name`. diff --git a/frontend/src/core/utils/markdown.ts b/frontend/src/core/utils/markdown.ts index fb22ebce9..fbef29001 100644 --- a/frontend/src/core/utils/markdown.ts +++ b/frontend/src/core/utils/markdown.ts @@ -1,10 +1,13 @@ +// Converter output can start with blank lines, and CommonMark allows up to three +// literal spaces before an ATX heading. Do not trim code indentation into a title. export function extractTitleFromMarkdown(markdown: string) { - if (markdown.startsWith("# ")) { - let title = markdown.split("\n")[0]!.trim(); - if (title.startsWith("# ")) { - title = title.slice(2).trim(); - } - return title; + const firstLine = markdown.split("\n").find((line) => line.trim() !== ""); + if (firstLine === undefined) { + return undefined; } - return undefined; + const headingPrefix = /^ {0,3}# /.exec(firstLine); + if (!headingPrefix) { + return undefined; + } + return firstLine.slice(headingPrefix[0].length).trim() || undefined; } diff --git a/frontend/tests/unit/core/utils/markdown.test.ts b/frontend/tests/unit/core/utils/markdown.test.ts new file mode 100644 index 000000000..140d64756 --- /dev/null +++ b/frontend/tests/unit/core/utils/markdown.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@rstest/core"; + +import { extractTitleFromMarkdown } from "@/core/utils/markdown"; + +test("reads the title from a leading ATX heading", () => { + expect(extractTitleFromMarkdown("# Real Title\n\nbody")).toBe("Real Title"); +}); + +test("skips blank lines before the first heading", () => { + expect(extractTitleFromMarkdown("\n# Real Title\n\nbody")).toBe("Real Title"); + expect(extractTitleFromMarkdown(" \n\n# Real Title")).toBe("Real Title"); +}); + +test("accepts the up-to-three-space indentation CommonMark allows", () => { + expect(extractTitleFromMarkdown(" # Real Title")).toBe("Real Title"); +}); + +test("ignores an indented code block that starts with a hash", () => { + expect(extractTitleFromMarkdown(" # Not A Title")).toBeUndefined(); + expect(extractTitleFromMarkdown("\t# Not A Title")).toBeUndefined(); +}); + +test.each([" \t", " \t", " \t"])( + "does not treat mixed indentation %j as a heading", + (indent) => { + expect( + extractTitleFromMarkdown(`${indent}# Code comment\n# Later heading`), + ).toBeUndefined(); + expect( + extractTitleFromMarkdown(`\n \n${indent}# Code comment`), + ).toBeUndefined(); + }, +); + +test.each(["", " ", " ", " "])( + "accepts a heading with %j indentation and CRLF line endings", + (indent) => { + expect(extractTitleFromMarkdown(`\r\n${indent}# Real Title\r\nbody`)).toBe( + "Real Title", + ); + }, +); + +test("ignores headings that are not level 1", () => { + expect(extractTitleFromMarkdown("## Section")).toBeUndefined(); + expect(extractTitleFromMarkdown("#NoSpace")).toBeUndefined(); +}); + +test("does not report an empty heading as a title", () => { + expect(extractTitleFromMarkdown("# \n\nbody")).toBeUndefined(); + expect(extractTitleFromMarkdown("#")).toBeUndefined(); +}); + +test("returns undefined when the document has no content", () => { + expect(extractTitleFromMarkdown("")).toBeUndefined(); + expect(extractTitleFromMarkdown(" \n ")).toBeUndefined(); + expect( + extractTitleFromMarkdown("Plain text with no heading"), + ).toBeUndefined(); +});