Skip to main content

claude_session_types/events/
root.rs

1//! Root-level event types (Level 1)
2//!
3//! All events in Claude Code JSONL files parse into one of these 7 root types.
4//!
5//! # Root Event Types
6//!
7//! ```text
8//! Root Events (.type)
9//! ├── progress (82,200)              - Progress updates (MOST FREQUENT)
10//! ├── assistant (49,426)             - Assistant responses
11//! ├── user (29,913)                  - User messages
12//! ├── file-history-snapshot (65)     - File state snapshots
13//! ├── queue-operation (58)           - Queue management
14//! ├── system (6)                     - System messages
15//! └── summary (1)                    - Session summaries
16//! ```
17//!
18//! # Parsing Strategy
19//!
20//! Use serde's tagged enum to automatically parse based on `type` field:
21//!
22//! ```rust
23//! use claude_session_types::events::SessionEvent;
24//!
25//! let line = r#"{"type": "user", "uuid": "test-uuid", "sessionId": "session-1", "timestamp": "2024-01-01T00:00:00Z", "isSidechain": false, "message": {"role": "user", "content": "hello"}}"#;
26//! let event: SessionEvent = serde_json::from_str(line)?;
27//!
28//! match event {
29//!     SessionEvent::User(_user) => { /* handle user message */ }
30//!     SessionEvent::Assistant(_assistant) => { /* handle assistant */ }
31//!     SessionEvent::Progress(_progress) => { /* handle progress */ }
32//!     _ => {}
33//! }
34//! # Ok::<(), serde_json::Error>(())
35//! ```
36
37use chrono::{DateTime, Utc};
38use serde::{Deserialize, Serialize};
39use serde_json::Value as JsonValue;
40use std::collections::HashMap;
41use std::fmt;
42
43use super::message::{ContentBlock, MessageContent};
44use super::metadata::EventMetadata;
45use super::progress::{ProgressData, ProgressEvent};
46use super::system::SystemEvent;
47use super::tool_result::ToolUseResult;
48
49/// Top-level session event discriminator
50///
51/// All events in a Claude Code session JSONL file parse into one of these variants.
52/// Uses serde's tagged enum feature to automatically select variant based on `type` field.
53///
54/// # Frequency Distribution (per large session)
55///
56/// 1. `Progress`: ~82,200 (51%)
57/// 2. `Assistant`: ~49,426 (31%)
58/// 3. `User`: ~29,913 (19%)
59/// 4. `FileSnapshot`: ~65 (<0.1%)
60/// 5. `QueueOperation`: ~58 (<0.1%)
61/// 6. `System`: ~6 (<0.1%)
62/// 7. `Summary`: ~1 (<0.1%)
63///
64/// # Links and Relationships
65///
66/// Events form a conversation graph via:
67/// - `uuid`: Unique identifier for this event
68/// - `parent_uuid`: Links to previous message in conversation chain
69/// - `tool_use_id`: Links progress to tool invocation
70/// - `source_tool_assistant_uuid`: Links tool result back to assistant
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(tag = "type", rename_all = "lowercase")]
73pub enum SessionEvent {
74    /// User message event
75    ///
76    /// Represents messages from:
77    /// - Human users (userType = "external")
78    /// - Tool results (has tool_use_result field)
79    ///
80    /// # Links
81    ///
82    /// - `parent_uuid` → previous message
83    /// - `source_tool_assistant_uuid` → assistant that invoked tool
84    /// - Contains `.message.content[]` with text/tool_result types
85    ///
86    /// # Frequency
87    ///
88    /// ~29,913 events per large session (~19%)
89    #[serde(rename = "user")]
90    User(UserEvent),
91
92    /// Assistant message event
93    ///
94    /// Represents responses from Claude, including:
95    /// - Text responses
96    /// - Tool use invocations
97    /// - Token usage statistics
98    ///
99    /// # Links
100    ///
101    /// - `parent_uuid` → user message being responded to
102    /// - Contains `.message.content[]` with text/tool_use types
103    /// - Tool uses link to user tool results via `id`
104    ///
105    /// # Frequency
106    ///
107    /// ~49,426 events per large session (~31%)
108    #[serde(rename = "assistant")]
109    Assistant(AssistantEvent),
110
111    /// Progress event
112    ///
113    /// Real-time updates during tool execution. Contains full conversation
114    /// context in `.data.normalizedMessages[]`.
115    ///
116    /// # Links
117    ///
118    /// - `tool_use_id` → tool invocation that triggered this
119    /// - `parent_uuid` → parent message
120    /// - Contains full conversation history (HUGE!)
121    ///
122    /// # Frequency
123    ///
124    /// ~82,200 events per large session (~51% - MOST FREQUENT!)
125    #[serde(rename = "progress")]
126    Progress(ProgressEvent),
127
128    /// System event
129    ///
130    /// System-level events:
131    /// - Compact boundaries (conversation compaction)
132    /// - API errors
133    /// - System reminders
134    ///
135    /// # Links
136    ///
137    /// - `logical_parent_uuid` → last message before compaction
138    ///
139    /// # Frequency
140    ///
141    /// ~6 events per large session (rare but important!)
142    #[serde(rename = "system")]
143    System(SystemEvent),
144
145    /// File history snapshot
146    ///
147    /// Tracks file state at message boundaries for undo/redo.
148    ///
149    /// # Links
150    ///
151    /// - `message_id` → message where snapshot was taken
152    ///
153    /// # Frequency
154    ///
155    /// ~65 events per large session
156    #[serde(rename = "file-history-snapshot")]
157    FileSnapshot(FileHistorySnapshot),
158
159    /// Queue operation event
160    ///
161    /// Tracks session queue management (enqueue/dequeue).
162    ///
163    /// # Frequency
164    ///
165    /// ~58 events per large session
166    #[serde(rename = "queue-operation")]
167    QueueOperation(QueueOperation),
168
169    /// Session summary
170    ///
171    /// Summary of entire session (typically at end).
172    ///
173    /// # Frequency
174    ///
175    /// ~1 event per session
176    #[serde(rename = "summary")]
177    Summary(SessionSummary),
178
179    /// Root-level attachment event
180    ///
181    /// A hook result, todo reminder, or similar side-channel notice — the
182    /// same payload shape as the nested `attachment` field found inside
183    /// progress `normalizedMessages`, but emitted directly at the root. The
184    /// dominant root event type on real transcripts (v2.1.2xx): dwarfs every
185    /// other type combined in raw line count, though it carries no
186    /// conversational text relevant to a restore digest.
187    #[serde(rename = "attachment")]
188    Attachment(RootAttachmentEvent),
189
190    /// Custom session title
191    ///
192    /// The session title Claude Code's own UI shows, set by the user or the
193    /// model. Repeats verbatim through the file as it is re-affirmed; the
194    /// **last** occurrence is authoritative. This is the provider's own
195    /// title/topic — prefer it over any inferred label.
196    #[serde(rename = "custom-title")]
197    CustomTitle(CustomTitleEvent),
198
199    /// Model-generated session title
200    ///
201    /// A model-authored title, distinct from `custom-title` (which reflects
202    /// an explicit/user-affirmed title). Used as the topic fallback when no
203    /// `custom-title` is present.
204    #[serde(rename = "ai-title")]
205    AiTitle(AiTitleEvent),
206
207    /// Latest verbatim user prompt
208    ///
209    /// Tracks the most recent human prompt text as the session progresses;
210    /// updates repeatedly. Falls back to this for the topic when neither
211    /// title event is present.
212    #[serde(rename = "last-prompt")]
213    LastPrompt(LastPromptEvent),
214
215    /// Bridge session correlation (cloud sync identity) — not conversational
216    /// content, tracked only so it does not fall into the generic unknown
217    /// bucket.
218    #[serde(rename = "bridge-session")]
219    BridgeSession(BridgeSessionEvent),
220
221    /// ATIS latch state — harness-internal signal, not conversational content.
222    #[serde(rename = "atis-latch")]
223    AtisLatch(AtisLatchEvent),
224
225    /// Conversation mode marker (e.g. "normal") — harness-internal signal.
226    #[serde(rename = "mode")]
227    Mode(ModeEvent),
228
229    /// Permission-mode marker (e.g. "auto") — harness-internal signal.
230    #[serde(rename = "permission-mode")]
231    PermissionMode(PermissionModeEvent),
232
233    /// Agent/session display-name marker — harness-internal signal.
234    #[serde(rename = "agent-name")]
235    AgentName(AgentNameEvent),
236
237    /// Incremental file-history delta (undo/redo tracking), the incremental
238    /// counterpart to `file-history-snapshot`.
239    #[serde(rename = "file-history-delta")]
240    FileHistoryDelta(FileHistoryDeltaEvent),
241
242    /// Unknown event type (forward compatibility)
243    ///
244    /// Anything not matched above lands here. This is normal on a
245    /// continuously-evolving transcript format and must never be treated as a
246    /// parse failure — the whole point of tolerant parsing is that one
247    /// unrecognized root type never drops the rest of the line's siblings.
248    #[serde(other)]
249    Unknown,
250}
251
252impl SessionEvent {
253    /// Extract common metadata present in most events
254    pub fn metadata(&self) -> Option<EventMetadata> {
255        match self {
256            Self::User(e) => Some(e.metadata.clone()),
257            Self::Assistant(e) => Some(e.metadata.clone()),
258            Self::Progress(e) => Some(e.metadata.clone()),
259            Self::System(e) => Some(e.metadata()),
260            Self::Attachment(e) => Some(e.metadata.clone()),
261            _ => None,
262        }
263    }
264
265    /// Get UUID of this event
266    pub fn uuid(&self) -> Option<&str> {
267        match self {
268            Self::User(e) => Some(&e.metadata.uuid),
269            Self::Assistant(e) => Some(&e.metadata.uuid),
270            Self::Progress(e) => Some(&e.metadata.uuid),
271            Self::System(e) => e.uuid.as_deref(),
272            Self::FileSnapshot(e) => Some(&e.message_id),
273            Self::QueueOperation(e) => Some(&e.session_id),
274            Self::Summary(e) => Some(&e.session_id),
275            Self::Attachment(e) => Some(&e.metadata.uuid),
276            Self::CustomTitle(e) => Some(&e.session_id),
277            Self::AiTitle(e) => Some(&e.session_id),
278            Self::LastPrompt(e) => Some(&e.session_id),
279            Self::BridgeSession(e) => Some(&e.session_id),
280            Self::AtisLatch(e) => Some(&e.session_id),
281            Self::Mode(e) => Some(&e.session_id),
282            Self::PermissionMode(e) => Some(&e.session_id),
283            Self::AgentName(e) => Some(&e.session_id),
284            Self::FileHistoryDelta(e) => Some(&e.message_id),
285            Self::Unknown => None,
286        }
287    }
288
289    /// Get parent UUID for conversation graph traversal
290    pub fn parent_uuid(&self) -> Option<&str> {
291        match self {
292            Self::User(e) => e.metadata.parent_uuid.as_deref(),
293            Self::Assistant(e) => e.metadata.parent_uuid.as_deref(),
294            Self::Progress(e) => e.metadata.parent_uuid.as_deref(),
295            Self::System(e) => e.parent_uuid.as_deref(),
296            Self::Attachment(e) => e.metadata.parent_uuid.as_deref(),
297            _ => None,
298        }
299    }
300
301    /// Get event timestamp
302    ///
303    /// Several harness-internal marker events (`custom-title`, `ai-title`,
304    /// `last-prompt`, `bridge-session`, `atis-latch`, `mode`,
305    /// `permission-mode`, `agent-name`) carry no timestamp field on disk;
306    /// these fall back to the current time, matching the existing `Unknown`
307    /// fallback, since ordering by them is never meaningful.
308    pub fn timestamp(&self) -> DateTime<Utc> {
309        match self {
310            Self::User(e) => e.metadata.timestamp,
311            Self::Assistant(e) => e.metadata.timestamp,
312            Self::Progress(e) => e.metadata.timestamp,
313            Self::System(e) => e.timestamp,
314            Self::FileSnapshot(e) => e.timestamp,
315            Self::QueueOperation(e) => e.timestamp,
316            Self::Summary(e) => e.timestamp,
317            Self::Attachment(e) => e.metadata.timestamp,
318            Self::FileHistoryDelta(e) => e.timestamp,
319            Self::CustomTitle(_)
320            | Self::AiTitle(_)
321            | Self::LastPrompt(_)
322            | Self::BridgeSession(_)
323            | Self::AtisLatch(_)
324            | Self::Mode(_)
325            | Self::PermissionMode(_)
326            | Self::AgentName(_)
327            | Self::Unknown => Utc::now(),
328        }
329    }
330
331    /// Extract all text content from this event (for FTS indexing)
332    pub fn extract_text_content(&self) -> Option<String> {
333        match self {
334            Self::User(e) => e.extract_text_content(),
335            Self::Assistant(e) => e.extract_text_content(),
336            Self::Progress(e) => match &e.data {
337                ProgressData::AgentProgress(agent) => Some(agent.prompt.clone()),
338                ProgressData::BashProgress(bash) => Some(bash.full_output.clone()),
339                _ => None,
340            },
341            Self::System(e) => e.content.clone(),
342            _ => None,
343        }
344    }
345
346    /// Extract file paths mentioned in this event
347    pub fn extract_file_paths(&self) -> Vec<String> {
348        match self {
349            Self::User(e) => e.extract_file_paths(),
350            Self::Assistant(e) => e.extract_file_paths(),
351            Self::FileSnapshot(e) => e.snapshot.tracked_file_backups.keys().cloned().collect(),
352            _ => Vec::new(),
353        }
354    }
355
356    /// Extract tool names used in this event
357    pub fn extract_tool_names(&self) -> Vec<String> {
358        match self {
359            Self::User(e) => e.extract_tool_names(),
360            Self::Assistant(e) => e.extract_tool_names(),
361            _ => Vec::new(),
362        }
363    }
364}
365
366/// Origin of a `user` turn.
367///
368/// Real `kind` values seen on disk: `"human"` (a genuine human-typed
369/// prompt), `"task-notification"` (a delegated agent's completion notice
370/// routed back as a `user` turn), and `"peer"` (a message from another
371/// Claude session, e.g. a cross-session hand-back). Other fields vary by
372/// `kind` and are not modeled — only `kind` is needed to exclude
373/// harness-generated turns from a restore digest.
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct OriginInfo {
376    /// Origin kind, e.g. `"human"`, `"task-notification"`, `"peer"`
377    pub kind: String,
378}
379
380/// User message event
381///
382/// Represents messages from:
383/// - Human users (userType = "external")
384/// - Tool results (has tool_use_result field)
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct UserEvent {
387    /// Common event metadata
388    #[serde(flatten)]
389    pub metadata: EventMetadata,
390
391    /// Message content
392    pub message: MessageContent,
393
394    /// Permission mode (for file operations)
395    #[serde(rename = "permissionMode")]
396    pub permission_mode: Option<String>,
397
398    /// Marks injected/synthetic turns: local-command caveats and similar
399    /// harness-generated notices rather than something the human actually typed.
400    #[serde(rename = "isMeta")]
401    pub is_meta: Option<bool>,
402
403    /// Marks a synthesized conversation-compaction summary turn (the text
404    /// Claude Code writes back as a `user` turn to replay a compacted
405    /// conversation) rather than something the human actually typed.
406    #[serde(rename = "isCompactSummary")]
407    pub is_compact_summary: Option<bool>,
408
409    /// Where this turn came from. Present on newer transcripts; absent on
410    /// older ones and on several harness-injected shapes that predate this
411    /// field, so its absence does not by itself mean "human" — only specific
412    /// present values (`"task-notification"`, `"peer"`) are used, as an
413    /// exclusion signal.
414    pub origin: Option<OriginInfo>,
415
416    /// Tool result (if this is a tool result message)
417    ///
418    /// Deserialized leniently — see
419    /// [`crate::events::tool_result::deserialize_tool_use_result_lenient`].
420    #[serde(
421        rename = "toolUseResult",
422        deserialize_with = "super::tool_result::deserialize_tool_use_result_lenient",
423        default
424    )]
425    pub tool_use_result: Option<ToolUseResult>,
426
427    /// Links result back to assistant message that invoked tool
428    #[serde(rename = "sourceToolAssistantUUID")]
429    pub source_tool_assistant_uuid: Option<String>,
430}
431
432impl UserEvent {
433    /// Get UUID
434    pub fn uuid(&self) -> &str {
435        &self.metadata.uuid
436    }
437
438    /// Get parent UUID
439    pub fn parent_uuid(&self) -> Option<&str> {
440        self.metadata.parent_uuid.as_deref()
441    }
442
443    /// Get timestamp
444    pub fn timestamp(&self) -> DateTime<Utc> {
445        self.metadata.timestamp
446    }
447
448    /// Extract text content for FTS indexing
449    pub fn extract_text_content(&self) -> Option<String> {
450        let texts: Vec<String> = self
451            .message
452            .content
453            .iter()
454            .filter_map(|block| block.as_text().map(std::string::ToString::to_string))
455            .collect();
456
457        if texts.is_empty() {
458            None
459        } else {
460            Some(texts.join("\n"))
461        }
462    }
463
464    /// Extract file paths mentioned
465    pub fn extract_file_paths(&self) -> Vec<String> {
466        let mut paths = Vec::new();
467
468        // Check tool use result
469        if let Some(result) = &self.tool_use_result {
470            if let Some(path) = result.file_path() {
471                paths.push(path.to_string());
472            }
473        }
474
475        // Check content blocks
476        for block in &self.message.content {
477            if let ContentBlock::ToolResult(result) = block {
478                if let Some(path) = extract_path_from_json(&result.content) {
479                    paths.push(path);
480                }
481            }
482        }
483
484        paths
485    }
486
487    /// Extract tool names
488    pub fn extract_tool_names(&self) -> Vec<String> {
489        self.message
490            .content
491            .iter()
492            .filter_map(|block| {
493                if let ContentBlock::ToolResult(result) = block {
494                    Some(format!("tool_result:{}", result.tool_use_id))
495                } else {
496                    None
497                }
498            })
499            .collect()
500    }
501
502    /// Classify this turn's content for a restore digest.
503    ///
504    /// Excludes, as [`UserTurnKind::HarnessNotification`]:
505    /// - non-external turns (tool results routed back as `user` events with
506    ///   `userType` unset or `"internal"`)
507    /// - `isMeta` turns (harness-injected notices)
508    /// - `isCompactSummary` turns (the synthesized replay of a compacted
509    ///   conversation, written back as a `user` turn — not human-authored)
510    /// - turns whose `origin.kind` is `"task-notification"` (a delegated
511    ///   agent's completion notice) or `"peer"` (a cross-session message)
512    /// - turns whose content is only tool results (no text block at all —
513    ///   handled implicitly, since [`ContentBlock::as_text`] only matches
514    ///   text blocks)
515    /// - local-command echoes (`<local-command-caveat>`,
516    ///   `<local-command-stdout>`, `<local-command-stderr>`), stray
517    ///   `<task-notification>`/`<system-reminder>` text blocks not caught by
518    ///   the structural checks above
519    ///
520    /// Recognizes, as [`UserTurnKind::SlashCommand`]: a `<command-name>`
521    /// invocation — a real user action, not free text, but not a harness
522    /// notification either. The caller decides how to render it.
523    ///
524    /// Everything else is [`UserTurnKind::HumanPrompt`] — including
525    /// system-generated notices of a genuine user action, e.g.
526    /// `[Request interrupted by user]`, which reflect something the human
527    /// actually did even though the string wasn't typed character-by-character.
528    #[must_use]
529    pub fn classify_turn(&self) -> UserTurnKind<'_> {
530        if self.metadata.user_type.as_deref() != Some("external") {
531            return UserTurnKind::HarnessNotification;
532        }
533        if self.is_meta == Some(true) {
534            return UserTurnKind::HarnessNotification;
535        }
536        if self.is_compact_summary == Some(true) {
537            return UserTurnKind::HarnessNotification;
538        }
539        if matches!(
540            self.origin.as_ref().map(|origin| origin.kind.as_str()),
541            Some("task-notification" | "peer")
542        ) {
543            return UserTurnKind::HarnessNotification;
544        }
545
546        let Some(text) = self.message.content.iter().find_map(ContentBlock::as_text) else {
547            return UserTurnKind::HarnessNotification;
548        };
549        let trimmed = text.trim_start();
550
551        if trimmed.starts_with("<command-name>") {
552            if let (Some(name), Some(args)) =
553                (extract_tag(trimmed, "command-name"), extract_tag(trimmed, "command-args"))
554            {
555                return UserTurnKind::SlashCommand { name, args };
556            }
557        }
558
559        const NOTIFICATION_PREFIXES: [&str; 5] = [
560            "<local-command-caveat>",
561            "<local-command-stdout>",
562            "<local-command-stderr>",
563            "<task-notification>",
564            "<system-reminder>",
565        ];
566        if NOTIFICATION_PREFIXES.iter().any(|prefix| trimmed.starts_with(prefix)) {
567            return UserTurnKind::HarnessNotification;
568        }
569
570        UserTurnKind::HumanPrompt(text)
571    }
572}
573
574/// Classification of a `user` event's content for a restore digest.
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576pub enum UserTurnKind<'a> {
577    /// Genuine human-authored prompt text, or a system-generated notice of a
578    /// real user action (e.g. an interruption marker).
579    HumanPrompt(&'a str),
580    /// A slash-command invocation, e.g. `/model` with argument
581    /// `claude-fable-5`, or `/compact` with no arguments. A real user
582    /// action, but not free text — the caller decides how to render it
583    /// (e.g. `"/model claude-fable-5"`).
584    SlashCommand {
585        /// Command name, including its leading slash (e.g. `"/model"`)
586        name: &'a str,
587        /// Raw argument text, possibly empty
588        args: &'a str,
589    },
590    /// A harness-generated notice with no human-authored content: a
591    /// non-external/tool-result turn, an `isMeta` or `isCompactSummary`
592    /// turn, a task-notification or peer/cross-session message, or a
593    /// local-command echo.
594    HarnessNotification,
595}
596
597/// Extract the text between `<tag>` and `</tag>` in `text`, if both are present.
598fn extract_tag<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
599    let open = format!("<{tag}>");
600    let close = format!("</{tag}>");
601    let start = text.find(&open)? + open.len();
602    let end = start + text[start..].find(&close)?;
603    Some(&text[start..end])
604}
605
606/// Assistant message event
607///
608/// Represents responses from Claude, including text and tool invocations.
609#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct AssistantEvent {
611    /// Common event metadata
612    #[serde(flatten)]
613    pub metadata: EventMetadata,
614
615    /// Assistant message with model info and usage
616    pub message: AssistantMessage,
617
618    /// Request ID for API correlation
619    #[serde(rename = "requestId")]
620    pub request_id: Option<String>,
621}
622
623impl AssistantEvent {
624    /// Get UUID
625    pub fn uuid(&self) -> &str {
626        &self.metadata.uuid
627    }
628
629    /// Get parent UUID
630    pub fn parent_uuid(&self) -> Option<&str> {
631        self.metadata.parent_uuid.as_deref()
632    }
633
634    /// Get timestamp
635    pub fn timestamp(&self) -> DateTime<Utc> {
636        self.metadata.timestamp
637    }
638
639    /// Extract text content for FTS indexing
640    pub fn extract_text_content(&self) -> Option<String> {
641        let texts: Vec<String> = self
642            .message
643            .content
644            .iter()
645            .filter_map(|block| block.as_text().map(std::string::ToString::to_string))
646            .collect();
647
648        if texts.is_empty() {
649            None
650        } else {
651            Some(texts.join("\n"))
652        }
653    }
654
655    /// Extract file paths from tool use inputs
656    pub fn extract_file_paths(&self) -> Vec<String> {
657        self.message
658            .content
659            .iter()
660            .filter_map(|block| {
661                if let Some((_, name, input)) = block.as_tool_use() {
662                    if matches!(name, "Read" | "Write" | "Edit") {
663                        extract_path_from_json(input)
664                    } else {
665                        None
666                    }
667                } else {
668                    None
669                }
670            })
671            .collect()
672    }
673
674    /// Extract tool names
675    pub fn extract_tool_names(&self) -> Vec<String> {
676        self.message
677            .content
678            .iter()
679            .filter_map(|block| {
680                if let Some((_, name, _)) = block.as_tool_use() {
681                    Some(name.to_string())
682                } else {
683                    None
684                }
685            })
686            .collect()
687    }
688}
689
690/// Assistant message with model info and usage
691#[derive(Debug, Clone, Serialize, Deserialize)]
692pub struct AssistantMessage {
693    /// Model name (e.g., "claude-sonnet-4-5-20250929")
694    pub model: String,
695
696    /// Message ID (API-level identifier)
697    pub id: String,
698
699    /// Message type (always "message")
700    #[serde(rename = "type")]
701    pub message_type: String,
702
703    /// Role (always "assistant")
704    pub role: String,
705
706    /// Message content blocks
707    pub content: Vec<ContentBlock>,
708
709    /// Stop reason: "end_turn", "tool_use", "max_tokens"
710    #[serde(rename = "stop_reason")]
711    pub stop_reason: Option<String>,
712
713    /// Stop sequence (if stopped by sequence)
714    #[serde(rename = "stop_sequence")]
715    pub stop_sequence: Option<String>,
716
717    /// Token usage statistics
718    pub usage: Option<TokenUsage>,
719
720    /// Context management (reserved for future use)
721    #[serde(rename = "context_management")]
722    pub context_management: Option<JsonValue>,
723}
724
725/// Token usage with granular cache tracking
726///
727/// Captures detailed token usage for cost analysis.
728#[derive(Debug, Clone, Default, Serialize, Deserialize)]
729pub struct TokenUsage {
730    /// Input tokens (new prompt tokens)
731    #[serde(default)]
732    pub input_tokens: u64,
733
734    /// Output tokens (response tokens)
735    #[serde(default)]
736    pub output_tokens: u64,
737
738    /// Cache creation tokens (tokens added to cache, 25% more expensive)
739    #[serde(default)]
740    pub cache_creation_input_tokens: u64,
741
742    /// Cache read tokens (tokens read from cache, 90% discount)
743    #[serde(default)]
744    pub cache_read_input_tokens: u64,
745
746    /// Cache creation details
747    #[serde(default)]
748    pub cache_creation: Option<CacheCreation>,
749
750    /// Service tier (for pricing)
751    #[serde(default)]
752    pub service_tier: Option<String>,
753}
754
755/// Cache creation details
756#[derive(Debug, Clone, Serialize, Deserialize)]
757pub struct CacheCreation {
758    /// Ephemeral 5-minute cache tokens
759    #[serde(default)]
760    pub ephemeral_5m_input_tokens: u64,
761
762    /// Ephemeral 1-hour cache tokens
763    #[serde(default)]
764    pub ephemeral_1h_input_tokens: u64,
765}
766
767/// File history snapshot
768///
769/// Tracks file state at message boundaries for undo/redo.
770#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct FileHistorySnapshot {
772    /// Message ID where snapshot was taken
773    #[serde(rename = "messageId")]
774    pub message_id: String,
775
776    /// File snapshot data
777    pub snapshot: Snapshot,
778
779    /// Is this an update to existing snapshot
780    #[serde(rename = "isSnapshotUpdate")]
781    pub is_snapshot_update: bool,
782
783    /// Snapshot timestamp
784    pub timestamp: DateTime<Utc>,
785}
786
787/// Snapshot data
788#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct Snapshot {
790    /// Message ID
791    #[serde(rename = "messageId")]
792    pub message_id: String,
793
794    /// Map of file path → backup content
795    #[serde(rename = "trackedFileBackups")]
796    pub tracked_file_backups: HashMap<String, String>,
797
798    /// Snapshot timestamp
799    pub timestamp: DateTime<Utc>,
800}
801
802/// Queue operation event
803///
804/// Tracks session queue management.
805#[derive(Debug, Clone, Serialize, Deserialize)]
806pub struct QueueOperation {
807    /// Operation type: "enqueue" or "dequeue"
808    pub operation: String,
809
810    /// Session ID
811    #[serde(rename = "sessionId")]
812    pub session_id: String,
813
814    /// Timestamp
815    pub timestamp: DateTime<Utc>,
816}
817
818/// Session summary
819///
820/// Summary of entire session (typically at end).
821#[derive(Debug, Clone, Serialize, Deserialize)]
822pub struct SessionSummary {
823    /// Session ID
824    #[serde(rename = "sessionId")]
825    pub session_id: String,
826
827    /// Summary text
828    pub summary: Option<String>,
829
830    /// Session statistics
831    pub stats: Option<JsonValue>,
832
833    /// Timestamp
834    pub timestamp: DateTime<Utc>,
835}
836
837/// Root-level attachment event
838///
839/// Same payload as the nested attachment field in progress
840/// `normalizedMessages` (see [`crate::events::attachment::AttachmentType`]),
841/// emitted directly at the root. Real example:
842///
843/// ```json
844/// {
845///   "type": "attachment",
846///   "uuid": "...", "parentUuid": null, "sessionId": "...",
847///   "timestamp": "...", "isSidechain": false, "userType": "external",
848///   "cwd": "...", "version": "2.1.258", "gitBranch": "main",
849///   "attachment": {"type": "hook_success", "hookName": "SessionStart:startup", ...}
850/// }
851/// ```
852#[derive(Debug, Clone, Serialize, Deserialize)]
853pub struct RootAttachmentEvent {
854    /// Common event metadata
855    #[serde(flatten)]
856    pub metadata: EventMetadata,
857
858    /// Attachment payload
859    pub attachment: crate::events::attachment::AttachmentType,
860}
861
862/// `custom-title` event — the session title Claude Code's own UI shows.
863#[derive(Debug, Clone, Serialize, Deserialize)]
864pub struct CustomTitleEvent {
865    /// Session ID
866    #[serde(rename = "sessionId")]
867    pub session_id: String,
868
869    /// Title text, verbatim
870    #[serde(rename = "customTitle")]
871    pub custom_title: String,
872}
873
874/// `ai-title` event — a model-generated session title.
875#[derive(Debug, Clone, Serialize, Deserialize)]
876pub struct AiTitleEvent {
877    /// Session ID
878    #[serde(rename = "sessionId")]
879    pub session_id: String,
880
881    /// Title text, verbatim
882    #[serde(rename = "aiTitle")]
883    pub ai_title: String,
884}
885
886/// `last-prompt` event — the latest verbatim human prompt seen so far.
887#[derive(Debug, Clone, Serialize, Deserialize)]
888pub struct LastPromptEvent {
889    /// Session ID
890    #[serde(rename = "sessionId")]
891    pub session_id: String,
892
893    /// Prompt text, verbatim
894    #[serde(rename = "lastPrompt")]
895    pub last_prompt: String,
896
897    /// UUID of the conversation leaf this prompt was taken from
898    #[serde(rename = "leafUuid")]
899    pub leaf_uuid: Option<String>,
900}
901
902/// `bridge-session` event — cloud sync correlation, not conversational content.
903#[derive(Debug, Clone, Serialize, Deserialize)]
904pub struct BridgeSessionEvent {
905    /// Session ID
906    #[serde(rename = "sessionId")]
907    pub session_id: String,
908}
909
910/// `atis-latch` event — harness-internal latch state.
911#[derive(Debug, Clone, Serialize, Deserialize)]
912pub struct AtisLatchEvent {
913    /// Session ID
914    #[serde(rename = "sessionId")]
915    pub session_id: String,
916
917    /// Latch value (frequently empty)
918    pub atis: Option<String>,
919}
920
921/// `mode` event — conversation mode marker (e.g. `"normal"`).
922#[derive(Debug, Clone, Serialize, Deserialize)]
923pub struct ModeEvent {
924    /// Session ID
925    #[serde(rename = "sessionId")]
926    pub session_id: String,
927
928    /// Mode value
929    pub mode: String,
930}
931
932/// `permission-mode` event — permission mode marker (e.g. `"auto"`).
933#[derive(Debug, Clone, Serialize, Deserialize)]
934pub struct PermissionModeEvent {
935    /// Session ID
936    #[serde(rename = "sessionId")]
937    pub session_id: String,
938
939    /// Permission mode value
940    #[serde(rename = "permissionMode")]
941    pub permission_mode: String,
942}
943
944/// `agent-name` event — agent/session display-name marker.
945#[derive(Debug, Clone, Serialize, Deserialize)]
946pub struct AgentNameEvent {
947    /// Session ID
948    #[serde(rename = "sessionId")]
949    pub session_id: String,
950
951    /// Display name
952    #[serde(rename = "agentName")]
953    pub agent_name: String,
954}
955
956/// `file-history-delta` event — incremental undo/redo tracking, the
957/// incremental counterpart to [`FileHistorySnapshot`].
958#[derive(Debug, Clone, Serialize, Deserialize)]
959pub struct FileHistoryDeltaEvent {
960    /// Message ID this delta is attached to
961    #[serde(rename = "messageId")]
962    pub message_id: String,
963
964    /// Message ID of the snapshot this delta is relative to
965    #[serde(rename = "snapshotMessageId")]
966    pub snapshot_message_id: Option<String>,
967
968    /// File path being tracked
969    #[serde(rename = "trackingPath")]
970    pub tracking_path: String,
971
972    /// Timestamp
973    pub timestamp: DateTime<Utc>,
974}
975
976/// Helper function to extract file path from JSON value
977fn extract_path_from_json(value: &JsonValue) -> Option<String> {
978    value
979        .get("file_path")
980        .or_else(|| value.get("filePath"))
981        .and_then(|v| v.as_str())
982        .map(std::string::ToString::to_string)
983}
984
985// Display implementations
986impl fmt::Display for SessionEvent {
987    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
988        match self {
989            Self::User(e) => write!(
990                f,
991                "User[{}] at {}",
992                e.metadata.uuid,
993                e.metadata.timestamp.format("%Y-%m-%d %H:%M:%S")
994            ),
995            Self::Assistant(e) => write!(
996                f,
997                "Assistant[{}] {} at {}",
998                e.metadata.uuid,
999                e.message.model,
1000                e.metadata.timestamp.format("%Y-%m-%d %H:%M:%S")
1001            ),
1002            Self::Progress(e) => write!(
1003                f,
1004                "Progress[{}] {:?} at {}",
1005                e.metadata.uuid,
1006                e.data,
1007                e.metadata.timestamp.format("%Y-%m-%d %H:%M:%S")
1008            ),
1009            Self::System(e) => {
1010                if let Some(uuid) = &e.uuid {
1011                    write!(
1012                        f,
1013                        "System[{}] {:?} at {}",
1014                        uuid,
1015                        e.subtype,
1016                        e.timestamp.format("%Y-%m-%d %H:%M:%S")
1017                    )
1018                } else {
1019                    write!(
1020                        f,
1021                        "System {:?} at {}",
1022                        e.subtype,
1023                        e.timestamp.format("%Y-%m-%d %H:%M:%S")
1024                    )
1025                }
1026            }
1027            Self::FileSnapshot(e) => {
1028                write!(
1029                    f,
1030                    "FileSnapshot[{}] {} files",
1031                    e.message_id,
1032                    e.snapshot.tracked_file_backups.len()
1033                )
1034            }
1035            Self::QueueOperation(e) => write!(
1036                f,
1037                "QueueOp[{}] {} at {}",
1038                e.session_id,
1039                e.operation,
1040                e.timestamp.format("%Y-%m-%d %H:%M:%S")
1041            ),
1042            Self::Summary(e) => write!(f, "Summary[{}]", e.session_id),
1043            Self::Attachment(e) => write!(
1044                f,
1045                "Attachment[{}] at {}",
1046                e.metadata.uuid,
1047                e.metadata.timestamp.format("%Y-%m-%d %H:%M:%S")
1048            ),
1049            Self::CustomTitle(e) => write!(f, "CustomTitle[{}] {:?}", e.session_id, e.custom_title),
1050            Self::AiTitle(e) => write!(f, "AiTitle[{}] {:?}", e.session_id, e.ai_title),
1051            Self::LastPrompt(e) => write!(f, "LastPrompt[{}] {:?}", e.session_id, e.last_prompt),
1052            Self::BridgeSession(e) => write!(f, "BridgeSession[{}]", e.session_id),
1053            Self::AtisLatch(e) => write!(f, "AtisLatch[{}]", e.session_id),
1054            Self::Mode(e) => write!(f, "Mode[{}] {}", e.session_id, e.mode),
1055            Self::PermissionMode(e) => {
1056                write!(f, "PermissionMode[{}] {}", e.session_id, e.permission_mode)
1057            }
1058            Self::AgentName(e) => write!(f, "AgentName[{}] {:?}", e.session_id, e.agent_name),
1059            Self::FileHistoryDelta(e) => {
1060                write!(f, "FileHistoryDelta[{}] {}", e.message_id, e.tracking_path)
1061            }
1062            Self::Unknown => write!(f, "Unknown event"),
1063        }
1064    }
1065}
1066
1067impl fmt::Display for ProgressData {
1068    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1069        match self {
1070            Self::BashProgress(data) => write!(f, "Bash({}s)", data.elapsed_time_seconds),
1071            Self::HookProgress(data) => write!(f, "Hook({})", data.hook_name),
1072            Self::AgentProgress(data) => write!(f, "Agent({})", data.agent_id),
1073            Self::QueryUpdate(data) => write!(f, "Query({})", data.query),
1074            Self::SearchResultsReceived(_) => write!(f, "SearchResults"),
1075            Self::WaitingForTask(data) => write!(f, "WaitingForTask({})", data.task_type),
1076            Self::Unknown => write!(f, "UnknownProgress"),
1077        }
1078    }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084
1085    #[test]
1086    fn test_parse_user_event() {
1087        let json = r#"{
1088            "type": "user",
1089            "uuid": "user-uuid",
1090            "parentUuid": null,
1091            "sessionId": "session-123",
1092            "timestamp": "2024-01-01T00:00:00Z",
1093            "isSidechain": false,
1094            "userType": "external",
1095            "cwd": "/test",
1096            "message": {
1097                "role": "user",
1098                "content": [
1099                    {"type": "text", "text": "Hello"}
1100                ]
1101            }
1102        }"#;
1103
1104        let event: SessionEvent = serde_json::from_str(json).unwrap();
1105        assert!(matches!(event, SessionEvent::User(_)));
1106        assert_eq!(event.uuid(), Some("user-uuid"));
1107    }
1108
1109    #[test]
1110    fn test_parse_assistant_event() {
1111        let json = r#"{
1112            "type": "assistant",
1113            "uuid": "assistant-uuid",
1114            "parentUuid": "user-uuid",
1115            "sessionId": "session-123",
1116            "timestamp": "2024-01-01T00:00:00Z",
1117            "isSidechain": false,
1118            "cwd": "/test",
1119            "message": {
1120                "model": "claude-sonnet-4-5",
1121                "id": "msg_123",
1122                "type": "message",
1123                "role": "assistant",
1124                "content": [
1125                    {"type": "text", "text": "Hello!"}
1126                ],
1127                "stop_reason": "end_turn"
1128            }
1129        }"#;
1130
1131        let event: SessionEvent = serde_json::from_str(json).unwrap();
1132        assert!(matches!(event, SessionEvent::Assistant(_)));
1133    }
1134
1135    #[test]
1136    fn test_parse_progress_event() {
1137        let json = r#"{
1138            "type": "progress",
1139            "uuid": "progress-uuid",
1140            "parentUuid": null,
1141            "sessionId": "session-123",
1142            "timestamp": "2024-01-01T00:00:00Z",
1143            "isSidechain": false,
1144            "cwd": "/test",
1145            "toolUseID": "tool-123",
1146            "data": {
1147                "type": "bash_progress",
1148                "output": "test",
1149                "fullOutput": "test",
1150                "elapsedTimeSeconds": 1,
1151                "totalLines": 1,
1152                "message": {},
1153                "normalizedMessages": []
1154            }
1155        }"#;
1156
1157        let event: SessionEvent = serde_json::from_str(json).unwrap();
1158        assert!(matches!(event, SessionEvent::Progress(_)));
1159    }
1160
1161    #[test]
1162    fn test_extract_text_content() {
1163        let json = r#"{
1164            "type": "user",
1165            "uuid": "user-uuid",
1166            "parentUuid": null,
1167            "sessionId": "session-123",
1168            "timestamp": "2024-01-01T00:00:00Z",
1169            "isSidechain": false,
1170            "userType": "external",
1171            "cwd": "/test",
1172            "message": {
1173                "role": "user",
1174                "content": [
1175                    {"type": "text", "text": "First line"},
1176                    {"type": "text", "text": "Second line"}
1177                ]
1178            }
1179        }"#;
1180
1181        let event: SessionEvent = serde_json::from_str(json).unwrap();
1182        let text = event.extract_text_content();
1183        assert_eq!(text, Some("First line\nSecond line".to_string()));
1184    }
1185
1186    // ------------------------------------------------------------------
1187    // Fixtures below are built from real event shapes observed on-disk in
1188    // Claude Code v2.1.2xx transcripts (content redacted to placeholders).
1189    // See docs/session-restore/audits/2026-09-23-restorers-live-test.md.
1190    // ------------------------------------------------------------------
1191
1192    #[test]
1193    fn test_parse_user_event_with_bare_string_content() {
1194        // Real shape: plain single-turn human prompts carry `content` as a
1195        // bare string, not an array of blocks (D1).
1196        let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"placeholder prompt text"},"uuid":"user-uuid","timestamp":"2026-09-05T23:53:25.920Z","permissionMode":"auto","userType":"external","entrypoint":"claude-desktop","cwd":"C:\\work","sessionId":"session-123","version":"2.1.258","gitBranch":"main"}"#;
1197
1198        let event: SessionEvent = serde_json::from_str(json).expect("bare string content must parse");
1199        let SessionEvent::User(user) = event else {
1200            panic!("expected user event");
1201        };
1202        assert_eq!(user.classify_turn(), UserTurnKind::HumanPrompt("placeholder prompt text"));
1203    }
1204
1205    #[test]
1206    fn test_classify_turn_skips_meta_turns() {
1207        let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"<local-command-caveat>Caveat: placeholder</local-command-caveat>"},"isMeta":true,"uuid":"user-uuid","timestamp":"2026-09-06T00:14:48.155Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.258","gitBranch":"main"}"#;
1208
1209        let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1210            panic!("expected user event");
1211        };
1212        assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1213    }
1214
1215    #[test]
1216    fn test_classify_turn_recognizes_slash_command_with_args() {
1217        // Real shape: `/model claude-fable-5`.
1218        let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"<command-name>/model</command-name>\n            <command-message>model</command-message>\n            <command-args>placeholder-model</command-args>"},"uuid":"user-uuid","timestamp":"2026-08-28T17:02:33.492Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}"#;
1219
1220        let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1221            panic!("expected user event");
1222        };
1223        assert_eq!(
1224            user.classify_turn(),
1225            UserTurnKind::SlashCommand { name: "/model", args: "placeholder-model" }
1226        );
1227    }
1228
1229    #[test]
1230    fn test_classify_turn_recognizes_slash_command_without_args() {
1231        // Real shape: `/compact` with an empty `<command-args>` tag.
1232        let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"<command-name>/compact</command-name>\n            <command-message>compact</command-message>\n            <command-args></command-args>"},"uuid":"user-uuid","timestamp":"2026-08-28T17:02:33.492Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}"#;
1233
1234        let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1235            panic!("expected user event");
1236        };
1237        assert_eq!(user.classify_turn(), UserTurnKind::SlashCommand { name: "/compact", args: "" });
1238    }
1239
1240    #[test]
1241    fn test_classify_turn_skips_local_command_stdout_and_stderr() {
1242        for content in [
1243            "<local-command-stdout>Set model to placeholder</local-command-stdout>",
1244            "<local-command-stderr>placeholder error</local-command-stderr>",
1245        ] {
1246            let json = format!(
1247                r#"{{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{{"role":"user","content":"{content}"}},"uuid":"user-uuid","timestamp":"2026-08-28T17:02:33.492Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}}"#
1248            );
1249            let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(&json).unwrap() else {
1250                panic!("expected user event");
1251            };
1252            assert_eq!(
1253                user.classify_turn(),
1254                UserTurnKind::HarnessNotification,
1255                "must skip: {content}"
1256            );
1257        }
1258    }
1259
1260    #[test]
1261    fn test_classify_turn_skips_task_notification_by_content_and_by_origin() {
1262        // By content prefix alone (no origin field — defense in depth).
1263        let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"<task-notification>\n<task-id>placeholder</task-id>\n<status>completed</status>\n</task-notification>"},"uuid":"user-uuid","timestamp":"2026-09-22T00:00:00.000Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.258","gitBranch":"main"}"#;
1264        let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1265            panic!("expected user event");
1266        };
1267        assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1268
1269        // Real shape: `origin: {"kind": "task-notification"}`.
1270        let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"placeholder result text, not a real prompt"},"uuid":"user-uuid","timestamp":"2026-09-22T00:00:00.000Z","userType":"external","origin":{"kind":"task-notification"},"cwd":"C:\\work","sessionId":"session-123","version":"2.1.258","gitBranch":"main"}"#;
1271        let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1272            panic!("expected user event");
1273        };
1274        assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1275    }
1276
1277    #[test]
1278    fn test_classify_turn_skips_peer_cross_session_message() {
1279        // Real shape: `origin: {"kind": "peer", ...}`, isMeta true.
1280        let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"Another Claude session sent a message:\n<cross-session-message from=\"placeholder\">placeholder body</cross-session-message>"},"isMeta":true,"origin":{"kind":"peer"},"uuid":"user-uuid","timestamp":"2026-08-28T17:02:33.492Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}"#;
1281        let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1282            panic!("expected user event");
1283        };
1284        assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1285    }
1286
1287    #[test]
1288    fn test_classify_turn_skips_compact_summary() {
1289        // Real shape: `isCompactSummary: true` — the synthesized replay text
1290        // Claude Code writes back as a `user` turn after compaction. userType
1291        // is "external" and isMeta is absent, so only the dedicated field
1292        // catches this.
1293        let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"This session is being continued from a previous conversation that ran out of context. Summary: placeholder"},"isCompactSummary":true,"isVisibleInTranscriptOnly":true,"uuid":"user-uuid","timestamp":"2026-09-10T00:00:00.000Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.258","gitBranch":"main"}"#;
1294        let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1295            panic!("expected user event");
1296        };
1297        assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1298    }
1299
1300    #[test]
1301    fn test_classify_turn_keeps_interruption_marker_as_human_prompt() {
1302        // Real shape: an array-content turn holding only a text block that
1303        // is a system-generated notice of a genuine user action (hitting
1304        // Escape), not free text — still counts as human.
1305        let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":[{"type":"text","text":"[Request interrupted by user]"}]},"uuid":"user-uuid","timestamp":"2026-08-28T17:02:27.169Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}"#;
1306        let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1307            panic!("expected user event");
1308        };
1309        assert_eq!(user.classify_turn(), UserTurnKind::HumanPrompt("[Request interrupted by user]"));
1310    }
1311
1312    #[test]
1313    fn test_classify_turn_skips_tool_result_only_turn() {
1314        // Real shape: a user turn whose content array holds only a
1315        // tool_result block (a system-reminder response), no text block.
1316        let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":[{"tool_use_id":"tool-use-id","type":"tool_result","content":"<system-reminder>placeholder warning</system-reminder>"}]},"uuid":"user-uuid","timestamp":"2026-08-30T23:51:17.045Z","toolUseResult":{"type":"text","file":{"filePath":"C:\\work\\out.txt","content":"","numLines":1,"startLine":1,"totalLines":1}},"sourceToolAssistantUUID":"assistant-uuid","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}"#;
1317
1318        let event: SessionEvent =
1319            serde_json::from_str(json).expect("mismatched toolUseResult shape must not fail the event");
1320        let SessionEvent::User(user) = event else {
1321            panic!("expected user event");
1322        };
1323        assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1324        assert!(user.tool_use_result.is_none());
1325    }
1326
1327    #[test]
1328    fn test_classify_turn_skips_internal_user_type() {
1329        let json = r#"{"parentUuid":"parent-uuid","isSidechain":false,"promptId":"prompt-id","type":"user","message":{"role":"user","content":"placeholder"},"uuid":"user-uuid","timestamp":"2026-08-28T17:02:33.492Z","userType":"internal","cwd":"C:\\work","sessionId":"session-123","version":"2.1.246","gitBranch":"main"}"#;
1330
1331        let SessionEvent::User(user) = serde_json::from_str::<SessionEvent>(json).unwrap() else {
1332            panic!("expected user event");
1333        };
1334        assert_eq!(user.classify_turn(), UserTurnKind::HarnessNotification);
1335    }
1336
1337    use crate::events::attachment::AttachmentType;
1338
1339    #[test]
1340    fn test_parse_root_attachment_event() {
1341        let json = r#"{"parentUuid":null,"isSidechain":false,"attachment":{"type":"hook_success","hookName":"SessionStart:startup","hookEvent":"SessionStart","output":"placeholder"},"type":"attachment","uuid":"attachment-uuid","timestamp":"2026-09-05T23:53:25.318Z","userType":"external","cwd":"C:\\work","sessionId":"session-123","version":"2.1.258","gitBranch":"main"}"#;
1342
1343        let event: SessionEvent = serde_json::from_str(json).expect("root attachment event must parse");
1344        let SessionEvent::Attachment(attachment) = event else {
1345            panic!("expected attachment event");
1346        };
1347        assert_eq!(attachment.metadata.uuid, "attachment-uuid");
1348        assert!(matches!(attachment.attachment, AttachmentType::HookSuccess(_)));
1349    }
1350
1351    #[test]
1352    fn test_parse_custom_title_last_prompt_ai_title() {
1353        let custom_title: SessionEvent =
1354            serde_json::from_str(r#"{"type":"custom-title","customTitle":"placeholder title","sessionId":"session-123"}"#)
1355                .unwrap();
1356        assert!(matches!(custom_title, SessionEvent::CustomTitle(ref e) if e.custom_title == "placeholder title"));
1357
1358        let ai_title: SessionEvent = serde_json::from_str(
1359            r#"{"type":"ai-title","aiTitle":"placeholder ai title","sessionId":"session-123"}"#,
1360        )
1361        .unwrap();
1362        assert!(matches!(ai_title, SessionEvent::AiTitle(ref e) if e.ai_title == "placeholder ai title"));
1363
1364        let last_prompt: SessionEvent = serde_json::from_str(
1365            r#"{"type":"last-prompt","lastPrompt":"placeholder last prompt","leafUuid":"leaf-uuid","sessionId":"session-123"}"#,
1366        )
1367        .unwrap();
1368        assert!(
1369            matches!(last_prompt, SessionEvent::LastPrompt(ref e) if e.last_prompt == "placeholder last prompt")
1370        );
1371    }
1372
1373    #[test]
1374    fn test_parse_harness_internal_markers_never_fail() {
1375        // bridge-session, atis-latch, mode, permission-mode, agent-name,
1376        // file-history-delta: none carry conversational content, but a new
1377        // root type must never fail the whole line.
1378        let lines = [
1379            r#"{"type":"bridge-session","sessionId":"session-123","bridgeSessionId":"cse_placeholder","lastSequenceNum":0}"#,
1380            r#"{"type":"atis-latch","atis":"","sessionId":"session-123"}"#,
1381            r#"{"type":"mode","mode":"normal","sessionId":"session-123"}"#,
1382            r#"{"type":"permission-mode","permissionMode":"auto","sessionId":"session-123"}"#,
1383            r#"{"type":"agent-name","agentName":"placeholder agent","sessionId":"session-123"}"#,
1384            r#"{"type":"file-history-delta","messageId":"message-id","snapshotMessageId":"snapshot-id","trackingPath":"src/lib.rs","backup":{"backupFileName":"placeholder","version":1},"timestamp":"2026-09-16T16:23:51.749Z"}"#,
1385        ];
1386        for line in lines {
1387            let event: SessionEvent =
1388                serde_json::from_str(line).unwrap_or_else(|error| panic!("must parse {line}: {error}"));
1389            assert!(
1390                !matches!(event, SessionEvent::Unknown),
1391                "must not fall back to Unknown: {line}"
1392            );
1393        }
1394    }
1395
1396    #[test]
1397    fn test_unrecognized_root_type_falls_back_to_unknown_without_failing() {
1398        let json = r#"{"type":"some-future-event-type","sessionId":"session-123","payload":{"nested":true}}"#;
1399        let event: SessionEvent = serde_json::from_str(json).expect("unknown root type must not fail parsing");
1400        assert!(matches!(event, SessionEvent::Unknown));
1401    }
1402}