mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
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:
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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 ).toBe("before ");
|
||||
expect(parser.renderInline("before ")).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;
|
||||
}
|
||||
Reference in New Issue
Block a user