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
This commit is contained in:
Mohamed Boudra
2026-09-12 08:49:25 +02:00
committed by GitHub
parent a7ce602c83
commit 2d7733cd65
6 changed files with 411 additions and 2 deletions
@@ -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);
});
});
@@ -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<void>,
): Promise<void> {
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<void> {
await agent.client.sendAgentMessage(agent.agentId, "Show the formatted streaming response.");
}
export async function expectUnfinishedBold(page: Page): Promise<void> {
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<void> {
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<void> {
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectCompletedMarkdown(page);
await captureMarkdown(page, testInfo, "completed-markdown");
}
export async function expectReloadedMarkdown(page: Page): Promise<void> {
await page.reload({ waitUntil: "domcontentloaded" });
await expectCompletedMarkdown(page);
}
async function captureMarkdown(page: Page, testInfo: TestInfo, name: string): Promise<void> {
await testInfo.attach(name, {
body: await page.screenshot({ path: testInfo.outputPath(`${name}.png`) }),
contentType: "image/png",
});
}
async function expectCompletedMarkdown(page: Page): Promise<void> {
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",
);
}
+9 -1
View File
@@ -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({
<MemoizedMarkdownBlock
text={block}
rules={markdownRules}
parser={markdownParser}
parser={
phase === "streaming" && index === keyedBlocks.length - 1
? streamingMarkdownParser
: markdownParser
}
onLinkPress={handleMarkdownLinkPress}
/>
</AssistantMessageBlockContainer>
@@ -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("<strong>bold</strong>");
}
});
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('<a href="https://example.com/path">docs</a>');
});
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)}</${tag}>`,
);
}
for (let length = 0; length <= marker.length; length++) {
expect(parser.renderInline(marker + "text" + marker.slice(0, length))).toBe(
`<${tag}>text</${tag}>`,
);
}
});
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("<em><strong>both</strong></em>");
}
expect(parser.renderInline("**bold and *italic")).toBe(
"<strong>bold and <em>italic</em></strong>",
);
expect(parser.renderInline("*italic and **bold")).toBe(
"<em>italic and <strong>bold</strong></em>",
);
});
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](<https://example.com/a(b)>)",
"[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("<strong>bold</strong>");
expect(parser.renderInline("[**bold**](https://exam")).toBe("<strong>bold</strong>");
expect(parser.renderInline("**see [docs](https://exam")).toBe("<strong>see docs</strong>");
});
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 <a href="https://example.org">example.com</a>',
);
});
it.each([
"\\*literal",
"\\[literal",
"some_identifier",
"some__identifier",
"`**literal [link](url`",
"``a ` b``",
"**already closed** after",
"[bad](javascript:alert(1))",
"<component",
"$$price",
"20~25",
])("preserves literal and complete inline text: %s", (source) => {
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(
"<ul>\n<li>first</li>\n<li><strong>bold</strong></li>\n</ul>\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 <img src="https://example.com/image.png" alt="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();
@@ -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;
}
@@ -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 && /(?<![\\~])~$/.test(state.src)) {
const last = state.tokens.at(-1);
if (last?.type === "text" && last.content.endsWith("~")) {
last.content = last.content.slice(0, -1);
}
}
const closing = state.push("text", "", 0);
closing.content = String.fromCharCode(delimiter.marker).repeat(
delimiter.marker === 126 ? 2 : 1,
);
delimiter.end = state.delimiters.length;
state.delimiters.push({
...delimiter,
token: state.tokens.length - 1,
end: -1,
open: false,
close: true,
});
}
return true;
}