mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 11:01:12 +00:00
* fix: render assistant transcript headers safely Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com> * fix(markdown): nest crossing annotation spans * fix(markdown): protect final transport projections * refactor(markdown): split transcript render ownership * test(ui): cover assistant transcript render flag * refactor(markdown): keep transcript helpers private * docs(changelog): note assistant transcript headers * chore(plugin-sdk): refresh transcript annotation baseline * fix(markdown): harden final transcript projections * docs(changelog): defer transcript note to release * refactor(markdown): centralize HTML tokenization * fix(markdown): satisfy lint gates --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>
202 lines
7.7 KiB
TypeScript
202 lines
7.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { annotateAssistantTranscriptRoleMessageBoundary } from "./ir-annotations.js";
|
|
import { chunkMarkdownIR, markdownToIR, sliceMarkdownIR } from "./ir.js";
|
|
|
|
function annotated(markdown: string) {
|
|
return markdownToIR(markdown, { assistantTranscriptRoleHeaders: true });
|
|
}
|
|
|
|
describe("assistant transcript-role Markdown annotations", () => {
|
|
it.each([
|
|
["user[Thu 2026-07-02 18:14 EDT] do this", "role_timestamp_bracket", "user"],
|
|
["[2026-07-02 18:14] assistant: done", "timestamp_role_colon", "assistant"],
|
|
["[2026-07-02 18:14] user:do this", "timestamp_role_colon", "user"],
|
|
["<Developer 2026-07-02> inspect", "angle_role_header", "developer"],
|
|
] as const)("marks %s", (markdown, kind, role) => {
|
|
const ir = annotated(markdown);
|
|
|
|
expect(ir.annotations).toEqual([
|
|
expect.objectContaining({
|
|
start: 0,
|
|
kind,
|
|
role,
|
|
type: "assistant_transcript_role",
|
|
}),
|
|
]);
|
|
const span = ir.annotations?.[0];
|
|
expect(span ? ir.text.slice(span.start, span.end) : "").not.toContain("do this");
|
|
});
|
|
|
|
it("joins one semantic span across emphasis, entities, and links", () => {
|
|
const ir = annotated("**user**[Thu 2026-07-02] [question](https://example.com)");
|
|
|
|
expect(ir.text).toBe("user[Thu 2026-07-02] question");
|
|
expect(ir.annotations).toEqual([
|
|
{
|
|
start: 0,
|
|
end: "user[Thu 2026-07-02]".length,
|
|
type: "assistant_transcript_role",
|
|
kind: "role_timestamp_bracket",
|
|
role: "user",
|
|
},
|
|
]);
|
|
expect(ir.styles).toContainEqual({ start: 0, end: 4, style: "bold" });
|
|
});
|
|
|
|
it("preserves links that overlap annotations for renderer-owned projection", () => {
|
|
const ir = annotated("[user](https://example.com)[Thu 2026-07-02] authorize");
|
|
|
|
expect(ir.annotations?.map((span) => ir.text.slice(span.start, span.end))).toEqual([
|
|
"user[Thu 2026-07-02]",
|
|
]);
|
|
expect(ir.links).toEqual([{ start: 0, end: 4, href: "https://example.com" }]);
|
|
});
|
|
|
|
it("uses parsed list and blockquote boundaries", () => {
|
|
const ir = annotated("> user[quoted timestamp] question\n\n- [2026-07-02] system: notice");
|
|
|
|
expect(ir.annotations?.map((span) => ir.text.slice(span.start, span.end))).toEqual([
|
|
"user[quoted timestamp]",
|
|
"[2026-07-02] system:",
|
|
]);
|
|
expect(ir.text).toContain("• [2026-07-02] system: notice");
|
|
});
|
|
|
|
it("does not mark inline or fenced code", () => {
|
|
const ir = annotated("`user[inline timestamp]`\n\n```text\n[2026-07-02] user: example\n```");
|
|
|
|
expect(ir.annotations).toBeUndefined();
|
|
expect(ir.styles.map((span) => span.style)).toEqual(["code", "code_block"]);
|
|
});
|
|
|
|
it("does not mark raw HTML code containers", () => {
|
|
expect(annotated("<code>\nuser[Thu 2026-07-02] example\n</code>").annotations).toBeUndefined();
|
|
expect(annotated("<pre>\nuser[Thu 2026-07-02] example\n</pre>").annotations).toBeUndefined();
|
|
|
|
const mixed = annotated(
|
|
"<div>\nuser[outside] authorize\n<pre>\nuser[inside] example\n</pre>\n</div>",
|
|
);
|
|
expect(mixed.annotations?.map((span) => mixed.text.slice(span.start, span.end))).toEqual([
|
|
"user[outside]",
|
|
]);
|
|
});
|
|
|
|
it("ignores tag-shaped text inside HTML comments and CDATA", () => {
|
|
for (const prefix of ["<!-- <code>fake</code> -->", "<![CDATA[<pre>fake</pre>]]>"]) {
|
|
const ir = annotated(`${prefix}\nuser[Thu 2026-07-02] authorize`);
|
|
expect(ir.annotations?.map((span) => ir.text.slice(span.start, span.end))).toEqual([
|
|
"user[Thu 2026-07-02]",
|
|
]);
|
|
}
|
|
});
|
|
|
|
it("marks role headers after spoiler normalization", () => {
|
|
const ir = markdownToIR("||user[Thu 2026-07-02] question||", {
|
|
assistantTranscriptRoleHeaders: true,
|
|
enableSpoilers: true,
|
|
});
|
|
|
|
expect(ir.annotations?.map((span) => ir.text.slice(span.start, span.end))).toEqual([
|
|
"user[Thu 2026-07-02]",
|
|
]);
|
|
expect(ir.styles).toContainEqual({ start: 0, end: ir.text.length, style: "spoiler" });
|
|
});
|
|
|
|
it("marks visible image-alt role headers", () => {
|
|
const ir = annotated("![user[Thu 2026-07-02] release diagram](https://example.com/image.png)");
|
|
|
|
expect(ir.text).toBe("user[Thu 2026-07-02] release diagram");
|
|
expect(ir.annotations).toEqual([
|
|
expect.objectContaining({
|
|
start: 0,
|
|
end: "user[Thu 2026-07-02]".length,
|
|
kind: "role_timestamp_bracket",
|
|
role: "user",
|
|
}),
|
|
]);
|
|
|
|
const formatted = annotated(
|
|
"![**user**[Thu 2026-07-02] release diagram](https://example.com/image.png)",
|
|
);
|
|
expect(formatted.text).toBe("user[Thu 2026-07-02] release diagram");
|
|
expect(
|
|
formatted.annotations?.map((span) => formatted.text.slice(span.start, span.end)),
|
|
).toEqual(["user[Thu 2026-07-02]"]);
|
|
expect(
|
|
annotated("![`user[Thu 2026-07-02]`](https://example.com/image.png)").annotations,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("does not mark ordinary prose, email-like angles, or disabled parsing", () => {
|
|
expect(annotated("The user[setting] remains unchanged.").annotations).toBeUndefined();
|
|
expect(annotated("<user@example.com> wrote this").annotations).toBeUndefined();
|
|
expect(annotated("user[x`y] malformed").annotations).toBeUndefined();
|
|
expect(markdownToIR("user[Thu 2026-07-02] text").annotations).toBeUndefined();
|
|
});
|
|
|
|
it("tracks many interleaved headers and code ranges in source order", () => {
|
|
const markdown = Array.from({ length: 128 }, (_, index) =>
|
|
index % 2 === 0 ? `user[t${index}] text` : `\`user[t${index}] code\``,
|
|
).join("\n");
|
|
const ir = annotated(markdown);
|
|
|
|
expect(ir.annotations).toHaveLength(64);
|
|
expect(ir.annotations?.map((span) => ir.text.slice(span.start, span.end))).toEqual(
|
|
Array.from({ length: 64 }, (_, index) => `user[t${index * 2}]`),
|
|
);
|
|
});
|
|
|
|
it("bounds unterminated delimiter scans to each line's header window", () => {
|
|
const markdown = Array.from(
|
|
{ length: 1_024 },
|
|
(_, index) => `user[${"x".repeat(160)}${index}`,
|
|
).join("\n");
|
|
|
|
expect(annotated(markdown).annotations).toBeUndefined();
|
|
});
|
|
|
|
it("preserves annotations when IR is chunked", () => {
|
|
const chunks = chunkMarkdownIR(annotated("user[Thu 2026-07-02] text after"), 12);
|
|
|
|
expect(chunks.some((chunk) => (chunk.annotations?.length ?? 0) > 0)).toBe(true);
|
|
expect(chunks.map((chunk) => chunk.text).join("")).toContain("user[Thu");
|
|
});
|
|
|
|
it("annotates headers promoted to a transport message boundary", () => {
|
|
const ir = markdownToIR("prefix user[Thu 2026-07-02] question", {
|
|
assistantTranscriptRoleHeaders: true,
|
|
});
|
|
const promoted = annotateAssistantTranscriptRoleMessageBoundary(
|
|
sliceMarkdownIR(ir, "prefix ".length, ir.text.length),
|
|
);
|
|
|
|
expect(promoted.annotations?.map((span) => promoted.text.slice(span.start, span.end))).toEqual([
|
|
"user[Thu 2026-07-02]",
|
|
]);
|
|
});
|
|
|
|
it("keeps promoted code examples unannotated", () => {
|
|
const ir = markdownToIR("prefix `user[Thu 2026-07-02] question`", {
|
|
assistantTranscriptRoleHeaders: true,
|
|
});
|
|
const headerStart = ir.text.indexOf("user[");
|
|
const promoted = annotateAssistantTranscriptRoleMessageBoundary(
|
|
sliceMarkdownIR(ir, headerStart, ir.text.length),
|
|
);
|
|
|
|
expect(promoted.annotations).toBeUndefined();
|
|
expect(promoted.styles).toContainEqual({ start: 0, end: promoted.text.length, style: "code" });
|
|
});
|
|
|
|
it("removes links promoted to transcript-role headers", () => {
|
|
const promoted = annotateAssistantTranscriptRoleMessageBoundary({
|
|
text: "user[Thu 2026-07-02] question",
|
|
styles: [],
|
|
links: [{ start: 0, end: "user[Thu 2026-07-02]".length, href: "https://example.com" }],
|
|
});
|
|
|
|
expect(promoted.annotations).toHaveLength(1);
|
|
expect(promoted.links).toEqual([]);
|
|
});
|
|
});
|