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 crate::permission_audit::record_bamboo_runtime_permission_metadata;
28
29use super::approvals::{self, ParkedAsk, RespondAndResumeOutcome, Responder};
30use super::platform::{CallbackQuery, InboundMessage, OutboundMessage, Platform, ReplyCtx};
31use super::render;
32
33/// `platform:chat_id:user_id` — the chat-scoped routing key mapping to a
34/// bamboo session id (see epic #447's "Bridge" design).
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct SessionKey {
37    pub platform: String,
38    pub chat_id: String,
39    pub user_id: String,
40}
41
42impl SessionKey {
43    pub fn as_string(&self) -> String {
44        format!("{}:{}:{}", self.platform, self.chat_id, self.user_id)
45    }
46}
47
48/// Max entries [`BoundedSeenSet`] retains before evicting the oldest
49/// (issue #454 follow-up). This is defense-in-depth dedup, layered on top
50/// of each adapter's own transport-level dedup (e.g. Telegram's offset
51/// advance) — it only needs to cover the realistic in-flight
52/// redelivery/retry window, not serve as a permanent audit log. A few
53/// thousand entries comfortably covers any plausible burst across every
54/// configured chat while keeping memory bounded for the life of the
55/// process.
56const DEDUP_CAPACITY: usize = 10_000;
57
58/// A `HashSet` bounded to at most `capacity` entries via FIFO eviction:
59/// once full, inserting a new key evicts the oldest still-tracked key.
60/// Used for [`ConnectBridge::seen_message_ids`] — a plain `HashSet` there
61/// would gain one entry per distinct `platform:message_id` for the life of
62/// the process (issue #454 follow-up).
63struct BoundedSeenSet {
64    set: HashSet<String>,
65    order: VecDeque<String>,
66    capacity: usize,
67}
68
69impl BoundedSeenSet {
70    fn new(capacity: usize) -> Self {
71        Self {
72            set: HashSet::new(),
73            order: VecDeque::new(),
74            capacity: capacity.max(1),
75        }
76    }
77
78    /// Inserts `key`, evicting the oldest tracked key(s) if this pushes the
79    /// set past capacity. Returns `true` if `key` was newly inserted (i.e.
80    /// not a duplicate) — same contract as `HashSet::insert`.
81    fn insert(&mut self, key: String) -> bool {
82        if !self.set.insert(key.clone()) {
83            return false;
84        }
85        self.order.push_back(key);
86        while self.order.len() > self.capacity {
87            if let Some(oldest) = self.order.pop_front() {
88                self.set.remove(&oldest);
89            }
90        }
91        true
92    }
93
94    #[cfg(test)]
95    fn len(&self) -> usize {
96        self.set.len()
97    }
98}
99
100/// Shared dependencies the bridge needs to run sessions through the
101/// canonical execution path. Cheap to clone (every field is an `Arc`, or
102/// small/`Clone`).
103#[derive(Clone)]
104pub struct ConnectContext {
105    pub agent: Arc<bamboo_engine::Agent>,
106    pub tools: Arc<dyn ToolExecutor>,
107    pub session_repo: SessionRepository,
108    pub agent_runners: Arc<TokioRwLock<HashMap<String, AgentRunner>>>,
109    pub session_event_senders: Arc<TokioRwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
110    pub account_feed_inbox: Option<bamboo_engine::execution::AccountFeedInbox>,
111    pub app_data_dir: Option<PathBuf>,
112    pub config: Arc<tokio::sync::RwLock<Config>>,
113    pub provider_registry: Arc<ProviderRegistry>,
114    pub project_store: Arc<bamboo_projects::ProjectStore>,
115    pub workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
116    /// Connector type -> Project membership for newly-created sessions.
117    /// Multi-instance connectors of the same type are currently rejected, so
118    /// the platform type is an unambiguous key.
119    pub project_ids_by_platform: Arc<HashMap<String, bamboo_domain::ProjectId>>,
120    /// Shared with `AppState::permission_checker` — needed so an approved
121    /// permission prompt (a gated tool asked to run, answered through
122    /// connect) actually grants the session permission the re-executed tool
123    /// call is checked against on resume (issue #458; see
124    /// `approvals::EngineResponder`).
125    pub permission_checker: Arc<dyn bamboo_tools::permission::PermissionChecker>,
126}
127
128/// Per-chat runtime state: whether a run is currently executing, the FIFO
129/// queue of messages that arrived while busy (drained at run end — mirrors
130/// cc-connect engine.go's `queueMessageForBusySession`), the cancel token of
131/// the in-flight run (if any) so `/stop` can reach it without waiting in the
132/// queue, and the chat's one parked ask (if any) — issue #458's
133/// approval/question relay.
134#[derive(Default)]
135struct ChatState {
136    busy: bool,
137    queue: VecDeque<(Arc<dyn Platform>, InboundMessage)>,
138    cancel_token: Option<CancellationToken>,
139    /// The chat's single in-flight pending question, if the current run is
140    /// paused on one (issue #458: "one parked ask per chat").
141    pending_ask: Option<ParkedAsk>,
142    /// Resolver for `pending_ask`, held by the render task
143    /// (`ConnectBridge::render_until_settled`) that's waiting on it.
144    /// `handle_inbound`/`handle_callback` push a resolution here instead of
145    /// queuing a matching reply — this is what lets an answer "jump" the
146    /// busy queue while the run is genuinely suspended waiting for exactly
147    /// this. Buffered at 1 so a resolver can send without the render task
148    /// having reached its `recv().await` yet.
149    ask_resolution: Option<mpsc::Sender<AskResolution>>,
150}
151
152/// What resolved (or invalidated) a chat's parked ask.
153#[derive(Debug, Clone)]
154enum AskResolution {
155    /// A button press or text reply matched the parked ask; submit this as
156    /// the answer.
157    Answer(String),
158    /// `/new`, session rotation, or an explicit clear invalidated the ask
159    /// before it was answered — the waiting render task must stop rendering
160    /// this (now-abandoned) run rather than hang forever.
161    Invalidated,
162}
163
164/// Resolved model/prompt/workspace configuration for a connect-driven run,
165/// derived from the live global config.
166///
167/// This is a thin alias over [`bamboo_engine::resolved_defaults::ResolvedDefaultRunConfig`]
168/// — the SOLE implementation of this resolution cascade, shared with the
169/// public `GET /api/v1/execute/defaults` handler (issue #480). Do not
170/// reimplement the model/prompt/workspace cascade here; extend the shared
171/// helper instead.
172type ResolvedConnectRunConfig = bamboo_engine::resolved_defaults::ResolvedDefaultRunConfig;
173
174fn resolve_connect_run_config(
175    config_snapshot: &Config,
176    provider_registry: &Arc<ProviderRegistry>,
177) -> ResolvedConnectRunConfig {
178    bamboo_engine::resolved_defaults::resolve_default_run_config(config_snapshot, provider_registry)
179}
180
181/// Builds a fresh session for a connect chat key. Mirrors
182/// `schedule_app::session_factory::create_schedule_session`.
183///
184/// `no_human_approver = false` (issue #458, flipped from phase 1's `true`): a
185/// connect-bridged chat now HAS a human attached — the ConnectBridge itself,
186/// relaying questions/approvals to and from the chat platform — so gated
187/// actions and pending questions should escalate normally rather than being
188/// tagged "no interactive approver available" (that tag only actually
189/// changes behavior for out-of-process sub-agent workers routing to an
190/// off-loop model reviewer — see `subagent_worker`'s `ApprovalProxy`; an
191/// in-process run like this one was never auto-decided by it, so this flip
192/// is a correctness/semantics fix for anything that reads the flag — e.g.
193/// child-session inheritance — rather than a change to THIS run's own pause
194/// behavior).
195fn create_connect_session(
196    key: &str,
197    model: &str,
198    system_prompt: &str,
199    base_system_prompt: &str,
200    workspace_path: Option<&str>,
201    project_id: Option<&bamboo_domain::ProjectId>,
202    reasoning_effort: Option<ReasoningEffort>,
203    workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
204) -> Session {
205    let session_id = uuid::Uuid::new_v4().to_string();
206    let mut session = Session::new(session_id.clone(), model.to_string());
207    session.title = format!("Connect: {key}");
208    session
209        .metadata
210        .insert("created_by_connect_key".to_string(), key.to_string());
211    session.metadata.insert(
212        "base_system_prompt".to_string(),
213        base_system_prompt.to_string(),
214    );
215    if let Some(project_id) = project_id {
216        session.set_project_id_meta(project_id.to_string());
217    }
218    if let Some(path) = workspace_path {
219        let final_workspace = workspace_resolver.publish_resolved_workspace(
220            &session_id,
221            PathBuf::from(path),
222            "connect",
223        );
224        session.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
225            &final_workspace,
226        ));
227    }
228    if let Some(effort) = reasoning_effort {
229        session.set_reasoning_effort_meta(effort.as_str());
230    }
231    session.add_message(Message::system(system_prompt.to_string()));
232    bamboo_engine::runner::refresh_prompt_snapshot(&mut session);
233    session
234        .agent_runtime_state
235        .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
236        .no_human_approver = false;
237    session
238}
239
240/// Strips a Telegram-style `@BotName` command suffix (`/stop@MyBot` ->
241/// `/stop`) so mention-qualified commands still match in group chats.
242fn strip_command_suffix(text: &str) -> &str {
243    text.split('@').next().unwrap_or(text)
244}
245
246async fn reply_text(platform: &Arc<dyn Platform>, ctx: &ReplyCtx, text: impl Into<String>) {
247    if let Err(error) = platform.reply(ctx, OutboundMessage::text(text)).await {
248        tracing::warn!("connect: failed to send reply: {error}");
249    }
250}
251
252/// Routes inbound platform messages to bamboo sessions, enforces the
253/// per-platform allow-list + dedup, and serializes execution per chat behind
254/// a busy lock + FIFO queue.
255pub struct ConnectBridge {
256    ctx: ConnectContext,
257    /// `SessionKey::as_string()` -> bamboo session id. Persisted as JSON
258    /// (atomic write) so a chat's session survives a server restart.
259    session_map: TokioRwLock<HashMap<String, String>>,
260    map_path: Option<PathBuf>,
261    chat_state: AsyncMutex<HashMap<String, ChatState>>,
262    /// `platform:message_id` seen so far — dedup defense-in-depth alongside
263    /// each adapter's own transport-level dedup (e.g. Telegram's offset
264    /// advance). Bounded (issue #454 follow-up: see [`BoundedSeenSet`]) so
265    /// it never grows without limit for the life of the process. A
266    /// `std::sync::Mutex` is fine here: only ever locked for a single
267    /// insert, never held across an `.await`.
268    seen_message_ids: StdMutex<BoundedSeenSet>,
269    process_start: DateTime<Utc>,
270    /// The resolution seam (issue #458): `submit_pending_response` +
271    /// `resume_session_execution`, or a fake in tests.
272    responder: Arc<dyn Responder>,
273}
274
275impl ConnectBridge {
276    /// Production constructor: wires up `approvals::EngineResponder` (the
277    /// in-proc respond+resume path) automatically. See [`Self::with_responder`]
278    /// for injecting a fake in tests.
279    pub fn new(ctx: ConnectContext, map_path: Option<PathBuf>) -> Self {
280        let responder = Arc::new(approvals::EngineResponder::new(ctx.clone()));
281        Self::with_responder(ctx, map_path, responder)
282    }
283
284    /// Test/advanced constructor: inject a [`Responder`] seam instead of the
285    /// production `EngineResponder` (issue #458: "Design a small Responder
286    /// seam on the bridge so tests inject a fake instead of full AppState").
287    pub fn with_responder(
288        ctx: ConnectContext,
289        map_path: Option<PathBuf>,
290        responder: Arc<dyn Responder>,
291    ) -> Self {
292        Self {
293            ctx,
294            session_map: TokioRwLock::new(HashMap::new()),
295            map_path,
296            chat_state: AsyncMutex::new(HashMap::new()),
297            seen_message_ids: StdMutex::new(BoundedSeenSet::new(DEDUP_CAPACITY)),
298            process_start: Utc::now(),
299            responder,
300        }
301    }
302
303    /// Loads the persisted chat -> session map from disk, if a `map_path`
304    /// was configured. Tolerates a missing or corrupt file (starts empty,
305    /// logging a warning on corruption) — a fresh/lost map degrades to
306    /// "every chat starts a new session," never a hard failure.
307    pub async fn load_session_map(&self) {
308        let Some(path) = &self.map_path else {
309            return;
310        };
311        match tokio::fs::read(path).await {
312            Ok(bytes) => match serde_json::from_slice::<HashMap<String, String>>(&bytes) {
313                Ok(map) => *self.session_map.write().await = map,
314                Err(error) => {
315                    tracing::warn!(
316                        "connect: session map at {path:?} is corrupt, starting empty: {error}"
317                    );
318                }
319            },
320            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
321            Err(error) => {
322                tracing::warn!("connect: failed to read session map at {path:?}: {error}");
323            }
324        }
325    }
326
327    pub async fn session_id_for_key(&self, key: &str) -> Option<String> {
328        self.session_map.read().await.get(key).cloned()
329    }
330
331    async fn set_session_id_for_key(&self, key: &str, session_id: &str) {
332        {
333            let mut map = self.session_map.write().await;
334            map.insert(key.to_string(), session_id.to_string());
335        }
336        self.persist_session_map().await;
337    }
338
339    /// Rotates the chat's session mapping (`/new`). Also invalidates any
340    /// parked ask first (issue #458: "`/new` and session rotation invalidate
341    /// parked asks") — an ask answered after its session has been rotated
342    /// away would resolve a question nobody can see anymore.
343    async fn rotate_session(&self, key: &str) {
344        self.invalidate_pending_ask(key).await;
345        {
346            let mut map = self.session_map.write().await;
347            map.remove(key);
348        }
349        self.persist_session_map().await;
350    }
351
352    /// Whether `key`'s chat currently has a parked ask awaiting resolution.
353    async fn has_pending_ask(&self, key: &str) -> bool {
354        self.chat_state
355            .lock()
356            .await
357            .get(key)
358            .is_some_and(|state| state.pending_ask.is_some())
359    }
360
361    /// If `key` has a parked ask AND `resolve` matches it, atomically clears
362    /// the parked ask + its resolver (so a concurrent duplicate resolution —
363    /// e.g. a button press racing a text reply — finds nothing left to
364    /// match) and returns the answer plus the channel to notify the waiting
365    /// render task on. `resolve` runs while holding the chat-state lock, so
366    /// it must be cheap and non-async (pure pattern matching against the
367    /// parked ask — see `approvals::match_text_answer`/`match_callback_data`).
368    async fn try_resolve_pending_ask(
369        &self,
370        key: &str,
371        resolve: impl FnOnce(&ParkedAsk) -> Option<String>,
372    ) -> Option<(String, mpsc::Sender<AskResolution>)> {
373        let mut guard = self.chat_state.lock().await;
374        let state = guard.get_mut(key)?;
375        let ask_ref = state.pending_ask.as_ref()?;
376        let answer = resolve(ask_ref)?;
377        let sender = state.ask_resolution.take()?;
378        state.pending_ask = None;
379        Some((answer, sender))
380    }
381
382    /// Clears `key`'s parked ask (if any) and wakes its waiting render task
383    /// with [`AskResolution::Invalidated`] instead of an answer.
384    async fn invalidate_pending_ask(&self, key: &str) {
385        let sender = {
386            let mut guard = self.chat_state.lock().await;
387            match guard.get_mut(key) {
388                Some(state) => {
389                    state.pending_ask = None;
390                    state.ask_resolution.take()
391                }
392                None => None,
393            }
394        };
395        if let Some(sender) = sender {
396            let _ = sender.send(AskResolution::Invalidated).await;
397        }
398    }
399
400    /// Clears `key`'s parked ask + resolver without sending a resolution —
401    /// used once a render task has already consumed one (whether an answer
402    /// or an invalidation) so a stale entry never lingers.
403    async fn clear_pending_ask(&self, key: &str) {
404        let mut guard = self.chat_state.lock().await;
405        if let Some(state) = guard.get_mut(key) {
406            state.pending_ask = None;
407            state.ask_resolution = None;
408        }
409    }
410
411    async fn persist_session_map(&self) {
412        let Some(path) = &self.map_path else {
413            return;
414        };
415        let snapshot = self.session_map.read().await.clone();
416        let json = match serde_json::to_vec_pretty(&snapshot) {
417            Ok(json) => json,
418            Err(error) => {
419                tracing::warn!("connect: failed to serialize session map: {error}");
420                return;
421            }
422        };
423        if let Err(error) = atomic_write(path, &json).await {
424            tracing::warn!("connect: failed to persist session map at {path:?}: {error}");
425        }
426    }
427
428    async fn set_cancel_token(&self, key: &str, token: CancellationToken) {
429        let mut guard = self.chat_state.lock().await;
430        guard.entry(key.to_string()).or_default().cancel_token = Some(token);
431    }
432
433    async fn clear_cancel_token(&self, key: &str) {
434        let mut guard = self.chat_state.lock().await;
435        if let Some(state) = guard.get_mut(key) {
436            state.cancel_token = None;
437        }
438    }
439
440    /// Entry point for every inbound platform message. Enforces allow-list +
441    /// dedup, answers `/stop` and `/status` immediately (bypassing the busy
442    /// queue — a queued `/stop` could never reach a busy chat), and otherwise
443    /// either runs the message right away or queues it behind the chat's
444    /// current run.
445    ///
446    /// Takes `self: Arc<Self>` (not `&self`) so it can hand the bridge off to
447    /// a detached `tokio::spawn` for the actual (potentially long-running)
448    /// execution — this method itself returns as soon as the message is
449    /// either answered inline or queued, so one slow chat can never block
450    /// another chat's inbound dispatch loop.
451    pub async fn handle_inbound(
452        self: Arc<Self>,
453        platform: Arc<dyn Platform>,
454        allow_from: Vec<String>,
455        msg: InboundMessage,
456    ) {
457        if !allow_from.iter().any(|allowed| allowed == &msg.user_id) {
458            tracing::warn!(
459                platform = %msg.platform,
460                chat_id = %msg.chat_id,
461                user_id = %msg.user_id,
462                "connect: rejected inbound message — user not in allow_from"
463            );
464            return;
465        }
466
467        if msg.sent_at < self.process_start {
468            tracing::debug!(
469                platform = %msg.platform,
470                message_id = %msg.message_id,
471                "connect: dropping message older than process start"
472            );
473            return;
474        }
475
476        let dedup_key = format!("{}:{}", msg.platform, msg.message_id);
477        {
478            let mut seen = self.seen_message_ids.lock().unwrap();
479            if !seen.insert(dedup_key) {
480                tracing::debug!(
481                    platform = %msg.platform,
482                    message_id = %msg.message_id,
483                    "connect: dropping duplicate message_id"
484                );
485                return;
486            }
487        }
488
489        let key = SessionKey {
490            platform: msg.platform.clone(),
491            chat_id: msg.chat_id.clone(),
492            user_id: msg.user_id.clone(),
493        }
494        .as_string();
495
496        let command = strip_command_suffix(msg.text.trim());
497        if command.eq_ignore_ascii_case("/stop") {
498            self.handle_stop(&key, &platform, &msg.reply_ctx).await;
499            return;
500        }
501        if command.eq_ignore_ascii_case("/status") {
502            self.handle_status(&key, &platform, &msg.reply_ctx).await;
503            return;
504        }
505
506        // Ask-resolution fast path (issue #458): a parked ask takes priority
507        // over normal busy/queue routing, even while `busy` is still true —
508        // the run backing it is genuinely suspended waiting for exactly this
509        // reply, so it must never sit behind the FIFO queue. A non-matching
510        // reply on a CLOSED ask (no free text allowed) falls through to the
511        // normal busy/queue handling below, exactly like any other message.
512        if let Some((answer, sender)) = self
513            .try_resolve_pending_ask(&key, |ask| approvals::match_text_answer(ask, &msg.text))
514            .await
515        {
516            let _ = sender.send(AskResolution::Answer(answer)).await;
517            return;
518        }
519
520        // `/new` is always an immediate escape hatch out of a parked ask
521        // (bypassing the queue, which would never drain while the chat waits
522        // on an answer nobody typed correctly) — the ordinary `/new` path in
523        // `process_one` still handles the non-paused case unchanged.
524        if command.eq_ignore_ascii_case("/new") && self.has_pending_ask(&key).await {
525            self.rotate_session(&key).await;
526            reply_text(&platform, &msg.reply_ctx, "Started a new session.").await;
527            return;
528        }
529
530        let mut guard = self.chat_state.lock().await;
531        let state = guard.entry(key.clone()).or_default();
532        if state.busy {
533            state.queue.push_back((platform, msg));
534            return;
535        }
536        state.busy = true;
537        drop(guard);
538
539        let bridge = self.clone();
540        tokio::spawn(async move {
541            bridge.drain_chat(key, platform, msg).await;
542        });
543    }
544
545    /// Processes `msg`, then keeps draining `chat_state`'s queue for `key`
546    /// (FIFO) until it is empty, at which point the chat is marked idle
547    /// again. Runs in its own spawned task (see [`Self::handle_inbound`]).
548    async fn drain_chat(
549        self: Arc<Self>,
550        key: String,
551        mut platform: Arc<dyn Platform>,
552        mut msg: InboundMessage,
553    ) {
554        loop {
555            self.process_one(&key, platform.clone(), msg).await;
556
557            let next = {
558                let mut guard = self.chat_state.lock().await;
559                match guard.get_mut(&key) {
560                    Some(state) => match state.queue.pop_front() {
561                        Some(item) => Some(item),
562                        None => {
563                            state.busy = false;
564                            None
565                        }
566                    },
567                    None => None,
568                }
569            };
570
571            match next {
572                Some((p, m)) => {
573                    platform = p;
574                    msg = m;
575                }
576                None => break,
577            }
578        }
579    }
580
581    /// Entry point for every inbound button-press callback (issue #458).
582    /// Unlike text messages, a callback NEVER queues and NEVER starts a run —
583    /// it can only ever resolve (or fail to resolve) the chat's parked ask.
584    /// Per the design constraint, the platform is ALWAYS acked
585    /// (`answer_callback`), even for a stale/forged/non-matching one, and a
586    /// non-match is dropped silently rather than ever being forwarded as
587    /// user text.
588    pub async fn handle_callback(
589        self: Arc<Self>,
590        platform: Arc<dyn Platform>,
591        allow_from: Vec<String>,
592        callback: CallbackQuery,
593    ) {
594        if !allow_from
595            .iter()
596            .any(|allowed| allowed == &callback.user_id)
597        {
598            tracing::warn!(
599                platform = %callback.platform,
600                chat_id = %callback.chat_id,
601                user_id = %callback.user_id,
602                "connect: rejected callback query — user not in allow_from"
603            );
604            let _ = platform
605                .answer_callback(&callback.callback_query_id, None)
606                .await;
607            return;
608        }
609
610        let key = SessionKey {
611            platform: callback.platform.clone(),
612            chat_id: callback.chat_id.clone(),
613            user_id: callback.user_id.clone(),
614        }
615        .as_string();
616
617        let resolved = self
618            .try_resolve_pending_ask(&key, |ask| {
619                approvals::match_callback_data(ask, &callback.data)
620            })
621            .await;
622
623        match resolved {
624            Some((answer, sender)) => {
625                let _ = platform
626                    .answer_callback(&callback.callback_query_id, None)
627                    .await;
628                let _ = sender.send(AskResolution::Answer(answer)).await;
629            }
630            None => {
631                tracing::debug!(
632                    platform = %callback.platform,
633                    chat_id = %callback.chat_id,
634                    "connect: dropping stale/forged callback_data"
635                );
636                let _ = platform
637                    .answer_callback(
638                        &callback.callback_query_id,
639                        Some("This action has expired."),
640                    )
641                    .await;
642            }
643        }
644    }
645
646    async fn process_one(&self, key: &str, platform: Arc<dyn Platform>, msg: InboundMessage) {
647        let command = strip_command_suffix(msg.text.trim());
648        if command.eq_ignore_ascii_case("/new") {
649            self.rotate_session(key).await;
650            reply_text(&platform, &msg.reply_ctx, "Started a new session.").await;
651            return;
652        }
653
654        let text = msg.text.trim();
655        if text.is_empty() {
656            return;
657        }
658
659        self.run_prompt(key, platform, &msg.reply_ctx, text).await;
660    }
661
662    async fn handle_stop(&self, key: &str, platform: &Arc<dyn Platform>, reply_ctx: &ReplyCtx) {
663        let token = {
664            self.chat_state
665                .lock()
666                .await
667                .get(key)
668                .and_then(|state| state.cancel_token.clone())
669        };
670        // A run paused on a parked ask has no live task for `cancel_token` to
671        // reach (the round that produced the question already returned) — an
672        // ask invalidation is what actually unblocks
673        // `render_until_settled`'s wait in that case.
674        let had_pending_ask = self.has_pending_ask(key).await;
675        if had_pending_ask {
676            self.invalidate_pending_ask(key).await;
677        }
678        match (token, had_pending_ask) {
679            (Some(token), _) => {
680                token.cancel();
681                reply_text(platform, reply_ctx, "Stopping the current run…").await;
682            }
683            (None, true) => {
684                reply_text(
685                    platform,
686                    reply_ctx,
687                    "Stopped — the pending question was cancelled.",
688                )
689                .await;
690            }
691            (None, false) => {
692                reply_text(platform, reply_ctx, "Nothing is running.").await;
693            }
694        }
695    }
696
697    async fn handle_status(&self, key: &str, platform: &Arc<dyn Platform>, reply_ctx: &ReplyCtx) {
698        let session_id = self.session_id_for_key(key).await;
699        let busy = {
700            self.chat_state
701                .lock()
702                .await
703                .get(key)
704                .map(|state| state.busy)
705                .unwrap_or(false)
706        };
707        let text = match session_id {
708            Some(id) => format!(
709                "Session: {id}\nStatus: {}",
710                if busy { "busy" } else { "idle" }
711            ),
712            None => "No session yet. Send a message to start one.".to_string(),
713        };
714        reply_text(platform, reply_ctx, text).await;
715    }
716
717    async fn create_and_register_session(
718        &self,
719        key: &str,
720        resolved: &ResolvedConnectRunConfig,
721    ) -> Result<Session, String> {
722        let model = resolved.model_roster.model.clone().unwrap_or_default();
723        let platform = key.split(':').next().unwrap_or_default();
724        let project_id = self.ctx.project_ids_by_platform.get(platform);
725        if let Some(project_id) = project_id {
726            match self.ctx.project_store.get(project_id) {
727                Ok(project) if project.status == bamboo_domain::ProjectStatus::Active => {}
728                Ok(_) => {
729                    return Err(format!(
730                        "Connect Project {project_id} is archived; no session was created"
731                    ));
732                }
733                Err(error) => {
734                    return Err(format!(
735                        "Connect Project {project_id} is unavailable; no session was created: {error}"
736                    ));
737                }
738            }
739        }
740        let final_workspace = crate::project_context::validate_workspace_assignment_with_resolver(
741            &self.ctx.project_store,
742            project_id,
743            project_id
744                .is_none()
745                .then_some(resolved.workspace_path.as_deref())
746                .flatten(),
747            &self.ctx.workspace_resolver,
748        )
749        .map_err(|error| {
750            format!("Connect workspace is unavailable; no session was created: {error}")
751        })?;
752        let final_workspace_display = final_workspace
753            .as_deref()
754            .map(bamboo_config::paths::path_to_display_string);
755        let binding_status = match (project_id, final_workspace.as_deref()) {
756            (Some(project_id), Some(workspace)) => {
757                let workspace = bamboo_config::paths::path_to_display_string(workspace);
758                if self
759                    .ctx
760                    .project_store
761                    .find_workspace_owner_for_path(&workspace)
762                    .map_err(|error| format!("resolve Connect workspace owner: {error}"))?
763                    .is_some_and(|owner| owner.id == *project_id)
764                {
765                    bamboo_engine::project_context::WorkspaceBindingStatus::Registered
766                } else {
767                    bamboo_engine::project_context::WorkspaceBindingStatus::Unregistered
768                }
769            }
770            _ => bamboo_engine::project_context::WorkspaceBindingStatus::Unregistered,
771        };
772        let system_prompt =
773            bamboo_engine::runtime::context::upsert_workspace_prompt_context_with_source(
774                &resolved.system_prompt,
775                final_workspace_display.as_deref(),
776                binding_status,
777                project_id.map(|_| bamboo_engine::project_context::WorkspaceSource::ProjectDefault),
778            );
779        let mut session = create_connect_session(
780            key,
781            &model,
782            &system_prompt,
783            &resolved.base_system_prompt,
784            final_workspace_display.as_deref(),
785            project_id,
786            resolved.reasoning_effort,
787            &self.ctx.workspace_resolver,
788        );
789        if project_id.is_some() {
790            session.metadata.insert(
791                bamboo_engine::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
792                bamboo_engine::project_context::WorkspaceSource::ProjectDefault
793                    .as_str()
794                    .to_string(),
795            );
796        }
797        self.set_session_id_for_key(key, &session.id).await;
798        Ok(session)
799    }
800
801    /// Runs `text` as a prompt for `key`'s session, through the canonical
802    /// `spawn_session_execution` path (reference:
803    /// `schedule_app::manager::run_schedule_job`), then renders the run's
804    /// live event stream back to the platform (`render::stream_execution`)
805    /// until it reaches a terminal state. Awaited inline by the caller (not
806    /// detached) so the run's completion IS this call's completion — that is
807    /// what lets [`Self::drain_chat`] serialize one run at a time per chat.
808    async fn run_prompt(
809        &self,
810        key: &str,
811        platform: Arc<dyn Platform>,
812        reply_ctx: &ReplyCtx,
813        text: &str,
814    ) {
815        let config_snapshot = self.ctx.config.read().await.clone();
816        let resolved = resolve_connect_run_config(&config_snapshot, &self.ctx.provider_registry);
817
818        if resolved
819            .model_roster
820            .model
821            .as_deref()
822            .unwrap_or("")
823            .trim()
824            .is_empty()
825        {
826            reply_text(
827                &platform,
828                reply_ctx,
829                "No model is configured for this bamboo instance; cannot run your request.",
830            )
831            .await;
832            return;
833        }
834
835        let existing_id = self.session_id_for_key(key).await;
836        let session = match existing_id {
837            Some(id) => match self.ctx.session_repo.load_merged(&id).await {
838                Some(session) => Ok(session),
839                None => self.create_and_register_session(key, &resolved).await,
840            },
841            None => self.create_and_register_session(key, &resolved).await,
842        };
843        let mut session = match session {
844            Ok(session) => session,
845            Err(error) => {
846                reply_text(&platform, reply_ctx, &error).await;
847                return;
848            }
849        };
850
851        let session_id = session.id.clone();
852        let session_tx =
853            get_or_create_event_sender(&self.ctx.session_event_senders, &session_id).await;
854        let execution_reservation = match reserve_session_execution(
855            &self.ctx.agent,
856            &self.ctx.agent_runners,
857            &self.ctx.session_event_senders,
858            &session_id,
859            &session_tx,
860        )
861        .await
862        {
863            SessionExecutionReserveOutcome::Reserved(reservation) => reservation,
864            SessionExecutionReserveOutcome::AlreadyRunning { .. } => {
865                reply_text(
866                    &platform,
867                    reply_ctx,
868                    "This session is already running elsewhere; please wait for it to finish.",
869                )
870                .await;
871                return;
872            }
873        };
874        let rx = session_tx.subscribe();
875
876        // Only the exact shared runner/router owner may publish a new prompt or
877        // mutate process-global permission workspace state.
878        session.add_message(Message::user(text.to_string()));
879        if let Some(config) = self.ctx.permission_checker.permission_config() {
880            if let Some(workspace) = session.workspace.as_ref() {
881                config.register_session_workspace(session_id.clone(), workspace.clone());
882            }
883            if let Err(error) =
884                record_bamboo_runtime_permission_metadata(&mut session, config.as_ref())
885            {
886                tracing::error!(%error, %session_id, "connect permission audit failed closed");
887                return;
888            }
889        }
890        self.ctx.session_repo.save_and_cache(&mut session).await;
891
892        self.set_cancel_token(key, execution_reservation.cancel_token().clone())
893            .await;
894
895        let (mpsc_tx, _forwarder_handle) = create_event_forwarder(
896            session_id.clone(),
897            session_tx.clone(),
898            self.ctx.agent_runners.clone(),
899            self.ctx.account_feed_inbox.clone(),
900        );
901
902        // Auxiliary (fast/background/summarization) model resolver — mirrors
903        // `schedule_app::manager::run_schedule_job` exactly.
904        let aux_fast_model = resolved.model_roster.fast_model();
905        let aux_fast_provider = resolved.model_roster.fast_model_provider();
906        let aux_background_model = resolved.model_roster.background_model();
907        let aux_background_provider = resolved.model_roster.background_model_provider();
908        let aux_summarization_model = resolved.model_roster.summarization_model();
909        let aux_summarization_provider = resolved.model_roster.summarization_model_provider();
910        let auxiliary_model_resolver = Arc::new(move || AuxiliaryModelConfig {
911            fast_model_name: aux_fast_model.clone(),
912            fast_model_provider: aux_fast_provider.clone(),
913            background_model_name: aux_background_model.clone(),
914            planning_model_name: None,
915            search_model_name: None,
916            summarization_model_name: aux_summarization_model.clone(),
917            background_model_provider: aux_background_provider.clone(),
918            summarization_model_provider: aux_summarization_provider.clone(),
919        });
920
921        spawn_session_execution(SessionExecutionArgs {
922            agent: self.ctx.agent.clone(),
923            session_id: session_id.clone(),
924            session,
925            execution_reservation,
926            tools_override: Some(self.ctx.tools.clone()),
927            provider_override: None,
928            model_roster: resolved.model_roster.clone(),
929            reasoning_effort: resolved.reasoning_effort,
930            reasoning_effort_source: "connect".to_string(),
931            auxiliary_model_resolver: Some(auxiliary_model_resolver),
932            disabled_filter_resolver: None,
933            disabled_tools: None,
934            disabled_skill_ids: None,
935            selected_skill_ids: None,
936            selected_skill_mode: None,
937            mpsc_tx,
938            image_fallback: None,
939            gold_config: resolved.gold_config.clone(),
940            // Approvals (guardian, bash resume) are a later phase of epic
941            // #447 — MVP has no channel to answer them (see
942            // `create_connect_session`'s `no_human_approver` doc comment).
943            guardian_config: None,
944            guardian_spawner: None,
945            bash_resume_hook: None,
946            bash_completion_sink: None,
947            app_data_dir: self.ctx.app_data_dir.clone(),
948            // No per-request override on this path; the config-level default
949            // (issue #221) still applies.
950            run_budget: None,
951            runners: self.ctx.agent_runners.clone(),
952            sessions_cache: self.ctx.session_repo.cache().clone(),
953            on_complete: None,
954            // Connect drives root sessions; a child finishing on this path
955            // is backstopped by the child-wait watchdog (#546).
956            child_completion_handler: None,
957        });
958
959        self.render_until_settled(key, platform, reply_ctx.clone(), &session_id, rx)
960            .await;
961
962        self.clear_cancel_token(key).await;
963    }
964
965    /// Renders one run to completion, looping back for as many
966    /// pause/answer/resume cycles as it takes to reach a genuinely terminal
967    /// state (issue #458). On [`render::RunOutcome::Paused`]: parks the ask,
968    /// renders it (buttons when the platform supports them, always also a
969    /// numbered text list), and waits for a resolution pushed by
970    /// `handle_inbound`'s ask-resolution fast path or `handle_callback` —
971    /// or an invalidation from `/new`/rotation/`/stop`. A resolved answer is
972    /// submitted through `self.responder`, which mirrors
973    /// `POST /sessions/{id}/respond`'s exact resolve-then-resume sequence;
974    /// the resumed run's fresh broadcast receiver is looped back into
975    /// another `render::stream_execution` call — together with the streaming
976    /// renderer's carried-over [`render::StreamState`] — so the SAME chat
977    /// keeps watching the SAME (now-continuing) run in the SAME status
978    /// message (one "⏳ Working…" bubble per run, no matter how many times it
979    /// pauses).
980    async fn render_until_settled(
981        &self,
982        key: &str,
983        platform: Arc<dyn Platform>,
984        reply_ctx: ReplyCtx,
985        session_id: &str,
986        mut rx: broadcast::Receiver<AgentEvent>,
987    ) {
988        let mut stream_state: Option<Box<render::StreamState>> = None;
989        loop {
990            match render::stream_execution(
991                platform.clone(),
992                reply_ctx.clone(),
993                rx,
994                stream_state.take(),
995            )
996            .await
997            {
998                render::RunOutcome::Terminal => return,
999                render::RunOutcome::Paused {
1000                    ask,
1001                    stream_state: paused_state,
1002                } => {
1003                    stream_state = paused_state;
1004                    let caps = platform.capabilities();
1005                    let parked =
1006                        ParkedAsk::new(approvals::new_nonce(), session_id.to_string(), &ask);
1007
1008                    if let Err(error) =
1009                        approvals::render_ask(&platform, &reply_ctx, &parked, caps.buttons).await
1010                    {
1011                        tracing::warn!("connect: failed to render pending ask: {error}");
1012                    }
1013
1014                    let (ask_tx, mut ask_rx) = mpsc::channel(1);
1015                    {
1016                        let mut guard = self.chat_state.lock().await;
1017                        let state = guard.entry(key.to_string()).or_default();
1018                        state.pending_ask = Some(parked);
1019                        state.ask_resolution = Some(ask_tx);
1020                    }
1021
1022                    match ask_rx.recv().await {
1023                        Some(AskResolution::Answer(answer)) => {
1024                            match self.responder.respond_and_resume(session_id, answer).await {
1025                                Ok(RespondAndResumeOutcome::Resumed(new_rx)) => {
1026                                    rx = new_rx;
1027                                    continue;
1028                                }
1029                                Ok(RespondAndResumeOutcome::NotResumed(reason)) => {
1030                                    reply_text(&platform, &reply_ctx, format!("({reason})")).await;
1031                                    return;
1032                                }
1033                                Err(error) => {
1034                                    reply_text(
1035                                        &platform,
1036                                        &reply_ctx,
1037                                        format!("Failed to record your answer: {error}"),
1038                                    )
1039                                    .await;
1040                                    return;
1041                                }
1042                            }
1043                        }
1044                        Some(AskResolution::Invalidated) | None => {
1045                            // Already cleared by the invalidator in the
1046                            // common case; clear defensively so a stale entry
1047                            // never lingers if the sender was dropped instead
1048                            // (e.g. a bug elsewhere) rather than sending
1049                            // `Invalidated` explicitly.
1050                            self.clear_pending_ask(key).await;
1051                            return;
1052                        }
1053                    }
1054                }
1055            }
1056        }
1057    }
1058}
1059
1060/// Writes `bytes` to `path` atomically: temp file in the same directory,
1061/// fsync, rename over the target. Mirrors
1062/// `handlers::settings::bamboo_config::config_endpoints::common::atomic_write`
1063/// (private there) so a crash mid-write leaves the old session map intact.
1064async fn atomic_write(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
1065    if let Some(parent) = path.parent() {
1066        tokio::fs::create_dir_all(parent).await?;
1067    }
1068    let tmp = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4()));
1069    {
1070        let mut file = tokio::fs::File::create(&tmp).await?;
1071        tokio::io::AsyncWriteExt::write_all(&mut file, bytes).await?;
1072        file.sync_all().await?;
1073    }
1074    tokio::fs::rename(&tmp, path).await?;
1075    if let Some(parent) = path.parent() {
1076        if let Ok(dir) = tokio::fs::File::open(parent).await {
1077            let _ = dir.sync_all().await;
1078        }
1079    }
1080    Ok(())
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085    use super::*;
1086    use crate::app_state::AppState;
1087    use crate::tools::ToolSurface;
1088    use std::time::Duration;
1089    use tokio::sync::Mutex as TokioMutex;
1090
1091    /// mpsc-backed fake `Platform` (per issue #452's test spec): records every
1092    /// `reply()`/`edit()`/`answer_callback()` call instead of talking to a
1093    /// real IM API. `capabilities` is configurable (issue #458 tests need
1094    /// buttons+edit_message; the original #452 tests want the all-`false`
1095    /// default).
1096    struct FakePlatform {
1097        label: String,
1098        capabilities: super::super::platform::Capabilities,
1099        sent: TokioMutex<Vec<String>>,
1100        sent_messages: TokioMutex<Vec<OutboundMessage>>,
1101        edits: TokioMutex<Vec<String>>,
1102        answered_callbacks: TokioMutex<Vec<(String, Option<String>)>>,
1103    }
1104
1105    impl FakePlatform {
1106        fn new(label: &str) -> Arc<Self> {
1107            Self::with_capabilities(label, Default::default())
1108        }
1109
1110        fn with_capabilities(
1111            label: &str,
1112            capabilities: super::super::platform::Capabilities,
1113        ) -> Arc<Self> {
1114            Arc::new(Self {
1115                label: label.to_string(),
1116                capabilities,
1117                sent: TokioMutex::new(Vec::new()),
1118                sent_messages: TokioMutex::new(Vec::new()),
1119                edits: TokioMutex::new(Vec::new()),
1120                answered_callbacks: TokioMutex::new(Vec::new()),
1121            })
1122        }
1123
1124        async fn sent_texts(&self) -> Vec<String> {
1125            self.sent.lock().await.clone()
1126        }
1127    }
1128
1129    #[async_trait::async_trait]
1130    impl Platform for FakePlatform {
1131        fn name(&self) -> &str {
1132            &self.label
1133        }
1134        fn capabilities(&self) -> super::super::platform::Capabilities {
1135            self.capabilities
1136        }
1137        async fn start(
1138            &self,
1139            _inbound: tokio::sync::mpsc::Sender<super::super::platform::Inbound>,
1140        ) -> super::super::platform::PlatformResult<()> {
1141            Ok(())
1142        }
1143        async fn reply(
1144            &self,
1145            _ctx: &ReplyCtx,
1146            msg: OutboundMessage,
1147        ) -> super::super::platform::PlatformResult<super::super::platform::MessageRef> {
1148            self.sent.lock().await.push(msg.text.clone());
1149            self.sent_messages.lock().await.push(msg);
1150            Ok(super::super::platform::MessageRef(serde_json::Value::Null))
1151        }
1152        async fn edit(
1153            &self,
1154            _msg_ref: &super::super::platform::MessageRef,
1155            new: OutboundMessage,
1156        ) -> super::super::platform::PlatformResult<()> {
1157            self.edits.lock().await.push(new.text);
1158            Ok(())
1159        }
1160        async fn answer_callback(
1161            &self,
1162            callback_query_id: &str,
1163            text: Option<&str>,
1164        ) -> super::super::platform::PlatformResult<()> {
1165            self.answered_callbacks
1166                .lock()
1167                .await
1168                .push((callback_query_id.to_string(), text.map(str::to_string)));
1169            Ok(())
1170        }
1171        async fn stop(&self) -> super::super::platform::PlatformResult<()> {
1172            Ok(())
1173        }
1174    }
1175
1176    /// Fake [`Responder`] (issue #458: "tests inject a fake instead of full
1177    /// AppState"): records every submitted answer and hands back a
1178    /// broadcast receiver subscribed to a test-controlled sender, so a test
1179    /// can drive the "resumed run" by sending events directly.
1180    struct FakeResponder {
1181        calls: TokioMutex<Vec<(String, String)>>,
1182        resume_sender: broadcast::Sender<AgentEvent>,
1183        fail_with: Option<String>,
1184    }
1185
1186    impl FakeResponder {
1187        fn new(resume_sender: broadcast::Sender<AgentEvent>) -> Arc<Self> {
1188            Arc::new(Self {
1189                calls: TokioMutex::new(Vec::new()),
1190                resume_sender,
1191                fail_with: None,
1192            })
1193        }
1194
1195        fn failing(resume_sender: broadcast::Sender<AgentEvent>, reason: &str) -> Arc<Self> {
1196            Arc::new(Self {
1197                calls: TokioMutex::new(Vec::new()),
1198                resume_sender,
1199                fail_with: Some(reason.to_string()),
1200            })
1201        }
1202    }
1203
1204    #[async_trait::async_trait]
1205    impl Responder for FakeResponder {
1206        async fn respond_and_resume(
1207            &self,
1208            session_id: &str,
1209            answer: String,
1210        ) -> Result<RespondAndResumeOutcome, super::super::approvals::ResponderError> {
1211            self.calls
1212                .lock()
1213                .await
1214                .push((session_id.to_string(), answer));
1215            if let Some(reason) = &self.fail_with {
1216                return Err(super::super::approvals::ResponderError::Other(
1217                    reason.clone(),
1218                ));
1219            }
1220            Ok(RespondAndResumeOutcome::Resumed(
1221                self.resume_sender.subscribe(),
1222            ))
1223        }
1224    }
1225
1226    /// Polls `bridge`'s internal chat state until `key` has a parked ask (or
1227    /// panics past a 5s deadline) — used to synchronize with
1228    /// `render_until_settled`'s pause branch, which parks the ask
1229    /// asynchronously.
1230    async fn wait_for_parked_ask(bridge: &ConnectBridge, key: &str) -> ParkedAsk {
1231        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1232        loop {
1233            if let Some(ask) = bridge
1234                .chat_state
1235                .lock()
1236                .await
1237                .get(key)
1238                .and_then(|state| state.pending_ask.clone())
1239            {
1240                return ask;
1241            }
1242            assert!(
1243                tokio::time::Instant::now() < deadline,
1244                "ask was never parked for {key}"
1245            );
1246            tokio::time::sleep(Duration::from_millis(10)).await;
1247        }
1248    }
1249
1250    /// Polls until `responder` has recorded at least `count` calls (or panics
1251    /// past a 5s deadline) — used to synchronize with
1252    /// `FakeResponder::subscribe` actually happening inside
1253    /// `render_until_settled`'s spawned task BEFORE a test sends an event on
1254    /// the "resumed" broadcast channel (`broadcast::Sender::send` errors with
1255    /// zero live subscribers).
1256    async fn wait_for_responder_calls(responder: &FakeResponder, count: usize) {
1257        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1258        loop {
1259            if responder.calls.lock().await.len() >= count {
1260                return;
1261            }
1262            assert!(
1263                tokio::time::Instant::now() < deadline,
1264                "responder never reached {count} call(s)"
1265            );
1266            tokio::time::sleep(Duration::from_millis(10)).await;
1267        }
1268    }
1269
1270    async fn test_context() -> (ConnectContext, tempfile::TempDir) {
1271        let dir = tempfile::tempdir().unwrap();
1272        // A full `AppState` gives the bridge a real `Agent`/tools/session-repo
1273        // without a network call. A model IS configured (so `run_prompt`'s
1274        // "no model configured" guard doesn't short-circuit before a session
1275        // is even created) but with no api_key, so the provider falls back to
1276        // `UnconfiguredProvider`: execution fails fast with an
1277        // `AgentEvent::Error` (non-retryable auth error), which is exactly
1278        // what these tests need to observe a run reach a terminal state
1279        // quickly without any real network access.
1280        let state = AppState::new(dir.path().to_path_buf())
1281            .await
1282            .expect("app state");
1283        {
1284            let mut cfg = state.config.write().await;
1285            cfg.provider = "openai".to_string();
1286            cfg.providers_mut().openai = Some(bamboo_config::OpenAIConfig {
1287                api_key: String::new(),
1288                api_key_from_env: false,
1289                api_key_encrypted: None,
1290                credential_ref: None,
1291                base_url: None,
1292                model: Some("gpt-4o-mini".to_string()),
1293                fast_model: None,
1294                vision_model: None,
1295                reasoning_effort: None,
1296                responses_only_models: Vec::new(),
1297                request_overrides: None,
1298                extra: Default::default(),
1299            });
1300        }
1301        let ctx = ConnectContext {
1302            agent: state.agent.clone(),
1303            tools: state.tools_for(ToolSurface::Root),
1304            session_repo: state.session_repo.clone(),
1305            agent_runners: state.agent_runners.clone(),
1306            session_event_senders: state.session_event_senders.clone(),
1307            account_feed_inbox: None,
1308            app_data_dir: Some(state.app_data_dir.clone()),
1309            config: state.config.clone(),
1310            provider_registry: state.provider_registry.clone(),
1311            project_store: state.project_store.clone(),
1312            workspace_resolver: state.workspace_resolver.clone(),
1313            project_ids_by_platform: Arc::new(HashMap::new()),
1314            permission_checker: state.permission_checker.clone(),
1315        };
1316        (ctx, dir)
1317    }
1318
1319    fn inbound(chat_id: &str, user_id: &str, message_id: &str, text: &str) -> InboundMessage {
1320        InboundMessage {
1321            platform: "fake".to_string(),
1322            chat_id: chat_id.to_string(),
1323            user_id: user_id.to_string(),
1324            message_id: message_id.to_string(),
1325            sent_at: Utc::now(),
1326            text: text.to_string(),
1327            reply_ctx: ReplyCtx(serde_json::json!({ "chat_id": chat_id })),
1328        }
1329    }
1330
1331    fn key_for(chat_id: &str, user_id: &str) -> String {
1332        SessionKey {
1333            platform: "fake".to_string(),
1334            chat_id: chat_id.to_string(),
1335            user_id: user_id.to_string(),
1336        }
1337        .as_string()
1338    }
1339
1340    #[test]
1341    fn session_key_formats_as_platform_chat_user() {
1342        let key = SessionKey {
1343            platform: "telegram".to_string(),
1344            chat_id: "42".to_string(),
1345            user_id: "7".to_string(),
1346        };
1347        assert_eq!(key.as_string(), "telegram:42:7");
1348    }
1349
1350    // ---- Issue #454 follow-up: bounded dedup set ----
1351
1352    #[test]
1353    fn bounded_seen_set_evicts_the_oldest_entry_once_over_capacity() {
1354        let mut set = BoundedSeenSet::new(2);
1355        assert!(set.insert("a".to_string()));
1356        assert!(set.insert("b".to_string()));
1357        assert_eq!(set.len(), 2);
1358
1359        // Pushes past capacity: "a" (oldest) is evicted; "b" and "c" remain
1360        // tracked as seen.
1361        assert!(set.insert("c".to_string()));
1362        assert_eq!(set.len(), 2);
1363        assert!(!set.insert("b".to_string()), "b must still be tracked");
1364        assert!(!set.insert("c".to_string()), "c must still be tracked");
1365
1366        // "a" was evicted — re-inserting it is treated as new, not a
1367        // duplicate (which in turn evicts "b", the now-oldest entry).
1368        assert!(set.insert("a".to_string()));
1369        assert_eq!(set.len(), 2);
1370    }
1371
1372    #[test]
1373    fn bounded_seen_set_still_dedups_within_capacity() {
1374        let mut set = BoundedSeenSet::new(10);
1375        assert!(set.insert("x".to_string()));
1376        assert!(!set.insert("x".to_string()), "duplicate must be rejected");
1377        assert_eq!(set.len(), 1);
1378    }
1379
1380    #[test]
1381    fn bounded_seen_set_never_grows_past_capacity() {
1382        let mut set = BoundedSeenSet::new(50);
1383        for i in 0..5_000 {
1384            set.insert(format!("msg-{i}"));
1385        }
1386        assert_eq!(set.len(), 50);
1387    }
1388
1389    #[tokio::test]
1390    async fn connect_session_creation_revalidates_project_after_startup() {
1391        let (mut ctx, _dir) = test_context().await;
1392        let project = ctx.project_store.create("Connect", None).unwrap();
1393        let mut projects = HashMap::new();
1394        projects.insert("fake".to_string(), project.id.clone());
1395        ctx.project_ids_by_platform = Arc::new(projects);
1396        ctx.project_store
1397            .archive(&project.id, project.revision)
1398            .unwrap();
1399        let resolved = {
1400            let config = ctx.config.read().await.clone();
1401            resolve_connect_run_config(&config, &ctx.provider_registry)
1402        };
1403        let bridge = ConnectBridge::new(ctx, None);
1404
1405        let error = bridge
1406            .create_and_register_session("fake:chat:user", &resolved)
1407            .await
1408            .expect_err("archived Project must reject Connect session creation");
1409        assert!(error.contains("archived"));
1410        assert!(
1411            bridge.session_id_for_key("fake:chat:user").await.is_none(),
1412            "failed validation must not publish a chat-to-session mapping"
1413        );
1414    }
1415
1416    #[test]
1417    fn connect_publication_uses_the_validating_instance_workspace_root() {
1418        let instance_root = tempfile::tempdir().expect("instance workspace root");
1419        let relocated = instance_root.path().join("connect-workspace");
1420        let resolver = bamboo_agent_core::workspace_state::WorkspaceResolver::new(|| None, {
1421            let root = instance_root.path().to_path_buf();
1422            move || bamboo_agent_core::workspace_state::WorkspaceRootConfig {
1423                root: root.clone(),
1424                confine: true,
1425            }
1426        });
1427
1428        let session = create_connect_session(
1429            "fake:chat:user",
1430            "model",
1431            "system",
1432            "base",
1433            Some(relocated.to_string_lossy().as_ref()),
1434            None,
1435            None,
1436            &resolver,
1437        );
1438
1439        assert_eq!(
1440            session.workspace_path_meta().as_deref(),
1441            Some(relocated.to_string_lossy().as_ref())
1442        );
1443        assert!(
1444            relocated.is_dir(),
1445            "the AppState resolver must materialize its own validated target"
1446        );
1447    }
1448
1449    #[tokio::test]
1450    async fn connect_session_uses_assigned_project_path_when_workspace_is_omitted() {
1451        let (mut ctx, _dir) = test_context().await;
1452        let project_path = tempfile::tempdir().expect("Project path");
1453        let project = ctx
1454            .project_store
1455            .create_with_project_path(
1456                "Connect",
1457                None,
1458                project_path.path().to_string_lossy(),
1459                Vec::new(),
1460            )
1461            .unwrap();
1462        let mut projects = HashMap::new();
1463        projects.insert("fake".to_string(), project.id.clone());
1464        ctx.project_ids_by_platform = Arc::new(projects);
1465        let mut resolved = {
1466            let config = ctx.config.read().await.clone();
1467            resolve_connect_run_config(&config, &ctx.provider_registry)
1468        };
1469        let foreign_global = tempfile::tempdir().expect("foreign global workspace");
1470        resolved.workspace_path = Some(foreign_global.path().to_string_lossy().into_owned());
1471        let bridge = ConnectBridge::new(ctx, None);
1472
1473        let session = bridge
1474            .create_and_register_session("fake:chat:user", &resolved)
1475            .await
1476            .expect("configured Project must provide the Connect workspace");
1477        assert_eq!(
1478            session.project_id_meta().as_deref(),
1479            Some(project.id.as_str())
1480        );
1481        assert_eq!(
1482            session.workspace_path_meta().as_deref(),
1483            project.project_path.as_deref()
1484        );
1485        assert_eq!(
1486            session
1487                .metadata
1488                .get(bamboo_engine::project_context::WORKSPACE_SOURCE_METADATA_KEY)
1489                .map(String::as_str),
1490            Some(bamboo_engine::project_context::WorkspaceSource::ProjectDefault.as_str())
1491        );
1492        let system_prompt = session
1493            .messages
1494            .iter()
1495            .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
1496            .expect("Connect system prompt");
1497        assert!(system_prompt
1498            .content
1499            .contains("Workspace source: project_default"));
1500    }
1501
1502    #[tokio::test]
1503    async fn connect_session_ignores_global_workspace_owned_by_another_project() {
1504        let (mut ctx, _dir) = test_context().await;
1505        let project_path = tempfile::tempdir().expect("Connect Project path");
1506        let workspace = tempfile::tempdir().expect("foreign global workspace");
1507        let connect_project = ctx
1508            .project_store
1509            .create_with_project_path(
1510                "Connect",
1511                None,
1512                project_path.path().to_string_lossy(),
1513                Vec::new(),
1514            )
1515            .unwrap();
1516        let _workspace_owner = ctx
1517            .project_store
1518            .create_with_bindings(
1519                "Workspace Owner",
1520                None,
1521                vec![bamboo_domain::WorkspaceBinding {
1522                    path: workspace.path().to_string_lossy().into_owned(),
1523                    label: None,
1524                    git_common_dir: None,
1525                }],
1526            )
1527            .expect("Workspace Owner");
1528        let mut projects = HashMap::new();
1529        projects.insert("fake".to_string(), connect_project.id.clone());
1530        ctx.project_ids_by_platform = Arc::new(projects);
1531        let mut resolved = {
1532            let config = ctx.config.read().await.clone();
1533            resolve_connect_run_config(&config, &ctx.provider_registry)
1534        };
1535        resolved.workspace_path = Some(workspace.path().to_string_lossy().into_owned());
1536        let bridge = ConnectBridge::new(ctx, None);
1537
1538        let session = bridge
1539            .create_and_register_session("fake:chat:user", &resolved)
1540            .await
1541            .expect("assigned Connect must ignore the foreign global default");
1542        assert_eq!(
1543            session.project_id_meta().as_deref(),
1544            Some(connect_project.id.as_str())
1545        );
1546        assert_eq!(
1547            session.workspace_path_meta().as_deref(),
1548            connect_project.project_path.as_deref()
1549        );
1550    }
1551
1552    #[tokio::test]
1553    async fn allow_from_denies_users_not_in_the_list() {
1554        let (ctx, _dir) = test_context().await;
1555        let bridge = Arc::new(ConnectBridge::new(ctx, None));
1556        let platform = FakePlatform::new("fake");
1557
1558        ConnectBridge::handle_inbound(
1559            bridge.clone(),
1560            platform.clone(),
1561            vec!["allowed-user".to_string()],
1562            inbound("chat1", "someone-else", "1", "hello"),
1563        )
1564        .await;
1565
1566        tokio::time::sleep(Duration::from_millis(50)).await;
1567        assert!(platform.sent_texts().await.is_empty());
1568        assert!(bridge
1569            .session_id_for_key(&key_for("chat1", "someone-else"))
1570            .await
1571            .is_none());
1572    }
1573
1574    #[tokio::test]
1575    async fn dedup_drops_repeated_message_ids() {
1576        let (ctx, _dir) = test_context().await;
1577        let bridge = Arc::new(ConnectBridge::new(ctx, None));
1578        let platform = FakePlatform::new("fake");
1579        let allow = vec!["u1".to_string()];
1580
1581        // `/status` replies synchronously with no engine/queue involved, so a
1582        // duplicate delivery of the SAME message_id must yield exactly one
1583        // reply, not two.
1584        ConnectBridge::handle_inbound(
1585            bridge.clone(),
1586            platform.clone(),
1587            allow.clone(),
1588            inbound("chat1", "u1", "dup-1", "/status"),
1589        )
1590        .await;
1591        ConnectBridge::handle_inbound(
1592            bridge.clone(),
1593            platform.clone(),
1594            allow,
1595            inbound("chat1", "u1", "dup-1", "/status"),
1596        )
1597        .await;
1598
1599        let sent = platform.sent_texts().await;
1600        assert_eq!(
1601            sent.len(),
1602            1,
1603            "duplicate message_id must be dropped: {sent:?}"
1604        );
1605    }
1606
1607    #[tokio::test]
1608    async fn older_than_process_start_messages_are_dropped() {
1609        let (ctx, _dir) = test_context().await;
1610        let bridge = Arc::new(ConnectBridge::new(ctx, None));
1611        let platform = FakePlatform::new("fake");
1612        let mut msg = inbound("chat1", "u1", "1", "/status");
1613        msg.sent_at = bridge.process_start - chrono::Duration::seconds(5);
1614
1615        ConnectBridge::handle_inbound(
1616            bridge.clone(),
1617            platform.clone(),
1618            vec!["u1".to_string()],
1619            msg,
1620        )
1621        .await;
1622
1623        assert!(platform.sent_texts().await.is_empty());
1624    }
1625
1626    #[tokio::test]
1627    async fn status_command_reports_idle_with_no_session_yet() {
1628        let (ctx, _dir) = test_context().await;
1629        let bridge = Arc::new(ConnectBridge::new(ctx, None));
1630        let platform = FakePlatform::new("fake");
1631
1632        ConnectBridge::handle_inbound(
1633            bridge.clone(),
1634            platform.clone(),
1635            vec!["u1".to_string()],
1636            inbound("chat1", "u1", "1", "/status"),
1637        )
1638        .await;
1639
1640        let sent = platform.sent_texts().await;
1641        assert_eq!(sent.len(), 1);
1642        assert!(sent[0].contains("No session yet"), "got: {:?}", sent[0]);
1643    }
1644
1645    #[tokio::test]
1646    async fn stop_with_nothing_running_replies_nothing_running() {
1647        let (ctx, _dir) = test_context().await;
1648        let bridge = Arc::new(ConnectBridge::new(ctx, None));
1649        let platform = FakePlatform::new("fake");
1650
1651        ConnectBridge::handle_inbound(
1652            bridge.clone(),
1653            platform.clone(),
1654            vec!["u1".to_string()],
1655            inbound("chat1", "u1", "1", "/stop"),
1656        )
1657        .await;
1658
1659        assert_eq!(
1660            platform.sent_texts().await,
1661            vec!["Nothing is running.".to_string()]
1662        );
1663    }
1664
1665    #[tokio::test]
1666    async fn prompt_creates_a_session_and_maps_it_to_the_chat_key() {
1667        let (ctx, _dir) = test_context().await;
1668        let bridge = Arc::new(ConnectBridge::new(ctx, None));
1669        let platform = FakePlatform::new("fake");
1670        let key = key_for("chat1", "u1");
1671
1672        ConnectBridge::handle_inbound(
1673            bridge.clone(),
1674            platform.clone(),
1675            vec!["u1".to_string()],
1676            inbound("chat1", "u1", "1", "hello there"),
1677        )
1678        .await;
1679
1680        let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
1681        loop {
1682            if bridge.session_id_for_key(&key).await.is_some() {
1683                break;
1684            }
1685            assert!(
1686                tokio::time::Instant::now() < deadline,
1687                "session was never created for the chat key"
1688            );
1689            tokio::time::sleep(Duration::from_millis(20)).await;
1690        }
1691    }
1692
1693    #[tokio::test]
1694    async fn new_command_rotates_the_session_mapping() {
1695        let (ctx, _dir) = test_context().await;
1696        let bridge = Arc::new(ConnectBridge::new(ctx, None));
1697        let platform = FakePlatform::new("fake");
1698        let key = key_for("chat1", "u1");
1699
1700        bridge
1701            .set_session_id_for_key(&key, "pre-existing-session")
1702            .await;
1703        assert_eq!(
1704            bridge.session_id_for_key(&key).await.as_deref(),
1705            Some("pre-existing-session")
1706        );
1707
1708        ConnectBridge::handle_inbound(
1709            bridge.clone(),
1710            platform.clone(),
1711            vec!["u1".to_string()],
1712            inbound("chat1", "u1", "1", "/new"),
1713        )
1714        .await;
1715
1716        let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
1717        loop {
1718            if bridge.session_id_for_key(&key).await.is_none() {
1719                break;
1720            }
1721            assert!(
1722                tokio::time::Instant::now() < deadline,
1723                "session mapping was never rotated away"
1724            );
1725            tokio::time::sleep(Duration::from_millis(20)).await;
1726        }
1727
1728        let sent = platform.sent_texts().await;
1729        assert!(sent.iter().any(|t| t == "Started a new session."));
1730    }
1731
1732    #[tokio::test]
1733    async fn busy_queue_drains_a_second_message_after_the_first_finishes() {
1734        let (ctx, _dir) = test_context().await;
1735        let bridge = Arc::new(ConnectBridge::new(ctx, None));
1736        let platform = FakePlatform::new("fake");
1737        let allow = vec!["u1".to_string()];
1738        let key = key_for("chat1", "u1");
1739
1740        // Two prompts arriving back-to-back for the SAME chat: the second
1741        // must queue behind the first (busy lock) rather than racing it, and
1742        // the chat must return to idle once both have run.
1743        ConnectBridge::handle_inbound(
1744            bridge.clone(),
1745            platform.clone(),
1746            allow.clone(),
1747            inbound("chat1", "u1", "1", "first"),
1748        )
1749        .await;
1750        ConnectBridge::handle_inbound(
1751            bridge.clone(),
1752            platform.clone(),
1753            allow,
1754            inbound("chat1", "u1", "2", "second"),
1755        )
1756        .await;
1757
1758        let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
1759        loop {
1760            let idle = {
1761                !bridge
1762                    .chat_state
1763                    .lock()
1764                    .await
1765                    .get(&key)
1766                    .map(|state| state.busy)
1767                    .unwrap_or(true)
1768            };
1769            if idle {
1770                break;
1771            }
1772            assert!(
1773                tokio::time::Instant::now() < deadline,
1774                "chat never drained back to idle"
1775            );
1776            tokio::time::sleep(Duration::from_millis(20)).await;
1777        }
1778
1779        assert!(bridge.session_id_for_key(&key).await.is_some());
1780    }
1781
1782    #[tokio::test]
1783    async fn session_map_persists_and_reloads_across_bridge_instances() {
1784        let (ctx, dir) = test_context().await;
1785        let map_path = dir.path().join("connect_sessions.json");
1786        let bridge = ConnectBridge::new(ctx.clone(), Some(map_path.clone()));
1787        bridge.set_session_id_for_key("k1", "sess-1").await;
1788
1789        let bridge2 = ConnectBridge::new(ctx, Some(map_path));
1790        bridge2.load_session_map().await;
1791
1792        assert_eq!(
1793            bridge2.session_id_for_key("k1").await.as_deref(),
1794            Some("sess-1")
1795        );
1796    }
1797
1798    // ---- Issue #458: approval/question relay ----
1799
1800    fn buttons_and_edit_capabilities() -> super::super::platform::Capabilities {
1801        super::super::platform::Capabilities {
1802            buttons: true,
1803            edit_message: true,
1804            images: false,
1805            files: false,
1806        }
1807    }
1808
1809    fn need_clarification_event(
1810        question: &str,
1811        options: Vec<&str>,
1812        allow_custom: bool,
1813    ) -> AgentEvent {
1814        AgentEvent::NeedClarification {
1815            question: question.to_string(),
1816            options: Some(options.into_iter().map(str::to_string).collect()),
1817            tool_call_id: Some("call-1".to_string()),
1818            tool_name: Some("request_permissions".to_string()),
1819            allow_custom,
1820        }
1821    }
1822
1823    #[tokio::test]
1824    async fn paused_run_renders_buttons_with_nonce_and_resolves_via_callback() {
1825        let (ctx, _dir) = test_context().await;
1826        let resume_tx = broadcast::channel::<AgentEvent>(16).0;
1827        let responder = FakeResponder::new(resume_tx.clone());
1828        let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
1829        let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
1830        let key = key_for("chat1", "u1");
1831        let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
1832
1833        let (tx, rx) = broadcast::channel(16);
1834        tx.send(need_clarification_event(
1835            "Approve?",
1836            vec!["Approve", "Deny"],
1837            false,
1838        ))
1839        .unwrap();
1840
1841        let render_handle = {
1842            let bridge = bridge.clone();
1843            let platform = platform.clone();
1844            let reply_ctx = reply_ctx.clone();
1845            let key = key.clone();
1846            tokio::spawn(async move {
1847                bridge
1848                    .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
1849                    .await;
1850            })
1851        };
1852
1853        let parked = wait_for_parked_ask(&bridge, &key).await;
1854        assert_eq!(
1855            parked.options,
1856            vec!["Approve".to_string(), "Deny".to_string()]
1857        );
1858
1859        // Buttons were rendered on the ask message, one per option, callback
1860        // data carrying the nonce (never raw option text/user text).
1861        let sent = platform.sent_messages.lock().await.clone();
1862        let ask_message = sent
1863            .iter()
1864            .find(|message| message.buttons.is_some())
1865            .expect("expected a buttoned ask message");
1866        let buttons = ask_message.buttons.as_ref().unwrap();
1867        assert_eq!(buttons.len(), 2);
1868        assert_eq!(buttons[0][0].callback_data, format!("{}:0", parked.nonce));
1869        assert_eq!(buttons[1][0].callback_data, format!("{}:1", parked.nonce));
1870
1871        let callback = CallbackQuery {
1872            platform: "fake".to_string(),
1873            chat_id: "chat1".to_string(),
1874            user_id: "u1".to_string(),
1875            callback_query_id: "cbq-1".to_string(),
1876            data: format!("{}:0", parked.nonce),
1877            reply_ctx: reply_ctx.clone(),
1878        };
1879        ConnectBridge::handle_callback(
1880            bridge.clone(),
1881            platform.clone(),
1882            vec!["u1".to_string()],
1883            callback,
1884        )
1885        .await;
1886
1887        // Wait for `render_until_settled`'s spawned task to have actually
1888        // called through to the responder (and thus subscribed to
1889        // `resume_tx`) before sending on it — `broadcast::Sender::send`
1890        // errors out with zero live subscribers.
1891        wait_for_responder_calls(&responder, 1).await;
1892
1893        resume_tx
1894            .send(AgentEvent::Complete {
1895                usage: bamboo_agent_core::TokenUsage {
1896                    prompt_tokens: 1,
1897                    completion_tokens: 1,
1898                    total_tokens: 2,
1899                },
1900            })
1901            .unwrap();
1902
1903        tokio::time::timeout(Duration::from_secs(5), render_handle)
1904            .await
1905            .expect("render task must finish")
1906            .unwrap();
1907
1908        assert_eq!(
1909            responder.calls.lock().await.as_slice(),
1910            &[("sess-1".to_string(), "Approve".to_string())]
1911        );
1912        assert_eq!(
1913            platform.answered_callbacks.lock().await.as_slice(),
1914            &[("cbq-1".to_string(), None)]
1915        );
1916        assert!(!bridge.has_pending_ask(&key).await);
1917    }
1918
1919    #[tokio::test]
1920    async fn stale_callback_nonce_is_dropped_and_acked_without_resolving() {
1921        let (ctx, _dir) = test_context().await;
1922        let resume_tx = broadcast::channel::<AgentEvent>(16).0;
1923        let responder = FakeResponder::new(resume_tx.clone());
1924        let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
1925        let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
1926        let key = key_for("chat1", "u1");
1927        let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
1928
1929        let (tx, rx) = broadcast::channel(16);
1930        tx.send(need_clarification_event(
1931            "Approve?",
1932            vec!["Approve", "Deny"],
1933            false,
1934        ))
1935        .unwrap();
1936
1937        let render_handle = {
1938            let bridge = bridge.clone();
1939            let platform = platform.clone();
1940            let reply_ctx = reply_ctx.clone();
1941            let key = key.clone();
1942            tokio::spawn(async move {
1943                bridge
1944                    .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
1945                    .await;
1946            })
1947        };
1948
1949        wait_for_parked_ask(&bridge, &key).await;
1950
1951        let stale_callback = CallbackQuery {
1952            platform: "fake".to_string(),
1953            chat_id: "chat1".to_string(),
1954            user_id: "u1".to_string(),
1955            callback_query_id: "cbq-stale".to_string(),
1956            data: "totally-wrong-nonce:0".to_string(),
1957            reply_ctx: reply_ctx.clone(),
1958        };
1959        ConnectBridge::handle_callback(
1960            bridge.clone(),
1961            platform.clone(),
1962            vec!["u1".to_string()],
1963            stale_callback,
1964        )
1965        .await;
1966
1967        // Acked (Telegram-style — always ack), but with an "expired" style
1968        // note, and NOT forwarded as an answer.
1969        let acked = platform.answered_callbacks.lock().await.clone();
1970        assert_eq!(acked.len(), 1);
1971        assert_eq!(acked[0].0, "cbq-stale");
1972        assert!(acked[0].1.is_some());
1973        assert!(responder.calls.lock().await.is_empty());
1974        // The real ask is still parked — the stale callback never touched it.
1975        assert!(bridge.has_pending_ask(&key).await);
1976
1977        bridge.invalidate_pending_ask(&key).await;
1978        tokio::time::timeout(Duration::from_secs(5), render_handle)
1979            .await
1980            .expect("render task must finish")
1981            .unwrap();
1982    }
1983
1984    #[tokio::test]
1985    async fn text_answer_resolves_an_open_question() {
1986        let (ctx, _dir) = test_context().await;
1987        let resume_tx = broadcast::channel::<AgentEvent>(16).0;
1988        let responder = FakeResponder::new(resume_tx.clone());
1989        let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
1990        let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
1991        let key = key_for("chat1", "u1");
1992        let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
1993
1994        let (tx, rx) = broadcast::channel(16);
1995        tx.send(need_clarification_event(
1996            "Anything else?",
1997            vec!["OK", "Need changes"],
1998            true,
1999        ))
2000        .unwrap();
2001
2002        let render_handle = {
2003            let bridge = bridge.clone();
2004            let platform = platform.clone();
2005            let reply_ctx = reply_ctx.clone();
2006            let key = key.clone();
2007            tokio::spawn(async move {
2008                bridge
2009                    .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
2010                    .await;
2011            })
2012        };
2013
2014        wait_for_parked_ask(&bridge, &key).await;
2015
2016        ConnectBridge::handle_inbound(
2017            bridge.clone(),
2018            platform.clone(),
2019            vec!["u1".to_string()],
2020            inbound("chat1", "u1", "answer-1", "please also add tests"),
2021        )
2022        .await;
2023
2024        // Wait for `render_until_settled`'s spawned task to have actually
2025        // called through to the responder (and thus subscribed to
2026        // `resume_tx`) before sending on it — `broadcast::Sender::send`
2027        // errors out with zero live subscribers.
2028        wait_for_responder_calls(&responder, 1).await;
2029
2030        resume_tx
2031            .send(AgentEvent::Complete {
2032                usage: bamboo_agent_core::TokenUsage {
2033                    prompt_tokens: 1,
2034                    completion_tokens: 1,
2035                    total_tokens: 2,
2036                },
2037            })
2038            .unwrap();
2039
2040        tokio::time::timeout(Duration::from_secs(5), render_handle)
2041            .await
2042            .expect("render task must finish")
2043            .unwrap();
2044
2045        assert_eq!(
2046            responder.calls.lock().await.as_slice(),
2047            &[("sess-1".to_string(), "please also add tests".to_string())]
2048        );
2049    }
2050
2051    #[tokio::test]
2052    async fn binary_ask_keyword_mapping_resolves_via_text() {
2053        let (ctx, _dir) = test_context().await;
2054        let resume_tx = broadcast::channel::<AgentEvent>(16).0;
2055        let responder = FakeResponder::new(resume_tx.clone());
2056        let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
2057        let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
2058        let key = key_for("chat1", "u1");
2059        let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
2060
2061        let (tx, rx) = broadcast::channel(16);
2062        tx.send(need_clarification_event(
2063            "Approve?",
2064            vec!["Approve", "Deny"],
2065            false,
2066        ))
2067        .unwrap();
2068
2069        let render_handle = {
2070            let bridge = bridge.clone();
2071            let platform = platform.clone();
2072            let reply_ctx = reply_ctx.clone();
2073            let key = key.clone();
2074            tokio::spawn(async move {
2075                bridge
2076                    .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
2077                    .await;
2078            })
2079        };
2080
2081        wait_for_parked_ask(&bridge, &key).await;
2082
2083        // "允许" (allow) is a first-affirmative keyword — resolves to
2084        // whichever option reads as approval, NEVER the raw keyword text.
2085        ConnectBridge::handle_inbound(
2086            bridge.clone(),
2087            platform.clone(),
2088            vec!["u1".to_string()],
2089            inbound("chat1", "u1", "answer-1", "允许"),
2090        )
2091        .await;
2092
2093        // Wait for `render_until_settled`'s spawned task to have actually
2094        // called through to the responder (and thus subscribed to
2095        // `resume_tx`) before sending on it — `broadcast::Sender::send`
2096        // errors out with zero live subscribers.
2097        wait_for_responder_calls(&responder, 1).await;
2098
2099        resume_tx
2100            .send(AgentEvent::Complete {
2101                usage: bamboo_agent_core::TokenUsage {
2102                    prompt_tokens: 1,
2103                    completion_tokens: 1,
2104                    total_tokens: 2,
2105                },
2106            })
2107            .unwrap();
2108
2109        tokio::time::timeout(Duration::from_secs(5), render_handle)
2110            .await
2111            .expect("render task must finish")
2112            .unwrap();
2113
2114        assert_eq!(
2115            responder.calls.lock().await.as_slice(),
2116            &[("sess-1".to_string(), "Approve".to_string())]
2117        );
2118    }
2119
2120    #[tokio::test]
2121    async fn new_command_invalidates_a_parked_ask_instead_of_answering_it() {
2122        let (ctx, _dir) = test_context().await;
2123        let resume_tx = broadcast::channel::<AgentEvent>(16).0;
2124        let responder = FakeResponder::new(resume_tx.clone());
2125        let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
2126        let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
2127        let key = key_for("chat1", "u1");
2128        let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
2129
2130        bridge.set_session_id_for_key(&key, "sess-1").await;
2131
2132        let (tx, rx) = broadcast::channel(16);
2133        tx.send(need_clarification_event(
2134            "Approve?",
2135            vec!["Approve", "Deny"],
2136            false,
2137        ))
2138        .unwrap();
2139
2140        let render_handle = {
2141            let bridge = bridge.clone();
2142            let platform = platform.clone();
2143            let reply_ctx = reply_ctx.clone();
2144            let key = key.clone();
2145            tokio::spawn(async move {
2146                bridge
2147                    .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
2148                    .await;
2149            })
2150        };
2151
2152        wait_for_parked_ask(&bridge, &key).await;
2153
2154        ConnectBridge::handle_inbound(
2155            bridge.clone(),
2156            platform.clone(),
2157            vec!["u1".to_string()],
2158            inbound("chat1", "u1", "new-1", "/new"),
2159        )
2160        .await;
2161
2162        tokio::time::timeout(Duration::from_secs(5), render_handle)
2163            .await
2164            .expect("render task must finish")
2165            .unwrap();
2166
2167        assert!(responder.calls.lock().await.is_empty());
2168        assert!(!bridge.has_pending_ask(&key).await);
2169        // Session mapping was rotated away, same as an ordinary `/new`.
2170        assert!(bridge.session_id_for_key(&key).await.is_none());
2171    }
2172
2173    #[tokio::test]
2174    async fn respond_error_reports_to_the_chat_without_hanging() {
2175        let (ctx, _dir) = test_context().await;
2176        let resume_tx = broadcast::channel::<AgentEvent>(16).0;
2177        let responder = FakeResponder::failing(resume_tx.clone(), "boom");
2178        let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
2179        let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
2180        let key = key_for("chat1", "u1");
2181        let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
2182
2183        let (tx, rx) = broadcast::channel(16);
2184        tx.send(need_clarification_event(
2185            "Approve?",
2186            vec!["Approve", "Deny"],
2187            false,
2188        ))
2189        .unwrap();
2190
2191        let render_handle = {
2192            let bridge = bridge.clone();
2193            let platform = platform.clone();
2194            let reply_ctx = reply_ctx.clone();
2195            let key = key.clone();
2196            tokio::spawn(async move {
2197                bridge
2198                    .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
2199                    .await;
2200            })
2201        };
2202
2203        let parked = wait_for_parked_ask(&bridge, &key).await;
2204        let callback = CallbackQuery {
2205            platform: "fake".to_string(),
2206            chat_id: "chat1".to_string(),
2207            user_id: "u1".to_string(),
2208            callback_query_id: "cbq-1".to_string(),
2209            data: format!("{}:0", parked.nonce),
2210            reply_ctx: reply_ctx.clone(),
2211        };
2212        ConnectBridge::handle_callback(
2213            bridge.clone(),
2214            platform.clone(),
2215            vec!["u1".to_string()],
2216            callback,
2217        )
2218        .await;
2219
2220        tokio::time::timeout(Duration::from_secs(5), render_handle)
2221            .await
2222            .expect("render task must finish even when the responder errors")
2223            .unwrap();
2224
2225        let sent = platform.sent_texts().await;
2226        assert!(
2227            sent.iter()
2228                .any(|text| text.contains("Failed to record your answer")),
2229            "expected an error report, got: {sent:?}"
2230        );
2231    }
2232
2233    /// PR #459 review must-fix: a run that pauses (and resumes) MULTIPLE
2234    /// times must keep exactly ONE status message — every resumed segment
2235    /// keeps EDITING the original "⏳ Working…" bubble via the carried
2236    /// `render::StreamState`, never opening a new one.
2237    #[tokio::test]
2238    async fn run_that_pauses_twice_keeps_a_single_status_message() {
2239        let (ctx, _dir) = test_context().await;
2240        let resume_tx = broadcast::channel::<AgentEvent>(16).0;
2241        let responder = FakeResponder::new(resume_tx.clone());
2242        let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
2243        let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
2244        let key = key_for("chat1", "u1");
2245        let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
2246
2247        // Segment 1: text + pause #1.
2248        let (tx, rx) = broadcast::channel(16);
2249        tx.send(AgentEvent::Token {
2250            content: "segment one ".to_string(),
2251        })
2252        .unwrap();
2253        tx.send(need_clarification_event(
2254            "First?",
2255            vec!["Approve", "Deny"],
2256            false,
2257        ))
2258        .unwrap();
2259
2260        let render_handle = {
2261            let bridge = bridge.clone();
2262            let platform = platform.clone();
2263            let reply_ctx = reply_ctx.clone();
2264            let key = key.clone();
2265            tokio::spawn(async move {
2266                bridge
2267                    .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
2268                    .await;
2269            })
2270        };
2271
2272        let first_ask = wait_for_parked_ask(&bridge, &key).await;
2273        ConnectBridge::handle_inbound(
2274            bridge.clone(),
2275            platform.clone(),
2276            vec!["u1".to_string()],
2277            inbound("chat1", "u1", "a1", "1"),
2278        )
2279        .await;
2280        wait_for_responder_calls(&responder, 1).await;
2281
2282        // Segment 2 (first resume): text + pause #2.
2283        resume_tx
2284            .send(AgentEvent::Token {
2285                content: "segment two ".to_string(),
2286            })
2287            .unwrap();
2288        resume_tx
2289            .send(need_clarification_event(
2290                "Second?",
2291                vec!["Approve", "Deny"],
2292                false,
2293            ))
2294            .unwrap();
2295
2296        // Wait until the SECOND ask is parked (a fresh nonce distinguishes
2297        // it from the first, already-resolved one).
2298        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
2299        loop {
2300            let parked = {
2301                bridge
2302                    .chat_state
2303                    .lock()
2304                    .await
2305                    .get(&key)
2306                    .and_then(|state| state.pending_ask.clone())
2307            };
2308            if let Some(ask) = parked {
2309                if ask.nonce != first_ask.nonce {
2310                    break;
2311                }
2312            }
2313            assert!(
2314                tokio::time::Instant::now() < deadline,
2315                "second ask was never parked"
2316            );
2317            tokio::time::sleep(Duration::from_millis(10)).await;
2318        }
2319
2320        ConnectBridge::handle_inbound(
2321            bridge.clone(),
2322            platform.clone(),
2323            vec!["u1".to_string()],
2324            inbound("chat1", "u1", "a2", "1"),
2325        )
2326        .await;
2327        wait_for_responder_calls(&responder, 2).await;
2328
2329        // Segment 3 (second resume): text + Complete.
2330        resume_tx
2331            .send(AgentEvent::Token {
2332                content: "segment three".to_string(),
2333            })
2334            .unwrap();
2335        resume_tx
2336            .send(AgentEvent::Complete {
2337                usage: bamboo_agent_core::TokenUsage {
2338                    prompt_tokens: 1,
2339                    completion_tokens: 1,
2340                    total_tokens: 2,
2341                },
2342            })
2343            .unwrap();
2344
2345        tokio::time::timeout(Duration::from_secs(5), render_handle)
2346            .await
2347            .expect("render task must finish")
2348            .unwrap();
2349
2350        // Exactly ONE "⏳ Working…" status message across the whole
2351        // twice-paused run; the other sends are the two ask messages.
2352        let sent = platform.sent_texts().await;
2353        let status_count = sent.iter().filter(|text| text.contains('⏳')).count();
2354        assert_eq!(
2355            status_count, 1,
2356            "expected exactly one status bubble, got: {sent:?}"
2357        );
2358        assert_eq!(sent.len(), 3, "status + 2 asks expected, got: {sent:?}");
2359
2360        // Edits continued across both resumes: the final ✅ edit carries text
2361        // from every segment (the buffer survived both pauses).
2362        let edits = platform.edits.lock().await;
2363        let last = edits.last().expect("expected a final edit");
2364        assert!(last.starts_with('✅'), "final edit not a success: {last}");
2365        assert!(last.contains("segment one"));
2366        assert!(last.contains("segment two"));
2367        assert!(last.contains("segment three"));
2368    }
2369
2370    /// PR #459 review item 3: `/stop` while a run is paused on a parked ask
2371    /// (no live cancel token — the round already returned) must invalidate
2372    /// the ask, unblock the render task, and reply with the dedicated
2373    /// "pending question was cancelled" message (the `(None, true)` branch).
2374    #[tokio::test]
2375    async fn stop_while_paused_cancels_the_pending_question() {
2376        let (ctx, _dir) = test_context().await;
2377        let resume_tx = broadcast::channel::<AgentEvent>(16).0;
2378        let responder = FakeResponder::new(resume_tx.clone());
2379        let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
2380        let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
2381        let key = key_for("chat1", "u1");
2382        let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
2383
2384        let (tx, rx) = broadcast::channel(16);
2385        tx.send(need_clarification_event(
2386            "Approve?",
2387            vec!["Approve", "Deny"],
2388            false,
2389        ))
2390        .unwrap();
2391
2392        let render_handle = {
2393            let bridge = bridge.clone();
2394            let platform = platform.clone();
2395            let reply_ctx = reply_ctx.clone();
2396            let key = key.clone();
2397            tokio::spawn(async move {
2398                bridge
2399                    .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
2400                    .await;
2401            })
2402        };
2403
2404        wait_for_parked_ask(&bridge, &key).await;
2405
2406        ConnectBridge::handle_inbound(
2407            bridge.clone(),
2408            platform.clone(),
2409            vec!["u1".to_string()],
2410            inbound("chat1", "u1", "stop-1", "/stop"),
2411        )
2412        .await;
2413
2414        tokio::time::timeout(Duration::from_secs(5), render_handle)
2415            .await
2416            .expect("render task must finish after /stop invalidates the ask")
2417            .unwrap();
2418
2419        let sent = platform.sent_texts().await;
2420        assert!(
2421            sent.iter()
2422                .any(|text| text == "Stopped — the pending question was cancelled."),
2423            "expected the pending-question-cancelled reply, got: {sent:?}"
2424        );
2425        assert!(!bridge.has_pending_ask(&key).await);
2426        assert!(responder.calls.lock().await.is_empty());
2427    }
2428
2429    /// The `(Some(token), true)` `/stop` branch: a parked ask AND a live
2430    /// cancel token (e.g. `/stop` racing the pause) — the token is
2431    /// cancelled, the ask is invalidated, and the generic "Stopping the
2432    /// current run…" reply is used.
2433    #[tokio::test]
2434    async fn stop_while_paused_with_live_token_cancels_both() {
2435        let (ctx, _dir) = test_context().await;
2436        let resume_tx = broadcast::channel::<AgentEvent>(16).0;
2437        let responder = FakeResponder::new(resume_tx.clone());
2438        let bridge = Arc::new(ConnectBridge::with_responder(ctx, None, responder.clone()));
2439        let platform = FakePlatform::with_capabilities("fake", buttons_and_edit_capabilities());
2440        let key = key_for("chat1", "u1");
2441        let reply_ctx = ReplyCtx(serde_json::json!({ "chat_id": "chat1" }));
2442
2443        let token = CancellationToken::new();
2444        bridge.set_cancel_token(&key, token.clone()).await;
2445
2446        let (tx, rx) = broadcast::channel(16);
2447        tx.send(need_clarification_event(
2448            "Approve?",
2449            vec!["Approve", "Deny"],
2450            false,
2451        ))
2452        .unwrap();
2453
2454        let render_handle = {
2455            let bridge = bridge.clone();
2456            let platform = platform.clone();
2457            let reply_ctx = reply_ctx.clone();
2458            let key = key.clone();
2459            tokio::spawn(async move {
2460                bridge
2461                    .render_until_settled(&key, platform, reply_ctx, "sess-1", rx)
2462                    .await;
2463            })
2464        };
2465
2466        wait_for_parked_ask(&bridge, &key).await;
2467
2468        ConnectBridge::handle_inbound(
2469            bridge.clone(),
2470            platform.clone(),
2471            vec!["u1".to_string()],
2472            inbound("chat1", "u1", "stop-1", "/stop"),
2473        )
2474        .await;
2475
2476        tokio::time::timeout(Duration::from_secs(5), render_handle)
2477            .await
2478            .expect("render task must finish after /stop")
2479            .unwrap();
2480
2481        assert!(token.is_cancelled(), "cancel token must be cancelled");
2482        assert!(!bridge.has_pending_ask(&key).await);
2483        let sent = platform.sent_texts().await;
2484        assert!(
2485            sent.iter().any(|text| text == "Stopping the current run…"),
2486            "expected the stopping reply, got: {sent:?}"
2487        );
2488    }
2489}