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