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