Skip to content

fix(slack): Unify finalized reply rendering#219

Draft
dcramer wants to merge 15 commits intomainfrom
dcramer/fix/slack-mrkdwn-normalization
Draft

fix(slack): Unify finalized reply rendering#219
dcramer wants to merge 15 commits intomainfrom
dcramer/fix/slack-mrkdwn-normalization

Conversation

@dcramer
Copy link
Copy Markdown
Member

@dcramer dcramer commented Apr 19, 2026

Supersedes #212.

Fix Slack finalized reply rendering by keeping normalized Slack output on one shared delivery path. The normalizer now repairs the CommonMark and GFM constructs that kept leaking into agent answers, escapes literal control characters without corrupting valid Slack tokens, and preserves simple markdown tables long enough for the shared Slack reply planner to upgrade single-post replies into native table blocks.

Before this, finalized replies could still split between the adapter post path and the raw Slack reply planner. That left live replies inconsistent with resumed replies and meant some rendering fixes only applied on one delivery path. This change standardizes finalized Slack replies on the shared raw Web API planner whenever we have a concrete Slack thread target, while keeping plain-text fallbacks and footer/context behavior centralized.

The footer-only helper is now separated from reply block building, table parsing stays isolated, and the Slack rendering, outbound, and delivery specs now describe the shipped contract instead of the abandoned render-intent direction.

Refs #208

devin-ai-integration bot and others added 11 commits April 18, 2026 19:05
Formalize the design from issue #208 as a canonical draft spec covering:
- Render-intent layer boundary between assistant output and Slack delivery
- Three lanes: final reply, in-flight progress, durable entities
- Plugin renderer registry contract (match/buildIntent/buildFallbackText/
  buildActions/buildWorkObject)
- SDK-first phasing using the installed chat/@chat-adapter/slack surfaces
  before adding Slack-specific block abstractions
- Accessibility and fallback rules requiring top-level text for every
  block-bearing message
- Failure model, degradation rules, and verification coverage targets

The spec sits alongside slack-agent-delivery-spec.md and
slack-outbound-contract-spec.md without changing their contracts, and is
marked Draft because implementation has not landed yet.

Refs: #208

Co-Authored-By: Claude Sonnet 4.5 <devin-ai-integration[bot]@users.noreply.github.com>
…a SKILL.md

- Remove work_object_reference intent and the durable-entity lane.
- Replace the plugin renderer registry with a guidance model: plugins
  influence rendering through SKILL.md content, not code or YAML templates.
  Rendering code stays in core; the intent palette is not plugin-extensible.
- Collapse lanes to two: final reply and in-flight progress.
- Rework the core render pipeline around one core renderer per intent
  kind with schema validation and plain_reply degradation on failure.
- Drop the work-object observability attribute and durable_entity lane
  label. Drop work-object coverage from first-party targets.

Refs: #208

Co-Authored-By: Claude Sonnet 4.5 <devin-ai-integration[bot]@users.noreply.github.com>
Introduce the closed, core-owned set of Slack render intents defined
in specs/slack-rendering-spec.md:

- plain_reply (pass-through)
- summary_card
- alert
- comparison_table
- result_carousel
- progress_plan

Each intent is validated by a zod discriminated-union schema. The
renderer translates an intent into Slack Block Kit blocks plus a
non-empty top-level fallback text derived from the same structured
fields, which satisfies the outbound-contract requirement that every
block-bearing message carry a non-empty top-level `text`.

Wiring these intents into the turn-end path (so the model can emit an
intent instead of raw text) is a follow-up change in the same track.

Co-Authored-By: Devin <devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: David Cramer <david@sentry.io>
Phase 1 of the Slack rendering engine (issue #208).

- Add optional 'reply' tool (Renderer pattern): one tool whose input
  schema is the SlackRenderIntent discriminated union. Plain text
  replies keep working unchanged; 'reply' is only called when the model
  wants a richer intent.
- Thread the captured intent from the tool -> AssistantReply ->
  planSlackReplyPosts -> postSlackApiReplyPosts, rendering blocks +
  non-empty fallback text at delivery time.
- Add GitHub SKILL.md guidance teaching the model when to emit a
  summary_card for PRs/issues, with exact field recipes.
- Update the spec to document the Intent Delivery Mechanism
  (ToolStrategy, Renderer vs Terminator trade-off, one tool with
  discriminated union).
- Tests: reply-tool schema + capture; planSlackReplyPosts with intent
  produces blocks+fallback; existing plain text path unaffected.

Co-Authored-By: David Cramer <david@sentry.io>
…ce to Linear and Sentry

Per PR review:
- The rendering engine ships as one coherent feature. Removed Phase 1/
  Phase 2 language from the spec; replaced the 'SDK-First Phasing'
  section with 'Renderer Implementation' that describes what ships and
  notes that newer Slack block primitives can swap in behind the same
  intent schema without a contract change.
- Strengthened the plugin guidance model to describe both axes plugins
  influence through SKILL.md: when to use an intent kind for their
  domain objects and how those objects populate the intent's
  structured fields.
- Added render-intent reference files for Linear (issue, project) and
  Sentry (issue) so the pattern is proven across three plugins, not
  just GitHub. Each file documents field recipes, when to prefer
  alert/carousel, and when not to call reply at all.

Co-Authored-By: David Cramer <david@sentry.io>
…im plugin recipes

Add a <render-capabilities> section to the Slack system prompt when the
native reply tool is registered, so the agent learns the palette and the
selection rules from one place instead of duplicating them across plugin
SKILL files. Plugin slack-render-intents files now carry only the
domain-specific field recipes (GitHub PR/issue, Linear issue/project,
Sentry issue) — no more palette preamble, no more 'when not to call
reply' boilerplate.

Also document the split in specs/slack-rendering-spec.md (section 5
'Guidance Model' + section 8 'Prompt and Model Behavior'): core teaches
the capability, plugins teach the recipes.

Co-Authored-By: David Cramer <david@sentry.io>
…e <slack-output> section

Agent was emitting GFM markdown tables (pipes render literally in Slack)
when users asked for comparisons. Root causes: (1) the <output-contract>
section carved out 'avoid tables unless explicitly requested', which
gave the model permission to emit pipe-tables; (2) mrkdwn vs GFM rules
were loose ('Slack-friendly markdown') with no syntax enumeration.

Consolidate <output-contract> and <render-capabilities> into a single
authoritative <slack-output> section that:
- Declares two forms: Form A (reply tool call with one palette intent)
  and Form B (plain Slack mrkdwn). Forbids any third shape.
- Enumerates allowed mrkdwn syntax explicitly (*bold*, _italic_,
  ~strike~, <url|label>, etc.) and marks each GFM equivalent as literal.
- Lists forbidden constructs explicitly: markdown tables, # headings,
  [label](url), HTML, raw Block Kit.
- Redirects table / comparison / matrix / diff requests to Form A
  comparison_table when the reply tool is registered.

Intent rules and mrkdwn rules now live together so the model sees one
coherent Slack surface contract. Extract buildSlackOutputContract as a
top-level helper to keep buildSystemPrompt readable.

Also update specs/slack-rendering-spec.md sections 5 and 8 to reflect
the merged section name.

Co-Authored-By: David Cramer <david@sentry.io>
…als, bump model to gpt-5.4

Previously no eval exercised reply-tool intent selection end-to-end, so
a regression like the agent emitting a GFM pipe-table when asked for a
comparison would only surface in manual Slack testing.

- Capture reply intents in the eval harness by recording
  AssistantReply.intent on the replyExecutor override and threading it
  through to EvalResult.replyIntents.
- Surface reply_intents on the judge's serialized output schema so
  rubrics can assert 'the agent called reply with kind=comparison_table'
  or 'reply_intents is empty for a plain prose answer'.
- Add packages/junior-evals/evals/core/slack-render-intents.eval.ts
  with two scenarios: (1) explicit 'give me a comparison table' must
  fire the reply tool with comparison_table and must not emit GFM pipe
  syntax; (2) a plain one-sentence question must not fire the reply
  tool and must stay in ordinary mrkdwn.
- Bump the eval judge model from openai/gpt-5.2 to openai/gpt-5.4 to
  match the rest of the codebase (chat-config, vision fixtures).

Co-Authored-By: David Cramer <david@sentry.io>
…rkdwn formatting guidance

Drops the render-intent palette (summary_card, alert, comparison_table,
result_carousel, progress_plan, plain_reply), the `reply` tool that
selected them, the per-intent renderer, and the plugin-side
slack-render-intents.md recipes. The output surface is now plain Slack
`mrkdwn` text; the prompt's job is to teach the model which `mrkdwn`
syntax Slack actually renders.

- `<slack-output>` renamed to `<output surface="slack" ...>` and
  simplified to an allow-list (`*bold*`, `_italic_`, `~strike~`,
  inline/fenced code, block quotes, `<url|label>` links, mentions,
  bullet lists, bold section labels) and a forbid-list (pipe tables,
  `##` headings, `[label](url)`, `**bold**`, `~~strike~~`, HTML,
  raw Block Kit JSON).
- `slack-rendering-spec.md` rewritten as a short output-contract spec;
  AGENTS.md and `specs/index.md` updated to match.
- `buildRuntimeServices` / `collectResults` / `EvalResult` no longer
  carry `replyIntents`; `reply_intents` removed from eval output
  schema.
- Plugin SKILL.md files (GitHub, Linear, Sentry) drop references to the
  deleted `slack-render-intents.md` recipes.
- Replaces the render-intent eval with three mrkdwn-hygiene evals
  (no pipe-tables, Slack-shape emphasis/link syntax, bold section
  labels instead of markdown headings).

Co-Authored-By: Devin <devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: David Cramer <david@sentry.io>
The 'table' keyword has a strong prior in the model's training data that
prompt-level rules don't reliably override. Dropping that scenario until
we're ready to address it with a deterministic mechanism (outbound
post-processor or provider-native response_format). The emphasis/link
and bold-section-labels scenarios still catch the failure modes the
<output> contract is designed to prevent.

Co-Authored-By: Devin <devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: David Cramer <david@sentry.io>
Render final Slack replies through a deterministic normalization pass for the common CommonMark and GFM constructs that Slack does not support. This keeps headings, markdown links, double emphasis, and simple tables from leaking literal syntax into user-visible replies.

Keep the Slack surface text-first and remove stale render-intent leftovers so the output contract stays owned by the prompt and output boundary rather than unused block helpers.

Refs #208
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
junior-docs Ready Ready Preview, Comment Apr 19, 2026 4:35pm

Request Review

Comment thread packages/junior/src/chat/slack/mrkdwn.ts
Keep the deterministic Slack markdown repair layer from corrupting valid markdown links with balanced parentheses in their URLs. Also make markdown table parsing treat Slack link delimiters as part of a cell so linked rows still normalize into a valid fenced table.

Add focused regression coverage for both cases so future formatter changes do not reintroduce these rendering failures.

Refs #208
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Teach the Slack normalizer a few more low-risk markdown repairs without broadening it into a full markdown converter. It now strips HTML comments, resolves reference-style markdown links, and keeps the valid Slack-native syntax boundary under explicit regression coverage.

This keeps the rendering path narrow and repo-owned while folding in the useful behavior learned from external mrkdwn converter projects.

Refs #208
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Comment thread packages/junior/src/chat/slack/mrkdwn.ts Fixed
Only unwrap raw URLs when formatting encloses a standalone URL token. This avoids corrupting emphasized prose that merely starts with a link.

Strip full HTML comments until stable and escape any leftover opener fragments so comment removal cannot reintroduce a literal comment start.

Co-Authored-By: Codex GPT-5 <noreply@openai.com>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7a04e88. Configure here.

return text
.replace(/\*\*([^\s*](?:[^\n]*?[^\s*])?)\*\*/g, "*$1*")
.replace(/~~([^\s~](?:[^\n]*?[^\s~])?)~~/g, "~$1~");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Triple asterisks normalize to forbidden double-bold output

Medium Severity

normalizeCommonMarkEmphasis converts ***text*** into **text** — the exact forbidden CommonMark double-bold the normalizer exists to eliminate. The regex [^\s*] rejects * as the first inner character, so with triple asterisks the match starts one position in, and the surrounding single * characters recombine into **...**. This can arise directly from model output (CommonMark bold+italic) or as a side effect when normalizeMarkdownHeadings wraps already-bold content like ## **Summary** into ***Summary***.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7a04e88. Configure here.

Keep Slack reply formatting on one shared delivery path instead of splitting finalized replies between the raw API planner and the adapter post path.

Escape literal Slack control characters in normalized prose, preserve valid Slack tokens, and upgrade simple single-post markdown tables into native Slack table blocks while keeping section/context fallbacks consistent across live and resumed replies.

Document the finalized reply envelope and add contract coverage for the shared raw Slack reply planner so rendering regressions are caught at the boundary where users see them.

Refs #208
Co-Authored-By: Codex GPT-5 <noreply@openai.com>
@dcramer dcramer changed the title fix(slack): Normalize unsupported reply markdown fix(slack): Unify finalized reply rendering Apr 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants