@@ -204,47 +204,31 @@ impl<'a> ChatRequestBuilder<'a> {
204204 call_id,
205205 ..
206206 } => {
207- let mut msg = json ! ( {
208- "role" : "assistant" ,
209- "content" : null,
210- "tool_calls" : [ {
211- "id" : call_id,
212- "type" : "function" ,
213- "function" : {
214- "name" : name,
215- "arguments" : arguments,
216- }
217- } ]
207+ let reasoning = reasoning_by_anchor_index. get ( & idx) . map ( String :: as_str) ;
208+ let tool_call = json ! ( {
209+ "id" : call_id,
210+ "type" : "function" ,
211+ "function" : {
212+ "name" : name,
213+ "arguments" : arguments,
214+ }
218215 } ) ;
219- if let Some ( reasoning) = reasoning_by_anchor_index. get ( & idx)
220- && let Some ( obj) = msg. as_object_mut ( )
221- {
222- obj. insert ( "reasoning" . to_string ( ) , json ! ( reasoning) ) ;
223- }
224- messages. push ( msg) ;
216+ push_tool_call_message ( & mut messages, tool_call, reasoning) ;
225217 }
226218 ResponseItem :: LocalShellCall {
227219 id,
228220 call_id : _,
229221 status,
230222 action,
231223 } => {
232- let mut msg = json ! ( {
233- "role" : "assistant" ,
234- "content" : null,
235- "tool_calls" : [ {
236- "id" : id. clone( ) . unwrap_or_default( ) ,
237- "type" : "local_shell_call" ,
238- "status" : status,
239- "action" : action,
240- } ]
224+ let reasoning = reasoning_by_anchor_index. get ( & idx) . map ( String :: as_str) ;
225+ let tool_call = json ! ( {
226+ "id" : id. clone( ) . unwrap_or_default( ) ,
227+ "type" : "local_shell_call" ,
228+ "status" : status,
229+ "action" : action,
241230 } ) ;
242- if let Some ( reasoning) = reasoning_by_anchor_index. get ( & idx)
243- && let Some ( obj) = msg. as_object_mut ( )
244- {
245- obj. insert ( "reasoning" . to_string ( ) , json ! ( reasoning) ) ;
246- }
247- messages. push ( msg) ;
231+ push_tool_call_message ( & mut messages, tool_call, reasoning) ;
248232 }
249233 ResponseItem :: FunctionCallOutput { call_id, output } => {
250234 let content_value = if let Some ( items) = & output. content_items {
@@ -277,18 +261,16 @@ impl<'a> ChatRequestBuilder<'a> {
277261 input,
278262 status : _,
279263 } => {
280- messages. push ( json ! ( {
281- "role" : "assistant" ,
282- "content" : null,
283- "tool_calls" : [ {
284- "id" : id,
285- "type" : "custom" ,
286- "custom" : {
287- "name" : name,
288- "input" : input,
289- }
290- } ]
291- } ) ) ;
264+ let tool_call = json ! ( {
265+ "id" : id,
266+ "type" : "custom" ,
267+ "custom" : {
268+ "name" : name,
269+ "input" : input,
270+ }
271+ } ) ;
272+ let reasoning = reasoning_by_anchor_index. get ( & idx) . map ( String :: as_str) ;
273+ push_tool_call_message ( & mut messages, tool_call, reasoning) ;
292274 }
293275 ResponseItem :: CustomToolCallOutput { call_id, output } => {
294276 messages. push ( json ! ( {
@@ -328,11 +310,50 @@ impl<'a> ChatRequestBuilder<'a> {
328310 }
329311}
330312
313+ fn push_tool_call_message ( messages : & mut Vec < Value > , tool_call : Value , reasoning : Option < & str > ) {
314+ // Chat Completions requires that tool calls are grouped into a single assistant message
315+ // (with `tool_calls: [...]`) followed by tool role responses.
316+ if let Some ( Value :: Object ( obj) ) = messages. last_mut ( )
317+ && obj. get ( "role" ) . and_then ( Value :: as_str) == Some ( "assistant" )
318+ && obj. get ( "content" ) . is_some_and ( Value :: is_null)
319+ && let Some ( tool_calls) = obj. get_mut ( "tool_calls" ) . and_then ( Value :: as_array_mut)
320+ {
321+ tool_calls. push ( tool_call) ;
322+ if let Some ( reasoning) = reasoning {
323+ if let Some ( Value :: String ( existing) ) = obj. get_mut ( "reasoning" ) {
324+ if !existing. is_empty ( ) {
325+ existing. push ( '\n' ) ;
326+ }
327+ existing. push_str ( reasoning) ;
328+ } else {
329+ obj. insert (
330+ "reasoning" . to_string ( ) ,
331+ Value :: String ( reasoning. to_string ( ) ) ,
332+ ) ;
333+ }
334+ }
335+ return ;
336+ }
337+
338+ let mut msg = json ! ( {
339+ "role" : "assistant" ,
340+ "content" : null,
341+ "tool_calls" : [ tool_call] ,
342+ } ) ;
343+ if let Some ( reasoning) = reasoning
344+ && let Some ( obj) = msg. as_object_mut ( )
345+ {
346+ obj. insert ( "reasoning" . to_string ( ) , json ! ( reasoning) ) ;
347+ }
348+ messages. push ( msg) ;
349+ }
350+
331351#[ cfg( test) ]
332352mod tests {
333353 use super :: * ;
334354 use crate :: provider:: RetryConfig ;
335355 use crate :: provider:: WireApi ;
356+ use codex_protocol:: models:: FunctionCallOutputPayload ;
336357 use codex_protocol:: protocol:: SessionSource ;
337358 use codex_protocol:: protocol:: SubAgentSource ;
338359 use http:: HeaderValue ;
@@ -385,4 +406,89 @@ mod tests {
385406 Some ( & HeaderValue :: from_static( "review" ) )
386407 ) ;
387408 }
409+
410+ #[ test]
411+ fn groups_consecutive_tool_calls_into_a_single_assistant_message ( ) {
412+ let prompt_input = vec ! [
413+ ResponseItem :: Message {
414+ id: None ,
415+ role: "user" . to_string( ) ,
416+ content: vec![ ContentItem :: InputText {
417+ text: "read these" . to_string( ) ,
418+ } ] ,
419+ } ,
420+ ResponseItem :: FunctionCall {
421+ id: None ,
422+ name: "read_file" . to_string( ) ,
423+ arguments: r#"{"path":"a.txt"}"# . to_string( ) ,
424+ call_id: "call-a" . to_string( ) ,
425+ } ,
426+ ResponseItem :: FunctionCall {
427+ id: None ,
428+ name: "read_file" . to_string( ) ,
429+ arguments: r#"{"path":"b.txt"}"# . to_string( ) ,
430+ call_id: "call-b" . to_string( ) ,
431+ } ,
432+ ResponseItem :: FunctionCall {
433+ id: None ,
434+ name: "read_file" . to_string( ) ,
435+ arguments: r#"{"path":"c.txt"}"# . to_string( ) ,
436+ call_id: "call-c" . to_string( ) ,
437+ } ,
438+ ResponseItem :: FunctionCallOutput {
439+ call_id: "call-a" . to_string( ) ,
440+ output: FunctionCallOutputPayload {
441+ content: "A" . to_string( ) ,
442+ ..Default :: default ( )
443+ } ,
444+ } ,
445+ ResponseItem :: FunctionCallOutput {
446+ call_id: "call-b" . to_string( ) ,
447+ output: FunctionCallOutputPayload {
448+ content: "B" . to_string( ) ,
449+ ..Default :: default ( )
450+ } ,
451+ } ,
452+ ResponseItem :: FunctionCallOutput {
453+ call_id: "call-c" . to_string( ) ,
454+ output: FunctionCallOutputPayload {
455+ content: "C" . to_string( ) ,
456+ ..Default :: default ( )
457+ } ,
458+ } ,
459+ ] ;
460+
461+ let req = ChatRequestBuilder :: new ( "gpt-test" , "inst" , & prompt_input, & [ ] )
462+ . build ( & provider ( ) )
463+ . expect ( "request" ) ;
464+
465+ let messages = req
466+ . body
467+ . get ( "messages" )
468+ . and_then ( |v| v. as_array ( ) )
469+ . expect ( "messages array" ) ;
470+ // system + user + assistant(tool_calls=[...]) + 3 tool outputs
471+ assert_eq ! ( messages. len( ) , 6 ) ;
472+
473+ assert_eq ! ( messages[ 0 ] [ "role" ] , "system" ) ;
474+ assert_eq ! ( messages[ 1 ] [ "role" ] , "user" ) ;
475+
476+ let tool_calls_msg = & messages[ 2 ] ;
477+ assert_eq ! ( tool_calls_msg[ "role" ] , "assistant" ) ;
478+ assert_eq ! ( tool_calls_msg[ "content" ] , serde_json:: Value :: Null ) ;
479+ let tool_calls = tool_calls_msg[ "tool_calls" ]
480+ . as_array ( )
481+ . expect ( "tool_calls array" ) ;
482+ assert_eq ! ( tool_calls. len( ) , 3 ) ;
483+ assert_eq ! ( tool_calls[ 0 ] [ "id" ] , "call-a" ) ;
484+ assert_eq ! ( tool_calls[ 1 ] [ "id" ] , "call-b" ) ;
485+ assert_eq ! ( tool_calls[ 2 ] [ "id" ] , "call-c" ) ;
486+
487+ assert_eq ! ( messages[ 3 ] [ "role" ] , "tool" ) ;
488+ assert_eq ! ( messages[ 3 ] [ "tool_call_id" ] , "call-a" ) ;
489+ assert_eq ! ( messages[ 4 ] [ "role" ] , "tool" ) ;
490+ assert_eq ! ( messages[ 4 ] [ "tool_call_id" ] , "call-b" ) ;
491+ assert_eq ! ( messages[ 5 ] [ "role" ] , "tool" ) ;
492+ assert_eq ! ( messages[ 5 ] [ "tool_call_id" ] , "call-c" ) ;
493+ }
388494}
0 commit comments