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