Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,11 @@ describe("provider switch on send", () => {
const request = lastRequest(claude);
expect(request.codexFastMode).toBeUndefined();
expect(request.reasoningLevel).toBeUndefined();
const audit = await container.resolve(HandoffStorage).readLatestHandoffAudit(threadId);
const audit = await vi.waitFor(async () => {
const written = await container.resolve(HandoffStorage).readLatestHandoffAudit(threadId);
if (written === null) throw new Error("The switch audit is not written yet");
return written;
});
expect(audit).toContain("schemaVersion: 2");
expect(audit).toContain("kind: switch");
expect(audit).toContain('fromProvider: "codex"');
Expand Down Expand Up @@ -446,7 +450,10 @@ describe("provider switch on send", () => {
expect(retry.message.match(/\[mcode-handoff:begin /g)).toHaveLength(1);
expect(retry.message.match(/ancient question/g)).toHaveLength(1);
expect(retry.message.endsWith("back on claude")).toBe(true);
expect(await container.resolve(HandoffStorage).readLatestHandoffAudit(threadId)).toContain("kind: switch");
// The switch writes its audit after it saves the provider, so the audit can trail the turn's settlement.
await vi.waitFor(async () => {
expect(await container.resolve(HandoffStorage).readLatestHandoffAudit(threadId)).toContain("kind: switch");
});
});

function notices(): string[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ describe("HandoffStorage", () => {
expect((await storage.readLatest("child"))?.meta).toMatchObject({ fromProvider: null, toProvider: "custom: provider\nname" });
});

it("skips an audit whose write is still in progress", async () => {
await storage.writeHandoffAudit("child", audit);
const inProgress = NodePath.join(dir, "threads", "child", "handoffs", "ZZZ-in-progress");
await NodeFSPromises.mkdir(inProgress, { recursive: true });
await NodeFSPromises.writeFile(NodePath.join(inProgress, "handoff.md.partial"), "---\nschemaVersion: 2\n");
expect((await storage.readLatest("child"))?.meta.kind).toBe("switch");
});

it("returns null for an absent thread and removes audits on thread deletion", async () => {
expect(await storage.readLatest("absent")).toBeNull();
await storage.writeHandoffAudit("child", audit);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export class HandoffStorage {
const dir = NodePath.join(handoffsRoot, entry);
// Pre-v2 artifacts have separate JSON metadata and must not enter the v2 wire contract.
if (NodeFS.existsSync(NodePath.join(dir, "handoff.json"))) continue;
// An audit still being written has its directory but not yet its renamed handoff.md.
if (!NodeFS.existsSync(NodePath.join(dir, "handoff.md"))) continue;
const markdown = await NodeFSPromises.readFile(NodePath.join(dir, "handoff.md"), "utf8");
const meta = readAuditMetadata(markdown);
if (meta) return { markdown, meta };
Expand All @@ -64,7 +66,10 @@ export class HandoffStorage {
`budgetBytes: ${audit.budgetBytes}`,
`droppedOptions: ${JSON.stringify(audit.droppedOptions)}`,
].join("\n");
await NodeFSPromises.writeFile(NodePath.join(handoffDir, "handoff.md"), `---\n${frontmatter}\n---\n\n${audit.history}\n`, "utf8");
// Readers must never see a half-written audit, so handoff.md appears only once it is complete.
const partialPath = NodePath.join(handoffDir, "handoff.md.partial");
await NodeFSPromises.writeFile(partialPath, `---\n${frontmatter}\n---\n\n${audit.history}\n`, "utf8");
await NodeFSPromises.rename(partialPath, NodePath.join(handoffDir, "handoff.md"));
return ulid;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ describe("resolveUserMessagePreview", () => {
);
});

it.each([
["call thread_target_list then delegate_task", "call thread_target_list then delegate_task"],
["see `src/__tests__/a_b.ts`", "see src/__tests__/a_b.ts"],
["open https://example.com/wiki/Foo_(bar)", "open https://example.com/wiki/Foo_(bar)"],
["fix issue #2269 when a > b and [x] is set", "fix issue #2269 when a > b and [x] is set"],
["2 * 3 * 4 = 24", "2 * 3 * 4 = 24"],
])("keeps literal characters in %j", (content, expected) => {
expect(resolveUserMessagePreview(userMessage({ content }))).toBe(expected);
});

it.each([
["## Plan\n> quoted _note_", "Plan quoted note"],
["read [the docs](https://example.com/a_(b)) first", "read the docs first"],
["__bold__ and ~~gone~~", "bold and gone"],
])("drops markdown syntax in %j", (content, expected) => {
expect(resolveUserMessagePreview(userMessage({ content }))).toBe(expected);
});

it("returns goal conditions for /goal set commands", () => {
expect(resolveUserMessagePreview(userMessage({ content: "/goal ship sticky messages" }))).toBe(
"ship sticky messages",
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/components/chat/user-message-preview.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PLAN_ANSWER_MESSAGE_PREFIX } from "@mcode/contracts";
import { stripInjectedFiles } from "@/lib/file-tags";
import { stripInlineMarkdown } from "@/lib/inline-markdown";
import type { Message } from "@/transport";

/**
Expand All @@ -16,16 +17,16 @@ function parseUserGoalCommand(content: string): { condition: string } | null {
return { condition: arg };
}

const BLOCK_MARKER = /^[ \t]{0,3}(?:#{1,6}[ \t]+|(?:>[ \t]?)+)/gm;

/** Collapses markdown noise so sticky previews stay readable in one or two lines. */
function toPlainPreview(text: string): string {
const codeBlocks = [...text.matchAll(/```[\w-]*\n?([\s\S]*?)```/g)]
.map((match) => match[1]?.trim() ?? "")
.filter(Boolean);

const plain = text
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]+)`/g, "$1")
.replace(/[#*_>~[\]()]/g, "")
const plain = stripInlineMarkdown(text.replace(/```[\s\S]*?```/g, " "))
.replace(BLOCK_MARKER, "")
.replace(/\s+/g, " ")
.trim();

Expand Down
16 changes: 3 additions & 13 deletions apps/web/src/features/updates/release-notes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { stripInlineMarkdown } from "@/lib/inline-markdown";

/** Release notes reduced to plain text the update card can render safely. */
export interface ParsedReleaseNotes {
/** Up to {@link MAX_RELEASE_NOTE_ITEMS} list items, as plain text. */
Expand All @@ -16,8 +18,6 @@ const MARKDOWN_HEADING = /^\s{0,3}#{1,6}\s/;
const EXECUTABLE_BLOCK = /<(script|style)\b[^>]*>[\s\S]*?(?:<\/\1\s*>|$)/gi;
const HTML_HEADING_BLOCK = /<h[1-6]\b[^>]*>[\s\S]*?<\/h[1-6]\s*>/gi;
const BLOCK_BOUNDARY = /<\/?(?:p|div|ul|ol|li|br|hr|h[1-6]|blockquote|pre|table|tr)\b[^>]*>/gi;
// Link targets may hold one level of parentheses, as in javascript:alert(1).
const MARKDOWN_LINK = /!?\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)/g;
const ANY_TAG = /<\/?[a-z!][^>]*>/gi;

const NAMED_ENTITIES: Readonly<Record<string, string>> = {
Expand All @@ -39,20 +39,10 @@ function decodeEntities(text: string): string {
});
}

function stripMarkdown(text: string): string {
return text
.replace(MARKDOWN_LINK, "$1")
.replace(/\[([^\]]*)\]\[[^\]]*\]/g, "$1")
.replace(/`+([^`]*)`+/g, "$1")
.replace(/(\*\*|__)(.+?)\1/g, "$2")
.replace(/(^|[^\w*])[*_]([^*_]+)[*_](?=[^\w*]|$)/g, "$1$2")
.replace(/~~(.+?)~~/g, "$1");
}

/** Reduces one fragment of HTML or markdown to a single line of plain text. */
function toPlainText(fragment: string): string {
const withoutTags = fragment.replace(EXECUTABLE_BLOCK, " ").replace(BLOCK_BOUNDARY, " ").replace(ANY_TAG, "");
return decodeEntities(stripMarkdown(withoutTags)).replace(/\s+/g, " ").trim();
return decodeEntities(stripInlineMarkdown(withoutTags)).replace(/\s+/g, " ").trim();
}

function htmlListItems(notes: string): string[] {
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/lib/inline-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const CODE_SPAN = /(`+)([\s\S]*?)\1/g;
const INLINE_LINK_ONE_PAREN_LEVEL = /!?\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)/g;
const REFERENCE_LINK = /\[([^\]]*)\]\[[^\]]*\]/g;
// Delimiters only count when they hug non-space text, so `2 * 3 * 4` keeps its stars.
const STRONG_STARS = /\*\*(?=\S)([\s\S]*?\S)\*\*/g;
const EMPHASIS_STAR = /\*(?=[^\s*])([^*]*?[^\s*])\*/g;
// Underscores never open or close inside a word, so `snake_case` stays intact.
const STRONG_UNDERSCORES = /(?<!\w)__(?=\S)([\s\S]*?\S)__(?!\w)/g;
const EMPHASIS_UNDERSCORE = /(?<!\w)_(?=[^\s_])([^_]*?[^\s_])_(?!\w)/g;
const STRIKETHROUGH = /~~(?=\S)([\s\S]*?\S)~~/g;

function stripDelimiters(prose: string): string {
return prose
.replace(INLINE_LINK_ONE_PAREN_LEVEL, "$1")
.replace(REFERENCE_LINK, "$1")
.replace(STRONG_STARS, "$1")
.replace(STRONG_UNDERSCORES, "$1")
.replace(EMPHASIS_STAR, "$1")
.replace(EMPHASIS_UNDERSCORE, "$1")
.replace(STRIKETHROUGH, "$1");
}

/**
* Removes inline Markdown syntax (links, emphasis, strikethrough, code-span
* backticks) and keeps every other character. Code span contents are kept
* verbatim because their underscores and brackets are literal.
*/
export function stripInlineMarkdown(text: string): string {
let plain = "";
let proseStart = 0;
for (const match of text.matchAll(CODE_SPAN)) {
plain += stripDelimiters(text.slice(proseStart, match.index)) + (match[2] ?? "");
proseStart = match.index + match[0].length;
}
return plain + stripDelimiters(text.slice(proseStart));
}
Loading