fabro/lib/components/fabro-slack/src/blocks.rs
Bryan Helmkamp c1335421e4
fix: stop a pipe in a link label from splitting Slack link markup
`slack_link` builds `<url|label>`, and `escape_slack_controls` covers
Slack's documented escapes (`&`, `<`, `>`) but not `|`. Slack has no
escape for `|`, so a label containing one splits the markup and can make
Slack reject the block.

`is_safe_slack_link_url` already guards the URL half against `|`; the
label half was unguarded. It did not matter before because the only
labels were "Open in Fabro" and a PR number. Review target labels are
model-authored, so this is now reachable.

Replace `|` inside link labels, which keeps the link working. Plain-text
labels are untouched, since `|` is fine outside link markup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:31:20 -04:00

1059 lines
39 KiB
Rust

use std::fmt::Write as _;
use fabro_interview::Question;
use fabro_types::QuestionType;
use serde_json::{Value, json};
use crate::payload::{SlackActionPayload, encode_action_value};
pub(crate) const ANSWER_ACTION_ID_PREFIX: &str = "interview.answer";
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
/// them at 255 characters. The selected option is carried in the button
/// `value` payload, so the `action_id` only needs to be unique — it doesn't
/// have to encode the selection. Suffixes are short, fixed-shape tokens
/// (`yes`, `no`, or the option index) to avoid any character-set or length
/// concerns when option keys are author-supplied.
fn answer_action_id(suffix: &str) -> String {
format!("{ANSWER_ACTION_ID_PREFIX}.{suffix}")
}
fn text_block(text: &str) -> Value {
json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": text
}
})
}
fn button(label: &str, value: &str, action_id: &str) -> Value {
json!({
"type": "button",
"text": {
"type": "plain_text",
"text": label
},
"value": value,
"action_id": action_id
})
}
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 = question.review_target.as_ref().map_or_else(
|| format!("*{}*", escape_slack_controls(&question.text)),
|target| {
let link = slack_link(target.url(), target.label());
format!("*{}*", target.question_text_with_link(&link))
},
);
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
}
fn option_descriptions_section(question: &Question) -> Option<Value> {
let rows = question
.options
.iter()
.filter_map(|option| {
let description = option.description.as_deref()?.trim();
if description.is_empty() {
return None;
}
Some(format!(
"• *{}* — {}",
escape_slack_controls(&option.label),
escape_slack_controls(description)
))
})
.collect::<Vec<_>>();
if rows.is_empty() {
return None;
}
Some(text_block(&truncate_to_limit(
&rows.join("\n"),
SLACK_SECTION_TEXT_LIMIT,
HEADER_TRUNCATION_SUFFIX,
)))
}
pub fn answered_blocks(question_text: &str, answer_text: &str) -> Vec<Value> {
vec![text_block(&format!(
"~{}~\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,
run_web_url: Option<&str>,
) -> Vec<Value> {
let mut blocks = lead_blocks(question, run_web_url);
if let Some(descriptions) = option_descriptions_section(question) {
blocks.push(descriptions);
}
match question.question_type {
QuestionType::YesNo | QuestionType::Confirmation => {
blocks.push(json!({
"type": "actions",
"elements": [
button("Yes", &encode_action_value(&SlackActionPayload::Yes {
run_id: run_id.to_string(),
qid: question_id.to_string(),
}), &answer_action_id("yes")),
button("No", &encode_action_value(&SlackActionPayload::No {
run_id: run_id.to_string(),
qid: question_id.to_string(),
}), &answer_action_id("no")),
]
}));
}
QuestionType::MultipleChoice => {
let elements: Vec<Value> = question
.options
.iter()
.enumerate()
.map(|(idx, opt)| {
button(
&opt.label,
&encode_action_value(&SlackActionPayload::Selected {
run_id: run_id.to_string(),
qid: question_id.to_string(),
key: opt.key.clone(),
}),
&answer_action_id(&idx.to_string()),
)
})
.collect();
blocks.push(json!({
"type": "actions",
"elements": elements,
}));
}
QuestionType::MultiSelect => {
let options: Vec<Value> = question
.options
.iter()
.map(|opt| {
let mut option = json!({
"text": { "type": "plain_text", "text": opt.label },
"value": opt.key
});
if let Some(description) = opt
.description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
option["description"] = json!({
"type": "plain_text",
"text": truncate_to_limit(
description,
75,
HEADER_TRUNCATION_SUFFIX
)
});
}
option
})
.collect();
blocks.push(json!({
"type": "actions",
"block_id": MULTI_SELECT_BLOCK_ID,
"elements": [{
"type": "checkboxes",
"action_id": MULTI_SELECT_ACTION_ID,
"options": options
}]
}));
blocks.push(json!({
"type": "actions",
"elements": [
button("Submit", &encode_action_value(&SlackActionPayload::SubmitMulti {
run_id: run_id.to_string(),
qid: question_id.to_string(),
}), MULTI_SELECT_SUBMIT_ACTION_ID),
]
}));
}
QuestionType::Freeform => {
blocks.push(text_block(
"_Reply in thread (mention me with your answer)._",
));
}
}
blocks
}
/// Pull request metadata rendered in run lifecycle notification messages.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RunLifecyclePullRequest<'a> {
pub number: u64,
pub title: Option<&'a str>,
pub url: Option<&'a str>,
}
/// Data needed to render a run lifecycle notification.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RunLifecycleBlocks<'a> {
pub run_id: &'a str,
pub run_url: Option<&'a str>,
pub workflow_label: &'a str,
pub result: Option<&'a str>,
pub duration_ms: Option<u64>,
pub pull_request: Option<RunLifecyclePullRequest<'a>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)]
pub enum RunLifecycleKind {
#[strum(serialize = "Fabro run started")]
Started,
#[strum(serialize = "Fabro run completed")]
Completed,
#[strum(serialize = "Fabro run failed")]
Failed,
}
const LIFECYCLE_FIELD_TEXT_LIMIT: usize = 800;
pub fn run_lifecycle_blocks(
kind: RunLifecycleKind,
details: &RunLifecycleBlocks<'_>,
) -> Vec<Value> {
let title: &'static str = kind.into();
let mut blocks = vec![
json!({
"type": "header",
"text": {
"type": "plain_text",
"text": title,
"emoji": true
}
}),
text_block(&format!("*{}*", lifecycle_field(details.workflow_label))),
];
let mut fields = vec![
lifecycle_mrkdwn_field(
"Workflow",
&format!("`{}`", lifecycle_field(details.workflow_label)),
),
lifecycle_mrkdwn_field("Run ID", &format!("`{}`", lifecycle_field(details.run_id))),
];
if !matches!(kind, RunLifecycleKind::Failed) {
if let Some(result) = details.result.filter(|value| !value.trim().is_empty()) {
fields.push(lifecycle_mrkdwn_field("Result", &lifecycle_field(result)));
}
}
if let Some(duration_ms) = details.duration_ms {
fields.push(lifecycle_mrkdwn_field(
"Duration",
&compact_duration(duration_ms),
));
}
blocks.push(json!({
"type": "section",
"fields": fields
}));
if matches!(kind, RunLifecycleKind::Failed) {
if let Some(result) = details.result.filter(|value| !value.trim().is_empty()) {
blocks.push(text_block(&format!(
"*Failure*\n{}",
lifecycle_field(result)
)));
}
}
if let Some(pull_request) = details.pull_request {
blocks.push(text_block(&format!(
"*Pull request*\n{}",
lifecycle_pull_request_text(&pull_request)
)));
}
if let Some(url) = details.run_url {
blocks.push(json!({
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": slack_link(url, "Open in Fabro")
}
]
}));
}
blocks
}
fn lifecycle_mrkdwn_field(label: &str, value: &str) -> Value {
json!({
"type": "mrkdwn",
"text": format!("*{label}*\n{value}")
})
}
fn lifecycle_field(text: &str) -> String {
truncate_to_limit(
&escape_slack_controls(text),
LIFECYCLE_FIELD_TEXT_LIMIT,
HEADER_TRUNCATION_SUFFIX,
)
}
fn lifecycle_pull_request_text(pull_request: &RunLifecyclePullRequest<'_>) -> String {
let label = format!("#{}", pull_request.number);
let mut text = pull_request
.url
.map(|url| slack_link(url, &label))
.unwrap_or(label);
if let Some(title) = pull_request.title.filter(|value| !value.trim().is_empty()) {
let _ = write!(text, " — {}", lifecycle_field(title));
}
text
}
/// Render `label` as a link to `url` in Slack's `<url|label>` syntax, falling
/// back to plain text when the URL cannot be embedded safely.
///
/// `escape_slack_controls` covers Slack's documented escapes (`&`, `<`, `>`)
/// but Slack has no escape for `|`, which separates the URL from the label.
/// A `|` in the label would split the markup and can make Slack reject the
/// whole block, so it is replaced inside link labels only. Labels can be
/// model-authored (a review target label, for example), so this is reachable.
fn slack_link(url: &str, label: &str) -> String {
if is_safe_slack_link_url(url) {
let label = escape_slack_controls(label).replace('|', "/");
format!("<{url}|{label}>")
} else {
escape_slack_controls(label)
}
}
fn is_safe_slack_link_url(url: &str) -> bool {
(url.starts_with("https://") || url.starts_with("http://"))
&& !url
.chars()
.any(|ch| matches!(ch, '<' | '>' | '|' | '\n' | '\r'))
}
fn compact_duration(ms: u64) -> String {
if ms < 1000 {
return format!("{ms}ms");
}
let seconds = ms / 1000;
if seconds < 60 {
if ms.is_multiple_of(1000) {
return format!("{seconds}s");
}
let tenths = (ms.saturating_add(50)) / 100;
let whole = tenths / 10;
let fraction = tenths % 10;
return if fraction == 0 {
format!("{whole}s")
} else {
format!("{whole}.{fraction}s")
};
}
let minutes = seconds / 60;
if minutes < 60 {
return format!("{}m {}s", minutes, seconds % 60);
}
let hours = minutes / 60;
if hours < 24 {
return format!("{}h {}m", hours, minutes % 60);
}
format!("{}d {}h", hours / 24, hours % 24)
}
#[cfg(test)]
mod tests {
use fabro_types::InterviewOption;
use super::*;
fn lifecycle_text(blocks: &[Value]) -> String {
let mut texts = Vec::new();
for block in blocks {
if let Some(text) = block["text"]["text"].as_str() {
texts.push(text);
}
if let Some(fields) = block["fields"].as_array() {
texts.extend(fields.iter().filter_map(|field| field["text"].as_str()));
}
if let Some(elements) = block["elements"].as_array() {
texts.extend(elements.iter().filter_map(|element| {
element["text"]
.as_str()
.or_else(|| element["text"]["text"].as_str())
}));
}
}
texts.join("\n")
}
#[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, None);
let blocks_json: Value = serde_json::to_value(&blocks).unwrap();
let section = &blocks_json[0];
assert_eq!(section["type"], "section");
assert!(
section["text"]["text"]
.as_str()
.unwrap()
.contains("Approve this PR?")
);
let actions = &blocks_json[1];
assert_eq!(actions["type"], "actions");
let elements = actions["elements"].as_array().unwrap();
assert_eq!(elements.len(), 2);
assert_eq!(elements[0]["text"]["text"], "Yes");
assert_eq!(elements[1]["text"]["text"], "No");
}
#[test]
fn confirmation_produces_two_buttons() {
let q = Question::new("Continue?", QuestionType::Confirmation);
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];
let elements = actions["elements"].as_array().unwrap();
assert_eq!(elements.len(), 2);
assert_eq!(elements[0]["text"]["text"], "Yes");
assert_eq!(elements[1]["text"]["text"], "No");
}
#[test]
fn multiple_choice_produces_button_per_option() {
let mut q = Question::new("Pick a language:", QuestionType::MultipleChoice);
q.options = vec![
InterviewOption {
key: "rs".to_string(),
label: "Rust".to_string(),
description: None,
preview: None,
},
InterviewOption {
key: "ts".to_string(),
label: "TypeScript".to_string(),
description: None,
preview: None,
},
InterviewOption {
key: "py".to_string(),
label: "Python".to_string(),
description: None,
preview: None,
},
];
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];
let elements = actions["elements"].as_array().unwrap();
assert_eq!(elements.len(), 3);
assert_eq!(elements[0]["text"]["text"], "Rust");
assert_eq!(elements[0]["action_id"], "interview.answer.0");
assert_eq!(elements[1]["action_id"], "interview.answer.1");
assert_eq!(elements[2]["action_id"], "interview.answer.2");
// Slack requires action_id to be unique within a message.
let ids: std::collections::HashSet<&str> = elements
.iter()
.map(|e| e["action_id"].as_str().unwrap())
.collect();
assert_eq!(ids.len(), elements.len());
// The option key remains in the button `value` payload so the server
// can still route the answer regardless of suffix scheme.
assert!(
elements[0]["value"]
.as_str()
.unwrap()
.contains("\"key\":\"rs\"")
);
assert!(
elements[0]["value"]
.as_str()
.unwrap()
.contains("\"run_id\":\"run-1\"")
);
assert_eq!(elements[1]["text"]["text"], "TypeScript");
assert_eq!(elements[2]["text"]["text"], "Python");
}
#[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, None);
let blocks_json: Value = serde_json::to_value(&blocks).unwrap();
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, None);
let blocks_json: Value = serde_json::to_value(&blocks).unwrap();
let actions = &blocks_json[1];
let elements = actions["elements"].as_array().unwrap();
assert_eq!(elements[0]["action_id"], "interview.answer.yes");
assert_eq!(elements[1]["action_id"], "interview.answer.no");
assert_ne!(elements[0]["action_id"], elements[1]["action_id"]);
let value = elements[0]["value"].as_str().unwrap();
assert!(value.contains("\"run_id\":\"run-7\""));
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_renders_review_target_as_the_question_link() {
let mut q = Question::new(
"Review the Quarry review exercise document, then choose the next action.",
QuestionType::MultipleChoice,
);
q.review_target = Some(
fabro_types::ReviewTarget::new(
"Quarry review exercise",
"https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef",
fabro_types::ReviewTargetKind::Document,
)
.unwrap(),
);
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_eq!(
header,
"*Review the \
<https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef|Quarry review \
exercise> document, then choose the next action.*"
);
}
#[test]
fn review_target_label_pipe_cannot_split_the_slack_link() {
let mut q = Question::new("Review", QuestionType::MultipleChoice);
q.review_target = Some(
fabro_types::ReviewTarget::new(
"Draft | v2",
"https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef",
fabro_types::ReviewTargetKind::Document,
)
.unwrap(),
);
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();
// Exactly one `|`: the one separating the URL from the label.
assert_eq!(header.matches('|').count(), 1);
assert!(header.contains("|Draft / v2>"));
}
#[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");
let json: Value = serde_json::to_value(&blocks).unwrap();
assert_eq!(json.as_array().unwrap().len(), 1);
let text = json[0]["text"]["text"].as_str().unwrap();
assert!(text.contains("Do you approve?"));
assert!(text.contains("Yes"));
}
#[test]
fn answered_blocks_have_no_actions() {
let blocks = answered_blocks("Pick one:", "Rust");
let json: Value = serde_json::to_value(&blocks).unwrap();
let has_actions = json
.as_array()
.unwrap()
.iter()
.any(|b| b["type"] == "actions");
assert!(!has_actions);
}
#[test]
fn multi_select_produces_checkboxes_and_submit_button() {
let mut q = Question::new("Select features:", QuestionType::MultiSelect);
q.options = vec![
InterviewOption {
key: "a".to_string(),
label: "Auth".to_string(),
description: None,
preview: None,
},
InterviewOption {
key: "b".to_string(),
label: "Billing".to_string(),
description: None,
preview: None,
},
];
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
let checkbox_block = &blocks_json[1];
assert_eq!(checkbox_block["type"], "actions");
assert_eq!(checkbox_block["block_id"], MULTI_SELECT_BLOCK_ID);
let cb_elements = checkbox_block["elements"].as_array().unwrap();
assert_eq!(cb_elements[0]["type"], "checkboxes");
assert_eq!(cb_elements[0]["action_id"], MULTI_SELECT_ACTION_ID);
// Submit button in a separate actions block
let submit_block = &blocks_json[2];
assert_eq!(submit_block["type"], "actions");
let submit_elements = submit_block["elements"].as_array().unwrap();
assert_eq!(submit_elements[0]["type"], "button");
assert_eq!(submit_elements[0]["text"]["text"], "Submit");
assert_eq!(
submit_elements[0]["action_id"],
MULTI_SELECT_SUBMIT_ACTION_ID
);
assert!(
submit_elements[0]["value"]
.as_str()
.unwrap()
.contains("\"qid\":\"q-5\"")
);
}
#[test]
fn option_descriptions_are_rendered_and_preview_is_not_special_cased() {
let mut q = Question::new("Pick one:", QuestionType::MultipleChoice);
q.options = vec![InterviewOption {
key: "ship".to_string(),
label: "Ship".to_string(),
description: Some("Deploy <now>".to_string()),
preview: Some("preview should not render".to_string()),
}];
let blocks_value: Value =
serde_json::to_value(question_to_blocks("run-1", "q-6", &q, None)).unwrap();
let text = blocks_value.to_string();
assert!(text.contains("Deploy &lt;now&gt;"));
assert!(!text.contains("preview should not render"));
}
#[test]
fn run_started_blocks_include_run_link_workflow_and_run_id_without_actions() {
let blocks = run_lifecycle_blocks(RunLifecycleKind::Started, &RunLifecycleBlocks {
run_id: "01HSTART",
run_url: Some("https://fabro.example/runs/01HSTART"),
workflow_label: "deploy",
result: None,
duration_ms: None,
pull_request: None,
});
assert_eq!(blocks[0]["type"], "header");
assert_eq!(blocks[0]["text"]["text"], "Fabro run started");
assert_eq!(blocks[1]["type"], "section");
assert_eq!(blocks[1]["text"]["text"], "*deploy*");
assert_eq!(blocks[2]["type"], "section");
let text = lifecycle_text(&blocks);
assert!(text.contains("*Workflow*\n`deploy`"));
assert!(text.contains("*Run ID*\n`01HSTART`"));
assert!(text.contains("<https://fabro.example/runs/01HSTART|Open in Fabro>"));
assert!(!blocks.iter().any(|block| block["type"] == "actions"));
}
#[test]
fn run_completed_blocks_include_result_duration_and_pull_request_title() {
let blocks = run_lifecycle_blocks(RunLifecycleKind::Completed, &RunLifecycleBlocks {
run_id: "01HDONE",
run_url: None,
workflow_label: "release",
result: Some("completed"),
duration_ms: Some(65_432),
pull_request: Some(RunLifecyclePullRequest {
number: 42,
title: Some("Ship <prod> & notify"),
url: Some("https://github.com/fabro-sh/fabro/pull/42"),
}),
});
assert_eq!(blocks[0]["type"], "header");
assert_eq!(blocks[0]["text"]["text"], "Fabro run completed");
assert_eq!(blocks[1]["text"]["text"], "*release*");
let text = lifecycle_text(&blocks);
assert!(text.contains("*Result*\ncompleted"));
assert!(text.contains("*Duration*\n1m 5s"));
assert!(text.contains("*Pull request*"));
assert!(text.contains("<https://github.com/fabro-sh/fabro/pull/42|#42>"));
assert!(text.contains("Ship &lt;prod&gt; &amp; notify"));
}
#[test]
fn run_failed_blocks_include_failure_result_message_and_duration() {
let blocks = run_lifecycle_blocks(RunLifecycleKind::Failed, &RunLifecycleBlocks {
run_id: "01HFAIL",
run_url: None,
workflow_label: "deploy",
result: Some("workflow_error — command <failed> & exited"),
duration_ms: Some(1_234),
pull_request: None,
});
assert_eq!(blocks[0]["type"], "header");
assert_eq!(blocks[0]["text"]["text"], "Fabro run failed");
let text = lifecycle_text(&blocks);
assert!(text.contains("*Failure*"));
assert!(text.contains("workflow_error — command &lt;failed&gt; &amp; exited"));
assert!(text.contains("*Duration*\n1.2s"));
}
#[test]
fn run_lifecycle_blocks_escape_and_truncate_untrusted_text() {
let blocks = run_lifecycle_blocks(RunLifecycleKind::Completed, &RunLifecycleBlocks {
run_id: "01H<&>",
run_url: None,
workflow_label: &format!("deploy <!here> {}", "x".repeat(4_000)),
result: Some("partial_success <needs-review> & done"),
duration_ms: None,
pull_request: None,
});
let text = lifecycle_text(&blocks);
assert!(!text.contains("<!here>"));
assert!(text.contains("&lt;!here&gt;"));
assert!(text.contains("01H&lt;&amp;&gt;"));
assert!(text.contains("partial_success &lt;needs-review&gt; &amp; done"));
for block in &blocks {
if let Some(text) = block["text"]["text"].as_str() {
assert!(
text.chars().count() <= SLACK_SECTION_TEXT_LIMIT,
"lifecycle block exceeded Slack section limit: {} chars",
text.chars().count()
);
}
}
assert!(text.contains(" …"));
}
#[test]
fn run_lifecycle_pull_request_without_title_uses_number_and_link_only() {
let blocks = run_lifecycle_blocks(RunLifecycleKind::Completed, &RunLifecycleBlocks {
run_id: "01HDONE",
run_url: None,
workflow_label: "release",
result: Some("completed"),
duration_ms: Some(1000),
pull_request: Some(RunLifecyclePullRequest {
number: 7,
title: None,
url: Some("https://github.com/fabro-sh/fabro/pull/7"),
}),
});
let text = lifecycle_text(&blocks);
assert!(text.contains("*Pull request*"));
assert!(text.contains("<https://github.com/fabro-sh/fabro/pull/7|#7>"));
assert!(!text.contains(" — "));
}
}