feat(automate): browser + app keyboard-shortcut fast-paths, artist-aware Music, actionable responses (#3558)

This commit is contained in:
Mega Mind
2026-06-09 13:31:28 -07:00
committed by GitHub
parent 9e16f2198c
commit 2974605a46
17 changed files with 2653 additions and 212 deletions
@@ -18,6 +18,7 @@ import {
type VoiceProviderView,
type VoiceSettings,
} from '../../../services/api/voiceSettingsApi';
import { IS_DEV_LIKE } from '../../../utils/config';
import {
openhumanGetVoiceServerSettings,
openhumanUpdateVoiceServerSettings,
@@ -87,10 +88,10 @@ interface VoicePanelProps {
embedded?: boolean;
}
/** Temporarily hide the always-on listening toggle. Set back to `true` to
* restore the control (the backend engine is unchanged). See
* docs/voice-system-actions.md. */
const SHOW_ALWAYS_ON_TOGGLE = false;
/** Always-on listening toggle is hidden in production for now, but shown in
* dev/debug builds so the feature can be exercised. Set unconditionally to
* `true` to expose it everywhere. See docs/voice-system-actions.md. */
const SHOW_ALWAYS_ON_TOGGLE = IS_DEV_LIKE;
const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
const { t } = useT();
@@ -494,7 +495,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
{/* ─── Always-on listening (Phase 2) ──────────────────────────── */}
{/* Temporarily hidden — gated on SHOW_ALWAYS_ON_TOGGLE (set to false). */}
{/* Gated on SHOW_ALWAYS_ON_TOGGLE — shown in dev/debug builds, hidden in prod. */}
{SHOW_ALWAYS_ON_TOGGLE && settings && (
<section className="space-y-3">
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4">
@@ -511,8 +511,7 @@ describe('VoicePanel', () => {
// ─── Always-on listening toggle ↔ notch indicator ───────────────────────
// Temporarily hidden in the UI (always-on toggle disabled for now in VoicePanel).
it.skip('shows the notch when always-on listening is enabled and hides it when disabled', async () => {
it('shows the notch when always-on listening is enabled and hides it when disabled', async () => {
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });
const toggle = await screen.findByTestId('voice-always-on-toggle');
@@ -537,8 +536,7 @@ describe('VoicePanel', () => {
await waitFor(() => expect(vi.mocked(syncNotchVisibility)).toHaveBeenCalledWith(false));
});
// Temporarily hidden in the UI (always-on toggle disabled for now in VoicePanel).
it.skip('does not touch the notch and reverts the toggle when the update RPC fails', async () => {
it('does not touch the notch and reverts the toggle when the update RPC fails', async () => {
vi.mocked(openhumanUpdateVoiceServerSettings).mockRejectedValueOnce(new Error('rpc down'));
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });
+51 -2
View File
@@ -3,7 +3,7 @@
**GitHub Issue:** [#3148](https://github.com/tinyhumansai/openhuman/issues/3148)
**Branch:** `feat/voice-always-on-all` (cumulative) — **all merged to `main`.** Phase 1 landed via [#3168](https://github.com/tinyhumansai/openhuman/pull/3168); the full feature was split from the mega-PR [#3307](https://github.com/tinyhumansai/openhuman/pull/3307) (now **closed** in favour of the stack) into an 8-PR stack — **all 8 merged 2026-06-04**: [#3340](https://github.com/tinyhumansai/openhuman/pull/3340) (main-thread input + CEF fix), [#3341](https://github.com/tinyhumansai/openhuman/pull/3341) (AX/UIA perception + automate engine), [#3342](https://github.com/tinyhumansai/openhuman/pull/3342) (wire automate/ax_interact tools), [#3343](https://github.com/tinyhumansai/openhuman/pull/3343) (Phase 2 always-on engine + RPC), [#3344](https://github.com/tinyhumansai/openhuman/pull/3344) (always-on Settings toggle + i18n), [#3345](https://github.com/tinyhumansai/openhuman/pull/3345) (notch status pill — supersedes the closed [#3166](https://github.com/tinyhumansai/openhuman/pull/3166)), [#3346](https://github.com/tinyhumansai/openhuman/pull/3346) (Phase 3 fast command router), [#3362](https://github.com/tinyhumansai/openhuman/pull/3362) (Phase 1.5 vision-click fallback)
**Started:** 2026-06-02
**Last updated:** 2026-06-09 — verified all 8 stack PRs merged to `main`; code paths confirmed present post-refactor [#3424](https://github.com/tinyhumansai/openhuman/pull/3424). Related: global push-to-talk ([#3090](https://github.com/tinyhumansai/openhuman/issues/3090) via [#3349](https://github.com/tinyhumansai/openhuman/pull/3349)) landed separately as an alternative manual trigger. **Only open item across all phases: the on-device audio wake-word model (Phase 3) — not started; text-based "Hey Tiny" match remains the interim.**
**Last updated:** 2026-06-09 — added **Change 1.17** (PR [#3558](https://github.com/tinyhumansai/openhuman/pull/3558)): browser & app (Spotify/Apple Music/Slack) keyboard-shortcut fast-paths, cross-platform shortcut tables, the `hotkey` verb, artist-aware Music verification, and actionable `automate` failure responses — all from live-transcript analysis. Earlier this day: verified all 8 always-on stack PRs merged to `main` (code paths confirmed post-refactor [#3424](https://github.com/tinyhumansai/openhuman/pull/3424)); global push-to-talk ([#3090](https://github.com/tinyhumansai/openhuman/issues/3090) via [#3349](https://github.com/tinyhumansai/openhuman/pull/3349)) landed separately. **Only open item across all phases: the on-device audio wake-word model (Phase 3) — not started; text-based "Hey Tiny" match remains the interim.**
---
@@ -330,7 +330,7 @@ test ... ok
> The bridge between today's `ax_interact` primitives and the always-on voice work. **Prerequisite for Phase 3** — fast voice routing into a slow/fragile action loop still feels slow. This is where "whatever I say happens, live" actually gets delivered.
>
> **Status:** the Rust inner loop (M1), poll-until-stable settle (M2), Music fast-path (M3, proven live), notch progress streaming (M4), the richer element model (M5), and the **vision fallback for Electron/partial-AX apps** (Change 1.16) are all shipped. (Additional per-app native fast-paths beyond Music — e.g. Spotify/Slack — are **descoped**: the general model-driven loop covers them, so a deterministic accelerator isn't worth the per-app maintenance right now.)
> **Status:** the Rust inner loop (M1), poll-until-stable settle (M2), Music fast-path (M3, proven live), notch progress streaming (M4), the richer element model (M5), the **vision fallback for Electron/partial-AX apps** (Change 1.16), and **Change 1.17** (browser & app fast-paths + keyboard-shortcut driving + artist-aware Music + actionable responses) are all shipped. Per-app fast-paths now cover **Music, browsers, Spotify, Apple Music, and Slack** (Spotify/Slack — previously descoped — are now handled by the `app_shortcuts.rs` keyboard-shortcut path).
**Detailed implementation plan:** [`voice-automate-plan.md`](voice-automate-plan.md) — decided approach: **Rust inner loop + fast model**, first proof target **Music**.
@@ -392,6 +392,49 @@ enigo's keyboard-layout lookup (`TSMGetInputSourceProperty`) **must run on the a
---
### Change 1.17 — browser & app fast-paths, keyboard-shortcut driving, artist-aware Music, actionable responses ✅ Done
**Status:** ✅ Shipped (PR [#3558](https://github.com/tinyhumansai/openhuman/pull/3558)). One body of work driven by **live-transcript analysis of the `automate` agent** across browsers, Apple Music, Spotify, and Slack. The transcripts surfaced four recurring failures the agent kept hitting:
1. **Browsers** — for *"open my Brave browser, go to youtube.com and play a music video"* the `desktop_control_agent` was re-delegated **6×** (each sub re-launched Brave + re-navigated), ran `ax_interact` on the **wrong app names** (`Google Chrome`/`Safari`, never the real `Brave Browser`, because Chromium exposes no AX tree), then "played" via a **blind hardcoded mouse click at (400,350)** with no verification.
2. **Wrong track***"play Numb by Linkin Park"* played a **same-titled song by the wrong artist** ("Numb" by Marshmello & Khalid) and the agent **claimed success**, because the AX row label is title-only and verification only checked *that* something played.
3. **Inert responses** — the `automate` tool's replies were *honest but gave no next move*, so the agent **re-ran the same failing search 6×** instead of changing tactics.
4. **No keyboard-shortcut lever** — the loop hunted AX labels even where an app's own shortcut would be instant and reliable.
The six parts below address all four. All fast-paths are consulted by `automate::run` **before** the model loop (`try_fastpath` order: `music``browser``app_shortcuts`); on any failure they fall through, so they can only *help*.
**(a) Browser fast-path** — `app_fastpaths/browser.rs` (new). Pure parsers + `run(app, goal, backend)`:
- `resolve_browser(app, goal)` — aliases → macOS display names (`brave``Brave Browser`, `chrome``Google Chrome`, `edge``Microsoft Edge`, `safari`/`firefox`/`arc`). Checks the `app` arg first, then the goal; a generic "browser" with no named product → **`None`** so it never guesses the wrong app (the original bug).
- `extract_destination(goal)` — YouTube → `youtube.com/results?search_query=…` (percent-encoded); Google/"search for X" → `google.com/search?q=…`; a bare domain/URL → normalized to `https://…`.
- **Navigation is one deterministic step:** `backend.open_url_in_app(browser, url)``open -a "<browser>" "<url>"` launches/foregrounds + navigates — no address-bar typing, no AX. Pure-navigation goals **complete here**; **play goals** navigate to the results URL then return non-success so the loop does the single "click first result" via `vision_click` (no native shortcut *selects* a result — we don't fake it). In-page media control ("pause/next the video") sends the YouTube shortcut directly.
**(b) Cross-platform browser shortcut table** — `app_fastpaths/browser_shortcuts.rs` (new). `shortcut(intent, browser, os)` resolves browser-level intents (address bar, new/close/reopen tab, tab N, next/prev tab, find, reload/hard-reload, back/forward, history, downloads, zoom, private window, fullscreen) per **Chrome/Firefox/Safari/Edge × macOS/Windows/Linux**. Nearly uniform (only ⌘↔Ctrl differs); encodes the real exceptions (Firefox-Linux `Alt+18`, Firefox private window `⌘/Ctrl+Shift+P`, per-browser History/Downloads, Mac `⌘[`/`]` back-forward, `F11` vs `⌃⌘F` fullscreen). Sourced from the official Chrome/Firefox/Edge/Safari docs. `browser.rs` parses commands ("open a new tab", "go back", "reload", "switch to tab 3") and dispatches the resolved chord. (Brave/Arc/Chromium → Chrome family.)
**(c) App-shortcut fast-path** — `app_fastpaths/app_shortcuts.rs` (new). Same model for **Spotify, Apple Music, Slack**: `shortcut(intent, app, os)` + goal parser. Drives transport/navigation by each app's own global shortcut (faster + more reliable than AX). Media intents (play/pause, next, prev, volume ±, mute, shuffle, repeat, search) for Spotify + Apple Music; Slack nav (quick-switcher, search, new message/compose DM, next/prev unread, next/prev channel, threads, all-unread, mark read). Encodes the quirks: **Spotify uses ↓/↑ for next/prev** (not ←/→); **Apple Music prefixes everything with Ctrl on Windows** (Ctrl+Space vs bare Space on Mac); Slack channel-nav uses **Option/Alt+arrows**. Intents with no simple chord (Spotify volume, Apple Music mute / Windows search access-key) return `None` → fall through. Complementary to `music.rs`, which still owns Apple Music *song search/play*.
**(d) Keyboard-shortcut plumbing** —
- Extracted shared `pub(crate)` helpers `run_hotkey`/`run_key`/`run_type_text` from `tools/impl/computer/keyboard.rs` (one place for validation + main-thread enigo dispatch, Change 1.15) — no behavior change to the `keyboard` tool.
- New `AutomateBackend` methods (non-breaking defaults): `open_url_in_app`, `key`, `type_text`, `now_playing`. `RealBackend` impls `open -a` per-app, delegates keystrokes to the keyboard helpers, and reads Music state via osascript.
- New **`hotkey` verb** in the general loop + system prompt (`{"action":"hotkey","keys":["Cmd","L"]}`) so the model can use any app's shortcuts — folded into the no-progress signature.
**(e) Artist-aware Music verification** — `music.rs` + `now_playing`. Apple Music's AX row label is title-only ("Numb - Single"), so a title match lands on the wrong artist. `music.rs` now parses the requested artist (`extract_artist`, "…by Linkin Park") and after Play reads the **now-playing name + artist** via AppleScript (`now_playing``osascript … get {name, artist} of current track`). On a match the summary names the verified track + artist; on a **mismatch it says so honestly** ("Now playing 'Numb' by 'Tom Odell'…") instead of claiming the requested track. Closes the wrong-artist false-success. **Follow-up:** *correcting* to the right artist is limited by AX `press`-by-label hitting only the first same-titled row — a library AppleScript `play (track whose name … and artist …)` fallback is the next step.
**(f) Actionable `automate` responses** — the tool's failures are now agent-actionable, not inert. Four fixes: **(1)** terminal failures (`music.rs` no-match/mismatch, `automate.rs` budget-exhausted/no-progress) carry **next-step guidance + a "don't repeat this" steer**; **(2)** they **list the actual candidates / on-screen labels** (`candidate_labels`, `screen_hint`) instead of a bare negative; **(3)** **verified-only success** — never "Playing 'X'" unless `now_playing` confirms it, and an artist/album press is reported as "navigated, no track started"; **(4)** `pick_row` now **prefers real song rows** (`AXCell`/`AXRow`) over artist/album buttons (the live bug pressed the "LINKIN PARK" artist row) and the step log names the element *type* pressed.
**Also:** the always-on toggle in `VoicePanel` is now shown in dev/debug builds (`IS_DEV_LIKE`) and hidden in production.
**Follow-up fix — Full OS access now enables app control.** `ax_interact` (press/set_value) and `automate` gated **only** on `computer_control.ax_interact_mutations`, which is independent of the autonomy level. A user who granted **Full** access in Settings → Agent Access still got *"App control isn't enabled yet…"* because that flag was untouched (confirmed live: `SecurityPolicy autonomy=Full` while the tool refused). Fixed with a shared `app_control_enabled(explicit_opt_in)` gate (`tools/impl/computer/ax_interact.rs`) that returns true when the explicit flag is set **or** the **live** policy (`security::live_policy::current()`) is `AutonomyLevel::Full` — so granting Full access takes effect immediately, no session restart or separate flag flip. Both refusal messages now point at Settings → Agent Access ("Grant Full access (or turn on App UI Control / App Automation)"). Covered by `app_control_enabled_combines_optin_and_full_access` + the two pinned-policy refusal tests.
**Follow-up fix — Windows browser nav + the re-delegation loop.** Live Windows transcript of *"open my browser, go to youtube.com and play a video"*: the orchestrator delegated to `desktop_control_agent` **7×**, every sub re-`launch_app`-ed Chrome (→ ~10 windows), and the AX path dead-ended — Chromium exposes no page content to UIA, `screenshot`/`vision_click` is unimplemented on Windows, and `ax_interact set_value` on the address bar **can't be submitted** (UIA Invoke on an Edit fails, no Enter). Two fixes:
> - **`open_url_in_app` now works on Windows** (`accessibility/automate.rs`) — was macOS-only (fell back to the *default* browser). Maps the resolved browser display name → the shell `start` App-Paths token (`windows_browser_launch_token`: Chrome→`chrome`, Edge→`msedge`, Brave→`brave`, Firefox→`firefox`; Safari/Arc→`None`→default handler) and runs `cmd /C start "" <token> "<url>"`. Launches/foregrounds the named browser and navigates in **one** step; when the browser is already open this lands in a **new tab of the existing window**, so the fast-path no longer piles up windows. The browser fast-path's deterministic nav is now cross-platform (mac `open -a`, win `start`). Tested by `browser_display_names_map_to_start_tokens` (`#[cfg(target_os="windows")]`).
> - **`desktop_control_agent` prompt steered to `automate` for browsers** (`agent_registry/agents/desktop_control_agent/prompt.md`) — it had `automate` but its only examples were Music/Slack, so it AX-fumbled the browser. Now: web browsers → use `automate{app, goal}` (deterministic URL nav), never type a URL into the address bar via `ax_interact`; **foreground each app at most once per task** (repeated `launch_app` piles up windows); and **stop after two failed attempts at a step** instead of re-launching and looping. (Still a Windows gap: `vision_click` for the final "click first result" — `screenshot` is unimplemented on Windows, so play-from-search-results can't complete deterministically yet; navigation/search now do.)
**Net for the browser example:** `open -a "Brave Browser" "https://www.youtube.com/results?search_query=music%20video"` + one `vision_click` — instead of 6 delegations and 5 failed AX calls.
**Tests (all green):** `app_fastpaths` **58**, `accessibility::automate` **21**, `computer::keyboard` **26**. Coverage: browser/app resolution + destination/intent parsers (incl. Spotify ↓/↑, Apple Music Win-Ctrl, Slack Alt-nav, generic-browser→None, percent-encoding); scripted-backend sequences (model-free nav, play→fall-through, media-control hotkey, dispatch order with music-wins-play); artist match→names-track / mismatch→honest; response shapes (candidate listing, wrong-artist steer, non-song honesty, song-row preference, budget/no-progress on-screen hints); a `hotkey`-verb loop test; `#[ignore]` macOS live tests. **Follow-ups:** wire the shortcut tables into the Phase 3 voice `command_router` (voice "pause"/"next" → frontmost-app shortcut); AppleScript library play-by-name+artist correction; expose `vision_click` as a standalone tool.
---
## Windows port — app interaction 🪟 ✅ Implemented
Phase 1's app-interaction layer is now ported to Windows. The macOS path uses the
@@ -648,6 +691,12 @@ From live agent-in-the-loop testing on 2026-06-03 (grounded in `~/.openhuman/log
| 1.5 | M5: richer element model (`enabled`) | ✅ Plumbed; AXEnabled found unreliable → informational only |
| 1.5 | Native fast-paths beyond Music (Spotify/Slack) | Descoped (general loop covers them; Music shipped in M3) |
| 1.5 | Vision fallback for Electron apps (Change 1.16) | ✅ Done (`vision_click`: screenshot → vision-locate → guarded click; frontmost guard; F2 coord-transform folded in) |
| 1.5 | Browser fast-path (Change 1.17a) | ✅ Done (`browser.rs`: deterministic `open -a "<browser>" url` nav; play→fall-through to vision_click) |
| 1.5 | Cross-platform browser shortcut table (Change 1.17b) | ✅ Done (`browser_shortcuts.rs`: Chrome/Firefox/Safari/Edge × macOS/Win/Linux) |
| 1.5 | App-shortcut fast-path: Spotify/Apple Music/Slack (Change 1.17c) | ✅ Done (`app_shortcuts.rs`: transport + nav via each app's shortcuts) |
| 1.5 | Keyboard-shortcut plumbing: `hotkey` verb + backend methods (Change 1.17d) | ✅ Done (`key`/`type_text`/`open_url_in_app`/`now_playing`; shared keyboard helpers) |
| 1.5 | Artist-aware Music verification (Change 1.17e) | ✅ Done (`now_playing` osascript; honest wrong-artist report, no false success) |
| 1.5 | Actionable `automate` responses (Change 1.17f) | ✅ Done (next-step guidance, candidate/label lists, verified-only success, song-row preference) |
| 2 | Always-on microphone loop | ✅ Done (cpal → VAD → STT → agent) |
| 2 | `always_on_enabled` config flag + Settings toggle | ✅ Done (RPC + UI + i18n) |
| 2 | Privacy hook (screen lock pause) | ✅ Done (macOS; other OSes follow-up) |
@@ -0,0 +1,519 @@
//! Keyboard-shortcut fast-path for Spotify, Apple Music, and Slack (Change 1.17).
//!
//! These desktop apps expose stable global shortcuts for the actions users ask
//! for by voice — "pause", "next song", "turn it up", "jump to a channel". For
//! those, driving the app's own shortcut is far faster and more reliable than
//! walking the AX tree: one `backend.key(chord)` and we're done. (Apple Music
//! *song search/play* still goes through `music.rs`, which types a query; this
//! module is transport/navigation control.)
//!
//! Like `browser_shortcuts.rs`, the table is keyed by `(intent, app, os)` and
//! returns key names the keyboard tool's `parse_key` understands, so the result
//! feeds straight into `AutomateBackend::key`. Sources: the official Spotify,
//! Apple Music (Mac + Windows), and Slack shortcut docs.
use super::browser_shortcuts::Os;
use super::AutomateBackend;
use super::AutomateOutcome;
/// Apps this fast-path can drive by shortcut.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppFamily {
Spotify,
AppleMusic,
Slack,
}
/// A control action. Media intents apply to Spotify/Apple Music; the rest to
/// Slack. [`shortcut`] returns `None` for an intent an app/OS doesn't support.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppIntent {
// ── media transport ──
PlayPause,
NextTrack,
PrevTrack,
VolumeUp,
VolumeDown,
Mute,
Shuffle,
Repeat,
MediaSearch,
// ── Slack navigation ──
QuickSwitcher,
SlackSearch,
NewMessage,
ComposeDm,
NextUnread,
PrevUnread,
NextChannel,
PrevChannel,
Threads,
AllUnread,
MarkRead,
}
/// Resolve the app family from the authoritative `app` arg first (so a stray
/// "music" in the goal text can't mis-route), then from an explicit app name in
/// the goal as a fallback.
fn resolve_family(app: &str, goal: &str) -> Option<AppFamily> {
if let Some(f) = family_from_name(&app.to_lowercase()) {
return Some(f);
}
let g = goal.to_lowercase();
if g.contains("spotify") {
Some(AppFamily::Spotify)
} else if g.contains("slack") {
Some(AppFamily::Slack)
} else if g.contains("apple music") || g.contains("itunes") {
Some(AppFamily::AppleMusic)
} else {
None
}
}
fn family_from_name(n: &str) -> Option<AppFamily> {
if n.contains("spotify") {
Some(AppFamily::Spotify)
} else if n.contains("slack") {
Some(AppFamily::Slack)
} else if n.contains("music") || n.contains("itunes") {
// The macOS app's display name is literally "Music".
Some(AppFamily::AppleMusic)
} else {
None
}
}
fn keys(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
/// Primary modifier: ⌘ on macOS, Ctrl elsewhere.
fn primary(os: Os) -> &'static str {
match os {
Os::Mac => "Cmd",
_ => "Ctrl",
}
}
/// Resolve an intent to a key chord for the app + OS, or `None` if that app/OS
/// has no simple chord for it (the loop then falls through).
pub fn shortcut(intent: AppIntent, app: AppFamily, os: Os) -> Option<Vec<String>> {
use AppIntent as I;
let p = primary(os);
let v = match (app, intent) {
// ── Spotify (Mac & Windows columns; Linux ≈ Windows) ──
(AppFamily::Spotify, I::PlayPause) => keys(&["space"]),
// Spotify quirk: ↓ = next, ↑ = previous (not ←/→).
(AppFamily::Spotify, I::NextTrack) => keys(&["down"]),
(AppFamily::Spotify, I::PrevTrack) => keys(&["up"]),
(AppFamily::Spotify, I::Mute) => keys(&["m"]),
(AppFamily::Spotify, I::Shuffle) => match os {
Os::Mac => keys(&["alt", "s"]),
_ => keys(&["ctrl", "s"]),
},
(AppFamily::Spotify, I::Repeat) => match os {
Os::Mac => keys(&["alt", "r"]),
_ => keys(&["ctrl", "r"]),
},
(AppFamily::Spotify, I::MediaSearch) => keys(&[p, "k"]),
// Spotify has no documented volume shortcut → fall through.
(AppFamily::Spotify, I::VolumeUp | I::VolumeDown) => return None,
// ── Apple Music ──
(AppFamily::AppleMusic, I::PlayPause) => match os {
Os::Mac => keys(&["space"]),
_ => keys(&["ctrl", "space"]),
},
(AppFamily::AppleMusic, I::NextTrack) => match os {
Os::Mac => keys(&["right"]),
_ => keys(&["ctrl", "right"]),
},
(AppFamily::AppleMusic, I::PrevTrack) => match os {
Os::Mac => keys(&["left"]),
_ => keys(&["ctrl", "left"]),
},
(AppFamily::AppleMusic, I::VolumeUp) => match os {
Os::Mac => keys(&["Cmd", "up"]),
_ => keys(&["ctrl", "up"]),
},
(AppFamily::AppleMusic, I::VolumeDown) => match os {
Os::Mac => keys(&["Cmd", "down"]),
_ => keys(&["ctrl", "down"]),
},
// Search field: ⌘F on Mac. Windows uses a sequential access key
// (Alt,N,F) we can't send as one chord → fall through there.
(AppFamily::AppleMusic, I::MediaSearch) => match os {
Os::Mac => keys(&["Cmd", "f"]),
_ => return None,
},
// No simple mute/shuffle/repeat chord in Apple Music → fall through.
(AppFamily::AppleMusic, I::Mute | I::Shuffle | I::Repeat) => return None,
// ── Slack ──
(AppFamily::Slack, I::QuickSwitcher) => keys(&[p, "k"]),
(AppFamily::Slack, I::SlackSearch) => keys(&[p, "g"]),
(AppFamily::Slack, I::NewMessage) => keys(&[p, "n"]),
(AppFamily::Slack, I::ComposeDm) => keys(&[p, "shift", "k"]),
// Option (mac) / Alt (win) both map to enigo's Alt key.
(AppFamily::Slack, I::NextUnread) => keys(&["alt", "shift", "down"]),
(AppFamily::Slack, I::PrevUnread) => keys(&["alt", "shift", "up"]),
(AppFamily::Slack, I::NextChannel) => keys(&["alt", "down"]),
(AppFamily::Slack, I::PrevChannel) => keys(&["alt", "up"]),
(AppFamily::Slack, I::Threads) => keys(&[p, "shift", "t"]),
(AppFamily::Slack, I::AllUnread) => keys(&[p, "shift", "a"]),
(AppFamily::Slack, I::MarkRead) => keys(&["esc"]),
// Any intent not applicable to the app.
_ => return None,
};
Some(v)
}
/// Does this (app, goal) name one of these apps with a recognizable control?
pub fn matches(app: &str, goal: &str) -> bool {
match resolve_family(app, goal) {
Some(fam) => extract_intent(goal, fam)
.and_then(|i| shortcut(i, fam, Os::current()))
.is_some(),
None => false,
}
}
/// Run the shortcut fast-path: resolve the intent, send the chord, done.
pub async fn run(app: &str, goal: &str, backend: &dyn AutomateBackend) -> AutomateOutcome {
use super::super::automate::progress;
use crate::openhuman::overlay::OverlayAttentionTone;
let mut steps: Vec<String> = Vec::new();
let fam = match resolve_family(app, goal) {
Some(f) => f,
None => return fail("no recognized app", steps),
};
let intent = match extract_intent(goal, fam) {
Some(i) => i,
None => return fail("no recognized control intent", steps),
};
let chord = match shortcut(intent, fam, Os::current()) {
Some(c) => c,
None => return fail("no shortcut for this intent on this OS", steps),
};
let combo = chord.join("+");
log::info!("[automate::app_shortcuts] ▶ {fam:?} {intent:?} keys={combo}");
progress(format!("Pressing {combo}"), OverlayAttentionTone::Accent);
match backend.key(&chord).await {
Ok(m) => {
steps.push(format!("hotkey {combo}: {m}"));
AutomateOutcome {
success: true,
summary: format!("Sent {combo} to {app}."),
steps,
}
}
Err(e) => {
steps.push(format!("hotkey FAILED: {e}"));
fail("could not send the shortcut", steps)
}
}
}
/// Parse a control intent from the goal, branching on app domain (media vs
/// Slack) so the same word ("search", "next") maps correctly.
fn extract_intent(goal: &str, fam: AppFamily) -> Option<AppIntent> {
let l = goal.to_lowercase();
match fam {
AppFamily::Slack => parse_slack(&l),
_ => parse_media(&l),
}
}
fn parse_media(l: &str) -> Option<AppIntent> {
use AppIntent as I;
if l.contains("volume up")
|| l.contains("louder")
|| l.contains("turn it up")
|| l.contains("turn up")
|| l.contains("increase volume")
|| l.contains("raise the volume")
{
return Some(I::VolumeUp);
}
if l.contains("volume down")
|| l.contains("quieter")
|| l.contains("turn it down")
|| l.contains("turn down")
|| l.contains("decrease volume")
|| l.contains("lower the volume")
|| l.contains("lower volume")
{
return Some(I::VolumeDown);
}
if has_word(l, "mute") || has_word(l, "unmute") {
return Some(I::Mute);
}
if has_word(l, "shuffle") {
return Some(I::Shuffle);
}
if has_word(l, "repeat") || has_word(l, "loop") {
return Some(I::Repeat);
}
if has_word(l, "next") || has_word(l, "skip") {
return Some(I::NextTrack);
}
if l.contains("previous")
|| l.contains("last song")
|| l.contains("last track")
|| l.contains("go back a song")
{
return Some(I::PrevTrack);
}
if has_word(l, "pause") || has_word(l, "resume") || has_word(l, "unpause") {
return Some(I::PlayPause);
}
if l.contains("continue playing") || l.contains("keep playing") {
return Some(I::PlayPause);
}
// Bare "play" toggle — but NOT "play <song name>" (that's `music.rs`).
if let Some(i) = word_index(l, "play") {
let after = l[i + "play".len()..].trim();
if after.is_empty()
|| matches!(
after,
"music"
| "it"
| "this"
| "the music"
| "the song"
| "song"
| "the track"
| "track"
| "playback"
| "the playback"
)
{
return Some(I::PlayPause);
}
}
if has_word(l, "search") || has_word(l, "find") {
return Some(I::MediaSearch);
}
None
}
fn parse_slack(l: &str) -> Option<AppIntent> {
use AppIntent as I;
if l.contains("next unread") {
Some(I::NextUnread)
} else if l.contains("previous unread") || l.contains("prev unread") {
Some(I::PrevUnread)
} else if l.contains("next channel") || l.contains("next conversation") || l.contains("next dm")
{
Some(I::NextChannel)
} else if l.contains("previous channel")
|| l.contains("prev channel")
|| l.contains("previous conversation")
{
Some(I::PrevChannel)
} else if l.contains("all unread") || l.contains("unreads") {
Some(I::AllUnread)
} else if l.contains("thread") {
Some(I::Threads)
} else if l.contains("mark") && l.contains("read") {
Some(I::MarkRead)
} else if l.contains("new message") {
Some(I::NewMessage)
} else if l.contains("compose")
|| l.contains("direct message")
|| has_word(l, "dm")
|| l.contains("message someone")
|| l.contains("send a message")
{
Some(I::ComposeDm)
} else if l.contains("jump to")
|| l.contains("quick switch")
|| l.contains("switch to")
|| l.contains("go to channel")
|| l.contains("open channel")
|| l.contains("open conversation")
|| l.contains("find channel")
{
Some(I::QuickSwitcher)
} else if has_word(l, "search") || has_word(l, "find") {
Some(I::SlackSearch)
} else {
None
}
}
fn fail(msg: &str, steps: Vec<String>) -> AutomateOutcome {
AutomateOutcome {
success: false,
summary: format!("App shortcut fast-path: {msg}"),
steps,
}
}
/// Whole-word membership test on an already-lowercased string.
fn has_word(haystack: &str, needle: &str) -> bool {
word_index(haystack, needle).is_some()
}
fn word_index(haystack: &str, needle: &str) -> Option<usize> {
let mut from = 0;
while let Some(rel) = haystack[from..].find(needle) {
let idx = from + rel;
let before_ok = idx == 0
|| !haystack[..idx]
.chars()
.next_back()
.map(|c| c.is_alphanumeric())
.unwrap_or(false);
let after = idx + needle.len();
let after_ok = haystack[after..]
.chars()
.next()
.map(|c| !c.is_alphanumeric())
.unwrap_or(true);
if before_ok && after_ok {
return Some(idx);
}
from = idx + needle.len();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use AppIntent as I;
#[test]
fn resolves_family_from_app_then_goal() {
assert_eq!(resolve_family("Spotify", "x"), Some(AppFamily::Spotify));
assert_eq!(resolve_family("Music", "x"), Some(AppFamily::AppleMusic));
assert_eq!(resolve_family("Slack", "x"), Some(AppFamily::Slack));
// From goal when app arg is unhelpful.
assert_eq!(
resolve_family("", "pause spotify"),
Some(AppFamily::Spotify)
);
// A stray "music" in the goal with a browser app must NOT mis-route.
assert_eq!(resolve_family("Brave Browser", "find music videos"), None);
}
#[test]
fn spotify_transport_quirks() {
let o = Os::Mac;
assert_eq!(
shortcut(I::PlayPause, AppFamily::Spotify, o),
Some(keys(&["space"]))
);
// ↓ next / ↑ previous (the Spotify quirk).
assert_eq!(
shortcut(I::NextTrack, AppFamily::Spotify, o),
Some(keys(&["down"]))
);
assert_eq!(
shortcut(I::PrevTrack, AppFamily::Spotify, o),
Some(keys(&["up"]))
);
assert_eq!(
shortcut(I::Shuffle, AppFamily::Spotify, Os::Windows),
Some(keys(&["ctrl", "s"]))
);
// No documented volume → None.
assert_eq!(shortcut(I::VolumeUp, AppFamily::Spotify, o), None);
}
#[test]
fn apple_music_mac_vs_windows() {
assert_eq!(
shortcut(I::PlayPause, AppFamily::AppleMusic, Os::Mac),
Some(keys(&["space"]))
);
assert_eq!(
shortcut(I::PlayPause, AppFamily::AppleMusic, Os::Windows),
Some(keys(&["ctrl", "space"]))
);
assert_eq!(
shortcut(I::VolumeUp, AppFamily::AppleMusic, Os::Mac),
Some(keys(&["Cmd", "up"]))
);
assert_eq!(
shortcut(I::NextTrack, AppFamily::AppleMusic, Os::Windows),
Some(keys(&["ctrl", "right"]))
);
// Search: Mac ⌘F; Windows access-key sequence → None.
assert_eq!(
shortcut(I::MediaSearch, AppFamily::AppleMusic, Os::Mac),
Some(keys(&["Cmd", "f"]))
);
assert_eq!(
shortcut(I::MediaSearch, AppFamily::AppleMusic, Os::Windows),
None
);
}
#[test]
fn slack_navigation() {
assert_eq!(
shortcut(I::QuickSwitcher, AppFamily::Slack, Os::Mac),
Some(keys(&["Cmd", "k"]))
);
assert_eq!(
shortcut(I::QuickSwitcher, AppFamily::Slack, Os::Windows),
Some(keys(&["Ctrl", "k"]))
);
assert_eq!(
shortcut(I::NextUnread, AppFamily::Slack, Os::Mac),
Some(keys(&["alt", "shift", "down"]))
);
assert_eq!(
shortcut(I::MarkRead, AppFamily::Slack, Os::Mac),
Some(keys(&["esc"]))
);
// Media intent on Slack → None.
assert_eq!(shortcut(I::Shuffle, AppFamily::Slack, Os::Mac), None);
}
#[test]
fn parse_media_intents() {
assert_eq!(parse_media("pause"), Some(I::PlayPause));
assert_eq!(parse_media("resume the music"), Some(I::PlayPause));
assert_eq!(parse_media("play"), Some(I::PlayPause));
assert_eq!(parse_media("play the music"), Some(I::PlayPause));
assert_eq!(parse_media("next song"), Some(I::NextTrack));
assert_eq!(parse_media("skip this"), Some(I::NextTrack));
assert_eq!(parse_media("previous track"), Some(I::PrevTrack));
assert_eq!(parse_media("turn it up"), Some(I::VolumeUp));
assert_eq!(parse_media("lower the volume"), Some(I::VolumeDown));
assert_eq!(parse_media("mute"), Some(I::Mute));
assert_eq!(parse_media("shuffle"), Some(I::Shuffle));
// "play <song>" is NOT a transport toggle (music.rs owns that).
assert_eq!(parse_media("play despacito"), None);
}
#[test]
fn parse_slack_intents() {
assert_eq!(parse_slack("jump to a channel"), Some(I::QuickSwitcher));
assert_eq!(parse_slack("go to channel general"), Some(I::QuickSwitcher));
assert_eq!(parse_slack("next unread"), Some(I::NextUnread));
assert_eq!(parse_slack("next channel"), Some(I::NextChannel));
assert_eq!(parse_slack("compose a message"), Some(I::ComposeDm));
assert_eq!(parse_slack("open threads"), Some(I::Threads));
assert_eq!(parse_slack("mark as read"), Some(I::MarkRead));
assert_eq!(parse_slack("search"), Some(I::SlackSearch));
assert_eq!(parse_slack("hello"), None);
}
#[test]
fn matches_gating() {
assert!(matches("Spotify", "next song"));
assert!(matches("Music", "pause"));
assert!(matches("Slack", "jump to a conversation"));
// Unknown app or no intent → no match.
assert!(!matches("Spotify", "hello there"));
assert!(!matches("Notes", "next song"));
}
}
@@ -0,0 +1,672 @@
//! Browser fast-path: "open `<browser>` and go to `<url>` / search / play".
//!
//! Encodes the deterministic browser flow the live transcript showed the model
//! getting wrong (tracker — Change 1.17): it re-launched the browser 6×, ran
//! `ax_interact` on the wrong app names (Chrome/Safari, never the real
//! `Brave Browser`), and finally clicked a hardcoded coordinate.
//!
//! Instead we navigate in ONE step via `open -a "<browser>" "<url>"`:
//! - **Pure navigation** ("open Brave and go to youtube.com", "search
//! youtube for X") completes here — no address-bar typing, no AX, no
//! re-launching.
//! - **Play intents** ("…and play a music video") navigate deterministically
//! to the search-results URL, then return a non-success outcome so the
//! general loop performs the single ambiguous "click the first result" via
//! `vision_click` (Chromium exposes no AX tree). There's no reliable native
//! shortcut that *selects* a search result, so we don't fake success.
//! - **In-page media control** ("pause/next/fullscreen the video") sends the
//! YouTube keyboard shortcut directly — the fastest possible path.
//!
//! Everything goes through the injectable [`AutomateBackend`], so the flow is
//! unit-testable with a scripted backend — no live browser, no model.
use super::browser_shortcuts::{shortcut, Browser, BrowserShortcut, Os};
use super::AutomateBackend;
use super::AutomateOutcome;
/// A resolved navigation target.
struct Destination {
/// Fully-qualified URL to open.
url: String,
/// True when the goal asks to *play* something at a search URL — the
/// fast-path navigates but then defers the final "click first result" to
/// the general loop's `vision_click`.
is_play: bool,
}
/// Does this (app, goal) look like a browser navigation / control request?
pub fn matches(app: &str, goal: &str) -> bool {
resolve_browser(app, goal).is_some()
&& (extract_destination(goal).is_some()
|| extract_browser_command(goal).is_some()
|| extract_media_control(goal).is_some())
}
/// Resolve the browser's macOS display name from the `app` arg first, then the
/// goal text. Returns `None` for a generic "browser" with no named product, so
/// we never guess the wrong app (the original transcript bug).
fn resolve_browser(app: &str, goal: &str) -> Option<String> {
// (alias substring, display name) — longest/most-specific aliases first.
const BROWSERS: &[(&str, &str)] = &[
("brave", "Brave Browser"),
("google chrome", "Google Chrome"),
("chrome", "Google Chrome"),
("microsoft edge", "Microsoft Edge"),
("edge", "Microsoft Edge"),
("firefox", "Firefox"),
("safari", "Safari"),
("arc", "Arc"),
];
let app_l = app.to_lowercase();
for (alias, display) in BROWSERS {
if app_l.contains(alias) {
return Some((*display).to_string());
}
}
let goal_l = goal.to_lowercase();
for (alias, display) in BROWSERS {
if goal_l.contains(alias) {
return Some((*display).to_string());
}
}
None
}
/// Resolve where to navigate. In priority order: YouTube intent → Google/web
/// search → a bare URL/domain mentioned in the goal. `None` if nothing matches.
fn extract_destination(goal: &str) -> Option<Destination> {
let lower = goal.to_lowercase();
let wants_play = has_word(&lower, "play");
// 1. YouTube.
if lower.contains("youtube") || lower.contains("you tube") {
if let Some(q) = extract_query(goal) {
return Some(Destination {
url: format!(
"https://www.youtube.com/results?search_query={}",
percent_encode(&q)
),
is_play: wants_play,
});
}
// Named YouTube but no query → just open the site.
return Some(Destination {
url: "https://www.youtube.com".to_string(),
is_play: false,
});
}
// 2. Google / web search.
if lower.contains("google") || lower.contains("search") {
// "search for X" / "search X for Y" via extract_query, else the text
// right after the word "google" ("google rust async traits").
let q = extract_query(goal).or_else(|| {
word_index(&lower, "google").and_then(|i| {
let after = clean_query(&goal[i + "google".len()..]);
(!after.is_empty()).then_some(after)
})
});
if let Some(q) = q {
return Some(Destination {
url: format!("https://www.google.com/search?q={}", percent_encode(&q)),
is_play: false,
});
}
}
// 3. A bare URL / domain anywhere in the goal ("go to example.com").
if let Some(url) = extract_url(goal) {
return Some(Destination {
url,
is_play: false,
});
}
None
}
/// Pull a search query out of the goal: text after "for" (search phrasing) or
/// after "play", with leading articles and trailing site words stripped. `None`
/// when there's nothing searchable.
fn extract_query(goal: &str) -> Option<String> {
let lower = goal.to_lowercase();
// "search [youtube|the web|google] for X" / "search for X".
if let Some(p) = lower.find(" for ") {
let after = goal[p + " for ".len()..].trim();
let q = clean_query(after);
if !q.is_empty() {
return Some(q);
}
}
// "play X", "play X on youtube".
if let Some(idx) = word_index(&lower, "play") {
let after = goal[idx + "play".len()..].trim();
let q = clean_query(after);
if !q.is_empty() {
return Some(q);
}
}
None
}
/// Trim filler around an extracted query: leading articles, a leading "me",
/// and a trailing "(on|in|at) <site>" / "video(s)" tail isn't stripped (it's a
/// fine search term), but a trailing "on youtube" etc. is removed.
fn clean_query(raw: &str) -> String {
let mut q = raw.trim().trim_end_matches(['.', '!', '?']).trim();
// Cut at a clause boundary so "go to youtube and play X then do Y" stops at X.
if let Some(p) = q.to_lowercase().find(" then ") {
q = q[..p].trim();
}
let mut s = q.to_string();
for lead in ["me ", "a ", "an ", "the ", "some "] {
if let Some(rest) = strip_prefix_ci(&s, lead) {
s = rest.trim().to_string();
break;
}
}
// Drop a trailing "(on|in) <site>" navigation tail.
let sl = s.to_lowercase();
for tail in [" on youtube", " in youtube", " on google", " on the web"] {
if let Some(p) = sl.rfind(tail) {
s.truncate(p);
break;
}
}
s.trim().to_string()
}
/// Find the first bare URL or `host.tld` token and normalize it to an
/// `https://` URL. Skips obvious non-hosts (must contain a dot, no spaces).
fn extract_url(goal: &str) -> Option<String> {
for tok in goal.split_whitespace() {
let t =
tok.trim_matches(|c: char| !c.is_alphanumeric() && c != '/' && c != ':' && c != '.');
let tl = t.to_lowercase();
if tl.starts_with("http://") || tl.starts_with("https://") {
return Some(t.to_string());
}
// host.tld with a plausible TLD and no scheme.
if t.contains('.') && !t.contains('/') && looks_like_domain(&tl) {
return Some(format!("https://{t}"));
}
}
None
}
/// Crude domain check: has a dot, and the last label is 224 ascii letters.
fn looks_like_domain(s: &str) -> bool {
match s.rsplit_once('.') {
Some((host, tld)) => {
!host.is_empty()
&& (2..=24).contains(&tld.len())
&& tld.chars().all(|c| c.is_ascii_alphabetic())
}
None => false,
}
}
/// Map a browser-level command ("new tab", "go back", "reload", "switch to tab
/// 3", …) to a [`BrowserShortcut`]. Resolved to actual keys per browser+OS by
/// [`browser_shortcuts::shortcut`]. Navigation (a URL/search) is handled earlier
/// by [`extract_destination`]; this covers tab/window/history/zoom verbs.
fn extract_browser_command(goal: &str) -> Option<BrowserShortcut> {
use BrowserShortcut as S;
let l = goal.to_lowercase();
// "switch to tab 3" / "go to tab 5" — a digit must follow "tab".
if let Some(n) = tab_number(&l) {
return Some(S::TabN(n));
}
// Most-specific phrases first.
if l.contains("hard reload") || l.contains("force reload") || l.contains("hard refresh") {
Some(S::HardReload)
} else if l.contains("reload") || l.contains("refresh") {
Some(S::Reload)
} else if l.contains("new tab") {
Some(S::NewTab)
} else if l.contains("close tab")
|| l.contains("close this tab")
|| l.contains("close the tab")
|| l.contains("close current tab")
{
Some(S::CloseTab)
} else if l.contains("reopen") || l.contains("restore tab") || l.contains("undo close") {
Some(S::ReopenTab)
} else if l.contains("incognito")
|| l.contains("inprivate")
|| l.contains("private window")
|| l.contains("private browsing")
{
Some(S::PrivateWindow)
} else if l.contains("new window") {
Some(S::NewWindow)
} else if l.contains("next tab") {
Some(S::NextTab)
} else if l.contains("previous tab") || l.contains("prev tab") {
Some(S::PrevTab)
} else if l.contains("last tab") {
Some(S::LastTab)
} else if l.contains("go back") || l.contains("navigate back") {
Some(S::Back)
} else if l.contains("go forward") || l.contains("navigate forward") {
Some(S::Forward)
} else if l.contains("address bar") || l.contains("url bar") || l.contains("location bar") {
Some(S::FocusAddressBar)
} else if has_word(&l, "history") {
Some(S::History)
} else if has_word(&l, "downloads") || l.contains("download list") {
Some(S::Downloads)
} else if l.contains("bookmark this")
|| l.contains("bookmark the page")
|| l.contains("bookmark page")
|| l.contains("add bookmark")
|| l.contains("save bookmark")
{
Some(S::BookmarkPage)
} else if l.contains("zoom in") {
Some(S::ZoomIn)
} else if l.contains("zoom out") {
Some(S::ZoomOut)
} else if l.contains("reset zoom") || l.contains("actual size") || l.contains("default zoom") {
Some(S::ZoomReset)
} else {
None
}
}
/// Extract a 19 tab number following the word "tab" ("switch to tab 3"). Also
/// accepts "tab number 3". `None` when no digit follows (so "new tab" / "next
/// tab" don't match).
fn tab_number(lower: &str) -> Option<u8> {
let idx = lower.find("tab ")?;
let rest = lower[idx + "tab ".len()..].trim_start();
let mut toks = rest.split_whitespace();
let first = toks.next()?;
let digit = if first == "number" || first == "no" || first == "#" {
toks.next()?
} else {
first
};
let n: u8 = digit
.trim_matches(|c: char| !c.is_ascii_digit())
.parse()
.ok()?;
(1..=9).contains(&n).then_some(n)
}
/// Map an in-page media-control goal to a YouTube keyboard shortcut, if any.
fn extract_media_control(goal: &str) -> Option<Vec<String>> {
let l = goal.to_lowercase();
// Only treat as a *control* command when there's no navigation verb.
if l.contains("open ") || l.contains("go to") || l.contains("search") {
return None;
}
let key = |k: &str| Some(vec![k.to_string()]);
if has_word(&l, "fullscreen") || l.contains("full screen") {
key("f")
} else if has_word(&l, "mute") || has_word(&l, "unmute") {
key("m")
} else if has_word(&l, "next") || has_word(&l, "skip") {
Some(vec!["shift".to_string(), "n".to_string()])
} else if has_word(&l, "previous") || has_word(&l, "back") {
Some(vec!["shift".to_string(), "p".to_string()])
} else if has_word(&l, "pause") || has_word(&l, "resume") || has_word(&l, "play") {
// YouTube `k` toggles play/pause.
key("k")
} else {
None
}
}
/// Run the browser fast-path.
pub async fn run(app: &str, goal: &str, backend: &dyn AutomateBackend) -> AutomateOutcome {
use super::super::automate::progress;
use crate::openhuman::overlay::OverlayAttentionTone;
let mut steps: Vec<String> = Vec::new();
let browser = match resolve_browser(app, goal) {
Some(b) => b,
None => return fail("no recognizable browser", steps),
};
// Navigation takes priority over in-page control.
if let Some(dest) = extract_destination(goal) {
log::info!(
"[automate::browser] ▶ open browser={browser:?} url={:?} is_play={}",
dest.url,
dest.is_play
);
progress(format!("Opening {browser}"), OverlayAttentionTone::Accent);
match backend.open_url_in_app(&browser, &dest.url).await {
Ok(m) => steps.push(format!("navigate: {m}")),
Err(e) => {
steps.push(format!("navigate FAILED: {e}"));
return fail("could not open the browser/URL", steps);
}
}
backend.settle(&browser).await;
// Give the page network time to render before any follow-up.
backend.wait(1200).await;
if dest.is_play {
// Deterministic part done; defer the single "click first result" to
// the general loop's vision_click (no reliable shortcut selects a
// search result). Returning non-success makes the loop take over —
// it does NOT re-launch from scratch, since the page is already up.
steps.push("navigated to search results; deferring play-click to vision".to_string());
return fail(
"navigated; first-result click deferred to general loop",
steps,
);
}
return AutomateOutcome {
success: true,
summary: format!("Opened {} in {browser}.", dest.url),
steps,
};
}
// Browser-level command ("new tab", "go back", "reload", "tab 3") via the
// cross-platform shortcut table — no AX, no navigation.
if let Some(intent) = extract_browser_command(goal) {
let keys = shortcut(intent, Browser::from_display(&browser), Os::current());
let combo = keys.join("+");
log::info!("[automate::browser] ▶ command {intent:?} browser={browser:?} keys={combo}");
progress(format!("Pressing {combo}"), OverlayAttentionTone::Accent);
match backend.key(&keys).await {
Ok(m) => {
steps.push(format!("hotkey {combo}: {m}"));
return AutomateOutcome {
success: true,
summary: format!("Sent {combo} to {browser}."),
steps,
};
}
Err(e) => {
steps.push(format!("hotkey FAILED: {e}"));
return fail("could not send the browser shortcut", steps);
}
}
}
// In-page media control via a YouTube keyboard shortcut.
if let Some(keys) = extract_media_control(goal) {
let combo = keys.join("+");
log::info!("[automate::browser] ▶ media control browser={browser:?} keys={combo}");
progress(format!("Pressing {combo}"), OverlayAttentionTone::Accent);
match backend.key(&keys).await {
Ok(m) => {
steps.push(format!("hotkey {combo}: {m}"));
return AutomateOutcome {
success: true,
summary: format!("Sent {combo} to {browser}."),
steps,
};
}
Err(e) => {
steps.push(format!("hotkey FAILED: {e}"));
return fail("could not send the media shortcut", steps);
}
}
}
fail("no browser destination or control in goal", steps)
}
fn fail(msg: &str, steps: Vec<String>) -> AutomateOutcome {
AutomateOutcome {
success: false,
summary: format!("Browser fast-path: {msg}"),
steps,
}
}
// ── small string helpers ────────────────────────────────────────────────────
/// True if `needle` appears as a whole word in `haystack` (already lowercased).
fn has_word(haystack: &str, needle: &str) -> bool {
word_index(haystack, needle).is_some()
}
/// Byte index of `needle` as a whole word in `haystack` (already lowercased).
fn word_index(haystack: &str, needle: &str) -> Option<usize> {
let mut from = 0;
while let Some(rel) = haystack[from..].find(needle) {
let idx = from + rel;
let before_ok = idx == 0
|| !haystack[..idx]
.chars()
.next_back()
.map(|c| c.is_alphanumeric())
.unwrap_or(false);
let after = idx + needle.len();
let after_ok = haystack[after..]
.chars()
.next()
.map(|c| !c.is_alphanumeric())
.unwrap_or(true);
if before_ok && after_ok {
return Some(idx);
}
from = idx + needle.len();
}
None
}
/// Case-insensitive `strip_prefix`.
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
if s.len() >= prefix.len()
&& s.is_char_boundary(prefix.len())
&& s[..prefix.len()].to_lowercase() == prefix.to_lowercase()
{
Some(&s[prefix.len()..])
} else {
None
}
}
/// Percent-encode reserved characters in a query value (enough for a `?q=`
/// search param; not a full RFC-3986 encoder). Mirrors `music::percent_encode`.
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod unit {
use super::*;
#[test]
fn resolve_browser_from_app_then_goal_never_guesses() {
// From the app arg.
assert_eq!(
resolve_browser("Brave Browser", "do something").as_deref(),
Some("Brave Browser")
);
// From the goal text.
assert_eq!(
resolve_browser("", "open chrome and go to x.com").as_deref(),
Some("Google Chrome")
);
assert_eq!(
resolve_browser("", "use safari to open apple.com").as_deref(),
Some("Safari")
);
assert_eq!(
resolve_browser("", "open microsoft edge").as_deref(),
Some("Microsoft Edge")
);
// Generic "browser" with no named product → None (the original bug: we
// must NOT guess Chrome/Safari).
assert_eq!(resolve_browser("", "open my browser"), None);
}
#[test]
fn matches_requires_browser_and_destination_or_control() {
assert!(matches("Brave Browser", "go to youtube.com"));
assert!(matches("", "open chrome and search youtube for lofi"));
assert!(matches("Brave Browser", "pause the video"));
// Browser but no destination/control.
assert!(!matches("Brave Browser", "hello there"));
// Destination but no resolvable browser.
assert!(!matches("Slack", "go to youtube.com"));
}
#[test]
fn destination_youtube_search_encodes_query() {
let d = extract_destination("go to youtube and play a music video").unwrap();
assert_eq!(
d.url,
"https://www.youtube.com/results?search_query=music%20video"
);
assert!(d.is_play);
}
#[test]
fn destination_youtube_search_for_phrasing() {
let d = extract_destination("search youtube for lofi beats").unwrap();
assert_eq!(
d.url,
"https://www.youtube.com/results?search_query=lofi%20beats"
);
assert!(!d.is_play); // no "play" word → navigation only
}
#[test]
fn destination_bare_youtube_no_query() {
let d = extract_destination("open youtube").unwrap();
assert_eq!(d.url, "https://www.youtube.com");
assert!(!d.is_play);
}
#[test]
fn destination_bare_domain_normalized_to_https() {
let d = extract_destination("go to example.com").unwrap();
assert_eq!(d.url, "https://example.com");
let d2 = extract_destination("open https://news.ycombinator.com").unwrap();
assert_eq!(d2.url, "https://news.ycombinator.com");
}
#[test]
fn destination_google_search() {
let d = extract_destination("google rust async traits").unwrap();
assert_eq!(
d.url,
"https://www.google.com/search?q=rust%20async%20traits"
);
}
#[test]
fn destination_none_when_no_target() {
assert!(extract_destination("just hang out").is_none());
}
#[test]
fn media_control_maps_to_youtube_shortcuts() {
assert_eq!(
extract_media_control("pause the video"),
Some(vec!["k".into()])
);
assert_eq!(
extract_media_control("resume playback"),
Some(vec!["k".into()])
);
assert_eq!(extract_media_control("mute it"), Some(vec!["m".into()]));
assert_eq!(
extract_media_control("next video"),
Some(vec!["shift".into(), "n".into()])
);
assert_eq!(
extract_media_control("go fullscreen"),
Some(vec!["f".into()])
);
// A navigation goal is NOT a control command.
assert_eq!(extract_media_control("open youtube and play lofi"), None);
}
#[test]
fn word_index_is_whole_word() {
// "play" must not match inside "display"/"playback".
assert!(has_word("play a song", "play"));
assert!(!has_word("display settings", "play"));
assert!(!has_word("open playback options", "play"));
}
#[test]
fn browser_command_maps_common_verbs() {
use BrowserShortcut as S;
assert_eq!(extract_browser_command("open a new tab"), Some(S::NewTab));
assert_eq!(extract_browser_command("close this tab"), Some(S::CloseTab));
assert_eq!(
extract_browser_command("reopen the closed tab"),
Some(S::ReopenTab)
);
assert_eq!(extract_browser_command("go back"), Some(S::Back));
assert_eq!(extract_browser_command("reload the page"), Some(S::Reload));
assert_eq!(extract_browser_command("hard reload"), Some(S::HardReload));
assert_eq!(extract_browser_command("next tab"), Some(S::NextTab));
assert_eq!(
extract_browser_command("open an incognito window"),
Some(S::PrivateWindow)
);
assert_eq!(extract_browser_command("show my history"), Some(S::History));
assert_eq!(extract_browser_command("zoom in"), Some(S::ZoomIn));
// No browser command.
assert_eq!(extract_browser_command("play a music video"), None);
}
#[test]
fn tab_number_only_with_digit() {
assert_eq!(tab_number("switch to tab 3"), Some(3));
assert_eq!(tab_number("go to tab number 5"), Some(5));
// "new tab"/"next tab" have no trailing digit → not a tab-number jump.
assert_eq!(tab_number("open a new tab"), None);
assert_eq!(tab_number("next tab"), None);
assert_eq!(
extract_browser_command("switch to tab 4"),
Some(BrowserShortcut::TabN(4))
);
}
}
/// Live integration test — drives a real browser. Ignored by default (needs a
/// browser installed + Accessibility/Screen-recording permission). Asserts
/// tool-level success only; the visual page state is best-effort.
///
/// cargo test --lib browser_fastpath_live -- --ignored --nocapture
#[cfg(all(test, target_os = "macos"))]
mod live {
use super::run;
use crate::openhuman::accessibility::automate::RealBackend;
#[tokio::test]
#[ignore = "requires macOS + a browser + Accessibility permission"]
async fn browser_fastpath_live() {
let backend = RealBackend::new(crate::openhuman::config::Config::default());
// Use Safari (always present on macOS) for a deterministic nav.
let out = run("Safari", "open safari and go to example.com", &backend).await;
println!(
"[browser_fastpath_live] success={} summary={}",
out.success, out.summary
);
for s in &out.steps {
println!(" - {s}");
}
assert!(
out.success,
"nav fast-path reported failure: {}",
out.summary
);
}
}
@@ -0,0 +1,351 @@
//! Cross-platform browser keyboard-shortcut table (Change 1.17).
//!
//! The four major desktop browsers (Chrome, Firefox, Safari, Edge) share almost
//! the same navigation/tab/find shortcuts — they differ mainly by the primary
//! modifier (⌘ on macOS, Ctrl on Windows/Linux), with a handful of real
//! per-browser exceptions (Firefox's `Alt+18` tab selection on Linux, its
//! `Ctrl/⌘+Shift+P` private window, per-browser History/Downloads keys, etc.).
//!
//! [`shortcut`] resolves `(intent, browser, os)` to a key chord expressed as the
//! same key names the keyboard tool's `parse_key` understands (`"Cmd"`,
//! `"Ctrl"`, `"Shift"`, `"Alt"`, single chars, `"left"`, `"tab"`, `"f11"`, …),
//! so the result feeds straight into `AutomateBackend::key`.
//!
//! Sources: Chrome/Firefox/Edge support pages (Win/macOS/Linux columns) and the
//! Safari for Mac guide. In-page media shortcuts (YouTube `k`/`f`/`m`/`Shift+N`)
//! are browser-independent and live in `browser.rs`, not here.
/// Target operating system — selects the primary modifier and the non-Mac
/// fallbacks (Alt+arrow history, F11 fullscreen, etc.).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Os {
Mac,
Windows,
Linux,
}
impl Os {
/// The OS this build is running on.
pub fn current() -> Os {
if cfg!(target_os = "macos") {
Os::Mac
} else if cfg!(target_os = "windows") {
Os::Windows
} else {
Os::Linux
}
}
}
/// Browser family — chosen by [`Browser::from_display`]. Chromium-based browsers
/// (Brave, Arc, Chromium) share Chrome's shortcuts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Browser {
Chrome,
Firefox,
Safari,
Edge,
}
impl Browser {
/// Map a macOS display name (or alias) to a family. Unknown / generic names
/// default to Chrome, since most other desktop browsers are Chromium-based
/// and share its bindings.
pub fn from_display(name: &str) -> Browser {
let n = name.to_lowercase();
if n.contains("firefox") {
Browser::Firefox
} else if n.contains("safari") {
Browser::Safari
} else if n.contains("edge") {
Browser::Edge
} else {
// Chrome, Brave, Arc, Chromium, Vivaldi, Opera, … → Chrome bindings.
Browser::Chrome
}
}
}
/// A browser action we can trigger with a keyboard shortcut.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowserShortcut {
FocusAddressBar,
NewTab,
CloseTab,
ReopenTab,
NewWindow,
PrivateWindow,
NextTab,
PrevTab,
/// Jump to tab N (1-based, 18). N≥9 is treated as the last tab.
TabN(u8),
LastTab,
Find,
FindNext,
FindPrev,
Reload,
HardReload,
ZoomIn,
ZoomOut,
ZoomReset,
Print,
BookmarkPage,
Back,
Forward,
History,
Downloads,
Fullscreen,
}
/// The primary modifier: ⌘ on macOS, Ctrl elsewhere.
fn primary(os: Os) -> &'static str {
match os {
Os::Mac => "Cmd",
_ => "Ctrl",
}
}
fn keys(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
/// Resolve a shortcut to a key chord for the given browser + OS.
pub fn shortcut(intent: BrowserShortcut, browser: Browser, os: Os) -> Vec<String> {
use BrowserShortcut as S;
let p = primary(os);
match intent {
// ── uniform across all four browsers (only ⌘↔Ctrl differs) ──
S::FocusAddressBar => keys(&[p, "l"]),
S::NewTab => keys(&[p, "t"]),
S::CloseTab => keys(&[p, "w"]),
S::ReopenTab => keys(&[p, "shift", "t"]),
S::NewWindow => keys(&[p, "n"]),
// Ctrl+Tab cycles tabs on every OS/browser (not ⌘ on macOS).
S::NextTab => keys(&["ctrl", "tab"]),
S::PrevTab => keys(&["ctrl", "shift", "tab"]),
S::Find => keys(&[p, "f"]),
S::FindNext => keys(&[p, "g"]),
S::FindPrev => keys(&[p, "shift", "g"]),
S::Reload => keys(&[p, "r"]),
S::HardReload => keys(&[p, "shift", "r"]),
// Use "=" (Ctrl/⌘+=) for zoom-in: it's the unshifted key and every
// browser accepts it as zoom-in, avoiding the Shift needed to type "+".
S::ZoomIn => keys(&[p, "="]),
S::ZoomOut => keys(&[p, "-"]),
S::ZoomReset => keys(&[p, "0"]),
S::Print => keys(&[p, "p"]),
S::BookmarkPage => keys(&[p, "d"]),
// ── per-browser / per-OS exceptions ──
S::PrivateWindow => match browser {
Browser::Firefox => keys(&[p, "shift", "p"]),
_ => keys(&[p, "shift", "n"]),
},
S::TabN(n) => {
let d = n.clamp(1, 8).to_string();
// Firefox on Linux uses Alt+18 (not Ctrl).
if browser == Browser::Firefox && os == Os::Linux {
keys(&["alt", &d])
} else {
keys(&[p, &d])
}
}
S::LastTab => {
if browser == Browser::Firefox && os == Os::Linux {
keys(&["alt", "9"])
} else {
keys(&[p, "9"])
}
}
S::Back => match os {
Os::Mac => keys(&[p, "["]),
_ => keys(&["alt", "left"]),
},
S::Forward => match os {
Os::Mac => keys(&[p, "]"]),
_ => keys(&["alt", "right"]),
},
S::History => match os {
Os::Mac => match browser {
// Chrome/Edge: ⌘Y. Firefox/Safari: ⌘⇧H.
Browser::Chrome | Browser::Edge => keys(&[p, "y"]),
Browser::Firefox | Browser::Safari => keys(&[p, "shift", "h"]),
},
_ => keys(&["ctrl", "h"]),
},
S::Downloads => match os {
Os::Mac => match browser {
Browser::Chrome => keys(&[p, "shift", "j"]),
Browser::Firefox => keys(&[p, "j"]),
// Edge / Safari: ⌥⌘L.
Browser::Edge | Browser::Safari => keys(&[p, "option", "l"]),
},
_ => keys(&["ctrl", "j"]),
},
S::Fullscreen => match os {
// Avoid the Fn key: ⌃⌘F is the standard macOS window fullscreen
// (Chrome/Edge/Safari); Firefox uses ⌘⇧F.
Os::Mac => match browser {
Browser::Firefox => keys(&[p, "shift", "f"]),
_ => keys(&["ctrl", "cmd", "f"]),
},
_ => keys(&["f11"]),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use BrowserShortcut as S;
#[test]
fn primary_modifier_per_os() {
assert_eq!(
shortcut(S::FocusAddressBar, Browser::Chrome, Os::Mac),
vec!["Cmd", "l"]
);
assert_eq!(
shortcut(S::FocusAddressBar, Browser::Chrome, Os::Windows),
vec!["Ctrl", "l"]
);
assert_eq!(
shortcut(S::FocusAddressBar, Browser::Safari, Os::Mac),
vec!["Cmd", "l"]
);
}
#[test]
fn uniform_core_shortcuts() {
assert_eq!(
shortcut(S::NewTab, Browser::Edge, Os::Linux),
vec!["Ctrl", "t"]
);
assert_eq!(
shortcut(S::ReopenTab, Browser::Firefox, Os::Mac),
vec!["Cmd", "shift", "t"]
);
assert_eq!(
shortcut(S::HardReload, Browser::Chrome, Os::Windows),
vec!["Ctrl", "shift", "r"]
);
// Next/prev tab is Ctrl-based on every OS, even macOS.
assert_eq!(
shortcut(S::NextTab, Browser::Chrome, Os::Mac),
vec!["ctrl", "tab"]
);
}
#[test]
fn private_window_firefox_differs() {
assert_eq!(
shortcut(S::PrivateWindow, Browser::Firefox, Os::Windows),
vec!["Ctrl", "shift", "p"]
);
assert_eq!(
shortcut(S::PrivateWindow, Browser::Chrome, Os::Windows),
vec!["Ctrl", "shift", "n"]
);
}
#[test]
fn tab_n_firefox_linux_uses_alt() {
assert_eq!(
shortcut(S::TabN(3), Browser::Firefox, Os::Linux),
vec!["alt", "3"]
);
assert_eq!(
shortcut(S::TabN(3), Browser::Chrome, Os::Linux),
vec!["Ctrl", "3"]
);
assert_eq!(
shortcut(S::TabN(3), Browser::Firefox, Os::Mac),
vec!["Cmd", "3"]
);
// Out-of-range clamps into 18.
assert_eq!(
shortcut(S::TabN(20), Browser::Chrome, Os::Mac),
vec!["Cmd", "8"]
);
assert_eq!(
shortcut(S::LastTab, Browser::Firefox, Os::Linux),
vec!["alt", "9"]
);
}
#[test]
fn back_forward_os_specific() {
assert_eq!(
shortcut(S::Back, Browser::Chrome, Os::Mac),
vec!["Cmd", "["]
);
assert_eq!(
shortcut(S::Back, Browser::Chrome, Os::Windows),
vec!["alt", "left"]
);
assert_eq!(
shortcut(S::Forward, Browser::Safari, Os::Mac),
vec!["Cmd", "]"]
);
}
#[test]
fn history_and_downloads_per_browser_on_mac() {
assert_eq!(
shortcut(S::History, Browser::Chrome, Os::Mac),
vec!["Cmd", "y"]
);
assert_eq!(
shortcut(S::History, Browser::Firefox, Os::Mac),
vec!["Cmd", "shift", "h"]
);
assert_eq!(
shortcut(S::History, Browser::Edge, Os::Windows),
vec!["ctrl", "h"]
);
assert_eq!(
shortcut(S::Downloads, Browser::Chrome, Os::Mac),
vec!["Cmd", "shift", "j"]
);
assert_eq!(
shortcut(S::Downloads, Browser::Firefox, Os::Mac),
vec!["Cmd", "j"]
);
assert_eq!(
shortcut(S::Downloads, Browser::Edge, Os::Mac),
vec!["Cmd", "option", "l"]
);
assert_eq!(
shortcut(S::Downloads, Browser::Chrome, Os::Linux),
vec!["ctrl", "j"]
);
}
#[test]
fn fullscreen_avoids_fn_key() {
assert_eq!(
shortcut(S::Fullscreen, Browser::Chrome, Os::Mac),
vec!["ctrl", "cmd", "f"]
);
assert_eq!(
shortcut(S::Fullscreen, Browser::Firefox, Os::Mac),
vec!["Cmd", "shift", "f"]
);
assert_eq!(
shortcut(S::Fullscreen, Browser::Chrome, Os::Windows),
vec!["f11"]
);
}
#[test]
fn from_display_maps_families() {
assert_eq!(Browser::from_display("Brave Browser"), Browser::Chrome);
assert_eq!(Browser::from_display("Arc"), Browser::Chrome);
assert_eq!(Browser::from_display("Google Chrome"), Browser::Chrome);
assert_eq!(Browser::from_display("Firefox"), Browser::Firefox);
assert_eq!(Browser::from_display("Microsoft Edge"), Browser::Edge);
assert_eq!(Browser::from_display("Safari"), Browser::Safari);
// Unknown → Chrome default.
assert_eq!(Browser::from_display("Some New Browser"), Browser::Chrome);
}
}
@@ -104,6 +104,8 @@ struct Backend {
/// Elements returned by perceive (the search results screen).
elements: Vec<AXElement>,
press_fail_on: Option<String>,
/// What `now_playing()` reports back (None = backend can't read the track).
now_playing: Option<(String, String)>,
}
impl Backend {
@@ -112,8 +114,13 @@ impl Backend {
acts: Mutex::new(Vec::new()),
elements,
press_fail_on: None,
now_playing: None,
}
}
fn with_now_playing(mut self, name: &str, artist: &str) -> Self {
self.now_playing = Some((name.to_string(), artist.to_string()));
self
}
fn acts(&self) -> Vec<String> {
self.acts.lock().unwrap().clone()
}
@@ -148,6 +155,23 @@ impl AutomateBackend for Backend {
self.acts.lock().unwrap().push(format!("open_url:{url}"));
Ok("ok".into())
}
async fn open_url_in_app(&self, app: &str, url: &str) -> Result<String, String> {
self.acts
.lock()
.unwrap()
.push(format!("open_url_in_app:{app}:{url}"));
Ok("ok".into())
}
async fn key(&self, keys: &[String]) -> Result<String, String> {
self.acts
.lock()
.unwrap()
.push(format!("key:{}", keys.join("+")));
Ok("ok".into())
}
async fn now_playing(&self) -> Option<(String, String)> {
self.now_playing.clone()
}
async fn settle(&self, _app: &str) {}
async fn wait(&self, _ms: u64) {}
}
@@ -170,12 +194,88 @@ async fn music_fastpath_full_sequence() {
}
#[tokio::test]
async fn music_fastpath_no_row_fails_for_fallthrough() {
// Search screen has nothing matching → fast-path fails (loop falls through).
let backend = Backend::new(vec![AXElement::new("AXButton", "Some Unrelated Button")]);
let out = music::run("play Numb", &backend).await;
async fn music_fastpath_reports_verified_track_and_artist() {
// now_playing matches the requested artist → success names the real track.
let backend = Backend::new(vec![song_row("Numb"), AXElement::new("AXButton", "Play")])
.with_now_playing("Numb", "Linkin Park");
let out = music::run("play Numb by Linkin Park", &backend).await;
assert!(out.success, "{out:?}");
assert!(
out.summary.contains("Linkin Park") && out.summary.contains("Numb"),
"summary should name the verified track+artist: {}",
out.summary
);
}
#[tokio::test]
async fn music_fastpath_flags_wrong_artist_honestly() {
// The search landed on a same-titled song by a different artist. The
// fast-path must name it AND steer the agent away from re-searching (#1/#3).
let backend = Backend::new(vec![song_row("Numb"), AXElement::new("AXButton", "Play")])
.with_now_playing("Numb", "Tom Odell");
let out = music::run("play Numb by Linkin Park", &backend).await;
let s = out.summary.to_lowercase();
assert!(
s.contains("tom odell"),
"must name what actually played: {}",
out.summary
);
// Actionable, anti-loop guidance.
assert!(
s.contains("won't surface") && s.contains("library"),
"must steer the agent (no blind re-search): {}",
out.summary
);
}
#[tokio::test]
async fn music_fastpath_no_row_lists_candidates_and_warns() {
// Song rows exist but none match the query → response lists what WAS found
// and tells the agent not to repeat the same search (#1/#2).
let backend = Backend::new(vec![
AXElement::new("AXCell", "Numb - Marshmello & Khalid"),
AXElement::new("AXCell", "Numb - Tom Odell"),
]);
let out = music::run("play Zelda Theme by Koji Kondo", &backend).await;
assert!(!out.success);
assert!(out.summary.contains("no matching song"), "{}", out.summary);
let s = out.summary.to_lowercase();
assert!(
s.contains("marshmello") && s.contains("tom odell"),
"{}",
out.summary
);
assert!(
s.contains("won't help") || s.contains("don't repeat"),
"{}",
out.summary
);
}
#[tokio::test]
async fn music_fastpath_artist_row_not_claimed_as_playing() {
// Only an artist AXButton matches (no song cell) and the backend can't read
// a track → must NOT claim "Playing"; say it only navigated (#3/#4).
let backend = Backend::new(vec![AXElement::new("AXButton", "LINKIN PARK")]);
let out = music::run("play Linkin Park Numb", &backend).await;
let s = out.summary.to_lowercase();
assert!(
!s.contains("playing '"),
"must not claim playback: {}",
out.summary
);
assert!(
s.contains("artist/album") && s.contains("specific song"),
"must explain it only navigated: {}",
out.summary
);
// It pressed the artist element, flagged as non-song in the step log.
assert!(
out.steps
.iter()
.any(|a| a.contains("artist/album 'LINKIN PARK'")),
"{:?}",
out.steps
);
}
#[tokio::test]
@@ -203,6 +303,121 @@ async fn try_fastpath_dispatches_music_and_skips_others() {
.is_some());
}
// ── Browser fast-path: scripted sequence ────────────────────────────
#[tokio::test]
async fn browser_nav_to_domain_succeeds_without_model() {
let backend = Backend::new(vec![]);
let out = super::browser::run(
"Brave Browser",
"open Brave and go to example.com",
&backend,
)
.await;
assert!(out.success, "pure navigation should succeed: {out:?}");
let acts = backend.acts();
// One deterministic open in the named browser — no model `decide` call
// (the scripted backend panics if `decide` is hit). Bare domain is
// normalized to https://.
assert_eq!(acts.len(), 1, "{acts:?}");
assert_eq!(acts[0], "open_url_in_app:Brave Browser:https://example.com");
}
#[tokio::test]
async fn browser_youtube_search_play_navigates_then_falls_through() {
let backend = Backend::new(vec![]);
let out = super::browser::run(
"Brave Browser",
"open my brave browser, go to youtube.com and play a music video",
&backend,
)
.await;
// Play intent → navigate deterministically, then return non-success so the
// general loop performs the single first-result click via vision_click.
assert!(!out.success, "play must defer the final click: {out:?}");
let acts = backend.acts();
assert_eq!(acts.len(), 1, "{acts:?}");
assert_eq!(
acts[0],
"open_url_in_app:Brave Browser:https://www.youtube.com/results?search_query=music%20video"
);
}
#[tokio::test]
async fn browser_media_control_sends_hotkey() {
let backend = Backend::new(vec![]);
let out = super::browser::run("Brave Browser", "pause the video", &backend).await;
assert!(out.success, "media control should succeed: {out:?}");
assert_eq!(backend.acts(), vec!["key:k".to_string()]);
}
#[tokio::test]
async fn browser_command_routes_resolved_shortcut() {
// "new tab" → a cross-platform chord resolved per browser+OS. Brave maps to
// the Chrome family; the primary modifier is OS-dependent, so accept either.
let backend = Backend::new(vec![]);
let out = super::browser::run("Brave Browser", "open a new tab", &backend).await;
assert!(out.success, "browser command should succeed: {out:?}");
let act = &backend.acts()[0];
assert!(
act == "key:Cmd+t" || act == "key:Ctrl+t",
"expected a new-tab chord, got {act}"
);
}
#[tokio::test]
async fn try_fastpath_dispatches_browser() {
let backend = Backend::new(vec![]);
let out = super::try_fastpath(
"Brave Browser",
"open Brave and go to example.com",
&backend,
)
.await;
assert!(
out.is_some(),
"browser nav should be claimed by a fast-path"
);
assert!(out.unwrap().success);
}
// ── App-shortcut fast-path (Spotify / Apple Music / Slack) ──────────
#[tokio::test]
async fn app_shortcut_spotify_next_sends_down_arrow() {
let backend = Backend::new(vec![]);
let out = super::app_shortcuts::run("Spotify", "next song", &backend).await;
assert!(out.success, "{out:?}");
// Spotify quirk: next = Down arrow.
assert_eq!(backend.acts(), vec!["key:down".to_string()]);
}
#[tokio::test]
async fn app_shortcut_slack_quick_switcher() {
let backend = Backend::new(vec![]);
let out = super::app_shortcuts::run("Slack", "jump to a conversation", &backend).await;
assert!(out.success, "{out:?}");
let act = &backend.acts()[0];
assert!(act == "key:Cmd+k" || act == "key:Ctrl+k", "got {act}");
}
#[tokio::test]
async fn try_fastpath_routes_apps_and_music_still_wins_play() {
let backend = Backend::new(vec![song_row("Numb")]);
// Apple Music "pause" → app-shortcut fast-path (music.rs declines: no query).
assert!(super::try_fastpath("Music", "pause", &backend)
.await
.is_some());
// Apple Music "play Numb" → still claimed (by music.rs, song search).
assert!(super::try_fastpath("Music", "play Numb", &backend)
.await
.is_some());
// Spotify "skip" → app-shortcut fast-path.
assert!(super::try_fastpath("Spotify", "skip this track", &backend)
.await
.is_some());
}
// Outcome type sanity: fast-paths build the same outcome the loop returns.
#[test]
fn outcome_shape() {
@@ -15,6 +15,9 @@
use super::automate::AutomateBackend;
use super::automate::AutomateOutcome;
mod app_shortcuts;
mod browser;
mod browser_shortcuts;
mod music;
/// Try every registered fast-path; return the first that claims the (app, goal).
@@ -26,6 +29,12 @@ pub async fn try_fastpath(
if music::matches(app, goal) {
return Some(music::run(goal, backend).await);
}
if browser::matches(app, goal) {
return Some(browser::run(app, goal, backend).await);
}
if app_shortcuts::matches(app, goal) {
return Some(app_shortcuts::run(app, goal, backend).await);
}
None
}
@@ -7,13 +7,32 @@
//! the injectable [`AutomateBackend`], so the whole flow is unit-testable with a
//! scripted backend — no live Music, no model.
use super::super::ax_interact::AXElement;
use super::AutomateBackend;
use super::AutomateOutcome;
const APP: &str = "Music";
/// Element roles that represent a tappable search result / song row.
const ROW_ROLES: &[&str] = &["AXCell", "AXRow", "ListItem", "AXButton", "AXStaticText"];
/// Roles that represent an actual song/track ROW — the preferred press target,
/// because pressing one navigates into (and can play) the track.
const SONG_ROLES: &[&str] = &["AXCell", "AXRow", "ListItem"];
/// Secondary roles — artist names, album headers, static text. Pressing these
/// usually only *navigates* (to an artist/album page), so they're a last resort
/// and we flag them so the caller can report honestly.
const OTHER_ROW_ROLES: &[&str] = &["AXButton", "AXStaticText"];
fn role_in(role: &str, set: &[&str]) -> bool {
set.iter().any(|r| role.contains(r))
}
/// A chosen press target plus whether it's a real song row (vs an artist/album
/// element that only navigates). The caller uses `is_song` to avoid claiming
/// "Playing X" when it merely opened an artist page.
#[derive(Debug, Clone, PartialEq)]
struct PickedRow {
label: String,
is_song: bool,
}
/// Does this (app, goal) look like an Apple Music "play X" request?
pub fn matches(app: &str, goal: &str) -> bool {
@@ -88,6 +107,38 @@ pub fn extract_play_query(goal: &str) -> Option<String> {
}
}
/// Pull the requested artist out of a "play X by <artist>" goal — used to
/// verify we played the *right* track. The Apple Music AX row label is
/// title-only ("Numb - Single"), so a "Numb" search can resolve to the wrong
/// artist; the artist lets us confirm via the now-playing track. `None` when no
/// "by <artist>" clause is present.
pub(crate) fn extract_artist(goal: &str) -> Option<String> {
let lower = goal.to_lowercase();
let p = lower.find(" by ")?;
let after = &goal[p + " by ".len()..];
// Cut at the first clause boundary.
let mut end = after.len();
for delim in [",", " and ", " then ", " in ", " on ", " from ", " for "] {
if let Some(q) = after.to_lowercase().find(delim) {
end = end.min(q);
}
}
let artist = after[..end].trim().trim_matches('"').trim().to_string();
if artist.is_empty() || is_pronoun(&artist) {
None
} else {
Some(artist)
}
}
/// Loose artist comparison: case-insensitive, matching if either string
/// contains the other (so "Linkin Park" matches "Linkin Park feat. …").
fn artist_matches(want: &str, got: &str) -> bool {
let w = want.trim().to_lowercase();
let g = got.trim().to_lowercase();
!w.is_empty() && !g.is_empty() && (g.contains(&w) || w.contains(&g))
}
/// Strip a trailing "(in|on) [apple] music" and rewrite " by " → " ".
fn clean_query(q: &str) -> String {
let mut q = q.trim().to_string();
@@ -216,26 +267,71 @@ fn first_token(query: &str) -> String {
.to_string()
}
/// Choose the best matching row from a perceive snapshot: an exact label match
/// first, else the first row-role element whose label shares a word with the
/// query. Returns the element label to press.
fn pick_row(elements: &[super::super::ax_interact::AXElement], query: &str) -> Option<String> {
/// Choose the best press target. Preference order: an exact-label **song row**,
/// then a token-matching **song row**, then any exact-label element, and only as
/// a last resort an artist/album element (which merely navigates). Returns the
/// label *and* whether it's a real song row, so the caller never falsely claims
/// playback after pressing an artist/album header.
///
/// (We deliberately do NOT skip elements whose reported `enabled` is false —
/// Apple Music marks pressable result rows as disabled; see AXElement::enabled.)
fn pick_row(elements: &[AXElement], query: &str) -> Option<PickedRow> {
let ql = query.to_lowercase();
// Exact label match wins. (We deliberately do NOT skip elements whose
// reported `enabled` is false — Apple Music marks pressable result rows as
// disabled; see AXElement::enabled docs.)
if let Some(e) = elements.iter().find(|e| e.label.to_lowercase() == ql) {
return Some(e.label.clone());
}
let tokens: Vec<&str> = ql.split_whitespace().filter(|t| t.len() > 2).collect();
let token_hit = |e: &&AXElement| {
let l = e.label.to_lowercase();
tokens.iter().any(|t| l.contains(t))
};
let song = |e: &&AXElement| role_in(&e.role, SONG_ROLES);
// 1. Exact match on a song row.
if let Some(e) = elements
.iter()
.find(|e| song(e) && e.label.to_lowercase() == ql)
{
return Some(PickedRow {
label: e.label.clone(),
is_song: true,
});
}
// 2. Token match on a song row.
if let Some(e) = elements.iter().find(|e| song(e) && token_hit(e)) {
return Some(PickedRow {
label: e.label.clone(),
is_song: true,
});
}
// 3. Exact match on any element.
if let Some(e) = elements.iter().find(|e| e.label.to_lowercase() == ql) {
return Some(PickedRow {
label: e.label.clone(),
is_song: role_in(&e.role, SONG_ROLES),
});
}
// 4. Last resort: an artist/album element that token-matches (navigates).
elements
.iter()
.filter(|e| ROW_ROLES.iter().any(|r| e.role.contains(r)))
.find(|e| {
let l = e.label.to_lowercase();
tokens.iter().any(|t| l.contains(t))
.find(|e| role_in(&e.role, OTHER_ROW_ROLES) && token_hit(e))
.map(|e| PickedRow {
label: e.label.clone(),
is_song: false,
})
.map(|e| e.label.clone())
}
/// Up to 5 distinct song-row labels currently visible — surfaced in failure
/// responses so the agent (or user) can see what *was* found and choose.
fn candidate_labels(elements: &[AXElement]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for e in elements.iter().filter(|e| role_in(&e.role, SONG_ROLES)) {
let l = e.label.trim().to_string();
if !l.is_empty() && !out.contains(&l) {
out.push(l);
if out.len() >= 5 {
break;
}
}
}
out
}
/// Run the play fast-path. Returns a failed [`AutomateOutcome`] (not a panic)
@@ -249,7 +345,10 @@ pub async fn run(goal: &str, backend: &dyn AutomateBackend) -> AutomateOutcome {
return fail("not a play request", steps);
}
};
log::info!("[automate::music] ▶ play query={query:?}");
// The artist the user asked for (if any), used to confirm we played the
// right track — the AX row label alone can't disambiguate same-titled songs.
let want_artist = extract_artist(goal);
log::info!("[automate::music] ▶ play query={query:?} artist={want_artist:?}");
use super::super::automate::progress;
use crate::openhuman::overlay::OverlayAttentionTone;
progress(
@@ -278,14 +377,17 @@ pub async fn run(goal: &str, backend: &dyn AutomateBackend) -> AutomateOutcome {
// filter the snapshot by one strong token (a substring filter can't
// match a whole multi-word title).
let filter = first_token(&query);
let mut row = None;
let mut row: Option<PickedRow> = None;
let mut last_els: Vec<AXElement> = Vec::new();
for attempt in 0..6 {
backend.settle(APP).await;
let els = backend.perceive(APP, &filter).await.unwrap_or_default();
if let Some(r) = pick_row(&els, &query) {
last_els = els;
row = Some(r);
break;
}
last_els = els;
// Catalog search results arrive asynchronously (~3-4s); element-count
// settle can report "stable" while the network fetch is still pending,
// so wait real time between attempts rather than spinning instantly.
@@ -293,19 +395,26 @@ pub async fn run(goal: &str, backend: &dyn AutomateBackend) -> AutomateOutcome {
backend.wait(800).await;
}
let row = match row {
// #1/#2: when nothing matched, list what WAS found and tell the agent
// not to repeat the same search — so it tries the library / asks the
// user instead of re-delegating the identical query.
None => return fail(&no_match_message(&query, &want_artist, &last_els), steps),
Some(r) => r,
None => return fail("no matching song row found", steps),
};
// Baseline count of "Play" controls *before* navigating, so we can tell
// when the song's detail-page Play has actually rendered (vs. only the
// toolbar transport Play that's always present).
let plays_before = count_play_buttons(backend).await;
match backend.act_press(APP, &row).await {
Ok(m) => steps.push(format!("open song: {m}")),
// #4: record what KIND of element we pressed — a song row that plays, or an
// artist/album element that only navigates.
let pressed_song = row.is_song;
let kind = if pressed_song { "song" } else { "artist/album" };
match backend.act_press(APP, &row.label).await {
Ok(m) => steps.push(format!("open {kind} '{}': {m}", row.label)),
Err(e) => {
steps.push(format!("open song FAILED: {e}"));
return fail("could not open the song", steps);
steps.push(format!("open {kind} FAILED: {e}"));
return fail("could not open the result", steps);
}
}
@@ -349,25 +458,114 @@ pub async fn run(goal: &str, backend: &dyn AutomateBackend) -> AutomateOutcome {
}
}
match verified {
Some(false) => {
steps.push("verify: player state never reached 'playing'".to_string());
fail("opened the song but playback didn't start", steps)
}
Some(true) => {
steps.push("verify: playing ✓".to_string());
progress(format!("Playing {query}"), OverlayAttentionTone::Success);
if matches!(verified, Some(false)) {
steps.push("verify: player state never reached 'playing'".to_string());
return fail("opened the song but playback didn't start", steps);
}
// 6. Confirm we played the RIGHT track. The AX row label carries only the
// title, so "Numb" can land on the wrong artist (this is the exact bug we
// hit live). Ask Music for the now-playing name + artist and check it
// against the requested artist — so we never falsely claim success on a
// wrong-artist match, and the summary names what's actually playing.
let now = backend.now_playing().await;
let candidates = candidate_labels(&last_els);
match (&want_artist, &now) {
// #1/#3: played, but the wrong artist — name what's actually playing,
// list the alternatives, and tell the agent NOT to repeat the search
// (success=true so this guidance reaches the user instead of being
// discarded by a fall-through to the model loop).
(Some(want), Some((name, got))) if !artist_matches(want, got) => {
steps.push(format!(
"verify: now playing '{name}' by '{got}' — wanted '{want}' (mismatch)"
));
progress(
format!("Playing {name} — not {want}"),
OverlayAttentionTone::Neutral,
);
AutomateOutcome {
success: true,
summary: format!("Playing '{query}' in Music."),
summary: format!(
"Now playing '{name}' by '{got}', not '{query}' by '{want}'. {}Re-running this search won't surface a different result — try the user's Library, or ask them to confirm the artist.",
candidates_phrase(&candidates),
),
steps,
}
}
None => AutomateOutcome {
success: true,
summary: format!("Started '{query}' in Music (playback unverified)."),
steps,
},
// #3: verified the actual track (right artist, or none requested).
(_, Some((name, got))) => {
steps.push(format!("verify: now playing '{name}' by '{got}' ✓"));
progress(format!("Playing {name}"), OverlayAttentionTone::Success);
AutomateOutcome {
success: true,
summary: format!("Playing '{name}' by '{got}' in Music."),
steps,
}
}
// #3/#4: can't read the track. Never claim "Playing X" — and if we only
// pressed an artist/album element (which just navigates), say so.
(_, None) if !pressed_song => {
steps.push("verify: pressed an artist/album element (navigation only)".to_string());
AutomateOutcome {
success: true,
summary: format!(
"Opened an artist/album page for '{query}' but no specific track started — pressing that only navigates. {}Open a specific song to play it.",
candidates_phrase(&candidates),
),
steps,
}
}
// Song row pressed, but the backend can't confirm the track (non-macOS).
(_, None) => {
let unverified = matches!(verified, None);
steps.push(if unverified {
"verify: playback unverified".to_string()
} else {
"verify: playing ✓ (track name unknown)".to_string()
});
if !unverified {
progress(format!("Playing {query}"), OverlayAttentionTone::Success);
}
AutomateOutcome {
success: true,
summary: if unverified {
format!("Started '{query}' in Music (playback unverified).")
} else {
format!("Playing '{query}' in Music.")
},
steps,
}
}
}
}
/// "Results seen: a, b, c. " — or empty when nothing was captured. Lets failure
/// responses show the agent/user the actual candidates to choose from.
fn candidates_phrase(cands: &[String]) -> String {
if cands.is_empty() {
String::new()
} else {
format!("Results seen: {}. ", cands.join(", "))
}
}
/// Build the "no match" response: list what was found (if anything) and steer
/// the agent away from blindly repeating the identical search.
fn no_match_message(query: &str, want_artist: &Option<String>, els: &[AXElement]) -> String {
let cands = candidate_labels(els);
let by = want_artist
.as_deref()
.map(|a| format!(" by '{a}'"))
.unwrap_or_default();
if cands.is_empty() {
format!(
"No song results found for '{query}'{by} — the search may not have loaded, or the track isn't in the catalog/library. Don't repeat this exact search; try the user's Library or ask them to confirm the title/artist."
)
} else {
format!(
"Couldn't find '{query}'{by}. Results seen: {}. Re-running this search won't help — pick the closest match, try the Library, or ask the user.",
cands.join(", ")
)
}
}
@@ -423,17 +621,73 @@ mod unit {
}
#[test]
fn pick_row_prefers_exact_then_token() {
use super::super::super::ax_interact::AXElement;
fn extract_artist_pulls_by_clause() {
assert_eq!(
extract_artist("play Numb by Linkin Park").as_deref(),
Some("Linkin Park")
);
// Trailing clause is cut.
assert_eq!(
extract_artist("play Highway to Hell by AC/DC in Apple Music").as_deref(),
Some("AC/DC")
);
assert_eq!(
extract_artist("search for \"Numb\" by Linkin Park and play it").as_deref(),
Some("Linkin Park")
);
// No "by" clause → None.
assert_eq!(extract_artist("play Numb"), None);
}
#[test]
fn artist_matches_is_loose() {
assert!(artist_matches("Linkin Park", "Linkin Park"));
assert!(artist_matches("Linkin Park", "Linkin Park feat. Jay-Z"));
assert!(artist_matches("ac/dc", "AC/DC"));
assert!(!artist_matches("Linkin Park", "Tom Odell"));
}
#[test]
fn pick_row_prefers_song_row_over_artist() {
// Token match (query has extra "AC/DC" the row label lacks).
let els = vec![
AXElement::new("AXCell", "Highway to Hell"),
AXElement::new("AXButton", "Play"),
];
// Token match (query has extra "AC/DC" the row label lacks).
assert_eq!(
pick_row(&els, "Highway to Hell AC/DC").as_deref(),
Some("Highway to Hell")
);
let p = pick_row(&els, "Highway to Hell AC/DC").unwrap();
assert_eq!(p.label, "Highway to Hell");
assert!(p.is_song);
// An artist AXButton and a song AXCell both token-match "Linkin Park
// Numb" — the SONG row must win (the live bug pressed the artist).
let els2 = vec![
AXElement::new("AXButton", "LINKIN PARK"),
AXElement::new("AXCell", "Numb"),
];
let p2 = pick_row(&els2, "Linkin Park Numb").unwrap();
assert_eq!(p2.label, "Numb");
assert!(p2.is_song);
// Only an artist element is present → chosen, but flagged non-song.
let els3 = vec![AXElement::new("AXButton", "LINKIN PARK")];
let p3 = pick_row(&els3, "Linkin Park Numb").unwrap();
assert_eq!(p3.label, "LINKIN PARK");
assert!(!p3.is_song);
}
#[test]
fn no_match_message_lists_candidates_and_warns() {
let els = vec![
AXElement::new("AXCell", "Numb - Marshmello & Khalid"),
AXElement::new("AXCell", "Numb - Tom Odell"),
AXElement::new("AXButton", "Play"), // not a song row → excluded
];
let m = no_match_message("Numb Linkin Park", &Some("Linkin Park".into()), &els);
assert!(m.contains("Marshmello") && m.contains("Tom Odell"), "{m}");
assert!(m.to_lowercase().contains("won't help"), "{m}");
// Empty results → still actionable, no fake candidate list.
let m2 = no_match_message("Numb", &None, &[]);
assert!(m2.to_lowercase().contains("don't repeat"), "{m2}");
}
}
+241 -10
View File
@@ -71,6 +71,9 @@ pub struct Action {
/// Natural-language target for `vision_click` (e.g. "the green Call button").
#[serde(default)]
pub description: String,
/// Key chord / single key for `hotkey` (e.g. `["Cmd","L"]`, `["/"]`).
#[serde(default)]
pub keys: Vec<String>,
/// Final message for `done` / `fail`.
#[serde(default)]
pub summary: String,
@@ -112,6 +115,25 @@ pub trait AutomateBackend: Send + Sync {
/// Open a URL / URI-scheme (e.g. `music://…search?term=…`) via the OS opener.
/// Used by deterministic app fast-paths; the general loop does not call it.
async fn open_url(&self, url: &str) -> Result<String, String>;
/// Open a URL in a **specific** app (e.g. a chosen browser) so navigation
/// lands in the app the user named — `open_url` uses the *default* handler,
/// which would send a `https://` link to whatever the default browser is.
/// Default delegates to [`open_url`](Self::open_url) so non-browser backends
/// stay correct. Used by the browser fast-path.
async fn open_url_in_app(&self, _app: &str, url: &str) -> Result<String, String> {
self.open_url(url).await
}
/// Send a keyboard chord (`["Cmd","L"]`) or a single key (`["/"]`) to the
/// frontmost app. Lets fast-paths and the loop drive app shortcuts (focus
/// the address bar, YouTube `/` search, `k`/space play-pause) instead of
/// hunting AX labels. Default errors so input-less backends can't actuate.
async fn key(&self, _keys: &[String]) -> Result<String, String> {
Err("keyboard unsupported by this backend".to_string())
}
/// Type literal text into the frontmost app. Default errors (see [`key`]).
async fn type_text(&self, _text: &str) -> Result<String, String> {
Err("typing unsupported by this backend".to_string())
}
/// Best-effort: is media currently playing? `None` when the backend can't
/// tell (non-macOS, or not applicable). Media fast-paths use this to confirm
/// an action *actually started playback* rather than just succeeding at the
@@ -119,6 +141,14 @@ pub trait AutomateBackend: Send + Sync {
async fn verify_playing(&self) -> Option<bool> {
None
}
/// The currently-playing track as `(name, artist)`, if the backend can read
/// it. Used by the Music fast-path to confirm it played the *right* track
/// (the AX row label carries only the title, so "Numb" can resolve to the
/// wrong artist — see tracker §1.x). `None` when unknown (non-macOS, nothing
/// playing, or not applicable).
async fn now_playing(&self) -> Option<(String, String)> {
None
}
/// Capture the target app's window + the geometry needed to map a click
/// from image pixels to screen points. Used by the `vision_click` fallback
/// for apps with no usable accessibility tree (Electron/Chromium). Default
@@ -183,17 +213,21 @@ fn system_prompt() -> String {
\n\
Respond with EXACTLY ONE JSON object and nothing else:\n\
{\"thought\":\"...\",\"action\":\"<verb>\",\"app\":\"<optional>\",\
\"filter\":\"...\",\"label\":\"...\",\"value\":\"...\",\"summary\":\"...\"}\n\
\"filter\":\"...\",\"label\":\"...\",\"value\":\"...\",\"keys\":[],\"summary\":\"...\"}\n\
\n\
Verbs:\n\
• launch — open the app (use first if it isn't showing any elements)\n\
• list — re-read elements; set `filter` to a substring to narrow them\n\
• press — activate the element whose label matches `label`\n\
• set_value — type `value` into the field matching `label` (omit label = first field)\n\
• hotkey — send an app keyboard shortcut; put the chord in `keys` (modifiers \
first, e.g. [\"Cmd\",\"L\"] to focus a browser address bar, [\"Cmd\",\"T\"] new tab) \
or a single key (e.g. [\"/\"] to focus YouTube search, [\"k\"] play/pause, [\"f\"] \
fullscreen). Prefer a known shortcut over hunting labels or clicking.\n\
• vision_click — click an element by sight; put a short `description` of the \
target (e.g. 'the green Call button'). Use this when the element list is \
EMPTY or missing your target — common for Electron apps (Slack, Discord, \
VS Code) that expose no accessibility tree.\n\
EMPTY or missing your target — common for Electron/Chromium apps (browsers, \
Slack, Discord, VS Code) that expose no accessibility tree.\n\
• done — goal achieved; put a short result in `summary`\n\
• fail — goal cannot be achieved; explain in `summary`\n\
\n\
@@ -203,8 +237,11 @@ fn system_prompt() -> String {
(e.g. open a song, THEN press its 'Play'). After such a press, `list` again \
to see the new screen.\n\
- Prefer an exact label match. Keep `filter` specific so the snapshot stays small.\n\
- If the app shows NO elements, prefer `vision_click` with a clear \
`description` over guessing labels.\n\
- For browsers and web apps, prefer `hotkey` for navigation and media control \
(address bar, search focus, play/pause/next) — it's faster and more reliable \
than clicking, and works even when the accessibility tree is empty.\n\
- If the app shows NO elements, prefer `hotkey` (if a known shortcut applies) \
or `vision_click` with a clear `description` over guessing labels.\n\
- Output JSON only — no prose, no code fences."
.to_string()
}
@@ -300,6 +337,10 @@ pub async fn run(
// instead of burning the whole step budget.
let mut last_sig = String::new();
let mut repeat_count = 0u32;
// Most recent rendered snapshot — surfaced in terminal failure responses so
// the agent sees what was actually on screen (instead of a bare "budget
// exhausted"), and can pick a real label next time.
let mut last_snapshot = String::new();
for step in 0..opts.step_budget {
// ── perceive ──
@@ -310,6 +351,7 @@ pub async fn run(
format!("(perceive error: {e})")
}
};
last_snapshot = snapshot.clone();
// ── decide ──
let user = format!(
@@ -361,8 +403,12 @@ pub async fn run(
// ── no-progress guard ──
if !matches!(action.action.as_str(), "done" | "fail") {
let sig = format!(
"{}|{}|{}|{}",
action.action, action.label, action.filter, action.description
"{}|{}|{}|{}|{}",
action.action,
action.label,
action.filter,
action.description,
action.keys.join("+")
);
if sig == last_sig {
repeat_count += 1;
@@ -378,7 +424,11 @@ pub async fn run(
action.action
));
return AutomateOutcome::fail(
"Got stuck repeating the same action with no progress.",
format!(
"Stuck repeating '{}' with no progress — that action isn't advancing the goal.{} Switch tactics: pick a specific label from the screen, take a screenshot + vision_click, or use a keyboard shortcut.",
action.action,
screen_hint(&last_snapshot),
),
steps,
);
}
@@ -455,6 +505,19 @@ pub async fn run(
}
backend.settle(target_app).await;
}
"hotkey" => {
if action.keys.is_empty() {
steps.push("hotkey skipped: no keys".to_string());
continue;
}
let combo = action.keys.join("+");
progress(format!("Pressing {combo}"), OverlayAttentionTone::Accent);
match backend.key(&action.keys).await {
Ok(msg) => steps.push(format!("hotkey: {msg}")),
Err(e) => steps.push(format!("hotkey FAILED: {e}")),
}
backend.settle(target_app).await;
}
"vision_click" => {
let description = action.description.trim();
if description.is_empty() {
@@ -527,13 +590,55 @@ pub async fn run(
log::info!("{LOG_PREFIX} step budget ({}) exhausted", opts.step_budget);
AutomateOutcome::fail(
format!(
"Step budget ({}) exhausted before the goal was confirmed complete.",
opts.step_budget
"Step budget ({}) exhausted before the goal was confirmed complete.{} Try a different approach (a screenshot/vision_click, a known keyboard shortcut, or a more specific filter) — repeating the same steps won't help.",
opts.step_budget,
screen_hint(&last_snapshot),
),
steps,
)
}
/// A compact " On screen: [role] a, [role] b, …" hint built from the last
/// rendered snapshot, for failure responses. Empty when there's nothing useful.
fn screen_hint(snapshot: &str) -> String {
let labels: Vec<&str> = snapshot
.lines()
.map(str::trim)
.filter(|l| l.starts_with('[') || l.starts_with("• ["))
.take(10)
.collect();
if labels.is_empty() {
String::new()
} else {
format!(" On screen: {}.", labels.join("; "))
}
}
/// Map a browser **display name** (as resolved by the browser fast-path —
/// `"Google Chrome"`, `"Brave Browser"`, …) to the token the Windows shell
/// `start` verb resolves via the `App Paths` registry. `None` for browsers that
/// don't exist on Windows (Safari/Arc) or any unrecognized name, so the caller
/// falls back to the default URL handler. Matched case-insensitively by
/// substring so aliases ("Chrome", "Microsoft Edge") all resolve.
#[cfg(target_os = "windows")]
pub(crate) fn windows_browser_launch_token(app: &str) -> Option<&'static str> {
let a = app.to_lowercase();
// Order matters: check the more specific names first ("microsoft edge"
// contains neither "chrome" nor "firefox", but keep edge before a bare
// "chrome" check anyway for clarity).
if a.contains("brave") {
Some("brave")
} else if a.contains("edge") {
Some("msedge")
} else if a.contains("firefox") {
Some("firefox")
} else if a.contains("chrome") || a.contains("chromium") {
Some("chrome")
} else {
None
}
}
/// Production backend: real AX primitives + a fast LLM for decisions.
pub struct RealBackend {
config: crate::openhuman::config::Config,
@@ -664,6 +769,84 @@ impl AutomateBackend for RealBackend {
}
}
async fn open_url_in_app(&self, app: &str, url: &str) -> Result<String, String> {
// macOS: `open -a "<app>" "<url>"` both launches/foregrounds the named
// app AND opens the URL in it — exactly the deterministic browser nav we
// want (no address-bar typing, no AX).
#[cfg(target_os = "macos")]
{
match tokio::process::Command::new("open")
.arg("-a")
.arg(app)
.arg(url)
.output()
.await
{
Ok(o) if o.status.success() => Ok(format!("Opened {url} in {app}")),
Ok(o) => Err(format!(
"open -a {app} exited {}: {}",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
)),
Err(e) => Err(format!("failed to launch opener: {e}")),
}
}
// Windows: the shell `start` verb resolves a browser by its registered
// App Paths token (`chrome`, `msedge`, `firefox`, `brave`, …) and opens
// the URL in it. When the browser is already running this lands in a NEW
// TAB of the existing window — so the deterministic fast-path does NOT
// pile up windows (the live bug: each re-delegation `launch_app`-ed Chrome
// again → ~10 windows). Falls back to the default handler when the named
// browser has no known token (e.g. Safari/Arc, which aren't on Windows).
#[cfg(target_os = "windows")]
{
let Some(token) = windows_browser_launch_token(app) else {
log::info!(
"[automate] open_url_in_app: no Windows token for {app:?}; using default handler"
);
return self.open_url(url).await;
};
// `cmd /C start "" <token> "<url>"` — the empty "" is `start`'s title
// arg (required so a quoted token isn't mistaken for the title). The
// URL is app-controlled (built by the fast-path), never user free-text.
match tokio::process::Command::new("cmd")
.args(["/C", "start", "", token, url])
.output()
.await
{
Ok(o) if o.status.success() => Ok(format!("Opened {url} in {app}")),
Ok(o) => {
// `start` failed (token not registered?) — best-effort fall back.
log::warn!(
"[automate] open_url_in_app: start {token} exited {}: {}; falling back",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
);
self.open_url(url).await
}
Err(e) => Err(format!("failed to launch opener: {e}")),
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = app;
self.open_url(url).await
}
}
async fn key(&self, keys: &[String]) -> Result<String, String> {
use crate::openhuman::tools::implementations::computer::keyboard;
match keys.len() {
0 => Err("no keys provided".to_string()),
1 => keyboard::run_key(&keys[0]).await,
_ => keyboard::run_hotkey(keys).await,
}
}
async fn type_text(&self, text: &str) -> Result<String, String> {
crate::openhuman::tools::implementations::computer::keyboard::run_type_text(text).await
}
async fn verify_playing(&self) -> Option<bool> {
// macOS: ask Apple Music for ground-truth player state. Other OSes can't
// verify this way → None (fast-path treats None as best-effort).
@@ -683,6 +866,34 @@ impl AutomateBackend for RealBackend {
}
}
async fn now_playing(&self) -> Option<(String, String)> {
// macOS: ask Apple Music for the current track's name + artist. We join
// them with a tab (unlikely in titles) so we can split unambiguously.
#[cfg(target_os = "macos")]
{
let script = "tell application \"Music\" to try
set t to current track
return (name of t) & \"\\t\" & (artist of t)
end try";
let out = tokio::process::Command::new("osascript")
.args(["-e", script])
.output()
.await
.ok()?;
let line = String::from_utf8_lossy(&out.stdout);
let line = line.trim();
if line.is_empty() {
return None;
}
let (name, artist) = line.split_once('\t')?;
Some((name.trim().to_string(), artist.trim().to_string()))
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
async fn settle(&self, app: &str) {
// M2: poll the element count until the UI stops changing (≤2s), instead
// of a blind fixed wait. Removes the timing-race class (tracker §1.11/
@@ -699,3 +910,23 @@ impl AutomateBackend for RealBackend {
#[cfg(test)]
#[path = "automate_tests.rs"]
mod tests;
#[cfg(all(test, target_os = "windows"))]
mod windows_tests {
use super::windows_browser_launch_token as tok;
#[test]
fn browser_display_names_map_to_start_tokens() {
assert_eq!(tok("Google Chrome"), Some("chrome"));
assert_eq!(tok("Brave Browser"), Some("brave"));
assert_eq!(tok("Microsoft Edge"), Some("msedge"));
assert_eq!(tok("Firefox"), Some("firefox"));
// Aliases / case-insensitive.
assert_eq!(tok("chrome"), Some("chrome"));
assert_eq!(tok("EDGE"), Some("msedge"));
// Not on Windows / unknown → None → caller uses the default handler.
assert_eq!(tok("Safari"), None);
assert_eq!(tok("Arc"), None);
assert_eq!(tok("Some Random App"), None);
}
}
+39 -1
View File
@@ -128,6 +128,13 @@ impl AutomateBackend for ScriptedBackend {
self.acts.lock().unwrap().push(format!("open_url:{url}"));
Ok(format!("Opened {url}"))
}
async fn key(&self, keys: &[String]) -> Result<String, String> {
self.acts
.lock()
.unwrap()
.push(format!("key:{}", keys.join("+")));
Ok(format!("Executed {}", keys.join("+")))
}
async fn settle(&self, _app: &str) {}
async fn wait(&self, _ms: u64) {}
}
@@ -190,6 +197,23 @@ async fn set_value_routes_app_override() {
);
}
#[tokio::test]
async fn hotkey_verb_sends_chord_to_backend() {
// The model can drive an app shortcut via the general loop instead of
// hunting AX labels. Use a neutral app/goal so no fast-path intercepts and
// the loop's `hotkey` verb is what runs.
let backend = ScriptedBackend::new(&[
r#"{"action":"hotkey","keys":["Cmd","L"]}"#,
r#"{"action":"done","summary":"sent shortcut"}"#,
]);
let out = run("Notes", "do a thing", &backend, opts(5)).await;
assert!(out.success, "expected success, got {out:?}");
assert_eq!(
backend.acts(),
vec!["launch:Notes", "key:Cmd+L"] // foreground-first, then the chord
);
}
#[tokio::test]
async fn budget_exhaustion_fails() {
// Script always lists → never done → budget guard ends the run.
@@ -197,6 +221,13 @@ async fn budget_exhaustion_fails() {
let out = run("Music", "never finishes", &backend, opts(3)).await;
assert!(!out.success);
assert!(out.summary.contains("budget"), "got: {}", out.summary);
// #2: surfaces what was on screen + a "try a different approach" steer.
assert!(out.summary.contains("On screen:"), "got: {}", out.summary);
assert!(
out.summary.to_lowercase().contains("different approach"),
"got: {}",
out.summary
);
}
#[tokio::test]
@@ -211,7 +242,14 @@ async fn no_progress_guard_aborts_repeated_action() {
let out = run("Photos", "do something", &backend, opts(10)).await;
assert!(!out.success);
assert!(
out.summary.contains("stuck repeating"),
out.summary.to_lowercase().contains("stuck repeating"),
"got: {}",
out.summary
);
// #1/#2: actionable — names the screen and tells it to switch tactics.
assert!(out.summary.contains("On screen:"), "got: {}", out.summary);
assert!(
out.summary.to_lowercase().contains("switch tactics"),
"got: {}",
out.summary
);
@@ -5,11 +5,13 @@ You are the desktop-control specialist. Launch apps and operate native desktop U
## Rules
- Use `launch_app` for explicit app-launch requests.
- Use `ax_interact` for semantic accessibility interactions.
- **Foreground each app at most ONCE per task.** If the app is already open (a prior step launched it, or the user says it's open), do NOT call `launch_app` again — repeated launches pile up duplicate windows. Re-launch only after a tool result explicitly reports the app isn't running.
- **Web browsers (Chrome, Edge, Brave, Firefox, Arc): use `automate`, not `ax_interact`.** To open a site, search, or play a video, call `automate` with the browser as the app and a plain-English goal — e.g. `automate{app:"Google Chrome", goal:"go to youtube.com and play a lofi music video"}`. It navigates deterministically by URL in one step. Do NOT type a URL into the address bar via `ax_interact`/`set_value`: Chromium exposes no page content to the accessibility tree (only browser chrome), and an address/search field set this way usually cannot be submitted — that path dead-ends and loops.
- Use `ax_interact` for semantic accessibility interactions in **native** (non-Chromium) apps.
- Always call `ax_interact` with `action:"list"` before `press` or `set_value`.
- Use `automate` for multi-step app workflows, such as playing a song in Music or sending a message in Slack.
- Before any keyboard or mouse action, foreground the target app with `launch_app`.
- Prefer `automate` or `ax_interact` first. If the accessibility tree is empty, stuck, or only shows menu-bar items, fall back to keyboard-driven control for Electron/Chromium apps.
- Use `automate` for multi-step app workflows: playing a song in Music, sending a message in Slack, or any browser navigation/search/playback (above).
- Before any keyboard or mouse action, foreground the target app with `launch_app` (subject to the once-per-task rule above).
- Prefer `automate` or `ax_interact` first. If the accessibility tree is empty, stuck, or only shows menu-bar items, fall back to keyboard-driven control for Electron/Chromium apps. **Do not retry the same failing approach repeatedly — if two attempts at a step fail, report it and stop rather than re-launching and re-trying in a loop.**
- Use `screenshot` plus `mouse` only when semantic or keyboard control cannot target the needed element.
- Never invent element labels. Act only on elements returned by `list` or clearly named by the user.
- Respect sensitive-app constraints and tool denials. Do not work around password managers, Keychain, System Settings, terminals, or other denied surfaces.
+26 -6
View File
@@ -11,7 +11,7 @@
//! ApprovalGate, and it refuses the sensitive-app denylist (password managers,
//! Keychain, System Settings, terminals) even on auto-approved turns.
use super::ax_interact::is_sensitive_app;
use super::ax_interact::{app_control_enabled, is_sensitive_app};
use crate::openhuman::accessibility::automate::{self, AutomateOptions, RealBackend};
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult};
use async_trait::async_trait;
@@ -121,12 +121,12 @@ impl Tool for AutomateTool {
)));
}
if !self.allow_mutations {
if !app_control_enabled(self.allow_mutations) {
log::warn!("[automate] refused: mutations disabled");
return Ok(ToolResult::error(
"App control isn't enabled yet. Turn on App Automation in \
Settings → Agent Access (it grants permission to control apps), \
then ask again. (Sets computer_control.ax_interact_mutations = true.)",
"App control isn't enabled yet. Grant Full access (or turn on App \
Automation) in Settings → Agent Access it grants permission to \
control apps — then ask again.",
));
}
@@ -194,13 +194,33 @@ mod tests {
#[tokio::test]
async fn refuses_when_mutations_disabled() {
// Pin a non-Full live policy so the Full-access bypass can't open the
// gate (other tests in this binary install a Full global policy).
let _env = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
{
use crate::openhuman::security::{live_policy, AutonomyLevel, SecurityPolicy};
use std::sync::Arc;
let ws = std::env::temp_dir().join("openhuman_automate_gate_test_ws");
live_policy::install(
Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
workspace_dir: ws.clone(),
..SecurityPolicy::default()
}),
ws.clone(),
ws,
);
}
let t = AutomateTool::new(false);
let r = t
.execute(json!({"app": "Music", "goal": "play a song"}))
.await
.unwrap();
assert!(r.is_error);
assert!(r.output().contains("ax_interact_mutations"));
// Gate closed → the refusal directs the user to Settings → Agent Access.
assert!(r.output().contains("Agent Access"));
}
#[tokio::test]
@@ -55,6 +55,25 @@ pub(crate) fn is_sensitive_app(app_name: &str) -> bool {
SENSITIVE_APPS.iter().any(|s| lower.contains(s))
}
/// Whether the agent may actuate app UI (`press` / `set_value` / `automate`).
///
/// Enabled when **either** the explicit opt-in
/// (`computer_control.ax_interact_mutations`, captured as `explicit_opt_in` at
/// tool-build time) is set, **or** the agent has been granted **Full** OS access
/// (autonomy level `Full`) in Settings → Agent Access. The autonomy level is
/// read from the **live** policy (`security::live_policy::current`), so granting
/// Full access mid-session takes effect immediately — the previous behaviour
/// gated only on the static config flag, so users who flipped Settings →
/// Agent Access to "Full" still saw "App control isn't enabled yet" because the
/// flag was untouched. `pub(crate)` so `automate` shares the identical gate.
pub(crate) fn app_control_enabled(explicit_opt_in: bool) -> bool {
use crate::openhuman::security::{live_policy, AutonomyLevel};
explicit_opt_in
|| live_policy::current()
.map(|p| matches!(p.autonomy, AutonomyLevel::Full))
.unwrap_or(false)
}
pub struct AxInteractTool {
/// When false, the mutating actions (`press` / `set_value`) are refused
/// with guidance to enable `computer_control.ax_interact_mutations`. The
@@ -229,14 +248,15 @@ impl Tool for AxInteractTool {
)));
}
// Mutating actions are opt-in. Read-only `list` is always allowed.
if mutating && !self.allow_mutations {
// Mutating actions are opt-in (or implied by Full OS access). Read-only
// `list` is always allowed.
if mutating && !app_control_enabled(self.allow_mutations) {
log::warn!("[ax_interact] refused: mutations disabled (action={action})");
return Ok(ToolResult::error(
"App control isn't enabled yet, so I can't press buttons or type into \
this app. Turn on App UI Control / App Automation in Settings → Agent \
Access, then ask again. (Reading the UI still works without it; sets \
computer_control.ax_interact_mutations = true.)",
this app. Grant Full access (or turn on App UI Control / App \
Automation) in Settings → Agent Access, then ask again. (Reading the \
UI still works without it.)",
));
}
@@ -351,6 +371,43 @@ impl Tool for AxInteractTool {
mod tests {
use super::*;
/// Force the process-global live policy to a given autonomy level so the
/// `app_control_enabled` Full-access bypass is deterministic. Other tests in
/// this binary (e.g. `security::live_policy`) install a Full global; callers
/// must hold `TEST_ENV_LOCK` while relying on the value they set here.
fn install_live_autonomy(level: crate::openhuman::security::AutonomyLevel) {
use crate::openhuman::security::{live_policy, SecurityPolicy};
use std::sync::Arc;
let ws = std::env::temp_dir().join("openhuman_ax_interact_gate_test_ws");
live_policy::install(
Arc::new(SecurityPolicy {
autonomy: level,
workspace_dir: ws.clone(),
..SecurityPolicy::default()
}),
ws.clone(),
ws,
);
}
#[test]
fn app_control_enabled_combines_optin_and_full_access() {
use crate::openhuman::security::AutonomyLevel;
let _env = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
// Explicit opt-in always enables, regardless of the live policy.
assert!(app_control_enabled(true));
// Supervised + no opt-in → gate closed.
install_live_autonomy(AutonomyLevel::Supervised);
assert!(!app_control_enabled(false));
// Full OS access opens the gate even without the explicit flag — this is
// the fix: granting "Full" in Settings → Agent Access now enables app
// control without separately flipping `ax_interact_mutations`.
install_live_autonomy(AutonomyLevel::Full);
assert!(app_control_enabled(false));
}
#[test]
fn name_and_permission() {
let tool = AxInteractTool::new(true);
@@ -416,6 +473,12 @@ mod tests {
#[tokio::test]
async fn refuses_mutations_when_disabled() {
// Pin a non-Full live policy for the test so the Full-access bypass can't
// open the gate (other tests in this binary install a Full global).
let _env = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
install_live_autonomy(crate::openhuman::security::AutonomyLevel::Supervised);
// mutations off → press/set_value blocked, but list still allowed past this guard.
let tool = AxInteractTool::new(false);
let press = tool
@@ -423,7 +486,8 @@ mod tests {
.await
.unwrap();
assert!(press.is_error);
assert!(press.output().contains("ax_interact_mutations"));
// Gate closed → the refusal points the user at Settings → Agent Access.
assert!(press.output().contains("Agent Access"));
}
#[tokio::test]
+134 -123
View File
@@ -97,6 +97,135 @@ fn is_modifier(key: &Key) -> bool {
matches!(key, Key::Control | Key::Shift | Key::Alt | Key::Meta)
}
// ── Shared execution helpers ────────────────────────────────────────────────
// These hold the validation + main-thread enigo dispatch once, so both the
// `keyboard` tool and the `automate` backend (`RealBackend::{key,type_text}`)
// drive synthetic input through a single code path. They return the raw
// `Result<String, String>`; the tool wraps it with `into_result`.
/// Type a literal string via the OS. Runs on the app main thread (macOS TSM
/// requirement, Change 1.15). Validates length like the `type` tool action.
pub(crate) async fn run_type_text(text: &str) -> Result<String, String> {
if text.is_empty() {
return Err("'text' cannot be empty".to_string());
}
if text.len() > MAX_TYPE_LENGTH {
return Err(format!(
"Text too long ({} chars). Maximum is {MAX_TYPE_LENGTH}.",
text.len()
));
}
let text = text.to_string();
let len = text.len();
run_input_on_main(move || {
let mut enigo = Enigo::new(&Settings::default())
.map_err(|e| format!("Failed to create enigo instance: {e}"))?;
enigo
.text(&text)
.map_err(|e| format!("text typing failed: {e}"))?;
Ok(format!("Typed {len} characters"))
})
.await
}
/// Tap a single key by name (e.g. "Enter", "/", "k"). Used for app shortcuts
/// like YouTube's `/` (focus search) or `k` (play/pause).
pub(crate) async fn run_key(key_name: &str) -> Result<String, String> {
let key = parse_key(key_name).ok_or_else(|| {
format!(
"Unknown key '{key_name}'. Use names like Enter, Tab, Escape, F1-F12, a-z, 0-9, Space, etc."
)
})?;
let key_name = key_name.to_string();
run_input_on_main(move || {
let mut enigo = Enigo::new(&Settings::default())
.map_err(|e| format!("Failed to create enigo instance: {e}"))?;
enigo
.key(key, Direction::Click)
.map_err(|e| format!("key press failed: {e}"))?;
Ok(format!("Pressed key '{key_name}'"))
})
.await
}
/// Execute a hotkey chord — modifiers first, then exactly one non-modifier
/// final key (e.g. `["Cmd","L"]`). Validates the modifier-first shape, then
/// presses in order and releases in reverse (even on error).
pub(crate) async fn run_hotkey(key_names: &[String]) -> Result<String, String> {
if key_names.is_empty() {
return Err("'keys' array cannot be empty".to_string());
}
if key_names.len() > 6 {
return Err("Too many keys in hotkey combination (max 6)".to_string());
}
if key_names.len() < 2 {
return Err(
"Hotkey requires at least one modifier and one final key (e.g. ['Ctrl', 'C'])"
.to_string(),
);
}
let mut keys: Vec<Key> = Vec::with_capacity(key_names.len());
for name in key_names {
let key =
parse_key(name).ok_or_else(|| format!("Unknown key '{name}' in hotkey combination"))?;
keys.push(key);
}
// Validate modifier-first: all but the last must be modifiers; the last
// must be a non-modifier.
let (modifiers, final_key) = keys.split_at(keys.len() - 1);
for (i, key) in modifiers.iter().enumerate() {
if !is_modifier(key) {
return Err(format!(
"Key '{}' at position {i} must be a modifier (Ctrl/Shift/Alt/Cmd). Non-modifier keys must be last.",
key_names[i]
));
}
}
if is_modifier(&final_key[0]) {
return Err(format!(
"Last key '{}' cannot be a modifier. Hotkey must end with a non-modifier key (e.g. 'C', 'Enter').",
key_names.last().unwrap()
));
}
let combo_desc = key_names.join("+");
run_input_on_main(move || {
let mut enigo = Enigo::new(&Settings::default())
.map_err(|e| format!("Failed to create enigo instance: {e}"))?;
// Press keys in order, tracking which were pressed so we can release
// them on error.
let mut pressed_keys: Vec<Key> = Vec::with_capacity(keys.len());
let press_result: Result<(), String> = (|| {
for key in &keys {
enigo
.key(*key, Direction::Press)
.map_err(|e| format!("key press failed for {key:?}: {e}"))?;
pressed_keys.push(*key);
std::thread::sleep(HOTKEY_INTER_KEY_DELAY);
}
Ok(())
})();
// Always release pressed keys in reverse, even on error.
for key in pressed_keys.iter().rev() {
if let Err(e) = enigo.key(*key, Direction::Release) {
tracing::warn!(
tool = "keyboard",
key = ?key,
error = %e,
"[computer] best-effort key release failed during cleanup"
);
}
}
press_result?;
Ok(format!("Executed hotkey: {combo_desc}"))
})
.await
}
#[async_trait]
impl Tool for KeyboardTool {
fn name(&self) -> &str {
@@ -183,57 +312,16 @@ impl Tool for KeyboardTool {
let text = args
.get("text")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("Missing 'text' for type action"))?
.to_string();
if text.is_empty() {
return Ok(ToolResult::error("'text' cannot be empty"));
}
if text.len() > MAX_TYPE_LENGTH {
return Ok(ToolResult::error(format!(
"Text too long ({} chars). Maximum is {MAX_TYPE_LENGTH}.",
text.len()
)));
}
let len = text.len();
into_result(
"type",
run_input_on_main(move || {
let mut enigo = Enigo::new(&Settings::default())
.map_err(|e| format!("Failed to create enigo instance: {e}"))?;
enigo
.text(&text)
.map_err(|e| format!("text typing failed: {e}"))?;
Ok(format!("Typed {len} characters"))
})
.await,
)
.ok_or_else(|| anyhow::anyhow!("Missing 'text' for type action"))?;
into_result("type", run_type_text(text).await)
}
"press" => {
let key_name = args
.get("key")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("Missing 'key' for press action"))?
.to_string();
let key = parse_key(&key_name).ok_or_else(|| {
anyhow::anyhow!("Unknown key '{key_name}'. Use names like Enter, Tab, Escape, F1-F12, a-z, 0-9, Space, etc.")
})?;
into_result(
"press",
run_input_on_main(move || {
let mut enigo = Enigo::new(&Settings::default())
.map_err(|e| format!("Failed to create enigo instance: {e}"))?;
enigo
.key(key, Direction::Click)
.map_err(|e| format!("key press failed: {e}"))?;
Ok(format!("Pressed key '{key_name}'"))
})
.await,
)
.ok_or_else(|| anyhow::anyhow!("Missing 'key' for press action"))?;
into_result("press", run_key(key_name).await)
}
"hotkey" => {
@@ -251,84 +339,7 @@ impl Tool for KeyboardTool {
key_names.push(s.to_string());
}
if key_names.is_empty() {
return Ok(ToolResult::error("'keys' array cannot be empty"));
}
if key_names.len() > 6 {
return Ok(ToolResult::error(
"Too many keys in hotkey combination (max 6)",
));
}
if key_names.len() < 2 {
return Ok(ToolResult::error(
"Hotkey requires at least one modifier and one final key (e.g. ['Ctrl', 'C'])",
));
}
// Parse all key names into Key values.
let mut keys: Vec<Key> = Vec::with_capacity(key_names.len());
for name in &key_names {
let key = parse_key(name).ok_or_else(|| {
anyhow::anyhow!("Unknown key '{name}' in hotkey combination")
})?;
keys.push(key);
}
// Validate modifier-first pattern: all keys except the last
// must be modifiers, and the last must be a non-modifier.
let (modifiers, final_key) = keys.split_at(keys.len() - 1);
for (i, key) in modifiers.iter().enumerate() {
if !is_modifier(key) {
return Ok(ToolResult::error(format!(
"Key '{}' at position {i} must be a modifier (Ctrl/Shift/Alt/Cmd). Non-modifier keys must be last.",
key_names[i]
)));
}
}
if is_modifier(&final_key[0]) {
return Ok(ToolResult::error(format!(
"Last key '{}' cannot be a modifier. Hotkey must end with a non-modifier key (e.g. 'C', 'Enter').",
key_names.last().unwrap()
)));
}
let combo_desc = key_names.join("+");
into_result(
"hotkey",
run_input_on_main(move || {
let mut enigo = Enigo::new(&Settings::default())
.map_err(|e| format!("Failed to create enigo instance: {e}"))?;
// Press keys in order, tracking which were pressed so we
// can release them on error.
let mut pressed_keys: Vec<Key> = Vec::with_capacity(keys.len());
let press_result: Result<(), String> = (|| {
for key in &keys {
enigo
.key(*key, Direction::Press)
.map_err(|e| format!("key press failed for {key:?}: {e}"))?;
pressed_keys.push(*key);
std::thread::sleep(HOTKEY_INTER_KEY_DELAY);
}
Ok(())
})();
// Always release pressed keys in reverse, even on error.
for key in pressed_keys.iter().rev() {
if let Err(e) = enigo.key(*key, Direction::Release) {
tracing::warn!(
tool = "keyboard",
key = ?key,
error = %e,
"[computer] best-effort key release failed during cleanup"
);
}
}
press_result?;
Ok(format!("Executed hotkey: {combo_desc}"))
})
.await,
)
into_result("hotkey", run_hotkey(&key_names).await)
}
other => Ok(ToolResult::error(format!(
+3 -1
View File
@@ -1,7 +1,9 @@
mod automate;
mod ax_interact;
mod human_path;
mod keyboard;
// `pub(crate)` so the automate backend can reuse the shared input helpers
// (`run_hotkey`/`run_key`/`run_type_text`) without going through the tool.
pub(crate) mod keyboard;
mod main_thread;
mod mouse;
+5
View File
@@ -1637,6 +1637,11 @@ fn uninstall_resolves_agents_skills_legacy_root() {
assert!(!dir.exists(), "uninstall should remove the dir");
}
// Unix-only: exercises `std::os::unix::fs::symlink`. Windows symlink creation
// uses a different API and requires elevated privileges / Developer Mode, so
// this case is gated off there (the Windows lib test binary otherwise fails to
// compile on this line).
#[cfg(unix)]
#[test]
fn symlinked_manifest_file_is_rejected() {
// `exists()` follows symlinks; a manifest pointed at an external file would