Skip to main content

bamboo_server/connect/
render.rs

1//! Renders a session's live [`AgentEvent`] stream into platform messages.
2//!
3//! Two rendering modes, chosen from `platform.capabilities().edit_message`:
4//! - **Legacy** (issue #452 MVP): tool-use one-liners and the final assistant
5//!   text are each sent as separate messages.
6//! - **Streaming edit-in-place** (issue #458 phase 2): one status message per
7//!   run, throttled-edited as tool lines/tokens arrive, replaced by a ✅/❌/⏹
8//!   final edit (or a short "done" edit + chunked follow-up when the final
9//!   text doesn't fit a single message).
10//!
11//! Both modes stop at the same three terminal `AgentEvent`s
12//! (`Complete`/`Cancelled`/`Error`) — the phase-1 termination contract — and
13//! BOTH now also stop at `AgentEvent::NeedClarification`, returning
14//! [`RunOutcome::Paused`] instead of continuing to wait for a terminal event
15//! that will never come while the run is genuinely suspended on a pending
16//! question. The bridge (`bridge::ConnectBridge::render_until_settled`) is
17//! responsible for turning a `Paused` outcome into a rendered ask
18//! (`connect::approvals`) and, once answered, calling `stream_execution`
19//! again on the resumed run's stream.
20
21use std::sync::Arc;
22use std::time::Duration;
23
24use tokio::sync::broadcast;
25use tokio::time::Instant;
26
27use bamboo_agent_core::AgentEvent;
28
29use super::platform::{MessageRef, OutboundMessage, Platform, ReplyCtx};
30
31/// Telegram's hard message-length limit (in UTF-16 characters, but ASCII/most
32/// text is 1 UTF-8 char == 1 unit; treating it as a char-count chunk size is a
33/// safe, conservative approximation other adapters can share too).
34pub const MAX_MESSAGE_CHARS: usize = 4096;
35
36/// Longest a single tool-use one-liner is allowed to be before truncation,
37/// well under [`MAX_MESSAGE_CHARS`] so a chatty tool call never dominates the
38/// stream of updates.
39const TOOL_LINE_MAX_CHARS: usize = 300;
40
41/// Minimum time between throttled status-message edits (cc-connect-tuned,
42/// issue #458).
43const EDIT_MIN_INTERVAL: Duration = Duration::from_millis(1500);
44/// Minimum new characters accumulated before a throttled edit fires, ANDed
45/// with [`EDIT_MIN_INTERVAL`] (issue #458).
46const EDIT_MIN_NEW_CHARS: usize = 30;
47
48/// What a live run's event stream settled into.
49#[derive(Debug)]
50pub enum RunOutcome {
51    /// Reached a terminal `AgentEvent` (or the stream closed) — nothing more
52    /// to render for this run.
53    Terminal,
54    /// Paused on `AgentEvent::NeedClarification` — a human decision is now
55    /// required before the run can continue.
56    Paused {
57        ask: PendingAsk,
58        /// The streaming renderer's accumulated state (status `MessageRef` +
59        /// text buffers + throttle bookkeeping), handed back so the caller's
60        /// pause/answer/resume loop can pass it into the NEXT
61        /// [`stream_execution`] call — the resumed run keeps EDITING the same
62        /// status message instead of opening a fresh "⏳ Working…" bubble per
63        /// resume. `None` in legacy (non-`edit_message`) mode, which has no
64        /// cross-run state. Boxed to keep the enum's variants close in size
65        /// (clippy `large_enum_variant`).
66        stream_state: Option<Box<StreamState>>,
67    },
68}
69
70/// Opaque carrier for the streaming renderer's state across a pause/resume
71/// boundary (see [`RunOutcome::Paused::stream_state`]). Fields are private —
72/// callers only thread it through, they never inspect it.
73#[derive(Debug)]
74pub struct StreamState {
75    tool_lines: Vec<String>,
76    assistant_text: String,
77    status_ref: Option<MessageRef>,
78    last_edit_at: Option<Instant>,
79    chars_since_edit: usize,
80}
81
82/// The pause-worthy subset of `AgentEvent::NeedClarification`'s fields,
83/// decoupled from the wire event so `connect::approvals` doesn't need to
84/// match on `AgentEvent` itself.
85#[derive(Debug, Clone)]
86pub struct PendingAsk {
87    pub tool_call_id: String,
88    pub tool_name: String,
89    pub question: String,
90    pub options: Vec<String>,
91    pub allow_custom: bool,
92}
93
94/// Split `text` into chunks of at most `limit` **characters** (not bytes), so
95/// a multi-byte UTF-8 sequence is never split mid-codepoint. Returns an empty
96/// vec for empty input (callers should skip sending in that case).
97pub fn chunk_message(text: &str, limit: usize) -> Vec<String> {
98    if text.is_empty() {
99        return Vec::new();
100    }
101    let chars: Vec<char> = text.chars().collect();
102    chars
103        .chunks(limit.max(1))
104        .map(|chunk| chunk.iter().collect())
105        .collect()
106}
107
108/// Truncate `text` to at most `max` characters, appending an ellipsis when cut.
109fn truncate_chars(text: &str, max: usize) -> String {
110    if text.chars().count() <= max {
111        return text.to_string();
112    }
113    let mut out: String = text.chars().take(max).collect();
114    out.push('…');
115    out
116}
117
118/// Keep the LAST `max` characters of `text` (a "tail-keep" truncation, as
119/// opposed to [`truncate_chars`]'s head-keep) — used for the rolling
120/// streaming-edit body so a long run's most RECENT progress stays visible
121/// instead of getting stuck showing only the earliest lines.
122fn tail_chars(text: &str, max: usize) -> String {
123    let chars: Vec<char> = text.chars().collect();
124    if chars.len() <= max {
125        return text.to_string();
126    }
127    let start = chars.len() - max;
128    chars[start..].iter().collect()
129}
130
131/// Best-effort one-line human summary of a tool call's arguments, used to
132/// keep the one-liner scannable (`⚙ Bash: cargo test…`) instead of dumping
133/// raw JSON.
134fn summarize_arguments(arguments: &serde_json::Value) -> String {
135    let Some(obj) = arguments.as_object() else {
136        return arguments.to_string();
137    };
138    for key in ["command", "file_path", "path", "query", "pattern", "url"] {
139        if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
140            return value.to_string();
141        }
142    }
143    serde_json::to_string(arguments).unwrap_or_default()
144}
145
146/// Formats a `ToolStart` event as a one-liner, truncated to
147/// [`TOOL_LINE_MAX_CHARS`].
148fn format_tool_line(tool_name: &str, arguments: &serde_json::Value) -> String {
149    let summary = summarize_arguments(arguments);
150    truncate_chars(&format!("⚙ {tool_name}: {summary}"), TOOL_LINE_MAX_CHARS)
151}
152
153async fn send_chunks(platform: &Arc<dyn Platform>, ctx: &ReplyCtx, text: &str) {
154    for chunk in chunk_message(text, MAX_MESSAGE_CHARS) {
155        if let Err(error) = platform.reply(ctx, OutboundMessage::text(chunk)).await {
156            tracing::warn!("connect: failed to deliver reply: {error}");
157        }
158    }
159}
160
161fn pending_ask_from_event(
162    question: String,
163    options: Option<Vec<String>>,
164    tool_call_id: Option<String>,
165    tool_name: Option<String>,
166    allow_custom: bool,
167) -> PendingAsk {
168    PendingAsk {
169        tool_call_id: tool_call_id.unwrap_or_default(),
170        tool_name: tool_name.unwrap_or_default(),
171        question,
172        options: options.unwrap_or_default(),
173        allow_custom,
174    }
175}
176
177/// Consume `rx` until a terminal `AgentEvent` or a pause, rendering into
178/// `platform` as it goes. Dispatches to the streaming edit-in-place mode when
179/// `platform.capabilities().edit_message`, else the legacy per-message mode
180/// (issue #452's original behavior, preserved verbatim for adapters that
181/// can't edit).
182///
183/// `prior` is the state a previous `stream_execution` call returned inside
184/// [`RunOutcome::Paused`] when the SAME logical run paused on a question and
185/// is now resuming after the answer — pass it back so the resumed run keeps
186/// editing the same status message. Pass `None` for a fresh run.
187pub async fn stream_execution(
188    platform: Arc<dyn Platform>,
189    reply_ctx: ReplyCtx,
190    rx: broadcast::Receiver<AgentEvent>,
191    prior: Option<Box<StreamState>>,
192) -> RunOutcome {
193    if platform.capabilities().edit_message {
194        stream_execution_streaming(platform, reply_ctx, rx, prior).await
195    } else {
196        stream_execution_legacy(platform, reply_ctx, rx).await
197    }
198}
199
200/// Legacy (issue #452) rendering: each tool one-liner and the final text (or
201/// error/cancellation note) is sent as its own message. Returns
202/// [`RunOutcome::Paused`] on `NeedClarification` instead of the old
203/// (pre-#458) behavior of silently ignoring it and waiting forever.
204async fn stream_execution_legacy(
205    platform: Arc<dyn Platform>,
206    reply_ctx: ReplyCtx,
207    mut rx: broadcast::Receiver<AgentEvent>,
208) -> RunOutcome {
209    let mut final_text = String::new();
210    let mut terminal_note: Option<String> = None;
211
212    loop {
213        match rx.recv().await {
214            Ok(AgentEvent::ToolStart {
215                tool_name,
216                arguments,
217                ..
218            }) => {
219                let line = format_tool_line(&tool_name, &arguments);
220                send_chunks(&platform, &reply_ctx, &line).await;
221            }
222            Ok(AgentEvent::Token { content }) => final_text.push_str(&content),
223            Ok(AgentEvent::NeedClarification {
224                question,
225                options,
226                tool_call_id,
227                tool_name,
228                allow_custom,
229            }) => {
230                return RunOutcome::Paused {
231                    ask: pending_ask_from_event(
232                        question,
233                        options,
234                        tool_call_id,
235                        tool_name,
236                        allow_custom,
237                    ),
238                    stream_state: None,
239                };
240            }
241            Ok(AgentEvent::Complete { .. }) => break,
242            Ok(AgentEvent::Cancelled { message }) => {
243                terminal_note = Some(message.unwrap_or_else(|| "Cancelled.".to_string()));
244                break;
245            }
246            Ok(AgentEvent::Error { message }) => {
247                terminal_note = Some(format!("Error: {message}"));
248                break;
249            }
250            Ok(_) => continue,
251            // A slow reader missed some events; the run is still live, keep going.
252            Err(broadcast::error::RecvError::Lagged(_)) => continue,
253            // Sender dropped without a terminal event (should not normally
254            // happen — treat as done rather than hanging forever).
255            Err(broadcast::error::RecvError::Closed) => break,
256        }
257    }
258
259    let body = terminal_note.unwrap_or(final_text);
260    if !body.trim().is_empty() {
261        send_chunks(&platform, &reply_ctx, &body).await;
262    }
263    RunOutcome::Terminal
264}
265
266/// Accumulated state for the streaming edit-in-place renderer, plus the
267/// throttle/edit-degrade machinery (issue #458 §B).
268struct StreamingRenderer {
269    platform: Arc<dyn Platform>,
270    reply_ctx: ReplyCtx,
271    tool_lines: Vec<String>,
272    assistant_text: String,
273    status_ref: Option<MessageRef>,
274    last_edit_at: Option<Instant>,
275    chars_since_edit: usize,
276}
277
278impl StreamingRenderer {
279    fn new(platform: Arc<dyn Platform>, reply_ctx: ReplyCtx) -> Self {
280        Self {
281            platform,
282            reply_ctx,
283            tool_lines: Vec::new(),
284            assistant_text: String::new(),
285            status_ref: None,
286            last_edit_at: None,
287            chars_since_edit: 0,
288        }
289    }
290
291    /// Rebuild the renderer from state carried across a pause/resume boundary
292    /// (see [`StreamState`]) — same status message, same accumulated text.
293    fn resume(platform: Arc<dyn Platform>, reply_ctx: ReplyCtx, state: StreamState) -> Self {
294        let StreamState {
295            tool_lines,
296            assistant_text,
297            status_ref,
298            last_edit_at,
299            chars_since_edit,
300        } = state;
301        Self {
302            platform,
303            reply_ctx,
304            tool_lines,
305            assistant_text,
306            status_ref,
307            last_edit_at,
308            chars_since_edit,
309        }
310    }
311
312    /// Extract the carry-across-pause state (drops the platform/ctx handles,
313    /// which the resuming caller supplies again).
314    fn into_state(self) -> StreamState {
315        StreamState {
316            tool_lines: self.tool_lines,
317            assistant_text: self.assistant_text,
318            status_ref: self.status_ref,
319            last_edit_at: self.last_edit_at,
320            chars_since_edit: self.chars_since_edit,
321        }
322    }
323
324    async fn send_initial(&mut self) {
325        match self
326            .platform
327            .reply(&self.reply_ctx, OutboundMessage::text("⏳ Working…"))
328            .await
329        {
330            Ok(msg_ref) => self.status_ref = Some(msg_ref),
331            Err(error) => {
332                tracing::warn!("connect: failed to send initial status message: {error}")
333            }
334        }
335    }
336
337    /// Full body (tool lines + assistant text), untruncated — used for the
338    /// final "does it fit in one message" check.
339    fn full_body(&self) -> String {
340        let mut body = String::new();
341        for line in &self.tool_lines {
342            body.push_str(line);
343            body.push('\n');
344        }
345        if !self.assistant_text.is_empty() {
346            if !body.is_empty() {
347                body.push('\n');
348            }
349            body.push_str(&self.assistant_text);
350        }
351        body
352    }
353
354    /// Rolling display body, tail-truncated to [`MAX_MESSAGE_CHARS`] so the
355    /// most recent progress always stays visible in the status message.
356    fn display_tail(&self) -> String {
357        tail_chars(&self.full_body(), MAX_MESSAGE_CHARS)
358    }
359
360    /// Record `added_chars` of new content and fire a throttled edit if both
361    /// the interval and char-count thresholds are met.
362    async fn note_growth(&mut self, added_chars: usize) {
363        self.chars_since_edit += added_chars;
364        let now = Instant::now();
365        let interval_ok = self
366            .last_edit_at
367            .map(|at| now.duration_since(at) >= EDIT_MIN_INTERVAL)
368            .unwrap_or(true);
369        if !interval_ok || self.chars_since_edit < EDIT_MIN_NEW_CHARS {
370            return;
371        }
372        let text = self.display_tail();
373        self.apply_edit(text).await;
374        self.last_edit_at = Some(now);
375        self.chars_since_edit = 0;
376    }
377
378    /// Apply an edit unconditionally (bypassing the throttle) — used for
379    /// terminal/pause renders where the final content must land regardless of
380    /// timing. Degrades to a fresh `reply()` when there's no status message
381    /// yet, or when the edit itself fails (message too old / unchanged
382    /// content / any other 400) — an edit failure must never fail the run.
383    async fn apply_edit(&mut self, text: String) {
384        if text.trim().is_empty() {
385            return;
386        }
387        let Some(msg_ref) = self.status_ref.clone() else {
388            match self
389                .platform
390                .reply(&self.reply_ctx, OutboundMessage::text(text))
391                .await
392            {
393                Ok(new_ref) => self.status_ref = Some(new_ref),
394                Err(error) => {
395                    tracing::warn!("connect: failed to send status message: {error}")
396                }
397            }
398            return;
399        };
400        if let Err(error) = self
401            .platform
402            .edit(&msg_ref, OutboundMessage::text(text.clone()))
403            .await
404        {
405            tracing::warn!("connect: status edit failed, degrading to a fresh message: {error}");
406            match self
407                .platform
408                .reply(&self.reply_ctx, OutboundMessage::text(text))
409                .await
410            {
411                Ok(new_ref) => self.status_ref = Some(new_ref),
412                Err(error) => tracing::warn!("connect: fallback send also failed: {error}"),
413            }
414        }
415    }
416
417    /// Final render on success: the completed status message becomes "✅ " +
418    /// the full text when it fits in one message; otherwise a short "✅ done"
419    /// edit plus the full result sent as fresh chunked messages (issue #458
420    /// §B point 5).
421    async fn finalize_success(&mut self) {
422        let full = self.full_body();
423        if full.trim().is_empty() {
424            self.apply_edit("✅ Done.".to_string()).await;
425            return;
426        }
427        if full.chars().count() <= MAX_MESSAGE_CHARS {
428            self.apply_edit(format!("✅ {full}")).await;
429        } else {
430            self.apply_edit("✅ done".to_string()).await;
431            send_chunks(&self.platform, &self.reply_ctx, &full).await;
432        }
433    }
434
435    /// Final render on error/cancel: `icon` + `note`, replacing whatever
436    /// partial progress the status message was showing.
437    async fn finalize_terminal_note(&mut self, icon: &str, note: &str) {
438        self.apply_edit(format!("{icon} {note}")).await;
439    }
440
441    /// Courtesy edit marking the status message as paused, before the ask
442    /// itself is rendered as a separate message by `connect::approvals`.
443    async fn finalize_paused(&mut self) {
444        let mut text = self.display_tail();
445        if !text.is_empty() {
446            text.push_str("\n\n");
447        }
448        text.push_str("⏸ Waiting for your input…");
449        self.apply_edit(text).await;
450    }
451}
452
453/// Streaming edit-in-place (issue #458 §B) rendering mode. `prior`, when
454/// `Some`, resumes an earlier pause's renderer — same status message, no new
455/// "⏳ Working…" bubble.
456async fn stream_execution_streaming(
457    platform: Arc<dyn Platform>,
458    reply_ctx: ReplyCtx,
459    mut rx: broadcast::Receiver<AgentEvent>,
460    prior: Option<Box<StreamState>>,
461) -> RunOutcome {
462    let mut renderer = match prior {
463        Some(state) => StreamingRenderer::resume(platform, reply_ctx, *state),
464        None => {
465            let mut renderer = StreamingRenderer::new(platform, reply_ctx);
466            renderer.send_initial().await;
467            renderer
468        }
469    };
470
471    loop {
472        match rx.recv().await {
473            Ok(AgentEvent::ToolStart {
474                tool_name,
475                arguments,
476                ..
477            }) => {
478                let line = format_tool_line(&tool_name, &arguments);
479                let added = line.chars().count();
480                renderer.tool_lines.push(line);
481                renderer.note_growth(added).await;
482            }
483            Ok(AgentEvent::Token { content }) => {
484                let added = content.chars().count();
485                renderer.assistant_text.push_str(&content);
486                renderer.note_growth(added).await;
487            }
488            Ok(AgentEvent::NeedClarification {
489                question,
490                options,
491                tool_call_id,
492                tool_name,
493                allow_custom,
494            }) => {
495                renderer.finalize_paused().await;
496                return RunOutcome::Paused {
497                    ask: pending_ask_from_event(
498                        question,
499                        options,
500                        tool_call_id,
501                        tool_name,
502                        allow_custom,
503                    ),
504                    stream_state: Some(Box::new(renderer.into_state())),
505                };
506            }
507            Ok(AgentEvent::Complete { .. }) => {
508                renderer.finalize_success().await;
509                return RunOutcome::Terminal;
510            }
511            Ok(AgentEvent::Cancelled { message }) => {
512                renderer
513                    .finalize_terminal_note(
514                        "⏹",
515                        &message.unwrap_or_else(|| "Cancelled.".to_string()),
516                    )
517                    .await;
518                return RunOutcome::Terminal;
519            }
520            Ok(AgentEvent::Error { message }) => {
521                renderer.finalize_terminal_note("❌ Error:", &message).await;
522                return RunOutcome::Terminal;
523            }
524            Ok(_) => continue,
525            Err(broadcast::error::RecvError::Lagged(_)) => continue,
526            // Sender dropped without a terminal event — matches the legacy
527            // mode's "treat as done, never hang" contract. Leave whatever
528            // partial status message is showing rather than editing it (no
529            // reliable terminal state to report).
530            Err(broadcast::error::RecvError::Closed) => return RunOutcome::Terminal,
531        }
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use crate::connect::platform::{Capabilities, InboundMessage};
539
540    #[test]
541    fn chunk_message_splits_on_char_boundaries_not_bytes() {
542        // Every char is 3 bytes in UTF-8 but 1 char; a byte-based chunker
543        // would split mid-codepoint at a limit of 2.
544        let text = "\u{4e2d}\u{6587}\u{6d4b}\u{8bd5}"; // 4 CJK chars, 12 bytes
545        let chunks = chunk_message(text, 2);
546        assert_eq!(chunks, vec!["\u{4e2d}\u{6587}", "\u{6d4b}\u{8bd5}"]);
547    }
548
549    #[test]
550    fn chunk_message_respects_the_4096_limit() {
551        let text = "a".repeat(10_000);
552        let chunks = chunk_message(&text, MAX_MESSAGE_CHARS);
553        assert_eq!(chunks.len(), 3);
554        assert_eq!(chunks[0].chars().count(), MAX_MESSAGE_CHARS);
555        assert_eq!(chunks[1].chars().count(), MAX_MESSAGE_CHARS);
556        assert_eq!(chunks[2].chars().count(), 10_000 - 2 * MAX_MESSAGE_CHARS);
557    }
558
559    #[test]
560    fn chunk_message_empty_text_yields_no_chunks() {
561        assert!(chunk_message("", MAX_MESSAGE_CHARS).is_empty());
562    }
563
564    #[test]
565    fn tail_chars_keeps_the_last_n_characters() {
566        let text = "0123456789";
567        assert_eq!(tail_chars(text, 4), "6789");
568        assert_eq!(tail_chars(text, 100), text);
569    }
570
571    #[test]
572    fn format_tool_line_prefers_command_field_and_truncates() {
573        let args = serde_json::json!({ "command": "cargo test --workspace" });
574        let line = format_tool_line("Bash", &args);
575        assert_eq!(line, "⚙ Bash: cargo test --workspace");
576    }
577
578    #[test]
579    fn format_tool_line_truncates_long_summaries() {
580        let long_command = "x".repeat(1000);
581        let args = serde_json::json!({ "command": long_command });
582        let line = format_tool_line("Bash", &args);
583        assert!(line.chars().count() <= TOOL_LINE_MAX_CHARS + 1);
584        assert!(line.ends_with('…'));
585    }
586
587    #[test]
588    fn format_tool_line_falls_back_to_json_for_unknown_shape() {
589        let args = serde_json::json!({ "foo": "bar" });
590        let line = format_tool_line("CustomTool", &args);
591        assert!(line.starts_with("⚙ CustomTool: "));
592        assert!(line.contains("foo"));
593    }
594
595    /// Records every `reply()`/`edit()` call. `edit_message` capability is
596    /// controlled by a constructor flag so the same fake drives both render
597    /// modes' tests.
598    struct RecordingPlatform {
599        edit_message: bool,
600        sent: tokio::sync::Mutex<Vec<String>>,
601        edits: tokio::sync::Mutex<Vec<String>>,
602        edit_should_fail: std::sync::atomic::AtomicBool,
603    }
604
605    impl RecordingPlatform {
606        fn new(edit_message: bool) -> Arc<Self> {
607            Arc::new(Self {
608                edit_message,
609                sent: tokio::sync::Mutex::new(Vec::new()),
610                edits: tokio::sync::Mutex::new(Vec::new()),
611                edit_should_fail: std::sync::atomic::AtomicBool::new(false),
612            })
613        }
614    }
615
616    #[async_trait::async_trait]
617    impl Platform for RecordingPlatform {
618        fn name(&self) -> &str {
619            "recording"
620        }
621        fn capabilities(&self) -> Capabilities {
622            Capabilities {
623                buttons: false,
624                edit_message: self.edit_message,
625                images: false,
626                files: false,
627            }
628        }
629        async fn start(
630            &self,
631            _inbound: tokio::sync::mpsc::Sender<super::super::platform::Inbound>,
632        ) -> super::super::platform::PlatformResult<()> {
633            Ok(())
634        }
635        async fn reply(
636            &self,
637            _ctx: &ReplyCtx,
638            msg: OutboundMessage,
639        ) -> super::super::platform::PlatformResult<super::super::platform::MessageRef> {
640            self.sent.lock().await.push(msg.text);
641            Ok(super::super::platform::MessageRef(serde_json::json!({
642                "id": self.sent.lock().await.len()
643            })))
644        }
645        async fn edit(
646            &self,
647            _msg_ref: &super::super::platform::MessageRef,
648            new: OutboundMessage,
649        ) -> super::super::platform::PlatformResult<()> {
650            if self
651                .edit_should_fail
652                .load(std::sync::atomic::Ordering::SeqCst)
653            {
654                return Err(super::super::platform::PlatformError::other("edit failed"));
655            }
656            self.edits.lock().await.push(new.text);
657            Ok(())
658        }
659        async fn stop(&self) -> super::super::platform::PlatformResult<()> {
660            Ok(())
661        }
662    }
663
664    fn ask_event(question: &str, options: Vec<&str>, allow_custom: bool) -> AgentEvent {
665        AgentEvent::NeedClarification {
666            question: question.to_string(),
667            options: Some(options.into_iter().map(str::to_string).collect()),
668            tool_call_id: Some("call-1".to_string()),
669            tool_name: Some("conclusion_with_options".to_string()),
670            allow_custom,
671        }
672    }
673
674    // ---- Legacy mode (edit_message = false) ----
675
676    #[tokio::test]
677    async fn stream_execution_renders_tool_lines_and_final_text() {
678        let (tx, rx) = broadcast::channel(16);
679        let platform = RecordingPlatform::new(false);
680        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
681
682        tx.send(AgentEvent::ToolStart {
683            tool_call_id: "1".to_string(),
684            tool_name: "Bash".to_string(),
685            arguments: serde_json::json!({ "command": "cargo test" }),
686        })
687        .unwrap();
688        tx.send(AgentEvent::Token {
689            content: "Hello ".to_string(),
690        })
691        .unwrap();
692        tx.send(AgentEvent::Token {
693            content: "world.".to_string(),
694        })
695        .unwrap();
696        tx.send(AgentEvent::Complete {
697            usage: bamboo_agent_core::TokenUsage {
698                prompt_tokens: 1,
699                completion_tokens: 1,
700                total_tokens: 2,
701            },
702        })
703        .unwrap();
704
705        let outcome = stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
706        assert!(matches!(outcome, RunOutcome::Terminal));
707
708        let sent = platform.sent.lock().await;
709        assert_eq!(sent.len(), 2);
710        assert_eq!(sent[0], "⚙ Bash: cargo test");
711        assert_eq!(sent[1], "Hello world.");
712    }
713
714    #[tokio::test]
715    async fn stream_execution_renders_error_note_instead_of_partial_text() {
716        let (tx, rx) = broadcast::channel(16);
717        let platform = RecordingPlatform::new(false);
718        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
719
720        tx.send(AgentEvent::Token {
721            content: "partial".to_string(),
722        })
723        .unwrap();
724        tx.send(AgentEvent::Error {
725            message: "boom".to_string(),
726        })
727        .unwrap();
728
729        stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
730
731        let sent = platform.sent.lock().await;
732        assert_eq!(sent.len(), 1);
733        assert_eq!(sent[0], "Error: boom");
734    }
735
736    #[tokio::test]
737    async fn stream_execution_returns_when_channel_closes_without_terminal_event() {
738        let (tx, rx) = broadcast::channel(16);
739        let platform = RecordingPlatform::new(false);
740        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
741        drop(tx);
742
743        // Must return promptly, not hang, when the sender is dropped.
744        tokio::time::timeout(
745            std::time::Duration::from_secs(5),
746            stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None),
747        )
748        .await
749        .expect("stream_execution must not hang on a closed channel");
750
751        assert!(platform.sent.lock().await.is_empty());
752    }
753
754    #[tokio::test]
755    async fn stream_execution_legacy_pauses_on_need_clarification() {
756        let (tx, rx) = broadcast::channel(16);
757        let platform = RecordingPlatform::new(false);
758        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
759
760        tx.send(ask_event("Pick one", vec!["A", "B"], false))
761            .unwrap();
762
763        let outcome = stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
764        match outcome {
765            RunOutcome::Paused { ask, stream_state } => {
766                assert_eq!(ask.question, "Pick one");
767                assert_eq!(ask.options, vec!["A".to_string(), "B".to_string()]);
768                assert_eq!(ask.tool_call_id, "call-1");
769                assert!(!ask.allow_custom);
770                // Legacy mode carries no streaming state.
771                assert!(stream_state.is_none());
772            }
773            RunOutcome::Terminal => panic!("expected Paused"),
774        }
775        // No terminal note sent — the ask itself is rendered by the bridge,
776        // not by render.rs.
777        assert!(platform.sent.lock().await.is_empty());
778    }
779
780    // ---- Streaming edit-in-place mode (edit_message = true) ----
781
782    #[tokio::test]
783    async fn streaming_mode_sends_one_initial_status_message() {
784        let (_tx, rx) = broadcast::channel(16);
785        let platform = RecordingPlatform::new(true);
786        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
787        drop(_tx);
788
789        stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
790
791        let sent = platform.sent.lock().await;
792        assert_eq!(sent.len(), 1);
793        assert_eq!(sent[0], "⏳ Working…");
794    }
795
796    #[tokio::test]
797    async fn streaming_mode_final_success_edits_status_with_checkmark() {
798        let (tx, rx) = broadcast::channel(16);
799        let platform = RecordingPlatform::new(true);
800        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
801
802        tx.send(AgentEvent::Token {
803            content: "All done.".to_string(),
804        })
805        .unwrap();
806        tx.send(AgentEvent::Complete {
807            usage: bamboo_agent_core::TokenUsage {
808                prompt_tokens: 1,
809                completion_tokens: 1,
810                total_tokens: 2,
811            },
812        })
813        .unwrap();
814
815        let outcome = stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
816        assert!(matches!(outcome, RunOutcome::Terminal));
817
818        // The 9-char token is below the 30-char throttle, so no mid-run edit
819        // fires — only the unconditional final edit.
820        let edits = platform.edits.lock().await;
821        assert_eq!(edits.len(), 1);
822        assert_eq!(edits[0], "✅ All done.");
823        // Never sent as a separate chunked message (it fit in the edit).
824        assert_eq!(platform.sent.lock().await.len(), 1);
825    }
826
827    #[tokio::test]
828    async fn streaming_mode_final_error_edits_status_with_cross() {
829        let (tx, rx) = broadcast::channel(16);
830        let platform = RecordingPlatform::new(true);
831        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
832
833        tx.send(AgentEvent::Error {
834            message: "boom".to_string(),
835        })
836        .unwrap();
837
838        stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
839
840        let edits = platform.edits.lock().await;
841        assert_eq!(edits.last().unwrap(), "❌ Error: boom");
842    }
843
844    #[tokio::test]
845    async fn streaming_mode_final_cancel_edits_status_with_stop_icon() {
846        let (tx, rx) = broadcast::channel(16);
847        let platform = RecordingPlatform::new(true);
848        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
849
850        tx.send(AgentEvent::Cancelled {
851            message: Some("user requested /stop".to_string()),
852        })
853        .unwrap();
854
855        stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
856
857        let edits = platform.edits.lock().await;
858        assert_eq!(edits.last().unwrap(), "⏹ user requested /stop");
859    }
860
861    #[tokio::test]
862    async fn streaming_mode_pauses_on_need_clarification_with_courtesy_edit() {
863        let (tx, rx) = broadcast::channel(16);
864        let platform = RecordingPlatform::new(true);
865        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
866
867        tx.send(AgentEvent::Token {
868            content: "Working on it".to_string(),
869        })
870        .unwrap();
871        tx.send(ask_event("Approve?", vec!["Approve", "Deny"], false))
872            .unwrap();
873
874        let outcome = stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
875        match outcome {
876            RunOutcome::Paused { ask, stream_state } => {
877                assert_eq!(ask.question, "Approve?");
878                // Streaming mode DOES carry state across the pause.
879                assert!(stream_state.is_some());
880            }
881            RunOutcome::Terminal => panic!("expected Paused"),
882        }
883
884        let edits = platform.edits.lock().await;
885        assert!(edits.last().unwrap().contains("Waiting for your input"));
886    }
887
888    #[tokio::test]
889    async fn streaming_mode_resume_keeps_editing_the_same_status_message() {
890        let platform = RecordingPlatform::new(true);
891        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
892
893        // Segment 1: some text, then a pause.
894        let (tx1, rx1) = broadcast::channel(16);
895        tx1.send(AgentEvent::Token {
896            content: "Before the question. ".to_string(),
897        })
898        .unwrap();
899        tx1.send(ask_event("Approve?", vec!["Approve", "Deny"], false))
900            .unwrap();
901        let outcome = stream_execution(
902            platform.clone() as Arc<dyn Platform>,
903            ctx.clone(),
904            rx1,
905            None,
906        )
907        .await;
908        let state = match outcome {
909            RunOutcome::Paused { stream_state, .. } => stream_state,
910            RunOutcome::Terminal => panic!("expected Paused"),
911        };
912        assert!(state.is_some());
913
914        // Segment 2 (resumed run): more text, then Complete — passing the
915        // carried state back in.
916        let (tx2, rx2) = broadcast::channel(16);
917        tx2.send(AgentEvent::Token {
918            content: "After the answer.".to_string(),
919        })
920        .unwrap();
921        tx2.send(AgentEvent::Complete {
922            usage: bamboo_agent_core::TokenUsage {
923                prompt_tokens: 1,
924                completion_tokens: 1,
925                total_tokens: 2,
926            },
927        })
928        .unwrap();
929        stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx2, state).await;
930
931        // Exactly ONE message was ever SENT — the initial "⏳ Working…"
932        // status bubble; the resumed segment kept EDITING it rather than
933        // opening a second one.
934        let sent = platform.sent.lock().await;
935        assert_eq!(sent.len(), 1, "expected a single status message: {sent:?}");
936        assert_eq!(sent[0], "⏳ Working…");
937        // The final edit carries text from BOTH segments (buffer survived
938        // the pause).
939        let edits = platform.edits.lock().await;
940        let last = edits.last().expect("expected a final edit");
941        assert!(last.starts_with('✅'), "final edit not a success: {last}");
942        assert!(last.contains("Before the question."));
943        assert!(last.contains("After the answer."));
944    }
945
946    #[tokio::test]
947    async fn streaming_mode_throttle_skips_edits_below_the_char_threshold() {
948        let (tx, rx) = broadcast::channel(16);
949        let platform = RecordingPlatform::new(true);
950        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
951
952        // Each token is well under the 30-char threshold; none should trigger
953        // a mid-run edit before the terminal event's unconditional edit.
954        for i in 0..5 {
955            tx.send(AgentEvent::Token {
956                content: format!("t{i} "),
957            })
958            .unwrap();
959        }
960        tx.send(AgentEvent::Complete {
961            usage: bamboo_agent_core::TokenUsage {
962                prompt_tokens: 1,
963                completion_tokens: 1,
964                total_tokens: 2,
965            },
966        })
967        .unwrap();
968
969        stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
970
971        // Exactly one edit: the final one.
972        assert_eq!(platform.edits.lock().await.len(), 1);
973    }
974
975    #[tokio::test]
976    async fn streaming_mode_long_final_text_chunks_instead_of_editing_in_full() {
977        let (tx, rx) = broadcast::channel(16);
978        let platform = RecordingPlatform::new(true);
979        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
980
981        let long_text = "a".repeat(5000);
982        tx.send(AgentEvent::Token {
983            content: long_text.clone(),
984        })
985        .unwrap();
986        tx.send(AgentEvent::Complete {
987            usage: bamboo_agent_core::TokenUsage {
988                prompt_tokens: 1,
989                completion_tokens: 1,
990                total_tokens: 2,
991            },
992        })
993        .unwrap();
994
995        stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
996
997        // Final edit is the short "done" marker, not the full 5000 chars.
998        let edits = platform.edits.lock().await;
999        assert_eq!(edits.last().unwrap(), "✅ done");
1000        // The full text was chunk-sent as fresh messages instead (2 chunks at
1001        // 4096 + initial status message = 3 sends total).
1002        let sent = platform.sent.lock().await;
1003        assert_eq!(sent.len(), 3);
1004    }
1005
1006    #[tokio::test]
1007    async fn streaming_mode_edit_failure_degrades_to_a_fresh_send() {
1008        let (tx, rx) = broadcast::channel(16);
1009        let platform = RecordingPlatform::new(true);
1010        platform
1011            .edit_should_fail
1012            .store(true, std::sync::atomic::Ordering::SeqCst);
1013        let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
1014
1015        tx.send(AgentEvent::Complete {
1016            usage: bamboo_agent_core::TokenUsage {
1017                prompt_tokens: 1,
1018                completion_tokens: 1,
1019                total_tokens: 2,
1020            },
1021        })
1022        .unwrap();
1023
1024        let outcome = stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
1025        assert!(matches!(outcome, RunOutcome::Terminal));
1026
1027        // No successful edits recorded (they all failed) — but the run never
1028        // errors out; it degrades to sending a fresh message instead.
1029        assert!(platform.edits.lock().await.is_empty());
1030        // Initial status + degraded final send.
1031        assert_eq!(platform.sent.lock().await.len(), 2);
1032    }
1033
1034    // Sanity: `InboundMessage` remains constructible with the same shape used
1035    // elsewhere in the module (guards against an accidental field drift when
1036    // `Inbound`/`CallbackQuery` were added alongside it).
1037    #[test]
1038    fn inbound_message_is_still_constructible() {
1039        let _ = InboundMessage {
1040            platform: "telegram".to_string(),
1041            chat_id: "1".to_string(),
1042            user_id: "1".to_string(),
1043            message_id: "1".to_string(),
1044            sent_at: chrono::Utc::now(),
1045            text: "hi".to_string(),
1046            reply_ctx: ReplyCtx(serde_json::Value::Null),
1047        };
1048    }
1049}