1313from azure .ai .agentserver .responses .hosting ._durable_orchestrator import (
1414 DurableResponseOrchestrator ,
1515 _is_recovered_entry ,
16+ _split_runtime_refs ,
1617)
1718
1819
@@ -162,7 +163,9 @@ async def test_calls_run_background_non_stream(self) -> None:
162163 ctx .entry_mode = "fresh"
163164 ctx .retry_attempt = 0
164165 ctx .is_steered_turn = False # Spec 016 FR-020: was_steered renamed
165- ctx .pending_input_count = 0 # Spec 016 FR-019: pending_inputs Sequence renamed to live int count
166+ ctx .pending_input_count = (
167+ 0 # Spec 016 FR-019: pending_inputs Sequence renamed to live int count
168+ )
166169 ctx .metadata = _FakeTaskMetadata ()
167170 ctx ._cancellation_signal = asyncio .Event ()
168171 ctx .shutdown = asyncio .Event ()
@@ -195,7 +198,9 @@ async def test_calls_run_background_non_stream(self) -> None:
195198 assert kwargs ["model" ] == "gpt-4o"
196199
197200 @pytest .mark .asyncio
198- async def test_recovery_and_steering_fields_flattened_on_response_context (self ) -> None :
201+ async def test_recovery_and_steering_fields_flattened_on_response_context (
202+ self ,
203+ ) -> None :
199204 """(Spec 024 Phase 5 — Proposal #10/#13) Recovery + steering
200205 classifiers land directly on ``ResponseContext`` flat fields.
201206 The pre-Phase-5 ``DurabilityContext`` indirection is deleted —
@@ -210,7 +215,10 @@ async def test_recovery_and_steering_fields_flattened_on_response_context(self)
210215 options = MagicMock (steerable_conversations = False ),
211216 )
212217
213- from azure .ai .agentserver .responses ._response_context import IsolationContext , ResponseContext
218+ from azure .ai .agentserver .responses ._response_context import (
219+ IsolationContext ,
220+ ResponseContext ,
221+ )
214222 from azure .ai .agentserver .responses .models .runtime import ResponseModeFlags
215223
216224 real_context = ResponseContext (
@@ -250,9 +258,13 @@ async def test_recovery_and_steering_fields_flattened_on_response_context(self)
250258 assert real_context .pending_input_count == 2
251259 assert not hasattr (real_context , "durability" )
252260 # The metadata facade was swapped in to back the task metadata.
253- from azure .ai .agentserver .responses ._durability_context import _DeveloperMetadataFacade
261+ from azure .ai .agentserver .responses ._durability_context import (
262+ _DeveloperMetadataFacade ,
263+ )
254264
255- assert isinstance (real_context .conversation_chain_metadata , _DeveloperMetadataFacade )
265+ assert isinstance (
266+ real_context .conversation_chain_metadata , _DeveloperMetadataFacade
267+ )
256268
257269 @pytest .mark .asyncio
258270 async def test_steerable_returns_none_for_implicit_suspend (self ) -> None :
@@ -270,7 +282,9 @@ async def test_steerable_returns_none_for_implicit_suspend(self) -> None:
270282 ctx .entry_mode = "fresh"
271283 ctx .retry_attempt = 0
272284 ctx .is_steered_turn = False # Spec 016 FR-020: was_steered renamed
273- ctx .pending_input_count = 0 # Spec 016 FR-019: pending_inputs Sequence renamed to live int count
285+ ctx .pending_input_count = (
286+ 0 # Spec 016 FR-019: pending_inputs Sequence renamed to live int count
287+ )
274288 ctx .metadata = _FakeTaskMetadata ()
275289 ctx ._cancellation_signal = asyncio .Event ()
276290 ctx .shutdown = asyncio .Event ()
@@ -310,7 +324,9 @@ async def test_non_steerable_returns_none_too(self) -> None:
310324 ctx .entry_mode = "fresh"
311325 ctx .retry_attempt = 0
312326 ctx .is_steered_turn = False # Spec 016 FR-020: was_steered renamed
313- ctx .pending_input_count = 0 # Spec 016 FR-019: pending_inputs Sequence renamed to live int count
327+ ctx .pending_input_count = (
328+ 0 # Spec 016 FR-019: pending_inputs Sequence renamed to live int count
329+ )
314330 ctx .metadata = _FakeTaskMetadata ()
315331 ctx ._cancellation_signal = asyncio .Event ()
316332 ctx .shutdown = asyncio .Event ()
@@ -350,7 +366,9 @@ async def test_cancel_bridge_propagates(self) -> None:
350366 ctx .entry_mode = "fresh"
351367 ctx .retry_attempt = 0
352368 ctx .is_steered_turn = False # Spec 016 FR-020: was_steered renamed
353- ctx .pending_input_count = 0 # Spec 016 FR-019: pending_inputs Sequence renamed to live int count
369+ ctx .pending_input_count = (
370+ 0 # Spec 016 FR-019: pending_inputs Sequence renamed to live int count
371+ )
354372 ctx .metadata = _FakeTaskMetadata ()
355373 ctx ._cancellation_signal = asyncio .Event ()
356374 ctx .shutdown = asyncio .Event ()
@@ -441,8 +459,12 @@ def test_pick_primitive_matrix(
441459 )
442460
443461 # Both primitives must exist (precondition for the matrix).
444- assert hasattr (orch , "_one_shot_task_fn" ), f"{ case_id } : orchestrator must register a one-shot primitive."
445- assert hasattr (orch , "_multi_turn_task_fn" ), f"{ case_id } : orchestrator must register a multi-turn primitive."
462+ assert hasattr (
463+ orch , "_one_shot_task_fn"
464+ ), f"{ case_id } : orchestrator must register a one-shot primitive."
465+ assert hasattr (
466+ orch , "_multi_turn_task_fn"
467+ ), f"{ case_id } : orchestrator must register a multi-turn primitive."
446468
447469 ctx_params = {
448470 "response_id" : "resp_test" ,
@@ -472,22 +494,31 @@ def test_orchestrator_registers_both_primitives_on_construction(self) -> None:
472494 deployment that mis-imports the core wheel fails fast at
473495 server startup instead of per-request.
474496 """
475- opts = MagicMock (steerable_conversations = False , max_pending = 10 , default_fetch_history_count = 100 )
497+ opts = MagicMock (
498+ steerable_conversations = False ,
499+ max_pending = 10 ,
500+ default_fetch_history_count = 100 ,
501+ )
476502 orch = DurableResponseOrchestrator (
477503 create_fn = AsyncMock (),
478504 provider = MagicMock (),
479505 options = opts ,
480506 )
481507
482508 # Both registrations are present.
483- assert hasattr (orch , "_one_shot_task_fn" ), "Construction must register the one-shot primitive."
484- assert hasattr (orch , "_multi_turn_task_fn" ), "Construction must register the multi-turn primitive."
509+ assert hasattr (
510+ orch , "_one_shot_task_fn"
511+ ), "Construction must register the one-shot primitive."
512+ assert hasattr (
513+ orch , "_multi_turn_task_fn"
514+ ), "Construction must register the multi-turn primitive."
485515
486516 # Names are distinct and well-formed.
487517 one_shot_name = orch ._one_shot_task_fn ._opts .name
488518 multi_turn_name = orch ._multi_turn_task_fn ._opts .name
489519 assert one_shot_name != multi_turn_name , (
490- f"Primitives must have distinct registration names " f"(both got { one_shot_name !r} )."
520+ f"Primitives must have distinct registration names "
521+ f"(both got { one_shot_name !r} )."
491522 )
492523 assert (
493524 "one_shot" in one_shot_name or "oneshot" in one_shot_name
@@ -499,13 +530,18 @@ def test_orchestrator_registers_both_primitives_on_construction(self) -> None:
499530 # The multi-turn primitive's steerable flag MUST match the
500531 # deployment's steerable_conversations option (per SOT §6.6).
501532 assert orch ._multi_turn_task_fn ._opts .steerable is False , (
502- "Multi-turn primitive's steerable flag must match " "options.steerable_conversations."
533+ "Multi-turn primitive's steerable flag must match "
534+ "options.steerable_conversations."
503535 )
504536
505537 def test_orchestrator_multi_turn_steerable_flag_propagated (self ) -> None :
506538 """With ``steerable_conversations=True``, the multi-turn primitive
507539 is registered with ``steerable=True``."""
508- opts = MagicMock (steerable_conversations = True , max_pending = 10 , default_fetch_history_count = 100 )
540+ opts = MagicMock (
541+ steerable_conversations = True ,
542+ max_pending = 10 ,
543+ default_fetch_history_count = 100 ,
544+ )
509545 orch = DurableResponseOrchestrator (
510546 create_fn = AsyncMock (),
511547 provider = MagicMock (),
@@ -514,3 +550,66 @@ def test_orchestrator_multi_turn_steerable_flag_propagated(self) -> None:
514550 assert (
515551 orch ._multi_turn_task_fn ._opts .steerable is True
516552 ), "Steerable flag must propagate from options to multi-turn primitive."
553+
554+
555+ class TestSplitRuntimeRefsSerializable :
556+ """The persisted durable-task input MUST be JSON-serializable.
557+
558+ Regression for the hosted bug where the gateway-injected
559+ ``agent_reference`` (an ``AgentReference`` model — a Mapping but not
560+ ``json.dumps``-serializable) leaked into the persisted params, making
561+ ``create_and_start`` raise ``TypeError`` and silently degrade the durable
562+ background run to a non-durable ``asyncio.create_task`` (no crash recovery).
563+ """
564+
565+ def test_persisted_params_json_serializable_with_agent_reference_model (
566+ self ,
567+ ) -> None :
568+ import json
569+
570+ from azure .ai .agentserver .responses .models import AgentReference
571+
572+ ctx_params = {
573+ "response_id" : "caresp_abc" ,
574+ "agent_name" : "durable-responses-agent-demo" ,
575+ "session_id" : "sess_1" ,
576+ "agent_reference" : AgentReference (
577+ name = "durable-responses-agent-demo" , version = "29"
578+ ),
579+ # a runtime-only object ref that must be stripped, never persisted
580+ "_record_ref" : object (),
581+ }
582+
583+ refs , persisted = _split_runtime_refs (ctx_params )
584+
585+ # refs hold the non-serializable object reference; not persisted
586+ assert "_record_ref" in refs
587+ assert "_record_ref" not in persisted
588+
589+ # agent_reference survives in the persisted input (needed across
590+ # cross-process recovery) but normalized to a plain dict
591+ assert isinstance (persisted ["agent_reference" ], dict )
592+ assert (
593+ persisted ["agent_reference" ].get ("name" ) == "durable-responses-agent-demo"
594+ )
595+ assert persisted ["agent_reference" ].get ("version" ) == "29"
596+
597+ # the whole persisted input must JSON-serialize (this is what the
598+ # core durable-task size check does and what previously raised)
599+ json .dumps (persisted ) # must not raise
600+
601+ def test_empty_agent_reference_sentinel_passthrough (self ) -> None :
602+ import json
603+
604+ # absent agent_reference is the ``{}`` sentinel — already serializable
605+ _ , persisted = _split_runtime_refs ({"response_id" : "r" , "agent_reference" : {}})
606+ assert persisted ["agent_reference" ] == {}
607+ json .dumps (persisted )
608+
609+ def test_dict_agent_reference_unchanged (self ) -> None :
610+ import json
611+
612+ ar = {"type" : "agent_reference" , "name" : "x" , "version" : "1" }
613+ _ , persisted = _split_runtime_refs ({"response_id" : "r" , "agent_reference" : ar })
614+ assert persisted ["agent_reference" ] == ar
615+ json .dumps (persisted )
0 commit comments