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"));
+    }
+}

View original on GitHub ↗

This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗