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
78 changes: 60 additions & 18 deletions src/harness/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,34 @@ fn messages_to_lines(meta: &Meta, messages: &[Message]) -> Vec<Line> {
lines
}

/// Emit the `response_item` (and paired display `event_msg`) lines for one message.
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));
}
}
}

#[allow(clippy::too_many_lines)]
fn push_message_lines<'a>(
lines: &mut Vec<Line>,
msg: &'a Message,
Expand Down Expand Up @@ -617,6 +644,14 @@ fn push_message_lines<'a>(
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 @@ -633,6 +668,14 @@ fn push_message_lines<'a>(
));
}
Block::ToolUse { id, tool } => {
flush_message_content(
lines,
&mut message_content,
&mut text_chunks,
msg.role,
role_str,
ts,
);
if is_patch_tool(tool) {
pending_patch_ids.insert(id.as_str());
} else {
Expand All @@ -645,6 +688,14 @@ fn push_message_lines<'a>(
content,
is_error,
} => {
flush_message_content(
lines,
&mut message_content,
&mut text_chunks,
msg.role,
role_str,
ts,
);
let (kind, output) = if pending_patch_ids.remove(tool_use_id.as_str()) {
(
"custom_tool_call_output",
Expand All @@ -662,23 +713,14 @@ fn push_message_lines<'a>(
}
}

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,
);
}

/// Emit the native call line for one tool invocation: `exec_command` for
Expand Down
77 changes: 77 additions & 0 deletions tests/integration/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,83 @@ fn codec_fixpoint_through_common_loses_nothing() {
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.");
}

#[test]
fn from_common_denormalizes_bash_to_exec_command() {
let mut common = sample_common();
Expand Down
Loading