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    pub duration_ms: u64,
622}
623
624#[derive(Debug, Clone, Serialize, Deserialize)]
625#[serde(rename_all = "camelCase")]
626pub struct PreservedSegment {
627    pub head_uuid: String,
628    pub anchor_uuid: String,
629    pub tail_uuid: String,
630}
631
632#[derive(Debug, Clone, Serialize, Deserialize)]
633#[serde(rename_all = "camelCase")]
634pub struct CompactMetadata {
635    pub trigger: String,
636    #[serde(skip_serializing_if = "Option::is_none")]
637    pub pre_tokens: Option<u64>,
638    #[serde(skip_serializing_if = "Option::is_none")]
639    pub post_tokens: Option<u64>,
640    #[serde(skip_serializing_if = "Option::is_none")]
641    pub duration_ms: Option<u64>,
642    #[serde(skip_serializing_if = "Option::is_none")]
643    pub preserved_segment: Option<PreservedSegment>,
644    #[serde(
645        rename = "preCompactDiscoveredTools",
646        skip_serializing_if = "Option::is_none"
647    )]
648    pub pre_compact_discovered_tools: Option<Vec<String>>,
649}
650
651// ---------------------------------------------------------------------------
652// Attachment entry
653// ---------------------------------------------------------------------------
654
655#[derive(Debug, Clone, Serialize, Deserialize)]
656pub struct AttachmentEntry {
657    #[serde(flatten)]
658    pub envelope: Envelope,
659    pub attachment: AttachmentData,
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize)]
663#[serde(tag = "type", rename_all = "snake_case")]
664pub enum AttachmentData {
665    // ── Hook results ─────────────────────────────────────────────────────
666    HookSuccess(HookResultAttachment),
667    HookNonBlockingError(HookResultAttachment),
668    HookBlockingError(HookResultAttachment),
669    HookCancelled(HookResultAttachment),
670
671    HookAdditionalContext {
672        content: Vec<String>,
673        #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
674        hook_name: Option<String>,
675        #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
676        tool_use_id: Option<String>,
677        #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
678        hook_event: Option<String>,
679    },
680
681    HookPermissionDecision {
682        decision: String,
683        #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
684        hook_name: Option<String>,
685        #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
686        tool_use_id: Option<String>,
687        #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
688        hook_event: Option<String>,
689    },
690
691    // ── File / filesystem ────────────────────────────────────────────────
692    File {
693        filename: String,
694        content: FileAttachmentContent,
695        #[serde(rename = "displayPath", skip_serializing_if = "Option::is_none")]
696        display_path: Option<String>,
697    },
698
699    EditedTextFile {
700        filename: String,
701        /// Line-numbered file content snippet.
702        snippet: String,
703    },
704
705    Directory {
706        path: String,
707        content: String,
708        #[serde(rename = "displayPath")]
709        display_path: String,
710    },
711
712    CompactFileReference {
713        filename: String,
714        #[serde(rename = "displayPath")]
715        display_path: String,
716    },
717
718    // ── Permissions ──────────────────────────────────────────────────────
719    CommandPermissions {
720        #[serde(rename = "allowedTools")]
721        allowed_tools: Vec<String>,
722    },
723
724    // ── Plan mode ────────────────────────────────────────────────────────
725    PlanMode {
726        #[serde(rename = "reminderType")]
727        reminder_type: String,
728        #[serde(rename = "isSubAgent")]
729        is_sub_agent: bool,
730        #[serde(rename = "planFilePath", skip_serializing_if = "Option::is_none")]
731        plan_file_path: Option<String>,
732        #[serde(rename = "planExists")]
733        plan_exists: bool,
734    },
735
736    PlanModeExit {
737        #[serde(rename = "planFilePath", skip_serializing_if = "Option::is_none")]
738        plan_file_path: Option<String>,
739        #[serde(rename = "planExists")]
740        plan_exists: bool,
741    },
742
743    // ── Skills ───────────────────────────────────────────────────────────
744    SkillListing {
745        content: String,
746        /// True on the very first skill listing injection for a session.
747        #[serde(rename = "isInitial", skip_serializing_if = "Option::is_none")]
748        is_initial: Option<bool>,
749        /// Total number of skills listed.
750        #[serde(rename = "skillCount", skip_serializing_if = "Option::is_none")]
751        skill_count: Option<u32>,
752    },
753
754    DynamicSkill {
755        #[serde(rename = "skillDir")]
756        skill_dir: String,
757        #[serde(rename = "skillNames")]
758        skill_names: Vec<String>,
759        #[serde(rename = "displayPath")]
760        display_path: String,
761    },
762
763    InvokedSkills {
764        skills: Vec<InvokedSkill>,
765    },
766
767    // ── Tasks ────────────────────────────────────────────────────────────
768    TaskReminder {
769        content: Vec<Value>,
770        #[serde(rename = "itemCount")]
771        item_count: u32,
772    },
773
774    // ── Diagnostics / IDE ────────────────────────────────────────────────
775    Diagnostics {
776        files: Vec<DiagnosticsFile>,
777        #[serde(rename = "isNew")]
778        is_new: bool,
779    },
780
781    // ── Dates / context ──────────────────────────────────────────────────
782    DateChange {
783        #[serde(rename = "newDate")]
784        new_date: String,
785    },
786
787    // ── Tool / MCP updates ───────────────────────────────────────────────
788    DeferredToolsDelta {
789        #[serde(rename = "addedNames")]
790        added_names: Vec<String>,
791        /// Legacy/alias field that mirrors addedNames; both are present in
792        /// some versions.
793        #[serde(rename = "addedLines", skip_serializing_if = "Option::is_none")]
794        added_lines: Option<Vec<String>>,
795        #[serde(rename = "removedNames", skip_serializing_if = "Option::is_none")]
796        removed_names: Option<Vec<String>>,
797    },
798
799    McpInstructionsDelta {
800        #[serde(rename = "addedNames")]
801        added_names: Vec<String>,
802        #[serde(rename = "addedBlocks")]
803        added_blocks: Vec<String>,
804        #[serde(rename = "removedNames", skip_serializing_if = "Option::is_none")]
805        removed_names: Option<Vec<String>>,
806    },
807
808    // ── Thinking effort ──────────────────────────────────────────────────
809    UltrathinkEffort {
810        level: String,
811    },
812
813    // ── Queued commands ──────────────────────────────────────────────────
814    QueuedCommand {
815        prompt: String,
816        #[serde(rename = "commandMode", skip_serializing_if = "Option::is_none")]
817        command_mode: Option<String>,
818    },
819
820    /// Catch-all for attachment types not yet recognised by the ingest binary.
821    #[serde(other)]
822    Unknown,
823}
824
825#[derive(Debug, Clone, Serialize, Deserialize)]
826#[serde(rename_all = "camelCase")]
827pub struct HookResultAttachment {
828    #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
829    pub hook_name: Option<String>,
830    #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
831    pub tool_use_id: Option<String>,
832    #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
833    pub hook_event: Option<String>,
834    #[serde(skip_serializing_if = "Option::is_none")]
835    pub content: Option<String>,
836    #[serde(skip_serializing_if = "Option::is_none")]
837    pub stdout: Option<String>,
838    #[serde(skip_serializing_if = "Option::is_none")]
839    pub stderr: Option<String>,
840    #[serde(skip_serializing_if = "Option::is_none")]
841    pub exit_code: Option<i32>,
842    #[serde(skip_serializing_if = "Option::is_none")]
843    pub command: Option<String>,
844    #[serde(skip_serializing_if = "Option::is_none")]
845    pub duration_ms: Option<u64>,
846    #[serde(rename = "blockingError", skip_serializing_if = "Option::is_none")]
847    pub blocking_error: Option<Value>,
848}
849
850/// Wrapper for a file content attachment.
851#[derive(Debug, Clone, Serialize, Deserialize)]
852#[serde(rename_all = "camelCase")]
853pub struct FileAttachmentContent {
854    #[serde(rename = "type")]
855    pub content_type: String,
856    pub file: FileData,
857}
858
859#[derive(Debug, Clone, Serialize, Deserialize)]
860#[serde(rename_all = "camelCase")]
861pub struct FileData {
862    pub file_path: String,
863    #[serde(skip_serializing_if = "Option::is_none")]
864    pub content: Option<String>,
865    #[serde(rename = "numLines", skip_serializing_if = "Option::is_none")]
866    pub num_lines: Option<u64>,
867    #[serde(rename = "startLine", skip_serializing_if = "Option::is_none")]
868    pub start_line: Option<u64>,
869    #[serde(rename = "totalLines", skip_serializing_if = "Option::is_none")]
870    pub total_lines: Option<u64>,
871}
872
873#[derive(Debug, Clone, Serialize, Deserialize)]
874pub struct InvokedSkill {
875    pub name: String,
876    pub path: String,
877    pub content: String,
878}
879
880#[derive(Debug, Clone, Serialize, Deserialize)]
881pub struct DiagnosticsFile {
882    pub uri: String,
883    pub diagnostics: Vec<Diagnostic>,
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize)]
887pub struct Diagnostic {
888    pub message: String,
889    pub severity: String,
890    pub range: DiagnosticRange,
891    #[serde(skip_serializing_if = "Option::is_none")]
892    pub source: Option<String>,
893    #[serde(skip_serializing_if = "Option::is_none")]
894    pub code: Option<Value>,
895}
896
897#[derive(Debug, Clone, Serialize, Deserialize)]
898pub struct DiagnosticRange {
899    pub start: DiagnosticPosition,
900    pub end: DiagnosticPosition,
901}
902
903#[derive(Debug, Clone, Serialize, Deserialize)]
904pub struct DiagnosticPosition {
905    pub line: u32,
906    pub character: u32,
907}
908
909// ---------------------------------------------------------------------------
910// Progress entry
911// ---------------------------------------------------------------------------
912
913#[derive(Debug, Clone, Serialize, Deserialize)]
914#[serde(rename_all = "camelCase")]
915pub struct ProgressEntry {
916    #[serde(flatten)]
917    pub envelope: Envelope,
918
919    pub data: ProgressData,
920
921    #[serde(rename = "parentToolUseID", skip_serializing_if = "Option::is_none")]
922    pub parent_tool_use_id: Option<String>,
923
924    #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
925    pub tool_use_id: Option<String>,
926}
927
928#[derive(Debug, Clone, Serialize, Deserialize)]
929#[serde(rename_all = "camelCase")]
930pub struct ProgressData {
931    #[serde(rename = "type")]
932    pub data_type: String,
933    #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
934    pub hook_event: Option<String>,
935    #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
936    pub hook_name: Option<String>,
937    #[serde(skip_serializing_if = "Option::is_none")]
938    pub command: Option<String>,
939    // agent_progress fields
940    #[serde(rename = "agentId", skip_serializing_if = "Option::is_none")]
941    pub agent_id: Option<String>,
942    #[serde(skip_serializing_if = "Option::is_none")]
943    pub prompt: Option<String>,
944    #[serde(skip_serializing_if = "Option::is_none")]
945    pub message: Option<Value>,
946    // query_update / search progress fields
947    #[serde(skip_serializing_if = "Option::is_none")]
948    pub query: Option<String>,
949    #[serde(rename = "resultCount", skip_serializing_if = "Option::is_none")]
950    pub result_count: Option<u32>,
951    // bash/command progress fields
952    #[serde(rename = "elapsedTimeSeconds", skip_serializing_if = "Option::is_none")]
953    pub elapsed_time_seconds: Option<f64>,
954    #[serde(rename = "fullOutput", skip_serializing_if = "Option::is_none")]
955    pub full_output: Option<String>,
956    #[serde(rename = "output", skip_serializing_if = "Option::is_none")]
957    pub output: Option<String>,
958    #[serde(rename = "timeoutMs", skip_serializing_if = "Option::is_none")]
959    pub timeout_ms: Option<u64>,
960    #[serde(rename = "totalLines", skip_serializing_if = "Option::is_none")]
961    pub total_lines: Option<u64>,
962    #[serde(rename = "totalBytes", skip_serializing_if = "Option::is_none")]
963    pub total_bytes: Option<u64>,
964    #[serde(rename = "taskId", skip_serializing_if = "Option::is_none")]
965    pub task_id: Option<String>,
966    // mcp tool progress fields
967    #[serde(rename = "serverName", skip_serializing_if = "Option::is_none")]
968    pub server_name: Option<String>,
969    #[serde(rename = "status", skip_serializing_if = "Option::is_none")]
970    pub status: Option<String>,
971    #[serde(rename = "toolName", skip_serializing_if = "Option::is_none")]
972    pub tool_name: Option<String>,
973    #[serde(rename = "elapsedTimeMs", skip_serializing_if = "Option::is_none")]
974    pub elapsed_time_ms: Option<f64>,
975    // agent task progress fields
976    #[serde(rename = "taskDescription", skip_serializing_if = "Option::is_none")]
977    pub task_description: Option<String>,
978    #[serde(rename = "taskType", skip_serializing_if = "Option::is_none")]
979    pub task_type: Option<String>,
980}
981
982// ---------------------------------------------------------------------------
983// Metadata-only entries
984// ---------------------------------------------------------------------------
985
986#[derive(Debug, Clone, Serialize, Deserialize)]
987#[serde(rename_all = "camelCase")]
988pub struct PermissionModeEntry {
989    pub permission_mode: String,
990    pub session_id: String,
991}
992
993#[derive(Debug, Clone, Serialize, Deserialize)]
994#[serde(rename_all = "camelCase")]
995pub struct LastPromptEntry {
996    pub last_prompt: String,
997    pub session_id: String,
998}
999
1000#[derive(Debug, Clone, Serialize, Deserialize)]
1001#[serde(rename_all = "camelCase")]
1002pub struct AiTitleEntry {
1003    pub ai_title: String,
1004    pub session_id: String,
1005}
1006
1007#[derive(Debug, Clone, Serialize, Deserialize)]
1008#[serde(rename_all = "camelCase")]
1009pub struct CustomTitleEntry {
1010    pub custom_title: String,
1011    pub session_id: String,
1012}
1013
1014#[derive(Debug, Clone, Serialize, Deserialize)]
1015#[serde(rename_all = "camelCase")]
1016pub struct AgentNameEntry {
1017    pub agent_name: String,
1018    pub session_id: String,
1019}
1020
1021#[derive(Debug, Clone, Serialize, Deserialize)]
1022#[serde(rename_all = "camelCase")]
1023pub struct AgentColorEntry {
1024    pub agent_color: String,
1025    pub session_id: String,
1026}
1027
1028#[derive(Debug, Clone, Serialize, Deserialize)]
1029#[serde(rename_all = "camelCase")]
1030pub struct AgentSettingEntry {
1031    pub agent_setting: String,
1032    pub session_id: String,
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize)]
1036#[serde(rename_all = "camelCase")]
1037pub struct TagEntry {
1038    pub tag: String,
1039    pub session_id: String,
1040}
1041
1042#[derive(Debug, Clone, Serialize, Deserialize)]
1043#[serde(rename_all = "camelCase")]
1044pub struct SummaryEntry {
1045    pub leaf_uuid: String,
1046    pub summary: String,
1047    pub session_id: String,
1048}
1049
1050#[derive(Debug, Clone, Serialize, Deserialize)]
1051#[serde(rename_all = "camelCase")]
1052pub struct TaskSummaryEntry {
1053    pub summary: String,
1054    pub session_id: String,
1055    pub timestamp: String,
1056}
1057
1058#[derive(Debug, Clone, Serialize, Deserialize)]
1059#[serde(rename_all = "camelCase")]
1060pub struct PrLinkEntry {
1061    pub session_id: String,
1062    pub pr_number: u32,
1063    pub pr_url: String,
1064    pub pr_repository: String,
1065    pub timestamp: String,
1066}
1067
1068#[derive(Debug, Clone, Serialize, Deserialize)]
1069#[serde(rename_all = "camelCase")]
1070pub struct ModeEntry {
1071    pub mode: SessionMode,
1072    pub session_id: String,
1073}
1074
1075#[derive(Debug, Clone, Serialize, Deserialize)]
1076#[serde(rename_all = "lowercase")]
1077pub enum SessionMode {
1078    Coordinator,
1079    Normal,
1080    #[serde(other)]
1081    Unknown,
1082}
1083
1084// worktreeSession is nullable (null = exited, object = active)
1085#[derive(Debug, Clone, Serialize, Deserialize)]
1086#[serde(rename_all = "camelCase")]
1087pub struct WorktreeStateEntry {
1088    pub session_id: String,
1089    /// null when the worktree session was exited; Some when active.
1090    pub worktree_session: Option<PersistedWorktreeSession>,
1091}
1092
1093#[derive(Debug, Clone, Serialize, Deserialize)]
1094#[serde(rename_all = "camelCase")]
1095pub struct PersistedWorktreeSession {
1096    pub original_cwd: String,
1097    pub worktree_path: String,
1098    pub worktree_name: String,
1099    pub session_id: String,
1100
1101    #[serde(skip_serializing_if = "Option::is_none")]
1102    pub worktree_branch: Option<String>,
1103
1104    #[serde(skip_serializing_if = "Option::is_none")]
1105    pub original_branch: Option<String>,
1106
1107    #[serde(skip_serializing_if = "Option::is_none")]
1108    pub original_head_commit: Option<String>,
1109
1110    #[serde(rename = "tmuxSessionName", skip_serializing_if = "Option::is_none")]
1111    pub tmux_session_name: Option<String>,
1112
1113    #[serde(skip_serializing_if = "Option::is_none")]
1114    pub hook_based: Option<bool>,
1115}
1116
1117#[derive(Debug, Clone, Serialize, Deserialize)]
1118#[serde(rename_all = "camelCase")]
1119pub struct ContentReplacementEntry {
1120    pub session_id: String,
1121    pub replacements: Vec<Value>,
1122    #[serde(skip_serializing_if = "Option::is_none")]
1123    pub agent_id: Option<String>,
1124}
1125
1126#[derive(Debug, Clone, Serialize, Deserialize)]
1127#[serde(rename_all = "camelCase")]
1128pub struct FileHistorySnapshotEntry {
1129    pub message_id: String,
1130    pub snapshot: FileHistorySnapshot,
1131    pub is_snapshot_update: bool,
1132}
1133
1134#[derive(Debug, Clone, Serialize, Deserialize)]
1135#[serde(rename_all = "camelCase")]
1136pub struct FileHistorySnapshot {
1137    pub message_id: String,
1138    pub tracked_file_backups: Value,
1139    pub timestamp: String,
1140}
1141
1142#[derive(Debug, Clone, Serialize, Deserialize)]
1143#[serde(rename_all = "camelCase")]
1144pub struct AttributionSnapshotEntry {
1145    pub message_id: String,
1146    pub surface: String,
1147    pub file_states: Value,
1148
1149    #[serde(skip_serializing_if = "Option::is_none")]
1150    pub prompt_count: Option<u32>,
1151
1152    #[serde(skip_serializing_if = "Option::is_none")]
1153    pub prompt_count_at_last_commit: Option<u32>,
1154
1155    #[serde(skip_serializing_if = "Option::is_none")]
1156    pub permission_prompt_count: Option<u32>,
1157
1158    #[serde(skip_serializing_if = "Option::is_none")]
1159    pub permission_prompt_count_at_last_commit: Option<u32>,
1160
1161    #[serde(skip_serializing_if = "Option::is_none")]
1162    pub escape_count: Option<u32>,
1163
1164    #[serde(skip_serializing_if = "Option::is_none")]
1165    pub escape_count_at_last_commit: Option<u32>,
1166}
1167
1168#[derive(Debug, Clone, Serialize, Deserialize)]
1169#[serde(rename_all = "camelCase")]
1170pub struct QueueOperationEntry {
1171    pub operation: String,
1172    pub timestamp: String,
1173    pub session_id: String,
1174    #[serde(skip_serializing_if = "Option::is_none")]
1175    pub content: Option<String>,
1176}
1177
1178// ---------------------------------------------------------------------------
1179// Context-collapse entries (internal, obfuscated type names)
1180// ---------------------------------------------------------------------------
1181
1182#[derive(Debug, Clone, Serialize, Deserialize)]
1183#[serde(rename_all = "camelCase")]
1184pub struct ContextCollapseCommitEntry {
1185    pub session_id: String,
1186    pub collapse_id: String,
1187    pub summary_uuid: String,
1188    pub summary_content: String,
1189    pub summary: String,
1190    pub first_archived_uuid: String,
1191    pub last_archived_uuid: String,
1192}
1193
1194#[derive(Debug, Clone, Serialize, Deserialize)]
1195#[serde(rename_all = "camelCase")]
1196pub struct ContextCollapseSnapshotEntry {
1197    pub session_id: String,
1198    pub staged: Vec<StagedSpan>,
1199    pub armed: bool,
1200    pub last_spawn_tokens: u64,
1201}
1202
1203#[derive(Debug, Clone, Serialize, Deserialize)]
1204#[serde(rename_all = "camelCase")]
1205pub struct StagedSpan {
1206    pub start_uuid: String,
1207    pub end_uuid: String,
1208    pub summary: String,
1209    pub risk: f64,
1210    pub staged_at: u64,
1211}
1212
1213#[derive(Debug, Clone, Serialize, Deserialize)]
1214#[serde(rename_all = "camelCase")]
1215pub struct SpeculationAcceptEntry {
1216    pub timestamp: String,
1217    pub time_saved_ms: u64,
1218}
1219
1220// ---------------------------------------------------------------------------
1221// Serde helper: distinguish JSON null from absent field
1222//
1223// Used with:
1224//   #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_nullable")]
1225//   pub field: Option<Option<T>>,
1226//
1227// Semantics:
1228//   None           → field absent  (skip_serializing_if prevents serialization)
1229//   Some(None)     → field present as JSON null
1230//   Some(Some(v))  → field present with value v
1231// ---------------------------------------------------------------------------
1232mod opt_nullable {
1233    use serde::{Deserialize, Deserializer, Serialize, Serializer};
1234    use serde_json::Value;
1235
1236    pub fn serialize<S>(val: &Option<Option<Value>>, ser: S) -> Result<S::Ok, S::Error>
1237    where
1238        S: Serializer,
1239    {
1240        match val {
1241            None => unreachable!("skip_serializing_if = \"Option::is_none\" should prevent this"),
1242            Some(inner) => inner.serialize(ser),
1243        }
1244    }
1245
1246    pub fn deserialize<'de, D>(de: D) -> Result<Option<Option<Value>>, D::Error>
1247    where
1248        D: Deserializer<'de>,
1249    {
1250        Ok(Some(Option::<Value>::deserialize(de)?))
1251    }
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256    use super::*;
1257
1258    #[test]
1259    fn attachment_data_unknown_variant() {
1260        let json = r#"{"type":"nested_memory","some_field":42}"#;
1261        let v: AttachmentData = serde_json::from_str(json).unwrap();
1262        assert!(matches!(v, AttachmentData::Unknown));
1263    }
1264
1265    #[test]
1266    fn assistant_content_block_unknown_variant() {
1267        let json = r#"{"type":"future_modality","data":"foo"}"#;
1268        let v: AssistantContentBlock = serde_json::from_str(json).unwrap();
1269        assert!(matches!(v, AssistantContentBlock::Unknown));
1270    }
1271
1272    #[test]
1273    fn user_content_block_unknown_variant() {
1274        let json = r#"{"type":"video","url":"https://example.com"}"#;
1275        let v: UserContentBlock = serde_json::from_str(json).unwrap();
1276        assert!(matches!(v, UserContentBlock::Unknown));
1277    }
1278
1279    #[test]
1280    fn image_source_unknown_variant() {
1281        let json = r#"{"type":"s3_bucket","key":"foo"}"#;
1282        let v: ImageSource = serde_json::from_str(json).unwrap();
1283        assert!(matches!(v, ImageSource::Unknown));
1284    }
1285
1286    #[test]
1287    fn document_source_unknown_variant() {
1288        let json = r#"{"type":"pdf","data":"base64data"}"#;
1289        let v: DocumentSource = serde_json::from_str(json).unwrap();
1290        assert!(matches!(v, DocumentSource::Unknown));
1291    }
1292
1293    // Verify known variants still parse correctly after adding Unknown.
1294    #[test]
1295    fn attachment_data_known_variant_unaffected() {
1296        let json = r#"{"type":"date_change","newDate":"2024-01-01"}"#;
1297        let v: AttachmentData = serde_json::from_str(json).unwrap();
1298        assert!(matches!(v, AttachmentData::DateChange { .. }));
1299    }
1300
1301    #[test]
1302    fn assistant_content_block_known_variant_unaffected() {
1303        let json = r#"{"type":"text","text":"hello"}"#;
1304        let v: AssistantContentBlock = serde_json::from_str(json).unwrap();
1305        assert!(matches!(v, AssistantContentBlock::Text { .. }));
1306    }
1307
1308    // ── New robustness tests (RED phase) ─────────────────────────────────
1309
1310    /// UserContent is untagged; a JSON object (neither string nor array)
1311    /// must not fail — should fall through to an Other/Value catch-all.
1312    #[test]
1313    fn user_content_unknown_shape_does_not_fail() {
1314        let json = r#"{"type":"future_format","data":42}"#;
1315        let v: UserContent = serde_json::from_str(json).unwrap();
1316        assert!(matches!(v, UserContent::Other(_)));
1317    }
1318
1319    /// ServerToolUse with a missing field (e.g. if Anthropic removes one)
1320    /// must deserialize successfully with a default of 0.
1321    #[test]
1322    fn server_tool_use_missing_field_uses_default() {
1323        let json = r#"{"web_search_requests":3}"#;
1324        let v: ServerToolUse = serde_json::from_str(json).unwrap();
1325        assert_eq!(v.web_search_requests, 3);
1326        assert_eq!(v.web_fetch_requests, 0);
1327    }
1328
1329    /// A new / unrecognised UserRole value must parse as Unknown.
1330    #[test]
1331    fn user_role_unknown_value_does_not_fail() {
1332        let json = r#""operator""#;
1333        let v: UserRole = serde_json::from_str(json).unwrap();
1334        assert!(matches!(v, UserRole::Unknown));
1335    }
1336
1337    /// A new / unrecognised AssistantRole value must parse as Unknown.
1338    #[test]
1339    fn assistant_role_unknown_value_does_not_fail() {
1340        let json = r#""system_agent""#;
1341        let v: AssistantRole = serde_json::from_str(json).unwrap();
1342        assert!(matches!(v, AssistantRole::Unknown));
1343    }
1344
1345    /// A new / unrecognised SessionMode value must parse as Unknown.
1346    #[test]
1347    fn session_mode_unknown_value_does_not_fail() {
1348        let json = r#""background""#;
1349        let v: SessionMode = serde_json::from_str(json).unwrap();
1350        assert!(matches!(v, SessionMode::Unknown));
1351    }
1352
1353    /// AssistantMessage without a "model" field (e.g. API error responses)
1354    /// must not fail deserialization.
1355    #[test]
1356    fn assistant_message_missing_model_uses_default() {
1357        let json = r#"{
1358            "id": "msg_err1",
1359            "type": "message",
1360            "role": "assistant",
1361            "content": [],
1362            "stop_reason": "error",
1363            "stop_sequence": null,
1364            "usage": {"input_tokens": 0, "output_tokens": 0}
1365        }"#;
1366        let v: AssistantMessage = serde_json::from_str(json).unwrap();
1367        assert!(v.model.is_none());
1368    }
1369}