use super::*;
use rpi_tui::terminal::{InputEvent, Terminal, TerminalInfo};
use std::sync::atomic::{AtomicUsize, Ordering};
fn state_with_root(root: &std::path::Path) -> Arc<TuiState> {
let mut state = test_tui_state();
Arc::get_mut(&mut state).unwrap().session_dir = root.to_path_buf();
state
}
fn assert_session_locked(path: &str) {
let lock = std::fs::OpenOptions::new()
.create(true)
.write(true)
.open(format!("{path}.lock"))
.unwrap();
assert!(
fs2::FileExt::try_lock_exclusive(&lock).is_err(),
"{path} must remain exclusively locked"
);
}
fn empty_catalog() -> Arc<std::sync::RwLock<Vec<rpi_ai::Model>>> {
Arc::new(std::sync::RwLock::new(Vec::new()))
}
#[tokio::test]
async fn tui_fork_locks_the_copy_and_keeps_the_selected_root_for_switch_and_picker() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path().join("project");
std::fs::create_dir_all(&cwd).unwrap();
let root = temporary.path().join("custom sessions");
let source = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
source
.append_message(user_message_with_images("custom-root history", vec![]))
.await
.unwrap();
let initial_id = source.storage().metadata().id.clone();
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(source).await.unwrap();
let state = state_with_root(&root);
let context = rpi_extensions::ToolCallContext::new(cwd.to_string_lossy());
fork_session(&harness, &cwd, &state.chat_container, &state, &context).await;
let fork_id = harness.session().storage().metadata().id.clone();
assert_ne!(fork_id, initial_id);
let metadata = crate::session::list_session_metadata_in_dir(&cwd.to_string_lossy(), &root)
.await
.unwrap();
assert_eq!(metadata.len(), 2);
for row in &metadata {
assert_session_locked(&row.path);
}
let lane: Arc<dyn AgentLane> = harness.lane("main");
assert!(
switch_to_session(
&harness,
&lane,
&initial_id,
&cwd,
&state.chat_container,
&state,
&context,
&empty_catalog()
)
.await
);
assert_eq!(harness.session().storage().metadata().id, initial_id);
let rendered = strip_ansi(&state.chat_container.render(160).join("\n"));
assert!(rendered.contains("custom-root history"));
let tui = Arc::new(TuiAltScreen::new(
Box::new(ProcessTerminal::new()),
true,
None,
));
let editor_container = Arc::new(Container::new());
let (tx, mut rx) = mpsc::unbounded_channel();
open_session_selector(&state, &editor_container, &state.editor, &tui, &tx);
assert!(
state.selector_open(),
"picker must find sessions in the selected root"
);
let router = state
.active_selector
.lock()
.unwrap()
.as_ref()
.unwrap()
.0
.clone();
router.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let Some(TuiMessage::SwitchSession(selected)) =
tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.unwrap()
else {
panic!("session selection expected")
};
assert!(metadata.iter().any(|row| row.path.contains(&selected)));
assert!(!cwd.join(".rpi/sessions").exists());
}
#[tokio::test]
async fn tui_import_preserves_identity_history_and_source_across_cwds() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path().join("project");
let old_cwd = temporary.path().join("original project");
std::fs::create_dir_all(&cwd).unwrap();
std::fs::create_dir_all(&old_cwd).unwrap();
let root = temporary.path().join("custom sessions");
let source_root = temporary.path().join("source");
let source = crate::session::create_jsonl_session(&source_root, &old_cwd.to_string_lossy())
.await
.unwrap();
source.set_name(Some("Imported history")).await.unwrap();
source
.append_message(user_message_with_images(
"preserved imported prompt",
vec![],
))
.await
.unwrap();
let expected = source.storage().metadata().id.clone();
let source_path = crate::session::list_session_files_sync(&source_root).remove(0);
let original = std::fs::read(&source_path).unwrap();
let harness = AgentHarness::create(Default::default()).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let state = state_with_root(&root);
let context = rpi_extensions::ToolCallContext::new(cwd.to_string_lossy());
import_session(
&harness,
&lane,
&source_path.to_string_lossy(),
&cwd,
&state.chat_container,
&state,
&context,
&empty_catalog(),
)
.await;
assert_eq!(harness.session().storage().metadata().id, expected);
assert_eq!(
harness.session().get_name().await.unwrap().as_deref(),
Some("Imported history")
);
assert!(strip_ansi(&state.chat_container.render(160).join("\n"))
.contains("preserved imported prompt"));
let metadata = crate::session::list_session_metadata_in_dir(&cwd.to_string_lossy(), &root)
.await
.unwrap();
assert_eq!(metadata.len(), 1);
assert_eq!(metadata[0].cwd, old_cwd.to_string_lossy());
assert_session_locked(&metadata[0].path);
let restored =
crate::session::open_session_by_id_in_dir(&expected, &cwd.to_string_lossy(), &root)
.await
.map_err(|error| error.to_string())
.unwrap()
.session;
assert_eq!(
restored.get_name().await.unwrap().as_deref(),
Some("Imported history")
);
let imported = std::fs::read(&metadata[0].path).unwrap();
assert_eq!(imported, original);
for path in [&source_path, &std::path::PathBuf::from(&metadata[0].path)] {
assert!(
crate::session::import_session_file(path, &cwd.to_string_lossy(), &root)
.await
.err()
.unwrap()
.contains("already exists")
);
}
assert_eq!(std::fs::read(&source_path).unwrap(), original);
assert_eq!(std::fs::read(&metadata[0].path).unwrap(), imported);
assert_eq!(crate::session::list_session_files_sync(&root).len(), 1);
}
#[tokio::test]
async fn invalid_import_leaves_the_active_session_and_repository_untouched() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("selected");
let invalid = cwd.join("invalid.jsonl");
std::fs::write(&invalid, "not a session\n").unwrap();
let harness = AgentHarness::create(Default::default()).await.unwrap();
let initial = harness.session().storage().metadata().id.clone();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let state = state_with_root(&root);
let context = rpi_extensions::ToolCallContext::new(cwd.to_string_lossy());
import_session(
&harness,
&lane,
&invalid.to_string_lossy(),
cwd,
&state.chat_container,
&state,
&context,
&empty_catalog(),
)
.await;
assert_eq!(harness.session().storage().metadata().id, initial);
assert!(crate::session::list_session_files_sync(&root).is_empty());
assert!(
std::fs::read_dir(&root).unwrap().next().is_none(),
"staging files must be cleaned up"
);
assert_eq!(std::fs::read_to_string(invalid).unwrap(), "not a session\n");
}
#[tokio::test]
async fn session_switch_banner_reports_real_capabilities() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
std::fs::create_dir_all(&root).unwrap();
let first = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let first_id = first.storage().metadata().id.clone();
first
.append_message(user_message_with_images("first session history", vec![]))
.await
.unwrap();
let second = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let second_id = second.storage().metadata().id.clone();
assert_ne!(first_id, second_id);
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(second).await.unwrap();
harness
.set_tools(
vec![rpi_harness::types::HarnessTool::new(Arc::new(
BannerProbeTool::new("probe-tool"),
))],
None,
)
.await
.unwrap();
harness
.set_resources(rpi_harness::types::AgentHarnessResources {
skills: Some(vec![rpi_harness::types::Skill {
name: "demo-skill".into(),
description: "a skill the banner must advertise".into(),
content: String::new(),
file_path: "/skills/demo-skill/SKILL.md".into(),
disable_model_invocation: None,
}]),
..Default::default()
})
.await
.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let state = state_with_root(&root);
let context = rpi_extensions::ToolCallContext::new(cwd.to_string_lossy());
assert!(
switch_to_session(
&harness,
&lane,
&first_id,
cwd,
&state.chat_container,
&state,
&context,
&empty_catalog()
)
.await
);
let banner = strip_ansi(&state.chat_container.render(160).join("\n"));
assert!(
banner.contains("Skills (1)"),
"the banner must count the session's skill: {banner}"
);
assert!(
banner.contains("demo-skill"),
"the banner must name the session's skill: {banner}"
);
assert!(
!banner.contains("Skills (0)"),
"the banner must not fall back to the empty capability state: {banner}"
);
assert!(
!banner.contains("Tools (0) none"),
"the banner must not report zero tools: {banner}"
);
assert!(
banner.contains("Tools (1)") && banner.contains("probe-tool"),
"the banner must count the session's tools: {banner}"
);
}
#[tokio::test]
async fn new_session_creates_a_file_and_resets_footer() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
std::fs::create_dir_all(&root).unwrap();
let source = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let previous_id = source.storage().metadata().id.clone();
source
.append_message(user_message_with_images(
"history that stays behind",
vec![],
))
.await
.unwrap();
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(source).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let state = state_with_root(&root);
let context = rpi_extensions::ToolCallContext::new(cwd.to_string_lossy());
state.footer.set_model("(prov) model");
state.footer.set_context_window(200_000);
state.footer.set_session_name(Some("old session"));
state.footer.add_usage(12_000, 3_400, 40_000, 1_200, 0.421);
state.footer.set_context_tokens(Some(126_400));
assert!(
start_new_session(
&harness,
&lane,
cwd,
&state.chat_container,
&state,
&context
)
.await
);
let new_id = harness.session().storage().metadata().id.clone();
assert_ne!(new_id, previous_id, "`/new` must swap the session id");
assert_eq!(
std::env::var(rpi_plugin_sdk::SESSION_ID_ENV).unwrap_or_default(),
new_id,
"the plugin env channel must follow the swap"
);
assert_eq!(
context.session_id().as_deref(),
Some(new_id.as_str()),
"the plugin tool context must follow the swap"
);
let files = jsonl_files_under(&root);
assert!(
files.iter().any(|path| path.contains(&new_id)),
"new session file missing under {}: {files:?}",
root.display()
);
assert_eq!(
files.iter().filter(|path| path.contains(&new_id)).count(),
1,
"exactly one file may back the new session: {files:?}"
);
assert!(
files.iter().any(|path| path.contains(&previous_id)),
"the previous session file must survive: {files:?}"
);
let stats = strip_ansi(&state.footer.render(120).join("\n"));
assert!(!stats.contains("↑12k"), "usage must reset: {stats}");
assert!(!stats.contains("$0.421"), "cost must reset: {stats}");
assert!(!stats.contains("old session"), "name must reset: {stats}");
assert!(
!stats.contains("63.2%"),
"context badge must reset: {stats}"
);
assert!(stats.contains("(prov) model"), "model kept: {stats}");
assert!(stats.contains("200k"), "window kept: {stats}");
let banner = strip_ansi(&state.chat_container.render(160).join("\n"));
assert!(
!banner.contains("history that stays behind"),
"the old transcript must not follow into the new session: {banner}"
);
assert!(
banner.contains(&format!("Started new session {new_id}.")),
"the note must name the new session: {banner}"
);
}
fn jsonl_files_under(root: &std::path::Path) -> Vec<String> {
let mut found = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(dir) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
pending.push(path);
} else if path.extension().is_some_and(|ext| ext == "jsonl")
&& !path.to_string_lossy().ends_with(".progress.jsonl")
{
found.push(path.to_string_lossy().into_owned());
}
}
}
found
}
fn ephemeral_harness_session() -> rpi_harness::session::Session {
let storage = rpi_harness::session::memory::InMemorySessionStorage::new(
rpi_harness::session::types::SessionMetadata {
id: "ephemeral".into(),
created_at: 0,
parent_session_id: None,
},
Arc::new(rpi_harness::session::memory::SystemClock),
Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
);
rpi_harness::session::Session::new(Arc::new(storage), None)
}
#[tokio::test]
async fn review_pr98_new_keeps_an_ephemeral_session_off_disk() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness
.set_session(ephemeral_harness_session())
.await
.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let state = state_with_root(&root);
let context = rpi_extensions::ToolCallContext::new(cwd.to_string_lossy());
assert!(!root.exists());
assert!(
start_new_session(
&harness,
&lane,
cwd,
&state.chat_container,
&state,
&context
)
.await
);
lane.append_message(user_message_with_images(
"review-only ephemeral message",
vec![],
))
.await
.unwrap();
let files = jsonl_files_under(cwd);
assert!(
files.is_empty(),
"--no-session must remain in memory after /new; persisted files: {files:?}"
);
}
#[tokio::test]
async fn review_pr98_new_must_not_switch_under_an_unscoped_user_bash_run() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
let source = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let previous_id = source.storage().metadata().id.clone();
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(source).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let state = state_with_root(&root);
let context = rpi_extensions::ToolCallContext::new(cwd.to_string_lossy());
let cancellation = state.begin_user_bash();
assert!(*state.status.lock().unwrap() == RunStatus::Idle);
let switched = start_new_session(
&harness,
&lane,
cwd,
&state.chat_container,
&state,
&context,
)
.await;
assert!(
!switched
|| cancellation.is_cancelled()
|| harness.session().storage().metadata().id == previous_id,
"/new switched while the previous session's unscoped !command remained active"
);
}
fn recorded_assistant_turn(
provider: &str,
model: &str,
usage: rpi_ai::types::Usage,
) -> AgentMessage {
let mut a = rpi_ai::AssistantMessage::empty(
rpi_ai::Api::Faux,
provider,
model,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or_default(),
);
a.stop_reason = rpi_ai::StopReason::Stop;
a.usage = usage;
AgentMessage::Assistant(Box::new(a))
}
#[tokio::test]
async fn switching_back_to_a_session_restores_its_model() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
std::fs::create_dir_all(&root).unwrap();
let first = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let first_id = first.storage().metadata().id.clone();
first
.append_message(recorded_assistant_turn(
"faux",
"second-model",
rpi_ai::types::Usage::zero(),
))
.await
.unwrap();
let second = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let second_id = second.storage().metadata().id.clone();
assert_ne!(first_id, second_id);
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(second).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
lane.set_model(rpi_ai::Model::new(
"first-model",
"first",
rpi_ai::Api::Faux,
"faux",
"http://x",
))
.await
.unwrap();
let state = state_with_root(&root);
let context = rpi_extensions::ToolCallContext::new(cwd.to_string_lossy());
let catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>> =
Arc::new(std::sync::RwLock::new(vec![
rpi_ai::Model::new(
"first-model",
"first",
rpi_ai::Api::Faux,
"faux",
"http://x",
),
rpi_ai::Model::new(
"second-model",
"second",
rpi_ai::Api::Faux,
"faux",
"http://x",
),
]));
assert!(
switch_to_session(
&harness,
&lane,
&first_id,
cwd,
&state.chat_container,
&state,
&context,
&catalog
)
.await
);
let model = lane.get_model().await.unwrap();
assert_eq!(
model.id, "second-model",
"the lane must run the resumed session's own model"
);
let footer = strip_ansi(&state.footer.render(120).join("\n"));
assert!(
footer.contains("(faux) second-model"),
"the footer must show the resumed session's model: {footer}"
);
}
#[tokio::test]
async fn switching_back_to_a_session_restores_its_footer_usage() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
std::fs::create_dir_all(&root).unwrap();
let first = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let first_id = first.storage().metadata().id.clone();
first
.append_message(user_message_with_images("history prompt", vec![]))
.await
.unwrap();
first
.append_message(recorded_assistant_turn(
"faux",
"m",
rpi_ai::types::Usage {
input: 12_000,
output: 3_400,
cache_read: 40_000,
cache_write: 1_200,
..rpi_ai::types::Usage::zero()
},
))
.await
.unwrap();
first
.append_message(recorded_assistant_turn(
"faux",
"m",
rpi_ai::types::Usage {
input: 6_000,
output: 1_000,
cache_read: 20_000,
cache_write: 0,
total_tokens: 126_400,
..rpi_ai::types::Usage::zero()
},
))
.await
.unwrap();
let second = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let second_id = second.storage().metadata().id.clone();
assert_ne!(first_id, second_id);
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(second).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let state = state_with_root(&root);
state.footer.set_context_window(200_000);
state.footer.add_usage(111_111, 22_222, 0, 0, 9.999);
assert!(
switch_to_session(
&harness,
&lane,
&first_id,
cwd,
&state.chat_container,
&state,
&rpi_extensions::ToolCallContext::new(cwd.to_string_lossy()),
&empty_catalog()
)
.await
);
let stats = strip_ansi(&state.footer.render(120).join("\n"));
assert!(stats.contains("↑18k"), "input totals restored: {stats}");
assert!(stats.contains("↓4.4k"), "output totals restored: {stats}");
assert!(
!stats.contains("↑111k"),
"stale totals must be gone: {stats}"
);
assert!(stats.contains("76.9%"), "cache hit rate restored: {stats}");
assert!(stats.contains("63.2%"), "context badge restored: {stats}");
}
#[tokio::test]
async fn unknown_session_model_leaves_the_current_model_in_place() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
std::fs::create_dir_all(&root).unwrap();
let first = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let first_id = first.storage().metadata().id.clone();
first
.append_message(recorded_assistant_turn(
"faux",
"retired-model",
rpi_ai::types::Usage::zero(),
))
.await
.unwrap();
let second = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let second_id = second.storage().metadata().id.clone();
assert_ne!(first_id, second_id);
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(second).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let before = lane.get_model().await.unwrap();
let state = state_with_root(&root);
let catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>> =
Arc::new(std::sync::RwLock::new(vec![rpi_ai::Model::new(
"first-model",
"first",
rpi_ai::Api::Faux,
"faux",
"http://x",
)]));
assert!(
switch_to_session(
&harness,
&lane,
&first_id,
cwd,
&state.chat_container,
&state,
&rpi_extensions::ToolCallContext::new(cwd.to_string_lossy()),
&catalog
)
.await,
"switching must succeed even when the recorded model is unavailable"
);
let after = lane.get_model().await.unwrap();
assert_eq!(after.id, before.id, "the lane model must stay untouched");
assert_eq!(
harness.session().storage().metadata().id,
first_id,
"the switch itself must have completed"
);
}
fn pr104_review_model(id: &str) -> rpi_ai::Model {
rpi_ai::Model::new(id, id, rpi_ai::Api::Faux, "faux", "http://x")
}
#[tokio::test]
async fn review_pr104_replay_preserves_the_live_cache_hit_rate() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
let source = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let source_id = source.storage().metadata().id.clone();
let messages = [
recorded_assistant_turn(
"faux",
"m",
rpi_ai::types::Usage {
input: 100,
output: 1,
cache_read: 900,
..rpi_ai::types::Usage::zero()
},
),
recorded_assistant_turn(
"faux",
"m",
rpi_ai::types::Usage {
input: 900,
output: 1,
cache_read: 100,
..rpi_ai::types::Usage::zero()
},
),
];
for message in &messages {
source.append_message(message.clone()).await.unwrap();
}
let target = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(target).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let state = state_with_root(&root);
let tui = Arc::new(TuiAltScreen::new(
Box::new(ProcessTerminal::new()),
true,
None,
));
for message in messages {
handle_agent_event(
AgentEvent::MessageEnd {
message_id: None,
message,
},
&tui,
&state,
&state.chat_container,
&lane,
)
.await;
}
let before = strip_ansi(&state.footer.render(120).join("\n"));
assert!(before.contains("CH10.0%"), "live hit rate: {before}");
assert!(
switch_to_session(
&harness,
&lane,
&source_id,
cwd,
&state.chat_container,
&state,
&rpi_extensions::ToolCallContext::new(cwd.to_string_lossy()),
&empty_catalog(),
)
.await
);
let after = strip_ansi(&state.footer.render(120).join("\n"));
assert!(
after.contains("CH10.0%"),
"restoring the same two turns must preserve the live rate, not average all turns: {after}"
);
}
#[tokio::test]
async fn review_pr104_restore_model_must_follow_the_selected_branch() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
let source = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let source_id = source.storage().metadata().id.clone();
let selected = source
.append_message(recorded_assistant_turn(
"faux",
"selected",
rpi_ai::types::Usage::zero(),
))
.await
.unwrap();
source
.append_message(recorded_assistant_turn(
"faux",
"abandoned",
rpi_ai::types::Usage::zero(),
))
.await
.unwrap();
source.move_lane("main", Some(&selected)).await.unwrap();
let target = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(target).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
lane.set_model(pr104_review_model("selected"))
.await
.unwrap();
let state = state_with_root(&root);
let catalog = Arc::new(std::sync::RwLock::new(vec![
pr104_review_model("selected"),
pr104_review_model("abandoned"),
]));
assert!(
switch_to_session(
&harness,
&lane,
&source_id,
cwd,
&state.chat_container,
&state,
&rpi_extensions::ToolCallContext::new(cwd.to_string_lossy()),
&catalog,
)
.await
);
assert_eq!(
lane.get_tip_id().await.unwrap().as_deref(),
Some(selected.as_str())
);
assert_eq!(
lane.get_model().await.unwrap().id,
"selected",
"a later entry outside the selected branch must not select the model"
);
}
#[tokio::test]
async fn review_pr104_restore_model_must_ignore_a_child_lane() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
let source = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let source_id = source.storage().metadata().id.clone();
let parent = source
.append_message(recorded_assistant_turn(
"faux",
"parent-model",
rpi_ai::types::Usage::zero(),
))
.await
.unwrap();
source
.create_lane("child-review", Some(&parent))
.await
.unwrap();
source
.view("child-review")
.append_message(recorded_assistant_turn(
"faux",
"child-model",
rpi_ai::types::Usage::zero(),
))
.await
.unwrap();
let target = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(target).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
lane.set_model(pr104_review_model("parent-model"))
.await
.unwrap();
let state = state_with_root(&root);
let catalog = Arc::new(std::sync::RwLock::new(vec![
pr104_review_model("parent-model"),
pr104_review_model("child-model"),
]));
assert!(
switch_to_session(
&harness,
&lane,
&source_id,
cwd,
&state.chat_container,
&state,
&rpi_extensions::ToolCallContext::new(cwd.to_string_lossy()),
&catalog,
)
.await
);
assert_eq!(
lane.get_model().await.unwrap().id,
"parent-model",
"a child lane's later assistant turn must not replace the parent's model"
);
}
#[tokio::test]
async fn review_pr104_fork_preserves_a_model_pick_before_the_next_turn() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
let source = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
source
.append_message(recorded_assistant_turn(
"faux",
"old-model",
rpi_ai::types::Usage::zero(),
))
.await
.unwrap();
let previous_id = source.storage().metadata().id.clone();
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(source).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let state = state_with_root(&root);
apply_model_selection(&lane, &state, pr104_review_model("new-model"), false)
.await
.unwrap();
fork_session(
&harness,
cwd,
&state.chat_container,
&state,
&rpi_extensions::ToolCallContext::new(cwd.to_string_lossy()),
)
.await;
assert_ne!(harness.session().storage().metadata().id, previous_id);
assert_eq!(
lane.get_model().await.unwrap().id,
"new-model",
"/fork must not undo a successful /model selection made before the next assistant turn"
);
}
fn review_084_context_turn(model: &str, tokens: i64) -> AgentMessage {
recorded_assistant_turn(
"faux",
model,
rpi_ai::types::Usage {
input: tokens,
output: 1,
total_tokens: tokens,
..rpi_ai::types::Usage::zero()
},
)
}
#[tokio::test]
async fn rereview_pr104_context_must_not_come_from_a_child_lane() {
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
let source = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let source_id = source.storage().metadata().id.clone();
let main_tip = source
.append_message(review_084_context_turn("parent-model", 50_000))
.await
.unwrap();
source
.create_lane("child-context", Some(&main_tip))
.await
.unwrap();
source
.view("child-context")
.append_message(review_084_context_turn("child-model", 190_000))
.await
.unwrap();
let target = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(target).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let state = state_with_root(&root);
let mut parent_model = pr104_review_model("parent-model");
parent_model.context_window = 200_000;
let catalog = Arc::new(std::sync::RwLock::new(vec![parent_model]));
assert!(
switch_to_session(
&harness,
&lane,
&source_id,
cwd,
&state.chat_container,
&state,
&rpi_extensions::ToolCallContext::new(cwd.to_string_lossy()),
&catalog,
)
.await
);
assert_eq!(lane.get_model().await.unwrap().id, "parent-model");
let footer = strip_ansi(&state.footer.render(160).join("\n"));
assert!(
footer.contains("25.0%/200k"),
"the main context is 50k/200k, not the child's 190k/200k: {footer}"
);
}
#[tokio::test]
async fn rereview_pr104_compaction_must_invalidate_pre_compaction_context() {
use rpi_harness::session::types::{ProvisionedEntry, ProvisionedKind};
let temporary = tempfile::tempdir().unwrap();
let cwd = temporary.path();
let root = cwd.join("sessions");
let source = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let source_id = source.storage().metadata().id.clone();
source
.append_message(review_084_context_turn("model", 100_000))
.await
.unwrap();
source
.append_entry(
ProvisionedEntry {
id: "review-context-compaction".into(),
kind: ProvisionedKind::Compaction {
summary: "Small replacement summary".into(),
retained_tail: vec![],
tokens_before: 100_000,
details: None,
usage: None,
},
},
"main",
)
.await
.unwrap();
let target = crate::session::create_jsonl_session(&root, &cwd.to_string_lossy())
.await
.unwrap();
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_session(target).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let state = state_with_root(&root);
let mut model = pr104_review_model("model");
model.context_window = 200_000;
let catalog = Arc::new(std::sync::RwLock::new(vec![model]));
assert!(
switch_to_session(
&harness,
&lane,
&source_id,
cwd,
&state.chat_container,
&state,
&rpi_extensions::ToolCallContext::new(cwd.to_string_lossy()),
&catalog,
)
.await
);
let footer = strip_ansi(&state.footer.render(160).join("\n"));
assert!(
footer.split_whitespace().any(|part| part == "0.0%/200k"),
"after compaction there is no new context report; old 50% must not reappear: {footer}"
);
}
struct BannerProbeTool {
schema: rpi_ai::types::Tool,
}
impl BannerProbeTool {
fn new(name: &str) -> Self {
Self {
schema: rpi_ai::types::Tool {
name: name.to_string(),
description: "probe tool".into(),
parameters: rpi_ai::types::Schema::new(serde_json::json!({
"type": "object",
"properties": {},
})),
constrained_sampling: None,
},
}
}
}
#[async_trait::async_trait]
impl rpi_agent::AgentTool for BannerProbeTool {
fn schema(&self) -> &rpi_ai::types::Tool {
&self.schema
}
fn label(&self) -> &str {
&self.schema.name
}
fn execution_mode(&self) -> rpi_agent::types::ToolExecutionMode {
rpi_agent::types::ToolExecutionMode::Parallel
}
async fn execute(
&self,
_tool_call_id: &str,
_params: serde_json::Value,
_signal: tokio_util::sync::CancellationToken,
_on_update: Arc<dyn Fn(rpi_agent::types::ToolResultPartial) + Send + Sync>,
) -> Result<rpi_agent::types::AgentToolResult, rpi_agent::AgentError> {
Ok(rpi_agent::types::AgentToolResult::text("probe"))
}
}
fn assistant_with_usage(
input: i64,
output: i64,
cache_read: i64,
cache_write: i64,
cost: f64,
) -> rpi_ai::types::AssistantMessage {
let mut message = rpi_ai::types::AssistantMessage::empty(
rpi_ai::Api::AnthropicMessages,
"probe-provider",
"probe-model",
0,
);
message.stop_reason = rpi_ai::StopReason::Stop;
message.usage = rpi_ai::types::Usage {
input,
output,
cache_read,
cache_write,
total_tokens: input + output + cache_read + cache_write,
cost: rpi_ai::types::UsageCost {
total: cost,
..Default::default()
},
..rpi_ai::types::Usage::zero()
};
message
}
fn footer_text(state: &Arc<TuiState>) -> String {
rpi_tui::utils::strip_ansi(&state.footer.render(200).join(" "))
}
#[tokio::test]
async fn footer_reconcile_rebuilds_totals_after_a_dropped_message_end() {
let harness = AgentHarness::create(Default::default()).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
lane.append_message(user_message_with_images("first", vec![]))
.await
.unwrap();
lane.append_message(AgentMessage::Assistant(Box::new(assistant_with_usage(
1_000, 200, 500, 100, 0.010,
))))
.await
.unwrap();
lane.append_message(user_message_with_images("second", vec![]))
.await
.unwrap();
lane.append_message(AgentMessage::Assistant(Box::new(assistant_with_usage(
2_000, 300, 6_000, 0, 0.020,
))))
.await
.unwrap();
let state = test_tui_state();
state.footer.set_context_window(200_000);
state.footer.add_usage(1_000, 200, 500, 100, 0.010);
let before = footer_text(&state);
assert!(
before.contains("↑1.0k"),
"precondition: only the first turn is counted: {before}"
);
reconcile_footer_from_lane(&lane, &state).await;
let after = footer_text(&state);
assert!(
after.contains("↑3.0k"),
"usage totals must be rebuilt from the log: {after}"
);
assert!(
after.contains("$0.030"),
"cost must be rebuilt from the log: {after}"
);
assert!(
after.contains("CH75.0%"),
"cache-hit rate must follow the latest turn: {after}"
);
}
#[tokio::test]
async fn footer_reconcile_is_idempotent_on_top_of_the_live_totals() {
let harness = AgentHarness::create(Default::default()).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
lane.append_message(AgentMessage::Assistant(Box::new(assistant_with_usage(
1_000, 200, 500, 100, 0.010,
))))
.await
.unwrap();
lane.append_message(AgentMessage::Assistant(Box::new(assistant_with_usage(
2_000, 300, 6_000, 0, 0.020,
))))
.await
.unwrap();
let state = test_tui_state();
state.footer.set_context_window(200_000);
state.footer.add_usage(1_000, 200, 500, 100, 0.010);
state.footer.add_usage(2_000, 300, 6_000, 0, 0.020);
reconcile_footer_from_lane(&lane, &state).await;
let once = footer_text(&state);
reconcile_footer_from_lane(&lane, &state).await;
let twice = footer_text(&state);
assert_eq!(once, twice, "reconciliation must not accumulate");
assert!(
once.contains("↑3.0k"),
"the live totals must survive unchanged: {once}"
);
}
#[tokio::test]
async fn review_distinct_turn_with_same_timestamp_and_usage_is_not_dropped() {
let harness = AgentHarness::create(Default::default()).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let mut first = assistant_with_usage(1000, 200, 500, 0, 0.01);
first.content = vec![rpi_ai::types::Content::text("first response")];
lane.append_message(AgentMessage::Assistant(Box::new(first)))
.await
.unwrap();
let state = test_tui_state();
reconcile_footer_from_lane(&lane, &state).await;
let mut second = assistant_with_usage(1000, 200, 500, 0, 0.01);
second.content = vec![rpi_ai::types::Content::text("different response")];
lane.append_message(AgentMessage::Assistant(Box::new(second.clone())))
.await
.unwrap();
let tui = Arc::new(TuiAltScreen::new(
Box::new(ProcessTerminal::new()),
true,
None,
));
handle_agent_event(
AgentEvent::MessageEnd {
message_id: None,
message: AgentMessage::Assistant(Box::new(second)),
},
&tui,
&state,
&state.chat_container,
&lane,
)
.await;
let text = footer_text(&state);
assert!(
text.contains("↑2.0k") && text.contains("$0.020"),
"a distinct turn was mistaken for a delayed copy: {text}"
);
}
#[tokio::test]
async fn review_reconcile_at_agent_end_is_not_recounted_by_a_later_identical_event() {
let harness = AgentHarness::create(Default::default()).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let message = AgentMessage::Assistant(Box::new(assistant_with_usage(1_000, 200, 500, 0, 0.01)));
lane.append_message(message.clone()).await.unwrap();
let state = test_tui_state();
let tui = Arc::new(TuiAltScreen::new(
Box::new(ProcessTerminal::new()),
true,
None,
));
handle_agent_event(
AgentEvent::MessageEnd {
message_id: None,
message,
},
&tui,
&state,
&state.chat_container,
&lane,
)
.await;
let expected = footer_text(&state);
reconcile_footer_from_lane(&lane, &state).await;
assert_eq!(
footer_text(&state),
expected,
"reconciliation must restore exactly what the live path counted"
);
let next_run =
AgentMessage::Assistant(Box::new(assistant_with_usage(1_000, 200, 500, 0, 0.01)));
lane.append_message(next_run.clone()).await.unwrap();
handle_agent_event(
AgentEvent::MessageEnd {
message_id: None,
message: next_run,
},
&tui,
&state,
&state.chat_container,
&lane,
)
.await;
let after = footer_text(&state);
assert!(
after.contains("↑2.0k"),
"a later identical-shaped turn must count on top of a rebuild: {after}"
);
}
#[tokio::test]
async fn review_pending_message_end_after_reconcile_does_not_double_usage() {
let harness = AgentHarness::create(Default::default()).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let message = AgentMessage::Assistant(Box::new(assistant_with_usage(1_000, 200, 500, 0, 0.01)));
lane.append_message(message.clone()).await.unwrap();
let state = test_tui_state();
let tui = Arc::new(TuiAltScreen::new(
Box::new(ProcessTerminal::new()),
true,
None,
));
handle_agent_event(
AgentEvent::MessageEnd {
message_id: None,
message,
},
&tui,
&state,
&state.chat_container,
&lane,
)
.await;
let expected = footer_text(&state);
reconcile_footer_from_lane(&lane, &state).await;
assert_eq!(
footer_text(&state),
expected,
"a rebuild after the drain applied every event must not double the usage"
);
reconcile_footer_from_lane(&lane, &state).await;
assert_eq!(
footer_text(&state),
expected,
"repeated rebuilds must stay idempotent"
);
}
#[tokio::test]
async fn review_unpersisted_message_after_reconcile_still_counts_once() {
let harness = AgentHarness::create(Default::default()).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
lane.append_message(AgentMessage::Assistant(Box::new(assistant_with_usage(
1_000, 200, 500, 0, 0.010,
))))
.await
.unwrap();
let state = test_tui_state();
let tui = Arc::new(TuiAltScreen::new(
Box::new(ProcessTerminal::new()),
true,
None,
));
reconcile_footer_from_lane(&lane, &state).await;
assert!(
footer_text(&state).contains("↑1.0k"),
"precondition: reconciliation rebuilt the persisted turn"
);
let unpersisted =
AgentMessage::Assistant(Box::new(assistant_with_usage(4_000, 300, 0, 0, 0.040)));
handle_agent_event(
AgentEvent::MessageEnd {
message_id: None,
message: unpersisted,
},
&tui,
&state,
&state.chat_container,
&lane,
)
.await;
let footer = footer_text(&state);
assert!(
footer.contains("↑5.0k"),
"an unpersisted turn must still be counted when its event arrives: {footer}"
);
assert!(
footer.contains("$0.050"),
"its cost must be counted too: {footer}"
);
}
#[tokio::test]
async fn a_sessions_name_reaches_footer_line_one() {
let harness = AgentHarness::create(Default::default()).await.unwrap();
harness.set_name(Some("named-session")).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
let state = test_tui_state();
state.footer.set_cwd("/home/u/proj");
render_session_history(
&harness,
&state.chat_container,
None,
Some(state.extension_session.clone()),
state.images_visible(),
Some(&state.footer),
)
.await;
let footer = footer_text(&state);
assert!(
footer.contains("named-session"),
"the recorded name must render on line 1: {footer}"
);
harness.set_name(None).await.unwrap();
render_session_history(
&harness,
&state.chat_container,
None,
Some(state.extension_session.clone()),
state.images_visible(),
Some(&state.footer),
)
.await;
let cleared = footer_text(&state);
assert!(
!cleared.contains("named-session"),
"a cleared name must leave line 1: {cleared}"
);
assert_eq!(
lane.session_view().get_name().await.unwrap(),
None,
"the lane must observe the same session the footer was rendered from"
);
}
#[tokio::test]
async fn a_dropped_message_end_leaves_the_footer_frozen_without_reconciliation() {
let harness = AgentHarness::create(Default::default()).await.unwrap();
let lane: Arc<dyn AgentLane> = harness.lane("main");
lane.append_message(AgentMessage::Assistant(Box::new(assistant_with_usage(
1_000, 200, 500, 100, 0.010,
))))
.await
.unwrap();
lane.append_message(AgentMessage::Assistant(Box::new(assistant_with_usage(
2_000, 300, 6_000, 0, 0.020,
))))
.await
.unwrap();
let state = test_tui_state();
state.footer.set_context_window(200_000);
state.footer.add_usage(1_000, 200, 500, 100, 0.010);
let last = assistant_with_usage(2_000, 300, 6_000, 0, 0.020);
record_context_usage(&state.footer, &last);
let frozen = footer_text(&state);
assert!(
frozen.contains("↑1.0k") && !frozen.contains("↑3.0k"),
"precondition: totals stay at the dropped-event value: {frozen}"
);
assert!(
!frozen.contains("CH"),
"precondition: the cache-hit rate never got set: {frozen}"
);
assert!(
frozen.contains("4.2%/200k"),
"precondition: the badge DID update, so the freeze looks partial: {frozen}"
);
}
#[tokio::test]
async fn review_the_export_advice_recovers_the_unsaved_message() {
use rpi_ai::types::UserContent;
let harness = AgentHarness::create(Default::default()).await.unwrap();
let text = "only on screen";
let refused = rpi_harness::messages::create_custom_message(
"probe",
UserContent::Text(text.into()),
true,
None,
0,
);
harness.keep_unsaved_message_for_test("a message you sent", refused);
assert_eq!(
harness.unsaved_messages().len(),
1,
"precondition: the harness retained the refused message"
);
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("export.md");
crate::export::export_session(&harness, crate::export::ExportFormat::Markdown, &out)
.await
.unwrap();
let written = std::fs::read_to_string(&out).unwrap();
assert!(
written.contains(text),
"/export must write the refused message it recommends exporting: {written}"
);
let notice = tui_session_storage_notice(&rpi_harness::result::HarnessError::session_storage(
"disk full",
));
assert!(notice.contains("/export"), "{notice}");
}
struct RenderCountingTerminal(Arc<AtomicUsize>);
impl Terminal for RenderCountingTerminal {
fn info(&self) -> TerminalInfo {
TerminalInfo {
columns: 80,
rows: 24,
..Default::default()
}
}
fn write(&self, text: &str) {
if text.contains("\x1b[2J") {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
fn hide_cursor(&self) {}
fn show_cursor(&self) {}
fn move_cursor(&self, _: usize, _: usize) {}
fn clear_screen(&self) {}
fn set_title(&self, _: &str) {}
fn enable_mouse(&self) {}
fn disable_mouse(&self) {}
fn enter_raw_mode(&self) {}
fn exit_raw_mode(&self) {}
fn refresh_size(&self) {}
fn start(&self, _: Box<dyn Fn(InputEvent) + Send + Sync>, _: Box<dyn Fn() + Send + Sync>) {}
fn stop(&self) {}
fn is_tty(&self) -> bool {
false
}
fn set_progress(&self, _: bool) {}
fn flush(&self) {}
}
#[test]
fn review_pr202_theme_watch_notify_repaints_idle_session() {
let frames = Arc::new(AtomicUsize::new(0));
let tui = Arc::new(TuiAltScreen::new(
Box::new(RenderCountingTerminal(frames.clone())),
false,
None,
));
tui.set_layout_root(Some(Arc::new(rpi_tui::Text::new("idle", 0, 0))));
tui.start_readerless();
let before = frames.load(Ordering::SeqCst);
let state = test_tui_state();
let notify_ok = theme_watch_notify(&state, tui.clone());
notify_ok(Ok("Theme reloaded.".into()));
let after_ok = frames.load(Ordering::SeqCst);
assert!(after_ok > before, "success notice must schedule a repaint");
let notify_err = theme_watch_notify(&state, tui.clone());
notify_err(Err("cannot load theme: bad value".into()));
assert!(
frames.load(Ordering::SeqCst) > after_ok,
"error notice must schedule a repaint too"
);
}