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)]
106#[serde(tag = "type", rename_all = "snake_case")]
107pub enum AgentEvent {
108 Token {
110 content: String,
112 },
113
114 ReasoningToken {
119 content: String,
121 },
122
123 ToolToken {
128 tool_call_id: String,
130 content: String,
132 },
133
134 ToolStart {
136 tool_call_id: String,
138 tool_name: String,
140 arguments: serde_json::Value,
142 },
143
144 ToolComplete {
146 tool_call_id: String,
148 result: ToolResult,
150 },
151
152 ToolError {
154 tool_call_id: String,
156 error: String,
158 },
159
160 ToolLifecycle {
166 tool_call_id: String,
168 tool_name: String,
170 phase: String,
172 #[serde(skip_serializing_if = "Option::is_none")]
174 elapsed_ms: Option<u64>,
175 is_mutating: bool,
177 auto_approved: bool,
179 #[serde(skip_serializing_if = "Option::is_none")]
181 summary: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
184 error: Option<String>,
185 },
186
187 HookLifecycle {
189 hook_name: String,
191 point: AgentHookPoint,
193 phase: String,
196 duration_ms: u64,
198 decision: HookResult,
200 },
201
202 NeedClarification {
204 question: String,
206 options: Option<Vec<String>>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 tool_call_id: Option<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 tool_name: Option<String>,
214 #[serde(default = "default_allow_custom")]
216 allow_custom: bool,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 source: Option<PendingQuestionSource>,
220 },
221
222 TaskListUpdated {
224 task_list: TaskList,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 version: Option<u64>,
229 },
230
231 TaskListItemProgress {
233 session_id: String,
235 item_id: String,
237 status: TaskItemStatus,
239 tool_calls_count: usize,
241 version: u64,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 item: Option<TaskItem>,
246 },
247
248 TaskListCompleted {
250 session_id: String,
252 completed_at: DateTime<Utc>,
254 total_rounds: u32,
256 total_tool_calls: usize,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 version: Option<u64>,
261 },
262
263 TaskEvaluationStarted {
265 session_id: String,
267 items_count: usize,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 generation: Option<u64>,
272 },
273
274 TaskEvaluationCompleted {
276 session_id: String,
278 updates_count: usize,
280 reasoning: String,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 generation: Option<u64>,
285 },
286
287 TaskEvaluationCancelled {
290 session_id: String,
291 reason: String,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 generation: Option<u64>,
295 },
296
297 GoldEvaluationStarted {
299 session_id: String,
301 checkpoint: GoldCheckpoint,
303 iteration: u32,
305 },
306
307 GoldEvaluationCompleted {
309 session_id: String,
311 checkpoint: GoldCheckpoint,
313 iteration: u32,
315 decision: GoldDecision,
317 confidence: GoldConfidence,
319 reasoning: String,
321 },
322
323 GoldEvaluationCancelled { session_id: String, reason: String },
325
326 GoalStatusChanged {
333 session_id: String,
335 goal_state: serde_json::Value,
338 },
339
340 TokenBudgetUpdated {
342 usage: TokenBudgetUsage,
344 },
345
346 ContextCompressionStatus {
348 phase: String,
350 status: String,
352 },
353
354 ContextSummarized {
356 summary: String,
358 messages_summarized: usize,
360 tokens_saved: u32,
362 #[serde(default)]
364 usage_before_percent: f64,
365 #[serde(default)]
367 usage_after_percent: f64,
368 #[serde(default)]
370 trigger_type: String,
371 },
372
373 ContextPressureNotification {
376 percent: f64,
378 level: String,
380 message: String,
382 },
383
384 SubAgentStarted {
386 parent_session_id: String,
387 child_session_id: String,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
390 title: Option<String>,
391 },
392
393 SubAgentEvent {
397 parent_session_id: String,
398 child_session_id: String,
399 event: Box<AgentEvent>,
400 },
401
402 SubAgentHeartbeat {
404 parent_session_id: String,
405 child_session_id: String,
406 timestamp: DateTime<Utc>,
407 },
408
409 SubAgentCompleted {
411 parent_session_id: String,
412 child_session_id: String,
413 status: String,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 error: Option<String>,
417 },
418
419 BashCompleted {
433 bash_id: String,
435 command: String,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
439 exit_code: Option<i32>,
440 status: String,
442 },
443
444 PlanModeEntered {
446 session_id: String,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
450 reason: Option<String>,
451 pre_permission_mode: String,
453 entered_at: chrono::DateTime<chrono::Utc>,
455 status: bamboo_domain::PlanModeStatus,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
459 plan_file_path: Option<String>,
460 },
461
462 PlanModeExited {
464 session_id: String,
466 approved: bool,
468 restored_mode: String,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
472 plan: Option<String>,
473 },
474
475 PlanFileUpdated {
477 session_id: String,
479 file_path: String,
481 content_summary: String,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
485 status: Option<bamboo_domain::PlanModeStatus>,
486 },
487
488 RunnerProgress {
493 session_id: String,
495 round_count: u32,
497 },
498
499 PermissionPostureActivated {
504 session_id: String,
505 policy_revision: u64,
506 requested_mode: String,
507 effective_mode: String,
508 executor_mapping: String,
509 },
510
511 SessionTitleUpdated {
513 session_id: String,
514 title: String,
515 title_version: u64,
516 #[serde(default = "default_title_generated")]
517 title_generated: bool,
518 source: TitleSource,
519 updated_at: chrono::DateTime<chrono::Utc>,
520 },
521
522 SessionPinnedUpdated {
528 session_id: String,
529 pinned: bool,
530 updated_at: chrono::DateTime<chrono::Utc>,
531 },
532
533 SessionCreated {
539 session_id: String,
540 #[serde(default)]
544 project_id: Option<String>,
545 title: String,
546 kind: bamboo_domain::SessionKind,
547 created_at: chrono::DateTime<chrono::Utc>,
548 },
549
550 SessionDeleted { session_id: String },
555
556 SessionCleared { session_id: String },
561
562 MessageAppended {
569 session_id: String,
570 message_id: String,
571 role: bamboo_domain::Role,
572 content: String,
573 created_at: chrono::DateTime<chrono::Utc>,
574 },
575
576 ExecutionStarted {
582 run_id: String,
584 session_id: String,
586 started_at: String,
588 },
589
590 ToolApprovalRequested {
597 tool_call_id: String,
599 tool_name: String,
601 parameters: serde_json::Value,
603 },
604
605 ChildApprovalRequested {
611 child_session_id: String,
613 request_id: String,
615 tool_name: String,
617 permission: String,
619 resource: String,
621 },
622
623 ChildApprovalChanged {
625 parent_session_id: String,
626 child_session_id: String,
627 #[serde(default)]
630 child_attempt: u32,
631 request_id: String,
632 version: u64,
633 status: String,
635 #[serde(default, skip_serializing_if = "Option::is_none")]
636 reason: Option<String>,
637 tool_name: String,
638 permission: String,
639 resource: String,
640 created_at: String,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
642 resolved_at: Option<String>,
643 },
644
645 BudgetExceeded {
655 session_id: String,
657 kind: String,
660 limit: u64,
662 actual: u64,
664 },
665
666 Complete {
668 usage: TokenUsage,
670 },
671
672 Cancelled {
674 #[serde(default, skip_serializing_if = "Option::is_none")]
676 message: Option<String>,
677 },
678
679 Error {
681 message: String,
683 },
684
685 WorkflowChanged {
687 workflow_id: String,
688 revision: u64,
689 scope: String,
690 },
691
692 WorkflowInvalid {
694 workflow_id: String,
695 revision: u64,
696 scope: String,
697 },
698
699 WorkflowRecovered {
701 workflow_id: String,
702 revision: u64,
703 scope: String,
704 },
705
706 ProjectCreated { project_id: String, revision: u64 },
708
709 ProjectUpdated { project_id: String, revision: u64 },
711
712 ProjectArchived { project_id: String, revision: u64 },
714
715 SessionProjectUpdated {
720 session_id: String,
721 #[serde(default)]
724 project_id: Option<String>,
725 #[serde(default)]
728 workspace_path: Option<String>,
729 metadata_version: u64,
730 },
731
732 #[serde(rename = "config.changed")]
734 ConfigChanged { section: String, revision: u64 },
735
736 #[serde(rename = "config.invalid")]
738 ConfigInvalid { section: String, revision: u64 },
739
740 #[serde(rename = "config.recovered")]
742 ConfigRecovered { section: String, revision: u64 },
743
744 WorkflowActivated {
746 event_id: String,
747 session_id: String,
748 workflow_id: String,
749 revision: u64,
750 invoked_by: String,
751 },
752
753 WorkflowDeactivated {
755 event_id: String,
756 session_id: String,
757 workflow_id: String,
758 revision: u64,
759 },
760
761 Notification {
767 id: String,
769 session_id: String,
771 category: String,
774 priority: String,
776 title: String,
778 body: String,
780 #[serde(default, skip_serializing_if = "Option::is_none")]
782 dedup_key: Option<String>,
783 created_at: String,
785 },
786}
787
788impl AgentEvent {
789 pub fn session_id(&self) -> Option<&str> {
798 match self {
799 AgentEvent::TaskListUpdated { task_list, .. } => Some(task_list.session_id.as_str()),
800 AgentEvent::TaskListItemProgress { session_id, .. }
801 | AgentEvent::TaskListCompleted { session_id, .. }
802 | AgentEvent::TaskEvaluationStarted { session_id, .. }
803 | AgentEvent::TaskEvaluationCompleted { session_id, .. }
804 | AgentEvent::TaskEvaluationCancelled { session_id, .. }
805 | AgentEvent::GoldEvaluationStarted { session_id, .. }
806 | AgentEvent::GoldEvaluationCompleted { session_id, .. }
807 | AgentEvent::GoldEvaluationCancelled { session_id, .. }
808 | AgentEvent::GoalStatusChanged { session_id, .. }
809 | AgentEvent::PlanModeEntered { session_id, .. }
810 | AgentEvent::PlanModeExited { session_id, .. }
811 | AgentEvent::PlanFileUpdated { session_id, .. }
812 | AgentEvent::RunnerProgress { session_id, .. }
813 | AgentEvent::PermissionPostureActivated { session_id, .. }
814 | AgentEvent::SessionTitleUpdated { session_id, .. }
815 | AgentEvent::SessionPinnedUpdated { session_id, .. }
816 | AgentEvent::SessionCreated { session_id, .. }
817 | AgentEvent::SessionDeleted { session_id, .. }
818 | AgentEvent::SessionCleared { session_id, .. }
819 | AgentEvent::MessageAppended { session_id, .. }
820 | AgentEvent::ExecutionStarted { session_id, .. }
821 | AgentEvent::BudgetExceeded { session_id, .. }
822 | AgentEvent::WorkflowActivated { session_id, .. }
823 | AgentEvent::WorkflowDeactivated { session_id, .. }
824 | AgentEvent::SessionProjectUpdated { session_id, .. }
825 | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
826 AgentEvent::SubAgentStarted {
827 parent_session_id, ..
828 }
829 | AgentEvent::SubAgentEvent {
830 parent_session_id, ..
831 }
832 | AgentEvent::SubAgentHeartbeat {
833 parent_session_id, ..
834 }
835 | AgentEvent::SubAgentCompleted {
836 parent_session_id, ..
837 }
838 | AgentEvent::ChildApprovalChanged {
839 parent_session_id, ..
840 } => Some(parent_session_id.as_str()),
841 _ => None,
842 }
843 }
844
845 pub fn is_replayable_session_state(&self) -> bool {
853 matches!(
854 self,
855 AgentEvent::TaskListUpdated { .. }
856 | AgentEvent::TaskListCompleted { .. }
857 | AgentEvent::SubAgentStarted { .. }
858 | AgentEvent::SubAgentCompleted { .. }
859 | AgentEvent::ChildApprovalRequested { .. }
860 | AgentEvent::ChildApprovalChanged { .. }
861 | AgentEvent::BashCompleted { .. }
862 | AgentEvent::SessionTitleUpdated { .. }
863 | AgentEvent::SessionPinnedUpdated { .. }
864 | AgentEvent::PlanModeEntered { .. }
865 | AgentEvent::PlanModeExited { .. }
866 | AgentEvent::BudgetExceeded { .. }
867 | AgentEvent::NeedClarification { .. }
868 | AgentEvent::WorkflowActivated { .. }
869 | AgentEvent::WorkflowDeactivated { .. }
870 )
871 }
872
873 pub fn is_durable_change(&self) -> bool {
884 matches!(
885 self,
886 AgentEvent::MessageAppended { .. }
887 | AgentEvent::SessionCreated { .. }
888 | AgentEvent::SessionDeleted { .. }
889 | AgentEvent::SessionCleared { .. }
890 | AgentEvent::SessionTitleUpdated { .. }
891 | AgentEvent::SessionPinnedUpdated { .. }
892 | AgentEvent::TaskListUpdated { .. }
893 | AgentEvent::TaskListItemProgress { .. }
894 | AgentEvent::TaskListCompleted { .. }
895 | AgentEvent::TaskEvaluationCompleted { .. }
896 | AgentEvent::TaskEvaluationCancelled { .. }
897 | AgentEvent::GoldEvaluationCancelled { .. }
898 | AgentEvent::PlanModeEntered { .. }
899 | AgentEvent::PlanModeExited { .. }
900 | AgentEvent::PlanFileUpdated { .. }
901 | AgentEvent::SubAgentStarted { .. }
902 | AgentEvent::SubAgentCompleted { .. }
903 | AgentEvent::ChildApprovalChanged { .. }
904 | AgentEvent::NeedClarification { .. }
905 | AgentEvent::ToolApprovalRequested { .. }
906 | AgentEvent::ExecutionStarted { .. }
907 | AgentEvent::BudgetExceeded { .. }
908 | AgentEvent::Complete { .. }
909 | AgentEvent::Cancelled { .. }
910 | AgentEvent::Error { .. }
911 | AgentEvent::WorkflowChanged { .. }
912 | AgentEvent::WorkflowInvalid { .. }
913 | AgentEvent::ProjectCreated { .. }
914 | AgentEvent::ProjectUpdated { .. }
915 | AgentEvent::ProjectArchived { .. }
916 | AgentEvent::SessionProjectUpdated { .. }
917 | AgentEvent::ConfigChanged { .. }
918 | AgentEvent::ConfigInvalid { .. }
919 | AgentEvent::ConfigRecovered { .. }
920 | AgentEvent::WorkflowRecovered { .. }
921 | AgentEvent::WorkflowActivated { .. }
922 | AgentEvent::WorkflowDeactivated { .. }
923 )
924 }
925}
926
927fn default_allow_custom() -> bool {
928 true
929}
930
931#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
933#[serde(rename_all = "snake_case")]
934pub enum GoldCheckpoint {
935 PostRound,
936 Terminal,
937}
938
939impl GoldCheckpoint {
940 pub fn as_str(self) -> &'static str {
941 match self {
942 Self::PostRound => "post_round",
943 Self::Terminal => "terminal",
944 }
945 }
946}
947
948#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
950#[serde(rename_all = "snake_case")]
951pub enum GoldDecision {
952 Continue,
953 Achieved,
954 Blocked,
955 NeedInput,
956 Exhausted,
957}
958
959impl GoldDecision {
960 pub fn as_str(self) -> &'static str {
961 match self {
962 Self::Continue => "continue",
963 Self::Achieved => "achieved",
964 Self::Blocked => "blocked",
965 Self::NeedInput => "need_input",
966 Self::Exhausted => "exhausted",
967 }
968 }
969}
970
971#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
973#[serde(rename_all = "snake_case")]
974pub enum GoldConfidence {
975 Low,
976 Medium,
977 High,
978}
979
980impl GoldConfidence {
981 pub fn as_str(self) -> &'static str {
982 match self {
983 Self::Low => "low",
984 Self::Medium => "medium",
985 Self::High => "high",
986 }
987 }
988
989 pub fn rank(self) -> u8 {
991 match self {
992 Self::Low => 0,
993 Self::Medium => 1,
994 Self::High => 2,
995 }
996 }
997
998 pub fn meets(self, floor: GoldConfidence) -> bool {
1000 self.rank() >= floor.rank()
1001 }
1002}
1003
1004#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1006#[serde(rename_all = "snake_case")]
1007pub enum TitleSource {
1008 Auto,
1009 Manual,
1010 Fallback,
1011}
1012
1013pub use bamboo_domain::TokenUsage;
1017
1018pub use bamboo_domain::budget_types::TokenBudgetUsage;
1019
1020#[cfg(test)]
1021mod tests {
1022 use super::*;
1023 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
1024
1025 fn sample_task_list() -> TaskList {
1026 TaskList {
1027 session_id: "session-1".to_string(),
1028 title: "Task List".to_string(),
1029 items: vec![TaskItem {
1030 id: "task_1".to_string(),
1031 description: "Implement event rename".to_string(),
1032 status: TaskItemStatus::InProgress,
1033 depends_on: Vec::new(),
1034 notes: "Implementing".to_string(),
1035 ..TaskItem::default()
1036 }],
1037 created_at: Utc::now(),
1038 updated_at: Utc::now(),
1039 }
1040 }
1041
1042 #[test]
1043 fn task_list_updated_serializes_with_task_names() {
1044 let event = AgentEvent::TaskListUpdated {
1045 task_list: sample_task_list(),
1046 version: Some(7),
1047 };
1048
1049 let value = serde_json::to_value(event).expect("event should serialize");
1050 assert_eq!(value["type"], "task_list_updated");
1051 assert!(value.get("task_list").is_some());
1052 assert_eq!(value["version"], 7);
1053 assert!(value.get("todo_list").is_none());
1054 }
1055
1056 #[test]
1057 fn session_project_updated_serializes_unassignment_as_explicit_null() {
1058 let event = AgentEvent::SessionProjectUpdated {
1059 session_id: "session-1".to_string(),
1060 project_id: None,
1061 workspace_path: Some("/workspaces/current".to_string()),
1062 metadata_version: 4,
1063 };
1064
1065 let value = serde_json::to_value(&event).expect("event should serialize");
1066 assert_eq!(value["type"], "session_project_updated");
1067 assert!(
1068 value
1069 .get("project_id")
1070 .is_some_and(serde_json::Value::is_null),
1071 "unassignment must carry an explicit project_id: null"
1072 );
1073 assert_eq!(value["workspace_path"], "/workspaces/current");
1074
1075 let restored: AgentEvent = serde_json::from_value(value).expect("event should deserialize");
1076 assert!(matches!(
1077 restored,
1078 AgentEvent::SessionProjectUpdated {
1079 session_id,
1080 project_id: None,
1081 workspace_path: Some(workspace_path),
1082 metadata_version: 4,
1083 } if session_id == "session-1" && workspace_path == "/workspaces/current"
1084 ));
1085 }
1086
1087 #[test]
1088 fn session_project_updated_deserializes_legacy_event_without_workspace() {
1089 let restored: AgentEvent = serde_json::from_value(serde_json::json!({
1090 "type": "session_project_updated",
1091 "session_id": "session-1",
1092 "project_id": "project-1",
1093 "metadata_version": 2
1094 }))
1095 .expect("legacy event should deserialize");
1096
1097 assert!(matches!(
1098 restored,
1099 AgentEvent::SessionProjectUpdated {
1100 workspace_path: None,
1101 metadata_version: 2,
1102 ..
1103 }
1104 ));
1105 }
1106
1107 #[test]
1108 fn cancelled_serializes_with_snake_case_type() {
1109 let event = AgentEvent::Cancelled {
1110 message: Some("Agent execution cancelled by user".to_string()),
1111 };
1112
1113 let value = serde_json::to_value(event).expect("event should serialize");
1114 assert_eq!(value["type"], "cancelled");
1115 assert_eq!(
1116 value["message"],
1117 serde_json::Value::String("Agent execution cancelled by user".to_string())
1118 );
1119 }
1120
1121 #[test]
1122 fn task_evaluation_completed_serializes_with_task_type() {
1123 let event = AgentEvent::TaskEvaluationCompleted {
1124 session_id: "session-1".to_string(),
1125 updates_count: 2,
1126 reasoning: "Updated statuses".to_string(),
1127 generation: Some(7),
1128 };
1129
1130 let value = serde_json::to_value(event).expect("event should serialize");
1131 assert_eq!(value["type"], "task_evaluation_completed");
1132 assert_eq!(value["generation"], 7);
1133 }
1134
1135 #[test]
1136 fn task_evaluation_event_without_generation_remains_deserializable() {
1137 let event: AgentEvent = serde_json::from_value(serde_json::json!({
1138 "type": "task_evaluation_started",
1139 "session_id": "session-1",
1140 "items_count": 2
1141 }))
1142 .expect("legacy task evaluation frame should remain compatible");
1143
1144 assert!(matches!(
1145 event,
1146 AgentEvent::TaskEvaluationStarted {
1147 generation: None,
1148 ..
1149 }
1150 ));
1151 }
1152
1153 #[test]
1154 fn evaluation_cancelled_events_serialize_as_terminal_lifecycle_events() {
1155 let task = AgentEvent::TaskEvaluationCancelled {
1156 session_id: "session-1".to_string(),
1157 reason: "run_suspended".to_string(),
1158 generation: Some(7),
1159 };
1160 let gold = AgentEvent::GoldEvaluationCancelled {
1161 session_id: "session-1".to_string(),
1162 reason: "run_completed".to_string(),
1163 };
1164
1165 assert!(task.is_durable_change());
1166 assert!(gold.is_durable_change());
1167 let task_value = serde_json::to_value(task).unwrap();
1168 let gold_value = serde_json::to_value(gold).unwrap();
1169 assert_eq!(task_value["type"], "task_evaluation_cancelled");
1170 assert_eq!(task_value["reason"], "run_suspended");
1171 assert_eq!(gold_value["type"], "gold_evaluation_cancelled");
1172 assert_eq!(gold_value["reason"], "run_completed");
1173 }
1174
1175 #[test]
1176 fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
1177 let event = AgentEvent::GoldEvaluationCompleted {
1178 session_id: "session-1".to_string(),
1179 checkpoint: GoldCheckpoint::PostRound,
1180 iteration: 3,
1181 decision: GoldDecision::Continue,
1182 confidence: GoldConfidence::Medium,
1183 reasoning: "Need one more iteration".to_string(),
1184 };
1185
1186 let value = serde_json::to_value(event).expect("event should serialize");
1187 assert_eq!(value["type"], "gold_evaluation_completed");
1188 assert_eq!(value["checkpoint"], "post_round");
1189 assert_eq!(value["iteration"], 3);
1190 assert_eq!(value["decision"], "continue");
1191 assert_eq!(value["confidence"], "medium");
1192 assert_eq!(value["reasoning"], "Need one more iteration");
1193 }
1194
1195 #[test]
1196 fn gold_evaluation_started_deserializes() {
1197 let json = serde_json::json!({
1198 "type": "gold_evaluation_started",
1199 "session_id": "session-1",
1200 "checkpoint": "terminal",
1201 "iteration": 7
1202 });
1203
1204 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1205 match event {
1206 AgentEvent::GoldEvaluationStarted {
1207 session_id,
1208 checkpoint,
1209 iteration,
1210 } => {
1211 assert_eq!(session_id, "session-1");
1212 assert_eq!(checkpoint, GoldCheckpoint::Terminal);
1213 assert_eq!(iteration, 7);
1214 }
1215 other => panic!("unexpected event: {other:?}"),
1216 }
1217 }
1218
1219 #[test]
1220 fn context_compression_status_serializes_with_phase_and_status() {
1221 let event = AgentEvent::ContextCompressionStatus {
1222 phase: "mid-turn".to_string(),
1223 status: "started".to_string(),
1224 };
1225
1226 let value = serde_json::to_value(event).expect("event should serialize");
1227 assert_eq!(value["type"], "context_compression_status");
1228 assert_eq!(value["phase"], "mid-turn");
1229 assert_eq!(value["status"], "started");
1230 }
1231
1232 #[test]
1233 fn need_clarification_serializes_with_new_fields() {
1234 let event = AgentEvent::NeedClarification {
1235 question: "Continue?".to_string(),
1236 options: Some(vec!["Yes".to_string(), "No".to_string()]),
1237 tool_call_id: Some("tool-1".to_string()),
1238 tool_name: Some("conclusion_with_options".to_string()),
1239 allow_custom: false,
1240 source: Some(PendingQuestionSource::PauseTool),
1241 };
1242
1243 let value = serde_json::to_value(event).expect("event should serialize");
1244 assert_eq!(value["type"], "need_clarification");
1245 assert_eq!(value["question"], "Continue?");
1246 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
1247 assert_eq!(value["tool_call_id"], "tool-1");
1248 assert_eq!(value["tool_name"], "conclusion_with_options");
1249 assert_eq!(value["allow_custom"], false);
1250 assert_eq!(value["source"], "pause_tool");
1251 }
1252
1253 #[test]
1254 fn need_clarification_deserializes_from_old_format_without_new_fields() {
1255 let json = serde_json::json!({
1256 "type": "need_clarification",
1257 "question": "Continue?",
1258 "options": ["Yes", "No"]
1259 });
1260
1261 let event: AgentEvent =
1262 serde_json::from_value(json).expect("should deserialize old format");
1263 match event {
1264 AgentEvent::NeedClarification {
1265 question,
1266 options,
1267 tool_call_id,
1268 tool_name,
1269 allow_custom,
1270 source,
1271 } => {
1272 assert_eq!(question, "Continue?");
1273 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
1274 assert_eq!(tool_call_id, None);
1275 assert_eq!(tool_name, None);
1276 assert!(allow_custom); assert_eq!(source, None);
1278 }
1279 other => panic!("unexpected event: {other:?}"),
1280 }
1281 }
1282
1283 #[test]
1284 fn need_clarification_deserializes_with_allow_custom_false() {
1285 let json = serde_json::json!({
1286 "type": "need_clarification",
1287 "question": "Pick one",
1288 "allow_custom": false
1289 });
1290
1291 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1292 match event {
1293 AgentEvent::NeedClarification {
1294 question,
1295 options,
1296 tool_call_id,
1297 tool_name,
1298 allow_custom,
1299 source,
1300 } => {
1301 assert_eq!(question, "Pick one");
1302 assert_eq!(options, None);
1303 assert_eq!(tool_call_id, None);
1304 assert_eq!(tool_name, None);
1305 assert!(!allow_custom);
1306 assert_eq!(source, None);
1307 }
1308 other => panic!("unexpected event: {other:?}"),
1309 }
1310 }
1311
1312 #[test]
1313 fn plan_mode_entered_serializes_correctly() {
1314 let entered_at = Utc::now();
1315 let event = AgentEvent::PlanModeEntered {
1316 session_id: "sess-1".to_string(),
1317 reason: Some("Complex refactor".to_string()),
1318 pre_permission_mode: "default".to_string(),
1319 entered_at,
1320 status: bamboo_domain::PlanModeStatus::Exploring,
1321 plan_file_path: None,
1322 };
1323
1324 let value = serde_json::to_value(event).expect("event should serialize");
1325 assert_eq!(value["type"], "plan_mode_entered");
1326 assert_eq!(value["session_id"], "sess-1");
1327 assert_eq!(value["reason"], "Complex refactor");
1328 assert_eq!(value["pre_permission_mode"], "default");
1329 assert_eq!(value["status"], "exploring");
1330 assert_eq!(
1333 value["entered_at"],
1334 serde_json::to_value(entered_at).unwrap()
1335 );
1336 }
1337
1338 #[test]
1339 fn plan_mode_exited_serializes_correctly() {
1340 let event = AgentEvent::PlanModeExited {
1341 session_id: "sess-1".to_string(),
1342 approved: true,
1343 restored_mode: "accept_edits".to_string(),
1344 plan: Some("# Plan\n1. Step one".to_string()),
1345 };
1346
1347 let value = serde_json::to_value(event).expect("event should serialize");
1348 assert_eq!(value["type"], "plan_mode_exited");
1349 assert_eq!(value["session_id"], "sess-1");
1350 assert_eq!(value["approved"], true);
1351 assert_eq!(value["restored_mode"], "accept_edits");
1352 assert_eq!(value["plan"], "# Plan\n1. Step one");
1353 }
1354
1355 #[test]
1356 fn plan_file_updated_serializes_correctly() {
1357 let event = AgentEvent::PlanFileUpdated {
1358 session_id: "sess-1".to_string(),
1359 file_path: "/tmp/plans/sess-1.md".to_string(),
1360 content_summary: "Implementation plan for feature X".to_string(),
1361 status: Some(bamboo_domain::PlanModeStatus::AwaitingApproval),
1362 };
1363
1364 let value = serde_json::to_value(event).expect("event should serialize");
1365 assert_eq!(value["type"], "plan_file_updated");
1366 assert_eq!(value["session_id"], "sess-1");
1367 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
1368 assert_eq!(
1369 value["content_summary"],
1370 "Implementation plan for feature X"
1371 );
1372 }
1373
1374 #[test]
1375 fn tool_approval_requested_serializes_correctly() {
1376 let event = AgentEvent::ToolApprovalRequested {
1377 tool_call_id: "call-abc".to_string(),
1378 tool_name: "Write".to_string(),
1379 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
1380 };
1381
1382 let value = serde_json::to_value(event).expect("event should serialize");
1383 assert_eq!(value["type"], "tool_approval_requested");
1384 assert_eq!(value["tool_call_id"], "call-abc");
1385 assert_eq!(value["tool_name"], "Write");
1386 assert_eq!(
1387 value["parameters"],
1388 serde_json::json!({"file_path": "/tmp/test.txt"})
1389 );
1390 }
1391
1392 #[test]
1393 fn child_approval_changed_routes_to_parent_and_is_durable() {
1394 let event = AgentEvent::ChildApprovalChanged {
1395 parent_session_id: "parent-1".into(),
1396 child_session_id: "child-1".into(),
1397 child_attempt: 3,
1398 request_id: "req-1".into(),
1399 version: 2,
1400 status: "approved".into(),
1401 reason: None,
1402 tool_name: "Bash".into(),
1403 permission: "execute".into(),
1404 resource: "/tmp/x".into(),
1405 created_at: "2026-01-01T00:00:00Z".into(),
1406 resolved_at: Some("2026-01-01T00:00:01Z".into()),
1407 };
1408 assert_eq!(event.session_id(), Some("parent-1"));
1409 assert!(event.is_durable_change());
1410 let value = serde_json::to_value(event).unwrap();
1411 assert_eq!(value["type"], "child_approval_changed");
1412 assert_eq!(value["status"], "approved");
1413 assert_eq!(value["child_attempt"], 3);
1414
1415 let mut legacy = value;
1416 legacy.as_object_mut().unwrap().remove("child_attempt");
1417 let restored: AgentEvent = serde_json::from_value(legacy).unwrap();
1418 assert!(matches!(
1419 restored,
1420 AgentEvent::ChildApprovalChanged {
1421 child_attempt: 0,
1422 ..
1423 }
1424 ));
1425 }
1426
1427 #[test]
1428 fn tool_approval_requested_deserializes_correctly() {
1429 let json = serde_json::json!({
1430 "type": "tool_approval_requested",
1431 "tool_call_id": "call-xyz",
1432 "tool_name": "Bash",
1433 "parameters": {"command": "ls -la"}
1434 });
1435
1436 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1437 match event {
1438 AgentEvent::ToolApprovalRequested {
1439 tool_call_id,
1440 tool_name,
1441 parameters,
1442 } => {
1443 assert_eq!(tool_call_id, "call-xyz");
1444 assert_eq!(tool_name, "Bash");
1445 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1446 }
1447 other => panic!("unexpected event: {other:?}"),
1448 }
1449 }
1450
1451 #[test]
1452 fn session_title_updated_round_trips_with_source_variants() {
1453 use chrono::Utc;
1454 let event = AgentEvent::SessionTitleUpdated {
1455 session_id: "sess-1".to_string(),
1456 title: "My title".to_string(),
1457 title_version: 3,
1458 title_generated: true,
1459 source: TitleSource::Auto,
1460 updated_at: Utc::now(),
1461 };
1462 let json = serde_json::to_string(&event).unwrap();
1463 assert!(
1464 json.contains("\"type\":\"session_title_updated\""),
1465 "json: {json}"
1466 );
1467 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1468 let decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1469 assert!(matches!(
1470 decoded,
1471 AgentEvent::SessionTitleUpdated {
1472 title_generated: true,
1473 ..
1474 }
1475 ));
1476
1477 let legacy = serde_json::json!({
1478 "type": "session_title_updated",
1479 "session_id": "sess-legacy",
1480 "title": "Existing title",
1481 "title_version": 2,
1482 "source": "manual",
1483 "updated_at": "2025-01-01T00:00:00Z"
1484 });
1485 let decoded: AgentEvent = serde_json::from_value(legacy).unwrap();
1486 assert!(matches!(
1487 decoded,
1488 AgentEvent::SessionTitleUpdated {
1489 title_generated: true,
1490 ..
1491 }
1492 ));
1493 }
1494
1495 #[test]
1496 fn plan_mode_events_deserialize_without_optional_fields() {
1497 let json = serde_json::json!({
1498 "type": "plan_mode_entered",
1499 "session_id": "sess-1",
1500 "pre_permission_mode": "default",
1501 "entered_at": "2025-01-01T00:00:00Z",
1502 "status": "exploring"
1503 });
1504
1505 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1506 match event {
1507 AgentEvent::PlanModeEntered {
1508 session_id,
1509 reason,
1510 pre_permission_mode,
1511 entered_at,
1512 status,
1513 plan_file_path,
1514 } => {
1515 assert_eq!(session_id, "sess-1");
1516 assert_eq!(reason, None);
1517 assert_eq!(pre_permission_mode, "default");
1518 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1519 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1520 assert_eq!(plan_file_path, None);
1521 }
1522 other => panic!("unexpected event: {other:?}"),
1523 }
1524 }
1525
1526 #[test]
1527 fn workflow_catalog_events_are_durable_and_account_scoped() {
1528 for event in [
1529 AgentEvent::WorkflowChanged {
1530 workflow_id: "review".to_string(),
1531 revision: 2,
1532 scope: "global".to_string(),
1533 },
1534 AgentEvent::WorkflowInvalid {
1535 workflow_id: "review".to_string(),
1536 revision: 3,
1537 scope: "workspace:1234".to_string(),
1538 },
1539 AgentEvent::WorkflowRecovered {
1540 workflow_id: "review".to_string(),
1541 revision: 4,
1542 scope: "workspace:1234".to_string(),
1543 },
1544 ] {
1545 assert!(event.is_durable_change());
1546 assert_eq!(event.session_id(), None);
1547 let encoded = serde_json::to_string(&event).expect("serialize");
1548 let _: AgentEvent = serde_json::from_str(&encoded).expect("deserialize");
1549 }
1550 }
1551
1552 #[test]
1553 fn clarification_is_shared_replayable_state_but_tokens_are_not() {
1554 let clarification = AgentEvent::NeedClarification {
1555 question: "Choose".to_string(),
1556 options: Some(vec!["A".to_string()]),
1557 tool_call_id: Some("call-1".to_string()),
1558 tool_name: Some("ConclusionWithOptions".to_string()),
1559 allow_custom: false,
1560 source: Some(PendingQuestionSource::PauseTool),
1561 };
1562 assert!(clarification.is_replayable_session_state());
1563 assert!(!AgentEvent::Token {
1564 content: "ephemeral".to_string(),
1565 }
1566 .is_replayable_session_state());
1567 }
1568}