1use 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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum CompactionReason {
18 Manual,
20 Threshold,
22 Overflow,
24}
25
26#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
28#[serde(rename_all = "snake_case")]
29pub enum SessionBeforeSwitchReason {
30 New,
32 Resume,
34}
35
36#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
38#[serde(rename_all = "snake_case")]
39pub enum SessionStartReason {
40 #[default]
42 Startup,
43 Reload,
45 New,
47 Resume,
49 Fork,
51}
52
53impl SessionStartReason {
54 #[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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
69#[serde(rename_all = "snake_case")]
70pub enum SessionShutdownReason {
71 New,
73 Resume,
75 Fork,
77 Reload,
79 Quit,
81}
82
83impl SessionShutdownReason {
84 #[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#[derive(Clone, Debug, Default, Eq, PartialEq)]
99pub struct SessionStartEvent {
100 pub reason: SessionStartReason,
102 pub previous_session_file: Option<String>,
104}
105
106#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
108#[serde(rename_all = "snake_case")]
109pub enum SessionBeforeForkPosition {
110 Before,
112 At,
114}
115
116#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
118#[serde(rename_all = "snake_case")]
119pub enum ModelSelectSource {
120 Set,
122 Cycle,
124 Restore,
126}
127
128#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
132#[serde(tag = "type", rename_all = "snake_case")]
133pub enum AgentSessionEvent {
134 SessionBeforeSwitch {
136 reason: SessionBeforeSwitchReason,
138 #[serde(
140 rename = "targetSessionFile",
141 default,
142 skip_serializing_if = "Option::is_none"
143 )]
144 target_session_file: Option<String>,
145 },
146 SessionBeforeFork {
148 #[serde(rename = "entryId")]
150 entry_id: String,
151 position: SessionBeforeForkPosition,
153 },
154 SessionStart {
157 reason: SessionStartReason,
159 #[serde(
161 rename = "previousSessionFile",
162 default,
163 skip_serializing_if = "Option::is_none"
164 )]
165 previous_session_file: Option<String>,
166 },
167 SessionShutdown {
170 reason: SessionShutdownReason,
172 #[serde(
174 rename = "targetSessionFile",
175 default,
176 skip_serializing_if = "Option::is_none"
177 )]
178 target_session_file: Option<String>,
179 },
180 ModelSelect {
182 model: Box<Model>,
184 #[serde(
186 rename = "previousModel",
187 default,
188 skip_serializing_if = "Option::is_none"
189 )]
190 previous_model: Option<Box<Model>>,
191 source: ModelSelectSource,
193 },
194 AgentStart,
196 AgentEnd {
198 messages: Vec<AgentMessage>,
200 #[serde(rename = "willRetry")]
202 will_retry: bool,
203 },
204 TurnStart,
206 TurnEnd {
208 message: AgentMessage,
210 #[serde(rename = "toolResults")]
212 tool_results: Vec<ToolResultMessage>,
213 },
214 MessageStart {
216 message: AgentMessage,
218 },
219 MessageUpdate {
221 message: AgentMessage,
223 #[serde(rename = "assistantMessageEvent")]
225 assistant_message_event: Box<AssistantMessageEvent>,
226 },
227 MessageEnd {
229 message: AgentMessage,
231 },
232 ToolExecutionStart {
234 #[serde(rename = "toolCallId")]
236 tool_call_id: String,
237 #[serde(rename = "toolName")]
239 tool_name: String,
240 args: Map<String, Value>,
242 },
243 ToolExecutionUpdate {
245 #[serde(rename = "toolCallId")]
247 tool_call_id: String,
248 #[serde(rename = "toolName")]
250 tool_name: String,
251 args: Map<String, Value>,
253 #[serde(rename = "partialResult")]
255 partial_result: AgentToolResult,
256 },
257 ToolExecutionEnd {
259 #[serde(rename = "toolCallId")]
261 tool_call_id: String,
262 #[serde(rename = "toolName")]
264 tool_name: String,
265 result: AgentToolResult,
267 #[serde(rename = "isError")]
269 is_error: bool,
270 },
271 AgentSettled,
273 QueueUpdate {
275 steering: Vec<String>,
277 #[serde(rename = "followUp")]
279 follow_up: Vec<String>,
280 },
281 CompactionStart {
283 reason: CompactionReason,
285 },
286 CompactionEnd {
288 reason: CompactionReason,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
292 result: Option<CompactionResult>,
293 aborted: bool,
295 #[serde(rename = "willRetry")]
297 will_retry: bool,
298 #[serde(
300 rename = "errorMessage",
301 default,
302 skip_serializing_if = "Option::is_none"
303 )]
304 error_message: Option<String>,
305 },
306 EntryAppended {
308 entry: SessionEntry,
310 },
311 SessionInfoChanged {
313 name: Option<String>,
315 },
316 ThinkingLevelChanged {
318 level: ModelThinkingLevel,
320 },
321 AutoRetryStart {
323 attempt: u32,
325 #[serde(rename = "maxAttempts")]
327 max_attempts: u32,
328 #[serde(rename = "delayMs")]
330 delay_ms: u64,
331 #[serde(rename = "errorMessage")]
333 error_message: String,
334 },
335 AutoRetryEnd {
337 success: bool,
339 attempt: u32,
341 #[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 #[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 #[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
446pub 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}