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    /// HTTP status returned by the API when the turn errored (e.g. 401, 429,
330    /// 400, 403). Populated alongside `error` / `is_api_error_message` on
331    /// failed turns; absent on successful turns.
332    #[serde(rename = "apiErrorStatus", skip_serializing_if = "Option::is_none")]
333    pub api_error_status: Option<u16>,
334
335    /// Subagent / plugin slug that produced this turn. Format `<plugin>:<agent>`
336    /// when emitted by a plugin-namespaced agent, or bare `<agent>` for
337    /// built-in agents. Absent on top-level turns.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub attribution_agent: Option<String>,
340
341    /// Plugin namespace owning the subagent for this turn. Canonical when it
342    /// disagrees with the `<plugin>:` prefix of `attribution_agent`. Absent
343    /// when `attribution_agent` is bare or absent.
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub attribution_plugin: Option<String>,
346
347    /// Skill slug invoked on this turn. Format `<plugin>:<skill>` for
348    /// plugin-namespaced skills, or bare `<skill>` for built-in skills.
349    /// Absent when no skill is active for this turn.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub attribution_skill: Option<String>,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct AssistantMessage {
356    pub id: String,
357    /// Always "message".
358    #[serde(rename = "type")]
359    pub msg_type: String,
360    pub role: AssistantRole,
361    #[serde(default)]
362    pub model: Option<String>,
363
364    /// null when no container; Some(None) = present as JSON null.
365    #[serde(
366        default,
367        skip_serializing_if = "Option::is_none",
368        with = "opt_nullable"
369    )]
370    pub container: Option<Option<Value>>,
371
372    pub content: Vec<AssistantContentBlock>,
373
374    /// The API always includes this field; null means the stream is still
375    /// ongoing or the field was not set.
376    pub stop_reason: Option<String>,
377
378    /// null when stop_reason != "stop_sequence"
379    pub stop_sequence: Option<String>,
380
381    /// null in most responses; some API versions emit structured details.
382    /// outer None = field absent, Some(None) = field present as JSON null.
383    #[serde(
384        default,
385        skip_serializing_if = "Option::is_none",
386        with = "opt_nullable"
387    )]
388    pub stop_details: Option<Option<Value>>,
389
390    pub usage: AssistantUsage,
391
392    /// null in most responses; Some(None) = present as JSON null.
393    #[serde(
394        default,
395        skip_serializing_if = "Option::is_none",
396        with = "opt_nullable"
397    )]
398    pub context_management: Option<Option<Value>>,
399
400    /// API-emitted cache-miss diagnostic. `null` on most turns; an object
401    /// when the API reports why prompt caching did not hit. Outer
402    /// `None` = field absent, `Some(None)` = JSON null, `Some(Some(d))`
403    /// = populated.
404    #[serde(
405        default,
406        skip_serializing_if = "Option::is_none",
407        with = "opt_nullable"
408    )]
409    pub diagnostics: Option<Option<AssistantDiagnostics>>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
413#[serde(rename_all = "lowercase")]
414pub enum AssistantRole {
415    Assistant,
416    #[serde(other)]
417    Unknown,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
421#[serde(tag = "type", rename_all = "snake_case")]
422pub enum AssistantContentBlock {
423    Text {
424        text: String,
425    },
426
427    /// Extended thinking block. `thinking` is always an empty string in
428    /// persisted transcripts (Claude Code redacts it for storage); the
429    /// cryptographic `signature` is retained.
430    Thinking {
431        thinking: String,
432        signature: String,
433    },
434
435    RedactedThinking {
436        data: String,
437    },
438
439    ToolUse {
440        id: String,
441        name: String,
442        input: Value,
443        /// Present in some versions to identify call origin.
444        #[serde(skip_serializing_if = "Option::is_none")]
445        caller: Option<ToolUseCaller>,
446    },
447
448    /// Catch-all for content block types not yet recognised by the ingest binary.
449    #[serde(other)]
450    Unknown,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct ToolUseCaller {
455    #[serde(rename = "type")]
456    pub caller_type: String,
457}
458
459// The Anthropic API returns usage fields in snake_case — no rename_all here.
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct AssistantUsage {
462    pub input_tokens: u64,
463    pub output_tokens: u64,
464
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub cache_creation_input_tokens: Option<u64>,
467
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub cache_read_input_tokens: Option<u64>,
470
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub server_tool_use: Option<ServerToolUse>,
473
474    /// null = explicitly set to null by API; absent = field not present.
475    #[serde(
476        default,
477        skip_serializing_if = "Option::is_none",
478        with = "opt_nullable"
479    )]
480    pub service_tier: Option<Option<Value>>,
481
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub cache_creation: Option<CacheCreation>,
484
485    /// null = explicitly set to null by API; absent = field not present.
486    #[serde(
487        default,
488        skip_serializing_if = "Option::is_none",
489        with = "opt_nullable"
490    )]
491    pub inference_geo: Option<Option<Value>>,
492
493    /// null = explicitly set to null by API; absent = field not present.
494    #[serde(
495        default,
496        skip_serializing_if = "Option::is_none",
497        with = "opt_nullable"
498    )]
499    pub iterations: Option<Option<Value>>,
500
501    /// null = explicitly set to null by API; absent = field not present.
502    #[serde(
503        default,
504        skip_serializing_if = "Option::is_none",
505        with = "opt_nullable"
506    )]
507    pub speed: Option<Option<Value>>,
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct ServerToolUse {
512    #[serde(default)]
513    pub web_search_requests: u64,
514    #[serde(default)]
515    pub web_fetch_requests: u64,
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct CacheCreation {
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub ephemeral_1h_input_tokens: Option<u64>,
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub ephemeral_5m_input_tokens: Option<u64>,
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct UsageIteration {
528    pub input_tokens: u64,
529    pub output_tokens: u64,
530
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub cache_read_input_tokens: Option<u64>,
533
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub cache_creation_input_tokens: Option<u64>,
536
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub cache_creation: Option<CacheCreation>,
539
540    /// Iteration type; typically "message".
541    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
542    pub iter_type: Option<String>,
543}
544
545// ---------------------------------------------------------------------------
546// System entry
547//
548// All subtype-specific fields are optional so a single flat struct covers
549// every subtype while preserving exact field order semantics.  Type safety
550// on the discriminant is still enforced via SystemSubtype.
551// ---------------------------------------------------------------------------
552
553#[derive(Debug, Clone, Serialize, Deserialize)]
554#[serde(rename_all = "camelCase")]
555pub struct SystemEntry {
556    #[serde(flatten)]
557    pub envelope: Envelope,
558
559    pub subtype: SystemSubtype,
560
561    /// Human-readable message text (most subtypes).
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub content: Option<String>,
564
565    /// Severity level: "info" | "warning" | "error" | "suggestion".
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub level: Option<String>,
568
569    /// True when the entry should be hidden from the main conversation view.
570    #[serde(rename = "isMeta", skip_serializing_if = "Option::is_none")]
571    pub is_meta: Option<bool>,
572
573    // ── api_error ────────────────────────────────────────────────────────
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub cause: Option<Value>,
576
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub error: Option<Value>,
579
580    #[serde(rename = "retryInMs", skip_serializing_if = "Option::is_none")]
581    pub retry_in_ms: Option<f64>,
582
583    #[serde(rename = "retryAttempt", skip_serializing_if = "Option::is_none")]
584    pub retry_attempt: Option<u32>,
585
586    #[serde(rename = "maxRetries", skip_serializing_if = "Option::is_none")]
587    pub max_retries: Option<u32>,
588
589    // ── stop_hook_summary ────────────────────────────────────────────────
590    #[serde(rename = "hookCount", skip_serializing_if = "Option::is_none")]
591    pub hook_count: Option<u32>,
592
593    #[serde(rename = "hookInfos", skip_serializing_if = "Option::is_none")]
594    pub hook_infos: Option<Vec<HookInfo>>,
595
596    #[serde(rename = "hookErrors", skip_serializing_if = "Option::is_none")]
597    pub hook_errors: Option<Vec<Value>>,
598
599    #[serde(
600        rename = "preventedContinuation",
601        skip_serializing_if = "Option::is_none"
602    )]
603    pub prevented_continuation: Option<bool>,
604
605    #[serde(rename = "stopReason", skip_serializing_if = "Option::is_none")]
606    pub stop_reason: Option<String>,
607
608    #[serde(rename = "hasOutput", skip_serializing_if = "Option::is_none")]
609    pub has_output: Option<bool>,
610
611    #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
612    pub tool_use_id: Option<String>,
613
614    // ── turn_duration ────────────────────────────────────────────────────
615    #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
616    pub duration_ms: Option<f64>,
617
618    #[serde(rename = "messageCount", skip_serializing_if = "Option::is_none")]
619    pub message_count: Option<u32>,
620
621    // ── bridge_status ────────────────────────────────────────────────────
622    #[serde(skip_serializing_if = "Option::is_none")]
623    pub url: Option<String>,
624
625    #[serde(rename = "upgradeNudge", skip_serializing_if = "Option::is_none")]
626    pub upgrade_nudge: Option<String>,
627
628    // ── compact_boundary ────────────────────────────────────────────────
629    #[serde(rename = "compactMetadata", skip_serializing_if = "Option::is_none")]
630    pub compact_metadata: Option<CompactMetadata>,
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize)]
634#[serde(rename_all = "snake_case")]
635pub enum SystemSubtype {
636    ApiError,
637    AwaySummary,
638    BridgeStatus,
639    CompactBoundary,
640    Informational,
641    LocalCommand,
642    ScheduledTaskFire,
643    StopHookSummary,
644    TurnDuration,
645    MicrocompactBoundary,
646    PermissionRetry,
647    AgentsKilled,
648    #[serde(other)]
649    Unknown,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize)]
653#[serde(rename_all = "camelCase")]
654pub struct HookInfo {
655    pub command: String,
656    #[serde(default, skip_serializing_if = "Option::is_none")]
657    pub duration_ms: Option<u64>,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
661#[serde(rename_all = "camelCase")]
662pub struct PreservedSegment {
663    pub head_uuid: String,
664    pub anchor_uuid: String,
665    pub tail_uuid: String,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize)]
669#[serde(rename_all = "camelCase")]
670pub struct CompactMetadata {
671    pub trigger: String,
672    #[serde(skip_serializing_if = "Option::is_none")]
673    pub pre_tokens: Option<u64>,
674    #[serde(skip_serializing_if = "Option::is_none")]
675    pub post_tokens: Option<u64>,
676    #[serde(skip_serializing_if = "Option::is_none")]
677    pub duration_ms: Option<u64>,
678    #[serde(skip_serializing_if = "Option::is_none")]
679    pub preserved_segment: Option<PreservedSegment>,
680    #[serde(
681        rename = "preCompactDiscoveredTools",
682        skip_serializing_if = "Option::is_none"
683    )]
684    pub pre_compact_discovered_tools: Option<Vec<String>>,
685}
686
687// ---------------------------------------------------------------------------
688// Attachment entry
689// ---------------------------------------------------------------------------
690
691#[derive(Debug, Clone, Serialize, Deserialize)]
692pub struct AttachmentEntry {
693    #[serde(flatten)]
694    pub envelope: Envelope,
695    pub attachment: AttachmentData,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize)]
699#[serde(tag = "type", rename_all = "snake_case")]
700pub enum AttachmentData {
701    // ── Hook results ─────────────────────────────────────────────────────
702    HookSuccess(HookResultAttachment),
703    HookNonBlockingError(HookResultAttachment),
704    HookBlockingError(HookResultAttachment),
705    HookCancelled(HookResultAttachment),
706
707    HookAdditionalContext {
708        content: Vec<String>,
709        #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
710        hook_name: Option<String>,
711        #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
712        tool_use_id: Option<String>,
713        #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
714        hook_event: Option<String>,
715    },
716
717    HookPermissionDecision {
718        decision: String,
719        #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
720        hook_name: Option<String>,
721        #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
722        tool_use_id: Option<String>,
723        #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
724        hook_event: Option<String>,
725    },
726
727    /// Emitted when a hook ended the assistant's turn (e.g. harness
728    /// `await_user_message`). Sibling of HookAdditionalContext but with a
729    /// single `message` field.
730    HookStoppedContinuation {
731        message: String,
732        #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
733        hook_name: Option<String>,
734        #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
735        tool_use_id: Option<String>,
736        #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
737        hook_event: Option<String>,
738    },
739
740    /// Single-string sibling of HookAdditionalContext.
741    HookSystemMessage {
742        content: String,
743        #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
744        hook_name: Option<String>,
745        #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
746        tool_use_id: Option<String>,
747        #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
748        hook_event: Option<String>,
749    },
750
751    // ── File / filesystem ────────────────────────────────────────────────
752    File {
753        filename: String,
754        content: FileAttachmentContent,
755        #[serde(rename = "displayPath", skip_serializing_if = "Option::is_none")]
756        display_path: Option<String>,
757    },
758
759    EditedTextFile {
760        filename: String,
761        /// Line-numbered file content snippet.
762        snippet: String,
763    },
764
765    Directory {
766        path: String,
767        content: String,
768        #[serde(rename = "displayPath")]
769        display_path: String,
770    },
771
772    CompactFileReference {
773        filename: String,
774        #[serde(rename = "displayPath")]
775        display_path: String,
776    },
777
778    // ── Permissions ──────────────────────────────────────────────────────
779    CommandPermissions {
780        #[serde(rename = "allowedTools")]
781        allowed_tools: Vec<String>,
782    },
783
784    // ── Plan mode ────────────────────────────────────────────────────────
785    PlanMode {
786        #[serde(rename = "reminderType")]
787        reminder_type: String,
788        #[serde(rename = "isSubAgent")]
789        is_sub_agent: bool,
790        #[serde(rename = "planFilePath", skip_serializing_if = "Option::is_none")]
791        plan_file_path: Option<String>,
792        #[serde(rename = "planExists")]
793        plan_exists: bool,
794    },
795
796    PlanModeExit {
797        #[serde(rename = "planFilePath", skip_serializing_if = "Option::is_none")]
798        plan_file_path: Option<String>,
799        #[serde(rename = "planExists")]
800        plan_exists: bool,
801    },
802
803    // ── Auto mode ────────────────────────────────────────────────────────
804    AutoMode {
805        #[serde(rename = "reminderType")]
806        reminder_type: String,
807    },
808
809    AutoModeExit,
810
811    // ── Plan file reference ─────────────────────────────────────────────
812    /// Snapshot of a plan markdown file pinned to the conversation. Carries
813    /// the absolute path and the full file content at pin time.
814    PlanFileReference {
815        #[serde(rename = "planFilePath")]
816        plan_file_path: String,
817        #[serde(rename = "planContent")]
818        plan_content: String,
819    },
820
821    // ── Skills ───────────────────────────────────────────────────────────
822    SkillListing {
823        content: String,
824        /// True on the very first skill listing injection for a session.
825        #[serde(rename = "isInitial", skip_serializing_if = "Option::is_none")]
826        is_initial: Option<bool>,
827        /// Total number of skills listed.
828        #[serde(rename = "skillCount", skip_serializing_if = "Option::is_none")]
829        skill_count: Option<u32>,
830    },
831
832    DynamicSkill {
833        #[serde(rename = "skillDir")]
834        skill_dir: String,
835        #[serde(rename = "skillNames")]
836        skill_names: Vec<String>,
837        #[serde(rename = "displayPath")]
838        display_path: String,
839    },
840
841    InvokedSkills {
842        skills: Vec<InvokedSkill>,
843    },
844
845    // ── Tasks ────────────────────────────────────────────────────────────
846    TaskReminder {
847        content: Vec<Value>,
848        #[serde(rename = "itemCount")]
849        item_count: u32,
850    },
851
852    /// Older alias for TaskReminder; identical shape, only the discriminator
853    /// differs. Observed payloads have always been empty.
854    TodoReminder {
855        content: Vec<Value>,
856        #[serde(rename = "itemCount")]
857        item_count: u32,
858    },
859
860    // ── Diagnostics / IDE ────────────────────────────────────────────────
861    Diagnostics {
862        files: Vec<DiagnosticsFile>,
863        #[serde(rename = "isNew")]
864        is_new: bool,
865    },
866
867    // ── Dates / context ──────────────────────────────────────────────────
868    DateChange {
869        #[serde(rename = "newDate")]
870        new_date: String,
871    },
872
873    // ── Tool / MCP updates ───────────────────────────────────────────────
874    DeferredToolsDelta {
875        #[serde(rename = "addedNames")]
876        added_names: Vec<String>,
877        /// Legacy/alias field that mirrors addedNames; both are present in
878        /// some versions.
879        #[serde(rename = "addedLines", skip_serializing_if = "Option::is_none")]
880        added_lines: Option<Vec<String>>,
881        #[serde(rename = "removedNames", skip_serializing_if = "Option::is_none")]
882        removed_names: Option<Vec<String>>,
883        /// Tools that were previously removed and have been re-added on this
884        /// turn. Disjoint from `addedNames` (which lists newly-added tools).
885        #[serde(rename = "readdedNames", skip_serializing_if = "Option::is_none")]
886        readded_names: Option<Vec<String>>,
887    },
888
889    McpInstructionsDelta {
890        #[serde(rename = "addedNames")]
891        added_names: Vec<String>,
892        #[serde(rename = "addedBlocks")]
893        added_blocks: Vec<String>,
894        #[serde(rename = "removedNames", skip_serializing_if = "Option::is_none")]
895        removed_names: Option<Vec<String>>,
896    },
897
898    /// Diff of available agent types announced to the assistant. Sibling of
899    /// `DeferredToolsDelta` / `McpInstructionsDelta` but for the agent
900    /// listing. `isInitial` is true on the first injection per session;
901    /// `showConcurrencyNote` toggles a UI hint about parallel agent dispatch.
902    AgentListingDelta {
903        #[serde(rename = "addedTypes")]
904        added_types: Vec<String>,
905        #[serde(rename = "addedLines")]
906        added_lines: Vec<String>,
907        #[serde(rename = "removedTypes")]
908        removed_types: Vec<String>,
909        #[serde(rename = "isInitial", skip_serializing_if = "Option::is_none")]
910        is_initial: Option<bool>,
911        #[serde(
912            rename = "showConcurrencyNote",
913            skip_serializing_if = "Option::is_none"
914        )]
915        show_concurrency_note: Option<bool>,
916    },
917
918    // ── Thinking effort ──────────────────────────────────────────────────
919    UltrathinkEffort {
920        level: String,
921    },
922
923    // ── Queued commands ──────────────────────────────────────────────────
924    QueuedCommand {
925        /// String for plain prompts, or array of content blocks (text/image)
926        /// for prompts that include attached images. Using Value because serde
927        /// cannot nest untagged enums inside an internally-tagged variant.
928        prompt: Value,
929        #[serde(rename = "commandMode", skip_serializing_if = "Option::is_none")]
930        command_mode: Option<String>,
931        /// IDs of images pasted into the queued prompt. Mirrors the field of
932        /// the same name on `UserEntry`.
933        #[serde(rename = "imagePasteIds", skip_serializing_if = "Option::is_none")]
934        image_paste_ids: Option<Vec<u64>>,
935    },
936
937    // ── Nested memory (CLAUDE.md imports) ────────────────────────────────
938    NestedMemory {
939        path: String,
940        content: NestedMemoryContent,
941        #[serde(rename = "displayPath")]
942        display_path: String,
943    },
944
945    /// Catch-all for attachment types not yet recognised by the ingest binary.
946    #[serde(other)]
947    Unknown,
948}
949
950#[derive(Debug, Clone, Serialize, Deserialize)]
951pub struct NestedMemoryContent {
952    pub path: String,
953    /// CLAUDE.md scope ("Project", "User", "Local", etc).
954    #[serde(rename = "type")]
955    pub memory_type: String,
956    pub content: String,
957    #[serde(
958        rename = "contentDiffersFromDisk",
959        skip_serializing_if = "Option::is_none"
960    )]
961    pub content_differs_from_disk: Option<bool>,
962}
963
964#[derive(Debug, Clone, Serialize, Deserialize)]
965#[serde(rename_all = "camelCase")]
966pub struct HookResultAttachment {
967    #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
968    pub hook_name: Option<String>,
969    #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
970    pub tool_use_id: Option<String>,
971    #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
972    pub hook_event: Option<String>,
973    #[serde(skip_serializing_if = "Option::is_none")]
974    pub content: Option<String>,
975    #[serde(skip_serializing_if = "Option::is_none")]
976    pub stdout: Option<String>,
977    #[serde(skip_serializing_if = "Option::is_none")]
978    pub stderr: Option<String>,
979    #[serde(skip_serializing_if = "Option::is_none")]
980    pub exit_code: Option<i32>,
981    #[serde(skip_serializing_if = "Option::is_none")]
982    pub command: Option<String>,
983    #[serde(skip_serializing_if = "Option::is_none")]
984    pub duration_ms: Option<u64>,
985    #[serde(rename = "blockingError", skip_serializing_if = "Option::is_none")]
986    pub blocking_error: Option<Value>,
987}
988
989/// Wrapper for a file content attachment.
990#[derive(Debug, Clone, Serialize, Deserialize)]
991#[serde(rename_all = "camelCase")]
992pub struct FileAttachmentContent {
993    #[serde(rename = "type")]
994    pub content_type: String,
995    pub file: FileData,
996}
997
998#[derive(Debug, Clone, Serialize, Deserialize)]
999#[serde(rename_all = "camelCase")]
1000pub struct FileData {
1001    pub file_path: String,
1002    #[serde(skip_serializing_if = "Option::is_none")]
1003    pub content: Option<String>,
1004    #[serde(rename = "numLines", skip_serializing_if = "Option::is_none")]
1005    pub num_lines: Option<u64>,
1006    #[serde(rename = "startLine", skip_serializing_if = "Option::is_none")]
1007    pub start_line: Option<u64>,
1008    #[serde(rename = "totalLines", skip_serializing_if = "Option::is_none")]
1009    pub total_lines: Option<u64>,
1010}
1011
1012#[derive(Debug, Clone, Serialize, Deserialize)]
1013pub struct InvokedSkill {
1014    pub name: String,
1015    pub path: String,
1016    pub content: String,
1017}
1018
1019#[derive(Debug, Clone, Serialize, Deserialize)]
1020pub struct DiagnosticsFile {
1021    pub uri: String,
1022    pub diagnostics: Vec<Diagnostic>,
1023}
1024
1025/// Cache-miss diagnostic emitted by the API on `AssistantMessage`. Indicates
1026/// why prompt caching did not hit on this turn and (when known) how many
1027/// input tokens missed the cache.
1028///
1029/// `kind` is kept as `String` (not an enum with `#[serde(other)]`) so the
1030/// raw value flows through to the ingest column without being collapsed
1031/// into a generic `Unknown` bucket.
1032#[derive(Debug, Clone, Serialize, Deserialize)]
1033pub struct CacheMissReason {
1034    #[serde(rename = "type")]
1035    pub kind: String,
1036
1037    #[serde(skip_serializing_if = "Option::is_none")]
1038    pub cache_missed_input_tokens: Option<u64>,
1039}
1040
1041/// Diagnostics container on `AssistantMessage`. Currently always shaped as
1042/// `{ "cache_miss_reason": … }` but boxed as a struct so future sibling
1043/// keys can be added without breaking deserialization.
1044#[derive(Debug, Clone, Serialize, Deserialize)]
1045pub struct AssistantDiagnostics {
1046    #[serde(skip_serializing_if = "Option::is_none")]
1047    pub cache_miss_reason: Option<CacheMissReason>,
1048}
1049
1050#[derive(Debug, Clone, Serialize, Deserialize)]
1051pub struct Diagnostic {
1052    pub message: String,
1053    pub severity: String,
1054    pub range: DiagnosticRange,
1055    #[serde(skip_serializing_if = "Option::is_none")]
1056    pub source: Option<String>,
1057    #[serde(skip_serializing_if = "Option::is_none")]
1058    pub code: Option<Value>,
1059}
1060
1061#[derive(Debug, Clone, Serialize, Deserialize)]
1062pub struct DiagnosticRange {
1063    pub start: DiagnosticPosition,
1064    pub end: DiagnosticPosition,
1065}
1066
1067#[derive(Debug, Clone, Serialize, Deserialize)]
1068pub struct DiagnosticPosition {
1069    pub line: u32,
1070    pub character: u32,
1071}
1072
1073// ---------------------------------------------------------------------------
1074// Progress entry
1075// ---------------------------------------------------------------------------
1076
1077#[derive(Debug, Clone, Serialize, Deserialize)]
1078#[serde(rename_all = "camelCase")]
1079pub struct ProgressEntry {
1080    #[serde(flatten)]
1081    pub envelope: Envelope,
1082
1083    pub data: ProgressData,
1084
1085    #[serde(rename = "parentToolUseID", skip_serializing_if = "Option::is_none")]
1086    pub parent_tool_use_id: Option<String>,
1087
1088    #[serde(rename = "toolUseID", skip_serializing_if = "Option::is_none")]
1089    pub tool_use_id: Option<String>,
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize)]
1093#[serde(rename_all = "camelCase")]
1094pub struct ProgressData {
1095    #[serde(rename = "type")]
1096    pub data_type: String,
1097    #[serde(rename = "hookEvent", skip_serializing_if = "Option::is_none")]
1098    pub hook_event: Option<String>,
1099    #[serde(rename = "hookName", skip_serializing_if = "Option::is_none")]
1100    pub hook_name: Option<String>,
1101    #[serde(skip_serializing_if = "Option::is_none")]
1102    pub command: Option<String>,
1103    // agent_progress fields
1104    #[serde(rename = "agentId", skip_serializing_if = "Option::is_none")]
1105    pub agent_id: Option<String>,
1106    #[serde(skip_serializing_if = "Option::is_none")]
1107    pub prompt: Option<String>,
1108    #[serde(skip_serializing_if = "Option::is_none")]
1109    pub message: Option<Value>,
1110    // query_update / search progress fields
1111    #[serde(skip_serializing_if = "Option::is_none")]
1112    pub query: Option<String>,
1113    #[serde(rename = "resultCount", skip_serializing_if = "Option::is_none")]
1114    pub result_count: Option<u32>,
1115    // bash/command progress fields
1116    #[serde(rename = "elapsedTimeSeconds", skip_serializing_if = "Option::is_none")]
1117    pub elapsed_time_seconds: Option<f64>,
1118    #[serde(rename = "fullOutput", skip_serializing_if = "Option::is_none")]
1119    pub full_output: Option<String>,
1120    #[serde(rename = "output", skip_serializing_if = "Option::is_none")]
1121    pub output: Option<String>,
1122    #[serde(rename = "timeoutMs", skip_serializing_if = "Option::is_none")]
1123    pub timeout_ms: Option<u64>,
1124    #[serde(rename = "totalLines", skip_serializing_if = "Option::is_none")]
1125    pub total_lines: Option<u64>,
1126    #[serde(rename = "totalBytes", skip_serializing_if = "Option::is_none")]
1127    pub total_bytes: Option<u64>,
1128    #[serde(rename = "taskId", skip_serializing_if = "Option::is_none")]
1129    pub task_id: Option<String>,
1130    // mcp tool progress fields
1131    #[serde(rename = "serverName", skip_serializing_if = "Option::is_none")]
1132    pub server_name: Option<String>,
1133    #[serde(rename = "status", skip_serializing_if = "Option::is_none")]
1134    pub status: Option<String>,
1135    #[serde(rename = "toolName", skip_serializing_if = "Option::is_none")]
1136    pub tool_name: Option<String>,
1137    #[serde(rename = "elapsedTimeMs", skip_serializing_if = "Option::is_none")]
1138    pub elapsed_time_ms: Option<f64>,
1139    // agent task progress fields
1140    #[serde(rename = "taskDescription", skip_serializing_if = "Option::is_none")]
1141    pub task_description: Option<String>,
1142    #[serde(rename = "taskType", skip_serializing_if = "Option::is_none")]
1143    pub task_type: Option<String>,
1144}
1145
1146// ---------------------------------------------------------------------------
1147// Metadata-only entries
1148// ---------------------------------------------------------------------------
1149
1150#[derive(Debug, Clone, Serialize, Deserialize)]
1151#[serde(rename_all = "camelCase")]
1152pub struct PermissionModeEntry {
1153    pub permission_mode: String,
1154    pub session_id: String,
1155}
1156
1157#[derive(Debug, Clone, Serialize, Deserialize)]
1158#[serde(rename_all = "camelCase")]
1159pub struct LastPromptEntry {
1160    #[serde(skip_serializing_if = "Option::is_none")]
1161    pub last_prompt: Option<String>,
1162    #[serde(skip_serializing_if = "Option::is_none")]
1163    pub leaf_uuid: Option<String>,
1164    pub session_id: String,
1165}
1166
1167#[derive(Debug, Clone, Serialize, Deserialize)]
1168#[serde(rename_all = "camelCase")]
1169pub struct AiTitleEntry {
1170    pub ai_title: String,
1171    pub session_id: String,
1172}
1173
1174#[derive(Debug, Clone, Serialize, Deserialize)]
1175#[serde(rename_all = "camelCase")]
1176pub struct CustomTitleEntry {
1177    pub custom_title: String,
1178    pub session_id: String,
1179}
1180
1181#[derive(Debug, Clone, Serialize, Deserialize)]
1182#[serde(rename_all = "camelCase")]
1183pub struct AgentNameEntry {
1184    pub agent_name: String,
1185    pub session_id: String,
1186}
1187
1188#[derive(Debug, Clone, Serialize, Deserialize)]
1189#[serde(rename_all = "camelCase")]
1190pub struct AgentColorEntry {
1191    pub agent_color: String,
1192    pub session_id: String,
1193}
1194
1195#[derive(Debug, Clone, Serialize, Deserialize)]
1196#[serde(rename_all = "camelCase")]
1197pub struct AgentSettingEntry {
1198    pub agent_setting: String,
1199    pub session_id: String,
1200}
1201
1202#[derive(Debug, Clone, Serialize, Deserialize)]
1203#[serde(rename_all = "camelCase")]
1204pub struct TagEntry {
1205    pub tag: String,
1206    pub session_id: String,
1207}
1208
1209#[derive(Debug, Clone, Serialize, Deserialize)]
1210#[serde(rename_all = "camelCase")]
1211pub struct SummaryEntry {
1212    pub leaf_uuid: String,
1213    pub summary: String,
1214    pub session_id: String,
1215}
1216
1217#[derive(Debug, Clone, Serialize, Deserialize)]
1218#[serde(rename_all = "camelCase")]
1219pub struct TaskSummaryEntry {
1220    pub summary: String,
1221    pub session_id: String,
1222    pub timestamp: String,
1223}
1224
1225#[derive(Debug, Clone, Serialize, Deserialize)]
1226#[serde(rename_all = "camelCase")]
1227pub struct PrLinkEntry {
1228    pub session_id: String,
1229    pub pr_number: u32,
1230    pub pr_url: String,
1231    pub pr_repository: String,
1232    pub timestamp: String,
1233}
1234
1235#[derive(Debug, Clone, Serialize, Deserialize)]
1236#[serde(rename_all = "camelCase")]
1237pub struct ModeEntry {
1238    pub mode: SessionMode,
1239    pub session_id: String,
1240}
1241
1242#[derive(Debug, Clone, Serialize, Deserialize)]
1243#[serde(rename_all = "lowercase")]
1244pub enum SessionMode {
1245    Coordinator,
1246    Normal,
1247    #[serde(other)]
1248    Unknown,
1249}
1250
1251// worktreeSession is nullable (null = exited, object = active)
1252#[derive(Debug, Clone, Serialize, Deserialize)]
1253#[serde(rename_all = "camelCase")]
1254pub struct WorktreeStateEntry {
1255    pub session_id: String,
1256    /// null when the worktree session was exited; Some when active.
1257    pub worktree_session: Option<PersistedWorktreeSession>,
1258}
1259
1260#[derive(Debug, Clone, Serialize, Deserialize)]
1261#[serde(rename_all = "camelCase")]
1262pub struct PersistedWorktreeSession {
1263    pub original_cwd: String,
1264    pub worktree_path: String,
1265    pub worktree_name: String,
1266    pub session_id: String,
1267
1268    #[serde(skip_serializing_if = "Option::is_none")]
1269    pub worktree_branch: Option<String>,
1270
1271    #[serde(skip_serializing_if = "Option::is_none")]
1272    pub original_branch: Option<String>,
1273
1274    #[serde(skip_serializing_if = "Option::is_none")]
1275    pub original_head_commit: Option<String>,
1276
1277    #[serde(rename = "tmuxSessionName", skip_serializing_if = "Option::is_none")]
1278    pub tmux_session_name: Option<String>,
1279
1280    #[serde(skip_serializing_if = "Option::is_none")]
1281    pub hook_based: Option<bool>,
1282}
1283
1284#[derive(Debug, Clone, Serialize, Deserialize)]
1285#[serde(rename_all = "camelCase")]
1286pub struct ContentReplacementEntry {
1287    pub session_id: String,
1288    pub replacements: Vec<Value>,
1289    #[serde(skip_serializing_if = "Option::is_none")]
1290    pub agent_id: Option<String>,
1291}
1292
1293#[derive(Debug, Clone, Serialize, Deserialize)]
1294#[serde(rename_all = "camelCase")]
1295pub struct FileHistorySnapshotEntry {
1296    pub message_id: String,
1297    pub snapshot: FileHistorySnapshot,
1298    pub is_snapshot_update: bool,
1299}
1300
1301#[derive(Debug, Clone, Serialize, Deserialize)]
1302#[serde(rename_all = "camelCase")]
1303pub struct FileHistorySnapshot {
1304    pub message_id: String,
1305    pub tracked_file_backups: Value,
1306    pub timestamp: String,
1307}
1308
1309#[derive(Debug, Clone, Serialize, Deserialize)]
1310#[serde(rename_all = "camelCase")]
1311pub struct AttributionSnapshotEntry {
1312    pub message_id: String,
1313    pub surface: String,
1314    pub file_states: Value,
1315
1316    #[serde(skip_serializing_if = "Option::is_none")]
1317    pub prompt_count: Option<u32>,
1318
1319    #[serde(skip_serializing_if = "Option::is_none")]
1320    pub prompt_count_at_last_commit: Option<u32>,
1321
1322    #[serde(skip_serializing_if = "Option::is_none")]
1323    pub permission_prompt_count: Option<u32>,
1324
1325    #[serde(skip_serializing_if = "Option::is_none")]
1326    pub permission_prompt_count_at_last_commit: Option<u32>,
1327
1328    #[serde(skip_serializing_if = "Option::is_none")]
1329    pub escape_count: Option<u32>,
1330
1331    #[serde(skip_serializing_if = "Option::is_none")]
1332    pub escape_count_at_last_commit: Option<u32>,
1333}
1334
1335#[derive(Debug, Clone, Serialize, Deserialize)]
1336#[serde(rename_all = "camelCase")]
1337pub struct QueueOperationEntry {
1338    pub operation: String,
1339    pub timestamp: String,
1340    pub session_id: String,
1341    #[serde(skip_serializing_if = "Option::is_none")]
1342    pub content: Option<String>,
1343}
1344
1345// ---------------------------------------------------------------------------
1346// Context-collapse entries (internal, obfuscated type names)
1347// ---------------------------------------------------------------------------
1348
1349#[derive(Debug, Clone, Serialize, Deserialize)]
1350#[serde(rename_all = "camelCase")]
1351pub struct ContextCollapseCommitEntry {
1352    pub session_id: String,
1353    pub collapse_id: String,
1354    pub summary_uuid: String,
1355    pub summary_content: String,
1356    pub summary: String,
1357    pub first_archived_uuid: String,
1358    pub last_archived_uuid: String,
1359}
1360
1361#[derive(Debug, Clone, Serialize, Deserialize)]
1362#[serde(rename_all = "camelCase")]
1363pub struct ContextCollapseSnapshotEntry {
1364    pub session_id: String,
1365    pub staged: Vec<StagedSpan>,
1366    pub armed: bool,
1367    pub last_spawn_tokens: u64,
1368}
1369
1370#[derive(Debug, Clone, Serialize, Deserialize)]
1371#[serde(rename_all = "camelCase")]
1372pub struct StagedSpan {
1373    pub start_uuid: String,
1374    pub end_uuid: String,
1375    pub summary: String,
1376    pub risk: f64,
1377    pub staged_at: u64,
1378}
1379
1380#[derive(Debug, Clone, Serialize, Deserialize)]
1381#[serde(rename_all = "camelCase")]
1382pub struct SpeculationAcceptEntry {
1383    pub timestamp: String,
1384    pub time_saved_ms: u64,
1385}
1386
1387// ---------------------------------------------------------------------------
1388// Serde helper: distinguish JSON null from absent field
1389//
1390// Used with:
1391//   #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_nullable")]
1392//   pub field: Option<Option<T>>,
1393//
1394// Semantics:
1395//   None           → field absent  (skip_serializing_if prevents serialization)
1396//   Some(None)     → field present as JSON null
1397//   Some(Some(v))  → field present with value v
1398// ---------------------------------------------------------------------------
1399mod opt_nullable {
1400    use serde::{Deserialize, Deserializer, Serialize, Serializer};
1401
1402    pub fn serialize<S, T>(val: &Option<Option<T>>, ser: S) -> Result<S::Ok, S::Error>
1403    where
1404        S: Serializer,
1405        T: Serialize,
1406    {
1407        match val {
1408            None => unreachable!("skip_serializing_if = \"Option::is_none\" should prevent this"),
1409            Some(inner) => inner.serialize(ser),
1410        }
1411    }
1412
1413    pub fn deserialize<'de, D, T>(de: D) -> Result<Option<Option<T>>, D::Error>
1414    where
1415        D: Deserializer<'de>,
1416        T: Deserialize<'de>,
1417    {
1418        Ok(Some(Option::<T>::deserialize(de)?))
1419    }
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424    use super::*;
1425
1426    #[test]
1427    fn attachment_data_unknown_variant() {
1428        let json = r#"{"type":"future_attachment_shape","some_field":42}"#;
1429        let v: AttachmentData = serde_json::from_str(json).unwrap();
1430        assert!(matches!(v, AttachmentData::Unknown));
1431    }
1432
1433    #[test]
1434    fn attachment_data_nested_memory_variant() {
1435        let json = r#"{"type":"nested_memory","path":"/p/CLAUDE.md","content":{"path":"/p/CLAUDE.md","type":"Project","content":"hi","contentDiffersFromDisk":false},"displayPath":"CLAUDE.md"}"#;
1436        let v: AttachmentData = serde_json::from_str(json).unwrap();
1437        match v {
1438            AttachmentData::NestedMemory {
1439                path,
1440                content,
1441                display_path,
1442            } => {
1443                assert_eq!(path, "/p/CLAUDE.md");
1444                assert_eq!(content.memory_type, "Project");
1445                assert_eq!(content.content, "hi");
1446                assert_eq!(content.content_differs_from_disk, Some(false));
1447                assert_eq!(display_path, "CLAUDE.md");
1448            }
1449            other => panic!("expected NestedMemory, got {other:?}"),
1450        }
1451    }
1452
1453    #[test]
1454    fn assistant_content_block_unknown_variant() {
1455        let json = r#"{"type":"future_modality","data":"foo"}"#;
1456        let v: AssistantContentBlock = serde_json::from_str(json).unwrap();
1457        assert!(matches!(v, AssistantContentBlock::Unknown));
1458    }
1459
1460    #[test]
1461    fn user_content_block_unknown_variant() {
1462        let json = r#"{"type":"video","url":"https://example.com"}"#;
1463        let v: UserContentBlock = serde_json::from_str(json).unwrap();
1464        assert!(matches!(v, UserContentBlock::Unknown));
1465    }
1466
1467    #[test]
1468    fn image_source_unknown_variant() {
1469        let json = r#"{"type":"s3_bucket","key":"foo"}"#;
1470        let v: ImageSource = serde_json::from_str(json).unwrap();
1471        assert!(matches!(v, ImageSource::Unknown));
1472    }
1473
1474    #[test]
1475    fn document_source_unknown_variant() {
1476        let json = r#"{"type":"pdf","data":"base64data"}"#;
1477        let v: DocumentSource = serde_json::from_str(json).unwrap();
1478        assert!(matches!(v, DocumentSource::Unknown));
1479    }
1480
1481    // Verify known variants still parse correctly after adding Unknown.
1482    #[test]
1483    fn attachment_data_known_variant_unaffected() {
1484        let json = r#"{"type":"date_change","newDate":"2024-01-01"}"#;
1485        let v: AttachmentData = serde_json::from_str(json).unwrap();
1486        assert!(matches!(v, AttachmentData::DateChange { .. }));
1487    }
1488
1489    #[test]
1490    fn assistant_content_block_known_variant_unaffected() {
1491        let json = r#"{"type":"text","text":"hello"}"#;
1492        let v: AssistantContentBlock = serde_json::from_str(json).unwrap();
1493        assert!(matches!(v, AssistantContentBlock::Text { .. }));
1494    }
1495
1496    // ── New robustness tests (RED phase) ─────────────────────────────────
1497
1498    /// UserContent is untagged; a JSON object (neither string nor array)
1499    /// must not fail — should fall through to an Other/Value catch-all.
1500    #[test]
1501    fn user_content_unknown_shape_does_not_fail() {
1502        let json = r#"{"type":"future_format","data":42}"#;
1503        let v: UserContent = serde_json::from_str(json).unwrap();
1504        assert!(matches!(v, UserContent::Other(_)));
1505    }
1506
1507    /// ServerToolUse with a missing field (e.g. if Anthropic removes one)
1508    /// must deserialize successfully with a default of 0.
1509    #[test]
1510    fn server_tool_use_missing_field_uses_default() {
1511        let json = r#"{"web_search_requests":3}"#;
1512        let v: ServerToolUse = serde_json::from_str(json).unwrap();
1513        assert_eq!(v.web_search_requests, 3);
1514        assert_eq!(v.web_fetch_requests, 0);
1515    }
1516
1517    /// A new / unrecognised UserRole value must parse as Unknown.
1518    #[test]
1519    fn user_role_unknown_value_does_not_fail() {
1520        let json = r#""operator""#;
1521        let v: UserRole = serde_json::from_str(json).unwrap();
1522        assert!(matches!(v, UserRole::Unknown));
1523    }
1524
1525    /// A new / unrecognised AssistantRole value must parse as Unknown.
1526    #[test]
1527    fn assistant_role_unknown_value_does_not_fail() {
1528        let json = r#""system_agent""#;
1529        let v: AssistantRole = serde_json::from_str(json).unwrap();
1530        assert!(matches!(v, AssistantRole::Unknown));
1531    }
1532
1533    /// A new / unrecognised SessionMode value must parse as Unknown.
1534    #[test]
1535    fn session_mode_unknown_value_does_not_fail() {
1536        let json = r#""background""#;
1537        let v: SessionMode = serde_json::from_str(json).unwrap();
1538        assert!(matches!(v, SessionMode::Unknown));
1539    }
1540
1541    /// AssistantMessage without a "model" field (e.g. API error responses)
1542    /// must not fail deserialization.
1543    #[test]
1544    fn assistant_message_missing_model_uses_default() {
1545        let json = r#"{
1546            "id": "msg_err1",
1547            "type": "message",
1548            "role": "assistant",
1549            "content": [],
1550            "stop_reason": "error",
1551            "stop_sequence": null,
1552            "usage": {"input_tokens": 0, "output_tokens": 0}
1553        }"#;
1554        let v: AssistantMessage = serde_json::from_str(json).unwrap();
1555        assert!(v.model.is_none());
1556    }
1557
1558    /// AssistantDiagnostics round-trips with cache_miss_reason and an
1559    /// optional cache_missed_input_tokens count.
1560    #[test]
1561    fn assistant_diagnostics_round_trip_with_tokens() {
1562        let json =
1563            r#"{"cache_miss_reason":{"type":"tools_changed","cache_missed_input_tokens":41735}}"#;
1564        let v: AssistantDiagnostics = serde_json::from_str(json).unwrap();
1565        let back = serde_json::to_string(&v).unwrap();
1566        assert_eq!(back, json);
1567    }
1568
1569    /// cache_missed_input_tokens is optional — absent on `unavailable` /
1570    /// `previous_message_not_found` variants seen in real transcripts.
1571    #[test]
1572    fn assistant_diagnostics_round_trip_without_tokens() {
1573        let json = r#"{"cache_miss_reason":{"type":"unavailable"}}"#;
1574        let v: AssistantDiagnostics = serde_json::from_str(json).unwrap();
1575        let back = serde_json::to_string(&v).unwrap();
1576        assert_eq!(back, json);
1577    }
1578
1579    /// Unknown cache_miss_reason.type values must pass through as-is, not error.
1580    /// (We intentionally keep `kind` as String, not an enum, to preserve the
1581    /// raw string for the ingest column.)
1582    #[test]
1583    fn assistant_diagnostics_unknown_kind_passes_through() {
1584        let json = r#"{"cache_miss_reason":{"type":"future_reason_not_yet_seen"}}"#;
1585        let v: AssistantDiagnostics = serde_json::from_str(json).unwrap();
1586        let cmr = v.cache_miss_reason.as_ref().expect("cache_miss_reason set");
1587        assert_eq!(cmr.kind, "future_reason_not_yet_seen");
1588    }
1589
1590    /// AssistantMessage with `diagnostics` populated round-trips both
1591    /// keys (cache_miss_reason + cache_missed_input_tokens) intact.
1592    #[test]
1593    fn assistant_message_with_diagnostics_round_trip() {
1594        let json = r#"{"id":"msg_dx","type":"message","role":"assistant","model":"claude-opus-4-7","content":[],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1},"diagnostics":{"cache_miss_reason":{"type":"system_changed","cache_missed_input_tokens":33656}}}"#;
1595        let v: AssistantMessage = serde_json::from_str(json).unwrap();
1596        let back = serde_json::to_string(&v).unwrap();
1597        assert_eq!(back, json);
1598    }
1599
1600    /// `diagnostics: null` (field present, value JSON null) must round-trip
1601    /// as null — not be dropped. Most assistant turns in the wild have this shape.
1602    #[test]
1603    fn assistant_message_with_null_diagnostics_round_trips_as_null() {
1604        let json = r#"{"id":"msg_dn","type":"message","role":"assistant","model":"claude-opus-4-7","content":[],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1},"diagnostics":null}"#;
1605        let v: AssistantMessage = serde_json::from_str(json).unwrap();
1606        let back = serde_json::to_string(&v).unwrap();
1607        assert_eq!(back, json);
1608    }
1609
1610    /// `diagnostics` absent must deserialize cleanly and not re-emit the key.
1611    /// All older transcripts (pre-2026-05-05 shape change) lack the field
1612    /// entirely, so this is the largest population in the wild.
1613    #[test]
1614    fn assistant_message_without_diagnostics_omits_field() {
1615        let json = r#"{"id":"msg_da","type":"message","role":"assistant","model":"claude-opus-4-7","content":[],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}"#;
1616        let v: AssistantMessage = serde_json::from_str(json).unwrap();
1617        assert!(
1618            v.diagnostics.is_none(),
1619            "outer Option should be None when key absent"
1620        );
1621        let back = serde_json::to_string(&v).unwrap();
1622        assert_eq!(back, json, "absent field must not re-emit as null");
1623    }
1624
1625    #[test]
1626    fn assistant_entry_round_trip_with_attribution_and_diagnostics() {
1627        let json = r#"{"uuid":"a1","parentUuid":null,"isSidechain":true,"sessionId":"s1","timestamp":"2026-05-05T00:00:00.000Z","type":"assistant","attributionAgent":"plugin1:agent1","attributionPlugin":"plugin1","message":{"id":"msg1","type":"message","role":"assistant","model":"claude-opus-4-7","content":[],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1},"diagnostics":{"cache_miss_reason":{"type":"messages_changed","cache_missed_input_tokens":204}}}}"#;
1628        let v: Entry = serde_json::from_str(json).unwrap();
1629        let back = serde_json::to_string(&v).unwrap();
1630
1631        let original: serde_json::Value = serde_json::from_str(json).unwrap();
1632        let roundtripped: serde_json::Value = serde_json::from_str(&back).unwrap();
1633        assert_eq!(roundtripped, original);
1634    }
1635
1636    #[test]
1637    fn assistant_entry_round_trip_with_attribution_skill() {
1638        // Note: `attributionSkill` value is opaque (user-specific plugin slug).
1639        // Test fixture uses neutral placeholders — values are not part of the schema contract.
1640        let json = r#"{"uuid":"a2","parentUuid":null,"isSidechain":true,"sessionId":"s1","timestamp":"2026-05-05T00:00:00.000Z","type":"assistant","attributionAgent":"plugin1:agent1","attributionPlugin":"plugin1","attributionSkill":"plugin1:skill1","message":{"id":"msg1","type":"message","role":"assistant","model":"claude-opus-4-7","content":[],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}"#;
1641        let v: Entry = serde_json::from_str(json).unwrap();
1642        let back = serde_json::to_string(&v).unwrap();
1643
1644        let original: serde_json::Value = serde_json::from_str(json).unwrap();
1645        let roundtripped: serde_json::Value = serde_json::from_str(&back).unwrap();
1646        assert_eq!(roundtripped, original);
1647    }
1648
1649    /// `apiErrorStatus` on assistant turn round-trips as a u16. Field is
1650    /// populated alongside `error` / `isApiErrorMessage` on failed turns.
1651    #[test]
1652    fn assistant_entry_round_trip_with_api_error_status() {
1653        let json = r#"{"uuid":"a4","parentUuid":null,"isSidechain":false,"sessionId":"s1","timestamp":"2026-05-05T00:00:00.000Z","type":"assistant","isApiErrorMessage":true,"error":"rate limit","apiErrorStatus":429,"message":{"id":"msg1","type":"message","role":"assistant","model":"claude-opus-4-7","content":[],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}"#;
1654        let v: Entry = serde_json::from_str(json).unwrap();
1655        let back = serde_json::to_string(&v).unwrap();
1656
1657        let original: serde_json::Value = serde_json::from_str(json).unwrap();
1658        let roundtripped: serde_json::Value = serde_json::from_str(&back).unwrap();
1659        assert_eq!(roundtripped, original);
1660    }
1661
1662    /// `plan_file_reference` attachment round-trips with planFilePath
1663    /// and planContent.
1664    #[test]
1665    fn attachment_plan_file_reference_round_trips() {
1666        let json = r##"{"uuid":"a8","parentUuid":null,"isSidechain":false,"sessionId":"s1","timestamp":"2026-05-05T00:00:00.000Z","type":"attachment","attachment":{"type":"plan_file_reference","planFilePath":"/tmp/plan.md","planContent":"# Plan body"}}"##;
1667        let v: Entry = serde_json::from_str(json).unwrap();
1668        let back = serde_json::to_string(&v).unwrap();
1669        let original: serde_json::Value = serde_json::from_str(json).unwrap();
1670        let roundtripped: serde_json::Value = serde_json::from_str(&back).unwrap();
1671        assert_eq!(roundtripped, original);
1672    }
1673
1674    /// `deferred_tools_delta` with `readdedNames` round-trips. Also covers
1675    /// the omit-when-absent case implicitly via prior tests.
1676    #[test]
1677    fn attachment_deferred_tools_delta_with_readded_names_round_trips() {
1678        let json = r#"{"uuid":"a7","parentUuid":null,"isSidechain":false,"sessionId":"s1","timestamp":"2026-05-05T00:00:00.000Z","type":"attachment","attachment":{"type":"deferred_tools_delta","addedNames":["A"],"addedLines":["- A: foo"],"removedNames":["B"],"readdedNames":["C"]}}"#;
1679        let v: Entry = serde_json::from_str(json).unwrap();
1680        let back = serde_json::to_string(&v).unwrap();
1681        let original: serde_json::Value = serde_json::from_str(json).unwrap();
1682        let roundtripped: serde_json::Value = serde_json::from_str(&back).unwrap();
1683        assert_eq!(roundtripped, original);
1684    }
1685
1686    /// `auto_mode` and `auto_mode_exit` attachment variants round-trip.
1687    /// Sibling shape of `plan_mode` / `plan_mode_exit`.
1688    #[test]
1689    fn attachment_auto_mode_round_trips() {
1690        let json = r#"{"uuid":"a5","parentUuid":null,"isSidechain":false,"sessionId":"s1","timestamp":"2026-05-05T00:00:00.000Z","type":"attachment","attachment":{"type":"auto_mode","reminderType":"full"}}"#;
1691        let v: Entry = serde_json::from_str(json).unwrap();
1692        let back = serde_json::to_string(&v).unwrap();
1693        let original: serde_json::Value = serde_json::from_str(json).unwrap();
1694        let roundtripped: serde_json::Value = serde_json::from_str(&back).unwrap();
1695        assert_eq!(roundtripped, original);
1696    }
1697
1698    #[test]
1699    fn attachment_auto_mode_exit_round_trips() {
1700        let json = r#"{"uuid":"a6","parentUuid":null,"isSidechain":false,"sessionId":"s1","timestamp":"2026-05-05T00:00:00.000Z","type":"attachment","attachment":{"type":"auto_mode_exit"}}"#;
1701        let v: Entry = serde_json::from_str(json).unwrap();
1702        let back = serde_json::to_string(&v).unwrap();
1703        let original: serde_json::Value = serde_json::from_str(json).unwrap();
1704        let roundtripped: serde_json::Value = serde_json::from_str(&back).unwrap();
1705        assert_eq!(roundtripped, original);
1706    }
1707
1708    /// `agent_listing_delta` attachment round-trips with all five fields.
1709    /// Regression test for the 2026-05-05 attachment shape change — prior
1710    /// code dropped this variant into `Unknown`, losing addedTypes/addedLines
1711    /// /removedTypes/isInitial/showConcurrencyNote.
1712    #[test]
1713    fn attachment_agent_listing_delta_round_trips() {
1714        let json = r#"{"uuid":"a3","parentUuid":null,"isSidechain":false,"sessionId":"s1","timestamp":"2026-05-05T00:00:00.000Z","type":"attachment","attachment":{"type":"agent_listing_delta","addedTypes":["Explore","plugin1:agent1"],"addedLines":["- Explore: Fast read-only search","- plugin1:agent1: example"],"removedTypes":[],"isInitial":true,"showConcurrencyNote":true}}"#;
1715        let v: Entry = serde_json::from_str(json).unwrap();
1716        let back = serde_json::to_string(&v).unwrap();
1717
1718        let original: serde_json::Value = serde_json::from_str(json).unwrap();
1719        let roundtripped: serde_json::Value = serde_json::from_str(&back).unwrap();
1720        assert_eq!(roundtripped, original);
1721    }
1722}
1723
1724#[cfg(test)]
1725mod last_prompt_tests {
1726    use super::*;
1727
1728    fn parse(line: &str) -> Entry {
1729        serde_json::from_str::<Entry>(line).expect("parse")
1730    }
1731
1732    #[test]
1733    fn last_prompt_old_format_inline_text() {
1734        let e = parse(r#"{"type":"last-prompt","lastPrompt":"hello world","sessionId":"S"}"#);
1735        match e {
1736            Entry::LastPrompt(x) => {
1737                assert_eq!(x.last_prompt.as_deref(), Some("hello world"));
1738                assert_eq!(x.leaf_uuid, None);
1739                assert_eq!(x.session_id, "S");
1740            }
1741            other => panic!("wrong variant: {other:?}"),
1742        }
1743    }
1744
1745    #[test]
1746    fn last_prompt_new_format_leaf_uuid_only() {
1747        let e = parse(r#"{"type":"last-prompt","leafUuid":"u1","sessionId":"S"}"#);
1748        match e {
1749            Entry::LastPrompt(x) => {
1750                assert_eq!(x.last_prompt, None);
1751                assert_eq!(x.leaf_uuid.as_deref(), Some("u1"));
1752                assert_eq!(x.session_id, "S");
1753            }
1754            other => panic!("wrong variant: {other:?}"),
1755        }
1756    }
1757
1758    #[test]
1759    fn last_prompt_hypothetical_both_fields() {
1760        let e = parse(
1761            r#"{"type":"last-prompt","lastPrompt":"inline","leafUuid":"u2","sessionId":"S"}"#,
1762        );
1763        match e {
1764            Entry::LastPrompt(x) => {
1765                assert_eq!(x.last_prompt.as_deref(), Some("inline"));
1766                assert_eq!(x.leaf_uuid.as_deref(), Some("u2"));
1767                assert_eq!(x.session_id, "S");
1768            }
1769            other => panic!("wrong variant: {other:?}"),
1770        }
1771    }
1772
1773    #[test]
1774    fn last_prompt_hypothetical_neither_field() {
1775        let e = parse(r#"{"type":"last-prompt","sessionId":"S"}"#);
1776        match e {
1777            Entry::LastPrompt(x) => {
1778                assert_eq!(x.last_prompt, None);
1779                assert_eq!(x.leaf_uuid, None);
1780                assert_eq!(x.session_id, "S");
1781            }
1782            other => panic!("wrong variant: {other:?}"),
1783        }
1784    }
1785}