@@ -542,3 +542,119 @@ async def self_check_output(**kwargs):
542542 tokens .append (token )
543543
544544 assert "" .join (tokens ) == "This is a complete response in a single chunk."
545+
546+
547+ @pytest .mark .asyncio
548+ async def test_streaming_output_rails_no_stale_substituted_param ():
549+ """Output rails that take the bot message as a substituted kwarg
550+ (text=$bot_message, as privateai / prompt_security / regex rails do) see
551+ each streamed chunk's own text, not a stale value from an earlier chunk.
552+ """
553+ config = RailsConfig .from_content (
554+ config = {
555+ "models" : [],
556+ "rails" : {
557+ "output" : {
558+ "flows" : ["capture output" ],
559+ "streaming" : {"enabled" : True , "chunk_size" : 4 , "context_size" : 2 },
560+ }
561+ },
562+ "streaming" : False ,
563+ },
564+ colang_content = """
565+ define user express greeting
566+ "hi"
567+
568+ define flow
569+ user express greeting
570+ bot tell joke
571+
572+ define subflow capture output
573+ execute capture_output(text=$bot_message)
574+ """ ,
575+ )
576+
577+ seen = []
578+
579+ @action (name = "capture_output" , output_mapping = lambda result : not result )
580+ async def capture_output (** params ):
581+ # the substituted `text` kwarg must match this chunk's bot_message
582+ seen .append ((params .get ("text" ), params ["context" ]["bot_message" ]))
583+ return True
584+
585+ chat = TestChat (
586+ config ,
587+ llm_completions = [
588+ ' express greeting\n bot express greeting\n "hi"' ,
589+ ' "one two three four five six"' ,
590+ ],
591+ streaming = True ,
592+ )
593+ chat .app .register_action (capture_output , name = "capture_output" )
594+
595+ async for _ in chat .app .stream_async (messages = [{"role" : "user" , "content" : "hi" }]):
596+ pass
597+
598+ assert len (seen ) >= 2 # response spans multiple chunks
599+ assert all (text == bot_message for text , bot_message in seen )
600+
601+ await asyncio .gather (* asyncio .all_tasks () - {asyncio .current_task ()})
602+
603+
604+ @pytest .mark .asyncio
605+ async def test_streaming_output_rails_substitutes_user_message_param ():
606+ """Output rails that take the user message as a substituted kwarg
607+ (text=$user_message) receive the resolved user message on every streamed
608+ chunk, not the literal "$user_message" placeholder.
609+ """
610+ config = RailsConfig .from_content (
611+ config = {
612+ "models" : [],
613+ "rails" : {
614+ "output" : {
615+ "flows" : ["capture output" ],
616+ "streaming" : {"enabled" : True , "chunk_size" : 4 , "context_size" : 2 },
617+ }
618+ },
619+ "streaming" : False ,
620+ },
621+ colang_content = """
622+ define user express greeting
623+ "hi"
624+
625+ define flow
626+ user express greeting
627+ bot tell joke
628+
629+ define subflow capture output
630+ execute capture_output(text=$user_message)
631+ """ ,
632+ )
633+
634+ seen = []
635+
636+ @action (name = "capture_output" , output_mapping = lambda result : not result )
637+ async def capture_output (** params ):
638+ # the substituted `text` kwarg must be the resolved user message
639+ seen .append ((params .get ("text" ), params ["context" ]["user_message" ]))
640+ return True
641+
642+ chat = TestChat (
643+ config ,
644+ llm_completions = [
645+ ' express greeting\n bot express greeting\n "hi"' ,
646+ ' "one two three four five six"' ,
647+ ],
648+ streaming = True ,
649+ )
650+ chat .app .register_action (capture_output , name = "capture_output" )
651+
652+ async for _ in chat .app .stream_async (messages = [{"role" : "user" , "content" : "hi" }]):
653+ pass
654+
655+ assert seen # the output rail ran on at least one chunk
656+ # $user_message was resolved, not passed through as the literal placeholder
657+ assert all (text != "$user_message" for text , _ in seen )
658+ assert all (text == user_message for text , user_message in seen )
659+
660+ await asyncio .gather (* asyncio .all_tasks () - {asyncio .current_task ()})
0 commit comments