Skip to content

Commit c2e6298

Browse files
committed
fix(cua-driver): classify an AX reply before the selection fallback
A reply the application answered the action with reached the collection-row selection fallback before it was classified. That fallback writes AXSelected and reads it back, so a -25200/-25205/-25206 press on a row-like element mutated the selection a second time and returned selection_verified, which the caller publishes as effect: confirmed with no reply code — the opposite of the uncertain dispatch this branch exists to report. The reply now decides what may happen next: an answered dispatch reports itself and touches nothing further, while a genuine refusal (Finder's -25204 on a row whose selectable object is an ancestor) keeps the fallback. The published half of the result moves into ax_click_structured so the effect, delivery mode and evidence a caller sees are testable without a live window server.
1 parent fcd1bbd commit c2e6298

1 file changed

Lines changed: 159 additions & 70 deletions

File tree

  • libs/cua-driver/rust/crates/platform-macos/src/tools

libs/cua-driver/rust/crates/platform-macos/src/tools/click.rs

Lines changed: 159 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
104140
fn 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+
12541279
fn 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

Comments
 (0)