From 2d7733cd6514a4b3422870163b749395ca2634e6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 08:49:25 +0200 Subject: [PATCH] fix(app): keep incomplete Markdown formatted while streaming (#4742) * fix(app): keep incomplete Markdown formatted while streaming Treat unfinished formatting at the growing assistant-message tail as provisional so delimiters and link destinations do not flash while tokens arrive. Completed messages retain ordinary Markdown parsing. * test(app): express streaming Markdown checks as user actions --- .../e2e/browser/streaming-markdown.spec.ts | 21 +++ .../e2e/support/helpers/streaming-markdown.ts | 81 ++++++++++ packages/app/src/components/message.tsx | 10 +- .../utils/assistant-markdown-parser.test.ts | 141 ++++++++++++++++ .../src/utils/assistant-markdown-parser.ts | 7 +- .../app/src/utils/streaming-markdown/index.ts | 153 ++++++++++++++++++ 6 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 packages/app/e2e/browser/streaming-markdown.spec.ts create mode 100644 packages/app/e2e/support/helpers/streaming-markdown.ts create mode 100644 packages/app/src/utils/streaming-markdown/index.ts diff --git a/packages/app/e2e/browser/streaming-markdown.spec.ts b/packages/app/e2e/browser/streaming-markdown.spec.ts new file mode 100644 index 0000000000..00aa16e7cc --- /dev/null +++ b/packages/app/e2e/browser/streaming-markdown.spec.ts @@ -0,0 +1,21 @@ +import { test } from "../support/fixtures"; +import { + expectFinishedMarkdown, + expectReloadedMarkdown, + expectUnfinishedBold, + expectUnfinishedLink, + requestStreamingMarkdown, + withStreamingMarkdown, +} from "../support/helpers/streaming-markdown"; + +test("formats unfinished Markdown while streaming and preserves the completed rendering", async ({ + page, +}, testInfo) => { + await withStreamingMarkdown(page, testInfo, async (agent) => { + await requestStreamingMarkdown(agent); + await expectUnfinishedBold(page); + await expectUnfinishedLink(page, testInfo); + await expectFinishedMarkdown(page, agent, testInfo); + await expectReloadedMarkdown(page); + }); +}); diff --git a/packages/app/e2e/support/helpers/streaming-markdown.ts b/packages/app/e2e/support/helpers/streaming-markdown.ts new file mode 100644 index 0000000000..144b706f5d --- /dev/null +++ b/packages/app/e2e/support/helpers/streaming-markdown.ts @@ -0,0 +1,81 @@ +import { expect, type Page, type TestInfo } from "@playwright/test"; +import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; + +export async function withStreamingMarkdown( + page: Page, + testInfo: TestInfo, + run: (agent: MockAgentWorkspace) => Promise, +): Promise { + testInfo.setTimeout(120_000); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "streaming-markdown-", + title: "Streaming Markdown", + featureValues: { + mockStreamingAssistantResponse: + "**Bold text stays bold** and [Paseo docs](https://example.com/documentation). Done.", + mockStreamingAssistantIntervalMs: 400, + }, + }); + try { + await openAgentRoute(page, agent); + await run(agent); + } finally { + await agent.cleanup(); + } +} + +export async function requestStreamingMarkdown(agent: MockAgentWorkspace): Promise { + await agent.client.sendAgentMessage(agent.agentId, "Show the formatted streaming response."); +} + +export async function expectUnfinishedBold(page: Page): Promise { + const message = page.getByTestId("assistant-message").last(); + const bold = message.locator('[data-paseo-markdown-tag="strong"]'); + await expect(bold).toContainText("Bold"); + await expect(message).not.toContainText("stays bold"); + await expect(bold).toHaveCSS("font-weight", "500"); + await expect(message).not.toContainText("*"); +} + +export async function expectUnfinishedLink(page: Page, testInfo: TestInfo): Promise { + const message = page.getByTestId("assistant-message").last(); + await expect(message).toContainText("Paseo docs"); + await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0); + await expect(message).not.toContainText("["); + await expect(message).not.toContainText("https:"); + await captureMarkdown(page, testInfo, "unfinished-link"); +} + +export async function expectFinishedMarkdown( + page: Page, + agent: MockAgentWorkspace, + testInfo: TestInfo, +): Promise { + await agent.client.waitForFinish(agent.agentId, 30_000); + await expectCompletedMarkdown(page); + await captureMarkdown(page, testInfo, "completed-markdown"); +} + +export async function expectReloadedMarkdown(page: Page): Promise { + await page.reload({ waitUntil: "domcontentloaded" }); + await expectCompletedMarkdown(page); +} + +async function captureMarkdown(page: Page, testInfo: TestInfo, name: string): Promise { + await testInfo.attach(name, { + body: await page.screenshot({ path: testInfo.outputPath(`${name}.png`) }), + contentType: "image/png", + }); +} + +async function expectCompletedMarkdown(page: Page): Promise { + const message = page.getByTestId("assistant-message").last(); + await expect(message).toHaveText("Bold text stays bold and Paseo docs. Done."); + await expect( + message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")), + ).toHaveAttribute("href", "https://example.com/documentation"); + await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveCSS( + "font-weight", + "500", + ); +} diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 830eb265ea..a8a56dee89 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1501,6 +1501,10 @@ export const AssistantMessage = memo(function AssistantMessage({ }: AssistantMessageProps) { const { t } = useTranslation(); const markdownParser = useMemo(createAssistantMarkdownParser, []); + const streamingMarkdownParser = useMemo( + () => createAssistantMarkdownParser({ streaming: true }), + [], + ); const renderedMessage = useMemo(() => capAssistantMessageForRender(message), [message]); // Paint a paced prefix while the turn is streaming so text arrives at a steady // rate instead of in whatever lumps the daemon's coalescing window produced. @@ -1982,7 +1986,11 @@ export const AssistantMessage = memo(function AssistantMessage({ diff --git a/packages/app/src/utils/assistant-markdown-parser.test.ts b/packages/app/src/utils/assistant-markdown-parser.test.ts index 70d806a1fb..05b1366e1c 100644 --- a/packages/app/src/utils/assistant-markdown-parser.test.ts +++ b/packages/app/src/utils/assistant-markdown-parser.test.ts @@ -2,6 +2,147 @@ import { describe, expect, it } from "vitest"; import { createAssistantMarkdownParser } from "./assistant-markdown-parser"; describe("createAssistantMarkdownParser", () => { + it("keeps bold text bold through every partial closing marker", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + + for (const source of ["**bold", "**bold*", "**bold**"]) { + expect(parser.renderInline(source)).toBe("bold"); + } + }); + + it("shows the growing link label and only links a complete destination", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + const source = "[docs](https://example.com/path)"; + for (let length = 1; length < source.length; length++) { + expect(parser.renderInline(source.slice(0, length))).toBe( + "docs".slice(0, Math.max(0, length - 1)), + ); + } + expect(parser.renderInline(source)).toBe('docs'); + }); + + it.each([ + ["**", "strong"], + ["__", "strong"], + ["*", "em"], + ["_", "em"], + ["~~", "s"], + ["`", "code"], + ["``", "code"], + ])("keeps %s formatting stable as text and closing markers arrive", (marker, tag) => { + const parser = createAssistantMarkdownParser({ streaming: true }); + for (let length = 1; length <= 4; length++) { + expect(parser.renderInline(marker + "text".slice(0, length))).toBe( + `<${tag}>${"text".slice(0, length)}`, + ); + } + for (let length = 0; length <= marker.length; length++) { + expect(parser.renderInline(marker + "text" + marker.slice(0, length))).toBe( + `<${tag}>text`, + ); + } + }); + + it("keeps combined and nested emphasis stable through closing markers", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + for (const closing of ["", "*", "**", "***"]) { + expect(parser.renderInline("***both" + closing)).toBe("both"); + } + expect(parser.renderInline("**bold and *italic")).toBe( + "bold and italic", + ); + expect(parser.renderInline("*italic and **bold")).toBe( + "italic and bold", + ); + }); + + it.each(["*", "**", "***", "_", "__", "~~", "`", "``"])( + "hides an opening %s while waiting for its text", + (marker) => { + expect( + createAssistantMarkdownParser({ streaming: true }).renderInline("hello " + marker), + ).toBe("hello "); + }, + ); + + it.each([ + "[docs](https://example.com/a(b)c)", + '[docs](https://example.com "a title)")', + "[docs]()", + "[docs](file:///tmp/example.ts)", + ])("waits for the entire destination of %s", (source) => { + const parser = createAssistantMarkdownParser({ streaming: true }); + for (let length = 6; length < source.length; length++) { + expect(parser.renderInline(source.slice(0, length))).toBe("docs"); + } + expect(parser.renderInline(source)).toBe(createAssistantMarkdownParser().renderInline(source)); + }); + + it("preserves formatting in incomplete labels and around incomplete links", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + expect(parser.renderInline("[**bold")).toBe("bold"); + expect(parser.renderInline("[**bold**](https://exam")).toBe("bold"); + expect(parser.renderInline("**see [docs](https://exam")).toBe("see docs"); + }); + + it("does not auto-link a URL label before the destination is complete", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + expect(parser.renderInline("Read [example.com](https://exam")).toBe("Read example.com"); + expect(parser.renderInline("Read [example.com](https://example.org)")).toBe( + 'Read example.com', + ); + }); + + it.each([ + "\\*literal", + "\\[literal", + "some_identifier", + "some__identifier", + "`**literal [link](url`", + "``a ` b``", + "**already closed** after", + "[bad](javascript:alert(1))", + " { + expect(createAssistantMarkdownParser({ streaming: true }).renderInline(source)).toBe( + createAssistantMarkdownParser().renderInline(source), + ); + }); + + it.each([ + "```md\n**literal [link](url", + "~~~md\n**literal [link](url", + " **literal [link](url", + "**earlier\n\nplain tail", + "- **earlier\n- plain tail", + ])("leaves literal code and earlier blocks alone: %s", (source) => { + expect(createAssistantMarkdownParser({ streaming: true }).render(source)).toBe( + createAssistantMarkdownParser().render(source), + ); + }); + + it("completes inline formatting inside the final list item", () => { + expect(createAssistantMarkdownParser({ streaming: true }).render("- first\n- **bold")).toBe( + "
    \n
  • first
  • \n
  • bold
  • \n
\n", + ); + }); + + it("hides incomplete images until their source is complete", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + expect(parser.renderInline("before ![alt](https://exam")).toBe("before "); + expect(parser.renderInline("before ![alt](https://example.com/image.png)")).toBe( + 'before alt', + ); + }); + + it("keeps ordinary parsing for completed messages", () => { + const parser = createAssistantMarkdownParser(); + expect(parser.renderInline("**unfinished")).toBe("**unfinished"); + expect(parser.renderInline("[unfinished")).toBe("[unfinished"); + }); + it("renders agent text verbatim", () => { const parser = createAssistantMarkdownParser(); diff --git a/packages/app/src/utils/assistant-markdown-parser.ts b/packages/app/src/utils/assistant-markdown-parser.ts index 5e64fb629f..e1e8f6f38a 100644 --- a/packages/app/src/utils/assistant-markdown-parser.ts +++ b/packages/app/src/utils/assistant-markdown-parser.ts @@ -1,7 +1,8 @@ import type MarkdownIt from "markdown-it"; import { createMarkdownParser } from "@/utils/markdown-parser"; +import { enableStreamingMarkdown } from "@/utils/streaming-markdown"; -export function createAssistantMarkdownParser(): MarkdownIt { +export function createAssistantMarkdownParser({ streaming = false } = {}): MarkdownIt { const parser = createMarkdownParser({ linkify: true }); const defaultValidateLink = parser.validateLink.bind(parser); @@ -10,5 +11,9 @@ export function createAssistantMarkdownParser(): MarkdownIt { parser.validateLink = (url: string) => url.trim().toLowerCase().startsWith("file://") || defaultValidateLink(url); + if (streaming) { + enableStreamingMarkdown(parser); + } + return parser; } diff --git a/packages/app/src/utils/streaming-markdown/index.ts b/packages/app/src/utils/streaming-markdown/index.ts new file mode 100644 index 0000000000..b70b464d76 --- /dev/null +++ b/packages/app/src/utils/streaming-markdown/index.ts @@ -0,0 +1,153 @@ +import type MarkdownIt from "markdown-it"; +import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; + +const STREAMING_TAIL = Symbol("streaming markdown tail"); + +/** Adds provisional inline formatting without changing the source or literal code blocks. */ +export function enableStreamingMarkdown(parser: MarkdownIt): void { + parser.core.ruler.at("inline", (state) => { + const tail = state.tokens.findLast((token) => token.nesting !== -1); + for (const token of state.tokens) { + if (token.type === "inline") { + const env = { ...state.env, [STREAMING_TAIL]: token === tail }; + token.children = []; + state.md.inline.parse(token.content, state.md, env, token.children); + } + } + }); + parser.inline.ruler.before("backticks", "streaming_code", completeCode); + parser.inline.ruler.before("strikethrough", "streaming_marker", hideOpeningMarker); + parser.inline.ruler.after("image", "streaming_link", completeLink); + parser.inline.ruler2.after("balance_pairs", "streaming_emphasis", completeEmphasis); + parser.core.ruler.after("linkify", "streaming_link_text", (state) => { + for (const block of state.tokens) { + for (const token of block.children ?? []) { + if (token.type === "streaming_link_text") token.type = "text"; + } + } + }); +} + +function isStreamingTail(state: StateInline): boolean { + return state.env[STREAMING_TAIL] === true && state.posMax === state.src.length; +} + +function hideOpeningMarker(state: StateInline, silent: boolean): boolean { + if (silent || !isStreamingTail(state)) return false; + if (!/^(?:\*{1,3}|_{1,3}|~{1,2})$/.test(state.src.slice(state.pos))) return false; + if (state.scanDelims(state.pos, state.src[state.pos] !== "_").can_close) return false; + state.pos = state.posMax; + return true; +} + +function completeCode(state: StateInline, silent: boolean): boolean { + if (silent || !isStreamingTail(state) || state.src[state.pos] !== "`") return false; + const runs = /`+/g; + runs.lastIndex = state.pos; + const opening = runs.exec(state.src)!; + let closing: RegExpExecArray | null; + while ((closing = runs.exec(state.src))) { + if (closing[0].length === opening[0].length) return false; + } + let content = state.src.slice(state.pos + opening[0].length); + // A shorter trailing run may be the first characters of the closing marker. + const partialClosing = content.match(/`+$/); + if (partialClosing && partialClosing[0].length < opening[0].length) { + content = content.slice(0, -partialClosing[0].length); + } + if (content) { + const token = state.push("code_inline", "code", 0); + token.markup = opening[0]; + token.content = content.replace(/\n/g, " ").replace(/^ (.+) $/, "$1"); + } + state.pos = state.posMax; + return true; +} + +function completeLink(state: StateInline, silent: boolean): boolean { + if (silent || !isStreamingTail(state)) return false; + const image = state.src[state.pos] === "!"; + const start = state.pos + (image ? 1 : 0); + if (state.src[start] !== "[") return false; + const labelEnd = state.md.helpers.parseLinkLabel(state, start, false); + if (labelEnd !== -1 && labelEnd + 1 < state.posMax) { + if (state.src[labelEnd + 1] !== "(" || hasClosedDestination(state.src, labelEnd + 1)) { + return false; + } + } + const end = state.posMax; + if (!image) { + if (state.pending) state.pushPending(); + const firstLabelToken = state.tokens.length; + state.pos = start + 1; + state.posMax = labelEnd === -1 ? end : labelEnd; + state.level++; + state.md.inline.tokenize(state); + state.level--; + // Keep URL-shaped labels out of the automatic linkifier until the real + // destination arrives. Restore ordinary text before tokens reach the view. + for (let index = firstLabelToken; index < state.tokens.length; index++) { + if (state.tokens[index].type === "text") state.tokens[index].type = "streaming_link_text"; + } + } + state.pos = state.posMax = end; + return true; +} + +function hasClosedDestination(source: string, start: number): boolean { + let depth = 0; + let quote = ""; + for (let index = start; index < source.length; index++) { + const char = source[index]; + if (char === "\\") { + index++; + } else if (quote) { + if (char === quote) quote = ""; + } else if (char === "<") { + quote = ">"; + } else if ((char === '"' || char === "'") && /\s/.test(source[index - 1])) { + quote = char; + } else if (char === "(") { + depth++; + } else if (char === ")" && --depth === 0) { + return true; + } + } + return false; +} + +function completeEmphasis(state: StateInline): boolean { + if (!isStreamingTail(state)) return false; + const open = []; + let closedThrough = -1; + // Reuse markdown-it's escape, word-boundary, nesting and delimiter matching + // decisions. Only unmatched openers outside already-closed spans reach EOF. + for (const [index, delimiter] of state.delimiters.entries()) { + if (delimiter.end !== -1) { + closedThrough = Math.max(closedThrough, delimiter.end); + } else if (index > closedThrough && delimiter.open) { + open.push(delimiter); + } + } + for (const delimiter of open.toReversed()) { + if (delimiter.marker === 126 && /(?