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)]
108#[serde(tag = "type", rename_all = "snake_case")]
109pub enum AgentEvent {
110 Token {
112 content: String,
114 },
115
116 ReasoningToken {
121 content: String,
123 },
124
125 ToolToken {
130 tool_call_id: String,
132 content: String,
134 },
135
136 ToolStart {
138 tool_call_id: String,
140 tool_name: String,
142 arguments: serde_json::Value,
144 },
145
146 ToolComplete {
148 tool_call_id: String,
150 result: ToolResult,
152 },
153
154 ToolError {
156 tool_call_id: String,
158 error: String,
160 },
161
162 ToolLifecycle {
168 tool_call_id: String,
170 tool_name: String,
172 phase: String,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 elapsed_ms: Option<u64>,
177 is_mutating: bool,
179 auto_approved: bool,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 summary: Option<String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
186 error: Option<String>,
187 },
188
189 HookLifecycle {
191 hook_name: String,
193 point: AgentHookPoint,
195 phase: String,
198 duration_ms: u64,
200 decision: HookResult,
202 },
203
204 NeedClarification {
206 question: String,
208 options: Option<Vec<String>>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 tool_call_id: Option<String>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 tool_name: Option<String>,
216 #[serde(default = "default_allow_custom")]
218 allow_custom: bool,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 source: Option<PendingQuestionSource>,
222 },
223
224 TaskListUpdated {
226 task_list: TaskList,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 version: Option<u64>,
231 },
232
233 TaskListItemProgress {
235 session_id: String,
237 item_id: String,
239 status: TaskItemStatus,
241 tool_calls_count: usize,
243 version: u64,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 item: Option<TaskItem>,
248 },
249
250 TaskListCompleted {
252 session_id: String,
254 completed_at: DateTime<Utc>,
256 total_rounds: u32,
258 total_tool_calls: usize,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
262 version: Option<u64>,
263 },
264
265 TaskEvaluationStarted {
267 session_id: String,
269 items_count: usize,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 generation: Option<u64>,
274 },
275
276 TaskEvaluationCompleted {
278 session_id: String,
280 updates_count: usize,
282 reasoning: String,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 generation: Option<u64>,
287 },
288
289 TaskEvaluationCancelled {
292 session_id: String,
293 reason: String,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 generation: Option<u64>,
297 },
298
299 GoldEvaluationStarted {
301 session_id: String,
303 checkpoint: GoldCheckpoint,
305 iteration: u32,
307 },
308
309 GoldEvaluationCompleted {
311 session_id: String,
313 checkpoint: GoldCheckpoint,
315 iteration: u32,
317 decision: GoldDecision,
319 confidence: GoldConfidence,
321 reasoning: String,
323 },
324
325 GoldEvaluationCancelled { session_id: String, reason: String },
327
328 GoalStatusChanged {
335 session_id: String,
337 goal_state: serde_json::Value,
340 },
341
342 TokenBudgetUpdated {
344 usage: TokenBudgetUsage,
346 },
347
348 ContextCompressionStatus {
350 phase: String,
352 status: String,
354 },
355
356 ContextSummarized {
358 summary: String,
360 messages_summarized: usize,
362 tokens_saved: u32,
364 #[serde(default)]
366 usage_before_percent: f64,
367 #[serde(default)]
369 usage_after_percent: f64,
370 #[serde(default)]
372 trigger_type: String,
373 },
374
375 ContextArchived {
379 archive_event_id: String,
380 trigger_type: String,
381 messages_archived: usize,
382 groups_archived: usize,
383 user_turns_archived: usize,
384 active_tokens_before: u32,
385 active_tokens_after: u32,
386 target_tokens: u32,
387 retained_recent_user_turns: usize,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
389 oldest_retained_message_id: Option<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 oldest_retained_user_message_id: Option<String>,
392 model_context_epoch: u64,
393 reset_reason: String,
394 },
395
396 ContextPressureNotification {
399 percent: f64,
401 level: String,
403 message: String,
405 },
406
407 SubAgentStarted {
409 parent_session_id: String,
410 child_session_id: String,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
413 title: Option<String>,
414 },
415
416 SubAgentEvent {
420 parent_session_id: String,
421 child_session_id: String,
422 event: Box<AgentEvent>,
423 },
424
425 SubAgentHeartbeat {
427 parent_session_id: String,
428 child_session_id: String,
429 timestamp: DateTime<Utc>,
430 },
431
432 SubAgentCompleted {
434 parent_session_id: String,
435 child_session_id: String,
436 status: String,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 error: Option<String>,
440 },
441
442 BashCompleted {
456 bash_id: String,
458 command: String,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 exit_code: Option<i32>,
463 status: String,
465 },
466
467 PlanModeEntered {
469 session_id: String,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
473 reason: Option<String>,
474 pre_permission_mode: String,
476 entered_at: chrono::DateTime<chrono::Utc>,
478 status: bamboo_domain::PlanModeStatus,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
482 plan_file_path: Option<String>,
483 },
484
485 PlanModeExited {
487 session_id: String,
489 approved: bool,
491 restored_mode: String,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
495 plan: Option<String>,
496 },
497
498 PlanFileUpdated {
500 session_id: String,
502 file_path: String,
504 content_summary: String,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
508 status: Option<bamboo_domain::PlanModeStatus>,
509 },
510
511 RunnerProgress {
516 session_id: String,
518 round_count: u32,
520 },
521
522 PermissionPostureActivated {
527 session_id: String,
528 policy_revision: u64,
529 requested_mode: String,
530 effective_mode: String,
531 executor_mapping: String,
532 },
533
534 SessionTitleUpdated {
536 session_id: String,
537 title: String,
538 title_version: u64,
539 #[serde(default = "default_title_generated")]
540 title_generated: bool,
541 source: TitleSource,
542 updated_at: chrono::DateTime<chrono::Utc>,
543 },
544
545 SessionPinnedUpdated {
551 session_id: String,
552 pinned: bool,
553 updated_at: chrono::DateTime<chrono::Utc>,
554 },
555
556 SessionCreated {
562 session_id: String,
563 #[serde(default)]
567 project_id: Option<String>,
568 title: String,
569 kind: bamboo_domain::SessionKind,
570 created_at: chrono::DateTime<chrono::Utc>,
571 },
572
573 SessionDeleted { session_id: String },
578
579 SessionCleared { session_id: String },
584
585 MessageAppended {
592 session_id: String,
593 message_id: String,
594 role: bamboo_domain::Role,
595 content: String,
596 created_at: chrono::DateTime<chrono::Utc>,
597 },
598
599 ExecutionStarted {
605 run_id: String,
607 session_id: String,
609 started_at: String,
611 },
612
613 ToolApprovalRequested {
620 tool_call_id: String,
622 tool_name: String,
624 parameters: serde_json::Value,
626 },
627
628 ChildApprovalRequested {
634 child_session_id: String,
636 request_id: String,
638 tool_name: String,
640 permission: String,
642 resource: String,
644 },
645
646 ChildApprovalChanged {
648 parent_session_id: String,
649 child_session_id: String,
650 #[serde(default)]
653 child_attempt: u32,
654 request_id: String,
655 version: u64,
656 status: String,
658 #[serde(default, skip_serializing_if = "Option::is_none")]
659 reason: Option<String>,
660 tool_name: String,
661 permission: String,
662 resource: String,
663 created_at: String,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 resolved_at: Option<String>,
666 },
667
668 BudgetExceeded {
678 session_id: String,
680 kind: String,
683 limit: u64,
685 actual: u64,
687 },
688
689 Complete {
691 usage: TokenUsage,
693 },
694
695 Cancelled {
697 #[serde(default, skip_serializing_if = "Option::is_none")]
699 message: Option<String>,
700 },
701
702 Error {
704 message: String,
706 },
707
708 WorkflowChanged {
710 workflow_id: String,
711 revision: u64,
712 scope: String,
713 },
714
715 WorkflowInvalid {
717 workflow_id: String,
718 revision: u64,
719 scope: String,
720 },
721
722 WorkflowRecovered {
724 workflow_id: String,
725 revision: u64,
726 scope: String,
727 },
728
729 ProjectCreated { project_id: String, revision: u64 },
731
732 ProjectUpdated { project_id: String, revision: u64 },
734
735 ProjectArchived { project_id: String, revision: u64 },
737
738 SessionProjectUpdated {
743 session_id: String,
744 #[serde(default)]
747 project_id: Option<String>,
748 #[serde(default)]
751 workspace_path: Option<String>,
752 metadata_version: u64,
753 },
754
755 #[serde(rename = "config.changed")]
757 ConfigChanged { section: String, revision: u64 },
758
759 #[serde(rename = "config.invalid")]
761 ConfigInvalid { section: String, revision: u64 },
762
763 #[serde(rename = "config.recovered")]
765 ConfigRecovered { section: String, revision: u64 },
766
767 WorkflowActivated {
769 event_id: String,
770 session_id: String,
771 workflow_id: String,
772 revision: u64,
773 invoked_by: String,
774 },
775
776 WorkflowDeactivated {
778 event_id: String,
779 session_id: String,
780 workflow_id: String,
781 revision: u64,
782 },
783
784 Notification {
790 id: String,
792 session_id: String,
794 category: String,
797 priority: String,
799 title: String,
801 body: String,
803 #[serde(default, skip_serializing_if = "Option::is_none")]
805 dedup_key: Option<String>,
806 created_at: String,
808 },
809}
810
811impl AgentEvent {
812 pub fn session_id(&self) -> Option<&str> {
821 match self {
822 AgentEvent::TaskListUpdated { task_list, .. } => Some(task_list.session_id.as_str()),
823 AgentEvent::TaskListItemProgress { session_id, .. }
824 | AgentEvent::TaskListCompleted { session_id, .. }
825 | AgentEvent::TaskEvaluationStarted { session_id, .. }
826 | AgentEvent::TaskEvaluationCompleted { session_id, .. }
827 | AgentEvent::TaskEvaluationCancelled { session_id, .. }
828 | AgentEvent::GoldEvaluationStarted { session_id, .. }
829 | AgentEvent::GoldEvaluationCompleted { session_id, .. }
830 | AgentEvent::GoldEvaluationCancelled { session_id, .. }
831 | AgentEvent::GoalStatusChanged { session_id, .. }
832 | AgentEvent::PlanModeEntered { session_id, .. }
833 | AgentEvent::PlanModeExited { session_id, .. }
834 | AgentEvent::PlanFileUpdated { session_id, .. }
835 | AgentEvent::RunnerProgress { session_id, .. }
836 | AgentEvent::PermissionPostureActivated { session_id, .. }
837 | AgentEvent::SessionTitleUpdated { session_id, .. }
838 | AgentEvent::SessionPinnedUpdated { session_id, .. }
839 | AgentEvent::SessionCreated { session_id, .. }
840 | AgentEvent::SessionDeleted { session_id, .. }
841 | AgentEvent::SessionCleared { session_id, .. }
842 | AgentEvent::MessageAppended { session_id, .. }
843 | AgentEvent::ExecutionStarted { session_id, .. }
844 | AgentEvent::BudgetExceeded { session_id, .. }
845 | AgentEvent::WorkflowActivated { session_id, .. }
846 | AgentEvent::WorkflowDeactivated { session_id, .. }
847 | AgentEvent::SessionProjectUpdated { session_id, .. }
848 | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
849 AgentEvent::SubAgentStarted {
850 parent_session_id, ..
851 }
852 | AgentEvent::SubAgentEvent {
853 parent_session_id, ..
854 }
855 | AgentEvent::SubAgentHeartbeat {
856 parent_session_id, ..
857 }
858 | AgentEvent::SubAgentCompleted {
859 parent_session_id, ..
860 }
861 | AgentEvent::ChildApprovalChanged {
862 parent_session_id, ..
863 } => Some(parent_session_id.as_str()),
864 _ => None,
865 }
866 }
867
868 pub fn is_replayable_session_state(&self) -> bool {
876 matches!(
877 self,
878 AgentEvent::TaskListUpdated { .. }
879 | AgentEvent::TaskListCompleted { .. }
880 | AgentEvent::SubAgentStarted { .. }
881 | AgentEvent::SubAgentCompleted { .. }
882 | AgentEvent::ChildApprovalRequested { .. }
883 | AgentEvent::ChildApprovalChanged { .. }
884 | AgentEvent::BashCompleted { .. }
885 | AgentEvent::SessionTitleUpdated { .. }
886 | AgentEvent::SessionPinnedUpdated { .. }
887 | AgentEvent::PlanModeEntered { .. }
888 | AgentEvent::PlanModeExited { .. }
889 | AgentEvent::BudgetExceeded { .. }
890 | AgentEvent::NeedClarification { .. }
891 | AgentEvent::WorkflowActivated { .. }
892 | AgentEvent::WorkflowDeactivated { .. }
893 )
894 }
895
896 pub fn is_durable_change(&self) -> bool {
907 matches!(
908 self,
909 AgentEvent::MessageAppended { .. }
910 | AgentEvent::SessionCreated { .. }
911 | AgentEvent::SessionDeleted { .. }
912 | AgentEvent::SessionCleared { .. }
913 | AgentEvent::SessionTitleUpdated { .. }
914 | AgentEvent::SessionPinnedUpdated { .. }
915 | AgentEvent::TaskListUpdated { .. }
916 | AgentEvent::TaskListItemProgress { .. }
917 | AgentEvent::TaskListCompleted { .. }
918 | AgentEvent::TaskEvaluationCompleted { .. }
919 | AgentEvent::TaskEvaluationCancelled { .. }
920 | AgentEvent::GoldEvaluationCancelled { .. }
921 | AgentEvent::PlanModeEntered { .. }
922 | AgentEvent::PlanModeExited { .. }
923 | AgentEvent::PlanFileUpdated { .. }
924 | AgentEvent::SubAgentStarted { .. }
925 | AgentEvent::SubAgentCompleted { .. }
926 | AgentEvent::ChildApprovalChanged { .. }
927 | AgentEvent::NeedClarification { .. }
928 | AgentEvent::ToolApprovalRequested { .. }
929 | AgentEvent::ExecutionStarted { .. }
930 | AgentEvent::BudgetExceeded { .. }
931 | AgentEvent::Complete { .. }
932 | AgentEvent::Cancelled { .. }
933 | AgentEvent::Error { .. }
934 | AgentEvent::WorkflowChanged { .. }
935 | AgentEvent::WorkflowInvalid { .. }
936 | AgentEvent::ProjectCreated { .. }
937 | AgentEvent::ProjectUpdated { .. }
938 | AgentEvent::ProjectArchived { .. }
939 | AgentEvent::SessionProjectUpdated { .. }
940 | AgentEvent::ConfigChanged { .. }
941 | AgentEvent::ConfigInvalid { .. }
942 | AgentEvent::ConfigRecovered { .. }
943 | AgentEvent::WorkflowRecovered { .. }
944 | AgentEvent::WorkflowActivated { .. }
945 | AgentEvent::WorkflowDeactivated { .. }
946 )
947 }
948}
949
950fn default_allow_custom() -> bool {
951 true
952}
953
954#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
956#[serde(rename_all = "snake_case")]
957pub enum GoldCheckpoint {
958 PostRound,
959 Terminal,
960}
961
962impl GoldCheckpoint {
963 pub fn as_str(self) -> &'static str {
964 match self {
965 Self::PostRound => "post_round",
966 Self::Terminal => "terminal",
967 }
968 }
969}
970
971#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
973#[serde(rename_all = "snake_case")]
974pub enum GoldDecision {
975 Continue,
976 Achieved,
977 Blocked,
978 NeedInput,
979 Exhausted,
980}
981
982impl GoldDecision {
983 pub fn as_str(self) -> &'static str {
984 match self {
985 Self::Continue => "continue",
986 Self::Achieved => "achieved",
987 Self::Blocked => "blocked",
988 Self::NeedInput => "need_input",
989 Self::Exhausted => "exhausted",
990 }
991 }
992}
993
994#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
996#[serde(rename_all = "snake_case")]
997pub enum GoldConfidence {
998 Low,
999 Medium,
1000 High,
1001}
1002
1003impl GoldConfidence {
1004 pub fn as_str(self) -> &'static str {
1005 match self {
1006 Self::Low => "low",
1007 Self::Medium => "medium",
1008 Self::High => "high",
1009 }
1010 }
1011
1012 pub fn rank(self) -> u8 {
1014 match self {
1015 Self::Low => 0,
1016 Self::Medium => 1,
1017 Self::High => 2,
1018 }
1019 }
1020
1021 pub fn meets(self, floor: GoldConfidence) -> bool {
1023 self.rank() >= floor.rank()
1024 }
1025}
1026
1027#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1029#[serde(rename_all = "snake_case")]
1030pub enum TitleSource {
1031 Auto,
1032 Manual,
1033 Fallback,
1034}
1035
1036pub use bamboo_domain::TokenUsage;
1040
1041pub use bamboo_domain::budget_types::TokenBudgetUsage;
1042
1043#[cfg(test)]
1044mod tests {
1045 use super::*;
1046 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
1047
1048 fn sample_task_list() -> TaskList {
1049 TaskList {
1050 session_id: "session-1".to_string(),
1051 title: "Task List".to_string(),
1052 items: vec![TaskItem {
1053 id: "task_1".to_string(),
1054 description: "Implement event rename".to_string(),
1055 status: TaskItemStatus::InProgress,
1056 depends_on: Vec::new(),
1057 notes: "Implementing".to_string(),
1058 ..TaskItem::default()
1059 }],
1060 created_at: Utc::now(),
1061 updated_at: Utc::now(),
1062 }
1063 }
1064
1065 #[test]
1066 fn task_list_updated_serializes_with_task_names() {
1067 let event = AgentEvent::TaskListUpdated {
1068 task_list: sample_task_list(),
1069 version: Some(7),
1070 };
1071
1072 let value = serde_json::to_value(event).expect("event should serialize");
1073 assert_eq!(value["type"], "task_list_updated");
1074 assert!(value.get("task_list").is_some());
1075 assert_eq!(value["version"], 7);
1076 assert!(value.get("todo_list").is_none());
1077 }
1078
1079 #[test]
1080 fn context_archived_serializes_structural_evidence_without_history_content() {
1081 let event = AgentEvent::ContextArchived {
1082 archive_event_id: "compression-event-1".to_string(),
1083 trigger_type: "auto".to_string(),
1084 messages_archived: 8,
1085 groups_archived: 4,
1086 user_turns_archived: 4,
1087 active_tokens_before: 9_000,
1088 active_tokens_after: 5_000,
1089 target_tokens: 6_000,
1090 retained_recent_user_turns: 3,
1091 oldest_retained_message_id: Some("message-9".to_string()),
1092 oldest_retained_user_message_id: Some("message-9".to_string()),
1093 model_context_epoch: 2,
1094 reset_reason: "compression".to_string(),
1095 };
1096
1097 let value = serde_json::to_value(&event).expect("archive event should serialize");
1098 assert_eq!(value["type"], "context_archived");
1099 assert_eq!(value["messages_archived"], 8);
1100 assert_eq!(value["active_tokens_after"], 5_000);
1101 assert_eq!(value["reset_reason"], "compression");
1102 let wire = serde_json::to_string(&value).unwrap();
1103 assert!(!wire.contains("summary"));
1104 assert!(!wire.contains("raw_message"));
1105 assert!(!wire.contains("tool_result"));
1106 assert!(matches!(
1107 serde_json::from_value::<AgentEvent>(value).unwrap(),
1108 AgentEvent::ContextArchived {
1109 archive_event_id,
1110 messages_archived: 8,
1111 model_context_epoch: 2,
1112 ..
1113 } if archive_event_id == "compression-event-1"
1114 ));
1115 }
1116
1117 #[test]
1118 fn session_project_updated_serializes_unassignment_as_explicit_null() {
1119 let event = AgentEvent::SessionProjectUpdated {
1120 session_id: "session-1".to_string(),
1121 project_id: None,
1122 workspace_path: Some("/workspaces/current".to_string()),
1123 metadata_version: 4,
1124 };
1125
1126 let value = serde_json::to_value(&event).expect("event should serialize");
1127 assert_eq!(value["type"], "session_project_updated");
1128 assert!(
1129 value
1130 .get("project_id")
1131 .is_some_and(serde_json::Value::is_null),
1132 "unassignment must carry an explicit project_id: null"
1133 );
1134 assert_eq!(value["workspace_path"], "/workspaces/current");
1135
1136 let restored: AgentEvent = serde_json::from_value(value).expect("event should deserialize");
1137 assert!(matches!(
1138 restored,
1139 AgentEvent::SessionProjectUpdated {
1140 session_id,
1141 project_id: None,
1142 workspace_path: Some(workspace_path),
1143 metadata_version: 4,
1144 } if session_id == "session-1" && workspace_path == "/workspaces/current"
1145 ));
1146 }
1147
1148 #[test]
1149 fn session_project_updated_deserializes_legacy_event_without_workspace() {
1150 let restored: AgentEvent = serde_json::from_value(serde_json::json!({
1151 "type": "session_project_updated",
1152 "session_id": "session-1",
1153 "project_id": "project-1",
1154 "metadata_version": 2
1155 }))
1156 .expect("legacy event should deserialize");
1157
1158 assert!(matches!(
1159 restored,
1160 AgentEvent::SessionProjectUpdated {
1161 workspace_path: None,
1162 metadata_version: 2,
1163 ..
1164 }
1165 ));
1166 }
1167
1168 #[test]
1169 fn cancelled_serializes_with_snake_case_type() {
1170 let event = AgentEvent::Cancelled {
1171 message: Some("Agent execution cancelled by user".to_string()),
1172 };
1173
1174 let value = serde_json::to_value(event).expect("event should serialize");
1175 assert_eq!(value["type"], "cancelled");
1176 assert_eq!(
1177 value["message"],
1178 serde_json::Value::String("Agent execution cancelled by user".to_string())
1179 );
1180 }
1181
1182 #[test]
1183 fn task_evaluation_completed_serializes_with_task_type() {
1184 let event = AgentEvent::TaskEvaluationCompleted {
1185 session_id: "session-1".to_string(),
1186 updates_count: 2,
1187 reasoning: "Updated statuses".to_string(),
1188 generation: Some(7),
1189 };
1190
1191 let value = serde_json::to_value(event).expect("event should serialize");
1192 assert_eq!(value["type"], "task_evaluation_completed");
1193 assert_eq!(value["generation"], 7);
1194 }
1195
1196 #[test]
1197 fn task_evaluation_event_without_generation_remains_deserializable() {
1198 let event: AgentEvent = serde_json::from_value(serde_json::json!({
1199 "type": "task_evaluation_started",
1200 "session_id": "session-1",
1201 "items_count": 2
1202 }))
1203 .expect("legacy task evaluation frame should remain compatible");
1204
1205 assert!(matches!(
1206 event,
1207 AgentEvent::TaskEvaluationStarted {
1208 generation: None,
1209 ..
1210 }
1211 ));
1212 }
1213
1214 #[test]
1215 fn evaluation_cancelled_events_serialize_as_terminal_lifecycle_events() {
1216 let task = AgentEvent::TaskEvaluationCancelled {
1217 session_id: "session-1".to_string(),
1218 reason: "run_suspended".to_string(),
1219 generation: Some(7),
1220 };
1221 let gold = AgentEvent::GoldEvaluationCancelled {
1222 session_id: "session-1".to_string(),
1223 reason: "run_completed".to_string(),
1224 };
1225
1226 assert!(task.is_durable_change());
1227 assert!(gold.is_durable_change());
1228 let task_value = serde_json::to_value(task).unwrap();
1229 let gold_value = serde_json::to_value(gold).unwrap();
1230 assert_eq!(task_value["type"], "task_evaluation_cancelled");
1231 assert_eq!(task_value["reason"], "run_suspended");
1232 assert_eq!(gold_value["type"], "gold_evaluation_cancelled");
1233 assert_eq!(gold_value["reason"], "run_completed");
1234 }
1235
1236 #[test]
1237 fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
1238 let event = AgentEvent::GoldEvaluationCompleted {
1239 session_id: "session-1".to_string(),
1240 checkpoint: GoldCheckpoint::PostRound,
1241 iteration: 3,
1242 decision: GoldDecision::Continue,
1243 confidence: GoldConfidence::Medium,
1244 reasoning: "Need one more iteration".to_string(),
1245 };
1246
1247 let value = serde_json::to_value(event).expect("event should serialize");
1248 assert_eq!(value["type"], "gold_evaluation_completed");
1249 assert_eq!(value["checkpoint"], "post_round");
1250 assert_eq!(value["iteration"], 3);
1251 assert_eq!(value["decision"], "continue");
1252 assert_eq!(value["confidence"], "medium");
1253 assert_eq!(value["reasoning"], "Need one more iteration");
1254 }
1255
1256 #[test]
1257 fn gold_evaluation_started_deserializes() {
1258 let json = serde_json::json!({
1259 "type": "gold_evaluation_started",
1260 "session_id": "session-1",
1261 "checkpoint": "terminal",
1262 "iteration": 7
1263 });
1264
1265 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1266 match event {
1267 AgentEvent::GoldEvaluationStarted {
1268 session_id,
1269 checkpoint,
1270 iteration,
1271 } => {
1272 assert_eq!(session_id, "session-1");
1273 assert_eq!(checkpoint, GoldCheckpoint::Terminal);
1274 assert_eq!(iteration, 7);
1275 }
1276 other => panic!("unexpected event: {other:?}"),
1277 }
1278 }
1279
1280 #[test]
1281 fn context_compression_status_serializes_with_phase_and_status() {
1282 let event = AgentEvent::ContextCompressionStatus {
1283 phase: "mid-turn".to_string(),
1284 status: "started".to_string(),
1285 };
1286
1287 let value = serde_json::to_value(event).expect("event should serialize");
1288 assert_eq!(value["type"], "context_compression_status");
1289 assert_eq!(value["phase"], "mid-turn");
1290 assert_eq!(value["status"], "started");
1291 }
1292
1293 #[test]
1294 fn need_clarification_serializes_with_new_fields() {
1295 let event = AgentEvent::NeedClarification {
1296 question: "Continue?".to_string(),
1297 options: Some(vec!["Yes".to_string(), "No".to_string()]),
1298 tool_call_id: Some("tool-1".to_string()),
1299 tool_name: Some("conclusion_with_options".to_string()),
1300 allow_custom: false,
1301 source: Some(PendingQuestionSource::PauseTool),
1302 };
1303
1304 let value = serde_json::to_value(event).expect("event should serialize");
1305 assert_eq!(value["type"], "need_clarification");
1306 assert_eq!(value["question"], "Continue?");
1307 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
1308 assert_eq!(value["tool_call_id"], "tool-1");
1309 assert_eq!(value["tool_name"], "conclusion_with_options");
1310 assert_eq!(value["allow_custom"], false);
1311 assert_eq!(value["source"], "pause_tool");
1312 }
1313
1314 #[test]
1315 fn need_clarification_deserializes_from_old_format_without_new_fields() {
1316 let json = serde_json::json!({
1317 "type": "need_clarification",
1318 "question": "Continue?",
1319 "options": ["Yes", "No"]
1320 });
1321
1322 let event: AgentEvent =
1323 serde_json::from_value(json).expect("should deserialize old format");
1324 match event {
1325 AgentEvent::NeedClarification {
1326 question,
1327 options,
1328 tool_call_id,
1329 tool_name,
1330 allow_custom,
1331 source,
1332 } => {
1333 assert_eq!(question, "Continue?");
1334 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
1335 assert_eq!(tool_call_id, None);
1336 assert_eq!(tool_name, None);
1337 assert!(allow_custom); assert_eq!(source, None);
1339 }
1340 other => panic!("unexpected event: {other:?}"),
1341 }
1342 }
1343
1344 #[test]
1345 fn need_clarification_deserializes_with_allow_custom_false() {
1346 let json = serde_json::json!({
1347 "type": "need_clarification",
1348 "question": "Pick one",
1349 "allow_custom": false
1350 });
1351
1352 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1353 match event {
1354 AgentEvent::NeedClarification {
1355 question,
1356 options,
1357 tool_call_id,
1358 tool_name,
1359 allow_custom,
1360 source,
1361 } => {
1362 assert_eq!(question, "Pick one");
1363 assert_eq!(options, None);
1364 assert_eq!(tool_call_id, None);
1365 assert_eq!(tool_name, None);
1366 assert!(!allow_custom);
1367 assert_eq!(source, None);
1368 }
1369 other => panic!("unexpected event: {other:?}"),
1370 }
1371 }
1372
1373 #[test]
1374 fn plan_mode_entered_serializes_correctly() {
1375 let entered_at = Utc::now();
1376 let event = AgentEvent::PlanModeEntered {
1377 session_id: "sess-1".to_string(),
1378 reason: Some("Complex refactor".to_string()),
1379 pre_permission_mode: "default".to_string(),
1380 entered_at,
1381 status: bamboo_domain::PlanModeStatus::Exploring,
1382 plan_file_path: None,
1383 };
1384
1385 let value = serde_json::to_value(event).expect("event should serialize");
1386 assert_eq!(value["type"], "plan_mode_entered");
1387 assert_eq!(value["session_id"], "sess-1");
1388 assert_eq!(value["reason"], "Complex refactor");
1389 assert_eq!(value["pre_permission_mode"], "default");
1390 assert_eq!(value["status"], "exploring");
1391 assert_eq!(
1394 value["entered_at"],
1395 serde_json::to_value(entered_at).unwrap()
1396 );
1397 }
1398
1399 #[test]
1400 fn plan_mode_exited_serializes_correctly() {
1401 let event = AgentEvent::PlanModeExited {
1402 session_id: "sess-1".to_string(),
1403 approved: true,
1404 restored_mode: "accept_edits".to_string(),
1405 plan: Some("# Plan\n1. Step one".to_string()),
1406 };
1407
1408 let value = serde_json::to_value(event).expect("event should serialize");
1409 assert_eq!(value["type"], "plan_mode_exited");
1410 assert_eq!(value["session_id"], "sess-1");
1411 assert_eq!(value["approved"], true);
1412 assert_eq!(value["restored_mode"], "accept_edits");
1413 assert_eq!(value["plan"], "# Plan\n1. Step one");
1414 }
1415
1416 #[test]
1417 fn plan_file_updated_serializes_correctly() {
1418 let event = AgentEvent::PlanFileUpdated {
1419 session_id: "sess-1".to_string(),
1420 file_path: "/tmp/plans/sess-1.md".to_string(),
1421 content_summary: "Implementation plan for feature X".to_string(),
1422 status: Some(bamboo_domain::PlanModeStatus::AwaitingApproval),
1423 };
1424
1425 let value = serde_json::to_value(event).expect("event should serialize");
1426 assert_eq!(value["type"], "plan_file_updated");
1427 assert_eq!(value["session_id"], "sess-1");
1428 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
1429 assert_eq!(
1430 value["content_summary"],
1431 "Implementation plan for feature X"
1432 );
1433 }
1434
1435 #[test]
1436 fn tool_approval_requested_serializes_correctly() {
1437 let event = AgentEvent::ToolApprovalRequested {
1438 tool_call_id: "call-abc".to_string(),
1439 tool_name: "Write".to_string(),
1440 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
1441 };
1442
1443 let value = serde_json::to_value(event).expect("event should serialize");
1444 assert_eq!(value["type"], "tool_approval_requested");
1445 assert_eq!(value["tool_call_id"], "call-abc");
1446 assert_eq!(value["tool_name"], "Write");
1447 assert_eq!(
1448 value["parameters"],
1449 serde_json::json!({"file_path": "/tmp/test.txt"})
1450 );
1451 }
1452
1453 #[test]
1454 fn child_approval_changed_routes_to_parent_and_is_durable() {
1455 let event = AgentEvent::ChildApprovalChanged {
1456 parent_session_id: "parent-1".into(),
1457 child_session_id: "child-1".into(),
1458 child_attempt: 3,
1459 request_id: "req-1".into(),
1460 version: 2,
1461 status: "approved".into(),
1462 reason: None,
1463 tool_name: "Bash".into(),
1464 permission: "execute".into(),
1465 resource: "/tmp/x".into(),
1466 created_at: "2026-01-01T00:00:00Z".into(),
1467 resolved_at: Some("2026-01-01T00:00:01Z".into()),
1468 };
1469 assert_eq!(event.session_id(), Some("parent-1"));
1470 assert!(event.is_durable_change());
1471 let value = serde_json::to_value(event).unwrap();
1472 assert_eq!(value["type"], "child_approval_changed");
1473 assert_eq!(value["status"], "approved");
1474 assert_eq!(value["child_attempt"], 3);
1475
1476 let mut legacy = value;
1477 legacy.as_object_mut().unwrap().remove("child_attempt");
1478 let restored: AgentEvent = serde_json::from_value(legacy).unwrap();
1479 assert!(matches!(
1480 restored,
1481 AgentEvent::ChildApprovalChanged {
1482 child_attempt: 0,
1483 ..
1484 }
1485 ));
1486 }
1487
1488 #[test]
1489 fn tool_approval_requested_deserializes_correctly() {
1490 let json = serde_json::json!({
1491 "type": "tool_approval_requested",
1492 "tool_call_id": "call-xyz",
1493 "tool_name": "Bash",
1494 "parameters": {"command": "ls -la"}
1495 });
1496
1497 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1498 match event {
1499 AgentEvent::ToolApprovalRequested {
1500 tool_call_id,
1501 tool_name,
1502 parameters,
1503 } => {
1504 assert_eq!(tool_call_id, "call-xyz");
1505 assert_eq!(tool_name, "Bash");
1506 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1507 }
1508 other => panic!("unexpected event: {other:?}"),
1509 }
1510 }
1511
1512 #[test]
1513 fn session_title_updated_round_trips_with_source_variants() {
1514 use chrono::Utc;
1515 let event = AgentEvent::SessionTitleUpdated {
1516 session_id: "sess-1".to_string(),
1517 title: "My title".to_string(),
1518 title_version: 3,
1519 title_generated: true,
1520 source: TitleSource::Auto,
1521 updated_at: Utc::now(),
1522 };
1523 let json = serde_json::to_string(&event).unwrap();
1524 assert!(
1525 json.contains("\"type\":\"session_title_updated\""),
1526 "json: {json}"
1527 );
1528 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1529 let decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1530 assert!(matches!(
1531 decoded,
1532 AgentEvent::SessionTitleUpdated {
1533 title_generated: true,
1534 ..
1535 }
1536 ));
1537
1538 let legacy = serde_json::json!({
1539 "type": "session_title_updated",
1540 "session_id": "sess-legacy",
1541 "title": "Existing title",
1542 "title_version": 2,
1543 "source": "manual",
1544 "updated_at": "2025-01-01T00:00:00Z"
1545 });
1546 let decoded: AgentEvent = serde_json::from_value(legacy).unwrap();
1547 assert!(matches!(
1548 decoded,
1549 AgentEvent::SessionTitleUpdated {
1550 title_generated: true,
1551 ..
1552 }
1553 ));
1554 }
1555
1556 #[test]
1557 fn plan_mode_events_deserialize_without_optional_fields() {
1558 let json = serde_json::json!({
1559 "type": "plan_mode_entered",
1560 "session_id": "sess-1",
1561 "pre_permission_mode": "default",
1562 "entered_at": "2025-01-01T00:00:00Z",
1563 "status": "exploring"
1564 });
1565
1566 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1567 match event {
1568 AgentEvent::PlanModeEntered {
1569 session_id,
1570 reason,
1571 pre_permission_mode,
1572 entered_at,
1573 status,
1574 plan_file_path,
1575 } => {
1576 assert_eq!(session_id, "sess-1");
1577 assert_eq!(reason, None);
1578 assert_eq!(pre_permission_mode, "default");
1579 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1580 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1581 assert_eq!(plan_file_path, None);
1582 }
1583 other => panic!("unexpected event: {other:?}"),
1584 }
1585 }
1586
1587 #[test]
1588 fn workflow_catalog_events_are_durable_and_account_scoped() {
1589 for event in [
1590 AgentEvent::WorkflowChanged {
1591 workflow_id: "review".to_string(),
1592 revision: 2,
1593 scope: "global".to_string(),
1594 },
1595 AgentEvent::WorkflowInvalid {
1596 workflow_id: "review".to_string(),
1597 revision: 3,
1598 scope: "workspace:1234".to_string(),
1599 },
1600 AgentEvent::WorkflowRecovered {
1601 workflow_id: "review".to_string(),
1602 revision: 4,
1603 scope: "workspace:1234".to_string(),
1604 },
1605 ] {
1606 assert!(event.is_durable_change());
1607 assert_eq!(event.session_id(), None);
1608 let encoded = serde_json::to_string(&event).expect("serialize");
1609 let _: AgentEvent = serde_json::from_str(&encoded).expect("deserialize");
1610 }
1611 }
1612
1613 #[test]
1614 fn clarification_is_shared_replayable_state_but_tokens_are_not() {
1615 let clarification = AgentEvent::NeedClarification {
1616 question: "Choose".to_string(),
1617 options: Some(vec!["A".to_string()]),
1618 tool_call_id: Some("call-1".to_string()),
1619 tool_name: Some("ConclusionWithOptions".to_string()),
1620 allow_custom: false,
1621 source: Some(PendingQuestionSource::PauseTool),
1622 };
1623 assert!(clarification.is_replayable_session_state());
1624 assert!(!AgentEvent::Token {
1625 content: "ephemeral".to_string(),
1626 }
1627 .is_replayable_session_state());
1628 }
1629}