use actix_web::http::header;
use actix_web::{web, HttpResponse};
use std::time::Duration;
use tokio::sync::broadcast;
use bamboo_agent_core::AgentEvent;
use crate::app_state::AppState;
use super::terminal::has_running_child;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CoalesceKey {
Token,
ReasoningToken,
ToolToken(String),
}
pub(crate) fn coalesce_key(event: &AgentEvent) -> Option<CoalesceKey> {
match event {
AgentEvent::Token { .. } => Some(CoalesceKey::Token),
AgentEvent::ReasoningToken { .. } => Some(CoalesceKey::ReasoningToken),
AgentEvent::ToolToken { tool_call_id, .. } => {
Some(CoalesceKey::ToolToken(tool_call_id.clone()))
}
_ => None,
}
}
pub(crate) const MAX_PENDING_CONTENT_BYTES: usize = 64 * 1024;
pub(crate) fn pending_content_len(event: &AgentEvent) -> usize {
match event {
AgentEvent::Token { content }
| AgentEvent::ReasoningToken { content }
| AgentEvent::ToolToken { content, .. } => content.len(),
_ => 0,
}
}
pub(crate) fn merge_token_into(pending: &mut AgentEvent, incoming: AgentEvent) {
match (pending, incoming) {
(AgentEvent::Token { content }, AgentEvent::Token { content: more })
| (AgentEvent::ReasoningToken { content }, AgentEvent::ReasoningToken { content: more })
| (AgentEvent::ToolToken { content, .. }, AgentEvent::ToolToken { content: more, .. }) => {
content.push_str(&more);
}
(_, _) => debug_assert!(false, "merge_token_into called with mismatched variants"),
}
}
#[derive(Default)]
pub(crate) struct Coalescer {
pending: Option<AgentEvent>,
}
impl Coalescer {
pub(crate) fn push(&mut self, event: AgentEvent) -> Vec<AgentEvent> {
match coalesce_key(&event) {
None => {
let mut out = Vec::new();
if let Some(pending) = self.pending.take() {
out.push(pending);
}
out.push(event);
out
}
Some(key) => {
let same_key = self
.pending
.as_ref()
.and_then(coalesce_key)
.is_some_and(|pk| pk == key);
if same_key {
if let Some(pending) = self.pending.as_mut() {
merge_token_into(pending, event);
if pending_content_len(pending) >= MAX_PENDING_CONTENT_BYTES {
return self.pending.take().into_iter().collect();
}
}
Vec::new()
} else {
let mut out = Vec::new();
if let Some(pending) = self.pending.take() {
out.push(pending);
}
self.pending = Some(event);
out
}
}
}
}
pub(crate) fn take_pending(&mut self) -> Option<AgentEvent> {
self.pending.take()
}
pub(crate) fn has_pending(&self) -> bool {
self.pending.is_some()
}
}
fn event_sse_data(event: &AgentEvent) -> Option<String> {
serde_json::to_string(event)
.ok()
.map(|event_json| format!("data: {}\n\n", event_json))
}
pub(super) fn terminal_response(
budget_event_to_replay: Option<AgentEvent>,
critical_events_to_replay: Vec<AgentEvent>,
terminal_event: AgentEvent,
) -> HttpResponse {
HttpResponse::Ok()
.append_header((header::CONTENT_TYPE, "text/event-stream; charset=utf-8"))
.append_header((header::CACHE_CONTROL, "no-cache, no-transform"))
.append_header((header::CONNECTION, "keep-alive"))
.append_header(("X-Accel-Buffering", "no"))
.streaming(async_stream::stream! {
for event in &critical_events_to_replay {
if let Some(sse_data) = as_sse_data(Some(event)) {
yield Ok::<_, actix_web::Error>(web::Bytes::from(sse_data));
}
}
if let Some(sse_data) = as_sse_data(budget_event_to_replay.as_ref()) {
yield Ok::<_, actix_web::Error>(web::Bytes::from(sse_data));
}
if let Ok(event_json) = serde_json::to_string(&terminal_event) {
let sse_data = format!("data: {}\n\n", event_json);
yield Ok::<_, actix_web::Error>(web::Bytes::from(sse_data));
}
yield Ok::<_, actix_web::Error>(web::Bytes::from(done_sse_data()));
})
}
pub(super) fn live_stream_response(
budget_event_to_replay: Option<AgentEvent>,
critical_events_to_replay: Vec<AgentEvent>,
mut receiver: broadcast::Receiver<AgentEvent>,
state: web::Data<AppState>,
session_id: String,
batch_ms: u64,
watcher_guard: crate::app_state::watchers::WatcherGuard,
) -> HttpResponse {
HttpResponse::Ok()
.append_header((header::CONTENT_TYPE, "text/event-stream; charset=utf-8"))
.append_header((header::CACHE_CONTROL, "no-cache, no-transform"))
.append_header((header::CONNECTION, "keep-alive"))
.append_header(("X-Accel-Buffering", "no"))
.streaming(async_stream::stream! {
let _watcher_guard = watcher_guard;
for event in &critical_events_to_replay {
if let Some(sse_data) = as_sse_data(Some(event)) {
yield Ok::<_, actix_web::Error>(web::Bytes::from(sse_data));
}
}
if let Some(sse_data) = as_sse_data(budget_event_to_replay.as_ref()) {
yield Ok::<_, actix_web::Error>(web::Bytes::from(sse_data));
}
let mut heartbeat = tokio::time::interval(Duration::from_secs(15));
heartbeat.tick().await;
let mut awaiting_children = false;
if batch_ms == 0 {
loop {
tokio::select! {
_ = heartbeat.tick() => {
if awaiting_children && !has_running_child(&state, &session_id).await {
yield Ok::<_, actix_web::Error>(web::Bytes::from(done_sse_data()));
break;
}
yield Ok::<_, actix_web::Error>(web::Bytes::from(keepalive_sse_data()));
}
recv = receiver.recv() => {
match recv {
Ok(event) => {
let is_terminal = is_terminal_event(&event);
let is_child_completed =
matches!(event, AgentEvent::SubAgentCompleted { .. });
let Ok(event_json) = serde_json::to_string(&event) else {
continue;
};
let sse_data = format!("data: {}\n\n", event_json);
yield Ok::<_, actix_web::Error>(web::Bytes::from(sse_data));
if is_terminal {
if has_running_child(&state, &session_id).await {
awaiting_children = true;
continue;
}
yield Ok::<_, actix_web::Error>(web::Bytes::from(done_sse_data()));
break;
}
if awaiting_children
&& is_child_completed
&& !has_running_child(&state, &session_id).await
{
yield Ok::<_, actix_web::Error>(web::Bytes::from(done_sse_data()));
break;
}
}
Err(broadcast::error::RecvError::Lagged(_skipped)) => {
continue;
}
Err(broadcast::error::RecvError::Closed) => {
break;
}
}
}
}
}
} else {
let mut coalescer = Coalescer::default();
let flush_window = Duration::from_millis(batch_ms);
let mut flush_deadline: Option<tokio::time::Instant> = None;
loop {
let sleep_until = flush_deadline
.unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(86_400));
tokio::select! {
_ = tokio::time::sleep_until(sleep_until), if flush_deadline.is_some() => {
if let Some(pending) = coalescer.take_pending() {
if let Some(sse_data) = event_sse_data(&pending) {
yield Ok::<_, actix_web::Error>(web::Bytes::from(sse_data));
}
}
flush_deadline = None;
}
_ = heartbeat.tick() => {
if let Some(pending) = coalescer.take_pending() {
if let Some(sse_data) = event_sse_data(&pending) {
yield Ok::<_, actix_web::Error>(web::Bytes::from(sse_data));
}
flush_deadline = None;
}
if awaiting_children && !has_running_child(&state, &session_id).await {
yield Ok::<_, actix_web::Error>(web::Bytes::from(done_sse_data()));
break;
}
yield Ok::<_, actix_web::Error>(web::Bytes::from(keepalive_sse_data()));
}
recv = receiver.recv() => {
match recv {
Ok(event) => {
let is_terminal = is_terminal_event(&event);
let is_child_completed =
matches!(event, AgentEvent::SubAgentCompleted { .. });
for out in coalescer.push(event) {
if let Some(sse_data) = event_sse_data(&out) {
yield Ok::<_, actix_web::Error>(web::Bytes::from(sse_data));
}
}
if coalescer.has_pending() {
if flush_deadline.is_none() {
flush_deadline =
Some(tokio::time::Instant::now() + flush_window);
}
} else {
flush_deadline = None;
}
if is_terminal {
if has_running_child(&state, &session_id).await {
awaiting_children = true;
continue;
}
yield Ok::<_, actix_web::Error>(web::Bytes::from(done_sse_data()));
break;
}
if awaiting_children
&& is_child_completed
&& !has_running_child(&state, &session_id).await
{
yield Ok::<_, actix_web::Error>(web::Bytes::from(done_sse_data()));
break;
}
}
Err(broadcast::error::RecvError::Lagged(_skipped)) => {
if let Some(pending) = coalescer.take_pending() {
if let Some(sse_data) = event_sse_data(&pending) {
yield Ok::<_, actix_web::Error>(web::Bytes::from(sse_data));
}
flush_deadline = None;
}
continue;
}
Err(broadcast::error::RecvError::Closed) => {
if let Some(pending) = coalescer.take_pending() {
if let Some(sse_data) = event_sse_data(&pending) {
yield Ok::<_, actix_web::Error>(web::Bytes::from(sse_data));
}
}
break;
}
}
}
}
}
}
})
}
fn as_sse_data(event: Option<&AgentEvent>) -> Option<String> {
let event = event?;
serde_json::to_string(event)
.ok()
.map(|event_json| format!("data: {}\n\n", event_json))
}
fn done_sse_data() -> &'static str {
"data: [DONE]\n\n"
}
fn keepalive_sse_data() -> &'static str {
"data: [KEEPALIVE]\n\n"
}
fn is_terminal_event(event: &AgentEvent) -> bool {
matches!(
event,
AgentEvent::Complete { .. } | AgentEvent::Cancelled { .. } | AgentEvent::Error { .. }
)
}
#[cfg(test)]
mod coalesce_tests {
use super::*;
use bamboo_agent_core::TokenUsage;
fn token(content: &str) -> AgentEvent {
AgentEvent::Token {
content: content.to_string(),
}
}
fn reasoning(content: &str) -> AgentEvent {
AgentEvent::ReasoningToken {
content: content.to_string(),
}
}
fn tool_token(id: &str, content: &str) -> AgentEvent {
AgentEvent::ToolToken {
tool_call_id: id.to_string(),
content: content.to_string(),
}
}
fn tool_start(id: &str) -> AgentEvent {
AgentEvent::ToolStart {
tool_call_id: id.to_string(),
tool_name: "Bash".to_string(),
arguments: serde_json::json!({}),
}
}
fn complete() -> AgentEvent {
AgentEvent::Complete {
usage: TokenUsage::default(),
}
}
fn content_of(event: &AgentEvent) -> &str {
match event {
AgentEvent::Token { content }
| AgentEvent::ReasoningToken { content }
| AgentEvent::ToolToken { content, .. } => content,
other => panic!("expected a token-class event, got {other:?}"),
}
}
#[test]
fn push_then_flush_loses_nothing_and_preserves_order() {
let mut c = Coalescer::default();
let mut emitted: Vec<AgentEvent> = Vec::new();
for ev in [token("a"), token("b"), tool_start("t1"), token("c")] {
emitted.extend(c.push(ev));
}
if let Some(p) = c.take_pending() {
emitted.push(p);
}
assert_eq!(emitted.len(), 3);
assert_eq!(content_of(&emitted[0]), "ab");
assert!(matches!(emitted[1], AgentEvent::ToolStart { .. }));
assert_eq!(content_of(&emitted[2]), "c");
}
#[test]
fn consecutive_tokens_merge() {
let mut c = Coalescer::default();
assert!(c.push(token("Hello")).is_empty());
assert!(c.push(token(", ")).is_empty());
assert!(c.push(token("world")).is_empty());
let pending = c.take_pending().expect("buffer should hold merged token");
assert!(matches!(pending, AgentEvent::Token { .. }));
assert_eq!(content_of(&pending), "Hello, world");
assert!(!c.has_pending());
}
#[test]
fn consecutive_reasoning_tokens_merge_but_not_with_token() {
let mut c = Coalescer::default();
assert!(c.push(reasoning("think")).is_empty());
assert!(c.push(reasoning("ing")).is_empty());
let out = c.push(token("answer"));
assert_eq!(out.len(), 1);
assert!(matches!(out[0], AgentEvent::ReasoningToken { .. }));
assert_eq!(content_of(&out[0]), "thinking");
let pending = c.take_pending().unwrap();
assert!(matches!(pending, AgentEvent::Token { .. }));
assert_eq!(content_of(&pending), "answer");
}
#[test]
fn non_token_event_flushes_pending_first() {
let mut c = Coalescer::default();
assert!(c.push(token("partial")).is_empty());
let out = c.push(tool_start("t1"));
assert_eq!(out.len(), 2, "flushed token then the tool_start");
assert!(matches!(out[0], AgentEvent::Token { .. }));
assert_eq!(content_of(&out[0]), "partial");
assert!(matches!(out[1], AgentEvent::ToolStart { .. }));
assert!(!c.has_pending());
}
#[test]
fn non_token_event_without_pending_passes_through() {
let mut c = Coalescer::default();
let out = c.push(tool_start("t1"));
assert_eq!(out.len(), 1);
assert!(matches!(out[0], AgentEvent::ToolStart { .. }));
assert!(!c.has_pending());
}
#[test]
fn tool_token_different_id_does_not_merge() {
let mut c = Coalescer::default();
assert!(c.push(tool_token("t1", "aa")).is_empty());
assert!(c.push(tool_token("t1", "bb")).is_empty());
let out = c.push(tool_token("t2", "cc"));
assert_eq!(out.len(), 1);
match &out[0] {
AgentEvent::ToolToken {
tool_call_id,
content,
} => {
assert_eq!(tool_call_id, "t1");
assert_eq!(content, "aabb");
}
other => panic!("expected ToolToken t1, got {other:?}"),
}
match c.take_pending().unwrap() {
AgentEvent::ToolToken {
tool_call_id,
content,
} => {
assert_eq!(tool_call_id, "t2");
assert_eq!(content, "cc");
}
other => panic!("expected ToolToken t2, got {other:?}"),
}
}
#[test]
fn tool_token_same_id_merges() {
let mut c = Coalescer::default();
assert!(c.push(tool_token("t1", "x")).is_empty());
assert!(c.push(tool_token("t1", "y")).is_empty());
match c.take_pending().unwrap() {
AgentEvent::ToolToken {
tool_call_id,
content,
} => {
assert_eq!(tool_call_id, "t1");
assert_eq!(content, "xy");
}
other => panic!("expected ToolToken t1, got {other:?}"),
}
}
#[test]
fn terminal_event_flushes_pending_first() {
let mut c = Coalescer::default();
assert!(c.push(token("final answer")).is_empty());
let out = c.push(complete());
assert_eq!(out.len(), 2);
assert!(matches!(out[0], AgentEvent::Token { .. }));
assert_eq!(content_of(&out[0]), "final answer");
assert!(matches!(out[1], AgentEvent::Complete { .. }));
assert!(!c.has_pending());
}
#[test]
fn oversized_buffer_force_flushes() {
let mut c = Coalescer::default();
let chunk = "x".repeat(MAX_PENDING_CONTENT_BYTES / 2 + 1);
assert!(
c.push(token(&chunk)).is_empty(),
"first chunk stays buffered (under cap)"
);
let out = c.push(token(&chunk));
assert_eq!(out.len(), 1, "second chunk crosses the cap → force-flush");
assert!(matches!(out[0], AgentEvent::Token { .. }));
assert_eq!(content_of(&out[0]).len(), chunk.len() * 2);
assert!(!c.has_pending());
assert!(c.push(token("next")).is_empty());
assert_eq!(content_of(&c.take_pending().unwrap()), "next");
}
#[test]
fn merged_event_serializes_as_token() {
let mut c = Coalescer::default();
c.push(token("foo"));
c.push(token("bar"));
let merged = c.take_pending().unwrap();
let value = serde_json::to_value(&merged).unwrap();
assert_eq!(value["type"], "token");
assert_eq!(value["content"], "foobar");
}
}