use std::collections::HashMap;
use std::path::PathBuf;
use serde_json::{Map, Value, json};
use toolpath_convo::{
ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn,
};
use crate::types::{
ContentPart, CustomToolCall, CustomToolCallOutput, FunctionCall, FunctionCallOutput, Message,
Reasoning, RolloutLine, SessionMeta, TurnContext,
};
#[derive(Debug, Clone, Default)]
pub struct CodexProjector {
pub cwd: Option<String>,
pub model: Option<String>,
pub originator: Option<String>,
pub cli_version: Option<String>,
}
impl CodexProjector {
pub fn new() -> Self {
Self::default()
}
pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn with_originator(mut self, originator: impl Into<String>) -> Self {
self.originator = Some(originator.into());
self
}
}
impl ConversationProjector for CodexProjector {
type Output = crate::types::Session;
fn project(&self, view: &ConversationView) -> Result<crate::types::Session> {
project_view(self, view).map_err(ConvoError::Provider)
}
}
fn project_view(
cfg: &CodexProjector,
view: &ConversationView,
) -> std::result::Result<crate::types::Session, String> {
let cwd = cfg
.cwd
.clone()
.or_else(|| {
view.turns
.iter()
.find_map(|t| t.environment.as_ref()?.working_dir.clone())
})
.unwrap_or_else(|| "/".to_string());
let model = cfg
.model
.clone()
.or_else(|| view.turns.iter().find_map(|t| t.model.clone()))
.unwrap_or_else(|| "unknown".to_string());
let session_timestamp = view
.started_at
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
.or_else(|| view.turns.first().map(|t| t.timestamp.clone()))
.unwrap_or_else(|| "1970-01-01T00:00:00.000Z".to_string());
let mut lines: Vec<RolloutLine> = Vec::new();
lines.push(make_session_meta_line(cfg, view, &session_timestamp, &cwd));
lines.push(make_turn_context_line(
view,
&session_timestamp,
&cwd,
&model,
));
let last_assistant_idx = view
.turns
.iter()
.rposition(|t| matches!(t.role, Role::Assistant));
for (idx, turn) in view.turns.iter().enumerate() {
let codex = codex_extras(turn).cloned().unwrap_or_default();
let is_final_assistant = Some(idx) == last_assistant_idx;
emit_turn_lines(turn, &codex, is_final_assistant, &cwd, &mut lines);
}
Ok(crate::types::Session {
id: view.id.clone(),
file_path: PathBuf::new(),
lines,
})
}
fn make_session_meta_line(
cfg: &CodexProjector,
view: &ConversationView,
timestamp: &str,
cwd: &str,
) -> RolloutLine {
let meta = SessionMeta {
id: view.id.clone(),
timestamp: timestamp.to_string(),
cwd: PathBuf::from(cwd),
originator: cfg
.originator
.clone()
.unwrap_or_else(|| "codex-toolpath".to_string()),
cli_version: cfg
.cli_version
.clone()
.unwrap_or_else(|| "0.0.0-projected".to_string()),
source: "cli".to_string(),
forked_from_id: None,
agent_nickname: None,
agent_role: None,
agent_path: None,
model_provider: Some("openai".to_string()),
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
git: None,
extra: HashMap::new(),
};
RolloutLine {
timestamp: timestamp.to_string(),
kind: "session_meta".to_string(),
payload: serde_json::to_value(&meta).unwrap_or(Value::Null),
extra: HashMap::new(),
}
}
fn make_turn_context_line(
view: &ConversationView,
timestamp: &str,
cwd: &str,
model: &str,
) -> RolloutLine {
let turn_id = view.id.clone();
let tc = TurnContext {
turn_id,
cwd: PathBuf::from(cwd),
current_date: None,
timezone: None,
approval_policy: None,
sandbox_policy: None,
model: Some(model.to_string()),
personality: None,
collaboration_mode: None,
extra: HashMap::new(),
};
RolloutLine {
timestamp: timestamp.to_string(),
kind: "turn_context".to_string(),
payload: serde_json::to_value(&tc).unwrap_or(Value::Null),
extra: HashMap::new(),
}
}
fn codex_extras(_turn: &Turn) -> Option<&'static Map<String, Value>> {
None
}
fn emit_turn_lines(
turn: &Turn,
codex: &Map<String, Value>,
is_final_assistant: bool,
session_cwd: &str,
lines: &mut Vec<RolloutLine>,
) {
match &turn.role {
Role::User => emit_user_message(turn, lines),
Role::Assistant => emit_assistant(turn, codex, is_final_assistant, session_cwd, lines),
Role::System => emit_developer_message(turn, lines),
Role::Other(_) => {
emit_developer_message(turn, lines);
}
}
}
fn emit_user_message(turn: &Turn, lines: &mut Vec<RolloutLine>) {
let msg = Message {
role: "user".to_string(),
content: vec![ContentPart::InputText {
text: turn.text.clone(),
extra: HashMap::new(),
}],
id: None,
end_turn: None,
phase: None,
extra: HashMap::new(),
};
lines.push(response_item_line(
&turn.timestamp,
"message",
serde_json::to_value(&msg).unwrap_or(Value::Null),
));
if !turn.text.is_empty() && !is_system_caveat(&turn.text) {
lines.push(event_msg_line(
&turn.timestamp,
json!({
"type": "user_message",
"message": turn.text,
"images": [],
"local_images": [],
"text_elements": [],
}),
));
}
}
fn is_system_caveat(text: &str) -> bool {
let trimmed = text.trim_start();
trimmed.starts_with('<') && trimmed.contains('>')
}
fn emit_developer_message(turn: &Turn, lines: &mut Vec<RolloutLine>) {
let msg = Message {
role: "developer".to_string(),
content: vec![ContentPart::InputText {
text: turn.text.clone(),
extra: HashMap::new(),
}],
id: None,
end_turn: None,
phase: None,
extra: HashMap::new(),
};
lines.push(response_item_line(
&turn.timestamp,
"message",
serde_json::to_value(&msg).unwrap_or(Value::Null),
));
}
fn emit_assistant(
turn: &Turn,
codex: &Map<String, Value>,
is_final_assistant: bool,
session_cwd: &str,
lines: &mut Vec<RolloutLine>,
) {
let encrypted_blobs = codex
.get("reasoning_encrypted")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if !encrypted_blobs.is_empty() {
for blob in encrypted_blobs {
let enc = blob.as_str().map(str::to_string);
let r = Reasoning {
id: None,
summary: vec![],
content: None,
encrypted_content: enc,
extra: HashMap::new(),
};
lines.push(response_item_line(
&turn.timestamp,
"reasoning",
serde_json::to_value(&r).unwrap_or(Value::Null),
));
}
} else if let Some(thinking) = &turn.thinking
&& !thinking.is_empty()
{
let r = Reasoning {
id: None,
summary: vec![json!({"type": "summary_text", "text": thinking})],
content: None,
encrypted_content: None,
extra: HashMap::new(),
};
lines.push(response_item_line(
&turn.timestamp,
"reasoning",
serde_json::to_value(&r).unwrap_or(Value::Null),
));
}
if let Some(usage) = &turn.token_usage {
lines.push(event_msg_line(
&turn.timestamp,
json!({
"type": "token_count",
"info": {
"total_token_usage": convo_usage_to_codex_json(usage),
"last_token_usage": convo_usage_to_codex_json(usage),
},
"rate_limits": Value::Null,
}),
));
}
let phase = Some(if is_final_assistant {
"final_answer".to_string()
} else {
"commentary".to_string()
});
let has_thinking = turn.thinking.as_ref().is_some_and(|s| !s.is_empty());
if is_final_assistant || !turn.text.is_empty() || !turn.tool_uses.is_empty() || has_thinking {
let msg = Message {
role: "assistant".to_string(),
content: vec![ContentPart::OutputText {
text: turn.text.clone(),
extra: HashMap::new(),
}],
id: None,
end_turn: None,
phase: phase.clone(),
extra: HashMap::new(),
};
lines.push(response_item_line(
&turn.timestamp,
"message",
serde_json::to_value(&msg).unwrap_or(Value::Null),
));
if !turn.text.is_empty() {
lines.push(event_msg_line(
&turn.timestamp,
json!({
"type": "agent_message",
"message": turn.text,
"phase": phase,
"memory_citation": Value::Null,
}),
));
}
}
let tool_extras = codex
.get("tool_extras")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
for tu in &turn.tool_uses {
let name = tool_native_name(tu);
emit_tool_call(turn, tu, &name, &tool_extras, session_cwd, lines);
}
}
fn emit_tool_call(
turn: &Turn,
tu: &ToolInvocation,
name: &str,
tool_extras: &Map<String, Value>,
session_cwd: &str,
lines: &mut Vec<RolloutLine>,
) {
let extras_for_call = tool_extras
.get(&tu.id)
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
if name == "apply_patch" {
let input_str = match &tu.input {
Value::String(s) => s.clone(),
other => serde_json::to_string(other).unwrap_or_default(),
};
let status = extras_for_call
.get("status")
.and_then(Value::as_str)
.map(str::to_string);
let call = CustomToolCall {
name: name.to_string(),
input: input_str,
call_id: tu.id.clone(),
status,
id: None,
extra: HashMap::new(),
};
lines.push(response_item_line(
&turn.timestamp,
"custom_tool_call",
serde_json::to_value(&call).unwrap_or(Value::Null),
));
if let Some(result) = &tu.result {
let mut out_extra = HashMap::new();
if result.is_error {
out_extra.insert("is_error".to_string(), Value::Bool(true));
}
let out = CustomToolCallOutput {
call_id: tu.id.clone(),
output: result.content.clone(),
extra: out_extra,
};
lines.push(response_item_line(
&turn.timestamp,
"custom_tool_call_output",
serde_json::to_value(&out).unwrap_or(Value::Null),
));
lines.push(event_msg_line(
&turn.timestamp,
json!({
"type": "patch_apply_end",
"call_id": tu.id,
"stdout": result.content,
"stderr": "",
"success": !result.is_error,
"changes": {},
}),
));
}
} else {
let arguments = serde_json::to_string(&tu.input).unwrap_or_else(|_| "{}".into());
let call = FunctionCall {
name: name.to_string(),
arguments,
call_id: tu.id.clone(),
id: None,
namespace: None,
extra: HashMap::new(),
};
lines.push(response_item_line(
&turn.timestamp,
"function_call",
serde_json::to_value(&call).unwrap_or(Value::Null),
));
if let Some(result) = &tu.result {
let mut out_extra = HashMap::new();
if result.is_error {
out_extra.insert("is_error".to_string(), Value::Bool(true));
}
let out = FunctionCallOutput {
call_id: tu.id.clone(),
output: result.content.clone(),
extra: out_extra,
};
lines.push(response_item_line(
&turn.timestamp,
"function_call_output",
serde_json::to_value(&out).unwrap_or(Value::Null),
));
if name == "exec_command" || name == "shell" {
let cmd_str = tu
.input
.get("cmd")
.or_else(|| tu.input.get("command"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let command = if cmd_str.is_empty() {
Vec::<String>::new()
} else {
vec!["bash".to_string(), "-lc".to_string(), cmd_str.clone()]
};
let exit_code = if result.is_error { 1 } else { 0 };
lines.push(event_msg_line(
&turn.timestamp,
json!({
"type": "exec_command_end",
"call_id": tu.id,
"turn_id": turn.id,
"command": command,
"cwd": session_cwd,
"parsed_cmd": [{"type": "unknown", "cmd": cmd_str}],
"source": "unified_exec_startup",
"stdout": "",
"stderr": "",
"aggregated_output": result.content,
"exit_code": exit_code,
"duration": {"secs": 0, "nanos": 0},
"formatted_output": "",
"status": "completed",
}),
));
}
}
}
}
fn tool_native_name(tu: &ToolInvocation) -> String {
if crate::provider::tool_category(&tu.name).is_some() {
return tu.name.clone();
}
if let Some(cat) = tu.category
&& let Some(remap) = crate::provider::native_name(cat, &tu.input)
{
return remap.to_string();
}
tu.name.clone()
}
fn response_item_line(timestamp: &str, inner_type: &str, mut payload: Value) -> RolloutLine {
if let Value::Object(m) = &mut payload {
m.entry("type".to_string())
.or_insert_with(|| Value::String(inner_type.to_string()));
}
RolloutLine {
timestamp: timestamp.to_string(),
kind: "response_item".to_string(),
payload,
extra: HashMap::new(),
}
}
fn event_msg_line(timestamp: &str, payload: Value) -> RolloutLine {
RolloutLine {
timestamp: timestamp.to_string(),
kind: "event_msg".to_string(),
payload,
extra: HashMap::new(),
}
}
fn convo_usage_to_codex_json(u: &toolpath_convo::TokenUsage) -> Value {
let mut m = Map::new();
if let Some(v) = u.input_tokens {
m.insert("input_tokens".to_string(), Value::from(v));
}
if let Some(v) = u.cache_read_tokens {
m.insert("cached_input_tokens".to_string(), Value::from(v));
}
if let Some(v) = u.output_tokens {
m.insert("output_tokens".to_string(), Value::from(v));
}
Value::Object(m)
}
#[cfg(test)]
mod tests {
use super::*;
use toolpath_convo::{TokenUsage, ToolCategory, ToolInvocation, ToolResult};
fn user_turn(id: &str, text: &str) -> Turn {
Turn {
id: id.into(),
parent_id: None,
role: Role::User,
timestamp: "2026-04-20T16:00:00.000Z".into(),
text: text.into(),
thinking: None,
tool_uses: vec![],
model: None,
stop_reason: None,
token_usage: None,
environment: None,
delegations: vec![],
file_mutations: Vec::new(),
}
}
fn assistant_turn(id: &str, text: &str) -> Turn {
Turn {
id: id.into(),
parent_id: None,
role: Role::Assistant,
timestamp: "2026-04-20T16:00:01.000Z".into(),
text: text.into(),
thinking: None,
tool_uses: vec![],
model: Some("gpt-5.4".into()),
stop_reason: Some("stop".into()),
token_usage: Some(TokenUsage {
input_tokens: Some(100),
output_tokens: Some(50),
cache_read_tokens: None,
cache_write_tokens: None,
}),
environment: None,
delegations: vec![],
file_mutations: Vec::new(),
}
}
fn view_with(turns: Vec<Turn>) -> ConversationView {
ConversationView {
id: "session-uuid".into(),
started_at: None,
last_activity: None,
turns,
total_usage: None,
provider_id: Some("codex".into()),
files_changed: vec![],
session_ids: vec![],
events: vec![],
..Default::default()
}
}
fn line_kinds(s: &crate::types::Session) -> Vec<String> {
s.lines.iter().map(|l| l.kind.clone()).collect()
}
fn inner_types(s: &crate::types::Session) -> Vec<String> {
s.lines
.iter()
.map(|l| {
l.payload
.get("type")
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
})
.collect()
}
#[test]
fn empty_view_yields_session_meta_plus_turn_context() {
let s = CodexProjector::default()
.project(&view_with(vec![]))
.unwrap();
assert_eq!(s.id, "session-uuid");
assert_eq!(line_kinds(&s), vec!["session_meta", "turn_context"]);
}
#[test]
fn user_turn_becomes_user_role_message() {
let s = CodexProjector::default()
.project(&view_with(vec![user_turn("u1", "hi")]))
.unwrap();
let kinds = line_kinds(&s);
assert_eq!(
kinds,
vec!["session_meta", "turn_context", "response_item", "event_msg"]
);
let payload = &s.lines[2].payload;
assert_eq!(payload["type"], "message");
assert_eq!(payload["role"], "user");
assert_eq!(payload["content"][0]["type"], "input_text");
assert_eq!(payload["content"][0]["text"], "hi");
let event = &s.lines[3].payload;
assert_eq!(event["type"], "user_message");
assert_eq!(event["message"], "hi");
}
#[test]
fn user_turn_with_system_caveat_skips_event_msg() {
let s = CodexProjector::default()
.project(&view_with(vec![user_turn(
"u1",
"<local-command-caveat>do not respond</local-command-caveat>",
)]))
.unwrap();
let kinds = line_kinds(&s);
assert_eq!(kinds, vec!["session_meta", "turn_context", "response_item"]);
}
#[test]
fn assistant_turn_with_function_call_and_output() {
let mut t = assistant_turn("a1", "Let me check.");
t.tool_uses = vec![ToolInvocation {
id: "call_001".into(),
name: "exec_command".into(),
input: json!({"cmd": "pwd"}),
result: Some(ToolResult {
content: "/tmp\n".into(),
is_error: false,
}),
category: Some(ToolCategory::Shell),
}];
let s = CodexProjector::default()
.project(&view_with(vec![t]))
.unwrap();
let inner = inner_types(&s);
assert_eq!(
inner,
vec![
"",
"",
"token_count",
"message",
"agent_message",
"function_call",
"function_call_output",
"exec_command_end"
]
);
let fc_payload = &s.lines[5].payload;
assert_eq!(fc_payload["type"], "function_call");
assert_eq!(fc_payload["call_id"], "call_001");
assert_eq!(fc_payload["name"], "exec_command");
let args = fc_payload["arguments"].as_str().unwrap();
let parsed: Value = serde_json::from_str(args).unwrap();
assert_eq!(parsed["cmd"], "pwd");
let fco_payload = &s.lines[6].payload;
assert_eq!(fco_payload["type"], "function_call_output");
assert_eq!(fco_payload["call_id"], "call_001");
assert_eq!(fco_payload["output"], "/tmp\n");
let exec = &s.lines[7].payload;
assert_eq!(exec["type"], "exec_command_end");
assert_eq!(exec["call_id"], "call_001");
assert_eq!(exec["aggregated_output"], "/tmp\n");
assert_eq!(exec["exit_code"], 0);
}
#[test]
fn foreign_tool_name_remaps_to_codex_via_category() {
let mut t = assistant_turn("a1", "");
t.tool_uses = vec![ToolInvocation {
id: "call_x".into(),
name: "Bash".into(),
input: json!({"command": "ls"}),
result: None,
category: Some(ToolCategory::Shell),
}];
let s = CodexProjector::default()
.project(&view_with(vec![t]))
.unwrap();
let fc = &s
.lines
.iter()
.find(|l| l.payload.get("type").and_then(Value::as_str) == Some("function_call"))
.expect("function_call line")
.payload;
assert_eq!(fc["name"], "exec_command");
}
#[test]
fn apply_patch_preserves_free_form_input_as_custom_tool_call() {
let patch_body =
"*** Begin Patch\n*** Add File: hello.rs\n+fn main(){}\n*** End Patch".to_string();
let mut t = assistant_turn("a1", "");
t.tool_uses = vec![ToolInvocation {
id: "call_p".into(),
name: "apply_patch".into(),
input: Value::String(patch_body.clone()),
result: Some(ToolResult {
content: "ok".into(),
is_error: false,
}),
category: Some(ToolCategory::FileWrite),
}];
let s = CodexProjector::default()
.project(&view_with(vec![t]))
.unwrap();
let inner = inner_types(&s);
assert!(inner.contains(&"custom_tool_call".to_string()));
assert!(inner.contains(&"custom_tool_call_output".to_string()));
let ctc = s
.lines
.iter()
.find(|l| l.payload.get("type").and_then(Value::as_str) == Some("custom_tool_call"))
.unwrap();
assert_eq!(ctc.payload["input"], patch_body);
}
#[test]
fn assistant_thinking_emits_reasoning_summary() {
let mut t = assistant_turn("a1", "Done.");
t.thinking = Some("hmm let me consider".into());
let s = CodexProjector::default()
.project(&view_with(vec![t]))
.unwrap();
let reasoning_line = s
.lines
.iter()
.find(|l| l.payload.get("type").and_then(Value::as_str) == Some("reasoning"));
assert!(reasoning_line.is_some(), "expected a reasoning line");
let summary = &reasoning_line.unwrap().payload["summary"];
assert!(summary.is_array());
assert_eq!(summary[0]["type"], "summary_text");
assert_eq!(summary[0]["text"], "hmm let me consider");
}
#[test]
fn session_meta_carries_default_originator() {
let s = CodexProjector::default()
.project(&view_with(vec![]))
.unwrap();
let meta = &s.lines[0].payload;
assert_eq!(meta["originator"], "codex-toolpath");
assert_eq!(meta["source"], "cli");
}
#[test]
fn last_assistant_gets_phase_final_others_commentary() {
let mut t1 = assistant_turn("a1", "first");
t1.stop_reason = Some("tool_use".into());
let mut t2 = assistant_turn("a2", "second");
t2.stop_reason = Some("tool_use".into());
let mut t3 = assistant_turn("a3", "All done.");
t3.stop_reason = Some("end_turn".into());
let s = CodexProjector::default()
.project(&view_with(vec![t1, t2, t3]))
.unwrap();
let messages: Vec<&RolloutLine> = s
.lines
.iter()
.filter(|l| {
l.payload.get("type").and_then(Value::as_str) == Some("message")
&& l.payload.get("role").and_then(Value::as_str) == Some("assistant")
})
.collect();
assert_eq!(messages.len(), 3);
assert_eq!(messages[0].payload["phase"], "commentary");
assert_eq!(messages[1].payload["phase"], "commentary");
assert_eq!(messages[2].payload["phase"], "final_answer");
for m in &messages {
assert!(
m.payload.get("end_turn").is_none(),
"end_turn should be absent: {}",
m.payload
);
}
}
#[test]
fn jsonl_serializes_one_line_per_entry() {
let s = CodexProjector::default()
.project(&view_with(vec![user_turn("u1", "hi")]))
.unwrap();
for line in &s.lines {
let serialized = serde_json::to_string(line).unwrap();
assert!(
!serialized.contains('\n'),
"line serialized with newline: {}",
serialized
);
}
}
}