1use crate::tools::ToolResult;
39use bamboo_domain::{AgentHookPoint, HookResult, 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 HookLifecycle {
183 hook_name: String,
185 point: AgentHookPoint,
187 phase: String,
190 duration_ms: u64,
192 decision: HookResult,
194 },
195
196 NeedClarification {
198 question: String,
200 options: Option<Vec<String>>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 tool_call_id: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 tool_name: Option<String>,
208 #[serde(default = "default_allow_custom")]
210 allow_custom: bool,
211 },
212
213 TaskListUpdated {
215 task_list: TaskList,
217 },
218
219 TaskListItemProgress {
221 session_id: String,
223 item_id: String,
225 status: TaskItemStatus,
227 tool_calls_count: usize,
229 version: u64,
231 },
232
233 TaskListCompleted {
235 session_id: String,
237 completed_at: DateTime<Utc>,
239 total_rounds: u32,
241 total_tool_calls: usize,
243 },
244
245 TaskEvaluationStarted {
247 session_id: String,
249 items_count: usize,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 generation: Option<u64>,
254 },
255
256 TaskEvaluationCompleted {
258 session_id: String,
260 updates_count: usize,
262 reasoning: String,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 generation: Option<u64>,
267 },
268
269 TaskEvaluationCancelled {
272 session_id: String,
273 reason: String,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 generation: Option<u64>,
277 },
278
279 GoldEvaluationStarted {
281 session_id: String,
283 checkpoint: GoldCheckpoint,
285 iteration: u32,
287 },
288
289 GoldEvaluationCompleted {
291 session_id: String,
293 checkpoint: GoldCheckpoint,
295 iteration: u32,
297 decision: GoldDecision,
299 confidence: GoldConfidence,
301 reasoning: String,
303 },
304
305 GoldEvaluationCancelled { session_id: String, reason: String },
307
308 GoalStatusChanged {
315 session_id: String,
317 goal_state: serde_json::Value,
320 },
321
322 TokenBudgetUpdated {
324 usage: TokenBudgetUsage,
326 },
327
328 ContextCompressionStatus {
330 phase: String,
332 status: String,
334 },
335
336 ContextSummarized {
338 summary: String,
340 messages_summarized: usize,
342 tokens_saved: u32,
344 #[serde(default)]
346 usage_before_percent: f64,
347 #[serde(default)]
349 usage_after_percent: f64,
350 #[serde(default)]
352 trigger_type: String,
353 },
354
355 ContextPressureNotification {
358 percent: f64,
360 level: String,
362 message: String,
364 },
365
366 SubAgentStarted {
368 parent_session_id: String,
369 child_session_id: String,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
372 title: Option<String>,
373 },
374
375 SubAgentEvent {
379 parent_session_id: String,
380 child_session_id: String,
381 event: Box<AgentEvent>,
382 },
383
384 SubAgentHeartbeat {
386 parent_session_id: String,
387 child_session_id: String,
388 timestamp: DateTime<Utc>,
389 },
390
391 SubAgentCompleted {
393 parent_session_id: String,
394 child_session_id: String,
395 status: String,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 error: Option<String>,
399 },
400
401 BashCompleted {
415 bash_id: String,
417 command: String,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
421 exit_code: Option<i32>,
422 status: String,
424 },
425
426 PlanModeEntered {
428 session_id: String,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
432 reason: Option<String>,
433 pre_permission_mode: String,
435 entered_at: chrono::DateTime<chrono::Utc>,
437 status: bamboo_domain::PlanModeStatus,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
441 plan_file_path: Option<String>,
442 },
443
444 PlanModeExited {
446 session_id: String,
448 approved: bool,
450 restored_mode: String,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
454 plan: Option<String>,
455 },
456
457 PlanFileUpdated {
459 session_id: String,
461 file_path: String,
463 content_summary: String,
465 },
466
467 RunnerProgress {
472 session_id: String,
474 round_count: u32,
476 },
477
478 SessionTitleUpdated {
480 session_id: String,
481 title: String,
482 title_version: u64,
483 source: TitleSource,
484 updated_at: chrono::DateTime<chrono::Utc>,
485 },
486
487 SessionPinnedUpdated {
493 session_id: String,
494 pinned: bool,
495 updated_at: chrono::DateTime<chrono::Utc>,
496 },
497
498 SessionCreated {
504 session_id: String,
505 title: String,
506 kind: bamboo_domain::SessionKind,
507 created_at: chrono::DateTime<chrono::Utc>,
508 },
509
510 SessionDeleted { session_id: String },
515
516 SessionCleared { session_id: String },
521
522 MessageAppended {
529 session_id: String,
530 message_id: String,
531 role: bamboo_domain::Role,
532 content: String,
533 created_at: chrono::DateTime<chrono::Utc>,
534 },
535
536 ExecutionStarted {
542 run_id: String,
544 session_id: String,
546 started_at: String,
548 },
549
550 ToolApprovalRequested {
557 tool_call_id: String,
559 tool_name: String,
561 parameters: serde_json::Value,
563 },
564
565 ChildApprovalRequested {
571 child_session_id: String,
573 request_id: String,
575 tool_name: String,
577 permission: String,
579 resource: String,
581 },
582
583 ChildApprovalChanged {
585 parent_session_id: String,
586 child_session_id: String,
587 #[serde(default)]
590 child_attempt: u32,
591 request_id: String,
592 version: u64,
593 status: String,
595 #[serde(default, skip_serializing_if = "Option::is_none")]
596 reason: Option<String>,
597 tool_name: String,
598 permission: String,
599 resource: String,
600 created_at: String,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 resolved_at: Option<String>,
603 },
604
605 BudgetExceeded {
615 session_id: String,
617 kind: String,
620 limit: u64,
622 actual: u64,
624 },
625
626 Complete {
628 usage: TokenUsage,
630 },
631
632 Cancelled {
634 #[serde(default, skip_serializing_if = "Option::is_none")]
636 message: Option<String>,
637 },
638
639 Error {
641 message: String,
643 },
644
645 WorkflowChanged {
647 workflow_id: String,
648 revision: u64,
649 scope: String,
650 },
651
652 WorkflowInvalid {
654 workflow_id: String,
655 revision: u64,
656 scope: String,
657 },
658
659 WorkflowRecovered {
661 workflow_id: String,
662 revision: u64,
663 scope: String,
664 },
665
666 #[serde(rename = "config.changed")]
668 ConfigChanged { section: String, revision: u64 },
669
670 #[serde(rename = "config.invalid")]
672 ConfigInvalid { section: String, revision: u64 },
673
674 #[serde(rename = "config.recovered")]
676 ConfigRecovered { section: String, revision: u64 },
677
678 WorkflowActivated {
680 event_id: String,
681 session_id: String,
682 workflow_id: String,
683 revision: u64,
684 invoked_by: String,
685 },
686
687 WorkflowDeactivated {
689 event_id: String,
690 session_id: String,
691 workflow_id: String,
692 revision: u64,
693 },
694
695 Notification {
701 id: String,
703 session_id: String,
705 category: String,
708 priority: String,
710 title: String,
712 body: String,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
716 dedup_key: Option<String>,
717 created_at: String,
719 },
720}
721
722impl AgentEvent {
723 pub fn session_id(&self) -> Option<&str> {
732 match self {
733 AgentEvent::TaskListUpdated { task_list } => Some(task_list.session_id.as_str()),
734 AgentEvent::TaskListItemProgress { session_id, .. }
735 | AgentEvent::TaskListCompleted { session_id, .. }
736 | AgentEvent::TaskEvaluationStarted { session_id, .. }
737 | AgentEvent::TaskEvaluationCompleted { session_id, .. }
738 | AgentEvent::TaskEvaluationCancelled { session_id, .. }
739 | AgentEvent::GoldEvaluationStarted { session_id, .. }
740 | AgentEvent::GoldEvaluationCompleted { session_id, .. }
741 | AgentEvent::GoldEvaluationCancelled { session_id, .. }
742 | AgentEvent::GoalStatusChanged { session_id, .. }
743 | AgentEvent::PlanModeEntered { session_id, .. }
744 | AgentEvent::PlanModeExited { session_id, .. }
745 | AgentEvent::PlanFileUpdated { session_id, .. }
746 | AgentEvent::RunnerProgress { session_id, .. }
747 | AgentEvent::SessionTitleUpdated { session_id, .. }
748 | AgentEvent::SessionPinnedUpdated { session_id, .. }
749 | AgentEvent::SessionCreated { session_id, .. }
750 | AgentEvent::SessionDeleted { session_id, .. }
751 | AgentEvent::SessionCleared { session_id, .. }
752 | AgentEvent::MessageAppended { session_id, .. }
753 | AgentEvent::ExecutionStarted { session_id, .. }
754 | AgentEvent::BudgetExceeded { session_id, .. }
755 | AgentEvent::WorkflowActivated { session_id, .. }
756 | AgentEvent::WorkflowDeactivated { session_id, .. }
757 | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
758 AgentEvent::SubAgentStarted {
759 parent_session_id, ..
760 }
761 | AgentEvent::SubAgentEvent {
762 parent_session_id, ..
763 }
764 | AgentEvent::SubAgentHeartbeat {
765 parent_session_id, ..
766 }
767 | AgentEvent::SubAgentCompleted {
768 parent_session_id, ..
769 }
770 | AgentEvent::ChildApprovalChanged {
771 parent_session_id, ..
772 } => Some(parent_session_id.as_str()),
773 _ => None,
774 }
775 }
776
777 pub fn is_durable_change(&self) -> bool {
788 matches!(
789 self,
790 AgentEvent::MessageAppended { .. }
791 | AgentEvent::SessionCreated { .. }
792 | AgentEvent::SessionDeleted { .. }
793 | AgentEvent::SessionCleared { .. }
794 | AgentEvent::SessionTitleUpdated { .. }
795 | AgentEvent::SessionPinnedUpdated { .. }
796 | AgentEvent::TaskListUpdated { .. }
797 | AgentEvent::TaskListItemProgress { .. }
798 | AgentEvent::TaskListCompleted { .. }
799 | AgentEvent::TaskEvaluationCompleted { .. }
800 | AgentEvent::TaskEvaluationCancelled { .. }
801 | AgentEvent::GoldEvaluationCancelled { .. }
802 | AgentEvent::PlanModeEntered { .. }
803 | AgentEvent::PlanModeExited { .. }
804 | AgentEvent::PlanFileUpdated { .. }
805 | AgentEvent::SubAgentStarted { .. }
806 | AgentEvent::SubAgentCompleted { .. }
807 | AgentEvent::ChildApprovalChanged { .. }
808 | AgentEvent::NeedClarification { .. }
809 | AgentEvent::ToolApprovalRequested { .. }
810 | AgentEvent::ExecutionStarted { .. }
811 | AgentEvent::BudgetExceeded { .. }
812 | AgentEvent::Complete { .. }
813 | AgentEvent::Cancelled { .. }
814 | AgentEvent::Error { .. }
815 | AgentEvent::WorkflowChanged { .. }
816 | AgentEvent::WorkflowInvalid { .. }
817 | AgentEvent::ConfigChanged { .. }
818 | AgentEvent::ConfigInvalid { .. }
819 | AgentEvent::ConfigRecovered { .. }
820 | AgentEvent::WorkflowRecovered { .. }
821 | AgentEvent::WorkflowActivated { .. }
822 | AgentEvent::WorkflowDeactivated { .. }
823 )
824 }
825}
826
827fn default_allow_custom() -> bool {
828 true
829}
830
831#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
833#[serde(rename_all = "snake_case")]
834pub enum GoldCheckpoint {
835 PostRound,
836 Terminal,
837}
838
839impl GoldCheckpoint {
840 pub fn as_str(self) -> &'static str {
841 match self {
842 Self::PostRound => "post_round",
843 Self::Terminal => "terminal",
844 }
845 }
846}
847
848#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
850#[serde(rename_all = "snake_case")]
851pub enum GoldDecision {
852 Continue,
853 Achieved,
854 Blocked,
855 NeedInput,
856 Exhausted,
857}
858
859impl GoldDecision {
860 pub fn as_str(self) -> &'static str {
861 match self {
862 Self::Continue => "continue",
863 Self::Achieved => "achieved",
864 Self::Blocked => "blocked",
865 Self::NeedInput => "need_input",
866 Self::Exhausted => "exhausted",
867 }
868 }
869}
870
871#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
873#[serde(rename_all = "snake_case")]
874pub enum GoldConfidence {
875 Low,
876 Medium,
877 High,
878}
879
880impl GoldConfidence {
881 pub fn as_str(self) -> &'static str {
882 match self {
883 Self::Low => "low",
884 Self::Medium => "medium",
885 Self::High => "high",
886 }
887 }
888
889 pub fn rank(self) -> u8 {
891 match self {
892 Self::Low => 0,
893 Self::Medium => 1,
894 Self::High => 2,
895 }
896 }
897
898 pub fn meets(self, floor: GoldConfidence) -> bool {
900 self.rank() >= floor.rank()
901 }
902}
903
904#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
906#[serde(rename_all = "snake_case")]
907pub enum TitleSource {
908 Auto,
909 Manual,
910 Fallback,
911}
912
913pub use bamboo_domain::TokenUsage;
917
918pub use bamboo_domain::budget_types::TokenBudgetUsage;
919
920#[cfg(test)]
921mod tests {
922 use super::*;
923 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
924
925 fn sample_task_list() -> TaskList {
926 TaskList {
927 session_id: "session-1".to_string(),
928 title: "Task List".to_string(),
929 items: vec![TaskItem {
930 id: "task_1".to_string(),
931 description: "Implement event rename".to_string(),
932 status: TaskItemStatus::InProgress,
933 depends_on: Vec::new(),
934 notes: "Implementing".to_string(),
935 ..TaskItem::default()
936 }],
937 created_at: Utc::now(),
938 updated_at: Utc::now(),
939 }
940 }
941
942 #[test]
943 fn task_list_updated_serializes_with_task_names() {
944 let event = AgentEvent::TaskListUpdated {
945 task_list: sample_task_list(),
946 };
947
948 let value = serde_json::to_value(event).expect("event should serialize");
949 assert_eq!(value["type"], "task_list_updated");
950 assert!(value.get("task_list").is_some());
951 assert!(value.get("todo_list").is_none());
952 }
953
954 #[test]
955 fn cancelled_serializes_with_snake_case_type() {
956 let event = AgentEvent::Cancelled {
957 message: Some("Agent execution cancelled by user".to_string()),
958 };
959
960 let value = serde_json::to_value(event).expect("event should serialize");
961 assert_eq!(value["type"], "cancelled");
962 assert_eq!(
963 value["message"],
964 serde_json::Value::String("Agent execution cancelled by user".to_string())
965 );
966 }
967
968 #[test]
969 fn task_evaluation_completed_serializes_with_task_type() {
970 let event = AgentEvent::TaskEvaluationCompleted {
971 session_id: "session-1".to_string(),
972 updates_count: 2,
973 reasoning: "Updated statuses".to_string(),
974 generation: Some(7),
975 };
976
977 let value = serde_json::to_value(event).expect("event should serialize");
978 assert_eq!(value["type"], "task_evaluation_completed");
979 assert_eq!(value["generation"], 7);
980 }
981
982 #[test]
983 fn task_evaluation_event_without_generation_remains_deserializable() {
984 let event: AgentEvent = serde_json::from_value(serde_json::json!({
985 "type": "task_evaluation_started",
986 "session_id": "session-1",
987 "items_count": 2
988 }))
989 .expect("legacy task evaluation frame should remain compatible");
990
991 assert!(matches!(
992 event,
993 AgentEvent::TaskEvaluationStarted {
994 generation: None,
995 ..
996 }
997 ));
998 }
999
1000 #[test]
1001 fn evaluation_cancelled_events_serialize_as_terminal_lifecycle_events() {
1002 let task = AgentEvent::TaskEvaluationCancelled {
1003 session_id: "session-1".to_string(),
1004 reason: "run_suspended".to_string(),
1005 generation: Some(7),
1006 };
1007 let gold = AgentEvent::GoldEvaluationCancelled {
1008 session_id: "session-1".to_string(),
1009 reason: "run_completed".to_string(),
1010 };
1011
1012 assert!(task.is_durable_change());
1013 assert!(gold.is_durable_change());
1014 let task_value = serde_json::to_value(task).unwrap();
1015 let gold_value = serde_json::to_value(gold).unwrap();
1016 assert_eq!(task_value["type"], "task_evaluation_cancelled");
1017 assert_eq!(task_value["reason"], "run_suspended");
1018 assert_eq!(gold_value["type"], "gold_evaluation_cancelled");
1019 assert_eq!(gold_value["reason"], "run_completed");
1020 }
1021
1022 #[test]
1023 fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
1024 let event = AgentEvent::GoldEvaluationCompleted {
1025 session_id: "session-1".to_string(),
1026 checkpoint: GoldCheckpoint::PostRound,
1027 iteration: 3,
1028 decision: GoldDecision::Continue,
1029 confidence: GoldConfidence::Medium,
1030 reasoning: "Need one more iteration".to_string(),
1031 };
1032
1033 let value = serde_json::to_value(event).expect("event should serialize");
1034 assert_eq!(value["type"], "gold_evaluation_completed");
1035 assert_eq!(value["checkpoint"], "post_round");
1036 assert_eq!(value["iteration"], 3);
1037 assert_eq!(value["decision"], "continue");
1038 assert_eq!(value["confidence"], "medium");
1039 assert_eq!(value["reasoning"], "Need one more iteration");
1040 }
1041
1042 #[test]
1043 fn gold_evaluation_started_deserializes() {
1044 let json = serde_json::json!({
1045 "type": "gold_evaluation_started",
1046 "session_id": "session-1",
1047 "checkpoint": "terminal",
1048 "iteration": 7
1049 });
1050
1051 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1052 match event {
1053 AgentEvent::GoldEvaluationStarted {
1054 session_id,
1055 checkpoint,
1056 iteration,
1057 } => {
1058 assert_eq!(session_id, "session-1");
1059 assert_eq!(checkpoint, GoldCheckpoint::Terminal);
1060 assert_eq!(iteration, 7);
1061 }
1062 other => panic!("unexpected event: {other:?}"),
1063 }
1064 }
1065
1066 #[test]
1067 fn context_compression_status_serializes_with_phase_and_status() {
1068 let event = AgentEvent::ContextCompressionStatus {
1069 phase: "mid-turn".to_string(),
1070 status: "started".to_string(),
1071 };
1072
1073 let value = serde_json::to_value(event).expect("event should serialize");
1074 assert_eq!(value["type"], "context_compression_status");
1075 assert_eq!(value["phase"], "mid-turn");
1076 assert_eq!(value["status"], "started");
1077 }
1078
1079 #[test]
1080 fn need_clarification_serializes_with_new_fields() {
1081 let event = AgentEvent::NeedClarification {
1082 question: "Continue?".to_string(),
1083 options: Some(vec!["Yes".to_string(), "No".to_string()]),
1084 tool_call_id: Some("tool-1".to_string()),
1085 tool_name: Some("conclusion_with_options".to_string()),
1086 allow_custom: false,
1087 };
1088
1089 let value = serde_json::to_value(event).expect("event should serialize");
1090 assert_eq!(value["type"], "need_clarification");
1091 assert_eq!(value["question"], "Continue?");
1092 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
1093 assert_eq!(value["tool_call_id"], "tool-1");
1094 assert_eq!(value["tool_name"], "conclusion_with_options");
1095 assert_eq!(value["allow_custom"], false);
1096 }
1097
1098 #[test]
1099 fn need_clarification_deserializes_from_old_format_without_new_fields() {
1100 let json = serde_json::json!({
1101 "type": "need_clarification",
1102 "question": "Continue?",
1103 "options": ["Yes", "No"]
1104 });
1105
1106 let event: AgentEvent =
1107 serde_json::from_value(json).expect("should deserialize old format");
1108 match event {
1109 AgentEvent::NeedClarification {
1110 question,
1111 options,
1112 tool_call_id,
1113 tool_name,
1114 allow_custom,
1115 } => {
1116 assert_eq!(question, "Continue?");
1117 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
1118 assert_eq!(tool_call_id, None);
1119 assert_eq!(tool_name, None);
1120 assert!(allow_custom); }
1122 other => panic!("unexpected event: {other:?}"),
1123 }
1124 }
1125
1126 #[test]
1127 fn need_clarification_deserializes_with_allow_custom_false() {
1128 let json = serde_json::json!({
1129 "type": "need_clarification",
1130 "question": "Pick one",
1131 "allow_custom": false
1132 });
1133
1134 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1135 match event {
1136 AgentEvent::NeedClarification {
1137 question,
1138 options,
1139 tool_call_id,
1140 tool_name,
1141 allow_custom,
1142 } => {
1143 assert_eq!(question, "Pick one");
1144 assert_eq!(options, None);
1145 assert_eq!(tool_call_id, None);
1146 assert_eq!(tool_name, None);
1147 assert!(!allow_custom);
1148 }
1149 other => panic!("unexpected event: {other:?}"),
1150 }
1151 }
1152
1153 #[test]
1154 fn plan_mode_entered_serializes_correctly() {
1155 let entered_at = Utc::now();
1156 let event = AgentEvent::PlanModeEntered {
1157 session_id: "sess-1".to_string(),
1158 reason: Some("Complex refactor".to_string()),
1159 pre_permission_mode: "default".to_string(),
1160 entered_at,
1161 status: bamboo_domain::PlanModeStatus::Exploring,
1162 plan_file_path: None,
1163 };
1164
1165 let value = serde_json::to_value(event).expect("event should serialize");
1166 assert_eq!(value["type"], "plan_mode_entered");
1167 assert_eq!(value["session_id"], "sess-1");
1168 assert_eq!(value["reason"], "Complex refactor");
1169 assert_eq!(value["pre_permission_mode"], "default");
1170 assert_eq!(value["status"], "exploring");
1171 assert_eq!(
1174 value["entered_at"],
1175 serde_json::to_value(entered_at).unwrap()
1176 );
1177 }
1178
1179 #[test]
1180 fn plan_mode_exited_serializes_correctly() {
1181 let event = AgentEvent::PlanModeExited {
1182 session_id: "sess-1".to_string(),
1183 approved: true,
1184 restored_mode: "accept_edits".to_string(),
1185 plan: Some("# Plan\n1. Step one".to_string()),
1186 };
1187
1188 let value = serde_json::to_value(event).expect("event should serialize");
1189 assert_eq!(value["type"], "plan_mode_exited");
1190 assert_eq!(value["session_id"], "sess-1");
1191 assert_eq!(value["approved"], true);
1192 assert_eq!(value["restored_mode"], "accept_edits");
1193 assert_eq!(value["plan"], "# Plan\n1. Step one");
1194 }
1195
1196 #[test]
1197 fn plan_file_updated_serializes_correctly() {
1198 let event = AgentEvent::PlanFileUpdated {
1199 session_id: "sess-1".to_string(),
1200 file_path: "/tmp/plans/sess-1.md".to_string(),
1201 content_summary: "Implementation plan for feature X".to_string(),
1202 };
1203
1204 let value = serde_json::to_value(event).expect("event should serialize");
1205 assert_eq!(value["type"], "plan_file_updated");
1206 assert_eq!(value["session_id"], "sess-1");
1207 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
1208 assert_eq!(
1209 value["content_summary"],
1210 "Implementation plan for feature X"
1211 );
1212 }
1213
1214 #[test]
1215 fn tool_approval_requested_serializes_correctly() {
1216 let event = AgentEvent::ToolApprovalRequested {
1217 tool_call_id: "call-abc".to_string(),
1218 tool_name: "Write".to_string(),
1219 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
1220 };
1221
1222 let value = serde_json::to_value(event).expect("event should serialize");
1223 assert_eq!(value["type"], "tool_approval_requested");
1224 assert_eq!(value["tool_call_id"], "call-abc");
1225 assert_eq!(value["tool_name"], "Write");
1226 assert_eq!(
1227 value["parameters"],
1228 serde_json::json!({"file_path": "/tmp/test.txt"})
1229 );
1230 }
1231
1232 #[test]
1233 fn child_approval_changed_routes_to_parent_and_is_durable() {
1234 let event = AgentEvent::ChildApprovalChanged {
1235 parent_session_id: "parent-1".into(),
1236 child_session_id: "child-1".into(),
1237 child_attempt: 3,
1238 request_id: "req-1".into(),
1239 version: 2,
1240 status: "approved".into(),
1241 reason: None,
1242 tool_name: "Bash".into(),
1243 permission: "execute".into(),
1244 resource: "/tmp/x".into(),
1245 created_at: "2026-01-01T00:00:00Z".into(),
1246 resolved_at: Some("2026-01-01T00:00:01Z".into()),
1247 };
1248 assert_eq!(event.session_id(), Some("parent-1"));
1249 assert!(event.is_durable_change());
1250 let value = serde_json::to_value(event).unwrap();
1251 assert_eq!(value["type"], "child_approval_changed");
1252 assert_eq!(value["status"], "approved");
1253 assert_eq!(value["child_attempt"], 3);
1254
1255 let mut legacy = value;
1256 legacy.as_object_mut().unwrap().remove("child_attempt");
1257 let restored: AgentEvent = serde_json::from_value(legacy).unwrap();
1258 assert!(matches!(
1259 restored,
1260 AgentEvent::ChildApprovalChanged {
1261 child_attempt: 0,
1262 ..
1263 }
1264 ));
1265 }
1266
1267 #[test]
1268 fn tool_approval_requested_deserializes_correctly() {
1269 let json = serde_json::json!({
1270 "type": "tool_approval_requested",
1271 "tool_call_id": "call-xyz",
1272 "tool_name": "Bash",
1273 "parameters": {"command": "ls -la"}
1274 });
1275
1276 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1277 match event {
1278 AgentEvent::ToolApprovalRequested {
1279 tool_call_id,
1280 tool_name,
1281 parameters,
1282 } => {
1283 assert_eq!(tool_call_id, "call-xyz");
1284 assert_eq!(tool_name, "Bash");
1285 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1286 }
1287 other => panic!("unexpected event: {other:?}"),
1288 }
1289 }
1290
1291 #[test]
1292 fn session_title_updated_round_trips_with_source_variants() {
1293 use chrono::Utc;
1294 let event = AgentEvent::SessionTitleUpdated {
1295 session_id: "sess-1".to_string(),
1296 title: "My title".to_string(),
1297 title_version: 3,
1298 source: TitleSource::Auto,
1299 updated_at: Utc::now(),
1300 };
1301 let json = serde_json::to_string(&event).unwrap();
1302 assert!(
1303 json.contains("\"type\":\"session_title_updated\""),
1304 "json: {json}"
1305 );
1306 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1307 let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1308 }
1309
1310 #[test]
1311 fn plan_mode_events_deserialize_without_optional_fields() {
1312 let json = serde_json::json!({
1313 "type": "plan_mode_entered",
1314 "session_id": "sess-1",
1315 "pre_permission_mode": "default",
1316 "entered_at": "2025-01-01T00:00:00Z",
1317 "status": "exploring"
1318 });
1319
1320 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1321 match event {
1322 AgentEvent::PlanModeEntered {
1323 session_id,
1324 reason,
1325 pre_permission_mode,
1326 entered_at,
1327 status,
1328 plan_file_path,
1329 } => {
1330 assert_eq!(session_id, "sess-1");
1331 assert_eq!(reason, None);
1332 assert_eq!(pre_permission_mode, "default");
1333 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1334 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1335 assert_eq!(plan_file_path, None);
1336 }
1337 other => panic!("unexpected event: {other:?}"),
1338 }
1339 }
1340
1341 #[test]
1342 fn workflow_catalog_events_are_durable_and_account_scoped() {
1343 for event in [
1344 AgentEvent::WorkflowChanged {
1345 workflow_id: "review".to_string(),
1346 revision: 2,
1347 scope: "global".to_string(),
1348 },
1349 AgentEvent::WorkflowInvalid {
1350 workflow_id: "review".to_string(),
1351 revision: 3,
1352 scope: "workspace:1234".to_string(),
1353 },
1354 AgentEvent::WorkflowRecovered {
1355 workflow_id: "review".to_string(),
1356 revision: 4,
1357 scope: "workspace:1234".to_string(),
1358 },
1359 ] {
1360 assert!(event.is_durable_change());
1361 assert_eq!(event.session_id(), None);
1362 let encoded = serde_json::to_string(&event).expect("serialize");
1363 let _: AgentEvent = serde_json::from_str(&encoded).expect("deserialize");
1364 }
1365 }
1366}