Tool result ordering is incorrect
Resolved 💬 4 comments Opened Dec 16, 2025 by dannykopping Closed Jan 31, 2026
💡 Likely answer: A maintainer (etraut-openai, contributor)
responded on this thread — see the highlighted reply below.
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"));
+ }
+}
4 Comments
The last version which works is v0.58.0
We've announced that we're deprecating support for the "chat" wire API and removing it in the near future. Please move to the "responses" wire API if you can. If you're blocked from moving to the "responses" API, let us know about your use case so we can help unblock.
Appreciate the swift response @etraut-openai 👍
We are still in the process of migrating to Responses, but in the interim our customers need this to work with Chat Completions.
We've instructed them to pin their Codex version to v0.58 as that was the last functional version.
We've removed support for the chat/completions endpoint, so closing this as "won't fix".