@@ -1266,7 +1266,8 @@ fn handle_switch_model_control(
12661266
12671267/// Handle a `permission_decision` control frame.
12681268///
1269- /// Extracts `channelId`, `requestNonce`, and `optionId` from the payload and
1269+ /// Extracts `channelId`, `requestNonce`, and a selected/cancelled outcome from
1270+ /// the payload and
12701271/// delivers a [`crate::acp::PermissionDecision`] to the in-flight read loop
12711272/// via the per-task `permission_decision_tx` mpsc channel.
12721273///
@@ -1296,18 +1297,48 @@ fn handle_permission_decision_control(
12961297 return ;
12971298 } ;
12981299
1299- let Some ( option_id) = payload
1300- . get ( "optionId" )
1301- . and_then ( |v| v. as_str ( ) )
1302- . filter ( |s| !s. is_empty ( ) )
1303- else {
1304- tracing:: warn!( "observer permission_decision control frame missing optionId" ) ;
1305- return ;
1300+ let selected_outcome = || {
1301+ payload
1302+ . get ( "optionId" )
1303+ . and_then ( |value| value. as_str ( ) )
1304+ . filter ( |value| !value. is_empty ( ) )
1305+ . map (
1306+ |option_id| crate :: acp:: PermissionDecisionOutcome :: Selected {
1307+ option_id : option_id. to_string ( ) ,
1308+ } ,
1309+ )
1310+ } ;
1311+ let outcome = match payload. get ( "outcome" ) {
1312+ Some ( serde_json:: Value :: String ( value) ) if value == "cancelled" => {
1313+ crate :: acp:: PermissionDecisionOutcome :: Cancelled
1314+ }
1315+ Some ( serde_json:: Value :: String ( value) ) if value == "selected" => {
1316+ let Some ( outcome) = selected_outcome ( ) else {
1317+ tracing:: warn!( "observer permission_decision selected outcome missing optionId" ) ;
1318+ return ;
1319+ } ;
1320+ outcome
1321+ }
1322+ None => {
1323+ let Some ( outcome) = selected_outcome ( ) else {
1324+ tracing:: warn!( "observer permission_decision selected outcome missing optionId" ) ;
1325+ return ;
1326+ } ;
1327+ outcome
1328+ }
1329+ Some ( other) => {
1330+ tracing:: warn!( outcome = %other, "observer permission_decision has unknown outcome" ) ;
1331+ return ;
1332+ }
13061333 } ;
13071334
13081335 let decision = crate :: acp:: PermissionDecision {
13091336 request_nonce : request_nonce. to_string ( ) ,
1310- option_id : option_id. to_string ( ) ,
1337+ outcome : outcome. clone ( ) ,
1338+ } ;
1339+ let outcome_name = match & outcome {
1340+ crate :: acp:: PermissionDecisionOutcome :: Selected { .. } => "selected" ,
1341+ crate :: acp:: PermissionDecisionOutcome :: Cancelled => "cancelled" ,
13111342 } ;
13121343
13131344 // Find the in-flight task for this channel and deliver via its mpsc.
@@ -1323,7 +1354,7 @@ fn handle_permission_decision_control(
13231354 tracing:: info!(
13241355 channel = %channel_id,
13251356 nonce = %request_nonce,
1326- option_id = %option_id ,
1357+ outcome = outcome_name ,
13271358 "permission_decision delivered to read loop"
13281359 ) ;
13291360 "sent"
@@ -1372,7 +1403,7 @@ fn handle_permission_decision_control(
13721403 "type" : "permission_decision" ,
13731404 "status" : status,
13741405 "requestNonce" : request_nonce,
1375- "optionId " : option_id ,
1406+ "outcome " : outcome_name ,
13761407 } ) ,
13771408 ) ;
13781409 }
@@ -4884,6 +4915,62 @@ mod owner_control_command_tests {
48844915 ControlSignal :: Rotate
48854916 ) ) ;
48864917 }
4918+
4919+ #[ tokio:: test]
4920+ async fn permission_control_delivers_cancel_and_legacy_selection ( ) {
4921+ let mut pool = AgentPool :: from_slots ( vec ! [ ] ) ;
4922+ let channel_id = Uuid :: new_v4 ( ) ;
4923+ let ( decision_tx, mut decision_rx) = tokio:: sync:: mpsc:: channel ( 1 ) ;
4924+
4925+ let task = pool. join_set . spawn ( std:: future:: pending :: < ( ) > ( ) ) ;
4926+ pool. task_map_mut ( ) . insert (
4927+ task. id ( ) ,
4928+ pool:: TaskMeta {
4929+ agent_index : 0 ,
4930+ channel_id : Some ( channel_id) ,
4931+ turn_id : "permission-turn" . to_string ( ) ,
4932+ recoverable_batch : None ,
4933+ control_tx : None ,
4934+ steer_tx : None ,
4935+ permission_decision_tx : Some ( decision_tx) ,
4936+ } ,
4937+ ) ;
4938+
4939+ handle_permission_decision_control (
4940+ & serde_json:: json!( {
4941+ "type" : "permission_decision" ,
4942+ "channelId" : channel_id,
4943+ "requestNonce" : "cancel-nonce" ,
4944+ "outcome" : "cancelled"
4945+ } ) ,
4946+ & mut pool,
4947+ None ,
4948+ ) ;
4949+ let cancelled = decision_rx. recv ( ) . await . expect ( "cancel decision" ) ;
4950+ assert_eq ! ( cancelled. request_nonce, "cancel-nonce" ) ;
4951+ assert ! ( matches!(
4952+ cancelled. outcome,
4953+ crate :: acp:: PermissionDecisionOutcome :: Cancelled
4954+ ) ) ;
4955+
4956+ handle_permission_decision_control (
4957+ & serde_json:: json!( {
4958+ "type" : "permission_decision" ,
4959+ "channelId" : channel_id,
4960+ "requestNonce" : "legacy-nonce" ,
4961+ "optionId" : "allow-once"
4962+ } ) ,
4963+ & mut pool,
4964+ None ,
4965+ ) ;
4966+ let selected = decision_rx. recv ( ) . await . expect ( "legacy selected decision" ) ;
4967+ assert_eq ! ( selected. request_nonce, "legacy-nonce" ) ;
4968+ assert ! ( matches!(
4969+ selected. outcome,
4970+ crate :: acp:: PermissionDecisionOutcome :: Selected { option_id }
4971+ if option_id == "allow-once"
4972+ ) ) ;
4973+ }
48874974}
48884975
48894976#[ cfg( test) ]
@@ -8049,6 +8136,7 @@ mod observer_payload_trim_tests {
80498136 event. authorization = Some ( crate :: observer:: AuthorizationEnvelope {
80508137 request_nonce : "test-nonce" . to_string ( ) ,
80518138 actionable : true ,
8139+ can_cancel : Some ( true ) ,
80528140 reason : None ,
80538141 } ) ;
80548142
0 commit comments