Skip to content
Merged
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
17 changes: 9 additions & 8 deletions specs/01-parser-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,15 @@ flowchart TD

### Version-Compatibility Normalisations

| Issue | Version | Fix |
| ----------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------- |
| Tool inputs JSON-encoded as strings | pre-v2.1.92 | Deserialise inner string → object |
| Fork reference in `forkedFrom` field | pre-v2.1.118 | Map to synthetic `fork-context-ref` |
| Hook payload in content text | all | Regex extraction of teammate ID, color, protocol |
| Large outputs written to disk | v2.1.89+ | `RE_PERSISTED_OUTPUT_PATH` → file read |
| Dynamic Workflow lifecycle types | v2.1.154+ | Add to `NOISE_ENTRY_TYPES`; capture workflow fields on `Entry` |
| `cache_creation_input_tokens` always 0 when API uses nested `cache_creation.input_tokens` | v2.1.152+ | `cache_creation_from_value()` reads both flat and nested forms; takes max |
| Issue | Version | Fix |
| ----------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Tool inputs JSON-encoded as strings | pre-v2.1.92 | Deserialise inner string → object |
| Fork reference in `forkedFrom` field | pre-v2.1.118 | Map to synthetic `fork-context-ref` |
| Hook payload in content text | all | Regex extraction of teammate ID, color, protocol |
| Large outputs written to disk | v2.1.89+ | `RE_PERSISTED_OUTPUT_PATH` → file read |
| Dynamic Workflow lifecycle types | v2.1.154+ | Add to `NOISE_ENTRY_TYPES`; capture workflow fields on `Entry` |
| `cache_creation_input_tokens` always 0 when API uses nested `cache_creation.input_tokens` | v2.1.152+ | `cache_creation_from_value()` reads both flat and nested forms; takes max |
| `MessageDisplay` hook event name | v2.1.152+ | `hook_event` stored as `String` — no enum; catch-all presence-of-hookEvent checks in classify handle any future event name without a code change |

---

Expand Down
90 changes: 90 additions & 0 deletions src-tauri/src/parser/classify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1709,6 +1709,96 @@ mod tests {
}
}

// --- Issue #117: v2.1.152+ MessageDisplay hook event is handled by existing catch-alls ---

#[test]
fn classify_message_display_progress_entry_produces_hook_msg() {
// v2.1.152+: MessageDisplay fires as a progress/hook_progress entry.
// The generic hookEvent presence check rescues it without an explicit match arm.
let e = Entry {
entry_type: "progress".to_string(),
uuid: "uuid-msg-display-progress".to_string(),
timestamp: "2026-05-27T10:00:00Z".to_string(),
data: Some(json!({
"type": "hook_progress",
"hookEvent": "MessageDisplay",
"hookName": "my-display-hook",
"command": "echo transforming"
})),
..Default::default()
};
match classify(e) {
Some(ClassifiedMsg::Hook(h)) => {
assert_eq!(h.hook_event, "MessageDisplay");
assert_eq!(h.hook_name, "my-display-hook");
}
other => panic!(
"Expected Hook for MessageDisplay progress entry, got {:?}",
other
),
}
}

#[test]
fn classify_message_display_system_hook_progress_entry_produces_hook_msg() {
// v2.1.152+: MessageDisplay may also arrive as type:"system", subtype:"hook_progress"
// in verbose/stream-json mode. The hook_progress subtype rescue must handle it.
let e = Entry {
entry_type: "system".to_string(),
uuid: "uuid-msg-display-sys".to_string(),
timestamp: "2026-05-27T10:01:00Z".to_string(),
subtype: "hook_progress".to_string(),
hook_event: "MessageDisplay".to_string(),
hook_name: "display-transform".to_string(),
..Default::default()
};
match classify(e) {
Some(ClassifiedMsg::Hook(h)) => {
assert_eq!(h.hook_event, "MessageDisplay");
assert_eq!(h.hook_name, "display-transform");
}
other => panic!(
"Expected Hook for system/hook_progress MessageDisplay entry, got {:?}",
other
),
}
}

#[test]
fn classify_message_display_attachment_entry_produces_hook_msg() {
// v2.1.152+: MessageDisplay hook results surface as attachment entries.
// The generic attachment hookEvent rescue must handle it without an explicit arm.
let e = Entry {
entry_type: "attachment".to_string(),
uuid: "uuid-msg-display-att".to_string(),
timestamp: "2026-05-27T10:02:00Z".to_string(),
attachment: Some(json!({
"type": "hook_success",
"hookEvent": "MessageDisplay",
"hookName": "my-display-hook",
"stdout": "transformed message text"
})),
..Default::default()
};
match classify(e) {
Some(ClassifiedMsg::Hook(h)) => {
assert_eq!(h.hook_event, "MessageDisplay");
assert_eq!(h.hook_name, "my-display-hook");
let meta = h
.metadata
.expect("metadata must be captured for attachment hooks");
assert_eq!(
meta.get("hookEvent").and_then(|v| v.as_str()),
Some("MessageDisplay")
);
}
other => panic!(
"Expected Hook for MessageDisplay attachment entry, got {:?}",
other
),
}
}

// --- Issue #60: fork-context-ref entry (v2.1.118+) is silently dropped ---

#[test]
Expand Down
46 changes: 46 additions & 0 deletions src-tauri/src/parser/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ pub struct Entry {
// Top-level fields present in system/hook_progress entries (verbose/stream-json mode).
#[serde(default)]
pub subtype: String,
// hook_event is stored as a plain String (not an enum) so that new hook event names
// introduced by future Claude Code releases (e.g. MessageDisplay added in v2.1.152) are
// captured as-is rather than rejected. Callers that need to distinguish specific event
// types should match on the string value with a wildcard fallback arm.
#[serde(default, rename = "hookEvent")]
pub hook_event: String,
#[serde(default, rename = "hookName")]
Expand Down Expand Up @@ -1036,6 +1040,48 @@ mod tests {
assert_eq!(entry.unwrap().uuid, "emoji-valid-pair");
}

// --- Issue #117: v2.1.152+ MessageDisplay hook event compat ---

#[test]
fn parse_entry_captures_message_display_hook_event_as_string() {
// v2.1.152+: MessageDisplay is a new hook event name that surfaces in JSONL entries.
// hook_event is stored as a plain String so this new value is captured without rejection.
let line = json!({
"type": "system",
"subtype": "hook_progress",
"uuid": "msg-display-uuid-001",
"timestamp": "2026-05-27T10:00:00Z",
"hookEvent": "MessageDisplay",
"hookName": "my-display-hook"
});
let bytes = serde_json::to_vec(&line).unwrap();
let entry = parse_entry(&bytes).expect("must parse MessageDisplay hook entry");
assert_eq!(entry.hook_event, "MessageDisplay");
assert_eq!(entry.hook_name, "my-display-hook");
}

#[test]
fn parse_entry_message_display_as_attachment_is_captured() {
// MessageDisplay can also surface as an attachment entry (hook result).
let line = json!({
"type": "attachment",
"uuid": "msg-display-att-uuid",
"timestamp": "2026-05-27T11:00:00Z",
"attachment": {
"type": "hook_success",
"hookEvent": "MessageDisplay",
"hookName": "transform-hook"
}
});
let bytes = serde_json::to_vec(&line).unwrap();
let entry = parse_entry(&bytes).expect("must parse MessageDisplay attachment entry");
let att = entry.attachment.expect("attachment must be captured");
assert_eq!(
att.get("hookEvent").and_then(|v| v.as_str()),
Some("MessageDisplay")
);
}

#[test]
fn parse_entry_unknown_fields_are_silently_ignored() {
// Future Claude Code versions may add more fields. The parser must never crash on
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/parser/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ fn resolve_live_chain_uuids(entries: &[Entry]) -> HashSet<String> {
// Step 3: walk backward from live_tip via parentUuid links.
// When parentUuid is empty but logicalParentUuid is set (compact_boundary entries),
// follow logicalParentUuid instead so that pre-compaction messages are included.
//
// UUID gap assumption (v2.1.152+): if a MessageDisplay hook hides an assistant message
// entirely, Claude Code may omit that entry from the JSONL, leaving a gap in the
// parentUuid chain. The `_ => break` arm below handles this gracefully — the backward
// walk simply terminates at the gap rather than panicking. The pre-gap messages are
// excluded from the live set, which is a conservative but safe degradation: they appear
// as a dead-end branch and are suppressed rather than shown in the wrong order.
let mut live_set: HashSet<String> = HashSet::new();
let mut current = live_tip;
loop {
Expand Down
Loading