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 GoalStatusChanged {
277 session_id: String,
279 goal_state: serde_json::Value,
282 },
283
284 TokenBudgetUpdated {
286 usage: TokenBudgetUsage,
288 },
289
290 ContextCompressionStatus {
292 phase: String,
294 status: String,
296 },
297
298 ContextSummarized {
300 summary: String,
302 messages_summarized: usize,
304 tokens_saved: u32,
306 #[serde(default)]
308 usage_before_percent: f64,
309 #[serde(default)]
311 usage_after_percent: f64,
312 #[serde(default)]
314 trigger_type: String,
315 },
316
317 ContextPressureNotification {
320 percent: f64,
322 level: String,
324 message: String,
326 },
327
328 SubAgentStarted {
330 parent_session_id: String,
331 child_session_id: String,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 title: Option<String>,
335 },
336
337 SubAgentEvent {
341 parent_session_id: String,
342 child_session_id: String,
343 event: Box<AgentEvent>,
344 },
345
346 SubAgentHeartbeat {
348 parent_session_id: String,
349 child_session_id: String,
350 timestamp: DateTime<Utc>,
351 },
352
353 SubAgentCompleted {
355 parent_session_id: String,
356 child_session_id: String,
357 status: String,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
360 error: Option<String>,
361 },
362
363 PlanModeEntered {
365 session_id: String,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 reason: Option<String>,
370 pre_permission_mode: String,
372 entered_at: chrono::DateTime<chrono::Utc>,
374 status: bamboo_domain::PlanModeStatus,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
378 plan_file_path: Option<String>,
379 },
380
381 PlanModeExited {
383 session_id: String,
385 approved: bool,
387 restored_mode: String,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
391 plan: Option<String>,
392 },
393
394 PlanFileUpdated {
396 session_id: String,
398 file_path: String,
400 content_summary: String,
402 },
403
404 RunnerProgress {
409 session_id: String,
411 round_count: u32,
413 },
414
415 SessionTitleUpdated {
417 session_id: String,
418 title: String,
419 title_version: u64,
420 source: TitleSource,
421 updated_at: chrono::DateTime<chrono::Utc>,
422 },
423
424 SessionPinnedUpdated {
430 session_id: String,
431 pinned: bool,
432 updated_at: chrono::DateTime<chrono::Utc>,
433 },
434
435 SessionCreated {
441 session_id: String,
442 title: String,
443 kind: bamboo_domain::SessionKind,
444 created_at: chrono::DateTime<chrono::Utc>,
445 },
446
447 SessionDeleted { session_id: String },
452
453 SessionCleared { session_id: String },
458
459 MessageAppended {
466 session_id: String,
467 message_id: String,
468 role: bamboo_domain::Role,
469 content: String,
470 created_at: chrono::DateTime<chrono::Utc>,
471 },
472
473 ExecutionStarted {
479 run_id: String,
481 session_id: String,
483 started_at: String,
485 },
486
487 ToolApprovalRequested {
494 tool_call_id: String,
496 tool_name: String,
498 parameters: serde_json::Value,
500 },
501
502 Complete {
504 usage: TokenUsage,
506 },
507
508 Cancelled {
510 #[serde(default, skip_serializing_if = "Option::is_none")]
512 message: Option<String>,
513 },
514
515 Error {
517 message: String,
519 },
520
521 Notification {
527 id: String,
529 session_id: String,
531 category: String,
534 priority: String,
536 title: String,
538 body: String,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
542 dedup_key: Option<String>,
543 created_at: String,
545 },
546}
547
548impl AgentEvent {
549 pub fn session_id(&self) -> Option<&str> {
558 match self {
559 AgentEvent::TaskListUpdated { task_list } => Some(task_list.session_id.as_str()),
560 AgentEvent::TaskListItemProgress { session_id, .. }
561 | AgentEvent::TaskListCompleted { session_id, .. }
562 | AgentEvent::TaskEvaluationStarted { session_id, .. }
563 | AgentEvent::TaskEvaluationCompleted { session_id, .. }
564 | AgentEvent::GoldEvaluationStarted { session_id, .. }
565 | AgentEvent::GoldEvaluationCompleted { session_id, .. }
566 | AgentEvent::GoalStatusChanged { session_id, .. }
567 | AgentEvent::PlanModeEntered { session_id, .. }
568 | AgentEvent::PlanModeExited { session_id, .. }
569 | AgentEvent::PlanFileUpdated { session_id, .. }
570 | AgentEvent::RunnerProgress { session_id, .. }
571 | AgentEvent::SessionTitleUpdated { session_id, .. }
572 | AgentEvent::SessionPinnedUpdated { session_id, .. }
573 | AgentEvent::SessionCreated { session_id, .. }
574 | AgentEvent::SessionDeleted { session_id, .. }
575 | AgentEvent::SessionCleared { session_id, .. }
576 | AgentEvent::MessageAppended { session_id, .. }
577 | AgentEvent::ExecutionStarted { session_id, .. }
578 | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
579 AgentEvent::SubAgentStarted {
580 parent_session_id, ..
581 }
582 | AgentEvent::SubAgentEvent {
583 parent_session_id, ..
584 }
585 | AgentEvent::SubAgentHeartbeat {
586 parent_session_id, ..
587 }
588 | AgentEvent::SubAgentCompleted {
589 parent_session_id, ..
590 } => Some(parent_session_id.as_str()),
591 _ => None,
592 }
593 }
594
595 pub fn is_durable_change(&self) -> bool {
606 matches!(
607 self,
608 AgentEvent::MessageAppended { .. }
609 | AgentEvent::SessionCreated { .. }
610 | AgentEvent::SessionDeleted { .. }
611 | AgentEvent::SessionCleared { .. }
612 | AgentEvent::SessionTitleUpdated { .. }
613 | AgentEvent::SessionPinnedUpdated { .. }
614 | AgentEvent::TaskListUpdated { .. }
615 | AgentEvent::TaskListItemProgress { .. }
616 | AgentEvent::TaskListCompleted { .. }
617 | AgentEvent::TaskEvaluationCompleted { .. }
618 | AgentEvent::PlanModeEntered { .. }
619 | AgentEvent::PlanModeExited { .. }
620 | AgentEvent::PlanFileUpdated { .. }
621 | AgentEvent::SubAgentStarted { .. }
622 | AgentEvent::SubAgentCompleted { .. }
623 | AgentEvent::NeedClarification { .. }
624 | AgentEvent::ToolApprovalRequested { .. }
625 | AgentEvent::ExecutionStarted { .. }
626 | AgentEvent::Complete { .. }
627 | AgentEvent::Cancelled { .. }
628 | AgentEvent::Error { .. }
629 )
630 }
631}
632
633fn default_allow_custom() -> bool {
634 true
635}
636
637#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
639#[serde(rename_all = "snake_case")]
640pub enum GoldCheckpoint {
641 PostRound,
642 Terminal,
643}
644
645impl GoldCheckpoint {
646 pub fn as_str(self) -> &'static str {
647 match self {
648 Self::PostRound => "post_round",
649 Self::Terminal => "terminal",
650 }
651 }
652}
653
654#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
656#[serde(rename_all = "snake_case")]
657pub enum GoldDecision {
658 Continue,
659 Achieved,
660 Blocked,
661 NeedInput,
662 Exhausted,
663}
664
665impl GoldDecision {
666 pub fn as_str(self) -> &'static str {
667 match self {
668 Self::Continue => "continue",
669 Self::Achieved => "achieved",
670 Self::Blocked => "blocked",
671 Self::NeedInput => "need_input",
672 Self::Exhausted => "exhausted",
673 }
674 }
675}
676
677#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
679#[serde(rename_all = "snake_case")]
680pub enum GoldConfidence {
681 Low,
682 Medium,
683 High,
684}
685
686impl GoldConfidence {
687 pub fn as_str(self) -> &'static str {
688 match self {
689 Self::Low => "low",
690 Self::Medium => "medium",
691 Self::High => "high",
692 }
693 }
694
695 pub fn rank(self) -> u8 {
697 match self {
698 Self::Low => 0,
699 Self::Medium => 1,
700 Self::High => 2,
701 }
702 }
703
704 pub fn meets(self, floor: GoldConfidence) -> bool {
706 self.rank() >= floor.rank()
707 }
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
712#[serde(rename_all = "snake_case")]
713pub enum TitleSource {
714 Auto,
715 Manual,
716 Fallback,
717}
718
719pub use bamboo_domain::TokenUsage;
723
724pub use bamboo_domain::budget_types::TokenBudgetUsage;
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
730
731 fn sample_task_list() -> TaskList {
732 TaskList {
733 session_id: "session-1".to_string(),
734 title: "Task List".to_string(),
735 items: vec![TaskItem {
736 id: "task_1".to_string(),
737 description: "Implement event rename".to_string(),
738 status: TaskItemStatus::InProgress,
739 depends_on: Vec::new(),
740 notes: "Implementing".to_string(),
741 ..TaskItem::default()
742 }],
743 created_at: Utc::now(),
744 updated_at: Utc::now(),
745 }
746 }
747
748 #[test]
749 fn task_list_updated_serializes_with_task_names() {
750 let event = AgentEvent::TaskListUpdated {
751 task_list: sample_task_list(),
752 };
753
754 let value = serde_json::to_value(event).expect("event should serialize");
755 assert_eq!(value["type"], "task_list_updated");
756 assert!(value.get("task_list").is_some());
757 assert!(value.get("todo_list").is_none());
758 }
759
760 #[test]
761 fn cancelled_serializes_with_snake_case_type() {
762 let event = AgentEvent::Cancelled {
763 message: Some("Agent execution cancelled by user".to_string()),
764 };
765
766 let value = serde_json::to_value(event).expect("event should serialize");
767 assert_eq!(value["type"], "cancelled");
768 assert_eq!(
769 value["message"],
770 serde_json::Value::String("Agent execution cancelled by user".to_string())
771 );
772 }
773
774 #[test]
775 fn task_evaluation_completed_serializes_with_task_type() {
776 let event = AgentEvent::TaskEvaluationCompleted {
777 session_id: "session-1".to_string(),
778 updates_count: 2,
779 reasoning: "Updated statuses".to_string(),
780 };
781
782 let value = serde_json::to_value(event).expect("event should serialize");
783 assert_eq!(value["type"], "task_evaluation_completed");
784 }
785
786 #[test]
787 fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
788 let event = AgentEvent::GoldEvaluationCompleted {
789 session_id: "session-1".to_string(),
790 checkpoint: GoldCheckpoint::PostRound,
791 iteration: 3,
792 decision: GoldDecision::Continue,
793 confidence: GoldConfidence::Medium,
794 reasoning: "Need one more iteration".to_string(),
795 };
796
797 let value = serde_json::to_value(event).expect("event should serialize");
798 assert_eq!(value["type"], "gold_evaluation_completed");
799 assert_eq!(value["checkpoint"], "post_round");
800 assert_eq!(value["iteration"], 3);
801 assert_eq!(value["decision"], "continue");
802 assert_eq!(value["confidence"], "medium");
803 assert_eq!(value["reasoning"], "Need one more iteration");
804 }
805
806 #[test]
807 fn gold_evaluation_started_deserializes() {
808 let json = serde_json::json!({
809 "type": "gold_evaluation_started",
810 "session_id": "session-1",
811 "checkpoint": "terminal",
812 "iteration": 7
813 });
814
815 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
816 match event {
817 AgentEvent::GoldEvaluationStarted {
818 session_id,
819 checkpoint,
820 iteration,
821 } => {
822 assert_eq!(session_id, "session-1");
823 assert_eq!(checkpoint, GoldCheckpoint::Terminal);
824 assert_eq!(iteration, 7);
825 }
826 other => panic!("unexpected event: {other:?}"),
827 }
828 }
829
830 #[test]
831 fn context_compression_status_serializes_with_phase_and_status() {
832 let event = AgentEvent::ContextCompressionStatus {
833 phase: "mid-turn".to_string(),
834 status: "started".to_string(),
835 };
836
837 let value = serde_json::to_value(event).expect("event should serialize");
838 assert_eq!(value["type"], "context_compression_status");
839 assert_eq!(value["phase"], "mid-turn");
840 assert_eq!(value["status"], "started");
841 }
842
843 #[test]
844 fn need_clarification_serializes_with_new_fields() {
845 let event = AgentEvent::NeedClarification {
846 question: "Continue?".to_string(),
847 options: Some(vec!["Yes".to_string(), "No".to_string()]),
848 tool_call_id: Some("tool-1".to_string()),
849 tool_name: Some("conclusion_with_options".to_string()),
850 allow_custom: false,
851 };
852
853 let value = serde_json::to_value(event).expect("event should serialize");
854 assert_eq!(value["type"], "need_clarification");
855 assert_eq!(value["question"], "Continue?");
856 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
857 assert_eq!(value["tool_call_id"], "tool-1");
858 assert_eq!(value["tool_name"], "conclusion_with_options");
859 assert_eq!(value["allow_custom"], false);
860 }
861
862 #[test]
863 fn need_clarification_deserializes_from_old_format_without_new_fields() {
864 let json = serde_json::json!({
865 "type": "need_clarification",
866 "question": "Continue?",
867 "options": ["Yes", "No"]
868 });
869
870 let event: AgentEvent =
871 serde_json::from_value(json).expect("should deserialize old format");
872 match event {
873 AgentEvent::NeedClarification {
874 question,
875 options,
876 tool_call_id,
877 tool_name,
878 allow_custom,
879 } => {
880 assert_eq!(question, "Continue?");
881 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
882 assert_eq!(tool_call_id, None);
883 assert_eq!(tool_name, None);
884 assert!(allow_custom); }
886 other => panic!("unexpected event: {other:?}"),
887 }
888 }
889
890 #[test]
891 fn need_clarification_deserializes_with_allow_custom_false() {
892 let json = serde_json::json!({
893 "type": "need_clarification",
894 "question": "Pick one",
895 "allow_custom": false
896 });
897
898 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
899 match event {
900 AgentEvent::NeedClarification {
901 question,
902 options,
903 tool_call_id,
904 tool_name,
905 allow_custom,
906 } => {
907 assert_eq!(question, "Pick one");
908 assert_eq!(options, None);
909 assert_eq!(tool_call_id, None);
910 assert_eq!(tool_name, None);
911 assert!(!allow_custom);
912 }
913 other => panic!("unexpected event: {other:?}"),
914 }
915 }
916
917 #[test]
918 fn plan_mode_entered_serializes_correctly() {
919 let entered_at = Utc::now();
920 let event = AgentEvent::PlanModeEntered {
921 session_id: "sess-1".to_string(),
922 reason: Some("Complex refactor".to_string()),
923 pre_permission_mode: "default".to_string(),
924 entered_at,
925 status: bamboo_domain::PlanModeStatus::Exploring,
926 plan_file_path: None,
927 };
928
929 let value = serde_json::to_value(event).expect("event should serialize");
930 assert_eq!(value["type"], "plan_mode_entered");
931 assert_eq!(value["session_id"], "sess-1");
932 assert_eq!(value["reason"], "Complex refactor");
933 assert_eq!(value["pre_permission_mode"], "default");
934 assert_eq!(value["status"], "exploring");
935 assert_eq!(
938 value["entered_at"],
939 serde_json::to_value(entered_at).unwrap()
940 );
941 }
942
943 #[test]
944 fn plan_mode_exited_serializes_correctly() {
945 let event = AgentEvent::PlanModeExited {
946 session_id: "sess-1".to_string(),
947 approved: true,
948 restored_mode: "accept_edits".to_string(),
949 plan: Some("# Plan\n1. Step one".to_string()),
950 };
951
952 let value = serde_json::to_value(event).expect("event should serialize");
953 assert_eq!(value["type"], "plan_mode_exited");
954 assert_eq!(value["session_id"], "sess-1");
955 assert_eq!(value["approved"], true);
956 assert_eq!(value["restored_mode"], "accept_edits");
957 assert_eq!(value["plan"], "# Plan\n1. Step one");
958 }
959
960 #[test]
961 fn plan_file_updated_serializes_correctly() {
962 let event = AgentEvent::PlanFileUpdated {
963 session_id: "sess-1".to_string(),
964 file_path: "/tmp/plans/sess-1.md".to_string(),
965 content_summary: "Implementation plan for feature X".to_string(),
966 };
967
968 let value = serde_json::to_value(event).expect("event should serialize");
969 assert_eq!(value["type"], "plan_file_updated");
970 assert_eq!(value["session_id"], "sess-1");
971 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
972 assert_eq!(
973 value["content_summary"],
974 "Implementation plan for feature X"
975 );
976 }
977
978 #[test]
979 fn tool_approval_requested_serializes_correctly() {
980 let event = AgentEvent::ToolApprovalRequested {
981 tool_call_id: "call-abc".to_string(),
982 tool_name: "Write".to_string(),
983 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
984 };
985
986 let value = serde_json::to_value(event).expect("event should serialize");
987 assert_eq!(value["type"], "tool_approval_requested");
988 assert_eq!(value["tool_call_id"], "call-abc");
989 assert_eq!(value["tool_name"], "Write");
990 assert_eq!(
991 value["parameters"],
992 serde_json::json!({"file_path": "/tmp/test.txt"})
993 );
994 }
995
996 #[test]
997 fn tool_approval_requested_deserializes_correctly() {
998 let json = serde_json::json!({
999 "type": "tool_approval_requested",
1000 "tool_call_id": "call-xyz",
1001 "tool_name": "Bash",
1002 "parameters": {"command": "ls -la"}
1003 });
1004
1005 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1006 match event {
1007 AgentEvent::ToolApprovalRequested {
1008 tool_call_id,
1009 tool_name,
1010 parameters,
1011 } => {
1012 assert_eq!(tool_call_id, "call-xyz");
1013 assert_eq!(tool_name, "Bash");
1014 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1015 }
1016 other => panic!("unexpected event: {other:?}"),
1017 }
1018 }
1019
1020 #[test]
1021 fn session_title_updated_round_trips_with_source_variants() {
1022 use chrono::Utc;
1023 let event = AgentEvent::SessionTitleUpdated {
1024 session_id: "sess-1".to_string(),
1025 title: "My title".to_string(),
1026 title_version: 3,
1027 source: TitleSource::Auto,
1028 updated_at: Utc::now(),
1029 };
1030 let json = serde_json::to_string(&event).unwrap();
1031 assert!(
1032 json.contains("\"type\":\"session_title_updated\""),
1033 "json: {json}"
1034 );
1035 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1036 let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1037 }
1038
1039 #[test]
1040 fn plan_mode_events_deserialize_without_optional_fields() {
1041 let json = serde_json::json!({
1042 "type": "plan_mode_entered",
1043 "session_id": "sess-1",
1044 "pre_permission_mode": "default",
1045 "entered_at": "2025-01-01T00:00:00Z",
1046 "status": "exploring"
1047 });
1048
1049 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1050 match event {
1051 AgentEvent::PlanModeEntered {
1052 session_id,
1053 reason,
1054 pre_permission_mode,
1055 entered_at,
1056 status,
1057 plan_file_path,
1058 } => {
1059 assert_eq!(session_id, "sess-1");
1060 assert_eq!(reason, None);
1061 assert_eq!(pre_permission_mode, "default");
1062 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1063 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1064 assert_eq!(plan_file_path, None);
1065 }
1066 other => panic!("unexpected event: {other:?}"),
1067 }
1068 }
1069}