From f10fcb9e010e4412bd444e08ede54791519da0f3 Mon Sep 17 00:00:00 2001 From: Yang Liu Date: Sun, 31 May 2026 13:45:23 +1200 Subject: [PATCH] fix: document and test MessageDisplay hook event compat (v2.1.152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code v2.1.152 adds a new MessageDisplay hook event. The parser already handles it correctly — hook_event is a plain String, not an enum, so unknown event names are captured without rejection, and all classify paths use presence-of-hookEvent checks rather than exhaustive matching. This commit: - Adds a comment on the hook_event field in entry.rs documenting the String storage rationale for forward compatibility - Adds 5 new tests covering MessageDisplay as progress, system/ hook_progress, and attachment entries - Adds a comment in session.rs explaining the uuid gap assumption when a MessageDisplay hook hides a message entirely: the backward chain walk terminates gracefully at the gap via the existing _ => break arm - Updates specs/01-parser-pipeline.md with a MessageDisplay entry in the version-compatibility normalisations table Fixes #117 --- specs/01-parser-pipeline.md | 17 +++--- src-tauri/src/parser/classify.rs | 90 ++++++++++++++++++++++++++++++++ src-tauri/src/parser/entry.rs | 46 ++++++++++++++++ src-tauri/src/parser/session.rs | 7 +++ 4 files changed, 152 insertions(+), 8 deletions(-) diff --git a/specs/01-parser-pipeline.md b/specs/01-parser-pipeline.md index 7130d79..4ebe09d 100644 --- a/specs/01-parser-pipeline.md +++ b/specs/01-parser-pipeline.md @@ -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 | --- diff --git a/src-tauri/src/parser/classify.rs b/src-tauri/src/parser/classify.rs index c5322cd..9e62e6a 100644 --- a/src-tauri/src/parser/classify.rs +++ b/src-tauri/src/parser/classify.rs @@ -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] diff --git a/src-tauri/src/parser/entry.rs b/src-tauri/src/parser/entry.rs index 1d4721e..a521001 100644 --- a/src-tauri/src/parser/entry.rs +++ b/src-tauri/src/parser/entry.rs @@ -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")] @@ -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 diff --git a/src-tauri/src/parser/session.rs b/src-tauri/src/parser/session.rs index 5893189..78ebfdd 100644 --- a/src-tauri/src/parser/session.rs +++ b/src-tauri/src/parser/session.rs @@ -137,6 +137,13 @@ fn resolve_live_chain_uuids(entries: &[Entry]) -> HashSet { // 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 = HashSet::new(); let mut current = live_tip; loop {