1use std::sync::Arc;
21
22use tokio::sync::broadcast;
23
24use bamboo_agent_core::tools::{ToolCall, ToolExecutionContext};
25use bamboo_agent_core::{AgentEvent, Session};
26use bamboo_engine::execution::{
27 create_event_forwarder, get_or_create_event_sender, reserve_session_execution,
28 SessionExecutionReserveOutcome,
29};
30use bamboo_engine::runtime::execution::agent_spawn::{
31 spawn_session_execution, SessionExecutionArgs,
32};
33use bamboo_engine::session_app::approval_replay::{
34 refresh_approval_replay_posture, ApprovalReplayDecision,
35};
36use bamboo_engine::session_app::resolution::resolve_resume_config_snapshot;
37use bamboo_engine::session_app::respond::{
38 submit_pending_response, PERMISSION_REEXECUTE_METADATA_KEY,
39};
40use bamboo_engine::session_app::resume::{
41 resume_session_execution, ResumeExecutionPort, ResumeSpawnRequest,
42};
43use bamboo_engine::session_app::types::RespondInput;
44use bamboo_engine::{ModelRoster, RoleModel};
45
46use super::bridge::ConnectContext;
47use super::platform::{Button, OutboundMessage, Platform, PlatformResult, ReplyCtx};
48use super::render::PendingAsk;
49
50const BUTTON_LABEL_MAX_CHARS: usize = 48;
54
55#[derive(Debug, Clone)]
63pub struct ParkedAsk {
64 pub nonce: String,
68 pub session_id: String,
69 pub tool_call_id: String,
70 pub tool_name: String,
71 pub question: String,
72 pub options: Vec<String>,
73 pub allow_custom: bool,
74}
75
76impl ParkedAsk {
77 pub fn new(nonce: String, session_id: String, ask: &PendingAsk) -> Self {
78 Self {
79 nonce,
80 session_id,
81 tool_call_id: ask.tool_call_id.clone(),
82 tool_name: ask.tool_name.clone(),
83 question: ask.question.clone(),
84 options: ask.options.clone(),
85 allow_custom: ask.allow_custom,
86 }
87 }
88}
89
90pub fn new_nonce() -> String {
95 let raw = uuid::Uuid::new_v4().to_string();
96 raw.split('-').next().unwrap_or(&raw).to_string()
97}
98
99fn truncate_label(text: &str) -> String {
104 if text.chars().count() <= BUTTON_LABEL_MAX_CHARS {
105 return text.to_string();
106 }
107 let mut out: String = text.chars().take(BUTTON_LABEL_MAX_CHARS - 1).collect();
108 out.push('…');
109 out
110}
111
112fn format_ask_text(ask: &ParkedAsk) -> String {
115 let mut text = ask.question.clone();
116 if !ask.options.is_empty() {
117 text.push_str("\n\n");
118 for (index, option) in ask.options.iter().enumerate() {
119 text.push_str(&format!("{}. {}\n", index + 1, option));
120 }
121 }
122 if ask.allow_custom {
123 text.push_str("\n(or reply with your own answer)");
124 }
125 text
126}
127
128pub async fn render_ask(
135 platform: &Arc<dyn Platform>,
136 reply_ctx: &ReplyCtx,
137 ask: &ParkedAsk,
138 buttons_capable: bool,
139) -> PlatformResult<()> {
140 let text = format_ask_text(ask);
141 let outbound = if buttons_capable && !ask.options.is_empty() {
142 let rows: Vec<Vec<Button>> = ask
143 .options
144 .iter()
145 .enumerate()
146 .map(|(index, option)| {
147 vec![Button::new(
148 truncate_label(option),
149 format!("{}:{index}", ask.nonce),
150 )]
151 })
152 .collect();
153 OutboundMessage::text(text).with_buttons(rows)
154 } else {
155 OutboundMessage::text(text)
156 };
157 platform.reply(reply_ctx, outbound).await.map(|_| ())
158}
159
160const AFFIRMATIVE_KEYWORDS: &[&str] = &[
165 "允许", "同意", "确定", "是", "yes", "allow", "approve", "ok",
166];
167const NEGATIVE_KEYWORDS: &[&str] = &["拒绝", "不", "否", "no", "deny", "reject", "stay"];
176
177fn classify_intent(text: &str) -> Option<bool> {
178 let lower = text.trim().to_lowercase();
179 if AFFIRMATIVE_KEYWORDS.iter().any(|keyword| lower == *keyword) {
180 return Some(true);
181 }
182 if NEGATIVE_KEYWORDS.iter().any(|keyword| lower == *keyword) {
183 return Some(false);
184 }
185 None
186}
187
188fn pick_option_by_intent(options: &[String], affirmative: bool) -> Option<String> {
193 let keywords: &[&str] = if affirmative {
194 AFFIRMATIVE_KEYWORDS
195 } else {
196 NEGATIVE_KEYWORDS
197 };
198 if let Some(option) = options.iter().find(|option| {
199 let lower = option.to_lowercase();
200 keywords.iter().any(|keyword| lower.contains(keyword))
201 }) {
202 return Some(option.clone());
203 }
204 if options.len() == 2 {
205 return Some(if affirmative {
206 options[0].clone()
207 } else {
208 options[1].clone()
209 });
210 }
211 None
212}
213
214pub fn match_text_answer(ask: &ParkedAsk, text: &str) -> Option<String> {
226 let trimmed = text.trim();
227 if trimmed.is_empty() {
228 return None;
229 }
230 if let Ok(index) = trimmed.parse::<usize>() {
231 if index >= 1 && index <= ask.options.len() {
232 return Some(ask.options[index - 1].clone());
233 }
234 }
235 if let Some(option) = ask
236 .options
237 .iter()
238 .find(|option| option.eq_ignore_ascii_case(trimmed))
239 {
240 return Some(option.clone());
241 }
242 if !ask.allow_custom {
243 if let Some(intent) = classify_intent(trimmed) {
244 if let Some(option) = pick_option_by_intent(&ask.options, intent) {
245 return Some(option);
246 }
247 }
248 }
249 if ask.allow_custom {
250 return Some(trimmed.to_string());
251 }
252 None
253}
254
255pub fn match_callback_data(ask: &ParkedAsk, data: &str) -> Option<String> {
261 let (nonce, index_str) = data.split_once(':')?;
262 if nonce != ask.nonce {
263 return None;
264 }
265 let index: usize = index_str.parse().ok()?;
266 ask.options.get(index).cloned()
267}
268
269pub enum RespondAndResumeOutcome {
275 Resumed(broadcast::Receiver<AgentEvent>),
278 NotResumed(String),
283}
284
285#[derive(Debug, thiserror::Error)]
289pub enum ResponderError {
290 #[error("session not found")]
291 NotFound,
292 #[error("no pending question waiting for a response")]
293 NoPendingQuestion,
294 #[error("invalid response: {0}")]
295 InvalidResponse(String),
296 #[error("{0}")]
297 Other(String),
298}
299
300#[async_trait::async_trait]
307pub trait Responder: Send + Sync {
308 async fn respond_and_resume(
309 &self,
310 session_id: &str,
311 answer: String,
312 ) -> Result<RespondAndResumeOutcome, ResponderError>;
313}
314
315fn map_respond_error(error: bamboo_engine::session_app::errors::RespondError) -> ResponderError {
316 use bamboo_engine::session_app::errors::RespondError;
317 match error {
318 RespondError::NotFound(_) => ResponderError::NotFound,
319 RespondError::NoPendingQuestion => ResponderError::NoPendingQuestion,
320 RespondError::InvalidResponse(message) => ResponderError::InvalidResponse(message),
321 other => ResponderError::Other(other.to_string()),
322 }
323}
324
325pub struct EngineResponder {
335 ctx: ConnectContext,
336}
337
338impl EngineResponder {
339 pub fn new(ctx: ConnectContext) -> Self {
340 Self { ctx }
341 }
342}
343
344#[async_trait::async_trait]
345impl Responder for EngineResponder {
346 async fn respond_and_resume(
347 &self,
348 session_id: &str,
349 answer: String,
350 ) -> Result<RespondAndResumeOutcome, ResponderError> {
351 let input = RespondInput {
352 session_id: session_id.to_string(),
353 user_response: answer,
354 model: None,
355 model_ref: None,
356 provider: None,
357 reasoning_effort: None,
358 };
359
360 let (session, _submitted_answer, plan_mode_transition, permission_grants) =
361 submit_pending_response(&self.ctx.session_repo, input)
362 .await
363 .map_err(map_respond_error)?;
364
365 for (perm_type, resource) in &permission_grants {
369 if let Some(request_id) = session.metadata.get(PERMISSION_REEXECUTE_METADATA_KEY) {
370 self.ctx.permission_checker.grant_once(
371 session_id,
372 request_id,
373 *perm_type,
374 resource.clone(),
375 );
376 }
377 }
378
379 let session_tx =
383 get_or_create_event_sender(&self.ctx.session_event_senders, session_id).await;
384 let receiver = session_tx.subscribe();
385
386 if let Some(event) = plan_mode_transition_event(session_id, plan_mode_transition.as_ref()) {
387 let _ = session_tx.send(event);
388 }
389
390 let config_snapshot = self.ctx.config.read().await.clone();
391 let resume_config = resolve_resume_config_snapshot(
392 &config_snapshot,
393 &self.ctx.provider_registry,
394 &session,
395 None,
396 );
397
398 let port = ConnectResumePort {
399 ctx: self.ctx.clone(),
400 };
401 let outcome = resume_session_execution(&port, session_id, resume_config).await;
402
403 match outcome {
404 bamboo_engine::session_app::types::ResumeOutcome::Started { .. } => {
405 Ok(RespondAndResumeOutcome::Resumed(receiver))
406 }
407 bamboo_engine::session_app::types::ResumeOutcome::AlreadyRunning { .. } => Ok(
408 RespondAndResumeOutcome::NotResumed("this session is already running".to_string()),
409 ),
410 bamboo_engine::session_app::types::ResumeOutcome::Completed => Ok(
411 RespondAndResumeOutcome::NotResumed("nothing left to resume".to_string()),
412 ),
413 bamboo_engine::session_app::types::ResumeOutcome::NotFound => Ok(
414 RespondAndResumeOutcome::NotResumed("session no longer exists".to_string()),
415 ),
416 }
417 }
418}
419
420fn plan_mode_transition_event(
421 session_id: &str,
422 transition: Option<&bamboo_engine::session_app::respond::PlanModeTransition>,
423) -> Option<AgentEvent> {
424 use bamboo_engine::session_app::respond::PlanModeTransition;
425 transition.map(|transition| match transition {
426 PlanModeTransition::Entered {
427 reason,
428 pre_permission_mode,
429 entered_at,
430 status,
431 plan_file_path,
432 } => AgentEvent::PlanModeEntered {
433 session_id: session_id.to_string(),
434 reason: reason.clone(),
435 pre_permission_mode: pre_permission_mode.clone(),
436 entered_at: *entered_at,
437 status: *status,
438 plan_file_path: plan_file_path.clone(),
439 },
440 PlanModeTransition::Exited {
441 approved,
442 restored_mode,
443 plan,
444 } => AgentEvent::PlanModeExited {
445 session_id: session_id.to_string(),
446 approved: *approved,
447 restored_mode: restored_mode.clone(),
448 plan: plan.clone(),
449 },
450 })
451}
452
453struct ConnectResumePort {
467 ctx: ConnectContext,
468}
469
470#[async_trait::async_trait]
471impl ResumeExecutionPort for ConnectResumePort {
472 async fn load_session(&self, session_id: &str) -> Option<Session> {
473 self.ctx.session_repo.load_merged(session_id).await
474 }
475
476 async fn save_and_cache_session(&self, session: &mut Session) {
477 self.ctx.session_repo.save_and_cache(session).await;
478 }
479
480 async fn reserve_session_execution(
481 &self,
482 session_id: &str,
483 event_sender: &broadcast::Sender<AgentEvent>,
484 ) -> SessionExecutionReserveOutcome {
485 reserve_session_execution(
486 &self.ctx.agent,
487 &self.ctx.agent_runners,
488 &self.ctx.session_event_senders,
489 session_id,
490 event_sender,
491 )
492 .await
493 }
494
495 async fn get_or_create_event_sender(&self, session_id: &str) -> broadcast::Sender<AgentEvent> {
496 get_or_create_event_sender(&self.ctx.session_event_senders, session_id).await
497 }
498
499 async fn spawn_resume_execution(&self, request: ResumeSpawnRequest) {
500 let ResumeSpawnRequest {
501 session_id,
502 session,
503 mut execution_reservation,
504 event_sender,
505 config,
506 } = request;
507 if let Err(error) = execution_reservation.ensure_registered().await {
508 tracing::warn!(
509 %session_id,
510 run_id = %execution_reservation.run_id(),
511 %error,
512 "cannot resume connect session without exact router ownership"
513 );
514 return;
515 }
516
517 let model = session.model.clone();
518 let reasoning_effort = session.reasoning_effort;
519 let model_roster = ModelRoster {
520 model: Some(model),
521 provider_name: Some(config.provider_name.clone()),
522 provider_type: config.provider_type.clone(),
523 fast: RoleModel::from_parts(config.fast_model.clone(), None),
524 background: RoleModel::from_parts(
525 config.background_model.clone(),
526 config.background_model_provider.clone(),
527 ),
528 summarization: RoleModel::from_parts(
529 config.summarization_model.clone(),
530 config.summarization_model_provider.clone(),
531 ),
532 };
533
534 let (mpsc_tx, _forwarder_handle) = create_event_forwarder(
535 session_id.clone(),
536 event_sender,
537 self.ctx.agent_runners.clone(),
538 self.ctx.account_feed_inbox.clone(),
539 );
540
541 let reexecute_tool_call_id = session
549 .metadata
550 .get(PERMISSION_REEXECUTE_METADATA_KEY)
551 .cloned();
552
553 let Some(reexecute_tool_call_id) = reexecute_tool_call_id else {
554 spawn_session_execution(SessionExecutionArgs {
555 agent: self.ctx.agent.clone(),
556 session_id,
557 session,
558 execution_reservation,
559 tools_override: Some(self.ctx.tools.clone()),
560 provider_override: None,
561 model_roster,
562 reasoning_effort,
563 reasoning_effort_source: "connect_resume".to_string(),
564 auxiliary_model_resolver: None,
565 disabled_filter_resolver: None,
566 disabled_tools: Some(config.disabled_tools.clone()),
567 disabled_skill_ids: Some(config.disabled_skill_ids.clone()),
568 selected_skill_ids: None,
569 selected_skill_mode: None,
570 mpsc_tx,
571 image_fallback: config.image_fallback.clone(),
572 gold_config: config.gold_config.clone(),
573 guardian_config: None,
574 guardian_spawner: None,
575 bash_resume_hook: None,
576 bash_completion_sink: None,
577 app_data_dir: self.ctx.app_data_dir.clone(),
578 run_budget: None,
581 runners: self.ctx.agent_runners.clone(),
582 sessions_cache: self.ctx.session_repo.cache().clone(),
583 on_complete: None,
584 child_completion_handler: None,
587 });
588 return;
589 };
590
591 let ctx = self.ctx.clone();
592 tokio::spawn(async move {
593 let mut session = session;
594
595 if let Some(tool_call) = find_pending_tool_call(&session, &reexecute_tool_call_id) {
596 let tool_name = tool_call.function.name.clone();
597 let configured_mode = ctx
598 .permission_checker
599 .permission_config()
600 .map(|config| config.mode())
601 .unwrap_or_default();
602 let decision = match refresh_approval_replay_posture(
603 ctx.session_repo.storage().as_ref(),
604 &mut session,
605 configured_mode,
606 &tool_name,
607 )
608 .await
609 {
610 Ok(decision) => decision,
611 Err(error) => {
612 tracing::error!(
613 %session_id,
614 tool_call_id = %reexecute_tool_call_id,
615 %error,
616 "connect approval replay posture refresh failed closed"
617 );
618 return;
619 }
620 };
621 session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
622
623 let (content, success) = match decision {
624 ApprovalReplayDecision::BlockedByPlan(_) => (
625 format!(
626 "Plan mode blocked approved mutating tool '{tool_name}'; the stale approval was not executed"
627 ),
628 false,
629 ),
630 ApprovalReplayDecision::Execute(flags) => {
631 let executor = ctx.tools.clone();
632 let is_mutating = bamboo_tools::orchestrator::classify_tool(&tool_name)
633 == bamboo_tools::orchestrator::ToolMutability::Mutating;
634 let mut emitter = bamboo_tools::ToolEmitter::new(
635 &tool_call.id,
636 &tool_name,
637 is_mutating,
638 );
639 emitter.set_auto_approved(true);
640 let _ = mpsc_tx
641 .send(emitter.begin().clone().into_agent_event())
642 .await;
643 let exec_result = executor
644 .execute_with_context(
645 &tool_call,
646 ToolExecutionContext {
647 session_id: Some(session.id.as_str()),
648 tool_call_id: reexecute_tool_call_id.as_str(),
649 event_tx: Some(&mpsc_tx),
650 available_tool_schemas: None,
651 bypass_permissions: flags.bypass_permissions,
652 auto_approve_permissions: flags.auto_approve_permissions,
653 plan_read_only: flags.plan_read_only,
654 can_async_resume: false,
655 bash_completion_sink: None,
656 pre_parsed_args: None,
657 },
658 )
659 .await;
660
661 match exec_result {
662 Ok(tool_result) => {
663 let _ = mpsc_tx
664 .send(
665 emitter
666 .finish(Some(
667 "Re-executed after approval".to_string(),
668 ))
669 .clone()
670 .into_agent_event(),
671 )
672 .await;
673 let _ = mpsc_tx
674 .send(AgentEvent::ToolComplete {
675 tool_call_id: tool_call.id.clone(),
676 result: tool_result.clone(),
677 })
678 .await;
679 (tool_result.result, tool_result.success)
680 }
681 Err(error) => {
682 let message =
683 format!("Tool re-execution after approval failed: {error}");
684 let _ = mpsc_tx
685 .send(
686 emitter.error(message.clone()).clone().into_agent_event(),
687 )
688 .await;
689 (message, false)
690 }
691 }
692 }
693 };
694
695 tracing::info!(
696 "[{}] connect: resolved approved tool replay '{}' ({}) -> success={}",
697 session_id,
698 tool_name,
699 reexecute_tool_call_id,
700 success
701 );
702 apply_tool_result(&mut session, &reexecute_tool_call_id, content, success);
703 ctx.session_repo.save_and_cache(&mut session).await;
704 } else {
705 session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
706 tracing::warn!(
707 "[{}] connect: permission re-exec marker set but tool call '{}' not found in history",
708 session_id,
709 reexecute_tool_call_id
710 );
711 }
712
713 spawn_session_execution(SessionExecutionArgs {
714 agent: ctx.agent.clone(),
715 session_id,
716 session,
717 execution_reservation,
718 tools_override: Some(ctx.tools.clone()),
719 provider_override: None,
720 model_roster,
721 reasoning_effort,
722 reasoning_effort_source: "connect_resume".to_string(),
723 auxiliary_model_resolver: None,
724 disabled_filter_resolver: None,
725 disabled_tools: Some(config.disabled_tools.clone()),
726 disabled_skill_ids: Some(config.disabled_skill_ids.clone()),
727 selected_skill_ids: None,
728 selected_skill_mode: None,
729 mpsc_tx,
730 image_fallback: config.image_fallback.clone(),
731 gold_config: config.gold_config.clone(),
732 guardian_config: None,
733 guardian_spawner: None,
734 bash_resume_hook: None,
735 bash_completion_sink: None,
736 app_data_dir: ctx.app_data_dir.clone(),
737 run_budget: None,
740 runners: ctx.agent_runners.clone(),
741 sessions_cache: ctx.session_repo.cache().clone(),
742 on_complete: None,
743 child_completion_handler: None,
746 });
747 });
748 }
749}
750
751fn find_pending_tool_call(session: &Session, tool_call_id: &str) -> Option<ToolCall> {
754 session.messages.iter().find_map(|message| {
755 message
756 .tool_calls
757 .as_ref()
758 .and_then(|calls| calls.iter().find(|call| call.id == tool_call_id).cloned())
759 })
760}
761
762fn apply_tool_result(session: &mut Session, tool_call_id: &str, content: String, success: bool) {
765 for message in &mut session.messages {
766 if message.tool_call_id.as_deref() == Some(tool_call_id) {
767 message.content = content;
768 message.tool_success = Some(success);
769 return;
770 }
771 }
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777
778 fn ask(options: Vec<&str>, allow_custom: bool) -> ParkedAsk {
779 ParkedAsk {
780 nonce: "abc12345".to_string(),
781 session_id: "sess-1".to_string(),
782 tool_call_id: "call-1".to_string(),
783 tool_name: "conclusion_with_options".to_string(),
784 question: "Approve?".to_string(),
785 options: options.into_iter().map(str::to_string).collect(),
786 allow_custom,
787 }
788 }
789
790 #[test]
791 fn new_nonce_is_short_and_hex_like() {
792 let nonce = new_nonce();
793 assert!(!nonce.is_empty());
794 assert!(nonce.len() <= 16);
795 assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
796 }
797
798 #[test]
799 fn match_text_answer_numeric_index_selects_option() {
800 let pending = ask(vec!["Approve", "Deny"], false);
801 assert_eq!(
802 match_text_answer(&pending, "1"),
803 Some("Approve".to_string())
804 );
805 assert_eq!(match_text_answer(&pending, "2"), Some("Deny".to_string()));
806 assert_eq!(match_text_answer(&pending, "3"), None);
807 }
808
809 #[test]
810 fn match_text_answer_exact_text_is_case_insensitive() {
811 let pending = ask(vec!["Approve", "Deny"], false);
812 assert_eq!(
813 match_text_answer(&pending, "approve"),
814 Some("Approve".to_string())
815 );
816 }
817
818 #[test]
819 fn match_text_answer_binary_keyword_mapping() {
820 let pending = ask(vec!["Approve", "Deny"], false);
821 assert_eq!(
822 match_text_answer(&pending, "允许"),
823 Some("Approve".to_string())
824 );
825 assert_eq!(
826 match_text_answer(&pending, "yes"),
827 Some("Approve".to_string())
828 );
829 assert_eq!(
830 match_text_answer(&pending, "deny"),
831 Some("Deny".to_string())
832 );
833 assert_eq!(match_text_answer(&pending, "no"), Some("Deny".to_string()));
834 }
835
836 #[test]
841 fn match_text_answer_exact_option_named_stay_beats_negative_keyword_fallback() {
842 let pending = ask(vec!["Stay", "Leave"], false);
843 assert_eq!(
844 match_text_answer(&pending, "stay"),
845 Some("Stay".to_string())
846 );
847 let plan_pending = ask(vec!["Approve", "Stay in plan mode"], false);
850 assert_eq!(
851 match_text_answer(&plan_pending, "stay"),
852 Some("Stay in plan mode".to_string())
853 );
854 }
855
856 #[test]
857 fn match_text_answer_closed_ask_non_matching_text_falls_through() {
858 let pending = ask(vec!["Approve", "Deny"], false);
859 assert_eq!(match_text_answer(&pending, "banana"), None);
860 }
861
862 #[test]
863 fn match_text_answer_open_question_accepts_any_free_text() {
864 let pending = ask(vec!["OK", "Need changes"], true);
865 assert_eq!(
866 match_text_answer(&pending, "please add tests too"),
867 Some("please add tests too".to_string())
868 );
869 }
870
871 #[test]
872 fn match_text_answer_empty_text_never_matches() {
873 let pending = ask(vec!["OK", "Need changes"], true);
874 assert_eq!(match_text_answer(&pending, " "), None);
875 }
876
877 #[test]
878 fn match_callback_data_requires_the_exact_nonce() {
879 let pending = ask(vec!["Approve", "Deny"], false);
880 assert_eq!(
881 match_callback_data(&pending, "abc12345:0"),
882 Some("Approve".to_string())
883 );
884 assert_eq!(match_callback_data(&pending, "stale-nonce:0"), None);
885 }
886
887 #[test]
888 fn match_callback_data_rejects_out_of_range_index() {
889 let pending = ask(vec!["Approve", "Deny"], false);
890 assert_eq!(match_callback_data(&pending, "abc12345:9"), None);
891 }
892
893 #[test]
894 fn match_callback_data_rejects_malformed_data() {
895 let pending = ask(vec!["Approve", "Deny"], false);
896 assert_eq!(match_callback_data(&pending, "not-a-valid-shape"), None);
897 assert_eq!(match_callback_data(&pending, "abc12345:not-a-number"), None);
898 }
899
900 #[test]
901 fn format_ask_text_numbers_every_option() {
902 let pending = ask(vec!["Approve", "Deny"], false);
903 let text = format_ask_text(&pending);
904 assert!(text.contains("1. Approve"));
905 assert!(text.contains("2. Deny"));
906 assert!(!text.contains("reply with your own answer"));
907 }
908
909 #[test]
910 fn format_ask_text_open_question_mentions_free_text() {
911 let pending = ask(vec!["OK", "Need changes"], true);
912 assert!(format_ask_text(&pending).contains("reply with your own answer"));
913 }
914}