diff --git a/src/harness/codex.rs b/src/harness/codex.rs index d2c5cc3..82873fa 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -543,7 +543,35 @@ fn messages_to_lines(meta: &Meta, messages: &[Message]) -> Vec { lines } +fn flush_message_content( + lines: &mut Vec, + message_content: &mut Vec, + text_chunks: &mut Vec, + role: Role, + role_str: &str, + ts: &str, +) { + if !message_content.is_empty() { + lines.push(meta_line_str( + ts, + "response_item", + json!({ "type": "message", "role": role_str, "content": std::mem::take(message_content) }), + )); + if !text_chunks.is_empty() { + let combined = std::mem::take(text_chunks).join("\n\n"); + let event = match role { + Role::User => { + json!({ "type": "user_message", "message": combined, "kind": "plain" }) + } + Role::Assistant => json!({ "type": "agent_message", "message": combined }), + }; + lines.push(meta_line_str(ts, "event_msg", event)); + } + } +} + /// Emit the `response_item` (and paired display `event_msg`) lines for one message. +#[allow(clippy::too_many_lines)] fn push_message_lines(lines: &mut Vec, msg: &Message, ts: &str) { let role_str = match msg.role { Role::User => "user", @@ -578,6 +606,14 @@ fn push_message_lines(lines: &mut Vec, msg: &Message, ts: &str) { text_chunks.push(text); } Block::Thinking { text, .. } => { + flush_message_content( + lines, + &mut message_content, + &mut text_chunks, + msg.role, + role_str, + ts, + ); lines.push(meta_line_str( ts, "response_item", @@ -594,6 +630,14 @@ fn push_message_lines(lines: &mut Vec, msg: &Message, ts: &str) { )); } Block::ToolUse { id, tool } => { + flush_message_content( + lines, + &mut message_content, + &mut text_chunks, + msg.role, + role_str, + ts, + ); let (name, input) = tool.to_canonical(); lines.push(meta_line_str( ts, @@ -611,6 +655,14 @@ fn push_message_lines(lines: &mut Vec, msg: &Message, ts: &str) { content, .. } => { + flush_message_content( + lines, + &mut message_content, + &mut text_chunks, + msg.role, + role_str, + ts, + ); lines.push(meta_line_str( ts, "response_item", @@ -624,23 +676,14 @@ fn push_message_lines(lines: &mut Vec, msg: &Message, ts: &str) { } } - if !message_content.is_empty() { - lines.push(meta_line_str( - ts, - "response_item", - json!({ "type": "message", "role": role_str, "content": message_content }), - )); - if !text_chunks.is_empty() { - let combined = text_chunks.join("\n\n"); - let event = match msg.role { - Role::User => { - json!({ "type": "user_message", "message": combined, "kind": "plain" }) - } - Role::Assistant => json!({ "type": "agent_message", "message": combined }), - }; - lines.push(meta_line_str(ts, "event_msg", event)); - } - } + flush_message_content( + lines, + &mut message_content, + &mut text_chunks, + msg.role, + role_str, + ts, + ); } /// The `OpenAI` API validates replayed function-call names with `[A-Za-z0-9_-]+`. diff --git a/tests/integration/codex.rs b/tests/integration/codex.rs index 9875507..cf60074 100644 --- a/tests/integration/codex.rs +++ b/tests/integration/codex.rs @@ -276,3 +276,80 @@ fn codec_fixpoint_through_common_loses_nothing() { let back = codex::Codex::to_common(&native).unwrap(); assert_eq!(common, back); } + +#[test] +fn from_common_preserves_interleaved_block_ordering() { + let meta = common::Meta { + id: "interleave-1".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: Some("main".into()), + title: None, + cli_version: Some("0.104.0".into()), + model: Some("gpt-5.2-codex".into()), + }; + let body = vec![common::Message { + role: common::Role::Assistant, + content: vec![ + common::Block::Text { + text: "Checking the directory first.".into(), + }, + common::Block::ToolUse { + id: "call-ls".into(), + tool: common::Tool::Bash { + command: "ls".into(), + workdir: None, + timeout_ms: None, + description: None, + run_in_background: false, + }, + }, + common::Block::Text { + text: "Directory check completed.".into(), + }, + ], + timestamp: ts("2026-01-02T03:04:06.000Z"), + model: Some("gpt-5.2-codex".into()), + stop_reason: None, + usage: None, + }]; + let transcript = Transcript::new(meta, body); + let native = codex::Codex::from_common(&transcript).unwrap(); + + let kinds: Vec<(&str, Option<&str>)> = native + .body + .iter() + .map(|line| { + let line_type = line.kind.as_str(); + let payload_type = line.payload.get("type").and_then(serde_json::Value::as_str); + (line_type, payload_type) + }) + .collect(); + + assert_eq!( + kinds, + vec![ + ("session_meta", None), + ("turn_context", None), + ("response_item", Some("message")), + ("event_msg", Some("agent_message")), + ("response_item", Some("function_call")), + ("response_item", Some("message")), + ("event_msg", Some("agent_message")), + ] + ); + + // Verify first message text came before the tool call + let first_text = native.body[2].payload["content"][0]["text"] + .as_str() + .unwrap(); + assert_eq!(first_text, "Checking the directory first."); + + let call_id = native.body[4].payload["call_id"].as_str().unwrap(); + assert_eq!(call_id, "call-ls"); + + let second_text = native.body[5].payload["content"][0]["text"] + .as_str() + .unwrap(); + assert_eq!(second_text, "Directory check completed."); +}