Skip to content
Closed
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
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
62 changes: 62 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,62 @@
import { describe } from "vitest";
import { mention, rubric, slackEval } from "../helpers";

describe("Slack mrkdwn hygiene", () => {
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).",
],
}),
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing pipe-table eval scenario described in PR

Medium Severity

The PR description states there are three eval scenarios, but only two are present. The missing first scenario — "Give me a short comparison table…" that validates GFM pipe-table syntax is not emitted — is absent. This is the primary regression the PR aims to fix ("the real regression we kept hitting in dev (e.g. GFM pipe-tables in comparison replies)"), and the Review & Testing Checklist instructs reviewers to "Run the three new evals," yet only two exist.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8b7cfc4. Configure here.

2 changes: 1 addition & 1 deletion packages/junior-evals/evals/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
runEvalScenario,
} from "./behavior-harness";

configure({ model: gateway("openai/gpt-5.2") });
configure({ model: gateway("openai/gpt-5.4") });

// ── Eval output schema ─────────────────────────────────────

Expand Down
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");
}

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
55 changes: 32 additions & 23 deletions packages/junior/src/chat/slack/footer.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,10 @@
import type {
SlackContextBlock,
SlackMessageBlock,
} from "@/chat/slack/render/blocks";
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 type { SlackMessageBlock };

export interface SlackReplyFooterItem {
label: string;
Expand Down Expand Up @@ -108,6 +97,27 @@ export function buildSlackReplyFooter(args: {
return items.length > 0 ? { items } : undefined;
}

/**
* Build the standalone footer `context` block (no surrounding section).
* Used when composing the footer onto an existing block-bearing message
* (e.g. an intent-rendered reply) so we don't double-render the body.
*/
export function buildSlackFooterContextBlock(
footer: SlackReplyFooter | undefined,
): SlackContextBlock | undefined {
if (!footer?.items.length) {
return undefined;
}

return {
type: "context",
elements: footer.items.map((item) => ({
type: "mrkdwn",
text: `*${escapeSlackMrkdwn(item.label)}:* ${escapeSlackMrkdwn(item.value)}`,
})),
};
}

/** Build Slack blocks for a finalized reply plus its optional footer context block. */
export function buildSlackReplyBlocks(
text: string,
Expand All @@ -117,6 +127,11 @@ export function buildSlackReplyBlocks(
return undefined;
}

const footerBlock = buildSlackFooterContextBlock(footer);
if (!footerBlock) {
return undefined;
}

return [
{
type: "section",
Expand All @@ -125,12 +140,6 @@ export function buildSlackReplyBlocks(
text,
},
},
{
type: "context",
elements: footer.items.map((item) => ({
type: "mrkdwn",
text: `*${escapeSlackMrkdwn(item.label)}:* ${escapeSlackMrkdwn(item.value)}`,
})),
},
footerBlock,
];
}
62 changes: 62 additions & 0 deletions packages/junior/src/chat/slack/render/blocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Slack Block Kit types used by the outbound reply boundary. This is a local
* subset of the Slack API surface — just the fields the repository actually
* emits when wrapping final `mrkdwn` replies in section/context envelopes.
*/

export interface SlackMrkdwnText {
text: string;
type: "mrkdwn";
}

export interface SlackPlainText {
emoji?: boolean;
text: string;
type: "plain_text";
}

export interface SlackHeaderBlock {
text: SlackPlainText;
type: "header";
}

export interface SlackSectionBlock {
fields?: SlackMrkdwnText[];
text?: SlackMrkdwnText;
type: "section";
}

export interface SlackDividerBlock {
type: "divider";
}

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

export interface SlackLinkButtonElement {
text: SlackPlainText;
type: "button";
url: string;
}

export interface SlackActionsBlock {
elements: SlackLinkButtonElement[];
type: "actions";
}

export type SlackMessageBlock =
| SlackActionsBlock
| SlackContextBlock
| SlackDividerBlock
| SlackHeaderBlock
| SlackSectionBlock;

/** Escape user-provided text for safe inclusion in Slack mrkdwn fields. */
export function escapeSlackMrkdwnText(text: string): string {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unused exported escape function duplicates existing one

Low Severity

escapeSlackMrkdwnText is exported from render/blocks.ts but never imported or called anywhere in the codebase. It's functionally identical to the private escapeSlackMrkdwn in footer.ts. This is dead code left over from the removed render-intent layer.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8b7cfc4. Configure here.

1 change: 1 addition & 0 deletions packages/junior/src/chat/slack/reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export function planSlackReplyPosts(args: {
args.reply,
);
const interrupted = isInterruptedVisibleReply(args.reply);

const posts: PlannedSlackReplyPost[] = [];

const textPosts = shouldPostThreadReply
Expand Down
2 changes: 2 additions & 0 deletions specs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- 2026-03-21: Added canonical chat architecture spec.
- 2026-04-15: Added canonical Slack agent delivery spec.
- 2026-04-16: Added canonical Slack write contract spec.
- 2026-04-17: Added draft Slack output contract spec (`slack-rendering-spec.md`) covering the `mrkdwn` allow-list and forbid-list for the Slack surface.

## Status

Expand Down Expand Up @@ -48,6 +49,7 @@ Define spec taxonomy, naming conventions, and canonical source-of-truth document
- `specs/chat-architecture-spec.md`
- `specs/slack-agent-delivery-spec.md`
- `specs/slack-outbound-contract-spec.md`
- `specs/slack-rendering-spec.md`
- `specs/skill-capabilities-spec.md`
- `specs/oauth-flows-spec.md`
- `specs/harness-agent-spec.md`
Expand Down
Loading
Loading