1use crate::tools::ToolResult;
39use bamboo_domain::{
40 AgentHookPoint, HookResult, PendingQuestionSource, TaskItem, TaskItemStatus, TaskList,
41};
42use chrono::{DateTime, Utc};
43use serde::{Deserialize, Serialize};
44
45fn default_title_generated() -> bool {
46 true
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(tag = "type", rename_all = "snake_case")]
108pub enum AgentEvent {
109 Token {
111 content: String,
113 },
114
115 ReasoningToken {
120 content: String,
122 },
123
124 ToolToken {
129 tool_call_id: String,
131 content: String,
133 },
134
135 ToolStart {
137 tool_call_id: String,
139 tool_name: String,
141 arguments: serde_json::Value,
143 },
144
145 ToolComplete {
147 tool_call_id: String,
149 result: ToolResult,
151 },
152
153 ToolError {
155 tool_call_id: String,
157 error: String,
159 },
160
161 ToolLifecycle {
167 tool_call_id: String,
169 tool_name: String,
171 phase: String,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 elapsed_ms: Option<u64>,
176 is_mutating: bool,
178 auto_approved: bool,
180 #[serde(skip_serializing_if = "Option::is_none")]
182 summary: Option<String>,
183 #[serde(skip_serializing_if = "Option::is_none")]
185 error: Option<String>,
186 },
187
188 HookLifecycle {
190 hook_name: String,
192 point: AgentHookPoint,
194 phase: String,
197 duration_ms: u64,
199 decision: HookResult,
201 },
202
203 NeedClarification {
205 question: String,
207 options: Option<Vec<String>>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 tool_call_id: Option<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 tool_name: Option<String>,
215 #[serde(default = "default_allow_custom")]
217 allow_custom: bool,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 source: Option<PendingQuestionSource>,
221 },
222
223 TaskListUpdated {
225 task_list: TaskList,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 version: Option<u64>,
230 },
231
232 TaskListItemProgress {
234 session_id: String,
236 item_id: String,
238 status: TaskItemStatus,
240 tool_calls_count: usize,
242 version: u64,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 item: Option<TaskItem>,
247 },
248
249 TaskListCompleted {
251 session_id: String,
253 completed_at: DateTime<Utc>,
255 total_rounds: u32,
257 total_tool_calls: usize,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 version: Option<u64>,
262 },
263
264 TaskEvaluationStarted {
266 session_id: String,
268 items_count: usize,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 generation: Option<u64>,
273 },
274
275 TaskEvaluationCompleted {
277 session_id: String,
279 updates_count: usize,
281 reasoning: String,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 generation: Option<u64>,
286 },
287
288 TaskEvaluationCancelled {
291 session_id: String,
292 reason: String,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
295 generation: Option<u64>,
296 },
297
298 GoldEvaluationStarted {
300 session_id: String,
302 checkpoint: GoldCheckpoint,
304 iteration: u32,
306 },
307
308 GoldEvaluationCompleted {
310 session_id: String,
312 checkpoint: GoldCheckpoint,
314 iteration: u32,
316 decision: GoldDecision,
318 confidence: GoldConfidence,
320 reasoning: String,
322 },
323
324 GoldEvaluationCancelled { session_id: String, reason: String },
326
327 GoalStatusChanged {
334 session_id: String,
336 goal_state: serde_json::Value,
339 },
340
341 TokenBudgetUpdated {
343 usage: TokenBudgetUsage,
345 },
346
347 ContextCompressionStatus {
349 phase: String,
351 status: String,
353 },
354
355 ContextSummarized {
357 summary: String,
359 messages_summarized: usize,
361 tokens_saved: u32,
363 #[serde(default)]
365 usage_before_percent: f64,
366 #[serde(default)]
368 usage_after_percent: f64,
369 #[serde(default)]
371 trigger_type: String,
372 },
373
374 ContextPressureNotification {
377 percent: f64,
379 level: String,
381 message: String,
383 },
384
385 SubAgentStarted {
387 parent_session_id: String,
388 child_session_id: String,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
391 title: Option<String>,
392 },
393
394 SubAgentEvent {
398 parent_session_id: String,
399 child_session_id: String,
400 event: Box<AgentEvent>,
401 },
402
403 SubAgentHeartbeat {
405 parent_session_id: String,
406 child_session_id: String,
407 timestamp: DateTime<Utc>,
408 },
409
410 SubAgentCompleted {
412 parent_session_id: String,
413 child_session_id: String,
414 status: String,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 error: Option<String>,
418 },
419
420 BashCompleted {
434 bash_id: String,
436 command: String,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
440 exit_code: Option<i32>,
441 status: String,
443 },
444
445 PlanModeEntered {
447 session_id: String,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 reason: Option<String>,
452 pre_permission_mode: String,
454 entered_at: chrono::DateTime<chrono::Utc>,
456 status: bamboo_domain::PlanModeStatus,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
460 plan_file_path: Option<String>,
461 },
462
463 PlanModeExited {
465 session_id: String,
467 approved: bool,
469 restored_mode: String,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
473 plan: Option<String>,
474 },
475
476 PlanFileUpdated {
478 session_id: String,
480 file_path: String,
482 content_summary: String,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
486 status: Option<bamboo_domain::PlanModeStatus>,
487 },
488
489 RunnerProgress {
494 session_id: String,
496 round_count: u32,
498 },
499
500 PermissionPostureActivated {
505 session_id: String,
506 policy_revision: u64,
507 requested_mode: String,
508 effective_mode: String,
509 executor_mapping: String,
510 },
511
512 SessionTitleUpdated {
514 session_id: String,
515 title: String,
516 title_version: u64,
517 #[serde(default = "default_title_generated")]
518 title_generated: bool,
519 source: TitleSource,
520 updated_at: chrono::DateTime<chrono::Utc>,
521 },
522
523 SessionPinnedUpdated {
529 session_id: String,
530 pinned: bool,
531 updated_at: chrono::DateTime<chrono::Utc>,
532 },
533
534 SessionCreated {
540 session_id: String,
541 #[serde(default)]
545 project_id: Option<String>,
546 title: String,
547 kind: bamboo_domain::SessionKind,
548 created_at: chrono::DateTime<chrono::Utc>,
549 },
550
551 SessionDeleted { session_id: String },
556
557 SessionCleared { session_id: String },
562
563 MessageAppended {
570 session_id: String,
571 message_id: String,
572 role: bamboo_domain::Role,
573 content: String,
574 created_at: chrono::DateTime<chrono::Utc>,
575 },
576
577 ExecutionStarted {
583 run_id: String,
585 session_id: String,
587 started_at: String,
589 },
590
591 ToolApprovalRequested {
598 tool_call_id: String,
600 tool_name: String,
602 parameters: serde_json::Value,
604 },
605
606 ChildApprovalRequested {
612 child_session_id: String,
614 request_id: String,
616 tool_name: String,
618 permission: String,
620 resource: String,
622 },
623
624 ChildApprovalChanged {
626 parent_session_id: String,
627 child_session_id: String,
628 #[serde(default)]
631 child_attempt: u32,
632 request_id: String,
633 version: u64,
634 status: String,
636 #[serde(default, skip_serializing_if = "Option::is_none")]
637 reason: Option<String>,
638 tool_name: String,
639 permission: String,
640 resource: String,
641 created_at: String,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
643 resolved_at: Option<String>,
644 },
645
646 BudgetExceeded {
656 session_id: String,
658 kind: String,
661 limit: u64,
663 actual: u64,
665 },
666
667 Complete {
669 usage: TokenUsage,
671 },
672
673 Cancelled {
675 #[serde(default, skip_serializing_if = "Option::is_none")]
677 message: Option<String>,
678 },
679
680 Error {
682 message: String,
684 },
685
686 WorkflowChanged {
688 workflow_id: String,
689 revision: u64,
690 scope: String,
691 },
692
693 WorkflowInvalid {
695 workflow_id: String,
696 revision: u64,
697 scope: String,
698 },
699
700 WorkflowRecovered {
702 workflow_id: String,
703 revision: u64,
704 scope: String,
705 },
706
707 ProjectCreated { project_id: String, revision: u64 },
709
710 ProjectUpdated { project_id: String, revision: u64 },
712
713 ProjectArchived { project_id: String, revision: u64 },
715
716 SessionProjectUpdated {
721 session_id: String,
722 #[serde(default)]
725 project_id: Option<String>,
726 #[serde(default)]
729 workspace_path: Option<String>,
730 metadata_version: u64,
731 },
732
733 #[serde(rename = "config.changed")]
735 ConfigChanged { section: String, revision: u64 },
736
737 #[serde(rename = "config.invalid")]
739 ConfigInvalid { section: String, revision: u64 },
740
741 #[serde(rename = "config.recovered")]
743 ConfigRecovered { section: String, revision: u64 },
744
745 WorkflowActivated {
747 event_id: String,
748 session_id: String,
749 workflow_id: String,
750 revision: u64,
751 invoked_by: String,
752 },
753
754 WorkflowDeactivated {
756 event_id: String,
757 session_id: String,
758 workflow_id: String,
759 revision: u64,
760 },
761
762 Notification {
768 id: String,
770 session_id: String,
772 category: String,
775 priority: String,
777 title: String,
779 body: String,
781 #[serde(default, skip_serializing_if = "Option::is_none")]
783 dedup_key: Option<String>,
784 created_at: String,
786 },
787}
788
789impl AgentEvent {
790 pub fn session_id(&self) -> Option<&str> {
799 match self {
800 AgentEvent::TaskListUpdated { task_list, .. } => Some(task_list.session_id.as_str()),
801 AgentEvent::TaskListItemProgress { session_id, .. }
802 | AgentEvent::TaskListCompleted { session_id, .. }
803 | AgentEvent::TaskEvaluationStarted { session_id, .. }
804 | AgentEvent::TaskEvaluationCompleted { session_id, .. }
805 | AgentEvent::TaskEvaluationCancelled { session_id, .. }
806 | AgentEvent::GoldEvaluationStarted { session_id, .. }
807 | AgentEvent::GoldEvaluationCompleted { session_id, .. }
808 | AgentEvent::GoldEvaluationCancelled { session_id, .. }
809 | AgentEvent::GoalStatusChanged { session_id, .. }
810 | AgentEvent::PlanModeEntered { session_id, .. }
811 | AgentEvent::PlanModeExited { session_id, .. }
812 | AgentEvent::PlanFileUpdated { session_id, .. }
813 | AgentEvent::RunnerProgress { session_id, .. }
814 | AgentEvent::PermissionPostureActivated { session_id, .. }
815 | AgentEvent::SessionTitleUpdated { session_id, .. }
816 | AgentEvent::SessionPinnedUpdated { session_id, .. }
817 | AgentEvent::SessionCreated { session_id, .. }
818 | AgentEvent::SessionDeleted { session_id, .. }
819 | AgentEvent::SessionCleared { session_id, .. }
820 | AgentEvent::MessageAppended { session_id, .. }
821 | AgentEvent::ExecutionStarted { session_id, .. }
822 | AgentEvent::BudgetExceeded { session_id, .. }
823 | AgentEvent::WorkflowActivated { session_id, .. }
824 | AgentEvent::WorkflowDeactivated { session_id, .. }
825 | AgentEvent::SessionProjectUpdated { session_id, .. }
826 | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
827 AgentEvent::SubAgentStarted {
828 parent_session_id, ..
829 }
830 | AgentEvent::SubAgentEvent {
831 parent_session_id, ..
832 }
833 | AgentEvent::SubAgentHeartbeat {
834 parent_session_id, ..
835 }
836 | AgentEvent::SubAgentCompleted {
837 parent_session_id, ..
838 }
839 | AgentEvent::ChildApprovalChanged {
840 parent_session_id, ..
841 } => Some(parent_session_id.as_str()),
842 _ => None,
843 }
844 }
845
846 pub fn is_replayable_session_state(&self) -> bool {
854 matches!(
855 self,
856 AgentEvent::TaskListUpdated { .. }
857 | AgentEvent::TaskListCompleted { .. }
858 | AgentEvent::SubAgentStarted { .. }
859 | AgentEvent::SubAgentCompleted { .. }
860 | AgentEvent::ChildApprovalRequested { .. }
861 | AgentEvent::ChildApprovalChanged { .. }
862 | AgentEvent::BashCompleted { .. }
863 | AgentEvent::SessionTitleUpdated { .. }
864 | AgentEvent::SessionPinnedUpdated { .. }
865 | AgentEvent::PlanModeEntered { .. }
866 | AgentEvent::PlanModeExited { .. }
867 | AgentEvent::BudgetExceeded { .. }
868 | AgentEvent::NeedClarification { .. }
869 | AgentEvent::WorkflowActivated { .. }
870 | AgentEvent::WorkflowDeactivated { .. }
871 )
872 }
873
874 pub fn is_durable_change(&self) -> bool {
885 matches!(
886 self,
887 AgentEvent::MessageAppended { .. }
888 | AgentEvent::SessionCreated { .. }
889 | AgentEvent::SessionDeleted { .. }
890 | AgentEvent::SessionCleared { .. }
891 | AgentEvent::SessionTitleUpdated { .. }
892 | AgentEvent::SessionPinnedUpdated { .. }
893 | AgentEvent::TaskListUpdated { .. }
894 | AgentEvent::TaskListItemProgress { .. }
895 | AgentEvent::TaskListCompleted { .. }
896 | AgentEvent::TaskEvaluationCompleted { .. }
897 | AgentEvent::TaskEvaluationCancelled { .. }
898 | AgentEvent::GoldEvaluationCancelled { .. }
899 | AgentEvent::PlanModeEntered { .. }
900 | AgentEvent::PlanModeExited { .. }
901 | AgentEvent::PlanFileUpdated { .. }
902 | AgentEvent::SubAgentStarted { .. }
903 | AgentEvent::SubAgentCompleted { .. }
904 | AgentEvent::ChildApprovalChanged { .. }
905 | AgentEvent::NeedClarification { .. }
906 | AgentEvent::ToolApprovalRequested { .. }
907 | AgentEvent::ExecutionStarted { .. }
908 | AgentEvent::BudgetExceeded { .. }
909 | AgentEvent::Complete { .. }
910 | AgentEvent::Cancelled { .. }
911 | AgentEvent::Error { .. }
912 | AgentEvent::WorkflowChanged { .. }
913 | AgentEvent::WorkflowInvalid { .. }
914 | AgentEvent::ProjectCreated { .. }
915 | AgentEvent::ProjectUpdated { .. }
916 | AgentEvent::ProjectArchived { .. }
917 | AgentEvent::SessionProjectUpdated { .. }
918 | AgentEvent::ConfigChanged { .. }
919 | AgentEvent::ConfigInvalid { .. }
920 | AgentEvent::ConfigRecovered { .. }
921 | AgentEvent::WorkflowRecovered { .. }
922 | AgentEvent::WorkflowActivated { .. }
923 | AgentEvent::WorkflowDeactivated { .. }
924 )
925 }
926}
927
928fn default_allow_custom() -> bool {
929 true
930}
931
932#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
934#[serde(rename_all = "snake_case")]
935pub enum GoldCheckpoint {
936 PostRound,
937 Terminal,
938}
939
940impl GoldCheckpoint {
941 pub fn as_str(self) -> &'static str {
942 match self {
943 Self::PostRound => "post_round",
944 Self::Terminal => "terminal",
945 }
946 }
947}
948
949#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
951#[serde(rename_all = "snake_case")]
952pub enum GoldDecision {
953 Continue,
954 Achieved,
955 Blocked,
956 NeedInput,
957 Exhausted,
958}
959
960impl GoldDecision {
961 pub fn as_str(self) -> &'static str {
962 match self {
963 Self::Continue => "continue",
964 Self::Achieved => "achieved",
965 Self::Blocked => "blocked",
966 Self::NeedInput => "need_input",
967 Self::Exhausted => "exhausted",
968 }
969 }
970}
971
972#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
974#[serde(rename_all = "snake_case")]
975pub enum GoldConfidence {
976 Low,
977 Medium,
978 High,
979}
980
981impl GoldConfidence {
982 pub fn as_str(self) -> &'static str {
983 match self {
984 Self::Low => "low",
985 Self::Medium => "medium",
986 Self::High => "high",
987 }
988 }
989
990 pub fn rank(self) -> u8 {
992 match self {
993 Self::Low => 0,
994 Self::Medium => 1,
995 Self::High => 2,
996 }
997 }
998
999 pub fn meets(self, floor: GoldConfidence) -> bool {
1001 self.rank() >= floor.rank()
1002 }
1003}
1004
1005#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1007#[serde(rename_all = "snake_case")]
1008pub enum TitleSource {
1009 Auto,
1010 Manual,
1011 Fallback,
1012}
1013
1014pub use bamboo_domain::TokenUsage;
1018
1019pub use bamboo_domain::budget_types::TokenBudgetUsage;
1020
1021#[cfg(test)]
1022mod tests {
1023 use super::*;
1024 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
1025
1026 fn sample_task_list() -> TaskList {
1027 TaskList {
1028 session_id: "session-1".to_string(),
1029 title: "Task List".to_string(),
1030 items: vec![TaskItem {
1031 id: "task_1".to_string(),
1032 description: "Implement event rename".to_string(),
1033 status: TaskItemStatus::InProgress,
1034 depends_on: Vec::new(),
1035 notes: "Implementing".to_string(),
1036 ..TaskItem::default()
1037 }],
1038 created_at: Utc::now(),
1039 updated_at: Utc::now(),
1040 }
1041 }
1042
1043 #[test]
1044 fn task_list_updated_serializes_with_task_names() {
1045 let event = AgentEvent::TaskListUpdated {
1046 task_list: sample_task_list(),
1047 version: Some(7),
1048 };
1049
1050 let value = serde_json::to_value(event).expect("event should serialize");
1051 assert_eq!(value["type"], "task_list_updated");
1052 assert!(value.get("task_list").is_some());
1053 assert_eq!(value["version"], 7);
1054 assert!(value.get("todo_list").is_none());
1055 }
1056
1057 #[test]
1058 fn session_project_updated_serializes_unassignment_as_explicit_null() {
1059 let event = AgentEvent::SessionProjectUpdated {
1060 session_id: "session-1".to_string(),
1061 project_id: None,
1062 workspace_path: Some("/workspaces/current".to_string()),
1063 metadata_version: 4,
1064 };
1065
1066 let value = serde_json::to_value(&event).expect("event should serialize");
1067 assert_eq!(value["type"], "session_project_updated");
1068 assert!(
1069 value
1070 .get("project_id")
1071 .is_some_and(serde_json::Value::is_null),
1072 "unassignment must carry an explicit project_id: null"
1073 );
1074 assert_eq!(value["workspace_path"], "/workspaces/current");
1075
1076 let restored: AgentEvent = serde_json::from_value(value).expect("event should deserialize");
1077 assert!(matches!(
1078 restored,
1079 AgentEvent::SessionProjectUpdated {
1080 session_id,
1081 project_id: None,
1082 workspace_path: Some(workspace_path),
1083 metadata_version: 4,
1084 } if session_id == "session-1" && workspace_path == "/workspaces/current"
1085 ));
1086 }
1087
1088 #[test]
1089 fn session_project_updated_deserializes_legacy_event_without_workspace() {
1090 let restored: AgentEvent = serde_json::from_value(serde_json::json!({
1091 "type": "session_project_updated",
1092 "session_id": "session-1",
1093 "project_id": "project-1",
1094 "metadata_version": 2
1095 }))
1096 .expect("legacy event should deserialize");
1097
1098 assert!(matches!(
1099 restored,
1100 AgentEvent::SessionProjectUpdated {
1101 workspace_path: None,
1102 metadata_version: 2,
1103 ..
1104 }
1105 ));
1106 }
1107
1108 #[test]
1109 fn cancelled_serializes_with_snake_case_type() {
1110 let event = AgentEvent::Cancelled {
1111 message: Some("Agent execution cancelled by user".to_string()),
1112 };
1113
1114 let value = serde_json::to_value(event).expect("event should serialize");
1115 assert_eq!(value["type"], "cancelled");
1116 assert_eq!(
1117 value["message"],
1118 serde_json::Value::String("Agent execution cancelled by user".to_string())
1119 );
1120 }
1121
1122 #[test]
1123 fn task_evaluation_completed_serializes_with_task_type() {
1124 let event = AgentEvent::TaskEvaluationCompleted {
1125 session_id: "session-1".to_string(),
1126 updates_count: 2,
1127 reasoning: "Updated statuses".to_string(),
1128 generation: Some(7),
1129 };
1130
1131 let value = serde_json::to_value(event).expect("event should serialize");
1132 assert_eq!(value["type"], "task_evaluation_completed");
1133 assert_eq!(value["generation"], 7);
1134 }
1135
1136 #[test]
1137 fn task_evaluation_event_without_generation_remains_deserializable() {
1138 let event: AgentEvent = serde_json::from_value(serde_json::json!({
1139 "type": "task_evaluation_started",
1140 "session_id": "session-1",
1141 "items_count": 2
1142 }))
1143 .expect("legacy task evaluation frame should remain compatible");
1144
1145 assert!(matches!(
1146 event,
1147 AgentEvent::TaskEvaluationStarted {
1148 generation: None,
1149 ..
1150 }
1151 ));
1152 }
1153
1154 #[test]
1155 fn evaluation_cancelled_events_serialize_as_terminal_lifecycle_events() {
1156 let task = AgentEvent::TaskEvaluationCancelled {
1157 session_id: "session-1".to_string(),
1158 reason: "run_suspended".to_string(),
1159 generation: Some(7),
1160 };
1161 let gold = AgentEvent::GoldEvaluationCancelled {
1162 session_id: "session-1".to_string(),
1163 reason: "run_completed".to_string(),
1164 };
1165
1166 assert!(task.is_durable_change());
1167 assert!(gold.is_durable_change());
1168 let task_value = serde_json::to_value(task).unwrap();
1169 let gold_value = serde_json::to_value(gold).unwrap();
1170 assert_eq!(task_value["type"], "task_evaluation_cancelled");
1171 assert_eq!(task_value["reason"], "run_suspended");
1172 assert_eq!(gold_value["type"], "gold_evaluation_cancelled");
1173 assert_eq!(gold_value["reason"], "run_completed");
1174 }
1175
1176 #[test]
1177 fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
1178 let event = AgentEvent::GoldEvaluationCompleted {
1179 session_id: "session-1".to_string(),
1180 checkpoint: GoldCheckpoint::PostRound,
1181 iteration: 3,
1182 decision: GoldDecision::Continue,
1183 confidence: GoldConfidence::Medium,
1184 reasoning: "Need one more iteration".to_string(),
1185 };
1186
1187 let value = serde_json::to_value(event).expect("event should serialize");
1188 assert_eq!(value["type"], "gold_evaluation_completed");
1189 assert_eq!(value["checkpoint"], "post_round");
1190 assert_eq!(value["iteration"], 3);
1191 assert_eq!(value["decision"], "continue");
1192 assert_eq!(value["confidence"], "medium");
1193 assert_eq!(value["reasoning"], "Need one more iteration");
1194 }
1195
1196 #[test]
1197 fn gold_evaluation_started_deserializes() {
1198 let json = serde_json::json!({
1199 "type": "gold_evaluation_started",
1200 "session_id": "session-1",
1201 "checkpoint": "terminal",
1202 "iteration": 7
1203 });
1204
1205 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1206 match event {
1207 AgentEvent::GoldEvaluationStarted {
1208 session_id,
1209 checkpoint,
1210 iteration,
1211 } => {
1212 assert_eq!(session_id, "session-1");
1213 assert_eq!(checkpoint, GoldCheckpoint::Terminal);
1214 assert_eq!(iteration, 7);
1215 }
1216 other => panic!("unexpected event: {other:?}"),
1217 }
1218 }
1219
1220 #[test]
1221 fn context_compression_status_serializes_with_phase_and_status() {
1222 let event = AgentEvent::ContextCompressionStatus {
1223 phase: "mid-turn".to_string(),
1224 status: "started".to_string(),
1225 };
1226
1227 let value = serde_json::to_value(event).expect("event should serialize");
1228 assert_eq!(value["type"], "context_compression_status");
1229 assert_eq!(value["phase"], "mid-turn");
1230 assert_eq!(value["status"], "started");
1231 }
1232
1233 #[test]
1234 fn need_clarification_serializes_with_new_fields() {
1235 let event = AgentEvent::NeedClarification {
1236 question: "Continue?".to_string(),
1237 options: Some(vec!["Yes".to_string(), "No".to_string()]),
1238 tool_call_id: Some("tool-1".to_string()),
1239 tool_name: Some("conclusion_with_options".to_string()),
1240 allow_custom: false,
1241 source: Some(PendingQuestionSource::PauseTool),
1242 };
1243
1244 let value = serde_json::to_value(event).expect("event should serialize");
1245 assert_eq!(value["type"], "need_clarification");
1246 assert_eq!(value["question"], "Continue?");
1247 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
1248 assert_eq!(value["tool_call_id"], "tool-1");
1249 assert_eq!(value["tool_name"], "conclusion_with_options");
1250 assert_eq!(value["allow_custom"], false);
1251 assert_eq!(value["source"], "pause_tool");
1252 }
1253
1254 #[test]
1255 fn need_clarification_deserializes_from_old_format_without_new_fields() {
1256 let json = serde_json::json!({
1257 "type": "need_clarification",
1258 "question": "Continue?",
1259 "options": ["Yes", "No"]
1260 });
1261
1262 let event: AgentEvent =
1263 serde_json::from_value(json).expect("should deserialize old format");
1264 match event {
1265 AgentEvent::NeedClarification {
1266 question,
1267 options,
1268 tool_call_id,
1269 tool_name,
1270 allow_custom,
1271 source,
1272 } => {
1273 assert_eq!(question, "Continue?");
1274 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
1275 assert_eq!(tool_call_id, None);
1276 assert_eq!(tool_name, None);
1277 assert!(allow_custom); assert_eq!(source, None);
1279 }
1280 other => panic!("unexpected event: {other:?}"),
1281 }
1282 }
1283
1284 #[test]
1285 fn need_clarification_deserializes_with_allow_custom_false() {
1286 let json = serde_json::json!({
1287 "type": "need_clarification",
1288 "question": "Pick one",
1289 "allow_custom": false
1290 });
1291
1292 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1293 match event {
1294 AgentEvent::NeedClarification {
1295 question,
1296 options,
1297 tool_call_id,
1298 tool_name,
1299 allow_custom,
1300 source,
1301 } => {
1302 assert_eq!(question, "Pick one");
1303 assert_eq!(options, None);
1304 assert_eq!(tool_call_id, None);
1305 assert_eq!(tool_name, None);
1306 assert!(!allow_custom);
1307 assert_eq!(source, None);
1308 }
1309 other => panic!("unexpected event: {other:?}"),
1310 }
1311 }
1312
1313 #[test]
1314 fn plan_mode_entered_serializes_correctly() {
1315 let entered_at = Utc::now();
1316 let event = AgentEvent::PlanModeEntered {
1317 session_id: "sess-1".to_string(),
1318 reason: Some("Complex refactor".to_string()),
1319 pre_permission_mode: "default".to_string(),
1320 entered_at,
1321 status: bamboo_domain::PlanModeStatus::Exploring,
1322 plan_file_path: None,
1323 };
1324
1325 let value = serde_json::to_value(event).expect("event should serialize");
1326 assert_eq!(value["type"], "plan_mode_entered");
1327 assert_eq!(value["session_id"], "sess-1");
1328 assert_eq!(value["reason"], "Complex refactor");
1329 assert_eq!(value["pre_permission_mode"], "default");
1330 assert_eq!(value["status"], "exploring");
1331 assert_eq!(
1334 value["entered_at"],
1335 serde_json::to_value(entered_at).unwrap()
1336 );
1337 }
1338
1339 #[test]
1340 fn plan_mode_exited_serializes_correctly() {
1341 let event = AgentEvent::PlanModeExited {
1342 session_id: "sess-1".to_string(),
1343 approved: true,
1344 restored_mode: "accept_edits".to_string(),
1345 plan: Some("# Plan\n1. Step one".to_string()),
1346 };
1347
1348 let value = serde_json::to_value(event).expect("event should serialize");
1349 assert_eq!(value["type"], "plan_mode_exited");
1350 assert_eq!(value["session_id"], "sess-1");
1351 assert_eq!(value["approved"], true);
1352 assert_eq!(value["restored_mode"], "accept_edits");
1353 assert_eq!(value["plan"], "# Plan\n1. Step one");
1354 }
1355
1356 #[test]
1357 fn plan_file_updated_serializes_correctly() {
1358 let event = AgentEvent::PlanFileUpdated {
1359 session_id: "sess-1".to_string(),
1360 file_path: "/tmp/plans/sess-1.md".to_string(),
1361 content_summary: "Implementation plan for feature X".to_string(),
1362 status: Some(bamboo_domain::PlanModeStatus::AwaitingApproval),
1363 };
1364
1365 let value = serde_json::to_value(event).expect("event should serialize");
1366 assert_eq!(value["type"], "plan_file_updated");
1367 assert_eq!(value["session_id"], "sess-1");
1368 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
1369 assert_eq!(
1370 value["content_summary"],
1371 "Implementation plan for feature X"
1372 );
1373 }
1374
1375 #[test]
1376 fn tool_approval_requested_serializes_correctly() {
1377 let event = AgentEvent::ToolApprovalRequested {
1378 tool_call_id: "call-abc".to_string(),
1379 tool_name: "Write".to_string(),
1380 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
1381 };
1382
1383 let value = serde_json::to_value(event).expect("event should serialize");
1384 assert_eq!(value["type"], "tool_approval_requested");
1385 assert_eq!(value["tool_call_id"], "call-abc");
1386 assert_eq!(value["tool_name"], "Write");
1387 assert_eq!(
1388 value["parameters"],
1389 serde_json::json!({"file_path": "/tmp/test.txt"})
1390 );
1391 }
1392
1393 #[test]
1394 fn child_approval_changed_routes_to_parent_and_is_durable() {
1395 let event = AgentEvent::ChildApprovalChanged {
1396 parent_session_id: "parent-1".into(),
1397 child_session_id: "child-1".into(),
1398 child_attempt: 3,
1399 request_id: "req-1".into(),
1400 version: 2,
1401 status: "approved".into(),
1402 reason: None,
1403 tool_name: "Bash".into(),
1404 permission: "execute".into(),
1405 resource: "/tmp/x".into(),
1406 created_at: "2026-01-01T00:00:00Z".into(),
1407 resolved_at: Some("2026-01-01T00:00:01Z".into()),
1408 };
1409 assert_eq!(event.session_id(), Some("parent-1"));
1410 assert!(event.is_durable_change());
1411 let value = serde_json::to_value(event).unwrap();
1412 assert_eq!(value["type"], "child_approval_changed");
1413 assert_eq!(value["status"], "approved");
1414 assert_eq!(value["child_attempt"], 3);
1415
1416 let mut legacy = value;
1417 legacy.as_object_mut().unwrap().remove("child_attempt");
1418 let restored: AgentEvent = serde_json::from_value(legacy).unwrap();
1419 assert!(matches!(
1420 restored,
1421 AgentEvent::ChildApprovalChanged {
1422 child_attempt: 0,
1423 ..
1424 }
1425 ));
1426 }
1427
1428 #[test]
1429 fn tool_approval_requested_deserializes_correctly() {
1430 let json = serde_json::json!({
1431 "type": "tool_approval_requested",
1432 "tool_call_id": "call-xyz",
1433 "tool_name": "Bash",
1434 "parameters": {"command": "ls -la"}
1435 });
1436
1437 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1438 match event {
1439 AgentEvent::ToolApprovalRequested {
1440 tool_call_id,
1441 tool_name,
1442 parameters,
1443 } => {
1444 assert_eq!(tool_call_id, "call-xyz");
1445 assert_eq!(tool_name, "Bash");
1446 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1447 }
1448 other => panic!("unexpected event: {other:?}"),
1449 }
1450 }
1451
1452 #[test]
1453 fn session_title_updated_round_trips_with_source_variants() {
1454 use chrono::Utc;
1455 let event = AgentEvent::SessionTitleUpdated {
1456 session_id: "sess-1".to_string(),
1457 title: "My title".to_string(),
1458 title_version: 3,
1459 title_generated: true,
1460 source: TitleSource::Auto,
1461 updated_at: Utc::now(),
1462 };
1463 let json = serde_json::to_string(&event).unwrap();
1464 assert!(
1465 json.contains("\"type\":\"session_title_updated\""),
1466 "json: {json}"
1467 );
1468 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1469 let decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1470 assert!(matches!(
1471 decoded,
1472 AgentEvent::SessionTitleUpdated {
1473 title_generated: true,
1474 ..
1475 }
1476 ));
1477
1478 let legacy = serde_json::json!({
1479 "type": "session_title_updated",
1480 "session_id": "sess-legacy",
1481 "title": "Existing title",
1482 "title_version": 2,
1483 "source": "manual",
1484 "updated_at": "2025-01-01T00:00:00Z"
1485 });
1486 let decoded: AgentEvent = serde_json::from_value(legacy).unwrap();
1487 assert!(matches!(
1488 decoded,
1489 AgentEvent::SessionTitleUpdated {
1490 title_generated: true,
1491 ..
1492 }
1493 ));
1494 }
1495
1496 #[test]
1497 fn plan_mode_events_deserialize_without_optional_fields() {
1498 let json = serde_json::json!({
1499 "type": "plan_mode_entered",
1500 "session_id": "sess-1",
1501 "pre_permission_mode": "default",
1502 "entered_at": "2025-01-01T00:00:00Z",
1503 "status": "exploring"
1504 });
1505
1506 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1507 match event {
1508 AgentEvent::PlanModeEntered {
1509 session_id,
1510 reason,
1511 pre_permission_mode,
1512 entered_at,
1513 status,
1514 plan_file_path,
1515 } => {
1516 assert_eq!(session_id, "sess-1");
1517 assert_eq!(reason, None);
1518 assert_eq!(pre_permission_mode, "default");
1519 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1520 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1521 assert_eq!(plan_file_path, None);
1522 }
1523 other => panic!("unexpected event: {other:?}"),
1524 }
1525 }
1526
1527 #[test]
1528 fn workflow_catalog_events_are_durable_and_account_scoped() {
1529 for event in [
1530 AgentEvent::WorkflowChanged {
1531 workflow_id: "review".to_string(),
1532 revision: 2,
1533 scope: "global".to_string(),
1534 },
1535 AgentEvent::WorkflowInvalid {
1536 workflow_id: "review".to_string(),
1537 revision: 3,
1538 scope: "workspace:1234".to_string(),
1539 },
1540 AgentEvent::WorkflowRecovered {
1541 workflow_id: "review".to_string(),
1542 revision: 4,
1543 scope: "workspace:1234".to_string(),
1544 },
1545 ] {
1546 assert!(event.is_durable_change());
1547 assert_eq!(event.session_id(), None);
1548 let encoded = serde_json::to_string(&event).expect("serialize");
1549 let _: AgentEvent = serde_json::from_str(&encoded).expect("deserialize");
1550 }
1551 }
1552
1553 #[test]
1554 fn clarification_is_shared_replayable_state_but_tokens_are_not() {
1555 let clarification = AgentEvent::NeedClarification {
1556 question: "Choose".to_string(),
1557 options: Some(vec!["A".to_string()]),
1558 tool_call_id: Some("call-1".to_string()),
1559 tool_name: Some("ConclusionWithOptions".to_string()),
1560 allow_custom: false,
1561 source: Some(PendingQuestionSource::PauseTool),
1562 };
1563 assert!(clarification.is_replayable_session_state());
1564 assert!(!AgentEvent::Token {
1565 content: "ephemeral".to_string(),
1566 }
1567 .is_replayable_session_state());
1568 }
1569}