diff --git a/package.json b/package.json index f1f0b4d851f..b237a56c18a 100644 --- a/package.json +++ b/package.json @@ -101,10 +101,7 @@ "agentmail": "^0.5.31", "cross-env": "^10.1.0", "esbuild": "^0.28.2", - "remark-gfm": "4.0.1", - "remark-parse": "11.0.0", "typescript": "^7.0.2", - "unified": "11.0.5", "vitest": "^5.0.3", "yaml": "^2.8.1", "turbo": "2.11.10" diff --git a/tests/runner-e2e/PRODUCT-JOURNEYS.md b/tests/runner-e2e/PRODUCT-JOURNEYS.md index 014a9ff6fe6..daee209ef45 100644 --- a/tests/runner-e2e/PRODUCT-JOURNEYS.md +++ b/tests/runner-e2e/PRODUCT-JOURNEYS.md @@ -116,7 +116,7 @@ reply path. A finish summary by itself, an earlier turn, a progress bubble, or a cancelled run cannot satisfy it. Private receipts record the run, thread anchor and observed text; classic protocol comment matchers stay strict. Markdown replies compare complete text and exact link destinations after parsing -with the production GFM and issue-reference plugins. The observer receives the +through the UI-owned projection of its existing Markdown renderer and GFM/issue-reference plugins. The observer receives the same visible-company prefix list used by the UI, so bare issue keys and URLs match their rendered links. Whitespace and inline formatting can change during rendering. A document or issue chip can replace a link label, but must retain its destination. diff --git a/tests/runner-e2e/task-reply-content.ts b/tests/runner-e2e/task-reply-content.ts index a79397bb57b..6b35116c0f3 100644 --- a/tests/runner-e2e/task-reply-content.ts +++ b/tests/runner-e2e/task-reply-content.ts @@ -1,11 +1,8 @@ -import { unified } from "unified"; -import remarkParse from "remark-parse"; -import remarkGfm from "remark-gfm"; +import { markdownReplyContent } from "../../ui/src/lib/markdown-reply-content.js"; import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "../../ui/src/lib/company-routes.js"; -import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../../ui/src/lib/issue-reference.js"; +import { parseIssueReferenceFromHref } from "../../ui/src/lib/issue-reference.js"; export type ReplyContentPart = { text: string } | { href: string; label?: string; decorated?: boolean }; -type MarkdownNode = { type: string; value?: string; url?: string; children?: MarkdownNode[] }; function normalized(parts: ReplyContentPart[], pageUrl: string, source = false) { const result: ReplyContentPart[] = []; @@ -43,23 +40,8 @@ export function taskReplyContentMatches( const displayed = observation.content.map(part => "text" in part ? part.text : part.label!).join("").replace(/\s+/g, " ").trim(); if (displayed !== observation.body.replace(/\s+/g, " ").trim()) return false; } - const parts: ReplyContentPart[] = []; - const inlineLabel = (node: MarkdownNode): boolean => ["text", "inlineCode", "strong", "emphasis", "delete"].includes(node.type) - && (node.children ?? []).every(inlineLabel); - const labelText = (node: MarkdownNode): string => node.value ?? (node.children ?? []).map(labelText).join(""); - function visit(node: MarkdownNode): boolean { - if (node.type === "text" || node.type === "inlineCode") { parts.push({ text: node.value ?? "" }); return true; } - if (node.type === "break") { parts.push({ text: " " }); return true; } - if (node.type === "link" && node.url && (node.children ?? []).every(inlineLabel)) { parts.push({ href: node.url, label: labelText(node) }); return true; } - if (!["root", "paragraph", "heading", "blockquote", "list", "listItem", "strong", "emphasis", "delete"].includes(node.type)) return false; - for (const child of node.children ?? []) if (!visit(child)) return false; - if (!["strong", "emphasis", "delete"].includes(node.type)) parts.push({ text: " " }); - return true; - } - const parser = unified().use(remarkParse).use(remarkGfm); - const tree = parser.runSync(parser.parse(markdown)) as MarkdownNode; - remarkLinkIssueReferences({ knownPrefixes: observation.knownCompanyPrefixes })(tree); - if (!visit(tree)) return false; + const parts = markdownReplyContent(markdown, observation.knownCompanyPrefixes); + if (!parts) return false; const pageUrl = observation.pageUrl ?? observation.origin ?? "https://reply.invalid"; const expected = normalized(parts, pageUrl, true); const actual = normalized(observation.content ?? [{ text: observation.body }], pageUrl); diff --git a/ui/src/lib/markdown-reply-content.ts b/ui/src/lib/markdown-reply-content.ts new file mode 100644 index 00000000000..1855b58272a --- /dev/null +++ b/ui/src/lib/markdown-reply-content.ts @@ -0,0 +1,52 @@ +import { Fragment, isValidElement, type ReactNode } from "react"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { remarkLinkIssueReferences } from "./issue-reference.js"; + +export type MarkdownReplyPart = { text: string } | { href: string; label: string }; +type AstNode = { type: string; children?: AstNode[] }; + +/** The reply observer consumes the UI's existing parser without adding a + * parallel Markdown dependency graph. Unsupported structures fail closed. */ +export function markdownReplyContent(markdown: string, knownPrefixes?: string[]): MarkdownReplyPart[] | null { + let rawHtml = false; + function rejectHtml() { + return (tree: AstNode) => { + const nodes = [tree]; + while (nodes.length) { + const node = nodes.pop()!; + if (node.type === "html") rawHtml = true; + nodes.push(...node.children ?? []); + } + }; + } + const rendered = Markdown({ children: markdown, + remarkPlugins: [remarkGfm, [remarkLinkIssueReferences, { knownPrefixes }], rejectHtml] }); + if (rawHtml) return null; + const parts: MarkdownReplyPart[] = []; + function visit(node: ReactNode, label?: string[]): boolean { + if (node == null || typeof node === "boolean") return true; + if (typeof node === "string" || typeof node === "number") { + if (label) label.push(String(node)); else parts.push({ text: String(node) }); + return true; + } + if (Array.isArray(node)) return node.every(child => visit(child, label)); + if (!isValidElement<{ children?: ReactNode; href?: string }>(node)) return false; + if (node.type === Fragment) return visit(node.props.children, label); + if (node.type === "a" && !label && node.props.href) { + const words: string[] = []; + if (!visit(node.props.children, words)) return false; + parts.push({ href: node.props.href, label: words.join("") }); + return true; + } + if (node.type === "br") { if (label) label.push(" "); else parts.push({ text: " " }); return true; } + if (["strong", "em", "del", "code"].includes(String(node.type))) return visit(node.props.children, label); + if (!label && ["p", "ul", "ol", "li", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6"].includes(String(node.type))) { + if (!visit(node.props.children)) return false; + parts.push({ text: " " }); + return true; + } + return false; + } + return visit(rendered) ? parts : null; +}