feat(sessions): implement sessions list, dump, transcribe + --follow [claudbg-bh11, claudbg-ei6j, claudbg-m4p1, claudbg-zwoj]

- Replace stub sessions commands with full implementations
- sessions list: lazy-syncs all discovered sessions, queries DB sorted most-recent-first, supports table/json/xml output and --verbose full UUID display
- sessions dump: resolves 8-char prefix via LIKE query, fetches raw JSONL from DB, renders table/json/xml with content preview (truncated at 80 chars unless --verbose)
- sessions transcribe: prints stats header then chat log; thinking/tool-result blocks gated by --include flags; json/xml output serialize structured messages
- --follow for dump and transcribe: bypasses DB, polls file with byte-seek offset at 500ms intervals
- Update agents.rs stubs to accept &GlobalOpts (Wave 6 prep)
- Update main.rs to pass &cli.global to all session and agent commands
- 89 tests pass, 0 clippy warnings

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

@ -1,10 +1,11 @@
---
# claudbg-bh11
title: sessions list command
status: todo
status: in-progress
type: task
priority: normal
created_at: 2026-03-27T19:40:02Z
updated_at: 2026-03-27T19:40:02Z
updated_at: 2026-03-28T18:08:05Z
parent: claudbg-4d4h
---

@ -1,10 +1,11 @@
---
# claudbg-ei6j
title: sessions dump command
status: todo
status: in-progress
type: task
priority: normal
created_at: 2026-03-27T19:40:02Z
updated_at: 2026-03-27T19:40:02Z
updated_at: 2026-03-28T18:12:41Z
parent: claudbg-4d4h
---

@ -1,10 +1,11 @@
---
# claudbg-m4p1
title: sessions transcribe command
status: todo
status: in-progress
type: task
priority: normal
created_at: 2026-03-27T19:40:02Z
updated_at: 2026-03-27T19:40:02Z
updated_at: 2026-03-28T18:12:42Z
parent: claudbg-4d4h
---

@ -1,10 +1,11 @@
---
# claudbg-zwoj
title: follow flag for sessions commands
status: todo
status: in-progress
type: task
priority: normal
created_at: 2026-03-27T19:40:28Z
updated_at: 2026-03-27T19:40:28Z
updated_at: 2026-03-28T18:12:42Z
parent: claudbg-4d4h
---

@ -1,12 +1,12 @@
//! Agents subcommand implementations.
//! Agents subcommand implementations (stubs; full implementation in Wave 6).
use crate::error::Result;
/// Run `agents list`.
///
/// Lists all agent runs within the session identified by `session_id`.
/// Full UUIDs shown when `verbose` is true.
pub async fn list(_session_id: &str, _verbose: bool) -> Result<()> {
/// (Wave 6 implementation pending.)
pub async fn list(_session_id: &str, _opts: &crate::cli::GlobalOpts) -> Result<()> {
println!("agents list: coming soon");
Ok(())
}
@ -15,7 +15,13 @@ pub async fn list(_session_id: &str, _verbose: bool) -> Result<()> {
///
/// Dumps raw messages from the agent run identified by `agent_id` within `session_id`.
/// Streams new entries when `follow` is true.
pub async fn dump(_session_id: &str, _agent_id: &str, _follow: bool, _verbose: bool) -> Result<()> {
/// (Wave 6 implementation pending.)
pub async fn dump(
_session_id: &str,
_agent_id: &str,
_follow: bool,
_opts: &crate::cli::GlobalOpts,
) -> Result<()> {
println!("agents dump: coming soon");
Ok(())
}
@ -24,11 +30,12 @@ pub async fn dump(_session_id: &str, _agent_id: &str, _follow: bool, _verbose: b
///
/// Shows a human-readable transcript of the agent run identified by `agent_id`.
/// Streams new entries when `follow` is true.
/// (Wave 6 implementation pending.)
pub async fn transcribe(
_session_id: &str,
_agent_id: &str,
_follow: bool,
_verbose: bool,
_opts: &crate::cli::GlobalOpts,
) -> Result<()> {
println!("agents transcribe: coming soon");
Ok(())
@ -37,25 +44,34 @@ pub async fn transcribe(
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{GlobalOpts, IncludeList, OutputFormat};
fn default_opts() -> GlobalOpts {
GlobalOpts {
output: OutputFormat::Table,
verbose: false,
include: IncludeList::default(),
}
}
/// `list` returns `Ok` without panicking.
#[tokio::test]
async fn list_returns_ok() {
let result = list("abc12345", false).await;
let result = list("abc12345", &default_opts()).await;
assert!(result.is_ok());
}
/// `dump` returns `Ok` without panicking.
#[tokio::test]
async fn dump_returns_ok() {
let result = dump("abc12345", "def67890", false, false).await;
let result = dump("abc12345", "def67890", false, &default_opts()).await;
assert!(result.is_ok());
}
/// `transcribe` returns `Ok` without panicking.
#[tokio::test]
async fn transcribe_returns_ok() {
let result = transcribe("abc12345", "def67890", false, false).await;
let result = transcribe("abc12345", "def67890", false, &default_opts()).await;
assert!(result.is_ok());
}
}

@ -2,54 +2,731 @@
use crate::error::Result;
/// Resolve an 8-char-or-full session ID prefix to a full session_id from the DB.
///
/// Returns [`crate::error::AppError::NotFound`] if no match, [`crate::error::AppError::InvalidArg`] if multiple matches.
async fn resolve_session_id(
db: &crate::db::connection::DbHandle,
prefix: &str,
) -> crate::error::Result<String> {
let conn = db
.connect()
.map_err(|e| crate::error::AppError::Db(e.to_string()))?;
let pattern = format!("{prefix}%");
let mut rows = conn
.query(
"SELECT session_id FROM sessions WHERE session_id LIKE ?1",
libsql::params![pattern],
)
.await
.map_err(|e| crate::error::AppError::Db(e.to_string()))?;
let mut matches = Vec::new();
while let Some(row) = rows
.next()
.await
.map_err(|e| crate::error::AppError::Db(e.to_string()))?
{
let sid: String = row
.get(0)
.map_err(|e| crate::error::AppError::Db(e.to_string()))?;
matches.push(sid);
}
match matches.len() {
0 => Err(crate::error::AppError::NotFound(format!(
"no session matching '{prefix}'"
))),
1 => Ok(matches.remove(0)),
_ => Err(crate::error::AppError::InvalidArg(format!(
"ambiguous prefix '{prefix}': {} matches",
matches.len()
))),
}
}
/// 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.
///
/// 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]");
}
}
}
}
}
}
/// Run `sessions list`.
///
/// Lists all sessions most-recent-first. Full UUIDs shown when `verbose` is true.
pub async fn list(_verbose: bool) -> Result<()> {
println!("sessions list: coming soon");
/// Discovers all session files, syncs them to the DB, then queries the DB
/// for a summary sorted most-recent-first. Respects `opts.output` and
/// `opts.verbose` (verbose shows full UUIDs instead of 8-char prefixes).
pub async fn list(opts: &crate::cli::GlobalOpts) -> Result<()> {
let db_path = crate::db::connection::default_db_path();
let db = crate::db::connection::open_db(&db_path, false).await?;
// Lazy sync all discovered sessions.
let sessions = crate::parser::discovery::discover_sessions()?;
for session_ref in &sessions {
crate::db::sync::ensure_synced(&db, session_ref).await?;
}
// Query DB for display.
let conn = db
.connect()
.map_err(|e| crate::error::AppError::Db(e.to_string()))?;
let mut rows_cursor = conn
.query(
"SELECT session_id, project_path, model, last_msg_at, message_count \
FROM sessions ORDER BY last_msg_at DESC",
(),
)
.await
.map_err(|e| crate::error::AppError::Db(e.to_string()))?;
// Collect rows.
let mut rows: Vec<Vec<String>> = Vec::new();
while let Some(row) = rows_cursor
.next()
.await
.map_err(|e| crate::error::AppError::Db(e.to_string()))?
{
let session_id: String = row.get(0).unwrap_or_default();
let project_path: String = row.get(1).unwrap_or_default();
let model: String = row.get(2).unwrap_or_default();
let last_msg_at: String = row.get(3).unwrap_or_default();
let message_count: i64 = row.get(4).unwrap_or_default();
let display_id = if opts.verbose {
session_id.clone()
} else {
crate::util::short_id(&session_id).to_string()
};
rows.push(vec![
display_id,
last_msg_at,
project_path,
model,
message_count.to_string(),
]);
}
let output = match opts.output {
crate::cli::OutputFormat::Table => {
crate::output::render_table(&["ID", "Date", "Project", "Model", "Messages"], &rows)?
}
crate::cli::OutputFormat::Json => crate::output::render_json(&rows)?,
crate::cli::OutputFormat::Xml => crate::output::render_xml_rows(
&["session_id", "date", "project", "model", "messages"],
&rows,
)?,
};
println!("{output}");
Ok(())
}
/// Run `sessions dump`.
///
/// Dumps raw messages from the session identified by `id` (8-char prefix or full UUID).
/// Streams new entries as they arrive when `follow` is true.
pub async fn dump(_id: &str, _follow: bool, _verbose: bool) -> Result<()> {
println!("sessions dump: coming soon");
/// When `follow` is true, streams new entries directly from the file as they arrive,
/// bypassing the DB cache.
pub async fn dump(id: &str, follow: bool, opts: &crate::cli::GlobalOpts) -> Result<()> {
if follow {
return dump_follow(id, opts).await;
}
let db_path = crate::db::connection::default_db_path();
let db = crate::db::connection::open_db(&db_path, false).await?;
// Sync all sessions so prefix resolution works.
let sessions = crate::parser::discovery::discover_sessions()?;
for sr in &sessions {
crate::db::sync::ensure_synced(&db, sr).await?;
}
let session_id = resolve_session_id(&db, id).await?;
// Fetch raw JSONL from DB.
let conn = db
.connect()
.map_err(|e| crate::error::AppError::Db(e.to_string()))?;
let mut rows_cursor = conn
.query(
"SELECT raw_jsonl FROM raw_sessions WHERE session_id = ?1",
libsql::params![session_id.clone()],
)
.await
.map_err(|e| crate::error::AppError::Db(e.to_string()))?;
let raw_jsonl = if let Some(row) = rows_cursor
.next()
.await
.map_err(|e| crate::error::AppError::Db(e.to_string()))?
{
row.get::<String>(0)
.map_err(|e| crate::error::AppError::Db(e.to_string()))?
} else {
return Err(crate::error::AppError::NotFound(format!(
"no raw data for session '{session_id}'"
)));
};
let truncate = if opts.verbose { usize::MAX } else { 80 };
let mut rows: Vec<Vec<String>> = Vec::new();
for (i, line) in raw_jsonl.lines().enumerate() {
if line.is_empty() {
continue;
}
let entry: crate::models::session::RawEntry = match serde_json::from_str(line) {
Ok(e) => e,
Err(_) => continue,
};
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 timestamp = entry.timestamp.clone().unwrap_or_default();
let preview = content_preview(&entry, truncate);
rows.push(vec![
(i + 1).to_string(),
timestamp,
entry_type,
role,
preview,
]);
}
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(())
}
/// Follow mode for `sessions dump`: polls the session file and prints new entries
/// as they arrive, bypassing the DB cache entirely.
async fn dump_follow(id: &str, opts: &crate::cli::GlobalOpts) -> Result<()> {
// Find the session file directly (bypass DB).
let sessions = crate::parser::discovery::discover_sessions()?;
let session_ref = sessions
.iter()
.find(|s| s.session_id.starts_with(id) || s.session_id == id)
.ok_or_else(|| crate::error::AppError::NotFound(format!("session '{id}' not found")))?
.clone();
let mut byte_offset: u64 = 0;
let mut seq = 0usize;
loop {
let file = tokio::fs::File::open(&session_ref.file_path).await?;
let metadata = file.metadata().await?;
let file_size = metadata.len();
if file_size > 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;
}
}
/// Run `sessions transcribe`.
///
/// Shows a human-readable transcript of the session identified by `id`.
/// Streams new entries as they arrive when `follow` is true.
pub async fn transcribe(_id: &str, _follow: bool, _verbose: bool) -> Result<()> {
println!("sessions transcribe: coming soon");
/// 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 from the file directly, bypassing the DB cache.
pub async fn transcribe(id: &str, follow: bool, opts: &crate::cli::GlobalOpts) -> Result<()> {
if follow {
return transcribe_follow(id, opts).await;
}
let db_path = crate::db::connection::default_db_path();
let db = crate::db::connection::open_db(&db_path, false).await?;
let sessions = crate::parser::discovery::discover_sessions()?;
for sr in &sessions {
crate::db::sync::ensure_synced(&db, sr).await?;
}
let session_id = resolve_session_id(&db, id).await?;
// Fetch raw JSONL.
let conn = db
.connect()
.map_err(|e| crate::error::AppError::Db(e.to_string()))?;
let mut rows_cursor = conn
.query(
"SELECT raw_jsonl FROM raw_sessions WHERE session_id = ?1",
libsql::params![session_id.clone()],
)
.await
.map_err(|e| crate::error::AppError::Db(e.to_string()))?;
let raw_jsonl = match rows_cursor
.next()
.await
.map_err(|e| crate::error::AppError::Db(e.to_string()))?
{
Some(row) => row
.get::<String>(0)
.map_err(|e| crate::error::AppError::Db(e.to_string()))?,
None => {
return Err(crate::error::AppError::NotFound(format!(
"no raw data for '{session_id}'"
)));
}
};
// Parse entries.
let entries: Vec<crate::models::session::RawEntry> = raw_jsonl
.lines()
.filter(|l| !l.is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
let stats = crate::models::stats::compute_stats(&entries);
match opts.output {
crate::cli::OutputFormat::Table => {
// Print stats header.
println!("Session: {}", crate::util::short_id(&session_id));
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!("Tools: {:?}", stats.tool_calls);
println!("Duration: {}ms", stats.duration_ms);
println!("{}", "─".repeat(80));
// Print chat log.
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()),
"type": e.entry_type,
"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(())
}
/// Follow mode for `sessions transcribe`: polls the session file and renders new
/// entries as they arrive, bypassing the DB cache entirely.
async fn transcribe_follow(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(id) || s.session_id == id)
.ok_or_else(|| crate::error::AppError::NotFound(format!("session '{id}' not found")))?
.clone();
let mut byte_offset: u64 = 0;
loop {
let file = tokio::fs::File::open(&session_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;
}
}
#[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(),
}
}
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` returns `Ok` without panicking.
/// `list` returns `Ok` with table output format without panicking.
///
/// Uses an isolated temp HOME so the test doesn't conflict with the real DB.
#[tokio::test]
async fn list_returns_ok() {
let result = list(false).await;
assert!(result.is_ok());
async fn list_table_returns_ok() {
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(&opts).await;
assert!(result.is_ok(), "list failed: {:?}", result.err());
}
/// `dump` returns `Ok` without panicking.
/// `list` returns `Ok` with JSON output format.
///
/// Uses an isolated temp HOME so the test doesn't conflict with the real DB.
#[tokio::test]
async fn dump_returns_ok() {
let result = dump("abc12345", false, false).await;
assert!(result.is_ok());
async fn list_json_returns_ok() {
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 = GlobalOpts {
output: OutputFormat::Json,
..default_opts()
};
let result = list(&opts).await;
assert!(result.is_ok(), "list json failed: {:?}", result.err());
}
/// `transcribe` returns `Ok` without panicking.
/// `list` returns `Ok` with XML output format.
///
/// Uses an isolated temp HOME so the test doesn't conflict with the real DB.
#[tokio::test]
async fn transcribe_returns_ok() {
let result = transcribe("abc12345", false, false).await;
assert!(result.is_ok());
async fn list_xml_returns_ok() {
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 = GlobalOpts {
output: OutputFormat::Xml,
..default_opts()
};
let result = list(&opts).await;
assert!(result.is_ok(), "list xml failed: {:?}", result.err());
}
/// `dump` with unknown ID returns `NotFound` (empty DB, no sessions discovered).
///
/// Uses an isolated temp HOME so the test doesn't conflict with the real DB.
#[tokio::test]
async fn dump_unknown_id_returns_not_found_or_ok() {
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();
// This ID is unlikely to exist; result should be Ok or NotFound, never a panic.
let result = dump("00000000", false, &opts).await;
match result {
Ok(_) => {}
Err(crate::error::AppError::NotFound(_)) => {}
Err(e) => panic!("unexpected error: {e}"),
}
}
/// `transcribe` with unknown ID returns `NotFound` or `Ok`.
///
/// Uses an isolated temp HOME so the test doesn't conflict with the real DB.
#[tokio::test]
async fn transcribe_unknown_id_returns_not_found_or_ok() {
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("00000000", false, &opts).await;
match result {
Ok(_) => {}
Err(crate::error::AppError::NotFound(_)) => {}
Err(e) => panic!("unexpected error: {e}"),
}
}
/// `resolve_session_id` returns `NotFound` for an empty DB.
#[tokio::test]
async fn resolve_session_id_empty_db_not_found() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test.db");
let db = crate::db::connection::open_db(&db_path, false)
.await
.expect("open db");
let result = resolve_session_id(&db, "abcdef01").await;
assert!(matches!(result, Err(crate::error::AppError::NotFound(_))));
}
/// `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 to `max_len` bytes.
#[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);
// The ellipsis adds a multi-byte char, so total len > 80 but raw slice ≤ 80.
assert!(preview.len() <= 84, "preview too long: {}", preview.len());
assert!(preview.ends_with('…'), "expected ellipsis");
}
/// `content_preview` returns 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 print thinking blocks when `include.thinking` is false.
#[test]
fn render_entry_text_thinking_gated() {
// We can only check it doesn't panic; actual stdout capture would need more setup.
let entry = make_raw_entry(
"assistant",
"assistant",
MessageContent::Blocks(vec![ContentBlock::Thinking {
thinking: "secret thoughts".to_string(),
}]),
);
let opts = default_opts(); // include.thinking = false
render_entry_text(&entry, &opts); // must not panic
}
/// `render_entry_text` does not print tool results when `include.output` is false.
#[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(); // include.output = false
render_entry_text(&entry, &opts); // must not panic
}
}

@ -9,38 +9,32 @@ async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Sessions { cmd } => match cmd {
SessionsCmd::List => claudbg::commands::sessions::list(cli.global.verbose).await?,
SessionsCmd::List => claudbg::commands::sessions::list(&cli.global).await?,
SessionsCmd::Dump { id, follow } => {
claudbg::commands::sessions::dump(&id, follow, cli.global.verbose).await?
claudbg::commands::sessions::dump(&id, follow, &cli.global).await?
}
SessionsCmd::Transcribe { id, follow } => {
claudbg::commands::sessions::transcribe(&id, follow, cli.global.verbose).await?
claudbg::commands::sessions::transcribe(&id, follow, &cli.global).await?
}
},
Commands::Agents { cmd } => match cmd {
AgentsCmd::List { session_id } => {
claudbg::commands::agents::list(&session_id, cli.global.verbose).await?
claudbg::commands::agents::list(&session_id, &cli.global).await?
}
AgentsCmd::Dump {
session_id,
agent_id,
follow,
} => {
claudbg::commands::agents::dump(&session_id, &agent_id, follow, cli.global.verbose)
.await?
claudbg::commands::agents::dump(&session_id, &agent_id, follow, &cli.global).await?
}
AgentsCmd::Transcribe {
session_id,
agent_id,
follow,
} => {
claudbg::commands::agents::transcribe(
&session_id,
&agent_id,
follow,
cli.global.verbose,
)
.await?
claudbg::commands::agents::transcribe(&session_id, &agent_id, follow, &cli.global)
.await?
}
},
Commands::Index { force } => claudbg::commands::index::run(force).await?,

Loading…
Cancel
Save