feat(agents): implement agents list/dump/transcribe/follow [claudbg-ki43, claudbg-kzcs, claudbg-iwqj, claudbg-ag0r]

Replace stub implementations with full Wave 6 agent commands: list
discovers and renders agents for a session; dump reads agent JSONL
directly (no DB); transcribe shows stats header + chat log with
include guards; --follow variant polls the file at 500ms for both
dump and transcribe. Adds 10 new unit tests; total now 99, 0 warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent 62e807f3e1
commit ec379c3b54

@ -1,10 +1,11 @@
--- ---
# claudbg-ag0r # claudbg-ag0r
title: follow flag for agents commands title: follow flag for agents commands
status: todo status: in-progress
type: task type: task
priority: normal
created_at: 2026-03-27T19:40:39Z created_at: 2026-03-27T19:40:39Z
updated_at: 2026-03-27T19:40:39Z updated_at: 2026-03-28T18:17:39Z
parent: claudbg-g12t parent: claudbg-g12t
--- ---

@ -1,10 +1,11 @@
--- ---
# claudbg-iwqj # claudbg-iwqj
title: agents transcribe command title: agents transcribe command
status: todo status: in-progress
type: task type: task
priority: normal
created_at: 2026-03-27T19:40:39Z created_at: 2026-03-27T19:40:39Z
updated_at: 2026-03-27T19:40:39Z updated_at: 2026-03-28T18:17:39Z
parent: claudbg-g12t parent: claudbg-g12t
--- ---

@ -1,10 +1,11 @@
--- ---
# claudbg-ki43 # claudbg-ki43
title: agents list command title: agents list command
status: todo status: in-progress
type: task type: task
priority: normal
created_at: 2026-03-27T19:40:39Z created_at: 2026-03-27T19:40:39Z
updated_at: 2026-03-27T19:40:39Z updated_at: 2026-03-28T18:15:23Z
parent: claudbg-g12t parent: claudbg-g12t
--- ---

@ -1,10 +1,11 @@
--- ---
# claudbg-kzcs # claudbg-kzcs
title: agents dump command title: agents dump command
status: todo status: in-progress
type: task type: task
priority: normal
created_at: 2026-03-27T19:40:39Z created_at: 2026-03-27T19:40:39Z
updated_at: 2026-03-27T19:40:39Z updated_at: 2026-03-28T18:17:39Z
parent: claudbg-g12t parent: claudbg-g12t
--- ---

@ -1,50 +1,429 @@
//! Agents subcommand implementations (stubs; full implementation in Wave 6). //! Agents subcommand implementations.
use crate::error::Result; 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::<Vec<_>>()
.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.
/// ///
/// Lists all agent runs within the session identified by `session_id`. /// Thinking blocks are shown only when `opts.include.thinking` is set.
/// (Wave 6 implementation pending.) /// Tool result blocks are shown only when `opts.include.output` is set.
pub async fn list(_session_id: &str, _opts: &crate::cli::GlobalOpts) -> Result<()> { fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) {
println!("agents list: coming soon"); 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).
///
/// Returns [`crate::error::AppError::NotFound`] if either is absent.
fn find_agent(session_id: &str, agent_id: &str) -> Result<crate::parser::discovery::AgentRef> {
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 <session-id>`.
///
/// 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<Vec<String>> = 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(()) Ok(())
} }
/// Run `agents dump`. /// Run `agents dump <session-id> <agent-id>`.
///
/// 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.
/// ///
/// Dumps raw messages from the agent run identified by `agent_id` within `session_id`. /// When `follow` is `true`, streams new entries via 500 ms polling without
/// Streams new entries when `follow` is true. /// returning (equivalent to `tail -f`).
/// (Wave 6 implementation pending.)
pub async fn dump( pub async fn dump(
_session_id: &str, session_id: &str,
_agent_id: &str, agent_id: &str,
_follow: bool, follow: bool,
_opts: &crate::cli::GlobalOpts, opts: &crate::cli::GlobalOpts,
) -> Result<()> { ) -> 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<Vec<String>> = 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(()) Ok(())
} }
/// Run `agents transcribe`. /// Run `agents transcribe <session-id> <agent-id>`.
///
/// 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`.
/// ///
/// Shows a human-readable transcript of the agent run identified by `agent_id`. /// When `follow` is `true`, streams new entries via 500 ms polling without
/// Streams new entries when `follow` is true. /// returning.
/// (Wave 6 implementation pending.)
pub async fn transcribe( pub async fn transcribe(
_session_id: &str, session_id: &str,
_agent_id: &str, agent_id: &str,
_follow: bool, follow: bool,
_opts: &crate::cli::GlobalOpts, opts: &crate::cli::GlobalOpts,
) -> Result<()> { ) -> 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<serde_json::Value> = 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<Vec<String>> = 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(()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::cli::{GlobalOpts, IncludeList, OutputFormat}; use crate::cli::{GlobalOpts, IncludeList, OutputFormat};
use crate::models::session::{ContentBlock, Message, MessageContent, RawEntry};
fn default_opts() -> GlobalOpts { fn default_opts() -> GlobalOpts {
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 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] #[tokio::test]
async fn list_returns_ok() { async fn transcribe_nonexistent_session_returns_not_found() {
let result = list("abc12345", &default_opts()).await; let dir = tempfile::tempdir().expect("tempdir");
assert!(result.is_ok()); // 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:?}"
);
} }
/// `dump` returns `Ok` without panicking. /// `list` with a real session but no agents returns `Ok` with an empty table.
#[tokio::test] #[tokio::test]
async fn dump_returns_ok() { async fn list_session_with_no_agents_returns_ok() {
let result = dump("abc12345", "def67890", false, &default_opts()).await; let dir = tempfile::tempdir().expect("tempdir");
assert!(result.is_ok()); // 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:?}");
} }
/// `transcribe` returns `Ok` without panicking. /// `dump` with a real session and agent file returns `Ok`.
#[tokio::test] #[tokio::test]
async fn transcribe_returns_ok() { async fn dump_with_real_agent_returns_ok() {
let result = transcribe("abc12345", "def67890", false, &default_opts()).await; let dir = tempfile::tempdir().expect("tempdir");
assert!(result.is_ok()); 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
} }
} }

Loading…
Cancel
Save