use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;
use bamboo_agent_core::Role;
use crate::app_state::AppState;
const MAX_HISTORY_MESSAGES: usize = 2000;
fn cap_cold_history<T>(
messages: &mut Vec<T>,
is_delta: bool,
is_tool_result: impl Fn(&T) -> bool,
) -> bool {
if is_delta || messages.len() <= MAX_HISTORY_MESSAGES {
return false;
}
let drop = messages.len() - MAX_HISTORY_MESSAGES;
messages.drain(..drop);
let orphan_head = messages.iter().take_while(|m| is_tool_result(m)).count();
if orphan_head > 0 {
messages.drain(..orphan_head);
}
true
}
#[derive(Debug, Default, Deserialize)]
pub struct HistoryQuery {
#[serde(default)]
pub since_message_id: Option<String>,
}
pub async fn handler(
state: web::Data<AppState>,
path: web::Path<String>,
query: web::Query<HistoryQuery>,
) -> impl Responder {
let session_id = path.into_inner();
let runner_active = {
let runners = state.agent_runners.read().await;
runners
.get(&session_id)
.is_some_and(|r| r.completed_at.is_none())
};
let mut session = if runner_active {
match state.storage.load_session(&session_id).await {
Ok(Some(s)) => Some(s),
Ok(None) => {
bamboo_engine::read_cached_session(&state.sessions, &session_id)
}
Err(e) => {
tracing::warn!(
"[{}] Disk read failed during active execution, falling back to memory: {}",
session_id,
e
);
bamboo_engine::read_cached_session(&state.sessions, &session_id)
}
}
} else {
bamboo_engine::read_cached_session(&state.sessions, &session_id)
};
if session.is_none() {
match state.storage.load_session(&session_id).await {
Ok(Some(s)) => session = Some(s),
Ok(None) => {
return HttpResponse::NotFound().json(serde_json::json!({
"error": "Session not found",
"session_id": session_id
}));
}
Err(e) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": format!("Failed to load session: {e}"),
"session_id": session_id
}));
}
}
}
let Some(session) = session else {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Session load unexpectedly returned no data",
"session_id": session_id
}));
};
let mut messages: Vec<_> = session
.messages
.into_iter()
.filter(|message| !bamboo_engine::session_app::execute::is_hidden_from_ui(message))
.collect();
let mut is_delta = false;
if let Some(cursor) = query.since_message_id.as_deref().filter(|c| !c.is_empty()) {
if let Some(idx) = messages.iter().position(|m| m.id == cursor) {
messages.drain(..=idx);
is_delta = true;
}
}
let total_message_count = messages.len();
let truncated = cap_cold_history(&mut messages, is_delta, |m| matches!(m.role, Role::Tool));
let gold_config = session
.metadata
.get(bamboo_engine::model_config_helper::GOLD_CONFIG_METADATA_KEY)
.and_then(|raw| serde_json::from_str::<bamboo_engine::config::GoldConfig>(raw).ok());
let goal_state = session
.metadata
.get("goal.state")
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok());
let mut response = serde_json::json!({
"session_id": session_id,
"messages": messages,
"is_delta": is_delta,
"truncated": truncated,
"total_message_count": total_message_count,
"compression_events": session.compression_events
});
if let Some(gc) = gold_config {
response
.as_object_mut()
.unwrap()
.insert("gold_config".to_string(), serde_json::to_value(gc).unwrap());
}
if let Some(gs) = goal_state {
response
.as_object_mut()
.unwrap()
.insert("goal_state".to_string(), gs);
}
HttpResponse::Ok().json(response)
}
#[cfg(test)]
mod cold_cap_tests {
use super::{cap_cold_history, MAX_HISTORY_MESSAGES};
#[test]
fn cap_cold_history_trims_cold_fetch_to_newest() {
let mut over: Vec<u32> = (0..(MAX_HISTORY_MESSAGES as u32 + 5)).collect();
let newest = *over.last().unwrap();
assert!(cap_cold_history(&mut over, false, |_| false));
assert_eq!(over.len(), MAX_HISTORY_MESSAGES);
assert_eq!(*over.last().unwrap(), newest, "keeps the newest message");
assert_eq!(over[0], 5, "drops the oldest overflow");
let mut small: Vec<u32> = (0..10).collect();
assert!(!cap_cold_history(&mut small, false, |_| false));
assert_eq!(small.len(), 10);
let mut delta: Vec<u32> = (0..(MAX_HISTORY_MESSAGES as u32 + 5)).collect();
assert!(!cap_cold_history(&mut delta, true, |_| false));
assert_eq!(delta.len(), MAX_HISTORY_MESSAGES + 5);
}
#[test]
fn cap_cold_history_drops_leading_orphaned_tool_results_to_safe_boundary() {
let mut msgs: Vec<(u32, bool)> = (0..(MAX_HISTORY_MESSAGES as u32 + 3))
.map(|i| (i, false))
.collect();
msgs[3].1 = true;
msgs[4].1 = true;
let is_tool = |m: &(u32, bool)| m.1;
assert!(cap_cold_history(&mut msgs, false, is_tool));
assert!(
!msgs.first().unwrap().1,
"head must not be a leading orphaned tool_result"
);
assert_eq!(
msgs.first().unwrap().0,
5,
"trimmed past the orphaned pair to the next safe boundary"
);
assert_eq!(msgs.len(), MAX_HISTORY_MESSAGES - 2);
}
#[test]
fn cap_cold_history_keeps_head_when_boundary_already_safe() {
let mut msgs: Vec<(u32, bool)> = (0..(MAX_HISTORY_MESSAGES as u32 + 3))
.map(|i| (i, false))
.collect();
let is_tool = |m: &(u32, bool)| m.1;
assert!(cap_cold_history(&mut msgs, false, is_tool));
assert_eq!(msgs.len(), MAX_HISTORY_MESSAGES, "no extra orphan trim");
assert_eq!(
msgs.first().unwrap().0,
3,
"only the count overflow dropped"
);
}
}
#[cfg(test)]
mod tests {
use actix_web::{http::StatusCode, test, web, App};
use serde_json::Value;
use tempfile::tempdir;
use crate::routes::configure_routes;
use crate::AppState;
use bamboo_agent_core::{Message, Session};
async fn app_state_with_session(messages: Vec<Message>) -> (web::Data<AppState>, String) {
let temp_dir = tempdir().expect("tempdir");
bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
let state = web::Data::new(
AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state"),
);
let mut session = Session::new("hist-delta", "model");
for m in messages {
session.add_message(m);
}
state.save_and_cache_session(&mut session).await;
(state, "hist-delta".to_string())
}
fn seqs(messages: &Value) -> Vec<String> {
messages
.as_array()
.unwrap()
.iter()
.map(|m| m["content"].as_str().unwrap().to_string())
.collect()
}
#[actix_web::test]
async fn delta_history_returns_only_messages_after_cursor() {
let (state, id) = app_state_with_session(vec![
Message::user("m1"),
Message::assistant("m2", None),
Message::user("m3"),
])
.await;
let app = test::init_service(
App::new()
.app_data(state.clone())
.configure(configure_routes),
)
.await;
let full: Value = test::call_and_read_body_json(
&app,
test::TestRequest::get()
.uri(&format!("/api/v1/history/{id}"))
.to_request(),
)
.await;
assert_eq!(full["is_delta"], false);
assert_eq!(seqs(&full["messages"]), vec!["m1", "m2", "m3"]);
let cursor = full["messages"][0]["id"].as_str().unwrap().to_string();
let delta: Value = test::call_and_read_body_json(
&app,
test::TestRequest::get()
.uri(&format!("/api/v1/history/{id}?since_message_id={cursor}"))
.to_request(),
)
.await;
assert_eq!(delta["is_delta"], true);
assert_eq!(seqs(&delta["messages"]), vec!["m2", "m3"]);
}
#[actix_web::test]
async fn history_response_includes_goal_state() {
let temp_dir = tempdir().expect("tempdir");
bamboo_config::paths::init_bamboo_dir(temp_dir.path().to_path_buf());
let state = web::Data::new(
AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state"),
);
let mut session = Session::new("hist-goal", "model");
session.add_message(Message::user("do it"));
session.metadata.insert(
"goal.state".to_string(),
serde_json::json!({
"objective": "ship it",
"status": "complete",
"continuation_count": 1,
"eval_history": [{
"checkpoint": "terminal",
"iteration": 3,
"decision": "achieved",
"confidence": "high",
"reasoning": "verified against current state",
"recorded_at": "2026-06-16T00:00:00Z"
}],
"created_at": "2026-06-16T00:00:00Z",
"updated_at": "2026-06-16T00:00:00Z"
})
.to_string(),
);
state.save_and_cache_session(&mut session).await;
let app = test::init_service(
App::new()
.app_data(state.clone())
.configure(configure_routes),
)
.await;
let resp: Value = test::call_and_read_body_json(
&app,
test::TestRequest::get()
.uri("/api/v1/history/hist-goal")
.to_request(),
)
.await;
assert_eq!(resp["goal_state"]["status"], "complete");
assert_eq!(resp["goal_state"]["continuation_count"], 1);
assert_eq!(
resp["goal_state"]["eval_history"][0]["decision"],
"achieved"
);
assert_eq!(
resp["goal_state"]["eval_history"][0]["checkpoint"],
"terminal"
);
}
#[actix_web::test]
async fn delta_history_unknown_cursor_falls_back_to_full() {
let (state, id) =
app_state_with_session(vec![Message::user("a"), Message::user("b")]).await;
let app = test::init_service(
App::new()
.app_data(state.clone())
.configure(configure_routes),
)
.await;
let resp = test::call_service(
&app,
test::TestRequest::get()
.uri(&format!(
"/api/v1/history/{id}?since_message_id=does-not-exist"
))
.to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body: Value = test::read_body_json(resp).await;
assert_eq!(body["is_delta"], false);
assert_eq!(seqs(&body["messages"]), vec!["a", "b"]);
}
}