@@ -157,8 +157,8 @@ pub fn normalize(
157157}
158158
159159pub fn render_provider ( decision : & NormalizedDecision ) -> Result < String , HookError > {
160- if let Some ( output) = decision . provider_output . as_ref ( ) {
161- return serde_json:: to_string ( output) . map_err ( |_| {
160+ if let Some ( output) = provider_output_with_aggregate_context ( decision ) {
161+ return serde_json:: to_string ( & output) . map_err ( |_| {
162162 HookError :: runtime (
163163 "provider-output-render-failed" ,
164164 "provider output could not be rendered" ,
@@ -203,6 +203,51 @@ pub fn render_provider(decision: &NormalizedDecision) -> Result<String, HookErro
203203 } )
204204}
205205
206+ fn provider_output_with_aggregate_context ( decision : & NormalizedDecision ) -> Option < Value > {
207+ let mut output = decision. provider_output . clone ( ) ?;
208+ if !matches ! (
209+ decision. action,
210+ DecisionAction :: Context | DecisionAction :: Warn
211+ ) {
212+ return Some ( output) ;
213+ }
214+ let Some ( context) = decision. context . as_deref ( ) else {
215+ return Some ( output) ;
216+ } ;
217+
218+ let Some ( root) = output. as_object_mut ( ) else {
219+ return Some ( json ! ( {
220+ "hookSpecificOutput" : {
221+ "hookEventName" : decision. event,
222+ "additionalContext" : context,
223+ }
224+ } ) ) ;
225+ } ;
226+ let has_top_level_context = root. contains_key ( "additionalContext" ) ;
227+ if let Some ( hook_output) = root
228+ . get_mut ( "hookSpecificOutput" )
229+ . and_then ( Value :: as_object_mut)
230+ {
231+ hook_output. insert ( "additionalContext" . to_string ( ) , json ! ( context) ) ;
232+ hook_output
233+ . entry ( "hookEventName" . to_string ( ) )
234+ . or_insert_with ( || json ! ( decision. event) ) ;
235+ if has_top_level_context {
236+ root. insert ( "additionalContext" . to_string ( ) , json ! ( context) ) ;
237+ }
238+ } else if has_top_level_context {
239+ root. insert ( "additionalContext" . to_string ( ) , json ! ( context) ) ;
240+ } else {
241+ return Some ( json ! ( {
242+ "hookSpecificOutput" : {
243+ "hookEventName" : decision. event,
244+ "additionalContext" : context,
245+ }
246+ } ) ) ;
247+ }
248+ Some ( output)
249+ }
250+
206251pub fn render_provider_error (
207252 product : Product ,
208253 event : & str ,
@@ -810,3 +855,127 @@ fn parse_provider_json(input: &[u8]) -> Result<Value, HookError> {
810855 }
811856 } )
812857}
858+
859+ #[ cfg( test) ]
860+ mod tests {
861+ use super :: * ;
862+ use crate :: model:: { DecisionReason , ShadowObservation } ;
863+
864+ #[ test]
865+ fn provider_render_uses_the_full_aggregated_context ( ) {
866+ for product in [ Product :: Codex , Product :: Claude ] {
867+ let decision = NormalizedDecision {
868+ schema_version : "agent-hook.decision.v1" . to_string ( ) ,
869+ request_id : "request:test" . to_string ( ) ,
870+ product,
871+ event : "UserPromptSubmit" . to_string ( ) ,
872+ action : DecisionAction :: Context ,
873+ reasons : vec ! [ DecisionReason {
874+ rule_id: "fixture.context" . to_string( ) ,
875+ code: "fixture-context" . to_string( ) ,
876+ disposition: "context" . to_string( ) ,
877+ } ] ,
878+ context : Some ( "first context\n second context" . to_string ( ) ) ,
879+ replacement : None ,
880+ shadow : Vec :: < ShadowObservation > :: new ( ) ,
881+ config_digest : "sha256:config" . to_string ( ) ,
882+ policy_digest : "sha256:policy" . to_string ( ) ,
883+ recovery_applied : false ,
884+ provider_output : Some ( json ! ( {
885+ "hookSpecificOutput" : {
886+ "hookEventName" : "UserPromptSubmit" ,
887+ "additionalContext" : "first context" ,
888+ } ,
889+ "suppressOutput" : true ,
890+ } ) ) ,
891+ } ;
892+
893+ let rendered: Value =
894+ serde_json:: from_str ( & render_provider ( & decision) . expect ( "provider output" ) )
895+ . expect ( "provider JSON" ) ;
896+ assert_eq ! (
897+ rendered[ "hookSpecificOutput" ] [ "additionalContext" ] ,
898+ "first context\n second context"
899+ ) ;
900+ assert_eq ! ( rendered[ "suppressOutput" ] , true ) ;
901+ }
902+ }
903+
904+ #[ test]
905+ fn provider_render_preserves_native_envelopes_without_aggregate_context ( ) {
906+ for product in [ Product :: Codex , Product :: Claude ] {
907+ for action in [
908+ DecisionAction :: Allow ,
909+ DecisionAction :: Block ,
910+ DecisionAction :: Transform ,
911+ ] {
912+ let provider_output = json ! ( {
913+ "decision" : "provider-native" ,
914+ "reason" : "preserve me" ,
915+ "suppressOutput" : true ,
916+ "providerExtension" : { "product" : product. as_str( ) } ,
917+ } ) ;
918+ let decision = NormalizedDecision {
919+ schema_version : "agent-hook.decision.v1" . to_string ( ) ,
920+ request_id : "request:provider-preservation" . to_string ( ) ,
921+ product,
922+ event : "PreToolUse" . to_string ( ) ,
923+ action,
924+ reasons : Vec :: new ( ) ,
925+ context : None ,
926+ replacement : None ,
927+ shadow : Vec :: < ShadowObservation > :: new ( ) ,
928+ config_digest : "sha256:config" . to_string ( ) ,
929+ policy_digest : "sha256:policy" . to_string ( ) ,
930+ recovery_applied : false ,
931+ provider_output : Some ( provider_output. clone ( ) ) ,
932+ } ;
933+
934+ let rendered: Value =
935+ serde_json:: from_str ( & render_provider ( & decision) . expect ( "provider output" ) )
936+ . expect ( "provider JSON" ) ;
937+ assert_eq ! ( rendered, provider_output) ;
938+ }
939+ }
940+ }
941+
942+ #[ test]
943+ fn provider_render_synchronizes_mixed_accepted_context_locations ( ) {
944+ for product in [ Product :: Codex , Product :: Claude ] {
945+ let decision = NormalizedDecision {
946+ schema_version : "agent-hook.decision.v1" . to_string ( ) ,
947+ request_id : "request:mixed-context" . to_string ( ) ,
948+ product,
949+ event : "UserPromptSubmit" . to_string ( ) ,
950+ action : DecisionAction :: Context ,
951+ reasons : Vec :: new ( ) ,
952+ context : Some ( "first context\n second context" . to_string ( ) ) ,
953+ replacement : None ,
954+ shadow : Vec :: < ShadowObservation > :: new ( ) ,
955+ config_digest : "sha256:config" . to_string ( ) ,
956+ policy_digest : "sha256:policy" . to_string ( ) ,
957+ recovery_applied : false ,
958+ provider_output : Some ( json ! ( {
959+ "additionalContext" : "first context" ,
960+ "hookSpecificOutput" : {
961+ "hookEventName" : "UserPromptSubmit" ,
962+ } ,
963+ "suppressOutput" : true ,
964+ } ) ) ,
965+ } ;
966+
967+ let rendered: Value =
968+ serde_json:: from_str ( & render_provider ( & decision) . expect ( "provider output" ) )
969+ . expect ( "provider JSON" ) ;
970+ assert_eq ! (
971+ rendered[ "hookSpecificOutput" ] [ "additionalContext" ] ,
972+ "first context\n second context"
973+ ) ;
974+ assert_eq ! (
975+ rendered[ "additionalContext" ] ,
976+ "first context\n second context"
977+ ) ;
978+ assert_eq ! ( rendered[ "suppressOutput" ] , true ) ;
979+ }
980+ }
981+ }
0 commit comments