Tool result ordering is incorrect
Resolved 💬 4 comments Opened Dec 16, 2025 by dannykopping Closed Jan 31, 2026
What version of Codex is running?
v0.73.0
What subscription do you have?
None
Which model were you using?
gpt-5
What platform is your computer?
Linux 6.8.0-64-generic x86_64 x86_64
What issue are you seeing?
I am able to reproduce this issue reliably.
The issue stems from incorrect message ordering:
{
"messages": [
...
{
"tool_calls": [
{
"id": "call_xjAFIx3u8en5kaMEOK1YeHlp",
"function": {
"arguments": "{\"command\":[\"bash\",\"-lc\",\"rg -n \\\"buildinfo\\\" || true\"],\"timeout_ms\":120000,\"workdir\":\"/home/coder/coder\"}",
"name": "shell"
},
"type": "function"
}
],
"role": "assistant"
},
{
"content": "Scanning the repo to find the buildinfo module and references.",
"role": "assistant"
},
{
"content": "{\"output\":\"Total output lines: 188\\n\\ncmd/coder/main.go:11:...", // 1.2KiB more
"tool_call_id": "call_xjAFIx3u8en5kaMEOK1YeHlp",
"role": "tool"
}
...
}
The error I receive is:
{
"error": {
"message": "An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. The following tool_call_ids did not have response messages: call_UIBG9qrrnACAROIPkcxv5d27",
"type": "invalid_request_error",
"param": "messages.[5].role",
"code": null
}
}
What steps can reproduce the bug?
Use Codex CLI with wire_api = "chat" and prompt such that a tool call is invoked.
What is the expected behavior?
Tool results are accumulated in the request in a way which respects the spec.
Additional information
I don't know Rust but I collaborated with an LLM which came up with this fix.
diff --git a/codex-rs/codex-api/src/endpoint/chat.rs b/codex-rs/codex-api/src/endpoint/chat.rs
index 4ad133dda..6f8ce9426 100644
--- a/codex-rs/codex-api/src/endpoint/chat.rs
+++ b/codex-rs/codex-api/src/endpoint/chat.rs
@@ -147,6 +147,27 @@ impl Stream for AggregatedStream {
}
}
+ // For non-assistant-message items (like FunctionCall), we need to
+ // flush any accumulated text BEFORE emitting the item. This ensures
+ // that assistant messages with tool_calls are immediately followed
+ // by tool responses, as required by the OpenAI Chat Completions API.
+ if !this.cumulative.is_empty() {
+ let aggregated_message = ResponseItem::Message {
+ id: None,
+ role: "assistant".to_string(),
+ content: vec![ContentItem::OutputText {
+ text: std::mem::take(&mut this.cumulative),
+ }],
+ };
+ // Queue the aggregated message first, then the current item
+ this.pending
+ .push_back(ResponseEvent::OutputItemDone(aggregated_message));
+ this.pending
+ .push_back(ResponseEvent::OutputItemDone(item));
+ // Return the first pending item (the message)
+ return Poll::Ready(Some(Ok(this.pending.pop_front().unwrap())));
+ }
+
return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item))));
}
Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))) => {
@@ -264,3 +285,167 @@ impl AggregatedStream {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use futures::StreamExt;
+ use tokio::sync::mpsc;
+
+ /// Helper to create a ResponseStream from a list of events.
+ fn mock_stream(events: Vec<Result<ResponseEvent, ApiError>>) -> ResponseStream {
+ let (tx, rx) = mpsc::channel(events.len() + 1);
+ tokio::spawn(async move {
+ for event in events {
+ let _ = tx.send(event).await;
+ }
+ });
+ ResponseStream { rx_event: rx }
+ }
+
+ /// Test that verifies the bug: when text deltas come before a tool call,
+ /// the aggregated message should be emitted BEFORE the tool call, not after.
+ ///
+ /// This reproduces the issue where an assistant message with tool_calls is
+ /// not immediately followed by tool messages, violating the OpenAI API contract.
+ #[tokio::test]
+ async fn test_aggregated_stream_emits_text_before_tool_call() {
+ // Simulate a stream where:
+ // 1. Text deltas arrive ("Hello ")
+ // 2. More text deltas ("world")
+ // 3. A FunctionCall item arrives
+ // 4. Completed arrives
+ let tool_call = ResponseItem::FunctionCall {
+ id: None,
+ name: "test_tool".to_string(),
+ arguments: "{}".to_string(),
+ call_id: "call_123".to_string(),
+ };
+
+ let events = vec![
+ Ok(ResponseEvent::OutputTextDelta("Hello ".to_string())),
+ Ok(ResponseEvent::OutputTextDelta("world".to_string())),
+ Ok(ResponseEvent::OutputItemDone(tool_call.clone())),
+ Ok(ResponseEvent::Completed {
+ response_id: "resp_123".to_string(),
+ token_usage: None,
+ }),
+ ];
+
+ let stream = mock_stream(events);
+ let mut aggregated = AggregatedStream::new(stream, AggregateMode::AggregatedOnly);
+
+ let mut output_items: Vec<ResponseItem> = Vec::new();
+
+ while let Some(result) = aggregated.next().await {
+ match result {
+ Ok(ResponseEvent::OutputItemDone(item)) => {
+ output_items.push(item);
+ }
+ Ok(ResponseEvent::Completed { .. }) => break,
+ Ok(_) => {}
+ Err(e) => panic!("Unexpected error: {:?}", e),
+ }
+ }
+
+ // We should have exactly 2 items: the aggregated message and the tool call
+ assert_eq!(output_items.len(), 2, "Expected 2 output items");
+
+ // The CRITICAL assertion: the Message must come BEFORE the FunctionCall
+ // This is required by the OpenAI API: tool calls must be immediately followed
+ // by tool responses, so any assistant text must come before the tool call.
+ assert!(
+ matches!(&output_items[0], ResponseItem::Message { role, .. } if role == "assistant"),
+ "First item should be an assistant Message, got: {:?}",
+ output_items[0]
+ );
+ assert!(
+ matches!(&output_items[1], ResponseItem::FunctionCall { .. }),
+ "Second item should be a FunctionCall, got: {:?}",
+ output_items[1]
+ );
+
+ // Verify the message content is correct
+ if let ResponseItem::Message { content, .. } = &output_items[0] {
+ let text: String = content
+ .iter()
+ .filter_map(|c| match c {
+ ContentItem::OutputText { text } => Some(text.clone()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(text, "Hello world", "Message content should be aggregated text");
+ }
+ }
+
+ /// Test that when there's no accumulated text, tool calls pass through correctly.
+ #[tokio::test]
+ async fn test_aggregated_stream_tool_call_without_text() {
+ let tool_call = ResponseItem::FunctionCall {
+ id: None,
+ name: "test_tool".to_string(),
+ arguments: "{}".to_string(),
+ call_id: "call_456".to_string(),
+ };
+
+ let events = vec![
+ Ok(ResponseEvent::OutputItemDone(tool_call.clone())),
+ Ok(ResponseEvent::Completed {
+ response_id: "resp_456".to_string(),
+ token_usage: None,
+ }),
+ ];
+
+ let stream = mock_stream(events);
+ let mut aggregated = AggregatedStream::new(stream, AggregateMode::AggregatedOnly);
+
+ let mut output_items: Vec<ResponseItem> = Vec::new();
+
+ while let Some(result) = aggregated.next().await {
+ match result {
+ Ok(ResponseEvent::OutputItemDone(item)) => {
+ output_items.push(item);
+ }
+ Ok(ResponseEvent::Completed { .. }) => break,
+ Ok(_) => {}
+ Err(e) => panic!("Unexpected error: {:?}", e),
+ }
+ }
+
+ // Should have just the tool call
+ assert_eq!(output_items.len(), 1);
+ assert!(matches!(&output_items[0], ResponseItem::FunctionCall { .. }));
+ }
+
+ /// Test that text-only responses still work correctly.
+ #[tokio::test]
+ async fn test_aggregated_stream_text_only() {
+ let events = vec![
+ Ok(ResponseEvent::OutputTextDelta("Hello ".to_string())),
+ Ok(ResponseEvent::OutputTextDelta("world".to_string())),
+ Ok(ResponseEvent::Completed {
+ response_id: "resp_789".to_string(),
+ token_usage: None,
+ }),
+ ];
+
+ let stream = mock_stream(events);
+ let mut aggregated = AggregatedStream::new(stream, AggregateMode::AggregatedOnly);
+
+ let mut output_items: Vec<ResponseItem> = Vec::new();
+
+ while let Some(result) = aggregated.next().await {
+ match result {
+ Ok(ResponseEvent::OutputItemDone(item)) => {
+ output_items.push(item);
+ }
+ Ok(ResponseEvent::Completed { .. }) => break,
+ Ok(_) => {}
+ Err(e) => panic!("Unexpected error: {:?}", e),
+ }
+ }
+
+ assert_eq!(output_items.len(), 1);
+ assert!(matches!(&output_items[0], ResponseItem::Message { role, .. } if role == "assistant"));
+ }
+}This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗