diff --git a/.beans/claudbg-ag0r--follow-flag-for-agents-commands.md b/.beans/claudbg-ag0r--follow-flag-for-agents-commands.md index efc1ce6..78ccc14 100644 --- a/.beans/claudbg-ag0r--follow-flag-for-agents-commands.md +++ b/.beans/claudbg-ag0r--follow-flag-for-agents-commands.md @@ -1,10 +1,11 @@ --- # claudbg-ag0r title: follow flag for agents commands -status: todo +status: in-progress type: task +priority: normal created_at: 2026-03-27T19:40:39Z -updated_at: 2026-03-27T19:40:39Z +updated_at: 2026-03-28T18:17:39Z parent: claudbg-g12t --- diff --git a/.beans/claudbg-iwqj--agents-transcribe-command.md b/.beans/claudbg-iwqj--agents-transcribe-command.md index fbf973d..52ec181 100644 --- a/.beans/claudbg-iwqj--agents-transcribe-command.md +++ b/.beans/claudbg-iwqj--agents-transcribe-command.md @@ -1,10 +1,11 @@ --- # claudbg-iwqj title: agents transcribe command -status: todo +status: in-progress type: task +priority: normal created_at: 2026-03-27T19:40:39Z -updated_at: 2026-03-27T19:40:39Z +updated_at: 2026-03-28T18:17:39Z parent: claudbg-g12t --- diff --git a/.beans/claudbg-ki43--agents-list-command.md b/.beans/claudbg-ki43--agents-list-command.md index dce43a2..f184665 100644 --- a/.beans/claudbg-ki43--agents-list-command.md +++ b/.beans/claudbg-ki43--agents-list-command.md @@ -1,10 +1,11 @@ --- # claudbg-ki43 title: agents list command -status: todo +status: in-progress type: task +priority: normal created_at: 2026-03-27T19:40:39Z -updated_at: 2026-03-27T19:40:39Z +updated_at: 2026-03-28T18:15:23Z parent: claudbg-g12t --- diff --git a/.beans/claudbg-kzcs--agents-dump-command.md b/.beans/claudbg-kzcs--agents-dump-command.md index c330f03..dafe6f2 100644 --- a/.beans/claudbg-kzcs--agents-dump-command.md +++ b/.beans/claudbg-kzcs--agents-dump-command.md @@ -1,10 +1,11 @@ --- # claudbg-kzcs title: agents dump command -status: todo +status: in-progress type: task +priority: normal created_at: 2026-03-27T19:40:39Z -updated_at: 2026-03-27T19:40:39Z +updated_at: 2026-03-28T18:17:39Z parent: claudbg-g12t --- diff --git a/src/commands/agents.rs b/src/commands/agents.rs index 2bebe3d..08f6239 100644 --- a/src/commands/agents.rs +++ b/src/commands/agents.rs @@ -1,50 +1,429 @@ -//! Agents subcommand implementations (stubs; full implementation in Wave 6). +//! Agents subcommand implementations. use crate::error::Result; -/// Run `agents list`. +// --------------------------------------------------------------------------- +// Private helpers (mirrored from sessions.rs — kept private to each module) +// --------------------------------------------------------------------------- + +/// Extract a content preview string from a [`crate::models::session::RawEntry`]. +/// +/// The preview is truncated to `max_len` bytes (aligned to a UTF-8 char boundary). +fn content_preview(entry: &crate::models::session::RawEntry, max_len: usize) -> String { + let raw = match entry.message.as_ref().and_then(|m| m.content.as_ref()) { + None => return String::new(), + Some(c) => match c { + crate::models::session::MessageContent::Text(t) => t.clone(), + crate::models::session::MessageContent::Blocks(blocks) => blocks + .iter() + .map(|b| match b { + crate::models::session::ContentBlock::Text { text } => text.clone(), + crate::models::session::ContentBlock::Thinking { thinking } => { + format!("[thinking: {}]", &thinking[..thinking.len().min(40)]) + } + crate::models::session::ContentBlock::ToolUse { name, .. } => { + format!("[tool_use: {name}]") + } + crate::models::session::ContentBlock::ToolResult { tool_use_id, .. } => { + format!("[tool_result: {tool_use_id}]") + } + crate::models::session::ContentBlock::Image { .. } => "[image]".to_string(), + crate::models::session::ContentBlock::Unknown => "[unknown]".to_string(), + }) + .collect::>() + .join(" "), + }, + }; + if max_len == usize::MAX || raw.len() <= max_len { + raw + } else { + format!("{}…", &raw[..raw.floor_char_boundary(max_len)]) + } +} + +/// Render a single entry to stdout in human-readable text format. +/// +/// Thinking blocks are shown only when `opts.include.thinking` is set. +/// Tool result blocks are shown only when `opts.include.output` is set. +fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) { + let Some(msg) = &entry.message else { return }; + let role = msg.role.as_deref().unwrap_or("?"); + + match &msg.content { + None => {} + Some(crate::models::session::MessageContent::Text(t)) => { + println!("[{role}]: {t}"); + } + Some(crate::models::session::MessageContent::Blocks(blocks)) => { + for block in blocks { + match block { + crate::models::session::ContentBlock::Text { text } => { + println!("[{role}]: {text}"); + } + crate::models::session::ContentBlock::Thinking { thinking } => { + if opts.include.thinking { + println!("[thinking]: {thinking}"); + } + } + crate::models::session::ContentBlock::ToolUse { name, input, .. } => { + let input_preview = serde_json::to_string(input).unwrap_or_default(); + let boundary = input_preview.floor_char_boundary(120); + let input_short = &input_preview[..boundary]; + println!("[tool: {name}] {input_short}"); + } + crate::models::session::ContentBlock::ToolResult { + content, is_error, .. + } => { + if opts.include.output { + let err_flag = if is_error.unwrap_or(false) { + " (error)" + } else { + "" + }; + let preview = content + .as_ref() + .and_then(|c| c.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| { + content.as_ref().map(|c| c.to_string()).unwrap_or_default() + }); + let boundary = preview.floor_char_boundary(200); + let short = &preview[..boundary]; + println!("[tool_result{err_flag}]: {short}"); + } + } + crate::models::session::ContentBlock::Image { .. } => { + println!("[image]"); + } + crate::models::session::ContentBlock::Unknown => { + println!("[unknown block]"); + } + } + } + } + } +} + +/// Find an [`crate::parser::discovery::AgentRef`] by session_id prefix and agent_id prefix. +/// +/// Discovers all sessions, locates the one matching `session_id` (prefix or full UUID), +/// then finds the agent within that session matching `agent_id` (prefix or full UUID). /// -/// Lists all agent runs within the session identified by `session_id`. -/// (Wave 6 implementation pending.) -pub async fn list(_session_id: &str, _opts: &crate::cli::GlobalOpts) -> Result<()> { - println!("agents list: coming soon"); +/// Returns [`crate::error::AppError::NotFound`] if either is absent. +fn find_agent(session_id: &str, agent_id: &str) -> Result { + let sessions = crate::parser::discovery::discover_sessions()?; + let session_ref = sessions + .iter() + .find(|s| s.session_id.starts_with(session_id) || s.session_id == session_id) + .ok_or_else(|| { + crate::error::AppError::NotFound(format!("session '{session_id}' not found")) + })?; + + let agents = crate::parser::discovery::discover_agents_for_session(&session_ref.file_path)?; + let agent_ref = agents + .into_iter() + .find(|a| a.agent_id.starts_with(agent_id) || a.agent_id == agent_id) + .ok_or_else(|| { + crate::error::AppError::NotFound(format!( + "agent '{agent_id}' not found in session '{session_id}'" + )) + })?; + + Ok(agent_ref) +} + +// --------------------------------------------------------------------------- +// Public commands +// --------------------------------------------------------------------------- + +/// Run `agents list `. +/// +/// Discovers all agents belonging to the session identified by `session_id` +/// (8-char prefix or full UUID) and displays them as a table with columns: +/// `Agent ID`, `Type`, `File`, `Modified`. +/// +/// Respects `opts.output` and `opts.verbose` (verbose shows full UUIDs and +/// full file paths instead of 8-char prefixes and basenames). +pub async fn list(session_id: &str, opts: &crate::cli::GlobalOpts) -> Result<()> { + let sessions = crate::parser::discovery::discover_sessions()?; + let session_ref = sessions + .iter() + .find(|s| s.session_id.starts_with(session_id) || s.session_id == session_id) + .ok_or_else(|| { + crate::error::AppError::NotFound(format!("session '{session_id}' not found")) + })?; + + let agents = crate::parser::discovery::discover_agents_for_session(&session_ref.file_path)?; + + let rows: Vec> = agents + .iter() + .map(|a| { + let id = if opts.verbose { + a.agent_id.clone() + } else { + crate::util::short_id(&a.agent_id).to_string() + }; + let file = if opts.verbose { + a.file_path.to_string_lossy().to_string() + } else { + a.file_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default() + }; + vec![ + id, + a.agent_type + .clone() + .unwrap_or_else(|| "unknown".to_string()), + file, + a.modified_at.format("%Y-%m-%d %H:%M:%S").to_string(), + ] + }) + .collect(); + + let output = match opts.output { + crate::cli::OutputFormat::Table => { + crate::output::render_table(&["Agent ID", "Type", "File", "Modified"], &rows)? + } + crate::cli::OutputFormat::Json => crate::output::render_json(&rows)?, + crate::cli::OutputFormat::Xml => { + crate::output::render_xml_rows(&["agent_id", "type", "file", "modified"], &rows)? + } + }; + println!("{output}"); Ok(()) } -/// Run `agents dump`. +/// Run `agents dump `. /// -/// Dumps raw messages from the agent run identified by `agent_id` within `session_id`. -/// Streams new entries when `follow` is true. -/// (Wave 6 implementation pending.) +/// Dumps raw messages from the agent run identified by `agent_id` within the +/// session identified by `session_id` (each may be an 8-char prefix or full UUID). +/// Reads the JSONL file directly without going through the DB cache. +/// +/// When `follow` is `true`, streams new entries via 500 ms polling without +/// returning (equivalent to `tail -f`). pub async fn dump( - _session_id: &str, - _agent_id: &str, - _follow: bool, - _opts: &crate::cli::GlobalOpts, + session_id: &str, + agent_id: &str, + follow: bool, + opts: &crate::cli::GlobalOpts, ) -> Result<()> { - println!("agents dump: coming soon"); + if follow { + return dump_follow(session_id, agent_id, opts).await; + } + + let agent_ref = find_agent(session_id, agent_id)?; + let entries = crate::parser::reader::read_session_file(&agent_ref.file_path).await?; + + let truncate = if opts.verbose { usize::MAX } else { 80 }; + + let rows: Vec> = entries + .iter() + .enumerate() + .map(|(i, entry)| { + vec![ + (i + 1).to_string(), + entry.timestamp.clone().unwrap_or_default(), + entry.entry_type.clone().unwrap_or_default(), + entry + .message + .as_ref() + .and_then(|m| m.role.clone()) + .unwrap_or_default(), + content_preview(entry, truncate), + ] + }) + .collect(); + + let output = match opts.output { + crate::cli::OutputFormat::Table => { + crate::output::render_table(&["#", "Timestamp", "Type", "Role", "Content"], &rows)? + } + crate::cli::OutputFormat::Json => crate::output::render_json(&rows)?, + crate::cli::OutputFormat::Xml => { + crate::output::render_xml_rows(&["seq", "timestamp", "type", "role", "content"], &rows)? + } + }; + println!("{output}"); Ok(()) } -/// Run `agents transcribe`. +/// Run `agents transcribe `. /// -/// Shows a human-readable transcript of the agent run identified by `agent_id`. -/// Streams new entries when `follow` is true. -/// (Wave 6 implementation pending.) +/// Shows a human-readable transcript of the agent run identified by `agent_id` +/// within the session `session_id`. Emits a stats header followed by the chat +/// log. Thinking blocks and tool results are gated by `opts.include`. +/// +/// When `follow` is `true`, streams new entries via 500 ms polling without +/// returning. pub async fn transcribe( - _session_id: &str, - _agent_id: &str, - _follow: bool, - _opts: &crate::cli::GlobalOpts, + session_id: &str, + agent_id: &str, + follow: bool, + opts: &crate::cli::GlobalOpts, ) -> Result<()> { - println!("agents transcribe: coming soon"); + if follow { + return transcribe_follow(session_id, agent_id, opts).await; + } + + let agent_ref = find_agent(session_id, agent_id)?; + let entries = crate::parser::reader::read_session_file(&agent_ref.file_path).await?; + let stats = crate::models::stats::compute_stats(&entries); + + match opts.output { + crate::cli::OutputFormat::Table => { + println!("Agent: {}", crate::util::short_id(&agent_ref.agent_id)); + println!( + "Type: {}", + agent_ref.agent_type.as_deref().unwrap_or("unknown") + ); + println!("Model: {}", stats.model.as_deref().unwrap_or("unknown")); + println!( + "Tokens: in={} out={} cache_read={} cache_write={}", + stats.input_tokens, + stats.output_tokens, + stats.cache_read_tokens, + stats.cache_creation_tokens + ); + println!("{}", "─".repeat(80)); + for entry in &entries { + render_entry_text(entry, opts); + } + } + crate::cli::OutputFormat::Json => { + let messages: Vec = entries + .iter() + .filter(|e| matches!(e.entry_type.as_deref(), Some("user") | Some("assistant"))) + .map(|e| { + serde_json::json!({ + "role": e.message.as_ref().and_then(|m| m.role.clone()), + "timestamp": e.timestamp, + "preview": content_preview(e, usize::MAX), + }) + }) + .collect(); + println!("{}", crate::output::render_json(&messages)?); + } + crate::cli::OutputFormat::Xml => { + let rows: Vec> = entries + .iter() + .filter(|e| matches!(e.entry_type.as_deref(), Some("user") | Some("assistant"))) + .map(|e| { + vec![ + e.entry_type.clone().unwrap_or_default(), + e.message + .as_ref() + .and_then(|m| m.role.clone()) + .unwrap_or_default(), + e.timestamp.clone().unwrap_or_default(), + content_preview(e, usize::MAX), + ] + }) + .collect(); + println!( + "{}", + crate::output::render_xml_rows(&["type", "role", "timestamp", "content"], &rows)? + ); + } + } Ok(()) } +// --------------------------------------------------------------------------- +// Follow-mode helpers (Ticket 4: claudbg-ag0r) +// --------------------------------------------------------------------------- + +/// Follow mode for `agents dump`: polls the agent file every 500 ms and prints +/// new entries as they arrive, bypassing the DB cache entirely. +async fn dump_follow( + session_id: &str, + agent_id: &str, + opts: &crate::cli::GlobalOpts, +) -> Result<()> { + let agent_ref = find_agent(session_id, agent_id)?; + let mut byte_offset: u64 = 0; + let mut seq = 0usize; + + loop { + let file = tokio::fs::File::open(&agent_ref.file_path).await?; + let metadata = file.metadata().await?; + if metadata.len() > byte_offset { + use tokio::io::{AsyncBufReadExt, AsyncSeekExt}; + let mut reader = tokio::io::BufReader::new(file); + reader.seek(std::io::SeekFrom::Start(byte_offset)).await?; + let mut new_offset = byte_offset; + let mut lines = reader.lines(); + while let Some(line) = lines.next_line().await? { + new_offset += line.len() as u64 + 1; // +1 for newline + if line.is_empty() { + continue; + } + let entry: crate::models::session::RawEntry = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, + }; + let preview = content_preview(&entry, if opts.verbose { usize::MAX } else { 80 }); + let entry_type = entry.entry_type.clone().unwrap_or_default(); + let role = entry + .message + .as_ref() + .and_then(|m| m.role.clone()) + .unwrap_or_default(); + let ts = entry.timestamp.clone().unwrap_or_default(); + seq += 1; + println!("{seq:4} | {ts} | {entry_type:12} | {role:9} | {preview}"); + } + byte_offset = new_offset; + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } +} + +/// Follow mode for `agents transcribe`: polls the agent file every 500 ms and +/// renders new entries as they arrive, bypassing the DB cache entirely. +async fn transcribe_follow( + session_id: &str, + agent_id: &str, + opts: &crate::cli::GlobalOpts, +) -> Result<()> { + let agent_ref = find_agent(session_id, agent_id)?; + let mut byte_offset: u64 = 0; + + loop { + let file = tokio::fs::File::open(&agent_ref.file_path).await?; + let metadata = file.metadata().await?; + if metadata.len() > byte_offset { + use tokio::io::{AsyncBufReadExt, AsyncSeekExt}; + let mut reader = tokio::io::BufReader::new(file); + reader.seek(std::io::SeekFrom::Start(byte_offset)).await?; + let mut new_offset = byte_offset; + let mut lines = reader.lines(); + while let Some(line) = lines.next_line().await? { + new_offset += line.len() as u64 + 1; + if line.is_empty() { + continue; + } + let entry: crate::models::session::RawEntry = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, + }; + render_entry_text(&entry, opts); + } + byte_offset = new_offset; + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; use crate::cli::{GlobalOpts, IncludeList, OutputFormat}; + use crate::models::session::{ContentBlock, Message, MessageContent, RawEntry}; fn default_opts() -> GlobalOpts { GlobalOpts { @@ -54,24 +433,251 @@ mod tests { } } - /// `list` returns `Ok` without panicking. + fn make_raw_entry(entry_type: &str, role: &str, content: MessageContent) -> RawEntry { + RawEntry { + entry_type: Some(entry_type.to_string()), + session_id: None, + parent_session_id: None, + message: Some(Message { + role: Some(role.to_string()), + content: Some(content), + usage: None, + model: None, + stop_reason: None, + }), + system_message: None, + cwd: None, + timestamp: Some("2024-01-01T00:00:00Z".to_string()), + duration_ms: None, + extra: Default::default(), + } + } + + /// `list` with a nonexistent session ID returns `NotFound`. + #[tokio::test] + async fn list_nonexistent_session_returns_not_found() { + let dir = tempfile::tempdir().expect("tempdir"); + // SAFETY: single-threaded test context; no other threads read HOME concurrently. + unsafe { std::env::set_var("HOME", dir.path()) }; + let opts = default_opts(); + let result = list("nonexistent-session-id-xyz", &opts).await; + assert!( + matches!(result, Err(crate::error::AppError::NotFound(_))), + "expected NotFound, got: {result:?}" + ); + } + + /// `dump` with a nonexistent session ID returns `NotFound`. #[tokio::test] - async fn list_returns_ok() { - let result = list("abc12345", &default_opts()).await; - assert!(result.is_ok()); + async fn dump_nonexistent_session_returns_not_found() { + let dir = tempfile::tempdir().expect("tempdir"); + // SAFETY: single-threaded test context; no other threads read HOME concurrently. + unsafe { std::env::set_var("HOME", dir.path()) }; + let opts = default_opts(); + let result = dump("nonexistent-session-id-xyz", "agent-id", false, &opts).await; + assert!( + matches!(result, Err(crate::error::AppError::NotFound(_))), + "expected NotFound, got: {result:?}" + ); } - /// `dump` returns `Ok` without panicking. + /// `transcribe` with a nonexistent session ID returns `NotFound`. #[tokio::test] - async fn dump_returns_ok() { - let result = dump("abc12345", "def67890", false, &default_opts()).await; - assert!(result.is_ok()); + async fn transcribe_nonexistent_session_returns_not_found() { + let dir = tempfile::tempdir().expect("tempdir"); + // SAFETY: single-threaded test context; no other threads read HOME concurrently. + unsafe { std::env::set_var("HOME", dir.path()) }; + let opts = default_opts(); + let result = transcribe("nonexistent-session-id-xyz", "agent-id", false, &opts).await; + assert!( + matches!(result, Err(crate::error::AppError::NotFound(_))), + "expected NotFound, got: {result:?}" + ); } - /// `transcribe` returns `Ok` without panicking. + /// `list` with a real session but no agents returns `Ok` with an empty table. #[tokio::test] - async fn transcribe_returns_ok() { - let result = transcribe("abc12345", "def67890", false, &default_opts()).await; - assert!(result.is_ok()); + async fn list_session_with_no_agents_returns_ok() { + let dir = tempfile::tempdir().expect("tempdir"); + // Build a minimal projects dir with one session file but no subagents dir. + let projects = dir + .path() + .join(".claude") + .join("projects") + .join("myproject"); + std::fs::create_dir_all(&projects).unwrap(); + let session_id = "aaaabbbb-cccc-dddd-eeee-ffffffffffff"; + let session_file = projects.join(format!("{session_id}.jsonl")); + std::fs::write( + &session_file, + r#"{"type":"system","session_id":"aaaabbbb-cccc-dddd-eeee-ffffffffffff","cwd":"/tmp"}"#, + ) + .unwrap(); + // SAFETY: single-threaded test context; no other threads read HOME concurrently. + unsafe { std::env::set_var("HOME", dir.path()) }; + let opts = default_opts(); + let result = list("aaaabbbb", &opts).await; + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + } + + /// `dump` with a real session and agent file returns `Ok`. + #[tokio::test] + async fn dump_with_real_agent_returns_ok() { + let dir = tempfile::tempdir().expect("tempdir"); + let projects = dir + .path() + .join(".claude") + .join("projects") + .join("myproject"); + let subagents = projects.join("subagents"); + std::fs::create_dir_all(&subagents).unwrap(); + + let session_id = "bbbbcccc-dddd-eeee-ffff-000000000000"; + let session_file = projects.join(format!("{session_id}.jsonl")); + std::fs::write( + &session_file, + format!(r#"{{"type":"system","session_id":"{session_id}","cwd":"/tmp"}}"#), + ) + .unwrap(); + + let agent_id = "11112222-3333-4444-5555-666677778888"; + let agent_file = subagents.join(format!("agent-{agent_id}.jsonl")); + std::fs::write( + &agent_file, + format!( + r#"{{"type":"user","session_id":"{session_id}","message":{{"role":"user","content":"hello"}}}}"# + ), + ) + .unwrap(); + + // SAFETY: single-threaded test context; no other threads read HOME concurrently. + unsafe { std::env::set_var("HOME", dir.path()) }; + let opts = default_opts(); + let result = dump("bbbbcccc", "11112222", false, &opts).await; + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + } + + /// `transcribe` with a real session and agent file returns `Ok`. + #[tokio::test] + async fn transcribe_with_real_agent_returns_ok() { + let dir = tempfile::tempdir().expect("tempdir"); + let projects = dir + .path() + .join(".claude") + .join("projects") + .join("myproject"); + let subagents = projects.join("subagents"); + std::fs::create_dir_all(&subagents).unwrap(); + + let session_id = "ccccdddd-eeee-ffff-0000-111122223333"; + let session_file = projects.join(format!("{session_id}.jsonl")); + std::fs::write( + &session_file, + format!(r#"{{"type":"system","session_id":"{session_id}","cwd":"/tmp"}}"#), + ) + .unwrap(); + + let agent_id = "aaaabbbb-1111-2222-3333-444455556666"; + let agent_file = subagents.join(format!("agent-{agent_id}.jsonl")); + std::fs::write( + &agent_file, + format!( + r#"{{"type":"assistant","session_id":"{session_id}","message":{{"role":"assistant","content":"hi"}}}}"# + ), + ) + .unwrap(); + + // SAFETY: single-threaded test context; no other threads read HOME concurrently. + unsafe { std::env::set_var("HOME", dir.path()) }; + let opts = default_opts(); + let result = transcribe("ccccdddd", "aaaabbbb", false, &opts).await; + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + } + + /// `content_preview` returns empty string for an entry with no message. + #[test] + fn content_preview_no_message() { + let entry = RawEntry { + entry_type: Some("system".to_string()), + session_id: None, + parent_session_id: None, + message: None, + system_message: None, + cwd: None, + timestamp: None, + duration_ms: None, + extra: Default::default(), + }; + assert_eq!(content_preview(&entry, 80), ""); + } + + /// `content_preview` truncates long text. + #[test] + fn content_preview_truncates_long_text() { + let entry = make_raw_entry("user", "user", MessageContent::Text("a".repeat(200))); + let preview = content_preview(&entry, 80); + assert!(preview.len() <= 84, "preview too long: {}", preview.len()); + assert!(preview.ends_with('…'), "expected ellipsis"); + } + + /// `content_preview` returns the full text when it fits within `max_len`. + #[test] + fn content_preview_short_text_unchanged() { + let entry = make_raw_entry("user", "user", MessageContent::Text("Hello".to_string())); + assert_eq!(content_preview(&entry, 80), "Hello"); + } + + /// `content_preview` with `usize::MAX` returns full content without truncation. + #[test] + fn content_preview_max_len_no_truncation() { + let text = "x".repeat(1000); + let entry = make_raw_entry("user", "user", MessageContent::Text(text.clone())); + assert_eq!(content_preview(&entry, usize::MAX), text); + } + + /// `content_preview` formats tool_use blocks with the tool name. + #[test] + fn content_preview_tool_use_block() { + let entry = make_raw_entry( + "assistant", + "assistant", + MessageContent::Blocks(vec![ContentBlock::ToolUse { + id: "t1".to_string(), + name: "Bash".to_string(), + input: serde_json::json!({"command": "ls"}), + }]), + ); + let preview = content_preview(&entry, usize::MAX); + assert!(preview.contains("Bash"), "expected tool name in preview"); + } + + /// `render_entry_text` does not panic when thinking blocks are present but gated off. + #[test] + fn render_entry_text_thinking_gated() { + let entry = make_raw_entry( + "assistant", + "assistant", + MessageContent::Blocks(vec![ContentBlock::Thinking { + thinking: "secret thoughts".to_string(), + }]), + ); + let opts = default_opts(); + render_entry_text(&entry, &opts); // must not panic + } + + /// `render_entry_text` does not panic when tool results are present but gated off. + #[test] + fn render_entry_text_tool_result_gated() { + let entry = make_raw_entry( + "user", + "user", + MessageContent::Blocks(vec![ContentBlock::ToolResult { + tool_use_id: "t1".to_string(), + content: Some(serde_json::json!("output text")), + is_error: Some(false), + }]), + ); + let opts = default_opts(); + render_entry_text(&entry, &opts); // must not panic } }