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(&first_group, &session_timestamp, &cwd, &model));
172 let mut current_group = Some(first_group);
173
174 let mut running = toolpath_convo::TokenUsage::default();
179 for (idx, turn) in view.turns.iter().enumerate() {
180 if matches!(turn.role, Role::Assistant) {
181 let group = group_of(idx, turn);
182 if current_group.as_deref() != Some(&group) {
183 lines.push(make_turn_context_line(&group, &turn.timestamp, &cwd, &model));
184 current_group = Some(group);
185 }
186 }
187 let codex = codex_extras(turn).cloned().unwrap_or_default();
188 let is_final_assistant = Some(idx) == last_assistant_idx;
189 emit_turn_lines(turn, &codex, is_final_assistant, &cwd, &mut lines, &mut running);
190 }
191
192 Ok(crate::types::Session {
193 id: view.id.clone(),
194 file_path: PathBuf::new(),
195 lines,
196 })
197}
198
199fn make_session_meta_line(
200 cfg: &CodexProjector,
201 view: &ConversationView,
202 timestamp: &str,
203 cwd: &str,
204) -> RolloutLine {
205 let meta = SessionMeta {
206 id: view.id.clone(),
207 timestamp: timestamp.to_string(),
208 cwd: PathBuf::from(cwd),
209 originator: cfg
210 .originator
211 .clone()
212 .unwrap_or_else(|| "codex-toolpath".to_string()),
213 cli_version: cfg
214 .cli_version
215 .clone()
216 .unwrap_or_else(|| "0.0.0-projected".to_string()),
217 source: "cli".to_string(),
218 forked_from_id: None,
219 agent_nickname: None,
220 agent_role: None,
221 agent_path: None,
222 model_provider: Some("openai".to_string()),
223 base_instructions: None,
224 dynamic_tools: None,
225 memory_mode: None,
226 git: None,
227 extra: HashMap::new(),
228 };
229 RolloutLine {
230 timestamp: timestamp.to_string(),
231 kind: "session_meta".to_string(),
232 payload: serde_json::to_value(&meta).unwrap_or(Value::Null),
233 extra: HashMap::new(),
234 }
235}
236
237fn make_turn_context_line(
238 turn_id: &str,
239 timestamp: &str,
240 cwd: &str,
241 model: &str,
242) -> RolloutLine {
243 let tc = TurnContext {
244 turn_id: turn_id.to_string(),
245 cwd: PathBuf::from(cwd),
246 current_date: None,
247 timezone: None,
248 approval_policy: None,
249 sandbox_policy: None,
250 model: Some(model.to_string()),
251 personality: None,
252 collaboration_mode: None,
253 extra: HashMap::new(),
254 };
255 RolloutLine {
256 timestamp: timestamp.to_string(),
257 kind: "turn_context".to_string(),
258 payload: serde_json::to_value(&tc).unwrap_or(Value::Null),
259 extra: HashMap::new(),
260 }
261}
262
263fn codex_extras(_turn: &Turn) -> Option<&'static Map<String, Value>> {
268 None
269}
270
271fn emit_turn_lines(
272 turn: &Turn,
273 codex: &Map<String, Value>,
274 is_final_assistant: bool,
275 session_cwd: &str,
276 lines: &mut Vec<RolloutLine>,
277 running: &mut toolpath_convo::TokenUsage,
278) {
279 match &turn.role {
280 Role::User => emit_user_message(turn, lines),
281 Role::Assistant => {
282 emit_assistant(turn, codex, is_final_assistant, session_cwd, lines, running)
283 }
284 Role::System => emit_developer_message(turn, lines),
285 Role::Other(_) => {
286 emit_developer_message(turn, lines);
290 }
291 }
292}
293
294fn emit_user_message(turn: &Turn, lines: &mut Vec<RolloutLine>) {
295 let msg = Message {
296 role: "user".to_string(),
297 content: vec![ContentPart::InputText {
298 text: turn.text.clone(),
299 extra: HashMap::new(),
300 }],
301 id: None,
302 end_turn: None,
303 phase: None,
304 extra: HashMap::new(),
305 };
306 lines.push(response_item_line(
307 &turn.timestamp,
308 "message",
309 serde_json::to_value(&msg).unwrap_or(Value::Null),
310 ));
311 if !turn.text.is_empty() && !is_system_caveat(&turn.text) {
312 lines.push(event_msg_line(
313 &turn.timestamp,
314 json!({
315 "type": "user_message",
316 "message": turn.text,
317 "images": [],
318 "local_images": [],
319 "text_elements": [],
320 }),
321 ));
322 }
323}
324
325fn is_system_caveat(text: &str) -> bool {
326 let trimmed = text.trim_start();
327 trimmed.starts_with('<') && trimmed.contains('>')
328}
329
330fn emit_developer_message(turn: &Turn, lines: &mut Vec<RolloutLine>) {
331 let msg = Message {
332 role: "developer".to_string(),
333 content: vec![ContentPart::InputText {
334 text: turn.text.clone(),
335 extra: HashMap::new(),
336 }],
337 id: None,
338 end_turn: None,
339 phase: None,
340 extra: HashMap::new(),
341 };
342 lines.push(response_item_line(
343 &turn.timestamp,
344 "message",
345 serde_json::to_value(&msg).unwrap_or(Value::Null),
346 ));
347}
348
349fn emit_assistant(
350 turn: &Turn,
351 codex: &Map<String, Value>,
352 is_final_assistant: bool,
353 session_cwd: &str,
354 lines: &mut Vec<RolloutLine>,
355 running: &mut toolpath_convo::TokenUsage,
356) {
357 let encrypted_blobs = codex
366 .get("reasoning_encrypted")
367 .and_then(Value::as_array)
368 .cloned()
369 .unwrap_or_default();
370 if !encrypted_blobs.is_empty() {
371 for blob in encrypted_blobs {
372 let enc = blob.as_str().map(str::to_string);
373 let r = Reasoning {
374 id: None,
375 summary: vec![],
376 content: None,
377 encrypted_content: enc,
378 extra: HashMap::new(),
379 };
380 lines.push(response_item_line(
381 &turn.timestamp,
382 "reasoning",
383 serde_json::to_value(&r).unwrap_or(Value::Null),
384 ));
385 }
386 } else if let Some(thinking) = &turn.thinking
387 && !thinking.is_empty()
388 {
389 let r = Reasoning {
393 id: None,
394 summary: vec![json!({"type": "summary_text", "text": thinking})],
395 content: None,
396 encrypted_content: None,
397 extra: HashMap::new(),
398 };
399 lines.push(response_item_line(
400 &turn.timestamp,
401 "reasoning",
402 serde_json::to_value(&r).unwrap_or(Value::Null),
403 ));
404 }
405
406 let phase = Some(if is_final_assistant {
409 "final_answer".to_string()
410 } else {
411 "commentary".to_string()
412 });
413 let has_thinking = turn.thinking.as_ref().is_some_and(|s| !s.is_empty());
421 if is_final_assistant || !turn.text.is_empty() || !turn.tool_uses.is_empty() || has_thinking {
422 let msg = Message {
423 role: "assistant".to_string(),
424 content: vec![ContentPart::OutputText {
425 text: turn.text.clone(),
426 extra: HashMap::new(),
427 }],
428 id: None,
429 end_turn: None,
430 phase: phase.clone(),
431 extra: HashMap::new(),
432 };
433 lines.push(response_item_line(
434 &turn.timestamp,
435 "message",
436 serde_json::to_value(&msg).unwrap_or(Value::Null),
437 ));
438 if !turn.text.is_empty() {
439 lines.push(event_msg_line(
440 &turn.timestamp,
441 json!({
442 "type": "agent_message",
443 "message": turn.text,
444 "phase": phase,
445 "memory_citation": Value::Null,
446 }),
447 ));
448 }
449 }
450
451 let tool_extras = codex
452 .get("tool_extras")
453 .and_then(Value::as_object)
454 .cloned()
455 .unwrap_or_default();
456 for tu in &turn.tool_uses {
457 let name = tool_native_name(tu);
458 emit_tool_call(turn, tu, &name, &tool_extras, session_cwd, lines);
459 }
460
461 if let Some(contribution) = turn
467 .attributed_token_usage
468 .as_ref()
469 .or(turn.token_usage.as_ref())
470 {
471 add_codex_usage(running, contribution);
472 lines.push(event_msg_line(
473 &turn.timestamp,
474 json!({
475 "type": "token_count",
476 "info": {
477 "total_token_usage": convo_usage_to_codex_json(running),
478 },
479 "rate_limits": Value::Null,
480 }),
481 ));
482 }
483}
484
485fn add_codex_usage(acc: &mut toolpath_convo::TokenUsage, delta: &toolpath_convo::TokenUsage) {
487 let add = |a: &mut Option<u32>, b: Option<u32>| {
488 if let Some(b) = b {
489 *a = Some(a.unwrap_or(0) + b);
490 }
491 };
492 add(&mut acc.input_tokens, delta.input_tokens);
493 add(&mut acc.output_tokens, delta.output_tokens);
494 add(&mut acc.cache_read_tokens, delta.cache_read_tokens);
495 add(&mut acc.cache_write_tokens, delta.cache_write_tokens);
496}
497
498fn emit_tool_call(
499 turn: &Turn,
500 tu: &ToolInvocation,
501 name: &str,
502 tool_extras: &Map<String, Value>,
503 session_cwd: &str,
504 lines: &mut Vec<RolloutLine>,
505) {
506 let extras_for_call = tool_extras
507 .get(&tu.id)
508 .and_then(Value::as_object)
509 .cloned()
510 .unwrap_or_default();
511
512 if name == "apply_patch" {
513 let input_str = match &tu.input {
514 Value::String(s) => s.clone(),
515 other => serde_json::to_string(other).unwrap_or_default(),
516 };
517 let status = extras_for_call
518 .get("status")
519 .and_then(Value::as_str)
520 .map(str::to_string);
521 let call = CustomToolCall {
522 name: name.to_string(),
523 input: input_str,
524 call_id: tu.id.clone(),
525 status,
526 id: None,
527 extra: HashMap::new(),
528 };
529 lines.push(response_item_line(
530 &turn.timestamp,
531 "custom_tool_call",
532 serde_json::to_value(&call).unwrap_or(Value::Null),
533 ));
534 if let Some(result) = &tu.result {
535 let mut out_extra = HashMap::new();
536 if result.is_error {
537 out_extra.insert("is_error".to_string(), Value::Bool(true));
538 }
539 let out = CustomToolCallOutput {
540 call_id: tu.id.clone(),
541 output: result.content.clone(),
542 extra: out_extra,
543 };
544 lines.push(response_item_line(
545 &turn.timestamp,
546 "custom_tool_call_output",
547 serde_json::to_value(&out).unwrap_or(Value::Null),
548 ));
549 lines.push(event_msg_line(
550 &turn.timestamp,
551 json!({
552 "type": "patch_apply_end",
553 "call_id": tu.id,
554 "stdout": result.content,
555 "stderr": "",
556 "success": !result.is_error,
557 "changes": {},
558 }),
559 ));
560 }
561 } else {
562 let arguments = serde_json::to_string(&tu.input).unwrap_or_else(|_| "{}".into());
564 let call = FunctionCall {
565 name: name.to_string(),
566 arguments,
567 call_id: tu.id.clone(),
568 id: None,
569 namespace: None,
570 extra: HashMap::new(),
571 };
572 lines.push(response_item_line(
573 &turn.timestamp,
574 "function_call",
575 serde_json::to_value(&call).unwrap_or(Value::Null),
576 ));
577 if let Some(result) = &tu.result {
578 let mut out_extra = HashMap::new();
583 if result.is_error {
584 out_extra.insert("is_error".to_string(), Value::Bool(true));
585 }
586 let out = FunctionCallOutput {
587 call_id: tu.id.clone(),
588 output: result.content.clone(),
589 extra: out_extra,
590 };
591 lines.push(response_item_line(
592 &turn.timestamp,
593 "function_call_output",
594 serde_json::to_value(&out).unwrap_or(Value::Null),
595 ));
596 if name == "exec_command" || name == "shell" {
599 let cmd_str = tu
600 .input
601 .get("cmd")
602 .or_else(|| tu.input.get("command"))
603 .and_then(Value::as_str)
604 .unwrap_or("")
605 .to_string();
606 let command = if cmd_str.is_empty() {
607 Vec::<String>::new()
608 } else {
609 vec!["bash".to_string(), "-lc".to_string(), cmd_str.clone()]
610 };
611 let exit_code = if result.is_error { 1 } else { 0 };
612 lines.push(event_msg_line(
613 &turn.timestamp,
614 json!({
615 "type": "exec_command_end",
616 "call_id": tu.id,
617 "turn_id": turn.id,
618 "command": command,
619 "cwd": session_cwd,
620 "parsed_cmd": [{"type": "unknown", "cmd": cmd_str}],
621 "source": "unified_exec_startup",
622 "stdout": "",
623 "stderr": "",
624 "aggregated_output": result.content,
625 "exit_code": exit_code,
626 "duration": {"secs": 0, "nanos": 0},
627 "formatted_output": "",
628 "status": "completed",
629 }),
630 ));
631 }
632 }
633 }
634}
635
636fn tool_native_name(tu: &ToolInvocation) -> String {
643 if crate::provider::tool_category(&tu.name).is_some() {
644 return tu.name.clone();
645 }
646 if let Some(cat) = tu.category
647 && let Some(remap) = crate::provider::native_name(cat, &tu.input)
648 {
649 return remap.to_string();
650 }
651 tu.name.clone()
652}
653
654fn response_item_line(timestamp: &str, inner_type: &str, mut payload: Value) -> RolloutLine {
655 if let Value::Object(m) = &mut payload {
659 m.entry("type".to_string())
660 .or_insert_with(|| Value::String(inner_type.to_string()));
661 }
662 RolloutLine {
663 timestamp: timestamp.to_string(),
664 kind: "response_item".to_string(),
665 payload,
666 extra: HashMap::new(),
667 }
668}
669
670fn event_msg_line(timestamp: &str, payload: Value) -> RolloutLine {
671 RolloutLine {
672 timestamp: timestamp.to_string(),
673 kind: "event_msg".to_string(),
674 payload,
675 extra: HashMap::new(),
676 }
677}
678
679fn convo_usage_to_codex_json(u: &toolpath_convo::TokenUsage) -> Value {
680 let mut m = Map::new();
681 if let Some(v) = u.input_tokens {
682 m.insert("input_tokens".to_string(), Value::from(v));
683 }
684 if let Some(v) = u.cache_read_tokens {
685 m.insert("cached_input_tokens".to_string(), Value::from(v));
686 }
687 if let Some(v) = u.output_tokens {
688 m.insert("output_tokens".to_string(), Value::from(v));
689 }
690 Value::Object(m)
691}
692
693#[cfg(test)]
696mod tests {
697 use super::*;
698 use toolpath_convo::{TokenUsage, ToolCategory, ToolInvocation, ToolResult};
699
700 fn user_turn(id: &str, text: &str) -> Turn {
701 Turn {
702 id: id.into(),
703 parent_id: None,
704 group_id: None,
705 role: Role::User,
706 timestamp: "2026-04-20T16:00:00.000Z".into(),
707 text: text.into(),
708 thinking: None,
709 tool_uses: vec![],
710 model: None,
711 stop_reason: None,
712 token_usage: None,
713 attributed_token_usage: None,
714 environment: None,
715 delegations: vec![],
716 file_mutations: Vec::new(),
717 }
718 }
719
720 fn assistant_turn(id: &str, text: &str) -> Turn {
721 Turn {
722 id: id.into(),
723 parent_id: None,
724 group_id: None,
725 role: Role::Assistant,
726 timestamp: "2026-04-20T16:00:01.000Z".into(),
727 text: text.into(),
728 thinking: None,
729 tool_uses: vec![],
730 model: Some("gpt-5.4".into()),
731 stop_reason: Some("stop".into()),
732 token_usage: Some(TokenUsage {
733 input_tokens: Some(100),
734 output_tokens: Some(50),
735 cache_read_tokens: None,
736 cache_write_tokens: None,
737 ..Default::default()
738 }),
739 attributed_token_usage: None,
740 environment: None,
741 delegations: vec![],
742 file_mutations: Vec::new(),
743 }
744 }
745
746 fn view_with(turns: Vec<Turn>) -> ConversationView {
747 ConversationView {
748 id: "session-uuid".into(),
749 started_at: None,
750 last_activity: None,
751 turns,
752 total_usage: None,
753 provider_id: Some("codex".into()),
754 files_changed: vec![],
755 session_ids: vec![],
756 events: vec![],
757 ..Default::default()
758 }
759 }
760
761 fn line_kinds(s: &crate::types::Session) -> Vec<String> {
762 s.lines.iter().map(|l| l.kind.clone()).collect()
763 }
764
765 fn inner_types(s: &crate::types::Session) -> Vec<String> {
766 s.lines
767 .iter()
768 .map(|l| {
769 l.payload
770 .get("type")
771 .and_then(Value::as_str)
772 .unwrap_or("")
773 .to_string()
774 })
775 .collect()
776 }
777
778 #[test]
779 fn empty_view_yields_session_meta_plus_turn_context() {
780 let s = CodexProjector::default()
781 .project(&view_with(vec![]))
782 .unwrap();
783 assert_eq!(s.id, "session-uuid");
784 assert_eq!(line_kinds(&s), vec!["session_meta", "turn_context"]);
785 }
786
787 #[test]
788 fn user_turn_becomes_user_role_message() {
789 let s = CodexProjector::default()
790 .project(&view_with(vec![user_turn("u1", "hi")]))
791 .unwrap();
792 let kinds = line_kinds(&s);
793 assert_eq!(
796 kinds,
797 vec!["session_meta", "turn_context", "response_item", "event_msg"]
798 );
799 let payload = &s.lines[2].payload;
800 assert_eq!(payload["type"], "message");
801 assert_eq!(payload["role"], "user");
802 assert_eq!(payload["content"][0]["type"], "input_text");
803 assert_eq!(payload["content"][0]["text"], "hi");
804 let event = &s.lines[3].payload;
805 assert_eq!(event["type"], "user_message");
806 assert_eq!(event["message"], "hi");
807 }
808
809 #[test]
810 fn user_turn_with_system_caveat_skips_event_msg() {
811 let s = CodexProjector::default()
814 .project(&view_with(vec![user_turn(
815 "u1",
816 "<local-command-caveat>do not respond</local-command-caveat>",
817 )]))
818 .unwrap();
819 let kinds = line_kinds(&s);
820 assert_eq!(kinds, vec!["session_meta", "turn_context", "response_item"]);
821 }
822
823 #[test]
824 fn assistant_turn_with_function_call_and_output() {
825 let mut t = assistant_turn("a1", "Let me check.");
826 t.tool_uses = vec![ToolInvocation {
827 id: "call_001".into(),
828 name: "exec_command".into(),
829 input: json!({"cmd": "pwd"}),
830 result: Some(ToolResult {
831 content: "/tmp\n".into(),
832 is_error: false,
833 }),
834 category: Some(ToolCategory::Shell),
835 }];
836 let s = CodexProjector::default()
837 .project(&view_with(vec![t]))
838 .unwrap();
839 let inner = inner_types(&s);
840 assert_eq!(
846 inner,
847 vec![
848 "",
849 "",
850 "message",
851 "agent_message",
852 "function_call",
853 "function_call_output",
854 "exec_command_end",
855 "token_count"
856 ]
857 );
858
859 let fc_payload = &s.lines[4].payload;
861 assert_eq!(fc_payload["type"], "function_call");
862 assert_eq!(fc_payload["call_id"], "call_001");
863 assert_eq!(fc_payload["name"], "exec_command");
864 let args = fc_payload["arguments"].as_str().unwrap();
865 let parsed: Value = serde_json::from_str(args).unwrap();
866 assert_eq!(parsed["cmd"], "pwd");
867
868 let fco_payload = &s.lines[5].payload;
869 assert_eq!(fco_payload["type"], "function_call_output");
870 assert_eq!(fco_payload["call_id"], "call_001");
871 assert_eq!(fco_payload["output"], "/tmp\n");
872
873 let exec = &s.lines[6].payload;
875 assert_eq!(exec["type"], "exec_command_end");
876 assert_eq!(exec["call_id"], "call_001");
877 assert_eq!(exec["aggregated_output"], "/tmp\n");
878 assert_eq!(exec["exit_code"], 0);
879 }
880
881 #[test]
882 fn foreign_tool_name_remaps_to_codex_via_category() {
883 let mut t = assistant_turn("a1", "");
886 t.tool_uses = vec![ToolInvocation {
887 id: "call_x".into(),
888 name: "Bash".into(),
889 input: json!({"command": "ls"}),
890 result: None,
891 category: Some(ToolCategory::Shell),
892 }];
893 let s = CodexProjector::default()
894 .project(&view_with(vec![t]))
895 .unwrap();
896 let fc = &s
897 .lines
898 .iter()
899 .find(|l| l.payload.get("type").and_then(Value::as_str) == Some("function_call"))
900 .expect("function_call line")
901 .payload;
902 assert_eq!(fc["name"], "exec_command");
903 }
904
905 #[test]
906 fn apply_patch_preserves_free_form_input_as_custom_tool_call() {
907 let patch_body =
911 "*** Begin Patch\n*** Add File: hello.rs\n+fn main(){}\n*** End Patch".to_string();
912 let mut t = assistant_turn("a1", "");
913 t.tool_uses = vec![ToolInvocation {
914 id: "call_p".into(),
915 name: "apply_patch".into(),
916 input: Value::String(patch_body.clone()),
917 result: Some(ToolResult {
918 content: "ok".into(),
919 is_error: false,
920 }),
921 category: Some(ToolCategory::FileWrite),
922 }];
923 let s = CodexProjector::default()
924 .project(&view_with(vec![t]))
925 .unwrap();
926 let inner = inner_types(&s);
927 assert!(inner.contains(&"custom_tool_call".to_string()));
928 assert!(inner.contains(&"custom_tool_call_output".to_string()));
929 let ctc = s
930 .lines
931 .iter()
932 .find(|l| l.payload.get("type").and_then(Value::as_str) == Some("custom_tool_call"))
933 .unwrap();
934 assert_eq!(ctc.payload["input"], patch_body);
935 }
936
937 #[test]
938 fn assistant_thinking_emits_reasoning_summary() {
939 let mut t = assistant_turn("a1", "Done.");
940 t.thinking = Some("hmm let me consider".into());
941 let s = CodexProjector::default()
942 .project(&view_with(vec![t]))
943 .unwrap();
944 let reasoning_line = s
945 .lines
946 .iter()
947 .find(|l| l.payload.get("type").and_then(Value::as_str) == Some("reasoning"));
948 assert!(reasoning_line.is_some(), "expected a reasoning line");
949 let summary = &reasoning_line.unwrap().payload["summary"];
950 assert!(summary.is_array());
951 assert_eq!(summary[0]["type"], "summary_text");
952 assert_eq!(summary[0]["text"], "hmm let me consider");
953 }
954
955 #[test]
956 fn session_meta_carries_default_originator() {
957 let s = CodexProjector::default()
958 .project(&view_with(vec![]))
959 .unwrap();
960 let meta = &s.lines[0].payload;
961 assert_eq!(meta["originator"], "codex-toolpath");
962 assert_eq!(meta["source"], "cli");
963 }
964
965 #[test]
966 fn last_assistant_gets_phase_final_others_commentary() {
967 let mut t1 = assistant_turn("a1", "first");
972 t1.stop_reason = Some("tool_use".into());
973 let mut t2 = assistant_turn("a2", "second");
974 t2.stop_reason = Some("tool_use".into());
975 let mut t3 = assistant_turn("a3", "All done.");
976 t3.stop_reason = Some("end_turn".into());
977
978 let s = CodexProjector::default()
979 .project(&view_with(vec![t1, t2, t3]))
980 .unwrap();
981 let messages: Vec<&RolloutLine> = s
982 .lines
983 .iter()
984 .filter(|l| {
985 l.payload.get("type").and_then(Value::as_str) == Some("message")
986 && l.payload.get("role").and_then(Value::as_str) == Some("assistant")
987 })
988 .collect();
989 assert_eq!(messages.len(), 3);
990 assert_eq!(messages[0].payload["phase"], "commentary");
991 assert_eq!(messages[1].payload["phase"], "commentary");
992 assert_eq!(messages[2].payload["phase"], "final_answer");
993 for m in &messages {
995 assert!(
996 m.payload.get("end_turn").is_none(),
997 "end_turn should be absent: {}",
998 m.payload
999 );
1000 }
1001 }
1002
1003 #[test]
1004 fn jsonl_serializes_one_line_per_entry() {
1005 let s = CodexProjector::default()
1006 .project(&view_with(vec![user_turn("u1", "hi")]))
1007 .unwrap();
1008 for line in &s.lines {
1009 let serialized = serde_json::to_string(line).unwrap();
1010 assert!(
1011 !serialized.contains('\n'),
1012 "line serialized with newline: {}",
1013 serialized
1014 );
1015 }
1016 }
1017}