-
Notifications
You must be signed in to change notification settings - Fork 30
fix(slack): Unify finalized reply rendering #219
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
47afd67
b04f564
a0a275a
d8bf7aa
9c09990
fe8faa8
5102cc3
49bc27d
3f28f4d
11ccb6e
dc85595
affb89f
a9c5642
7a04e88
b712bc0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { describe } from "vitest"; | ||
| import { mention, rubric, slackEval } from "../helpers"; | ||
|
|
||
| describe("Slack mrkdwn hygiene", () => { | ||
| slackEval("rewrites markdown tables into Slack-safe output", { | ||
| events: [ | ||
| mention( | ||
| "Give me a short comparison table of Sentry, Bugsnag, and Rollbar focused on deployment model and tracing.", | ||
| ), | ||
| ], | ||
| overrides: { | ||
| reply_timeout_ms: 120_000, | ||
| }, | ||
| requireSandboxReady: false, | ||
| taskTimeout: 150_000, | ||
| timeout: 210_000, | ||
| criteria: rubric({ | ||
| contract: | ||
| "Comparison output is Slack-safe: it may use bullets or a fenced code block, but it must not leave a raw markdown pipe table as the visible reply.", | ||
| pass: [ | ||
| "assistant_posts contains a single reply that compares Sentry, Bugsnag, and Rollbar.", | ||
| "If the reply uses a table-like layout, it is fenced as code or otherwise reformatted for Slack-safe rendering.", | ||
| ], | ||
| fail: [ | ||
| "Do not emit an unfenced markdown table with a separator row such as `| --- | --- |`.", | ||
| "Do not leave a raw pipe-table as the final visible reply.", | ||
| ], | ||
| }), | ||
| }); | ||
|
|
||
| slackEval( | ||
| "uses single-asterisk bold, single-tilde strike, and Slack link syntax", | ||
| { | ||
| events: [ | ||
| mention( | ||
| "In one short Slack reply, bold the word 'ready', strike through the word 'draft', and link the label 'docs' to https://docs.slack.dev/ .", | ||
| ), | ||
| ], | ||
| overrides: { | ||
| reply_timeout_ms: 120_000, | ||
| }, | ||
| requireSandboxReady: false, | ||
| taskTimeout: 150_000, | ||
| timeout: 210_000, | ||
| criteria: rubric({ | ||
| contract: | ||
| "Emphasis and link syntax follow Slack `mrkdwn`: single-asterisk bold, single-tilde strike, and `<url|label>` links. CommonMark/GFM equivalents are forbidden.", | ||
| pass: [ | ||
| "assistant_posts contains a single reply that addresses the bold, strike, and link asks.", | ||
| "Bold uses `*ready*` (single asterisks).", | ||
| "Strike uses `~draft~` (single tildes).", | ||
| "The docs link appears as `<https://docs.slack.dev/|docs>` or the bare URL.", | ||
| ], | ||
| fail: [ | ||
| "Do not emit `**ready**` (CommonMark bold).", | ||
| "Do not emit `~~draft~~` (CommonMark strike).", | ||
| "Do not emit `[docs](https://docs.slack.dev/)` (CommonMark link).", | ||
| ], | ||
| }), | ||
| }, | ||
| ); | ||
|
|
||
| slackEval("uses bold section labels instead of markdown headings", { | ||
| events: [ | ||
| mention( | ||
| "Give me a two-section Slack reply with short headings 'Summary' and 'Next steps', each with one sentence under it.", | ||
| ), | ||
| ], | ||
| overrides: { | ||
| reply_timeout_ms: 120_000, | ||
| }, | ||
| requireSandboxReady: false, | ||
| taskTimeout: 150_000, | ||
| timeout: 210_000, | ||
| criteria: rubric({ | ||
| contract: | ||
| "Section structure uses a bold label on its own line. Markdown heading syntax is forbidden because Slack does not render it.", | ||
| pass: [ | ||
| "assistant_posts contains a single reply with two sections.", | ||
| "Each section label appears as `*Summary*` and `*Next steps*` on their own lines (bold labels), followed by a sentence.", | ||
| ], | ||
| fail: [ | ||
| "Do not emit `# Summary`, `## Summary`, `### Summary`, or any other markdown heading syntax.", | ||
| "Do not emit `**Summary**` (CommonMark bold).", | ||
| ], | ||
| }), | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| export interface MarkdownTableMatch { | ||
| after: string; | ||
| before: string; | ||
| rows: string[][]; | ||
| } | ||
|
|
||
| /** Parse a single markdown table row while preserving Slack link cells. */ | ||
| export function parseMarkdownTableRow(line: string): string[] | null { | ||
| if (!line.includes("|")) { | ||
| return null; | ||
| } | ||
|
|
||
| const normalized = line.trim().replace(/^\|/, "").replace(/\|$/, ""); | ||
| const cells: string[] = []; | ||
| let current = ""; | ||
| let insideSlackLink = false; | ||
|
|
||
| for (const char of normalized) { | ||
| if (char === "<") { | ||
| insideSlackLink = true; | ||
| current += char; | ||
| continue; | ||
| } | ||
| if (char === ">") { | ||
| insideSlackLink = false; | ||
| current += char; | ||
| continue; | ||
| } | ||
| if (char === "|" && !insideSlackLink) { | ||
| cells.push(current.trim()); | ||
| current = ""; | ||
| continue; | ||
| } | ||
| current += char; | ||
| } | ||
|
|
||
| cells.push(current.trim()); | ||
| return cells.length >= 2 ? cells : null; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prose pipes misfire table parsingMedium Severity Table detection treats any line with two pipe-separated segments as a header if the following line looks like a GFM separator. Ordinary prose such as Additional Locations (1)Reviewed by Cursor Bugbot for commit b712bc0. Configure here. |
||
| } | ||
|
|
||
| /** Return true when a line is a markdown table separator row. */ | ||
| export function isMarkdownTableSeparator(line: string): boolean { | ||
| const cells = parseMarkdownTableRow(line); | ||
| return ( | ||
| cells !== null && | ||
| cells.length >= 2 && | ||
| cells.every((cell) => /^:?-{3,}:?$/.test(cell)) | ||
| ); | ||
| } | ||
|
|
||
| /** Render parsed markdown-table rows into a Slack-safe ASCII code block. */ | ||
| export function renderMarkdownTableCodeBlock(rows: string[][]): string { | ||
| const columnCount = Math.max(...rows.map((row) => row.length)); | ||
| const normalizedRows = rows.map((row) => | ||
| Array.from({ length: columnCount }, (_unused, index) => row[index] ?? ""), | ||
| ); | ||
| const widths = Array.from({ length: columnCount }, (_unused, index) => | ||
| Math.max(3, ...normalizedRows.map((row) => (row[index] ?? "").length)), | ||
| ); | ||
|
|
||
| const formatRow = (row: string[]) => | ||
| row | ||
| .map((cell, index) => cell.padEnd(widths[index] ?? 3)) | ||
| .join(" | ") | ||
| .trimEnd(); | ||
|
|
||
| const header = formatRow(normalizedRows[0] ?? []); | ||
| const separator = widths | ||
| .map((width) => "-".repeat(Math.max(3, width))) | ||
| .join(" | "); | ||
| const body = normalizedRows.slice(1).map(formatRow); | ||
|
|
||
| return ["```", header, separator, ...body, "```"].join("\n"); | ||
| } | ||
|
|
||
| /** Extract the first simple markdown table plus surrounding text. */ | ||
| export function extractFirstMarkdownTable( | ||
| text: string, | ||
| ): MarkdownTableMatch | null { | ||
| const lines = text.split("\n"); | ||
|
|
||
| for (let index = 0; index < lines.length; index += 1) { | ||
| const header = parseMarkdownTableRow(lines[index] ?? ""); | ||
| if (!header || !isMarkdownTableSeparator(lines[index + 1] ?? "")) { | ||
| continue; | ||
| } | ||
|
|
||
| const rows = [header]; | ||
| let nextIndex = index + 2; | ||
| while (nextIndex < lines.length) { | ||
| const row = parseMarkdownTableRow(lines[nextIndex] ?? ""); | ||
| if (!row) { | ||
| break; | ||
| } | ||
| rows.push(row); | ||
| nextIndex += 1; | ||
| } | ||
|
|
||
| return { | ||
| after: lines.slice(nextIndex).join("\n"), | ||
| before: lines.slice(0, index).join("\n"), | ||
| rows, | ||
| }; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||


Uh oh!
There was an error while loading. Please reload this page.