You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
803 lines
31 KiB
Rust
803 lines
31 KiB
Rust
//! 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::<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 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<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)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: SessionRow impl for AgentRef
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Wrapper around [`crate::parser::discovery::AgentRef`] that implements
|
|
/// [`crate::filter::SessionRow`] so filters can be applied to agent list rows.
|
|
struct AgentRowRef<'a>(&'a crate::parser::discovery::AgentRef);
|
|
|
|
impl crate::filter::SessionRow for AgentRowRef<'_> {
|
|
fn model(&self) -> &str {
|
|
// Expose agent_type via the `model` key so filters like `model:subagent` work.
|
|
self.0.agent_type.as_deref().unwrap_or("")
|
|
}
|
|
fn project(&self) -> &str {
|
|
// No meaningful project for an agent row.
|
|
""
|
|
}
|
|
fn id(&self) -> &str {
|
|
&self.0.agent_id
|
|
}
|
|
fn agents(&self) -> u64 {
|
|
0
|
|
}
|
|
fn messages(&self) -> u64 {
|
|
0
|
|
}
|
|
fn tokens(&self) -> Option<u64> {
|
|
None
|
|
}
|
|
fn date(&self) -> Option<chrono::NaiveDate> {
|
|
Some(self.0.modified_at.date_naive())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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).
|
|
/// The `limit` controls how many rows are returned (default 10, or all).
|
|
/// The `filters` are ANDed together; each is a filter query string as
|
|
/// accepted by [`crate::filter::Filter::parse`].
|
|
pub async fn list(
|
|
session_id: &str,
|
|
limit: crate::cli::Limit,
|
|
filters: Vec<String>,
|
|
opts: &crate::cli::GlobalOpts,
|
|
) -> Result<()> {
|
|
// Parse all filter expressions up front so we can report errors immediately.
|
|
let parsed_filters: Vec<crate::filter::Filter> = filters
|
|
.iter()
|
|
.map(|s| crate::filter::Filter::parse(s))
|
|
.collect::<Result<Vec<_>>>()?;
|
|
|
|
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)?;
|
|
|
|
// Apply filters (AND semantics: all filters must match).
|
|
let agents: Vec<_> = agents
|
|
.into_iter()
|
|
.filter(|a| parsed_filters.iter().all(|f| f.matches(&AgentRowRef(a))))
|
|
.collect();
|
|
|
|
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 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<serde_json::Value> = 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 <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.
|
|
///
|
|
/// 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<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 => {
|
|
let objects: Vec<serde_json::Value> = 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 <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`.
|
|
///
|
|
/// 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<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(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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(),
|
|
vec![],
|
|
&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(), vec![], &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
|
|
}
|
|
}
|