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