authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 09:07:18-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 09:07:18-03:00
log0ce43a4eea2f21fe404639c248fb86328c410c5d
tree61799bde2617e667d5d303458048b781732a6710
parentdf4ae529eacfff8db38f2f4b9b767d3717446c8a
signature Signed by PGP key B5690EEEBB952194

fix(chat): repair unclosed local file links in assistant responses (#15520)


5 files changed, 216 insertions(+), 3 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+13-2
......@@ -10,6 +10,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
1010import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
1111import { scopeThreadRef } from "@t3tools/client-runtime/environment";
1212import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
13import { repairMarkdownFileLinks } from "@t3tools/client-runtime/repair-markdown-file-links";
1314import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
1415import {
1516 type OrchestrationMessageContext,
......@@ -907,7 +908,17 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
907908 readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
908909}) {
909910 const segments = useMemo(
910 () => splitCodexArtifactTemplateMarkdown(props.markdown),
911 () =>
912 splitCodexArtifactTemplateMarkdown(props.markdown).map((segment) =>
913 segment.kind === "markdown"
914 ? {
915 ...segment,
916 markdown: renderCodexFileCitationsAsMarkdown(
917 repairMarkdownFileLinks(segment.markdown),
918 ),
919 }
920 : segment,
921 ),
911922 [props.markdown],
912923 );
913924
......@@ -923,7 +934,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
923934 }
924935 if (segment.markdown.trim().length === 0) return null;
925936
926 const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown);
937 const markdown = segment.markdown;
927938 return hasNativeSelectableMarkdownText() ? (
928939 <SelectableMarkdownText
929940 key={`markdown:${segment.sourceOffset}`}
apps/web/src/components/chat/MessagesTimeline.tsx+3-1
......@@ -35,6 +35,7 @@ import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/en
3535import { useAtomValue } from "@effect/atom-react";
3636import { environmentThreadDetails } from "../../state/threads";
3737import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
38import { repairMarkdownFileLinks } from "@t3tools/client-runtime/repair-markdown-file-links";
3839import { Link } from "@tanstack/react-router";
3940import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
4041import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution";
......@@ -2494,6 +2495,7 @@ function AttemptFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "at
24942495function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
24952496 const ctx = use(TimelineRowCtx);
24962497 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
2498 const renderedText = useMemo(() => repairMarkdownFileLinks(messageText), [messageText]);
24972499
24982500 return (
24992501 <>
......@@ -2507,7 +2509,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
25072509 listRef={ctx.listRef}
25082510 >
25092511 <ChatMarkdown
2510 text={messageText}
2512 text={renderedText}
25112513 cwd={ctx.markdownCwd}
25122514 threadRef={ctx.threadRef ?? undefined}
25132515 isStreaming={Boolean(row.message.streaming)}
packages/client-runtime/package.json+4
......@@ -55,6 +55,10 @@
5555 "types": "./src/markdownLinks.ts",
5656 "default": "./src/markdownLinks.ts"
5757 },
58 "./repair-markdown-file-links": {
59 "types": "./src/repairMarkdownFileLinks.ts",
60 "default": "./src/repairMarkdownFileLinks.ts"
61 },
5862 "./media-reference": {
5963 "types": "./src/mediaReference.ts",
6064 "default": "./src/mediaReference.ts"
packages/client-runtime/src/markdownLinks.test.ts+112
......@@ -1,4 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
2import remarkParse from "remark-parse";
3import { unified } from "unified";
24
35import {
46 fileBasename,
......@@ -9,6 +11,116 @@ import {
911 splitFilePathPosition,
1012 workspaceRelativeFilePath,
1113} from "./markdownLinks.ts";
14import { repairMarkdownFileLinks } from "./repairMarkdownFileLinks.ts";
15import {
16 renderCodexDirectivesForCopy,
17 renderCodexFileCitationsAsMarkdown,
18 splitCodexArtifactTemplateMarkdown,
19} from "./codexMarkdownDirectives.ts";
20
21describe("repairMarkdownFileLinks", () => {
22 it.each([
23 "CONTRIBUTING.md",
24 "local/path/file.md",
25 "docs/My Folder/file.md",
26 "./scripts/deploy",
27 "~/notes/today.md",
28 "/home/me/My Folder/file.md",
29 "/tmp/reports/",
30 "C:\\Users\\me\\My Folder\\file.md",
31 "file:///home/me/file.md",
32 "src/main.ts:12:5",
33 ])("repairs a complete local destination: %s", (path) => {
34 const source = `- [file](<${path})`;
35 const rendered = repairMarkdownFileLinks(source);
36 expect(rendered).toBe(`- [file](<${path}>)`);
37 expect(unified().use(remarkParse).parse(rendered).children[0]).toMatchObject({
38 type: "list",
39 children: [{ children: [{ children: [{ type: "link" }] }] }],
40 });
41 expect(repairMarkdownFileLinks(rendered)).toBe(rendered);
42 });
43
44 it.each([
45 "ordinary prose",
46 "[file](<CONTRIBUTING.md>)",
47 "[site](<https://example.com/docs)",
48 "[route](</chat/settings)",
49 "[prose](<not a file)",
50 "[file](<src/main.ts",
51 "[file](<src/main.ts>",
52 "[file](<./file(one).md)",
53 "[file](<./file.md))",
54 "[file](<./file)name.md)",
55 "[file](<./file\\).md)",
56 "![image](<./file.png)",
57 "\\\\![image](<./file.png)",
58 "\\\\\\\\![image](<./file.png)",
59 "\\[file](<./file.md)",
60 "\\\\\\[file](<./file.md)",
61 "`[file](<./file.md)`",
62 "``[file](<./file.md)``",
63 "```md\n[file](<./file.md)\n```",
64 "~~~md\n[file](<./file.md)\n~~~",
65 " [file](<./file.md)",
66 "<div>\n[file](<./file.md)\n</div>",
67 "HTML <span>[file](<./file.md)</span>",
68 '<span title="[file](<./file.md)">text</span>',
69 "[outer [file](<./file.md)](https://example.com)",
70 "[outer [file](<./file.md)][ref]\n\n[ref]: https://example.com",
71 '[ref]: ./file.md "[file](<./other.md)"',
72 ':codex-file-citation{path="./[file](<./other.md)" purpose="output"}',
73 ':codex-file-citation{path="./[file](<./other.md)',
74 '::artifact-template{skill_name="artifact-template-example" skill_directory="/tmp/skills/example" display_name="[file](<./file.md)" artifact_kind="document"}',
75 '::artifact-template{display_name="[file](<./file.md)',
76 ])("preserves protected or uncertain syntax: %s", (source) => {
77 expect(repairMarkdownFileLinks(source)).toBe(source);
78 });
79
80 it("uses CommonMark backslash parity for link openers", () => {
81 expect(repairMarkdownFileLinks("\\\\[file](<./file.md)")).toBe("\\\\[file](<./file.md>)");
82 expect(repairMarkdownFileLinks("\\\\\\\\[file](<./file.md)")).toBe(
83 "\\\\\\\\[file](<./file.md>)",
84 );
85 expect(repairMarkdownFileLinks("\\![file](<./file.md)")).toBe("\\![file](<./file.md>)");
86 });
87
88 it("repairs ordinary segments after splitting mobile artifacts and preserves copy source", () => {
89 const link = "[file](<./file.md)";
90 const template =
91 '::artifact-template{skill_name="artifact-template-example" skill_directory="/tmp/skills/example" display_name="[file](<./file.md)" artifact_kind="document"}';
92 const citation = ':codex-file-citation{path="./report.md"}';
93 const source = `${link}\n\n${template}\n\n${citation}`;
94 const segments = splitCodexArtifactTemplateMarkdown(source);
95 const rendered = segments.map((segment) =>
96 segment.kind === "markdown"
97 ? {
98 ...segment,
99 markdown: renderCodexFileCitationsAsMarkdown(repairMarkdownFileLinks(segment.markdown)),
100 }
101 : segment,
102 );
103 expect(rendered[0]).toEqual({
104 kind: "markdown",
105 markdown: `${link.slice(0, -1)}>)\n\n`,
106 sourceOffset: 0,
107 });
108 expect(rendered[1]).toEqual(segments[1]);
109 expect(rendered[1]).toMatchObject({
110 kind: "artifact-template",
111 sourceOffset: link.length + 2,
112 template: { displayName: link, skillName: "artifact-template-example" },
113 });
114 expect(rendered[2]).toEqual({
115 kind: "markdown",
116 sourceOffset: source.indexOf("\n\n" + citation),
117 markdown: "\n\n[report.md](<./report.md>)",
118 });
119 expect(renderCodexDirectivesForCopy(source)).toBe(
120 `${link}\n\n${link} (Document template)\n\n[report.md](<./report.md>)`,
121 );
122 });
123});
12124
13125describe("isMarkdownFileLinkLabel", () => {
14126 it.each([
packages/client-runtime/src/repairMarkdownFileLinks.ts created+84
......@@ -0,0 +1,84 @@
1import remarkParse from "remark-parse";
2import { unified } from "unified";
3
4import { remarkCodexDirectives } from "./codexMarkdownDirectives.ts";
5import { parseMarkdownFileLink } from "./markdownLinks.ts";
6
7const parser = unified().use(remarkParse).use(remarkCodexDirectives).freeze();
8const CANDIDATE = /\[[^[\]\r\n]*\]\([ \t]*<([^<>()\r\n]+)\)/g;
9const PROTECTED = new Set([
10 "code",
11 "inlineCode",
12 "html",
13 "link",
14 "image",
15 "linkReference",
16 "imageReference",
17 "definition",
18 "textDirective",
19 "leafDirective",
20 "containerDirective",
21]);
22
23function isEscaped(source: string, offset: number): boolean {
24 let backslashes = 0;
25 while (offset > 0 && source[--offset] === "\\") backslashes += 1;
26 return backslashes % 2 === 1;
27}
28
29/** Closes only complete, unambiguous local-file destinations for assistant rendering. */
30export function repairMarkdownFileLinks(markdown: string): string {
31 if (!/\]\([ \t]*</.test(markdown)) return markdown;
32 const candidates = Array.from(markdown.matchAll(CANDIDATE)).filter((match) => {
33 const start = match.index;
34 const close = start + match[0].length - 1;
35 return (
36 !isEscaped(markdown, start) &&
37 !(markdown[start - 1] === "!" && !isEscaped(markdown, start - 1)) &&
38 !isEscaped(markdown, close) &&
39 (close + 1 === markdown.length || /[\s.,;:!?}\]*_~]/.test(markdown[close + 1] ?? "")) &&
40 parseMarkdownFileLink(match[1] ?? "") !== null
41 );
42 });
43 if (candidates.length === 0) return markdown;
44
45 const protectedRanges: Array<{ start: number; end: number }> = [];
46 interface Node {
47 type: string;
48 position?:
49 | { start: { offset?: number | undefined }; end: { offset?: number | undefined } }
50 | undefined;
51 children?: Node[] | undefined;
52 }
53 const visit = (node: Node, paragraph?: Node) => {
54 const containingParagraph = node.type === "paragraph" ? node : paragraph;
55 const sourceStart = node.position?.start.offset;
56 const sourceEnd = node.position?.end.offset;
57 const unfinishedDirective =
58 (node.type === "textDirective" && markdown[sourceEnd ?? -1] === "{") ||
59 (node.type === "text" &&
60 markdown.slice(sourceStart, sourceEnd).includes("::artifact-template{"));
61 if (PROTECTED.has(node.type) || unfinishedDirective) {
62 // Inline HTML and unfinished directive attributes can span separate text nodes.
63 const protectedNode =
64 node.type === "html" || unfinishedDirective ? (containingParagraph ?? node) : node;
65 const start = protectedNode.position?.start.offset;
66 const end = protectedNode.position?.end.offset;
67 if (start !== undefined && end !== undefined) protectedRanges.push({ start, end });
68 return;
69 }
70 node.children?.forEach((child) => visit(child, containingParagraph));
71 };
72 visit(parser.parse(markdown));
73
74 let rendered = "";
75 let cursor = 0;
76 for (const match of candidates) {
77 const start = match.index;
78 const end = start + match[0].length;
79 if (protectedRanges.some((range) => start < range.end && end > range.start)) continue;
80 rendered += markdown.slice(cursor, end - 1) + ">";
81 cursor = end - 1;
82 }
83 return rendered + markdown.slice(cursor);
84}