@@ -326,6 +326,7 @@ async def test_resolves_provider_trace_context_for_local_events(self):
326326
327327 @pytest .mark .asyncio
328328 async def test_forwards_provider_trace_headers_to_server_when_ids_omitted (self ):
329+ """Server POST should resolve trace headers from the provider when omitted."""
329330 from agent_control_models import Step
330331
331332 controls = [{
@@ -355,7 +356,12 @@ async def test_forwards_provider_trace_headers_to_server_when_ids_omitted(self):
355356 client .http_client = AsyncMock ()
356357 client .http_client .post = AsyncMock (return_value = mock_http_response )
357358 step = Step (type = "llm" , name = "test-step" , input = "hello" )
358- set_trace_context_provider (lambda : {"trace_id" : "c" * 32 , "span_id" : "d" * 16 })
359+ set_trace_context_provider (
360+ lambda : {
361+ "trace_id" : "c" * 32 ,
362+ "span_id" : "d" * 16 ,
363+ }
364+ )
359365
360366 with patch ("agent_control.evaluation.list_evaluators" , return_value = ["regex" ]):
361367 await evaluation .check_evaluation_with_local (
@@ -366,67 +372,158 @@ async def test_forwards_provider_trace_headers_to_server_when_ids_omitted(self):
366372 controls = controls ,
367373 )
368374
369- headers = client .http_client .post .call_args .kwargs ["headers" ]
370- assert headers ["X-Trace-Id" ] == "c" * 32
371- assert headers ["X-Span-Id" ] == "d" * 16
372- # Verify POST was called with headers
373375 call_kwargs = client .http_client .post .call_args
374376 headers = call_kwargs .kwargs .get ("headers" , {})
375- assert headers ["X-Trace-Id" ] == "aaaa1111bbbb2222cccc3333dddd4444"
376- assert headers ["X-Span-Id" ] == "eeee5555ffff6666"
377+ assert headers ["X-Trace-Id" ] == "c" * 32
378+ assert headers ["X-Span-Id" ] == "d" * 16
377379
380+
381+ class TestCheckEvaluation :
378382 @pytest .mark .asyncio
379- async def test_forwards_provider_trace_headers_to_server_when_ids_omitted (self ):
380- """Server POST should resolve trace headers from the provider when omitted."""
383+ async def test_default_path_keeps_server_only_behavior (self ):
381384 from agent_control_models import Step
382385
386+ mock_http_response = MagicMock ()
387+ mock_http_response .raise_for_status = MagicMock ()
388+ mock_http_response .json .return_value = {
389+ "is_safe" : True ,
390+ "confidence" : 0.9 ,
391+ "matches" : None ,
392+ "errors" : None ,
393+ "non_matches" : None ,
394+ }
395+
396+ client = MagicMock ()
397+ client .http_client = AsyncMock ()
398+ client .http_client .post = AsyncMock (return_value = mock_http_response )
399+ step = Step (type = "llm" , name = "test-step" , input = "hello" )
400+
401+ with patch ("agent_control.evaluation.has_control_event_sink" , return_value = False ), \
402+ patch ("agent_control.evaluation.emit_control_events" ) as mock_emit :
403+ result = await evaluation .check_evaluation (
404+ client = client ,
405+ agent_name = "agent-000000000001" ,
406+ step = step ,
407+ stage = "pre" ,
408+ )
409+
410+ call_kwargs = client .http_client .post .call_args .kwargs
411+ assert call_kwargs ["headers" ] is None
412+ mock_emit .assert_not_called ()
413+ assert result .is_safe is True
414+ assert result .confidence == 0.9
415+
416+ @pytest .mark .asyncio
417+ async def test_merged_event_sink_emits_reconstructed_server_events (self ):
418+ from agent_control_models import Step
419+
420+ controls = [
421+ {
422+ "id" : 2 ,
423+ "name" : "server-ctrl" ,
424+ "control" : {
425+ "condition" : {
426+ "evaluator" : {"name" : "regex" , "config" : {"pattern" : "test" }},
427+ "selector" : {"path" : "input" },
428+ },
429+ "action" : {"decision" : "allow" },
430+ "execution" : "server" ,
431+ },
432+ }
433+ ]
434+
435+ mock_http_response = MagicMock ()
436+ mock_http_response .raise_for_status = MagicMock ()
437+ mock_http_response .json .return_value = {
438+ "is_safe" : True ,
439+ "confidence" : 0.9 ,
440+ "matches" : [
441+ {
442+ "control_id" : 2 ,
443+ "control_name" : "server-ctrl" ,
444+ "action" : "allow" ,
445+ "control_execution_id" : "ce-server" ,
446+ "result" : {"matched" : True , "confidence" : 0.4 },
447+ }
448+ ],
449+ "errors" : None ,
450+ "non_matches" : None ,
451+ }
452+
453+ client = MagicMock ()
454+ client .http_client = AsyncMock ()
455+ client .http_client .post = AsyncMock (return_value = mock_http_response )
456+ step = Step (type = "llm" , name = "test-step" , input = "hello" )
457+
458+ with patch ("agent_control.evaluation.has_control_event_sink" , return_value = True ), \
459+ patch ("agent_control.evaluation.get_trace_and_span_ids" , return_value = ("a" * 32 , "b" * 16 )), \
460+ patch ("agent_control.evaluation.emit_control_events" ) as mock_emit , \
461+ patch .object (evaluation .state , "server_controls" , controls ):
462+ result = await evaluation .check_evaluation (
463+ client = client ,
464+ agent_name = "agent-000000000001" ,
465+ step = step ,
466+ stage = "pre" ,
467+ )
468+
469+ headers = client .http_client .post .call_args .kwargs ["headers" ]
470+ assert headers ["X-Trace-Id" ] == "a" * 32
471+ assert headers ["X-Span-Id" ] == "b" * 16
472+ assert headers ["X-Agent-Control-Merge-Events" ] == "true"
473+
474+ mock_emit .assert_called_once ()
475+ emitted_events = mock_emit .call_args .args [0 ]
476+ assert len (emitted_events ) == 1
477+ assert emitted_events [0 ].control_id == 2
478+ assert emitted_events [0 ].trace_id == "a" * 32
479+ assert emitted_events [0 ].span_id == "b" * 16
480+ assert emitted_events [0 ].metadata ["primary_evaluator" ] == "regex"
481+ assert result .matches is not None
482+ assert len (result .matches ) == 1
483+
484+ @pytest .mark .asyncio
485+ async def test_skips_local_event_reconstruction_when_nothing_consumes_events (self ):
486+ from agent_control_models import EvaluationResponse , Step
487+
383488 controls = [{
384489 "id" : 1 ,
385- "name" : "server -ctrl" ,
490+ "name" : "local -ctrl" ,
386491 "control" : {
387492 "condition" : {
388493 "evaluator" : {"name" : "regex" , "config" : {"pattern" : "test" }},
389494 "selector" : {"path" : "input" },
390495 },
391- "action" : {"decision" : "deny " },
392- "execution" : "server " ,
496+ "action" : {"decision" : "allow " },
497+ "execution" : "sdk " ,
393498 },
394499 }]
395500
396- mock_http_response = MagicMock ()
397- mock_http_response .json .return_value = {
398- "is_safe" : True ,
399- "confidence" : 1.0 ,
400- "matches" : None ,
401- "errors" : None ,
402- "non_matches" : None ,
403- }
404- mock_http_response .raise_for_status = MagicMock ()
501+ mock_response = EvaluationResponse (is_safe = True , confidence = 1.0 )
502+ mock_engine = MagicMock ()
503+ mock_engine .process = AsyncMock (return_value = mock_response )
405504
406505 client = MagicMock ()
407506 client .http_client = AsyncMock ()
408- client .http_client .post = AsyncMock (return_value = mock_http_response )
409507 step = Step (type = "llm" , name = "test-step" , input = "hello" )
410- set_trace_context_provider (
411- lambda : {
412- "trace_id" : "c" * 32 ,
413- "span_id" : "d" * 16 ,
414- }
415- )
416508
417- with patch ("agent_control.evaluation.list_evaluators" , return_value = ["regex" ]):
418- await evaluation .check_evaluation_with_local (
509+ with patch ("agent_control.evaluation.ControlEngine" , return_value = mock_engine ), \
510+ patch ("agent_control.evaluation.list_evaluators" , return_value = ["regex" ]), \
511+ patch ("agent_control.evaluation.has_control_event_sink" , return_value = False ), \
512+ patch ("agent_control.evaluation.is_observability_enabled" , return_value = False ), \
513+ patch ("agent_control.evaluation.build_control_execution_events" ) as mock_build , \
514+ patch ("agent_control.evaluation.enqueue_observability_events" ) as mock_enqueue :
515+ result = await evaluation .check_evaluation_with_local (
419516 client = client ,
420517 agent_name = "agent-000000000001" ,
421518 step = step ,
422519 stage = "pre" ,
423520 controls = controls ,
424521 )
425522
426- call_kwargs = client . http_client . post . call_args
427- headers = call_kwargs . kwargs . get ( "headers" , {} )
428- assert headers [ "X-Trace-Id" ] == "c" * 32
429- assert headers [ "X-Span-Id" ] == "d" * 16
523+ mock_build . assert_not_called ()
524+ mock_enqueue . assert_not_called ( )
525+ assert result . is_safe is True
526+ assert result . confidence == 1.0
430527
431528
432529# =============================================================================
0 commit comments