fix(dump): collapse consecutive identical rows in sessions dump

Add collapse_dump_rows() post-processing pass that merges runs of rows
with identical type/role/content into a single row showing the seq range
as "N-M". Applies to table, JSON, and XML output uniformly.

Closes claudbg-d9ev

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

@ -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.

@ -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() {

Loading…
Cancel
Save