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