use crate::Tool;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(into = "String", from = "String")]
pub enum NotificationKind {
PermissionPrompt,
ElicitationDialog,
IdlePrompt,
Setup,
Info,
Other(String),
}
impl NotificationKind {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::PermissionPrompt => "permission_prompt",
Self::ElicitationDialog => "elicitation_dialog",
Self::IdlePrompt => "idle_prompt",
Self::Setup => "setup",
Self::Info => "info",
Self::Other(s) => s.as_str(),
}
}
}
impl std::fmt::Display for NotificationKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl NotificationKind {
fn try_from_known(s: &str) -> Option<Self> {
Some(match s {
"permission_prompt" => Self::PermissionPrompt,
"elicitation_dialog" => Self::ElicitationDialog,
"idle_prompt" => Self::IdlePrompt,
"setup" => Self::Setup,
"info" => Self::Info,
_ => return None,
})
}
}
impl From<&str> for NotificationKind {
fn from(s: &str) -> Self {
Self::try_from_known(s).unwrap_or_else(|| Self::Other(s.to_string()))
}
}
impl From<String> for NotificationKind {
fn from(s: String) -> Self {
Self::try_from_known(&s).unwrap_or(Self::Other(s))
}
}
impl From<NotificationKind> for String {
fn from(k: NotificationKind) -> Self {
match k {
NotificationKind::Other(s) => s,
other => other.as_str().to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "reason", rename_all = "snake_case")]
pub enum NeedsInputReason {
InteractiveTool { tool: Tool },
PermissionGate { tool: Tool },
Notification {
kind: NotificationKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LifecycleEvent {
SessionStart { source: Option<String> },
SessionEnd { reason: Option<String> },
WorkingStart,
WorkingEnd,
Idle,
PromptSubmit { prompt: Option<String> },
NeedsInput { reason: NeedsInputReason },
ToolCallStart {
name: Tool,
tool_use_id: Option<String>,
input: Option<serde_json::Value>,
},
ToolCallEnd {
name: Tool,
tool_use_id: Option<String>,
is_error: bool,
},
ContextCompactStart { trigger: Option<String> },
ContextUpdate {
tokens: Option<u64>,
cost_usd: Option<f64>,
},
ProviderModelChange {
provider: Option<String>,
model: Option<String>,
},
Notification {
message: Option<String>,
kind: Option<NotificationKind>,
},
ChildSessionStart {
id: Option<String>,
role: Option<String>,
},
ChildSessionEnd { id: Option<String> },
}
impl LifecycleEvent {
#[must_use]
pub fn is_terminal_for_turn(&self) -> bool {
matches!(
self,
Self::WorkingEnd | Self::Idle | Self::SessionEnd { .. }
)
}
#[must_use]
pub fn is_starting(&self) -> bool {
matches!(
self,
Self::SessionStart { .. } | Self::WorkingStart | Self::PromptSubmit { .. }
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lifecycle_event_serde_roundtrip() {
let cases = vec![
LifecycleEvent::SessionStart {
source: Some("startup".into()),
},
LifecycleEvent::SessionEnd {
reason: Some("quit".into()),
},
LifecycleEvent::WorkingStart,
LifecycleEvent::WorkingEnd,
LifecycleEvent::Idle,
LifecycleEvent::PromptSubmit {
prompt: Some("hello".into()),
},
LifecycleEvent::NeedsInput {
reason: NeedsInputReason::InteractiveTool {
tool: Tool::AskUserQuestion,
},
},
LifecycleEvent::NeedsInput {
reason: NeedsInputReason::PermissionGate { tool: Tool::Bash },
},
LifecycleEvent::NeedsInput {
reason: NeedsInputReason::Notification {
kind: NotificationKind::PermissionPrompt,
label: None,
},
},
LifecycleEvent::NeedsInput {
reason: NeedsInputReason::Notification {
kind: NotificationKind::PermissionPrompt,
label: Some("rm -rf /tmp".into()),
},
},
LifecycleEvent::ToolCallStart {
name: Tool::Bash,
tool_use_id: Some("tu_123".into()),
input: Some(serde_json::json!({"command": "ls /tmp"})),
},
LifecycleEvent::ToolCallEnd {
name: Tool::Bash,
tool_use_id: Some("tu_123".into()),
is_error: false,
},
LifecycleEvent::ContextCompactStart {
trigger: Some("auto".into()),
},
LifecycleEvent::ContextUpdate {
tokens: Some(1024),
cost_usd: Some(0.05),
},
LifecycleEvent::ProviderModelChange {
provider: Some("openai-codex".into()),
model: Some("gpt-5.5".into()),
},
LifecycleEvent::Notification {
message: Some("hi".into()),
kind: Some(NotificationKind::Info),
},
LifecycleEvent::ChildSessionStart {
id: Some("agent-1".into()),
role: Some("explore".into()),
},
LifecycleEvent::ChildSessionEnd {
id: Some("agent-1".into()),
},
];
for ev in cases {
let json = serde_json::to_string(&ev).expect("serialize");
let back: LifecycleEvent = serde_json::from_str(&json).expect("deserialize");
assert_eq!(ev, back, "roundtrip failed: {json}");
}
}
#[test]
fn terminal_and_starting_classification() {
assert!(LifecycleEvent::WorkingEnd.is_terminal_for_turn());
assert!(LifecycleEvent::Idle.is_terminal_for_turn());
assert!(LifecycleEvent::SessionEnd { reason: None }.is_terminal_for_turn());
assert!(!LifecycleEvent::WorkingStart.is_terminal_for_turn());
assert!(LifecycleEvent::SessionStart { source: None }.is_starting());
assert!(LifecycleEvent::WorkingStart.is_starting());
assert!(LifecycleEvent::PromptSubmit { prompt: None }.is_starting());
assert!(!LifecycleEvent::WorkingEnd.is_starting());
}
#[test]
fn notification_kind_wire_format_known() {
assert_eq!(
serde_json::to_string(&NotificationKind::PermissionPrompt).unwrap(),
"\"permission_prompt\""
);
assert_eq!(
serde_json::from_str::<NotificationKind>("\"permission_prompt\"").unwrap(),
NotificationKind::PermissionPrompt
);
}
#[test]
fn notification_kind_other_passthrough() {
let custom = NotificationKind::Other("vendor_specific".into());
let json = serde_json::to_string(&custom).unwrap();
assert_eq!(json, "\"vendor_specific\"");
assert_eq!(
serde_json::from_str::<NotificationKind>(&json).unwrap(),
custom
);
}
}