use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const MICROAPP_ERROR_NOTIFY_METHOD: &str = "nexo/notify/microapp_error";
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MicroappErrorKind {
InitTimeout,
HandlerPanic,
Exit,
CapabilityDenied,
}
impl MicroappErrorKind {
pub fn as_wire_str(self) -> &'static str {
match self {
MicroappErrorKind::InitTimeout => "init_timeout",
MicroappErrorKind::HandlerPanic => "handler_panic",
MicroappErrorKind::Exit => "exit",
MicroappErrorKind::CapabilityDenied => "capability_denied",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MicroappError {
pub microapp_id: String,
pub kind: MicroappErrorKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub correlation_id: Option<String>,
pub occurred_at: DateTime<Utc>,
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stack_trace: Option<String>,
}
impl MicroappError {
pub fn new(
microapp_id: impl Into<String>,
kind: MicroappErrorKind,
summary: impl Into<String>,
) -> Self {
Self {
microapp_id: microapp_id.into(),
kind,
correlation_id: None,
occurred_at: Utc::now(),
summary: summary.into(),
stack_trace: None,
}
}
pub fn with_correlation_id(mut self, id: impl Into<String>) -> Self {
self.correlation_id = Some(id.into());
self
}
pub fn with_stack_trace(mut self, trace: impl Into<String>) -> Self {
self.stack_trace = Some(trace.into());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notify_method_constant_is_stable() {
assert_eq!(MICROAPP_ERROR_NOTIFY_METHOD, "nexo/notify/microapp_error");
}
#[test]
fn kind_serialises_snake_case() {
let json = serde_json::to_string(&MicroappErrorKind::InitTimeout).unwrap();
assert_eq!(json, "\"init_timeout\"");
let json = serde_json::to_string(&MicroappErrorKind::HandlerPanic).unwrap();
assert_eq!(json, "\"handler_panic\"");
let json = serde_json::to_string(&MicroappErrorKind::CapabilityDenied).unwrap();
assert_eq!(json, "\"capability_denied\"");
let json = serde_json::to_string(&MicroappErrorKind::Exit).unwrap();
assert_eq!(json, "\"exit\"");
}
#[test]
fn kind_wire_str_matches_serde() {
for k in [
MicroappErrorKind::InitTimeout,
MicroappErrorKind::HandlerPanic,
MicroappErrorKind::Exit,
MicroappErrorKind::CapabilityDenied,
] {
let serde_json = serde_json::to_string(&k).unwrap();
let trimmed = serde_json.trim_matches('"');
assert_eq!(trimmed, k.as_wire_str());
}
}
#[test]
fn microapp_error_round_trips_full_payload() {
let err = MicroappError {
microapp_id: "agent-creator".into(),
kind: MicroappErrorKind::HandlerPanic,
correlation_id: Some("12".into()),
occurred_at: chrono::DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
summary: "tool 'register' panicked: index out of bounds".into(),
stack_trace: Some("at handler.rs:42\nat dispatch.rs:118".into()),
};
let json = serde_json::to_string(&err).unwrap();
let back: MicroappError = serde_json::from_str(&json).unwrap();
assert_eq!(back, err);
}
#[test]
fn microapp_error_optional_fields_skip_when_none() {
let err = MicroappError::new(
"agent-creator",
MicroappErrorKind::Exit,
"exit code 137 (SIGKILL)",
);
let json = serde_json::to_string(&err).unwrap();
assert!(!json.contains("correlation_id"));
assert!(!json.contains("stack_trace"));
}
#[test]
fn builder_chain_sets_optional_fields() {
let err = MicroappError::new(
"x",
MicroappErrorKind::CapabilityDenied,
"needs agents_crud",
)
.with_correlation_id("app:abc-123")
.with_stack_trace("at admin.rs:99");
assert_eq!(err.correlation_id.as_deref(), Some("app:abc-123"));
assert_eq!(err.stack_trace.as_deref(), Some("at admin.rs:99"));
}
}