diff --git a/.beans/claudbg-d9ev--sessions-dump-should-collapse-consecutive-identica.md b/.beans/claudbg-d9ev--sessions-dump-should-collapse-consecutive-identica.md index 512d3b6..abf9bbf 100644 --- a/.beans/claudbg-d9ev--sessions-dump-should-collapse-consecutive-identica.md +++ b/.beans/claudbg-d9ev--sessions-dump-should-collapse-consecutive-identica.md @@ -1,11 +1,11 @@ --- # claudbg-d9ev title: '`sessions dump` should collapse consecutive identical rows' -status: todo +status: completed type: bug priority: normal created_at: 2026-03-30T04:38:42Z -updated_at: 2026-03-30T04:41:14Z +updated_at: 2026-03-30T05:08:51Z parent: claudbg-tci9 --- @@ -41,3 +41,7 @@ After building the `rows` vector in `dump()`, run a post-processing pass: iterat ## Relevant files - `src/commands/sessions.rs` — `dump()` function, row-building loop + +## Summary of Changes + +Added collapse_dump_rows() post-processing pass in sessions.rs that merges consecutive rows with identical type/role/content into a single row with seq shown as 'N-M' range. Applied before output format branching so it affects table, JSON, and XML output uniformly. diff --git a/src/commands/sessions.rs b/src/commands/sessions.rs index e7c2d70..5422383 100644 --- a/src/commands/sessions.rs +++ b/src/commands/sessions.rs @@ -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> { + if rows.is_empty() { + return rows; + } + + let mut collapsed: Vec> = 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::>::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() {