1use std::collections::HashMap;
29use std::path::PathBuf;
30
31use serde_json::{Map, Value, json};
32use toolpath_convo::{
33 ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn,
34};
35
36use crate::types::{
37 ContentPart, CustomToolCall, CustomToolCallOutput, FunctionCall, FunctionCallOutput, Message,
38 Reasoning, RolloutLine, SessionMeta, TurnContext,
39};
40
41#[derive(Debug, Clone, Default)]
66pub struct CodexProjector {
67 pub cwd: Option<String>,
69 pub model: Option<String>,
72 pub originator: Option<String>,
76 pub cli_version: Option<String>,
78}
79
80impl CodexProjector {
81 pub fn new() -> Self {
82 Self::default()
83 }
84
85 pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
86 self.cwd = Some(cwd.into());
87 self
88 }
89
90 pub fn with_model(mut self, model: impl Into<String>) -> Self {
91 self.model = Some(model.into());
92 self
93 }
94
95 pub fn with_originator(mut self, originator: impl Into<String>) -> Self {
96 self.originator = Some(originator.into());
97 self
98 }
99}
100
101impl ConversationProjector for CodexProjector {
102 type Output = crate::types::Session;
103
104 fn project(&self, view: &ConversationView) -> Result<crate::types::Session> {
105 project_view(self, view).map_err(ConvoError::Provider)
106 }
107}
108
109fn project_view(
112 cfg: &CodexProjector,
113 view: &ConversationView,
114) -> std::result::Result<crate::types::Session, String> {
115 let cwd = cfg
116 .cwd
117 .clone()
118 .or_else(|| {
119 view.turns
120 .iter()
121 .find_map(|t| t.environment.as_ref()?.working_dir.clone())
122 })
123 .unwrap_or_else(|| "/".to_string());
124
125 let model = cfg
126 .model
127 .clone()
128 .or_else(|| view.turns.iter().find_map(|t| t.model.clone()))
129 .unwrap_or_else(|| "unknown".to_string());
130
131 let session_timestamp = view
132 .started_at
133 .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
134 .or_else(|| view.turns.first().map(|t| t.timestamp.clone()))
135 .unwrap_or_else(|| "1970-01-01T00:00:00.000Z".to_string());
136
137 let mut lines: Vec<RolloutLine> = Vec::new();
138
139 lines.push(make_session_meta_line(cfg, view, &session_timestamp, &cwd));
141
142 let last_assistant_idx = view
146 .turns
147 .iter()
148 .rposition(|t| matches!(t.role, Role::Assistant));
149
150 let group_of = |idx: usize, turn: &Turn| -> String {
153 turn.group_id
154 .clone()
155 .unwrap_or_else(|| format!("{}-t{}", view.id, idx))
156 };
157
158 let first_group = view
165 .turns
166 .iter()
167 .enumerate()
168 .find(|(_, t)| matches!(t.role, Role::Assistant))
169 .map(|(i, t)| group_of(i, t))
170 .unwrap_or_else(|| view.id.clone());
171 lines.push(make_turn_context_line(
172 &first_group,
173 &session_timestamp,
174 &cwd,
175 &model,
176 ));
177 let mut current_group = Some(first_group);
178
179 let mut running = toolpath_convo::TokenUsage::default();
184 for (idx, turn) in view.turns.iter().enumerate() {
185 if matches!(turn.role, Role::Assistant) {
186 let group = group_of(idx, turn);
187 if current_group.as_deref() != Some(&group) {
188 lines.push(make_turn_context_line(
189 &group,
190 &turn.timestamp,
191 &cwd,
192 &model,
193 ));
194 current_group = Some(group);
195 }
196 }
197 let codex = codex_extras(turn).cloned().unwrap_or_default();
198 let is_final_assistant = Some(idx) == last_assistant_idx;
199 emit_turn_lines(
200 turn,
201 &codex,
202 is_final_assistant,
203 &cwd,
204 &mut lines,
205 &mut running,
206 );
207 }
208
209 Ok(crate::types::Session {
210 id: view.id.clone(),
211 file_path: PathBuf::new(),
212 lines,
213 })
214}
215
216fn make_session_meta_line(
217 cfg: &CodexProjector,
218 view: &ConversationView,
219 timestamp: &str,
220 cwd: &str,
221) -> RolloutLine {
222 let meta = SessionMeta {
223 id: view.id.clone(),
224 timestamp: timestamp.to_string(),
225 cwd: PathBuf::from(cwd),
226 originator: cfg
227 .originator
228 .clone()
229 .unwrap_or_else(|| "codex-toolpath".to_string()),
230 cli_version: cfg
231 .cli_version
232 .clone()
233 .unwrap_or_else(|| "0.0.0-projected".to_string()),
234 source: "cli".to_string(),
235 forked_from_id: None,
236 agent_nickname: None,
237 agent_role: None,
238 agent_path: None,
239 model_provider: Some("openai".to_string()),
240 base_instructions: None,
241 dynamic_tools: None,
242 memory_mode: None,
243 git: None,
244 extra: HashMap::new(),
245 };
246 RolloutLine {
247 timestamp: timestamp.to_string(),
248 kind: "session_meta".to_string(),
249 payload: serde_json::to_value(&meta).unwrap_or(Value::Null),
250 extra: HashMap::new(),
251 }
252}
253
254fn make_turn_context_line(turn_id: &str, timestamp: &str, cwd: &str, model: &str) -> RolloutLine {
255 let tc = TurnContext {
256 turn_id: turn_id.to_string(),
257 cwd: PathBuf::from(cwd),
258 current_date: None,
259 timezone: None,
260 approval_policy: None,
261 sandbox_policy: None,
262 model: Some(model.to_string()),
263 personality: None,
264 collaboration_mode: None,
265 extra: HashMap::new(),
266 };
267 RolloutLine {
268 timestamp: timestamp.to_string(),
269 kind: "turn_context".to_string(),
270 payload: serde_json::to_value(&tc).unwrap_or(Value::Null),
271 extra: HashMap::new(),
272 }
273}
274
275fn codex_extras(_turn: &Turn) -> Option<&'static Map<String, Value>> {
280 None
281}
282
283fn emit_turn_lines(
284 turn: &Turn,
285 codex: &Map<String, Value>,
286 is_final_assistant: bool,
287 session_cwd: &str,
288 lines: &mut Vec<RolloutLine>,
289 running: &mut toolpath_convo::TokenUsage,
290) {
291 match &turn.role {
292 Role::User => emit_user_message(turn, lines),
293 Role::Assistant => {
294 emit_assistant(turn, codex, is_final_assistant, session_cwd, lines, running)
295 }
296 Role::System => emit_developer_message(turn, lines),
297 Role::Other(_) => {
298 emit_developer_message(turn, lines);
302 }
303 }
304}
305
306fn emit_user_message(turn: &Turn, lines: &mut Vec<RolloutLine>) {
307 let msg = Message {
308 role: "user".to_string(),
309 content: vec![ContentPart::InputText {
310 text: turn.text.clone(),
311 extra: HashMap::new(),
312 }],
313 id: None,
314 end_turn: None,
315 phase: None,
316 extra: HashMap::new(),
317 };
318 lines.push(response_item_line(
319 &turn.timestamp,
320 "message",
321 serde_json::to_value(&msg).unwrap_or(Value::Null),
322 ));
323 if !turn.text.is_empty() && !is_system_caveat(&turn.text) {
324 lines.push(event_msg_line(
325 &turn.timestamp,
326 json!({
327 "type": "user_message",
328 "message": turn.text,
329 "images": [],
330 "local_images": [],
331 "text_elements": [],
332 }),
333 ));
334 }
335}
336
337fn is_system_caveat(text: &str) -> bool {
338 let trimmed = text.trim_start();
339 trimmed.starts_with('<') && trimmed.contains('>')
340}
341
342fn emit_developer_message(turn: &Turn, lines: &mut Vec<RolloutLine>) {
343 let msg = Message {
344 role: "developer".to_string(),
345 content: vec![ContentPart::InputText {
346 text: turn.text.clone(),
347 extra: HashMap::new(),
348 }],
349 id: None,
350 end_turn: None,
351 phase: None,
352 extra: HashMap::new(),
353 };
354 lines.push(response_item_line(
355 &turn.timestamp,
356 "message",
357 serde_json::to_value(&msg).unwrap_or(Value::Null),
358 ));
359}
360
361fn emit_assistant(
362 turn: &Turn,
363 codex: &Map<String, Value>,
364 is_final_assistant: bool,
365 session_cwd: &str,
366 lines: &mut Vec<RolloutLine>,
367 running: &mut toolpath_convo::TokenUsage,
368) {
369 let encrypted_blobs = codex
378 .get("reasoning_encrypted")
379 .and_then(Value::as_array)
380 .cloned()
381 .unwrap_or_default();
382 if !encrypted_blobs.is_empty() {
383 for blob in encrypted_blobs {
384 let enc = blob.as_str().map(str::to_string);
385 let r = Reasoning {
386 id: None,
387 summary: vec![],
388 content: None,
389 encrypted_content: enc,
390 extra: HashMap::new(),
391 };
392 lines.push(response_item_line(
393 &turn.timestamp,
394 "reasoning",
395 serde_json::to_value(&r).unwrap_or(Value::Null),
396 ));
397 }
398 } else if let Some(thinking) = &turn.thinking
399 && !thinking.is_empty()
400 {
401 let r = Reasoning {
405 id: None,
406 summary: vec![json!({"type": "summary_text", "text": thinking})],
407 content: None,
408 encrypted_content: None,
409 extra: HashMap::new(),
410 };
411 lines.push(response_item_line(
412 &turn.timestamp,
413 "reasoning",
414 serde_json::to_value(&r).unwrap_or(Value::Null),
415 ));
416 }
417
418 let phase = Some(if is_final_assistant {
421 "final_answer".to_string()
422 } else {
423 "commentary".to_string()
424 });
425 let has_thinking = turn.thinking.as_ref().is_some_and(|s| !s.is_empty());
433 if is_final_assistant || !turn.text.is_empty() || !turn.tool_uses.is_empty() || has_thinking {
434 let msg = Message {
435 role: "assistant".to_string(),
436 content: vec![ContentPart::OutputText {
437 text: turn.text.clone(),
438 extra: HashMap::new(),
439 }],
440 id: None,
441 end_turn: None,
442 phase: phase.clone(),
443 extra: HashMap::new(),
444 };
445 lines.push(response_item_line(
446 &turn.timestamp,
447 "message",
448 serde_json::to_value(&msg).unwrap_or(Value::Null),
449 ));
450 if !turn.text.is_empty() {
451 lines.push(event_msg_line(
452 &turn.timestamp,
453 json!({
454 "type": "agent_message",
455 "message": turn.text,
456 "phase": phase,
457 "memory_citation": Value::Null,
458 }),
459 ));
460 }
461 }
462
463 let tool_extras = codex
464 .get("tool_extras")
465 .and_then(Value::as_object)
466 .cloned()
467 .unwrap_or_default();
468 for tu in &turn.tool_uses {
469 let name = tool_native_name(tu);
470 emit_tool_call(turn, tu, &name, &tool_extras, session_cwd, lines);
471 }
472
473 if let Some(contribution) = turn
479 .attributed_token_usage
480 .as_ref()
481 .or(turn.token_usage.as_ref())
482 {
483 add_codex_usage(running, contribution);
484 lines.push(event_msg_line(
485 &turn.timestamp,
486 json!({
487 "type": "token_count",
488 "info": {
489 "total_token_usage": convo_usage_to_codex_json(running),
490 },
491 "rate_limits": Value::Null,
492 }),
493 ));
494 }
495}
496
497fn add_codex_usage(acc: &mut toolpath_convo::TokenUsage, delta: &toolpath_convo::TokenUsage) {
499 let add = |a: &mut Option<u32>, b: Option<u32>| {
500 if let Some(b) = b {
501 *a = Some(a.unwrap_or(0) + b);
502 }
503 };
504 add(&mut acc.input_tokens, delta.input_tokens);
505 add(&mut acc.output_tokens, delta.output_tokens);
506 add(&mut acc.cache_read_tokens, delta.cache_read_tokens);
507 add(&mut acc.cache_write_tokens, delta.cache_write_tokens);
508}
509
510fn emit_tool_call(
511 turn: &Turn,
512 tu: &ToolInvocation,
513 name: &str,
514 tool_extras: &Map<String, Value>,
515 session_cwd: &str,
516 lines: &mut Vec<RolloutLine>,
517) {
518 let extras_for_call = tool_extras
519 .get(&tu.id)
520 .and_then(Value::as_object)
521 .cloned()
522 .unwrap_or_default();
523
524 if name == "apply_patch" {
525 let input_str = match &tu.input {
526 Value::String(s) => s.clone(),
527 other => serde_json::to_string(other).unwrap_or_default(),
528 };
529 let status = extras_for_call
530 .get("status")
531 .and_then(Value::as_str)
532 .map(str::to_string);
533 let call = CustomToolCall {
534 name: name.to_string(),
535 input: input_str,
536 call_id: tu.id.clone(),
537 status,
538 id: None,
539 extra: HashMap::new(),
540 };
541 lines.push(response_item_line(
542 &turn.timestamp,
543 "custom_tool_call",
544 serde_json::to_value(&call).unwrap_or(Value::Null),
545 ));
546 if let Some(result) = &tu.result {
547 let mut out_extra = HashMap::new();
548 if result.is_error {
549 out_extra.insert("is_error".to_string(), Value::Bool(true));
550 }
551 let out = CustomToolCallOutput {
552 call_id: tu.id.clone(),
553 output: result.content.clone(),
554 extra: out_extra,
555 };
556 lines.push(response_item_line(
557 &turn.timestamp,
558 "custom_tool_call_output",
559 serde_json::to_value(&out).unwrap_or(Value::Null),
560 ));
561 lines.push(event_msg_line(
562 &turn.timestamp,
563 json!({
564 "type": "patch_apply_end",
565 "call_id": tu.id,
566 "stdout": result.content,
567 "stderr": "",
568 "success": !result.is_error,
569 "changes": {},
570 }),
571 ));
572 }
573 } else {
574 let arguments = serde_json::to_string(&tu.input).unwrap_or_else(|_| "{}".into());
576 let call = FunctionCall {
577 name: name.to_string(),
578 arguments,
579 call_id: tu.id.clone(),
580 id: None,
581 namespace: None,
582 extra: HashMap::new(),
583 };
584 lines.push(response_item_line(
585 &turn.timestamp,
586 "function_call",
587 serde_json::to_value(&call).unwrap_or(Value::Null),
588 ));
589 if let Some(result) = &tu.result {
590 let mut out_extra = HashMap::new();
595 if result.is_error {
596 out_extra.insert("is_error".to_string(), Value::Bool(true));
597 }
598 let out = FunctionCallOutput {
599 call_id: tu.id.clone(),
600 output: result.content.clone(),
601 extra: out_extra,
602 };
603 lines.push(response_item_line(
604 &turn.timestamp,
605 "function_call_output",
606 serde_json::to_value(&out).unwrap_or(Value::Null),
607 ));
608 if name == "exec_command" || name == "shell" {
611 let cmd_str = tu
612 .input
613 .get("cmd")
614 .or_else(|| tu.input.get("command"))
615 .and_then(Value::as_str)
616 .unwrap_or("")
617 .to_string();
618 let command = if cmd_str.is_empty() {
619 Vec::<String>::new()
620 } else {
621 vec!["bash".to_string(), "-lc".to_string(), cmd_str.clone()]
622 };
623 let exit_code = if result.is_error { 1 } else { 0 };
624 lines.push(event_msg_line(
625 &turn.timestamp,
626 json!({
627 "type": "exec_command_end",
628 "call_id": tu.id,
629 "turn_id": turn.id,
630 "command": command,
631 "cwd": session_cwd,
632 "parsed_cmd": [{"type": "unknown", "cmd": cmd_str}],
633 "source": "unified_exec_startup",
634 "stdout": "",
635 "stderr": "",
636 "aggregated_output": result.content,
637 "exit_code": exit_code,
638 "duration": {"secs": 0, "nanos": 0},
639 "formatted_output": "",
640 "status": "completed",
641 }),
642 ));
643 }
644 }
645 }
646}
647
648fn tool_native_name(tu: &ToolInvocation) -> String {
655 if crate::provider::tool_category(&tu.name).is_some() {
656 return tu.name.clone();
657 }
658 if let Some(cat) = tu.category
659 && let Some(remap) = crate::provider::native_name(cat, &tu.input)
660 {
661 return remap.to_string();
662 }
663 tu.name.clone()
664}
665
666fn response_item_line(timestamp: &str, inner_type: &str, mut payload: Value) -> RolloutLine {
667 if let Value::Object(m) = &mut payload {
671 m.entry("type".to_string())
672 .or_insert_with(|| Value::String(inner_type.to_string()));
673 }
674 RolloutLine {
675 timestamp: timestamp.to_string(),
676 kind: "response_item".to_string(),
677 payload,
678 extra: HashMap::new(),
679 }
680}
681
682fn event_msg_line(timestamp: &str, payload: Value) -> RolloutLine {
683 RolloutLine {
684 timestamp: timestamp.to_string(),
685 kind: "event_msg".to_string(),
686 payload,
687 extra: HashMap::new(),
688 }
689}
690
691fn convo_usage_to_codex_json(u: &toolpath_convo::TokenUsage) -> Value {
692 let mut m = Map::new();
693 if let Some(v) = u.input_tokens {
694 m.insert("input_tokens".to_string(), Value::from(v));
695 }
696 if let Some(v) = u.cache_read_tokens {
697 m.insert("cached_input_tokens".to_string(), Value::from(v));
698 }
699 if let Some(v) = u.output_tokens {
700 m.insert("output_tokens".to_string(), Value::from(v));
701 }
702 Value::Object(m)
703}
704
705#[cfg(test)]
708mod tests {
709 use super::*;
710 use toolpath_convo::{TokenUsage, ToolCategory, ToolInvocation, ToolResult};
711
712 fn user_turn(id: &str, text: &str) -> Turn {
713 Turn {
714 id: id.into(),
715 parent_id: None,
716 group_id: None,
717 role: Role::User,
718 timestamp: "2026-04-20T16:00:00.000Z".into(),
719 text: text.into(),
720 thinking: None,
721 tool_uses: vec![],
722 model: None,
723 stop_reason: None,
724 token_usage: None,
725 attributed_token_usage: None,
726 environment: None,
727 delegations: vec![],
728 file_mutations: Vec::new(),
729 }
730 }
731
732 fn assistant_turn(id: &str, text: &str) -> Turn {
733 Turn {
734 id: id.into(),
735 parent_id: None,
736 group_id: None,
737 role: Role::Assistant,
738 timestamp: "2026-04-20T16:00:01.000Z".into(),
739 text: text.into(),
740 thinking: None,
741 tool_uses: vec![],
742 model: Some("gpt-5.4".into()),
743 stop_reason: Some("stop".into()),
744 token_usage: Some(TokenUsage {
745 input_tokens: Some(100),
746 output_tokens: Some(50),
747 cache_read_tokens: None,
748 cache_write_tokens: None,
749 ..Default::default()
750 }),
751 attributed_token_usage: None,
752 environment: None,
753 delegations: vec![],
754 file_mutations: Vec::new(),
755 }
756 }
757
758 fn view_with(turns: Vec<Turn>) -> ConversationView {
759 ConversationView {
760 id: "session-uuid".into(),
761 started_at: None,
762 last_activity: None,
763 turns,
764 total_usage: None,
765 provider_id: Some("codex".into()),
766 files_changed: vec![],
767 session_ids: vec![],
768 events: vec![],
769 ..Default::default()
770 }
771 }
772
773 fn line_kinds(s: &crate::types::Session) -> Vec<String> {
774 s.lines.iter().map(|l| l.kind.clone()).collect()
775 }
776
777 fn inner_types(s: &crate::types::Session) -> Vec<String> {
778 s.lines
779 .iter()
780 .map(|l| {
781 l.payload
782 .get("type")
783 .and_then(Value::as_str)
784 .unwrap_or("")
785 .to_string()
786 })
787 .collect()
788 }
789
790 #[test]
791 fn empty_view_yields_session_meta_plus_turn_context() {
792 let s = CodexProjector::default()
793 .project(&view_with(vec![]))
794 .unwrap();
795 assert_eq!(s.id, "session-uuid");
796 assert_eq!(line_kinds(&s), vec!["session_meta", "turn_context"]);
797 }
798
799 #[test]
800 fn user_turn_becomes_user_role_message() {
801 let s = CodexProjector::default()
802 .project(&view_with(vec![user_turn("u1", "hi")]))
803 .unwrap();
804 let kinds = line_kinds(&s);
805 assert_eq!(
808 kinds,
809 vec!["session_meta", "turn_context", "response_item", "event_msg"]
810 );
811 let payload = &s.lines[2].payload;
812 assert_eq!(payload["type"], "message");
813 assert_eq!(payload["role"], "user");
814 assert_eq!(payload["content"][0]["type"], "input_text");
815 assert_eq!(payload["content"][0]["text"], "hi");
816 let event = &s.lines[3].payload;
817 assert_eq!(event["type"], "user_message");
818 assert_eq!(event["message"], "hi");
819 }
820
821 #[test]
822 fn user_turn_with_system_caveat_skips_event_msg() {
823 let s = CodexProjector::default()
826 .project(&view_with(vec![user_turn(
827 "u1",
828 "<local-command-caveat>do not respond</local-command-caveat>",
829 )]))
830 .unwrap();
831 let kinds = line_kinds(&s);
832 assert_eq!(kinds, vec!["session_meta", "turn_context", "response_item"]);
833 }
834
835 #[test]
836 fn assistant_turn_with_function_call_and_output() {
837 let mut t = assistant_turn("a1", "Let me check.");
838 t.tool_uses = vec![ToolInvocation {
839 id: "call_001".into(),
840 name: "exec_command".into(),
841 input: json!({"cmd": "pwd"}),
842 result: Some(ToolResult {
843 content: "/tmp\n".into(),
844 is_error: false,
845 }),
846 category: Some(ToolCategory::Shell),
847 }];
848 let s = CodexProjector::default()
849 .project(&view_with(vec![t]))
850 .unwrap();
851 let inner = inner_types(&s);
852 assert_eq!(
858 inner,
859 vec![
860 "",
861 "",
862 "message",
863 "agent_message",
864 "function_call",
865 "function_call_output",
866 "exec_command_end",
867 "token_count"
868 ]
869 );
870
871 let fc_payload = &s.lines[4].payload;
873 assert_eq!(fc_payload["type"], "function_call");
874 assert_eq!(fc_payload["call_id"], "call_001");
875 assert_eq!(fc_payload["name"], "exec_command");
876 let args = fc_payload["arguments"].as_str().unwrap();
877 let parsed: Value = serde_json::from_str(args).unwrap();
878 assert_eq!(parsed["cmd"], "pwd");
879
880 let fco_payload = &s.lines[5].payload;
881 assert_eq!(fco_payload["type"], "function_call_output");
882 assert_eq!(fco_payload["call_id"], "call_001");
883 assert_eq!(fco_payload["output"], "/tmp\n");
884
885 let exec = &s.lines[6].payload;
887 assert_eq!(exec["type"], "exec_command_end");
888 assert_eq!(exec["call_id"], "call_001");
889 assert_eq!(exec["aggregated_output"], "/tmp\n");
890 assert_eq!(exec["exit_code"], 0);
891 }
892
893 #[test]
894 fn foreign_tool_name_remaps_to_codex_via_category() {
895 let mut t = assistant_turn("a1", "");
898 t.tool_uses = vec![ToolInvocation {
899 id: "call_x".into(),
900 name: "Bash".into(),
901 input: json!({"command": "ls"}),
902 result: None,
903 category: Some(ToolCategory::Shell),
904 }];
905 let s = CodexProjector::default()
906 .project(&view_with(vec![t]))
907 .unwrap();
908 let fc = &s
909 .lines
910 .iter()
911 .find(|l| l.payload.get("type").and_then(Value::as_str) == Some("function_call"))
912 .expect("function_call line")
913 .payload;
914 assert_eq!(fc["name"], "exec_command");
915 }
916
917 #[test]
918 fn apply_patch_preserves_free_form_input_as_custom_tool_call() {
919 let patch_body =
923 "*** Begin Patch\n*** Add File: hello.rs\n+fn main(){}\n*** End Patch".to_string();
924 let mut t = assistant_turn("a1", "");
925 t.tool_uses = vec![ToolInvocation {
926 id: "call_p".into(),
927 name: "apply_patch".into(),
928 input: Value::String(patch_body.clone()),
929 result: Some(ToolResult {
930 content: "ok".into(),
931 is_error: false,
932 }),
933 category: Some(ToolCategory::FileWrite),
934 }];
935 let s = CodexProjector::default()
936 .project(&view_with(vec![t]))
937 .unwrap();
938 let inner = inner_types(&s);
939 assert!(inner.contains(&"custom_tool_call".to_string()));
940 assert!(inner.contains(&"custom_tool_call_output".to_string()));
941 let ctc = s
942 .lines
943 .iter()
944 .find(|l| l.payload.get("type").and_then(Value::as_str) == Some("custom_tool_call"))
945 .unwrap();
946 assert_eq!(ctc.payload["input"], patch_body);
947 }
948
949 #[test]
950 fn assistant_thinking_emits_reasoning_summary() {
951 let mut t = assistant_turn("a1", "Done.");
952 t.thinking = Some("hmm let me consider".into());
953 let s = CodexProjector::default()
954 .project(&view_with(vec![t]))
955 .unwrap();
956 let reasoning_line = s
957 .lines
958 .iter()
959 .find(|l| l.payload.get("type").and_then(Value::as_str) == Some("reasoning"));
960 assert!(reasoning_line.is_some(), "expected a reasoning line");
961 let summary = &reasoning_line.unwrap().payload["summary"];
962 assert!(summary.is_array());
963 assert_eq!(summary[0]["type"], "summary_text");
964 assert_eq!(summary[0]["text"], "hmm let me consider");
965 }
966
967 #[test]
968 fn session_meta_carries_default_originator() {
969 let s = CodexProjector::default()
970 .project(&view_with(vec![]))
971 .unwrap();
972 let meta = &s.lines[0].payload;
973 assert_eq!(meta["originator"], "codex-toolpath");
974 assert_eq!(meta["source"], "cli");
975 }
976
977 #[test]
978 fn last_assistant_gets_phase_final_others_commentary() {
979 let mut t1 = assistant_turn("a1", "first");
984 t1.stop_reason = Some("tool_use".into());
985 let mut t2 = assistant_turn("a2", "second");
986 t2.stop_reason = Some("tool_use".into());
987 let mut t3 = assistant_turn("a3", "All done.");
988 t3.stop_reason = Some("end_turn".into());
989
990 let s = CodexProjector::default()
991 .project(&view_with(vec![t1, t2, t3]))
992 .unwrap();
993 let messages: Vec<&RolloutLine> = s
994 .lines
995 .iter()
996 .filter(|l| {
997 l.payload.get("type").and_then(Value::as_str) == Some("message")
998 && l.payload.get("role").and_then(Value::as_str) == Some("assistant")
999 })
1000 .collect();
1001 assert_eq!(messages.len(), 3);
1002 assert_eq!(messages[0].payload["phase"], "commentary");
1003 assert_eq!(messages[1].payload["phase"], "commentary");
1004 assert_eq!(messages[2].payload["phase"], "final_answer");
1005 for m in &messages {
1007 assert!(
1008 m.payload.get("end_turn").is_none(),
1009 "end_turn should be absent: {}",
1010 m.payload
1011 );
1012 }
1013 }
1014
1015 #[test]
1016 fn jsonl_serializes_one_line_per_entry() {
1017 let s = CodexProjector::default()
1018 .project(&view_with(vec![user_turn("u1", "hi")]))
1019 .unwrap();
1020 for line in &s.lines {
1021 let serialized = serde_json::to_string(line).unwrap();
1022 assert!(
1023 !serialized.contains('\n'),
1024 "line serialized with newline: {}",
1025 serialized
1026 );
1027 }
1028 }
1029}