1use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::PathBuf;
10use std::sync::{Arc, Mutex as StdMutex};
11
12use chrono::{DateTime, Utc};
13use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, RwLock as TokioRwLock};
14use tokio_util::sync::CancellationToken;
15
16use bamboo_agent_core::tools::ToolExecutor;
17use bamboo_agent_core::{AgentEvent, Message, Session};
18use bamboo_domain::reasoning::ReasoningEffort;
19use bamboo_engine::execution::runner_state::AgentRunner;
20use bamboo_engine::execution::{
21 create_event_forwarder, get_or_create_event_sender, reserve_session_execution,
22 spawn_session_execution, SessionExecutionArgs, SessionExecutionReserveOutcome,
23};
24use bamboo_engine::{AuxiliaryModelConfig, SessionRepository};
25use bamboo_llm::{Config, ProviderRegistry};
26
27use super::approvals::{self, ParkedAsk, RespondAndResumeOutcome, Responder};
28use super::platform::{CallbackQuery, InboundMessage, OutboundMessage, Platform, ReplyCtx};
29use super::render;
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34pub struct SessionKey {
35 pub platform: String,
36 pub chat_id: String,
37 pub user_id: String,
38}
39
40impl SessionKey {
41 pub fn as_string(&self) -> String {
42 format!("{}:{}:{}", self.platform, self.chat_id, self.user_id)
43 }
44}
45
46const DEDUP_CAPACITY: usize = 10_000;
55
56struct BoundedSeenSet {
62 set: HashSet<String>,
63 order: VecDeque<String>,
64 capacity: usize,
65}
66
67impl BoundedSeenSet {
68 fn new(capacity: usize) -> Self {
69 Self {
70 set: HashSet::new(),
71 order: VecDeque::new(),
72 capacity: capacity.max(1),
73 }
74 }
75
76 fn insert(&mut self, key: String) -> bool {
80 if !self.set.insert(key.clone()) {
81 return false;
82 }
83 self.order.push_back(key);
84 while self.order.len() > self.capacity {
85 if let Some(oldest) = self.order.pop_front() {
86 self.set.remove(&oldest);
87 }
88 }
89 true
90 }
91
92 #[cfg(test)]
93 fn len(&self) -> usize {
94 self.set.len()
95 }
96}
97
98#[derive(Clone)]
102pub struct ConnectContext {
103 pub agent: Arc<bamboo_engine::Agent>,
104 pub tools: Arc<dyn ToolExecutor>,
105 pub session_repo: SessionRepository,
106 pub agent_runners: Arc<TokioRwLock<HashMap<String, AgentRunner>>>,
107 pub session_event_senders: Arc<TokioRwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
108 pub account_feed_inbox: Option<bamboo_engine::execution::AccountFeedInbox>,
109 pub app_data_dir: Option<PathBuf>,
110 pub config: Arc<tokio::sync::RwLock<Config>>,
111 pub provider_registry: Arc<ProviderRegistry>,
112 pub project_store: Arc<bamboo_projects::ProjectStore>,
113 pub project_ids_by_platform: Arc<HashMap<String, bamboo_domain::ProjectId>>,
117 pub permission_checker: Arc<dyn bamboo_tools::permission::PermissionChecker>,
123}
124
125#[derive(Default)]
132struct ChatState {
133 busy: bool,
134 queue: VecDeque<(Arc<dyn Platform>, InboundMessage)>,
135 cancel_token: Option<CancellationToken>,
136 pending_ask: Option<ParkedAsk>,
139 ask_resolution: Option<mpsc::Sender<AskResolution>>,
147}
148
149#[derive(Debug, Clone)]
151enum AskResolution {
152 Answer(String),
155 Invalidated,
159}
160
161type ResolvedConnectRunConfig = bamboo_engine::resolved_defaults::ResolvedDefaultRunConfig;
170
171fn resolve_connect_run_config(
172 config_snapshot: &Config,
173 provider_registry: &Arc<ProviderRegistry>,
174) -> ResolvedConnectRunConfig {
175 bamboo_engine::resolved_defaults::resolve_default_run_config(config_snapshot, provider_registry)
176}
177
178fn create_connect_session(
193 key: &str,
194 model: &str,
195 system_prompt: &str,
196 base_system_prompt: &str,
197 workspace_path: Option<&str>,
198 project_id: Option<&bamboo_domain::ProjectId>,
199 reasoning_effort: Option<ReasoningEffort>,
200) -> Session {
201 let session_id = uuid::Uuid::new_v4().to_string();
202 let mut session = Session::new(session_id.clone(), model.to_string());
203 session.title = format!("Connect: {key}");
204 session
205 .metadata
206 .insert("created_by_connect_key".to_string(), key.to_string());
207 session.metadata.insert(
208 "base_system_prompt".to_string(),
209 base_system_prompt.to_string(),
210 );
211 if let Some(project_id) = project_id {
212 session.set_project_id_meta(project_id.to_string());
213 }
214 if let Some(path) = workspace_path {
215 let final_workspace =
216 bamboo_tools::tools::workspace_state::set_workspace(&session_id, PathBuf::from(path));
217 session.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
218 &final_workspace,
219 ));
220 }
221 if let Some(effort) = reasoning_effort {
222 session.set_reasoning_effort_meta(effort.as_str());
223 }
224 session.add_message(Message::system(system_prompt.to_string()));
225 bamboo_engine::runner::refresh_prompt_snapshot(&mut session);
226 session
227 .agent_runtime_state
228 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
229 .no_human_approver = false;
230 session
231}
232
233fn strip_command_suffix(text: &str) -> &str {
236 text.split('@').next().unwrap_or(text)
237}
238
239async fn reply_text(platform: &Arc<dyn Platform>, ctx: &ReplyCtx, text: impl Into<String>) {
240 if let Err(error) = platform.reply(ctx, OutboundMessage::text(text)).await {
241 tracing::warn!("connect: failed to send reply: {error}");
242 }
243}
244
245pub struct ConnectBridge {
249 ctx: ConnectContext,
250 session_map: TokioRwLock<HashMap<String, String>>,
253 map_path: Option<PathBuf>,
254 chat_state: AsyncMutex<HashMap<String, ChatState>>,
255 seen_message_ids: StdMutex<BoundedSeenSet>,
262 process_start: DateTime<Utc>,
263 responder: Arc<dyn Responder>,
266}
267
268impl ConnectBridge {
269 pub fn new(ctx: ConnectContext, map_path: Option<PathBuf>) -> Self {
273 let responder = Arc::new(approvals::EngineResponder::new(ctx.clone()));
274 Self::with_responder(ctx, map_path, responder)
275 }
276
277 pub fn with_responder(
281 ctx: ConnectContext,
282 map_path: Option<PathBuf>,
283 responder: Arc<dyn Responder>,
284 ) -> Self {
285 Self {
286 ctx,
287 session_map: TokioRwLock::new(HashMap::new()),
288 map_path,
289 chat_state: AsyncMutex::new(HashMap::new()),
290 seen_message_ids: StdMutex::new(BoundedSeenSet::new(DEDUP_CAPACITY)),
291 process_start: Utc::now(),
292 responder,
293 }
294 }
295
296 pub async fn load_session_map(&self) {
301 let Some(path) = &self.map_path else {
302 return;
303 };
304 match tokio::fs::read(path).await {
305 Ok(bytes) => match serde_json::from_slice::<HashMap<String, String>>(&bytes) {
306 Ok(map) => *self.session_map.write().await = map,
307 Err(error) => {
308 tracing::warn!(
309 "connect: session map at {path:?} is corrupt, starting empty: {error}"
310 );
311 }
312 },
313 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
314 Err(error) => {
315 tracing::warn!("connect: failed to read session map at {path:?}: {error}");
316 }
317 }
318 }
319
320 pub async fn session_id_for_key(&self, key: &str) -> Option<String> {
321 self.session_map.read().await.get(key).cloned()
322 }
323
324 async fn set_session_id_for_key(&self, key: &str, session_id: &str) {
325 {
326 let mut map = self.session_map.write().await;
327 map.insert(key.to_string(), session_id.to_string());
328 }
329 self.persist_session_map().await;
330 }
331
332 async fn rotate_session(&self, key: &str) {
337 self.invalidate_pending_ask(key).await;
338 {
339 let mut map = self.session_map.write().await;
340 map.remove(key);
341 }
342 self.persist_session_map().await;
343 }
344
345 async fn has_pending_ask(&self, key: &str) -> bool {
347 self.chat_state
348 .lock()
349 .await
350 .get(key)
351 .is_some_and(|state| state.pending_ask.is_some())
352 }
353
354 async fn try_resolve_pending_ask(
362 &self,
363 key: &str,
364 resolve: impl FnOnce(&ParkedAsk) -> Option<String>,
365 ) -> Option<(String, mpsc::Sender<AskResolution>)> {
366 let mut guard = self.chat_state.lock().await;
367 let state = guard.get_mut(key)?;
368 let ask_ref = state.pending_ask.as_ref()?;
369 let answer = resolve(ask_ref)?;
370 let sender = state.ask_resolution.take()?;
371 state.pending_ask = None;
372 Some((answer, sender))
373 }
374
375 async fn invalidate_pending_ask(&self, key: &str) {
378 let sender = {
379 let mut guard = self.chat_state.lock().await;
380 match guard.get_mut(key) {
381 Some(state) => {
382 state.pending_ask = None;
383 state.ask_resolution.take()
384 }
385 None => None,
386 }
387 };
388 if let Some(sender) = sender {
389 let _ = sender.send(AskResolution::Invalidated).await;
390 }
391 }
392
393 async fn clear_pending_ask(&self, key: &str) {
397 let mut guard = self.chat_state.lock().await;
398 if let Some(state) = guard.get_mut(key) {
399 state.pending_ask = None;
400 state.ask_resolution = None;
401 }
402 }
403
404 async fn persist_session_map(&self) {
405 let Some(path) = &self.map_path else {
406 return;
407 };
408 let snapshot = self.session_map.read().await.clone();
409 let json = match serde_json::to_vec_pretty(&snapshot) {
410 Ok(json) => json,
411 Err(error) => {
412 tracing::warn!("connect: failed to serialize session map: {error}");
413 return;
414 }
415 };
416 if let Err(error) = atomic_write(path, &json).await {
417 tracing::warn!("connect: failed to persist session map at {path:?}: {error}");
418 }
419 }
420
421 async fn set_cancel_token(&self, key: &str, token: CancellationToken) {
422 let mut guard = self.chat_state.lock().await;
423 guard.entry(key.to_string()).or_default().cancel_token = Some(token);
424 }
425
426 async fn clear_cancel_token(&self, key: &str) {
427 let mut guard = self.chat_state.lock().await;
428 if let Some(state) = guard.get_mut(key) {
429 state.cancel_token = None;
430 }
431 }
432
433 pub async fn handle_inbound(
445 self: Arc<Self>,
446 platform: Arc<dyn Platform>,
447 allow_from: Vec<String>,
448 msg: InboundMessage,
449 ) {
450 if !allow_from.iter().any(|allowed| allowed == &msg.user_id) {
451 tracing::warn!(
452 platform = %msg.platform,
453 chat_id = %msg.chat_id,
454 user_id = %msg.user_id,
455 "connect: rejected inbound message — user not in allow_from"
456 );
457 return;
458 }
459
460 if msg.sent_at < self.process_start {
461 tracing::debug!(
462 platform = %msg.platform,
463 message_id = %msg.message_id,
464 "connect: dropping message older than process start"
465 );
466 return;
467 }
468
469 let dedup_key = format!("{}:{}", msg.platform, msg.message_id);
470 {
471 let mut seen = self.seen_message_ids.lock().unwrap();
472 if !seen.insert(dedup_key) {
473 tracing::debug!(
474 platform = %msg.platform,
475 message_id = %msg.message_id,
476 "connect: dropping duplicate message_id"
477 );
478 return;
479 }
480 }
481
482 let key = SessionKey {
483 platform: msg.platform.clone(),
484 chat_id: msg.chat_id.clone(),
485 user_id: msg.user_id.clone(),
486 }
487 .as_string();
488
489 let command = strip_command_suffix(msg.text.trim());
490 if command.eq_ignore_ascii_case("/stop") {
491 self.handle_stop(&key, &platform, &msg.reply_ctx).await;
492 return;
493 }
494 if command.eq_ignore_ascii_case("/status") {
495 self.handle_status(&key, &platform, &msg.reply_ctx).await;
496 return;
497 }
498
499 if let Some((answer, sender)) = self
506 .try_resolve_pending_ask(&key, |ask| approvals::match_text_answer(ask, &msg.text))
507 .await
508 {
509 let _ = sender.send(AskResolution::Answer(answer)).await;
510 return;
511 }
512
513 if command.eq_ignore_ascii_case("/new") && self.has_pending_ask(&key).await {
518 self.rotate_session(&key).await;
519 reply_text(&platform, &msg.reply_ctx, "Started a new session.").await;
520 return;
521 }
522
523 let mut guard = self.chat_state.lock().await;
524 let state = guard.entry(key.clone()).or_default();
525 if state.busy {
526 state.queue.push_back((platform, msg));
527 return;
528 }
529 state.busy = true;
530 drop(guard);
531
532 let bridge = self.clone();
533 tokio::spawn(async move {
534 bridge.drain_chat(key, platform, msg).await;
535 });
536 }
537
538 async fn drain_chat(
542 self: Arc<Self>,
543 key: String,
544 mut platform: Arc<dyn Platform>,
545 mut msg: InboundMessage,
546 ) {
547 loop {
548 self.process_one(&key, platform.clone(), msg).await;
549
550 let next = {
551 let mut guard = self.chat_state.lock().await;
552 match guard.get_mut(&key) {
553 Some(state) => match state.queue.pop_front() {
554 Some(item) => Some(item),
555 None => {
556 state.busy = false;
557 None
558 }
559 },
560 None => None,
561 }
562 };
563
564 match next {
565 Some((p, m)) => {
566 platform = p;
567 msg = m;
568 }
569 None => break,
570 }
571 }
572 }
573
574 pub async fn handle_callback(
582 self: Arc<Self>,
583 platform: Arc<dyn Platform>,
584 allow_from: Vec<String>,
585 callback: CallbackQuery,
586 ) {
587 if !allow_from
588 .iter()
589 .any(|allowed| allowed == &callback.user_id)
590 {
591 tracing::warn!(
592 platform = %callback.platform,
593 chat_id = %callback.chat_id,
594 user_id = %callback.user_id,
595 "connect: rejected callback query — user not in allow_from"
596 );
597 let _ = platform
598 .answer_callback(&callback.callback_query_id, None)
599 .await;
600 return;
601 }
602
603 let key = SessionKey {
604 platform: callback.platform.clone(),
605 chat_id: callback.chat_id.clone(),
606 user_id: callback.user_id.clone(),
607 }
608 .as_string();
609
610 let resolved = self
611 .try_resolve_pending_ask(&key, |ask| {
612 approvals::match_callback_data(ask, &callback.data)
613 })
614 .await;
615
616 match resolved {
617 Some((answer, sender)) => {
618 let _ = platform
619 .answer_callback(&callback.callback_query_id, None)
620 .await;
621 let _ = sender.send(AskResolution::Answer(answer)).await;
622 }
623 None => {
624 tracing::debug!(
625 platform = %callback.platform,
626 chat_id = %callback.chat_id,
627 "connect: dropping stale/forged callback_data"
628 );
629 let _ = platform
630 .answer_callback(
631 &callback.callback_query_id,
632 Some("This action has expired."),
633 )
634 .await;
635 }
636 }
637 }
638
639 async fn process_one(&self, key: &str, platform: Arc<dyn Platform>, msg: InboundMessage) {
640 let command = strip_command_suffix(msg.text.trim());
641 if command.eq_ignore_ascii_case("/new") {
642 self.rotate_session(key).await;
643 reply_text(&platform, &msg.reply_ctx, "Started a new session.").await;
644 return;
645 }
646
647 let text = msg.text.trim();
648 if text.is_empty() {
649 return;
650 }
651
652 self.run_prompt(key, platform, &msg.reply_ctx, text).await;
653 }
654
655 async fn handle_stop(&self, key: &str, platform: &Arc<dyn Platform>, reply_ctx: &ReplyCtx) {
656 let token = {
657 self.chat_state
658 .lock()
659 .await
660 .get(key)
661 .and_then(|state| state.cancel_token.clone())
662 };
663 let had_pending_ask = self.has_pending_ask(key).await;
668 if had_pending_ask {
669 self.invalidate_pending_ask(key).await;
670 }
671 match (token, had_pending_ask) {
672 (Some(token), _) => {
673 token.cancel();
674 reply_text(platform, reply_ctx, "Stopping the current run…").await;
675 }
676 (None, true) => {
677 reply_text(
678 platform,
679 reply_ctx,
680 "Stopped — the pending question was cancelled.",
681 )
682 .await;
683 }
684 (None, false) => {
685 reply_text(platform, reply_ctx, "Nothing is running.").await;
686 }
687 }
688 }
689
690 async fn handle_status(&self, key: &str, platform: &Arc<dyn Platform>, reply_ctx: &ReplyCtx) {
691 let session_id = self.session_id_for_key(key).await;
692 let busy = {
693 self.chat_state
694 .lock()
695 .await
696 .get(key)
697 .map(|state| state.busy)
698 .unwrap_or(false)
699 };
700 let text = match session_id {
701 Some(id) => format!(
702 "Session: {id}\nStatus: {}",
703 if busy { "busy" } else { "idle" }
704 ),
705 None => "No session yet. Send a message to start one.".to_string(),
706 };
707 reply_text(platform, reply_ctx, text).await;
708 }
709
710 async fn create_and_register_session(
711 &self,
712 key: &str,
713 resolved: &ResolvedConnectRunConfig,
714 ) -> Result<Session, String> {
715 let model = resolved.model_roster.model.clone().unwrap_or_default();
716 let platform = key.split(':').next().unwrap_or_default();
717 let project_id = self.ctx.project_ids_by_platform.get(platform);
718 if let Some(project_id) = project_id {
719 match self.ctx.project_store.get(project_id) {
720 Ok(project) if project.status == bamboo_domain::ProjectStatus::Active => {}
721 Ok(_) => {
722 return Err(format!(
723 "Connect Project {project_id} is archived; no session was created"
724 ));
725 }
726 Err(error) => {
727 return Err(format!(
728 "Connect Project {project_id} is unavailable; no session was created: {error}"
729 ));
730 }
731 }
732 }
733 let final_workspace = crate::project_context::validate_workspace_assignment(
734 &self.ctx.project_store,
735 project_id,
736 resolved.workspace_path.as_deref(),
737 )
738 .map_err(|error| {
739 format!("Connect workspace is unavailable; no session was created: {error}")
740 })?;
741 let final_workspace_display = final_workspace
742 .as_deref()
743 .map(bamboo_config::paths::path_to_display_string);
744 let binding_status = match (project_id, final_workspace.as_deref()) {
745 (Some(project_id), Some(workspace)) => {
746 let workspace = bamboo_config::paths::path_to_display_string(workspace);
747 if self
748 .ctx
749 .project_store
750 .find_workspace_owner_for_path(&workspace)
751 .map_err(|error| format!("resolve Connect workspace owner: {error}"))?
752 .is_some_and(|owner| owner.id == *project_id)
753 {
754 bamboo_engine::project_context::WorkspaceBindingStatus::Registered
755 } else {
756 bamboo_engine::project_context::WorkspaceBindingStatus::Unregistered
757 }
758 }
759 _ => bamboo_engine::project_context::WorkspaceBindingStatus::Unregistered,
760 };
761 let system_prompt = bamboo_engine::runtime::context::upsert_workspace_prompt_context(
762 &resolved.system_prompt,
763 final_workspace_display.as_deref(),
764 binding_status,
765 );
766 let session = create_connect_session(
767 key,
768 &model,
769 &system_prompt,
770 &resolved.base_system_prompt,
771 final_workspace_display.as_deref(),
772 project_id,
773 resolved.reasoning_effort,
774 );
775 self.set_session_id_for_key(key, &session.id).await;
776 Ok(session)
777 }
778
779 async fn run_prompt(
787 &self,
788 key: &str,
789 platform: Arc<dyn Platform>,
790 reply_ctx: &ReplyCtx,
791 text: &str,
792 ) {
793 let config_snapshot = self.ctx.config.read().await.clone();
794 let resolved = resolve_connect_run_config(&config_snapshot, &self.ctx.provider_registry);
795
796 if resolved
797 .model_roster
798 .model
799 .as_deref()
800 .unwrap_or("")
801 .trim()
802 .is_empty()
803 {
804 reply_text(
805 &platform,
806 reply_ctx,
807 "No model is configured for this bamboo instance; cannot run your request.",
808 )
809 .await;
810 return;
811 }
812
813 let existing_id = self.session_id_for_key(key).await;
814 let session = match existing_id {
815 Some(id) => match self.ctx.session_repo.load_merged(&id).await {
816 Some(session) => Ok(session),
817 None => self.create_and_register_session(key, &resolved).await,
818 },
819 None => self.create_and_register_session(key, &resolved).await,
820 };
821 let mut session = match session {
822 Ok(session) => session,
823 Err(error) => {
824 reply_text(&platform, reply_ctx, &error).await;
825 return;
826 }
827 };
828
829 let session_id = session.id.clone();
830 let session_tx =
831 get_or_create_event_sender(&self.ctx.session_event_senders, &session_id).await;
832 let execution_reservation = match reserve_session_execution(
833 &self.ctx.agent,
834 &self.ctx.agent_runners,
835 &self.ctx.session_event_senders,
836 &session_id,
837 &session_tx,
838 )
839 .await
840 {
841 SessionExecutionReserveOutcome::Reserved(reservation) => reservation,
842 SessionExecutionReserveOutcome::AlreadyRunning { .. } => {
843 reply_text(
844 &platform,
845 reply_ctx,
846 "This session is already running elsewhere; please wait for it to finish.",
847 )
848 .await;
849 return;
850 }
851 };
852 let rx = session_tx.subscribe();
853
854 session.add_message(Message::user(text.to_string()));
857 if let Some(config) = self.ctx.permission_checker.permission_config() {
858 if let Some(workspace) = session.workspace.as_ref() {
859 config.register_session_workspace(session_id.clone(), workspace.clone());
860 }
861 session.metadata.insert(
862 "permission.policy_revision".to_string(),
863 config.policy_revision().to_string(),
864 );
865 session.metadata.insert(
866 "permission.effective_mode".to_string(),
867 format!("{:?}", config.mode()).to_ascii_lowercase(),
868 );
869 }
870 self.ctx.session_repo.save_and_cache(&mut session).await;
871
872 self.set_cancel_token(key, execution_reservation.cancel_token().clone())
873 .await;
874
875 let (mpsc_tx, _forwarder_handle) = create_event_forwarder(
876 session_id.clone(),
877 session_tx.clone(),
878 self.ctx.agent_runners.clone(),
879 self.ctx.account_feed_inbox.clone(),
880 );
881
882 let aux_fast_model = resolved.model_roster.fast_model();
885 let aux_fast_provider = resolved.model_roster.fast_model_provider();
886 let aux_background_model = resolved.model_roster.background_model();
887 let aux_background_provider = resolved.model_roster.background_model_provider();
888 let aux_summarization_model = resolved.model_roster.summarization_model();
889 let aux_summarization_provider = resolved.model_roster.summarization_model_provider();
890 let auxiliary_model_resolver = Arc::new(move || AuxiliaryModelConfig {
891 fast_model_name: aux_fast_model.clone(),
892 fast_model_provider: aux_fast_provider.clone(),
893 background_model_name: aux_background_model.clone(),
894 planning_model_name: None,
895 search_model_name: None,
896 summarization_model_name: aux_summarization_model.clone(),
897 background_model_provider: aux_background_provider.clone(),
898 summarization_model_provider: aux_summarization_provider.clone(),
899 });
900
901 spawn_session_execution(SessionExecutionArgs {
902 agent: self.ctx.agent.clone(),
903 session_id: session_id.clone(),
904 session,
905 execution_reservation,
906 tools_override: Some(self.ctx.tools.clone()),
907 provider_override: None,
908 model_roster: resolved.model_roster.clone(),
909 reasoning_effort: resolved.reasoning_effort,
910 reasoning_effort_source: "connect".to_string(),
911 auxiliary_model_resolver: Some(auxiliary_model_resolver),
912 disabled_filter_resolver: None,
913 disabled_tools: None,
914 disabled_skill_ids: None,
915 selected_skill_ids: None,
916 selected_skill_mode: None,
917 mpsc_tx,
918 image_fallback: None,
919 gold_config: resolved.gold_config.clone(),
920 guardian_config: None,
924 guardian_spawner: None,
925 bash_resume_hook: None,
926 bash_completion_sink: None,
927 app_data_dir: self.ctx.app_data_dir.clone(),
928 run_budget: None,
931 runners: self.ctx.agent_runners.clone(),
932 sessions_cache: self.ctx.session_repo.cache().clone(),
933 on_complete: None,
934 child_completion_handler: None,
937 });
938
939 self.render_until_settled(key, platform, reply_ctx.clone(), &session_id, rx)
940 .await;
941
942 self.clear_cancel_token(key).await;
943 }
944
945 async fn render_until_settled(
961 &self,
962 key: &str,
963 platform: Arc<dyn Platform>,
964 reply_ctx: ReplyCtx,
965 session_id: &str,
966 mut rx: broadcast::Receiver<AgentEvent>,
967 ) {
968 let mut stream_state: Option<Box<render::StreamState>> = None;
969 loop {
970 match render::stream_execution(
971 platform.clone(),
972 reply_ctx.clone(),
973 rx,
974 stream_state.take(),
975 )
976 .await
977 {
978 render::RunOutcome::Terminal => return,
979 render::RunOutcome::Paused {
980 ask,
981 stream_state: paused_state,
982 } => {
983 stream_state = paused_state;
984 let caps = platform.capabilities();
985 let parked =
986 ParkedAsk::new(approvals::new_nonce(), session_id.to_string(), &ask);
987
988 if let Err(error) =
989 approvals::render_ask(&platform, &reply_ctx, &parked, caps.buttons).await
990 {
991 tracing::warn!("connect: failed to render pending ask: {error}");
992 }
993
994 let (ask_tx, mut ask_rx) = mpsc::channel(1);
995 {
996 let mut guard = self.chat_state.lock().await;
997 let state = guard.entry(key.to_string()).or_default();
998 state.pending_ask = Some(parked);
999 state.ask_resolution = Some(ask_tx);
1000 }
1001
1002 match ask_rx.recv().await {
1003 Some(AskResolution::Answer(answer)) => {
1004 match self.responder.respond_and_resume(session_id, answer).await {
1005 Ok(RespondAndResumeOutcome::Resumed(new_rx)) => {
1006 rx = new_rx;
1007 continue;
1008 }
1009 Ok(RespondAndResumeOutcome::NotResumed(reason)) => {
1010 reply_text(&platform, &reply_ctx, format!("({reason})")).await;
1011 return;
1012 }
1013 Err(error) => {
1014 reply_text(
1015 &platform,
1016 &reply_ctx,
1017 format!("Failed to record your answer: {error}"),
1018 )
1019 .await;
1020 return;
1021 }
1022 }
1023 }
1024 Some(AskResolution::Invalidated) | None => {
1025 self.clear_pending_ask(key).await;
1031 return;
1032 }
1033 }
1034 }
1035 }
1036 }
1037 }
1038}
1039
1040async fn atomic_write(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
1045 if let Some(parent) = path.parent() {
1046 tokio::fs::create_dir_all(parent).await?;
1047 }
1048 let tmp = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4()));
1049 {
1050 let mut file = tokio::fs::File::create(&tmp).await?;
1051 tokio::io::AsyncWriteExt::write_all(&mut file, bytes).await?;
1052 file.sync_all().await?;
1053 }
1054 tokio::fs::rename(&tmp, path).await?;
1055 if let Some(parent) = path.parent() {
1056 if let Ok(dir) = tokio::fs::File::open(parent).await {
1057 let _ = dir.sync_all().await;
1058 }
1059 }
1060 Ok(())
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065 use super::*;
1066 use crate::app_state::AppState;
1067 use crate::tools::ToolSurface;
1068 use std::time::Duration;
1069 use tokio::sync::Mutex as TokioMutex;
1070
1071 struct FakePlatform {
1077 label: String,
1078 capabilities: super::super::platform::Capabilities,
1079 sent: TokioMutex<Vec<String>>,
1080 sent_messages: TokioMutex<Vec<OutboundMessage>>,
1081 edits: TokioMutex<Vec<String>>,
1082 answered_callbacks: TokioMutex<Vec<(String, Option<String>)>>,
1083 }
1084
1085 impl FakePlatform {
1086 fn new(label: &str) -> Arc<Self> {
1087 Self::with_capabilities(label, Default::default())
1088 }
1089
1090 fn with_capabilities(
1091 label: &str,
1092 capabilities: super::super::platform::Capabilities,
1093 ) -> Arc<Self> {
1094 Arc::new(Self {
1095 label: label.to_string(),
1096 capabilities,
1097 sent: TokioMutex::new(Vec::new()),
1098 sent_messages: TokioMutex::new(Vec::new()),
1099 edits: TokioMutex::new(Vec::new()),
1100 answered_callbacks: TokioMutex::new(Vec::new()),
1101 })
1102 }
1103
1104 async fn sent_texts(&self) -> Vec<String> {
1105 self.sent.lock().await.clone()
1106 }
1107 }
1108
1109 #[async_trait::async_trait]
1110 impl Platform for FakePlatform {
1111 fn name(&self) -> &str {
1112 &self.label
1113 }
1114 fn capabilities(&self) -> super::super::platform::Capabilities {
1115 self.capabilities
1116 }
1117 async fn start(
1118 &self,
1119 _inbound: tokio::sync::mpsc::Sender<super::super::platform::Inbound>,
1120 ) -> super::super::platform::PlatformResult<()> {
1121 Ok(())
1122 }
1123 async fn reply(
1124 &self,
1125 _ctx: &ReplyCtx,
1126 msg: OutboundMessage,
1127 ) -> super::super::platform::PlatformResult<super::super::platform::MessageRef> {
1128 self.sent.lock().await.push(msg.text.clone());
1129 self.sent_messages.lock().await.push(msg);
1130 Ok(super::super::platform::MessageRef(serde_json::Value::Null))
1131 }
1132 async fn edit(
1133 &self,
1134 _msg_ref: &super::super::platform::MessageRef,
1135 new: OutboundMessage,
1136 ) -> super::super::platform::PlatformResult<()> {
1137 self.edits.lock().await.push(new.text);
1138 Ok(())
1139 }
1140 async fn answer_callback(
1141 &self,
1142 callback_query_id: &str,
1143 text: Option<&str>,
1144 ) -> super::super::platform::PlatformResult<()> {
1145 self.answered_callbacks
1146 .lock()
1147 .await
1148 .push((callback_query_id.to_string(), text.map(str::to_string)));
1149 Ok(())
1150 }
1151 async fn stop(&self) -> super::super::platform::PlatformResult<()> {
1152 Ok(())
1153 }
1154 }
1155
1156 struct FakeResponder {
1161 calls: TokioMutex<Vec<(String, String)>>,
1162 resume_sender: broadcast::Sender<AgentEvent>,
1163 fail_with: Option<String>,
1164 }
1165
1166 impl FakeResponder {
1167 fn new(resume_sender: broadcast::Sender<AgentEvent>) -> Arc<Self> {
1168 Arc::new(Self {
1169 calls: TokioMutex::new(Vec::new()),
1170 resume_sender,
1171 fail_with: None,
1172 })
1173 }
1174
1175 fn failing(resume_sender: broadcast::Sender<AgentEvent>, reason: &str) -> Arc<Self> {
1176 Arc::new(Self {
1177 calls: TokioMutex::new(Vec::new()),
1178 resume_sender,
1179 fail_with: Some(reason.to_string()),
1180 })
1181 }
1182 }
1183
1184 #[async_trait::async_trait]
1185 impl Responder for FakeResponder {
1186 async fn respond_and_resume(
1187 &self,
1188 session_id: &str,
1189 answer: String,
1190 ) -> Result<RespondAndResumeOutcome, super::super::approvals::ResponderError> {
1191 self.calls
1192 .lock()
1193 .await
1194 .push((session_id.to_string(), answer));
1195 if let Some(reason) = &self.fail_with {
1196 return Err(super::super::approvals::ResponderError::Other(
1197 reason.clone(),
1198 ));
1199 }
1200 Ok(RespondAndResumeOutcome::Resumed(
1201 self.resume_sender.subscribe(),
1202 ))
1203 }
1204 }
1205
1206 async fn wait_for_parked_ask(bridge: &ConnectBridge, key: &str) -> ParkedAsk {
1211 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1212 loop {
1213 if let Some(ask) = bridge
1214 .chat_state
1215 .lock()
1216 .await
1217 .get(key)
1218 .and_then(|state| state.pending_ask.clone())
1219 {
1220 return ask;
1221 }
1222 assert!(
1223 tokio::time::Instant::now() < deadline,
1224 "ask was never parked for {key}"
1225 );
1226 tokio::time::sleep(Duration::from_millis(10)).await;
1227 }
1228 }
1229
1230 async fn wait_for_responder_calls(responder: &FakeResponder, count: usize) {
1237 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1238 loop {
1239 if responder.calls.lock().await.len() >= count {
1240 return;
1241 }
1242 assert!(
1243 tokio::time::Instant::now() < deadline,
1244 "responder never reached {count} call(s)"
1245 );
1246 tokio::time::sleep(Duration::from_millis(10)).await;
1247 }
1248 }
1249
1250 async fn test_context() -> (ConnectContext, tempfile::TempDir) {
1251 let dir = tempfile::tempdir().unwrap();
1252 let state = AppState::new(dir.path().to_path_buf())
1261 .await
1262 .expect("app state");
1263 {
1264 let mut cfg = state.config.write().await;
1265 cfg.provider = "openai".to_string();
1266 cfg.providers_mut().openai = Some(bamboo_config::OpenAIConfig {
1267 api_key: String::new(),
1268 api_key_from_env: false,
1269 api_key_encrypted: None,
1270 credential_ref: None,
1271 base_url: None,
1272 model: Some("gpt-4o-mini".to_string()),
1273 fast_model: None,
1274 vision_model: None,
1275 reasoning_effort: None,
1276 responses_only_models: Vec::new(),
1277 request_overrides: None,
1278 extra: Default::default(),
1279 });
1280 }
1281 let ctx = ConnectContext {
1282 agent: state.agent.clone(),
1283 tools: state.tools_for(ToolSurface::Root),
1284 session_repo: state.session_repo.clone(),
1285 agent_runners: state.agent_runners.clone(),
1286 session_event_senders: state.session_event_senders.clone(),
1287 account_feed_inbox: None,
1288 app_data_dir: Some(state.app_data_dir.clone()),
1289 config: state.config.clone(),
1290 provider_registry: state.provider_registry.clone(),
1291 project_store: state.project_store.clone(),
1292 project_ids_by_platform: Arc::new(HashMap::new()),
1293 permission_checker: state.permission_checker.clone(),
1294 };
1295 (ctx, dir)
1296 }
1297
1298 fn inbound(chat_id: &str, user_id: &str, message_id: &str, text: &str) -> InboundMessage {
1299 InboundMessage {
1300 platform: "fake".to_string(),
1301 chat_id: chat_id.to_string(),
1302 user_id: user_id.to_string(),
1303 message_id: message_id.to_string(),
1304 sent_at: Utc::now(),
1305 text: text.to_string(),
1306 reply_ctx: ReplyCtx(serde_json::json!({ "chat_id": chat_id })),
1307 }
1308 }
1309
1310 fn key_for(chat_id: &str, user_id: &str) -> String {
1311 SessionKey {
1312 platform: "fake".to_string(),
1313 chat_id: chat_id.to_string(),
1314 user_id: user_id.to_string(),
1315 }
1316 .as_string()
1317 }
1318
1319 #[test]
1320 fn session_key_formats_as_platform_chat_user() {
1321 let key = SessionKey {
1322 platform: "telegram".to_string(),
1323 chat_id: "42".to_string(),
1324 user_id: "7".to_string(),
1325 };
1326 assert_eq!(key.as_string(), "telegram:42:7");
1327 }
1328
1329 #[test]
1332 fn bounded_seen_set_evicts_the_oldest_entry_once_over_capacity() {
1333 let mut set = BoundedSeenSet::new(2);
1334 assert!(set.insert("a".to_string()));
1335 assert!(set.insert("b".to_string()));
1336 assert_eq!(set.len(), 2);
1337
1338 assert!(set.insert("c".to_string()));
1341 assert_eq!(set.len(), 2);
1342 assert!(!set.insert("b".to_string()), "b must still be tracked");
1343 assert!(!set.insert("c".to_string()), "c must still be tracked");
1344
1345 assert!(set.insert("a".to_string()));
1348 assert_eq!(set.len(), 2);
1349 }
1350
1351 #[test]
1352 fn bounded_seen_set_still_dedups_within_capacity() {
1353 let mut set = BoundedSeenSet::new(10);
1354 assert!(set.insert("x".to_string()));
1355 assert!(!set.insert("x".to_string()), "duplicate must be rejected");
1356 assert_eq!(set.len(), 1);
1357 }
1358
1359 #[test]
1360 fn bounded_seen_set_never_grows_past_capacity() {
1361 let mut set = BoundedSeenSet::new(50);
1362 for i in 0..5_000 {
1363 set.insert(format!("msg-{i}"));
1364 }
1365 assert_eq!(set.len(), 50);
1366 }
1367
1368 #[tokio::test]
1369 async fn connect_session_creation_revalidates_project_after_startup() {
1370 let (mut ctx, _dir) = test_context().await;
1371 let project = ctx.project_store.create("Connect", None).unwrap();
1372 let mut projects = HashMap::new();
1373 projects.insert("fake".to_string(), project.id.clone());
1374 ctx.project_ids_by_platform = Arc::new(projects);
1375 ctx.project_store
1376 .archive(&project.id, project.revision)
1377 .unwrap();
1378 let resolved = {
1379 let config = ctx.config.read().await.clone();
1380 resolve_connect_run_config(&config, &ctx.provider_registry)
1381 };
1382 let bridge = ConnectBridge::new(ctx, None);
1383
1384 let error = bridge
1385 .create_and_register_session("fake:chat:user", &resolved)
1386 .await
1387 .expect_err("archived Project must reject Connect session creation");
1388 assert!(error.contains("archived"));
1389 assert!(
1390 bridge.session_id_for_key("fake:chat:user").await.is_none(),
1391 "failed validation must not publish a chat-to-session mapping"
1392 );
1393 }
1394
1395 #[tokio::test]
1396 async fn connect_session_creation_rejects_another_projects_workspace() {
1397 let (mut ctx, _dir) = test_context().await;
1398 let workspace = tempfile::tempdir().expect("workspace");
1399 let connect_project = ctx.project_store.create("Connect", None).unwrap();
1400 let _workspace_owner = ctx
1401 .project_store
1402 .create_with_bindings(
1403 "Workspace Owner",
1404 None,
1405 vec![bamboo_domain::WorkspaceBinding {
1406 path: workspace.path().to_string_lossy().into_owned(),
1407 label: None,
1408 git_common_dir: None,
1409 }],
1410 )
1411 .expect("Workspace Owner");
1412 let mut projects = HashMap::new();
1413 projects.insert("fake".to_string(), connect_project.id);
1414 ctx.project_ids_by_platform = Arc::new(projects);
1415 let mut resolved = {
1416 let config = ctx.config.read().await.clone();
1417 resolve_connect_run_config(&config, &ctx.provider_registry)
1418 };
1419 resolved.workspace_path = Some(workspace.path().to_string_lossy().into_owned());
1420 let bridge = ConnectBridge::new(ctx, None);
1421
1422 let error = bridge
1423 .create_and_register_session("fake:chat:user", &resolved)
1424 .await
1425 .expect_err("cross-Project workspace must reject Connect session creation");
1426 assert!(error.contains("belongs to Project"));
1427 assert!(
1428 bridge.session_id_for_key("fake:chat:user").await.is_none(),
1429 "failed workspace validation must not publish a chat-to-session mapping"
1430 );
1431 }
1432
1433 #[tokio::test]
1434 async fn allow_from_denies_users_not_in_the_list() {
1435 let (ctx, _dir) = test_context().await;
1436 let bridge = Arc::new(ConnectBridge::new(ctx, None));
1437 let platform = FakePlatform::new("fake");
1438
1439 ConnectBridge::handle_inbound(
1440 bridge.clone(),
1441 platform.clone(),
1442 vec!["allowed-user".to_string()],
1443 inbound("chat1", "someone-else", "1", "hello"),
1444 )
1445 .await;
1446
1447 tokio::time::sleep(Duration::from_millis(50)).await;
1448 assert!(platform.sent_texts().await.is_empty());
1449 assert!(bridge
1450 .session_id_for_key(&key_for("chat1", "someone-else"))
1451 .await
1452 .is_none());
1453 }
1454
1455 #[tokio::test]
1456 async fn dedup_drops_repeated_message_ids() {
1457 let (ctx, _dir) = test_context().await;
1458 let bridge = Arc::new(ConnectBridge::new(ctx, None));
1459 let platform = FakePlatform::new("fake");
1460 let allow = vec!["u1".to_string()];
1461
1462 ConnectBridge::handle_inbound(
1466 bridge.clone(),
1467 platform.clone(),
1468 allow.clone(),
1469 inbound("chat1", "u1", "dup-1", "/status"),
1470 )
1471 .await;
1472 ConnectBridge::handle_inbound(
1473 bridge.clone(),
1474 platform.clone(),
1475 allow,
1476 inbound("chat1", "u1", "dup-1", "/status"),
1477 )
1478 .await;
1479
1480 let sent = platform.sent_texts().await;
1481 assert_eq!(
1482 sent.len(),
1483 1,
1484 "duplicate message_id must be dropped: {sent:?}"
1485 );
1486 }
1487
1488 #[tokio::test]
1489 async fn older_than_process_start_messages_are_dropped() {
1490 let (ctx, _dir) = test_context().await;
1491 let bridge = Arc::new(ConnectBridge::new(ctx, None));
1492 let platform = FakePlatform::new("fake");
1493 let mut msg = inbound("chat1", "u1", "1", "/status");
1494 msg.sent_at = bridge.process_start - chrono::Duration::seconds(5);
1495
1496 ConnectBridge::handle_inbound(
1497 bridge.clone(),
1498 platform.clone(),
1499 vec!["u1".to_string()],
1500 msg,
1501 )
1502 .await;
1503
1504 assert!(platform.sent_texts().await.is_empty());
1505 }
1506
1507 #[tokio::test]
1508 async fn status_command_reports_idle_with_no_session_yet() {
1509 let (ctx, _dir) = test_context().await;
1510 let bridge = Arc::new(ConnectBridge::new(ctx, None));
1511 let platform = FakePlatform::new("fake");
1512
1513 ConnectBridge::handle_inbound(
1514 bridge.clone(),
1515 platform.clone(),
1516 vec!["u1".to_string()],
1517 inbound("chat1", "u1", "1", "/status"),
1518 )
1519 .await;
1520
1521 let sent = platform.sent_texts().await;
1522 assert_eq!(sent.len(), 1);
1523 assert!(sent[0].contains("No session yet"), "got: {:?}", sent[0]);
1524 }
1525
1526 #[tokio::test]
1527 async fn stop_with_nothing_running_replies_nothing_running() {
1528 let (ctx, _dir) = test_context().await;
1529 let bridge = Arc::new(ConnectBridge::new(ctx, None));
1530 let platform = FakePlatform::new("fake");
1531
1532 ConnectBridge::handle_inbound(
1533 bridge.clone(),
1534 platform.clone(),
1535 vec!["u1".to_string()],
1536 inbound("chat1", "u1", "1", "/stop"),
1537 )
1538 .await;
1539
1540 assert_eq!(
1541 platform.sent_texts().await,
1542 vec!["Nothing is running.".to_string()]
1543 );
1544 }
1545
1546 #[tokio::test]
1547 async fn prompt_creates_a_session_and_maps_it_to_the_chat_key() {
1548 let (ctx, _dir) = test_context().await;
1549 let bridge = Arc::new(ConnectBridge::new(ctx, None));
1550 let platform = FakePlatform::new("fake");
1551 let key = key_for("chat1", "u1");
1552
1553 ConnectBridge::handle_inbound(
1554 bridge.clone(),
1555 platform.clone(),
1556 vec!["u1".to_string()],
1557 inbound("chat1", "u1", "1", "hello there"),
1558 )
1559 .await;
1560
1561 let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
1562 loop {
1563 if bridge.session_id_for_key(&key).await.is_some() {
1564 break;
1565 }
1566 assert!(
1567 tokio::time::Instant::now() < deadline,
1568 "session was never created for the chat key"
1569 );
1570 tokio::time::sleep(Duration::from_millis(20)).await;
1571 }
1572 }
1573
1574 #[tokio::test]
1575 async fn new_command_rotates_the_session_mapping() {
1576 let (ctx, _dir) = test_context().await;
1577 let bridge = Arc::new(ConnectBridge::new(ctx, None));
1578 let platform = FakePlatform::new("fake");
1579 let key = key_for("chat1", "u1");
1580
1581 bridge
1582 .set_session_id_for_key(&key, "pre-existing-session")
1583 .await;
1584 assert_eq!(
1585 bridge.session_id_for_key(&key).await.as_deref(),
1586 Some("pre-existing-session")
1587 );
1588
1589 ConnectBridge::handle_inbound(
1590 bridge.clone(),
1591 platform.clone(),
1592 vec!["u1".to_string()],
1593 inbound("chat1", "u1", "1", "/new"),
1594 )
1595 .await;
1596
1597 let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
1598 loop {
1599 if bridge.session_id_for_key(&key).await.is_none() {
1600 break;
1601 }
1602 assert!(
1603 tokio::time::Instant::now() < deadline,
1604 "session mapping was never rotated away"
1605 );
1606 tokio::time::sleep(Duration::from_millis(20)).await;
1607 }
1608
1609 let sent = platform.sent_texts().await;
1610 assert!(sent.iter().any(|t| t == "Started a new session."));
1611 }
1612
1613 #[tokio::test]
1614 async fn busy_queue_drains_a_second_message_after_the_first_finishes() {
1615 let (ctx, _dir) = test_context().await;
1616 let bridge = Arc::new(ConnectBridge::new(ctx, None));
1617 let platform = FakePlatform::new("fake");
1618 let allow = vec!["u1".to_string()];
1619 let key = key_for("chat1", "u1");
1620
1621 ConnectBridge::handle_inbound(
1625 bridge.clone(),
1626 platform.clone(),
1627 allow.clone(),
1628 inbound("chat1", "u1", "1", "first"),
1629 )
1630 .await;
1631 ConnectBridge::handle_inbound(
1632 bridge.clone(),
1633 platform.clone(),
1634 allow,
1635 inbound("chat1", "u1", "2", "second"),
1636 )
1637 .await;
1638
1639 let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
1640 loop {
1641 let idle = {
1642 !bridge
1643 .chat_state
1644 .lock()
1645 .await
1646 .get(&key)
1647 .map(|state| state.busy)
1648 .unwrap_or(true)
1649 };
1650 if idle {
1651 break;
1652 }
1653 assert!(
1654 tokio::time::Instant::now() < deadline,
1655 "chat never drained back to idle"
1656 );
1657 tokio::time::sleep(Duration::from_millis(20)).await;
1658 }
1659
1660 assert!(bridge.session_id_for_key(&key).await.is_some());
1661 }
1662
1663 #[tokio::test]
1664 async fn session_map_persists_and_reloads_across_bridge_instances() {
1665 let (ctx, dir) = test_context().await;
1666 let map_path = dir.path().join("connect_sessions.json");
1667 let bridge = ConnectBridge::new(ctx.clone(), Some(map_path.clone()));
1668 bridge.set_session_id_for_key("k1", "sess-1").await;
1669
1670 let bridge2 = ConnectBridge::new(ctx, Some(map_path));
1671 bridge2.load_session_map().await;
1672
1673 assert_eq!(
1674 bridge2.session_id_for_key("k1").await.as_deref(),
1675 Some("sess-1")
1676 );
1677 }
1678
1679 fn buttons_and_edit_capabilities() -> super::super::platform::Capabilities {
1682 super::super::platform::Capabilities {
1683 buttons: true,
1684 edit_message: true,
1685 images: false,
1686 files: false,
1687 }
1688 }
1689
1690 fn need_clarification_event(
1691 question: &str,
1692 options: Vec<&str>,
1693 allow_custom: bool,
1694 ) -> AgentEvent {
1695 AgentEvent::NeedClarification {
1696 question: question.to_string(),
1697 options: Some(options.into_iter().map(str::to_string).collect()),
1698 tool_call_id: Some("call-1".to_string()),
1699 tool_name: Some("request_permissions".to_string()),
1700 allow_custom,
1701 }
1702 }
1703
1704 #[tokio::test]
1705 async fn paused_run_renders_buttons_with_nonce_and_resolves_via_callback() {
1706 let (ctx, _dir) = test_context().await;
1707 let resume_tx = broadcast::channel::<AgentEvent>(16).0;
1708 let responder = FakeResponder::new(resume_tx.clone());
1709 let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
1710 let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
1711 let key = key_for("chat1", "u1");
1712 let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
1713
1714 let (tx, rx) = broadcast::channel(16);
1715 tx.send(need_clarification_event(
1716 "Approve?",
1717 vec!["Approve", "Deny"],
1718 false,
1719 ))
1720 .unwrap();
1721
1722 let render_handle = {
1723 let bridge = bridge.clone();
1724 let platform = platform.clone();
1725 let reply_ctx = reply_ctx.clone();
1726 let key = key.clone();
1727 tokio::spawn(async move {
1728 bridge
1729 .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
1730 .await;
1731 })
1732 };
1733
1734 let parked = wait_for_parked_ask(&bridge, &key).await;
1735 assert_eq!(
1736 parked.options,
1737 vec!["Approve".to_string(), "Deny".to_string()]
1738 );
1739
1740 let sent = platform.sent_messages.lock().await.clone();
1743 let ask_message = sent
1744 .iter()
1745 .find(|message| message.buttons.is_some())
1746 .expect("expected a buttoned ask message");
1747 let buttons = ask_message.buttons.as_ref().unwrap();
1748 assert_eq!(buttons.len(), 2);
1749 assert_eq!(buttons[0][0].callback_data, format!("{}:0", parked.nonce));
1750 assert_eq!(buttons[1][0].callback_data, format!("{}:1", parked.nonce));
1751
1752 let callback = CallbackQuery {
1753 platform: "fake".to_string(),
1754 chat_id: "chat1".to_string(),
1755 user_id: "u1".to_string(),
1756 callback_query_id: "cbq-1".to_string(),
1757 data: format!("{}:0", parked.nonce),
1758 reply_ctx: reply_ctx.clone(),
1759 };
1760 ConnectBridge::handle_callback(
1761 bridge.clone(),
1762 platform.clone(),
1763 vec!["u1".to_string()],
1764 callback,
1765 )
1766 .await;
1767
1768 wait_for_responder_calls(&responder, 1).await;
1773
1774 resume_tx
1775 .send(AgentEvent::Complete {
1776 usage: bamboo_agent_core::TokenUsage {
1777 prompt_tokens: 1,
1778 completion_tokens: 1,
1779 total_tokens: 2,
1780 },
1781 })
1782 .unwrap();
1783
1784 tokio::time::timeout(Duration::from_secs(5), render_handle)
1785 .await
1786 .expect("render task must finish")
1787 .unwrap();
1788
1789 assert_eq!(
1790 responder.calls.lock().await.as_slice(),
1791 &[("sess-1".to_string(), "Approve".to_string())]
1792 );
1793 assert_eq!(
1794 platform.answered_callbacks.lock().await.as_slice(),
1795 &[("cbq-1".to_string(), None)]
1796 );
1797 assert!(!bridge.has_pending_ask(&key).await);
1798 }
1799
1800 #[tokio::test]
1801 async fn stale_callback_nonce_is_dropped_and_acked_without_resolving() {
1802 let (ctx, _dir) = test_context().await;
1803 let resume_tx = broadcast::channel::<AgentEvent>(16).0;
1804 let responder = FakeResponder::new(resume_tx.clone());
1805 let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
1806 let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
1807 let key = key_for("chat1", "u1");
1808 let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
1809
1810 let (tx, rx) = broadcast::channel(16);
1811 tx.send(need_clarification_event(
1812 "Approve?",
1813 vec!["Approve", "Deny"],
1814 false,
1815 ))
1816 .unwrap();
1817
1818 let render_handle = {
1819 let bridge = bridge.clone();
1820 let platform = platform.clone();
1821 let reply_ctx = reply_ctx.clone();
1822 let key = key.clone();
1823 tokio::spawn(async move {
1824 bridge
1825 .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
1826 .await;
1827 })
1828 };
1829
1830 wait_for_parked_ask(&bridge, &key).await;
1831
1832 let stale_callback = CallbackQuery {
1833 platform: "fake".to_string(),
1834 chat_id: "chat1".to_string(),
1835 user_id: "u1".to_string(),
1836 callback_query_id: "cbq-stale".to_string(),
1837 data: "totally-wrong-nonce:0".to_string(),
1838 reply_ctx: reply_ctx.clone(),
1839 };
1840 ConnectBridge::handle_callback(
1841 bridge.clone(),
1842 platform.clone(),
1843 vec!["u1".to_string()],
1844 stale_callback,
1845 )
1846 .await;
1847
1848 let acked = platform.answered_callbacks.lock().await.clone();
1851 assert_eq!(acked.len(), 1);
1852 assert_eq!(acked[0].0, "cbq-stale");
1853 assert!(acked[0].1.is_some());
1854 assert!(responder.calls.lock().await.is_empty());
1855 assert!(bridge.has_pending_ask(&key).await);
1857
1858 bridge.invalidate_pending_ask(&key).await;
1859 tokio::time::timeout(Duration::from_secs(5), render_handle)
1860 .await
1861 .expect("render task must finish")
1862 .unwrap();
1863 }
1864
1865 #[tokio::test]
1866 async fn text_answer_resolves_an_open_question() {
1867 let (ctx, _dir) = test_context().await;
1868 let resume_tx = broadcast::channel::<AgentEvent>(16).0;
1869 let responder = FakeResponder::new(resume_tx.clone());
1870 let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
1871 let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
1872 let key = key_for("chat1", "u1");
1873 let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
1874
1875 let (tx, rx) = broadcast::channel(16);
1876 tx.send(need_clarification_event(
1877 "Anything else?",
1878 vec!["OK", "Need changes"],
1879 true,
1880 ))
1881 .unwrap();
1882
1883 let render_handle = {
1884 let bridge = bridge.clone();
1885 let platform = platform.clone();
1886 let reply_ctx = reply_ctx.clone();
1887 let key = key.clone();
1888 tokio::spawn(async move {
1889 bridge
1890 .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
1891 .await;
1892 })
1893 };
1894
1895 wait_for_parked_ask(&bridge, &key).await;
1896
1897 ConnectBridge::handle_inbound(
1898 bridge.clone(),
1899 platform.clone(),
1900 vec!["u1".to_string()],
1901 inbound("chat1", "u1", "answer-1", "please also add tests"),
1902 )
1903 .await;
1904
1905 wait_for_responder_calls(&responder, 1).await;
1910
1911 resume_tx
1912 .send(AgentEvent::Complete {
1913 usage: bamboo_agent_core::TokenUsage {
1914 prompt_tokens: 1,
1915 completion_tokens: 1,
1916 total_tokens: 2,
1917 },
1918 })
1919 .unwrap();
1920
1921 tokio::time::timeout(Duration::from_secs(5), render_handle)
1922 .await
1923 .expect("render task must finish")
1924 .unwrap();
1925
1926 assert_eq!(
1927 responder.calls.lock().await.as_slice(),
1928 &[("sess-1".to_string(), "please also add tests".to_string())]
1929 );
1930 }
1931
1932 #[tokio::test]
1933 async fn binary_ask_keyword_mapping_resolves_via_text() {
1934 let (ctx, _dir) = test_context().await;
1935 let resume_tx = broadcast::channel::<AgentEvent>(16).0;
1936 let responder = FakeResponder::new(resume_tx.clone());
1937 let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
1938 let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
1939 let key = key_for("chat1", "u1");
1940 let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
1941
1942 let (tx, rx) = broadcast::channel(16);
1943 tx.send(need_clarification_event(
1944 "Approve?",
1945 vec!["Approve", "Deny"],
1946 false,
1947 ))
1948 .unwrap();
1949
1950 let render_handle = {
1951 let bridge = bridge.clone();
1952 let platform = platform.clone();
1953 let reply_ctx = reply_ctx.clone();
1954 let key = key.clone();
1955 tokio::spawn(async move {
1956 bridge
1957 .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
1958 .await;
1959 })
1960 };
1961
1962 wait_for_parked_ask(&bridge, &key).await;
1963
1964 ConnectBridge::handle_inbound(
1967 bridge.clone(),
1968 platform.clone(),
1969 vec!["u1".to_string()],
1970 inbound("chat1", "u1", "answer-1", "允许"),
1971 )
1972 .await;
1973
1974 wait_for_responder_calls(&responder, 1).await;
1979
1980 resume_tx
1981 .send(AgentEvent::Complete {
1982 usage: bamboo_agent_core::TokenUsage {
1983 prompt_tokens: 1,
1984 completion_tokens: 1,
1985 total_tokens: 2,
1986 },
1987 })
1988 .unwrap();
1989
1990 tokio::time::timeout(Duration::from_secs(5), render_handle)
1991 .await
1992 .expect("render task must finish")
1993 .unwrap();
1994
1995 assert_eq!(
1996 responder.calls.lock().await.as_slice(),
1997 &[("sess-1".to_string(), "Approve".to_string())]
1998 );
1999 }
2000
2001 #[tokio::test]
2002 async fn new_command_invalidates_a_parked_ask_instead_of_answering_it() {
2003 let (ctx, _dir) = test_context().await;
2004 let resume_tx = broadcast::channel::<AgentEvent>(16).0;
2005 let responder = FakeResponder::new(resume_tx.clone());
2006 let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
2007 let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
2008 let key = key_for("chat1", "u1");
2009 let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
2010
2011 bridge.set_session_id_for_key(&key, "sess-1").await;
2012
2013 let (tx, rx) = broadcast::channel(16);
2014 tx.send(need_clarification_event(
2015 "Approve?",
2016 vec!["Approve", "Deny"],
2017 false,
2018 ))
2019 .unwrap();
2020
2021 let render_handle = {
2022 let bridge = bridge.clone();
2023 let platform = platform.clone();
2024 let reply_ctx = reply_ctx.clone();
2025 let key = key.clone();
2026 tokio::spawn(async move {
2027 bridge
2028 .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
2029 .await;
2030 })
2031 };
2032
2033 wait_for_parked_ask(&bridge, &key).await;
2034
2035 ConnectBridge::handle_inbound(
2036 bridge.clone(),
2037 platform.clone(),
2038 vec!["u1".to_string()],
2039 inbound("chat1", "u1", "new-1", "/new"),
2040 )
2041 .await;
2042
2043 tokio::time::timeout(Duration::from_secs(5), render_handle)
2044 .await
2045 .expect("render task must finish")
2046 .unwrap();
2047
2048 assert!(responder.calls.lock().await.is_empty());
2049 assert!(!bridge.has_pending_ask(&key).await);
2050 assert!(bridge.session_id_for_key(&key).await.is_none());
2052 }
2053
2054 #[tokio::test]
2055 async fn respond_error_reports_to_the_chat_without_hanging() {
2056 let (ctx, _dir) = test_context().await;
2057 let resume_tx = broadcast::channel::<AgentEvent>(16).0;
2058 let responder = FakeResponder::failing(resume_tx.clone(), "boom");
2059 let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
2060 let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
2061 let key = key_for("chat1", "u1");
2062 let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
2063
2064 let (tx, rx) = broadcast::channel(16);
2065 tx.send(need_clarification_event(
2066 "Approve?",
2067 vec!["Approve", "Deny"],
2068 false,
2069 ))
2070 .unwrap();
2071
2072 let render_handle = {
2073 let bridge = bridge.clone();
2074 let platform = platform.clone();
2075 let reply_ctx = reply_ctx.clone();
2076 let key = key.clone();
2077 tokio::spawn(async move {
2078 bridge
2079 .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
2080 .await;
2081 })
2082 };
2083
2084 let parked = wait_for_parked_ask(&bridge, &key).await;
2085 let callback = CallbackQuery {
2086 platform: "fake".to_string(),
2087 chat_id: "chat1".to_string(),
2088 user_id: "u1".to_string(),
2089 callback_query_id: "cbq-1".to_string(),
2090 data: format!("{}:0", parked.nonce),
2091 reply_ctx: reply_ctx.clone(),
2092 };
2093 ConnectBridge::handle_callback(
2094 bridge.clone(),
2095 platform.clone(),
2096 vec!["u1".to_string()],
2097 callback,
2098 )
2099 .await;
2100
2101 tokio::time::timeout(Duration::from_secs(5), render_handle)
2102 .await
2103 .expect("render task must finish even when the responder errors")
2104 .unwrap();
2105
2106 let sent = platform.sent_texts().await;
2107 assert!(
2108 sent.iter()
2109 .any(|text| text.contains("Failed to record your answer")),
2110 "expected an error report, got: {sent:?}"
2111 );
2112 }
2113
2114 #[tokio::test]
2119 async fn run_that_pauses_twice_keeps_a_single_status_message() {
2120 let (ctx, _dir) = test_context().await;
2121 let resume_tx = broadcast::channel::<AgentEvent>(16).0;
2122 let responder = FakeResponder::new(resume_tx.clone());
2123 let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
2124 let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
2125 let key = key_for("chat1", "u1");
2126 let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
2127
2128 let (tx, rx) = broadcast::channel(16);
2130 tx.send(AgentEvent::Token {
2131 content: "segment one ".to_string(),
2132 })
2133 .unwrap();
2134 tx.send(need_clarification_event(
2135 "First?",
2136 vec!["Approve", "Deny"],
2137 false,
2138 ))
2139 .unwrap();
2140
2141 let render_handle = {
2142 let bridge = bridge.clone();
2143 let platform = platform.clone();
2144 let reply_ctx = reply_ctx.clone();
2145 let key = key.clone();
2146 tokio::spawn(async move {
2147 bridge
2148 .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
2149 .await;
2150 })
2151 };
2152
2153 let first_ask = wait_for_parked_ask(&bridge, &key).await;
2154 ConnectBridge::handle_inbound(
2155 bridge.clone(),
2156 platform.clone(),
2157 vec!["u1".to_string()],
2158 inbound("chat1", "u1", "a1", "1"),
2159 )
2160 .await;
2161 wait_for_responder_calls(&responder, 1).await;
2162
2163 resume_tx
2165 .send(AgentEvent::Token {
2166 content: "segment two ".to_string(),
2167 })
2168 .unwrap();
2169 resume_tx
2170 .send(need_clarification_event(
2171 "Second?",
2172 vec!["Approve", "Deny"],
2173 false,
2174 ))
2175 .unwrap();
2176
2177 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
2180 loop {
2181 let parked = {
2182 bridge
2183 .chat_state
2184 .lock()
2185 .await
2186 .get(&key)
2187 .and_then(|state| state.pending_ask.clone())
2188 };
2189 if let Some(ask) = parked {
2190 if ask.nonce != first_ask.nonce {
2191 break;
2192 }
2193 }
2194 assert!(
2195 tokio::time::Instant::now() < deadline,
2196 "second ask was never parked"
2197 );
2198 tokio::time::sleep(Duration::from_millis(10)).await;
2199 }
2200
2201 ConnectBridge::handle_inbound(
2202 bridge.clone(),
2203 platform.clone(),
2204 vec!["u1".to_string()],
2205 inbound("chat1", "u1", "a2", "1"),
2206 )
2207 .await;
2208 wait_for_responder_calls(&responder, 2).await;
2209
2210 resume_tx
2212 .send(AgentEvent::Token {
2213 content: "segment three".to_string(),
2214 })
2215 .unwrap();
2216 resume_tx
2217 .send(AgentEvent::Complete {
2218 usage: bamboo_agent_core::TokenUsage {
2219 prompt_tokens: 1,
2220 completion_tokens: 1,
2221 total_tokens: 2,
2222 },
2223 })
2224 .unwrap();
2225
2226 tokio::time::timeout(Duration::from_secs(5), render_handle)
2227 .await
2228 .expect("render task must finish")
2229 .unwrap();
2230
2231 let sent = platform.sent_texts().await;
2234 let status_count = sent.iter().filter(|text| text.contains('⏳')).count();
2235 assert_eq!(
2236 status_count, 1,
2237 "expected exactly one status bubble, got: {sent:?}"
2238 );
2239 assert_eq!(sent.len(), 3, "status + 2 asks expected, got: {sent:?}");
2240
2241 let edits = platform.edits.lock().await;
2244 let last = edits.last().expect("expected a final edit");
2245 assert!(last.starts_with('✅'), "final edit not a success: {last}");
2246 assert!(last.contains("segment one"));
2247 assert!(last.contains("segment two"));
2248 assert!(last.contains("segment three"));
2249 }
2250
2251 #[tokio::test]
2256 async fn stop_while_paused_cancels_the_pending_question() {
2257 let (ctx, _dir) = test_context().await;
2258 let resume_tx = broadcast::channel::<AgentEvent>(16).0;
2259 let responder = FakeResponder::new(resume_tx.clone());
2260 let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
2261 let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
2262 let key = key_for("chat1", "u1");
2263 let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
2264
2265 let (tx, rx) = broadcast::channel(16);
2266 tx.send(need_clarification_event(
2267 "Approve?",
2268 vec!["Approve", "Deny"],
2269 false,
2270 ))
2271 .unwrap();
2272
2273 let render_handle = {
2274 let bridge = bridge.clone();
2275 let platform = platform.clone();
2276 let reply_ctx = reply_ctx.clone();
2277 let key = key.clone();
2278 tokio::spawn(async move {
2279 bridge
2280 .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
2281 .await;
2282 })
2283 };
2284
2285 wait_for_parked_ask(&bridge, &key).await;
2286
2287 ConnectBridge::handle_inbound(
2288 bridge.clone(),
2289 platform.clone(),
2290 vec!["u1".to_string()],
2291 inbound("chat1", "u1", "stop-1", "/stop"),
2292 )
2293 .await;
2294
2295 tokio::time::timeout(Duration::from_secs(5), render_handle)
2296 .await
2297 .expect("render task must finish after /stop invalidates the ask")
2298 .unwrap();
2299
2300 let sent = platform.sent_texts().await;
2301 assert!(
2302 sent.iter()
2303 .any(|text| text == "Stopped — the pending question was cancelled."),
2304 "expected the pending-question-cancelled reply, got: {sent:?}"
2305 );
2306 assert!(!bridge.has_pending_ask(&key).await);
2307 assert!(responder.calls.lock().await.is_empty());
2308 }
2309
2310 #[tokio::test]
2315 async fn stop_while_paused_with_live_token_cancels_both() {
2316 let (ctx, _dir) = test_context().await;
2317 let resume_tx = broadcast::channel::<AgentEvent>(16).0;
2318 let responder = FakeResponder::new(resume_tx.clone());
2319 let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
2320 let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
2321 let key = key_for("chat1", "u1");
2322 let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
2323
2324 let token = CancellationToken::new();
2325 bridge.set_cancel_token(&key, token.clone()).await;
2326
2327 let (tx, rx) = broadcast::channel(16);
2328 tx.send(need_clarification_event(
2329 "Approve?",
2330 vec!["Approve", "Deny"],
2331 false,
2332 ))
2333 .unwrap();
2334
2335 let render_handle = {
2336 let bridge = bridge.clone();
2337 let platform = platform.clone();
2338 let reply_ctx = reply_ctx.clone();
2339 let key = key.clone();
2340 tokio::spawn(async move {
2341 bridge
2342 .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
2343 .await;
2344 })
2345 };
2346
2347 wait_for_parked_ask(&bridge, &key).await;
2348
2349 ConnectBridge::handle_inbound(
2350 bridge.clone(),
2351 platform.clone(),
2352 vec!["u1".to_string()],
2353 inbound("chat1", "u1", "stop-1", "/stop"),
2354 )
2355 .await;
2356
2357 tokio::time::timeout(Duration::from_secs(5), render_handle)
2358 .await
2359 .expect("render task must finish after /stop")
2360 .unwrap();
2361
2362 assert!(token.is_cancelled(), "cancel token must be cancelled");
2363 assert!(!bridge.has_pending_ask(&key).await);
2364 let sent = platform.sent_texts().await;
2365 assert!(
2366 sent.iter().any(|text| text == "Stopping the current run…"),
2367 "expected the stopping reply, got: {sent:?}"
2368 );
2369 }
2370}