fix(gateway): render agent Markdown in Slack row bodies, with native lists (#2316)

A queue row's body written in Markdown now renders in Slack instead of showing raw **bold**, ## headings, [text](url) links and hyphen lists. Bold, heading lines, links (including bare URLs in list items), code spans and fences render as Slack mrkdwn, and lists become native Slack rich_text list blocks, with nesting and ordered numbering, falling back to text at the block limit. A hand-written Slack link <url|text> is kept as a link. Mentions and Slack control syntax stay literal text, unsafe links aren't linked, and secret redaction runs as before. A fenced code block split across several messages is closed and reopened in each part, with room reserved for the delimiters. Source +210/-8, tests +252.
This commit is contained in:
David Riott authored and GitHub committed 2026-11-01 07:16:45 -08:00
1 parent 660598e641
commit cd8f8b4f08
3 files changed
+462 -8

No files matched your search

@@ -29,6 +29,8 @@ export interface QitemLike {
/** #1013 — the structured evidenceRef: for a parked gate it rides the evidence link/media
* path when it is an https ref, else it renders as one Evidence line (never the packet). */
evidenceRef?: string | null;
/** The code fence an earlier part of a split brief left open; this body starts inside it. */
bodyOpenFence?: string | null;
}
/** #1013 — a workflow gate row parked on a human seat (the projector parks it
@@ -220,6 +222,196 @@ function inert(text: string): string {
return escapeSlackText(redactSecrets(text));
}
/** Split a matched bare URL from what follows it in the sentence: trailing punctuation, a
* `)` or `]` with no opening partner inside the URL, and closing `**` or unbalanced `__`
* markers. `…/Function_(mathematics)` and `#object.__init__` stay whole. */
function trimBareUrl(match: string): [url: string, rest: string] {
const count = (s: string, part: string) => s.split(part).length - 1;
let url = match;
for (;;) {
if (url.endsWith("**")) url = url.slice(0, -2);
else if (url.endsWith("__") && count(url, "__") % 2 === 1) url = url.slice(0, -2);
else if (/[.,;:!?]$/.test(url)) url = url.slice(0, -1);
else if (url.endsWith(")") && count(url, ")") > count(url, "(")) url = url.slice(0, -1);
else if (url.endsWith("]") && count(url, "]") > count(url, "[")) url = url.slice(0, -1);
else return [url, match.slice(url.length)];
}
}
/** Convert one non-fenced, already-redacted line of Markdown to inert Slack mrkdwn. The line is
* escaped first, so it holds no raw `<` or `>`, and `<n>` can mark a code span or link set aside
* without colliding with input. A Markdown `[text](url)` or a Slack `<url|text>` / `<url>` becomes
* a live link only for a safe https URL without `<`, `>` or `|` and a label (code included)
* without `|`; otherwise it stays escaped text. */
function inlineMarkdownToMrkdwn(line: string): string {
const held: string[] = [];
const hold = (rendered: string) => `<${held.push(rendered) - 1}>`;
const restore = (s: string) => s.replace(/<(\d+)>/g, (_, i: string) => held[Number(i)] ?? "");
const link = (original: string, url: string, label?: string) => {
const text = label === undefined ? undefined : restore(label);
const raw = url.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
if (!isSafeHttpsUrl(raw) || /[<>|]/.test(raw) || text?.includes("|")) return original;
return hold(text === undefined ? `<${url}>` : `<${url}|${text}>`);
};
return restore(escapeSlackText(line)
.replace(/`[^`\n]+`/g, hold)
.replace(/\[([^\]\n]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (m, label: string, url: string) => link(m, url, label))
.replace(/&lt;(https:\/\/[^\s|]+?)(?:\|(.+?))?&gt;/g, (m, url: string, label?: string) => link(m, url, label))
// A bare URL is held as written: Slack links it, and `__` or `**` inside it is not bold.
.replace(/https:\/\/[^\s<>|`]+/g, (m) => {
const [url, rest] = trimBareUrl(m);
return hold(url) + rest;
})
.replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, "*$1*")
.replace(/__(?=\S)(.+?)(?<=\S)__/g, "*$1*"));
}
const FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/;
/** One pass over the lines: the converted text, and the fence still open at its end ("" if none). */
function convertMarkdown(text: string, openFence: string): { text: string; openFence: string } {
let fence = openFence;
const lines = redactSecrets(text).split("\n").map((line) => {
const marker = FENCE_PATTERN.exec(line)?.[1];
if (fence) {
if (marker && marker[0] === fence[0] && marker.length >= fence.length && /^ {0,3}[`~]+\s*$/.test(line)) fence = "";
return escapeSlackText(line);
}
if (marker) {
fence = marker;
return escapeSlackText(line);
}
const heading = /^#{1,6}\s+(.*?)(?:\s+#+)?\s*$/.exec(line);
if (heading) {
if (!heading[1]) return "";
const inner = inlineMarkdownToMrkdwn(heading[1]);
return /^\*[^*\n]+\*$/.test(inner) ? inner : `*${inner}*`;
}
// Section mrkdwn has no list syntax; a plain "- " reads better than a typed bullet character.
const item = /^(\s*)[-*+]\s+(.*)$/.exec(line);
if (item) {
const [, indent = "", rest = ""] = item;
return `${indent}- ${inlineMarkdownToMrkdwn(rest)}`;
}
return inlineMarkdownToMrkdwn(line);
});
return { text: lines.join("\n"), openFence: fence };
}
/** Agent-written Markdown as Slack mrkdwn, through the same inert pipeline as every other
* queue-controlled field (secrets redacted, then &, <, > escaped). `**b**`/`__b__` become
* `*b*`, `#` headings a bold line, `-`/`*`/`+` list items `- ` (indent kept), and safe https
* `[text](url)` and `<url|text>` links live links. A list marker needs a following space, so
* `*bold*` is not a list item. Code spans and ``` or ~~~ fenced blocks are only escaped. `openFence` is the
* fence a split brief's earlier part left open (see openFenceAfter). */
export function markdownToSlackMrkdwn(text: string, openFence = ""): string {
const converted = convertMarkdown(text, openFence);
// Slack knows only ``` blocks, and only inside one message: a part that starts or ends inside one is wrapped to match.
const head = openFence.startsWith("`") ? "```\n" : "";
const tail = converted.openFence.startsWith("`") ? "\n```" : "";
return `${head}${converted.text}${tail}`;
}
/** Room a split part needs for the ``` lines markdownToSlackMrkdwn may add around it. */
export const CODE_FENCE_ROOM = 8;
/** The fence still open after `text`, so the next part of a split brief continues inside it. */
export function openFenceAfter(text: string, openFence = ""): string {
return convertMarkdown(text, openFence).openFence;
}
type RichInline =
| { type: "text"; text: string; style?: { bold?: boolean; code?: boolean } }
| { type: "link"; url: string; text?: string };
// Code spans, Markdown links, Slack links, bare https URLs, then **bold**, __bold__ and *bold*.
const RICH_INLINE = /`([^`\n]+)`|\[([^\]\n]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)|<(https:\/\/[^\s|>]+)(?:\|([^>\n]+))?>|(https:\/\/[^\s<>|`]+)|\*\*(?=\S)(.+?)(?<=\S)\*\*|__(?=\S)(.+?)(?<=\S)__|\*(?=\S)([^*\n]+?)(?<=\S)\*/g;
/** One already-redacted list item as rich text elements. Rich text is never parsed for
* mentions or links, so plain text needs no escaping; a link element is emitted only for a
* URL that passes the same checks as a mrkdwn link. */
function richInline(text: string): RichInline[] {
const out: RichInline[] = [];
const plain = (t: string) => {
const last = out.at(-1);
if (last?.type === "text" && !last.style) last.text += t;
else if (t) out.push({ type: "text", text: t });
};
const re = new RegExp(RICH_INLINE.source, "g");
let at = 0;
for (let m = re.exec(text); m; m = re.exec(text)) {
plain(text.slice(at, m.index));
const [all, code, mdLabel, mdUrl, slackUrl, slackLabel, bare, b1, b2, b3] = m;
const url = mdUrl ?? slackUrl;
const label = mdLabel ?? slackLabel;
const bold = b1 ?? b2 ?? b3;
at = m.index + all.length;
if (code !== undefined) out.push({ type: "text", text: code, style: { code: true } });
else if (bare !== undefined) {
const [target] = trimBareUrl(bare);
if (isSafeHttpsUrl(target)) out.push({ type: "link", url: target });
else plain(target);
// What follows the URL (closing markers included) is parsed again.
at = re.lastIndex = m.index + target.length;
} else if (bold !== undefined) {
// Bold content is parsed again, so a link inside stays a link and the text around it is bold.
for (const el of richInline(bold)) out.push(el.type === "text" ? { ...el, style: { ...el.style, bold: true } } : el);
} else if (url !== undefined && isSafeHttpsUrl(url) && !/[<>|]/.test(url)) out.push(label ? { type: "link", url, text: label } : { type: "link", url });
else plain(all);
}
plain(text.slice(at));
return out.length ? out : [{ type: "text", text: " " }];
}
const LIST_ITEM = /^(\s*)(?:([-*+])|(\d{1,9})[.)])\s+(.*)$/;
// ponytail: Slack documents no indent maximum; 4 covers real briefs and keeps clear of invalid_blocks.
const MAX_LIST_INDENT = 4;
/** The body as blocks: runs of list items (outside code fences) become one native `rich_text`
* list block each; everything else stays mrkdwn sections. Nesting is two spaces per level. */
export function buildBodyBlocks(text: string, openFence = ""): unknown[] {
type List = { type: "rich_text_list"; style: "bullet" | "ordered"; indent: number; offset?: number; elements: unknown[] };
const blocks: unknown[] = [];
let fence = openFence;
let runFence = openFence;
let run: string[] = [];
let lists: List[] = [];
const flushText = () => {
const source = run.join("\n");
run = [];
if (!source.trim()) return;
const converted = markdownToSlackMrkdwn(source, runFence);
if (converted.trim()) blocks.push({ type: "section", text: { type: "mrkdwn", text: bounded(converted, SLACK_SECTION_CAP, "body") } });
};
const flushLists = () => {
if (lists.length) blocks.push({ type: "rich_text", elements: lists });
lists = [];
};
for (const line of redactSecrets(text).split("\n")) {
const item = fence ? null : LIST_ITEM.exec(line);
if (item) {
flushText();
const [, lead = "", bullet, number, rest = ""] = item;
const style = bullet ? "bullet" : "ordered";
const indent = Math.min(MAX_LIST_INDENT, Math.floor(lead.replaceAll("\t", " ").length / 2));
const section = { type: "rich_text_section", elements: richInline(rest) };
const last = lists.at(-1);
if (last && last.style === style && last.indent === indent) last.elements.push(section);
// An ordered run keeps its own numbering (a list starting at 5, or resuming after a nested list or a split).
else if (number && Number(number) > 1) lists.push({ type: "rich_text_list", style, indent, offset: Number(number) - 1, elements: [section] });
else lists.push({ type: "rich_text_list", style, indent, elements: [section] });
continue;
}
flushLists();
if (!run.length) runFence = fence;
run.push(line);
fence = openFenceAfter(line, fence);
}
flushText();
flushLists();
return blocks;
}
function isSafeHttpsUrl(url: string): boolean {
if (!/^https:\/\/\S+$/.test(url) || containsSecret(url)) return false;
try {
@@ -389,7 +581,7 @@ export function buildOutboundMessage(q: QitemLike, opts: OutboundMessageOpts): S
? (!gateEvidence || gateEvidenceCarried
? ""
: bounded(`Evidence: ${inert(gateEvidence)}`, SLACK_SECTION_CAP, "body"))
: bounded(inert(String(q.body || "")), SLACK_SECTION_CAP, "body");
: bounded(markdownToSlackMrkdwn(String(q.body || ""), q.bodyOpenFence ?? ""), SLACK_SECTION_CAP, "body");
const mention = opts.mentionUserId ? `<@${opts.mentionUserId}> :rotating_light: ` : "";
const headline = bounded(`${mention}*${summary}*`, SLACK_SECTION_CAP, "subject");
const machine = opts.attribution && !opts.attribution.host && opts.machine ? ` on ${inert(opts.machine)}` : "";
@@ -419,11 +611,16 @@ export function buildOutboundMessage(q: QitemLike, opts: OutboundMessageOpts): S
const repeatBlock = shownRepeat ? { type: "context", elements: [{ type: "mrkdwn", text: shownRepeat }] } : null;
if (repeatBlock) blocks.push(repeatBlock);
if (shownContext) blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: shownContext }] });
if (body.trim()) blocks.push({ type: "section", text: { type: "mrkdwn", text: body } });
const bodySection = { type: "section", text: { type: "mrkdwn", text: body } };
const bodyBlocks = !body.trim() ? [] : humanGate ? [bodySection] : buildBodyBlocks(String(q.body || ""), q.bodyOpenFence ?? "");
const bodyAt = blocks.length;
blocks.push(...bodyBlocks);
if (questionParts) blocks.push(...questionParts.blocks);
if (replyHint) blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: replyHint }] });
blocks.push(...imageBlocks);
if (evidence) blocks.push(evidence.block);
// A body split into many list and text runs falls back to one mrkdwn section rather than refusing the message.
if (blocks.length > 50 && bodyBlocks.length > 1) blocks.splice(bodyAt, bodyBlocks.length, bodySection);
if (repeatBlock && blocks.length > 50) blocks.splice(blocks.indexOf(repeatBlock), 1);
if (blocks.length > 50) throw new HumanMessageShapeError("Message exceeds 50 Slack blocks. Reduce attachments before sending.");
return { text, blocks };
@@ -18,7 +18,7 @@ import fs from "node:fs";
import path from "node:path";
import { postChatMessage, getUploadURLExternal, uploadBytesExternal, completeUploadExternal, fetchRecentMessageTexts, type FetchImpl } from "./slack-api.js";
import {
buildOutboundMessage, attributionFromSession, reconcileToken, redactSecrets, splitForSlack,
buildOutboundMessage, attributionFromSession, CODE_FENCE_ROOM, openFenceAfter, reconcileToken, redactSecrets, splitForSlack,
HumanMessageShapeError, SLACK_SECTION_CAP, SLACK_TEXT_CAP, type SlackMediaRef,
} from "./message.js";
import type { SeenStore } from "./state-store.js";
@@ -241,7 +241,7 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
log(`delivery: decision ${decision.decisionId} already delivered — re-ack, no re-post`);
return { ok: true };
}
const q = (decision.payload ?? {}) as OutboundPostPayload & { media?: SlackMediaRef[] };
const q = (decision.payload ?? {}) as DeliveryPart;
// #192: the channel first, from this post's record when it has one, so the thread below is
// chosen inside the channel the post actually goes to (and went to, on a retry).
const pinned = firstAttemptChannel(opts, opts.attempted.load(), decision.decisionId);
@@ -302,6 +302,7 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
state: q.state,
tags: q.tags,
evidenceRef: q.evidenceRef,
bodyOpenFence: q.bodyOpenFence,
},
{
sourceLabel: opts.sourceLabel,
@@ -531,7 +532,7 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
};
}
type DeliveryPart = OutboundPostPayload & { media?: SlackMediaRef[] };
type DeliveryPart = OutboundPostPayload & { media?: SlackMediaRef[]; bodyOpenFence?: string };
/** #897 — the most Slack messages one ask may post: the primary plus its thread replies. */
export const MAX_HUMAN_MESSAGE_PARTS = 20;
@@ -609,7 +610,7 @@ function splitIntoParts(opts: SubsystemSlackDeliveryOpts, q: DeliveryPart, decis
// A part's room is what its subject, options, evidence, sender and marker leave of both limits.
const roomFor = (part: DeliveryPart, index: number, reserve = 0): number => {
const frame = renderPart(opts, q, { ...part, body: "" }, index, partIdFor(decisionId, 2, index));
return Math.min(SLACK_SECTION_CAP, SLACK_TEXT_CAP - frame.text.length - 1) - reserve;
return Math.min(SLACK_SECTION_CAP, SLACK_TEXT_CAP - frame.text.length - 1) - reserve - CODE_FENCE_ROOM;
};
const primary: DeliveryPart = { ...q, humanDetail: undefined };
// A retry replays its first attempt's cuts, so no piece moves between the parts already posted and the rest.
@@ -623,10 +624,14 @@ function splitIntoParts(opts: SubsystemSlackDeliveryOpts, q: DeliveryPart, decis
// A brief that fits keeps the usual note; only a brief that continues says so.
const note = body.length > 1 ? CONTINUES_NOTE : DETAIL_NOTE;
const parts: DeliveryPart[] = [{ ...primary, body: body[0] ? `${body[0]}\n\n${note}` : note }];
for (let k = 1; k < body.length; k++) parts.push({ ...reply(`Continued (${k + 1} of ${body.length})`), body: body[k] });
// A piece cut inside a code fence keeps rendering as code in the next part.
const fences = (pieces: string[]) => { let open = ""; return pieces.map((piece) => { const start = open; open = openFenceAfter(piece, open); return start; }); };
const bodyFences = fences(body);
for (let k = 1; k < body.length; k++) parts.push({ ...reply(`Continued (${k + 1} of ${body.length})`), body: body[k], bodyOpenFence: bodyFences[k] });
// #1013 — a detail piece carries the human's own content, never the agent packet, so the
// parked-gate state must not ride it (the gate check would eat its body like the packet's).
detail.forEach((piece, j) => parts.push({ ...reply(`Supplemental detail (${j + 1} of ${detail.length})`), body: piece, state: null }));
const detailFences = fences(detail);
detail.forEach((piece, j) => parts.push({ ...reply(`Supplemental detail (${j + 1} of ${detail.length})`), body: piece, state: null, bodyOpenFence: detailFences[j] }));
if (parts.length > MAX_HUMAN_MESSAGE_PARTS) {
throw new HumanMessageShapeError(`This brief needs ${parts.length} Slack messages (maximum ${MAX_HUMAN_MESSAGE_PARTS}). Shorten it, or put the long part in a file and link it.`);
}
@@ -0,0 +1,252 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildBodyBlocks, buildOutboundMessage, containsSecret, escapeSlackText, markdownToSlackMrkdwn, openFenceAfter, splitForSlack } from "../src/domain/gateway/slack/message.js";
import { subsystemSlackDeliver } from "../src/domain/gateway/slack/slack-delivery.js";
import { SeenStore } from "../src/domain/gateway/slack/state-store.js";
import type { OutboundDecision } from "../src/domain/gateway/protocol.js";
const md = markdownToSlackMrkdwn;
type Item = Array<Record<string, unknown>>;
const list = (lists: Array<{ style: string; indent: number; offset?: number; items: Item[] }>) => ({
type: "rich_text",
elements: lists.map(({ style, indent, offset, items }) => ({ type: "rich_text_list", style, indent, ...(offset ? { offset } : {}), elements: items.map((elements) => ({ type: "rich_text_section", elements })) })),
});
const t = (text: string, style?: Record<string, boolean>) => (style ? { type: "text", text, style } : { type: "text", text });
describe("Markdown in a row body renders as Slack mrkdwn", () => {
it("turns **bold** and __bold__ into *bold*", () => {
expect(md("a **strong** word and __another__ one")).toBe("a *strong* word and *another* one");
});
it("turns # and ## headings into a bold line", () => {
expect(md("# Title\ntext\n## Next step ##")).toBe("*Title*\ntext\n*Next step*");
expect(md("## **Plan**\n# __Done__")).toBe("*Plan*\n*Done*");
});
it("turns a safe https [text](url) into <url|text>", () => {
expect(md("see [the PR](https://example.com/pr/1?a=1&b=2) now")).toBe("see <https://example.com/pr/1?a=1&amp;b=2|the PR> now");
});
it("leaves a non-https or unsafe link as escaped text", () => {
expect(md("[x](http://example.com)")).toBe("[x](http://example.com)");
expect(md("[x](https://user:[email protected])")).toBe("[x](https://user:[email protected])");
expect(md("[a|b](https://example.com)")).toBe("[a|b](https://example.com)");
});
it("writes -, * and + list items as \"- \", keeping indentation", () => {
expect(md("- one\n* two\n+ three\n - nested\n * deeper")).toBe("- one\n- two\n- three\n - nested\n - deeper");
});
it("does not read a *bold* line as a list item", () => {
expect(md("*bold* start\n* item")).toBe("*bold* start\n- item");
});
it("keeps numbered lists as text", () => {
expect(md("1. first\n2. second")).toBe("1. first\n2. second");
});
it("leaves code spans and fenced blocks untouched apart from escaping", () => {
expect(md("run `**x** [a](https://e.com)` here")).toBe("run `**x** [a](https://e.com)` here");
expect(md("```\n# not a heading\n- not a list\n**x** <y>\n```\n- a list")).toBe(
"```\n# not a heading\n- not a list\n**x** &lt;y&gt;\n```\n- a list",
);
});
it("keeps a code span inside a link label", () => {
expect(md("[`npm test`](https://example.com)")).toBe("<https://example.com|`npm test`>");
});
it("still neutralizes Slack control syntax and redacts secrets", () => {
expect(md("**hi** <!channel> <@U123> & co")).toBe("*hi* &lt;!channel&gt; &lt;@U123&gt; &amp; co");
expect(md("[a](https://example.com/<!here>)")).toBe("[a](https://example.com/&lt;!here&gt;)");
const out = md("- token xoxb-EXAMPLE-000000000000-doNotUseFake\n[hook](https://hooks.slack.com/services/T00/B00/xyz)");
expect(containsSecret(out)).toBe(false);
expect(out).toContain("- token [redacted-secret]");
expect(out).not.toContain("<");
});
it("is applied to the body section and the fallback text of a posted message", () => {
const m = buildOutboundMessage({ qitemId: "q", summary: "s", body: "## Plan\n- **ship** [it](https://example.com)" }, { sourceLabel: "host" });
expect(m.text).toContain("*Plan*\n- *ship* <https://example.com|it>");
expect(m.blocks).toContainEqual({ type: "section", text: { type: "mrkdwn", text: "*Plan*" } });
expect(m.blocks).toContainEqual(list([{ style: "bullet", indent: 0, items: [[{ type: "text", text: "ship", style: { bold: true } }, { type: "text", text: " " }, { type: "link", url: "https://example.com", text: "it" }]] }]));
});
it("keeps input that looks like an internal marker literal, without growing the body", () => {
expect(md("before \uE0000\uE001 after <0> <1>")).toBe("before \uE0000\uE001 after &lt;0&gt; &lt;1&gt;");
expect(md("`hello` <0>")).toBe("`hello` &lt;0&gt;");
const long = "`" + "x".repeat(1600) + "` <0> \uE0000\uE001";
expect(md(long).length).toBeLessThanOrEqual(escapeSlackText(long).length);
expect(() => buildOutboundMessage({ qitemId: "q", summary: "s", body: long }, { sourceLabel: "host" })).not.toThrow();
});
it("checks a link label for | after restoring its code", () => {
expect(md("[`a|b`](https://example.com)")).toBe("[`a|b`](https://example.com)");
});
it("closes a fence only with the same character and at least the opening length", () => {
expect(md("````\n```\n# literal\n```\n````\n# heading")).toBe("````\n```\n# literal\n```\n````\n*heading*");
expect(md("~~~\n# literal\n**literal**\n```\n~~~\n**bold**")).toBe("~~~\n# literal\n**literal**\n```\n~~~\n*bold*");
});
it("continues an open fence into the next part of a split brief", () => {
const body = "```\n" + "x".repeat(1900) + "\n# literal\n**literal**\n```\n# after";
const pieces = splitForSlack(body, () => 1910);
expect(pieces.length).toBe(2);
expect(md(pieces[0]!).endsWith("\n```")).toBe(true);
const second = md(pieces[1]!, openFenceAfter(pieces[0]!));
expect(second.startsWith("```\n# literal\n**literal**\n```")).toBe(true);
expect(second.endsWith("*after*")).toBe(true);
expect(md("~~~\nx", "~~~")).toBe("~~~\nx");
});
it("delivers a backtick code block split across Slack messages as a complete ``` block in every part", async () => {
const home = mkdtempSync(join(tmpdir(), "slack-mrkdwn-"));
try {
const posts: Array<{ blocks: Array<{ text?: { text: string } }> }> = [];
const deliver = subsystemSlackDeliver({
delivered: new SeenStore(join(home, "delivered")), attempted: new SeenStore(join(home, "attempted")), outboundSeen: new SeenStore(join(home, "seen")),
botToken: "synthetic", channel: "C", sourceLabel: "fixture",
fetchImpl: async (_url, init) => {
posts.push(JSON.parse(String(init?.body)));
return new Response(JSON.stringify({ ok: true, ts: `${posts.length}.1` }), { status: 200, headers: { "content-type": "application/json" } });
},
});
const body = "```\n" + "x ".repeat(2000) + "\n# literal\n- literal\n**literal**\n```\n## Next\n- item";
const decision: OutboundDecision = { kind: "outbound_decision", decisionId: "d", op: "post_message", entityBindingRef: "h@external",
payload: { qitemId: "q-d", summary: "s", body, sourceSession: "a@rig", destinationSession: "h@external" } };
expect(await deliver(decision)).toEqual({ ok: true });
expect(posts.length).toBeGreaterThan(1);
const last = posts.at(-1)!.blocks;
for (const p of posts) {
const code = p.blocks.map((b) => b.text?.text ?? "").join("\n").split("```").length - 1;
expect(code % 2).toBe(0);
}
expect(last.map((b) => b.text?.text ?? "").join("\n")).toContain("# literal\n- literal\n**literal**\n```\n*Next*");
expect(last.find((b) => b.text?.text.includes("# literal"))!.text!.text.startsWith("```\n")).toBe(true);
expect(last).toContainEqual(list([{ style: "bullet", indent: 0, items: [[{ type: "text", text: "item" }]] }]));
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it("keeps a Slack-format link written by hand live, under the same checks", () => {
expect(md("- <https://github.com/o/r/pull/1|PR #1> and <https://example.com/a?b=1&c=2>")).toBe(
"- <https://github.com/o/r/pull/1|PR #1> and <https://example.com/a?b=1&amp;c=2>",
);
expect(md("<http://example.com|x> <https://user:[email protected]|x> <!channel> <@U1> <https://e.com|a|b>")).toBe(
"&lt;http://example.com|x&gt; &lt;https://user:[email protected]|x&gt; &lt;!channel&gt; &lt;@U1&gt; &lt;https://e.com|a|b&gt;",
);
expect(md("`<https://example.com|x>`")).toBe("`&lt;https://example.com|x&gt;`");
});
});
describe("list items in a row body render as native Slack lists", () => {
it("groups items into rich_text lists, nested by indent, bullet or ordered", () => {
expect(buildBodyBlocks("Intro\n- one\n* two\n + nested\n- three\n1. first\n2) second\nOutro")).toEqual([
{ type: "section", text: { type: "mrkdwn", text: "Intro" } },
list([
{ style: "bullet", indent: 0, items: [[t("one")], [t("two")]] },
{ style: "bullet", indent: 1, items: [[t("nested")]] },
{ style: "bullet", indent: 0, items: [[t("three")]] },
{ style: "ordered", indent: 0, items: [[t("first")], [t("second")]] },
]),
{ type: "section", text: { type: "mrkdwn", text: "Outro" } },
]);
});
it("styles bold and code, and links only safe https URLs", () => {
const [block] = buildBodyBlocks("- **a** __b__ *c* `**d**` [e](https://e.com) <https://f.com|f> <https://g.com> [h](http://h.com) <https://u:[email protected]|i>") as Array<{ elements: Array<{ elements: Array<{ elements: unknown[] }> }> }>;
expect(block!.elements[0]!.elements[0]!.elements).toEqual([
t("a", { bold: true }), t(" "), t("b", { bold: true }), t(" "), t("c", { bold: true }), t(" "), t("**d**", { code: true }), t(" "),
{ type: "link", url: "https://e.com", text: "e" }, t(" "), { type: "link", url: "https://f.com", text: "f" }, t(" "),
{ type: "link", url: "https://g.com" }, t(" [h](http://h.com) <https://u:[email protected]|i>"),
]);
});
it("keeps mentions as literal text, redacts secrets, and never emits empty text", () => {
const json = JSON.stringify(buildBodyBlocks("- <!channel> <@U1> xoxb-EXAMPLE-000000000000-doNotUseFake\n- "));
expect(json).toContain('"text":"<!channel> <@U1> [redacted-secret]"');
expect(containsSecret(json)).toBe(false);
expect(json).not.toContain('"text":""');
});
it("leaves list lines inside a code fence in the mrkdwn section", () => {
expect(buildBodyBlocks("```\n- not a list\n```\n- a list")).toEqual([
{ type: "section", text: { type: "mrkdwn", text: "```\n- not a list\n```" } },
list([{ style: "bullet", indent: 0, items: [[t("a list")]] }]),
]);
expect(buildBodyBlocks("- still code\n```", "```")[0]).toEqual({ type: "section", text: { type: "mrkdwn", text: "```\n- still code\n```" } });
});
it("falls back to one mrkdwn section when list runs would pass 50 blocks", () => {
const body = Array.from({ length: 30 }, (_, i) => `text ${i}\n- item ${i}`).join("\n");
const m = buildOutboundMessage({ qitemId: "q", summary: "s", body }, { sourceLabel: "host" });
expect(m.blocks.length).toBeLessThanOrEqual(50);
expect(m.blocks.some((b) => (b as { type: string }).type === "rich_text")).toBe(false);
expect(m.text).toContain("text 29\n- item 29");
});
it("never emits an empty section around a list", () => {
for (const body of ["# \n- item", "- item\n# ", "\n# \n- item\n \n"]) {
expect(buildBodyBlocks(body)).toEqual([list([{ style: "bullet", indent: 0, items: [[t("item")]] }])]);
}
});
it("keeps ordered numbering when a list starts later or resumes after nesting", () => {
expect(buildBodyBlocks("5. five\n6. six")).toEqual([list([{ style: "ordered", indent: 0, offset: 4, items: [[t("five")], [t("six")]] }])]);
expect(buildBodyBlocks("1. one\n - sub\n2. two")).toEqual([list([
{ style: "ordered", indent: 0, items: [[t("one")]] },
{ style: "bullet", indent: 1, items: [[t("sub")]] },
{ style: "ordered", indent: 0, offset: 1, items: [[t("two")]] },
])]);
});
it("adds no empty code section before a list when a part continues a code block", () => {
const m = buildOutboundMessage({ qitemId: "q", summary: "s", body: "code\n```\n- one\n- two", bodyOpenFence: "```" }, { sourceLabel: "host" });
const body = m.blocks.slice(2);
expect(body).toEqual([
{ type: "section", text: { type: "mrkdwn", text: "```\ncode\n```" } },
list([{ style: "bullet", indent: 0, items: [[t("one")], [t("two")]] }]),
]);
});
it("links a bare https URL in a list item, and keeps it byte for byte", () => {
const url = "https://docs.python.org/3/reference/datamodel.html#object.__init__";
expect(buildBodyBlocks(`- PR: https://github.com/o/r/pull/1\n- see ${url}.\n- https://u:[email protected]`)).toEqual([list([{ style: "bullet", indent: 0, items: [
[t("PR: "), { type: "link", url: "https://github.com/o/r/pull/1" }],
[t("see "), { type: "link", url }, t(".")],
[t("https://u:[email protected]")],
] }])]);
});
it("keeps a bare URL out of bold conversion in mrkdwn text", () => {
const url = "https://docs.python.org/3/reference/datamodel.html#object.__init__";
expect(md(`see ${url} and **b**`)).toBe(`see ${url} and *b*`);
expect(md("https://example.com/a?x=1&y=__z__")).toBe("https://example.com/a?x=1&amp;y=__z__");
});
it("keeps balanced parentheses in a bare URL and leaves sentence brackets outside", () => {
const wiki = "https://en.wikipedia.org/wiki/Function_(mathematics)";
expect(buildBodyBlocks(`- ${wiki}\n- (see ${wiki}).`)).toEqual([list([{ style: "bullet", indent: 0, items: [
[{ type: "link", url: wiki }],
[t("(see "), { type: "link", url: wiki }, t(").")],
] }])]);
expect(md(`(see ${wiki}).`)).toBe(`(see ${wiki}).`);
});
it("keeps bold that encloses a bare URL", () => {
expect(md("**See https://example.com** and __See https://example.com__")).toBe("*See https://example.com* and *See https://example.com*");
expect(buildBodyBlocks("- **See https://example.com** now")).toEqual([list([{ style: "bullet", indent: 0, items: [
[t("See ", { bold: true }), { type: "link", url: "https://example.com" }, t(" now")],
] }])]);
});
it("keeps balanced parentheses in a Markdown link target", () => {
const wiki = "https://en.wikipedia.org/wiki/Function_(mathematics)";
expect(md(`see [wiki](${wiki}).`)).toBe(`see <${wiki}|wiki>.`);
expect(buildBodyBlocks(`- [wiki](${wiki})`)).toEqual([list([{ style: "bullet", indent: 0, items: [[{ type: "link", url: wiki, text: "wiki" }]] }])]);
});
});