Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/formats/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ everything else is preserved verbatim so a native load/save round-trips byte-los
| `<command-name>` markup | Slash command the user ran, as XML-ish tags filling a body | `Block::ToolUse` with `Tool::Command` |
| `<local-command-stdout>` markup | What the command printed | `Block::ToolResult` paired via `parentUuid` |
| `<local-command-caveat>` 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 |
Expand Down
11 changes: 11 additions & 0 deletions src/harness/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ pub(crate) fn records_to_messages(records: &[Record], fallback_ts: DateTime<Utc>

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
Expand Down Expand Up @@ -224,6 +227,14 @@ pub(crate) fn records_to_messages(records: &[Record], fallback_ts: DateTime<Utc>
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
Expand Down
23 changes: 23 additions & 0 deletions tests/integration/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
}