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 #[serde(default)]
509 project_id: Option<String>,
510 title: String,
511 kind: bamboo_domain::SessionKind,
512 created_at: chrono::DateTime<chrono::Utc>,
513 },
514
515 SessionDeleted { session_id: String },
520
521 SessionCleared { session_id: String },
526
527 MessageAppended {
534 session_id: String,
535 message_id: String,
536 role: bamboo_domain::Role,
537 content: String,
538 created_at: chrono::DateTime<chrono::Utc>,
539 },
540
541 ExecutionStarted {
547 run_id: String,
549 session_id: String,
551 started_at: String,
553 },
554
555 ToolApprovalRequested {
562 tool_call_id: String,
564 tool_name: String,
566 parameters: serde_json::Value,
568 },
569
570 ChildApprovalRequested {
576 child_session_id: String,
578 request_id: String,
580 tool_name: String,
582 permission: String,
584 resource: String,
586 },
587
588 ChildApprovalChanged {
590 parent_session_id: String,
591 child_session_id: String,
592 #[serde(default)]
595 child_attempt: u32,
596 request_id: String,
597 version: u64,
598 status: String,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
601 reason: Option<String>,
602 tool_name: String,
603 permission: String,
604 resource: String,
605 created_at: String,
606 #[serde(default, skip_serializing_if = "Option::is_none")]
607 resolved_at: Option<String>,
608 },
609
610 BudgetExceeded {
620 session_id: String,
622 kind: String,
625 limit: u64,
627 actual: u64,
629 },
630
631 Complete {
633 usage: TokenUsage,
635 },
636
637 Cancelled {
639 #[serde(default, skip_serializing_if = "Option::is_none")]
641 message: Option<String>,
642 },
643
644 Error {
646 message: String,
648 },
649
650 WorkflowChanged {
652 workflow_id: String,
653 revision: u64,
654 scope: String,
655 },
656
657 WorkflowInvalid {
659 workflow_id: String,
660 revision: u64,
661 scope: String,
662 },
663
664 WorkflowRecovered {
666 workflow_id: String,
667 revision: u64,
668 scope: String,
669 },
670
671 ProjectCreated { project_id: String, revision: u64 },
673
674 ProjectUpdated { project_id: String, revision: u64 },
676
677 ProjectArchived { project_id: String, revision: u64 },
679
680 SessionProjectUpdated {
682 session_id: String,
683 #[serde(default)]
686 project_id: Option<String>,
687 metadata_version: u64,
688 },
689
690 #[serde(rename = "config.changed")]
692 ConfigChanged { section: String, revision: u64 },
693
694 #[serde(rename = "config.invalid")]
696 ConfigInvalid { section: String, revision: u64 },
697
698 #[serde(rename = "config.recovered")]
700 ConfigRecovered { section: String, revision: u64 },
701
702 WorkflowActivated {
704 event_id: String,
705 session_id: String,
706 workflow_id: String,
707 revision: u64,
708 invoked_by: String,
709 },
710
711 WorkflowDeactivated {
713 event_id: String,
714 session_id: String,
715 workflow_id: String,
716 revision: u64,
717 },
718
719 Notification {
725 id: String,
727 session_id: String,
729 category: String,
732 priority: String,
734 title: String,
736 body: String,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
740 dedup_key: Option<String>,
741 created_at: String,
743 },
744}
745
746impl AgentEvent {
747 pub fn session_id(&self) -> Option<&str> {
756 match self {
757 AgentEvent::TaskListUpdated { task_list } => Some(task_list.session_id.as_str()),
758 AgentEvent::TaskListItemProgress { session_id, .. }
759 | AgentEvent::TaskListCompleted { session_id, .. }
760 | AgentEvent::TaskEvaluationStarted { session_id, .. }
761 | AgentEvent::TaskEvaluationCompleted { session_id, .. }
762 | AgentEvent::TaskEvaluationCancelled { session_id, .. }
763 | AgentEvent::GoldEvaluationStarted { session_id, .. }
764 | AgentEvent::GoldEvaluationCompleted { session_id, .. }
765 | AgentEvent::GoldEvaluationCancelled { session_id, .. }
766 | AgentEvent::GoalStatusChanged { session_id, .. }
767 | AgentEvent::PlanModeEntered { session_id, .. }
768 | AgentEvent::PlanModeExited { session_id, .. }
769 | AgentEvent::PlanFileUpdated { session_id, .. }
770 | AgentEvent::RunnerProgress { session_id, .. }
771 | AgentEvent::SessionTitleUpdated { session_id, .. }
772 | AgentEvent::SessionPinnedUpdated { session_id, .. }
773 | AgentEvent::SessionCreated { session_id, .. }
774 | AgentEvent::SessionDeleted { session_id, .. }
775 | AgentEvent::SessionCleared { session_id, .. }
776 | AgentEvent::MessageAppended { session_id, .. }
777 | AgentEvent::ExecutionStarted { session_id, .. }
778 | AgentEvent::BudgetExceeded { session_id, .. }
779 | AgentEvent::WorkflowActivated { session_id, .. }
780 | AgentEvent::WorkflowDeactivated { session_id, .. }
781 | AgentEvent::SessionProjectUpdated { session_id, .. }
782 | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
783 AgentEvent::SubAgentStarted {
784 parent_session_id, ..
785 }
786 | AgentEvent::SubAgentEvent {
787 parent_session_id, ..
788 }
789 | AgentEvent::SubAgentHeartbeat {
790 parent_session_id, ..
791 }
792 | AgentEvent::SubAgentCompleted {
793 parent_session_id, ..
794 }
795 | AgentEvent::ChildApprovalChanged {
796 parent_session_id, ..
797 } => Some(parent_session_id.as_str()),
798 _ => None,
799 }
800 }
801
802 pub fn is_durable_change(&self) -> bool {
813 matches!(
814 self,
815 AgentEvent::MessageAppended { .. }
816 | AgentEvent::SessionCreated { .. }
817 | AgentEvent::SessionDeleted { .. }
818 | AgentEvent::SessionCleared { .. }
819 | AgentEvent::SessionTitleUpdated { .. }
820 | AgentEvent::SessionPinnedUpdated { .. }
821 | AgentEvent::TaskListUpdated { .. }
822 | AgentEvent::TaskListItemProgress { .. }
823 | AgentEvent::TaskListCompleted { .. }
824 | AgentEvent::TaskEvaluationCompleted { .. }
825 | AgentEvent::TaskEvaluationCancelled { .. }
826 | AgentEvent::GoldEvaluationCancelled { .. }
827 | AgentEvent::PlanModeEntered { .. }
828 | AgentEvent::PlanModeExited { .. }
829 | AgentEvent::PlanFileUpdated { .. }
830 | AgentEvent::SubAgentStarted { .. }
831 | AgentEvent::SubAgentCompleted { .. }
832 | AgentEvent::ChildApprovalChanged { .. }
833 | AgentEvent::NeedClarification { .. }
834 | AgentEvent::ToolApprovalRequested { .. }
835 | AgentEvent::ExecutionStarted { .. }
836 | AgentEvent::BudgetExceeded { .. }
837 | AgentEvent::Complete { .. }
838 | AgentEvent::Cancelled { .. }
839 | AgentEvent::Error { .. }
840 | AgentEvent::WorkflowChanged { .. }
841 | AgentEvent::WorkflowInvalid { .. }
842 | AgentEvent::ProjectCreated { .. }
843 | AgentEvent::ProjectUpdated { .. }
844 | AgentEvent::ProjectArchived { .. }
845 | AgentEvent::SessionProjectUpdated { .. }
846 | AgentEvent::ConfigChanged { .. }
847 | AgentEvent::ConfigInvalid { .. }
848 | AgentEvent::ConfigRecovered { .. }
849 | AgentEvent::WorkflowRecovered { .. }
850 | AgentEvent::WorkflowActivated { .. }
851 | AgentEvent::WorkflowDeactivated { .. }
852 )
853 }
854}
855
856fn default_allow_custom() -> bool {
857 true
858}
859
860#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
862#[serde(rename_all = "snake_case")]
863pub enum GoldCheckpoint {
864 PostRound,
865 Terminal,
866}
867
868impl GoldCheckpoint {
869 pub fn as_str(self) -> &'static str {
870 match self {
871 Self::PostRound => "post_round",
872 Self::Terminal => "terminal",
873 }
874 }
875}
876
877#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
879#[serde(rename_all = "snake_case")]
880pub enum GoldDecision {
881 Continue,
882 Achieved,
883 Blocked,
884 NeedInput,
885 Exhausted,
886}
887
888impl GoldDecision {
889 pub fn as_str(self) -> &'static str {
890 match self {
891 Self::Continue => "continue",
892 Self::Achieved => "achieved",
893 Self::Blocked => "blocked",
894 Self::NeedInput => "need_input",
895 Self::Exhausted => "exhausted",
896 }
897 }
898}
899
900#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
902#[serde(rename_all = "snake_case")]
903pub enum GoldConfidence {
904 Low,
905 Medium,
906 High,
907}
908
909impl GoldConfidence {
910 pub fn as_str(self) -> &'static str {
911 match self {
912 Self::Low => "low",
913 Self::Medium => "medium",
914 Self::High => "high",
915 }
916 }
917
918 pub fn rank(self) -> u8 {
920 match self {
921 Self::Low => 0,
922 Self::Medium => 1,
923 Self::High => 2,
924 }
925 }
926
927 pub fn meets(self, floor: GoldConfidence) -> bool {
929 self.rank() >= floor.rank()
930 }
931}
932
933#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
935#[serde(rename_all = "snake_case")]
936pub enum TitleSource {
937 Auto,
938 Manual,
939 Fallback,
940}
941
942pub use bamboo_domain::TokenUsage;
946
947pub use bamboo_domain::budget_types::TokenBudgetUsage;
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
953
954 fn sample_task_list() -> TaskList {
955 TaskList {
956 session_id: "session-1".to_string(),
957 title: "Task List".to_string(),
958 items: vec![TaskItem {
959 id: "task_1".to_string(),
960 description: "Implement event rename".to_string(),
961 status: TaskItemStatus::InProgress,
962 depends_on: Vec::new(),
963 notes: "Implementing".to_string(),
964 ..TaskItem::default()
965 }],
966 created_at: Utc::now(),
967 updated_at: Utc::now(),
968 }
969 }
970
971 #[test]
972 fn task_list_updated_serializes_with_task_names() {
973 let event = AgentEvent::TaskListUpdated {
974 task_list: sample_task_list(),
975 };
976
977 let value = serde_json::to_value(event).expect("event should serialize");
978 assert_eq!(value["type"], "task_list_updated");
979 assert!(value.get("task_list").is_some());
980 assert!(value.get("todo_list").is_none());
981 }
982
983 #[test]
984 fn session_project_updated_serializes_unassignment_as_explicit_null() {
985 let event = AgentEvent::SessionProjectUpdated {
986 session_id: "session-1".to_string(),
987 project_id: None,
988 metadata_version: 4,
989 };
990
991 let value = serde_json::to_value(&event).expect("event should serialize");
992 assert_eq!(value["type"], "session_project_updated");
993 assert!(
994 value
995 .get("project_id")
996 .is_some_and(serde_json::Value::is_null),
997 "unassignment must carry an explicit project_id: null"
998 );
999
1000 let restored: AgentEvent = serde_json::from_value(value).expect("event should deserialize");
1001 assert!(matches!(
1002 restored,
1003 AgentEvent::SessionProjectUpdated {
1004 session_id,
1005 project_id: None,
1006 metadata_version: 4,
1007 } if session_id == "session-1"
1008 ));
1009 }
1010
1011 #[test]
1012 fn cancelled_serializes_with_snake_case_type() {
1013 let event = AgentEvent::Cancelled {
1014 message: Some("Agent execution cancelled by user".to_string()),
1015 };
1016
1017 let value = serde_json::to_value(event).expect("event should serialize");
1018 assert_eq!(value["type"], "cancelled");
1019 assert_eq!(
1020 value["message"],
1021 serde_json::Value::String("Agent execution cancelled by user".to_string())
1022 );
1023 }
1024
1025 #[test]
1026 fn task_evaluation_completed_serializes_with_task_type() {
1027 let event = AgentEvent::TaskEvaluationCompleted {
1028 session_id: "session-1".to_string(),
1029 updates_count: 2,
1030 reasoning: "Updated statuses".to_string(),
1031 generation: Some(7),
1032 };
1033
1034 let value = serde_json::to_value(event).expect("event should serialize");
1035 assert_eq!(value["type"], "task_evaluation_completed");
1036 assert_eq!(value["generation"], 7);
1037 }
1038
1039 #[test]
1040 fn task_evaluation_event_without_generation_remains_deserializable() {
1041 let event: AgentEvent = serde_json::from_value(serde_json::json!({
1042 "type": "task_evaluation_started",
1043 "session_id": "session-1",
1044 "items_count": 2
1045 }))
1046 .expect("legacy task evaluation frame should remain compatible");
1047
1048 assert!(matches!(
1049 event,
1050 AgentEvent::TaskEvaluationStarted {
1051 generation: None,
1052 ..
1053 }
1054 ));
1055 }
1056
1057 #[test]
1058 fn evaluation_cancelled_events_serialize_as_terminal_lifecycle_events() {
1059 let task = AgentEvent::TaskEvaluationCancelled {
1060 session_id: "session-1".to_string(),
1061 reason: "run_suspended".to_string(),
1062 generation: Some(7),
1063 };
1064 let gold = AgentEvent::GoldEvaluationCancelled {
1065 session_id: "session-1".to_string(),
1066 reason: "run_completed".to_string(),
1067 };
1068
1069 assert!(task.is_durable_change());
1070 assert!(gold.is_durable_change());
1071 let task_value = serde_json::to_value(task).unwrap();
1072 let gold_value = serde_json::to_value(gold).unwrap();
1073 assert_eq!(task_value["type"], "task_evaluation_cancelled");
1074 assert_eq!(task_value["reason"], "run_suspended");
1075 assert_eq!(gold_value["type"], "gold_evaluation_cancelled");
1076 assert_eq!(gold_value["reason"], "run_completed");
1077 }
1078
1079 #[test]
1080 fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
1081 let event = AgentEvent::GoldEvaluationCompleted {
1082 session_id: "session-1".to_string(),
1083 checkpoint: GoldCheckpoint::PostRound,
1084 iteration: 3,
1085 decision: GoldDecision::Continue,
1086 confidence: GoldConfidence::Medium,
1087 reasoning: "Need one more iteration".to_string(),
1088 };
1089
1090 let value = serde_json::to_value(event).expect("event should serialize");
1091 assert_eq!(value["type"], "gold_evaluation_completed");
1092 assert_eq!(value["checkpoint"], "post_round");
1093 assert_eq!(value["iteration"], 3);
1094 assert_eq!(value["decision"], "continue");
1095 assert_eq!(value["confidence"], "medium");
1096 assert_eq!(value["reasoning"], "Need one more iteration");
1097 }
1098
1099 #[test]
1100 fn gold_evaluation_started_deserializes() {
1101 let json = serde_json::json!({
1102 "type": "gold_evaluation_started",
1103 "session_id": "session-1",
1104 "checkpoint": "terminal",
1105 "iteration": 7
1106 });
1107
1108 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1109 match event {
1110 AgentEvent::GoldEvaluationStarted {
1111 session_id,
1112 checkpoint,
1113 iteration,
1114 } => {
1115 assert_eq!(session_id, "session-1");
1116 assert_eq!(checkpoint, GoldCheckpoint::Terminal);
1117 assert_eq!(iteration, 7);
1118 }
1119 other => panic!("unexpected event: {other:?}"),
1120 }
1121 }
1122
1123 #[test]
1124 fn context_compression_status_serializes_with_phase_and_status() {
1125 let event = AgentEvent::ContextCompressionStatus {
1126 phase: "mid-turn".to_string(),
1127 status: "started".to_string(),
1128 };
1129
1130 let value = serde_json::to_value(event).expect("event should serialize");
1131 assert_eq!(value["type"], "context_compression_status");
1132 assert_eq!(value["phase"], "mid-turn");
1133 assert_eq!(value["status"], "started");
1134 }
1135
1136 #[test]
1137 fn need_clarification_serializes_with_new_fields() {
1138 let event = AgentEvent::NeedClarification {
1139 question: "Continue?".to_string(),
1140 options: Some(vec!["Yes".to_string(), "No".to_string()]),
1141 tool_call_id: Some("tool-1".to_string()),
1142 tool_name: Some("conclusion_with_options".to_string()),
1143 allow_custom: false,
1144 };
1145
1146 let value = serde_json::to_value(event).expect("event should serialize");
1147 assert_eq!(value["type"], "need_clarification");
1148 assert_eq!(value["question"], "Continue?");
1149 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
1150 assert_eq!(value["tool_call_id"], "tool-1");
1151 assert_eq!(value["tool_name"], "conclusion_with_options");
1152 assert_eq!(value["allow_custom"], false);
1153 }
1154
1155 #[test]
1156 fn need_clarification_deserializes_from_old_format_without_new_fields() {
1157 let json = serde_json::json!({
1158 "type": "need_clarification",
1159 "question": "Continue?",
1160 "options": ["Yes", "No"]
1161 });
1162
1163 let event: AgentEvent =
1164 serde_json::from_value(json).expect("should deserialize old format");
1165 match event {
1166 AgentEvent::NeedClarification {
1167 question,
1168 options,
1169 tool_call_id,
1170 tool_name,
1171 allow_custom,
1172 } => {
1173 assert_eq!(question, "Continue?");
1174 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
1175 assert_eq!(tool_call_id, None);
1176 assert_eq!(tool_name, None);
1177 assert!(allow_custom); }
1179 other => panic!("unexpected event: {other:?}"),
1180 }
1181 }
1182
1183 #[test]
1184 fn need_clarification_deserializes_with_allow_custom_false() {
1185 let json = serde_json::json!({
1186 "type": "need_clarification",
1187 "question": "Pick one",
1188 "allow_custom": false
1189 });
1190
1191 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1192 match event {
1193 AgentEvent::NeedClarification {
1194 question,
1195 options,
1196 tool_call_id,
1197 tool_name,
1198 allow_custom,
1199 } => {
1200 assert_eq!(question, "Pick one");
1201 assert_eq!(options, None);
1202 assert_eq!(tool_call_id, None);
1203 assert_eq!(tool_name, None);
1204 assert!(!allow_custom);
1205 }
1206 other => panic!("unexpected event: {other:?}"),
1207 }
1208 }
1209
1210 #[test]
1211 fn plan_mode_entered_serializes_correctly() {
1212 let entered_at = Utc::now();
1213 let event = AgentEvent::PlanModeEntered {
1214 session_id: "sess-1".to_string(),
1215 reason: Some("Complex refactor".to_string()),
1216 pre_permission_mode: "default".to_string(),
1217 entered_at,
1218 status: bamboo_domain::PlanModeStatus::Exploring,
1219 plan_file_path: None,
1220 };
1221
1222 let value = serde_json::to_value(event).expect("event should serialize");
1223 assert_eq!(value["type"], "plan_mode_entered");
1224 assert_eq!(value["session_id"], "sess-1");
1225 assert_eq!(value["reason"], "Complex refactor");
1226 assert_eq!(value["pre_permission_mode"], "default");
1227 assert_eq!(value["status"], "exploring");
1228 assert_eq!(
1231 value["entered_at"],
1232 serde_json::to_value(entered_at).unwrap()
1233 );
1234 }
1235
1236 #[test]
1237 fn plan_mode_exited_serializes_correctly() {
1238 let event = AgentEvent::PlanModeExited {
1239 session_id: "sess-1".to_string(),
1240 approved: true,
1241 restored_mode: "accept_edits".to_string(),
1242 plan: Some("# Plan\n1. Step one".to_string()),
1243 };
1244
1245 let value = serde_json::to_value(event).expect("event should serialize");
1246 assert_eq!(value["type"], "plan_mode_exited");
1247 assert_eq!(value["session_id"], "sess-1");
1248 assert_eq!(value["approved"], true);
1249 assert_eq!(value["restored_mode"], "accept_edits");
1250 assert_eq!(value["plan"], "# Plan\n1. Step one");
1251 }
1252
1253 #[test]
1254 fn plan_file_updated_serializes_correctly() {
1255 let event = AgentEvent::PlanFileUpdated {
1256 session_id: "sess-1".to_string(),
1257 file_path: "/tmp/plans/sess-1.md".to_string(),
1258 content_summary: "Implementation plan for feature X".to_string(),
1259 };
1260
1261 let value = serde_json::to_value(event).expect("event should serialize");
1262 assert_eq!(value["type"], "plan_file_updated");
1263 assert_eq!(value["session_id"], "sess-1");
1264 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
1265 assert_eq!(
1266 value["content_summary"],
1267 "Implementation plan for feature X"
1268 );
1269 }
1270
1271 #[test]
1272 fn tool_approval_requested_serializes_correctly() {
1273 let event = AgentEvent::ToolApprovalRequested {
1274 tool_call_id: "call-abc".to_string(),
1275 tool_name: "Write".to_string(),
1276 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
1277 };
1278
1279 let value = serde_json::to_value(event).expect("event should serialize");
1280 assert_eq!(value["type"], "tool_approval_requested");
1281 assert_eq!(value["tool_call_id"], "call-abc");
1282 assert_eq!(value["tool_name"], "Write");
1283 assert_eq!(
1284 value["parameters"],
1285 serde_json::json!({"file_path": "/tmp/test.txt"})
1286 );
1287 }
1288
1289 #[test]
1290 fn child_approval_changed_routes_to_parent_and_is_durable() {
1291 let event = AgentEvent::ChildApprovalChanged {
1292 parent_session_id: "parent-1".into(),
1293 child_session_id: "child-1".into(),
1294 child_attempt: 3,
1295 request_id: "req-1".into(),
1296 version: 2,
1297 status: "approved".into(),
1298 reason: None,
1299 tool_name: "Bash".into(),
1300 permission: "execute".into(),
1301 resource: "/tmp/x".into(),
1302 created_at: "2026-01-01T00:00:00Z".into(),
1303 resolved_at: Some("2026-01-01T00:00:01Z".into()),
1304 };
1305 assert_eq!(event.session_id(), Some("parent-1"));
1306 assert!(event.is_durable_change());
1307 let value = serde_json::to_value(event).unwrap();
1308 assert_eq!(value["type"], "child_approval_changed");
1309 assert_eq!(value["status"], "approved");
1310 assert_eq!(value["child_attempt"], 3);
1311
1312 let mut legacy = value;
1313 legacy.as_object_mut().unwrap().remove("child_attempt");
1314 let restored: AgentEvent = serde_json::from_value(legacy).unwrap();
1315 assert!(matches!(
1316 restored,
1317 AgentEvent::ChildApprovalChanged {
1318 child_attempt: 0,
1319 ..
1320 }
1321 ));
1322 }
1323
1324 #[test]
1325 fn tool_approval_requested_deserializes_correctly() {
1326 let json = serde_json::json!({
1327 "type": "tool_approval_requested",
1328 "tool_call_id": "call-xyz",
1329 "tool_name": "Bash",
1330 "parameters": {"command": "ls -la"}
1331 });
1332
1333 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1334 match event {
1335 AgentEvent::ToolApprovalRequested {
1336 tool_call_id,
1337 tool_name,
1338 parameters,
1339 } => {
1340 assert_eq!(tool_call_id, "call-xyz");
1341 assert_eq!(tool_name, "Bash");
1342 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1343 }
1344 other => panic!("unexpected event: {other:?}"),
1345 }
1346 }
1347
1348 #[test]
1349 fn session_title_updated_round_trips_with_source_variants() {
1350 use chrono::Utc;
1351 let event = AgentEvent::SessionTitleUpdated {
1352 session_id: "sess-1".to_string(),
1353 title: "My title".to_string(),
1354 title_version: 3,
1355 source: TitleSource::Auto,
1356 updated_at: Utc::now(),
1357 };
1358 let json = serde_json::to_string(&event).unwrap();
1359 assert!(
1360 json.contains("\"type\":\"session_title_updated\""),
1361 "json: {json}"
1362 );
1363 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1364 let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1365 }
1366
1367 #[test]
1368 fn plan_mode_events_deserialize_without_optional_fields() {
1369 let json = serde_json::json!({
1370 "type": "plan_mode_entered",
1371 "session_id": "sess-1",
1372 "pre_permission_mode": "default",
1373 "entered_at": "2025-01-01T00:00:00Z",
1374 "status": "exploring"
1375 });
1376
1377 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1378 match event {
1379 AgentEvent::PlanModeEntered {
1380 session_id,
1381 reason,
1382 pre_permission_mode,
1383 entered_at,
1384 status,
1385 plan_file_path,
1386 } => {
1387 assert_eq!(session_id, "sess-1");
1388 assert_eq!(reason, None);
1389 assert_eq!(pre_permission_mode, "default");
1390 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1391 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1392 assert_eq!(plan_file_path, None);
1393 }
1394 other => panic!("unexpected event: {other:?}"),
1395 }
1396 }
1397
1398 #[test]
1399 fn workflow_catalog_events_are_durable_and_account_scoped() {
1400 for event in [
1401 AgentEvent::WorkflowChanged {
1402 workflow_id: "review".to_string(),
1403 revision: 2,
1404 scope: "global".to_string(),
1405 },
1406 AgentEvent::WorkflowInvalid {
1407 workflow_id: "review".to_string(),
1408 revision: 3,
1409 scope: "workspace:1234".to_string(),
1410 },
1411 AgentEvent::WorkflowRecovered {
1412 workflow_id: "review".to_string(),
1413 revision: 4,
1414 scope: "workspace:1234".to_string(),
1415 },
1416 ] {
1417 assert!(event.is_durable_change());
1418 assert_eq!(event.session_id(), None);
1419 let encoded = serde_json::to_string(&event).expect("serialize");
1420 let _: AgentEvent = serde_json::from_str(&encoded).expect("deserialize");
1421 }
1422 }
1423}