From 88cbbb6af3e61ee616e67469d20e050f2574e411 Mon Sep 17 00:00:00 2001 From: longxboy Date: Sun, 7 Jun 2026 13:58:12 +0800 Subject: [PATCH] fix(send_message): pick active composer when ghost frames exist WeChat's accessibility tree can contain multiple edit+send pairs: the live main-window composer plus stale "ghost" frames left behind by chats previously detached into separate windows. The old depth-first "take the first pair" logic grabbed the wrong (ghost) composer, whose input never received text and whose Send button stayed DISABLED forever, causing the plan to loop and ultimately fail with "No action selected". Collect every candidate edit+send pair and rank them so the genuinely active composer wins: 1. editable currently FOCUSED (strongest signal) 2. Send button NOT disabled (composer already has text) 3. pair under the main "Weixin" frame (not a ghost/detached frame) DFS order breaks any remaining ties. This makes 'wx messages send' reliable even when stale frames are present, removing the need to restart the container to clear ghost windows. --- .../src/plans/send_message.rs | 108 +++++++++++++----- 1 file changed, 79 insertions(+), 29 deletions(-) diff --git a/packages/agent-server-rust/src/plans/send_message.rs b/packages/agent-server-rust/src/plans/send_message.rs index 815f8d64..9708c5a6 100644 --- a/packages/agent-server-rust/src/plans/send_message.rs +++ b/packages/agent-server-rust/src/plans/send_message.rs @@ -29,47 +29,97 @@ pub struct SendMessagePlanState { pub confirm_attempts: u32, } -fn find_edit_and_send_button(a11y: &A11yNode) -> Option<(&A11yNode, &A11yNode)> { - let send_btn = query_selector(a11y, r#"push-button[name="Send(S)"]"#)?; - // Find sibling EDITABLE text node via parent - // Since we don't have parent refs in the tree-based approach, - // we search the tree for the pattern - find_edit_near_send(a11y, send_btn) +fn node_has_state(node: &A11yNode, state: &str) -> bool { + node.states + .as_ref() + .map(|s| s.iter().any(|st| st == state)) + .unwrap_or(false) +} + +/// A candidate composer: the editable text input plus its sibling Send(S) button. +struct ComposerPair<'a> { + edit: &'a A11yNode, + send: &'a A11yNode, + /// True if this pair lives under the main "Weixin" application frame + /// (as opposed to a detached/ghost chat frame leftover in the a11y tree). + in_main_frame: bool, } -fn find_edit_near_send<'a>( - root: &'a A11yNode, - _send_btn: &A11yNode, -) -> Option<(&'a A11yNode, &'a A11yNode)> { - // Walk tree looking for a parent that has both an EDITABLE text and Send(S) button - find_edit_send_pair(root) +/// Find the composer (editable + Send button) to operate on. +/// +/// WeChat's accessibility tree can contain *multiple* edit+send pairs: +/// the live main-window composer plus stale "ghost" frames left behind by +/// chats that were previously detached into separate windows. A naive +/// depth-first "take the first pair" grabs the wrong (ghost) composer, whose +/// input never receives text and whose Send stays DISABLED forever, causing +/// the plan to loop and ultimately fail with "No action selected". +/// +/// To be robust we collect every candidate pair, then rank them so the +/// genuinely active composer wins: +/// 1. editable currently FOCUSED (strongest signal) +/// 2. Send button NOT disabled (composer already has text) +/// 3. pair under the main "Weixin" frame (not a ghost/detached frame) +/// The first pair (DFS order) breaks any remaining ties. +fn find_edit_and_send_button(a11y: &A11yNode) -> Option<(&A11yNode, &A11yNode)> { + let mut candidates: Vec = Vec::new(); + collect_edit_send_pairs(a11y, false, &mut candidates); + + if candidates.is_empty() { + return None; + } + + // Pick the best candidate by preference, preserving DFS order on ties. + let best = candidates.iter().enumerate().min_by_key(|(idx, c)| { + let focused = node_has_state(c.edit, "FOCUSED"); + let send_enabled = !node_has_state(c.send, "DISABLED"); + // Lower key = higher priority. Each desirable property subtracts rank. + let mut score: i32 = 0; + if focused { + score -= 100; + } + if send_enabled { + score -= 10; + } + if c.in_main_frame { + score -= 1; + } + // Tie-break: earlier DFS position wins. + (score, *idx as i32) + }); + + best.map(|(_, c)| (c.edit, c.send)) } -fn find_edit_send_pair(node: &A11yNode) -> Option<(&A11yNode, &A11yNode)> { +/// Recursively collect all edit+send composer pairs, tracking whether each +/// pair is inside the main "Weixin" frame. +fn collect_edit_send_pairs<'a>( + node: &'a A11yNode, + in_main_frame: bool, + out: &mut Vec>, +) { + // Once we enter the main "Weixin" frame, everything below it is in-main. + let in_main_frame = in_main_frame || (node.role == "frame" && node.name == "Weixin"); + if let Some(children) = &node.children { - let send_btn = children.iter().find(|c| { - c.role == "push-button" && c.name == "Send(S)" - }); - let edit_node = children.iter().find(|c| { - c.role == "text" - && c.states - .as_ref() - .map(|s| s.iter().any(|st| st == "EDITABLE")) - .unwrap_or(false) - }); + let send_btn = children + .iter() + .find(|c| c.role == "push-button" && c.name == "Send(S)"); + let edit_node = children + .iter() + .find(|c| c.role == "text" && node_has_state(c, "EDITABLE")); if let (Some(edit), Some(send)) = (edit_node, send_btn) { - return Some((edit, send)); + out.push(ComposerPair { + edit, + send, + in_main_frame, + }); } - // Recurse for child in children { - if let Some(result) = find_edit_send_pair(child) { - return Some(result); - } + collect_edit_send_pairs(child, in_main_frame, out); } } - None } #[async_trait::async_trait]