@@ -124,7 +124,7 @@ describe("injectSyntheticToolResults", () => {
124124 ]
125125
126126 const result = injectSyntheticToolResults ( messages )
127- expect ( result ) . toEqual ( messages )
127+ expect ( result ) . toBe ( messages )
128128 } )
129129
130130 it ( "should inject synthetic tool_result for orphan tool_call" , ( ) => {
@@ -250,6 +250,75 @@ describe("injectSyntheticToolResults", () => {
250250 // Both tool_uses have matching tool_results, no injection needed
251251 expect ( result ) . toEqual ( messages )
252252 } )
253+
254+ it ( "should independently pair two consecutive assistant tool_use turns" , ( ) => {
255+ // Regression guard: the per-assistant-message loop must pair EACH turn independently.
256+ // A fix that only patches the first orphan and skips subsequent ones would pass the
257+ // single-orphan cases above but fail here.
258+ const messages : ApiMessage [ ] = [
259+ { role : "user" , content : "Hello" , ts : 1 } ,
260+ {
261+ role : "assistant" ,
262+ content : [ { type : "tool_use" , id : "tool-A" , name : "read_file" , input : { path : "a.ts" } } ] ,
263+ ts : 2 ,
264+ } ,
265+ {
266+ role : "user" ,
267+ content : [ { type : "tool_result" , tool_use_id : "tool-A" , content : "contents a" } ] ,
268+ ts : 3 ,
269+ } ,
270+ {
271+ role : "assistant" ,
272+ content : [ { type : "tool_use" , id : "tool-B" , name : "read_file" , input : { path : "b.ts" } } ] ,
273+ ts : 4 ,
274+ } ,
275+ // No tool_result for tool-B
276+ ]
277+
278+ const result = injectSyntheticToolResults ( messages )
279+
280+ // 4 input messages + 1 synthetic = 5
281+ expect ( result . length ) . toBe ( 5 )
282+ // First pair must be unchanged.
283+ expect ( ( result [ 2 ] . content as any [ ] ) [ 0 ] ) . toMatchObject ( { type : "tool_result" , tool_use_id : "tool-A" } )
284+ // Synthetic for second orphan must be spliced immediately after the second assistant.
285+ expect ( result [ 4 ] . role ) . toBe ( "user" )
286+ const synthContent = result [ 4 ] . content as any [ ]
287+ expect ( synthContent ) . toHaveLength ( 1 )
288+ expect ( synthContent [ 0 ] ) . toMatchObject ( { type : "tool_result" , tool_use_id : "tool-B" } )
289+ } )
290+
291+ it ( "should merge synthetic for missing tool_use_id into first surviving user message when result set is partial" , ( ) => {
292+ // Two tool_uses on one assistant turn; the first result survives, the second was filtered
293+ // away. The synthetic for the missing id must be merged into the existing user message
294+ // (which carries the first result) rather than appended as a new third message.
295+ const messages : ApiMessage [ ] = [
296+ { role : "user" , content : "Hello" , ts : 1 } ,
297+ {
298+ role : "assistant" ,
299+ content : [
300+ { type : "tool_use" , id : "tool-present" , name : "read_file" , input : { path : "a.ts" } } ,
301+ { type : "tool_use" , id : "tool-missing" , name : "read_file" , input : { path : "b.ts" } } ,
302+ ] ,
303+ ts : 2 ,
304+ } ,
305+ {
306+ role : "user" ,
307+ content : [ { type : "tool_result" , tool_use_id : "tool-present" , content : "contents a" } ] ,
308+ ts : 3 ,
309+ } ,
310+ // tool-missing has no result
311+ ]
312+
313+ const result = injectSyntheticToolResults ( messages )
314+
315+ // Synthetic merges into existing user message — length stays 3.
316+ expect ( result . length ) . toBe ( 3 )
317+ const merged = result [ 2 ] . content as any [ ]
318+ expect ( merged ) . toHaveLength ( 2 )
319+ expect ( merged [ 0 ] ) . toMatchObject ( { type : "tool_result" , tool_use_id : "tool-present" } )
320+ expect ( merged [ 1 ] ) . toMatchObject ( { type : "tool_result" , tool_use_id : "tool-missing" } )
321+ } )
253322} )
254323
255324describe ( "getMessagesSinceLastSummary" , ( ) => {
0 commit comments