Skip to main content

atm_core/
lifecycle.rs

1//! Vendor-neutral agent lifecycle events.
2//!
3//! `LifecycleEvent` is the abstraction that crosses the daemon boundary —
4//! every vendor adapter (Claude Code, pi, future) translates its native
5//! event vocabulary into this enum. The daemon's session state machine
6//! only ever pattern-matches on `LifecycleEvent`, never on a raw vendor
7//! event type.
8//!
9//! ## Design
10//!
11//! Three properties drove the shape:
12//!
13//! 1. **No vendor escape hatch.** Earlier drafts had a `VendorSpecific`
14//!    variant; that turned out to leak vendor knowledge into every
15//!    consumer. Instead, every event maps to a generic concept (e.g.
16//!    Claude `SubagentStart` → `ChildSessionStart`).
17//! 2. **`NeedsInput` is adapter-synthesized, not raw.** Pi has no
18//!    permission-prompt event — its extension synthesizes one inside a
19//!    `tool_call` handler. Claude only signals it via `PreToolUse` of an
20//!    interactive tool. So this variant is emitted by the translation
21//!    layer, never directly by a vendor.
22//! 3. **Provider/model are session metadata, not per-event.** Pi is
23//!    provider-agnostic — one session can switch from Anthropic to
24//!    OpenAI mid-stream — so changes ride a dedicated
25//!    `ProviderModelChange` variant rather than annotating every event.
26//!
27//! Tool identity rides the typed `Tool` enum instead of free strings,
28//! so the well-known set is defined once and the open tail of MCP /
29//! vendor-specific names lives in `Tool::Other(String)`.
30
31use crate::Tool;
32use serde::{Deserialize, Serialize};
33
34/// Sub-kind of a notification, for the cases the daemon special-cases.
35///
36/// Pi doesn't use these strings — its permission gating is extension-
37/// mediated and surfaces via `NeedsInputReason::PermissionGate`. The
38/// known variants here are Claude `Notification(notification_type)`
39/// values plus the `setup` synthetic kind we emit when translating
40/// Claude's one-time `Setup` hook event.
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(into = "String", from = "String")]
43pub enum NotificationKind {
44    /// Claude permission prompt — agent is waiting on a yes/no.
45    PermissionPrompt,
46    /// Claude MCP elicitation — extension wants structured input.
47    ElicitationDialog,
48    /// Claude idle prompt — agent has gone idle.
49    IdlePrompt,
50    /// Claude one-time `Setup` hook (renamed from raw event).
51    Setup,
52    /// Generic informational notification.
53    Info,
54    /// Any other notification kind (vendor-specific, future kinds).
55    Other(String),
56}
57
58impl NotificationKind {
59    /// Wire-format string for this kind.
60    #[must_use]
61    pub fn as_str(&self) -> &str {
62        match self {
63            Self::PermissionPrompt => "permission_prompt",
64            Self::ElicitationDialog => "elicitation_dialog",
65            Self::IdlePrompt => "idle_prompt",
66            Self::Setup => "setup",
67            Self::Info => "info",
68            Self::Other(s) => s.as_str(),
69        }
70    }
71}
72
73impl std::fmt::Display for NotificationKind {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.write_str(self.as_str())
76    }
77}
78
79impl NotificationKind {
80    /// Canonical lookup for known variants. Returns `None` for inputs
81    /// that should fall through to `Other(_)`.
82    fn try_from_known(s: &str) -> Option<Self> {
83        Some(match s {
84            "permission_prompt" => Self::PermissionPrompt,
85            "elicitation_dialog" => Self::ElicitationDialog,
86            "idle_prompt" => Self::IdlePrompt,
87            "setup" => Self::Setup,
88            "info" => Self::Info,
89            _ => return None,
90        })
91    }
92}
93
94impl From<&str> for NotificationKind {
95    fn from(s: &str) -> Self {
96        Self::try_from_known(s).unwrap_or_else(|| Self::Other(s.to_string()))
97    }
98}
99
100impl From<String> for NotificationKind {
101    fn from(s: String) -> Self {
102        // Reuse the owned String on the Other path to avoid re-allocation.
103        Self::try_from_known(&s).unwrap_or(Self::Other(s))
104    }
105}
106
107impl From<NotificationKind> for String {
108    fn from(k: NotificationKind) -> Self {
109        match k {
110            NotificationKind::Other(s) => s,
111            other => other.as_str().to_string(),
112        }
113    }
114}
115
116/// Why a session is awaiting user input.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(tag = "reason", rename_all = "snake_case")]
119pub enum NeedsInputReason {
120    /// Claude `PreToolUse` for an interactive tool
121    /// (`AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`).
122    InteractiveTool { tool: Tool },
123    /// Pi extension-mediated `tool_call` permission gate.
124    PermissionGate { tool: Tool },
125    /// Generic notification-driven prompt
126    /// (Claude `Notification(permission_prompt|elicitation_dialog)`,
127    /// pi `atm_needs_input_open`). `label` carries an optional
128    /// vendor-supplied human string (e.g. the dialog title or the
129    /// command being gated) so the TUI can show *what* is being
130    /// asked, not just that *something* is.
131    Notification {
132        kind: NotificationKind,
133        #[serde(default, skip_serializing_if = "Option::is_none")]
134        label: Option<String>,
135    },
136}
137
138/// Vendor-neutral lifecycle event.
139///
140/// Adapters translate native vendor events into these variants; the
141/// daemon dispatches on them to update session state.
142///
143/// `Eq` is intentionally not derived: `ContextUpdate` carries an
144/// `Option<f64>` cost, which is not `Eq`.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(tag = "type", rename_all = "snake_case")]
147pub enum LifecycleEvent {
148    /// Session opened. `source` carries why (Claude: `startup`/`resume`/
149    /// `clear`; pi: `startup`).
150    SessionStart { source: Option<String> },
151
152    /// Session closed. `reason` is vendor-supplied when available
153    /// (pi `session_shutdown.reason`; Claude `SessionEnd.reason`).
154    SessionEnd { reason: Option<String> },
155
156    /// Agent began a working block.
157    /// (Pi `agent_start`; Claude has no direct analog — synthesized
158    /// from `UserPromptSubmit` or first `PreToolUse`.)
159    WorkingStart,
160
161    /// Agent finished a working block.
162    /// (Pi `agent_end`; Claude `Stop`.)
163    WorkingEnd,
164
165    /// Explicit idle signal. Distinct from `WorkingEnd` because pi can
166    /// transition idle → idle (no work happened) via
167    /// `ctx.isIdle() && !ctx.hasPendingMessages()`.
168    Idle,
169
170    /// User submitted a prompt.
171    PromptSubmit { prompt: Option<String> },
172
173    /// Session is waiting on user input.
174    NeedsInput { reason: NeedsInputReason },
175
176    /// A tool started executing.
177    ///
178    /// `tool_use_id` is the correlation id pairing this with its later
179    /// `ToolCallEnd` (Claude `tool_use_id`, pi `toolCallId`). `input`
180    /// is the tool arguments JSON, when the vendor exposes it.
181    ToolCallStart {
182        name: Tool,
183        tool_use_id: Option<String>,
184        input: Option<serde_json::Value>,
185    },
186
187    /// A tool finished executing. `tool_use_id` matches the originating
188    /// `ToolCallStart`.
189    ToolCallEnd {
190        name: Tool,
191        tool_use_id: Option<String>,
192        is_error: bool,
193    },
194
195    /// Context compaction is starting. `trigger` is the cause
196    /// (Claude: `auto`/`manual`).
197    ContextCompactStart { trigger: Option<String> },
198
199    /// Periodic context-usage update (tokens used, accumulated cost).
200    /// Either field may be `None` if the vendor doesn't expose it.
201    ContextUpdate {
202        tokens: Option<u64>,
203        cost_usd: Option<f64>,
204    },
205
206    /// Provider or model changed mid-session (pi `model_select`).
207    ProviderModelChange {
208        provider: Option<String>,
209        model: Option<String>,
210    },
211
212    /// Free-form notification surfaced to the user. `kind` carries an
213    /// optional sub-type the UI can render specially.
214    Notification {
215        message: Option<String>,
216        kind: Option<NotificationKind>,
217    },
218
219    /// A child session (subagent / task / fork) started.
220    /// `id` is a vendor-supplied correlation id; `role` is a free-form
221    /// tag (e.g. Claude subagent role: `"explore"`, `"plan"`).
222    ChildSessionStart {
223        id: Option<String>,
224        role: Option<String>,
225    },
226
227    /// A child session finished.
228    ChildSessionEnd { id: Option<String> },
229}
230
231impl LifecycleEvent {
232    /// True if this event ends/clears the session's working state.
233    #[must_use]
234    pub fn is_terminal_for_turn(&self) -> bool {
235        matches!(
236            self,
237            Self::WorkingEnd | Self::Idle | Self::SessionEnd { .. }
238        )
239    }
240
241    /// True if this event opens the session's working state.
242    #[must_use]
243    pub fn is_starting(&self) -> bool {
244        matches!(
245            self,
246            Self::SessionStart { .. } | Self::WorkingStart | Self::PromptSubmit { .. }
247        )
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn lifecycle_event_serde_roundtrip() {
257        let cases = vec![
258            LifecycleEvent::SessionStart {
259                source: Some("startup".into()),
260            },
261            LifecycleEvent::SessionEnd {
262                reason: Some("quit".into()),
263            },
264            LifecycleEvent::WorkingStart,
265            LifecycleEvent::WorkingEnd,
266            LifecycleEvent::Idle,
267            LifecycleEvent::PromptSubmit {
268                prompt: Some("hello".into()),
269            },
270            LifecycleEvent::NeedsInput {
271                reason: NeedsInputReason::InteractiveTool {
272                    tool: Tool::AskUserQuestion,
273                },
274            },
275            LifecycleEvent::NeedsInput {
276                reason: NeedsInputReason::PermissionGate { tool: Tool::Bash },
277            },
278            LifecycleEvent::NeedsInput {
279                reason: NeedsInputReason::Notification {
280                    kind: NotificationKind::PermissionPrompt,
281                    label: None,
282                },
283            },
284            LifecycleEvent::NeedsInput {
285                reason: NeedsInputReason::Notification {
286                    kind: NotificationKind::PermissionPrompt,
287                    label: Some("rm -rf /tmp".into()),
288                },
289            },
290            LifecycleEvent::ToolCallStart {
291                name: Tool::Bash,
292                tool_use_id: Some("tu_123".into()),
293                input: Some(serde_json::json!({"command": "ls /tmp"})),
294            },
295            LifecycleEvent::ToolCallEnd {
296                name: Tool::Bash,
297                tool_use_id: Some("tu_123".into()),
298                is_error: false,
299            },
300            LifecycleEvent::ContextCompactStart {
301                trigger: Some("auto".into()),
302            },
303            LifecycleEvent::ContextUpdate {
304                tokens: Some(1024),
305                cost_usd: Some(0.05),
306            },
307            LifecycleEvent::ProviderModelChange {
308                provider: Some("openai-codex".into()),
309                model: Some("gpt-5.5".into()),
310            },
311            LifecycleEvent::Notification {
312                message: Some("hi".into()),
313                kind: Some(NotificationKind::Info),
314            },
315            LifecycleEvent::ChildSessionStart {
316                id: Some("agent-1".into()),
317                role: Some("explore".into()),
318            },
319            LifecycleEvent::ChildSessionEnd {
320                id: Some("agent-1".into()),
321            },
322        ];
323
324        for ev in cases {
325            let json = serde_json::to_string(&ev).expect("serialize");
326            let back: LifecycleEvent = serde_json::from_str(&json).expect("deserialize");
327            assert_eq!(ev, back, "roundtrip failed: {json}");
328        }
329    }
330
331    #[test]
332    fn terminal_and_starting_classification() {
333        assert!(LifecycleEvent::WorkingEnd.is_terminal_for_turn());
334        assert!(LifecycleEvent::Idle.is_terminal_for_turn());
335        assert!(LifecycleEvent::SessionEnd { reason: None }.is_terminal_for_turn());
336        assert!(!LifecycleEvent::WorkingStart.is_terminal_for_turn());
337
338        assert!(LifecycleEvent::SessionStart { source: None }.is_starting());
339        assert!(LifecycleEvent::WorkingStart.is_starting());
340        assert!(LifecycleEvent::PromptSubmit { prompt: None }.is_starting());
341        assert!(!LifecycleEvent::WorkingEnd.is_starting());
342    }
343
344    #[test]
345    fn notification_kind_wire_format_known() {
346        assert_eq!(
347            serde_json::to_string(&NotificationKind::PermissionPrompt).unwrap(),
348            "\"permission_prompt\""
349        );
350        assert_eq!(
351            serde_json::from_str::<NotificationKind>("\"permission_prompt\"").unwrap(),
352            NotificationKind::PermissionPrompt
353        );
354    }
355
356    #[test]
357    fn notification_kind_other_passthrough() {
358        let custom = NotificationKind::Other("vendor_specific".into());
359        let json = serde_json::to_string(&custom).unwrap();
360        assert_eq!(json, "\"vendor_specific\"");
361        assert_eq!(
362            serde_json::from_str::<NotificationKind>(&json).unwrap(),
363            custom
364        );
365    }
366}