@@ -894,3 +894,204 @@ def track_execution(call_id: str) -> str:
894894 assert (
895895 len (function_response_events ) == 1
896896 ), f"Expected 1 function response event, got { len (function_response_events )} "
897+
898+
899+ def test_progressive_sse_partials_share_event_id ():
900+ """Partial chunks and the final event of one response share one id."""
901+
902+ response1 = LlmResponse (
903+ content = types .Content (
904+ role = "model" , parts = [types .Part .from_text (text = "Checking weather..." )]
905+ ),
906+ )
907+ response2 = LlmResponse (
908+ content = types .Content (
909+ role = "model" ,
910+ parts = [
911+ types .Part .from_function_call (
912+ name = "get_weather" , args = {"location" : "Tokyo" }
913+ )
914+ ],
915+ ),
916+ )
917+ response3 = LlmResponse (
918+ content = types .Content (
919+ role = "model" ,
920+ parts = [
921+ types .Part .from_function_call (
922+ name = "get_weather" , args = {"location" : "New York" }
923+ )
924+ ],
925+ ),
926+ finish_reason = types .FinishReason .STOP ,
927+ )
928+
929+ mock_model = StreamingMockModel (
930+ stream_chunks = [response1 , response2 , response3 ]
931+ )
932+ agent = Agent (name = "weather_agent" , model = mock_model , tools = [get_weather ])
933+ run_config = RunConfig (streaming_mode = StreamingMode .SSE )
934+ runner = InMemoryRunner (agent = agent )
935+ session = runner .session_service .create_session_sync (
936+ app_name = runner .app_name , user_id = "test_user"
937+ )
938+
939+ events = []
940+ for event in runner .run (
941+ user_id = "test_user" ,
942+ session_id = session .id ,
943+ new_message = types .Content (
944+ role = "user" ,
945+ parts = [types .Part .from_text (text = "What is the weather?" )],
946+ ),
947+ run_config = run_config ,
948+ ):
949+ events .append (event )
950+
951+ assert len (events ) == 6
952+ # events 0-2 are partials and event 3 is the aggregated final of one
953+ # streaming response, so they all share the id minted once for that call.
954+ assert events [0 ].id == events [1 ].id == events [2 ].id == events [3 ].id
955+ # The function-response event is a separate event with its own id.
956+ assert events [4 ].id != events [0 ].id
957+ # A distinct LLM call mints a fresh id.
958+ assert events [5 ].id != events [0 ].id
959+
960+
961+ def test_progressive_sse_text_stream_shares_event_id ():
962+ """Every partial and the final of a pure-text stream share one id."""
963+
964+ response1 = LlmResponse (
965+ content = types .Content (
966+ role = "model" , parts = [types .Part .from_text (text = "Hello " )]
967+ ),
968+ )
969+ response2 = LlmResponse (
970+ content = types .Content (
971+ role = "model" , parts = [types .Part .from_text (text = "world" )]
972+ ),
973+ )
974+ response3 = LlmResponse (
975+ content = types .Content (
976+ role = "model" , parts = [types .Part .from_text (text = "!" )]
977+ ),
978+ finish_reason = types .FinishReason .STOP ,
979+ )
980+
981+ mock_model = StreamingMockModel (
982+ stream_chunks = [response1 , response2 , response3 ]
983+ )
984+ agent = Agent (name = "text_stream_agent" , model = mock_model )
985+ run_config = RunConfig (streaming_mode = StreamingMode .SSE )
986+ runner = InMemoryRunner (agent = agent )
987+ session = runner .session_service .create_session_sync (
988+ app_name = runner .app_name , user_id = "test_user"
989+ )
990+
991+ events = []
992+ for event in runner .run (
993+ user_id = "test_user" ,
994+ session_id = session .id ,
995+ new_message = types .Content (
996+ role = "user" ,
997+ parts = [types .Part .from_text (text = "Say hello." )],
998+ ),
999+ run_config = run_config ,
1000+ ):
1001+ events .append (event )
1002+
1003+ model_events = [
1004+ e for e in events if e .author == "text_stream_agent" and e .content
1005+ ]
1006+ assert any (e .partial for e in model_events )
1007+ assert any (not e .partial for e in model_events )
1008+ assert len ({e .id for e in model_events }) == 1
1009+
1010+
1011+ class TwoFinalResponsesMockModel (BaseLlm ):
1012+ """Yields two separate non-partial responses (text then a function call)."""
1013+
1014+ model : str = "two-finals-mock"
1015+ call_count : int = 0
1016+
1017+ @classmethod
1018+ def supported_models (cls ) -> list [str ]:
1019+ return ["two-finals-mock" ]
1020+
1021+ async def generate_content_async (
1022+ self , llm_request : LlmRequest , stream : bool = False
1023+ ) -> AsyncGenerator [LlmResponse , None ]:
1024+ self .call_count += 1
1025+ if self .call_count == 1 :
1026+ yield LlmResponse (
1027+ content = types .Content (
1028+ role = "model" ,
1029+ parts = [types .Part .from_text (text = "Let me check the weather." )],
1030+ ),
1031+ partial = False ,
1032+ )
1033+ yield LlmResponse (
1034+ content = types .Content (
1035+ role = "model" ,
1036+ parts = [
1037+ types .Part .from_function_call (
1038+ name = "get_weather" , args = {"location" : "Tokyo" }
1039+ )
1040+ ],
1041+ ),
1042+ partial = False ,
1043+ finish_reason = types .FinishReason .STOP ,
1044+ )
1045+ return
1046+ yield LlmResponse (
1047+ content = types .Content (
1048+ role = "model" , parts = [types .Part .from_text (text = "Done." )]
1049+ ),
1050+ partial = False ,
1051+ finish_reason = types .FinishReason .STOP ,
1052+ )
1053+
1054+
1055+ def test_progressive_sse_saved_events_get_distinct_ids ():
1056+ """Distinct saved (non-partial) events in one turn must get distinct ids.
1057+
1058+ A saved text event and a saved function-call event produced in the same
1059+ streaming turn must not collapse onto a shared id. The id is re-minted after
1060+ every complete event, so this must hold regardless of future refactors of the
1061+ id-minting loop.
1062+ """
1063+
1064+ mock_model = TwoFinalResponsesMockModel ()
1065+ agent = Agent (name = "distinct_id_agent" , model = mock_model , tools = [get_weather ])
1066+ run_config = RunConfig (streaming_mode = StreamingMode .SSE )
1067+ runner = InMemoryRunner (agent = agent )
1068+ session = runner .session_service .create_session_sync (
1069+ app_name = runner .app_name , user_id = "test_user"
1070+ )
1071+
1072+ events = []
1073+ for event in runner .run (
1074+ user_id = "test_user" ,
1075+ session_id = session .id ,
1076+ new_message = types .Content (
1077+ role = "user" ,
1078+ parts = [types .Part .from_text (text = "What is the weather?" )],
1079+ ),
1080+ run_config = run_config ,
1081+ ):
1082+ events .append (event )
1083+
1084+ # Only non-partial events are saved to the session.
1085+ saved_events = [e for e in events if not e .partial ]
1086+ text_event = next (
1087+ e
1088+ for e in saved_events
1089+ if e .author == "distinct_id_agent"
1090+ and e .content
1091+ and any (p .text for p in e .content .parts )
1092+ )
1093+ function_call_event = next (e for e in saved_events if e .get_function_calls ())
1094+ assert text_event .id != function_call_event .id
1095+ # Every saved event in the turn has a unique id.
1096+ saved_ids = [e .id for e in saved_events ]
1097+ assert len (saved_ids ) == len (set (saved_ids ))
0 commit comments