@@ -328,4 +328,51 @@ describe('trimMessages', () => {
328328 expect ( result [ 0 ] . role ) . toBe ( 'system' ) ;
329329 expect ( result . length ) . toBeLessThanOrEqual ( MAX_CONTEXT_MESSAGES ) ;
330330 } ) ;
331+
332+ it ( 'skips orphaned tool messages at trim boundary' , ( ) => {
333+ // Simulate a real agent conversation where trimming slices between
334+ // an assistant+tool_calls message and its tool response.
335+ // OpenAI rejects orphaned tool messages with 400.
336+ const msgs : { role : string ; content : string } [ ] = [
337+ { role : 'user' , content : 'system prompt' } ,
338+ ] ;
339+ // Fill with user/assistant pairs to push past the limit
340+ for ( let i = 1 ; i < MAX_CONTEXT_MESSAGES ; i ++ ) {
341+ msgs . push ( { role : i % 2 === 0 ? 'user' : 'assistant' , content : `msg-${ i } ` } ) ;
342+ }
343+ // Now add a tool response that will land at the start of the tail
344+ // after its parent assistant message gets sliced off
345+ msgs . push ( { role : 'tool' , content : 'tool-response-orphaned' } ) ;
346+ msgs . push ( { role : 'tool' , content : 'tool-response-orphaned-2' } ) ;
347+ msgs . push ( { role : 'assistant' , content : 'next-reasoning' } ) ;
348+ msgs . push ( { role : 'user' , content : 'latest' } ) ;
349+
350+ const result = trimMessages ( msgs ) ;
351+ // The orphaned tool messages right after the anchor should be skipped
352+ expect ( result [ 1 ] . role ) . not . toBe ( 'tool' ) ;
353+ // The non-orphaned messages (assistant, user) should follow the anchor
354+ expect ( result [ 1 ] . role ) . toBe ( 'assistant' ) ;
355+ } ) ;
356+
357+ it ( 'handles tool message right after anchor role collision' , ( ) => {
358+ // Edge case: anchor is 'user', tail starts with 'user' (dropped by
359+ // existing dedup), then 'tool' (should also be dropped)
360+ const msgs : { role : string ; content : string } [ ] = [
361+ { role : 'user' , content : 'anchor' } ,
362+ ] ;
363+ // Push past limit with alternating messages
364+ for ( let i = 1 ; i <= MAX_CONTEXT_MESSAGES + 2 ; i ++ ) {
365+ msgs . push ( { role : i % 2 === 0 ? 'user' : 'assistant' , content : `fill-${ i } ` } ) ;
366+ }
367+ // Manually inject a user+tool sequence at the expected tail boundary
368+ // After slicing, tail[0] = 'user' (deduped), tail[1] = 'tool' (orphaned)
369+ const tailStart = msgs . length - MAX_CONTEXT_MESSAGES + 1 ;
370+ msgs [ tailStart ] = { role : 'user' , content : 'collision' } ;
371+ msgs [ tailStart + 1 ] = { role : 'tool' , content : 'orphaned-tool' } ;
372+ msgs [ tailStart + 2 ] = { role : 'assistant' , content : 'recovery' } ;
373+
374+ const result = trimMessages ( msgs ) ;
375+ expect ( result [ 0 ] . role ) . toBe ( 'user' ) ;
376+ expect ( result [ 1 ] . role ) . not . toBe ( 'tool' ) ;
377+ } ) ;
331378} ) ;
0 commit comments