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