@@ -101,6 +101,42 @@ fn ax_reply_is_outcome_unverifiable(code: crate::ax::bindings::AXError) -> bool
101101 )
102102}
103103
104+ /// What a reply to `AXUIElementPerformAction` licenses the driver to do next.
105+ #[ derive( Clone , Copy , Debug , PartialEq , Eq ) ]
106+ enum AxReplyDisposition {
107+ Performed ,
108+ /// The application answered the action without saying whether it acted, so
109+ /// nothing further may touch the UI: a selection write could act a second
110+ /// time, and its read-back would then report a confirmed effect the reply
111+ /// does not support.
112+ Dispatched ( crate :: ax:: bindings:: AXError ) ,
113+ /// A refusal to a plain press. Finder answers `kAXErrorCannotComplete` for
114+ /// a press on a collection row whose selectable object is an ancestor, so
115+ /// the bounded `AXSelected` fallback is still worth trying.
116+ TrySelection ( crate :: ax:: bindings:: AXError ) ,
117+ Failed ( crate :: ax:: bindings:: AXError ) ,
118+ }
119+
120+ fn ax_reply_disposition (
121+ code : crate :: ax:: bindings:: AXError ,
122+ ax_action : & str ,
123+ modifiers : & [ String ] ,
124+ ) -> AxReplyDisposition {
125+ if code == crate :: ax:: bindings:: kAXErrorSuccess {
126+ AxReplyDisposition :: Performed
127+ } else if ax_reply_is_outcome_unverifiable ( code) {
128+ AxReplyDisposition :: Dispatched ( code)
129+ } else if ax_action == "AXPress" && modifiers. is_empty ( ) {
130+ AxReplyDisposition :: TrySelection ( code)
131+ } else {
132+ AxReplyDisposition :: Failed ( code)
133+ }
134+ }
135+
136+ fn ax_reply_failure ( ax_action : & str , code : crate :: ax:: bindings:: AXError ) -> anyhow:: Error {
137+ anyhow:: anyhow!( "AXUIElementPerformAction({ax_action}) returned {code}" )
138+ }
139+
104140fn ax_reply_code_name ( code : crate :: ax:: bindings:: AXError ) -> & ' static str {
105141 match code {
106142 crate :: ax:: bindings:: kAXErrorFailure => "kAXErrorFailure" ,
@@ -728,69 +764,15 @@ impl Tool for ClickTool {
728764 let changes = super :: finish_window_observation ( snapshot, & args) . await ;
729765
730766 match result {
731- Ok ( Ok ( (
732- AxClickOutcome {
733- summary : mut msg,
734- needs_webkit_delay,
735- suspected_noop,
736- selection_verified,
737- selection_via_pixel,
738- unverified,
739- } ,
740- fronted,
741- ) ) ) => {
767+ Ok ( Ok ( ( mut outcome, fronted) ) ) => {
742768 // For text inputs, wait 800ms for WebKit DOM focus to settle
743769 // before returning — matches the Swift reference behaviour.
744- if needs_webkit_delay {
770+ if outcome . needs_webkit_delay {
745771 tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( 800 ) ) . await ;
746772 }
773+ let structured = ax_click_structured ( & outcome, fronted) ;
774+ let mut msg = std:: mem:: take ( & mut outcome. summary ) ;
747775 msg. push_str ( & changes. result_suffix ( ) ) ;
748- // AX dispatch went through, but AXPerformAction returning
749- // success does not confirm the on-screen effect (many elements
750- // no-op silently). A click is never driver-verifiable (no
751- // read-back) → verified:false stays for back-compat. The
752- // tri-state `effect` is the richer signal:
753- // * suspected_noop — the element didn't advertise the action,
754- // so the press likely did nothing → cross to vision/pixel.
755- // * unverifiable — dispatched fine, driver just can't confirm;
756- // the caller verifies via screenshot.
757- let mut structured = serde_json:: json!( {
758- "path" : if selection_via_pixel {
759- if fronted { "cgevent_fg" } else { "cgevent" }
760- } else if fronted {
761- "ax_fg"
762- } else {
763- "ax"
764- } ,
765- "verified" : selection_verified,
766- "effect" : if selection_verified {
767- "confirmed"
768- } else if suspected_noop {
769- "suspected_noop"
770- } else {
771- "unverifiable"
772- } ,
773- } ) ;
774- if selection_verified {
775- structured[ "evidence" ] = serde_json:: json!( [
776- { "kind" : "accessibility_readback" }
777- ] ) ;
778- }
779- // The application answered the dispatch with a code that
780- // establishes neither delivery nor a no-op, so the mode
781- // the action was delivered in is not known either.
782- if unverified. is_some ( ) {
783- structured[ "delivery_mode" ] = serde_json:: json!( "unknown" ) ;
784- }
785- if suspected_noop {
786- structured[ "escalation" ] = serde_json:: json!( {
787- "recommended" : "px" ,
788- "reason" : "element does not advertise this action — the \
789- AX press likely no-op'd. Do an element px \
790- action: click by pixel (x,y) off the \
791- screenshot from get_window_state."
792- } ) ;
793- }
794776 ToolResult :: text ( msg) . with_structured ( structured)
795777 }
796778 Ok ( Err ( e) ) => ToolResult :: error ( format ! ( "AX action failed: {e}" ) ) ,
@@ -1251,6 +1233,49 @@ struct AxClickOutcome {
12511233 unverified : Option < crate :: ax:: bindings:: AXError > ,
12521234}
12531235
1236+ /// The machine-readable half of an AX click result. A generic click has no
1237+ /// independent read-back, so `verified` stays false unless a selection write
1238+ /// was confirmed, and the tri-state `effect` carries the richer verdict:
1239+ /// `suspected_noop` for an action the element never advertised (cross to the
1240+ /// vision/pixel path), `unverifiable` for a dispatch the driver cannot settle
1241+ /// (the caller's own observation does). A reply that establishes neither
1242+ /// delivery nor a no-op leaves the delivery mode unknown as well.
1243+ fn ax_click_structured ( outcome : & AxClickOutcome , fronted : bool ) -> serde_json:: Value {
1244+ let mut structured = serde_json:: json!( {
1245+ "path" : if outcome. selection_via_pixel {
1246+ if fronted { "cgevent_fg" } else { "cgevent" }
1247+ } else if fronted {
1248+ "ax_fg"
1249+ } else {
1250+ "ax"
1251+ } ,
1252+ "verified" : outcome. selection_verified,
1253+ "effect" : if outcome. selection_verified {
1254+ "confirmed"
1255+ } else if outcome. suspected_noop {
1256+ "suspected_noop"
1257+ } else {
1258+ "unverifiable"
1259+ } ,
1260+ } ) ;
1261+ if outcome. selection_verified {
1262+ structured[ "evidence" ] = serde_json:: json!( [ { "kind" : "accessibility_readback" } ] ) ;
1263+ }
1264+ if outcome. unverified . is_some ( ) {
1265+ structured[ "delivery_mode" ] = serde_json:: json!( "unknown" ) ;
1266+ }
1267+ if outcome. suspected_noop {
1268+ structured[ "escalation" ] = serde_json:: json!( {
1269+ "recommended" : "px" ,
1270+ "reason" : "element does not advertise this action — the \
1271+ AX press likely no-op'd. Do an element px \
1272+ action: click by pixel (x,y) off the \
1273+ screenshot from get_window_state."
1274+ } ) ;
1275+ }
1276+ structured
1277+ }
1278+
12541279fn perform_ax_click (
12551280 element_ptr : usize ,
12561281 idx : usize ,
@@ -1393,30 +1418,25 @@ fn perform_ax_click(
13931418 }
13941419
13951420 let err = unsafe { crate :: ax:: bindings:: perform_action ( element, ax_action) } ;
1396- let unverified = if err == crate :: ax:: bindings:: kAXErrorSuccess {
1397- None
1398- } else {
1399- // Some collection rows claim a click-like action but Finder returns
1400- // kAXErrorCannotComplete. Use the same verified selection fallback
1401- // before surfacing the dispatch error.
1402- if ax_action == "AXPress" && modifiers. is_empty ( ) {
1421+ let unverified = match ax_reply_disposition ( err, ax_action, modifiers) {
1422+ AxReplyDisposition :: Performed => None ,
1423+ AxReplyDisposition :: Dispatched ( code) => Some ( code) ,
1424+ AxReplyDisposition :: TrySelection ( code) => {
14031425 if let Some ( selected_role) =
14041426 crate :: input:: ax_actions:: select_nearest_container ( element_ptr)
14051427 {
14061428 return Ok ( AxClickOutcome {
14071429 summary : format ! (
14081430 "✅ Selected nearest {selected_role} for [{idx}] {role} \" {title}\" \
1409- after AXPress returned {err }; confirmed AXSelected=true."
1431+ after AXPress returned {code }; confirmed AXSelected=true."
14101432 ) ,
14111433 selection_verified : true ,
14121434 ..AxClickOutcome :: default ( )
14131435 } ) ;
14141436 }
1437+ return Err ( ax_reply_failure ( ax_action, code) ) ;
14151438 }
1416- if !ax_reply_is_outcome_unverifiable ( err) {
1417- anyhow:: bail!( "AXUIElementPerformAction({ax_action}) returned {err}" ) ;
1418- }
1419- Some ( err)
1439+ AxReplyDisposition :: Failed ( code) => return Err ( ax_reply_failure ( ax_action, code) ) ,
14201440 } ;
14211441
14221442 let mut summary = ax_reply_summary ( unverified, ax_action, idx, & role, & title) ;
@@ -1728,4 +1748,73 @@ mod tests {
17281748 "strict-suppression paths retain their existing ownership"
17291749 ) ;
17301750 }
1751+
1752+ /// The reply decides what may happen next, before anything else touches
1753+ /// the UI. An application that answered the action may have performed it,
1754+ /// so the collection-row selection fallback — an `AXSelected` write whose
1755+ /// read-back would publish a confirmed effect — must not run for those
1756+ /// replies. `-25204` (kAXErrorCannotComplete) is the refusal that fallback
1757+ /// exists for and keeps it.
1758+ #[ test]
1759+ fn an_unverifiable_reply_is_classified_before_the_selection_fallback ( ) {
1760+ for code in [
1761+ crate :: ax:: bindings:: kAXErrorFailure,
1762+ crate :: ax:: bindings:: kAXErrorAttributeUnsupported,
1763+ crate :: ax:: bindings:: kAXErrorActionUnsupported,
1764+ ] {
1765+ assert_eq ! (
1766+ ax_reply_disposition( code, "AXPress" , & [ ] ) ,
1767+ AxReplyDisposition :: Dispatched ( code) ,
1768+ "{code}"
1769+ ) ;
1770+ }
1771+ assert_eq ! (
1772+ ax_reply_disposition( -25204 , "AXPress" , & [ ] ) ,
1773+ AxReplyDisposition :: TrySelection ( -25204 )
1774+ ) ;
1775+ assert_eq ! (
1776+ ax_reply_disposition( -25204 , "AXPress" , & [ "cmd" . to_string( ) ] ) ,
1777+ AxReplyDisposition :: Failed ( -25204 ) ,
1778+ "a modified click never improvises a selection"
1779+ ) ;
1780+ assert_eq ! (
1781+ ax_reply_disposition( -25204 , "AXShowMenu" , & [ ] ) ,
1782+ AxReplyDisposition :: Failed ( -25204 ) ,
1783+ "only a plain press has a selection equivalent"
1784+ ) ;
1785+ assert_eq ! (
1786+ ax_reply_disposition( crate :: ax:: bindings:: kAXErrorSuccess, "AXPress" , & [ ] ) ,
1787+ AxReplyDisposition :: Performed
1788+ ) ;
1789+ }
1790+
1791+ /// The published result of a dispatch the application answered: uncertain,
1792+ /// with the delivery mode unknown and no read-back evidence. Only a
1793+ /// confirmed selection write earns `confirmed`.
1794+ #[ test]
1795+ fn an_answered_dispatch_is_not_published_as_a_confirmed_effect ( ) {
1796+ let dispatched = ax_click_structured (
1797+ & AxClickOutcome {
1798+ unverified : Some ( crate :: ax:: bindings:: kAXErrorFailure) ,
1799+ ..AxClickOutcome :: default ( )
1800+ } ,
1801+ false ,
1802+ ) ;
1803+ assert_eq ! ( dispatched[ "effect" ] , "unverifiable" ) ;
1804+ assert_eq ! ( dispatched[ "verified" ] , false ) ;
1805+ assert_eq ! ( dispatched[ "delivery_mode" ] , "unknown" ) ;
1806+ assert ! ( dispatched. get( "evidence" ) . is_none( ) , "{dispatched}" ) ;
1807+
1808+ let selected = ax_click_structured (
1809+ & AxClickOutcome {
1810+ selection_verified : true ,
1811+ ..AxClickOutcome :: default ( )
1812+ } ,
1813+ false ,
1814+ ) ;
1815+ assert_eq ! ( selected[ "effect" ] , "confirmed" ) ;
1816+ assert_eq ! ( selected[ "verified" ] , true ) ;
1817+ assert_eq ! ( selected[ "evidence" ] [ 0 ] [ "kind" ] , "accessibility_readback" ) ;
1818+ assert ! ( selected. get( "delivery_mode" ) . is_none( ) , "{selected}" ) ;
1819+ }
17311820}
0 commit comments