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
508impl AgentEvent {
509 pub fn session_id(&self) -> Option<&str> {
518 match self {
519 AgentEvent::TaskListUpdated { task_list } => Some(task_list.session_id.as_str()),
520 AgentEvent::TaskListItemProgress { session_id, .. }
521 | AgentEvent::TaskListCompleted { session_id, .. }
522 | AgentEvent::TaskEvaluationStarted { session_id, .. }
523 | AgentEvent::TaskEvaluationCompleted { session_id, .. }
524 | AgentEvent::GoldEvaluationStarted { session_id, .. }
525 | AgentEvent::GoldEvaluationCompleted { session_id, .. }
526 | AgentEvent::PlanModeEntered { session_id, .. }
527 | AgentEvent::PlanModeExited { session_id, .. }
528 | AgentEvent::PlanFileUpdated { session_id, .. }
529 | AgentEvent::RunnerProgress { session_id, .. }
530 | AgentEvent::SessionTitleUpdated { session_id, .. }
531 | AgentEvent::SessionPinnedUpdated { session_id, .. }
532 | AgentEvent::SessionCreated { session_id, .. }
533 | AgentEvent::SessionDeleted { session_id, .. }
534 | AgentEvent::SessionCleared { session_id, .. }
535 | AgentEvent::MessageAppended { session_id, .. }
536 | AgentEvent::ExecutionStarted { session_id, .. } => Some(session_id.as_str()),
537 AgentEvent::SubAgentStarted {
538 parent_session_id, ..
539 }
540 | AgentEvent::SubAgentEvent {
541 parent_session_id, ..
542 }
543 | AgentEvent::SubAgentHeartbeat {
544 parent_session_id, ..
545 }
546 | AgentEvent::SubAgentCompleted {
547 parent_session_id, ..
548 } => Some(parent_session_id.as_str()),
549 _ => None,
550 }
551 }
552
553 pub fn is_durable_change(&self) -> bool {
564 matches!(
565 self,
566 AgentEvent::MessageAppended { .. }
567 | AgentEvent::SessionCreated { .. }
568 | AgentEvent::SessionDeleted { .. }
569 | AgentEvent::SessionCleared { .. }
570 | AgentEvent::SessionTitleUpdated { .. }
571 | AgentEvent::SessionPinnedUpdated { .. }
572 | AgentEvent::TaskListUpdated { .. }
573 | AgentEvent::TaskListItemProgress { .. }
574 | AgentEvent::TaskListCompleted { .. }
575 | AgentEvent::TaskEvaluationCompleted { .. }
576 | AgentEvent::PlanModeEntered { .. }
577 | AgentEvent::PlanModeExited { .. }
578 | AgentEvent::PlanFileUpdated { .. }
579 | AgentEvent::SubAgentStarted { .. }
580 | AgentEvent::SubAgentCompleted { .. }
581 | AgentEvent::NeedClarification { .. }
582 | AgentEvent::ToolApprovalRequested { .. }
583 | AgentEvent::ExecutionStarted { .. }
584 | AgentEvent::Complete { .. }
585 | AgentEvent::Cancelled { .. }
586 | AgentEvent::Error { .. }
587 )
588 }
589}
590
591fn default_allow_custom() -> bool {
592 true
593}
594
595#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
597#[serde(rename_all = "snake_case")]
598pub enum GoldCheckpoint {
599 PostRound,
600 Terminal,
601}
602
603impl GoldCheckpoint {
604 pub fn as_str(self) -> &'static str {
605 match self {
606 Self::PostRound => "post_round",
607 Self::Terminal => "terminal",
608 }
609 }
610}
611
612#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
614#[serde(rename_all = "snake_case")]
615pub enum GoldDecision {
616 Continue,
617 Achieved,
618 Blocked,
619 NeedInput,
620 Exhausted,
621}
622
623impl GoldDecision {
624 pub fn as_str(self) -> &'static str {
625 match self {
626 Self::Continue => "continue",
627 Self::Achieved => "achieved",
628 Self::Blocked => "blocked",
629 Self::NeedInput => "need_input",
630 Self::Exhausted => "exhausted",
631 }
632 }
633}
634
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
637#[serde(rename_all = "snake_case")]
638pub enum GoldConfidence {
639 Low,
640 Medium,
641 High,
642}
643
644impl GoldConfidence {
645 pub fn as_str(self) -> &'static str {
646 match self {
647 Self::Low => "low",
648 Self::Medium => "medium",
649 Self::High => "high",
650 }
651 }
652
653 pub fn rank(self) -> u8 {
655 match self {
656 Self::Low => 0,
657 Self::Medium => 1,
658 Self::High => 2,
659 }
660 }
661
662 pub fn meets(self, floor: GoldConfidence) -> bool {
664 self.rank() >= floor.rank()
665 }
666}
667
668#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
670#[serde(rename_all = "snake_case")]
671pub enum TitleSource {
672 Auto,
673 Manual,
674 Fallback,
675}
676
677pub use bamboo_domain::TokenUsage;
681
682pub use bamboo_domain::budget_types::TokenBudgetUsage;
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
688
689 fn sample_task_list() -> TaskList {
690 TaskList {
691 session_id: "session-1".to_string(),
692 title: "Task List".to_string(),
693 items: vec![TaskItem {
694 id: "task_1".to_string(),
695 description: "Implement event rename".to_string(),
696 status: TaskItemStatus::InProgress,
697 depends_on: Vec::new(),
698 notes: "Implementing".to_string(),
699 ..TaskItem::default()
700 }],
701 created_at: Utc::now(),
702 updated_at: Utc::now(),
703 }
704 }
705
706 #[test]
707 fn task_list_updated_serializes_with_task_names() {
708 let event = AgentEvent::TaskListUpdated {
709 task_list: sample_task_list(),
710 };
711
712 let value = serde_json::to_value(event).expect("event should serialize");
713 assert_eq!(value["type"], "task_list_updated");
714 assert!(value.get("task_list").is_some());
715 assert!(value.get("todo_list").is_none());
716 }
717
718 #[test]
719 fn cancelled_serializes_with_snake_case_type() {
720 let event = AgentEvent::Cancelled {
721 message: Some("Agent execution cancelled by user".to_string()),
722 };
723
724 let value = serde_json::to_value(event).expect("event should serialize");
725 assert_eq!(value["type"], "cancelled");
726 assert_eq!(
727 value["message"],
728 serde_json::Value::String("Agent execution cancelled by user".to_string())
729 );
730 }
731
732 #[test]
733 fn task_evaluation_completed_serializes_with_task_type() {
734 let event = AgentEvent::TaskEvaluationCompleted {
735 session_id: "session-1".to_string(),
736 updates_count: 2,
737 reasoning: "Updated statuses".to_string(),
738 };
739
740 let value = serde_json::to_value(event).expect("event should serialize");
741 assert_eq!(value["type"], "task_evaluation_completed");
742 }
743
744 #[test]
745 fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
746 let event = AgentEvent::GoldEvaluationCompleted {
747 session_id: "session-1".to_string(),
748 checkpoint: GoldCheckpoint::PostRound,
749 iteration: 3,
750 decision: GoldDecision::Continue,
751 confidence: GoldConfidence::Medium,
752 reasoning: "Need one more iteration".to_string(),
753 };
754
755 let value = serde_json::to_value(event).expect("event should serialize");
756 assert_eq!(value["type"], "gold_evaluation_completed");
757 assert_eq!(value["checkpoint"], "post_round");
758 assert_eq!(value["iteration"], 3);
759 assert_eq!(value["decision"], "continue");
760 assert_eq!(value["confidence"], "medium");
761 assert_eq!(value["reasoning"], "Need one more iteration");
762 }
763
764 #[test]
765 fn gold_evaluation_started_deserializes() {
766 let json = serde_json::json!({
767 "type": "gold_evaluation_started",
768 "session_id": "session-1",
769 "checkpoint": "terminal",
770 "iteration": 7
771 });
772
773 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
774 match event {
775 AgentEvent::GoldEvaluationStarted {
776 session_id,
777 checkpoint,
778 iteration,
779 } => {
780 assert_eq!(session_id, "session-1");
781 assert_eq!(checkpoint, GoldCheckpoint::Terminal);
782 assert_eq!(iteration, 7);
783 }
784 other => panic!("unexpected event: {other:?}"),
785 }
786 }
787
788 #[test]
789 fn context_compression_status_serializes_with_phase_and_status() {
790 let event = AgentEvent::ContextCompressionStatus {
791 phase: "mid-turn".to_string(),
792 status: "started".to_string(),
793 };
794
795 let value = serde_json::to_value(event).expect("event should serialize");
796 assert_eq!(value["type"], "context_compression_status");
797 assert_eq!(value["phase"], "mid-turn");
798 assert_eq!(value["status"], "started");
799 }
800
801 #[test]
802 fn need_clarification_serializes_with_new_fields() {
803 let event = AgentEvent::NeedClarification {
804 question: "Continue?".to_string(),
805 options: Some(vec!["Yes".to_string(), "No".to_string()]),
806 tool_call_id: Some("tool-1".to_string()),
807 tool_name: Some("conclusion_with_options".to_string()),
808 allow_custom: false,
809 };
810
811 let value = serde_json::to_value(event).expect("event should serialize");
812 assert_eq!(value["type"], "need_clarification");
813 assert_eq!(value["question"], "Continue?");
814 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
815 assert_eq!(value["tool_call_id"], "tool-1");
816 assert_eq!(value["tool_name"], "conclusion_with_options");
817 assert_eq!(value["allow_custom"], false);
818 }
819
820 #[test]
821 fn need_clarification_deserializes_from_old_format_without_new_fields() {
822 let json = serde_json::json!({
823 "type": "need_clarification",
824 "question": "Continue?",
825 "options": ["Yes", "No"]
826 });
827
828 let event: AgentEvent =
829 serde_json::from_value(json).expect("should deserialize old format");
830 match event {
831 AgentEvent::NeedClarification {
832 question,
833 options,
834 tool_call_id,
835 tool_name,
836 allow_custom,
837 } => {
838 assert_eq!(question, "Continue?");
839 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
840 assert_eq!(tool_call_id, None);
841 assert_eq!(tool_name, None);
842 assert!(allow_custom); }
844 other => panic!("unexpected event: {other:?}"),
845 }
846 }
847
848 #[test]
849 fn need_clarification_deserializes_with_allow_custom_false() {
850 let json = serde_json::json!({
851 "type": "need_clarification",
852 "question": "Pick one",
853 "allow_custom": false
854 });
855
856 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
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, "Pick one");
866 assert_eq!(options, None);
867 assert_eq!(tool_call_id, None);
868 assert_eq!(tool_name, None);
869 assert!(!allow_custom);
870 }
871 other => panic!("unexpected event: {other:?}"),
872 }
873 }
874
875 #[test]
876 fn plan_mode_entered_serializes_correctly() {
877 let entered_at = Utc::now();
878 let event = AgentEvent::PlanModeEntered {
879 session_id: "sess-1".to_string(),
880 reason: Some("Complex refactor".to_string()),
881 pre_permission_mode: "default".to_string(),
882 entered_at,
883 status: bamboo_domain::PlanModeStatus::Exploring,
884 plan_file_path: None,
885 };
886
887 let value = serde_json::to_value(event).expect("event should serialize");
888 assert_eq!(value["type"], "plan_mode_entered");
889 assert_eq!(value["session_id"], "sess-1");
890 assert_eq!(value["reason"], "Complex refactor");
891 assert_eq!(value["pre_permission_mode"], "default");
892 assert_eq!(value["status"], "exploring");
893 assert_eq!(value["entered_at"], serde_json::to_value(entered_at).unwrap());
896 }
897
898 #[test]
899 fn plan_mode_exited_serializes_correctly() {
900 let event = AgentEvent::PlanModeExited {
901 session_id: "sess-1".to_string(),
902 approved: true,
903 restored_mode: "accept_edits".to_string(),
904 plan: Some("# Plan\n1. Step one".to_string()),
905 };
906
907 let value = serde_json::to_value(event).expect("event should serialize");
908 assert_eq!(value["type"], "plan_mode_exited");
909 assert_eq!(value["session_id"], "sess-1");
910 assert_eq!(value["approved"], true);
911 assert_eq!(value["restored_mode"], "accept_edits");
912 assert_eq!(value["plan"], "# Plan\n1. Step one");
913 }
914
915 #[test]
916 fn plan_file_updated_serializes_correctly() {
917 let event = AgentEvent::PlanFileUpdated {
918 session_id: "sess-1".to_string(),
919 file_path: "/tmp/plans/sess-1.md".to_string(),
920 content_summary: "Implementation plan for feature X".to_string(),
921 };
922
923 let value = serde_json::to_value(event).expect("event should serialize");
924 assert_eq!(value["type"], "plan_file_updated");
925 assert_eq!(value["session_id"], "sess-1");
926 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
927 assert_eq!(
928 value["content_summary"],
929 "Implementation plan for feature X"
930 );
931 }
932
933 #[test]
934 fn tool_approval_requested_serializes_correctly() {
935 let event = AgentEvent::ToolApprovalRequested {
936 tool_call_id: "call-abc".to_string(),
937 tool_name: "Write".to_string(),
938 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
939 };
940
941 let value = serde_json::to_value(event).expect("event should serialize");
942 assert_eq!(value["type"], "tool_approval_requested");
943 assert_eq!(value["tool_call_id"], "call-abc");
944 assert_eq!(value["tool_name"], "Write");
945 assert_eq!(
946 value["parameters"],
947 serde_json::json!({"file_path": "/tmp/test.txt"})
948 );
949 }
950
951 #[test]
952 fn tool_approval_requested_deserializes_correctly() {
953 let json = serde_json::json!({
954 "type": "tool_approval_requested",
955 "tool_call_id": "call-xyz",
956 "tool_name": "Bash",
957 "parameters": {"command": "ls -la"}
958 });
959
960 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
961 match event {
962 AgentEvent::ToolApprovalRequested {
963 tool_call_id,
964 tool_name,
965 parameters,
966 } => {
967 assert_eq!(tool_call_id, "call-xyz");
968 assert_eq!(tool_name, "Bash");
969 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
970 }
971 other => panic!("unexpected event: {other:?}"),
972 }
973 }
974
975 #[test]
976 fn session_title_updated_round_trips_with_source_variants() {
977 use chrono::Utc;
978 let event = AgentEvent::SessionTitleUpdated {
979 session_id: "sess-1".to_string(),
980 title: "My title".to_string(),
981 title_version: 3,
982 source: TitleSource::Auto,
983 updated_at: Utc::now(),
984 };
985 let json = serde_json::to_string(&event).unwrap();
986 assert!(
987 json.contains("\"type\":\"session_title_updated\""),
988 "json: {json}"
989 );
990 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
991 let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
992 }
993
994 #[test]
995 fn plan_mode_events_deserialize_without_optional_fields() {
996 let json = serde_json::json!({
997 "type": "plan_mode_entered",
998 "session_id": "sess-1",
999 "pre_permission_mode": "default",
1000 "entered_at": "2025-01-01T00:00:00Z",
1001 "status": "exploring"
1002 });
1003
1004 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1005 match event {
1006 AgentEvent::PlanModeEntered {
1007 session_id,
1008 reason,
1009 pre_permission_mode,
1010 entered_at,
1011 status,
1012 plan_file_path,
1013 } => {
1014 assert_eq!(session_id, "sess-1");
1015 assert_eq!(reason, None);
1016 assert_eq!(pre_permission_mode, "default");
1017 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1018 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1019 assert_eq!(plan_file_path, None);
1020 }
1021 other => panic!("unexpected event: {other:?}"),
1022 }
1023 }
1024}