mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-08-05 13:40:17 +00:00
Settle the Playwright-grade reliability contract in agent-desktop-core before the Windows/Linux adapters are built, so they inherit it instead of redesigning it. Every command now observes, waits, verifies, and reports honestly instead of firing blindly. Highlights: capability-supertrait split of PlatformAdapter with not_supported() defaults; canonical role/state vocabulary with live `is --property visible`; display enumeration (`list-displays`) and honest `--screen` with scale factor; truthful Automation permission; `native_id` identity spine; window-id-first resolution; serializable `LocatorQuery` with live `find`; default-on auto-wait before every ref action; three-way `hit_test` occlusion gate; `scroll_into_view` in core; core accessible-name precedence; typed `ActionStep` delivery tier; `ProcessState` and `APP_UNRESPONSIVE`; `LaunchOptions`; baseline-diff desktop signals (`wait --event`); typed clipboard (`Text`/`Image`/`FileUrls`); mouse modifier chords and `mouse-wheel`. Hardened through a 35-reviewer pass with independent validation and a green live e2e gate (109/0), plus a head-vs-main performance comparison harness. BREAKING CHANGE: default-on auto-wait changes the timing of every previously-untouched ref-action call (bounded 5000 ms default; `--timeout-ms 0` restores single-shot). `ENVELOPE_VERSION` is now `2.1` (adds the `APP_UNRESPONSIVE` code and process state in error details). FFI ABI major is `3` (append-only struct evolution; `wait --event` is intentionally not exposed over FFI). The legacy string clipboard API is removed in favor of typed content. `key-down`/`key-up` fail closed until daemon-owned held input exists. `close-app` verifies termination and the osascript fallback path is removed. `--text` matching is subtree containment: `find --text X --first` returns the outermost matching container.
156 lines
4.5 KiB
Rust
156 lines
4.5 KiB
Rust
use agent_desktop_core::{
|
|
ActionOps, AdapterError, Deadline, ErrorCode, InputOps, InteractionLease, ObservationOps,
|
|
SystemOps, WindowInfo, WindowOp, WindowState,
|
|
};
|
|
use std::sync::Mutex;
|
|
|
|
#[path = "../support/noop_ops.rs"]
|
|
mod noop_ops;
|
|
|
|
struct WindowIdentityAdapter {
|
|
windows: Vec<WindowInfo>,
|
|
last_window_op_id: Mutex<Option<String>>,
|
|
}
|
|
|
|
impl ObservationOps for WindowIdentityAdapter {}
|
|
|
|
impl ActionOps for WindowIdentityAdapter {}
|
|
|
|
impl InputOps for WindowIdentityAdapter {}
|
|
|
|
impl SystemOps for WindowIdentityAdapter {
|
|
fn acquire_interaction_lease(
|
|
&self,
|
|
deadline: Deadline,
|
|
) -> Result<InteractionLease, AdapterError> {
|
|
InteractionLease::guarded(deadline, ())
|
|
}
|
|
|
|
fn resolve_window_strict(
|
|
&self,
|
|
win: &WindowInfo,
|
|
_deadline: Deadline,
|
|
) -> Result<WindowInfo, AdapterError> {
|
|
let live = self
|
|
.windows
|
|
.iter()
|
|
.find(|candidate| candidate.id == win.id)
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
AdapterError::new(
|
|
ErrorCode::WindowNotFound,
|
|
format!("Window '{}' not found", win.id),
|
|
)
|
|
})?;
|
|
if live.pid != win.pid || live.process_instance != win.process_instance {
|
|
return Err(AdapterError::new(
|
|
ErrorCode::WindowNotFound,
|
|
format!("Window '{}' identity mismatch", win.id),
|
|
));
|
|
}
|
|
if !win.title.is_empty() && live.title != win.title {
|
|
return Err(AdapterError::new(
|
|
ErrorCode::WindowNotFound,
|
|
format!("Window '{}' identity mismatch", win.id),
|
|
));
|
|
}
|
|
Ok(live)
|
|
}
|
|
|
|
fn window_op(
|
|
&self,
|
|
win: &WindowInfo,
|
|
_op: WindowOp,
|
|
lease: &InteractionLease,
|
|
) -> Result<(), AdapterError> {
|
|
let resolved = self.resolve_window_strict(win, lease.deadline())?;
|
|
*self.last_window_op_id.lock().unwrap() = Some(resolved.id);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn untitled(id: &str, pid: u32) -> WindowInfo {
|
|
WindowInfo {
|
|
id: id.into(),
|
|
title: "Untitled".into(),
|
|
app: "TextEdit".into(),
|
|
pid: agent_desktop_core::ProcessId::new(pid),
|
|
process_instance: Some(format!("contract-process-{pid}")),
|
|
bounds: None,
|
|
state: WindowState::default(),
|
|
}
|
|
}
|
|
|
|
fn lease() -> InteractionLease {
|
|
InteractionLease::guarded(Deadline::standard().unwrap(), ()).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn id_addressed_window_op_targets_matching_id_not_first_title_match() {
|
|
let adapter = WindowIdentityAdapter {
|
|
windows: vec![untitled("w-1", 10), untitled("w-2", 10)],
|
|
last_window_op_id: Mutex::new(None),
|
|
};
|
|
let target = untitled("w-2", 10);
|
|
|
|
SystemOps::window_op(&adapter, &target, WindowOp::Minimize, &lease()).unwrap();
|
|
|
|
assert_eq!(
|
|
*adapter.last_window_op_id.lock().unwrap(),
|
|
Some("w-2".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_id_returns_window_not_found() {
|
|
let adapter = WindowIdentityAdapter {
|
|
windows: vec![untitled("w-1", 10)],
|
|
last_window_op_id: Mutex::new(None),
|
|
};
|
|
let target = untitled("w-999", 10);
|
|
|
|
let err = SystemOps::window_op(&adapter, &target, WindowOp::Minimize, &lease()).unwrap_err();
|
|
|
|
assert_eq!(err.code, ErrorCode::WindowNotFound);
|
|
}
|
|
|
|
#[test]
|
|
fn recycled_id_with_wrong_pid_fails_closed() {
|
|
let adapter = WindowIdentityAdapter {
|
|
windows: vec![untitled("w-100", 99)],
|
|
last_window_op_id: Mutex::new(None),
|
|
};
|
|
let target = untitled("w-100", 10);
|
|
|
|
let err = adapter
|
|
.resolve_window_strict(&target, Deadline::standard().unwrap())
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code, ErrorCode::WindowNotFound);
|
|
}
|
|
|
|
#[test]
|
|
fn recycled_id_with_same_pid_and_new_process_instance_fails_closed() {
|
|
let mut live = untitled("w-100", 10);
|
|
live.process_instance = Some("replacement-process".into());
|
|
let adapter = WindowIdentityAdapter {
|
|
windows: vec![live],
|
|
last_window_op_id: Mutex::new(None),
|
|
};
|
|
let target = untitled("w-100", 10);
|
|
|
|
let err = adapter
|
|
.resolve_window_strict(&target, Deadline::standard().unwrap())
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code, ErrorCode::WindowNotFound);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_window_strict_default_is_not_supported() {
|
|
let err = noop_ops::NoopAdapter
|
|
.resolve_window_strict(&untitled("w-1", 10), Deadline::standard().unwrap())
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code, ErrorCode::PlatformNotSupported);
|
|
}
|