@@ -335,6 +335,85 @@ async def test_chat_client_streaming_observability_with_instructions(
335335 assert [msg .get ("role" ) for msg in input_messages ] == ["user" ]
336336
337337
338+ @pytest .mark .parametrize ("enable_sensitive_data" , [False ], indirect = True )
339+ async def test_chat_client_streaming_sync_setup_span_is_parented_to_chat_span (
340+ span_exporter : InMemorySpanExporter , enable_sensitive_data
341+ ):
342+ """Regression guard for the streaming sync-setup parenting gap.
343+
344+ When a chat client subclass creates spans inside ``_inner_get_response`` during
345+ the synchronous setup phase (before returning the ``ResponseStream``), those
346+ spans must be nested under the chat-completion span produced by
347+ ``ChatTelemetryLayer``. The chat span is created via ``_start_streaming_span``
348+ (which does not attach the span as current) and ``with_pull_context_manager``
349+ only activates the span around each pull, so the synchronous setup window
350+ would otherwise see the chat span existing-but-not-current. ``ChatTelemetryLayer``
351+ therefore wraps the synchronous ``super_get_response(...)`` call in
352+ ``_activate_span(span)`` so subclass spans opened during setup parent correctly.
353+ """
354+ from agent_framework .observability import get_tracer
355+
356+ class SyncSetupChatClient (ChatTelemetryLayer , BaseChatClient [Any ]):
357+ def service_url (self ):
358+ return "https://test.example.com"
359+
360+ def _inner_get_response (
361+ self , * , messages : Sequence [Message ], stream : bool , options : Mapping [str , Any ], ** kwargs : Any
362+ ) -> Awaitable [ChatResponse ] | ResponseStream [ChatResponseUpdate , ChatResponse ]:
363+ assert stream is True , "this fixture only exercises the streaming path"
364+
365+ # Synchronous setup the subclass performs before the stream object is
366+ # constructed. Real clients do payload building, auth resolution, etc.
367+ # here. We model that as a child span the subclass wants to nest under
368+ # the chat-completion span.
369+ with get_tracer ().start_as_current_span ("subclass_sync_setup" ) as setup_span :
370+ setup_span .set_attribute ("subclass.work" , "payload_build" )
371+
372+ async def _stream () -> AsyncIterable [ChatResponseUpdate ]:
373+ yield ChatResponseUpdate (contents = [Content .from_text ("hi" )], role = "assistant" , finish_reason = "stop" )
374+
375+ def _finalize (updates : Sequence [ChatResponseUpdate ]) -> ChatResponse :
376+ return ChatResponse .from_updates (updates )
377+
378+ return ResponseStream (_stream (), finalizer = _finalize )
379+
380+ client = SyncSetupChatClient ()
381+ span_exporter .clear ()
382+
383+ stream = client .get_response (stream = True , messages = [Message ("user" , ["go" ])], options = {"model" : "Test" })
384+ async for _update in stream :
385+ pass
386+ await stream .get_final_response ()
387+
388+ spans = span_exporter .get_finished_spans ()
389+ chat_spans = [s for s in spans if s .name == "chat Test" ]
390+ setup_spans = [s for s in spans if s .name == "subclass_sync_setup" ]
391+
392+ assert len (chat_spans ) == 1 , f"expected exactly one chat span, got { [s .name for s in spans ]} "
393+ assert len (setup_spans ) == 1 , f"expected exactly one setup span, got { [s .name for s in spans ]} "
394+
395+ chat_span = chat_spans [0 ]
396+ setup_span = setup_spans [0 ]
397+
398+ # Both spans must be part of the same trace.
399+ assert setup_span .context is not None
400+ assert chat_span .context is not None
401+ assert setup_span .context .trace_id == chat_span .context .trace_id , (
402+ "setup span ended up in a different trace from the chat span; "
403+ "they should share the trace produced by ChatTelemetryLayer"
404+ )
405+
406+ # And the chat span must be the parent of the setup span.
407+ assert setup_span .parent is not None , (
408+ "subclass setup span has no parent; expected it to be a child of the chat span"
409+ )
410+ assert setup_span .parent .span_id == chat_span .context .span_id , (
411+ "subclass setup span is not parented to the chat span "
412+ f"(parent={ setup_span .parent .span_id :x} , chat={ chat_span .context .span_id :x} ); "
413+ "this is the streaming sync-setup parenting gap"
414+ )
415+
416+
338417@pytest .mark .parametrize ("enable_sensitive_data" , [True ], indirect = True )
339418async def test_chat_client_observability_with_system_message_and_instructions (
340419 mock_chat_client , span_exporter : InMemorySpanExporter , enable_sensitive_data
@@ -582,6 +661,96 @@ async def test_agent_streaming_response_with_diagnostics_enabled(
582661 assert span .attributes .get (OtelAttr .OUTPUT_MESSAGES ) is not None # type: ignore[union-attr] # ty: ignore[unresolved-attribute] # Streaming, so no usage yet
583662
584663
664+ @pytest .mark .parametrize ("enable_sensitive_data" , [False ], indirect = True )
665+ async def test_agent_streaming_sync_setup_span_is_parented_to_agent_span (
666+ span_exporter : InMemorySpanExporter , enable_sensitive_data
667+ ):
668+ """Regression guard for the streaming sync-setup parenting gap in ``AgentTelemetryLayer``.
669+
670+ Mirrors :func:`test_chat_client_streaming_sync_setup_span_is_parented_to_chat_span`
671+ but at the agent layer. When an agent's ``run(stream=True)`` synchronously
672+ constructs the ``ResponseStream`` (rather than returning a coroutine that
673+ the framework wraps via ``ResponseStream.from_awaitable``), any spans the
674+ subclass opens during that synchronous setup must still be nested under
675+ the agent invoke span produced by ``AgentTelemetryLayer``.
676+
677+ The agent invoke span is created via ``_start_streaming_span`` (which does
678+ not attach the span as current) and ``with_pull_context_manager`` only
679+ activates the span around each pull, so the synchronous setup window would
680+ otherwise see the agent span existing-but-not-current. ``BaseAgent.run``
681+ happens to side-step this by always wrapping its streaming path in
682+ ``from_awaitable``, but subclasses that return a stream synchronously do not
683+ get the same protection. ``AgentTelemetryLayer`` therefore wraps the
684+ synchronous ``execute()`` call in ``_activate_span(span)`` so subclass spans
685+ opened during setup parent correctly regardless of the return shape.
686+ """
687+ from agent_framework import AgentResponse , AgentResponseUpdate
688+ from agent_framework .observability import get_tracer
689+
690+ class _SyncSetupAgent :
691+ AGENT_PROVIDER_NAME = "test_agent_system"
692+
693+ def __init__ (self ) -> None :
694+ self .id = "sync_setup_agent_id"
695+ self .name = "sync_setup_agent"
696+ self .description = "Agent that performs synchronous setup before streaming."
697+ self .default_options : dict [str , Any ] = {"model" : "TestModel" }
698+
699+ def run (self , messages = None , * , session = None , stream = False , ** kwargs ): # type: ignore[no-untyped-def]
700+ assert stream is True , "this fixture only exercises the streaming path"
701+
702+ # Synchronous setup the agent subclass performs before constructing
703+ # the ResponseStream (e.g. resolving credentials, building payload,
704+ # opening transport). Real subclasses may want spans here to nest
705+ # under the agent invoke span.
706+ with get_tracer ().start_as_current_span ("agent_subclass_sync_setup" ) as setup_span :
707+ setup_span .set_attribute ("agent.subclass.work" , "payload_build" )
708+
709+ async def _stream () -> AsyncIterable [AgentResponseUpdate ]:
710+ yield AgentResponseUpdate (contents = [Content .from_text ("hi" )], role = "assistant" )
711+
712+ return ResponseStream (_stream (), finalizer = AgentResponse .from_updates )
713+
714+ class SyncSetupAgent (AgentTelemetryLayer , _SyncSetupAgent ): # type: ignore
715+ pass
716+
717+ agent = SyncSetupAgent ()
718+ span_exporter .clear ()
719+
720+ stream = agent .run ("go" , stream = True )
721+ async for _update in stream :
722+ pass
723+ await stream .get_final_response ()
724+
725+ spans = span_exporter .get_finished_spans ()
726+ agent_spans = [s for s in spans if s .name == "invoke_agent sync_setup_agent" ]
727+ setup_spans = [s for s in spans if s .name == "agent_subclass_sync_setup" ]
728+
729+ assert len (agent_spans ) == 1 , f"expected exactly one agent span, got { [s .name for s in spans ]} "
730+ assert len (setup_spans ) == 1 , f"expected exactly one setup span, got { [s .name for s in spans ]} "
731+
732+ agent_span = agent_spans [0 ]
733+ setup_span = setup_spans [0 ]
734+
735+ # Both spans must be part of the same trace.
736+ assert setup_span .context is not None
737+ assert agent_span .context is not None
738+ assert setup_span .context .trace_id == agent_span .context .trace_id , (
739+ "setup span ended up in a different trace from the agent span; "
740+ "they should share the trace produced by AgentTelemetryLayer"
741+ )
742+
743+ # And the agent span must be the parent of the setup span.
744+ assert setup_span .parent is not None , (
745+ "agent subclass setup span has no parent; expected it to be a child of the agent span"
746+ )
747+ assert setup_span .parent .span_id == agent_span .context .span_id , (
748+ "agent subclass setup span is not parented to the agent span "
749+ f"(parent={ setup_span .parent .span_id :x} , agent={ agent_span .context .span_id :x} ); "
750+ "this is the streaming sync-setup parenting gap at the agent layer"
751+ )
752+
753+
585754async def test_function_call_with_error_handling (span_exporter : InMemorySpanExporter ):
586755 """Test that function call errors are properly captured in telemetry."""
587756
@@ -4728,6 +4897,40 @@ async def make_stream() -> ResponseStream[int, list[int]]:
47284897 assert events [resolve_index - 1 ] == "enter" # Pull context active during resolution
47294898
47304899
4900+ async def test_with_pull_context_manager_wraps_stream_resolution_via_get_final_response ():
4901+ """``get_final_response`` resolves the inner stream under the pull contexts (no prior iteration)."""
4902+ import contextlib
4903+
4904+ events : list [str ] = []
4905+
4906+ @contextlib .contextmanager
4907+ def cm ():
4908+ events .append ("enter" )
4909+ try :
4910+ yield
4911+ finally :
4912+ events .append ("exit" )
4913+
4914+ async def inner () -> AsyncIterable [int ]:
4915+ yield 1
4916+
4917+ async def make_stream () -> ResponseStream [int , list [int ]]:
4918+ # Record that we resolve while a pull context is active.
4919+ events .append ("resolving" )
4920+ return ResponseStream (inner (), finalizer = lambda updates : list (updates ))
4921+
4922+ stream : ResponseStream [int , list [int ]] = ResponseStream .from_awaitable (make_stream ())
4923+ stream .with_pull_context_manager (cm )
4924+
4925+ # Drive get_final_response() directly, without any prior `async for` or `await stream`.
4926+ final = await stream .get_final_response ()
4927+
4928+ assert final == [1 ]
4929+ assert "resolving" in events
4930+ resolve_index = events .index ("resolving" )
4931+ assert events [resolve_index - 1 ] == "enter" # Pull context active during resolution
4932+
4933+
47314934# region Test streaming telemetry error paths
47324935
47334936
0 commit comments