Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 60 additions & 17 deletions src/harness/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,35 @@ fn messages_to_lines(meta: &Meta, messages: &[Message]) -> Vec<Line> {
lines
}

fn flush_message_content(
lines: &mut Vec<Line>,
message_content: &mut Vec<Value>,
text_chunks: &mut Vec<String>,
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<Line>, msg: &Message, ts: &str) {
let role_str = match msg.role {
Role::User => "user",
Expand Down Expand Up @@ -578,6 +606,14 @@ fn push_message_lines(lines: &mut Vec<Line>, 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",
Expand All @@ -594,6 +630,14 @@ fn push_message_lines(lines: &mut Vec<Line>, 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,
Expand All @@ -611,6 +655,14 @@ fn push_message_lines(lines: &mut Vec<Line>, 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",
Expand All @@ -624,23 +676,14 @@ fn push_message_lines(lines: &mut Vec<Line>, 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_-]+`.
Expand Down
77 changes: 77 additions & 0 deletions tests/integration/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Loading