Skip to main content

pi/core/agent_session/
events.rs

1//! Product-level session events (superset of `pi_agent::AgentEvent`).
2//!
3//! Serialized RAW (tagged `type`, `snake_case` variants, camelCase payload
4//! fields) for json/rpc parity. `agent_end` is rewritten with `willRetry`.
5
6use pi_agent::{AgentEvent, AgentMessage, AgentToolResult};
7use pi_ai::{AssistantMessageEvent, Model, ModelThinkingLevel, ToolResultMessage};
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11use crate::core::compaction::CompactionResult;
12use crate::core::sessions::SessionEntry;
13
14/// Compaction trigger reason.
15#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum CompactionReason {
18    /// User-initiated `/compact`.
19    Manual,
20    /// Threshold-based auto compaction.
21    Threshold,
22    /// Context-overflow recovery.
23    Overflow,
24}
25
26/// Why a session replacement is about to occur.
27#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
28#[serde(rename_all = "snake_case")]
29pub enum SessionBeforeSwitchReason {
30    /// A new session is being created.
31    New,
32    /// An existing or imported session is being resumed.
33    Resume,
34}
35
36/// Reason passed to `session_start`.
37#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
38#[serde(rename_all = "snake_case")]
39pub enum SessionStartReason {
40    /// First bind for this session.
41    #[default]
42    Startup,
43    /// Bind after `/reload`.
44    Reload,
45    /// Bind after a new-session replacement.
46    New,
47    /// Bind after resume/switch/import.
48    Resume,
49    /// Bind after a fork replacement.
50    Fork,
51}
52
53impl SessionStartReason {
54    /// Wire discriminant matching TS.
55    #[must_use]
56    pub const fn as_str(self) -> &'static str {
57        match self {
58            Self::Startup => "startup",
59            Self::Reload => "reload",
60            Self::New => "new",
61            Self::Resume => "resume",
62            Self::Fork => "fork",
63        }
64    }
65}
66
67/// Reason passed to `session_shutdown`.
68#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
69#[serde(rename_all = "snake_case")]
70pub enum SessionShutdownReason {
71    /// New session replacing this one.
72    New,
73    /// Resume/switch/import replacing this one.
74    Resume,
75    /// Fork replacing this one.
76    Fork,
77    /// `/reload`.
78    Reload,
79    /// Runtime disposal.
80    Quit,
81}
82
83impl SessionShutdownReason {
84    /// Wire discriminant matching TS.
85    #[must_use]
86    pub const fn as_str(self) -> &'static str {
87        match self {
88            Self::New => "new",
89            Self::Resume => "resume",
90            Self::Fork => "fork",
91            Self::Reload => "reload",
92            Self::Quit => "quit",
93        }
94    }
95}
96
97/// Session-start metadata stored at construction and emitted on first bind.
98#[derive(Clone, Debug, Default, Eq, PartialEq)]
99pub struct SessionStartEvent {
100    /// Why this session started.
101    pub reason: SessionStartReason,
102    /// Previously active session file (new/resume/fork).
103    pub previous_session_file: Option<String>,
104}
105
106/// Position used for a session fork.
107#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
108#[serde(rename_all = "snake_case")]
109pub enum SessionBeforeForkPosition {
110    /// Fork from the selected user message's parent.
111    Before,
112    /// Fork at the selected entry.
113    At,
114}
115
116/// Source of a model selection.
117#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
118#[serde(rename_all = "snake_case")]
119pub enum ModelSelectSource {
120    /// Explicit model selection.
121    Set,
122    /// Model cycling.
123    Cycle,
124    /// Session/runtime restoration.
125    Restore,
126}
127
128/// Session-specific events that extend the core agent event surface.
129///
130/// Wire tags and field names match TypeScript `AgentSessionEvent`.
131#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
132#[serde(tag = "type", rename_all = "snake_case")]
133pub enum AgentSessionEvent {
134    /// Extensions may cancel a pending session switch.
135    SessionBeforeSwitch {
136        /// Whether this creates a new session or resumes one.
137        reason: SessionBeforeSwitchReason,
138        /// Target session file for resume/import operations.
139        #[serde(
140            rename = "targetSessionFile",
141            default,
142            skip_serializing_if = "Option::is_none"
143        )]
144        target_session_file: Option<String>,
145    },
146    /// Extensions may cancel a pending session fork.
147    SessionBeforeFork {
148        /// Selected session entry.
149        #[serde(rename = "entryId")]
150        entry_id: String,
151        /// Whether the fork starts before or at the selected entry.
152        position: SessionBeforeForkPosition,
153    },
154    /// Extension-host-facing session start (never routed through
155    /// `emit_public`; the public event stream excludes it, matching TS).
156    SessionStart {
157        /// Why this session started.
158        reason: SessionStartReason,
159        /// Previously active session file (new/resume/fork).
160        #[serde(
161            rename = "previousSessionFile",
162            default,
163            skip_serializing_if = "Option::is_none"
164        )]
165        previous_session_file: Option<String>,
166    },
167    /// Extension-host-facing session shutdown (never routed through
168    /// `emit_public`).
169    SessionShutdown {
170        /// Why this session is shutting down.
171        reason: SessionShutdownReason,
172        /// Session file replacing this one (new/resume/fork).
173        #[serde(
174            rename = "targetSessionFile",
175            default,
176            skip_serializing_if = "Option::is_none"
177        )]
178        target_session_file: Option<String>,
179    },
180    /// A new model was selected.
181    ModelSelect {
182        /// Newly selected model (boxed to keep the enum small; wire unchanged).
183        model: Box<Model>,
184        /// Previously selected model, absent during initial restoration.
185        #[serde(
186            rename = "previousModel",
187            default,
188            skip_serializing_if = "Option::is_none"
189        )]
190        previous_model: Option<Box<Model>>,
191        /// Selection source.
192        source: ModelSelectSource,
193    },
194    /// A new agent run has started.
195    AgentStart,
196    /// The agent run finished; `will_retry` is true when auto-retry will continue.
197    AgentEnd {
198        /// Messages produced by this run.
199        messages: Vec<AgentMessage>,
200        /// Whether session-level auto-retry will continue the run.
201        #[serde(rename = "willRetry")]
202        will_retry: bool,
203    },
204    /// A turn is about to begin.
205    TurnStart,
206    /// A turn finished with an assistant message and any tool results.
207    TurnEnd {
208        /// Assistant message that completed the turn.
209        message: AgentMessage,
210        /// Tool-result messages for this turn.
211        #[serde(rename = "toolResults")]
212        tool_results: Vec<ToolResultMessage>,
213    },
214    /// A transcript message is starting.
215    MessageStart {
216        /// Message snapshot at start.
217        message: AgentMessage,
218    },
219    /// An assistant message was updated during streaming.
220    MessageUpdate {
221        /// Latest assistant message snapshot.
222        message: AgentMessage,
223        /// Underlying provider stream event.
224        #[serde(rename = "assistantMessageEvent")]
225        assistant_message_event: Box<AssistantMessageEvent>,
226    },
227    /// A transcript message has ended.
228    MessageEnd {
229        /// Final message snapshot.
230        message: AgentMessage,
231    },
232    /// Tool execution is starting for one tool call.
233    ToolExecutionStart {
234        /// Tool-call identifier.
235        #[serde(rename = "toolCallId")]
236        tool_call_id: String,
237        /// Registered tool name.
238        #[serde(rename = "toolName")]
239        tool_name: String,
240        /// Validated tool arguments.
241        args: Map<String, Value>,
242    },
243    /// Tool execution produced a partial result update.
244    ToolExecutionUpdate {
245        /// Tool-call identifier.
246        #[serde(rename = "toolCallId")]
247        tool_call_id: String,
248        /// Registered tool name.
249        #[serde(rename = "toolName")]
250        tool_name: String,
251        /// Validated tool arguments.
252        args: Map<String, Value>,
253        /// Partial tool result.
254        #[serde(rename = "partialResult")]
255        partial_result: AgentToolResult,
256    },
257    /// Tool execution finished for one tool call.
258    ToolExecutionEnd {
259        /// Tool-call identifier.
260        #[serde(rename = "toolCallId")]
261        tool_call_id: String,
262        /// Registered tool name.
263        #[serde(rename = "toolName")]
264        tool_name: String,
265        /// Final tool result.
266        result: AgentToolResult,
267        /// Whether the result is treated as an error.
268        #[serde(rename = "isError")]
269        is_error: bool,
270    },
271    /// Session-level idle after retries / compaction / queued continuations.
272    AgentSettled,
273    /// Mirror of pending steering / follow-up queue text for UI.
274    QueueUpdate {
275        /// Pending steering message texts.
276        steering: Vec<String>,
277        /// Pending follow-up message texts.
278        #[serde(rename = "followUp")]
279        follow_up: Vec<String>,
280    },
281    /// Compaction is starting.
282    CompactionStart {
283        /// Why compaction was triggered.
284        reason: CompactionReason,
285    },
286    /// Compaction finished.
287    CompactionEnd {
288        /// Why compaction was triggered.
289        reason: CompactionReason,
290        /// Result when compaction produced a summary.
291        #[serde(default, skip_serializing_if = "Option::is_none")]
292        result: Option<CompactionResult>,
293        /// Whether compaction was aborted.
294        aborted: bool,
295        /// Whether the session will retry after this compaction.
296        #[serde(rename = "willRetry")]
297        will_retry: bool,
298        /// Error text when compaction failed.
299        #[serde(
300            rename = "errorMessage",
301            default,
302            skip_serializing_if = "Option::is_none"
303        )]
304        error_message: Option<String>,
305    },
306    /// A session entry was appended.
307    EntryAppended {
308        /// Appended entry.
309        entry: SessionEntry,
310    },
311    /// Session display name changed.
312    SessionInfoChanged {
313        /// New name (`None` clears).
314        name: Option<String>,
315    },
316    /// Thinking level changed.
317    ThinkingLevelChanged {
318        /// New thinking level.
319        level: ModelThinkingLevel,
320    },
321    /// Auto-retry backoff is starting.
322    AutoRetryStart {
323        /// Current attempt (1-based).
324        attempt: u32,
325        /// Configured max attempts.
326        #[serde(rename = "maxAttempts")]
327        max_attempts: u32,
328        /// Backoff delay in milliseconds.
329        #[serde(rename = "delayMs")]
330        delay_ms: u64,
331        /// Error that triggered the retry.
332        #[serde(rename = "errorMessage")]
333        error_message: String,
334    },
335    /// Auto-retry finished (success, exhaustion, or cancel).
336    AutoRetryEnd {
337        /// Whether a later assistant response succeeded.
338        success: bool,
339        /// Attempt count at end.
340        attempt: u32,
341        /// Final error when unsuccessful.
342        #[serde(
343            rename = "finalError",
344            default,
345            skip_serializing_if = "Option::is_none"
346        )]
347        final_error: Option<String>,
348    },
349}
350
351impl AgentSessionEvent {
352    /// Convert a core agent event into a session event.
353    ///
354    /// `agent_end` requires `will_retry` from the session retry policy.
355    #[must_use]
356    pub fn from_agent_event(event: AgentEvent, will_retry: bool) -> Self {
357        match event {
358            AgentEvent::AgentStart => Self::AgentStart,
359            AgentEvent::AgentEnd { messages } => Self::AgentEnd {
360                messages,
361                will_retry,
362            },
363            AgentEvent::TurnStart => Self::TurnStart,
364            AgentEvent::TurnEnd {
365                message,
366                tool_results,
367            } => Self::TurnEnd {
368                message,
369                tool_results,
370            },
371            AgentEvent::MessageStart { message } => Self::MessageStart { message },
372            AgentEvent::MessageUpdate {
373                message,
374                assistant_message_event,
375            } => Self::MessageUpdate {
376                message,
377                assistant_message_event,
378            },
379            AgentEvent::MessageEnd { message } => Self::MessageEnd { message },
380            AgentEvent::ToolExecutionStart {
381                tool_call_id,
382                tool_name,
383                args,
384            } => Self::ToolExecutionStart {
385                tool_call_id,
386                tool_name,
387                args,
388            },
389            AgentEvent::ToolExecutionUpdate {
390                tool_call_id,
391                tool_name,
392                args,
393                partial_result,
394            } => Self::ToolExecutionUpdate {
395                tool_call_id,
396                tool_name,
397                args,
398                partial_result,
399            },
400            AgentEvent::ToolExecutionEnd {
401                tool_call_id,
402                tool_name,
403                result,
404                is_error,
405            } => Self::ToolExecutionEnd {
406                tool_call_id,
407                tool_name,
408                result,
409                is_error,
410            },
411        }
412    }
413
414    /// Wire `type` discriminant.
415    #[must_use]
416    pub fn type_name(&self) -> &'static str {
417        match self {
418            Self::SessionBeforeSwitch { .. } => "session_before_switch",
419            Self::SessionBeforeFork { .. } => "session_before_fork",
420            Self::SessionStart { .. } => "session_start",
421            Self::SessionShutdown { .. } => "session_shutdown",
422            Self::ModelSelect { .. } => "model_select",
423            Self::AgentStart => "agent_start",
424            Self::AgentEnd { .. } => "agent_end",
425            Self::TurnStart => "turn_start",
426            Self::TurnEnd { .. } => "turn_end",
427            Self::MessageStart { .. } => "message_start",
428            Self::MessageUpdate { .. } => "message_update",
429            Self::MessageEnd { .. } => "message_end",
430            Self::ToolExecutionStart { .. } => "tool_execution_start",
431            Self::ToolExecutionUpdate { .. } => "tool_execution_update",
432            Self::ToolExecutionEnd { .. } => "tool_execution_end",
433            Self::AgentSettled => "agent_settled",
434            Self::QueueUpdate { .. } => "queue_update",
435            Self::CompactionStart { .. } => "compaction_start",
436            Self::CompactionEnd { .. } => "compaction_end",
437            Self::EntryAppended { .. } => "entry_appended",
438            Self::SessionInfoChanged { .. } => "session_info_changed",
439            Self::ThinkingLevelChanged { .. } => "thinking_level_changed",
440            Self::AutoRetryStart { .. } => "auto_retry_start",
441            Self::AutoRetryEnd { .. } => "auto_retry_end",
442        }
443    }
444}
445
446/// Listener invoked for every public session event.
447pub type AgentSessionEventListener = Arc<dyn Fn(&AgentSessionEvent) + Send + Sync>;
448
449use std::sync::Arc;
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use pi_agent::user_text;
455    use serde_json::json;
456
457    #[test]
458    fn agent_end_wire_includes_will_retry() -> Result<(), serde_json::Error> {
459        let event = AgentSessionEvent::AgentEnd {
460            messages: vec![user_text("hi", std::iter::empty())],
461            will_retry: true,
462        };
463        let value = serde_json::to_value(&event)?;
464        assert_eq!(value["type"], json!("agent_end"));
465        assert_eq!(value["willRetry"], json!(true));
466        assert!(value["messages"].is_array());
467        Ok(())
468    }
469
470    #[test]
471    fn queue_update_wire_camel_case() -> Result<(), serde_json::Error> {
472        let event = AgentSessionEvent::QueueUpdate {
473            steering: vec!["a".into()],
474            follow_up: vec!["b".into()],
475        };
476        let value = serde_json::to_value(&event)?;
477        assert_eq!(value["type"], json!("queue_update"));
478        assert_eq!(value["steering"], json!(["a"]));
479        assert_eq!(value["followUp"], json!(["b"]));
480        Ok(())
481    }
482
483    #[test]
484    fn lifecycle_events_use_reference_wire_payloads() -> Result<(), serde_json::Error> {
485        let switch = serde_json::to_value(AgentSessionEvent::SessionBeforeSwitch {
486            reason: SessionBeforeSwitchReason::Resume,
487            target_session_file: Some("/tmp/session.jsonl".into()),
488        })?;
489        assert_eq!(
490            switch,
491            json!({
492                "type": "session_before_switch",
493                "reason": "resume",
494                "targetSessionFile": "/tmp/session.jsonl"
495            })
496        );
497
498        let fork = serde_json::to_value(AgentSessionEvent::SessionBeforeFork {
499            entry_id: "entry-1".into(),
500            position: SessionBeforeForkPosition::Before,
501        })?;
502        assert_eq!(
503            fork,
504            json!({
505                "type": "session_before_fork",
506                "entryId": "entry-1",
507                "position": "before"
508            })
509        );
510
511        let model = pi_agent::state::default_model();
512        let selected = serde_json::to_value(AgentSessionEvent::ModelSelect {
513            model: Box::new(model.clone()),
514            previous_model: Some(Box::new(model)),
515            source: ModelSelectSource::Cycle,
516        })?;
517        assert_eq!(selected["type"], json!("model_select"));
518        assert_eq!(selected["source"], json!("cycle"));
519        assert!(selected.get("previousModel").is_some());
520        Ok(())
521    }
522
523    #[test]
524    fn optional_lifecycle_fields_are_omitted() -> Result<(), serde_json::Error> {
525        let switch = serde_json::to_value(AgentSessionEvent::SessionBeforeSwitch {
526            reason: SessionBeforeSwitchReason::New,
527            target_session_file: None,
528        })?;
529        assert!(switch.get("targetSessionFile").is_none());
530        Ok(())
531    }
532
533    #[test]
534    fn agent_settled_is_tag_only() -> Result<(), serde_json::Error> {
535        let event = AgentSessionEvent::AgentSettled;
536        let value = serde_json::to_value(&event)?;
537        assert_eq!(value, json!({"type": "agent_settled"}));
538        Ok(())
539    }
540
541    #[test]
542    fn session_start_wire_shape_and_round_trip() -> Result<(), serde_json::Error> {
543        let startup = AgentSessionEvent::SessionStart {
544            reason: SessionStartReason::Startup,
545            previous_session_file: None,
546        };
547        let value = serde_json::to_value(&startup)?;
548        assert_eq!(value, json!({"type": "session_start", "reason": "startup"}));
549        assert!(value.get("previousSessionFile").is_none());
550        assert_eq!(serde_json::from_value::<AgentSessionEvent>(value)?, startup);
551
552        let resume = AgentSessionEvent::SessionStart {
553            reason: SessionStartReason::Resume,
554            previous_session_file: Some("/tmp/prev.jsonl".into()),
555        };
556        let value = serde_json::to_value(&resume)?;
557        assert_eq!(
558            value,
559            json!({
560                "type": "session_start",
561                "reason": "resume",
562                "previousSessionFile": "/tmp/prev.jsonl"
563            })
564        );
565        assert_eq!(serde_json::from_value::<AgentSessionEvent>(value)?, resume);
566        Ok(())
567    }
568
569    #[test]
570    fn session_shutdown_wire_shape_and_round_trip() -> Result<(), serde_json::Error> {
571        let quit = AgentSessionEvent::SessionShutdown {
572            reason: SessionShutdownReason::Quit,
573            target_session_file: None,
574        };
575        let value = serde_json::to_value(&quit)?;
576        assert_eq!(value, json!({"type": "session_shutdown", "reason": "quit"}));
577        assert!(value.get("targetSessionFile").is_none());
578        assert_eq!(serde_json::from_value::<AgentSessionEvent>(value)?, quit);
579
580        let new = AgentSessionEvent::SessionShutdown {
581            reason: SessionShutdownReason::New,
582            target_session_file: Some("/tmp/next.jsonl".into()),
583        };
584        let value = serde_json::to_value(&new)?;
585        assert_eq!(
586            value,
587            json!({
588                "type": "session_shutdown",
589                "reason": "new",
590                "targetSessionFile": "/tmp/next.jsonl"
591            })
592        );
593        assert_eq!(serde_json::from_value::<AgentSessionEvent>(value)?, new);
594        Ok(())
595    }
596
597    #[test]
598    fn lifecycle_reason_strings_match_wire_contract() {
599        assert_eq!(
600            [
601                SessionStartReason::Startup.as_str(),
602                SessionStartReason::Reload.as_str(),
603                SessionStartReason::New.as_str(),
604                SessionStartReason::Resume.as_str(),
605                SessionStartReason::Fork.as_str(),
606            ],
607            ["startup", "reload", "new", "resume", "fork"]
608        );
609        assert_eq!(
610            [
611                SessionShutdownReason::Quit.as_str(),
612                SessionShutdownReason::Reload.as_str(),
613                SessionShutdownReason::New.as_str(),
614                SessionShutdownReason::Resume.as_str(),
615                SessionShutdownReason::Fork.as_str(),
616            ],
617            ["quit", "reload", "new", "resume", "fork"]
618        );
619        assert_eq!(SessionStartReason::default(), SessionStartReason::Startup);
620    }
621}