@ -317,6 +317,11 @@ pub async fn dump(id: &str, follow: bool, opts: &crate::cli::GlobalOpts) -> Resu
] ) ;
}
// Collapse consecutive rows that are identical in type (col 2), role (col 3),
// and content preview (col 4). Collapsed rows show a "N-M" range in the seq
// column and retain the first row's timestamp.
let rows = collapse_dump_rows ( rows ) ;
let output = match opts . output {
crate ::cli ::OutputFormat ::Table = > {
crate ::output ::render_table ( & [ "#" , "Timestamp" , "Type" , "Role" , "Content" ] , & rows ) ?
@ -344,6 +349,48 @@ pub async fn dump(id: &str, follow: bool, opts: &crate::cli::GlobalOpts) -> Resu
Ok ( ( ) )
}
/// Collapse consecutive duplicate rows in a dump row set.
///
/// Rows are `[seq, timestamp, type, role, content]`. Two consecutive rows are
/// considered duplicates when their `type` (index 2), `role` (index 3), and
/// `content` (index 4) are all equal. A run of N identical rows is collapsed
/// into a single row whose `seq` field shows the range `"first-last"` (e.g.
/// `"555-661"`). Single (non-repeated) rows are left unchanged.
fn collapse_dump_rows ( rows : Vec < Vec < String > > ) -> Vec < Vec < String > > {
if rows . is_empty ( ) {
return rows ;
}
let mut collapsed : Vec < Vec < String > > = Vec ::new ( ) ;
let mut run_start = rows [ 0 ] . clone ( ) ;
let mut run_end_seq = rows [ 0 ] [ 0 ] . clone ( ) ;
for row in rows . into_iter ( ) . skip ( 1 ) {
if row [ 2 ] = = run_start [ 2 ] & & row [ 3 ] = = run_start [ 3 ] & & row [ 4 ] = = run_start [ 4 ] {
// Extend the current run.
run_end_seq = row [ 0 ] . clone ( ) ;
} else {
// Flush the current run.
let mut flushed = run_start . clone ( ) ;
if flushed [ 0 ] ! = run_end_seq {
flushed [ 0 ] = format! ( "{}-{}" , flushed [ 0 ] , run_end_seq ) ;
}
collapsed . push ( flushed ) ;
run_start = row ;
run_end_seq = run_start [ 0 ] . clone ( ) ;
}
}
// Flush the final run.
let mut flushed = run_start ;
if flushed [ 0 ] ! = run_end_seq {
flushed [ 0 ] = format! ( "{}-{}" , flushed [ 0 ] , run_end_seq ) ;
}
collapsed . push ( flushed ) ;
collapsed
}
/// 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 < ( ) > {
@ -756,6 +803,114 @@ mod tests {
render_entry_text ( & entry , & opts ) ; // must not panic
}
/// `collapse_dump_rows` leaves an empty input unchanged.
#[ test ]
fn collapse_dump_rows_empty ( ) {
assert_eq! ( collapse_dump_rows ( vec! [ ] ) , Vec ::< Vec < String > > ::new ( ) ) ;
}
/// `collapse_dump_rows` leaves a single row unchanged (no range suffix).
#[ test ]
fn collapse_dump_rows_single_row ( ) {
let rows = vec! [ vec! [
"1" . to_string ( ) ,
"ts" . to_string ( ) ,
"progress" . to_string ( ) ,
"" . to_string ( ) ,
"msg" . to_string ( ) ,
] ] ;
let result = collapse_dump_rows ( rows ) ;
assert_eq! ( result . len ( ) , 1 ) ;
assert_eq! ( result [ 0 ] [ 0 ] , "1" ) ;
}
/// `collapse_dump_rows` does not add a range to two different rows.
#[ test ]
fn collapse_dump_rows_no_duplicates ( ) {
let rows = vec! [
vec! [
"1" . to_string ( ) ,
"ts1" . to_string ( ) ,
"user" . to_string ( ) ,
"user" . to_string ( ) ,
"hello" . to_string ( ) ,
] ,
vec! [
"2" . to_string ( ) ,
"ts2" . to_string ( ) ,
"assistant" . to_string ( ) ,
"assistant" . to_string ( ) ,
"world" . to_string ( ) ,
] ,
] ;
let result = collapse_dump_rows ( rows ) ;
assert_eq! ( result . len ( ) , 2 ) ;
assert_eq! ( result [ 0 ] [ 0 ] , "1" ) ;
assert_eq! ( result [ 1 ] [ 0 ] , "2" ) ;
}
/// `collapse_dump_rows` collapses a run of identical type/role/content rows.
#[ test ]
fn collapse_dump_rows_collapses_run ( ) {
let make = | seq : & str , ts : & str | {
vec! [
seq . to_string ( ) ,
ts . to_string ( ) ,
"progress" . to_string ( ) ,
"" . to_string ( ) ,
"busy" . to_string ( ) ,
]
} ;
let rows = vec! [
make ( "1" , "t1" ) ,
make ( "2" , "t2" ) ,
make ( "3" , "t3" ) ,
make ( "4" , "t4" ) ,
] ;
let result = collapse_dump_rows ( rows ) ;
assert_eq! ( result . len ( ) , 1 ) ;
assert_eq! ( result [ 0 ] [ 0 ] , "1-4" , "seq should show range" ) ;
assert_eq! ( result [ 0 ] [ 1 ] , "t1" , "should keep first timestamp" ) ;
assert_eq! ( result [ 0 ] [ 2 ] , "progress" ) ;
}
/// `collapse_dump_rows` handles mixed runs correctly.
#[ test ]
fn collapse_dump_rows_mixed_runs ( ) {
let progress = | seq : & str | {
vec! [
seq . to_string ( ) ,
"ts" . to_string ( ) ,
"progress" . to_string ( ) ,
"" . to_string ( ) ,
"busy" . to_string ( ) ,
]
} ;
let user = | seq : & str | {
vec! [
seq . to_string ( ) ,
"ts" . to_string ( ) ,
"user" . to_string ( ) ,
"user" . to_string ( ) ,
"hello" . to_string ( ) ,
]
} ;
// progress x3, user x1, progress x2
let rows = vec! [
progress ( "1" ) ,
progress ( "2" ) ,
progress ( "3" ) ,
user ( "4" ) ,
progress ( "5" ) ,
progress ( "6" ) ,
] ;
let result = collapse_dump_rows ( rows ) ;
assert_eq! ( result . len ( ) , 3 ) ;
assert_eq! ( result [ 0 ] [ 0 ] , "1-3" ) ;
assert_eq! ( result [ 1 ] [ 0 ] , "4" ) ;
assert_eq! ( result [ 2 ] [ 0 ] , "5-6" ) ;
}
/// `render_entry_text` does not print tool results when `include.output` is false.
#[ test ]
fn render_entry_text_tool_result_gated ( ) {