1use std::collections::HashMap;
27
28use crate::io::ConvoIO;
29use crate::types::{
30 EventMsg, ExecCommandEnd, Message, PatchApplyEnd, PatchChange, ResponseItem, RolloutItem,
31 Session, TokenCountInfo, TokenUsage as CodexTokenUsage,
32};
33use serde_json::Value;
34use toolpath_convo::{
35 ConversationEvent, ConversationMeta, ConversationProvider, ConversationView, ConvoError,
36 EnvironmentSnapshot, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory,
37 ToolInvocation, ToolResult, Turn,
38};
39
40#[derive(Debug, Clone, Default)]
42pub struct CodexConvo {
43 io: ConvoIO,
44}
45
46impl CodexConvo {
47 pub fn new() -> Self {
48 Self { io: ConvoIO::new() }
49 }
50
51 pub fn with_resolver(resolver: crate::paths::PathResolver) -> Self {
52 Self {
53 io: ConvoIO::with_resolver(resolver),
54 }
55 }
56
57 pub fn io(&self) -> &ConvoIO {
58 &self.io
59 }
60
61 pub fn resolver(&self) -> &crate::paths::PathResolver {
62 self.io.resolver()
63 }
64
65 pub fn read_session(&self, session_id: &str) -> crate::Result<Session> {
67 self.io.read_session(session_id)
68 }
69
70 pub fn list_sessions(&self) -> crate::Result<Vec<crate::types::SessionMetadata>> {
72 self.io.list_sessions()
73 }
74
75 pub fn most_recent_session(&self) -> crate::Result<Option<Session>> {
77 let metas = self.list_sessions()?;
78 match metas.first() {
79 Some(m) => Ok(Some(self.read_session(&m.id)?)),
80 None => Ok(None),
81 }
82 }
83
84 pub fn read_all_sessions(&self) -> crate::Result<Vec<Session>> {
86 let metas = self.list_sessions()?;
87 let mut out = Vec::with_capacity(metas.len());
88 for m in metas {
89 match self.read_session(&m.id) {
90 Ok(s) => out.push(s),
91 Err(e) => eprintln!("Warning: could not read session {}: {}", m.id, e),
92 }
93 }
94 Ok(out)
95 }
96}
97
98pub fn tool_category(name: &str) -> Option<ToolCategory> {
102 match name {
103 "read_file" | "read_many_files" | "list_dir" | "view_image" | "mcp_resource" => {
104 Some(ToolCategory::FileRead)
105 }
106 "glob" | "grep_search" | "search_file_content" | "tool_search" | "tool_suggest" => {
107 Some(ToolCategory::FileSearch)
108 }
109 "write_file" | "apply_patch" | "replace" | "edit" => Some(ToolCategory::FileWrite),
110 "shell" | "exec_command" | "unified_exec" | "write_stdin" | "js_repl" => {
111 Some(ToolCategory::Shell)
112 }
113 "web_fetch" | "web_search" | "google_web_search" => Some(ToolCategory::Network),
114 "spawn_agent" | "close_agent" | "wait_agent" | "resume_agent" | "send_message"
115 | "followup_task" | "list_agents" | "agent_jobs" | "task" | "activate_skill" => {
116 Some(ToolCategory::Delegation)
117 }
118 _ => None,
119 }
120}
121
122pub fn native_name(category: ToolCategory, args: &Value) -> Option<&'static str> {
133 match category {
134 ToolCategory::Shell => Some("exec_command"),
135 ToolCategory::FileRead => Some(if args.get("file_paths").is_some() {
136 "read_many_files"
137 } else if args.get("path").is_some() && args.get("file_path").is_none() {
138 "list_dir"
139 } else {
140 "read_file"
141 }),
142 ToolCategory::FileSearch => Some(if args.get("pattern").is_some() {
143 "grep_search"
144 } else {
145 "glob"
146 }),
147 ToolCategory::FileWrite => Some("write_file"),
148 ToolCategory::Network => Some(if args.get("url").is_some() {
149 "web_fetch"
150 } else {
151 "web_search"
152 }),
153 ToolCategory::Delegation => Some("spawn_agent"),
154 }
155}
156
157pub fn to_view(session: &Session) -> ConversationView {
162 Builder::new(session).build()
163}
164
165pub fn to_turn(line_payload: &ResponseItem) -> Option<Turn> {
169 if let ResponseItem::Message(m) = line_payload {
170 Some(message_to_turn(m, "", None, None))
171 } else {
172 None
173 }
174}
175
176struct Builder<'a> {
177 session: &'a Session,
178 turns: Vec<Turn>,
179 events: Vec<ConversationEvent>,
180 pending_reasoning_plaintext: Vec<String>,
183 pending_token_usage: Option<TokenUsage>,
184 working_dir: Option<String>,
185 current_model: Option<String>,
186 call_index: HashMap<String, (usize, usize)>,
187 total_usage: TokenUsage,
188 total_usage_set: bool,
189 files_changed_order: Vec<String>,
190 files_changed_seen: std::collections::HashSet<String>,
191}
192
193impl<'a> Builder<'a> {
194 fn new(session: &'a Session) -> Self {
195 Self {
196 session,
197 turns: Vec::new(),
198 events: Vec::new(),
199 pending_reasoning_plaintext: Vec::new(),
200 pending_token_usage: None,
201 working_dir: None,
202 current_model: None,
203 call_index: HashMap::new(),
204 total_usage: TokenUsage::default(),
205 total_usage_set: false,
206 files_changed_order: Vec::new(),
207 files_changed_seen: std::collections::HashSet::new(),
208 }
209 }
210
211 fn build(mut self) -> ConversationView {
212 for line in &self.session.lines {
213 match line.item() {
214 RolloutItem::SessionMeta(m) => {
215 self.working_dir = Some(m.cwd.to_string_lossy().to_string());
216 self.events.push(event_from_raw(
217 &line.timestamp,
218 "session_meta",
219 &line.payload,
220 ));
221 }
222 RolloutItem::TurnContext(tc) => {
223 if let Some(m) = &tc.model {
224 self.current_model = Some(m.clone());
225 }
226 let wd = tc.cwd.to_string_lossy().to_string();
227 if !wd.is_empty() {
228 self.working_dir = Some(wd);
229 }
230 self.events.push(event_from_raw(
231 &line.timestamp,
232 "turn_context",
233 &line.payload,
234 ));
235 }
236 RolloutItem::ResponseItem(ri) => self.handle_response_item(&line.timestamp, ri),
237 RolloutItem::EventMsg(ev) => {
238 self.handle_event_msg(&line.timestamp, ev, &line.payload)
239 }
240 RolloutItem::SessionState(payload) => {
241 self.events
242 .push(event_from_raw(&line.timestamp, "session_state", &payload));
243 }
244 RolloutItem::Compacted(payload) => {
245 self.events
246 .push(event_from_raw(&line.timestamp, "compacted", &payload));
247 }
248 RolloutItem::Unknown { kind, payload } => {
249 self.events
250 .push(event_from_raw(&line.timestamp, &kind, &payload));
251 }
252 }
253 }
254
255 let meta = self.session.meta();
257 let base = {
258 let wd = meta
259 .as_ref()
260 .map(|m| m.cwd.to_string_lossy().to_string())
261 .filter(|s| !s.is_empty())
262 .or_else(|| self.working_dir.clone());
263 let git = meta.as_ref().and_then(|m| m.git.as_ref());
264 let revision = git.and_then(|g| g.commit_hash.clone());
265 let branch = git.and_then(|g| g.branch.clone());
266 let remote = git.and_then(|g| g.repository_url.clone());
267 if wd.is_some() || revision.is_some() || branch.is_some() || remote.is_some() {
268 Some(SessionBase {
269 working_dir: wd,
270 vcs_revision: revision,
271 vcs_branch: branch,
272 vcs_remote: remote,
273 })
274 } else {
275 None
276 }
277 };
278
279 let producer = meta.as_ref().map(|m| ProducerInfo {
286 name: m.originator.clone(),
287 version: Some(m.cli_version.clone()),
288 });
289
290 self.turns
294 .retain(|t| !(t.text.is_empty() && t.thinking.is_none() && t.tool_uses.is_empty()));
295
296 for (idx, t) in self.turns.iter_mut().enumerate() {
302 if t.id.is_empty() {
303 t.id = format!("codex-turn-{:04}", idx + 1);
304 }
305 }
306 let mut prev: Option<String> = None;
307 for t in self.turns.iter_mut() {
308 if t.parent_id.is_none() {
309 t.parent_id = prev.clone();
310 }
311 prev = Some(t.id.clone());
312 }
313
314 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
320 for t in &self.turns {
321 seen.insert(t.id.clone());
322 }
323 for (i, e) in self.events.iter_mut().enumerate() {
324 if !seen.insert(e.id.clone()) {
325 e.id = format!("{}-{:04}", e.id, i);
326 seen.insert(e.id.clone());
327 }
328 }
329
330 ConversationView {
331 id: self.session.id.clone(),
332 started_at: self.session.started_at(),
333 last_activity: self.session.last_activity(),
334 turns: self.turns,
335 total_usage: if self.total_usage_set {
336 Some(self.total_usage)
337 } else {
338 None
339 },
340 provider_id: Some("codex".into()),
341 files_changed: self.files_changed_order,
342 session_ids: vec![],
343 events: self.events,
344 base,
345 producer,
346 }
347 }
348
349 fn handle_response_item(&mut self, timestamp: &str, ri: ResponseItem) {
350 match ri {
351 ResponseItem::Message(msg) => {
352 let turn = message_to_turn(
353 &msg,
354 timestamp,
355 self.working_dir.as_deref(),
356 self.current_model.as_deref(),
357 );
358 self.push_turn(turn);
359 }
360 ResponseItem::Reasoning(r) => {
361 if let Some(Value::Array(arr)) = r.content.as_ref() {
363 for v in arr {
364 if let Some(s) = v.get("text").and_then(|t| t.as_str()) {
365 self.pending_reasoning_plaintext.push(s.to_string());
366 }
367 }
368 }
369 for v in &r.summary {
371 if let Some(s) = v.get("text").and_then(|t| t.as_str()) {
372 self.pending_reasoning_plaintext.push(s.to_string());
373 }
374 }
375 }
376 ResponseItem::FunctionCall(fc) => {
377 let name = fc.name.clone();
378 let input = fc.arguments_as_json();
379 let input = if input.is_null() {
380 Value::String(fc.arguments.clone())
381 } else {
382 input
383 };
384 self.attach_tool_call(timestamp, fc.call_id, name, input);
385 }
386 ResponseItem::FunctionCallOutput(out) => {
387 let is_error = out
388 .extra
389 .get("is_error")
390 .and_then(Value::as_bool)
391 .unwrap_or(false);
392 self.attach_tool_output(&out.call_id, &out.output, is_error);
393 }
394 ResponseItem::CustomToolCall(ct) => {
395 let input = Value::String(ct.input.clone());
396 self.attach_tool_call(timestamp, ct.call_id, ct.name, input);
397 }
398 ResponseItem::CustomToolCallOutput(out) => {
399 let is_error = out
400 .extra
401 .get("is_error")
402 .and_then(Value::as_bool)
403 .unwrap_or(false);
404 self.attach_tool_output(&out.call_id, &out.output, is_error);
405 }
406 ResponseItem::Other { kind, payload } => {
407 self.events.push(ConversationEvent {
408 id: synthetic_event_id(timestamp, &kind),
409 timestamp: timestamp.to_string(),
410 parent_id: None,
411 event_type: format!("response_item.{}", kind),
412 data: data_from_value(&payload),
413 });
414 }
415 }
416 }
417
418 fn handle_event_msg(&mut self, timestamp: &str, ev: EventMsg, raw_payload: &Value) {
419 match ev {
420 EventMsg::TokenCount(tc) => {
421 if let Some(info) = tc.info.as_ref() {
422 apply_token_count(&mut self.total_usage, info);
423 self.total_usage_set = true;
424 if let Some(total) = info.total_token_usage.as_ref() {
425 self.pending_token_usage = Some(codex_usage_to_convo(total));
426 }
427 }
428 self.events
429 .push(event_from_raw(timestamp, "token_count", raw_payload));
430 }
431 EventMsg::ExecCommandEnd(exec) => {
432 self.apply_exec_command_end(&exec);
433 self.events
434 .push(event_from_raw(timestamp, "exec_command_end", raw_payload));
435 }
436 EventMsg::PatchApplyEnd(patch) => {
437 self.apply_patch_apply_end(&patch);
438 self.events
439 .push(event_from_raw(timestamp, "patch_apply_end", raw_payload));
440 }
441 EventMsg::AgentMessage(_)
442 | EventMsg::UserMessage(_)
443 | EventMsg::TaskStarted(_)
444 | EventMsg::TaskComplete(_) => {
445 self.events
446 .push(event_from_raw(timestamp, ev.kind(), raw_payload));
447 }
448 EventMsg::Other { kind, payload } => {
449 self.events.push(event_from_raw(timestamp, &kind, &payload));
450 }
451 }
452 }
453
454 fn attach_tool_call(&mut self, timestamp: &str, call_id: String, name: String, input: Value) {
455 let category = tool_category(&name);
456 let invocation = ToolInvocation {
457 id: call_id.clone(),
458 name,
459 input,
460 result: None,
461 category,
462 };
463
464 let turn_idx = match self.last_assistant_turn_index() {
465 Some(idx) => idx,
466 None => {
467 let mut t = synthetic_assistant_turn(
468 timestamp,
469 self.working_dir.as_deref(),
470 self.current_model.as_deref(),
471 );
472 self.drain_pending_onto(&mut t);
473 self.turns.push(t);
474 self.turns.len() - 1
475 }
476 };
477 let tool_idx = self.turns[turn_idx].tool_uses.len();
478 self.turns[turn_idx].tool_uses.push(invocation);
479 self.call_index.insert(call_id, (turn_idx, tool_idx));
480 }
481
482 fn attach_tool_output(&mut self, call_id: &str, output: &str, is_error: bool) {
483 if let Some((turn_idx, tool_idx)) = self.call_index.get(call_id).copied() {
484 let turn = &mut self.turns[turn_idx];
485 if let Some(inv) = turn.tool_uses.get_mut(tool_idx) {
486 let prior_error = inv.result.as_ref().map(|r| r.is_error).unwrap_or(false);
487 let merged = match inv.result.as_ref() {
488 Some(existing) => format!("{}\n{}", existing.content, output),
489 None => output.to_string(),
490 };
491 inv.result = Some(ToolResult {
492 content: merged,
493 is_error: is_error || prior_error,
494 });
495 }
496 }
497 }
498
499 fn apply_exec_command_end(&mut self, exec: &ExecCommandEnd) {
500 if let Some((turn_idx, tool_idx)) = self.call_index.get(&exec.call_id).copied() {
501 let turn = &mut self.turns[turn_idx];
502 if let Some(inv) = turn.tool_uses.get_mut(tool_idx) {
503 let is_error = exec.exit_code.map(|c| c != 0).unwrap_or(false);
504 if inv.result.is_none() {
505 let body = if !exec.aggregated_output.is_empty() {
506 exec.aggregated_output.clone()
507 } else if !exec.stdout.is_empty() || !exec.stderr.is_empty() {
508 let mut s = String::new();
509 if !exec.stdout.is_empty() {
510 s.push_str(&exec.stdout);
511 }
512 if !exec.stderr.is_empty() {
513 if !s.is_empty() {
514 s.push('\n');
515 }
516 s.push_str(&exec.stderr);
517 }
518 s
519 } else {
520 format!("(exit {})", exec.exit_code.unwrap_or_default())
521 };
522 inv.result = Some(ToolResult {
523 content: body,
524 is_error,
525 });
526 } else if is_error && let Some(r) = inv.result.as_mut() {
527 r.is_error = true;
528 }
529 }
530 }
531 }
532
533 fn apply_patch_apply_end(&mut self, patch: &PatchApplyEnd) {
534 let loc = self.call_index.get(&patch.call_id).copied();
535
536 let mut paths: Vec<&String> = patch.changes.keys().collect();
539 paths.sort();
540
541 if let Some((turn_idx, _tool_idx)) = loc {
545 let turn = &mut self.turns[turn_idx];
546 for path in &paths {
547 if let Some(change) = patch.changes.get(*path) {
548 let mut fm = patch_change_to_file_mutation(path, change);
549 fm.tool_id = Some(patch.call_id.clone());
550 turn.file_mutations.push(fm);
551 }
552 }
553 }
554
555 for path in paths {
556 if self.files_changed_seen.insert(path.clone()) {
557 self.files_changed_order.push(path.clone());
558 }
559 }
560 }
561
562 fn push_turn(&mut self, mut turn: Turn) {
563 self.drain_pending_onto(&mut turn);
564 self.turns.push(turn);
565 }
566
567 fn drain_pending_onto(&mut self, turn: &mut Turn) {
568 if turn.role != Role::Assistant {
569 return;
570 }
571 if !self.pending_reasoning_plaintext.is_empty() {
573 turn.thinking = Some(self.pending_reasoning_plaintext.join("\n\n"));
574 self.pending_reasoning_plaintext.clear();
575 }
576 if let Some(tu) = self.pending_token_usage.take() {
577 turn.token_usage = Some(tu);
578 }
579 }
580
581 fn last_assistant_turn_index(&self) -> Option<usize> {
582 self.turns
583 .iter()
584 .rposition(|t| t.role == Role::Assistant)
585 .or_else(|| self.turns.len().checked_sub(1))
586 }
587}
588
589fn patch_change_to_file_mutation(path: &str, change: &PatchChange) -> FileMutation {
592 let mut fm = FileMutation {
593 path: path.to_string(),
594 ..Default::default()
595 };
596 match change {
597 PatchChange::Add { content, .. } => {
598 fm.operation = Some("add".into());
599 fm.after = Some(content.clone());
600 fm.raw_diff = Some(synth_add_diff(content));
601 }
602 PatchChange::Update {
603 unified_diff,
604 move_path,
605 ..
606 } => {
607 fm.operation = Some("update".into());
608 fm.raw_diff = Some(unified_diff.clone());
609 fm.rename_to = move_path.clone();
610 }
611 PatchChange::Delete {
612 original_content, ..
613 } => {
614 fm.operation = Some("delete".into());
615 fm.before = original_content.clone();
616 fm.raw_diff = original_content.as_deref().map(synth_delete_diff);
617 }
618 PatchChange::Unknown => {
619 fm.operation = Some("unknown".into());
620 }
621 }
622 fm
623}
624
625fn synth_add_diff(content: &str) -> String {
626 let lines: Vec<&str> = content.split('\n').collect();
627 let effective: &[&str] = if lines.last() == Some(&"") {
628 &lines[..lines.len().saturating_sub(1)]
629 } else {
630 &lines[..]
631 };
632 let mut buf = format!("@@ -0,0 +1,{} @@\n", effective.len());
633 for l in effective {
634 buf.push('+');
635 buf.push_str(l);
636 buf.push('\n');
637 }
638 buf
639}
640
641fn synth_delete_diff(original: &str) -> String {
642 let lines: Vec<&str> = original.split('\n').collect();
643 let effective: &[&str] = if lines.last() == Some(&"") {
644 &lines[..lines.len().saturating_sub(1)]
645 } else {
646 &lines[..]
647 };
648 let mut buf = format!("@@ -1,{} +0,0 @@\n", effective.len());
649 for l in effective {
650 buf.push('-');
651 buf.push_str(l);
652 buf.push('\n');
653 }
654 buf
655}
656
657fn message_to_turn(
658 msg: &Message,
659 timestamp: &str,
660 working_dir: Option<&str>,
661 model: Option<&str>,
662) -> Turn {
663 let role = match msg.role.as_str() {
664 "user" => Role::User,
665 "assistant" => Role::Assistant,
666 "developer" | "system" => Role::System,
667 other => Role::Other(other.to_string()),
668 };
669
670 let text = msg.text();
671
672 let environment = working_dir.map(|wd| EnvironmentSnapshot {
673 working_dir: Some(wd.to_string()),
674 vcs_branch: None,
675 vcs_revision: None,
676 });
677
678 Turn {
679 id: msg.id.clone().unwrap_or_default(),
680 parent_id: None,
681 role: role.clone(),
682 timestamp: timestamp.to_string(),
683 text,
684 thinking: None,
685 tool_uses: Vec::new(),
686 model: if role == Role::Assistant {
687 model.map(str::to_string)
688 } else {
689 None
690 },
691 stop_reason: None,
692 token_usage: None,
693 environment,
694 delegations: Vec::new(),
695 file_mutations: Vec::new(),
696 }
697}
698
699fn synthetic_assistant_turn(
700 timestamp: &str,
701 working_dir: Option<&str>,
702 model: Option<&str>,
703) -> Turn {
704 Turn {
705 id: format!("synth-{}", timestamp),
706 parent_id: None,
707 role: Role::Assistant,
708 timestamp: timestamp.to_string(),
709 text: String::new(),
710 thinking: None,
711 tool_uses: Vec::new(),
712 model: model.map(str::to_string),
713 stop_reason: None,
714 token_usage: None,
715 environment: working_dir.map(|wd| EnvironmentSnapshot {
716 working_dir: Some(wd.to_string()),
717 vcs_branch: None,
718 vcs_revision: None,
719 }),
720 delegations: Vec::new(),
721 file_mutations: Vec::new(),
722 }
723}
724
725fn codex_usage_to_convo(u: &CodexTokenUsage) -> TokenUsage {
726 TokenUsage {
727 input_tokens: u.input_tokens,
728 output_tokens: u.output_tokens,
729 cache_read_tokens: u.cached_input_tokens,
730 cache_write_tokens: None,
731 }
732}
733
734fn apply_token_count(total: &mut TokenUsage, info: &TokenCountInfo) {
735 if let Some(t) = info.total_token_usage.as_ref() {
736 total.input_tokens = t.input_tokens.or(total.input_tokens);
737 total.output_tokens = t.output_tokens.or(total.output_tokens);
738 total.cache_read_tokens = t.cached_input_tokens.or(total.cache_read_tokens);
739 }
740}
741
742fn event_from_raw(timestamp: &str, event_type: &str, payload: &Value) -> ConversationEvent {
743 ConversationEvent {
744 id: synthetic_event_id(timestamp, event_type),
745 timestamp: timestamp.to_string(),
746 parent_id: None,
747 event_type: event_type.to_string(),
748 data: data_from_value(payload),
749 }
750}
751
752fn synthetic_event_id(timestamp: &str, kind: &str) -> String {
753 format!("{}-{}", kind, timestamp)
754}
755
756fn data_from_value(v: &Value) -> HashMap<String, Value> {
757 match v {
758 Value::Object(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
759 _ => {
760 let mut m = HashMap::new();
761 m.insert("value".into(), v.clone());
762 m
763 }
764 }
765}
766
767impl ConversationProvider for CodexConvo {
770 fn list_conversations(&self, _project: &str) -> toolpath_convo::Result<Vec<String>> {
771 let metas = self
772 .list_sessions()
773 .map_err(|e| ConvoError::Provider(e.to_string()))?;
774 Ok(metas.into_iter().map(|m| m.id).collect())
775 }
776
777 fn load_conversation(
778 &self,
779 _project: &str,
780 conversation_id: &str,
781 ) -> toolpath_convo::Result<ConversationView> {
782 let session = self
783 .read_session(conversation_id)
784 .map_err(|e| ConvoError::Provider(e.to_string()))?;
785 Ok(to_view(&session))
786 }
787
788 fn load_metadata(
789 &self,
790 _project: &str,
791 conversation_id: &str,
792 ) -> toolpath_convo::Result<ConversationMeta> {
793 let path = self
794 .io
795 .resolver()
796 .find_rollout_file(conversation_id)
797 .map_err(|e| ConvoError::Provider(e.to_string()))?;
798 let meta = self
799 .io
800 .read_metadata(path)
801 .map_err(|e| ConvoError::Provider(e.to_string()))?;
802 Ok(ConversationMeta {
803 id: meta.id,
804 started_at: meta.started_at,
805 last_activity: meta.last_activity,
806 message_count: meta.line_count,
807 file_path: Some(meta.file_path),
808 predecessor: None,
809 successor: None,
810 })
811 }
812
813 fn list_metadata(&self, _project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
814 let metas = self
815 .list_sessions()
816 .map_err(|e| ConvoError::Provider(e.to_string()))?;
817 Ok(metas
818 .into_iter()
819 .map(|m| ConversationMeta {
820 id: m.id,
821 started_at: m.started_at,
822 last_activity: m.last_activity,
823 message_count: m.line_count,
824 file_path: Some(m.file_path),
825 predecessor: None,
826 successor: None,
827 })
828 .collect())
829 }
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835 use std::fs;
836 use tempfile::TempDir;
837
838 fn setup_session_fixture(body: &str) -> (TempDir, CodexConvo, String) {
839 let temp = TempDir::new().unwrap();
840 let codex = temp.path().join(".codex");
841 let day = codex.join("sessions/2026/04/20");
842 fs::create_dir_all(&day).unwrap();
843 let name = "rollout-2026-04-20T10-00-00-019dabc6-8fef-7681-a054-b5bb75fcb97d";
844 fs::write(day.join(format!("{}.jsonl", name)), body).unwrap();
845 let resolver = crate::paths::PathResolver::new().with_codex_dir(&codex);
846 (temp, CodexConvo::with_resolver(resolver), name.to_string())
847 }
848
849 fn minimal_session() -> String {
850 [
851 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","git":{"commit_hash":"abc","branch":"main"}}}"#,
852 r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"t1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#,
853 r#"{"timestamp":"2026-04-20T16:44:37.775Z","type":"event_msg","payload":{"type":"task_started","turn_id":"t1"}}"#,
854 r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"please do a thing"}]}}"#,
855 r#"{"timestamp":"2026-04-20T16:44:38.000Z","type":"response_item","payload":{"type":"reasoning","summary":[],"content":null,"encrypted_content":"encrypted-blob-1"}}"#,
856 r#"{"timestamp":"2026-04-20T16:44:38.100Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"working on it"}],"phase":"commentary"}}"#,
857 r#"{"timestamp":"2026-04-20T16:44:38.200Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}","call_id":"call_1"}}"#,
858 r#"{"timestamp":"2026-04-20T16:44:38.300Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"Command: pwd\nOutput:\n/tmp/proj\n"}}"#,
859 r#"{"timestamp":"2026-04-20T16:44:38.400Z","type":"event_msg","payload":{"type":"exec_command_end","call_id":"call_1","command":["/bin/bash","-lc","pwd"],"stdout":"/tmp/proj\n","exit_code":0,"aggregated_output":"/tmp/proj\n"}}"#,
860 r#"{"timestamp":"2026-04-20T16:44:38.500Z","type":"response_item","payload":{"type":"custom_tool_call","status":"completed","call_id":"call_2","name":"apply_patch","input":"*** Begin Patch\n*** Add File: /tmp/proj/a.rs\n+fn main() {}\n*** End Patch"}}"#,
861 r#"{"timestamp":"2026-04-20T16:44:38.600Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_2","output":"{\"output\":\"ok\"}"}}"#,
862 r#"{"timestamp":"2026-04-20T16:44:38.700Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"call_2","success":true,"changes":{"/tmp/proj/a.rs":{"type":"add","content":"fn main() {}\n"}}}}"#,
863 r#"{"timestamp":"2026-04-20T16:44:38.800Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"output_tokens":20,"cached_input_tokens":10,"total_tokens":130}}}}"#,
864 r#"{"timestamp":"2026-04-20T16:44:38.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}],"phase":"final","end_turn":true}}"#,
865 r#"{"timestamp":"2026-04-20T16:44:39.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"t1","last_agent_message":"done"}}"#,
866 ]
867 .join("\n")
868 }
869
870 #[test]
871 fn build_view_basic() {
872 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
873 let session = mgr.read_session(&id).unwrap();
874 let view = to_view(&session);
875
876 assert_eq!(view.id, "019dabc6-8fef-7681-a054-b5bb75fcb97d");
877 assert_eq!(view.provider_id.as_deref(), Some("codex"));
878 assert_eq!(view.turns.len(), 3);
879 assert_eq!(view.turns[0].role, Role::User);
880 assert_eq!(view.turns[0].text, "please do a thing");
881 assert_eq!(view.turns[1].role, Role::Assistant);
882 assert_eq!(view.turns[1].text, "working on it");
883 assert_eq!(view.turns[1].model.as_deref(), Some("gpt-5.4"));
884 }
885
886 #[test]
887 fn encrypted_reasoning_does_not_land_on_thinking() {
888 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
892 let view = to_view(&mgr.read_session(&id).unwrap());
893 let assistant = &view.turns[1];
894 assert!(
895 assistant.thinking.is_none(),
896 "encrypted ciphertext must not appear as thinking"
897 );
898 }
899
900 #[test]
901 fn plaintext_reasoning_lands_on_thinking() {
902 let body = [
905 r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/p","originator":"x","cli_version":"1","source":"cli"}}"#,
906 r#"{"timestamp":"t","type":"response_item","payload":{"type":"reasoning","summary":[],"content":[{"type":"text","text":"I should check the file"}]}}"#,
907 r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"checking"}]}}"#,
908 ]
909 .join("\n");
910 let (_t, mgr, id) = setup_session_fixture(&body);
911 let view = to_view(&mgr.read_session(&id).unwrap());
912 assert_eq!(
913 view.turns[0].thinking.as_deref(),
914 Some("I should check the file")
915 );
916 }
917
918 #[test]
919 fn function_call_pairs_with_output() {
920 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
921 let view = to_view(&mgr.read_session(&id).unwrap());
922 let assistant = &view.turns[1];
923 assert_eq!(assistant.tool_uses.len(), 2);
924 let exec = &assistant.tool_uses[0];
925 assert_eq!(exec.name, "exec_command");
926 assert_eq!(exec.category, Some(ToolCategory::Shell));
927 assert!(exec.result.is_some());
928 assert!(exec.result.as_ref().unwrap().content.contains("/tmp/proj"));
929 }
930
931 #[test]
932 fn custom_tool_call_preserves_raw_input() {
933 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
934 let view = to_view(&mgr.read_session(&id).unwrap());
935 let assistant = &view.turns[1];
936 let apply = &assistant.tool_uses[1];
937 assert_eq!(apply.name, "apply_patch");
938 assert_eq!(apply.category, Some(ToolCategory::FileWrite));
939 let input_str = apply.input.as_str().unwrap();
940 assert!(input_str.contains("*** Begin Patch"));
941 }
942
943 #[test]
944 fn patch_apply_end_aggregates_files_changed() {
945 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
946 let view = to_view(&mgr.read_session(&id).unwrap());
947 assert_eq!(view.files_changed, vec!["/tmp/proj/a.rs".to_string()]);
948 }
949
950 #[test]
951 fn files_changed_order_is_deterministic() {
952 let body = [
955 r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/p","originator":"x","cli_version":"1","source":"cli"}}"#,
956 r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"go"}]}}"#,
957 r#"{"timestamp":"t","type":"response_item","payload":{"type":"custom_tool_call","call_id":"c","name":"apply_patch","input":""}}"#,
958 r#"{"timestamp":"t","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"c","success":true,"changes":{"/p/z.rs":{"type":"add","content":"z"},"/p/a.rs":{"type":"add","content":"a"},"/p/m.rs":{"type":"add","content":"m"}}}}"#,
959 ]
960 .join("\n");
961 let (_t, mgr, id) = setup_session_fixture(&body);
962 let view = to_view(&mgr.read_session(&id).unwrap());
963 assert_eq!(
964 view.files_changed,
965 vec![
966 "/p/a.rs".to_string(),
967 "/p/m.rs".to_string(),
968 "/p/z.rs".to_string(),
969 ]
970 );
971 }
972
973 #[test]
974 fn patch_apply_end_populates_turn_file_mutations() {
975 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
976 let view = to_view(&mgr.read_session(&id).unwrap());
977 let apply_patch_id = view
980 .turns
981 .iter()
982 .flat_map(|t| t.tool_uses.iter())
983 .find(|tu| tu.name == "apply_patch")
984 .map(|tu| tu.id.clone())
985 .expect("apply_patch tool invocation present");
986 let fm = view
987 .turns
988 .iter()
989 .flat_map(|t| t.file_mutations.iter())
990 .find(|fm| fm.path == "/tmp/proj/a.rs")
991 .expect("file mutation present");
992 assert_eq!(fm.tool_id.as_ref(), Some(&apply_patch_id));
993 assert_eq!(fm.operation.as_deref(), Some("add"));
994 assert!(fm.raw_diff.is_some());
995 }
996
997 #[test]
998 fn total_usage_populated() {
999 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1000 let view = to_view(&mgr.read_session(&id).unwrap());
1001 let u = view.total_usage.as_ref().unwrap();
1002 assert_eq!(u.input_tokens, Some(100));
1003 assert_eq!(u.output_tokens, Some(20));
1004 assert_eq!(u.cache_read_tokens, Some(10));
1005 }
1006
1007 #[test]
1008 fn events_preserve_non_turn_content() {
1009 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1010 let view = to_view(&mgr.read_session(&id).unwrap());
1011 let kinds: Vec<&str> = view.events.iter().map(|e| e.event_type.as_str()).collect();
1012 assert!(kinds.contains(&"session_meta"));
1013 assert!(kinds.contains(&"turn_context"));
1014 assert!(kinds.contains(&"task_started"));
1015 assert!(kinds.contains(&"task_complete"));
1016 assert!(kinds.contains(&"exec_command_end"));
1017 assert!(kinds.contains(&"patch_apply_end"));
1018 assert!(kinds.contains(&"token_count"));
1019 }
1020
1021 #[test]
1022 fn tool_category_mapping() {
1023 assert_eq!(tool_category("exec_command"), Some(ToolCategory::Shell));
1024 assert_eq!(tool_category("apply_patch"), Some(ToolCategory::FileWrite));
1025 assert_eq!(tool_category("read_file"), Some(ToolCategory::FileRead));
1026 assert_eq!(tool_category("grep_search"), Some(ToolCategory::FileSearch));
1027 assert_eq!(tool_category("web_fetch"), Some(ToolCategory::Network));
1028 assert_eq!(tool_category("spawn_agent"), Some(ToolCategory::Delegation));
1029 assert_eq!(tool_category("unknown_xyz"), None);
1030 }
1031
1032 #[test]
1033 fn provider_trait_list_load() {
1034 let (_t, mgr, _name) = setup_session_fixture(&minimal_session());
1035 let ids = ConversationProvider::list_conversations(&mgr, "").unwrap();
1036 assert_eq!(
1038 ids,
1039 vec!["019dabc6-8fef-7681-a054-b5bb75fcb97d".to_string()]
1040 );
1041 let view = ConversationProvider::load_conversation(
1042 &mgr,
1043 "",
1044 "019dabc6-8fef-7681-a054-b5bb75fcb97d",
1045 )
1046 .unwrap();
1047 assert_eq!(view.turns.len(), 3);
1048 }
1049
1050 #[test]
1051 fn developer_role_becomes_system() {
1052 let body = [
1053 r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/","originator":"x","cli_version":"1","source":"cli"}}"#,
1054 r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"system instructions"}]}}"#,
1055 ]
1056 .join("\n");
1057 let (_t, mgr, id) = setup_session_fixture(&body);
1058 let view = to_view(&mgr.read_session(&id).unwrap());
1059 assert_eq!(view.turns[0].role, Role::System);
1060 }
1061}