From 776168f1d49fcda36f3d6188c330c648c6207ef3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 06:19:41 +0000 Subject: [PATCH] fix(chat): repair unclosed local file links in assistant responses --- .../src/features/threads/ThreadFeed.tsx | 15 ++- .../src/components/chat/MessagesTimeline.tsx | 4 +- packages/client-runtime/package.json | 4 + .../client-runtime/src/markdownLinks.test.ts | 112 ++++++++++++++++++ .../src/repairMarkdownFileLinks.ts | 84 +++++++++++++ 5 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 packages/client-runtime/src/repairMarkdownFileLinks.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 7d591d59e8c5..aa04d2a7b95a 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -10,6 +10,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; +import { repairMarkdownFileLinks } from "@t3tools/client-runtime/repair-markdown-file-links"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; import { type OrchestrationMessageContext, @@ -889,7 +890,17 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly skills?: ReadonlyArray | undefined; }) { const segments = useMemo( - () => splitCodexArtifactTemplateMarkdown(props.markdown), + () => + splitCodexArtifactTemplateMarkdown(props.markdown).map((segment) => + segment.kind === "markdown" + ? { + ...segment, + markdown: renderCodexFileCitationsAsMarkdown( + repairMarkdownFileLinks(segment.markdown), + ), + } + : segment, + ), [props.markdown], ); @@ -905,7 +916,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { } if (segment.markdown.trim().length === 0) return null; - const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); + const markdown = segment.markdown; return hasNativeSelectableMarkdownText() ? ( }) { const ctx = use(TimelineRowCtx); const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + const renderedText = useMemo(() => repairMarkdownFileLinks(messageText), [messageText]); return ( <> @@ -2503,7 +2505,7 @@ function AssistantTimelineRow({ row }: { row: Extract { + it.each([ + "CONTRIBUTING.md", + "local/path/file.md", + "docs/My Folder/file.md", + "./scripts/deploy", + "~/notes/today.md", + "/home/me/My Folder/file.md", + "/tmp/reports/", + "C:\\Users\\me\\My Folder\\file.md", + "file:///home/me/file.md", + "src/main.ts:12:5", + ])("repairs a complete local destination: %s", (path) => { + const source = `- [file](<${path})`; + const rendered = repairMarkdownFileLinks(source); + expect(rendered).toBe(`- [file](<${path}>)`); + expect(unified().use(remarkParse).parse(rendered).children[0]).toMatchObject({ + type: "list", + children: [{ children: [{ children: [{ type: "link" }] }] }], + }); + expect(repairMarkdownFileLinks(rendered)).toBe(rendered); + }); + + it.each([ + "ordinary prose", + "[file]()", + "[site](", + "[file](<./file(one).md)", + "[file](<./file.md))", + "[file](<./file)name.md)", + "[file](<./file\\).md)", + "![image](<./file.png)", + "\\\\![image](<./file.png)", + "\\\\\\\\![image](<./file.png)", + "\\[file](<./file.md)", + "\\\\\\[file](<./file.md)", + "`[file](<./file.md)`", + "``[file](<./file.md)``", + "```md\n[file](<./file.md)\n```", + "~~~md\n[file](<./file.md)\n~~~", + " [file](<./file.md)", + "
\n[file](<./file.md)\n
", + "HTML [file](<./file.md)", + 'text', + "[outer [file](<./file.md)](https://example.com)", + "[outer [file](<./file.md)][ref]\n\n[ref]: https://example.com", + '[ref]: ./file.md "[file](<./other.md)"', + ':codex-file-citation{path="./[file](<./other.md)" purpose="output"}', + ':codex-file-citation{path="./[file](<./other.md)', + '::artifact-template{skill_name="artifact-template-example" skill_directory="/tmp/skills/example" display_name="[file](<./file.md)" artifact_kind="document"}', + '::artifact-template{display_name="[file](<./file.md)', + ])("preserves protected or uncertain syntax: %s", (source) => { + expect(repairMarkdownFileLinks(source)).toBe(source); + }); + + it("uses CommonMark backslash parity for link openers", () => { + expect(repairMarkdownFileLinks("\\\\[file](<./file.md)")).toBe("\\\\[file](<./file.md>)"); + expect(repairMarkdownFileLinks("\\\\\\\\[file](<./file.md)")).toBe( + "\\\\\\\\[file](<./file.md>)", + ); + expect(repairMarkdownFileLinks("\\![file](<./file.md)")).toBe("\\![file](<./file.md>)"); + }); + + it("repairs ordinary segments after splitting mobile artifacts and preserves copy source", () => { + const link = "[file](<./file.md)"; + const template = + '::artifact-template{skill_name="artifact-template-example" skill_directory="/tmp/skills/example" display_name="[file](<./file.md)" artifact_kind="document"}'; + const citation = ':codex-file-citation{path="./report.md"}'; + const source = `${link}\n\n${template}\n\n${citation}`; + const segments = splitCodexArtifactTemplateMarkdown(source); + const rendered = segments.map((segment) => + segment.kind === "markdown" + ? { + ...segment, + markdown: renderCodexFileCitationsAsMarkdown(repairMarkdownFileLinks(segment.markdown)), + } + : segment, + ); + expect(rendered[0]).toEqual({ + kind: "markdown", + markdown: `${link.slice(0, -1)}>)\n\n`, + sourceOffset: 0, + }); + expect(rendered[1]).toEqual(segments[1]); + expect(rendered[1]).toMatchObject({ + kind: "artifact-template", + sourceOffset: link.length + 2, + template: { displayName: link, skillName: "artifact-template-example" }, + }); + expect(rendered[2]).toEqual({ + kind: "markdown", + sourceOffset: source.indexOf("\n\n" + citation), + markdown: "\n\n[report.md](<./report.md>)", + }); + expect(renderCodexDirectivesForCopy(source)).toBe( + `${link}\n\n${link} (Document template)\n\n[report.md](<./report.md>)`, + ); + }); +}); describe("inlineCodeFilePathCandidate", () => { it.each([ diff --git a/packages/client-runtime/src/repairMarkdownFileLinks.ts b/packages/client-runtime/src/repairMarkdownFileLinks.ts new file mode 100644 index 000000000000..9c863ee26f9f --- /dev/null +++ b/packages/client-runtime/src/repairMarkdownFileLinks.ts @@ -0,0 +1,84 @@ +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +import { remarkCodexDirectives } from "./codexMarkdownDirectives.ts"; +import { parseMarkdownFileLink } from "./markdownLinks.ts"; + +const parser = unified().use(remarkParse).use(remarkCodexDirectives).freeze(); +const CANDIDATE = /\[[^[\]\r\n]*\]\([ \t]*<([^<>()\r\n]+)\)/g; +const PROTECTED = new Set([ + "code", + "inlineCode", + "html", + "link", + "image", + "linkReference", + "imageReference", + "definition", + "textDirective", + "leafDirective", + "containerDirective", +]); + +function isEscaped(source: string, offset: number): boolean { + let backslashes = 0; + while (offset > 0 && source[--offset] === "\\") backslashes += 1; + return backslashes % 2 === 1; +} + +/** Closes only complete, unambiguous local-file destinations for assistant rendering. */ +export function repairMarkdownFileLinks(markdown: string): string { + if (!/\]\([ \t]* { + const start = match.index; + const close = start + match[0].length - 1; + return ( + !isEscaped(markdown, start) && + !(markdown[start - 1] === "!" && !isEscaped(markdown, start - 1)) && + !isEscaped(markdown, close) && + (close + 1 === markdown.length || /[\s.,;:!?}\]*_~]/.test(markdown[close + 1] ?? "")) && + parseMarkdownFileLink(match[1] ?? "") !== null + ); + }); + if (candidates.length === 0) return markdown; + + const protectedRanges: Array<{ start: number; end: number }> = []; + interface Node { + type: string; + position?: + | { start: { offset?: number | undefined }; end: { offset?: number | undefined } } + | undefined; + children?: Node[] | undefined; + } + const visit = (node: Node, paragraph?: Node) => { + const containingParagraph = node.type === "paragraph" ? node : paragraph; + const sourceStart = node.position?.start.offset; + const sourceEnd = node.position?.end.offset; + const unfinishedDirective = + (node.type === "textDirective" && markdown[sourceEnd ?? -1] === "{") || + (node.type === "text" && + markdown.slice(sourceStart, sourceEnd).includes("::artifact-template{")); + if (PROTECTED.has(node.type) || unfinishedDirective) { + // Inline HTML and unfinished directive attributes can span separate text nodes. + const protectedNode = + node.type === "html" || unfinishedDirective ? (containingParagraph ?? node) : node; + const start = protectedNode.position?.start.offset; + const end = protectedNode.position?.end.offset; + if (start !== undefined && end !== undefined) protectedRanges.push({ start, end }); + return; + } + node.children?.forEach((child) => visit(child, containingParagraph)); + }; + visit(parser.parse(markdown)); + + let rendered = ""; + let cursor = 0; + for (const match of candidates) { + const start = match.index; + const end = start + match[0].length; + if (protectedRanges.some((range) => start < range.end && end > range.start)) continue; + rendered += markdown.slice(cursor, end - 1) + ">"; + cursor = end - 1; + } + return rendered + markdown.slice(cursor); +}