huskies: merge 1228 story Render agent questions as numbered options in chat protocols without question UI

This commit is contained in:
Huskies Agent
2026-07-19 19:23:16 +00:00
parent 933fb5a54b
commit f4f0981f17
26 changed files with 1361 additions and 4 deletions
@@ -318,6 +318,10 @@ mod tests {
pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
),
question_timeout_secs: 120,
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
})
@@ -95,7 +95,8 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
String::new()
};
let prompt = format!(
"{event_log_ctx}{seed_prefix}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n{active_project_ctx}\n{}",
"{event_log_ctx}{seed_prefix}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n[{}]\n{active_project_ctx}\n{}",
crate::chat::util::QUESTION_FORMAT_INSTRUCTION,
format_user_prompt(&sender, &user_message)
);
@@ -336,6 +336,60 @@ async fn try_handle_stop_command(
true
}
/// Parse a chat reply to a pending [`QuestionSpec`](crate::http::context::QuestionSpec)
/// into the answer it represents (story 1228, AC2/AC4/AC5).
///
/// Tries, in order: a comma-separated list of 1-based option numbers (e.g.
/// `"2"` or `"1,3"`, validated against `num_options` and `multi_select`); a
/// case-insensitive exact match against one of `labels`; and finally free
/// text (the always-available "Other" path). Returns `None` only when the
/// reply looks like an attempted numeric selection but is out of range or
/// violates `multi_select` — the caller must re-prompt in that case rather
/// than guessing or picking a default (AC4).
fn parse_question_reply(
body: &str,
num_options: usize,
multi_select: bool,
labels: &[String],
) -> Option<crate::http::context::QuestionAnswer> {
let body_trimmed = body.trim();
// Strip a leading "@mention " prefix (e.g. "@timmy 1") entirely, not just
// the '@' character, so a mention-prefixed reply still parses as a
// number/label rather than falling through to free text.
let trimmed = if body_trimmed.starts_with('@') {
body_trimmed
.split_once(char::is_whitespace)
.map(|(_, rest)| rest.trim_start())
.unwrap_or(body_trimmed)
} else {
body_trimmed
};
let tokens: Vec<&str> = trimmed.split(',').map(str::trim).collect();
let parsed_indices: Option<Vec<usize>> =
tokens.iter().map(|t| t.parse::<usize>().ok()).collect();
match parsed_indices {
Some(indices) if !trimmed.is_empty() => {
if indices.iter().any(|&i| i == 0 || i > num_options) {
None
} else {
let mut zero_based: Vec<usize> = indices.into_iter().map(|i| i - 1).collect();
zero_based.dedup();
if zero_based.len() > 1 && !multi_select {
None
} else {
Some(crate::http::context::QuestionAnswer::Selected(zero_based))
}
}
}
_ => match labels.iter().position(|l| l.eq_ignore_ascii_case(trimmed)) {
Some(idx) => Some(crate::http::context::QuestionAnswer::Selected(vec![idx])),
None => Some(crate::http::context::QuestionAnswer::FreeText(
trimmed.to_string(),
)),
},
}
}
pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
ev: OriginalSyncRoomMessageEvent,
room: Room,
@@ -471,6 +525,59 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
}
}
// If there is a pending question for this room, interpret the message as
// an answer instead of starting a new chat (story 1228). Checked before
// the pending-permission block below so the two never conflate (AC4):
// each is a fully independent store, and this only fires when a question
// is actually pending for this room.
if let Some((num_options, multi_select, labels)) = ctx
.services
.pending_question_replies
.peek_oldest_meta(incoming_room_id.as_str())
.await
{
let outcome = parse_question_reply(&body, num_options, multi_select, &labels);
match outcome {
None => {
// Invalid attempted selection — re-prompt without consuming
// the pending question (AC4: never strand the agent or
// silently pick a default).
let msg = "I didn't understand that reply. Please reply with a number \
(or numbers separated by commas), an option's label, or your \
own answer.";
let html = markdown_to_html(msg);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, msg, &html).await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
}
return;
}
Some(answer) => {
if let Some(tx) = ctx
.services
.pending_question_replies
.resolve_oldest(incoming_room_id.as_str())
.await
{
let _ = tx.send(Ok(answer));
}
let confirmation = "Got it — thanks for answering.";
let html = markdown_to_html(confirmation);
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, confirmation, &html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
}
return;
}
}
}
// If there is a pending permission prompt for this room, interpret the
// message as a yes/no response instead of starting a new chat.
{
@@ -1496,14 +1603,74 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
mod tests {
use super::{
eval_gateway_overview_command, eval_gateway_status_command, eval_switch_command,
try_handle_compact_command, try_handle_stop_command,
parse_question_reply, try_handle_compact_command, try_handle_stop_command,
};
use crate::chat::{ChatTransport, MessageId};
use crate::http::context::QuestionAnswer;
use crate::service::gateway::config::ProjectEntry;
use async_trait::async_trait;
use std::collections::BTreeMap;
use tokio::sync::RwLock;
// -- parse_question_reply (story 1228) -----------------------------------
fn sample_labels() -> Vec<String> {
vec!["Fast".to_string(), "Safe".to_string()]
}
#[test]
fn parse_question_reply_single_number_selects_option() {
let outcome = parse_question_reply("2", 2, false, &sample_labels());
assert_eq!(outcome, Some(QuestionAnswer::Selected(vec![1])));
}
#[test]
fn parse_question_reply_multi_select_comma_list() {
let outcome = parse_question_reply("1,2", 2, true, &sample_labels());
assert_eq!(outcome, Some(QuestionAnswer::Selected(vec![0, 1])));
}
#[test]
fn parse_question_reply_multi_numbers_rejected_when_not_multi_select() {
let outcome = parse_question_reply("1,2", 2, false, &sample_labels());
assert_eq!(outcome, None, "must re-prompt, not silently pick one");
}
#[test]
fn parse_question_reply_out_of_range_number_is_invalid() {
let outcome = parse_question_reply("5", 2, false, &sample_labels());
assert_eq!(outcome, None);
}
#[test]
fn parse_question_reply_zero_is_invalid() {
let outcome = parse_question_reply("0", 2, false, &sample_labels());
assert_eq!(outcome, None);
}
#[test]
fn parse_question_reply_matches_option_label_case_insensitively() {
let outcome = parse_question_reply("fast", 2, false, &sample_labels());
assert_eq!(outcome, Some(QuestionAnswer::Selected(vec![0])));
}
#[test]
fn parse_question_reply_unmatched_text_is_free_text() {
let outcome = parse_question_reply("Let's do something else", 2, false, &sample_labels());
assert_eq!(
outcome,
Some(QuestionAnswer::FreeText(
"Let's do something else".to_string()
))
);
}
#[test]
fn parse_question_reply_strips_leading_mention() {
let outcome = parse_question_reply("@timmy 1", 2, false, &sample_labels());
assert_eq!(outcome, Some(QuestionAnswer::Selected(vec![0])));
}
/// Regression test: `switch` reads from the live store, not a snapshot Vec.
///
/// Seeds an empty store, inserts a project at runtime, then asserts the
@@ -12,6 +12,10 @@ pub mod messages;
/// Permission listener — registers as a permission responder for the bot's
/// lifetime and forwards permission requests to the configured Matrix room.
pub mod permission_listener;
/// Question listener — registers as a question responder for the bot's
/// lifetime and forwards `ask_question` requests to the configured Matrix
/// room, rendered as numbered text (story 1228).
pub mod question_listener;
/// Bot run loop — the main async task that drives the Matrix sync loop.
pub mod run;
/// Device verification — handles Matrix cross-signing and emoji verification flows.
@@ -174,6 +174,10 @@ mod tests {
pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
),
question_timeout_secs: 120,
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
})
@@ -0,0 +1,335 @@
//! Background task that registers as a question responder for the bot's
//! lifetime and forwards `ask_question` requests to the configured Matrix
//! room, rendering them as numbered text (story 1228).
//!
//! Mirrors `permission_listener.rs`'s always-on registration pattern so an
//! agent's question reaches chat even when the bot isn't mid-turn.
use crate::chat::ChatTransport;
use crate::http::context::QuestionSpec;
use crate::services::Services;
use crate::slog;
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId};
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex as TokioMutex;
use super::format::markdown_to_html;
/// Render a [`QuestionSpec`] as readable, numbered chat text — the question,
/// then each option numbered with its label and description — never raw
/// JSON (story 1228, AC1).
pub fn format_question_as_text(question: &QuestionSpec) -> String {
let mut out = format!("**{}**\n\n{}\n\n", question.header, question.question);
for (i, opt) in question.options.iter().enumerate() {
out.push_str(&format!("{}. {}{}\n", i + 1, opt.label, opt.description));
}
out.push('\n');
if question.multi_select {
out.push_str(
"Reply with a number, or multiple numbers separated by commas (e.g. \"1,3\"), \
or type your own answer.",
);
} else {
out.push_str("Reply with a number, or type your own answer.");
}
out
}
/// Spawn a background task that registers with `services.question_registry`
/// for the bot's lifetime and forwards each incoming question request to
/// `target_room` as a chat message. Replies are resolved by `on_room_message`
/// via `pending_question_replies`.
pub fn spawn_question_listener(
services: Arc<Services>,
transport: Arc<dyn ChatTransport>,
target_room: OwnedRoomId,
bot_sent_event_ids: Arc<TokioMutex<HashSet<OwnedEventId>>>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let (_responder_guard, mut question_rx) = services.question_registry.register();
let target_room_str = target_room.as_str().to_string();
slog!("[matrix-bot] question listener started; forwarding requests to {target_room_str}");
while let Some(q_fwd) = question_rx.recv().await {
let prompt_msg = format_question_as_text(&q_fwd.question);
let html = markdown_to_html(&prompt_msg);
if let Ok(msg_id) = transport
.send_message(&target_room_str, &prompt_msg, &html)
.await
&& let Ok(event_id) = msg_id.parse::<OwnedEventId>()
{
bot_sent_event_ids.lock().await.insert(event_id);
}
let num_options = q_fwd.question.options.len();
let multi_select = q_fwd.question.multi_select;
let labels: Vec<String> = q_fwd
.question
.options
.iter()
.map(|o| o.label.clone())
.collect();
services
.pending_question_replies
.insert(
target_room.to_string(),
q_fwd.request_id.clone(),
num_options,
multi_select,
labels,
q_fwd.response_tx,
)
.await;
// Spawn a per-request timeout: give up (fail-closed) if the user
// does not respond within `question_timeout_secs`.
let pending = Arc::clone(&services.pending_question_replies);
let timeout_room_key = target_room.to_string();
let timeout_request_id = q_fwd.request_id.clone();
let timeout_transport = Arc::clone(&transport);
let timeout_room_str = target_room_str.clone();
let timeout_sent_ids = Arc::clone(&bot_sent_event_ids);
let timeout_secs = services.question_timeout_secs;
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(timeout_secs)).await;
if let Some(tx) = pending
.remove_by_request_id(&timeout_room_key, &timeout_request_id)
.await
{
let _ = tx.send(Err("Question timed out waiting for a reply.".to_string()));
let msg = "Question timed out — no answer received.";
let html = markdown_to_html(msg);
if let Ok(msg_id) = timeout_transport
.send_message(&timeout_room_str, msg, &html)
.await
&& let Ok(event_id) = msg_id.parse::<OwnedEventId>()
{
timeout_sent_ids.lock().await.insert(event_id);
}
}
});
}
slog!("[matrix-bot] question listener exiting (channel closed)");
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::{QuestionAnswer, QuestionForward, QuestionOption};
use async_trait::async_trait;
struct RecordingTransport {
sent: Arc<std::sync::Mutex<Vec<(String, String)>>>,
}
#[async_trait]
impl crate::chat::ChatTransport for RecordingTransport {
async fn send_message(
&self,
room_id: &str,
plain: &str,
_html: &str,
) -> Result<crate::chat::MessageId, String> {
self.sent
.lock()
.unwrap()
.push((room_id.to_string(), plain.to_string()));
Ok("$test_event_id:example.com".to_string())
}
async fn edit_message(&self, _: &str, _: &str, _: &str, _: &str) -> Result<(), String> {
Ok(())
}
async fn send_typing(&self, _: &str, _: bool) -> Result<(), String> {
Ok(())
}
}
async fn wait_until(mut cond: impl FnMut() -> bool, timeout: std::time::Duration) -> bool {
let start = tokio::time::Instant::now();
loop {
if cond() {
return true;
}
if start.elapsed() > timeout {
return false;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
fn test_services() -> Arc<Services> {
Arc::new(Services {
project_root: std::path::PathBuf::from("/tmp/test"),
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
bot_name: "Assistant".to_string(),
bot_user_id: "@bot:example.com".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
permission_registry: crate::service::permission_router::ResponderRegistry::new(),
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
),
question_timeout_secs: 120,
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
})
}
fn sample_question(multi_select: bool) -> QuestionSpec {
QuestionSpec {
header: "Approach".to_string(),
question: "Which approach should we use?".to_string(),
options: vec![
QuestionOption {
label: "Fast".to_string(),
description: "Ship quickly".to_string(),
},
QuestionOption {
label: "Safe".to_string(),
description: "Take more time".to_string(),
},
],
multi_select,
}
}
#[test]
fn format_question_as_text_renders_numbered_options_never_json() {
let text = format_question_as_text(&sample_question(false));
assert!(text.contains("**Approach**"));
assert!(text.contains("Which approach should we use?"));
assert!(text.contains("1. Fast — Ship quickly"));
assert!(text.contains("2. Safe — Take more time"));
assert!(!text.contains('{'), "must never render raw JSON: {text}");
}
#[test]
fn format_question_as_text_mentions_multi_select_syntax_when_enabled() {
let text = format_question_as_text(&sample_question(true));
assert!(text.contains("1,3"));
}
#[tokio::test]
async fn listener_forwards_question_to_target_room_and_registers_pending_reply() {
let services = test_services();
let sent: Arc<std::sync::Mutex<Vec<(String, String)>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let transport: Arc<dyn crate::chat::ChatTransport> = Arc::new(RecordingTransport {
sent: Arc::clone(&sent),
});
let target_room: OwnedRoomId = "!question:example.com".parse().unwrap();
let bot_sent_event_ids = Arc::new(TokioMutex::new(HashSet::new()));
spawn_question_listener(
Arc::clone(&services),
Arc::clone(&transport),
target_room.clone(),
Arc::clone(&bot_sent_event_ids),
);
assert!(
wait_until(
|| !services.question_registry.is_empty(),
std::time::Duration::from_secs(2)
)
.await,
"listener never registered as a responder"
);
let (response_tx, _response_rx) = tokio::sync::oneshot::channel();
services.question_registry.dispatch(QuestionForward {
request_id: "req-1".to_string(),
question: sample_question(false),
response_tx,
});
assert!(
wait_until(
|| !sent.lock().unwrap().is_empty(),
std::time::Duration::from_secs(2)
)
.await,
"listener never sent the question prompt"
);
let recorded = sent.lock().unwrap().clone();
assert_eq!(recorded[0].0, target_room.as_str());
assert!(recorded[0].1.contains("1. Fast — Ship quickly"));
let mut resolved = None;
for _ in 0..50 {
resolved = services
.pending_question_replies
.peek_oldest_meta(target_room.as_str())
.await;
if resolved.is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
assert_eq!(
resolved,
Some((2, false, vec!["Fast".to_string(), "Safe".to_string()])),
"pending_question_replies missing entry with correct option count"
);
}
#[tokio::test]
async fn resolved_reply_delivers_answer_through_response_channel() {
let services = test_services();
let sent: Arc<std::sync::Mutex<Vec<(String, String)>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let transport: Arc<dyn crate::chat::ChatTransport> = Arc::new(RecordingTransport {
sent: Arc::clone(&sent),
});
let target_room: OwnedRoomId = "!question2:example.com".parse().unwrap();
let bot_sent_event_ids = Arc::new(TokioMutex::new(HashSet::new()));
spawn_question_listener(
Arc::clone(&services),
Arc::clone(&transport),
target_room.clone(),
Arc::clone(&bot_sent_event_ids),
);
assert!(
wait_until(
|| !services.question_registry.is_empty(),
std::time::Duration::from_secs(2)
)
.await
);
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
services.question_registry.dispatch(QuestionForward {
request_id: "req-2".to_string(),
question: sample_question(false),
response_tx,
});
let mut tx = None;
for _ in 0..50 {
tx = services
.pending_question_replies
.resolve_oldest(target_room.as_str())
.await;
if tx.is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
let tx = tx.expect("pending reply must be present");
let _ = tx.send(Ok(QuestionAnswer::Selected(vec![1])));
assert_eq!(
response_rx.await.unwrap().unwrap(),
QuestionAnswer::Selected(vec![1])
);
}
}
@@ -306,6 +306,12 @@ pub async fn run_bot(
target_room.clone(),
Arc::clone(&bot_sent_event_ids),
);
super::question_listener::spawn_question_listener(
Arc::clone(&services),
Arc::clone(&transport),
target_room.clone(),
Arc::clone(&bot_sent_event_ids),
);
}
// The forwarder only needs live (future) events — resubscribe is fine.
@@ -625,6 +625,10 @@ mod tests {
pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
),
question_timeout_secs: 120,
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
});
@@ -657,6 +661,10 @@ mod tests {
pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
),
question_timeout_secs: 120,
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
});
@@ -315,6 +315,10 @@ mod tests {
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
),
question_timeout_secs: 120,
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
});
Arc::new(WhatsAppWebhookContext {
+40
View File
@@ -3,6 +3,23 @@
//! These functions are transport-agnostic helpers for processing chat messages:
//! prefix stripping, bot-mention handling, and paragraph buffering.
/// Instruction prepended to chat-bot prompts telling the model how to ask a
/// clarifying multiple-choice question over a chat protocol that has no
/// interactive question UI (story 1228).
///
/// Claude Code's built-in `AskUserQuestion` tool requires an interactive
/// terminal/IDE to render and answer; in the headless `claude -p` sessions
/// this server spawns for chat transports it either isn't offered to the
/// model at all, or (if forced into scope) degrades into the model emitting
/// its attempted call as raw tool-call syntax in plain text. Disallowing it
/// (`--disallowedTools AskUserQuestion` on the CLI invocation) closes that
/// leak; this instruction points the model at the huskies-owned MCP
/// `ask_question` tool as the replacement, which renders the numbered
/// options in chat, blocks until a reply resolves it, and returns the
/// answer directly to this tool call — the model does not need to format
/// the question itself or wait for a future turn to see the reply.
pub const QUESTION_FORMAT_INSTRUCTION: &str = "This chat has no interactive question UI, and the built-in AskUserQuestion tool is unavailable here. When you need to ask the user a clarifying multiple-choice question, call the `ask_question` MCP tool instead — never emit JSON or tool-call syntax as plain text. Pass a `question`, an optional `header`, and at least two `options` (each with a `label` and `description`); set `multi_select: true` if the user may choose more than one. The tool renders the options as a numbered list in chat and blocks until the user replies, then returns the selected option(s) or free-text answer for you to continue with.";
/// Truncate `s` to at most `max_bytes` bytes without splitting a UTF-8 codepoint.
///
/// If `s.len() <= max_bytes` the original slice is returned unchanged.
@@ -318,6 +335,29 @@ mod tests {
assert_eq!(truncate_at_char_boundary("hi", 100), "hi");
}
// -- QUESTION_FORMAT_INSTRUCTION (story 1228) ---------------------------
#[test]
fn question_format_instruction_directs_model_to_ask_question_tool() {
assert!(QUESTION_FORMAT_INSTRUCTION.contains("ask_question"));
assert!(QUESTION_FORMAT_INSTRUCTION.contains("AskUserQuestion"));
assert!(QUESTION_FORMAT_INSTRUCTION.contains("never emit JSON or tool-call syntax"));
}
#[test]
fn question_format_instruction_describes_required_arguments() {
assert!(QUESTION_FORMAT_INSTRUCTION.contains("question"));
assert!(QUESTION_FORMAT_INSTRUCTION.contains("options"));
assert!(QUESTION_FORMAT_INSTRUCTION.contains("label"));
assert!(QUESTION_FORMAT_INSTRUCTION.contains("multi_select"));
}
#[test]
fn question_format_instruction_explains_blocking_reply_behavior() {
assert!(QUESTION_FORMAT_INSTRUCTION.contains("blocks until the user replies"));
assert!(QUESTION_FORMAT_INSTRUCTION.contains("free-text"));
}
// -- is_permission_approval ---------------------------------------------
#[test]