Skip to content

Commit 7c223ec

Browse files
Merge remote-tracking branch 'github/main' into takeover-24205
2 parents 129245a + bc8d02f commit 7c223ec

18 files changed

Lines changed: 354 additions & 46 deletions

‎apps/mobile/src/features/files/FileMarkdownPreview.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { settlePromise } from "@t3tools/client-runtime/state/runtime";
22
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
33
import { resolveMobileMarkdownMediaSource } from "../../lib/markdownMediaSource";
44
import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects";
5+
import { maskMarkdownFrontmatter } from "@t3tools/shared/markdownFrontmatter";
56
import { useState } from "react";
67
import { StackActions, useNavigation } from "@react-navigation/native";
78
import { RefreshControl, ScrollView, View } from "react-native";
@@ -235,7 +236,7 @@ export function FileMarkdownPreview(props: {
235236
>
236237
<View className="mx-auto w-full max-w-[760px]">
237238
<SelectableMarkdownText
238-
markdown={props.markdown}
239+
markdown={maskMarkdownFrontmatter(props.markdown)}
239240
fileEmbedLabel={(href) =>
240241
markdownFileEmbedLabel(
241242
href,

‎apps/mobile/src/lib/markdownDocument.ts‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Root, RootContent } from "mdast";
2-
import { unified } from "unified";
2+
import { unified, type Processor } from "unified";
33
import remarkParse from "remark-parse";
44
import remarkGfm from "remark-gfm";
5+
import { isWindowsAbsolutePath } from "@t3tools/shared/path";
56

67
const parser = unified().use(remarkParse).use(remarkGfm);
78
export type MarkdownNode = Root | RootContent;
@@ -382,13 +383,62 @@ export function markdownWithTableBreaks(markdown: string): string {
382383
return transformNativeMarkdown(markdown, undefined, true);
383384
}
384385

386+
// `>` covers a destination continued on a blockquote line.
387+
const WINDOWS_DESTINATION = /\](?:\(|:)[\s>]*<?(?:[A-Za-z]:[\\/]|\\\\)/;
388+
389+
interface DestinationToken {
390+
readonly start: { readonly offset: number };
391+
readonly end: { readonly offset: number };
392+
}
393+
394+
/**
395+
* Markdown reads `C:\work\.t3` as `C:\work.t3`. Web keeps authored Windows destinations
396+
* literally (see `remarkKeepWindowsPaths` in shared), so escape their backslashes to give
397+
* both parsers here the same path. `\&` stays an escape, as `markdownWithHtml` emits it.
398+
* Expects authored Markdown: already escaped output would be escaped twice.
399+
*/
400+
function markdownWithWindowsPathDestinations(markdown: string): string {
401+
// Skips the extra parse for the common message that has no Windows destination.
402+
if (!WINDOWS_DESTINATION.test(markdown)) return markdown;
403+
const edits: MarkdownEdit[] = [];
404+
function record(
405+
this: { resume(): string; sliceSerialize(token: DestinationToken): string },
406+
token: DestinationToken,
407+
) {
408+
this.resume();
409+
const authored = this.sliceSerialize(token);
410+
if (!isWindowsAbsolutePath(authored) || !authored.includes("\\") || /[<>]/.test(authored))
411+
return;
412+
const start = token.start.offset;
413+
const end = token.end.offset;
414+
const escaped = authored.replace(/\\(?!&)/g, "\\\\");
415+
// A bare destination ends at an unescaped `)`, so wrap it to keep `\)` inside.
416+
const angled = markdown[start - 1] === "<";
417+
edits.push({ start, end, content: angled ? escaped : `<${escaped}>` });
418+
}
419+
unified()
420+
.use(remarkParse)
421+
.use(remarkGfm)
422+
.use(function (this: Processor) {
423+
(this.data().fromMarkdownExtensions ??= []).push({
424+
exit: { resourceDestinationString: record, definitionDestinationString: record },
425+
});
426+
})
427+
.parse(markdown);
428+
return applyMarkdownEdits(markdown, edits);
429+
}
430+
431+
// HTML wrappers can expose Markdown that wasn't in the first AST, so normalize them first.
432+
export function markdownForNativeParsing(markdown: string): string {
433+
return markdownWithWindowsPathDestinations(markdownWithHtml(markdown));
434+
}
435+
385436
export function parseMarkdownDocument(markdown: string): Root {
386-
return parser.parse(markdownWithHtml(markdown));
437+
return parser.parse(markdownForNativeParsing(markdown));
387438
}
388439

389440
export function markdownForNativeRendering(markdown: string, fileLink: RegExp): string {
390-
// HTML wrappers can expose Markdown that wasn't in the first AST, so normalize them first.
391-
return transformNativeMarkdown(markdownWithHtml(markdown), fileLink, true);
441+
return transformNativeMarkdown(markdownForNativeParsing(markdown), fileLink, true);
392442
}
393443

394444
/** Native media slots only cover standalone images; nested file embeds must become links. */

‎apps/mobile/src/lib/markdownHtml.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vite-plus/test";
22
import {
3+
markdownForNativeRendering,
34
markdownHtmlContent,
45
markdownWithHtml,
56
markdownWithFileEmbeds,
@@ -9,6 +10,7 @@ import {
910
import { markdownImageSources } from "./markdownImageSources";
1011
import { markdownFileEmbedLabel, resolveMarkdownMediaPreview } from "./markdownMedia";
1112
import { resolveMarkdownLinkPresentation } from "./markdownLinks";
13+
import { ENRICHED_INLINE_FILE_LINK_REGEX } from "./enrichedLinkPresentation";
1214
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
1315
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
1416

@@ -296,3 +298,35 @@ describe("file embeds in native thread and file Markdown", () => {
296298
expect(converted.fileHrefs.has(href)).toBe(true);
297299
});
298300
});
301+
302+
describe("Windows paths in native Markdown", () => {
303+
// regression: #23722 Markdown escapes ate the separator before `.t3` and `_build`
304+
it("keeps backslashes in Windows image and link destinations", async () => {
305+
const image = String.raw`C:\work\.t3\_build\shot.png`;
306+
const link = String.raw`\\server\share\.t3\notes.md`;
307+
const markdown = [
308+
`![shot](${image} "Shot")`,
309+
String.raw`![paren](C:\a\)b.png)`,
310+
`[notes](<${link}>)`,
311+
`<img src="${image}">`,
312+
String.raw`![amp](C:\R&D\.t3\a&amp;b.png)`,
313+
String.raw`<img src="C:\R&amp;D\.t3\b.png">`,
314+
"> ![quoted](\n> " + image + ")",
315+
].join("\n\n");
316+
317+
const native = await parseMarkdown(
318+
markdownForNativeRendering(markdown, ENRICHED_INLINE_FILE_LINK_REGEX),
319+
);
320+
const urls: string[] = [];
321+
const collect = (node: { attributes?: { url?: string }; children?: unknown[] }) => {
322+
if (node.attributes?.url) urls.push(node.attributes.url);
323+
node.children?.forEach((child) => collect(child as typeof node));
324+
};
325+
collect(native);
326+
const paren = String.raw`C:\a\)b.png`;
327+
const amp = String.raw`C:\R&D\.t3\a&b.png`;
328+
const htmlAmp = String.raw`C:\R&D\.t3\b.png`;
329+
expect(urls).toEqual([image, paren, link, image, amp, htmlAmp, image]);
330+
expect(markdownImageSources(markdown)).toEqual([image, paren, amp, htmlAmp]);
331+
});
332+
});

‎apps/mobile/src/lib/markdownInlineFileLinks.test.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { ENRICHED_INLINE_FILE_LINK_REGEX } from "./enrichedLinkPresentation";
77
import {
88
markdownForNativeRendering,
99
markdownWithInlineFileLinks,
10-
parseMarkdownDocument,
1110
markdownWithHtml,
1211
} from "./markdownDocument";
1312

@@ -87,7 +86,7 @@ describe("inline code that names a file", () => {
8786

8887
it("links to the exact path when it holds backslashes or ampersands", () => {
8988
for (const path of ["C:\\repo\\_private\\file.ts", "./src/a&copy;.ts", "./src/[id].ts"]) {
90-
const paragraph = parseMarkdownDocument(rewrite(`\`${path}\``)).children[0];
89+
const paragraph = parser.parse(rewrite(`\`${path}\``)).children[0];
9190
const link = paragraph?.type === "paragraph" ? paragraph.children[0] : undefined;
9291
expect(link?.type === "link" && link.url).toBe(path);
9392
expect(

‎apps/mobile/src/lib/markdownLinks.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,7 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese
265265
}
266266
} catch {}
267267

268-
const target =
269-
parseMarkdownFileLink(normalized) ??
270-
(/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+$/.test(normalized)
271-
? { path: normalized }
272-
: null);
268+
const target = parseMarkdownFileLink(normalized);
273269
if (target) {
274270
return {
275271
kind: "file",

‎apps/mobile/src/native/EnrichedMarkdownText.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import {
88
markdownForNativeRendering,
99
markdownWithFileEmbeds,
10-
markdownWithHtml,
10+
markdownForNativeParsing,
1111
markdownWithInlineFileLinks,
1212
markdownWithTableBreaks,
1313
} from "../lib/markdownDocument";
@@ -165,7 +165,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
165165
};
166166
}
167167
const content = markdownWithInlineFileLinks(
168-
markdownWithHtml(props.markdown),
168+
markdownForNativeParsing(props.markdown),
169169
ENRICHED_INLINE_FILE_LINK_REGEX,
170170
);
171171
const files = markdownWithFileEmbeds(content, fileEmbedLabel);

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5077,6 +5077,138 @@ describe("ClaudeAdapterV2 background wake turns", () => {
50775077
),
50785078
);
50795079

5080+
it.effect("an interrupted turn after a rollback stops resuming at the rolled-back head", () =>
5081+
Effect.gen(function* () {
5082+
const harness = yield* makeWakeHarnessWithOptions({
5083+
close: (sdkMessages) => Queue.shutdown(sdkMessages),
5084+
freshQueueOnReopen: true,
5085+
});
5086+
const now = yield* DateTime.now;
5087+
const pinnedHead = "00000000-0000-4000-8000-000000025510";
5088+
const pinnedThread = {
5089+
...harness.providerThread,
5090+
nativeConversationHeadRef: {
5091+
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
5092+
nativeId: pinnedHead,
5093+
strength: "weak" as const,
5094+
},
5095+
};
5096+
const interruptedInput = makeClaudeTestTurnInput({
5097+
threadId: harness.threadId,
5098+
providerThread: pinnedThread,
5099+
now,
5100+
attemptId: RunAttemptId.make("attempt-claude-interrupt-after-rollback"),
5101+
text: "The secret word is apricot-25510. Then run sleep 60.",
5102+
attachments: [],
5103+
providerTurnOrdinal: 2,
5104+
nativeThreadHasTurns: true,
5105+
});
5106+
yield* harness.runtime.startTurn(interruptedInput);
5107+
assert.equal(harness.getOpenedOptions()?.resumeSessionAt, pinnedHead);
5108+
yield* harness.offerAndWait(
5109+
makeAssistantTextFrame({
5110+
uuid: "00000000-0000-4000-8000-000000025511",
5111+
text: "Noted apricot-25510. Running sleep.",
5112+
}),
5113+
);
5114+
yield* harness.runtime.interruptTurn({
5115+
providerThread: pinnedThread,
5116+
providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
5117+
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
5118+
nativeTurnId: `turn:${interruptedInput.attemptId}`,
5119+
}),
5120+
});
5121+
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted");
5122+
const afterInterrupt = harness.events.findLast(
5123+
(event) => event.type === "provider_thread.updated",
5124+
)?.providerThread;
5125+
assert.isDefined(afterInterrupt);
5126+
assert.isNull(afterInterrupt.nativeConversationHeadRef);
5127+
5128+
yield* harness.runtime.startTurn(
5129+
makeClaudeTestTurnInput({
5130+
threadId: harness.threadId,
5131+
providerThread: afterInterrupt,
5132+
now,
5133+
attemptId: RunAttemptId.make("attempt-claude-after-interrupt"),
5134+
text: "What is the secret word?",
5135+
attachments: [],
5136+
providerTurnOrdinal: 3,
5137+
nativeThreadHasTurns: true,
5138+
}),
5139+
);
5140+
assert.equal(harness.processQueues.length, 2);
5141+
assert.isUndefined(harness.getOpenedOptions()?.resumeSessionAt);
5142+
}).pipe(
5143+
Effect.provide(
5144+
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
5145+
),
5146+
),
5147+
);
5148+
5149+
it.effect("an interrupted turn with no assistant output keeps the rolled-back head", () =>
5150+
Effect.gen(function* () {
5151+
const harness = yield* makeWakeHarnessWithOptions({
5152+
close: (sdkMessages) => Queue.shutdown(sdkMessages),
5153+
freshQueueOnReopen: true,
5154+
});
5155+
const now = yield* DateTime.now;
5156+
const pinnedHead = "00000000-0000-4000-8000-000000025510";
5157+
const pinnedThread = {
5158+
...harness.providerThread,
5159+
nativeConversationHeadRef: {
5160+
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
5161+
nativeId: pinnedHead,
5162+
strength: "weak" as const,
5163+
},
5164+
};
5165+
const interruptedInput = makeClaudeTestTurnInput({
5166+
threadId: harness.threadId,
5167+
providerThread: pinnedThread,
5168+
now,
5169+
attemptId: RunAttemptId.make("attempt-claude-silent-interrupt-after-rollback"),
5170+
text: "The secret word is apricot-25510. Then run sleep 60.",
5171+
attachments: [],
5172+
providerTurnOrdinal: 2,
5173+
nativeThreadHasTurns: true,
5174+
});
5175+
yield* harness.runtime.startTurn(interruptedInput);
5176+
assert.equal(harness.getOpenedOptions()?.resumeSessionAt, pinnedHead);
5177+
yield* harness.runtime.interruptTurn({
5178+
providerThread: pinnedThread,
5179+
providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
5180+
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
5181+
nativeTurnId: `turn:${interruptedInput.attemptId}`,
5182+
}),
5183+
});
5184+
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted");
5185+
const afterInterrupt = harness.events.findLast(
5186+
(event) => event.type === "provider_thread.updated",
5187+
)?.providerThread;
5188+
assert.isDefined(afterInterrupt);
5189+
assert.equal(afterInterrupt.nativeConversationHeadRef?.nativeId, pinnedHead);
5190+
5191+
yield* harness.runtime.startTurn(
5192+
makeClaudeTestTurnInput({
5193+
threadId: harness.threadId,
5194+
providerThread: afterInterrupt,
5195+
now,
5196+
attemptId: RunAttemptId.make("attempt-claude-after-silent-interrupt"),
5197+
text: "What is the secret word?",
5198+
attachments: [],
5199+
providerTurnOrdinal: 3,
5200+
nativeThreadHasTurns: true,
5201+
}),
5202+
);
5203+
assert.equal(harness.processQueues.length, 2);
5204+
assert.equal(harness.getOpenedOptions()?.resumeSessionAt, pinnedHead);
5205+
}).pipe(
5206+
Effect.provide(
5207+
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
5208+
),
5209+
),
5210+
);
5211+
50805212
it.effect("never records a subagent uuid for a turn with no main-thread assistant message", () =>
50815213
Effect.gen(function* () {
50825214
const harness = yield* makeWakeHarnessWithOptions({

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7257,8 +7257,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
72577257
yield* Ref.get(awaitedBackgroundTasksByNativeThread),
72587258
nativeThreadId,
72597259
);
7260+
// A stopped turn that wrote to the transcript moved past the pinned head.
72607261
const clearConversationHead =
7261-
input.status === "completed" &&
7262+
(input.status === "completed" || input.context.nativeMessageCursor !== null) &&
72627263
input.context.input.providerThread.nativeConversationHeadRef !== null;
72637264
const goal =
72647265
nativeThreadId === null ? undefined : goalsByNativeThread.get(nativeThreadId);

‎apps/web/src/components/chat/ComposerPrimaryActions.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,8 +378,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
378378
}
379379
>
380380
<Minimize2Icon aria-hidden="true" />
381-
{/* The token count lives in the tooltip so the permission label keeps its room. */}
382-
{keepFullHistory ? "Keep full" : "Compact next"}
381+
{keepFullHistory ? "Keep" : "Compact"}
382+
<span className="text-muted-foreground">{compactTokens}</span>
383383
</TooltipTrigger>
384384
<TooltipPopup>
385385
<div>

‎apps/web/src/components/files/AttachmentFilePreview.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react";
1616

1717
import { useAssetUrlRefresh } from "~/assets/assetUrls";
1818
import ChatMarkdown from "~/components/ChatMarkdown";
19+
import { maskMarkdownFrontmatter } from "@t3tools/shared/markdownFrontmatter";
1920
import { MorphIcon } from "~/components/MorphIcon";
2021
import { ScrollArea } from "~/components/ui/scroll-area";
2122
import { toastManager } from "~/components/ui/toast";
@@ -281,7 +282,11 @@ export function AttachmentFilePreview(props: {
281282
</Suspense>
282283
) : kind === "markdown" && rendered ? (
283284
<ScrollArea className="min-h-0 flex-1">
284-
<ChatMarkdown text={content.text} cwd={undefined} className="mx-auto max-w-4xl px-6 py-5" />
285+
<ChatMarkdown
286+
text={maskMarkdownFrontmatter(content.text)}
287+
cwd={undefined}
288+
className="mx-auto max-w-4xl px-6 py-5"
289+
/>
285290
</ScrollArea>
286291
) : (
287292
<ReadOnlySourcePreview name={props.name} text={content.text} />

0 commit comments

Comments
 (0)