use std::sync::Arc;
use crate::brain::agent::QueuedUserMessage;
use crate::brain::agent::service::AgentService;
use crate::brain::provider::Provider;
use crate::db::Database;
use crate::db::models::Message;
use crate::services::ServiceContext;
use crate::tests::agent_service_mocks::MockProvider;
use crate::tui::app::App;
use crate::tui::app::DisplayMessage;
use crate::tui::render::chat::{turn_anchor, turn_folds_for_display, turn_is_folded};
use std::collections::HashMap;
use uuid::Uuid;
async fn app() -> App {
let db = Database::connect_in_memory().await.unwrap();
db.run_migrations().await.unwrap();
let context = ServiceContext::new(db.pool().clone());
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let service = Arc::new(AgentService::new_for_test(provider, context.clone()).await);
#[cfg(feature = "whatsapp")]
{
App::new(
service,
context,
Arc::new(crate::channels::whatsapp::WhatsAppState::new()),
)
}
#[cfg(not(feature = "whatsapp"))]
{
App::new(service, context)
}
}
fn live(role: &str, content: &str, at: i64) -> DisplayMessage {
DisplayMessage {
id: Uuid::new_v4(),
role: role.to_string(),
content: content.to_string(),
timestamp: chrono::DateTime::from_timestamp(at, 0).expect("valid timestamp"),
token_count: None,
cost: None,
approval: None,
approve_menu: None,
details: None,
expanded: false,
expanded_full: false,
tool_group: None,
duration_secs: None,
}
}
fn persisted(session_id: Uuid, sequence: i32, role: &str, content: &str, at: i64) -> Message {
Message {
id: Uuid::new_v4(),
session_id,
role: role.to_string(),
content: content.to_string(),
sequence,
created_at: chrono::DateTime::from_timestamp(at, 0).expect("valid timestamp"),
token_count: None,
cost: None,
input_tokens: None,
cache_creation_tokens: None,
cache_read_tokens: None,
thinking: None,
duration_secs: None,
}
}
#[test]
fn an_expanded_turn_survives_a_reload_from_the_database() {
let session_id = Uuid::new_v4();
let at = 1_760_000_000i64;
let live_turn = [live("user", "do it", at), live("assistant", "done", at + 5)];
let live_anchor = turn_anchor(&live_turn[0]);
let mut turn_expanded: HashMap<String, bool> = HashMap::new();
turn_expanded.insert(live_anchor.clone(), true);
assert!(
!turn_is_folded(&turn_expanded, &live_anchor),
"sanity: the clicked-open turn is open while it is live"
);
let reloaded: Vec<DisplayMessage> = vec![
persisted(session_id, 1, "user", "do it", at).into(),
persisted(session_id, 2, "assistant", "done", at + 5).into(),
];
assert_ne!(
reloaded[0].id, live_turn[0].id,
"the reload stamps a different UUID; a UUID key could never match"
);
let reloaded_anchor = turn_anchor(&reloaded[0]);
assert_eq!(
reloaded_anchor, live_anchor,
"the anchor must be identical on both sides of the round trip"
);
assert!(
!turn_is_folded(&turn_expanded, &reloaded_anchor),
"a turn the user expanded must still be expanded after a session reload"
);
}
#[test]
fn the_anchor_distinguishes_turns() {
let a = live("user", "first", 1_760_000_000);
let b = live("user", "second", 1_760_000_060);
assert_ne!(turn_anchor(&a), turn_anchor(&b));
let mut turn_expanded: HashMap<String, bool> = HashMap::new();
turn_expanded.insert(turn_anchor(&a), true);
assert!(!turn_is_folded(&turn_expanded, &turn_anchor(&a)));
assert!(
turn_is_folded(&turn_expanded, &turn_anchor(&b)),
"an untouched turn stays folded"
);
}
#[test]
fn a_running_turn_is_never_folded() {
let anchor = turn_anchor(&live("user", "build the thing", 1_760_000_000));
let mut turn_expanded: HashMap<String, bool> = HashMap::new();
assert!(
!turn_folds_for_display(&turn_expanded, &anchor, true, true),
"the newest turn is open while it is still processing"
);
turn_expanded.insert(anchor.clone(), false);
assert!(
!turn_folds_for_display(&turn_expanded, &anchor, true, true),
"a stale fold override must not hide the running turn"
);
assert!(
turn_folds_for_display(&turn_expanded, &anchor, true, false),
"a settled turn honours the recorded fold"
);
turn_expanded.insert(anchor.clone(), true);
assert!(
!turn_folds_for_display(&turn_expanded, &anchor, true, false),
"a settled turn honours the recorded expand"
);
}
#[test]
fn an_older_turn_stays_folded_while_another_one_runs() {
let anchor = turn_anchor(&live("user", "earlier question", 1_760_000_000));
let turn_expanded: HashMap<String, bool> = HashMap::new();
assert!(
turn_folds_for_display(&turn_expanded, &anchor, false, true),
"a turn that is not the running one is unaffected by is_processing"
);
}
#[test]
fn the_anchor_ignores_content_that_the_database_rewrites() {
let at = 1_760_000_000i64;
let live_row = live("user", "look <<IMG:/tmp/shot.png>>", at);
let stored = persisted(
Uuid::new_v4(),
1,
"user",
"look [image attached: /tmp/shot.png]",
at,
);
let reloaded: DisplayMessage = stored.into();
assert_ne!(
live_row.content, reloaded.content,
"the text really does differ"
);
assert_eq!(turn_anchor(&live_row), turn_anchor(&reloaded));
}
#[tokio::test]
async fn cancelling_one_session_does_not_abort_another_sessions_turn() {
let mut app = app().await;
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let turn_a = tokio::spawn(std::future::pending::<()>());
let turn_b = tokio::spawn(std::future::pending::<()>());
app.task_abort_handles.insert(a, turn_a.abort_handle());
app.task_abort_handles.insert(b, turn_b.abort_handle());
app.abort_session_task(a);
let killed = turn_a.await;
assert!(
killed.is_err() && killed.unwrap_err().is_cancelled(),
"the cancelled session's own turn must die"
);
assert!(
!turn_b.is_finished(),
"another session's turn must keep running when a different session cancels"
);
assert!(
app.task_abort_handles.contains_key(&b),
"the other session's handle must still be registered"
);
assert!(
!app.task_abort_handles.contains_key(&a),
"a cancelled handle is consumed, so a second cancel is a no-op"
);
app.abort_session_task(a);
assert!(!turn_b.is_finished());
app.abort_session_task(b);
let killed_b = turn_b.await;
assert!(killed_b.is_err() && killed_b.unwrap_err().is_cancelled());
}
#[tokio::test]
async fn switching_sessions_leaves_the_running_turn_registered() {
let mut app = app().await;
let a = app
.session_service
.create_session(Some("first".into()))
.await
.unwrap();
let b = app
.session_service
.create_session(Some("second".into()))
.await
.unwrap();
let turn = tokio::spawn(std::future::pending::<()>());
app.task_abort_handles.insert(a.id, turn.abort_handle());
app.processing_sessions.insert(a.id);
app.load_session(b.id).await.unwrap();
app.load_session(a.id).await.unwrap();
assert!(
app.task_abort_handles.contains_key(&a.id),
"a switch must not drop the running turn's abort handle"
);
assert!(
!turn.is_finished(),
"a switch must not kill the turn the user left running"
);
assert!(
app.is_processing,
"coming back to a running session must still show it as running"
);
turn.abort();
}
#[tokio::test]
async fn a_queued_message_survives_a_switch_away_and_back() {
let mut app = app().await;
let a = app
.session_service
.create_session(Some("first".into()))
.await
.unwrap();
let b = app
.session_service
.create_session(Some("second".into()))
.await
.unwrap();
app.queued_messages
.lock()
.unwrap()
.entry(a.id)
.or_default()
.push(QueuedUserMessage::plain(
"and also check the logs".to_string(),
));
app.load_session(b.id).await.unwrap();
app.load_session(a.id).await.unwrap();
let queued: Vec<String> = app
.queued_messages
.lock()
.unwrap()
.get(&a.id)
.map(|msgs| msgs.iter().map(|m| m.display_text.clone()).collect())
.unwrap_or_default();
assert_eq!(
queued,
vec!["and also check the logs".to_string()],
"the message typed during the turn must still be queued after the round trip"
);
}