Skip to main content

toolpath_opencode/
types.rs

1//! On-disk schema for opencode SQLite databases.
2//!
3//! opencode stores everything in `opencode.db`. Most tables are
4//! simple scalar rows, but `message.data` and `part.data` are JSON
5//! blobs that discriminate on `role` and `type` respectively. These
6//! types mirror the upstream Zod schemas in
7//! [`packages/opencode/src/session/message-v2.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/message-v2.ts)
8//! closely enough for fidelity, but use `Value` fallbacks on enum
9//! variants we don't exhaustively enumerate so unknown future
10//! payloads survive round-trip.
11
12use chrono::{DateTime, TimeZone, Utc};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::HashMap;
16use std::path::PathBuf;
17
18// ── Project ─────────────────────────────────────────────────────────
19
20/// A `project` row. `id` is the SHA of the repo's first root commit.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Project {
23    pub id: String,
24    pub worktree: PathBuf,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub vcs: Option<String>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub name: Option<String>,
29    pub time_created: i64,
30    pub time_updated: i64,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub time_initialized: Option<i64>,
33    /// JSON `string[]` — sandbox paths.
34    #[serde(default)]
35    pub sandboxes: Vec<String>,
36}
37
38// ── Session ─────────────────────────────────────────────────────────
39
40/// A `session` row — one opencode conversation.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Session {
43    pub id: String,
44    pub project_id: String,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub workspace_id: Option<String>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub parent_id: Option<String>,
49    pub slug: String,
50    pub directory: PathBuf,
51    pub title: String,
52    pub version: String,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub share_url: Option<String>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub summary_additions: Option<i64>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub summary_deletions: Option<i64>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub summary_files: Option<i64>,
61    pub time_created: i64,
62    pub time_updated: i64,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub time_compacting: Option<i64>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub time_archived: Option<i64>,
67
68    /// Ordered messages, populated by the reader. Empty until
69    /// `read_session` is called.
70    #[serde(default)]
71    pub messages: Vec<Message>,
72}
73
74impl Session {
75    pub fn started_at(&self) -> Option<DateTime<Utc>> {
76        Utc.timestamp_millis_opt(self.time_created).single()
77    }
78    pub fn last_activity(&self) -> Option<DateTime<Utc>> {
79        Utc.timestamp_millis_opt(self.time_updated).single()
80    }
81
82    /// First user-message text across the conversation. Rides over
83    /// any system/summary messages at the head.
84    pub fn first_user_text(&self) -> Option<String> {
85        for msg in &self.messages {
86            if let MessageData::User(_) = &msg.data {
87                for part in &msg.parts {
88                    if let PartData::Text(t) = &part.data
89                        && !t.text.is_empty()
90                    {
91                        return Some(t.text.clone());
92                    }
93                }
94            }
95        }
96        None
97    }
98}
99
100/// Lightweight metadata (no message bodies).
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SessionMetadata {
103    pub id: String,
104    pub project_id: String,
105    pub directory: PathBuf,
106    pub title: String,
107    pub version: String,
108    pub started_at: Option<DateTime<Utc>>,
109    pub last_activity: Option<DateTime<Utc>>,
110    pub message_count: usize,
111    pub first_user_message: Option<String>,
112    pub summary_additions: Option<i64>,
113    pub summary_deletions: Option<i64>,
114    pub summary_files: Option<i64>,
115}
116
117// ── Message ────────────────────────────────────────────────────────
118
119/// A `message` row plus its associated parts, ordered.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Message {
122    pub id: String,
123    pub session_id: String,
124    pub time_created: i64,
125    pub time_updated: i64,
126    pub data: MessageData,
127    #[serde(default)]
128    pub parts: Vec<Part>,
129}
130
131/// `message.data` — discriminated on `role`.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(tag = "role", rename_all = "lowercase")]
134pub enum MessageData {
135    User(UserMessage),
136    Assistant(AssistantMessage),
137    /// Catch-all for future roles.
138    #[serde(other)]
139    Other,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct UserMessage {
144    pub time: MessageTime,
145    #[serde(default)]
146    pub agent: String,
147    pub model: ModelRef,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub format: Option<Value>,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub summary: Option<UserSummary>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub system: Option<String>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub tools: Option<HashMap<String, bool>>,
156    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
157    pub extra: HashMap<String, Value>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct UserSummary {
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub title: Option<String>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub body: Option<String>,
166    #[serde(default)]
167    pub diffs: Vec<Value>,
168    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
169    pub extra: HashMap<String, Value>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct AssistantMessage {
174    #[serde(rename = "parentID")]
175    pub parent_id: String,
176    pub time: MessageTime,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub error: Option<Value>,
179    #[serde(default)]
180    pub agent: String,
181    /// Deprecated in upstream, still on old rows.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub mode: Option<String>,
184    #[serde(rename = "modelID", default)]
185    pub model_id: String,
186    #[serde(rename = "providerID", default)]
187    pub provider_id: String,
188    pub path: MessagePath,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub summary: Option<bool>,
191    #[serde(default)]
192    pub cost: f64,
193    #[serde(default)]
194    pub tokens: Tokens,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub structured: Option<Value>,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub variant: Option<String>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub finish: Option<String>,
201    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
202    pub extra: HashMap<String, Value>,
203}
204
205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206pub struct MessageTime {
207    pub created: i64,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub completed: Option<i64>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct ModelRef {
214    #[serde(rename = "providerID")]
215    pub provider_id: String,
216    #[serde(rename = "modelID")]
217    pub model_id: String,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub variant: Option<String>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct MessagePath {
224    pub cwd: PathBuf,
225    pub root: PathBuf,
226}
227
228#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229pub struct Tokens {
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub total: Option<u64>,
232    #[serde(default)]
233    pub input: u64,
234    #[serde(default)]
235    pub output: u64,
236    #[serde(default)]
237    pub reasoning: u64,
238    #[serde(default)]
239    pub cache: TokenCache,
240}
241
242#[derive(Debug, Clone, Default, Serialize, Deserialize)]
243pub struct TokenCache {
244    #[serde(default)]
245    pub read: u64,
246    #[serde(default)]
247    pub write: u64,
248}
249
250// ── Part ───────────────────────────────────────────────────────────
251
252/// A `part` row. The `data` column is the typed variant payload.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct Part {
255    pub id: String,
256    pub message_id: String,
257    pub session_id: String,
258    pub time_created: i64,
259    pub time_updated: i64,
260    pub data: PartData,
261}
262
263/// `part.data` — discriminated on `type`.
264///
265/// Unknown variants land in [`PartData::Unknown`] so a future
266/// opencode release that adds a new part type still round-trips.
267#[derive(Debug, Clone, Serialize, Deserialize)]
268#[serde(tag = "type", rename_all = "kebab-case")]
269pub enum PartData {
270    Text(TextPart),
271    Reasoning(ReasoningPart),
272    Tool(ToolPart),
273    File(FilePart),
274    Agent(AgentPart),
275    Subtask(SubtaskPart),
276    Retry(RetryPart),
277    Compaction(CompactionPart),
278    StepStart(StepStartPart),
279    StepFinish(StepFinishPart),
280    Snapshot(SnapshotPart),
281    Patch(PatchPart),
282    /// Catch-all for future variants. The `type` tag and payload are
283    /// preserved in the typed layer only when the round-trip deserializer
284    /// records them; `serde(other)` discards the tag string itself.
285    #[serde(other)]
286    Unknown,
287}
288
289impl PartData {
290    /// The upstream `type` tag value.
291    pub fn kind(&self) -> &'static str {
292        match self {
293            PartData::Text(_) => "text",
294            PartData::Reasoning(_) => "reasoning",
295            PartData::Tool(_) => "tool",
296            PartData::File(_) => "file",
297            PartData::Agent(_) => "agent",
298            PartData::Subtask(_) => "subtask",
299            PartData::Retry(_) => "retry",
300            PartData::Compaction(_) => "compaction",
301            PartData::StepStart(_) => "step-start",
302            PartData::StepFinish(_) => "step-finish",
303            PartData::Snapshot(_) => "snapshot",
304            PartData::Patch(_) => "patch",
305            PartData::Unknown => "unknown",
306        }
307    }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct TextPart {
312    #[serde(default)]
313    pub text: String,
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub synthetic: Option<bool>,
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub ignored: Option<bool>,
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub time: Option<TimeRange>,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub metadata: Option<Value>,
322    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
323    pub extra: HashMap<String, Value>,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct ReasoningPart {
328    #[serde(default)]
329    pub text: String,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub time: Option<TimeRange>,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub metadata: Option<Value>,
334    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
335    pub extra: HashMap<String, Value>,
336}
337
338#[derive(Debug, Clone, Default, Serialize, Deserialize)]
339pub struct TimeRange {
340    pub start: i64,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub end: Option<i64>,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct ToolPart {
347    pub tool: String,
348    #[serde(rename = "callID")]
349    pub call_id: String,
350    pub state: ToolState,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub metadata: Option<Value>,
353    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
354    pub extra: HashMap<String, Value>,
355}
356
357/// Tool-call execution state. Discriminated on `status`.
358#[derive(Debug, Clone, Serialize, Deserialize)]
359#[serde(tag = "status", rename_all = "lowercase")]
360pub enum ToolState {
361    Pending(ToolStatePending),
362    Running(ToolStateRunning),
363    Completed(ToolStateCompleted),
364    Error(ToolStateError),
365    /// Unknown future status. The raw payload is preserved only
366    /// at the JSON layer; this variant carries no fields.
367    #[serde(other)]
368    Unknown,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct ToolStatePending {
373    #[serde(default)]
374    pub input: Value,
375    #[serde(default)]
376    pub raw: String,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct ToolStateRunning {
381    #[serde(default)]
382    pub input: Value,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub title: Option<String>,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub metadata: Option<Value>,
387    pub time: ToolStartTime,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct ToolStartTime {
392    pub start: i64,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct ToolStateCompleted {
397    #[serde(default)]
398    pub input: Value,
399    #[serde(default)]
400    pub output: String,
401    #[serde(default)]
402    pub title: String,
403    #[serde(default)]
404    pub metadata: Value,
405    pub time: ToolRunTime,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub attachments: Option<Vec<Value>>,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct ToolRunTime {
412    pub start: i64,
413    pub end: i64,
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub compacted: Option<i64>,
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct ToolStateError {
420    #[serde(default)]
421    pub input: Value,
422    #[serde(default)]
423    pub error: String,
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub metadata: Option<Value>,
426    pub time: ToolRunTime,
427}
428
429impl ToolState {
430    /// Extract the common `input` field regardless of status variant.
431    pub fn input(&self) -> Option<&Value> {
432        match self {
433            ToolState::Pending(p) => Some(&p.input),
434            ToolState::Running(r) => Some(&r.input),
435            ToolState::Completed(c) => Some(&c.input),
436            ToolState::Error(e) => Some(&e.input),
437            ToolState::Unknown => None,
438        }
439    }
440
441    pub fn output(&self) -> Option<&str> {
442        match self {
443            ToolState::Completed(c) => Some(&c.output),
444            _ => None,
445        }
446    }
447
448    pub fn error(&self) -> Option<&str> {
449        match self {
450            ToolState::Error(e) => Some(&e.error),
451            _ => None,
452        }
453    }
454
455    pub fn is_error(&self) -> bool {
456        matches!(self, ToolState::Error(_))
457    }
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct FilePart {
462    pub mime: String,
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub filename: Option<String>,
465    pub url: String,
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub source: Option<Value>,
468    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
469    pub extra: HashMap<String, Value>,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct AgentPart {
474    pub name: String,
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub source: Option<Value>,
477    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
478    pub extra: HashMap<String, Value>,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct SubtaskPart {
483    pub prompt: String,
484    pub description: String,
485    pub agent: String,
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub model: Option<ModelRef>,
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub command: Option<String>,
490    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
491    pub extra: HashMap<String, Value>,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct RetryPart {
496    pub attempt: u32,
497    pub error: Value,
498    pub time: RetryTime,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct RetryTime {
503    pub created: i64,
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct CompactionPart {
508    pub auto: bool,
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub overflow: Option<bool>,
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub tail_start_id: Option<String>,
513    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
514    pub extra: HashMap<String, Value>,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct StepStartPart {
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub snapshot: Option<String>,
521    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
522    pub extra: HashMap<String, Value>,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct StepFinishPart {
527    pub reason: String,
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub snapshot: Option<String>,
530    #[serde(default)]
531    pub cost: f64,
532    #[serde(default)]
533    pub tokens: Tokens,
534    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
535    pub extra: HashMap<String, Value>,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct SnapshotPart {
540    pub snapshot: String,
541    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
542    pub extra: HashMap<String, Value>,
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct PatchPart {
547    pub hash: String,
548    #[serde(default)]
549    pub files: Vec<String>,
550    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
551    pub extra: HashMap<String, Value>,
552}
553
554// ── Tests ──────────────────────────────────────────────────────────
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn parse_user_message() {
562        let raw = r#"{"role":"user","time":{"created":1776792838512},"agent":"build","model":{"providerID":"opencode","modelID":"big-pickle"},"summary":{"diffs":[]}}"#;
563        let md: MessageData = serde_json::from_str(raw).unwrap();
564        match md {
565            MessageData::User(u) => {
566                assert_eq!(u.agent, "build");
567                assert_eq!(u.model.provider_id, "opencode");
568                assert_eq!(u.model.model_id, "big-pickle");
569                assert_eq!(u.time.created, 1776792838512);
570            }
571            _ => panic!("expected User"),
572        }
573    }
574
575    #[test]
576    fn parse_assistant_message() {
577        let raw = r#"{"parentID":"msg_x","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/p","root":"/p"},"cost":0.0,"tokens":{"total":10,"input":5,"output":3,"reasoning":2,"cache":{"read":1,"write":0}},"modelID":"m","providerID":"p","time":{"created":1,"completed":2},"finish":"stop"}"#;
578        let md: MessageData = serde_json::from_str(raw).unwrap();
579        match md {
580            MessageData::Assistant(a) => {
581                assert_eq!(a.parent_id, "msg_x");
582                assert_eq!(a.model_id, "m");
583                assert_eq!(a.provider_id, "p");
584                assert_eq!(a.finish.as_deref(), Some("stop"));
585                assert_eq!(a.tokens.input, 5);
586                assert_eq!(a.tokens.cache.read, 1);
587            }
588            _ => panic!("expected Assistant"),
589        }
590    }
591
592    #[test]
593    fn unknown_role_roundtrips_to_other() {
594        let raw = r#"{"role":"system","text":"hi"}"#;
595        let md: MessageData = serde_json::from_str(raw).unwrap();
596        assert!(matches!(md, MessageData::Other));
597    }
598
599    #[test]
600    fn parse_text_part() {
601        let raw = r#"{"type":"text","text":"hello"}"#;
602        let pd: PartData = serde_json::from_str(raw).unwrap();
603        match pd {
604            PartData::Text(t) => assert_eq!(t.text, "hello"),
605            _ => panic!("expected Text"),
606        }
607    }
608
609    #[test]
610    fn parse_reasoning_part() {
611        let raw = r#"{"type":"reasoning","text":"thinking","time":{"start":1,"end":2},"metadata":{"anthropic":{"signature":"abc"}}}"#;
612        let pd: PartData = serde_json::from_str(raw).unwrap();
613        match pd {
614            PartData::Reasoning(r) => {
615                assert_eq!(r.text, "thinking");
616                assert_eq!(r.time.as_ref().unwrap().start, 1);
617                assert_eq!(r.time.as_ref().unwrap().end, Some(2));
618            }
619            _ => panic!("expected Reasoning"),
620        }
621    }
622
623    #[test]
624    fn parse_tool_part_completed() {
625        let raw = r#"{"type":"tool","tool":"bash","callID":"c1","state":{"status":"completed","input":{"command":"ls"},"output":"a\nb\n","title":"List","metadata":{"exit":0},"time":{"start":1,"end":2}}}"#;
626        let pd: PartData = serde_json::from_str(raw).unwrap();
627        match pd {
628            PartData::Tool(t) => {
629                assert_eq!(t.tool, "bash");
630                assert_eq!(t.call_id, "c1");
631                match &t.state {
632                    ToolState::Completed(c) => {
633                        assert_eq!(c.output, "a\nb\n");
634                        assert_eq!(c.title, "List");
635                        assert_eq!(c.metadata["exit"], 0);
636                    }
637                    _ => panic!("expected Completed"),
638                }
639            }
640            _ => panic!("expected Tool"),
641        }
642    }
643
644    #[test]
645    fn parse_tool_part_error() {
646        let raw = r#"{"type":"tool","tool":"bash","callID":"c","state":{"status":"error","input":{"command":"false"},"error":"exit 1","time":{"start":1,"end":2}}}"#;
647        let pd: PartData = serde_json::from_str(raw).unwrap();
648        match pd {
649            PartData::Tool(t) => match &t.state {
650                ToolState::Error(e) => {
651                    assert_eq!(e.error, "exit 1");
652                }
653                _ => panic!("expected Error"),
654            },
655            _ => panic!("expected Tool"),
656        }
657    }
658
659    #[test]
660    fn parse_step_parts() {
661        let raw_start = r#"{"type":"step-start","snapshot":"abc"}"#;
662        let pd: PartData = serde_json::from_str(raw_start).unwrap();
663        assert!(matches!(pd, PartData::StepStart(_)));
664
665        let raw_finish = r#"{"type":"step-finish","reason":"stop","snapshot":"abc","tokens":{"input":1,"output":2,"reasoning":0,"cache":{"read":0,"write":0}},"cost":0.001}"#;
666        let pd: PartData = serde_json::from_str(raw_finish).unwrap();
667        match pd {
668            PartData::StepFinish(s) => {
669                assert_eq!(s.reason, "stop");
670                assert_eq!(s.snapshot.as_deref(), Some("abc"));
671            }
672            _ => panic!("expected StepFinish"),
673        }
674    }
675
676    #[test]
677    fn parse_unknown_part_type() {
678        let raw = r#"{"type":"future-thing","foo":"bar"}"#;
679        let pd: PartData = serde_json::from_str(raw).unwrap();
680        assert!(matches!(pd, PartData::Unknown));
681        assert_eq!(pd.kind(), "unknown");
682    }
683
684    #[test]
685    fn parse_compaction_and_retry() {
686        let c: PartData =
687            serde_json::from_str(r#"{"type":"compaction","auto":true,"overflow":false}"#).unwrap();
688        assert!(matches!(c, PartData::Compaction(_)));
689
690        let r: PartData = serde_json::from_str(
691            r#"{"type":"retry","attempt":1,"error":{"message":"nope"},"time":{"created":1}}"#,
692        )
693        .unwrap();
694        assert!(matches!(r, PartData::Retry(_)));
695    }
696
697    #[test]
698    fn tokens_roundtrip() {
699        let raw = r#"{"total":10,"input":5,"output":3,"reasoning":2,"cache":{"read":1,"write":0}}"#;
700        let t: Tokens = serde_json::from_str(raw).unwrap();
701        let back = serde_json::to_value(&t).unwrap();
702        let orig: serde_json::Value = serde_json::from_str(raw).unwrap();
703        assert_eq!(orig, back);
704    }
705
706    #[test]
707    fn message_extras_survive() {
708        let raw = r#"{"role":"user","time":{"created":1},"agent":"build","model":{"providerID":"p","modelID":"m"},"future_field":"kept"}"#;
709        let md: MessageData = serde_json::from_str(raw).unwrap();
710        match md {
711            MessageData::User(u) => {
712                assert_eq!(
713                    u.extra.get("future_field"),
714                    Some(&serde_json::json!("kept"))
715                );
716            }
717            _ => panic!("expected User"),
718        }
719    }
720}