Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
00f422d
docs(specs): add draft Slack rendering spec for render intents
devin-ai-integration[bot] Apr 17, 2026
fdf42b6
docs(specs): drop work objects, make intents native, plugins teach vi…
devin-ai-integration[bot] Apr 17, 2026
1afc304
feat(slack): add render-intent palette and block renderer
devin-ai-integration[bot] Apr 17, 2026
22f623f
feat(slack): wire reply tool through delivery path with GitHub test bed
devin-ai-integration[bot] Apr 17, 2026
3d9f490
docs(spec, plugins): drop phased framing, extend render-intent guidan…
devin-ai-integration[bot] Apr 17, 2026
b4a7eb2
feat(slack): centralize render-intent capability in system prompt, tr…
devin-ai-integration[bot] Apr 17, 2026
e000f4f
fix(slack): consolidate output contract and intent palette into singl…
devin-ai-integration[bot] Apr 17, 2026
c36c1c9
test(evals): add comparison_table and plain-reply intent selection ev…
devin-ai-integration[bot] Apr 17, 2026
7520801
refactor(slack): remove render-intent tool infrastructure, focus on m…
devin-ai-integration[bot] Apr 17, 2026
0f02708
test(evals): drop comparison-table scenario from slack-mrkdwn-hygiene
devin-ai-integration[bot] Apr 17, 2026
4164c51
fix(slack): Normalize unsupported reply markdown
dcramer Apr 19, 2026
2c50d78
fix(slack): Handle link edge cases in mrkdwn repair
dcramer Apr 19, 2026
f85b479
fix(slack): Expand mrkdwn repair coverage
dcramer Apr 19, 2026
676482e
fix(slack): Harden mrkdwn normalization edges
dcramer Apr 19, 2026
40b2693
fix(slack): Unify finalized reply rendering
dcramer Apr 19, 2026
2393447
fix(slack): stabilize mrkdwn branch checks
dcramer Apr 21, 2026
8e78121
fix(slack): normalize triple-asterisk emphasis
dcramer Apr 21, 2026
bfe1e48
fix(slack): restore canvas reply guidance
dcramer Apr 21, 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).",
],
}),
});
});
1 change: 0 additions & 1 deletion packages/junior-evals/evals/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ const canvasSchema = z.object({
"Initial markdown body written into the created Slack canvas during the turn",
),
});

const evalOutputSchema = z.object({
assistant_posts: z
.array(assistantPostSchema)
Expand Down
63 changes: 44 additions & 19 deletions packages/junior/src/chat/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,45 @@ 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.",
`- Prefer a single compact thread reply when the full answer comfortably fits within this inline budget (${params.maxInlineChars} chars / ${params.maxInlineLines} lines).`,
"- When canvas creation is available and a research or document-style answer would likely need continuation, multiple sections, or future reference value, create a Slack canvas and keep the thread reply to a short summary plus the canvas link.",
"- Typical canvas-first cases include long-form research summaries, timelines, bios or profiles, structured notes, plans, comparisons, and other reusable reference documents.",
"- Do not create a canvas for short factual answers that fit cleanly in one normal thread reply.",
"- 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 All @@ -274,6 +313,7 @@ function formatReferenceFilesSection(): string[] {
];
}

/** Build the canonical system prompt from repo policy, runtime context, and active skill state. */
export function buildSystemPrompt(params: {
availableSkills: SkillMetadata[];
activeSkills: Skill[];
Expand Down Expand Up @@ -562,25 +602,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.",
"- Prefer a single compact thread reply when the full answer comfortably fits within this inline budget.",
"- When canvas creation is available and a research or document-style answer would likely need continuation, multiple sections, or future reference value, create a Slack canvas and keep the thread reply to a short summary plus the canvas link.",
"- Typical canvas-first cases include long-form research summaries, timelines, bios or profiles, structured notes, plans, comparisons, and other reusable reference documents.",
"- Do not create a canvas for short factual answers that fit cleanly in one normal thread reply.",
"- 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
8 changes: 4 additions & 4 deletions packages/junior/src/chat/runtime/reply-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ interface ReplyExecutorDeps {
services: ReplyExecutorServices;
}

/** Create the main turn executor that prepares context, runs the agent, and delivers the reply. */
export function createReplyToThread(deps: ReplyExecutorDeps) {
return async function replyToThread(
thread: Thread,
Expand Down Expand Up @@ -445,21 +446,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 @@ -122,30 +98,3 @@ export function buildSlackReplyFooter(args: {

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

/** Build Slack blocks for a finalized reply plus its optional metadata footer. */
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)}`,
})),
},
];
}
Loading
Loading