1use crate::tools::ToolResult;
39use bamboo_domain::{TaskItemStatus, TaskList};
40use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
96#[serde(tag = "type", rename_all = "snake_case")]
97pub enum AgentEvent {
98 Token {
100 content: String,
102 },
103
104 ReasoningToken {
109 content: String,
111 },
112
113 ToolToken {
118 tool_call_id: String,
120 content: String,
122 },
123
124 ToolStart {
126 tool_call_id: String,
128 tool_name: String,
130 arguments: serde_json::Value,
132 },
133
134 ToolComplete {
136 tool_call_id: String,
138 result: ToolResult,
140 },
141
142 ToolError {
144 tool_call_id: String,
146 error: String,
148 },
149
150 ToolLifecycle {
156 tool_call_id: String,
158 tool_name: String,
160 phase: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 elapsed_ms: Option<u64>,
165 is_mutating: bool,
167 auto_approved: bool,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 summary: Option<String>,
172 #[serde(skip_serializing_if = "Option::is_none")]
174 error: Option<String>,
175 },
176
177 NeedClarification {
179 question: String,
181 options: Option<Vec<String>>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 tool_call_id: Option<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 tool_name: Option<String>,
189 #[serde(default = "default_allow_custom")]
191 allow_custom: bool,
192 },
193
194 TaskListUpdated {
196 task_list: TaskList,
198 },
199
200 TaskListItemProgress {
202 session_id: String,
204 item_id: String,
206 status: TaskItemStatus,
208 tool_calls_count: usize,
210 version: u64,
212 },
213
214 TaskListCompleted {
216 session_id: String,
218 completed_at: DateTime<Utc>,
220 total_rounds: u32,
222 total_tool_calls: usize,
224 },
225
226 TaskEvaluationStarted {
228 session_id: String,
230 items_count: usize,
232 },
233
234 TaskEvaluationCompleted {
236 session_id: String,
238 updates_count: usize,
240 reasoning: String,
242 },
243
244 GoldEvaluationStarted {
246 session_id: String,
248 checkpoint: GoldCheckpoint,
250 iteration: u32,
252 },
253
254 GoldEvaluationCompleted {
256 session_id: String,
258 checkpoint: GoldCheckpoint,
260 iteration: u32,
262 decision: GoldDecision,
264 confidence: GoldConfidence,
266 reasoning: String,
268 },
269
270 TokenBudgetUpdated {
272 usage: TokenBudgetUsage,
274 },
275
276 ContextCompressionStatus {
278 phase: String,
280 status: String,
282 },
283
284 ContextSummarized {
286 summary: String,
288 messages_summarized: usize,
290 tokens_saved: u32,
292 #[serde(default)]
294 usage_before_percent: f64,
295 #[serde(default)]
297 usage_after_percent: f64,
298 #[serde(default)]
300 trigger_type: String,
301 },
302
303 ContextPressureNotification {
306 percent: f64,
308 level: String,
310 message: String,
312 },
313
314 SubAgentStarted {
316 parent_session_id: String,
317 child_session_id: String,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 title: Option<String>,
321 },
322
323 SubAgentEvent {
327 parent_session_id: String,
328 child_session_id: String,
329 event: Box<AgentEvent>,
330 },
331
332 SubAgentHeartbeat {
334 parent_session_id: String,
335 child_session_id: String,
336 timestamp: DateTime<Utc>,
337 },
338
339 SubAgentCompleted {
341 parent_session_id: String,
342 child_session_id: String,
343 status: String,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 error: Option<String>,
347 },
348
349 PlanModeEntered {
351 session_id: String,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
355 reason: Option<String>,
356 pre_permission_mode: String,
358 entered_at: chrono::DateTime<chrono::Utc>,
360 status: bamboo_domain::PlanModeStatus,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 plan_file_path: Option<String>,
365 },
366
367 PlanModeExited {
369 session_id: String,
371 approved: bool,
373 restored_mode: String,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
377 plan: Option<String>,
378 },
379
380 PlanFileUpdated {
382 session_id: String,
384 file_path: String,
386 content_summary: String,
388 },
389
390 RunnerProgress {
395 session_id: String,
397 round_count: u32,
399 },
400
401 SessionTitleUpdated {
403 session_id: String,
404 title: String,
405 title_version: u64,
406 source: TitleSource,
407 updated_at: chrono::DateTime<chrono::Utc>,
408 },
409
410 SessionPinnedUpdated {
416 session_id: String,
417 pinned: bool,
418 updated_at: chrono::DateTime<chrono::Utc>,
419 },
420
421 SessionCreated {
427 session_id: String,
428 title: String,
429 kind: bamboo_domain::SessionKind,
430 created_at: chrono::DateTime<chrono::Utc>,
431 },
432
433 SessionDeleted { session_id: String },
438
439 SessionCleared { session_id: String },
444
445 MessageAppended {
452 session_id: String,
453 message_id: String,
454 role: bamboo_domain::Role,
455 content: String,
456 created_at: chrono::DateTime<chrono::Utc>,
457 },
458
459 ExecutionStarted {
465 run_id: String,
467 session_id: String,
469 started_at: String,
471 },
472
473 ToolApprovalRequested {
480 tool_call_id: String,
482 tool_name: String,
484 parameters: serde_json::Value,
486 },
487
488 Complete {
490 usage: TokenUsage,
492 },
493
494 Cancelled {
496 #[serde(default, skip_serializing_if = "Option::is_none")]
498 message: Option<String>,
499 },
500
501 Error {
503 message: String,
505 },
506
507 Notification {
513 id: String,
515 session_id: String,
517 category: String,
520 priority: String,
522 title: String,
524 body: String,
526 #[serde(default, skip_serializing_if = "Option::is_none")]
528 dedup_key: Option<String>,
529 created_at: String,
531 },
532}
533
534impl AgentEvent {
535 pub fn session_id(&self) -> Option<&str> {
544 match self {
545 AgentEvent::TaskListUpdated { task_list } => Some(task_list.session_id.as_str()),
546 AgentEvent::TaskListItemProgress { session_id, .. }
547 | AgentEvent::TaskListCompleted { session_id, .. }
548 | AgentEvent::TaskEvaluationStarted { session_id, .. }
549 | AgentEvent::TaskEvaluationCompleted { session_id, .. }
550 | AgentEvent::GoldEvaluationStarted { session_id, .. }
551 | AgentEvent::GoldEvaluationCompleted { session_id, .. }
552 | AgentEvent::PlanModeEntered { session_id, .. }
553 | AgentEvent::PlanModeExited { session_id, .. }
554 | AgentEvent::PlanFileUpdated { session_id, .. }
555 | AgentEvent::RunnerProgress { session_id, .. }
556 | AgentEvent::SessionTitleUpdated { session_id, .. }
557 | AgentEvent::SessionPinnedUpdated { session_id, .. }
558 | AgentEvent::SessionCreated { session_id, .. }
559 | AgentEvent::SessionDeleted { session_id, .. }
560 | AgentEvent::SessionCleared { session_id, .. }
561 | AgentEvent::MessageAppended { session_id, .. }
562 | AgentEvent::ExecutionStarted { session_id, .. }
563 | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
564 AgentEvent::SubAgentStarted {
565 parent_session_id, ..
566 }
567 | AgentEvent::SubAgentEvent {
568 parent_session_id, ..
569 }
570 | AgentEvent::SubAgentHeartbeat {
571 parent_session_id, ..
572 }
573 | AgentEvent::SubAgentCompleted {
574 parent_session_id, ..
575 } => Some(parent_session_id.as_str()),
576 _ => None,
577 }
578 }
579
580 pub fn is_durable_change(&self) -> bool {
591 matches!(
592 self,
593 AgentEvent::MessageAppended { .. }
594 | AgentEvent::SessionCreated { .. }
595 | AgentEvent::SessionDeleted { .. }
596 | AgentEvent::SessionCleared { .. }
597 | AgentEvent::SessionTitleUpdated { .. }
598 | AgentEvent::SessionPinnedUpdated { .. }
599 | AgentEvent::TaskListUpdated { .. }
600 | AgentEvent::TaskListItemProgress { .. }
601 | AgentEvent::TaskListCompleted { .. }
602 | AgentEvent::TaskEvaluationCompleted { .. }
603 | AgentEvent::PlanModeEntered { .. }
604 | AgentEvent::PlanModeExited { .. }
605 | AgentEvent::PlanFileUpdated { .. }
606 | AgentEvent::SubAgentStarted { .. }
607 | AgentEvent::SubAgentCompleted { .. }
608 | AgentEvent::NeedClarification { .. }
609 | AgentEvent::ToolApprovalRequested { .. }
610 | AgentEvent::ExecutionStarted { .. }
611 | AgentEvent::Complete { .. }
612 | AgentEvent::Cancelled { .. }
613 | AgentEvent::Error { .. }
614 )
615 }
616}
617
618fn default_allow_custom() -> bool {
619 true
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
624#[serde(rename_all = "snake_case")]
625pub enum GoldCheckpoint {
626 PostRound,
627 Terminal,
628}
629
630impl GoldCheckpoint {
631 pub fn as_str(self) -> &'static str {
632 match self {
633 Self::PostRound => "post_round",
634 Self::Terminal => "terminal",
635 }
636 }
637}
638
639#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
641#[serde(rename_all = "snake_case")]
642pub enum GoldDecision {
643 Continue,
644 Achieved,
645 Blocked,
646 NeedInput,
647 Exhausted,
648}
649
650impl GoldDecision {
651 pub fn as_str(self) -> &'static str {
652 match self {
653 Self::Continue => "continue",
654 Self::Achieved => "achieved",
655 Self::Blocked => "blocked",
656 Self::NeedInput => "need_input",
657 Self::Exhausted => "exhausted",
658 }
659 }
660}
661
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
664#[serde(rename_all = "snake_case")]
665pub enum GoldConfidence {
666 Low,
667 Medium,
668 High,
669}
670
671impl GoldConfidence {
672 pub fn as_str(self) -> &'static str {
673 match self {
674 Self::Low => "low",
675 Self::Medium => "medium",
676 Self::High => "high",
677 }
678 }
679
680 pub fn rank(self) -> u8 {
682 match self {
683 Self::Low => 0,
684 Self::Medium => 1,
685 Self::High => 2,
686 }
687 }
688
689 pub fn meets(self, floor: GoldConfidence) -> bool {
691 self.rank() >= floor.rank()
692 }
693}
694
695#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
697#[serde(rename_all = "snake_case")]
698pub enum TitleSource {
699 Auto,
700 Manual,
701 Fallback,
702}
703
704pub use bamboo_domain::TokenUsage;
708
709pub use bamboo_domain::budget_types::TokenBudgetUsage;
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
715
716 fn sample_task_list() -> TaskList {
717 TaskList {
718 session_id: "session-1".to_string(),
719 title: "Task List".to_string(),
720 items: vec![TaskItem {
721 id: "task_1".to_string(),
722 description: "Implement event rename".to_string(),
723 status: TaskItemStatus::InProgress,
724 depends_on: Vec::new(),
725 notes: "Implementing".to_string(),
726 ..TaskItem::default()
727 }],
728 created_at: Utc::now(),
729 updated_at: Utc::now(),
730 }
731 }
732
733 #[test]
734 fn task_list_updated_serializes_with_task_names() {
735 let event = AgentEvent::TaskListUpdated {
736 task_list: sample_task_list(),
737 };
738
739 let value = serde_json::to_value(event).expect("event should serialize");
740 assert_eq!(value["type"], "task_list_updated");
741 assert!(value.get("task_list").is_some());
742 assert!(value.get("todo_list").is_none());
743 }
744
745 #[test]
746 fn cancelled_serializes_with_snake_case_type() {
747 let event = AgentEvent::Cancelled {
748 message: Some("Agent execution cancelled by user".to_string()),
749 };
750
751 let value = serde_json::to_value(event).expect("event should serialize");
752 assert_eq!(value["type"], "cancelled");
753 assert_eq!(
754 value["message"],
755 serde_json::Value::String("Agent execution cancelled by user".to_string())
756 );
757 }
758
759 #[test]
760 fn task_evaluation_completed_serializes_with_task_type() {
761 let event = AgentEvent::TaskEvaluationCompleted {
762 session_id: "session-1".to_string(),
763 updates_count: 2,
764 reasoning: "Updated statuses".to_string(),
765 };
766
767 let value = serde_json::to_value(event).expect("event should serialize");
768 assert_eq!(value["type"], "task_evaluation_completed");
769 }
770
771 #[test]
772 fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
773 let event = AgentEvent::GoldEvaluationCompleted {
774 session_id: "session-1".to_string(),
775 checkpoint: GoldCheckpoint::PostRound,
776 iteration: 3,
777 decision: GoldDecision::Continue,
778 confidence: GoldConfidence::Medium,
779 reasoning: "Need one more iteration".to_string(),
780 };
781
782 let value = serde_json::to_value(event).expect("event should serialize");
783 assert_eq!(value["type"], "gold_evaluation_completed");
784 assert_eq!(value["checkpoint"], "post_round");
785 assert_eq!(value["iteration"], 3);
786 assert_eq!(value["decision"], "continue");
787 assert_eq!(value["confidence"], "medium");
788 assert_eq!(value["reasoning"], "Need one more iteration");
789 }
790
791 #[test]
792 fn gold_evaluation_started_deserializes() {
793 let json = serde_json::json!({
794 "type": "gold_evaluation_started",
795 "session_id": "session-1",
796 "checkpoint": "terminal",
797 "iteration": 7
798 });
799
800 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
801 match event {
802 AgentEvent::GoldEvaluationStarted {
803 session_id,
804 checkpoint,
805 iteration,
806 } => {
807 assert_eq!(session_id, "session-1");
808 assert_eq!(checkpoint, GoldCheckpoint::Terminal);
809 assert_eq!(iteration, 7);
810 }
811 other => panic!("unexpected event: {other:?}"),
812 }
813 }
814
815 #[test]
816 fn context_compression_status_serializes_with_phase_and_status() {
817 let event = AgentEvent::ContextCompressionStatus {
818 phase: "mid-turn".to_string(),
819 status: "started".to_string(),
820 };
821
822 let value = serde_json::to_value(event).expect("event should serialize");
823 assert_eq!(value["type"], "context_compression_status");
824 assert_eq!(value["phase"], "mid-turn");
825 assert_eq!(value["status"], "started");
826 }
827
828 #[test]
829 fn need_clarification_serializes_with_new_fields() {
830 let event = AgentEvent::NeedClarification {
831 question: "Continue?".to_string(),
832 options: Some(vec!["Yes".to_string(), "No".to_string()]),
833 tool_call_id: Some("tool-1".to_string()),
834 tool_name: Some("conclusion_with_options".to_string()),
835 allow_custom: false,
836 };
837
838 let value = serde_json::to_value(event).expect("event should serialize");
839 assert_eq!(value["type"], "need_clarification");
840 assert_eq!(value["question"], "Continue?");
841 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
842 assert_eq!(value["tool_call_id"], "tool-1");
843 assert_eq!(value["tool_name"], "conclusion_with_options");
844 assert_eq!(value["allow_custom"], false);
845 }
846
847 #[test]
848 fn need_clarification_deserializes_from_old_format_without_new_fields() {
849 let json = serde_json::json!({
850 "type": "need_clarification",
851 "question": "Continue?",
852 "options": ["Yes", "No"]
853 });
854
855 let event: AgentEvent =
856 serde_json::from_value(json).expect("should deserialize old format");
857 match event {
858 AgentEvent::NeedClarification {
859 question,
860 options,
861 tool_call_id,
862 tool_name,
863 allow_custom,
864 } => {
865 assert_eq!(question, "Continue?");
866 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
867 assert_eq!(tool_call_id, None);
868 assert_eq!(tool_name, None);
869 assert!(allow_custom); }
871 other => panic!("unexpected event: {other:?}"),
872 }
873 }
874
875 #[test]
876 fn need_clarification_deserializes_with_allow_custom_false() {
877 let json = serde_json::json!({
878 "type": "need_clarification",
879 "question": "Pick one",
880 "allow_custom": false
881 });
882
883 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
884 match event {
885 AgentEvent::NeedClarification {
886 question,
887 options,
888 tool_call_id,
889 tool_name,
890 allow_custom,
891 } => {
892 assert_eq!(question, "Pick one");
893 assert_eq!(options, None);
894 assert_eq!(tool_call_id, None);
895 assert_eq!(tool_name, None);
896 assert!(!allow_custom);
897 }
898 other => panic!("unexpected event: {other:?}"),
899 }
900 }
901
902 #[test]
903 fn plan_mode_entered_serializes_correctly() {
904 let entered_at = Utc::now();
905 let event = AgentEvent::PlanModeEntered {
906 session_id: "sess-1".to_string(),
907 reason: Some("Complex refactor".to_string()),
908 pre_permission_mode: "default".to_string(),
909 entered_at,
910 status: bamboo_domain::PlanModeStatus::Exploring,
911 plan_file_path: None,
912 };
913
914 let value = serde_json::to_value(event).expect("event should serialize");
915 assert_eq!(value["type"], "plan_mode_entered");
916 assert_eq!(value["session_id"], "sess-1");
917 assert_eq!(value["reason"], "Complex refactor");
918 assert_eq!(value["pre_permission_mode"], "default");
919 assert_eq!(value["status"], "exploring");
920 assert_eq!(
923 value["entered_at"],
924 serde_json::to_value(entered_at).unwrap()
925 );
926 }
927
928 #[test]
929 fn plan_mode_exited_serializes_correctly() {
930 let event = AgentEvent::PlanModeExited {
931 session_id: "sess-1".to_string(),
932 approved: true,
933 restored_mode: "accept_edits".to_string(),
934 plan: Some("# Plan\n1. Step one".to_string()),
935 };
936
937 let value = serde_json::to_value(event).expect("event should serialize");
938 assert_eq!(value["type"], "plan_mode_exited");
939 assert_eq!(value["session_id"], "sess-1");
940 assert_eq!(value["approved"], true);
941 assert_eq!(value["restored_mode"], "accept_edits");
942 assert_eq!(value["plan"], "# Plan\n1. Step one");
943 }
944
945 #[test]
946 fn plan_file_updated_serializes_correctly() {
947 let event = AgentEvent::PlanFileUpdated {
948 session_id: "sess-1".to_string(),
949 file_path: "/tmp/plans/sess-1.md".to_string(),
950 content_summary: "Implementation plan for feature X".to_string(),
951 };
952
953 let value = serde_json::to_value(event).expect("event should serialize");
954 assert_eq!(value["type"], "plan_file_updated");
955 assert_eq!(value["session_id"], "sess-1");
956 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
957 assert_eq!(
958 value["content_summary"],
959 "Implementation plan for feature X"
960 );
961 }
962
963 #[test]
964 fn tool_approval_requested_serializes_correctly() {
965 let event = AgentEvent::ToolApprovalRequested {
966 tool_call_id: "call-abc".to_string(),
967 tool_name: "Write".to_string(),
968 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
969 };
970
971 let value = serde_json::to_value(event).expect("event should serialize");
972 assert_eq!(value["type"], "tool_approval_requested");
973 assert_eq!(value["tool_call_id"], "call-abc");
974 assert_eq!(value["tool_name"], "Write");
975 assert_eq!(
976 value["parameters"],
977 serde_json::json!({"file_path": "/tmp/test.txt"})
978 );
979 }
980
981 #[test]
982 fn tool_approval_requested_deserializes_correctly() {
983 let json = serde_json::json!({
984 "type": "tool_approval_requested",
985 "tool_call_id": "call-xyz",
986 "tool_name": "Bash",
987 "parameters": {"command": "ls -la"}
988 });
989
990 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
991 match event {
992 AgentEvent::ToolApprovalRequested {
993 tool_call_id,
994 tool_name,
995 parameters,
996 } => {
997 assert_eq!(tool_call_id, "call-xyz");
998 assert_eq!(tool_name, "Bash");
999 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1000 }
1001 other => panic!("unexpected event: {other:?}"),
1002 }
1003 }
1004
1005 #[test]
1006 fn session_title_updated_round_trips_with_source_variants() {
1007 use chrono::Utc;
1008 let event = AgentEvent::SessionTitleUpdated {
1009 session_id: "sess-1".to_string(),
1010 title: "My title".to_string(),
1011 title_version: 3,
1012 source: TitleSource::Auto,
1013 updated_at: Utc::now(),
1014 };
1015 let json = serde_json::to_string(&event).unwrap();
1016 assert!(
1017 json.contains("\"type\":\"session_title_updated\""),
1018 "json: {json}"
1019 );
1020 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1021 let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1022 }
1023
1024 #[test]
1025 fn plan_mode_events_deserialize_without_optional_fields() {
1026 let json = serde_json::json!({
1027 "type": "plan_mode_entered",
1028 "session_id": "sess-1",
1029 "pre_permission_mode": "default",
1030 "entered_at": "2025-01-01T00:00:00Z",
1031 "status": "exploring"
1032 });
1033
1034 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1035 match event {
1036 AgentEvent::PlanModeEntered {
1037 session_id,
1038 reason,
1039 pre_permission_mode,
1040 entered_at,
1041 status,
1042 plan_file_path,
1043 } => {
1044 assert_eq!(session_id, "sess-1");
1045 assert_eq!(reason, None);
1046 assert_eq!(pre_permission_mode, "default");
1047 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1048 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1049 assert_eq!(plan_file_path, None);
1050 }
1051 other => panic!("unexpected event: {other:?}"),
1052 }
1053 }
1054}