Skip to main content

bamboo_server/connect/
bridge.rs

1//! Chat ⇄ bamboo-session routing, busy lock + FIFO queue, and execution
2//! through the single canonical [`spawn_session_execution`] path.
3//!
4//! Reference: `schedule_app::manager::run_schedule_job` — the SAME
5//! `SessionExecutionArgs` + event-forwarder + reservation pattern is reused
6//! here (see `run_prompt`), rather than inventing a parallel loop.
7
8use 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/// `platform:chat_id:user_id` — the chat-scoped routing key mapping to a
32/// bamboo session id (see epic #447's "Bridge" design).
33#[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
46/// Max entries [`BoundedSeenSet`] retains before evicting the oldest
47/// (issue #454 follow-up). This is defense-in-depth dedup, layered on top
48/// of each adapter's own transport-level dedup (e.g. Telegram's offset
49/// advance) — it only needs to cover the realistic in-flight
50/// redelivery/retry window, not serve as a permanent audit log. A few
51/// thousand entries comfortably covers any plausible burst across every
52/// configured chat while keeping memory bounded for the life of the
53/// process.
54const DEDUP_CAPACITY: usize = 10_000;
55
56/// A `HashSet` bounded to at most `capacity` entries via FIFO eviction:
57/// once full, inserting a new key evicts the oldest still-tracked key.
58/// Used for [`ConnectBridge::seen_message_ids`] — a plain `HashSet` there
59/// would gain one entry per distinct `platform:message_id` for the life of
60/// the process (issue #454 follow-up).
61struct 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    /// Inserts `key`, evicting the oldest tracked key(s) if this pushes the
77    /// set past capacity. Returns `true` if `key` was newly inserted (i.e.
78    /// not a duplicate) — same contract as `HashSet::insert`.
79    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/// Shared dependencies the bridge needs to run sessions through the
99/// canonical execution path. Cheap to clone (every field is an `Arc`, or
100/// small/`Clone`).
101#[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    /// Connector type -> Project membership for newly-created sessions.
114    /// Multi-instance connectors of the same type are currently rejected, so
115    /// the platform type is an unambiguous key.
116    pub project_ids_by_platform: Arc<HashMap<String, bamboo_domain::ProjectId>>,
117    /// Shared with `AppState::permission_checker` — needed so an approved
118    /// permission prompt (a gated tool asked to run, answered through
119    /// connect) actually grants the session permission the re-executed tool
120    /// call is checked against on resume (issue #458; see
121    /// `approvals::EngineResponder`).
122    pub permission_checker: Arc<dyn bamboo_tools::permission::PermissionChecker>,
123}
124
125/// Per-chat runtime state: whether a run is currently executing, the FIFO
126/// queue of messages that arrived while busy (drained at run end — mirrors
127/// cc-connect engine.go's `queueMessageForBusySession`), the cancel token of
128/// the in-flight run (if any) so `/stop` can reach it without waiting in the
129/// queue, and the chat's one parked ask (if any) — issue #458's
130/// approval/question relay.
131#[derive(Default)]
132struct ChatState {
133    busy: bool,
134    queue: VecDeque<(Arc<dyn Platform>, InboundMessage)>,
135    cancel_token: Option<CancellationToken>,
136    /// The chat's single in-flight pending question, if the current run is
137    /// paused on one (issue #458: "one parked ask per chat").
138    pending_ask: Option<ParkedAsk>,
139    /// Resolver for `pending_ask`, held by the render task
140    /// (`ConnectBridge::render_until_settled`) that's waiting on it.
141    /// `handle_inbound`/`handle_callback` push a resolution here instead of
142    /// queuing a matching reply — this is what lets an answer "jump" the
143    /// busy queue while the run is genuinely suspended waiting for exactly
144    /// this. Buffered at 1 so a resolver can send without the render task
145    /// having reached its `recv().await` yet.
146    ask_resolution: Option<mpsc::Sender<AskResolution>>,
147}
148
149/// What resolved (or invalidated) a chat's parked ask.
150#[derive(Debug, Clone)]
151enum AskResolution {
152    /// A button press or text reply matched the parked ask; submit this as
153    /// the answer.
154    Answer(String),
155    /// `/new`, session rotation, or an explicit clear invalidated the ask
156    /// before it was answered — the waiting render task must stop rendering
157    /// this (now-abandoned) run rather than hang forever.
158    Invalidated,
159}
160
161/// Resolved model/prompt/workspace configuration for a connect-driven run,
162/// derived from the live global config.
163///
164/// This is a thin alias over [`bamboo_engine::resolved_defaults::ResolvedDefaultRunConfig`]
165/// — the SOLE implementation of this resolution cascade, shared with the
166/// public `GET /api/v1/execute/defaults` handler (issue #480). Do not
167/// reimplement the model/prompt/workspace cascade here; extend the shared
168/// helper instead.
169type 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
178/// Builds a fresh session for a connect chat key. Mirrors
179/// `schedule_app::session_factory::create_schedule_session`.
180///
181/// `no_human_approver = false` (issue #458, flipped from phase 1's `true`): a
182/// connect-bridged chat now HAS a human attached — the ConnectBridge itself,
183/// relaying questions/approvals to and from the chat platform — so gated
184/// actions and pending questions should escalate normally rather than being
185/// tagged "no interactive approver available" (that tag only actually
186/// changes behavior for out-of-process sub-agent workers routing to an
187/// off-loop model reviewer — see `subagent_worker`'s `ApprovalProxy`; an
188/// in-process run like this one was never auto-decided by it, so this flip
189/// is a correctness/semantics fix for anything that reads the flag — e.g.
190/// child-session inheritance — rather than a change to THIS run's own pause
191/// behavior).
192fn 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
233/// Strips a Telegram-style `@BotName` command suffix (`/stop@MyBot` ->
234/// `/stop`) so mention-qualified commands still match in group chats.
235fn 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
245/// Routes inbound platform messages to bamboo sessions, enforces the
246/// per-platform allow-list + dedup, and serializes execution per chat behind
247/// a busy lock + FIFO queue.
248pub struct ConnectBridge {
249    ctx: ConnectContext,
250    /// `SessionKey::as_string()` -> bamboo session id. Persisted as JSON
251    /// (atomic write) so a chat's session survives a server restart.
252    session_map: TokioRwLock<HashMap<String, String>>,
253    map_path: Option<PathBuf>,
254    chat_state: AsyncMutex<HashMap<String, ChatState>>,
255    /// `platform:message_id` seen so far — dedup defense-in-depth alongside
256    /// each adapter's own transport-level dedup (e.g. Telegram's offset
257    /// advance). Bounded (issue #454 follow-up: see [`BoundedSeenSet`]) so
258    /// it never grows without limit for the life of the process. A
259    /// `std::sync::Mutex` is fine here: only ever locked for a single
260    /// insert, never held across an `.await`.
261    seen_message_ids: StdMutex<BoundedSeenSet>,
262    process_start: DateTime<Utc>,
263    /// The resolution seam (issue #458): `submit_pending_response` +
264    /// `resume_session_execution`, or a fake in tests.
265    responder: Arc<dyn Responder>,
266}
267
268impl ConnectBridge {
269    /// Production constructor: wires up `approvals::EngineResponder` (the
270    /// in-proc respond+resume path) automatically. See [`Self::with_responder`]
271    /// for injecting a fake in tests.
272    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    /// Test/advanced constructor: inject a [`Responder`] seam instead of the
278    /// production `EngineResponder` (issue #458: "Design a small Responder
279    /// seam on the bridge so tests inject a fake instead of full AppState").
280    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    /// Loads the persisted chat -> session map from disk, if a `map_path`
297    /// was configured. Tolerates a missing or corrupt file (starts empty,
298    /// logging a warning on corruption) — a fresh/lost map degrades to
299    /// "every chat starts a new session," never a hard failure.
300    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    /// Rotates the chat's session mapping (`/new`). Also invalidates any
333    /// parked ask first (issue #458: "`/new` and session rotation invalidate
334    /// parked asks") — an ask answered after its session has been rotated
335    /// away would resolve a question nobody can see anymore.
336    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    /// Whether `key`'s chat currently has a parked ask awaiting resolution.
346    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    /// If `key` has a parked ask AND `resolve` matches it, atomically clears
355    /// the parked ask + its resolver (so a concurrent duplicate resolution —
356    /// e.g. a button press racing a text reply — finds nothing left to
357    /// match) and returns the answer plus the channel to notify the waiting
358    /// render task on. `resolve` runs while holding the chat-state lock, so
359    /// it must be cheap and non-async (pure pattern matching against the
360    /// parked ask — see `approvals::match_text_answer`/`match_callback_data`).
361    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    /// Clears `key`'s parked ask (if any) and wakes its waiting render task
376    /// with [`AskResolution::Invalidated`] instead of an answer.
377    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    /// Clears `key`'s parked ask + resolver without sending a resolution —
394    /// used once a render task has already consumed one (whether an answer
395    /// or an invalidation) so a stale entry never lingers.
396    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    /// Entry point for every inbound platform message. Enforces allow-list +
434    /// dedup, answers `/stop` and `/status` immediately (bypassing the busy
435    /// queue — a queued `/stop` could never reach a busy chat), and otherwise
436    /// either runs the message right away or queues it behind the chat's
437    /// current run.
438    ///
439    /// Takes `self: Arc<Self>` (not `&self`) so it can hand the bridge off to
440    /// a detached `tokio::spawn` for the actual (potentially long-running)
441    /// execution — this method itself returns as soon as the message is
442    /// either answered inline or queued, so one slow chat can never block
443    /// another chat's inbound dispatch loop.
444    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        // Ask-resolution fast path (issue #458): a parked ask takes priority
500        // over normal busy/queue routing, even while `busy` is still true —
501        // the run backing it is genuinely suspended waiting for exactly this
502        // reply, so it must never sit behind the FIFO queue. A non-matching
503        // reply on a CLOSED ask (no free text allowed) falls through to the
504        // normal busy/queue handling below, exactly like any other message.
505        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        // `/new` is always an immediate escape hatch out of a parked ask
514        // (bypassing the queue, which would never drain while the chat waits
515        // on an answer nobody typed correctly) — the ordinary `/new` path in
516        // `process_one` still handles the non-paused case unchanged.
517        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    /// Processes `msg`, then keeps draining `chat_state`'s queue for `key`
539    /// (FIFO) until it is empty, at which point the chat is marked idle
540    /// again. Runs in its own spawned task (see [`Self::handle_inbound`]).
541    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    /// Entry point for every inbound button-press callback (issue #458).
575    /// Unlike text messages, a callback NEVER queues and NEVER starts a run —
576    /// it can only ever resolve (or fail to resolve) the chat's parked ask.
577    /// Per the design constraint, the platform is ALWAYS acked
578    /// (`answer_callback`), even for a stale/forged/non-matching one, and a
579    /// non-match is dropped silently rather than ever being forwarded as
580    /// user text.
581    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        // A run paused on a parked ask has no live task for `cancel_token` to
664        // reach (the round that produced the question already returned) — an
665        // ask invalidation is what actually unblocks
666        // `render_until_settled`'s wait in that case.
667        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    /// Runs `text` as a prompt for `key`'s session, through the canonical
780    /// `spawn_session_execution` path (reference:
781    /// `schedule_app::manager::run_schedule_job`), then renders the run's
782    /// live event stream back to the platform (`render::stream_execution`)
783    /// until it reaches a terminal state. Awaited inline by the caller (not
784    /// detached) so the run's completion IS this call's completion — that is
785    /// what lets [`Self::drain_chat`] serialize one run at a time per chat.
786    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        // Only the exact shared runner/router owner may publish a new prompt or
855        // mutate process-global permission workspace state.
856        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        // Auxiliary (fast/background/summarization) model resolver — mirrors
883        // `schedule_app::manager::run_schedule_job` exactly.
884        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            // Approvals (guardian, bash resume) are a later phase of epic
921            // #447 — MVP has no channel to answer them (see
922            // `create_connect_session`'s `no_human_approver` doc comment).
923            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            // No per-request override on this path; the config-level default
929            // (issue #221) still applies.
930            run_budget: None,
931            runners: self.ctx.agent_runners.clone(),
932            sessions_cache: self.ctx.session_repo.cache().clone(),
933            on_complete: None,
934            // Connect drives root sessions; a child finishing on this path
935            // is backstopped by the child-wait watchdog (#546).
936            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    /// Renders one run to completion, looping back for as many
946    /// pause/answer/resume cycles as it takes to reach a genuinely terminal
947    /// state (issue #458). On [`render::RunOutcome::Paused`]: parks the ask,
948    /// renders it (buttons when the platform supports them, always also a
949    /// numbered text list), and waits for a resolution pushed by
950    /// `handle_inbound`'s ask-resolution fast path or `handle_callback` —
951    /// or an invalidation from `/new`/rotation/`/stop`. A resolved answer is
952    /// submitted through `self.responder`, which mirrors
953    /// `POST /sessions/{id}/respond`'s exact resolve-then-resume sequence;
954    /// the resumed run's fresh broadcast receiver is looped back into
955    /// another `render::stream_execution` call — together with the streaming
956    /// renderer's carried-over [`render::StreamState`] — so the SAME chat
957    /// keeps watching the SAME (now-continuing) run in the SAME status
958    /// message (one "⏳ Working…" bubble per run, no matter how many times it
959    /// pauses).
960    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                            // Already cleared by the invalidator in the
1026                            // common case; clear defensively so a stale entry
1027                            // never lingers if the sender was dropped instead
1028                            // (e.g. a bug elsewhere) rather than sending
1029                            // `Invalidated` explicitly.
1030                            self.clear_pending_ask(key).await;
1031                            return;
1032                        }
1033                    }
1034                }
1035            }
1036        }
1037    }
1038}
1039
1040/// Writes `bytes` to `path` atomically: temp file in the same directory,
1041/// fsync, rename over the target. Mirrors
1042/// `handlers::settings::bamboo_config::config_endpoints::common::atomic_write`
1043/// (private there) so a crash mid-write leaves the old session map intact.
1044async 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    /// mpsc-backed fake `Platform` (per issue #452's test spec): records every
1072    /// `reply()`/`edit()`/`answer_callback()` call instead of talking to a
1073    /// real IM API. `capabilities` is configurable (issue #458 tests need
1074    /// buttons+edit_message; the original #452 tests want the all-`false`
1075    /// default).
1076    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    /// Fake [`Responder`] (issue #458: "tests inject a fake instead of full
1157    /// AppState"): records every submitted answer and hands back a
1158    /// broadcast receiver subscribed to a test-controlled sender, so a test
1159    /// can drive the "resumed run" by sending events directly.
1160    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    /// Polls `bridge`'s internal chat state until `key` has a parked ask (or
1207    /// panics past a 5s deadline) — used to synchronize with
1208    /// `render_until_settled`'s pause branch, which parks the ask
1209    /// asynchronously.
1210    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    /// Polls until `responder` has recorded at least `count` calls (or panics
1231    /// past a 5s deadline) — used to synchronize with
1232    /// `FakeResponder::subscribe` actually happening inside
1233    /// `render_until_settled`'s spawned task BEFORE a test sends an event on
1234    /// the "resumed" broadcast channel (`broadcast::Sender::send` errors with
1235    /// zero live subscribers).
1236    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        // A full `AppState` gives the bridge a real `Agent`/tools/session-repo
1253        // without a network call. A model IS configured (so `run_prompt`'s
1254        // "no model configured" guard doesn't short-circuit before a session
1255        // is even created) but with no api_key, so the provider falls back to
1256        // `UnconfiguredProvider`: execution fails fast with an
1257        // `AgentEvent::Error` (non-retryable auth error), which is exactly
1258        // what these tests need to observe a run reach a terminal state
1259        // quickly without any real network access.
1260        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    // ---- Issue #454 follow-up: bounded dedup set ----
1330
1331    #[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        // Pushes past capacity: "a" (oldest) is evicted; "b" and "c" remain
1339        // tracked as seen.
1340        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        // "a" was evicted — re-inserting it is treated as new, not a
1346        // duplicate (which in turn evicts "b", the now-oldest entry).
1347        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        // `/status` replies synchronously with no engine/queue involved, so a
1463        // duplicate delivery of the SAME message_id must yield exactly one
1464        // reply, not two.
1465        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        // Two prompts arriving back-to-back for the SAME chat: the second
1622        // must queue behind the first (busy lock) rather than racing it, and
1623        // the chat must return to idle once both have run.
1624        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    // ---- Issue #458: approval/question relay ----
1680
1681    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        // Buttons were rendered on the ask message, one per option, callback
1741        // data carrying the nonce (never raw option text/user text).
1742        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 `render_until_settled`'s spawned task to have actually
1769        // called through to the responder (and thus subscribed to
1770        // `resume_tx`) before sending on it — `broadcast::Sender::send`
1771        // errors out with zero live subscribers.
1772        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        // Acked (Telegram-style — always ack), but with an "expired" style
1849        // note, and NOT forwarded as an answer.
1850        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        // The real ask is still parked — the stale callback never touched it.
1856        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 `render_until_settled`'s spawned task to have actually
1906        // called through to the responder (and thus subscribed to
1907        // `resume_tx`) before sending on it — `broadcast::Sender::send`
1908        // errors out with zero live subscribers.
1909        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        // "允许" (allow) is a first-affirmative keyword — resolves to
1965        // whichever option reads as approval, NEVER the raw keyword text.
1966        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 `render_until_settled`'s spawned task to have actually
1975        // called through to the responder (and thus subscribed to
1976        // `resume_tx`) before sending on it — `broadcast::Sender::send`
1977        // errors out with zero live subscribers.
1978        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        // Session mapping was rotated away, same as an ordinary `/new`.
2051        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    /// PR #459 review must-fix: a run that pauses (and resumes) MULTIPLE
2115    /// times must keep exactly ONE status message — every resumed segment
2116    /// keeps EDITING the original "⏳ Working…" bubble via the carried
2117    /// `render::StreamState`, never opening a new one.
2118    #[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        // Segment 1: text + pause #1.
2129        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        // Segment 2 (first resume): text + pause #2.
2164        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        // Wait until the SECOND ask is parked (a fresh nonce distinguishes
2178        // it from the first, already-resolved one).
2179        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        // Segment 3 (second resume): text + Complete.
2211        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        // Exactly ONE "⏳ Working…" status message across the whole
2232        // twice-paused run; the other sends are the two ask messages.
2233        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        // Edits continued across both resumes: the final ✅ edit carries text
2242        // from every segment (the buffer survived both pauses).
2243        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    /// PR #459 review item 3: `/stop` while a run is paused on a parked ask
2252    /// (no live cancel token — the round already returned) must invalidate
2253    /// the ask, unblock the render task, and reply with the dedicated
2254    /// "pending question was cancelled" message (the `(None, true)` branch).
2255    #[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    /// The `(Some(token), true)` `/stop` branch: a parked ask AND a live
2311    /// cancel token (e.g. `/stop` racing the pause) — the token is
2312    /// cancelled, the ask is invalidated, and the generic "Stopping the
2313    /// current run…" reply is used.
2314    #[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}