@@ -25,6 +25,7 @@ import { APICallError, RetryError } from 'ai'
2525import { z } from 'zod/v4'
2626
2727import { loopAgentSteps } from '../run-agent-step'
28+ import { frameSteeringText , STEERING_NOTE } from '../util/messages'
2829import { clearAgentGeneratorCache } from '../run-programmatic-step'
2930import {
3031 MAX_CONSECUTIVE_STREAM_RECOVERIES ,
@@ -1600,9 +1601,9 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
16001601 } ,
16011602 } )
16021603 const expected = [
1603- [ { type : 'text' , text : 'Text correction' } ] ,
1604- [ { type : 'text' , text : 'Compare both images' } , ...images ] ,
1605- [ images [ 1 ] ] ,
1604+ [ { type : 'text' , text : frameSteeringText ( 'Text correction' ) } ] ,
1605+ [ { type : 'text' , text : frameSteeringText ( 'Compare both images' ) } , ...images ] ,
1606+ [ { type : 'text' , text : frameSteeringText ( '' ) } , images [ 1 ] ] ,
16061607 ]
16071608 expect ( seen ) . toHaveLength ( 2 )
16081609 for ( const content of expected ) {
@@ -1643,6 +1644,46 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
16431644 expect ( ( steered as { tags ?: string [ ] } ) . tags ) . toContain ( 'USER_PROMPT' )
16441645 } )
16451646
1647+ it ( 'frames a steered message exactly once, as sent mid-turn, so the earlier request is kept' , async ( ) => {
1648+ const steerText = 'also add a README'
1649+ const seen : unknown [ ] [ ] = [ ]
1650+ let drained = false
1651+ await loopAgentSteps ( {
1652+ ...loopAgentStepsBaseParams ,
1653+ prompt : 'build the login page' ,
1654+ drainSteeringMessages : ( ) => {
1655+ if ( drained ) return [ ]
1656+ drained = true
1657+ // The second is already framed (a host re-delivering it): not re-wrapped.
1658+ return [ steerText , frameSteeringText ( 'and a footer' ) ]
1659+ } ,
1660+ promptAiSdkStream : async function * ( { messages } ) {
1661+ seen . push ( structuredClone ( messages ) )
1662+ yield createToolCallChunk ( 'end_turn' , { } )
1663+ return promptSuccess ( `step-${ seen . length } ` )
1664+ } ,
1665+ } )
1666+
1667+ expect ( seen ) . toHaveLength ( 2 )
1668+ const request = JSON . stringify ( seen [ 1 ] )
1669+ // The original request is still there, unframed.
1670+ expect ( request ) . toContain ( 'build the login page' )
1671+ expect ( request ) . not . toContain (
1672+ JSON . stringify ( frameSteeringText ( 'build the login page' ) ) . slice ( 1 , - 1 ) ,
1673+ )
1674+ for ( const text of [ steerText , 'and a footer' ] ) {
1675+ expect ( seen [ 1 ] ) . toContainEqual (
1676+ expect . objectContaining ( {
1677+ role : 'user' ,
1678+ content : [ { type : 'text' , text : frameSteeringText ( text ) } ] ,
1679+ } ) ,
1680+ )
1681+ }
1682+ // One note per steered message, never a nested frame.
1683+ expect ( request . split ( STEERING_NOTE ) . length - 1 ) . toBe ( 2 )
1684+ expect ( request . split ( '<user_message_sent_while_working>' ) . length - 1 ) . toBe ( 2 )
1685+ } )
1686+
16461687 it ( 'does not extend the turn when no steering messages arrive' , async ( ) => {
16471688 const result = await loopAgentSteps ( {
16481689 ...loopAgentStepsBaseParams ,
0 commit comments