From cbe73b400d50654a10444598acf57d87b7a9a404 Mon Sep 17 00:00:00 2001 From: Alfio Caprino Date: Tue, 27 Oct 2026 14:24:26 +0100 Subject: [PATCH] fix(claude_code): drop the compaction summary from Common After a compaction Claude Code keeps the earlier rows in the file before the `compact_boundary` and writes the summary as a user row flagged `isCompactSummary`. Reading in file order already carries that history, so the summary became a user turn that repeated it. It now stays in the native body only. Fixes #100 --- CHANGELOG.md | 1 + docs/formats/claude-code.md | 1 + src/harness/claude_code.rs | 11 +++++++++++ tests/integration/claude_code.rs | 23 +++++++++++++++++++++++ 4 files changed, 36 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4fae10..57d48b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Each release is published to [crates.io](https://crates.io/crates/txcript), ### Fixed +- Drop the compaction summary from `claude_code` conversions ([#100](https://github.com/skillsynchq/txcript/issues/100)). The `user` line flagged `isCompactSummary` used to become a user prompt that repeated, condensed, the earlier history the file still holds before the `compact_boundary`. It now stays in the native body only. - Resolve a session id that exists in several stores to the most recently modified copy. `view`, `export`, `continue` and `crop` used to load whichever copy the file system listed first; discovery now breaks start-time ties by file mtime. ### Added diff --git a/docs/formats/claude-code.md b/docs/formats/claude-code.md index 4dfc0f3..c892dc2 100644 --- a/docs/formats/claude-code.md +++ b/docs/formats/claude-code.md @@ -60,6 +60,7 @@ everything else is preserved verbatim so a native load/save round-trips byte-los | `` markup | Slash command the user ran, as XML-ish tags filling a body | `Block::ToolUse` with `Tool::Command` | | `` markup | What the command printed | `Block::ToolResult` paired via `parentUuid` | | `` markup | Fixed boilerplate, regenerated by the CLI | dropped | +| `user` line with `isCompactSummary` | The summary a compaction writes after its `compact_boundary` (with `isVisibleInTranscriptOnly`); the file still holds the history it condenses, before the boundary | kept in the native body, no Common turn | | `summary` line | Session title with a `leafUuid` anchor | `Meta.title` (lowest precedence) | | `custom-title` / `agent-name` line | User-set or agent-derived title | `Meta.title` (custom-title wins) | | `teamName` / `agentName` envelope fields | Present on every line of a teammate session the team feature starts (distinct from the `agent-name` *line type* above) | `Meta.lineage` — see Lineage | diff --git a/src/harness/claude_code.rs b/src/harness/claude_code.rs index 5466689..9e90871 100644 --- a/src/harness/claude_code.rs +++ b/src/harness/claude_code.rs @@ -181,6 +181,9 @@ pub(crate) fn records_to_messages(records: &[Record], fallback_ts: DateTime for record in records { let (role, entry) = match record { + // The file still holds the history a compaction summary condenses + // (#100). + Record::User(e) if is_compact_summary(e) => continue, Record::User(e) => (Role::User, e), Record::Assistant(e) => (Role::Assistant, e), // Newer CLI versions write the same command envelopes on @@ -224,6 +227,14 @@ pub(crate) fn records_to_messages(records: &[Record], fallback_ts: DateTime messages } +/// Whether a `user` line is the summary a compaction writes after its +/// `compact_boundary`. Reading in file order already carries the history it +/// condenses, which the file keeps before the boundary, so the summary would +/// repeat it as a user turn. +fn is_compact_summary(entry: &EntryLine) -> bool { + entry.extra.get("isCompactSummary") == Some(&Value::Bool(true)) +} + /// The native records for a canonical conversation: one entry line per /// message (local-command turns as their envelopes), a linear `parentUuid` /// chain, and a leading summary line when `meta.title` is set. `meta.id` is diff --git a/tests/integration/claude_code.rs b/tests/integration/claude_code.rs index 0e6033f..d6fd58f 100644 --- a/tests/integration/claude_code.rs +++ b/tests/integration/claude_code.rs @@ -702,3 +702,26 @@ fn discover_leaves_lineage_none_when_the_lead_prefix_is_ambiguous() { let teammate = found.iter().find(|d| d.meta.id == "teammate").unwrap(); assert!(teammate.meta.lineage.is_none()); } + +/// The compaction summary repeats history the file still holds before its +/// `compact_boundary`: it is not a user turn (#100). +#[test] +fn the_compaction_summary_is_not_a_user_turn() { + let jsonl = r#"{"type":"user","uuid":"u1","sessionId":"s","timestamp":"2026-01-02T03:04:05.000Z","message":{"role":"user","content":"first prompt"}} +{"type":"system","subtype":"compact_boundary","uuid":"b1","parentUuid":null,"logicalParentUuid":"u1","sessionId":"s","timestamp":"2026-01-02T03:04:06.000Z","content":"Conversation compacted"} +{"type":"user","uuid":"c1","parentUuid":"b1","isCompactSummary":true,"isVisibleInTranscriptOnly":true,"sessionId":"s","timestamp":"2026-01-02T03:04:07.000Z","message":{"role":"user","content":"summary"}} +{"type":"user","uuid":"u2","parentUuid":"c1","sessionId":"s","timestamp":"2026-01-02T03:04:08.000Z","message":{"role":"user","content":"second prompt"}} +"#; + let native = claude_code::ClaudeCode::from_text(jsonl).unwrap(); + let conversation = claude_code::ClaudeCode::to_common(&native).unwrap(); + let texts: Vec<&str> = conversation + .body + .iter() + .flat_map(|m| &m.content) + .filter_map(|b| match b { + common::Block::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["first prompt", "second prompt"]); +}