fabro/lib
David Julia e4f7b8028b
feat(slack): render context_display and run link in interview messages
Slack interview messages previously showed only the hexagon node's label
(e.g. "Approve Plan") with no preview of the upstream stage's output and
no link back to the run. A reviewer who only sees the Slack message has
nothing to act on; they have to open the web UI to find the plan, the
artifacts, or any other context. That defeats the point of routing the
gate through Slack.

The data needed to fix this is already on the wire. InterviewStartedProps
carries `context_display` (populated by fabro-workflow with the previous
stage's response, e.g. plan summary + Dossier URLs), and AppState exposes
`run_web_url` for the deep link. This change wires both into
question_to_blocks so the Slack message is self-sufficient.

Outbound (blocks.rs):
- question_to_blocks gains a `run_web_url: Option<&str>` argument.
- A new header_section renders bold question text, an optional stage hint
  (`stage \`plan\``), and an "Open in Fabro" link when the URL is known.
- A new context_section renders question.context_display below the header,
  truncated to fit Slack's documented 3000-character section text limit
  with an explicit "(truncated; open the run in Fabro for the full
  context)" suffix. Empty context_display is skipped.
- A divider separates context from the action buttons.

Slack control characters:
- New escape_slack_controls applies HTML-entity escapes to `&`, `<`, `>`
  in untrusted strings (question text, stage, context_display, and the
  answered_blocks question/answer texts). This neutralises LLM-produced
  payloads like `<!here>`, `<@U…>`, or `<#C…>` so a stage's response
  cannot ping people or surface channels by accident.
- Markdown formatting (`*bold*`, `_italic_`, `` `code` ``, `~strike~`)
  is intentionally NOT escaped so legitimate formatting in plan summaries
  still renders.
- Per https://docs.slack.dev/messaging/formatting-message-text/#escaping.

Defensive length capping:
- truncate_to_limit clamps each section's final text against
  SLACK_SECTION_TEXT_LIMIT (3000 chars), including the truncation suffix
  in the budget so the result is guaranteed under the limit. Applies to
  both the header text and the context block, so a pathological question
  or LLM response cannot produce `invalid_blocks` from Slack.

Server plumbing (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 the
  web UI is disabled or `server.web.url` is unset.

Tests (+10 in 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
- answered_blocks_escape_slack_control_chars

84/84 fabro-slack tests pass (was 74 after the action_id fix in
fix/slack-action-id-uniqueness).
`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 against a real Slack workspace: a multiple_choice
"Approve Plan" gate now renders with bold header, stage hint, "Open in
Fabro" link, the upstream plan summary (Dossier canonical + version
URLs, artifact paths, plan-summary bullets), a divider, and [A]/[R]
buttons. A reviewer can act on the gate from Slack without opening the
web UI.

Stacks on fix/slack-action-id-uniqueness.
2026-05-12 22:23:25 -06:00
..
crates feat(slack): render context_display and run link in interview messages 2026-05-12 22:23:25 -06:00
packages/fabro-api-client feat(model): support open provider catalog data (#245) 2026-05-12 15:42:49 -04:00