mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
* 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
170 lines
6.5 KiB
TypeScript
170 lines
6.5 KiB
TypeScript
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();
|
|
|
|
// The reported bug, plus the substitutions that share its cause.
|
|
expect(parser.renderInline("(c) (C) (r) (tm) (p)")).toBe("(c) (C) (r) (tm) (p)");
|
|
expect(parser.renderInline("wait for it...")).toBe("wait for it...");
|
|
expect(parser.renderInline("a -- b")).toBe("a -- b");
|
|
// Smart quotes are off too: a curled quote is not pasteable into a shell.
|
|
expect(parser.renderInline(`run --name="my repo"`)).toBe("run --name="my repo"");
|
|
expect(parser.renderInline("it's fine")).toBe("it's fine");
|
|
});
|
|
|
|
it("allows file:// links, unlike every other parser", () => {
|
|
const parser = createAssistantMarkdownParser();
|
|
|
|
expect(parser.render("[open](file:///tmp/a.ts)")).toContain('href="file:///tmp/a.ts"');
|
|
});
|
|
|
|
it("still rejects javascript: links", () => {
|
|
const parser = createAssistantMarkdownParser();
|
|
|
|
expect(parser.render("[x](javascript:alert(1))")).not.toContain("href");
|
|
});
|
|
});
|