mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-08-09 00:17:27 +00:00
281 lines
8.8 KiB
Rust
281 lines
8.8 KiB
Rust
use serde::Serialize;
|
|
use serde_json::Value;
|
|
use thiserror::Error;
|
|
|
|
use crate::interaction_policy::InteractionPolicy;
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
pub enum ErrorCode {
|
|
PermDenied,
|
|
ElementNotFound,
|
|
AppNotFound,
|
|
ActionFailed,
|
|
ActionNotSupported,
|
|
StaleRef,
|
|
AmbiguousTarget,
|
|
WindowNotFound,
|
|
PlatformNotSupported,
|
|
Timeout,
|
|
InvalidArgs,
|
|
NotificationNotFound,
|
|
SnapshotNotFound,
|
|
PolicyDenied,
|
|
Internal,
|
|
}
|
|
|
|
impl ErrorCode {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
ErrorCode::PermDenied => "PERM_DENIED",
|
|
ErrorCode::ElementNotFound => "ELEMENT_NOT_FOUND",
|
|
ErrorCode::AppNotFound => "APP_NOT_FOUND",
|
|
ErrorCode::ActionFailed => "ACTION_FAILED",
|
|
ErrorCode::ActionNotSupported => "ACTION_NOT_SUPPORTED",
|
|
ErrorCode::StaleRef => "STALE_REF",
|
|
ErrorCode::AmbiguousTarget => "AMBIGUOUS_TARGET",
|
|
ErrorCode::WindowNotFound => "WINDOW_NOT_FOUND",
|
|
ErrorCode::PlatformNotSupported => "PLATFORM_NOT_SUPPORTED",
|
|
ErrorCode::Timeout => "TIMEOUT",
|
|
ErrorCode::InvalidArgs => "INVALID_ARGS",
|
|
ErrorCode::NotificationNotFound => "NOTIFICATION_NOT_FOUND",
|
|
ErrorCode::SnapshotNotFound => "SNAPSHOT_NOT_FOUND",
|
|
ErrorCode::PolicyDenied => "POLICY_DENIED",
|
|
ErrorCode::Internal => "INTERNAL",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Error, Clone)]
|
|
#[error("{message}")]
|
|
pub struct AdapterError {
|
|
pub code: ErrorCode,
|
|
pub message: String,
|
|
#[source]
|
|
pub source: Option<Box<SourceError>>,
|
|
pub suggestion: Option<String>,
|
|
pub platform_detail: Option<String>,
|
|
pub details: Option<Value>,
|
|
}
|
|
|
|
#[derive(Debug, Error, Clone)]
|
|
#[error("{0}")]
|
|
pub struct SourceError(pub String);
|
|
|
|
impl AdapterError {
|
|
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
|
Self {
|
|
code,
|
|
message: message.into(),
|
|
source: None,
|
|
suggestion: None,
|
|
platform_detail: None,
|
|
details: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_suggestion(mut self, s: impl Into<String>) -> Self {
|
|
self.suggestion = Some(s.into());
|
|
self
|
|
}
|
|
|
|
pub fn with_platform_detail(mut self, d: impl Into<String>) -> Self {
|
|
self.platform_detail = Some(d.into());
|
|
self
|
|
}
|
|
|
|
pub fn with_details(mut self, details: Value) -> Self {
|
|
self.details = Some(details);
|
|
self
|
|
}
|
|
|
|
pub fn stale_ref(ref_id: &str) -> Self {
|
|
Self::new(
|
|
ErrorCode::StaleRef,
|
|
format!("{ref_id} not found in current RefMap"),
|
|
)
|
|
.with_suggestion(
|
|
"Use the snapshot_id returned with this ref, or run 'snapshot' / 'snapshot --skeleton' again for fresh refs",
|
|
)
|
|
}
|
|
|
|
pub fn ambiguous_target(message: impl Into<String>) -> Self {
|
|
Self::new(ErrorCode::AmbiguousTarget, message)
|
|
.with_suggestion("Run 'snapshot' to refresh, then retry with a more specific ref")
|
|
}
|
|
|
|
pub fn not_supported(method: &str) -> Self {
|
|
Self::new(
|
|
ErrorCode::PlatformNotSupported,
|
|
format!("{method} is not supported on this platform"),
|
|
)
|
|
.with_suggestion("This platform adapter ships in Phase 2")
|
|
}
|
|
|
|
pub fn element_not_found(ref_id: &str) -> Self {
|
|
Self::new(
|
|
ErrorCode::ElementNotFound,
|
|
format!("Element {ref_id} could not be resolved"),
|
|
)
|
|
.with_suggestion("Run 'snapshot' to get fresh refs")
|
|
}
|
|
|
|
pub fn timeout(msg: impl Into<String>) -> Self {
|
|
Self::new(ErrorCode::Timeout, msg)
|
|
.with_suggestion("The target application may be busy or unresponsive")
|
|
}
|
|
|
|
pub fn notification_not_found(index: usize) -> Self {
|
|
Self::new(
|
|
ErrorCode::NotificationNotFound,
|
|
format!("Notification at index {index} not found"),
|
|
)
|
|
.with_suggestion("Notification may have been dismissed or expired. Run 'list-notifications' to see current notifications")
|
|
}
|
|
|
|
pub fn internal(msg: impl Into<String>) -> Self {
|
|
Self::new(ErrorCode::Internal, msg)
|
|
}
|
|
|
|
pub fn permission_denied() -> Self {
|
|
Self::new(
|
|
ErrorCode::PermDenied,
|
|
"Accessibility permission not granted",
|
|
)
|
|
.with_suggestion(
|
|
"Open System Settings > Privacy & Security > Accessibility and add the app that launches agent-desktop",
|
|
)
|
|
}
|
|
|
|
pub fn snapshot_not_found(snapshot_id: &str) -> Self {
|
|
Self::new(
|
|
ErrorCode::SnapshotNotFound,
|
|
format!("Snapshot '{snapshot_id}' not found"),
|
|
)
|
|
.with_suggestion("Run 'snapshot' again and retry with the returned snapshot_id; if you omitted --snapshot, use the same --session as the snapshot")
|
|
}
|
|
|
|
pub fn policy_denied(message: impl Into<String>) -> Self {
|
|
Self::new(ErrorCode::PolicyDenied, message).with_suggestion(
|
|
"Use an explicit mouse/focus command if physical interaction is intended",
|
|
)
|
|
}
|
|
|
|
pub fn policy_denied_for_policy(message: impl Into<String>, policy: InteractionPolicy) -> Self {
|
|
Self::new(ErrorCode::PolicyDenied, message)
|
|
.with_suggestion(policy_denied_suggestion(policy))
|
|
}
|
|
}
|
|
|
|
fn policy_denied_suggestion(policy: InteractionPolicy) -> &'static str {
|
|
if policy.allow_focus_steal && !policy.allow_cursor_move {
|
|
"Retry with --headed to permit cursor movement, or use an explicit mouse command if physical input is intended"
|
|
} else if !policy.allow_focus_steal && !policy.allow_cursor_move {
|
|
"Headless mode allows only accessibility-backed actions; refresh the snapshot or target an element with the needed semantic action"
|
|
} else {
|
|
"Use an explicit mouse/focus command if physical interaction is intended"
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum AppError {
|
|
#[error(transparent)]
|
|
Adapter(#[from] AdapterError),
|
|
|
|
#[error("IO error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
|
|
#[error("JSON error: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
|
|
#[error("{0}")]
|
|
Internal(String),
|
|
}
|
|
|
|
impl AppError {
|
|
pub fn code(&self) -> &str {
|
|
match self {
|
|
AppError::Adapter(e) => e.code.as_str(),
|
|
AppError::Io(_) | AppError::Json(_) | AppError::Internal(_) => "INTERNAL",
|
|
}
|
|
}
|
|
|
|
pub fn suggestion(&self) -> Option<&str> {
|
|
match self {
|
|
AppError::Adapter(e) => e.suggestion.as_deref(),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn stale_ref(ref_id: &str) -> Self {
|
|
AppError::Adapter(AdapterError::stale_ref(ref_id))
|
|
}
|
|
|
|
pub fn invalid_input(msg: impl Into<String>) -> Self {
|
|
AppError::Adapter(AdapterError::new(ErrorCode::InvalidArgs, msg))
|
|
}
|
|
|
|
pub fn invalid_input_with_suggestion(
|
|
msg: impl Into<String>,
|
|
suggestion: impl Into<String>,
|
|
) -> Self {
|
|
AppError::Adapter(
|
|
AdapterError::new(ErrorCode::InvalidArgs, msg).with_suggestion(suggestion),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn notification_not_found_error_has_correct_code() {
|
|
let err = AdapterError::notification_not_found(5);
|
|
assert_eq!(err.code, ErrorCode::NotificationNotFound);
|
|
assert!(err.message.contains("5"));
|
|
assert!(err.suggestion.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn error_code_serialization() {
|
|
let code = ErrorCode::NotificationNotFound;
|
|
let json = serde_json::to_string(&code).unwrap();
|
|
assert_eq!(json, "\"NOTIFICATION_NOT_FOUND\"");
|
|
}
|
|
|
|
#[test]
|
|
fn stale_ref_suggestion_mentions_skeleton() {
|
|
let err = AdapterError::stale_ref("@e7");
|
|
assert_eq!(err.code, ErrorCode::StaleRef);
|
|
assert!(err.message.contains("@e7"));
|
|
let suggestion = err
|
|
.suggestion
|
|
.as_deref()
|
|
.expect("stale_ref should carry a suggestion");
|
|
assert!(
|
|
suggestion.contains("skeleton"),
|
|
"stale-ref suggestion should mention skeleton refresh, got: {suggestion}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ambiguous_target_error_has_machine_readable_code() {
|
|
let err = AdapterError::ambiguous_target("2 candidates matched");
|
|
|
|
assert_eq!(err.code, ErrorCode::AmbiguousTarget);
|
|
assert_eq!(err.code.as_str(), "AMBIGUOUS_TARGET");
|
|
assert!(err.suggestion.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn policy_denied_suggestion_is_mode_aware() {
|
|
let headless =
|
|
AdapterError::policy_denied_for_policy("blocked", InteractionPolicy::headless());
|
|
assert!(!headless.suggestion.unwrap().contains("--headed"));
|
|
|
|
let focus_fallback =
|
|
AdapterError::policy_denied_for_policy("blocked", InteractionPolicy::focus_fallback());
|
|
assert!(focus_fallback.suggestion.unwrap().contains("--headed"));
|
|
}
|
|
}
|