feat(slack): render plan summary + run link in interview messages (re #253, stacked on #252) (#254)

This branch contains both commits:
1. The action_id uniqueness fix from #252 (`fix(slack): make per-button
action_id unique to satisfy Slack's invalid_blocks check`).
2. The new context+link fix (`feat(slack): render context_display and
run link in interview messages`).

The first commit needs to land (or be rebuilt by the maintainer
workflow) before the second is meaningful, because without it
multi-button gates still fail with `invalid_blocks` and the new context
block never reaches Slack. Reviewing #252 first and this issue/PR second
is the cleanest flow.

## What

Two-file change in `lib/crates/fabro-slack/` and
`lib/crates/fabro-server/`. The Slack interview message now includes the
upstream stage's response (`context_display`) and a link back to the
run, so a reviewer in Slack can decide A vs R without opening the web
UI.

## Why

See **#253** for the full repro, screenshots, and threat model. Short
version: today the Slack message contains only the hexagon node's
`label` plus the buttons, which is not enough information to act on.

## Diff shape

- `question_to_blocks` gains a `run_web_url: Option<&str>` argument.
- New helpers:
- `header_section(question, run_web_url)`: bold question text, optional
`stage \`{stage}\`` hint, and an "Open in Fabro" link when the URL is
known.
- `context_section(context_display)`: renders the upstream stage's
response (e.g. plan summary, Dossier URLs) below the header, separated
by a `divider`. Empty context_display is skipped so the message falls
back cleanly to the old two-block shape.
- `escape_slack_controls(text)`: HTML-entity escapes `&`, `<`, `>` in
untrusted strings (question, stage, context_display, answered_blocks
question and answer text). Neutralises LLM-produced payloads like
`<!here>`, `<@U…>`, `<#C…>` while leaving Markdown formatting (`*bold*`,
`_italic_`, `` `code` ``, `~strike~`) intact. Per
https://docs.slack.dev/messaging/formatting-message-text/#escaping.
- `truncate_to_limit`: clamps both the header text and the context block
against Slack's documented 3000-character section text limit, with the
truncation suffix counted against the budget so the result is always
under the cap. Defends against pathological questions or oversized LLM
responses producing `invalid_blocks`.
- `server.rs`: `start_optional_slack_service`'s event subscriber calls
`state.run_web_url(&envelope.event.run_id)` per event and forwards the
result to `SlackService::handle_event`, which threads it into
`question_to_blocks`. Returns `None` (and the link is omitted) when
`server.web.enabled` is `false` or `server.web.url` is unset.

## Tests

10 new in `lib/crates/fabro-slack/src/blocks.rs`:

- `header_includes_run_link_when_url_provided`
- `header_omits_link_when_url_missing`
- `header_shows_stage_when_present`
- `header_truncates_when_inputs_exceed_section_limit`
- `context_display_renders_between_header_and_actions`
- `context_display_truncates_oversized_text_to_fit_slack_budget`
- `empty_context_display_is_skipped`
- `slack_control_chars_in_question_text_are_escaped`
- `slack_control_chars_in_context_display_are_escaped` (covers
`<!here>`/`<@U…>`/`<#C…>` neutralisation and verifies Markdown survives)
- `answered_blocks_escape_slack_control_chars`

84/84 `fabro-slack` tests pass (was 74 after #252). `cargo
+nightly-2026-04-14 fmt --check --all` and `cargo +nightly-2026-04-14
clippy -p fabro-slack -p fabro-server --all-targets -- -D warnings` both
clean.

## Verified end-to-end

Built a patched binary, swapped it for the brew install, triggered a
fresh multi-choice approve gate against a real Slack workspace. The
Slack message now renders with bold "Approve Plan" header, `stage
\`approve\`` hint, "Open in Fabro" link, the upstream plan summary block
(Dossier canonical and version URLs, `tmp-docs/fabro-plan.html` artifact
path, the plan-summary bullets), a divider, and the `[A] Approve` and
`[R] Revise` buttons. Reviewer can act on the gate from Slack alone.

## Latent observation, not in this diff

`lib/crates/fabro-server/src/server.rs::AppState::run_web_url` has a
comment saying it is snapshotted at create-time so attach replays remain
stable when `server.web.url` changes, but the implementation reads
current settings via `server_settings()`/`canonical_origin()`. This
patch is unaffected (per-event call), but the comment looks stale.

## Closes

Closes #253 if you choose to land this directly. Otherwise this PR is
background material for the issue.

---------

Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
This commit is contained in:
David Julia 2026-05-13 05:41:54 -06:00 • committed by GitHub
parent 69ce83be43
commit a591a1ca63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 323 additions and 32 deletions

View file

@ -385,7 +385,7 @@ impl SlackService {
}
}
async fn handle_event(&self, event: &RunEvent) {
async fn handle_event(&self, event: &RunEvent, run_web_url: Option<&str>) {
match &event.body {
EventBody::InterviewStarted(props) => {
if props.question_id.is_empty() {
@ -415,6 +415,7 @@ impl SlackService {
&event.run_id.to_string(),
&props.question_id,
&question,
run_web_url,
);
if let Ok(posted) = self
@ -919,7 +920,14 @@ fn start_optional_slack_service(state: &Arc<AppState>) {
loop {
match rx.recv().await {
Ok(envelope) => {
event_service.handle_event(&envelope.event).await;
// Resolve the run's web URL once per event so the Slack
// message can deep-link back to Fabro. Returns None when
// the web UI is disabled or `server.web.url` is unset, in
// which case `question_to_blocks` simply omits the link.
let run_web_url = event_state.run_web_url(&envelope.event.run_id);
event_service
.handle_event(&envelope.event, run_web_url.as_deref())
.await;
}
Err(RecvError::Lagged(_)) => {}
Err(RecvError::Closed) => break,

View file

@ -1,3 +1,5 @@
use std::fmt::Write as _;
use fabro_interview::Question;
use fabro_types::QuestionType;
use serde_json::{Value, json};
@ -9,6 +11,23 @@ const MULTI_SELECT_BLOCK_ID: &str = "interview.checkboxes";
const MULTI_SELECT_ACTION_ID: &str = "interview.select";
const MULTI_SELECT_SUBMIT_ACTION_ID: &str = "interview.submit";
/// Slack section block `text.text` is documented to accept at most 3000
/// characters (Unicode scalars). Both the header section and the context
/// preview are capped against this so a pathological question, stage, URL,
/// or LLM-produced context_display can never produce an `invalid_blocks`
/// response. See https://docs.slack.dev/reference/block-kit/blocks/section-block/.
const SLACK_SECTION_TEXT_LIMIT: usize = 3000;
/// Suffix appended when `context_display` is truncated. Included in the
/// budget arithmetic so the final block is guaranteed to fit under the
/// section limit no matter how long the upstream stage's response was.
const CONTEXT_TRUNCATION_SUFFIX: &str =
"\n…\n_(truncated; open the run in Fabro for the full context)_";
/// Suffix appended when the header text itself exceeds the section limit
/// (e.g. an extremely long question label combined with a long stage name).
const HEADER_TRUNCATION_SUFFIX: &str = " …";
/// Build a Slack-unique `action_id` for an interview button.
///
/// Slack requires `action_id`s to be unique within a single message and caps
@ -43,18 +62,116 @@ fn button(label: &str, value: &str, action_id: &str) -> Value {
})
}
fn divider() -> Value {
json!({ "type": "divider" })
}
/// Escape Slack control characters in untrusted text. Slack treats `<…>`
/// as link/mention syntax and `&` as the escape character, so leaving them
/// raw lets an upstream LLM stage post `<!here>`, `<@U…>`, or `<#C…>`
/// payloads that ping people or surface channels. Escaping these does NOT
/// break legitimate markdown like `*bold*`, `_italic_`, `~strike~`, or
/// `` `code` `` — those characters are not escaped here on purpose so
/// formatted text (e.g. a plan summary) still renders.
/// Per https://docs.slack.dev/messaging/formatting-message-text/#escaping.
fn escape_slack_controls(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// Truncate a string to at most `limit` Unicode scalars, appending `suffix`
/// when truncation occurs. `suffix` is included in the budget so the result
/// is always `<= limit` characters total.
fn truncate_to_limit(text: &str, limit: usize, suffix: &str) -> String {
if text.chars().count() <= limit {
return text.to_string();
}
let suffix_len = suffix.chars().count();
let keep = limit.saturating_sub(suffix_len);
let mut out: String = text.chars().take(keep).collect();
out.push_str(suffix);
out
}
/// Build the leading section block for an interview message: question label,
/// stage hint, and a deep link back to the run when one is available. The
/// final text is bounded by Slack's section-text limit so even pathological
/// inputs cannot produce `invalid_blocks`.
fn header_section(question: &Question, run_web_url: Option<&str>) -> Value {
let mut text = format!("*{}*", escape_slack_controls(&question.text));
if !question.stage.is_empty() {
let _ = write!(
text,
" · stage `{}`",
escape_slack_controls(&question.stage)
);
}
if let Some(url) = run_web_url {
// The URL is server-owned (built from `server.web.url` + run id) and
// does not flow through escape_slack_controls so the `<…|…>` link
// syntax is preserved.
let _ = write!(text, "\n<{url}|Open in Fabro>");
}
text_block(&truncate_to_limit(
&text,
SLACK_SECTION_TEXT_LIMIT,
HEADER_TRUNCATION_SUFFIX,
))
}
/// Build a context section showing the upstream stage's response so a Slack
/// reviewer has enough information to act on the buttons without having to
/// open the run in the web UI. Slack control characters are escaped (so
/// LLM-produced content can't trigger unintended pings or channel mentions)
/// while leaving Markdown formatting intact. Truncated to fit Slack's
/// section text limit.
fn context_section(context_display: &str) -> Option<Value> {
let trimmed = context_display.trim();
if trimmed.is_empty() {
return None;
}
let neutralized = escape_slack_controls(trimmed);
let bounded = truncate_to_limit(
&neutralized,
SLACK_SECTION_TEXT_LIMIT,
CONTEXT_TRUNCATION_SUFFIX,
);
Some(text_block(&bounded))
}
/// Assemble the leading blocks shared by every question shape: header
/// section + optional context preview + a divider before the buttons.
fn lead_blocks(question: &Question, run_web_url: Option<&str>) -> Vec<Value> {
let mut blocks = vec![header_section(question, run_web_url)];
if let Some(context_display) = question.context_display.as_deref() {
if let Some(section) = context_section(context_display) {
blocks.push(section);
blocks.push(divider());
}
}
blocks
}
pub fn answered_blocks(question_text: &str, answer_text: &str) -> Vec<Value> {
vec![text_block(&format!(
"~{question_text}~\n*Answer:* {answer_text}"
"~{}~\n*Answer:* {}",
escape_slack_controls(question_text),
escape_slack_controls(answer_text),
))]
}
pub fn question_to_blocks(run_id: &str, question_id: &str, question: &Question) -> Vec<Value> {
let section = text_block(&question.text);
pub fn question_to_blocks(
run_id: &str,
question_id: &str,
question: &Question,
run_web_url: Option<&str>,
) -> Vec<Value> {
let mut blocks = lead_blocks(question, run_web_url);
match question.question_type {
QuestionType::YesNo | QuestionType::Confirmation => {
let actions = json!({
blocks.push(json!({
"type": "actions",
"elements": [
button("Yes", &encode_action_value(&SlackActionPayload::Yes {
@ -66,8 +183,7 @@ pub fn question_to_blocks(run_id: &str, question_id: &str, question: &Question)
qid: question_id.to_string(),
}), &answer_action_id("no")),
]
});
vec![section, actions]
}));
}
QuestionType::MultipleChoice => {
let elements: Vec<Value> = question
@ -86,11 +202,10 @@ pub fn question_to_blocks(run_id: &str, question_id: &str, question: &Question)
)
})
.collect();
let actions = json!({
blocks.push(json!({
"type": "actions",
"elements": elements
});
vec![section, actions]
"elements": elements,
}));
}
QuestionType::MultiSelect => {
let options: Vec<Value> = question
@ -103,7 +218,7 @@ pub fn question_to_blocks(run_id: &str, question_id: &str, question: &Question)
})
})
.collect();
let checkboxes = json!({
blocks.push(json!({
"type": "actions",
"block_id": MULTI_SELECT_BLOCK_ID,
"elements": [{
@ -111,8 +226,8 @@ pub fn question_to_blocks(run_id: &str, question_id: &str, question: &Question)
"action_id": MULTI_SELECT_ACTION_ID,
"options": options
}]
});
let submit = json!({
}));
blocks.push(json!({
"type": "actions",
"elements": [
button("Submit", &encode_action_value(&SlackActionPayload::SubmitMulti {
@ -120,16 +235,15 @@ pub fn question_to_blocks(run_id: &str, question_id: &str, question: &Question)
qid: question_id.to_string(),
}), MULTI_SELECT_SUBMIT_ACTION_ID),
]
});
vec![section, checkboxes, submit]
}));
}
QuestionType::Freeform => {
vec![text_block(&format!(
"{}\n_Please reply in thread (mention me with your answer)._",
question.text
))]
blocks.push(text_block(
"_Reply in thread (mention me with your answer)._",
));
}
}
blocks
}
#[cfg(test)]
@ -141,7 +255,7 @@ mod tests {
#[test]
fn yes_no_produces_two_buttons() {
let q = Question::new("Approve this PR?", QuestionType::YesNo);
let blocks = question_to_blocks("run-1", "q-1", &q);
let blocks = question_to_blocks("run-1", "q-1", &q, None);
let blocks_json: Value = serde_json::to_value(&blocks).unwrap();
let section = &blocks_json[0];
@ -164,7 +278,7 @@ mod tests {
#[test]
fn confirmation_produces_two_buttons() {
let q = Question::new("Continue?", QuestionType::Confirmation);
let blocks = question_to_blocks("run-1", "q-2", &q);
let blocks = question_to_blocks("run-1", "q-2", &q, None);
let blocks_json: Value = serde_json::to_value(&blocks).unwrap();
let actions = &blocks_json[1];
@ -191,7 +305,7 @@ mod tests {
label: "Python".to_string(),
},
];
let blocks = question_to_blocks("run-1", "q-3", &q);
let blocks = question_to_blocks("run-1", "q-3", &q, None);
let blocks_json: Value = serde_json::to_value(&blocks).unwrap();
let actions = &blocks_json[1];
@ -228,20 +342,22 @@ mod tests {
#[test]
fn freeform_produces_section_prompting_thread_reply() {
let q = Question::new("What's the repo URL?", QuestionType::Freeform);
let blocks = question_to_blocks("run-1", "q-4", &q);
let blocks = question_to_blocks("run-1", "q-4", &q, None);
let blocks_json: Value = serde_json::to_value(&blocks).unwrap();
assert_eq!(blocks_json.as_array().unwrap().len(), 1);
let text = blocks_json[0]["text"]["text"].as_str().unwrap();
assert!(text.contains("What's the repo URL?"));
assert!(text.contains("reply in thread"));
assert!(text.contains("mention me"));
let arr = blocks_json.as_array().unwrap();
assert_eq!(arr.len(), 2, "header section + thread-reply prompt");
let header_text = arr[0]["text"]["text"].as_str().unwrap();
assert!(header_text.contains("What's the repo URL?"));
let prompt_text = arr[1]["text"]["text"].as_str().unwrap();
assert!(prompt_text.contains("Reply in thread"));
assert!(prompt_text.contains("mention me"));
}
#[test]
fn action_values_include_run_id_and_question_id() {
let q = Question::new("Approve?", QuestionType::YesNo);
let blocks = question_to_blocks("run-7", "q-7", &q);
let blocks = question_to_blocks("run-7", "q-7", &q, None);
let blocks_json: Value = serde_json::to_value(&blocks).unwrap();
let actions = &blocks_json[1];
@ -254,6 +370,173 @@ mod tests {
assert!(value.contains("\"qid\":\"q-7\""));
}
#[test]
fn header_includes_run_link_when_url_provided() {
let q = Question::new("Approve Plan", QuestionType::YesNo);
let blocks = question_to_blocks(
"run-1",
"q-1",
&q,
Some("http://127.0.0.1:32276/runs/run-1"),
);
let header = serde_json::to_value(&blocks).unwrap()[0]["text"]["text"]
.as_str()
.unwrap()
.to_string();
assert!(header.contains("<http://127.0.0.1:32276/runs/run-1|Open in Fabro>"));
}
#[test]
fn header_omits_link_when_url_missing() {
let q = Question::new("Approve Plan", QuestionType::YesNo);
let blocks = question_to_blocks("run-1", "q-1", &q, None);
let header = serde_json::to_value(&blocks).unwrap()[0]["text"]["text"]
.as_str()
.unwrap()
.to_string();
assert!(!header.contains("Open in Fabro"));
}
#[test]
fn header_shows_stage_when_present() {
let mut q = Question::new("Approve Plan", QuestionType::YesNo);
q.stage = "plan".to_string();
let blocks = question_to_blocks("run-1", "q-1", &q, None);
let header = serde_json::to_value(&blocks).unwrap()[0]["text"]["text"]
.as_str()
.unwrap()
.to_string();
assert!(header.contains("stage `plan`"));
}
#[test]
fn header_truncates_when_inputs_exceed_section_limit() {
let mut q = Question::new("a".repeat(4000), QuestionType::YesNo);
q.stage = "b".repeat(2000);
let blocks = question_to_blocks(
"run-1",
"q-1",
&q,
Some("http://127.0.0.1:32276/runs/run-1"),
);
let header = serde_json::to_value(&blocks).unwrap()[0]["text"]["text"]
.as_str()
.unwrap()
.to_string();
assert!(
header.chars().count() <= 3000,
"header text exceeded Slack section limit: {} chars",
header.chars().count()
);
assert!(header.ends_with(" …"));
}
#[test]
fn context_display_renders_between_header_and_actions() {
let mut q = Question::new("Approve Plan", QuestionType::YesNo);
q.context_display = Some(
"Plan artifact created and published.\n\n\
- Local artifact: tmp-docs/fabro-plan.html\n\
- Dossier canonical URL: https://example.test/s/siv-1067/eng-design-doc"
.to_string(),
);
let blocks_json =
serde_json::to_value(question_to_blocks("run-1", "q-1", &q, None)).unwrap();
let arr = blocks_json.as_array().unwrap();
// 0: header section, 1: context section, 2: divider, 3: actions
assert_eq!(arr.len(), 4);
assert_eq!(arr[0]["type"], "section");
assert_eq!(arr[1]["type"], "section");
assert_eq!(arr[2]["type"], "divider");
assert_eq!(arr[3]["type"], "actions");
let context_text = arr[1]["text"]["text"].as_str().unwrap();
assert!(context_text.contains("Plan artifact created"));
assert!(context_text.contains("tmp-docs/fabro-plan.html"));
}
#[test]
fn context_display_truncates_oversized_text_to_fit_slack_budget() {
let mut q = Question::new("Approve Plan", QuestionType::YesNo);
q.context_display = Some("x".repeat(10_000));
let blocks_json =
serde_json::to_value(question_to_blocks("run-1", "q-1", &q, None)).unwrap();
let context_text = blocks_json[1]["text"]["text"].as_str().unwrap();
assert!(
context_text.chars().count() <= 3000,
"context block exceeded Slack section text limit: {} chars",
context_text.chars().count()
);
assert!(context_text.contains("truncated"));
}
#[test]
fn empty_context_display_is_skipped() {
let mut q = Question::new("Approve Plan", QuestionType::YesNo);
q.context_display = Some(" \n\t ".to_string());
let blocks_json =
serde_json::to_value(question_to_blocks("run-1", "q-1", &q, None)).unwrap();
let arr = blocks_json.as_array().unwrap();
// Falls back to header + actions when there's nothing meaningful.
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["type"], "section");
assert_eq!(arr[1]["type"], "actions");
}
#[test]
fn slack_control_chars_in_question_text_are_escaped() {
let q = Question::new("Approve <plan> & merge?", QuestionType::YesNo);
let blocks_json =
serde_json::to_value(question_to_blocks("run-1", "q-1", &q, None)).unwrap();
let header = blocks_json[0]["text"]["text"].as_str().unwrap();
// &, <, > must be escaped so Slack doesn't reinterpret them as link
// or mention syntax. Other Markdown metacharacters (*, _, ~, `) are
// intentionally left untouched so legitimate formatting still renders.
assert!(header.contains("&lt;plan&gt;"));
assert!(header.contains("&amp;"));
}
#[test]
fn slack_control_chars_in_context_display_are_escaped() {
// An LLM-produced context_display could embed `<!here>`, `<@U…>`, or
// `<#C…>` which Slack would treat as a notification or mention. The
// escape must neutralise them while keeping bullets/bold/code intact.
let mut q = Question::new("Approve Plan", QuestionType::YesNo);
q.context_display = Some(
"Heads up: <!here> please review\n\
- tagged: <@U12345>\n\
- moved channel: <#C67890>\n\
- kept: *bold* _italic_ `code` ~strike~"
.to_string(),
);
let blocks_json =
serde_json::to_value(question_to_blocks("run-1", "q-1", &q, None)).unwrap();
let context = blocks_json[1]["text"]["text"].as_str().unwrap();
// Pings are neutralised.
assert!(!context.contains("<!here>"));
assert!(!context.contains("<@U12345>"));
assert!(!context.contains("<#C67890>"));
assert!(context.contains("&lt;!here&gt;"));
assert!(context.contains("&lt;@U12345&gt;"));
assert!(context.contains("&lt;#C67890&gt;"));
// Markdown formatting is preserved.
assert!(context.contains("*bold*"));
assert!(context.contains("_italic_"));
assert!(context.contains("`code`"));
assert!(context.contains("~strike~"));
}
#[test]
fn answered_blocks_escape_slack_control_chars() {
let blocks = answered_blocks("Approve <plan>?", "Yes & ship");
let text = serde_json::to_value(&blocks).unwrap()[0]["text"]["text"]
.as_str()
.unwrap()
.to_string();
assert!(!text.contains("<plan>"));
assert!(text.contains("&lt;plan&gt;"));
assert!(text.contains("Yes &amp; ship"));
}
#[test]
fn answered_blocks_show_question_and_answer() {
let blocks = answered_blocks("Do you approve?", "Yes");
@ -291,7 +574,7 @@ mod tests {
label: "Billing".to_string(),
},
];
let blocks = question_to_blocks("run-1", "q-5", &q);
let blocks = question_to_blocks("run-1", "q-5", &q, None);
let blocks_json: Value = serde_json::to_value(&blocks).unwrap();
// Checkboxes in their own block with a block_id