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 Notification {
657 id: String,
659 session_id: String,
661 category: String,
664 priority: String,
666 title: String,
668 body: String,
670 #[serde(default, skip_serializing_if = "Option::is_none")]
672 dedup_key: Option<String>,
673 created_at: String,
675 },
676}
677
678impl AgentEvent {
679 pub fn session_id(&self) -> Option<&str> {
688 match self {
689 AgentEvent::TaskListUpdated { task_list } => Some(task_list.session_id.as_str()),
690 AgentEvent::TaskListItemProgress { session_id, .. }
691 | AgentEvent::TaskListCompleted { session_id, .. }
692 | AgentEvent::TaskEvaluationStarted { session_id, .. }
693 | AgentEvent::TaskEvaluationCompleted { session_id, .. }
694 | AgentEvent::TaskEvaluationCancelled { session_id, .. }
695 | AgentEvent::GoldEvaluationStarted { session_id, .. }
696 | AgentEvent::GoldEvaluationCompleted { session_id, .. }
697 | AgentEvent::GoldEvaluationCancelled { session_id, .. }
698 | AgentEvent::GoalStatusChanged { session_id, .. }
699 | AgentEvent::PlanModeEntered { session_id, .. }
700 | AgentEvent::PlanModeExited { session_id, .. }
701 | AgentEvent::PlanFileUpdated { session_id, .. }
702 | AgentEvent::RunnerProgress { session_id, .. }
703 | AgentEvent::SessionTitleUpdated { session_id, .. }
704 | AgentEvent::SessionPinnedUpdated { session_id, .. }
705 | AgentEvent::SessionCreated { session_id, .. }
706 | AgentEvent::SessionDeleted { session_id, .. }
707 | AgentEvent::SessionCleared { session_id, .. }
708 | AgentEvent::MessageAppended { session_id, .. }
709 | AgentEvent::ExecutionStarted { session_id, .. }
710 | AgentEvent::BudgetExceeded { session_id, .. }
711 | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
712 AgentEvent::SubAgentStarted {
713 parent_session_id, ..
714 }
715 | AgentEvent::SubAgentEvent {
716 parent_session_id, ..
717 }
718 | AgentEvent::SubAgentHeartbeat {
719 parent_session_id, ..
720 }
721 | AgentEvent::SubAgentCompleted {
722 parent_session_id, ..
723 }
724 | AgentEvent::ChildApprovalChanged {
725 parent_session_id, ..
726 } => Some(parent_session_id.as_str()),
727 _ => None,
728 }
729 }
730
731 pub fn is_durable_change(&self) -> bool {
742 matches!(
743 self,
744 AgentEvent::MessageAppended { .. }
745 | AgentEvent::SessionCreated { .. }
746 | AgentEvent::SessionDeleted { .. }
747 | AgentEvent::SessionCleared { .. }
748 | AgentEvent::SessionTitleUpdated { .. }
749 | AgentEvent::SessionPinnedUpdated { .. }
750 | AgentEvent::TaskListUpdated { .. }
751 | AgentEvent::TaskListItemProgress { .. }
752 | AgentEvent::TaskListCompleted { .. }
753 | AgentEvent::TaskEvaluationCompleted { .. }
754 | AgentEvent::TaskEvaluationCancelled { .. }
755 | AgentEvent::GoldEvaluationCancelled { .. }
756 | AgentEvent::PlanModeEntered { .. }
757 | AgentEvent::PlanModeExited { .. }
758 | AgentEvent::PlanFileUpdated { .. }
759 | AgentEvent::SubAgentStarted { .. }
760 | AgentEvent::SubAgentCompleted { .. }
761 | AgentEvent::ChildApprovalChanged { .. }
762 | AgentEvent::NeedClarification { .. }
763 | AgentEvent::ToolApprovalRequested { .. }
764 | AgentEvent::ExecutionStarted { .. }
765 | AgentEvent::BudgetExceeded { .. }
766 | AgentEvent::Complete { .. }
767 | AgentEvent::Cancelled { .. }
768 | AgentEvent::Error { .. }
769 | AgentEvent::WorkflowChanged { .. }
770 | AgentEvent::WorkflowInvalid { .. }
771 | AgentEvent::WorkflowRecovered { .. }
772 )
773 }
774}
775
776fn default_allow_custom() -> bool {
777 true
778}
779
780#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
782#[serde(rename_all = "snake_case")]
783pub enum GoldCheckpoint {
784 PostRound,
785 Terminal,
786}
787
788impl GoldCheckpoint {
789 pub fn as_str(self) -> &'static str {
790 match self {
791 Self::PostRound => "post_round",
792 Self::Terminal => "terminal",
793 }
794 }
795}
796
797#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
799#[serde(rename_all = "snake_case")]
800pub enum GoldDecision {
801 Continue,
802 Achieved,
803 Blocked,
804 NeedInput,
805 Exhausted,
806}
807
808impl GoldDecision {
809 pub fn as_str(self) -> &'static str {
810 match self {
811 Self::Continue => "continue",
812 Self::Achieved => "achieved",
813 Self::Blocked => "blocked",
814 Self::NeedInput => "need_input",
815 Self::Exhausted => "exhausted",
816 }
817 }
818}
819
820#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
822#[serde(rename_all = "snake_case")]
823pub enum GoldConfidence {
824 Low,
825 Medium,
826 High,
827}
828
829impl GoldConfidence {
830 pub fn as_str(self) -> &'static str {
831 match self {
832 Self::Low => "low",
833 Self::Medium => "medium",
834 Self::High => "high",
835 }
836 }
837
838 pub fn rank(self) -> u8 {
840 match self {
841 Self::Low => 0,
842 Self::Medium => 1,
843 Self::High => 2,
844 }
845 }
846
847 pub fn meets(self, floor: GoldConfidence) -> bool {
849 self.rank() >= floor.rank()
850 }
851}
852
853#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
855#[serde(rename_all = "snake_case")]
856pub enum TitleSource {
857 Auto,
858 Manual,
859 Fallback,
860}
861
862pub use bamboo_domain::TokenUsage;
866
867pub use bamboo_domain::budget_types::TokenBudgetUsage;
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
873
874 fn sample_task_list() -> TaskList {
875 TaskList {
876 session_id: "session-1".to_string(),
877 title: "Task List".to_string(),
878 items: vec![TaskItem {
879 id: "task_1".to_string(),
880 description: "Implement event rename".to_string(),
881 status: TaskItemStatus::InProgress,
882 depends_on: Vec::new(),
883 notes: "Implementing".to_string(),
884 ..TaskItem::default()
885 }],
886 created_at: Utc::now(),
887 updated_at: Utc::now(),
888 }
889 }
890
891 #[test]
892 fn task_list_updated_serializes_with_task_names() {
893 let event = AgentEvent::TaskListUpdated {
894 task_list: sample_task_list(),
895 };
896
897 let value = serde_json::to_value(event).expect("event should serialize");
898 assert_eq!(value["type"], "task_list_updated");
899 assert!(value.get("task_list").is_some());
900 assert!(value.get("todo_list").is_none());
901 }
902
903 #[test]
904 fn cancelled_serializes_with_snake_case_type() {
905 let event = AgentEvent::Cancelled {
906 message: Some("Agent execution cancelled by user".to_string()),
907 };
908
909 let value = serde_json::to_value(event).expect("event should serialize");
910 assert_eq!(value["type"], "cancelled");
911 assert_eq!(
912 value["message"],
913 serde_json::Value::String("Agent execution cancelled by user".to_string())
914 );
915 }
916
917 #[test]
918 fn task_evaluation_completed_serializes_with_task_type() {
919 let event = AgentEvent::TaskEvaluationCompleted {
920 session_id: "session-1".to_string(),
921 updates_count: 2,
922 reasoning: "Updated statuses".to_string(),
923 generation: Some(7),
924 };
925
926 let value = serde_json::to_value(event).expect("event should serialize");
927 assert_eq!(value["type"], "task_evaluation_completed");
928 assert_eq!(value["generation"], 7);
929 }
930
931 #[test]
932 fn task_evaluation_event_without_generation_remains_deserializable() {
933 let event: AgentEvent = serde_json::from_value(serde_json::json!({
934 "type": "task_evaluation_started",
935 "session_id": "session-1",
936 "items_count": 2
937 }))
938 .expect("legacy task evaluation frame should remain compatible");
939
940 assert!(matches!(
941 event,
942 AgentEvent::TaskEvaluationStarted {
943 generation: None,
944 ..
945 }
946 ));
947 }
948
949 #[test]
950 fn evaluation_cancelled_events_serialize_as_terminal_lifecycle_events() {
951 let task = AgentEvent::TaskEvaluationCancelled {
952 session_id: "session-1".to_string(),
953 reason: "run_suspended".to_string(),
954 generation: Some(7),
955 };
956 let gold = AgentEvent::GoldEvaluationCancelled {
957 session_id: "session-1".to_string(),
958 reason: "run_completed".to_string(),
959 };
960
961 assert!(task.is_durable_change());
962 assert!(gold.is_durable_change());
963 let task_value = serde_json::to_value(task).unwrap();
964 let gold_value = serde_json::to_value(gold).unwrap();
965 assert_eq!(task_value["type"], "task_evaluation_cancelled");
966 assert_eq!(task_value["reason"], "run_suspended");
967 assert_eq!(gold_value["type"], "gold_evaluation_cancelled");
968 assert_eq!(gold_value["reason"], "run_completed");
969 }
970
971 #[test]
972 fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
973 let event = AgentEvent::GoldEvaluationCompleted {
974 session_id: "session-1".to_string(),
975 checkpoint: GoldCheckpoint::PostRound,
976 iteration: 3,
977 decision: GoldDecision::Continue,
978 confidence: GoldConfidence::Medium,
979 reasoning: "Need one more iteration".to_string(),
980 };
981
982 let value = serde_json::to_value(event).expect("event should serialize");
983 assert_eq!(value["type"], "gold_evaluation_completed");
984 assert_eq!(value["checkpoint"], "post_round");
985 assert_eq!(value["iteration"], 3);
986 assert_eq!(value["decision"], "continue");
987 assert_eq!(value["confidence"], "medium");
988 assert_eq!(value["reasoning"], "Need one more iteration");
989 }
990
991 #[test]
992 fn gold_evaluation_started_deserializes() {
993 let json = serde_json::json!({
994 "type": "gold_evaluation_started",
995 "session_id": "session-1",
996 "checkpoint": "terminal",
997 "iteration": 7
998 });
999
1000 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1001 match event {
1002 AgentEvent::GoldEvaluationStarted {
1003 session_id,
1004 checkpoint,
1005 iteration,
1006 } => {
1007 assert_eq!(session_id, "session-1");
1008 assert_eq!(checkpoint, GoldCheckpoint::Terminal);
1009 assert_eq!(iteration, 7);
1010 }
1011 other => panic!("unexpected event: {other:?}"),
1012 }
1013 }
1014
1015 #[test]
1016 fn context_compression_status_serializes_with_phase_and_status() {
1017 let event = AgentEvent::ContextCompressionStatus {
1018 phase: "mid-turn".to_string(),
1019 status: "started".to_string(),
1020 };
1021
1022 let value = serde_json::to_value(event).expect("event should serialize");
1023 assert_eq!(value["type"], "context_compression_status");
1024 assert_eq!(value["phase"], "mid-turn");
1025 assert_eq!(value["status"], "started");
1026 }
1027
1028 #[test]
1029 fn need_clarification_serializes_with_new_fields() {
1030 let event = AgentEvent::NeedClarification {
1031 question: "Continue?".to_string(),
1032 options: Some(vec!["Yes".to_string(), "No".to_string()]),
1033 tool_call_id: Some("tool-1".to_string()),
1034 tool_name: Some("conclusion_with_options".to_string()),
1035 allow_custom: false,
1036 };
1037
1038 let value = serde_json::to_value(event).expect("event should serialize");
1039 assert_eq!(value["type"], "need_clarification");
1040 assert_eq!(value["question"], "Continue?");
1041 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
1042 assert_eq!(value["tool_call_id"], "tool-1");
1043 assert_eq!(value["tool_name"], "conclusion_with_options");
1044 assert_eq!(value["allow_custom"], false);
1045 }
1046
1047 #[test]
1048 fn need_clarification_deserializes_from_old_format_without_new_fields() {
1049 let json = serde_json::json!({
1050 "type": "need_clarification",
1051 "question": "Continue?",
1052 "options": ["Yes", "No"]
1053 });
1054
1055 let event: AgentEvent =
1056 serde_json::from_value(json).expect("should deserialize old format");
1057 match event {
1058 AgentEvent::NeedClarification {
1059 question,
1060 options,
1061 tool_call_id,
1062 tool_name,
1063 allow_custom,
1064 } => {
1065 assert_eq!(question, "Continue?");
1066 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
1067 assert_eq!(tool_call_id, None);
1068 assert_eq!(tool_name, None);
1069 assert!(allow_custom); }
1071 other => panic!("unexpected event: {other:?}"),
1072 }
1073 }
1074
1075 #[test]
1076 fn need_clarification_deserializes_with_allow_custom_false() {
1077 let json = serde_json::json!({
1078 "type": "need_clarification",
1079 "question": "Pick one",
1080 "allow_custom": false
1081 });
1082
1083 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1084 match event {
1085 AgentEvent::NeedClarification {
1086 question,
1087 options,
1088 tool_call_id,
1089 tool_name,
1090 allow_custom,
1091 } => {
1092 assert_eq!(question, "Pick one");
1093 assert_eq!(options, None);
1094 assert_eq!(tool_call_id, None);
1095 assert_eq!(tool_name, None);
1096 assert!(!allow_custom);
1097 }
1098 other => panic!("unexpected event: {other:?}"),
1099 }
1100 }
1101
1102 #[test]
1103 fn plan_mode_entered_serializes_correctly() {
1104 let entered_at = Utc::now();
1105 let event = AgentEvent::PlanModeEntered {
1106 session_id: "sess-1".to_string(),
1107 reason: Some("Complex refactor".to_string()),
1108 pre_permission_mode: "default".to_string(),
1109 entered_at,
1110 status: bamboo_domain::PlanModeStatus::Exploring,
1111 plan_file_path: None,
1112 };
1113
1114 let value = serde_json::to_value(event).expect("event should serialize");
1115 assert_eq!(value["type"], "plan_mode_entered");
1116 assert_eq!(value["session_id"], "sess-1");
1117 assert_eq!(value["reason"], "Complex refactor");
1118 assert_eq!(value["pre_permission_mode"], "default");
1119 assert_eq!(value["status"], "exploring");
1120 assert_eq!(
1123 value["entered_at"],
1124 serde_json::to_value(entered_at).unwrap()
1125 );
1126 }
1127
1128 #[test]
1129 fn plan_mode_exited_serializes_correctly() {
1130 let event = AgentEvent::PlanModeExited {
1131 session_id: "sess-1".to_string(),
1132 approved: true,
1133 restored_mode: "accept_edits".to_string(),
1134 plan: Some("# Plan\n1. Step one".to_string()),
1135 };
1136
1137 let value = serde_json::to_value(event).expect("event should serialize");
1138 assert_eq!(value["type"], "plan_mode_exited");
1139 assert_eq!(value["session_id"], "sess-1");
1140 assert_eq!(value["approved"], true);
1141 assert_eq!(value["restored_mode"], "accept_edits");
1142 assert_eq!(value["plan"], "# Plan\n1. Step one");
1143 }
1144
1145 #[test]
1146 fn plan_file_updated_serializes_correctly() {
1147 let event = AgentEvent::PlanFileUpdated {
1148 session_id: "sess-1".to_string(),
1149 file_path: "/tmp/plans/sess-1.md".to_string(),
1150 content_summary: "Implementation plan for feature X".to_string(),
1151 };
1152
1153 let value = serde_json::to_value(event).expect("event should serialize");
1154 assert_eq!(value["type"], "plan_file_updated");
1155 assert_eq!(value["session_id"], "sess-1");
1156 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
1157 assert_eq!(
1158 value["content_summary"],
1159 "Implementation plan for feature X"
1160 );
1161 }
1162
1163 #[test]
1164 fn tool_approval_requested_serializes_correctly() {
1165 let event = AgentEvent::ToolApprovalRequested {
1166 tool_call_id: "call-abc".to_string(),
1167 tool_name: "Write".to_string(),
1168 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
1169 };
1170
1171 let value = serde_json::to_value(event).expect("event should serialize");
1172 assert_eq!(value["type"], "tool_approval_requested");
1173 assert_eq!(value["tool_call_id"], "call-abc");
1174 assert_eq!(value["tool_name"], "Write");
1175 assert_eq!(
1176 value["parameters"],
1177 serde_json::json!({"file_path": "/tmp/test.txt"})
1178 );
1179 }
1180
1181 #[test]
1182 fn child_approval_changed_routes_to_parent_and_is_durable() {
1183 let event = AgentEvent::ChildApprovalChanged {
1184 parent_session_id: "parent-1".into(),
1185 child_session_id: "child-1".into(),
1186 child_attempt: 3,
1187 request_id: "req-1".into(),
1188 version: 2,
1189 status: "approved".into(),
1190 reason: None,
1191 tool_name: "Bash".into(),
1192 permission: "execute".into(),
1193 resource: "/tmp/x".into(),
1194 created_at: "2026-01-01T00:00:00Z".into(),
1195 resolved_at: Some("2026-01-01T00:00:01Z".into()),
1196 };
1197 assert_eq!(event.session_id(), Some("parent-1"));
1198 assert!(event.is_durable_change());
1199 let value = serde_json::to_value(event).unwrap();
1200 assert_eq!(value["type"], "child_approval_changed");
1201 assert_eq!(value["status"], "approved");
1202 assert_eq!(value["child_attempt"], 3);
1203
1204 let mut legacy = value;
1205 legacy.as_object_mut().unwrap().remove("child_attempt");
1206 let restored: AgentEvent = serde_json::from_value(legacy).unwrap();
1207 assert!(matches!(
1208 restored,
1209 AgentEvent::ChildApprovalChanged {
1210 child_attempt: 0,
1211 ..
1212 }
1213 ));
1214 }
1215
1216 #[test]
1217 fn tool_approval_requested_deserializes_correctly() {
1218 let json = serde_json::json!({
1219 "type": "tool_approval_requested",
1220 "tool_call_id": "call-xyz",
1221 "tool_name": "Bash",
1222 "parameters": {"command": "ls -la"}
1223 });
1224
1225 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1226 match event {
1227 AgentEvent::ToolApprovalRequested {
1228 tool_call_id,
1229 tool_name,
1230 parameters,
1231 } => {
1232 assert_eq!(tool_call_id, "call-xyz");
1233 assert_eq!(tool_name, "Bash");
1234 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1235 }
1236 other => panic!("unexpected event: {other:?}"),
1237 }
1238 }
1239
1240 #[test]
1241 fn session_title_updated_round_trips_with_source_variants() {
1242 use chrono::Utc;
1243 let event = AgentEvent::SessionTitleUpdated {
1244 session_id: "sess-1".to_string(),
1245 title: "My title".to_string(),
1246 title_version: 3,
1247 source: TitleSource::Auto,
1248 updated_at: Utc::now(),
1249 };
1250 let json = serde_json::to_string(&event).unwrap();
1251 assert!(
1252 json.contains("\"type\":\"session_title_updated\""),
1253 "json: {json}"
1254 );
1255 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1256 let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1257 }
1258
1259 #[test]
1260 fn plan_mode_events_deserialize_without_optional_fields() {
1261 let json = serde_json::json!({
1262 "type": "plan_mode_entered",
1263 "session_id": "sess-1",
1264 "pre_permission_mode": "default",
1265 "entered_at": "2025-01-01T00:00:00Z",
1266 "status": "exploring"
1267 });
1268
1269 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1270 match event {
1271 AgentEvent::PlanModeEntered {
1272 session_id,
1273 reason,
1274 pre_permission_mode,
1275 entered_at,
1276 status,
1277 plan_file_path,
1278 } => {
1279 assert_eq!(session_id, "sess-1");
1280 assert_eq!(reason, None);
1281 assert_eq!(pre_permission_mode, "default");
1282 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1283 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1284 assert_eq!(plan_file_path, None);
1285 }
1286 other => panic!("unexpected event: {other:?}"),
1287 }
1288 }
1289
1290 #[test]
1291 fn workflow_catalog_events_are_durable_and_account_scoped() {
1292 for event in [
1293 AgentEvent::WorkflowChanged {
1294 workflow_id: "review".to_string(),
1295 revision: 2,
1296 scope: "global".to_string(),
1297 },
1298 AgentEvent::WorkflowInvalid {
1299 workflow_id: "review".to_string(),
1300 revision: 3,
1301 scope: "workspace:1234".to_string(),
1302 },
1303 AgentEvent::WorkflowRecovered {
1304 workflow_id: "review".to_string(),
1305 revision: 4,
1306 scope: "workspace:1234".to_string(),
1307 },
1308 ] {
1309 assert!(event.is_durable_change());
1310 assert_eq!(event.session_id(), None);
1311 let encoded = serde_json::to_string(&event).expect("serialize");
1312 let _: AgentEvent = serde_json::from_str(&encoded).expect("deserialize");
1313 }
1314 }
1315}