1use crate::tools::ToolResult;
39use bamboo_domain::{TaskItemStatus, TaskList};
40use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
100#[serde(tag = "type", rename_all = "snake_case")]
101pub enum AgentEvent {
102 Token {
104 content: String,
106 },
107
108 ReasoningToken {
113 content: String,
115 },
116
117 ToolToken {
122 tool_call_id: String,
124 content: String,
126 },
127
128 ToolStart {
130 tool_call_id: String,
132 tool_name: String,
134 arguments: serde_json::Value,
136 },
137
138 ToolComplete {
140 tool_call_id: String,
142 result: ToolResult,
144 },
145
146 ToolError {
148 tool_call_id: String,
150 error: String,
152 },
153
154 ToolLifecycle {
160 tool_call_id: String,
162 tool_name: String,
164 phase: String,
166 #[serde(skip_serializing_if = "Option::is_none")]
168 elapsed_ms: Option<u64>,
169 is_mutating: bool,
171 auto_approved: bool,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 summary: Option<String>,
176 #[serde(skip_serializing_if = "Option::is_none")]
178 error: Option<String>,
179 },
180
181 NeedClarification {
183 question: String,
185 options: Option<Vec<String>>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 tool_call_id: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 tool_name: Option<String>,
193 #[serde(default = "default_allow_custom")]
195 allow_custom: bool,
196 },
197
198 TaskListUpdated {
200 task_list: TaskList,
202 },
203
204 TaskListItemProgress {
206 session_id: String,
208 item_id: String,
210 status: TaskItemStatus,
212 tool_calls_count: usize,
214 version: u64,
216 },
217
218 TaskListCompleted {
220 session_id: String,
222 completed_at: DateTime<Utc>,
224 total_rounds: u32,
226 total_tool_calls: usize,
228 },
229
230 TaskEvaluationStarted {
232 session_id: String,
234 items_count: usize,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 generation: Option<u64>,
239 },
240
241 TaskEvaluationCompleted {
243 session_id: String,
245 updates_count: usize,
247 reasoning: String,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 generation: Option<u64>,
252 },
253
254 TaskEvaluationCancelled {
257 session_id: String,
258 reason: String,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 generation: Option<u64>,
262 },
263
264 GoldEvaluationStarted {
266 session_id: String,
268 checkpoint: GoldCheckpoint,
270 iteration: u32,
272 },
273
274 GoldEvaluationCompleted {
276 session_id: String,
278 checkpoint: GoldCheckpoint,
280 iteration: u32,
282 decision: GoldDecision,
284 confidence: GoldConfidence,
286 reasoning: String,
288 },
289
290 GoldEvaluationCancelled { session_id: String, reason: String },
292
293 GoalStatusChanged {
300 session_id: String,
302 goal_state: serde_json::Value,
305 },
306
307 TokenBudgetUpdated {
309 usage: TokenBudgetUsage,
311 },
312
313 ContextCompressionStatus {
315 phase: String,
317 status: String,
319 },
320
321 ContextSummarized {
323 summary: String,
325 messages_summarized: usize,
327 tokens_saved: u32,
329 #[serde(default)]
331 usage_before_percent: f64,
332 #[serde(default)]
334 usage_after_percent: f64,
335 #[serde(default)]
337 trigger_type: String,
338 },
339
340 ContextPressureNotification {
343 percent: f64,
345 level: String,
347 message: String,
349 },
350
351 SubAgentStarted {
353 parent_session_id: String,
354 child_session_id: String,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
357 title: Option<String>,
358 },
359
360 SubAgentEvent {
364 parent_session_id: String,
365 child_session_id: String,
366 event: Box<AgentEvent>,
367 },
368
369 SubAgentHeartbeat {
371 parent_session_id: String,
372 child_session_id: String,
373 timestamp: DateTime<Utc>,
374 },
375
376 SubAgentCompleted {
378 parent_session_id: String,
379 child_session_id: String,
380 status: String,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 error: Option<String>,
384 },
385
386 BashCompleted {
400 bash_id: String,
402 command: String,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
406 exit_code: Option<i32>,
407 status: String,
409 },
410
411 PlanModeEntered {
413 session_id: String,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
417 reason: Option<String>,
418 pre_permission_mode: String,
420 entered_at: chrono::DateTime<chrono::Utc>,
422 status: bamboo_domain::PlanModeStatus,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
426 plan_file_path: Option<String>,
427 },
428
429 PlanModeExited {
431 session_id: String,
433 approved: bool,
435 restored_mode: String,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
439 plan: Option<String>,
440 },
441
442 PlanFileUpdated {
444 session_id: String,
446 file_path: String,
448 content_summary: String,
450 },
451
452 RunnerProgress {
457 session_id: String,
459 round_count: u32,
461 },
462
463 SessionTitleUpdated {
465 session_id: String,
466 title: String,
467 title_version: u64,
468 source: TitleSource,
469 updated_at: chrono::DateTime<chrono::Utc>,
470 },
471
472 SessionPinnedUpdated {
478 session_id: String,
479 pinned: bool,
480 updated_at: chrono::DateTime<chrono::Utc>,
481 },
482
483 SessionCreated {
489 session_id: String,
490 title: String,
491 kind: bamboo_domain::SessionKind,
492 created_at: chrono::DateTime<chrono::Utc>,
493 },
494
495 SessionDeleted { session_id: String },
500
501 SessionCleared { session_id: String },
506
507 MessageAppended {
514 session_id: String,
515 message_id: String,
516 role: bamboo_domain::Role,
517 content: String,
518 created_at: chrono::DateTime<chrono::Utc>,
519 },
520
521 ExecutionStarted {
527 run_id: String,
529 session_id: String,
531 started_at: String,
533 },
534
535 ToolApprovalRequested {
542 tool_call_id: String,
544 tool_name: String,
546 parameters: serde_json::Value,
548 },
549
550 ChildApprovalRequested {
556 child_session_id: String,
558 request_id: String,
560 tool_name: String,
562 permission: String,
564 resource: String,
566 },
567
568 ChildApprovalChanged {
570 parent_session_id: String,
571 child_session_id: String,
572 #[serde(default)]
575 child_attempt: u32,
576 request_id: String,
577 version: u64,
578 status: String,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
581 reason: Option<String>,
582 tool_name: String,
583 permission: String,
584 resource: String,
585 created_at: String,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 resolved_at: Option<String>,
588 },
589
590 BudgetExceeded {
600 session_id: String,
602 kind: String,
605 limit: u64,
607 actual: u64,
609 },
610
611 Complete {
613 usage: TokenUsage,
615 },
616
617 Cancelled {
619 #[serde(default, skip_serializing_if = "Option::is_none")]
621 message: Option<String>,
622 },
623
624 Error {
626 message: String,
628 },
629
630 WorkflowChanged {
632 workflow_id: String,
633 revision: u64,
634 scope: String,
635 },
636
637 WorkflowInvalid {
639 workflow_id: String,
640 revision: u64,
641 scope: String,
642 },
643
644 WorkflowRecovered {
646 workflow_id: String,
647 revision: u64,
648 scope: String,
649 },
650
651 WorkflowActivated {
653 event_id: String,
654 session_id: String,
655 workflow_id: String,
656 revision: u64,
657 invoked_by: String,
658 },
659
660 WorkflowDeactivated {
662 event_id: String,
663 session_id: String,
664 workflow_id: String,
665 revision: u64,
666 },
667
668 Notification {
674 id: String,
676 session_id: String,
678 category: String,
681 priority: String,
683 title: String,
685 body: String,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
689 dedup_key: Option<String>,
690 created_at: String,
692 },
693}
694
695impl AgentEvent {
696 pub fn session_id(&self) -> Option<&str> {
705 match self {
706 AgentEvent::TaskListUpdated { task_list } => Some(task_list.session_id.as_str()),
707 AgentEvent::TaskListItemProgress { session_id, .. }
708 | AgentEvent::TaskListCompleted { session_id, .. }
709 | AgentEvent::TaskEvaluationStarted { session_id, .. }
710 | AgentEvent::TaskEvaluationCompleted { session_id, .. }
711 | AgentEvent::TaskEvaluationCancelled { session_id, .. }
712 | AgentEvent::GoldEvaluationStarted { session_id, .. }
713 | AgentEvent::GoldEvaluationCompleted { session_id, .. }
714 | AgentEvent::GoldEvaluationCancelled { session_id, .. }
715 | AgentEvent::GoalStatusChanged { session_id, .. }
716 | AgentEvent::PlanModeEntered { session_id, .. }
717 | AgentEvent::PlanModeExited { session_id, .. }
718 | AgentEvent::PlanFileUpdated { session_id, .. }
719 | AgentEvent::RunnerProgress { session_id, .. }
720 | AgentEvent::SessionTitleUpdated { session_id, .. }
721 | AgentEvent::SessionPinnedUpdated { session_id, .. }
722 | AgentEvent::SessionCreated { session_id, .. }
723 | AgentEvent::SessionDeleted { session_id, .. }
724 | AgentEvent::SessionCleared { session_id, .. }
725 | AgentEvent::MessageAppended { session_id, .. }
726 | AgentEvent::ExecutionStarted { session_id, .. }
727 | AgentEvent::BudgetExceeded { session_id, .. }
728 | AgentEvent::WorkflowActivated { session_id, .. }
729 | AgentEvent::WorkflowDeactivated { session_id, .. }
730 | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
731 AgentEvent::SubAgentStarted {
732 parent_session_id, ..
733 }
734 | AgentEvent::SubAgentEvent {
735 parent_session_id, ..
736 }
737 | AgentEvent::SubAgentHeartbeat {
738 parent_session_id, ..
739 }
740 | AgentEvent::SubAgentCompleted {
741 parent_session_id, ..
742 }
743 | AgentEvent::ChildApprovalChanged {
744 parent_session_id, ..
745 } => Some(parent_session_id.as_str()),
746 _ => None,
747 }
748 }
749
750 pub fn is_durable_change(&self) -> bool {
761 matches!(
762 self,
763 AgentEvent::MessageAppended { .. }
764 | AgentEvent::SessionCreated { .. }
765 | AgentEvent::SessionDeleted { .. }
766 | AgentEvent::SessionCleared { .. }
767 | AgentEvent::SessionTitleUpdated { .. }
768 | AgentEvent::SessionPinnedUpdated { .. }
769 | AgentEvent::TaskListUpdated { .. }
770 | AgentEvent::TaskListItemProgress { .. }
771 | AgentEvent::TaskListCompleted { .. }
772 | AgentEvent::TaskEvaluationCompleted { .. }
773 | AgentEvent::TaskEvaluationCancelled { .. }
774 | AgentEvent::GoldEvaluationCancelled { .. }
775 | AgentEvent::PlanModeEntered { .. }
776 | AgentEvent::PlanModeExited { .. }
777 | AgentEvent::PlanFileUpdated { .. }
778 | AgentEvent::SubAgentStarted { .. }
779 | AgentEvent::SubAgentCompleted { .. }
780 | AgentEvent::ChildApprovalChanged { .. }
781 | AgentEvent::NeedClarification { .. }
782 | AgentEvent::ToolApprovalRequested { .. }
783 | AgentEvent::ExecutionStarted { .. }
784 | AgentEvent::BudgetExceeded { .. }
785 | AgentEvent::Complete { .. }
786 | AgentEvent::Cancelled { .. }
787 | AgentEvent::Error { .. }
788 | AgentEvent::WorkflowChanged { .. }
789 | AgentEvent::WorkflowInvalid { .. }
790 | AgentEvent::WorkflowRecovered { .. }
791 | AgentEvent::WorkflowActivated { .. }
792 | AgentEvent::WorkflowDeactivated { .. }
793 )
794 }
795}
796
797fn default_allow_custom() -> bool {
798 true
799}
800
801#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
803#[serde(rename_all = "snake_case")]
804pub enum GoldCheckpoint {
805 PostRound,
806 Terminal,
807}
808
809impl GoldCheckpoint {
810 pub fn as_str(self) -> &'static str {
811 match self {
812 Self::PostRound => "post_round",
813 Self::Terminal => "terminal",
814 }
815 }
816}
817
818#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
820#[serde(rename_all = "snake_case")]
821pub enum GoldDecision {
822 Continue,
823 Achieved,
824 Blocked,
825 NeedInput,
826 Exhausted,
827}
828
829impl GoldDecision {
830 pub fn as_str(self) -> &'static str {
831 match self {
832 Self::Continue => "continue",
833 Self::Achieved => "achieved",
834 Self::Blocked => "blocked",
835 Self::NeedInput => "need_input",
836 Self::Exhausted => "exhausted",
837 }
838 }
839}
840
841#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
843#[serde(rename_all = "snake_case")]
844pub enum GoldConfidence {
845 Low,
846 Medium,
847 High,
848}
849
850impl GoldConfidence {
851 pub fn as_str(self) -> &'static str {
852 match self {
853 Self::Low => "low",
854 Self::Medium => "medium",
855 Self::High => "high",
856 }
857 }
858
859 pub fn rank(self) -> u8 {
861 match self {
862 Self::Low => 0,
863 Self::Medium => 1,
864 Self::High => 2,
865 }
866 }
867
868 pub fn meets(self, floor: GoldConfidence) -> bool {
870 self.rank() >= floor.rank()
871 }
872}
873
874#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
876#[serde(rename_all = "snake_case")]
877pub enum TitleSource {
878 Auto,
879 Manual,
880 Fallback,
881}
882
883pub use bamboo_domain::TokenUsage;
887
888pub use bamboo_domain::budget_types::TokenBudgetUsage;
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
894
895 fn sample_task_list() -> TaskList {
896 TaskList {
897 session_id: "session-1".to_string(),
898 title: "Task List".to_string(),
899 items: vec![TaskItem {
900 id: "task_1".to_string(),
901 description: "Implement event rename".to_string(),
902 status: TaskItemStatus::InProgress,
903 depends_on: Vec::new(),
904 notes: "Implementing".to_string(),
905 ..TaskItem::default()
906 }],
907 created_at: Utc::now(),
908 updated_at: Utc::now(),
909 }
910 }
911
912 #[test]
913 fn task_list_updated_serializes_with_task_names() {
914 let event = AgentEvent::TaskListUpdated {
915 task_list: sample_task_list(),
916 };
917
918 let value = serde_json::to_value(event).expect("event should serialize");
919 assert_eq!(value["type"], "task_list_updated");
920 assert!(value.get("task_list").is_some());
921 assert!(value.get("todo_list").is_none());
922 }
923
924 #[test]
925 fn cancelled_serializes_with_snake_case_type() {
926 let event = AgentEvent::Cancelled {
927 message: Some("Agent execution cancelled by user".to_string()),
928 };
929
930 let value = serde_json::to_value(event).expect("event should serialize");
931 assert_eq!(value["type"], "cancelled");
932 assert_eq!(
933 value["message"],
934 serde_json::Value::String("Agent execution cancelled by user".to_string())
935 );
936 }
937
938 #[test]
939 fn task_evaluation_completed_serializes_with_task_type() {
940 let event = AgentEvent::TaskEvaluationCompleted {
941 session_id: "session-1".to_string(),
942 updates_count: 2,
943 reasoning: "Updated statuses".to_string(),
944 generation: Some(7),
945 };
946
947 let value = serde_json::to_value(event).expect("event should serialize");
948 assert_eq!(value["type"], "task_evaluation_completed");
949 assert_eq!(value["generation"], 7);
950 }
951
952 #[test]
953 fn task_evaluation_event_without_generation_remains_deserializable() {
954 let event: AgentEvent = serde_json::from_value(serde_json::json!({
955 "type": "task_evaluation_started",
956 "session_id": "session-1",
957 "items_count": 2
958 }))
959 .expect("legacy task evaluation frame should remain compatible");
960
961 assert!(matches!(
962 event,
963 AgentEvent::TaskEvaluationStarted {
964 generation: None,
965 ..
966 }
967 ));
968 }
969
970 #[test]
971 fn evaluation_cancelled_events_serialize_as_terminal_lifecycle_events() {
972 let task = AgentEvent::TaskEvaluationCancelled {
973 session_id: "session-1".to_string(),
974 reason: "run_suspended".to_string(),
975 generation: Some(7),
976 };
977 let gold = AgentEvent::GoldEvaluationCancelled {
978 session_id: "session-1".to_string(),
979 reason: "run_completed".to_string(),
980 };
981
982 assert!(task.is_durable_change());
983 assert!(gold.is_durable_change());
984 let task_value = serde_json::to_value(task).unwrap();
985 let gold_value = serde_json::to_value(gold).unwrap();
986 assert_eq!(task_value["type"], "task_evaluation_cancelled");
987 assert_eq!(task_value["reason"], "run_suspended");
988 assert_eq!(gold_value["type"], "gold_evaluation_cancelled");
989 assert_eq!(gold_value["reason"], "run_completed");
990 }
991
992 #[test]
993 fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
994 let event = AgentEvent::GoldEvaluationCompleted {
995 session_id: "session-1".to_string(),
996 checkpoint: GoldCheckpoint::PostRound,
997 iteration: 3,
998 decision: GoldDecision::Continue,
999 confidence: GoldConfidence::Medium,
1000 reasoning: "Need one more iteration".to_string(),
1001 };
1002
1003 let value = serde_json::to_value(event).expect("event should serialize");
1004 assert_eq!(value["type"], "gold_evaluation_completed");
1005 assert_eq!(value["checkpoint"], "post_round");
1006 assert_eq!(value["iteration"], 3);
1007 assert_eq!(value["decision"], "continue");
1008 assert_eq!(value["confidence"], "medium");
1009 assert_eq!(value["reasoning"], "Need one more iteration");
1010 }
1011
1012 #[test]
1013 fn gold_evaluation_started_deserializes() {
1014 let json = serde_json::json!({
1015 "type": "gold_evaluation_started",
1016 "session_id": "session-1",
1017 "checkpoint": "terminal",
1018 "iteration": 7
1019 });
1020
1021 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1022 match event {
1023 AgentEvent::GoldEvaluationStarted {
1024 session_id,
1025 checkpoint,
1026 iteration,
1027 } => {
1028 assert_eq!(session_id, "session-1");
1029 assert_eq!(checkpoint, GoldCheckpoint::Terminal);
1030 assert_eq!(iteration, 7);
1031 }
1032 other => panic!("unexpected event: {other:?}"),
1033 }
1034 }
1035
1036 #[test]
1037 fn context_compression_status_serializes_with_phase_and_status() {
1038 let event = AgentEvent::ContextCompressionStatus {
1039 phase: "mid-turn".to_string(),
1040 status: "started".to_string(),
1041 };
1042
1043 let value = serde_json::to_value(event).expect("event should serialize");
1044 assert_eq!(value["type"], "context_compression_status");
1045 assert_eq!(value["phase"], "mid-turn");
1046 assert_eq!(value["status"], "started");
1047 }
1048
1049 #[test]
1050 fn need_clarification_serializes_with_new_fields() {
1051 let event = AgentEvent::NeedClarification {
1052 question: "Continue?".to_string(),
1053 options: Some(vec!["Yes".to_string(), "No".to_string()]),
1054 tool_call_id: Some("tool-1".to_string()),
1055 tool_name: Some("conclusion_with_options".to_string()),
1056 allow_custom: false,
1057 };
1058
1059 let value = serde_json::to_value(event).expect("event should serialize");
1060 assert_eq!(value["type"], "need_clarification");
1061 assert_eq!(value["question"], "Continue?");
1062 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
1063 assert_eq!(value["tool_call_id"], "tool-1");
1064 assert_eq!(value["tool_name"], "conclusion_with_options");
1065 assert_eq!(value["allow_custom"], false);
1066 }
1067
1068 #[test]
1069 fn need_clarification_deserializes_from_old_format_without_new_fields() {
1070 let json = serde_json::json!({
1071 "type": "need_clarification",
1072 "question": "Continue?",
1073 "options": ["Yes", "No"]
1074 });
1075
1076 let event: AgentEvent =
1077 serde_json::from_value(json).expect("should deserialize old format");
1078 match event {
1079 AgentEvent::NeedClarification {
1080 question,
1081 options,
1082 tool_call_id,
1083 tool_name,
1084 allow_custom,
1085 } => {
1086 assert_eq!(question, "Continue?");
1087 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
1088 assert_eq!(tool_call_id, None);
1089 assert_eq!(tool_name, None);
1090 assert!(allow_custom); }
1092 other => panic!("unexpected event: {other:?}"),
1093 }
1094 }
1095
1096 #[test]
1097 fn need_clarification_deserializes_with_allow_custom_false() {
1098 let json = serde_json::json!({
1099 "type": "need_clarification",
1100 "question": "Pick one",
1101 "allow_custom": false
1102 });
1103
1104 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1105 match event {
1106 AgentEvent::NeedClarification {
1107 question,
1108 options,
1109 tool_call_id,
1110 tool_name,
1111 allow_custom,
1112 } => {
1113 assert_eq!(question, "Pick one");
1114 assert_eq!(options, None);
1115 assert_eq!(tool_call_id, None);
1116 assert_eq!(tool_name, None);
1117 assert!(!allow_custom);
1118 }
1119 other => panic!("unexpected event: {other:?}"),
1120 }
1121 }
1122
1123 #[test]
1124 fn plan_mode_entered_serializes_correctly() {
1125 let entered_at = Utc::now();
1126 let event = AgentEvent::PlanModeEntered {
1127 session_id: "sess-1".to_string(),
1128 reason: Some("Complex refactor".to_string()),
1129 pre_permission_mode: "default".to_string(),
1130 entered_at,
1131 status: bamboo_domain::PlanModeStatus::Exploring,
1132 plan_file_path: None,
1133 };
1134
1135 let value = serde_json::to_value(event).expect("event should serialize");
1136 assert_eq!(value["type"], "plan_mode_entered");
1137 assert_eq!(value["session_id"], "sess-1");
1138 assert_eq!(value["reason"], "Complex refactor");
1139 assert_eq!(value["pre_permission_mode"], "default");
1140 assert_eq!(value["status"], "exploring");
1141 assert_eq!(
1144 value["entered_at"],
1145 serde_json::to_value(entered_at).unwrap()
1146 );
1147 }
1148
1149 #[test]
1150 fn plan_mode_exited_serializes_correctly() {
1151 let event = AgentEvent::PlanModeExited {
1152 session_id: "sess-1".to_string(),
1153 approved: true,
1154 restored_mode: "accept_edits".to_string(),
1155 plan: Some("# Plan\n1. Step one".to_string()),
1156 };
1157
1158 let value = serde_json::to_value(event).expect("event should serialize");
1159 assert_eq!(value["type"], "plan_mode_exited");
1160 assert_eq!(value["session_id"], "sess-1");
1161 assert_eq!(value["approved"], true);
1162 assert_eq!(value["restored_mode"], "accept_edits");
1163 assert_eq!(value["plan"], "# Plan\n1. Step one");
1164 }
1165
1166 #[test]
1167 fn plan_file_updated_serializes_correctly() {
1168 let event = AgentEvent::PlanFileUpdated {
1169 session_id: "sess-1".to_string(),
1170 file_path: "/tmp/plans/sess-1.md".to_string(),
1171 content_summary: "Implementation plan for feature X".to_string(),
1172 };
1173
1174 let value = serde_json::to_value(event).expect("event should serialize");
1175 assert_eq!(value["type"], "plan_file_updated");
1176 assert_eq!(value["session_id"], "sess-1");
1177 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
1178 assert_eq!(
1179 value["content_summary"],
1180 "Implementation plan for feature X"
1181 );
1182 }
1183
1184 #[test]
1185 fn tool_approval_requested_serializes_correctly() {
1186 let event = AgentEvent::ToolApprovalRequested {
1187 tool_call_id: "call-abc".to_string(),
1188 tool_name: "Write".to_string(),
1189 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
1190 };
1191
1192 let value = serde_json::to_value(event).expect("event should serialize");
1193 assert_eq!(value["type"], "tool_approval_requested");
1194 assert_eq!(value["tool_call_id"], "call-abc");
1195 assert_eq!(value["tool_name"], "Write");
1196 assert_eq!(
1197 value["parameters"],
1198 serde_json::json!({"file_path": "/tmp/test.txt"})
1199 );
1200 }
1201
1202 #[test]
1203 fn child_approval_changed_routes_to_parent_and_is_durable() {
1204 let event = AgentEvent::ChildApprovalChanged {
1205 parent_session_id: "parent-1".into(),
1206 child_session_id: "child-1".into(),
1207 child_attempt: 3,
1208 request_id: "req-1".into(),
1209 version: 2,
1210 status: "approved".into(),
1211 reason: None,
1212 tool_name: "Bash".into(),
1213 permission: "execute".into(),
1214 resource: "/tmp/x".into(),
1215 created_at: "2026-01-01T00:00:00Z".into(),
1216 resolved_at: Some("2026-01-01T00:00:01Z".into()),
1217 };
1218 assert_eq!(event.session_id(), Some("parent-1"));
1219 assert!(event.is_durable_change());
1220 let value = serde_json::to_value(event).unwrap();
1221 assert_eq!(value["type"], "child_approval_changed");
1222 assert_eq!(value["status"], "approved");
1223 assert_eq!(value["child_attempt"], 3);
1224
1225 let mut legacy = value;
1226 legacy.as_object_mut().unwrap().remove("child_attempt");
1227 let restored: AgentEvent = serde_json::from_value(legacy).unwrap();
1228 assert!(matches!(
1229 restored,
1230 AgentEvent::ChildApprovalChanged {
1231 child_attempt: 0,
1232 ..
1233 }
1234 ));
1235 }
1236
1237 #[test]
1238 fn tool_approval_requested_deserializes_correctly() {
1239 let json = serde_json::json!({
1240 "type": "tool_approval_requested",
1241 "tool_call_id": "call-xyz",
1242 "tool_name": "Bash",
1243 "parameters": {"command": "ls -la"}
1244 });
1245
1246 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1247 match event {
1248 AgentEvent::ToolApprovalRequested {
1249 tool_call_id,
1250 tool_name,
1251 parameters,
1252 } => {
1253 assert_eq!(tool_call_id, "call-xyz");
1254 assert_eq!(tool_name, "Bash");
1255 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1256 }
1257 other => panic!("unexpected event: {other:?}"),
1258 }
1259 }
1260
1261 #[test]
1262 fn session_title_updated_round_trips_with_source_variants() {
1263 use chrono::Utc;
1264 let event = AgentEvent::SessionTitleUpdated {
1265 session_id: "sess-1".to_string(),
1266 title: "My title".to_string(),
1267 title_version: 3,
1268 source: TitleSource::Auto,
1269 updated_at: Utc::now(),
1270 };
1271 let json = serde_json::to_string(&event).unwrap();
1272 assert!(
1273 json.contains("\"type\":\"session_title_updated\""),
1274 "json: {json}"
1275 );
1276 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1277 let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1278 }
1279
1280 #[test]
1281 fn plan_mode_events_deserialize_without_optional_fields() {
1282 let json = serde_json::json!({
1283 "type": "plan_mode_entered",
1284 "session_id": "sess-1",
1285 "pre_permission_mode": "default",
1286 "entered_at": "2025-01-01T00:00:00Z",
1287 "status": "exploring"
1288 });
1289
1290 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1291 match event {
1292 AgentEvent::PlanModeEntered {
1293 session_id,
1294 reason,
1295 pre_permission_mode,
1296 entered_at,
1297 status,
1298 plan_file_path,
1299 } => {
1300 assert_eq!(session_id, "sess-1");
1301 assert_eq!(reason, None);
1302 assert_eq!(pre_permission_mode, "default");
1303 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1304 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1305 assert_eq!(plan_file_path, None);
1306 }
1307 other => panic!("unexpected event: {other:?}"),
1308 }
1309 }
1310
1311 #[test]
1312 fn workflow_catalog_events_are_durable_and_account_scoped() {
1313 for event in [
1314 AgentEvent::WorkflowChanged {
1315 workflow_id: "review".to_string(),
1316 revision: 2,
1317 scope: "global".to_string(),
1318 },
1319 AgentEvent::WorkflowInvalid {
1320 workflow_id: "review".to_string(),
1321 revision: 3,
1322 scope: "workspace:1234".to_string(),
1323 },
1324 AgentEvent::WorkflowRecovered {
1325 workflow_id: "review".to_string(),
1326 revision: 4,
1327 scope: "workspace:1234".to_string(),
1328 },
1329 ] {
1330 assert!(event.is_durable_change());
1331 assert_eq!(event.session_id(), None);
1332 let encoded = serde_json::to_string(&event).expect("serialize");
1333 let _: AgentEvent = serde_json::from_str(&encoded).expect("deserialize");
1334 }
1335 }
1336}