1use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::collections::HashMap;
20use std::path::PathBuf;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RolloutLine {
29 pub timestamp: String,
30
31 #[serde(rename = "type")]
35 pub kind: String,
36
37 pub payload: Value,
40
41 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
43 pub extra: HashMap<String, Value>,
44}
45
46impl RolloutLine {
47 pub fn item(&self) -> RolloutItem {
50 match self.kind.as_str() {
51 "session_meta" => match serde_json::from_value(self.payload.clone()) {
52 Ok(v) => RolloutItem::SessionMeta(Box::new(v)),
53 Err(_) => RolloutItem::Unknown {
54 kind: self.kind.clone(),
55 payload: self.payload.clone(),
56 },
57 },
58 "turn_context" => match serde_json::from_value(self.payload.clone()) {
59 Ok(v) => RolloutItem::TurnContext(Box::new(v)),
60 Err(_) => RolloutItem::Unknown {
61 kind: self.kind.clone(),
62 payload: self.payload.clone(),
63 },
64 },
65 "response_item" => RolloutItem::ResponseItem(ResponseItem::from_value(&self.payload)),
66 "event_msg" => RolloutItem::EventMsg(EventMsg::from_value(&self.payload)),
67 "session_state" => RolloutItem::SessionState(self.payload.clone()),
68 "compacted" => RolloutItem::Compacted(self.payload.clone()),
69 _ => RolloutItem::Unknown {
70 kind: self.kind.clone(),
71 payload: self.payload.clone(),
72 },
73 }
74 }
75
76 pub fn parsed_timestamp(&self) -> Option<DateTime<Utc>> {
78 self.timestamp.parse::<DateTime<Utc>>().ok()
79 }
80}
81
82#[derive(Debug, Clone)]
84pub enum RolloutItem {
85 SessionMeta(Box<SessionMeta>),
86 TurnContext(Box<TurnContext>),
87 ResponseItem(ResponseItem),
88 EventMsg(EventMsg),
89 SessionState(Value),
90 Compacted(Value),
91 Unknown { kind: String, payload: Value },
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct SessionMeta {
102 pub id: String,
104
105 pub timestamp: String,
108
109 pub cwd: PathBuf,
111
112 pub originator: String,
114
115 pub cli_version: String,
116
117 pub source: String,
119
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub forked_from_id: Option<String>,
123
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub agent_nickname: Option<String>,
126
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub agent_role: Option<String>,
129
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub agent_path: Option<String>,
132
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub model_provider: Option<String>,
135
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub base_instructions: Option<BaseInstructions>,
139
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub dynamic_tools: Option<Value>,
142
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub memory_mode: Option<String>,
145
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub git: Option<GitInfo>,
149
150 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
152 pub extra: HashMap<String, Value>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct BaseInstructions {
157 pub text: String,
158 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
159 pub extra: HashMap<String, Value>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct GitInfo {
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub commit_hash: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub branch: Option<String>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub repository_url: Option<String>,
170 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
171 pub extra: HashMap<String, Value>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct TurnContext {
179 pub turn_id: String,
180 pub cwd: PathBuf,
181
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub current_date: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub timezone: Option<String>,
186
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub approval_policy: Option<String>,
189
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub sandbox_policy: Option<SandboxPolicy>,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub model: Option<String>,
195
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub personality: Option<String>,
198
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub collaboration_mode: Option<Value>,
201
202 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
203 pub extra: HashMap<String, Value>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct SandboxPolicy {
208 #[serde(rename = "type")]
210 pub kind: String,
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub writable_roots: Vec<PathBuf>,
213 #[serde(default)]
214 pub network_access: bool,
215 #[serde(default)]
216 pub exclude_tmpdir_env_var: bool,
217 #[serde(default)]
218 pub exclude_slash_tmp: bool,
219 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
220 pub extra: HashMap<String, Value>,
221}
222
223#[derive(Debug, Clone)]
233pub enum ResponseItem {
234 Message(Message),
235 Reasoning(Reasoning),
236 FunctionCall(FunctionCall),
237 FunctionCallOutput(FunctionCallOutput),
238 CustomToolCall(CustomToolCall),
239 CustomToolCallOutput(CustomToolCallOutput),
240 Other { kind: String, payload: Value },
241}
242
243impl ResponseItem {
244 pub fn from_value(payload: &Value) -> Self {
246 let kind = payload
247 .get("type")
248 .and_then(|t| t.as_str())
249 .unwrap_or("")
250 .to_string();
251 let attempt = match kind.as_str() {
252 "message" => serde_json::from_value::<Message>(payload.clone())
253 .ok()
254 .map(ResponseItem::Message),
255 "reasoning" => serde_json::from_value::<Reasoning>(payload.clone())
256 .ok()
257 .map(ResponseItem::Reasoning),
258 "function_call" => serde_json::from_value::<FunctionCall>(payload.clone())
259 .ok()
260 .map(ResponseItem::FunctionCall),
261 "function_call_output" => serde_json::from_value::<FunctionCallOutput>(payload.clone())
262 .ok()
263 .map(ResponseItem::FunctionCallOutput),
264 "custom_tool_call" => serde_json::from_value::<CustomToolCall>(payload.clone())
265 .ok()
266 .map(ResponseItem::CustomToolCall),
267 "custom_tool_call_output" => {
268 serde_json::from_value::<CustomToolCallOutput>(payload.clone())
269 .ok()
270 .map(ResponseItem::CustomToolCallOutput)
271 }
272 _ => None,
273 };
274 attempt.unwrap_or(ResponseItem::Other {
275 kind,
276 payload: payload.clone(),
277 })
278 }
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct Message {
283 pub role: String,
285
286 pub content: Vec<ContentPart>,
287
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub id: Option<String>,
290
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub end_turn: Option<bool>,
293
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub phase: Option<String>,
297
298 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
299 pub extra: HashMap<String, Value>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
303#[serde(tag = "type", rename_all = "snake_case")]
304pub enum ContentPart {
305 InputText {
306 text: String,
307 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
308 extra: HashMap<String, Value>,
309 },
310 OutputText {
311 text: String,
312 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
313 extra: HashMap<String, Value>,
314 },
315 #[serde(other)]
317 Unknown,
318}
319
320impl ContentPart {
321 pub fn text(&self) -> Option<&str> {
322 match self {
323 ContentPart::InputText { text, .. } | ContentPart::OutputText { text, .. } => {
324 Some(text)
325 }
326 ContentPart::Unknown => None,
327 }
328 }
329}
330
331impl Message {
332 pub fn text(&self) -> String {
334 self.content
335 .iter()
336 .filter_map(|p| p.text())
337 .collect::<Vec<_>>()
338 .join("\n")
339 }
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct Reasoning {
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub id: Option<String>,
346
347 #[serde(default)]
349 pub summary: Vec<Value>,
350
351 #[serde(default)]
353 pub content: Option<Value>,
354
355 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub encrypted_content: Option<String>,
359
360 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
361 pub extra: HashMap<String, Value>,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct FunctionCall {
366 pub name: String,
367
368 pub arguments: String,
372
373 pub call_id: String,
374
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub id: Option<String>,
377
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub namespace: Option<String>,
380
381 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
382 pub extra: HashMap<String, Value>,
383}
384
385impl FunctionCall {
386 pub fn arguments_as_json(&self) -> Value {
388 serde_json::from_str(&self.arguments).unwrap_or(Value::Null)
389 }
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct FunctionCallOutput {
394 pub call_id: String,
395
396 pub output: String,
398
399 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
400 pub extra: HashMap<String, Value>,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct CustomToolCall {
405 pub name: String,
406
407 pub input: String,
410
411 pub call_id: String,
412
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub status: Option<String>,
415
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub id: Option<String>,
418
419 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
420 pub extra: HashMap<String, Value>,
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct CustomToolCallOutput {
425 pub call_id: String,
426
427 pub output: String,
428
429 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
430 pub extra: HashMap<String, Value>,
431}
432
433#[derive(Debug, Clone)]
440pub enum EventMsg {
441 TaskStarted(Value),
442 TaskComplete(Value),
443 UserMessage(Value),
444 AgentMessage(Value),
445 TokenCount(Box<TokenCountEvent>),
446 ExecCommandEnd(Box<ExecCommandEnd>),
447 PatchApplyEnd(Box<PatchApplyEnd>),
448 Other { kind: String, payload: Value },
449}
450
451impl EventMsg {
452 pub fn from_value(payload: &Value) -> Self {
453 let kind = payload
454 .get("type")
455 .and_then(|t| t.as_str())
456 .unwrap_or("")
457 .to_string();
458 let attempt = match kind.as_str() {
459 "task_started" => Some(EventMsg::TaskStarted(payload.clone())),
460 "task_complete" => Some(EventMsg::TaskComplete(payload.clone())),
461 "user_message" => Some(EventMsg::UserMessage(payload.clone())),
462 "agent_message" => Some(EventMsg::AgentMessage(payload.clone())),
463 "token_count" => serde_json::from_value::<TokenCountEvent>(payload.clone())
464 .ok()
465 .map(|v| EventMsg::TokenCount(Box::new(v))),
466 "exec_command_end" => serde_json::from_value::<ExecCommandEnd>(payload.clone())
467 .ok()
468 .map(|v| EventMsg::ExecCommandEnd(Box::new(v))),
469 "patch_apply_end" => serde_json::from_value::<PatchApplyEnd>(payload.clone())
470 .ok()
471 .map(|v| EventMsg::PatchApplyEnd(Box::new(v))),
472 _ => None,
473 };
474 attempt.unwrap_or(EventMsg::Other {
475 kind,
476 payload: payload.clone(),
477 })
478 }
479
480 pub fn kind(&self) -> &str {
482 match self {
483 EventMsg::TaskStarted(_) => "task_started",
484 EventMsg::TaskComplete(_) => "task_complete",
485 EventMsg::UserMessage(_) => "user_message",
486 EventMsg::AgentMessage(_) => "agent_message",
487 EventMsg::TokenCount(_) => "token_count",
488 EventMsg::ExecCommandEnd(_) => "exec_command_end",
489 EventMsg::PatchApplyEnd(_) => "patch_apply_end",
490 EventMsg::Other { kind, .. } => kind.as_str(),
491 }
492 }
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct TokenCountEvent {
497 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub info: Option<TokenCountInfo>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub rate_limits: Option<Value>,
501 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
502 pub extra: HashMap<String, Value>,
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct TokenCountInfo {
507 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub total_token_usage: Option<TokenUsage>,
509 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub last_token_usage: Option<TokenUsage>,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub model_context_window: Option<u32>,
513 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
514 pub extra: HashMap<String, Value>,
515}
516
517#[derive(Debug, Clone, Default, Serialize, Deserialize)]
518pub struct TokenUsage {
519 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub input_tokens: Option<u32>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub cached_input_tokens: Option<u32>,
523 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub output_tokens: Option<u32>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub reasoning_output_tokens: Option<u32>,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub total_tokens: Option<u32>,
529 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
530 pub extra: HashMap<String, Value>,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize)]
534pub struct ExecCommandEnd {
535 pub call_id: String,
536
537 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub process_id: Option<String>,
539
540 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub turn_id: Option<String>,
542
543 pub command: Vec<String>,
544
545 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub cwd: Option<PathBuf>,
547
548 #[serde(default)]
549 pub parsed_cmd: Vec<Value>,
550
551 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub source: Option<String>,
553
554 #[serde(default)]
555 pub stdout: String,
556
557 #[serde(default)]
558 pub stderr: String,
559
560 #[serde(default)]
561 pub aggregated_output: String,
562
563 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub exit_code: Option<i32>,
565
566 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub duration: Option<Value>,
568
569 #[serde(default, skip_serializing_if = "String::is_empty")]
570 pub formatted_output: String,
571
572 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub status: Option<String>,
574
575 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
576 pub extra: HashMap<String, Value>,
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct PatchApplyEnd {
581 pub call_id: String,
582
583 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub turn_id: Option<String>,
585
586 #[serde(default)]
587 pub stdout: String,
588
589 #[serde(default)]
590 pub stderr: String,
591
592 #[serde(default)]
593 pub success: bool,
594
595 #[serde(default)]
597 pub changes: HashMap<String, PatchChange>,
598
599 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
600 pub extra: HashMap<String, Value>,
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
608#[serde(tag = "type", rename_all = "snake_case")]
609pub enum PatchChange {
610 Add {
611 content: String,
612 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
613 extra: HashMap<String, Value>,
614 },
615 Update {
616 unified_diff: String,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
618 move_path: Option<String>,
619 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
620 extra: HashMap<String, Value>,
621 },
622 Delete {
623 #[serde(default, skip_serializing_if = "Option::is_none")]
624 original_content: Option<String>,
625 #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
626 extra: HashMap<String, Value>,
627 },
628 #[serde(other)]
630 Unknown,
631}
632
633#[derive(Debug, Clone)]
638pub struct Session {
639 pub id: String,
640 pub file_path: PathBuf,
641 pub lines: Vec<RolloutLine>,
642}
643
644impl Session {
645 pub fn meta(&self) -> Option<SessionMeta> {
648 self.lines.iter().find_map(|l| match l.item() {
649 RolloutItem::SessionMeta(m) => Some(*m),
650 _ => None,
651 })
652 }
653
654 pub fn items(&self) -> impl Iterator<Item = RolloutItem> + '_ {
656 self.lines.iter().map(|l| l.item())
657 }
658
659 pub fn started_at(&self) -> Option<DateTime<Utc>> {
660 self.lines.iter().filter_map(|l| l.parsed_timestamp()).min()
661 }
662
663 pub fn last_activity(&self) -> Option<DateTime<Utc>> {
664 self.lines.iter().filter_map(|l| l.parsed_timestamp()).max()
665 }
666
667 pub fn first_user_text(&self) -> Option<String> {
677 for line in &self.lines {
678 if line.kind == "event_msg"
679 && line.payload.get("type").and_then(|v| v.as_str()) == Some("user_message")
680 && let Some(msg) = line.payload.get("message").and_then(|v| v.as_str())
681 && !msg.is_empty()
682 {
683 return Some(msg.to_string());
684 }
685 }
686 self.items().find_map(|item| match item {
687 RolloutItem::ResponseItem(ResponseItem::Message(m)) if m.role == "user" => {
688 let t = m.text();
689 if t.is_empty() { None } else { Some(t) }
690 }
691 _ => None,
692 })
693 }
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct SessionMetadata {
699 pub id: String,
700 pub file_path: PathBuf,
701 pub started_at: Option<DateTime<Utc>>,
702 pub last_activity: Option<DateTime<Utc>>,
703 pub cwd: Option<PathBuf>,
704 pub cli_version: Option<String>,
705 pub first_user_message: Option<String>,
706 pub git_branch: Option<String>,
707 pub git_commit: Option<String>,
708 pub line_count: usize,
710}
711
712#[cfg(test)]
715mod tests {
716 use super::*;
717
718 const SAMPLE_META: &str = r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{"id":"019dabc6-8fef-7681-a054-b5bb75fcb97d","timestamp":"2026-04-20T16:43:30.171Z","cwd":"/tmp/proj","originator":"codex-tui","cli_version":"0.118.0","source":"cli","model_provider":"openai","git":{"commit_hash":"abc","branch":"main","repository_url":"git@example:x/y.git"}}}"#;
719
720 #[test]
721 fn rollout_line_parses_session_meta() {
722 let line: RolloutLine = serde_json::from_str(SAMPLE_META).unwrap();
723 assert_eq!(line.kind, "session_meta");
724 match line.item() {
725 RolloutItem::SessionMeta(m) => {
726 assert_eq!(m.id, "019dabc6-8fef-7681-a054-b5bb75fcb97d");
727 assert_eq!(m.cwd.to_str().unwrap(), "/tmp/proj");
728 assert_eq!(m.git.as_ref().unwrap().branch.as_deref(), Some("main"));
729 }
730 _ => panic!("expected SessionMeta"),
731 }
732 }
733
734 #[test]
735 fn rollout_line_preserves_unknown_kind() {
736 let raw = r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"new_future_type","payload":{"foo":"bar"}}"#;
737 let line: RolloutLine = serde_json::from_str(raw).unwrap();
738 match line.item() {
739 RolloutItem::Unknown { kind, payload } => {
740 assert_eq!(kind, "new_future_type");
741 assert_eq!(payload["foo"], "bar");
742 }
743 _ => panic!("expected Unknown"),
744 }
745 }
746
747 #[test]
748 fn response_item_message_variants() {
749 let raw = r#"{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}],"phase":"commentary"}"#;
750 let v: Value = serde_json::from_str(raw).unwrap();
751 match ResponseItem::from_value(&v) {
752 ResponseItem::Message(m) => {
753 assert_eq!(m.role, "assistant");
754 assert_eq!(m.text(), "hello");
755 assert_eq!(m.phase.as_deref(), Some("commentary"));
756 }
757 _ => panic!("expected Message"),
758 }
759 }
760
761 #[test]
762 fn response_item_reasoning_keeps_encrypted_content() {
763 let raw =
764 r#"{"type":"reasoning","summary":[],"content":null,"encrypted_content":"gAAA..."}"#;
765 let v: Value = serde_json::from_str(raw).unwrap();
766 match ResponseItem::from_value(&v) {
767 ResponseItem::Reasoning(r) => {
768 assert_eq!(r.encrypted_content.as_deref(), Some("gAAA..."));
769 assert!(r.summary.is_empty());
770 }
771 _ => panic!("expected Reasoning"),
772 }
773 }
774
775 #[test]
776 fn response_item_function_call_keeps_raw_arguments() {
777 let raw = r#"{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}","call_id":"call_1"}"#;
778 let v: Value = serde_json::from_str(raw).unwrap();
779 match ResponseItem::from_value(&v) {
780 ResponseItem::FunctionCall(c) => {
781 assert_eq!(c.name, "exec_command");
782 assert_eq!(c.call_id, "call_1");
783 assert_eq!(c.arguments_as_json()["cmd"], "pwd");
784 }
785 _ => panic!("expected FunctionCall"),
786 }
787 }
788
789 #[test]
790 fn response_item_unknown_survives() {
791 let raw = r#"{"type":"web_search_call","query":"rust","call_id":"c"}"#;
792 let v: Value = serde_json::from_str(raw).unwrap();
793 match ResponseItem::from_value(&v) {
794 ResponseItem::Other { kind, payload } => {
795 assert_eq!(kind, "web_search_call");
796 assert_eq!(payload["query"], "rust");
797 }
798 _ => panic!("expected Other"),
799 }
800 }
801
802 #[test]
803 fn event_msg_variants_dispatch() {
804 let token = serde_json::json!({
805 "type":"token_count",
806 "info":{"total_token_usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120}}
807 });
808 match EventMsg::from_value(&token) {
809 EventMsg::TokenCount(tc) => {
810 let usage = tc
811 .info
812 .as_ref()
813 .unwrap()
814 .total_token_usage
815 .as_ref()
816 .unwrap();
817 assert_eq!(usage.input_tokens, Some(100));
818 assert_eq!(usage.output_tokens, Some(20));
819 }
820 _ => panic!("expected TokenCount"),
821 }
822 }
823
824 #[test]
825 fn patch_apply_end_change_variants() {
826 let add = r#"{"type":"add","content":"hello\n"}"#;
827 let pc: PatchChange = serde_json::from_str(add).unwrap();
828 assert!(matches!(pc, PatchChange::Add { .. }));
829
830 let upd = r#"{"type":"update","unified_diff":"@@\n-x\n+y"}"#;
831 let pc: PatchChange = serde_json::from_str(upd).unwrap();
832 assert!(matches!(pc, PatchChange::Update { .. }));
833 }
834
835 #[test]
836 fn patch_apply_end_unknown_change_type() {
837 let raw = r#"{"type":"rename","from":"a","to":"b"}"#;
838 let pc: PatchChange = serde_json::from_str(raw).unwrap();
839 assert!(matches!(pc, PatchChange::Unknown));
840 }
841
842 #[test]
843 fn session_meta_roundtrip() {
844 let line: RolloutLine = serde_json::from_str(SAMPLE_META).unwrap();
845 let back = serde_json::to_string(&line).unwrap();
846 let orig: Value = serde_json::from_str(SAMPLE_META).unwrap();
847 let back_v: Value = serde_json::from_str(&back).unwrap();
848 assert_eq!(orig, back_v);
850 }
851
852 #[test]
853 fn rollout_line_preserves_unknown_toplevel_field() {
854 let raw = r#"{"timestamp":"t","type":"session_meta","payload":{},"new_field":42}"#;
855 let line: RolloutLine = serde_json::from_str(raw).unwrap();
856 assert_eq!(line.extra.get("new_field"), Some(&serde_json::json!(42)));
857 let back = serde_json::to_string(&line).unwrap();
858 assert!(back.contains("new_field"));
859 }
860
861 #[test]
862 fn content_part_unknown_survives() {
863 let raw = r#"{"type":"image_url","url":"data:..."}"#;
864 let cp: ContentPart = serde_json::from_str(raw).unwrap();
865 assert!(matches!(cp, ContentPart::Unknown));
866 }
867
868 #[test]
869 fn message_multi_part_text() {
870 let m = Message {
871 role: "user".into(),
872 content: vec![
873 ContentPart::InputText {
874 text: "one".into(),
875 extra: Default::default(),
876 },
877 ContentPart::InputText {
878 text: "two".into(),
879 extra: Default::default(),
880 },
881 ],
882 id: None,
883 end_turn: None,
884 phase: None,
885 extra: Default::default(),
886 };
887 assert_eq!(m.text(), "one\ntwo");
888 }
889
890 fn session_from_lines(lines: &[&str]) -> Session {
891 let parsed: Vec<RolloutLine> = lines
892 .iter()
893 .map(|l| serde_json::from_str(l).unwrap())
894 .collect();
895 Session {
896 id: "s".to_string(),
897 file_path: PathBuf::from("/tmp/session.jsonl"),
898 lines: parsed,
899 }
900 }
901
902 #[test]
903 fn first_user_text_prefers_user_message_event() {
904 let s = session_from_lines(&[
908 r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>\n<cwd>/x</cwd>\n</environment_context>"}]}}"#,
909 r#"{"timestamp":"t","type":"event_msg","payload":{"type":"user_message","message":"build me a thing"}}"#,
910 r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"build me a thing"}]}}"#,
911 ]);
912 assert_eq!(s.first_user_text().as_deref(), Some("build me a thing"));
913 }
914
915 #[test]
916 fn first_user_text_falls_back_when_no_user_message_event() {
917 let s = session_from_lines(&[
920 r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}"#,
921 ]);
922 assert_eq!(s.first_user_text().as_deref(), Some("hello"));
923 }
924
925 #[test]
926 fn first_user_text_ignores_empty_user_message_event() {
927 let s = session_from_lines(&[
929 r#"{"timestamp":"t","type":"event_msg","payload":{"type":"user_message","message":""}}"#,
930 r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"real prompt"}]}}"#,
931 ]);
932 assert_eq!(s.first_user_text().as_deref(), Some("real prompt"));
933 }
934}