use crate::tui::app::background_session::{BackgroundSessionState, SessionStateMut};
#[test]
fn empty_state_reports_no_live_content() {
let bg = BackgroundSessionState::default();
assert!(
!bg.has_live_state(),
"a freshly-defaulted sidecar must report no live state — \
otherwise `demote_to_background` would insert empty \
entries into the map and leak across sessions that never \
actually had a turn"
);
}
#[test]
fn streaming_response_marks_state_as_live() {
let mut bg = BackgroundSessionState::default();
let mut routing = SessionStateMut::Background(&mut bg);
routing.append_streaming_chunk("hello ");
routing.append_streaming_chunk("world");
assert!(bg.has_live_state());
assert_eq!(bg.streaming_response.as_deref(), Some("hello world"));
}
#[test]
fn streaming_chunk_clears_pending_reasoning() {
let mut bg = BackgroundSessionState::default();
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.append_reasoning_chunk("planning the next step");
}
assert!(bg.streaming_reasoning.is_some());
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.append_streaming_chunk("here's the answer");
}
assert_eq!(bg.streaming_reasoning, None);
assert!(bg.streaming_response.is_some());
}
#[test]
fn reasoning_skips_empty_and_whitespace_first_chunks() {
let mut bg = BackgroundSessionState::default();
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.append_reasoning_chunk("");
routing.append_reasoning_chunk(" \n\n ");
}
assert_eq!(bg.streaming_reasoning, None);
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.append_reasoning_chunk("thinking");
routing.append_reasoning_chunk("\n");
}
assert_eq!(bg.streaming_reasoning.as_deref(), Some("thinking\n"));
}
#[test]
fn streaming_output_tokens_accumulate_and_advance_tps_tracker() {
use std::time::Instant;
let mut bg = BackgroundSessionState::default();
let t0 = Instant::now();
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.add_streaming_output_tokens(10);
routing.add_streaming_output_tokens(20);
}
assert_eq!(bg.streaming_output_tokens, 30);
let observed = bg.tps_tracker.active_secs_now(t0);
assert!(observed >= 0.0);
}
#[test]
fn processing_flag_round_trips() {
let mut bg = BackgroundSessionState::default();
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.set_processing(true);
}
assert!(bg.is_processing);
assert!(bg.processing_started_at.is_some());
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.set_processing(false);
}
assert!(!bg.is_processing);
assert!(bg.processing_started_at.is_none());
}
#[test]
fn active_tool_group_round_trips_through_routing_helper() {
use crate::tui::app::{ToolCallEntry, ToolCallGroup};
use serde_json::Value;
let mut bg = BackgroundSessionState::default();
let group = ToolCallGroup {
calls: vec![ToolCallEntry {
description: "grep `IDENTITY` in ~/srv".to_string(),
success: true,
details: None,
completed: false,
tool_input: Value::Null,
}],
expanded: false,
};
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.set_active_tool_group(Some(group));
}
assert!(bg.active_tool_group.is_some());
{
let mut routing = SessionStateMut::Background(&mut bg);
let g = routing
.active_tool_group_mut()
.expect("group should be present after set_active_tool_group");
g.calls[0].completed = true;
g.calls[0].success = false;
}
let g = bg.active_tool_group.as_ref().unwrap();
assert!(g.calls[0].completed);
assert!(!g.calls[0].success);
}
#[test]
fn clear_turn_state_drops_every_live_field() {
let mut bg = BackgroundSessionState::default();
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.append_streaming_chunk("partial");
routing.append_reasoning_chunk("thinking");
routing.set_processing(true);
routing.add_streaming_output_tokens(42);
}
assert!(bg.has_live_state());
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.clear_turn_state();
}
assert!(
!bg.has_live_state(),
"clear_turn_state must drop every in-flight live field — \
this is what ResponseComplete + demote_to_background \
rely on to keep the background_sessions map bounded"
);
}
#[test]
fn display_token_count_and_last_input_tokens_route() {
let mut bg = BackgroundSessionState::default();
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.set_display_token_count(81449);
routing.set_last_input_tokens(81449);
}
assert_eq!(bg.display_token_count, 81449);
assert_eq!(bg.last_input_tokens, Some(81449));
}
#[test]
fn push_message_routes_to_pending_messages_for_background() {
use crate::tui::app::DisplayMessage;
let mut bg = BackgroundSessionState::default();
let msg = DisplayMessage {
id: uuid::Uuid::new_v4(),
role: "user".to_string(),
content: "queued while in background".to_string(),
timestamp: chrono::Utc::now(),
token_count: None,
cost: None,
approval: None,
approve_menu: None,
details: None,
expanded: false,
tool_group: None,
};
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.push_message(msg.clone());
}
assert_eq!(bg.pending_messages.len(), 1);
assert_eq!(bg.pending_messages[0].content, msg.content);
assert!(
bg.has_live_state(),
"a pending message must mark the state as live so demote_to_background \
keeps the entry around for the next focus switch"
);
}
#[test]
fn last_message_mut_returns_latest_pending_for_background() {
use crate::tui::app::DisplayMessage;
let mut bg = BackgroundSessionState::default();
{
let mut routing = SessionStateMut::Background(&mut bg);
for content in ["one", "two", "three"] {
routing.push_message(DisplayMessage {
id: uuid::Uuid::new_v4(),
role: "assistant".to_string(),
content: content.to_string(),
timestamp: chrono::Utc::now(),
token_count: None,
cost: None,
approval: None,
approve_menu: None,
details: None,
expanded: false,
tool_group: None,
});
}
let last = routing
.last_message_mut()
.expect("pending_messages non-empty");
last.content = "three-edited".to_string();
}
assert_eq!(bg.pending_messages.last().unwrap().content, "three-edited");
}
#[test]
fn last_message_mut_returns_none_on_empty_background() {
let mut bg = BackgroundSessionState::default();
let mut routing = SessionStateMut::Background(&mut bg);
assert!(routing.last_message_mut().is_none());
}
#[test]
fn clear_turn_state_preserves_pending_messages() {
use crate::tui::app::DisplayMessage;
let mut bg = BackgroundSessionState::default();
{
let mut routing = SessionStateMut::Background(&mut bg);
routing.push_message(DisplayMessage {
id: uuid::Uuid::new_v4(),
role: "assistant".to_string(),
content: "flushed text".to_string(),
timestamp: chrono::Utc::now(),
token_count: None,
cost: None,
approval: None,
approve_menu: None,
details: None,
expanded: false,
tool_group: None,
});
routing.append_streaming_chunk("in flight");
routing.set_processing(true);
routing.clear_turn_state();
}
assert_eq!(bg.streaming_response, None);
assert!(!bg.is_processing);
assert_eq!(bg.pending_messages.len(), 1);
assert!(bg.has_live_state());
}
const STATE_SRC: &str = include_str!("../tui/app/state.rs");
#[test]
fn approval_requests_are_not_routed_through_session_state_mut() {
let no_comments: String = STATE_SRC
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
for variant in [
"TuiEvent::ToolApprovalRequested",
"TuiEvent::SudoPasswordRequested",
"TuiEvent::SshPasswordRequested",
] {
let arm_start = no_comments
.find(variant)
.unwrap_or_else(|| panic!("expected {variant} arm in state.rs"));
let rest = &no_comments[arm_start + variant.len()..];
let arm_end = rest
.find("\n TuiEvent::")
.map(|off| arm_start + variant.len() + off)
.unwrap_or(no_comments.len());
let arm = &no_comments[arm_start..arm_end];
assert!(
!arm.contains("session_state_mut"),
"{variant} must stay foreground-only — routing it through \
session_state_mut would let a background-session turn block on \
approval with no UI surface. See background_session.rs doc \
for the routing model."
);
}
}