Skip to main content

claude_code_transcripts/
types.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4// ---------------------------------------------------------------------------
5// Top-level Entry — one per JSONL line
6// ---------------------------------------------------------------------------
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(tag = "type")]
10pub enum Entry {
11    // ── Message-bearing entries ──────────────────────────────────────────
12    #[serde(rename = "user")]
13    User(UserEntry),
14
15    #[serde(rename = "assistant")]
16    Assistant(AssistantEntry),
17
18    #[serde(rename = "system")]
19    System(SystemEntry),
20
21    #[serde(rename = "attachment")]
22    Attachment(AttachmentEntry),
23
24    #[serde(rename = "progress")]
25    Progress(ProgressEntry),
26
27    // ── Metadata-only entries (no envelope) ─────────────────────────────
28    #[serde(rename = "permission-mode")]
29    PermissionMode(PermissionModeEntry),
30
31    #[serde(rename = "last-prompt")]
32    LastPrompt(LastPromptEntry),
33
34    #[serde(rename = "ai-title")]
35    AiTitle(AiTitleEntry),
36
37    #[serde(rename = "custom-title")]
38    CustomTitle(CustomTitleEntry),
39
40    #[serde(rename = "agent-name")]
41    AgentName(AgentNameEntry),
42
43    #[serde(rename = "agent-color")]
44    AgentColor(AgentColorEntry),
45
46    #[serde(rename = "agent-setting")]
47    AgentSetting(AgentSettingEntry),
48
49    #[serde(rename = "tag")]
50    Tag(TagEntry),
51
52    #[serde(rename = "summary")]
53    Summary(SummaryEntry),
54
55    #[serde(rename = "task-summary")]
56    TaskSummary(TaskSummaryEntry),
57
58    #[serde(rename = "pr-link")]
59    PrLink(PrLinkEntry),
60
61    #[serde(rename = "mode")]
62    Mode(ModeEntry),
63
64    #[serde(rename = "worktree-state")]
65    WorktreeState(WorktreeStateEntry),
66
67    #[serde(rename = "content-replacement")]
68    ContentReplacement(ContentReplacementEntry),
69
70    #[serde(rename = "file-history-snapshot")]
71    FileHistorySnapshot(FileHistorySnapshotEntry),
72
73    #[serde(rename = "attribution-snapshot")]
74    AttributionSnapshot(AttributionSnapshotEntry),
75
76    #[serde(rename = "queue-operation")]
77    QueueOperation(QueueOperationEntry),
78
79    #[serde(rename = "marble-origami-commit")]
80    ContextCollapseCommit(ContextCollapseCommitEntry),
81
82    #[serde(rename = "marble-origami-snapshot")]
83    ContextCollapseSnapshot(ContextCollapseSnapshotEntry),
84
85    #[serde(rename = "speculation-accept")]
86    SpeculationAccept(SpeculationAcceptEntry),
87
88    /// Catch-all for entry types not yet recognised by the ingest binary.
89    /// Allows forward-compatible parsing: new Claude Code entry types in
90    /// the JSONL will be silently skipped rather than aborting ingest.
91    #[serde(other)]
92    Unknown,
93}
94
95// ---------------------------------------------------------------------------
96// Shared envelope — present on all message-bearing entries
97//
98// parentUuid serialises WITHOUT skip_serializing_if so that explicit JSON
99// nulls (first message in a session) round-trip correctly as null rather
100// than being dropped.
101// ---------------------------------------------------------------------------
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct Envelope {
106    pub uuid: String,
107
108    /// null = first message in session; UUID = linked to previous entry.
109    pub parent_uuid: Option<String>,
110
111    /// Preserves logical chain across compact boundaries (parentUuid is
112    /// nulled at those points).
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub logical_parent_uuid: Option<String>,
115
116    pub is_sidechain: bool,
117    pub session_id: String,
118    pub timestamp: String,
119
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub user_type: Option<String>,
122
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub entrypoint: Option<String>,
125
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub cwd: Option<String>,
128
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub version: Option<String>,
131
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub git_branch: Option<String>,
134
135    /// Human-readable session slug, e.g. "drifting-tinkering-parnas".
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub slug: Option<String>,
138
139    /// 7-char hex id for sidechain / subagent sessions.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub agent_id: Option<String>,
142
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub team_name: Option<String>,
145
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub agent_name: Option<String>,
148
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub agent_color: Option<String>,
151
152    /// Correlates with OTel prompt.id for user-prompt messages.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub prompt_id: Option<String>,
155
156    /// True when this entry should be hidden in the UI (meta / invisible).
157    #[serde(rename = "isMeta", skip_serializing_if = "Option::is_none")]
158    pub is_meta: Option<bool>,
159
160    /// Set when this session was forked from another session.
161    #[serde(rename = "forkedFrom", skip_serializing_if = "Option::is_none")]
162    pub forked_from: Option<ForkedFrom>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct ForkedFrom {
168    pub message_uuid: String,
169    pub session_id: String,
170}
171
172// ---------------------------------------------------------------------------
173// User entry
174// ---------------------------------------------------------------------------
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct UserEntry {
178    #[serde(flatten)]
179    pub envelope: Envelope,
180    pub message: UserMessage,
181
182    /// Structured result of the tool call this message delivers (populated
183    /// by Claude Code, not the API).
184    #[serde(rename = "toolUseResult", skip_serializing_if = "Option::is_none")]
185    pub tool_use_result: Option<Value>,
186
187    /// UUID of the assistant message that requested this tool result.
188    #[serde(
189        rename = "sourceToolAssistantUUID",
190        skip_serializing_if = "Option::is_none"
191    )]
192    pub source_tool_assistant_uuid: Option<String>,
193
194    /// ID of the tool use block that triggered this user message.
195    #[serde(rename = "sourceToolUseID", skip_serializing_if = "Option::is_none")]
196    pub source_tool_use_id: Option<String>,
197
198    #[serde(rename = "permissionMode", skip_serializing_if = "Option::is_none")]
199    pub permission_mode: Option<String>,
200
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub origin: Option<Value>,
203
204    #[serde(rename = "isCompactSummary", skip_serializing_if = "Option::is_none")]
205    pub is_compact_summary: Option<bool>,
206
207    #[serde(
208        rename = "isVisibleInTranscriptOnly",
209        skip_serializing_if = "Option::is_none"
210    )]
211    pub is_visible_in_transcript_only: Option<bool>,
212
213    #[serde(rename = "imagePasteIds", skip_serializing_if = "Option::is_none")]
214    pub image_paste_ids: Option<Vec<u64>>,
215
216    #[serde(rename = "planContent", skip_serializing_if = "Option::is_none")]
217    pub plan_content: Option<String>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct UserMessage {
222    pub role: UserRole,
223    pub content: UserContent,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(rename_all = "lowercase")]
228pub enum UserRole {
229    User,
230    #[serde(other)]
231    Unknown,
232}
233
234/// User content is either a plain string or an array of typed blocks.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(untagged)]
237pub enum UserContent {
238    Text(String),
239    Blocks(Vec<UserContentBlock>),
240    /// Catch-all for content shapes not yet recognised (e.g. future object forms).
241    Other(Value),
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(tag = "type", rename_all = "snake_case")]
246pub enum UserContentBlock {
247    Text {
248        text: String,
249    },
250
251    ToolResult {
252        tool_use_id: String,
253        /// String for plain text, or array of content blocks for rich results.
254        /// Using Value here because serde cannot nest untagged enums inside
255        /// the fields of an internally-tagged enum variant.
256        content: Value,
257        #[serde(skip_serializing_if = "Option::is_none")]
258        is_error: Option<bool>,
259    },
260
261    Image {
262        source: ImageSource,
263    },
264
265    Document {
266        source: DocumentSource,
267        #[serde(skip_serializing_if = "Option::is_none")]
268        title: Option<String>,
269    },
270
271    /// Catch-all for block types not yet recognised by the ingest binary.
272    #[serde(other)]
273    Unknown,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(tag = "type", rename_all = "snake_case")]
278pub enum ImageSource {
279    Base64 {
280        media_type: String,
281        data: String,
282    },
283    Url {
284        url: String,
285    },
286    /// Catch-all for source types not yet recognised.
287    #[serde(other)]
288    Unknown,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
292#[serde(tag = "type", rename_all = "snake_case")]
293pub enum DocumentSource {
294    Base64 {
295        media_type: String,
296        data: String,
297    },
298    Text {
299        data: String,
300    },
301    Url {
302        url: String,
303    },
304    /// Catch-all for source types not yet recognised.
305    #[serde(other)]
306    Unknown,
307}
308
309// ---------------------------------------------------------------------------
310// Assistant entry
311// ---------------------------------------------------------------------------
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
314#[serde(rename_all = "camelCase")]
315pub struct AssistantEntry {
316    #[serde(flatten)]
317    pub envelope: Envelope,
318    pub message: AssistantMessage,
319
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub request_id: Option<String>,
322
323    #[serde(rename = "isApiErrorMessage", skip_serializing_if = "Option::is_none")]
324    pub is_api_error_message: Option<bool>,
325
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub error: Option<String>,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct AssistantMessage {
332    pub id: String,
333    /// Always "message".
334    #[serde(rename = "type")]
335    pub msg_type: String,
336    pub role: AssistantRole,
337    #[serde(default)]
338    pub model: Option<String>,
339
340    /// null when no container; Some(None) = present as JSON null.
341    #[serde(
342        default,
343        skip_serializing_if = "Option::is_none",
344        with = "opt_nullable"
345    )]
346    pub container: Option<Option<Value>>,
347
348    pub content: Vec<AssistantContentBlock>,
349
350    /// The API always includes this field; null means the stream is still
351    /// ongoing or the field was not set.
352    pub stop_reason: Option<String>,
353
354    /// null when stop_reason != "stop_sequence"
355    pub stop_sequence: Option<String>,
356
357    /// null in most responses; some API versions emit structured details.
358    /// outer None = field absent, Some(None) = field present as JSON null.
359    #[serde(
360        default,
361        skip_serializing_if = "Option::is_none",
362        with = "opt_nullable"
363    )]
364    pub stop_details: Option<Option<Value>>,
365
366    pub usage: AssistantUsage,
367
368    /// null in most responses; Some(None) = present as JSON null.
369    #[serde(
370        default,
371        skip_serializing_if = "Option::is_none",
372        with = "opt_nullable"
373    )]
374    pub context_management: Option<Option<Value>>,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
378#[serde(rename_all = "lowercase")]
379pub enum AssistantRole {
380    Assistant,
381    #[serde(other)]
382    Unknown,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
386#[serde(tag = "type", rename_all = "snake_case")]
387pub enum AssistantContentBlock {
388    Text {
389        text: String,
390    },
391
392    /// Extended thinking block. `thinking` is always an empty string in
393    /// persisted transcripts (Claude Code redacts it for storage); the
394    /// cryptographic `signature` is retained.
395    Thinking {
396        thinking: String,
397        signature: String,
398    },
399
400    RedactedThinking {
401        data: String,
402    },
403
404    ToolUse {
405        id: String,
406        name: String,
407        input: Value,
408        /// Present in some versions to identify call origin.
409        #[serde(skip_serializing_if = "Option::is_none")]
410        caller: Option<ToolUseCaller>,
411    },
412
413    /// Catch-all for content block types not yet recognised by the ingest binary.
414    #[serde(other)]
415    Unknown,
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct ToolUseCaller {
420    #[serde(rename = "type")]
421    pub caller_type: String,
422}
423
424// The Anthropic API returns usage fields in snake_case — no rename_all here.
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct AssistantUsage {
427    pub input_tokens: u64,
428    pub output_tokens: u64,
429
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub cache_creation_input_tokens: Option<u64>,
432
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub cache_read_input_tokens: Option<u64>,
435
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub server_tool_use: Option<ServerToolUse>,
438
439    /// null = explicitly set to null by API; absent = field not present.
440    #[serde(
441        default,
442        skip_serializing_if = "Option::is_none",
443        with = "opt_nullable"
444    )]
445    pub service_tier: Option<Option<Value>>,
446
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub cache_creation: Option<CacheCreation>,
449
450    /// null = explicitly set to null by API; absent = field not present.
451    #[serde(
452        default,
453        skip_serializing_if = "Option::is_none",
454        with = "opt_nullable"
455    )]
456    pub inference_geo: Option<Option<Value>>,
457
458    /// null = explicitly set to null by API; absent = field not present.
459    #[serde(
460        default,
461        skip_serializing_if = "Option::is_none",
462        with = "opt_nullable"
463    )]
464    pub iterations: Option<Option<Value>>,
465
466    /// null = explicitly set to null by API; absent = field not present.
467    #[serde(
468        default,
469        skip_serializing_if = "Option::is_none",
470        with = "opt_nullable"
471    )]
472    pub speed: Option<Option<Value>>,
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct ServerToolUse {
477    #[serde(default)]
478    pub web_search_requests: u64,
479    #[serde(default)]
480    pub web_fetch_requests: u64,
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct CacheCreation {
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub ephemeral_1h_input_tokens: Option<u64>,
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub ephemeral_5m_input_tokens: Option<u64>,
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct UsageIteration {
493    pub input_tokens: u64,
494    pub output_tokens: u64,
495
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub cache_read_input_tokens: Option<u64>,
498
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub cache_creation_input_tokens: Option<u64>,
501
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub cache_creation: Option<CacheCreation>,
504
505    /// Iteration type; typically "message".
506    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
507    pub iter_type: Option<String>,
508}
509
510// ---------------------------------------------------------------------------
511// System entry
512//
513// All subtype-specific fields are optional so a single flat struct covers
514// every subtype while preserving exact field order semantics.  Type safety
515// on the discriminant is still enforced via SystemSubtype.
516// ---------------------------------------------------------------------------
517
518#[derive(Debug, Clone, Serialize, Deserialize)]
519#[serde(rename_all = "camelCase")]
520pub struct SystemEntry {
521    #[serde(flatten)]
522    pub envelope: Envelope,
523
524    pub subtype: SystemSubtype,
525
526    /// Human-readable message text (most subtypes).
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub content: Option<String>,
529
530    /// Severity level: "info" | "warning" | "error" | "suggestion".
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub level: Option<String>,
533
534    /// True when the entry should be hidden from the main conversation view.
535    #[serde(rename = "isMeta", skip_serializing_if = "Option::is_none")]
536    pub is_meta: Option<bool>,
537
538    // ── api_error ────────────────────────────────────────────────────────
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub cause: Option<Value>,
541
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub error: Option<Value>,
544
545    #[serde(rename = "retryInMs", skip_serializing_if = "Option::is_none")]
546    pub retry_in_ms: Option<f64>,
547
548    #[serde(rename = "retryAttempt", skip_serializing_if = "Option::is_none")]
549    pub retry_attempt: Option<u32>,
550
551    #[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")]
552    pub max_retries: Option<u32>,
553
554    // ── stop_hook_summary ────────────────────────────────────────────────
555    #[serde(rename = "hookCount", skip_serializing_if = "Option::is_none")]
556    pub hook_count: Option<u32>,
557
558    #[serde(rename = "hookInfos", skip_serializing_if = "Option::is_none")]
559    pub hook_infos: Option<Vec<HookInfo>>,
560
561    #[serde(rename = "hookErrors", skip_serializing_if = "Option::is_none")]
562    pub hook_errors: Option<Vec<Value>>,
563
564    #[serde(
565        rename = "preventedContinuation",
566        skip_serializing_if = "Option::is_none"
567    )]
568    pub prevented_continuation: Option<bool>,
569
570    #[serde(rename = "stopReason", skip_serializing_if = "Option::is_none")]
571    pub stop_reason: Option<String>,
572
573    #[serde(rename = "hasOutput", skip_serializing_if = "Option::is_none")]
574    pub has_output: Option<bool>,
575
576    #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
577    pub tool_use_id: Option<String>,
578
579    // ── turn_duration ────────────────────────────────────────────────────
580    #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
581    pub duration_ms: Option<f64>,
582
583    #[serde(rename = "messageCount", skip_serializing_if = "Option::is_none")]
584    pub message_count: Option<u32>,
585
586    // ── bridge_status ────────────────────────────────────────────────────
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub url: Option<String>,
589
590    #[serde(rename = "upgradeNudge", skip_serializing_if = "Option::is_none")]
591    pub upgrade_nudge: Option<String>,
592
593    // ── compact_boundary ────────────────────────────────────────────────
594    #[serde(rename = "compactMetadata", skip_serializing_if = "Option::is_none")]
595    pub compact_metadata: Option<CompactMetadata>,
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize)]
599#[serde(rename_all = "snake_case")]
600pub enum SystemSubtype {
601    ApiError,
602    AwaySummary,
603    BridgeStatus,
604    CompactBoundary,
605    Informational,
606    LocalCommand,
607    ScheduledTaskFire,
608    StopHookSummary,
609    TurnDuration,
610    MicrocompactBoundary,
611    PermissionRetry,
612    AgentsKilled,
613    #[serde(other)]
614    Unknown,
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize)]
618#[serde(rename_all = "camelCase")]
619pub struct HookInfo {
620    pub command: String,
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub duration_ms: Option<u64>,
623}
624
625#[derive(Debug, Clone, Serialize, Deserialize)]
626#[serde(rename_all = "camelCase")]
627pub struct PreservedSegment {
628    pub head_uuid: String,
629    pub anchor_uuid: String,
630    pub tail_uuid: String,
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize)]
634#[serde(rename_all = "camelCase")]
635pub struct CompactMetadata {
636    pub trigger: String,
637    #[serde(skip_serializing_if = "Option::is_none")]
638    pub pre_tokens: Option<u64>,
639    #[serde(skip_serializing_if = "Option::is_none")]
640    pub post_tokens: Option<u64>,
641    #[serde(skip_serializing_if = "Option::is_none")]
642    pub duration_ms: Option<u64>,
643    #[serde(skip_serializing_if = "Option::is_none")]
644    pub preserved_segment: Option<PreservedSegment>,
645    #[serde(
646        rename = "preCompactDiscoveredTools",
647        skip_serializing_if = "Option::is_none"
648    )]
649    pub pre_compact_discovered_tools: Option<Vec<String>>,
650}
651
652// ---------------------------------------------------------------------------
653// Attachment entry
654// ---------------------------------------------------------------------------
655
656#[derive(Debug, Clone, Serialize, Deserialize)]
657pub struct AttachmentEntry {
658    #[serde(flatten)]
659    pub envelope: Envelope,
660    pub attachment: AttachmentData,
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize)]
664#[serde(tag = "type", rename_all = "snake_case")]
665pub enum AttachmentData {
666    // ── Hook results ─────────────────────────────────────────────────────
667    HookSuccess(HookResultAttachment),
668    HookNonBlockingError(HookResultAttachment),
669    HookBlockingError(HookResultAttachment),
670    HookCancelled(HookResultAttachment),
671
672    HookAdditionalContext {
673        content: Vec<String>,
674        #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
675        hook_name: Option<String>,
676        #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
677        tool_use_id: Option<String>,
678        #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
679        hook_event: Option<String>,
680    },
681
682    HookPermissionDecision {
683        decision: String,
684        #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
685        hook_name: Option<String>,
686        #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
687        tool_use_id: Option<String>,
688        #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
689        hook_event: Option<String>,
690    },
691
692    /// Emitted when a hook ended the assistant's turn (e.g. harness
693    /// `await_user_message`). Sibling of HookAdditionalContext but with a
694    /// single `message` field.
695    HookStoppedContinuation {
696        message: String,
697        #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
698        hook_name: Option<String>,
699        #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
700        tool_use_id: Option<String>,
701        #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
702        hook_event: Option<String>,
703    },
704
705    /// Single-string sibling of HookAdditionalContext.
706    HookSystemMessage {
707        content: String,
708        #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
709        hook_name: Option<String>,
710        #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
711        tool_use_id: Option<String>,
712        #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
713        hook_event: Option<String>,
714    },
715
716    // ── File / filesystem ────────────────────────────────────────────────
717    File {
718        filename: String,
719        content: FileAttachmentContent,
720        #[serde(rename = "displayPath", skip_serializing_if = "Option::is_none")]
721        display_path: Option<String>,
722    },
723
724    EditedTextFile {
725        filename: String,
726        /// Line-numbered file content snippet.
727        snippet: String,
728    },
729
730    Directory {
731        path: String,
732        content: String,
733        #[serde(rename = "displayPath")]
734        display_path: String,
735    },
736
737    CompactFileReference {
738        filename: String,
739        #[serde(rename = "displayPath")]
740        display_path: String,
741    },
742
743    // ── Permissions ──────────────────────────────────────────────────────
744    CommandPermissions {
745        #[serde(rename = "allowedTools")]
746        allowed_tools: Vec<String>,
747    },
748
749    // ── Plan mode ────────────────────────────────────────────────────────
750    PlanMode {
751        #[serde(rename = "reminderType")]
752        reminder_type: String,
753        #[serde(rename = "isSubAgent")]
754        is_sub_agent: bool,
755        #[serde(rename = "planFilePath", skip_serializing_if = "Option::is_none")]
756        plan_file_path: Option<String>,
757        #[serde(rename = "planExists")]
758        plan_exists: bool,
759    },
760
761    PlanModeExit {
762        #[serde(rename = "planFilePath", skip_serializing_if = "Option::is_none")]
763        plan_file_path: Option<String>,
764        #[serde(rename = "planExists")]
765        plan_exists: bool,
766    },
767
768    // ── Skills ───────────────────────────────────────────────────────────
769    SkillListing {
770        content: String,
771        /// True on the very first skill listing injection for a session.
772        #[serde(rename = "isInitial", skip_serializing_if = "Option::is_none")]
773        is_initial: Option<bool>,
774        /// Total number of skills listed.
775        #[serde(rename = "skillCount", skip_serializing_if = "Option::is_none")]
776        skill_count: Option<u32>,
777    },
778
779    DynamicSkill {
780        #[serde(rename = "skillDir")]
781        skill_dir: String,
782        #[serde(rename = "skillNames")]
783        skill_names: Vec<String>,
784        #[serde(rename = "displayPath")]
785        display_path: String,
786    },
787
788    InvokedSkills {
789        skills: Vec<InvokedSkill>,
790    },
791
792    // ── Tasks ────────────────────────────────────────────────────────────
793    TaskReminder {
794        content: Vec<Value>,
795        #[serde(rename = "itemCount")]
796        item_count: u32,
797    },
798
799    /// Older alias for TaskReminder; identical shape, only the discriminator
800    /// differs. Observed payloads have always been empty.
801    TodoReminder {
802        content: Vec<Value>,
803        #[serde(rename = "itemCount")]
804        item_count: u32,
805    },
806
807    // ── Diagnostics / IDE ────────────────────────────────────────────────
808    Diagnostics {
809        files: Vec<DiagnosticsFile>,
810        #[serde(rename = "isNew")]
811        is_new: bool,
812    },
813
814    // ── Dates / context ──────────────────────────────────────────────────
815    DateChange {
816        #[serde(rename = "newDate")]
817        new_date: String,
818    },
819
820    // ── Tool / MCP updates ───────────────────────────────────────────────
821    DeferredToolsDelta {
822        #[serde(rename = "addedNames")]
823        added_names: Vec<String>,
824        /// Legacy/alias field that mirrors addedNames; both are present in
825        /// some versions.
826        #[serde(rename = "addedLines", skip_serializing_if = "Option::is_none")]
827        added_lines: Option<Vec<String>>,
828        #[serde(rename = "removedNames", skip_serializing_if = "Option::is_none")]
829        removed_names: Option<Vec<String>>,
830    },
831
832    McpInstructionsDelta {
833        #[serde(rename = "addedNames")]
834        added_names: Vec<String>,
835        #[serde(rename = "addedBlocks")]
836        added_blocks: Vec<String>,
837        #[serde(rename = "removedNames", skip_serializing_if = "Option::is_none")]
838        removed_names: Option<Vec<String>>,
839    },
840
841    // ── Thinking effort ──────────────────────────────────────────────────
842    UltrathinkEffort {
843        level: String,
844    },
845
846    // ── Queued commands ──────────────────────────────────────────────────
847    QueuedCommand {
848        /// String for plain prompts, or array of content blocks (text/image)
849        /// for prompts that include attached images. Using Value because serde
850        /// cannot nest untagged enums inside an internally-tagged variant.
851        prompt: Value,
852        #[serde(rename = "commandMode", skip_serializing_if = "Option::is_none")]
853        command_mode: Option<String>,
854    },
855
856    // ── Nested memory (CLAUDE.md imports) ────────────────────────────────
857    NestedMemory {
858        path: String,
859        content: NestedMemoryContent,
860        #[serde(rename = "displayPath")]
861        display_path: String,
862    },
863
864    /// Catch-all for attachment types not yet recognised by the ingest binary.
865    #[serde(other)]
866    Unknown,
867}
868
869#[derive(Debug, Clone, Serialize, Deserialize)]
870pub struct NestedMemoryContent {
871    pub path: String,
872    /// CLAUDE.md scope ("Project", "User", "Local", etc).
873    #[serde(rename = "type")]
874    pub memory_type: String,
875    pub content: String,
876    #[serde(
877        rename = "contentDiffersFromDisk",
878        skip_serializing_if = "Option::is_none"
879    )]
880    pub content_differs_from_disk: Option<bool>,
881}
882
883#[derive(Debug, Clone, Serialize, Deserialize)]
884#[serde(rename_all = "camelCase")]
885pub struct HookResultAttachment {
886    #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
887    pub hook_name: Option<String>,
888    #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
889    pub tool_use_id: Option<String>,
890    #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
891    pub hook_event: Option<String>,
892    #[serde(skip_serializing_if = "Option::is_none")]
893    pub content: Option<String>,
894    #[serde(skip_serializing_if = "Option::is_none")]
895    pub stdout: Option<String>,
896    #[serde(skip_serializing_if = "Option::is_none")]
897    pub stderr: Option<String>,
898    #[serde(skip_serializing_if = "Option::is_none")]
899    pub exit_code: Option<i32>,
900    #[serde(skip_serializing_if = "Option::is_none")]
901    pub command: Option<String>,
902    #[serde(skip_serializing_if = "Option::is_none")]
903    pub duration_ms: Option<u64>,
904    #[serde(rename = "blockingError", skip_serializing_if = "Option::is_none")]
905    pub blocking_error: Option<Value>,
906}
907
908/// Wrapper for a file content attachment.
909#[derive(Debug, Clone, Serialize, Deserialize)]
910#[serde(rename_all = "camelCase")]
911pub struct FileAttachmentContent {
912    #[serde(rename = "type")]
913    pub content_type: String,
914    pub file: FileData,
915}
916
917#[derive(Debug, Clone, Serialize, Deserialize)]
918#[serde(rename_all = "camelCase")]
919pub struct FileData {
920    pub file_path: String,
921    #[serde(skip_serializing_if = "Option::is_none")]
922    pub content: Option<String>,
923    #[serde(rename = "numLines", skip_serializing_if = "Option::is_none")]
924    pub num_lines: Option<u64>,
925    #[serde(rename = "startLine", skip_serializing_if = "Option::is_none")]
926    pub start_line: Option<u64>,
927    #[serde(rename = "totalLines", skip_serializing_if = "Option::is_none")]
928    pub total_lines: Option<u64>,
929}
930
931#[derive(Debug, Clone, Serialize, Deserialize)]
932pub struct InvokedSkill {
933    pub name: String,
934    pub path: String,
935    pub content: String,
936}
937
938#[derive(Debug, Clone, Serialize, Deserialize)]
939pub struct DiagnosticsFile {
940    pub uri: String,
941    pub diagnostics: Vec<Diagnostic>,
942}
943
944#[derive(Debug, Clone, Serialize, Deserialize)]
945pub struct Diagnostic {
946    pub message: String,
947    pub severity: String,
948    pub range: DiagnosticRange,
949    #[serde(skip_serializing_if = "Option::is_none")]
950    pub source: Option<String>,
951    #[serde(skip_serializing_if = "Option::is_none")]
952    pub code: Option<Value>,
953}
954
955#[derive(Debug, Clone, Serialize, Deserialize)]
956pub struct DiagnosticRange {
957    pub start: DiagnosticPosition,
958    pub end: DiagnosticPosition,
959}
960
961#[derive(Debug, Clone, Serialize, Deserialize)]
962pub struct DiagnosticPosition {
963    pub line: u32,
964    pub character: u32,
965}
966
967// ---------------------------------------------------------------------------
968// Progress entry
969// ---------------------------------------------------------------------------
970
971#[derive(Debug, Clone, Serialize, Deserialize)]
972#[serde(rename_all = "camelCase")]
973pub struct ProgressEntry {
974    #[serde(flatten)]
975    pub envelope: Envelope,
976
977    pub data: ProgressData,
978
979    #[serde(rename = "parentToolUseID", skip_serializing_if = "Option::is_none")]
980    pub parent_tool_use_id: Option<String>,
981
982    #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
983    pub tool_use_id: Option<String>,
984}
985
986#[derive(Debug, Clone, Serialize, Deserialize)]
987#[serde(rename_all = "camelCase")]
988pub struct ProgressData {
989    #[serde(rename = "type")]
990    pub data_type: String,
991    #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
992    pub hook_event: Option<String>,
993    #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
994    pub hook_name: Option<String>,
995    #[serde(skip_serializing_if = "Option::is_none")]
996    pub command: Option<String>,
997    // agent_progress fields
998    #[serde(rename = "agentId", skip_serializing_if = "Option::is_none")]
999    pub agent_id: Option<String>,
1000    #[serde(skip_serializing_if = "Option::is_none")]
1001    pub prompt: Option<String>,
1002    #[serde(skip_serializing_if = "Option::is_none")]
1003    pub message: Option<Value>,
1004    // query_update / search progress fields
1005    #[serde(skip_serializing_if = "Option::is_none")]
1006    pub query: Option<String>,
1007    #[serde(rename = "resultCount", skip_serializing_if = "Option::is_none")]
1008    pub result_count: Option<u32>,
1009    // bash/command progress fields
1010    #[serde(rename = "elapsedTimeSeconds", skip_serializing_if = "Option::is_none")]
1011    pub elapsed_time_seconds: Option<f64>,
1012    #[serde(rename = "fullOutput", skip_serializing_if = "Option::is_none")]
1013    pub full_output: Option<String>,
1014    #[serde(rename = "output", skip_serializing_if = "Option::is_none")]
1015    pub output: Option<String>,
1016    #[serde(rename = "timeoutMs", skip_serializing_if = "Option::is_none")]
1017    pub timeout_ms: Option<u64>,
1018    #[serde(rename = "totalLines", skip_serializing_if = "Option::is_none")]
1019    pub total_lines: Option<u64>,
1020    #[serde(rename = "totalBytes", skip_serializing_if = "Option::is_none")]
1021    pub total_bytes: Option<u64>,
1022    #[serde(rename = "taskId", skip_serializing_if = "Option::is_none")]
1023    pub task_id: Option<String>,
1024    // mcp tool progress fields
1025    #[serde(rename = "serverName", skip_serializing_if = "Option::is_none")]
1026    pub server_name: Option<String>,
1027    #[serde(rename = "status", skip_serializing_if = "Option::is_none")]
1028    pub status: Option<String>,
1029    #[serde(rename = "toolName", skip_serializing_if = "Option::is_none")]
1030    pub tool_name: Option<String>,
1031    #[serde(rename = "elapsedTimeMs", skip_serializing_if = "Option::is_none")]
1032    pub elapsed_time_ms: Option<f64>,
1033    // agent task progress fields
1034    #[serde(rename = "taskDescription", skip_serializing_if = "Option::is_none")]
1035    pub task_description: Option<String>,
1036    #[serde(rename = "taskType", skip_serializing_if = "Option::is_none")]
1037    pub task_type: Option<String>,
1038}
1039
1040// ---------------------------------------------------------------------------
1041// Metadata-only entries
1042// ---------------------------------------------------------------------------
1043
1044#[derive(Debug, Clone, Serialize, Deserialize)]
1045#[serde(rename_all = "camelCase")]
1046pub struct PermissionModeEntry {
1047    pub permission_mode: String,
1048    pub session_id: String,
1049}
1050
1051#[derive(Debug, Clone, Serialize, Deserialize)]
1052#[serde(rename_all = "camelCase")]
1053pub struct LastPromptEntry {
1054    #[serde(skip_serializing_if = "Option::is_none")]
1055    pub last_prompt: Option<String>,
1056    #[serde(skip_serializing_if = "Option::is_none")]
1057    pub leaf_uuid: Option<String>,
1058    pub session_id: String,
1059}
1060
1061#[derive(Debug, Clone, Serialize, Deserialize)]
1062#[serde(rename_all = "camelCase")]
1063pub struct AiTitleEntry {
1064    pub ai_title: String,
1065    pub session_id: String,
1066}
1067
1068#[derive(Debug, Clone, Serialize, Deserialize)]
1069#[serde(rename_all = "camelCase")]
1070pub struct CustomTitleEntry {
1071    pub custom_title: String,
1072    pub session_id: String,
1073}
1074
1075#[derive(Debug, Clone, Serialize, Deserialize)]
1076#[serde(rename_all = "camelCase")]
1077pub struct AgentNameEntry {
1078    pub agent_name: String,
1079    pub session_id: String,
1080}
1081
1082#[derive(Debug, Clone, Serialize, Deserialize)]
1083#[serde(rename_all = "camelCase")]
1084pub struct AgentColorEntry {
1085    pub agent_color: String,
1086    pub session_id: String,
1087}
1088
1089#[derive(Debug, Clone, Serialize, Deserialize)]
1090#[serde(rename_all = "camelCase")]
1091pub struct AgentSettingEntry {
1092    pub agent_setting: String,
1093    pub session_id: String,
1094}
1095
1096#[derive(Debug, Clone, Serialize, Deserialize)]
1097#[serde(rename_all = "camelCase")]
1098pub struct TagEntry {
1099    pub tag: String,
1100    pub session_id: String,
1101}
1102
1103#[derive(Debug, Clone, Serialize, Deserialize)]
1104#[serde(rename_all = "camelCase")]
1105pub struct SummaryEntry {
1106    pub leaf_uuid: String,
1107    pub summary: String,
1108    pub session_id: String,
1109}
1110
1111#[derive(Debug, Clone, Serialize, Deserialize)]
1112#[serde(rename_all = "camelCase")]
1113pub struct TaskSummaryEntry {
1114    pub summary: String,
1115    pub session_id: String,
1116    pub timestamp: String,
1117}
1118
1119#[derive(Debug, Clone, Serialize, Deserialize)]
1120#[serde(rename_all = "camelCase")]
1121pub struct PrLinkEntry {
1122    pub session_id: String,
1123    pub pr_number: u32,
1124    pub pr_url: String,
1125    pub pr_repository: String,
1126    pub timestamp: String,
1127}
1128
1129#[derive(Debug, Clone, Serialize, Deserialize)]
1130#[serde(rename_all = "camelCase")]
1131pub struct ModeEntry {
1132    pub mode: SessionMode,
1133    pub session_id: String,
1134}
1135
1136#[derive(Debug, Clone, Serialize, Deserialize)]
1137#[serde(rename_all = "lowercase")]
1138pub enum SessionMode {
1139    Coordinator,
1140    Normal,
1141    #[serde(other)]
1142    Unknown,
1143}
1144
1145// worktreeSession is nullable (null = exited, object = active)
1146#[derive(Debug, Clone, Serialize, Deserialize)]
1147#[serde(rename_all = "camelCase")]
1148pub struct WorktreeStateEntry {
1149    pub session_id: String,
1150    /// null when the worktree session was exited; Some when active.
1151    pub worktree_session: Option<PersistedWorktreeSession>,
1152}
1153
1154#[derive(Debug, Clone, Serialize, Deserialize)]
1155#[serde(rename_all = "camelCase")]
1156pub struct PersistedWorktreeSession {
1157    pub original_cwd: String,
1158    pub worktree_path: String,
1159    pub worktree_name: String,
1160    pub session_id: String,
1161
1162    #[serde(skip_serializing_if = "Option::is_none")]
1163    pub worktree_branch: Option<String>,
1164
1165    #[serde(skip_serializing_if = "Option::is_none")]
1166    pub original_branch: Option<String>,
1167
1168    #[serde(skip_serializing_if = "Option::is_none")]
1169    pub original_head_commit: Option<String>,
1170
1171    #[serde(rename = "tmuxSessionName", skip_serializing_if = "Option::is_none")]
1172    pub tmux_session_name: Option<String>,
1173
1174    #[serde(skip_serializing_if = "Option::is_none")]
1175    pub hook_based: Option<bool>,
1176}
1177
1178#[derive(Debug, Clone, Serialize, Deserialize)]
1179#[serde(rename_all = "camelCase")]
1180pub struct ContentReplacementEntry {
1181    pub session_id: String,
1182    pub replacements: Vec<Value>,
1183    #[serde(skip_serializing_if = "Option::is_none")]
1184    pub agent_id: Option<String>,
1185}
1186
1187#[derive(Debug, Clone, Serialize, Deserialize)]
1188#[serde(rename_all = "camelCase")]
1189pub struct FileHistorySnapshotEntry {
1190    pub message_id: String,
1191    pub snapshot: FileHistorySnapshot,
1192    pub is_snapshot_update: bool,
1193}
1194
1195#[derive(Debug, Clone, Serialize, Deserialize)]
1196#[serde(rename_all = "camelCase")]
1197pub struct FileHistorySnapshot {
1198    pub message_id: String,
1199    pub tracked_file_backups: Value,
1200    pub timestamp: String,
1201}
1202
1203#[derive(Debug, Clone, Serialize, Deserialize)]
1204#[serde(rename_all = "camelCase")]
1205pub struct AttributionSnapshotEntry {
1206    pub message_id: String,
1207    pub surface: String,
1208    pub file_states: Value,
1209
1210    #[serde(skip_serializing_if = "Option::is_none")]
1211    pub prompt_count: Option<u32>,
1212
1213    #[serde(skip_serializing_if = "Option::is_none")]
1214    pub prompt_count_at_last_commit: Option<u32>,
1215
1216    #[serde(skip_serializing_if = "Option::is_none")]
1217    pub permission_prompt_count: Option<u32>,
1218
1219    #[serde(skip_serializing_if = "Option::is_none")]
1220    pub permission_prompt_count_at_last_commit: Option<u32>,
1221
1222    #[serde(skip_serializing_if = "Option::is_none")]
1223    pub escape_count: Option<u32>,
1224
1225    #[serde(skip_serializing_if = "Option::is_none")]
1226    pub escape_count_at_last_commit: Option<u32>,
1227}
1228
1229#[derive(Debug, Clone, Serialize, Deserialize)]
1230#[serde(rename_all = "camelCase")]
1231pub struct QueueOperationEntry {
1232    pub operation: String,
1233    pub timestamp: String,
1234    pub session_id: String,
1235    #[serde(skip_serializing_if = "Option::is_none")]
1236    pub content: Option<String>,
1237}
1238
1239// ---------------------------------------------------------------------------
1240// Context-collapse entries (internal, obfuscated type names)
1241// ---------------------------------------------------------------------------
1242
1243#[derive(Debug, Clone, Serialize, Deserialize)]
1244#[serde(rename_all = "camelCase")]
1245pub struct ContextCollapseCommitEntry {
1246    pub session_id: String,
1247    pub collapse_id: String,
1248    pub summary_uuid: String,
1249    pub summary_content: String,
1250    pub summary: String,
1251    pub first_archived_uuid: String,
1252    pub last_archived_uuid: String,
1253}
1254
1255#[derive(Debug, Clone, Serialize, Deserialize)]
1256#[serde(rename_all = "camelCase")]
1257pub struct ContextCollapseSnapshotEntry {
1258    pub session_id: String,
1259    pub staged: Vec<StagedSpan>,
1260    pub armed: bool,
1261    pub last_spawn_tokens: u64,
1262}
1263
1264#[derive(Debug, Clone, Serialize, Deserialize)]
1265#[serde(rename_all = "camelCase")]
1266pub struct StagedSpan {
1267    pub start_uuid: String,
1268    pub end_uuid: String,
1269    pub summary: String,
1270    pub risk: f64,
1271    pub staged_at: u64,
1272}
1273
1274#[derive(Debug, Clone, Serialize, Deserialize)]
1275#[serde(rename_all = "camelCase")]
1276pub struct SpeculationAcceptEntry {
1277    pub timestamp: String,
1278    pub time_saved_ms: u64,
1279}
1280
1281// ---------------------------------------------------------------------------
1282// Serde helper: distinguish JSON null from absent field
1283//
1284// Used with:
1285//   #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_nullable")]
1286//   pub field: Option<Option<T>>,
1287//
1288// Semantics:
1289//   None           → field absent  (skip_serializing_if prevents serialization)
1290//   Some(None)     → field present as JSON null
1291//   Some(Some(v))  → field present with value v
1292// ---------------------------------------------------------------------------
1293mod opt_nullable {
1294    use serde::{Deserialize, Deserializer, Serialize, Serializer};
1295    use serde_json::Value;
1296
1297    pub fn serialize<S>(val: &Option<Option<Value>>, ser: S) -> Result<S::Ok, S::Error>
1298    where
1299        S: Serializer,
1300    {
1301        match val {
1302            None => unreachable!("skip_serializing_if = \"Option::is_none\" should prevent this"),
1303            Some(inner) => inner.serialize(ser),
1304        }
1305    }
1306
1307    pub fn deserialize<'de, D>(de: D) -> Result<Option<Option<Value>>, D::Error>
1308    where
1309        D: Deserializer<'de>,
1310    {
1311        Ok(Some(Option::<Value>::deserialize(de)?))
1312    }
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317    use super::*;
1318
1319    #[test]
1320    fn attachment_data_unknown_variant() {
1321        let json = r#"{"type":"future_attachment_shape","some_field":42}"#;
1322        let v: AttachmentData = serde_json::from_str(json).unwrap();
1323        assert!(matches!(v, AttachmentData::Unknown));
1324    }
1325
1326    #[test]
1327    fn attachment_data_nested_memory_variant() {
1328        let json = r#"{"type":"nested_memory","path":"/p/CLAUDE.md","content":{"path":"/p/CLAUDE.md","type":"Project","content":"hi","contentDiffersFromDisk":false},"displayPath":"CLAUDE.md"}"#;
1329        let v: AttachmentData = serde_json::from_str(json).unwrap();
1330        match v {
1331            AttachmentData::NestedMemory {
1332                path,
1333                content,
1334                display_path,
1335            } => {
1336                assert_eq!(path, "/p/CLAUDE.md");
1337                assert_eq!(content.memory_type, "Project");
1338                assert_eq!(content.content, "hi");
1339                assert_eq!(content.content_differs_from_disk, Some(false));
1340                assert_eq!(display_path, "CLAUDE.md");
1341            }
1342            other => panic!("expected NestedMemory, got {other:?}"),
1343        }
1344    }
1345
1346    #[test]
1347    fn assistant_content_block_unknown_variant() {
1348        let json = r#"{"type":"future_modality","data":"foo"}"#;
1349        let v: AssistantContentBlock = serde_json::from_str(json).unwrap();
1350        assert!(matches!(v, AssistantContentBlock::Unknown));
1351    }
1352
1353    #[test]
1354    fn user_content_block_unknown_variant() {
1355        let json = r#"{"type":"video","url":"https://example.com"}"#;
1356        let v: UserContentBlock = serde_json::from_str(json).unwrap();
1357        assert!(matches!(v, UserContentBlock::Unknown));
1358    }
1359
1360    #[test]
1361    fn image_source_unknown_variant() {
1362        let json = r#"{"type":"s3_bucket","key":"foo"}"#;
1363        let v: ImageSource = serde_json::from_str(json).unwrap();
1364        assert!(matches!(v, ImageSource::Unknown));
1365    }
1366
1367    #[test]
1368    fn document_source_unknown_variant() {
1369        let json = r#"{"type":"pdf","data":"base64data"}"#;
1370        let v: DocumentSource = serde_json::from_str(json).unwrap();
1371        assert!(matches!(v, DocumentSource::Unknown));
1372    }
1373
1374    // Verify known variants still parse correctly after adding Unknown.
1375    #[test]
1376    fn attachment_data_known_variant_unaffected() {
1377        let json = r#"{"type":"date_change","newDate":"2024-01-01"}"#;
1378        let v: AttachmentData = serde_json::from_str(json).unwrap();
1379        assert!(matches!(v, AttachmentData::DateChange { .. }));
1380    }
1381
1382    #[test]
1383    fn assistant_content_block_known_variant_unaffected() {
1384        let json = r#"{"type":"text","text":"hello"}"#;
1385        let v: AssistantContentBlock = serde_json::from_str(json).unwrap();
1386        assert!(matches!(v, AssistantContentBlock::Text { .. }));
1387    }
1388
1389    // ── New robustness tests (RED phase) ─────────────────────────────────
1390
1391    /// UserContent is untagged; a JSON object (neither string nor array)
1392    /// must not fail — should fall through to an Other/Value catch-all.
1393    #[test]
1394    fn user_content_unknown_shape_does_not_fail() {
1395        let json = r#"{"type":"future_format","data":42}"#;
1396        let v: UserContent = serde_json::from_str(json).unwrap();
1397        assert!(matches!(v, UserContent::Other(_)));
1398    }
1399
1400    /// ServerToolUse with a missing field (e.g. if Anthropic removes one)
1401    /// must deserialize successfully with a default of 0.
1402    #[test]
1403    fn server_tool_use_missing_field_uses_default() {
1404        let json = r#"{"web_search_requests":3}"#;
1405        let v: ServerToolUse = serde_json::from_str(json).unwrap();
1406        assert_eq!(v.web_search_requests, 3);
1407        assert_eq!(v.web_fetch_requests, 0);
1408    }
1409
1410    /// A new / unrecognised UserRole value must parse as Unknown.
1411    #[test]
1412    fn user_role_unknown_value_does_not_fail() {
1413        let json = r#""operator""#;
1414        let v: UserRole = serde_json::from_str(json).unwrap();
1415        assert!(matches!(v, UserRole::Unknown));
1416    }
1417
1418    /// A new / unrecognised AssistantRole value must parse as Unknown.
1419    #[test]
1420    fn assistant_role_unknown_value_does_not_fail() {
1421        let json = r#""system_agent""#;
1422        let v: AssistantRole = serde_json::from_str(json).unwrap();
1423        assert!(matches!(v, AssistantRole::Unknown));
1424    }
1425
1426    /// A new / unrecognised SessionMode value must parse as Unknown.
1427    #[test]
1428    fn session_mode_unknown_value_does_not_fail() {
1429        let json = r#""background""#;
1430        let v: SessionMode = serde_json::from_str(json).unwrap();
1431        assert!(matches!(v, SessionMode::Unknown));
1432    }
1433
1434    /// AssistantMessage without a "model" field (e.g. API error responses)
1435    /// must not fail deserialization.
1436    #[test]
1437    fn assistant_message_missing_model_uses_default() {
1438        let json = r#"{
1439            "id": "msg_err1",
1440            "type": "message",
1441            "role": "assistant",
1442            "content": [],
1443            "stop_reason": "error",
1444            "stop_sequence": null,
1445            "usage": {"input_tokens": 0, "output_tokens": 0}
1446        }"#;
1447        let v: AssistantMessage = serde_json::from_str(json).unwrap();
1448        assert!(v.model.is_none());
1449    }
1450}
1451
1452#[cfg(test)]
1453mod last_prompt_tests {
1454    use super::*;
1455
1456    fn parse(line: &str) -> Entry {
1457        serde_json::from_str::<Entry>(line).expect("parse")
1458    }
1459
1460    #[test]
1461    fn last_prompt_old_format_inline_text() {
1462        let e = parse(r#"{"type":"last-prompt","lastPrompt":"hello world","sessionId":"S"}"#);
1463        match e {
1464            Entry::LastPrompt(x) => {
1465                assert_eq!(x.last_prompt.as_deref(), Some("hello world"));
1466                assert_eq!(x.leaf_uuid, None);
1467                assert_eq!(x.session_id, "S");
1468            }
1469            other => panic!("wrong variant: {other:?}"),
1470        }
1471    }
1472
1473    #[test]
1474    fn last_prompt_new_format_leaf_uuid_only() {
1475        let e = parse(r#"{"type":"last-prompt","leafUuid":"u1","sessionId":"S"}"#);
1476        match e {
1477            Entry::LastPrompt(x) => {
1478                assert_eq!(x.last_prompt, None);
1479                assert_eq!(x.leaf_uuid.as_deref(), Some("u1"));
1480                assert_eq!(x.session_id, "S");
1481            }
1482            other => panic!("wrong variant: {other:?}"),
1483        }
1484    }
1485
1486    #[test]
1487    fn last_prompt_hypothetical_both_fields() {
1488        let e = parse(
1489            r#"{"type":"last-prompt","lastPrompt":"inline","leafUuid":"u2","sessionId":"S"}"#,
1490        );
1491        match e {
1492            Entry::LastPrompt(x) => {
1493                assert_eq!(x.last_prompt.as_deref(), Some("inline"));
1494                assert_eq!(x.leaf_uuid.as_deref(), Some("u2"));
1495                assert_eq!(x.session_id, "S");
1496            }
1497            other => panic!("wrong variant: {other:?}"),
1498        }
1499    }
1500
1501    #[test]
1502    fn last_prompt_hypothetical_neither_field() {
1503        let e = parse(r#"{"type":"last-prompt","sessionId":"S"}"#);
1504        match e {
1505            Entry::LastPrompt(x) => {
1506                assert_eq!(x.last_prompt, None);
1507                assert_eq!(x.leaf_uuid, None);
1508                assert_eq!(x.session_id, "S");
1509            }
1510            other => panic!("wrong variant: {other:?}"),
1511        }
1512    }
1513}