//! Agents subcommand implementations. use crate::error::Result; // --------------------------------------------------------------------------- // 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 always shown, truncated to 200 chars unless `opts.verbose`. /// Label prefixes are color-coded when `opts.color_enabled()` returns `true`. 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("?"); let color = opts.color_enabled(); // Returns a colored role label like "[assistant]" or "[user]". let role_label = |r: &str| -> String { let label = format!("[{r}]"); match r { "assistant" => crate::output::color::orange(&label, color), "user" => crate::output::color::grey(&label, color), _ => label, } }; match &msg.content { None => {} Some(crate::models::session::MessageContent::Text(t)) => { println!("{}: {t}", role_label(role)); } Some(crate::models::session::MessageContent::Blocks(blocks)) => { for block in blocks { match block { crate::models::session::ContentBlock::Text { text } => { println!("{}: {text}", role_label(role)); } 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 cap = if opts.verbose { usize::MAX } else { 120 }; let boundary = input_preview.floor_char_boundary(cap); let input_short = &input_preview[..boundary]; let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" }; let label = crate::output::color::blue(&format!("[tool: {name}]"), color); println!("{label} {input_short}{ellipsis}"); } crate::models::session::ContentBlock::ToolResult { content, is_error, .. } => { let is_err = is_error.unwrap_or(false); let err_flag = if is_err { " (error)" } else { "" }; let label_text = format!("[tool_result{err_flag}]"); let label = if is_err { crate::output::color::red(&label_text, color) } else { crate::output::color::green(&label_text, color) }; 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() }); if opts.verbose { println!("{label}: {preview}"); } else { let boundary = preview.floor_char_boundary(200); let short = &preview[..boundary]; println!("{label}: {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). /// /// 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). /// The `limit` controls how many rows are returned (default 10, or all). pub async fn list(session_id: &str, limit: crate::cli::Limit, 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 rows = limit.apply(rows); let output = match opts.output { crate::cli::OutputFormat::Table => { crate::output::render_table(&["Agent ID", "Type", "File", "Modified"], &rows)? } crate::cli::OutputFormat::Json => { let objects: Vec = rows .iter() .map(|r| { serde_json::json!({ "id": r[0], "type": r[1], "file": r[2], "modified": r[3], }) }) .collect(); crate::output::render_json(&objects)? } crate::cli::OutputFormat::Xml => { crate::output::render_xml_rows(&["agent_id", "type", "file", "modified"], &rows)? } }; println!("{output}"); Ok(()) } /// Run `agents dump `. /// /// 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, ) -> Result<()> { 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 => { let objects: Vec = rows .iter() .map(|r| { serde_json::json!({ "seq": r[0], "timestamp": r[1], "type": r[2], "role": r[3], "content": r[4], }) }) .collect(); crate::output::render_json(&objects)? } crate::cli::OutputFormat::Xml => { crate::output::render_xml_rows(&["seq", "timestamp", "type", "role", "content"], &rows)? } }; println!("{output}"); Ok(()) } /// Run `agents transcribe `. /// /// 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, ) -> Result<()> { 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 { output: OutputFormat::Table, verbose: false, include: IncludeList::default(), color: false, no_color: false, } } 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", crate::cli::Limit::default(), &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 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:?}" ); } /// `transcribe` with a nonexistent session ID returns `NotFound`. #[tokio::test] 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:?}" ); } /// `list` with a real session but no agents returns `Ok` with an empty table. #[tokio::test] 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", crate::cli::Limit::default(), &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 session_id = "bbbbcccc-dddd-eeee-ffff-000000000000"; // subagents live inside a directory named after the session UUID let subagents = projects.join(session_id).join("subagents"); std::fs::create_dir_all(&subagents).unwrap(); 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 session_id = "ccccdddd-eeee-ffff-0000-111122223333"; // subagents live inside a directory named after the session UUID let subagents = projects.join(session_id).join("subagents"); std::fs::create_dir_all(&subagents).unwrap(); 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` always shows tool results (truncated by default) without panicking. #[test] fn render_entry_text_tool_result_always_shown() { 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(); // verbose = false → truncated at 200 chars render_entry_text(&entry, &opts); // must not panic } }