use crate::app_state::AppState;
use crate::session_app::metadata::{MetadataError, SessionMetadataService};
use bamboo_agent_core::{AgentEvent, Session, TitleSource};
async fn make_state() -> AppState {
let temp_dir = tempfile::tempdir().unwrap();
AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state init")
}
async fn seed_session(state: &AppState, session_id: &str, title: &str) -> Session {
let mut session = Session::new(session_id.to_string(), "test-model".to_string());
session.title = title.to_string();
state
.storage
.save_session(&session)
.await
.expect("seed save");
session
}
#[tokio::test]
async fn set_title_bumps_version_and_emits_event() {
let state = make_state().await;
seed_session(&state, "s1", "New Session").await;
let sender = state.get_session_event_sender("s1").await;
let mut subscriber = sender.subscribe();
let result = SessionMetadataService::set_title(&state, "s1", " Hello ", None)
.await
.expect("set_title ok");
let (applied_title, version) = result.expect("change applied");
assert_eq!(applied_title, "Hello");
assert_eq!(version, 1);
let persisted = state.storage.load_session("s1").await.unwrap().unwrap();
assert_eq!(persisted.title, "Hello");
assert_eq!(persisted.title_version, 1);
assert_eq!(persisted.metadata_version, 1);
let event = tokio::time::timeout(std::time::Duration::from_millis(100), subscriber.recv())
.await
.expect("event before timeout")
.expect("event received");
match event {
AgentEvent::SessionTitleUpdated {
session_id,
title,
title_version,
source,
..
} => {
assert_eq!(session_id, "s1");
assert_eq!(title, "Hello");
assert_eq!(title_version, 1);
assert_eq!(source, TitleSource::Manual);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[tokio::test]
async fn set_title_short_circuits_when_unchanged() {
let state = make_state().await;
seed_session(&state, "s1", "Same").await;
let sender = state.get_session_event_sender("s1").await;
let mut subscriber = sender.subscribe();
let result = SessionMetadataService::set_title(&state, "s1", "Same", None)
.await
.expect("ok");
assert!(result.is_none());
let persisted = state.storage.load_session("s1").await.unwrap().unwrap();
assert_eq!(persisted.title_version, 0);
assert_eq!(persisted.metadata_version, 0);
let event_or_timeout =
tokio::time::timeout(std::time::Duration::from_millis(50), subscriber.recv()).await;
assert!(event_or_timeout.is_err(), "no event should be broadcast");
}
#[tokio::test]
async fn apply_generated_title_aborts_on_concurrent_rename() {
let state = make_state().await;
seed_session(&state, "s1", "User Picked This").await;
let result = SessionMetadataService::apply_generated_title(
&state,
"s1",
"Auto Title",
TitleSource::Auto,
false,
)
.await
.expect("ok");
assert!(
result.is_none(),
"should abort because title is no longer untitled"
);
let persisted = state.storage.load_session("s1").await.unwrap().unwrap();
assert_eq!(persisted.title, "User Picked This");
}
#[tokio::test]
async fn apply_generated_title_force_overrides_existing() {
let state = make_state().await;
seed_session(&state, "s1", "User Picked This").await;
let result = SessionMetadataService::apply_generated_title(
&state,
"s1",
"Forced Auto",
TitleSource::Auto,
true,
)
.await
.expect("ok");
let (applied, version) = result.expect("force applied");
assert_eq!(applied, "Forced Auto");
assert_eq!(version, 1);
let persisted = state.storage.load_session("s1").await.unwrap().unwrap();
assert_eq!(persisted.title, "Forced Auto");
assert_eq!(persisted.metadata_version, 1);
}
#[tokio::test]
async fn apply_generated_title_accepts_prompt_scoped_default_placeholder() {
let state = make_state().await;
seed_session(&state, "s1", "New session with Bodhi").await;
let result = SessionMetadataService::apply_generated_title(
&state,
"s1",
"Real Generated Title",
TitleSource::Auto,
false,
)
.await
.expect("ok");
let (applied, version) = result.expect("applied");
assert_eq!(applied, "Real Generated Title");
assert_eq!(version, 1);
let persisted = state.storage.load_session("s1").await.unwrap().unwrap();
assert_eq!(persisted.title, "Real Generated Title");
assert_eq!(persisted.title_version, 1);
}
#[tokio::test]
async fn apply_generated_title_uses_correct_source_label() {
let state = make_state().await;
seed_session(&state, "s1", "New Session").await;
let sender = state.get_session_event_sender("s1").await;
let mut subscriber = sender.subscribe();
SessionMetadataService::apply_generated_title(
&state,
"s1",
"Heuristic Title",
TitleSource::Fallback,
false,
)
.await
.expect("ok")
.expect("applied");
let event = tokio::time::timeout(std::time::Duration::from_millis(100), subscriber.recv())
.await
.expect("event")
.expect("not closed");
match event {
AgentEvent::SessionTitleUpdated { source, .. } => {
assert_eq!(source, TitleSource::Fallback);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[tokio::test]
async fn set_pinned_emits_event_and_updates_disk() {
let state = make_state().await;
seed_session(&state, "s1", "Title").await;
let sender = state.get_session_event_sender("s1").await;
let mut subscriber = sender.subscribe();
let result = SessionMetadataService::set_pinned(&state, "s1", true, None)
.await
.expect("ok");
assert_eq!(result, Some(true));
let persisted = state.storage.load_session("s1").await.unwrap().unwrap();
assert!(persisted.pinned);
assert_eq!(persisted.metadata_version, 1);
let event = tokio::time::timeout(std::time::Duration::from_millis(100), subscriber.recv())
.await
.expect("event")
.expect("not closed");
match event {
AgentEvent::SessionPinnedUpdated {
session_id, pinned, ..
} => {
assert_eq!(session_id, "s1");
assert!(pinned);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[tokio::test]
async fn set_pinned_short_circuits_when_unchanged() {
let state = make_state().await;
seed_session(&state, "s1", "Title").await;
let result = SessionMetadataService::set_pinned(&state, "s1", false, None)
.await
.expect("ok");
assert!(result.is_none());
}
#[tokio::test]
async fn set_title_honors_matching_if_match() {
let state = make_state().await;
seed_session(&state, "s1", "New Session").await;
let result = SessionMetadataService::set_title(&state, "s1", "Renamed", Some(0))
.await
.expect("matching precondition applies");
assert_eq!(result.expect("applied").1, 1);
let persisted = state.storage.load_session("s1").await.unwrap().unwrap();
assert_eq!(persisted.metadata_version, 1);
}
#[tokio::test]
async fn set_title_rejects_stale_if_match() {
let state = make_state().await;
seed_session(&state, "s1", "New Session").await;
SessionMetadataService::set_pinned(&state, "s1", true, None)
.await
.expect("ok")
.expect("applied");
let err = SessionMetadataService::set_title(&state, "s1", "Nope", Some(0))
.await
.expect_err("stale precondition must conflict");
match err {
MetadataError::VersionConflict { expected, current } => {
assert_eq!(expected, 0);
assert_eq!(current, 1);
}
other => panic!("unexpected error: {other:?}"),
}
let persisted = state.storage.load_session("s1").await.unwrap().unwrap();
assert_eq!(persisted.title, "New Session");
}
#[tokio::test]
async fn set_title_returns_not_found_for_unknown_session() {
let state = make_state().await;
let err = SessionMetadataService::set_title(&state, "missing", "x", None)
.await
.unwrap_err();
assert!(matches!(err, MetadataError::NotFound(_)));
}
#[tokio::test]
async fn concurrent_authoritative_title_writes_serialize() {
let state = std::sync::Arc::new(make_state().await);
seed_session(&state, "c1", "New Session").await;
let sender = state.get_session_event_sender("c1").await;
let mut subscriber = sender.subscribe();
let state_a = state.clone();
let state_b = state.clone();
let (a, b) = tokio::join!(
SessionMetadataService::set_title(state_a.as_ref(), "c1", "Title A", None),
SessionMetadataService::set_title(state_b.as_ref(), "c1", "Title B", None),
);
let a = a.expect("A ok").expect("A applied");
let b = b.expect("B ok").expect("B applied");
assert!(
a.1 != b.1,
"concurrent writes must produce distinct title_versions"
);
assert!(
a.1 == 1 && b.1 == 2 || a.1 == 2 && b.1 == 1,
"versions must be 1 and 2"
);
let persisted = state.storage.load_session("c1").await.unwrap().unwrap();
assert!(
persisted.title == "Title A" || persisted.title == "Title B",
"final title must be one of the two writes"
);
assert_eq!(persisted.title_version, 2);
assert_eq!(persisted.metadata_version, 2);
let event1 = tokio::time::timeout(std::time::Duration::from_millis(200), subscriber.recv())
.await
.expect("event1")
.expect("not closed");
let event2 = tokio::time::timeout(std::time::Duration::from_millis(200), subscriber.recv())
.await
.expect("event2")
.expect("not closed");
let versions: Vec<u64> = vec![
match &event1 {
AgentEvent::SessionTitleUpdated { title_version, .. } => *title_version,
_ => panic!("unexpected event: {event1:?}"),
},
match &event2 {
AgentEvent::SessionTitleUpdated { title_version, .. } => *title_version,
_ => panic!("unexpected event: {event2:?}"),
},
];
assert_eq!(versions, vec![1, 2], "event order must match commit order");
}
#[tokio::test]
async fn manual_title_beats_generated_title_without_lying_event() {
let state = std::sync::Arc::new(make_state().await);
seed_session(&state, "m1", "New Session").await;
let sender = state.get_session_event_sender("m1").await;
let mut subscriber = sender.subscribe();
let state_gen = state.clone();
let state_manual = state.clone();
let manual = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
SessionMetadataService::set_title(state_manual.as_ref(), "m1", "Manual Override", None).await
});
let gen = tokio::spawn(async move {
SessionMetadataService::apply_generated_title(
state_gen.as_ref(),
"m1",
"Auto Generated",
TitleSource::Auto,
false,
)
.await
});
let manual_result = manual.await.expect("manual ok").expect("manual ok");
let _gen_result = gen.await.expect("gen ok").expect("gen ok");
let _manual_changed = manual_result.expect("manual applied");
let persisted = state.storage.load_session("m1").await.unwrap().unwrap();
assert!(persisted.title == "Manual Override" || persisted.title == "Auto Generated");
let mut saw_manual = false;
let mut events: Vec<AgentEvent> = Vec::new();
while let Ok(Ok(e)) =
tokio::time::timeout(std::time::Duration::from_millis(100), subscriber.recv()).await
{
events.push(e);
}
for e in &events {
if let AgentEvent::SessionTitleUpdated { source, .. } = e {
if *source == TitleSource::Manual {
saw_manual = true;
}
}
}
assert!(saw_manual, "must emit manual event");
assert!(!events.is_empty(), "must emit at least one event");
}
#[tokio::test]
async fn set_pinned_then_runtime_save_does_not_clobber() {
let state = make_state().await;
seed_session(&state, "p1", "Title").await;
SessionMetadataService::set_pinned(&state, "p1", true, None)
.await
.expect("ok")
.expect("applied");
let after_pin = state.storage.load_session("p1").await.unwrap().unwrap();
assert!(after_pin.pinned);
assert_eq!(after_pin.metadata_version, 1);
let mut runtime_copy = Session::new("p1".to_string(), "test-model");
runtime_copy.pinned = false;
runtime_copy.metadata_version = 0;
runtime_copy.title = "Title".to_string();
state
.persistence
.merge_save_runtime(&mut runtime_copy)
.await
.expect("runtime save ok");
let after_runtime = state.storage.load_session("p1").await.unwrap().unwrap();
assert!(after_runtime.pinned, "runtime save must not clobber pinned");
assert_eq!(
after_runtime.metadata_version, 1,
"runtime save must preserve metadata_version"
);
}