Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
47afd67
docs(specs): add draft Slack rendering spec for render intents
devin-ai-integration[bot] Apr 17, 2026
b04f564
docs(specs): drop work objects, make intents native, plugins teach vi…
devin-ai-integration[bot] Apr 17, 2026
a0a275a
feat(slack): add render-intent palette and block renderer
devin-ai-integration[bot] Apr 17, 2026
d8bf7aa
feat(slack): wire reply tool through delivery path with GitHub test bed
devin-ai-integration[bot] Apr 17, 2026
9c09990
docs(spec, plugins): drop phased framing, extend render-intent guidan…
devin-ai-integration[bot] Apr 17, 2026
fe8faa8
feat(slack): centralize render-intent capability in system prompt, tr…
devin-ai-integration[bot] Apr 17, 2026
5102cc3
fix(slack): consolidate output contract and intent palette into singl…
devin-ai-integration[bot] Apr 17, 2026
49bc27d
test(evals): add comparison_table and plain-reply intent selection ev…
devin-ai-integration[bot] Apr 17, 2026
3f28f4d
refactor(slack): remove render-intent tool infrastructure, focus on m…
devin-ai-integration[bot] Apr 17, 2026
11ccb6e
test(evals): drop comparison-table scenario from slack-mrkdwn-hygiene
devin-ai-integration[bot] Apr 17, 2026
dc85595
fix(slack): Normalize unsupported reply markdown
dcramer Apr 19, 2026
affb89f
fix(slack): Handle link edge cases in mrkdwn repair
dcramer Apr 19, 2026
a9c5642
fix(slack): Expand mrkdwn repair coverage
dcramer Apr 19, 2026
7a04e88
fix(slack): Harden mrkdwn normalization edges
dcramer Apr 19, 2026
b712bc0
fix(slack): Unify finalized reply rendering
dcramer Apr 19, 2026
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Co-Authored-By: (agent model name) <email>
- `specs/chat-architecture-spec.md` (chat composition, service, and test-seam architecture contract)
- `specs/slack-agent-delivery-spec.md` (Slack entry surfaces, reply delivery, continuation, files, images, and resume behavior contract)
- `specs/slack-outbound-contract-spec.md` (Slack outbound boundary, message/file/reaction safety rules, and markdown-to-`mrkdwn` ownership)
- `specs/slack-rendering-spec.md` (Slack `mrkdwn` output contract: allow-list / forbid-list for the Slack surface — draft)
- `specs/skill-capabilities-spec.md` (capability declaration + broker/injection contract)
- `specs/oauth-flows-spec.md` (OAuth authorization code flow + Slack UX contract)
- `specs/harness-agent-spec.md` (agent loop and output contract)
Expand Down
88 changes: 88 additions & 0 deletions packages/junior-evals/evals/core/slack-mrkdwn-hygiene.eval.ts
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).",
],
}),
});
});
54 changes: 39 additions & 15 deletions packages/junior/src/chat/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,41 @@ function baseSystemPrompt(): string {
].join("\n");
}

function buildSlackOutputContract(params: {
maxInlineChars: number;
maxInlineLines: number;
}): string {
return [
`<output surface="slack" max_inline_chars="${params.maxInlineChars}" max_inline_lines="${params.maxInlineLines}">`,
"Your reply is delivered as plain Slack `mrkdwn` text. Slack `mrkdwn` is a strict, smaller syntax than CommonMark or GitHub-Flavored Markdown — anything outside the allow-list below renders as literal characters.",
"",
"Allowed mrkdwn (you may use these):",
"- `*bold*` — surround with single asterisks. Slack does NOT support `**bold**`; it renders the asterisks literally.",
"- `_italic_` — surround with single underscores.",
"- `~strike~` — surround with single tildes. Slack does NOT support `~~strike~~`.",
"- `` `inline code` `` and triple-backtick fenced code blocks for code, commands, and monospaced snippets.",
"- `> quoted text` at the start of a line for block quotes. A blank line ends the quote.",
"- `<https://example.com|Label>` for hyperlinks with a label. A bare `https://example.com` auto-links without a label. Slack does NOT support `[Label](https://example.com)` — it renders literally.",
"- `<@USERID>`, `<#CHANNELID>`, `<!subteam^TEAMID>` for user, channel, and group mentions. Use the raw IDs exposed elsewhere in this prompt.",
"- `- item` or `* item` at the start of a line for bullet lists. Numbered lists (`1. item`) render but indent awkwardly — prefer bullets.",
"- A bold label on its own line (`*Section*`) in place of markdown headings.",
"",
"Forbidden (do NOT emit these — they render as literal characters or broken formatting):",
"- Markdown tables using pipes and dashes (`| col | col |` / `|---|---|`). Slack renders the pipes verbatim. When you need tabular data, use short bulleted lists grouped by row, or a fenced code block with manually aligned columns.",
"- Markdown headings (`#`, `##`, `###`, and so on). Use a bold label on its own line instead.",
"- Markdown link syntax (`[label](url)`). Rewrite as `<url|label>` or a bare URL.",
"- CommonMark bold/strike doubles (`**bold**`, `~~strike~~`). Use the single-delimiter forms above.",
"- HTML tags, image embeds, and raw Slack Block Kit JSON.",
"",
"Other response rules:",
"- Keep responses brief and scannable. Lead with the answer; add detail only when depth is warranted.",
"- For tool-heavy research, discovery, or source-checking requests, do not send an initial acknowledgement. Start the visible reply only once you can present the actual answer.",
"- Do not narrate tool execution or emit repeated status updates in the visible reply.",
"- End every turn with a single final user-facing response in the format above.",
"</output>",
].join("\n");
}
Comment thread
cursor[bot] marked this conversation as resolved.

function formatReferenceFilesSection(): string[] {
const files = listReferenceFiles();
if (files.length === 0) {
Expand Down Expand Up @@ -578,21 +613,10 @@ export function buildSystemPrompt(params: {
"- If no skill is a clear fit, continue with normal tool usage.",
].join("\n"),
),
renderTag(
"output-contract",
[
"Always produce output that follows this contract:",
`<output format="slack-mrkdwn" max_inline_chars="${slackOutputPolicy.maxInlineChars}" max_inline_lines="${slackOutputPolicy.maxInlineLines}">`,
"- Use Slack-friendly markdown, not full CommonMark. Prefer bold section labels over markdown headings, and use bullets and short code blocks when helpful.",
"- Keep normal responses brief and scannable.",
"- If depth is needed, start with a concise summary and then provide fuller detail.",
"- For tool-heavy research, discovery, or source-checking requests, do not send an initial acknowledgment. Start the visible reply only once you can present the actual answer.",
"- Do not narrate tool execution or repeated status updates in the visible reply.",
"- Avoid tables and markdown links like `[label](url)` unless explicitly requested. Prefer plain URLs or Slack-native entities when exact rendering matters.",
"- End every turn with a final user-facing markdown response.",
"</output>",
].join("\n"),
),
buildSlackOutputContract({
maxInlineChars: slackOutputPolicy.maxInlineChars,
maxInlineLines: slackOutputPolicy.maxInlineLines,
}),
availableSkillsSection,
activeSkillsSection,
...(activeToolsSection ? [activeToolsSection] : []),
Expand Down
7 changes: 3 additions & 4 deletions packages/junior/src/chat/runtime/reply-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,21 +445,20 @@ export function createReplyToThread(deps: ReplyExecutorDeps) {
traceId: getActiveTraceId(),
usage: reply.diagnostics.usage,
});
const shouldUseSlackFooter =
Boolean(replyFooter) &&
const shouldUseSlackApiReplyDelivery =
Boolean(channelId && threadTs) &&
(thread.adapter as { name?: string } | undefined)?.name === "slack";

// Final Slack delivery is part of turn success. We only mark the turn
// completed after the visible reply has been accepted by Slack.
if (plannedPosts.length > 0) {
let sent: SentMessage | undefined;
if (shouldUseSlackFooter) {
if (shouldUseSlackApiReplyDelivery) {
const slackChannelId = channelId;
const slackThreadTs = threadTs;
if (!slackChannelId || !slackThreadTs) {
throw new Error(
"Slack footer delivery requires a concrete channel and thread timestamp",
"Slack reply delivery requires a concrete channel and thread timestamp",
);
}

Expand Down
53 changes: 1 addition & 52 deletions packages/junior/src/chat/slack/footer.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,6 @@
import type { AgentTurnUsage } from "@/chat/usage";

interface SlackMrkdwnTextObject {
text: string;
type: "mrkdwn";
}

interface SlackSectionBlock {
text: SlackMrkdwnTextObject;
type: "section";
}

interface SlackContextBlock {
elements: SlackMrkdwnTextObject[];
type: "context";
}

export type SlackMessageBlock = SlackSectionBlock | SlackContextBlock;

export interface SlackReplyFooterItem {
interface SlackReplyFooterItem {
label: string;
value: string;
}
Expand All @@ -26,13 +9,6 @@ export interface SlackReplyFooter {
items: SlackReplyFooterItem[];
}

function escapeSlackMrkdwn(text: string): string {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}

function formatSlackTokenCount(value: number): string {
return new Intl.NumberFormat("en-US").format(value);
}
Expand Down Expand Up @@ -118,30 +94,3 @@ export function buildSlackReplyFooter(args: {

return items.length > 0 ? { items } : undefined;
}

/** Build Slack blocks for a finalized reply plus its optional footer context block. */
export function buildSlackReplyBlocks(
text: string,
footer: SlackReplyFooter | undefined,
): SlackMessageBlock[] | undefined {
if (!text.trim() || !footer?.items.length) {
return undefined;
}

return [
{
type: "section",
text: {
type: "mrkdwn",
text,
},
},
{
type: "context",
elements: footer.items.map((item) => ({
type: "mrkdwn",
text: `*${escapeSlackMrkdwn(item.label)}:* ${escapeSlackMrkdwn(item.value)}`,
})),
},
];
}
107 changes: 107 additions & 0 deletions packages/junior/src/chat/slack/markdown-table.ts
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prose pipes misfire table parsing

Medium 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 foo | bar on one line and | --- | --- | on the next can be rewritten as a fenced table or native Slack table block incorrectly.

Additional Locations (1)
Fix in Cursor Fix in Web

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;
}
Loading
Loading