agent-desktop/crates/core/src/error.rs
2026-06-17 13:43:20 -07:00

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"));
}
}