diff --git a/docs/content/docs/cua-driver/reference/mcp-tools.mdx b/docs/content/docs/cua-driver/reference/mcp-tools.mdx index 9c2cd20983..35940dced1 100644 --- a/docs/content/docs/cua-driver/reference/mcp-tools.mdx +++ b/docs/content/docs/cua-driver/reference/mcp-tools.mdx @@ -521,7 +521,9 @@ Update cua-driver-rs configuration. Changes to capture_mode and max_image_dimens ### set_agent_cursor_enabled -Show or hide the agent cursor overlay for a cursor instance. With no `cursor_id`, this targets the calling session's own cursor (see the per-session note above). +Show or hide the agent cursor overlay for a cursor instance. The overlay is **ON by default** and each MCP session automatically owns its own cursor — you do not need to call this to make the cursor appear; use it only to hide (`enabled:false`) or re-show (`enabled:true`) it. With no `cursor_id`, this targets the calling session's own cursor (see the per-session note above). + +> **Visibility caveat (AX runs).** On a pure accessibility-action run (clicking by `element_index`), the session cursor seeds on-screen and pulses on its very first action rather than playing a long glide, so it is easy to miss in a screen recording. For a clearly *gliding* cursor in a demo, issue a pixel `click({pid,x,y})` or a `move_agent_cursor` first to put the cursor on-screen; subsequent AX clicks then glide normally. **Arguments:** diff --git a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md index 13d4f2b906..cfcb7649bc 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md +++ b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md @@ -124,12 +124,31 @@ cua-driver stop ## Agent cursor overlay Visual cursor overlay for demos and screen recordings. Default: -enabled. Toggle with `cua-driver set_agent_cursor_enabled -'{"enabled":true|false}'`. A triangle pointer Bezier-glides to each -click target, ring-ripples on landing, idle-hides after ~1.5s. -Motion knobs: `set_agent_cursor_motion` takes any subset of -`start_handle`, `end_handle`, `arc_size`, `arc_flow`, `spring` — -tuneable at runtime, persisted to config. +enabled — you do NOT need to enable it. Toggle with +`cua-driver set_agent_cursor_enabled '{"enabled":true|false}'` only to +hide or re-show it. A triangle pointer Bezier-glides to each click +target, ring-ripples on landing, idle-hides after ~1.5s. Motion knobs: +`set_agent_cursor_motion` takes any subset of `start_handle`, +`end_handle`, `arc_size`, `arc_flow`, `spring` — tuneable at runtime, +persisted to config. + +**Per-session cursors.** Each MCP session automatically owns its own +cursor, keyed by the session's id (the proxy mints one session id per +MCP connection and the daemon scopes the cursor, config overrides, and +recording to it). You normally pass nothing — the session key is wired +through for you. Pass an explicit `cursor_id` only to *deliberately +share* one cursor across sessions. When a session ends (the MCP client +disconnects) its cursor is removed automatically. + +**Visibility caveat (AX runs).** On a pure accessibility-action run +(clicking by `element_index`), the first action **seeds the cursor +on-screen a short distance from the target and plays a brief glide + +pulse** — not the long Bezier sweep a cursor already on-screen would +trace from its previous spot. It's subtle and easy to miss in a +recording. If you want a clearly *gliding* cursor for a demo or screen +recording, do a pixel click (`click({pid,x,y})`) or a `move_agent_cursor` +first to put the cursor on-screen; subsequent AX actions then glide the +full path normally. Requires the daemon process's UI runloop, which `cua-driver serve` / `mcp` bootstraps. One-shot CLI invocations skip the overlay entirely. @@ -474,10 +493,13 @@ respective companion files. is for visual disambiguation, not coordinates. Use the `element_index`. - **Prefer accessibility actions over pixels.** `click({pid, x, y})` - works for canvas / WebView regions, but it lands blindly and skips - the agent-cursor overlay. Exhaust accessibility paths (menu bars, - cmd-k palettes, toolbar items, keyboard shortcuts) before dropping - to coordinates. + works for canvas / WebView regions, but it lands blindly on raw + coordinates. Exhaust accessibility paths (menu bars, cmd-k palettes, + toolbar items, keyboard shortcuts) before dropping to coordinates. + (The AX path does **not** skip the agent-cursor overlay — it seeds and + pulses the session cursor and draws a focus rect on the targeted + element; it just doesn't play a long glide on the very first action. + See "Agent cursor overlay" for the demo-recording caveat.) - **Never** drive destructive actions (delete files, close unsaved documents, send messages, submit forms) without explicit user intent for that specific destructive step. diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/protocol.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/protocol.rs index e27e44d63a..2839f16d11 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/protocol.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/protocol.rs @@ -197,7 +197,7 @@ Workflow per turn: 4. click/type_text/press_key using element_index from step 3 5. get_window_state(pid, window_id) again → verify the action landed -Agent cursor: set_agent_cursor_* tools visualise where the agent is acting without affecting the real mouse pointer. +Agent cursor: a per-session overlay cursor (ON by default, one per MCP session) visualises where the agent is acting without affecting the real mouse pointer; it is removed when the session ends. set_agent_cursor_* tools hide/show/customise it. Note: a pure accessibility-action (element_index) click snaps the cursor with a brief pulse on its very first action rather than a long glide, so it can be easy to miss — issue a pixel click or move_agent_cursor first for a visibly gliding demo/recording. If a `cua-driver` skill is loaded in your harness (Claude Code / Codex / OpenClaw / OpenCode dirs), prefer its detailed workflow — SKILL.md plus {platform_skill_pointer}. Install with `cua-driver skills install` if not yet present."# ) diff --git a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs index 3585984569..fb74f57ce6 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs @@ -59,6 +59,13 @@ pub enum Command { /// (checked inside the gate itself), so the flag is only one of /// two opt-out signals. no_permissions_gate: bool, + /// True when `--claude-code-computer-use-compat` is on argv. The MCP + /// proxy forwards this flag to the daemon it auto-launches (see + /// `launch_daemon_and_wait`) so the proxy path registers the compat + /// `screenshot` surface, not just the in-process path. Without it the + /// flag was a no-op for `cua-driver mcp --claude-code-computer-use-compat`, + /// which always routes through the proxy on an installed bundle. + claude_code_compat: bool, }, Stop { socket: Option }, Status { socket: Option }, @@ -182,6 +189,28 @@ pub fn parse_command() -> Command { println!(" --no-daemon-relaunch Stay in-process; skip auto-launching the CuaDriver daemon."); println!(" Also: CUA_DRIVER_RS_MCP_NO_RELAUNCH=1"); println!(" --socket Override the daemon UDS path used by the proxy fallback."); + println!(" --claude-code-computer-use-compat"); + println!(" Select the Claude Code computer-use compat surface."); + println!(" Now forwarded to the proxy-launched daemon (was a no-op"); + println!(" on the proxy path — the path you actually run — because"); + println!(" the daemon hardcoded compat=false). Note: the compat"); + println!(" screenshot tool itself was removed in #1692, so the flag"); + println!(" has no tool-surface effect today; the wiring is in place"); + println!(" for any future compat-gated tool."); + println!(); + println!("agent cursor overlay (serve / mcp only — needs the daemon UI runloop):"); + println!(" The overlay is ON by default: every MCP session automatically gets its own"); + println!(" cursor (keyed by session id) that shows where the agent acts without moving the"); + println!(" real pointer. It is removed when the session ends. A pure accessibility (AX)"); + println!(" action snaps the cursor with a brief pulse on its first action instead of a long"); + println!(" glide, so it can be easy to miss — do a pixel click or move_agent_cursor first"); + println!(" for a visibly gliding demo. These flags tune the overlay on `serve`/`mcp`:"); + println!(" --no-overlay Disable the cursor overlay entirely for this daemon."); + println!(" --cursor-id Name the default cursor instance (default: 'default')."); + println!(" --cursor-icon Use a custom PNG cursor icon."); + println!(" --cursor-palette Pick a built-in colour palette for the cursor."); + println!(" (These are no-ops for one-shot CLI calls like `cua-driver call` — the overlay"); + println!(" needs the long-lived AppKit runloop that only `serve` / `mcp` keep alive.)"); println!(); println!("doctor options:"); println!(" --json Emit the probe report as JSON for scripting."); @@ -260,6 +289,7 @@ pub fn parse_command() -> Command { socket, // Bare flag — present anywhere on argv counts as "skip the gate". no_permissions_gate: args.iter().any(|a| a == "--no-permissions-gate"), + claude_code_compat, }, Some("stop") => Command::Stop { socket }, Some("status") => Command::Status { socket }, @@ -562,7 +592,11 @@ pub fn should_use_daemon_proxy(no_daemon_relaunch: bool) -> bool { /// `waitForDaemon`. Split into one Rust function because we don't /// need the post-launch probe separation Swift has. #[cfg(target_os = "macos")] -pub fn launch_daemon_and_wait(socket_path: &str, timeout_secs: u64) -> anyhow::Result<()> { +pub fn launch_daemon_and_wait( + socket_path: &str, + timeout_secs: u64, + claude_code_compat: bool, +) -> anyhow::Result<()> { use std::process::{Command as Cmd, Stdio}; use std::time::{Duration, Instant}; @@ -579,6 +613,20 @@ pub fn launch_daemon_and_wait(socket_path: &str, timeout_secs: u64) -> anyhow::R open_args.push("--socket"); open_args.push(socket_path); } + // Thread the Claude-Code compat flag through to the daemon. Without this + // the proxy-spawned daemon always called build_macos_registry() (compat + // hardcoded false), so `cua-driver mcp --claude-code-computer-use-compat` + // SILENTLY DROPPED the flag on the proxy path — the path users actually + // run on an installed bundle. Today this is latent: the compat screenshot + // tool was removed in #1692, so `register_all(compat)` ignores the flag and + // the served surface is identical either way. But the flag was being lost + // before reaching the daemon at all, so the moment any compat-gated tool is + // re-introduced the proxy path would not honour it. This makes the flag + // travel end-to-end. Only honoured on a freshly-launched daemon — a + // pre-existing daemon keeps whatever surface it launched with. + if claude_code_compat { + open_args.push("--claude-code-computer-use-compat"); + } let status = Cmd::new("/usr/bin/open") // `-n` forces a new instance: CuaDriver.app might already be @@ -630,7 +678,10 @@ pub fn launch_daemon_and_wait(socket_path: &str, timeout_secs: u64) -> anyhow::R /// `open` if needed), then `crate::proxy::run_proxy` against its /// socket. Builds its own tokio runtime — same shape as the other /// `run_*` helpers in this file that own their event loop. -pub fn run_mcp_via_daemon_proxy(socket: Option) -> anyhow::Result<()> { +pub fn run_mcp_via_daemon_proxy( + socket: Option, + claude_code_compat: bool, +) -> anyhow::Result<()> { // Windows: prefer the uiAccess'd worker pipe over the regular daemon pipe // when both are running, so MCP tool calls land in a process that can // bypass UIPI for UWP apps. The protocol on both pipes is identical so @@ -678,8 +729,10 @@ pub fn run_mcp_via_daemon_proxy(socket: Option) -> anyhow::Result<()> { auto-launching the daemon via `open -n -g -a CuaDriver --args serve{socket_suffix}` \ and proxying MCP requests through it. Pass --no-daemon-relaunch to stay in-process." ); - launch_daemon_and_wait(&socket_path, 10)?; + launch_daemon_and_wait(&socket_path, 10, claude_code_compat)?; } + #[cfg(not(target_os = "macos"))] + let _ = claude_code_compat; // On Linux / Windows there's no equivalent `open -a CuaDriver` // mechanism to spawn a daemon attributed to the user's // interactive session. The caller is expected to have one @@ -1603,7 +1656,8 @@ fn run_permissions_grant() { "A dialog titled \u{201c}Cua Driver\u{201d} will appear — approve Accessibility \ and Screen Recording in System Settings, then this command continues." ); - if let Err(e) = launch_daemon_and_wait(&socket, 180) { + // Permissions-grant launch never needs the compat screenshot surface. + if let Err(e) = launch_daemon_and_wait(&socket, 180, false) { eprintln!("\nDidn't detect the CuaDriver daemon: {e}"); eprintln!( "If you haven't yet, grant Accessibility + Screen Recording to CuaDriver \ diff --git a/libs/cua-driver/rust/crates/cua-driver/src/main.rs b/libs/cua-driver/rust/crates/cua-driver/src/main.rs index 3c6b9d8dfe..b35f7cb58b 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/main.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/main.rs @@ -224,7 +224,7 @@ fn main() { cli::run_call(reg, &tool, json_args, screenshot_out_file, socket); return; } - cli::Command::Serve { socket, no_permissions_gate } => { + cli::Command::Serve { socket, no_permissions_gate, claude_code_compat } => { // Long-running daemon — kick off the background update check // before any blocking work so the banner can land on stderr // early in the serve lifecycle. @@ -259,7 +259,12 @@ fn main() { None => pip_preview::PipConfig::from_args(), }; maybe_init_pip(); - let reg = Arc::new(build_macos_registry()); + // Honour the compat flag forwarded by the MCP proxy + // (launch_daemon_and_wait passes `serve + // --claude-code-computer-use-compat`). The Serve arm is the daemon + // the proxy talks to, so without this the proxy path always served + // the full screenshot tool regardless of the client's request. + let reg = Arc::new(build_macos_registry_with_compat(claude_code_compat)); reg.init_self_weak(); let sp = socket.unwrap_or_else(serve::default_socket_path); let pid_path = serve::default_pid_file_path(); @@ -401,7 +406,7 @@ fn main() { // attribution and forwards stdio MCP through its socket. // Issue #1525 / mirror of Swift PR #1479. if cli::should_use_daemon_proxy(no_daemon_relaunch) { - if let Err(e) = cli::run_mcp_via_daemon_proxy(socket) { + if let Err(e) = cli::run_mcp_via_daemon_proxy(socket, claude_code_compat) { eprintln!("cua-driver-rs: {e}"); std::process::exit(1); } @@ -540,14 +545,17 @@ fn main() -> anyhow::Result<()> { }).join().ok(); return Ok(()); } - cli::Command::Serve { socket, no_permissions_gate } => { + cli::Command::Serve { socket, no_permissions_gate, claude_code_compat } => { // Long-running daemon — kick off the background update check // before any blocking work so the banner can land on stderr. version_check::maybe_announce_update(); // The Rust permissions gate is macOS-only (TCC concept). // On Windows / Linux the flag is silently accepted for - // CLI uniformity and ignored. + // CLI uniformity and ignored. The Claude-Code compat screenshot + // surface is likewise macOS-only (register_tools_with_compat), + // so the flag is accepted-and-ignored here for CLI uniformity. let _ = no_permissions_gate; + let _ = claude_code_compat; // Serve mode needs the cursor overlay just like MCP mode. let cursor_cfg = cursor_overlay::CursorConfig::from_args(); let reg = Arc::new(build_registry(cursor_cfg)); @@ -641,7 +649,7 @@ fn main() -> anyhow::Result<()> { // Code over SSH lands in Session 0 and every desktop // tool returns empty. See `cli::should_use_daemon_proxy`. if cli::should_use_daemon_proxy(no_daemon_relaunch) { - if let Err(e) = cli::run_mcp_via_daemon_proxy(socket) { + if let Err(e) = cli::run_mcp_via_daemon_proxy(socket, claude_code_compat) { eprintln!("cua-driver-rs: {e}"); std::process::exit(1); } diff --git a/libs/cua-driver/rust/crates/platform-macos/src/cursor/overlay.rs b/libs/cua-driver/rust/crates/platform-macos/src/cursor/overlay.rs index 97a9b6fada..e9263e9bf2 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/cursor/overlay.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/cursor/overlay.rs @@ -207,17 +207,86 @@ pub fn current_motion(key: &str) -> MotionConfig { .unwrap_or_default() } +/// Seed a brand-new (sentinel-positioned) cursor at an on-screen start point +/// offset up-left of `(target_x, target_y)` so the immediately-following +/// `MoveTo` glides INTO the target instead of silently snapping. Without this, +/// a cursor's very first action (common on a pure-AX run — launch app, AX-press +/// a button) produces no visible motion: `animate_cursor_to` early-returned at +/// the sentinel and only `ClickPulse` snapped a static arrow, which is easy to +/// miss. See the AX-no-glide report. +/// +/// No-op when the cursor is already on-screen (pos.0 > -50.0) or absent. The +/// seed is clamped to the main screen frame so it never starts off-display. +/// Returns true if a seed was applied (i.e. the cursor was at the sentinel and +/// is now primed to glide). +fn seed_start_if_sentinel(key: &CursorKey, target_x: f64, target_y: f64) -> bool { + let mut guard = RENDER.lock().unwrap(); + let Some(map) = guard.as_mut() else { return false }; + seed_start_in_map(map, key, target_x, target_y) +} + +/// Pure seed step operating on a borrowed [`RenderMap`] — factored out of +/// `seed_start_if_sentinel` so the get-or-create + clamp logic is unit-testable +/// without the global `RENDER` static or AppKit. +fn seed_start_in_map(map: &mut RenderMap, key: &CursorKey, target_x: f64, target_y: f64) -> bool { + // Offset the start up-left of the target so the Dubins path has room to + // curve in; 140pt is enough to read as motion at 900pt/s peak speed. + const SEED_OFFSET: f64 = 140.0; + let (win_w, win_h) = (map.win_w, map.win_h); + // Respect the resurrection guard: never seed (and thus re-create) a cursor + // whose session already ended. + if map.ended.contains(key) { + return false; + } + // Get-or-create the cursor so the very first AX action seeds + glides even + // when the lazy render-thread creation hasn't drained the PinAbove yet + // (the render loop's drain would otherwise win the race and the seed read + // an absent cursor). Mirrors apply_msg's entry().or_insert_with. + let template = map.template.clone(); + let k = key.clone(); + let rs = map + .cursors + .entry(key.clone()) + .or_insert_with(|| render_state_for_key(&template, &k)); + if !(rs.core.cfg.enabled && rs.core.pos.0 < -50.0) { + return false; + } + let mut sx = target_x - SEED_OFFSET; + let mut sy = target_y - SEED_OFFSET; + // Clamp into the screen frame when we know it (win_w/h are 0 until the + // AppKit window is up; in that headless case the unclamped seed is still + // on-screen-by-construction for any realistic target). + if win_w > 0.0 && win_h > 0.0 { + sx = sx.clamp(2.0, win_w - 2.0); + sy = sy.clamp(2.0, win_h - 2.0); + // If clamping collapsed the seed onto the target (target in a corner), + // nudge it the other way so there is still a visible glide distance. + if (sx - target_x).abs() < 8.0 && (sy - target_y).abs() < 8.0 { + sx = (target_x + SEED_OFFSET).min(win_w - 2.0); + sy = (target_y + SEED_OFFSET).min(win_h - 2.0); + } + } + rs.core.pos = (sx, sy); + true +} + /// Animate the overlay cursor to `(x, y)` and suspend until the Dubins path /// completes and the spring overshoot begins. /// /// Mirrors Swift's `AgentCursor.shared.animateAndWait(to:)`. -/// Returns immediately (no animation) when: -/// - the overlay is disabled, or -/// - the cursor is still at the off-screen sentinel `(-200, -200)` — in that -/// case the caller should rely on `ClickPulse` to snap the cursor. +/// Returns immediately (no animation) only when the overlay is disabled for +/// this cursor. A brand-new cursor still at the off-screen sentinel is first +/// seeded on-screen via [`seed_start_if_sentinel`] so its FIRST action glides +/// in (it previously snapped silently via `ClickPulse`, invisible on a pure-AX +/// run). pub async fn animate_cursor_to(key: CursorKey, x: f64, y: f64) { - // Check whether animation should run for THIS cursor. A not-yet-created - // cursor (sentinel position) relies on ClickPulse to snap, same as before. + // Seed a sentinel cursor on-screen so the MoveTo below glides instead of + // being short-circuited. After this the cursor's pos.0 > -50.0, so the + // should-animate check passes on the first action just like later ones. + seed_start_if_sentinel(&key, x, y); + + // Check whether animation should run for THIS cursor. A disabled cursor + // never animates; an absent cursor (seed found nothing to prime) is skipped. let should_animate = { let guard = RENDER.lock().unwrap(); match guard.as_ref().and_then(|m| m.cursors.get(&key)) { @@ -899,6 +968,46 @@ mod tests { assert!(map.cursors.contains_key("default")); } + #[test] + fn seed_moves_sentinel_cursor_on_screen_for_first_action() { + // BUG 2 regression: a brand-new session cursor at the sentinel must be + // seeded on-screen (pos.0 > -50) so the immediately-following MoveTo + // glides instead of silently snapping via ClickPulse. + let mut map = empty_map(); // 100x100 frame + // No "sessA" cursor exists yet — the seed must get-or-create it. + let seeded = seed_start_in_map(&mut map, &"sessA".to_owned(), 60.0, 60.0); + assert!(seeded, "sentinel cursor must be seeded"); + let pos = map.cursors["sessA"].core.pos; + assert!(pos.0 > -50.0 && pos.1 > -50.0, "seed must be on-screen, got {pos:?}"); + // And it must be a DIFFERENT point from the target so there is a glide. + assert!((pos.0 - 60.0).abs() > 4.0 || (pos.1 - 60.0).abs() > 4.0, + "seed must differ from target to produce a visible glide, got {pos:?}"); + } + + #[test] + fn seed_is_noop_when_cursor_already_on_screen() { + // A second action: the cursor already landed somewhere on-screen, so the + // seed must NOT move it (the MoveTo path should start from where it is). + let mut map = empty_map(); + // Put sessA on-screen first. + seed_start_in_map(&mut map, &"sessA".to_owned(), 60.0, 60.0); + map.cursors.get_mut("sessA").unwrap().core.pos = (30.0, 30.0); + let seeded_again = seed_start_in_map(&mut map, &"sessA".to_owned(), 80.0, 80.0); + assert!(!seeded_again, "on-screen cursor must not be re-seeded"); + assert_eq!(map.cursors["sessA"].core.pos, (30.0, 30.0), "pos must be untouched"); + } + + #[test] + fn seed_does_not_resurrect_ended_session() { + // The seed shares the resurrection guard: it must not re-create a cursor + // whose session already ended. + let mut map = empty_map(); + map.ended.insert("sessA".to_owned()); + let seeded = seed_start_in_map(&mut map, &"sessA".to_owned(), 60.0, 60.0); + assert!(!seeded, "ended session must not be seeded"); + assert!(!map.cursors.contains_key("sessA"), "ended session must not be resurrected"); + } + #[test] fn per_key_arrival_isolation() { // Two concurrent waiters keyed A and B; firing A must not cancel B. diff --git a/libs/cua-driver/rust/crates/platform-macos/src/tools/cursor_tools.rs b/libs/cua-driver/rust/crates/platform-macos/src/tools/cursor_tools.rs index ffccaae2f6..101e247e24 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/tools/cursor_tools.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/tools/cursor_tools.rs @@ -48,7 +48,11 @@ static ENABLED_DEF: std::sync::OnceLock = std::sync::OnceLock::new(); fn enabled_def() -> &'static ToolDef { ENABLED_DEF.get_or_init(|| ToolDef { name: "set_agent_cursor_enabled".into(), - description: "Show or hide the agent cursor overlay for a cursor instance.".into(), + description: "Show or hide the agent cursor overlay for a cursor instance. The overlay \ + is ON by default and each MCP session automatically owns its own cursor \ + (keyed by session id) — you do NOT need to call this to make the cursor \ + appear. Use enabled=false to hide it, or enabled=true to re-show a hidden \ + one. Pass cursor_id only to target a deliberately-shared cursor.".into(), input_schema: serde_json::json!({ "type": "object", "required": ["enabled"], @@ -546,6 +550,50 @@ mod tests { assert!(reg.get("sessC-never-touched").is_none()); } + #[test] + fn enable_and_ax_click_resolve_the_same_session_cursor() { + // BUG verify (b): in one MCP session, set_agent_cursor_enabled (no + // cursor_id) and a click(element_index) carry the SAME injected + // _session_id, so both resolve the same cursor key — i.e. enabling the + // cursor lights the very cursor the AX click drives. Mirrors the args + // the daemon injects (_session_id) for each forwarded tool call. + let session = "mcp-12345-678"; + let enable_args = json!({ "enabled": true, "_session_id": session }); + let ax_click_args = + json!({ "pid": 844, "window_id": 10725, "element_index": 14, "_session_id": session }); + let enable_key = resolve_cursor_key(&enable_args); + let click_key = resolve_cursor_key(&ax_click_args); + assert_eq!(enable_key, session); + assert_eq!(enable_key, click_key, + "set_agent_cursor_enabled and the AX click must drive the same session cursor"); + } + + #[test] + fn get_config_reports_calling_session_cursor_deterministically() { + // BUG 3 regression: get_config's cursor_enabled must reflect the CALLING + // session's own cursor (resolved by key), not a nondeterministic + // HashMap.first(). Two sessions with opposite enabled flags must each + // read back their OWN value. + use crate::cursor::CursorRegistry; + let reg = CursorRegistry::new(); + reg.set_enabled("sessA", true); + reg.set_enabled("sessB", false); + + // Replicate get_config's resolution: key = resolve_cursor_key(args), + // then get(key) or get("default"). + let read_for = |args: &serde_json::Value| -> bool { + let key = resolve_cursor_key(args); + reg.get(&key) + .or_else(|| reg.get("default")) + .map(|s| s.config.enabled) + .unwrap_or(true) + }; + assert!(read_for(&json!({ "_session_id": "sessA" }))); + assert!(!read_for(&json!({ "_session_id": "sessB" }))); + // Anonymous caller (no session) falls back to the seeded default (on). + assert!(read_for(&json!({}))); + } + #[test] fn empty_strings_fall_through() { // An empty cursor_id falls through to _session_id; empty session falls diff --git a/libs/cua-driver/rust/crates/platform-macos/src/tools/get_config.rs b/libs/cua-driver/rust/crates/platform-macos/src/tools/get_config.rs index 7d79c8ad6d..f238a36d9f 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/tools/get_config.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/tools/get_config.rs @@ -41,8 +41,14 @@ impl Tool for GetConfigTool { let cfg = self.state.config.read().unwrap(); self.state.session_config.effective(session_id.as_deref(), &cfg) }; - let cursor_enabled = self.state.cursor_registry.all_states() - .first() + // Report the CALLING session's own cursor enabled-state, not a + // nondeterministic HashMap.first(). Resolve the same key the click / + // cursor tools use (cursor_id > _session_id > "default"); fall back to + // the seeded "default" cursor when this session hasn't materialised its + // own cursor yet, and finally to `true` (the overlay default). + let cursor_key = super::cursor_tools::resolve_cursor_key(&args); + let cursor_enabled = self.state.cursor_registry.get(&cursor_key) + .or_else(|| self.state.cursor_registry.get("default")) .map(|s| s.config.enabled) .unwrap_or(true); // PiP values aren't in DriverConfig — they're file-only since the