use crate::format::*;
use crate::prompt::*;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::RwLock;
use yoagent::agent::Agent;
use yoagent::context::{compact_messages, total_tokens, ContextConfig};
use yoagent::types::{AgentMessage, Content, Message};
use crate::cli::{
AUTO_COMPACT_THRESHOLD, AUTO_SAVE_SESSION_PATH, DEFAULT_SESSION_PATH,
PROACTIVE_COMPACT_THRESHOLD,
};
static COMPACT_THRASH_COUNT: AtomicU32 = AtomicU32::new(0);
const COMPACT_THRASH_THRESHOLD: u32 = 2;
const COMPACT_MIN_REDUCTION: f64 = 0.10;
pub fn reset_compact_thrash() {
COMPACT_THRASH_COUNT.store(0, Ordering::Relaxed);
}
pub fn is_compact_thrashing() -> bool {
COMPACT_THRASH_COUNT.load(Ordering::Relaxed) >= COMPACT_THRASH_THRESHOLD
}
pub fn compact_agent(agent: &mut Agent) -> Option<(usize, u64, usize, u64)> {
let messages = agent.messages().to_vec();
let before_tokens = total_tokens(&messages) as u64;
let before_count = messages.len();
let config = ContextConfig::default();
let compacted = compact_messages(messages, &config);
let after_tokens = total_tokens(&compacted) as u64;
let after_count = compacted.len();
agent.replace_messages(compacted);
if before_tokens == after_tokens {
None
} else {
let reduction = if before_tokens > 0 {
(before_tokens - after_tokens) as f64 / before_tokens as f64
} else {
0.0
};
if reduction < COMPACT_MIN_REDUCTION {
COMPACT_THRASH_COUNT.fetch_add(1, Ordering::Relaxed);
} else {
COMPACT_THRASH_COUNT.store(0, Ordering::Relaxed);
}
Some((before_count, before_tokens, after_count, after_tokens))
}
}
pub fn auto_compact_if_needed(agent: &mut Agent) {
let messages = agent.messages().to_vec();
let used = total_tokens(&messages) as u64;
let ratio = used as f64 / crate::cli::effective_context_tokens() as f64;
if ratio > AUTO_COMPACT_THRESHOLD {
if is_compact_thrashing() {
eprintln!(
"{DIM} ⚠ Context is mostly incompressible — consider /clear or starting a new session{RESET}"
);
return;
}
if let Some((before_count, before_tokens, after_count, after_tokens)) = compact_agent(agent)
{
println!(
"{DIM} ⚡ auto-compacted: {before_count} → {after_count} messages, ~{} → ~{} tokens{RESET}",
format_token_count(before_tokens),
format_token_count(after_tokens)
);
}
}
}
pub fn proactive_compact_if_needed(agent: &mut Agent) -> bool {
let messages = agent.messages().to_vec();
let used = total_tokens(&messages) as u64;
let ratio = used as f64 / crate::cli::effective_context_tokens() as f64;
if ratio > PROACTIVE_COMPACT_THRESHOLD {
if is_compact_thrashing() {
eprintln!(
"{DIM} ⚠ Context is mostly incompressible — consider /clear or starting a new session{RESET}"
);
return false;
}
if let Some((before_count, before_tokens, after_count, after_tokens)) = compact_agent(agent)
{
eprintln!(
"{DIM} ⚡ proactive compact: {before_count} → {after_count} messages, ~{} → ~{} tokens{RESET}",
format_token_count(before_tokens),
format_token_count(after_tokens)
);
return true;
}
}
false
}
pub fn handle_compact(agent: &mut Agent) {
let messages = agent.messages();
let before_count = messages.len();
let before_tokens = total_tokens(messages) as u64;
match compact_agent(agent) {
Some((_, _, after_count, after_tokens)) => {
println!(
"{DIM} compacted: {before_count} → {after_count} messages, ~{} → ~{} tokens{RESET}\n",
format_token_count(before_tokens),
format_token_count(after_tokens)
);
}
None => {
println!(
"{DIM} (nothing to compact — {before_count} messages, ~{} tokens){RESET}\n",
format_token_count(before_tokens)
);
}
}
}
pub fn last_session_exists() -> bool {
std::path::Path::new(AUTO_SAVE_SESSION_PATH).exists()
}
pub fn auto_save_on_exit(agent: &Agent) {
if agent.messages().is_empty() {
return;
}
if let Ok(json) = agent.save_messages() {
let _ = std::fs::create_dir_all(".yoyo");
if std::fs::write(AUTO_SAVE_SESSION_PATH, &json).is_ok() {
eprintln!(
"{DIM} session auto-saved to {AUTO_SAVE_SESSION_PATH} ({} messages){RESET}",
agent.messages().len()
);
}
}
}
pub fn continue_session_path() -> &'static str {
if last_session_exists() {
AUTO_SAVE_SESSION_PATH
} else {
DEFAULT_SESSION_PATH
}
}
pub fn handle_save(agent: &Agent, input: &str) {
let path = input.strip_prefix("/save").unwrap_or("").trim();
let path = if path.is_empty() {
DEFAULT_SESSION_PATH
} else {
path
};
match agent.save_messages() {
Ok(json) => match std::fs::write(path, &json) {
Ok(_) => println!(
"{DIM} (session saved to {path}, {} messages){RESET}\n",
agent.messages().len()
),
Err(e) => eprintln!("{RED} error saving: {e}{RESET}\n"),
},
Err(e) => eprintln!("{RED} error serializing: {e}{RESET}\n"),
}
}
pub fn handle_load(agent: &mut Agent, input: &str) {
let path = input.strip_prefix("/load").unwrap_or("").trim();
let path = if path.is_empty() {
DEFAULT_SESSION_PATH
} else {
path
};
match std::fs::read_to_string(path) {
Ok(json) => match agent.restore_messages(&json) {
Ok(_) => println!(
"{DIM} (session loaded from {path}, {} messages){RESET}\n",
agent.messages().len()
),
Err(e) => eprintln!("{RED} error parsing: {e}{RESET}\n"),
},
Err(e) => eprintln!("{RED} error reading {path}: {e}{RESET}\n"),
}
}
pub fn handle_history(agent: &Agent) {
let messages = agent.messages();
if messages.is_empty() {
println!("{DIM} (no messages in conversation){RESET}\n");
} else {
println!("{DIM} Conversation ({} messages):", messages.len());
for (i, msg) in messages.iter().enumerate() {
let (role, preview) = summarize_message(msg);
let idx = i + 1;
println!(" {idx:>3}. [{role}] {preview}");
}
println!("{RESET}");
}
}
pub fn handle_search(agent: &Agent, input: &str) {
if input == "/search" {
println!("{DIM} usage: /search <query>");
println!(" Search conversation history for messages containing <query>.{RESET}\n");
return;
}
let query = input.trim_start_matches("/search ").trim();
if query.is_empty() {
println!("{DIM} usage: /search <query>{RESET}\n");
return;
}
let messages = agent.messages();
if messages.is_empty() {
println!("{DIM} (no messages to search){RESET}\n");
return;
}
let results = search_messages(messages, query);
if results.is_empty() {
println!(
"{DIM} No matches for '{query}' in {len} messages.{RESET}\n",
len = messages.len()
);
} else {
println!(
"{DIM} {count} match{es} for '{query}':",
count = results.len(),
es = if results.len() == 1 { "" } else { "es" }
);
for (idx, role, preview) in &results {
println!(" {idx:>3}. [{role}] {preview}");
}
println!("{RESET}");
}
}
pub type Bookmarks = HashMap<String, String>;
pub fn parse_bookmark_name(input: &str, prefix: &str) -> Option<String> {
let name = input.strip_prefix(prefix).unwrap_or("").trim().to_string();
if name.is_empty() {
None
} else {
Some(name)
}
}
pub fn handle_mark(agent: &Agent, input: &str, bookmarks: &mut Bookmarks) {
let name = match parse_bookmark_name(input, "/mark") {
Some(n) => n,
None => {
println!("{DIM} usage: /mark <name>");
println!(" Save a bookmark at the current point in the conversation.");
println!(" Use /jump <name> to return to this point later.{RESET}\n");
return;
}
};
match agent.save_messages() {
Ok(json) => {
let msg_count = agent.messages().len();
let overwriting = bookmarks.contains_key(&name);
bookmarks.insert(name.clone(), json);
if overwriting {
println!("{GREEN} ✓ bookmark '{name}' updated ({msg_count} messages){RESET}\n");
} else {
println!("{GREEN} ✓ bookmark '{name}' saved ({msg_count} messages){RESET}\n");
}
}
Err(e) => eprintln!("{RED} error saving bookmark: {e}{RESET}\n"),
}
}
pub fn handle_jump(agent: &mut Agent, input: &str, bookmarks: &Bookmarks) {
let name = match parse_bookmark_name(input, "/jump") {
Some(n) => n,
None => {
println!("{DIM} usage: /jump <name>");
println!(" Restore the conversation to a previously saved bookmark.");
println!(" Messages added after the bookmark will be discarded.{RESET}\n");
return;
}
};
match bookmarks.get(&name) {
Some(json) => match agent.restore_messages(json) {
Ok(_) => {
let msg_count = agent.messages().len();
println!("{GREEN} ✓ jumped to bookmark '{name}' ({msg_count} messages){RESET}\n");
}
Err(e) => eprintln!("{RED} error restoring bookmark: {e}{RESET}\n"),
},
None => {
let available: Vec<&str> = bookmarks.keys().map(|k| k.as_str()).collect();
if available.is_empty() {
eprintln!("{RED} bookmark '{name}' not found — no bookmarks saved yet.");
eprintln!(" Use /mark <name> to save one.{RESET}\n");
} else {
eprintln!("{RED} bookmark '{name}' not found.");
eprintln!("{DIM} available: {}{RESET}\n", available.join(", "));
}
}
}
}
pub fn handle_marks(bookmarks: &Bookmarks) {
if bookmarks.is_empty() {
println!("{DIM} (no bookmarks saved)");
println!(" Use /mark <name> to save a bookmark.{RESET}\n");
} else {
println!("{DIM} Saved bookmarks:");
let mut names: Vec<&String> = bookmarks.keys().collect();
names.sort();
for name in names {
println!(" • {name}");
}
println!("{RESET}");
}
}
const DEFAULT_EXPORT_PATH: &str = "conversation.md";
pub fn format_conversation_as_markdown(messages: &[AgentMessage]) -> String {
let mut out = String::new();
out.push_str("# Conversation\n\n");
for msg in messages {
match msg {
AgentMessage::Llm(Message::User { content, .. }) => {
out.push_str("## User\n\n");
for c in content {
if let Content::Text { text } = c {
out.push_str(text);
out.push_str("\n\n");
}
}
}
AgentMessage::Llm(Message::Assistant { content, .. }) => {
out.push_str("## Assistant\n\n");
for c in content {
match c {
Content::Text { text } if !text.is_empty() => {
out.push_str(text);
out.push_str("\n\n");
}
Content::Thinking { thinking, .. } if !thinking.is_empty() => {
out.push_str("*Thinking:*\n\n> ");
out.push_str(&thinking.replace('\n', "\n> "));
out.push_str("\n\n");
}
_ => {} }
}
}
AgentMessage::Llm(Message::ToolResult {
tool_name, content, ..
}) => {
out.push_str(&format!("### Tool: {tool_name}\n\n"));
let text: String = content
.iter()
.filter_map(|c| match c {
Content::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if !text.is_empty() {
out.push_str("```\n");
out.push_str(&text);
out.push_str("\n```\n\n");
}
}
AgentMessage::Extension(_) => {} }
}
out
}
pub fn parse_export_path(input: &str) -> &str {
let path = input.strip_prefix("/export").unwrap_or("").trim();
if path.is_empty() {
DEFAULT_EXPORT_PATH
} else {
path
}
}
pub fn handle_export(agent: &Agent, input: &str) {
let path = parse_export_path(input);
let messages = agent.messages();
if messages.is_empty() {
println!("{DIM} (no messages to export){RESET}\n");
return;
}
let markdown = format_conversation_as_markdown(messages);
match std::fs::write(path, &markdown) {
Ok(_) => println!(
"{GREEN} ✓ conversation exported to {path} ({} messages){RESET}\n",
messages.len()
),
Err(e) => eprintln!("{RED} error writing to {path}: {e}{RESET}\n"),
}
}
struct StashEntry {
description: String,
messages_json: String,
timestamp: String,
}
static CONVERSATION_STASH: RwLock<Vec<StashEntry>> = RwLock::new(Vec::new());
pub fn parse_stash_subcommand(input: &str) -> (&str, &str) {
let rest = input.strip_prefix("/stash").unwrap_or("").trim();
if rest.is_empty() {
return ("push", "");
}
if rest == "pop" || rest.starts_with("pop ") {
return ("pop", rest.strip_prefix("pop").unwrap_or("").trim());
}
if rest == "list" {
return ("list", "");
}
if rest == "drop" || rest.starts_with("drop ") {
return ("drop", rest.strip_prefix("drop").unwrap_or("").trim());
}
if rest.starts_with("push ") {
return ("push", rest.strip_prefix("push").unwrap_or("").trim());
}
if rest == "push" {
return ("push", "");
}
("push", rest)
}
pub fn handle_stash_push(agent: &mut Agent, description: &str) -> String {
let messages_json = match agent.save_messages() {
Ok(json) => json,
Err(e) => return format!("{RED} failed to save conversation: {e}{RESET}\n"),
};
let msg_count = agent.messages().len();
let mut stash = CONVERSATION_STASH.write().unwrap();
let idx = stash.len();
let desc = if description.is_empty() {
format!("stash@{{{idx}}}")
} else {
description.to_string()
};
let timestamp = {
use std::time::SystemTime;
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let h = (secs % 86400) / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
format!("{h:02}:{m:02}:{s:02}")
};
stash.push(StashEntry {
description: desc.clone(),
messages_json,
timestamp,
});
agent.replace_messages(Vec::new());
format!("{GREEN} ✓ stashed: \"{desc}\" ({msg_count} messages) — conversation cleared{RESET}\n")
}
pub fn handle_stash_pop(agent: &mut Agent) -> String {
let mut stash = CONVERSATION_STASH.write().unwrap();
if stash.is_empty() {
return format!("{DIM} (stash is empty — nothing to pop){RESET}\n");
}
let entry = stash.pop().unwrap();
drop(stash);
match agent.restore_messages(&entry.messages_json) {
Ok(_) => format!(
"{GREEN} ✓ popped: \"{}\" ({} messages restored){RESET}\n",
entry.description,
agent.messages().len()
),
Err(e) => format!("{RED} failed to restore stash: {e}{RESET}\n"),
}
}
pub fn handle_stash_list() -> String {
let stash = CONVERSATION_STASH.read().unwrap();
if stash.is_empty() {
return format!("{DIM} (stash is empty){RESET}\n");
}
let mut out = String::new();
out.push_str(&format!(
"{DIM} Conversation stash ({} entries):\n",
stash.len()
));
for (i, entry) in stash.iter().rev().enumerate() {
let idx = stash.len() - 1 - i;
out.push_str(&format!(
" {idx}: {} [{}]\n",
entry.description, entry.timestamp
));
}
out.push_str(&format!("{RESET}"));
out
}
pub fn handle_stash_drop(index_str: &str) -> String {
let index: usize = if index_str.is_empty() {
let stash = CONVERSATION_STASH.read().unwrap();
if stash.is_empty() {
return format!("{DIM} (stash is empty — nothing to drop){RESET}\n");
}
stash.len() - 1
} else {
match index_str.parse() {
Ok(n) => n,
Err(_) => return format!("{RED} invalid index: {index_str}{RESET}\n"),
}
};
let mut stash = CONVERSATION_STASH.write().unwrap();
if index >= stash.len() {
return format!(
"{RED} stash index {index} out of range (have {} entries){RESET}\n",
stash.len()
);
}
let entry = stash.remove(index);
format!(
"{GREEN} ✓ dropped: \"{}\" (index {index}){RESET}\n",
entry.description
)
}
pub fn handle_stash(agent: &mut Agent, input: &str) -> String {
let (subcmd, arg) = parse_stash_subcommand(input);
match subcmd {
"push" => handle_stash_push(agent, arg),
"pop" => handle_stash_pop(agent),
"list" => handle_stash_list(),
"drop" => handle_stash_drop(arg),
_ => format!("{DIM} unknown stash subcommand: {subcmd}{RESET}\n"),
}
}
#[cfg(test)]
pub fn stash_default_description(index: usize) -> String {
format!("stash@{{{index}}}")
}
pub fn clear_confirmation_message(message_count: usize, token_count: u64) -> Option<String> {
if message_count <= 4 {
return None;
}
Some(format!(
"Clear {} messages (~{} tokens)? [y/N] ",
message_count,
format_token_count(token_count)
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::AUTO_SAVE_SESSION_PATH;
use crate::commands::{is_unknown_command, KNOWN_COMMANDS};
use yoagent::types::Usage;
#[test]
fn test_compact_thrash_constants() {
assert_eq!(COMPACT_THRASH_THRESHOLD, 2);
assert!((COMPACT_MIN_REDUCTION - 0.10).abs() < f64::EPSILON);
}
#[test]
fn test_reset_compact_thrash() {
COMPACT_THRASH_COUNT.store(5, Ordering::Relaxed);
reset_compact_thrash();
assert_eq!(COMPACT_THRASH_COUNT.load(Ordering::Relaxed), 0);
}
#[test]
fn test_compact_thrash_detection_increments_on_low_reduction() {
reset_compact_thrash();
assert!(!is_compact_thrashing());
COMPACT_THRASH_COUNT.fetch_add(1, Ordering::Relaxed);
assert!(!is_compact_thrashing()); COMPACT_THRASH_COUNT.fetch_add(1, Ordering::Relaxed);
assert!(is_compact_thrashing());
reset_compact_thrash(); }
#[test]
fn test_compact_thrash_detection_resets_on_meaningful_reduction() {
reset_compact_thrash();
COMPACT_THRASH_COUNT.store(2, Ordering::Relaxed);
assert!(is_compact_thrashing());
COMPACT_THRASH_COUNT.store(0, Ordering::Relaxed);
assert!(!is_compact_thrashing());
reset_compact_thrash(); }
#[test]
fn test_is_compact_thrashing_boundary() {
reset_compact_thrash();
COMPACT_THRASH_COUNT.store(1, Ordering::Relaxed);
assert!(!is_compact_thrashing());
COMPACT_THRASH_COUNT.store(2, Ordering::Relaxed);
assert!(is_compact_thrashing());
COMPACT_THRASH_COUNT.store(10, Ordering::Relaxed);
assert!(is_compact_thrashing());
reset_compact_thrash(); }
#[test]
fn test_auto_save_session_path_constant() {
assert_eq!(AUTO_SAVE_SESSION_PATH, ".yoyo/last-session.json");
}
#[test]
fn test_continue_session_path_fallback() {
let path = continue_session_path();
assert!(
path == AUTO_SAVE_SESSION_PATH || path == DEFAULT_SESSION_PATH,
"continue_session_path should return a valid session path, got: {path}"
);
}
#[test]
fn test_last_session_exists_returns_bool() {
let _exists = last_session_exists();
}
#[test]
fn test_auto_save_creates_directory_and_file() {
use yoagent::agent::Agent;
use yoagent::provider::AnthropicProvider;
let tmp_dir = std::env::temp_dir().join("yoyo_test_autosave");
let _ = std::fs::remove_dir_all(&tmp_dir);
std::fs::create_dir_all(&tmp_dir).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&tmp_dir).unwrap();
let agent = Agent::new(AnthropicProvider)
.with_system_prompt("test")
.with_model("test-model")
.with_api_key("test-key");
auto_save_on_exit(&agent);
assert!(
!std::path::Path::new(AUTO_SAVE_SESSION_PATH).exists(),
"Should not save empty conversations"
);
std::env::set_current_dir(&original_dir).unwrap();
let _ = std::fs::remove_dir_all(&tmp_dir);
}
#[test]
fn test_continue_session_path_prefers_auto_save() {
let tmp_dir = std::env::temp_dir().join("yoyo_test_continue_path");
let _ = std::fs::remove_dir_all(&tmp_dir);
std::fs::create_dir_all(tmp_dir.join(".yoyo")).unwrap();
std::fs::write(tmp_dir.join(".yoyo/last-session.json"), "[]").unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&tmp_dir).unwrap();
let path = continue_session_path();
assert_eq!(
path, AUTO_SAVE_SESSION_PATH,
"Should prefer .yoyo/last-session.json when it exists"
);
std::env::set_current_dir(&original_dir).unwrap();
let _ = std::fs::remove_dir_all(&tmp_dir);
}
#[test]
fn test_continue_session_path_falls_back_to_default() {
let tmp_dir = std::env::temp_dir().join("yoyo_test_continue_fallback");
let _ = std::fs::remove_dir_all(&tmp_dir);
std::fs::create_dir_all(&tmp_dir).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&tmp_dir).unwrap();
let path = continue_session_path();
assert_eq!(
path, DEFAULT_SESSION_PATH,
"Should fall back to yoyo-session.json when .yoyo/last-session.json doesn't exist"
);
std::env::set_current_dir(&original_dir).unwrap();
let _ = std::fs::remove_dir_all(&tmp_dir);
}
#[test]
fn test_format_conversation_as_markdown_empty() {
let messages: Vec<AgentMessage> = vec![];
let md = format_conversation_as_markdown(&messages);
assert_eq!(md, "# Conversation\n\n");
}
#[test]
fn test_format_conversation_as_markdown_user_message() {
let messages = vec![AgentMessage::Llm(Message::user("Hello, world!"))];
let md = format_conversation_as_markdown(&messages);
assert!(md.contains("## User"));
assert!(md.contains("Hello, world!"));
}
#[test]
fn test_format_conversation_as_markdown_mixed_messages() {
let messages = vec![
AgentMessage::Llm(Message::user("What is 2+2?")),
AgentMessage::Llm(Message::Assistant {
content: vec![Content::Text {
text: "The answer is 4.".to_string(),
}],
stop_reason: yoagent::types::StopReason::Stop,
model: "test".to_string(),
provider: "test".to_string(),
usage: Usage::default(),
timestamp: 0,
error_message: None,
}),
AgentMessage::Llm(Message::ToolResult {
tool_call_id: "tc_1".to_string(),
tool_name: "bash".to_string(),
content: vec![Content::Text {
text: "file.txt".to_string(),
}],
is_error: false,
timestamp: 0,
}),
];
let md = format_conversation_as_markdown(&messages);
assert!(md.contains("## User"), "Should have user heading");
assert!(md.contains("What is 2+2?"), "Should have user text");
assert!(md.contains("## Assistant"), "Should have assistant heading");
assert!(
md.contains("The answer is 4."),
"Should have assistant text"
);
assert!(md.contains("### Tool: bash"), "Should have tool heading");
assert!(md.contains("file.txt"), "Should have tool output");
assert!(md.contains("```"), "Tool output should be in code block");
}
#[test]
fn test_format_conversation_as_markdown_thinking_block() {
let messages = vec![AgentMessage::Llm(Message::Assistant {
content: vec![
Content::Thinking {
thinking: "Let me think about this.".to_string(),
signature: None,
},
Content::Text {
text: "Here's my answer.".to_string(),
},
],
stop_reason: yoagent::types::StopReason::Stop,
model: "test".to_string(),
provider: "test".to_string(),
usage: Usage::default(),
timestamp: 0,
error_message: None,
})];
let md = format_conversation_as_markdown(&messages);
assert!(md.contains("*Thinking:*"), "Should contain thinking label");
assert!(
md.contains("Let me think about this."),
"Should contain thinking text"
);
assert!(
md.contains("Here's my answer."),
"Should contain response text"
);
}
#[test]
fn test_format_conversation_as_markdown_skips_tool_calls() {
let messages = vec![AgentMessage::Llm(Message::Assistant {
content: vec![
Content::Text {
text: "I'll check that.".to_string(),
},
Content::ToolCall {
id: "tc_1".to_string(),
name: "bash".to_string(),
arguments: serde_json::json!({"command": "ls"}),
},
],
stop_reason: yoagent::types::StopReason::Stop,
model: "test".to_string(),
provider: "test".to_string(),
usage: Usage::default(),
timestamp: 0,
error_message: None,
})];
let md = format_conversation_as_markdown(&messages);
assert!(
md.contains("I'll check that."),
"Should include text blocks"
);
assert!(
!md.contains("\"command\""),
"Should not include tool call arguments"
);
}
#[test]
fn test_parse_export_path_default() {
assert_eq!(parse_export_path("/export"), "conversation.md");
}
#[test]
fn test_parse_export_path_custom() {
assert_eq!(parse_export_path("/export myfile.md"), "myfile.md");
}
#[test]
fn test_parse_export_path_with_directory() {
assert_eq!(
parse_export_path("/export output/chat.md"),
"output/chat.md"
);
}
#[test]
fn test_parse_export_path_whitespace() {
assert_eq!(parse_export_path("/export notes.md "), "notes.md");
}
#[test]
fn test_clear_confirmation_empty_conversation() {
assert_eq!(clear_confirmation_message(0, 0), None);
}
#[test]
fn test_clear_confirmation_at_threshold() {
assert_eq!(clear_confirmation_message(4, 1000), None);
}
#[test]
fn test_clear_confirmation_above_threshold_contains_count() {
let msg = clear_confirmation_message(10, 5000);
assert!(msg.is_some(), "should prompt for 10 messages");
let text = msg.unwrap();
assert!(
text.contains("10 messages"),
"should mention message count: {text}"
);
}
#[test]
fn test_clear_confirmation_above_threshold_contains_tokens() {
let msg = clear_confirmation_message(10, 5000);
assert!(msg.is_some());
let text = msg.unwrap();
assert!(
text.contains("5.0k"),
"should contain formatted token count: {text}"
);
}
#[test]
fn test_clear_confirmation_just_above_threshold() {
let msg = clear_confirmation_message(5, 200);
assert!(msg.is_some(), "5 messages should trigger confirmation");
let text = msg.unwrap();
assert!(text.contains("5 messages"));
assert!(text.contains("200"));
}
#[test]
fn test_clear_force_in_known_commands() {
assert!(
KNOWN_COMMANDS.contains(&"/clear!"),
"/clear! should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_proactive_compact_threshold_is_lower_than_auto() {
use crate::cli::{AUTO_COMPACT_THRESHOLD, PROACTIVE_COMPACT_THRESHOLD};
const {
assert!(PROACTIVE_COMPACT_THRESHOLD < AUTO_COMPACT_THRESHOLD);
}
}
#[test]
fn test_proactive_compact_threshold_in_valid_range() {
use crate::cli::PROACTIVE_COMPACT_THRESHOLD;
const {
assert!(PROACTIVE_COMPACT_THRESHOLD > 0.5);
assert!(PROACTIVE_COMPACT_THRESHOLD < 0.8);
}
}
#[test]
fn test_parse_stash_subcommand_push() {
let (cmd, arg) = parse_stash_subcommand("/stash push WIP");
assert_eq!(cmd, "push");
assert_eq!(arg, "WIP");
}
#[test]
fn test_parse_stash_subcommand_pop() {
let (cmd, arg) = parse_stash_subcommand("/stash pop");
assert_eq!(cmd, "pop");
assert_eq!(arg, "");
}
#[test]
fn test_parse_stash_subcommand_list() {
let (cmd, arg) = parse_stash_subcommand("/stash list");
assert_eq!(cmd, "list");
assert_eq!(arg, "");
}
#[test]
fn test_parse_stash_subcommand_drop() {
let (cmd, arg) = parse_stash_subcommand("/stash drop 2");
assert_eq!(cmd, "drop");
assert_eq!(arg, "2");
}
#[test]
fn test_parse_stash_subcommand_default() {
let (cmd, arg) = parse_stash_subcommand("/stash");
assert_eq!(cmd, "push");
assert_eq!(arg, "");
}
#[test]
fn test_parse_stash_subcommand_implicit_push_with_description() {
let (cmd, arg) = parse_stash_subcommand("/stash some description");
assert_eq!(cmd, "push");
assert_eq!(arg, "some description");
}
#[test]
fn test_stash_entry_description_default() {
let desc = stash_default_description(0);
assert_eq!(desc, "stash@{0}");
let desc2 = stash_default_description(3);
assert_eq!(desc2, "stash@{3}");
}
#[test]
fn test_stash_list_empty() {
{
let mut stash = CONVERSATION_STASH.write().unwrap();
stash.clear();
}
let result = handle_stash_list();
assert!(result.contains("empty"), "Empty stash should say so");
}
#[test]
fn test_stash_drop_empty() {
{
let mut stash = CONVERSATION_STASH.write().unwrap();
stash.clear();
}
let result = handle_stash_drop("");
assert!(
result.contains("empty"),
"Drop on empty stash should say so"
);
}
#[test]
fn test_stash_drop_out_of_range() {
{
let mut stash = CONVERSATION_STASH.write().unwrap();
stash.clear();
}
let result = handle_stash_drop("5");
assert!(
result.contains("out of range"),
"Should report out of range"
);
}
#[test]
fn test_stash_drop_invalid_index() {
let result = handle_stash_drop("abc");
assert!(result.contains("invalid"), "Should report invalid index");
}
#[test]
fn test_save_load_command_matching() {
let save_matches = |s: &str| s == "/save" || s.starts_with("/save ");
let load_matches = |s: &str| s == "/load" || s.starts_with("/load ");
assert!(save_matches("/save"));
assert!(save_matches("/save myfile.json"));
assert!(!save_matches("/savefile"));
assert!(!save_matches("/saveXYZ"));
assert!(load_matches("/load"));
assert!(load_matches("/load myfile.json"));
assert!(!load_matches("/loadfile"));
assert!(!load_matches("/loadXYZ"));
}
#[test]
fn test_mark_command_recognized() {
assert!(!is_unknown_command("/mark"));
assert!(!is_unknown_command("/mark checkpoint"));
assert!(
KNOWN_COMMANDS.contains(&"/mark"),
"/mark should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_jump_command_recognized() {
assert!(!is_unknown_command("/jump"));
assert!(!is_unknown_command("/jump checkpoint"));
assert!(
KNOWN_COMMANDS.contains(&"/jump"),
"/jump should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_marks_command_recognized() {
assert!(!is_unknown_command("/marks"));
assert!(
KNOWN_COMMANDS.contains(&"/marks"),
"/marks should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_parse_bookmark_name_with_name() {
let name = parse_bookmark_name("/mark checkpoint", "/mark");
assert_eq!(name, Some("checkpoint".to_string()));
}
#[test]
fn test_parse_bookmark_name_with_spaces() {
let name = parse_bookmark_name("/mark my bookmark ", "/mark");
assert_eq!(name, Some("my bookmark".to_string()));
}
#[test]
fn test_parse_bookmark_name_empty() {
let name = parse_bookmark_name("/mark", "/mark");
assert_eq!(name, None);
}
#[test]
fn test_parse_bookmark_name_whitespace_only() {
let name = parse_bookmark_name("/mark ", "/mark");
assert_eq!(name, None);
}
#[test]
fn test_parse_bookmark_name_for_jump() {
let name = parse_bookmark_name("/jump start", "/jump");
assert_eq!(name, Some("start".to_string()));
}
#[test]
fn test_bookmarks_create_and_list() {
let mut bookmarks = Bookmarks::new();
assert!(bookmarks.is_empty());
bookmarks.insert("start".to_string(), "[]".to_string());
assert_eq!(bookmarks.len(), 1);
assert!(bookmarks.contains_key("start"));
}
#[test]
fn test_bookmarks_overwrite_same_name() {
let mut bookmarks = Bookmarks::new();
bookmarks.insert("checkpoint".to_string(), "[1]".to_string());
bookmarks.insert("checkpoint".to_string(), "[1,2]".to_string());
assert_eq!(bookmarks.len(), 1);
assert_eq!(bookmarks.get("checkpoint").unwrap(), "[1,2]");
}
#[test]
fn test_bookmarks_nonexistent_returns_none() {
let bookmarks = Bookmarks::new();
assert!(!bookmarks.contains_key("nonexistent"));
}
#[test]
fn test_bookmarks_multiple_entries() {
let mut bookmarks = Bookmarks::new();
bookmarks.insert("start".to_string(), "[]".to_string());
bookmarks.insert("middle".to_string(), "[1]".to_string());
bookmarks.insert("end".to_string(), "[1,2,3]".to_string());
assert_eq!(bookmarks.len(), 3);
assert!(bookmarks.contains_key("start"));
assert!(bookmarks.contains_key("middle"));
assert!(bookmarks.contains_key("end"));
}
#[test]
fn test_handle_marks_empty_does_not_panic() {
let bookmarks = Bookmarks::new();
handle_marks(&bookmarks);
}
#[test]
fn test_handle_marks_with_entries_does_not_panic() {
let mut bookmarks = Bookmarks::new();
bookmarks.insert("alpha".to_string(), "[]".to_string());
bookmarks.insert("beta".to_string(), "[]".to_string());
handle_marks(&bookmarks);
}
#[test]
fn test_mark_command_matching() {
let mark_matches = |s: &str| s == "/mark" || s.starts_with("/mark ");
assert!(mark_matches("/mark"));
assert!(mark_matches("/mark checkpoint"));
assert!(!mark_matches("/marker"));
assert!(!mark_matches("/marking"));
}
#[test]
fn test_jump_command_matching() {
let jump_matches = |s: &str| s == "/jump" || s.starts_with("/jump ");
assert!(jump_matches("/jump"));
assert!(jump_matches("/jump checkpoint"));
assert!(!jump_matches("/jumping"));
assert!(!jump_matches("/jumped"));
}
}