1use std::collections::HashMap;
36
37use crate::io::ConvoIO;
38use crate::types::{
39 EventMsg, ExecCommandEnd, Message, PatchApplyEnd, PatchChange, ResponseItem, RolloutItem,
40 Session, TokenCountInfo,
41};
42use serde_json::Value;
43use toolpath_convo::{
44 ConversationEvent, ConversationMeta, ConversationProvider, ConversationView, ConvoError,
45 EnvironmentSnapshot, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory,
46 ToolInvocation, ToolResult, Turn,
47};
48
49#[derive(Debug, Clone, Default)]
51pub struct CodexConvo {
52 io: ConvoIO,
53}
54
55impl CodexConvo {
56 pub fn new() -> Self {
57 Self { io: ConvoIO::new() }
58 }
59
60 pub fn with_resolver(resolver: crate::paths::PathResolver) -> Self {
61 Self {
62 io: ConvoIO::with_resolver(resolver),
63 }
64 }
65
66 pub fn io(&self) -> &ConvoIO {
67 &self.io
68 }
69
70 pub fn resolver(&self) -> &crate::paths::PathResolver {
71 self.io.resolver()
72 }
73
74 pub fn read_session(&self, session_id: &str) -> crate::Result<Session> {
76 self.io.read_session(session_id)
77 }
78
79 pub fn list_sessions(&self) -> crate::Result<Vec<crate::types::SessionMetadata>> {
81 self.io.list_sessions()
82 }
83
84 pub fn list_session_ids(&self) -> crate::Result<Vec<String>> {
86 self.io.list_session_ids()
87 }
88
89 pub fn most_recent_session(&self) -> crate::Result<Option<Session>> {
91 let metas = self.list_sessions()?;
92 match metas.first() {
93 Some(m) => Ok(Some(self.read_session(&m.id)?)),
94 None => Ok(None),
95 }
96 }
97
98 pub fn read_all_sessions(&self) -> crate::Result<Vec<Session>> {
100 let metas = self.list_sessions()?;
101 let mut out = Vec::with_capacity(metas.len());
102 for m in metas {
103 match self.read_session(&m.id) {
104 Ok(s) => out.push(s),
105 Err(e) => eprintln!("Warning: could not read session {}: {}", m.id, e),
106 }
107 }
108 Ok(out)
109 }
110}
111
112pub fn tool_category(name: &str) -> Option<ToolCategory> {
116 match name {
117 "read_file" | "read_many_files" | "list_dir" | "view_image" | "mcp_resource" => {
118 Some(ToolCategory::FileRead)
119 }
120 "glob" | "grep_search" | "search_file_content" | "tool_search" | "tool_suggest" => {
121 Some(ToolCategory::FileSearch)
122 }
123 "write_file" | "apply_patch" | "replace" | "edit" => Some(ToolCategory::FileWrite),
124 "shell" | "exec_command" | "unified_exec" | "write_stdin" | "js_repl" => {
125 Some(ToolCategory::Shell)
126 }
127 "web_fetch" | "web_search" | "google_web_search" => Some(ToolCategory::Network),
128 "spawn_agent" | "close_agent" | "wait_agent" | "resume_agent" | "send_message"
129 | "followup_task" | "list_agents" | "agent_jobs" | "task" | "activate_skill" => {
130 Some(ToolCategory::Delegation)
131 }
132 _ => None,
133 }
134}
135
136pub fn native_name(category: ToolCategory, args: &Value) -> Option<&'static str> {
147 match category {
148 ToolCategory::Shell => Some("exec_command"),
149 ToolCategory::FileRead => Some(if args.get("file_paths").is_some() {
150 "read_many_files"
151 } else if args.get("path").is_some() && args.get("file_path").is_none() {
152 "list_dir"
153 } else {
154 "read_file"
155 }),
156 ToolCategory::FileSearch => Some(if args.get("pattern").is_some() {
157 "grep_search"
158 } else {
159 "glob"
160 }),
161 ToolCategory::FileWrite => Some("write_file"),
162 ToolCategory::Network => Some(if args.get("url").is_some() {
163 "web_fetch"
164 } else {
165 "web_search"
166 }),
167 ToolCategory::Delegation => Some("spawn_agent"),
168 }
169}
170
171pub fn to_view(session: &Session) -> ConversationView {
176 Builder::new(session).build()
177}
178
179pub fn to_turn(line_payload: &ResponseItem) -> Option<Turn> {
183 if let ResponseItem::Message(m) = line_payload {
184 Some(message_to_turn(m, "", None, None))
185 } else {
186 None
187 }
188}
189
190struct Builder<'a> {
191 session: &'a Session,
192 turns: Vec<Turn>,
193 events: Vec<ConversationEvent>,
194 pending_reasoning_plaintext: Vec<String>,
197 current_round_id: Option<String>,
201 pending_attributed: Option<TokenUsage>,
204 working_dir: Option<String>,
205 current_model: Option<String>,
206 call_index: HashMap<String, (usize, usize)>,
207 total_usage: TokenUsage,
208 total_usage_set: bool,
209 files_changed_order: Vec<String>,
210 files_changed_seen: std::collections::HashSet<String>,
211}
212
213impl<'a> Builder<'a> {
214 fn new(session: &'a Session) -> Self {
215 Self {
216 session,
217 turns: Vec::new(),
218 events: Vec::new(),
219 pending_reasoning_plaintext: Vec::new(),
220 current_round_id: None,
221 pending_attributed: None,
222 working_dir: None,
223 current_model: None,
224 call_index: HashMap::new(),
225 total_usage: TokenUsage::default(),
226 total_usage_set: false,
227 files_changed_order: Vec::new(),
228 files_changed_seen: std::collections::HashSet::new(),
229 }
230 }
231
232 fn build(mut self) -> ConversationView {
233 for line in &self.session.lines {
234 match line.item() {
235 RolloutItem::SessionMeta(m) => {
236 self.working_dir = Some(m.cwd.to_string_lossy().to_string());
237 self.events.push(event_from_raw(
238 &line.timestamp,
239 "session_meta",
240 &line.payload,
241 ));
242 }
243 RolloutItem::TurnContext(tc) => {
244 self.start_round(&tc.turn_id);
245 if let Some(m) = &tc.model {
246 self.current_model = Some(m.clone());
247 }
248 let wd = tc.cwd.to_string_lossy().to_string();
249 if !wd.is_empty() {
250 self.working_dir = Some(wd);
251 }
252 self.events.push(event_from_raw(
253 &line.timestamp,
254 "turn_context",
255 &line.payload,
256 ));
257 }
258 RolloutItem::ResponseItem(ri) => self.handle_response_item(&line.timestamp, ri),
259 RolloutItem::EventMsg(ev) => {
260 self.handle_event_msg(&line.timestamp, ev, &line.payload)
261 }
262 RolloutItem::SessionState(payload) => {
263 self.events
264 .push(event_from_raw(&line.timestamp, "session_state", &payload));
265 }
266 RolloutItem::Compacted(payload) => {
267 self.events
268 .push(event_from_raw(&line.timestamp, "compacted", &payload));
269 }
270 RolloutItem::Unknown { kind, payload } => {
271 self.events
272 .push(event_from_raw(&line.timestamp, &kind, &payload));
273 }
274 }
275 }
276
277 self.finalize_usage();
279
280 let meta = self.session.meta();
282 let base = {
283 let wd = meta
284 .as_ref()
285 .map(|m| m.cwd.to_string_lossy().to_string())
286 .filter(|s| !s.is_empty())
287 .or_else(|| self.working_dir.clone());
288 let git = meta.as_ref().and_then(|m| m.git.as_ref());
289 let revision = git.and_then(|g| g.commit_hash.clone());
290 let branch = git.and_then(|g| g.branch.clone());
291 let remote = git.and_then(|g| g.repository_url.clone());
292 if wd.is_some() || revision.is_some() || branch.is_some() || remote.is_some() {
293 Some(SessionBase {
294 working_dir: wd,
295 vcs_revision: revision,
296 vcs_branch: branch,
297 vcs_remote: remote,
298 })
299 } else {
300 None
301 }
302 };
303
304 let producer = meta.as_ref().map(|m| ProducerInfo {
311 name: m.originator.clone(),
312 version: Some(m.cli_version.clone()),
313 });
314
315 self.turns
319 .retain(|t| !(t.text.is_empty() && t.thinking.is_none() && t.tool_uses.is_empty()));
320
321 for (idx, t) in self.turns.iter_mut().enumerate() {
327 if t.id.is_empty() {
328 t.id = format!("codex-turn-{:04}", idx + 1);
329 }
330 }
331 let mut prev: Option<String> = None;
332 for t in self.turns.iter_mut() {
333 if t.parent_id.is_none() {
334 t.parent_id = prev.clone();
335 }
336 prev = Some(t.id.clone());
337 }
338
339 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
345 for t in &self.turns {
346 seen.insert(t.id.clone());
347 }
348 for (i, e) in self.events.iter_mut().enumerate() {
349 if !seen.insert(e.id.clone()) {
350 e.id = format!("{}-{:04}", e.id, i);
351 seen.insert(e.id.clone());
352 }
353 }
354
355 ConversationView {
356 id: self.session.id.clone(),
357 started_at: self.session.started_at(),
358 last_activity: self.session.last_activity(),
359 turns: self.turns,
360 total_usage: if self.total_usage_set {
361 Some(self.total_usage)
362 } else {
363 None
364 },
365 provider_id: Some("codex".into()),
366 files_changed: self.files_changed_order,
367 session_ids: vec![],
368 events: self.events,
369 base,
370 producer,
371 }
372 }
373
374 fn handle_response_item(&mut self, timestamp: &str, ri: ResponseItem) {
375 match ri {
376 ResponseItem::Message(msg) => {
377 let turn = message_to_turn(
378 &msg,
379 timestamp,
380 self.working_dir.as_deref(),
381 self.current_model.as_deref(),
382 );
383 self.push_turn(turn);
384 }
385 ResponseItem::Reasoning(r) => {
386 if let Some(Value::Array(arr)) = r.content.as_ref() {
388 for v in arr {
389 if let Some(s) = v.get("text").and_then(|t| t.as_str()) {
390 self.pending_reasoning_plaintext.push(s.to_string());
391 }
392 }
393 }
394 for v in &r.summary {
396 if let Some(s) = v.get("text").and_then(|t| t.as_str()) {
397 self.pending_reasoning_plaintext.push(s.to_string());
398 }
399 }
400 }
401 ResponseItem::FunctionCall(fc) => {
402 let name = fc.name.clone();
403 let input = fc.arguments_as_json();
404 let input = if input.is_null() {
405 Value::String(fc.arguments.clone())
406 } else {
407 input
408 };
409 self.attach_tool_call(timestamp, fc.call_id, name, input);
410 }
411 ResponseItem::FunctionCallOutput(out) => {
412 let is_error = out
413 .extra
414 .get("is_error")
415 .and_then(Value::as_bool)
416 .unwrap_or(false);
417 self.attach_tool_output(&out.call_id, &out.output, is_error);
418 }
419 ResponseItem::CustomToolCall(ct) => {
420 let input = Value::String(ct.input.clone());
421 self.attach_tool_call(timestamp, ct.call_id, ct.name, input);
422 }
423 ResponseItem::CustomToolCallOutput(out) => {
424 let is_error = out
425 .extra
426 .get("is_error")
427 .and_then(Value::as_bool)
428 .unwrap_or(false);
429 self.attach_tool_output(&out.call_id, &out.output, is_error);
430 }
431 ResponseItem::Other { kind, payload } => {
432 self.events.push(ConversationEvent {
433 id: synthetic_event_id(timestamp, &kind),
434 timestamp: timestamp.to_string(),
435 parent_id: None,
436 event_type: format!("response_item.{}", kind),
437 data: data_from_value(&payload),
438 });
439 }
440 }
441 }
442
443 fn handle_event_msg(&mut self, timestamp: &str, ev: EventMsg, raw_payload: &Value) {
444 match ev {
445 EventMsg::TokenCount(tc) => {
446 if let Some(info) = tc.info.as_ref() {
447 let prev_total = self.total_usage.clone();
458 apply_token_count(&mut self.total_usage, info);
459 self.total_usage_set = true;
460 let delta = usage_delta(&self.total_usage, &prev_total);
461 if !is_usage_zero(&delta) {
462 self.attribute_delta(delta);
463 }
464 }
465 self.events
466 .push(event_from_raw(timestamp, "token_count", raw_payload));
467 }
468 EventMsg::ExecCommandEnd(exec) => {
469 self.apply_exec_command_end(&exec);
470 self.events
471 .push(event_from_raw(timestamp, "exec_command_end", raw_payload));
472 }
473 EventMsg::PatchApplyEnd(patch) => {
474 self.apply_patch_apply_end(&patch);
475 self.events
476 .push(event_from_raw(timestamp, "patch_apply_end", raw_payload));
477 }
478 EventMsg::TaskStarted(payload) => {
479 if let Some(tid) = payload.get("turn_id").and_then(|v| v.as_str()) {
480 self.start_round(tid);
481 }
482 self.events
483 .push(event_from_raw(timestamp, "task_started", raw_payload));
484 }
485 EventMsg::TaskComplete(_) => {
486 self.current_round_id = None;
490 self.events
491 .push(event_from_raw(timestamp, "task_complete", raw_payload));
492 }
493 EventMsg::AgentMessage(_) | EventMsg::UserMessage(_) => {
494 self.events
495 .push(event_from_raw(timestamp, ev.kind(), raw_payload));
496 }
497 EventMsg::Other { kind, payload } => {
498 self.events.push(event_from_raw(timestamp, &kind, &payload));
499 }
500 }
501 }
502
503 fn attach_tool_call(&mut self, timestamp: &str, call_id: String, name: String, input: Value) {
504 let category = tool_category(&name);
505 let invocation = ToolInvocation {
506 id: call_id.clone(),
507 name,
508 input,
509 result: None,
510 category,
511 };
512
513 let turn_idx = match self.last_assistant_turn_index() {
514 Some(idx) => idx,
515 None => {
516 let t = synthetic_assistant_turn(
517 timestamp,
518 self.working_dir.as_deref(),
519 self.current_model.as_deref(),
520 );
521 self.push_turn(t);
522 self.turns.len() - 1
523 }
524 };
525 let tool_idx = self.turns[turn_idx].tool_uses.len();
526 self.turns[turn_idx].tool_uses.push(invocation);
527 self.call_index.insert(call_id, (turn_idx, tool_idx));
528 }
529
530 fn attach_tool_output(&mut self, call_id: &str, output: &str, is_error: bool) {
531 if let Some((turn_idx, tool_idx)) = self.call_index.get(call_id).copied() {
532 let turn = &mut self.turns[turn_idx];
533 if let Some(inv) = turn.tool_uses.get_mut(tool_idx) {
534 let prior_error = inv.result.as_ref().map(|r| r.is_error).unwrap_or(false);
535 let merged = match inv.result.as_ref() {
536 Some(existing) => format!("{}\n{}", existing.content, output),
537 None => output.to_string(),
538 };
539 inv.result = Some(ToolResult {
540 content: merged,
541 is_error: is_error || prior_error,
542 });
543 }
544 }
545 }
546
547 fn apply_exec_command_end(&mut self, exec: &ExecCommandEnd) {
548 if let Some((turn_idx, tool_idx)) = self.call_index.get(&exec.call_id).copied() {
549 let turn = &mut self.turns[turn_idx];
550 if let Some(inv) = turn.tool_uses.get_mut(tool_idx) {
551 let is_error = exec.exit_code.map(|c| c != 0).unwrap_or(false);
552 if inv.result.is_none() {
553 let body = if !exec.aggregated_output.is_empty() {
554 exec.aggregated_output.clone()
555 } else if !exec.stdout.is_empty() || !exec.stderr.is_empty() {
556 let mut s = String::new();
557 if !exec.stdout.is_empty() {
558 s.push_str(&exec.stdout);
559 }
560 if !exec.stderr.is_empty() {
561 if !s.is_empty() {
562 s.push('\n');
563 }
564 s.push_str(&exec.stderr);
565 }
566 s
567 } else {
568 format!("(exit {})", exec.exit_code.unwrap_or_default())
569 };
570 inv.result = Some(ToolResult {
571 content: body,
572 is_error,
573 });
574 } else if is_error && let Some(r) = inv.result.as_mut() {
575 r.is_error = true;
576 }
577 }
578 }
579 }
580
581 fn apply_patch_apply_end(&mut self, patch: &PatchApplyEnd) {
582 let loc = self.call_index.get(&patch.call_id).copied();
583
584 let mut paths: Vec<&String> = patch.changes.keys().collect();
587 paths.sort();
588
589 if let Some((turn_idx, _tool_idx)) = loc {
593 let turn = &mut self.turns[turn_idx];
594 for path in &paths {
595 if let Some(change) = patch.changes.get(*path) {
596 let mut fm = patch_change_to_file_mutation(path, change);
597 fm.tool_id = Some(patch.call_id.clone());
598 turn.file_mutations.push(fm);
599 }
600 }
601 }
602
603 for path in paths {
604 if self.files_changed_seen.insert(path.clone()) {
605 self.files_changed_order.push(path.clone());
606 }
607 }
608 }
609
610 fn push_turn(&mut self, mut turn: Turn) {
611 self.drain_pending_onto(&mut turn);
612 if turn.role == Role::Assistant && turn.group_id.is_none() {
613 turn.group_id = self.current_round_id.clone();
614 }
615 self.turns.push(turn);
616 }
617
618 fn drain_pending_onto(&mut self, turn: &mut Turn) {
619 if turn.role != Role::Assistant {
620 return;
621 }
622 if !self.pending_reasoning_plaintext.is_empty() {
624 turn.thinking = Some(self.pending_reasoning_plaintext.join("\n\n"));
625 self.pending_reasoning_plaintext.clear();
626 }
627 if let Some(pending) = self.pending_attributed.take() {
630 add_usage(
631 turn.attributed_token_usage
632 .get_or_insert_with(TokenUsage::default),
633 &pending,
634 );
635 }
636 }
637
638 fn attribute_delta(&mut self, delta: TokenUsage) {
643 let target = self
644 .turns
645 .iter()
646 .enumerate()
647 .rev()
648 .find(|(_, t)| t.role == Role::Assistant)
649 .filter(|(_, t)| t.group_id == self.current_round_id)
650 .map(|(i, _)| i);
651 match target {
652 Some(idx) => add_usage(
653 self.turns[idx]
654 .attributed_token_usage
655 .get_or_insert_with(TokenUsage::default),
656 &delta,
657 ),
658 None => match &mut self.pending_attributed {
659 Some(acc) => add_usage(acc, &delta),
660 None => self.pending_attributed = Some(delta),
661 },
662 }
663 }
664
665 fn start_round(&mut self, round_id: &str) {
668 if round_id.is_empty() || self.current_round_id.as_deref() == Some(round_id) {
669 return;
670 }
671 self.current_round_id = Some(round_id.to_string());
672 }
673
674 fn finalize_usage(&mut self) {
681 if let Some(pending) = self.pending_attributed.take()
684 && let Some(idx) = self.turns.iter().rposition(|t| t.role == Role::Assistant)
685 {
686 add_usage(
687 self.turns[idx]
688 .attributed_token_usage
689 .get_or_insert_with(TokenUsage::default),
690 &pending,
691 );
692 }
693
694 let assistants: Vec<usize> = (0..self.turns.len())
695 .filter(|&i| self.turns[i].role == Role::Assistant)
696 .collect();
697 let mut k = 0;
698 while k < assistants.len() {
699 let start = k;
700 let mid = self.turns[assistants[k]].group_id.clone();
701 if mid.is_some() {
702 while k + 1 < assistants.len() && self.turns[assistants[k + 1]].group_id == mid {
703 k += 1;
704 }
705 }
706 let mut total: Option<TokenUsage> = None;
707 for &gi in &assistants[start..=k] {
708 if let Some(a) = &self.turns[gi].attributed_token_usage {
709 add_usage(total.get_or_insert_with(TokenUsage::default), a);
710 }
711 }
712 if let Some(total) = total {
713 self.turns[assistants[k]].token_usage = Some(total);
714 }
715 k += 1;
716 }
717 }
718
719 fn last_assistant_turn_index(&self) -> Option<usize> {
720 self.turns
721 .iter()
722 .rposition(|t| t.role == Role::Assistant)
723 .or_else(|| self.turns.len().checked_sub(1))
724 }
725}
726
727fn patch_change_to_file_mutation(path: &str, change: &PatchChange) -> FileMutation {
730 let mut fm = FileMutation {
731 path: path.to_string(),
732 ..Default::default()
733 };
734 match change {
735 PatchChange::Add { content, .. } => {
736 fm.operation = Some("add".into());
737 fm.after = Some(content.clone());
738 fm.raw_diff = Some(synth_add_diff(content));
739 }
740 PatchChange::Update {
741 unified_diff,
742 move_path,
743 ..
744 } => {
745 fm.operation = Some("update".into());
746 fm.raw_diff = Some(unified_diff.clone());
747 fm.rename_to = move_path.clone();
748 }
749 PatchChange::Delete {
750 original_content, ..
751 } => {
752 fm.operation = Some("delete".into());
753 fm.before = original_content.clone();
754 fm.raw_diff = original_content.as_deref().map(synth_delete_diff);
755 }
756 PatchChange::Unknown => {
757 fm.operation = Some("unknown".into());
758 }
759 }
760 fm
761}
762
763fn synth_add_diff(content: &str) -> String {
764 let lines: Vec<&str> = content.split('\n').collect();
765 let effective: &[&str] = if lines.last() == Some(&"") {
766 &lines[..lines.len().saturating_sub(1)]
767 } else {
768 &lines[..]
769 };
770 let mut buf = format!("@@ -0,0 +1,{} @@\n", effective.len());
771 for l in effective {
772 buf.push('+');
773 buf.push_str(l);
774 buf.push('\n');
775 }
776 buf
777}
778
779fn synth_delete_diff(original: &str) -> String {
780 let lines: Vec<&str> = original.split('\n').collect();
781 let effective: &[&str] = if lines.last() == Some(&"") {
782 &lines[..lines.len().saturating_sub(1)]
783 } else {
784 &lines[..]
785 };
786 let mut buf = format!("@@ -1,{} +0,0 @@\n", effective.len());
787 for l in effective {
788 buf.push('-');
789 buf.push_str(l);
790 buf.push('\n');
791 }
792 buf
793}
794
795fn message_to_turn(
796 msg: &Message,
797 timestamp: &str,
798 working_dir: Option<&str>,
799 model: Option<&str>,
800) -> Turn {
801 let role = match msg.role.as_str() {
802 "user" => Role::User,
803 "assistant" => Role::Assistant,
804 "developer" | "system" => Role::System,
805 other => Role::Other(other.to_string()),
806 };
807
808 let text = msg.text();
809
810 let environment = working_dir.map(|wd| EnvironmentSnapshot {
811 working_dir: Some(wd.to_string()),
812 vcs_branch: None,
813 vcs_revision: None,
814 });
815
816 Turn {
817 id: msg.id.clone().unwrap_or_default(),
818 parent_id: None,
819 group_id: None,
820 role: role.clone(),
821 timestamp: timestamp.to_string(),
822 text,
823 thinking: None,
824 tool_uses: Vec::new(),
825 model: if role == Role::Assistant {
826 model.map(str::to_string)
827 } else {
828 None
829 },
830 stop_reason: None,
831 token_usage: None,
832 attributed_token_usage: None,
833 environment,
834 delegations: Vec::new(),
835 file_mutations: Vec::new(),
836 }
837}
838
839fn synthetic_assistant_turn(
840 timestamp: &str,
841 working_dir: Option<&str>,
842 model: Option<&str>,
843) -> Turn {
844 Turn {
845 id: format!("synth-{}", timestamp),
846 parent_id: None,
847 group_id: None,
848 role: Role::Assistant,
849 timestamp: timestamp.to_string(),
850 text: String::new(),
851 thinking: None,
852 tool_uses: Vec::new(),
853 model: model.map(str::to_string),
854 stop_reason: None,
855 token_usage: None,
856 attributed_token_usage: None,
857 environment: working_dir.map(|wd| EnvironmentSnapshot {
858 working_dir: Some(wd.to_string()),
859 vcs_branch: None,
860 vcs_revision: None,
861 }),
862 delegations: Vec::new(),
863 file_mutations: Vec::new(),
864 }
865}
866
867fn add_usage(acc: &mut TokenUsage, delta: &TokenUsage) {
871 let add = |a: &mut Option<u32>, b: Option<u32>| {
872 if let Some(b) = b {
873 *a = Some(a.unwrap_or(0) + b);
874 }
875 };
876 add(&mut acc.input_tokens, delta.input_tokens);
877 add(&mut acc.output_tokens, delta.output_tokens);
878 add(&mut acc.cache_read_tokens, delta.cache_read_tokens);
879 add(&mut acc.cache_write_tokens, delta.cache_write_tokens);
880 for (class, inner) in &delta.breakdowns {
881 let target = acc.breakdowns.entry(class.clone()).or_default();
882 for (sub, n) in inner {
883 *target.entry(sub.clone()).or_insert(0) += *n;
884 }
885 }
886}
887
888fn is_usage_zero(u: &TokenUsage) -> bool {
890 [
891 u.input_tokens,
892 u.output_tokens,
893 u.cache_read_tokens,
894 u.cache_write_tokens,
895 ]
896 .iter()
897 .all(|f| f.unwrap_or(0) == 0)
898}
899
900fn usage_delta(current: &TokenUsage, prev: &TokenUsage) -> TokenUsage {
904 let sub = |c: Option<u32>, p: Option<u32>| c.map(|c| c.saturating_sub(p.unwrap_or(0)));
905 let mut delta = TokenUsage {
906 input_tokens: sub(current.input_tokens, prev.input_tokens),
907 output_tokens: sub(current.output_tokens, prev.output_tokens),
908 cache_read_tokens: sub(current.cache_read_tokens, prev.cache_read_tokens),
909 cache_write_tokens: sub(current.cache_write_tokens, prev.cache_write_tokens),
910 ..Default::default()
911 };
912 for (class, inner) in ¤t.breakdowns {
916 let prev_inner = prev.breakdowns.get(class);
917 let mut diffed: std::collections::BTreeMap<String, u32> = Default::default();
918 for (sub, cur) in inner {
919 let p = prev_inner.and_then(|m| m.get(sub)).copied().unwrap_or(0);
920 let d = cur.saturating_sub(p);
921 if d > 0 {
922 diffed.insert(sub.clone(), d);
923 }
924 }
925 if !diffed.is_empty() {
926 delta.breakdowns.insert(class.clone(), diffed);
927 }
928 }
929 delta
930}
931
932fn apply_token_count(total: &mut TokenUsage, info: &TokenCountInfo) {
933 if let Some(t) = info.total_token_usage.as_ref() {
934 total.input_tokens = t.input_tokens.or(total.input_tokens);
935 total.output_tokens = t.output_tokens.or(total.output_tokens);
936 total.cache_read_tokens = t.cached_input_tokens.or(total.cache_read_tokens);
937 if let Some(r) = t.reasoning_output_tokens.filter(|&r| r > 0) {
942 total
943 .breakdowns
944 .entry("output".to_string())
945 .or_default()
946 .insert("reasoning".to_string(), r);
947 }
948 }
949}
950
951fn event_from_raw(timestamp: &str, event_type: &str, payload: &Value) -> ConversationEvent {
952 ConversationEvent {
953 id: synthetic_event_id(timestamp, event_type),
954 timestamp: timestamp.to_string(),
955 parent_id: None,
956 event_type: event_type.to_string(),
957 data: data_from_value(payload),
958 }
959}
960
961fn synthetic_event_id(timestamp: &str, kind: &str) -> String {
962 format!("{}-{}", kind, timestamp)
963}
964
965fn data_from_value(v: &Value) -> HashMap<String, Value> {
966 match v {
967 Value::Object(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
968 _ => {
969 let mut m = HashMap::new();
970 m.insert("value".into(), v.clone());
971 m
972 }
973 }
974}
975
976impl ConversationProvider for CodexConvo {
979 fn list_conversations(&self, _project: &str) -> toolpath_convo::Result<Vec<String>> {
980 let metas = self
981 .list_sessions()
982 .map_err(|e| ConvoError::Provider(e.to_string()))?;
983 Ok(metas.into_iter().map(|m| m.id).collect())
984 }
985
986 fn load_conversation(
987 &self,
988 _project: &str,
989 conversation_id: &str,
990 ) -> toolpath_convo::Result<ConversationView> {
991 let session = self
992 .read_session(conversation_id)
993 .map_err(|e| ConvoError::Provider(e.to_string()))?;
994 Ok(to_view(&session))
995 }
996
997 fn load_metadata(
998 &self,
999 _project: &str,
1000 conversation_id: &str,
1001 ) -> toolpath_convo::Result<ConversationMeta> {
1002 let path = self
1003 .io
1004 .resolver()
1005 .find_rollout_file(conversation_id)
1006 .map_err(|e| ConvoError::Provider(e.to_string()))?;
1007 let meta = self
1008 .io
1009 .read_metadata(path)
1010 .map_err(|e| ConvoError::Provider(e.to_string()))?;
1011 Ok(ConversationMeta {
1012 id: meta.id,
1013 started_at: meta.started_at,
1014 last_activity: meta.last_activity,
1015 message_count: meta.line_count,
1016 file_path: Some(meta.file_path),
1017 predecessor: None,
1018 successor: None,
1019 })
1020 }
1021
1022 fn list_metadata(&self, _project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
1023 let metas = self
1024 .list_sessions()
1025 .map_err(|e| ConvoError::Provider(e.to_string()))?;
1026 Ok(metas
1027 .into_iter()
1028 .map(|m| ConversationMeta {
1029 id: m.id,
1030 started_at: m.started_at,
1031 last_activity: m.last_activity,
1032 message_count: m.line_count,
1033 file_path: Some(m.file_path),
1034 predecessor: None,
1035 successor: None,
1036 })
1037 .collect())
1038 }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use super::*;
1044 use std::fs;
1045 use tempfile::TempDir;
1046
1047 fn setup_session_fixture(body: &str) -> (TempDir, CodexConvo, String) {
1048 let temp = TempDir::new().unwrap();
1049 let codex = temp.path().join(".codex");
1050 let day = codex.join("sessions/2026/04/20");
1051 fs::create_dir_all(&day).unwrap();
1052 let name = "rollout-2026-04-20T10-00-00-019dabc6-8fef-7681-a054-b5bb75fcb97d";
1053 fs::write(day.join(format!("{}.jsonl", name)), body).unwrap();
1054 let resolver = crate::paths::PathResolver::new().with_codex_dir(&codex);
1055 (temp, CodexConvo::with_resolver(resolver), name.to_string())
1056 }
1057
1058 fn minimal_session() -> String {
1059 [
1060 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"}}}"#,
1061 r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"t1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#,
1062 r#"{"timestamp":"2026-04-20T16:44:37.775Z","type":"event_msg","payload":{"type":"task_started","turn_id":"t1"}}"#,
1063 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"}]}}"#,
1064 r#"{"timestamp":"2026-04-20T16:44:38.000Z","type":"response_item","payload":{"type":"reasoning","summary":[],"content":null,"encrypted_content":"encrypted-blob-1"}}"#,
1065 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"}}"#,
1066 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"}}"#,
1067 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"}}"#,
1068 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"}}"#,
1069 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"}}"#,
1070 r#"{"timestamp":"2026-04-20T16:44:38.600Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_2","output":"{\"output\":\"ok\"}"}}"#,
1071 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"}}}}"#,
1072 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}}}}"#,
1073 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}}"#,
1074 r#"{"timestamp":"2026-04-20T16:44:39.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"t1","last_agent_message":"done"}}"#,
1075 ]
1076 .join("\n")
1077 }
1078
1079 #[test]
1080 fn build_view_basic() {
1081 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1082 let session = mgr.read_session(&id).unwrap();
1083 let view = to_view(&session);
1084
1085 assert_eq!(view.id, "019dabc6-8fef-7681-a054-b5bb75fcb97d");
1086 assert_eq!(view.provider_id.as_deref(), Some("codex"));
1087 assert_eq!(view.turns.len(), 3);
1088 assert_eq!(view.turns[0].role, Role::User);
1089 assert_eq!(view.turns[0].text, "please do a thing");
1090 assert_eq!(view.turns[1].role, Role::Assistant);
1091 assert_eq!(view.turns[1].text, "working on it");
1092 assert_eq!(view.turns[1].model.as_deref(), Some("gpt-5.4"));
1093 }
1094
1095 fn two_round_session(with_last: bool) -> String {
1099 let last1 = r#","last_token_usage":{"input_tokens":100,"output_tokens":20,"cached_input_tokens":10,"total_tokens":130}"#;
1100 let last2 = r#","last_token_usage":{"input_tokens":200,"output_tokens":30,"cached_input_tokens":30,"total_tokens":260}"#;
1101 [
1102 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"}}"#.to_string(),
1103 r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"t1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#.to_string(),
1104 r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"round one"}]}}"#.to_string(),
1105 format!(
1106 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}}{}}}}}}}"#,
1107 if with_last { last1 } else { "" }
1108 ),
1109 r#"{"timestamp":"2026-04-20T16:44:38.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"first"}],"phase":"final","end_turn":true}}"#.to_string(),
1110 r#"{"timestamp":"2026-04-20T16:44:39.700Z","type":"turn_context","payload":{"turn_id":"t2","cwd":"/tmp/proj","model":"gpt-5.4"}}"#.to_string(),
1111 r#"{"timestamp":"2026-04-20T16:44:39.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"round two"}]}}"#.to_string(),
1112 format!(
1113 r#"{{"timestamp":"2026-04-20T16:44:40.800Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":300,"output_tokens":50,"cached_input_tokens":40,"total_tokens":390}}{}}}}}}}"#,
1114 if with_last { last2 } else { "" }
1115 ),
1116 r#"{"timestamp":"2026-04-20T16:44:40.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"second"}],"phase":"final","end_turn":true}}"#.to_string(),
1117 ]
1118 .join("\n")
1119 }
1120
1121 #[test]
1122 fn turn_usage_is_per_round_delta_from_last_token_usage() {
1123 let (_t, mgr, id) = setup_session_fixture(&two_round_session(true));
1124 let view = to_view(&mgr.read_session(&id).unwrap());
1125
1126 let first = view.turns[1].token_usage.as_ref().unwrap();
1127 assert_eq!(first.input_tokens, Some(100));
1128 assert_eq!(first.output_tokens, Some(20));
1129 assert_eq!(first.cache_read_tokens, Some(10));
1130
1131 let second = view.turns[3].token_usage.as_ref().unwrap();
1132 assert_eq!(second.input_tokens, Some(200));
1133 assert_eq!(second.output_tokens, Some(30));
1134 assert_eq!(second.cache_read_tokens, Some(30));
1135
1136 let total = view.total_usage.as_ref().unwrap();
1138 assert_eq!(total.input_tokens, Some(300));
1139 assert_eq!(total.output_tokens, Some(50));
1140 }
1141
1142 #[test]
1143 fn per_step_attribution_from_deduped_cumulative_deltas() {
1144 let dup = |total_out: u32, total_in: u32| {
1150 format!(
1151 r#"{{"timestamp":"2026-04-20T16:44:38.800Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":{total_in},"output_tokens":{total_out},"cached_input_tokens":0,"total_tokens":{}}}}}}}}}"#,
1152 total_in + total_out
1153 )
1154 };
1155 let body = [
1156 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"}}"#.to_string(),
1157 r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"r1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#.to_string(),
1158 r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"go"}]}}"#.to_string(),
1159 r#"{"timestamp":"2026-04-20T16:44:38.100Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"step one"}],"phase":"commentary"}}"#.to_string(),
1160 dup(40, 10), dup(40, 10), r#"{"timestamp":"2026-04-20T16:44:38.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"step two"}],"phase":"final","end_turn":true}}"#.to_string(),
1162 dup(100, 20), dup(100, 20), r#"{"timestamp":"2026-04-20T16:44:39.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"r1"}}"#.to_string(),
1164 ].join("\n");
1165 let (_t, mgr, id) = setup_session_fixture(&body);
1166 let view = to_view(&mgr.read_session(&id).unwrap());
1167
1168 let assistants: Vec<&Turn> = view
1169 .turns
1170 .iter()
1171 .filter(|t| t.role == Role::Assistant)
1172 .collect();
1173 assert_eq!(assistants.len(), 2);
1174 assert_eq!(
1176 assistants[0]
1177 .attributed_token_usage
1178 .as_ref()
1179 .unwrap()
1180 .output_tokens,
1181 Some(40)
1182 );
1183 assert_eq!(
1184 assistants[1]
1185 .attributed_token_usage
1186 .as_ref()
1187 .unwrap()
1188 .output_tokens,
1189 Some(60)
1190 );
1191 assert_eq!(
1193 assistants[1].token_usage.as_ref().unwrap().output_tokens,
1194 Some(100)
1195 );
1196 let sum: u32 = assistants
1197 .iter()
1198 .filter_map(|t| t.attributed_token_usage.as_ref()?.output_tokens)
1199 .sum();
1200 assert_eq!(sum, 100);
1201 }
1202
1203 fn reasoning_of(u: Option<&TokenUsage>) -> Option<u32> {
1205 u?.breakdowns.get("output")?.get("reasoning").copied()
1206 }
1207
1208 #[test]
1209 fn reasoning_breakdown_is_per_step_delta_and_round_sum() {
1210 let dup = |total_out: u32, total_reason: u32| {
1217 format!(
1218 r#"{{"timestamp":"2026-04-20T16:44:38.800Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":10,"output_tokens":{total_out},"reasoning_output_tokens":{total_reason},"cached_input_tokens":0,"total_tokens":{}}}}}}}}}"#,
1219 10 + total_out
1220 )
1221 };
1222 let body = [
1223 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"}}"#.to_string(),
1224 r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"r1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#.to_string(),
1225 r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"go"}]}}"#.to_string(),
1226 r#"{"timestamp":"2026-04-20T16:44:38.100Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"step one"}],"phase":"commentary"}}"#.to_string(),
1227 dup(200, 100), dup(200, 100), r#"{"timestamp":"2026-04-20T16:44:38.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"step two"}],"phase":"final","end_turn":true}}"#.to_string(),
1229 dup(500, 260), dup(500, 260), r#"{"timestamp":"2026-04-20T16:44:39.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"r1"}}"#.to_string(),
1231 ].join("\n");
1232 let (_t, mgr, id) = setup_session_fixture(&body);
1233 let view = to_view(&mgr.read_session(&id).unwrap());
1234
1235 let assistants: Vec<&Turn> = view
1236 .turns
1237 .iter()
1238 .filter(|t| t.role == Role::Assistant)
1239 .collect();
1240 assert_eq!(assistants.len(), 2);
1241 assert_eq!(
1243 reasoning_of(assistants[0].attributed_token_usage.as_ref()),
1244 Some(100)
1245 );
1246 assert_eq!(
1247 reasoning_of(assistants[1].attributed_token_usage.as_ref()),
1248 Some(160)
1249 );
1250 let round = assistants[1].token_usage.as_ref().unwrap();
1252 assert_eq!(reasoning_of(Some(round)), Some(260));
1253 assert!(260 <= round.output_tokens.unwrap());
1255 }
1256
1257 #[test]
1258 fn zero_reasoning_produces_no_breakdown_entry() {
1259 let dup = |total_out: u32| {
1262 format!(
1263 r#"{{"timestamp":"2026-04-20T16:44:38.800Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":10,"output_tokens":{total_out},"reasoning_output_tokens":0,"cached_input_tokens":0,"total_tokens":{}}}}}}}}}"#,
1264 10 + total_out
1265 )
1266 };
1267 let body = [
1268 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"}}"#.to_string(),
1269 r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"r1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#.to_string(),
1270 r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"go"}]}}"#.to_string(),
1271 r#"{"timestamp":"2026-04-20T16:44:38.100Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}],"phase":"final","end_turn":true}}"#.to_string(),
1272 dup(40), dup(40),
1273 r#"{"timestamp":"2026-04-20T16:44:39.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"r1"}}"#.to_string(),
1274 ].join("\n");
1275 let (_t, mgr, id) = setup_session_fixture(&body);
1276 let view = to_view(&mgr.read_session(&id).unwrap());
1277 let a = view
1278 .turns
1279 .iter()
1280 .find(|t| t.role == Role::Assistant)
1281 .unwrap();
1282 assert!(
1283 a.attributed_token_usage
1284 .as_ref()
1285 .unwrap()
1286 .breakdowns
1287 .is_empty()
1288 );
1289 assert!(a.token_usage.as_ref().unwrap().breakdowns.is_empty());
1290 }
1291
1292 #[test]
1293 fn round_turns_share_group_id_and_usage_lands_on_round_final_turn() {
1294 let body = [
1300 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"}}"#,
1301 r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"round-1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#,
1302 r#"{"timestamp":"2026-04-20T16:44:37.775Z","type":"event_msg","payload":{"type":"task_started","turn_id":"round-1"}}"#,
1303 r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"go"}]}}"#,
1304 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"}}"#,
1305 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},"last_token_usage":{"input_tokens":100,"output_tokens":20,"cached_input_tokens":10,"total_tokens":130}}}}"#,
1306 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}}"#,
1307 r#"{"timestamp":"2026-04-20T16:44:39.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"round-1","last_agent_message":"done"}}"#,
1308 ]
1309 .join("\n");
1310 let (_t, mgr, id) = setup_session_fixture(&body);
1311 let view = to_view(&mgr.read_session(&id).unwrap());
1312
1313 assert_eq!(view.turns.len(), 3);
1314 assert!(view.turns[0].group_id.is_none(), "user turn ungrouped");
1315 assert_eq!(view.turns[1].group_id.as_deref(), Some("round-1"));
1316 assert_eq!(view.turns[2].group_id.as_deref(), Some("round-1"));
1317 assert!(
1318 view.turns[1].token_usage.is_none(),
1319 "interior turn of the round must not carry usage"
1320 );
1321 let total = view.turns[2].token_usage.as_ref().unwrap();
1322 assert_eq!(total.output_tokens, Some(20));
1323 assert_eq!(total.input_tokens, Some(100));
1324 }
1325
1326 #[test]
1327 fn turn_usage_delta_is_computed_when_last_token_usage_missing() {
1328 let (_t, mgr, id) = setup_session_fixture(&two_round_session(false));
1331 let view = to_view(&mgr.read_session(&id).unwrap());
1332
1333 let second = view.turns[3].token_usage.as_ref().unwrap();
1334 assert_eq!(second.input_tokens, Some(200));
1335 assert_eq!(second.output_tokens, Some(30));
1336 assert_eq!(second.cache_read_tokens, Some(30));
1337 }
1338
1339 #[test]
1340 fn encrypted_reasoning_does_not_land_on_thinking() {
1341 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1345 let view = to_view(&mgr.read_session(&id).unwrap());
1346 let assistant = &view.turns[1];
1347 assert!(
1348 assistant.thinking.is_none(),
1349 "encrypted ciphertext must not appear as thinking"
1350 );
1351 }
1352
1353 #[test]
1354 fn plaintext_reasoning_lands_on_thinking() {
1355 let body = [
1358 r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/p","originator":"x","cli_version":"1","source":"cli"}}"#,
1359 r#"{"timestamp":"t","type":"response_item","payload":{"type":"reasoning","summary":[],"content":[{"type":"text","text":"I should check the file"}]}}"#,
1360 r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"checking"}]}}"#,
1361 ]
1362 .join("\n");
1363 let (_t, mgr, id) = setup_session_fixture(&body);
1364 let view = to_view(&mgr.read_session(&id).unwrap());
1365 assert_eq!(
1366 view.turns[0].thinking.as_deref(),
1367 Some("I should check the file")
1368 );
1369 }
1370
1371 #[test]
1372 fn function_call_pairs_with_output() {
1373 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1374 let view = to_view(&mgr.read_session(&id).unwrap());
1375 let assistant = &view.turns[1];
1376 assert_eq!(assistant.tool_uses.len(), 2);
1377 let exec = &assistant.tool_uses[0];
1378 assert_eq!(exec.name, "exec_command");
1379 assert_eq!(exec.category, Some(ToolCategory::Shell));
1380 assert!(exec.result.is_some());
1381 assert!(exec.result.as_ref().unwrap().content.contains("/tmp/proj"));
1382 }
1383
1384 #[test]
1385 fn custom_tool_call_preserves_raw_input() {
1386 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1387 let view = to_view(&mgr.read_session(&id).unwrap());
1388 let assistant = &view.turns[1];
1389 let apply = &assistant.tool_uses[1];
1390 assert_eq!(apply.name, "apply_patch");
1391 assert_eq!(apply.category, Some(ToolCategory::FileWrite));
1392 let input_str = apply.input.as_str().unwrap();
1393 assert!(input_str.contains("*** Begin Patch"));
1394 }
1395
1396 #[test]
1397 fn patch_apply_end_aggregates_files_changed() {
1398 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1399 let view = to_view(&mgr.read_session(&id).unwrap());
1400 assert_eq!(view.files_changed, vec!["/tmp/proj/a.rs".to_string()]);
1401 }
1402
1403 #[test]
1404 fn files_changed_order_is_deterministic() {
1405 let body = [
1408 r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/p","originator":"x","cli_version":"1","source":"cli"}}"#,
1409 r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"go"}]}}"#,
1410 r#"{"timestamp":"t","type":"response_item","payload":{"type":"custom_tool_call","call_id":"c","name":"apply_patch","input":""}}"#,
1411 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"}}}}"#,
1412 ]
1413 .join("\n");
1414 let (_t, mgr, id) = setup_session_fixture(&body);
1415 let view = to_view(&mgr.read_session(&id).unwrap());
1416 assert_eq!(
1417 view.files_changed,
1418 vec![
1419 "/p/a.rs".to_string(),
1420 "/p/m.rs".to_string(),
1421 "/p/z.rs".to_string(),
1422 ]
1423 );
1424 }
1425
1426 #[test]
1427 fn patch_apply_end_populates_turn_file_mutations() {
1428 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1429 let view = to_view(&mgr.read_session(&id).unwrap());
1430 let apply_patch_id = view
1433 .turns
1434 .iter()
1435 .flat_map(|t| t.tool_uses.iter())
1436 .find(|tu| tu.name == "apply_patch")
1437 .map(|tu| tu.id.clone())
1438 .expect("apply_patch tool invocation present");
1439 let fm = view
1440 .turns
1441 .iter()
1442 .flat_map(|t| t.file_mutations.iter())
1443 .find(|fm| fm.path == "/tmp/proj/a.rs")
1444 .expect("file mutation present");
1445 assert_eq!(fm.tool_id.as_ref(), Some(&apply_patch_id));
1446 assert_eq!(fm.operation.as_deref(), Some("add"));
1447 assert!(fm.raw_diff.is_some());
1448 }
1449
1450 #[test]
1451 fn total_usage_populated() {
1452 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1453 let view = to_view(&mgr.read_session(&id).unwrap());
1454 let u = view.total_usage.as_ref().unwrap();
1455 assert_eq!(u.input_tokens, Some(100));
1456 assert_eq!(u.output_tokens, Some(20));
1457 assert_eq!(u.cache_read_tokens, Some(10));
1458 }
1459
1460 #[test]
1461 fn events_preserve_non_turn_content() {
1462 let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1463 let view = to_view(&mgr.read_session(&id).unwrap());
1464 let kinds: Vec<&str> = view.events.iter().map(|e| e.event_type.as_str()).collect();
1465 assert!(kinds.contains(&"session_meta"));
1466 assert!(kinds.contains(&"turn_context"));
1467 assert!(kinds.contains(&"task_started"));
1468 assert!(kinds.contains(&"task_complete"));
1469 assert!(kinds.contains(&"exec_command_end"));
1470 assert!(kinds.contains(&"patch_apply_end"));
1471 assert!(kinds.contains(&"token_count"));
1472 }
1473
1474 #[test]
1475 fn tool_category_mapping() {
1476 assert_eq!(tool_category("exec_command"), Some(ToolCategory::Shell));
1477 assert_eq!(tool_category("apply_patch"), Some(ToolCategory::FileWrite));
1478 assert_eq!(tool_category("read_file"), Some(ToolCategory::FileRead));
1479 assert_eq!(tool_category("grep_search"), Some(ToolCategory::FileSearch));
1480 assert_eq!(tool_category("web_fetch"), Some(ToolCategory::Network));
1481 assert_eq!(tool_category("spawn_agent"), Some(ToolCategory::Delegation));
1482 assert_eq!(tool_category("unknown_xyz"), None);
1483 }
1484
1485 #[test]
1486 fn provider_trait_list_load() {
1487 let (_t, mgr, _name) = setup_session_fixture(&minimal_session());
1488 let ids = ConversationProvider::list_conversations(&mgr, "").unwrap();
1489 assert_eq!(
1491 ids,
1492 vec!["019dabc6-8fef-7681-a054-b5bb75fcb97d".to_string()]
1493 );
1494 let view = ConversationProvider::load_conversation(
1495 &mgr,
1496 "",
1497 "019dabc6-8fef-7681-a054-b5bb75fcb97d",
1498 )
1499 .unwrap();
1500 assert_eq!(view.turns.len(), 3);
1501 }
1502
1503 #[test]
1504 fn developer_role_becomes_system() {
1505 let body = [
1506 r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/","originator":"x","cli_version":"1","source":"cli"}}"#,
1507 r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"system instructions"}]}}"#,
1508 ]
1509 .join("\n");
1510 let (_t, mgr, id) = setup_session_fixture(&body);
1511 let view = to_view(&mgr.read_session(&id).unwrap());
1512 assert_eq!(view.turns[0].role, Role::System);
1513 }
1514}