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 #[doc(hidden)]
121 VisibleMessageStart {
122 message_id: String,
124 created_at: DateTime<Utc>,
126 },
127
128 #[doc(hidden)]
132 VisibleMessageDiscard { message_id: String },
133
134 ReasoningToken {
139 content: String,
141 },
142
143 ToolToken {
148 tool_call_id: String,
150 content: String,
152 },
153
154 ToolStart {
156 tool_call_id: String,
158 tool_name: String,
160 arguments: serde_json::Value,
162 },
163
164 ToolComplete {
166 tool_call_id: String,
168 result: ToolResult,
170 },
171
172 ToolError {
174 tool_call_id: String,
176 error: String,
178 },
179
180 ToolLifecycle {
186 tool_call_id: String,
188 tool_name: String,
190 phase: String,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 elapsed_ms: Option<u64>,
195 is_mutating: bool,
197 auto_approved: bool,
199 #[serde(skip_serializing_if = "Option::is_none")]
201 summary: Option<String>,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 error: Option<String>,
205 },
206
207 HookLifecycle {
209 hook_name: String,
211 point: AgentHookPoint,
213 phase: String,
216 duration_ms: u64,
218 decision: HookResult,
220 },
221
222 NeedClarification {
224 question: String,
226 options: Option<Vec<String>>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 tool_call_id: Option<String>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 tool_name: Option<String>,
234 #[serde(default = "default_allow_custom")]
236 allow_custom: bool,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 source: Option<PendingQuestionSource>,
240 },
241
242 TaskListUpdated {
244 task_list: TaskList,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 version: Option<u64>,
249 },
250
251 TaskListItemProgress {
253 session_id: String,
255 item_id: String,
257 status: TaskItemStatus,
259 tool_calls_count: usize,
261 version: u64,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 item: Option<TaskItem>,
266 },
267
268 TaskListCompleted {
270 session_id: String,
272 completed_at: DateTime<Utc>,
274 total_rounds: u32,
276 total_tool_calls: usize,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
280 version: Option<u64>,
281 },
282
283 TaskEvaluationStarted {
285 session_id: String,
287 items_count: usize,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 generation: Option<u64>,
292 },
293
294 TaskEvaluationCompleted {
296 session_id: String,
298 updates_count: usize,
300 reasoning: String,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
304 generation: Option<u64>,
305 },
306
307 TaskEvaluationCancelled {
310 session_id: String,
311 reason: String,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
314 generation: Option<u64>,
315 },
316
317 GoldEvaluationStarted {
319 session_id: String,
321 checkpoint: GoldCheckpoint,
323 iteration: u32,
325 },
326
327 GoldEvaluationCompleted {
329 session_id: String,
331 checkpoint: GoldCheckpoint,
333 iteration: u32,
335 decision: GoldDecision,
337 confidence: GoldConfidence,
339 reasoning: String,
341 },
342
343 GoldEvaluationCancelled { session_id: String, reason: String },
347
348 GoalStatusChanged {
355 session_id: String,
357 goal_state: serde_json::Value,
360 },
361
362 TokenBudgetUpdated {
364 usage: TokenBudgetUsage,
366 },
367
368 ContextCompressionStatus {
370 phase: String,
372 status: String,
374 },
375
376 ContextSummarized {
378 summary: String,
380 messages_summarized: usize,
382 tokens_saved: u32,
384 #[serde(default)]
386 usage_before_percent: f64,
387 #[serde(default)]
389 usage_after_percent: f64,
390 #[serde(default)]
392 trigger_type: String,
393 },
394
395 ContextArchived {
399 archive_event_id: String,
400 trigger_type: String,
401 messages_archived: usize,
402 groups_archived: usize,
403 user_turns_archived: usize,
404 active_tokens_before: u32,
405 active_tokens_after: u32,
406 target_tokens: u32,
407 retained_recent_user_turns: usize,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 oldest_retained_message_id: Option<String>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 oldest_retained_user_message_id: Option<String>,
412 model_context_epoch: u64,
413 reset_reason: String,
414 },
415
416 ContextPressureNotification {
419 percent: f64,
421 level: String,
423 message: String,
425 },
426
427 SubAgentStarted {
429 parent_session_id: String,
430 child_session_id: String,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
433 title: Option<String>,
434 },
435
436 SubAgentEvent {
440 parent_session_id: String,
441 child_session_id: String,
442 event: Box<AgentEvent>,
443 },
444
445 SubAgentHeartbeat {
447 parent_session_id: String,
448 child_session_id: String,
449 timestamp: DateTime<Utc>,
450 },
451
452 SubAgentCompleted {
454 parent_session_id: String,
455 child_session_id: String,
456 status: String,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
459 error: Option<String>,
460 },
461
462 BashCompleted {
476 bash_id: String,
478 command: String,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
482 exit_code: Option<i32>,
483 status: String,
485 },
486
487 PlanModeEntered {
489 session_id: String,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
493 reason: Option<String>,
494 pre_permission_mode: String,
496 entered_at: chrono::DateTime<chrono::Utc>,
498 status: bamboo_domain::PlanModeStatus,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
502 plan_file_path: Option<String>,
503 },
504
505 PlanModeExited {
507 session_id: String,
509 approved: bool,
511 restored_mode: String,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
515 plan: Option<String>,
516 },
517
518 PlanFileUpdated {
520 session_id: String,
522 file_path: String,
524 content_summary: String,
526 #[serde(default, skip_serializing_if = "Option::is_none")]
528 status: Option<bamboo_domain::PlanModeStatus>,
529 },
530
531 RunnerProgress {
536 session_id: String,
538 round_count: u32,
540 },
541
542 PermissionPostureActivated {
547 session_id: String,
548 policy_revision: u64,
549 requested_mode: String,
550 effective_mode: String,
551 executor_mapping: String,
552 },
553
554 SessionTitleUpdated {
556 session_id: String,
557 title: String,
558 title_version: u64,
559 #[serde(default = "default_title_generated")]
560 title_generated: bool,
561 source: TitleSource,
562 updated_at: chrono::DateTime<chrono::Utc>,
563 },
564
565 SessionPinnedUpdated {
571 session_id: String,
572 pinned: bool,
573 updated_at: chrono::DateTime<chrono::Utc>,
574 },
575
576 SessionCreated {
582 session_id: String,
583 #[serde(default)]
587 project_id: Option<String>,
588 title: String,
589 kind: bamboo_domain::SessionKind,
590 created_at: chrono::DateTime<chrono::Utc>,
591 },
592
593 SessionDeleted { session_id: String },
598
599 SessionCleared { session_id: String },
604
605 MessageAppended {
612 session_id: String,
613 message_id: String,
614 role: bamboo_domain::Role,
615 content: String,
616 created_at: chrono::DateTime<chrono::Utc>,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
618 thread_id: Option<String>,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
620 in_reply_to: Option<String>,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
622 correlation_id: Option<String>,
623 },
624
625 SessionHistoryCommitted { session_id: String },
632
633 ExecutionStarted {
639 run_id: String,
641 session_id: String,
643 started_at: String,
645 },
646
647 ToolApprovalRequested {
654 tool_call_id: String,
656 tool_name: String,
658 parameters: serde_json::Value,
660 },
661
662 ChildApprovalRequested {
668 child_session_id: String,
670 request_id: String,
672 tool_name: String,
674 permission: String,
676 resource: String,
678 },
679
680 ChildApprovalChanged {
682 parent_session_id: String,
683 child_session_id: String,
684 #[serde(default)]
687 child_attempt: u32,
688 request_id: String,
689 version: u64,
690 status: String,
692 #[serde(default, skip_serializing_if = "Option::is_none")]
693 reason: Option<String>,
694 tool_name: String,
695 permission: String,
696 resource: String,
697 created_at: String,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
699 resolved_at: Option<String>,
700 },
701
702 BudgetExceeded {
712 session_id: String,
714 kind: String,
717 limit: u64,
719 actual: u64,
721 },
722
723 Complete {
725 usage: TokenUsage,
727 },
728
729 Cancelled {
731 #[serde(default, skip_serializing_if = "Option::is_none")]
733 message: Option<String>,
734 },
735
736 Error {
738 message: String,
740 },
741
742 WorkflowChanged {
744 workflow_id: String,
745 revision: u64,
746 scope: String,
747 },
748
749 WorkflowInvalid {
751 workflow_id: String,
752 revision: u64,
753 scope: String,
754 },
755
756 WorkflowRecovered {
758 workflow_id: String,
759 revision: u64,
760 scope: String,
761 },
762
763 ProjectCreated { project_id: String, revision: u64 },
765
766 ProjectUpdated { project_id: String, revision: u64 },
768
769 ProjectArchived { project_id: String, revision: u64 },
771
772 SessionProjectUpdated {
777 session_id: String,
778 #[serde(default)]
781 project_id: Option<String>,
782 #[serde(default)]
785 workspace_path: Option<String>,
786 metadata_version: u64,
787 },
788
789 #[serde(rename = "config.changed")]
791 ConfigChanged { section: String, revision: u64 },
792
793 #[serde(rename = "config.invalid")]
795 ConfigInvalid { section: String, revision: u64 },
796
797 #[serde(rename = "config.recovered")]
799 ConfigRecovered { section: String, revision: u64 },
800
801 WorkflowActivated {
803 event_id: String,
804 session_id: String,
805 workflow_id: String,
806 revision: u64,
807 invoked_by: String,
808 },
809
810 WorkflowDeactivated {
812 event_id: String,
813 session_id: String,
814 workflow_id: String,
815 revision: u64,
816 },
817
818 Notification {
824 id: String,
826 session_id: String,
828 category: String,
831 priority: String,
833 title: String,
835 body: String,
837 #[serde(default, skip_serializing_if = "Option::is_none")]
839 dedup_key: Option<String>,
840 created_at: String,
842 },
843}
844
845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
848pub struct LegacyChildLifecycleObservation<'a> {
849 pub parent_session_id: &'a str,
850 pub child_session_id: &'a str,
851 pub completed: bool,
852}
853
854impl AgentEvent {
855 pub fn message_appended(session_id: &str, message: &bamboo_domain::Message) -> Self {
858 let reference = |field: &str| {
859 message
860 .metadata
861 .as_ref()
862 .and_then(|m| m.get("session_message"))
863 .and_then(|m| m.get(field))
864 .and_then(serde_json::Value::as_str)
865 .map(str::to_owned)
866 };
867 Self::MessageAppended {
868 session_id: session_id.into(),
869 message_id: message.id.clone(),
870 role: message.role.clone(),
871 content: message.content.clone(),
872 created_at: message.created_at,
873 thread_id: reference("thread_id"),
874 in_reply_to: reference("in_reply_to"),
875 correlation_id: reference("correlation_id"),
876 }
877 }
878 pub fn legacy_child_lifecycle_observation(
883 &self,
884 ) -> Option<LegacyChildLifecycleObservation<'_>> {
885 const MAX_ID_BYTES: usize = 256;
886 const MAX_DETAIL_BYTES: usize = 4096;
887 let leaf = match self {
888 Self::SubAgentEvent {
889 parent_session_id,
890 child_session_id,
891 event,
892 } => {
893 if parent_session_id.is_empty()
894 || parent_session_id.len() > MAX_ID_BYTES
895 || child_session_id.is_empty()
896 || child_session_id.len() > MAX_ID_BYTES
897 || parent_session_id == child_session_id
898 {
899 return None;
900 }
901 let inner = event.as_ref();
902 let inner_parent = match inner {
903 Self::SubAgentStarted {
904 parent_session_id, ..
905 }
906 | Self::SubAgentCompleted {
907 parent_session_id, ..
908 } => parent_session_id,
909 _ => return None,
910 };
911 if inner_parent != child_session_id {
912 return None;
913 }
914 inner
915 }
916 other => other,
917 };
918 let observation = match leaf {
919 Self::SubAgentStarted {
920 parent_session_id,
921 child_session_id,
922 title,
923 } => {
924 if title
925 .as_ref()
926 .is_some_and(|title| title.len() > MAX_DETAIL_BYTES)
927 {
928 return None;
929 }
930 LegacyChildLifecycleObservation {
931 parent_session_id,
932 child_session_id,
933 completed: false,
934 }
935 }
936 Self::SubAgentCompleted {
937 parent_session_id,
938 child_session_id,
939 error,
940 ..
941 } => {
942 if error
943 .as_ref()
944 .is_some_and(|error| error.len() > MAX_DETAIL_BYTES)
945 {
946 return None;
947 }
948 LegacyChildLifecycleObservation {
949 parent_session_id,
950 child_session_id,
951 completed: true,
952 }
953 }
954 _ => return None,
955 };
956 if observation.parent_session_id.is_empty()
957 || observation.parent_session_id.len() > MAX_ID_BYTES
958 || observation.child_session_id.is_empty()
959 || observation.child_session_id.len() > MAX_ID_BYTES
960 || observation.parent_session_id == observation.child_session_id
961 {
962 return None;
963 }
964 Some(observation)
965 }
966
967 pub fn session_id(&self) -> Option<&str> {
976 match self {
977 AgentEvent::TaskListUpdated { task_list, .. } => Some(task_list.session_id.as_str()),
978 AgentEvent::TaskListItemProgress { session_id, .. }
979 | AgentEvent::TaskListCompleted { session_id, .. }
980 | AgentEvent::TaskEvaluationStarted { session_id, .. }
981 | AgentEvent::TaskEvaluationCompleted { session_id, .. }
982 | AgentEvent::TaskEvaluationCancelled { session_id, .. }
983 | AgentEvent::GoldEvaluationStarted { session_id, .. }
984 | AgentEvent::GoldEvaluationCompleted { session_id, .. }
985 | AgentEvent::GoldEvaluationCancelled { session_id, .. }
986 | AgentEvent::GoalStatusChanged { session_id, .. }
987 | AgentEvent::PlanModeEntered { session_id, .. }
988 | AgentEvent::PlanModeExited { session_id, .. }
989 | AgentEvent::PlanFileUpdated { session_id, .. }
990 | AgentEvent::RunnerProgress { session_id, .. }
991 | AgentEvent::PermissionPostureActivated { session_id, .. }
992 | AgentEvent::SessionTitleUpdated { session_id, .. }
993 | AgentEvent::SessionPinnedUpdated { session_id, .. }
994 | AgentEvent::SessionCreated { session_id, .. }
995 | AgentEvent::SessionDeleted { session_id, .. }
996 | AgentEvent::SessionCleared { session_id, .. }
997 | AgentEvent::MessageAppended { session_id, .. }
998 | AgentEvent::SessionHistoryCommitted { session_id }
999 | AgentEvent::ExecutionStarted { session_id, .. }
1000 | AgentEvent::BudgetExceeded { session_id, .. }
1001 | AgentEvent::WorkflowActivated { session_id, .. }
1002 | AgentEvent::WorkflowDeactivated { session_id, .. }
1003 | AgentEvent::SessionProjectUpdated { session_id, .. }
1004 | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
1005 AgentEvent::SubAgentStarted {
1006 parent_session_id, ..
1007 }
1008 | AgentEvent::SubAgentEvent {
1009 parent_session_id, ..
1010 }
1011 | AgentEvent::SubAgentHeartbeat {
1012 parent_session_id, ..
1013 }
1014 | AgentEvent::SubAgentCompleted {
1015 parent_session_id, ..
1016 }
1017 | AgentEvent::ChildApprovalChanged {
1018 parent_session_id, ..
1019 } => Some(parent_session_id.as_str()),
1020 _ => None,
1021 }
1022 }
1023
1024 pub fn is_replayable_session_state(&self) -> bool {
1032 self.legacy_child_lifecycle_observation().is_some()
1033 || matches!(
1034 self,
1035 AgentEvent::TaskListUpdated { .. }
1036 | AgentEvent::TaskListCompleted { .. }
1037 | AgentEvent::SubAgentStarted { .. }
1038 | AgentEvent::SubAgentCompleted { .. }
1039 | AgentEvent::ChildApprovalRequested { .. }
1040 | AgentEvent::ChildApprovalChanged { .. }
1041 | AgentEvent::BashCompleted { .. }
1042 | AgentEvent::SessionTitleUpdated { .. }
1043 | AgentEvent::SessionPinnedUpdated { .. }
1044 | AgentEvent::PlanModeEntered { .. }
1045 | AgentEvent::PlanModeExited { .. }
1046 | AgentEvent::BudgetExceeded { .. }
1047 | AgentEvent::NeedClarification { .. }
1048 | AgentEvent::WorkflowActivated { .. }
1049 | AgentEvent::WorkflowDeactivated { .. }
1050 )
1051 }
1052
1053 pub fn is_durable_change(&self) -> bool {
1064 matches!(
1065 self,
1066 AgentEvent::MessageAppended { .. }
1067 | AgentEvent::SessionHistoryCommitted { .. }
1068 | AgentEvent::SessionCreated { .. }
1069 | AgentEvent::SessionDeleted { .. }
1070 | AgentEvent::SessionCleared { .. }
1071 | AgentEvent::SessionTitleUpdated { .. }
1072 | AgentEvent::SessionPinnedUpdated { .. }
1073 | AgentEvent::TaskListUpdated { .. }
1074 | AgentEvent::TaskListItemProgress { .. }
1075 | AgentEvent::TaskListCompleted { .. }
1076 | AgentEvent::TaskEvaluationCompleted { .. }
1077 | AgentEvent::TaskEvaluationCancelled { .. }
1078 | AgentEvent::GoldEvaluationCancelled { .. }
1079 | AgentEvent::PlanModeEntered { .. }
1080 | AgentEvent::PlanModeExited { .. }
1081 | AgentEvent::PlanFileUpdated { .. }
1082 | AgentEvent::SubAgentStarted { .. }
1083 | AgentEvent::SubAgentCompleted { .. }
1084 | AgentEvent::ChildApprovalChanged { .. }
1085 | AgentEvent::NeedClarification { .. }
1086 | AgentEvent::ToolApprovalRequested { .. }
1087 | AgentEvent::ExecutionStarted { .. }
1088 | AgentEvent::BudgetExceeded { .. }
1089 | AgentEvent::Complete { .. }
1090 | AgentEvent::Cancelled { .. }
1091 | AgentEvent::Error { .. }
1092 | AgentEvent::WorkflowChanged { .. }
1093 | AgentEvent::WorkflowInvalid { .. }
1094 | AgentEvent::ProjectCreated { .. }
1095 | AgentEvent::ProjectUpdated { .. }
1096 | AgentEvent::ProjectArchived { .. }
1097 | AgentEvent::SessionProjectUpdated { .. }
1098 | AgentEvent::ConfigChanged { .. }
1099 | AgentEvent::ConfigInvalid { .. }
1100 | AgentEvent::ConfigRecovered { .. }
1101 | AgentEvent::WorkflowRecovered { .. }
1102 | AgentEvent::WorkflowActivated { .. }
1103 | AgentEvent::WorkflowDeactivated { .. }
1104 )
1105 }
1106}
1107
1108fn default_allow_custom() -> bool {
1109 true
1110}
1111
1112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1114#[serde(rename_all = "snake_case")]
1115pub enum GoldCheckpoint {
1116 PostRound,
1117 Terminal,
1118}
1119
1120impl GoldCheckpoint {
1121 pub fn as_str(self) -> &'static str {
1122 match self {
1123 Self::PostRound => "post_round",
1124 Self::Terminal => "terminal",
1125 }
1126 }
1127}
1128
1129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1131#[serde(rename_all = "snake_case")]
1132pub enum GoldDecision {
1133 Continue,
1134 Achieved,
1135 Blocked,
1136 NeedInput,
1137 Exhausted,
1138}
1139
1140impl GoldDecision {
1141 pub fn as_str(self) -> &'static str {
1142 match self {
1143 Self::Continue => "continue",
1144 Self::Achieved => "achieved",
1145 Self::Blocked => "blocked",
1146 Self::NeedInput => "need_input",
1147 Self::Exhausted => "exhausted",
1148 }
1149 }
1150}
1151
1152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1154#[serde(rename_all = "snake_case")]
1155pub enum GoldConfidence {
1156 Low,
1157 Medium,
1158 High,
1159}
1160
1161impl GoldConfidence {
1162 pub fn as_str(self) -> &'static str {
1163 match self {
1164 Self::Low => "low",
1165 Self::Medium => "medium",
1166 Self::High => "high",
1167 }
1168 }
1169
1170 pub fn rank(self) -> u8 {
1172 match self {
1173 Self::Low => 0,
1174 Self::Medium => 1,
1175 Self::High => 2,
1176 }
1177 }
1178
1179 pub fn meets(self, floor: GoldConfidence) -> bool {
1181 self.rank() >= floor.rank()
1182 }
1183}
1184
1185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1187#[serde(rename_all = "snake_case")]
1188pub enum TitleSource {
1189 Auto,
1190 Manual,
1191 Fallback,
1192}
1193
1194pub use bamboo_domain::TokenUsage;
1198
1199pub use bamboo_domain::budget_types::{ProviderPromptUsage, TokenBudgetUsage};
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
1205
1206 fn sample_task_list() -> TaskList {
1207 TaskList {
1208 session_id: "session-1".to_string(),
1209 title: "Task List".to_string(),
1210 items: vec![TaskItem {
1211 id: "task_1".to_string(),
1212 description: "Implement event rename".to_string(),
1213 status: TaskItemStatus::InProgress,
1214 depends_on: Vec::new(),
1215 notes: "Implementing".to_string(),
1216 ..TaskItem::default()
1217 }],
1218 created_at: Utc::now(),
1219 updated_at: Utc::now(),
1220 }
1221 }
1222
1223 #[test]
1224 fn task_list_updated_serializes_with_task_names() {
1225 let event = AgentEvent::TaskListUpdated {
1226 task_list: sample_task_list(),
1227 version: Some(7),
1228 };
1229
1230 let value = serde_json::to_value(event).expect("event should serialize");
1231 assert_eq!(value["type"], "task_list_updated");
1232 assert!(value.get("task_list").is_some());
1233 assert_eq!(value["version"], 7);
1234 assert!(value.get("todo_list").is_none());
1235 }
1236
1237 #[test]
1238 fn context_archived_serializes_structural_evidence_without_history_content() {
1239 let event = AgentEvent::ContextArchived {
1240 archive_event_id: "compression-event-1".to_string(),
1241 trigger_type: "auto".to_string(),
1242 messages_archived: 8,
1243 groups_archived: 4,
1244 user_turns_archived: 4,
1245 active_tokens_before: 9_000,
1246 active_tokens_after: 5_000,
1247 target_tokens: 6_000,
1248 retained_recent_user_turns: 3,
1249 oldest_retained_message_id: Some("message-9".to_string()),
1250 oldest_retained_user_message_id: Some("message-9".to_string()),
1251 model_context_epoch: 2,
1252 reset_reason: "compression".to_string(),
1253 };
1254
1255 let value = serde_json::to_value(&event).expect("archive event should serialize");
1256 assert_eq!(value["type"], "context_archived");
1257 assert_eq!(value["messages_archived"], 8);
1258 assert_eq!(value["active_tokens_after"], 5_000);
1259 assert_eq!(value["reset_reason"], "compression");
1260 let wire = serde_json::to_string(&value).unwrap();
1261 assert!(!wire.contains("summary"));
1262 assert!(!wire.contains("raw_message"));
1263 assert!(!wire.contains("tool_result"));
1264 assert!(matches!(
1265 serde_json::from_value::<AgentEvent>(value).unwrap(),
1266 AgentEvent::ContextArchived {
1267 archive_event_id,
1268 messages_archived: 8,
1269 model_context_epoch: 2,
1270 ..
1271 } if archive_event_id == "compression-event-1"
1272 ));
1273 }
1274
1275 #[test]
1276 fn session_project_updated_serializes_unassignment_as_explicit_null() {
1277 let event = AgentEvent::SessionProjectUpdated {
1278 session_id: "session-1".to_string(),
1279 project_id: None,
1280 workspace_path: Some("/workspaces/current".to_string()),
1281 metadata_version: 4,
1282 };
1283
1284 let value = serde_json::to_value(&event).expect("event should serialize");
1285 assert_eq!(value["type"], "session_project_updated");
1286 assert!(
1287 value
1288 .get("project_id")
1289 .is_some_and(serde_json::Value::is_null),
1290 "unassignment must carry an explicit project_id: null"
1291 );
1292 assert_eq!(value["workspace_path"], "/workspaces/current");
1293
1294 let restored: AgentEvent = serde_json::from_value(value).expect("event should deserialize");
1295 assert!(matches!(
1296 restored,
1297 AgentEvent::SessionProjectUpdated {
1298 session_id,
1299 project_id: None,
1300 workspace_path: Some(workspace_path),
1301 metadata_version: 4,
1302 } if session_id == "session-1" && workspace_path == "/workspaces/current"
1303 ));
1304 }
1305
1306 #[test]
1307 fn session_project_updated_deserializes_legacy_event_without_workspace() {
1308 let restored: AgentEvent = serde_json::from_value(serde_json::json!({
1309 "type": "session_project_updated",
1310 "session_id": "session-1",
1311 "project_id": "project-1",
1312 "metadata_version": 2
1313 }))
1314 .expect("legacy event should deserialize");
1315
1316 assert!(matches!(
1317 restored,
1318 AgentEvent::SessionProjectUpdated {
1319 workspace_path: None,
1320 metadata_version: 2,
1321 ..
1322 }
1323 ));
1324 }
1325
1326 #[test]
1327 fn cancelled_serializes_with_snake_case_type() {
1328 let event = AgentEvent::Cancelled {
1329 message: Some("Agent execution cancelled by user".to_string()),
1330 };
1331
1332 let value = serde_json::to_value(event).expect("event should serialize");
1333 assert_eq!(value["type"], "cancelled");
1334 assert_eq!(
1335 value["message"],
1336 serde_json::Value::String("Agent execution cancelled by user".to_string())
1337 );
1338 }
1339
1340 #[test]
1341 fn session_history_committed_is_a_routable_durable_barrier() {
1342 let event = AgentEvent::SessionHistoryCommitted {
1343 session_id: "session-1".to_string(),
1344 };
1345
1346 let value = serde_json::to_value(&event).expect("event should serialize");
1347 assert_eq!(value["type"], "session_history_committed");
1348 assert_eq!(value["session_id"], "session-1");
1349 assert_eq!(event.session_id(), Some("session-1"));
1350 assert!(event.is_durable_change());
1351
1352 let restored: AgentEvent = serde_json::from_value(value).expect("event should deserialize");
1353 assert!(matches!(
1354 restored,
1355 AgentEvent::SessionHistoryCommitted { session_id } if session_id == "session-1"
1356 ));
1357 }
1358
1359 #[test]
1360 fn task_evaluation_completed_serializes_with_task_type() {
1361 let event = AgentEvent::TaskEvaluationCompleted {
1362 session_id: "session-1".to_string(),
1363 updates_count: 2,
1364 reasoning: "Updated statuses".to_string(),
1365 generation: Some(7),
1366 };
1367
1368 let value = serde_json::to_value(event).expect("event should serialize");
1369 assert_eq!(value["type"], "task_evaluation_completed");
1370 assert_eq!(value["generation"], 7);
1371 }
1372
1373 #[test]
1374 fn task_evaluation_event_without_generation_remains_deserializable() {
1375 let event: AgentEvent = serde_json::from_value(serde_json::json!({
1376 "type": "task_evaluation_started",
1377 "session_id": "session-1",
1378 "items_count": 2
1379 }))
1380 .expect("legacy task evaluation frame should remain compatible");
1381
1382 assert!(matches!(
1383 event,
1384 AgentEvent::TaskEvaluationStarted {
1385 generation: None,
1386 ..
1387 }
1388 ));
1389 }
1390
1391 #[test]
1392 fn evaluation_cancelled_events_serialize_as_terminal_lifecycle_events() {
1393 let task = AgentEvent::TaskEvaluationCancelled {
1394 session_id: "session-1".to_string(),
1395 reason: "run_suspended".to_string(),
1396 generation: Some(7),
1397 };
1398 let gold = AgentEvent::GoldEvaluationCancelled {
1399 session_id: "session-1".to_string(),
1400 reason: "run_completed".to_string(),
1401 };
1402
1403 assert!(task.is_durable_change());
1404 assert!(gold.is_durable_change());
1405 let task_value = serde_json::to_value(task).unwrap();
1406 let gold_value = serde_json::to_value(gold).unwrap();
1407 assert_eq!(task_value["type"], "task_evaluation_cancelled");
1408 assert_eq!(task_value["reason"], "run_suspended");
1409 assert_eq!(gold_value["type"], "gold_evaluation_cancelled");
1410 assert_eq!(gold_value["reason"], "run_completed");
1411 }
1412
1413 #[test]
1414 fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
1415 let event = AgentEvent::GoldEvaluationCompleted {
1416 session_id: "session-1".to_string(),
1417 checkpoint: GoldCheckpoint::PostRound,
1418 iteration: 3,
1419 decision: GoldDecision::Continue,
1420 confidence: GoldConfidence::Medium,
1421 reasoning: "Need one more iteration".to_string(),
1422 };
1423
1424 let value = serde_json::to_value(event).expect("event should serialize");
1425 assert_eq!(value["type"], "gold_evaluation_completed");
1426 assert_eq!(value["checkpoint"], "post_round");
1427 assert_eq!(value["iteration"], 3);
1428 assert_eq!(value["decision"], "continue");
1429 assert_eq!(value["confidence"], "medium");
1430 assert_eq!(value["reasoning"], "Need one more iteration");
1431 }
1432
1433 #[test]
1434 fn gold_evaluation_started_deserializes() {
1435 let json = serde_json::json!({
1436 "type": "gold_evaluation_started",
1437 "session_id": "session-1",
1438 "checkpoint": "terminal",
1439 "iteration": 7
1440 });
1441
1442 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1443 match event {
1444 AgentEvent::GoldEvaluationStarted {
1445 session_id,
1446 checkpoint,
1447 iteration,
1448 } => {
1449 assert_eq!(session_id, "session-1");
1450 assert_eq!(checkpoint, GoldCheckpoint::Terminal);
1451 assert_eq!(iteration, 7);
1452 }
1453 other => panic!("unexpected event: {other:?}"),
1454 }
1455 }
1456
1457 #[test]
1458 fn context_compression_status_serializes_with_phase_and_status() {
1459 let event = AgentEvent::ContextCompressionStatus {
1460 phase: "mid-turn".to_string(),
1461 status: "started".to_string(),
1462 };
1463
1464 let value = serde_json::to_value(event).expect("event should serialize");
1465 assert_eq!(value["type"], "context_compression_status");
1466 assert_eq!(value["phase"], "mid-turn");
1467 assert_eq!(value["status"], "started");
1468 }
1469
1470 #[test]
1471 fn need_clarification_serializes_with_new_fields() {
1472 let event = AgentEvent::NeedClarification {
1473 question: "Continue?".to_string(),
1474 options: Some(vec!["Yes".to_string(), "No".to_string()]),
1475 tool_call_id: Some("tool-1".to_string()),
1476 tool_name: Some("conclusion_with_options".to_string()),
1477 allow_custom: false,
1478 source: Some(PendingQuestionSource::PauseTool),
1479 };
1480
1481 let value = serde_json::to_value(event).expect("event should serialize");
1482 assert_eq!(value["type"], "need_clarification");
1483 assert_eq!(value["question"], "Continue?");
1484 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
1485 assert_eq!(value["tool_call_id"], "tool-1");
1486 assert_eq!(value["tool_name"], "conclusion_with_options");
1487 assert_eq!(value["allow_custom"], false);
1488 assert_eq!(value["source"], "pause_tool");
1489 }
1490
1491 #[test]
1492 fn need_clarification_deserializes_from_old_format_without_new_fields() {
1493 let json = serde_json::json!({
1494 "type": "need_clarification",
1495 "question": "Continue?",
1496 "options": ["Yes", "No"]
1497 });
1498
1499 let event: AgentEvent =
1500 serde_json::from_value(json).expect("should deserialize old format");
1501 match event {
1502 AgentEvent::NeedClarification {
1503 question,
1504 options,
1505 tool_call_id,
1506 tool_name,
1507 allow_custom,
1508 source,
1509 } => {
1510 assert_eq!(question, "Continue?");
1511 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
1512 assert_eq!(tool_call_id, None);
1513 assert_eq!(tool_name, None);
1514 assert!(allow_custom); assert_eq!(source, None);
1516 }
1517 other => panic!("unexpected event: {other:?}"),
1518 }
1519 }
1520
1521 #[test]
1522 fn need_clarification_deserializes_with_allow_custom_false() {
1523 let json = serde_json::json!({
1524 "type": "need_clarification",
1525 "question": "Pick one",
1526 "allow_custom": false
1527 });
1528
1529 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1530 match event {
1531 AgentEvent::NeedClarification {
1532 question,
1533 options,
1534 tool_call_id,
1535 tool_name,
1536 allow_custom,
1537 source,
1538 } => {
1539 assert_eq!(question, "Pick one");
1540 assert_eq!(options, None);
1541 assert_eq!(tool_call_id, None);
1542 assert_eq!(tool_name, None);
1543 assert!(!allow_custom);
1544 assert_eq!(source, None);
1545 }
1546 other => panic!("unexpected event: {other:?}"),
1547 }
1548 }
1549
1550 #[test]
1551 fn plan_mode_entered_serializes_correctly() {
1552 let entered_at = Utc::now();
1553 let event = AgentEvent::PlanModeEntered {
1554 session_id: "sess-1".to_string(),
1555 reason: Some("Complex refactor".to_string()),
1556 pre_permission_mode: "default".to_string(),
1557 entered_at,
1558 status: bamboo_domain::PlanModeStatus::Exploring,
1559 plan_file_path: None,
1560 };
1561
1562 let value = serde_json::to_value(event).expect("event should serialize");
1563 assert_eq!(value["type"], "plan_mode_entered");
1564 assert_eq!(value["session_id"], "sess-1");
1565 assert_eq!(value["reason"], "Complex refactor");
1566 assert_eq!(value["pre_permission_mode"], "default");
1567 assert_eq!(value["status"], "exploring");
1568 assert_eq!(
1571 value["entered_at"],
1572 serde_json::to_value(entered_at).unwrap()
1573 );
1574 }
1575
1576 #[test]
1577 fn plan_mode_exited_serializes_correctly() {
1578 let event = AgentEvent::PlanModeExited {
1579 session_id: "sess-1".to_string(),
1580 approved: true,
1581 restored_mode: "accept_edits".to_string(),
1582 plan: Some("# Plan\n1. Step one".to_string()),
1583 };
1584
1585 let value = serde_json::to_value(event).expect("event should serialize");
1586 assert_eq!(value["type"], "plan_mode_exited");
1587 assert_eq!(value["session_id"], "sess-1");
1588 assert_eq!(value["approved"], true);
1589 assert_eq!(value["restored_mode"], "accept_edits");
1590 assert_eq!(value["plan"], "# Plan\n1. Step one");
1591 }
1592
1593 #[test]
1594 fn plan_file_updated_serializes_correctly() {
1595 let event = AgentEvent::PlanFileUpdated {
1596 session_id: "sess-1".to_string(),
1597 file_path: "/tmp/plans/sess-1.md".to_string(),
1598 content_summary: "Implementation plan for feature X".to_string(),
1599 status: Some(bamboo_domain::PlanModeStatus::AwaitingApproval),
1600 };
1601
1602 let value = serde_json::to_value(event).expect("event should serialize");
1603 assert_eq!(value["type"], "plan_file_updated");
1604 assert_eq!(value["session_id"], "sess-1");
1605 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
1606 assert_eq!(
1607 value["content_summary"],
1608 "Implementation plan for feature X"
1609 );
1610 }
1611
1612 #[test]
1613 fn tool_approval_requested_serializes_correctly() {
1614 let event = AgentEvent::ToolApprovalRequested {
1615 tool_call_id: "call-abc".to_string(),
1616 tool_name: "Write".to_string(),
1617 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
1618 };
1619
1620 let value = serde_json::to_value(event).expect("event should serialize");
1621 assert_eq!(value["type"], "tool_approval_requested");
1622 assert_eq!(value["tool_call_id"], "call-abc");
1623 assert_eq!(value["tool_name"], "Write");
1624 assert_eq!(
1625 value["parameters"],
1626 serde_json::json!({"file_path": "/tmp/test.txt"})
1627 );
1628 }
1629
1630 #[test]
1631 fn child_approval_changed_routes_to_parent_and_is_durable() {
1632 let event = AgentEvent::ChildApprovalChanged {
1633 parent_session_id: "parent-1".into(),
1634 child_session_id: "child-1".into(),
1635 child_attempt: 3,
1636 request_id: "req-1".into(),
1637 version: 2,
1638 status: "approved".into(),
1639 reason: None,
1640 tool_name: "Bash".into(),
1641 permission: "execute".into(),
1642 resource: "/tmp/x".into(),
1643 created_at: "2026-01-01T00:00:00Z".into(),
1644 resolved_at: Some("2026-01-01T00:00:01Z".into()),
1645 };
1646 assert_eq!(event.session_id(), Some("parent-1"));
1647 assert!(event.is_durable_change());
1648 let value = serde_json::to_value(event).unwrap();
1649 assert_eq!(value["type"], "child_approval_changed");
1650 assert_eq!(value["status"], "approved");
1651 assert_eq!(value["child_attempt"], 3);
1652
1653 let mut legacy = value;
1654 legacy.as_object_mut().unwrap().remove("child_attempt");
1655 let restored: AgentEvent = serde_json::from_value(legacy).unwrap();
1656 assert!(matches!(
1657 restored,
1658 AgentEvent::ChildApprovalChanged {
1659 child_attempt: 0,
1660 ..
1661 }
1662 ));
1663 }
1664
1665 #[test]
1666 fn tool_approval_requested_deserializes_correctly() {
1667 let json = serde_json::json!({
1668 "type": "tool_approval_requested",
1669 "tool_call_id": "call-xyz",
1670 "tool_name": "Bash",
1671 "parameters": {"command": "ls -la"}
1672 });
1673
1674 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1675 match event {
1676 AgentEvent::ToolApprovalRequested {
1677 tool_call_id,
1678 tool_name,
1679 parameters,
1680 } => {
1681 assert_eq!(tool_call_id, "call-xyz");
1682 assert_eq!(tool_name, "Bash");
1683 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1684 }
1685 other => panic!("unexpected event: {other:?}"),
1686 }
1687 }
1688
1689 #[test]
1690 fn session_title_updated_round_trips_with_source_variants() {
1691 use chrono::Utc;
1692 let event = AgentEvent::SessionTitleUpdated {
1693 session_id: "sess-1".to_string(),
1694 title: "My title".to_string(),
1695 title_version: 3,
1696 title_generated: true,
1697 source: TitleSource::Auto,
1698 updated_at: Utc::now(),
1699 };
1700 let json = serde_json::to_string(&event).unwrap();
1701 assert!(
1702 json.contains("\"type\":\"session_title_updated\""),
1703 "json: {json}"
1704 );
1705 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1706 let decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1707 assert!(matches!(
1708 decoded,
1709 AgentEvent::SessionTitleUpdated {
1710 title_generated: true,
1711 ..
1712 }
1713 ));
1714
1715 let legacy = serde_json::json!({
1716 "type": "session_title_updated",
1717 "session_id": "sess-legacy",
1718 "title": "Existing title",
1719 "title_version": 2,
1720 "source": "manual",
1721 "updated_at": "2025-01-01T00:00:00Z"
1722 });
1723 let decoded: AgentEvent = serde_json::from_value(legacy).unwrap();
1724 assert!(matches!(
1725 decoded,
1726 AgentEvent::SessionTitleUpdated {
1727 title_generated: true,
1728 ..
1729 }
1730 ));
1731 }
1732
1733 #[test]
1734 fn plan_mode_events_deserialize_without_optional_fields() {
1735 let json = serde_json::json!({
1736 "type": "plan_mode_entered",
1737 "session_id": "sess-1",
1738 "pre_permission_mode": "default",
1739 "entered_at": "2025-01-01T00:00:00Z",
1740 "status": "exploring"
1741 });
1742
1743 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1744 match event {
1745 AgentEvent::PlanModeEntered {
1746 session_id,
1747 reason,
1748 pre_permission_mode,
1749 entered_at,
1750 status,
1751 plan_file_path,
1752 } => {
1753 assert_eq!(session_id, "sess-1");
1754 assert_eq!(reason, None);
1755 assert_eq!(pre_permission_mode, "default");
1756 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1757 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1758 assert_eq!(plan_file_path, None);
1759 }
1760 other => panic!("unexpected event: {other:?}"),
1761 }
1762 }
1763
1764 #[test]
1765 fn workflow_catalog_events_are_durable_and_account_scoped() {
1766 for event in [
1767 AgentEvent::WorkflowChanged {
1768 workflow_id: "review".to_string(),
1769 revision: 2,
1770 scope: "global".to_string(),
1771 },
1772 AgentEvent::WorkflowInvalid {
1773 workflow_id: "review".to_string(),
1774 revision: 3,
1775 scope: "workspace:1234".to_string(),
1776 },
1777 AgentEvent::WorkflowRecovered {
1778 workflow_id: "review".to_string(),
1779 revision: 4,
1780 scope: "workspace:1234".to_string(),
1781 },
1782 ] {
1783 assert!(event.is_durable_change());
1784 assert_eq!(event.session_id(), None);
1785 let encoded = serde_json::to_string(&event).expect("serialize");
1786 let _: AgentEvent = serde_json::from_str(&encoded).expect("deserialize");
1787 }
1788 }
1789
1790 #[test]
1791 fn raw_child_and_streaming_payloads_are_never_durable_account_changes() {
1792 let raw_child = AgentEvent::SubAgentEvent {
1793 parent_session_id: "parent".to_string(),
1794 child_session_id: "child".to_string(),
1795 event: Box::new(AgentEvent::ReasoningToken {
1796 content: "private reasoning".to_string(),
1797 }),
1798 };
1799 for event in [
1800 AgentEvent::Token {
1801 content: "visible but ephemeral".to_string(),
1802 },
1803 AgentEvent::ReasoningToken {
1804 content: "private reasoning".to_string(),
1805 },
1806 AgentEvent::ToolToken {
1807 tool_call_id: "call-1".to_string(),
1808 content: "private tool output".to_string(),
1809 },
1810 raw_child,
1811 ] {
1812 assert!(
1813 !event.is_durable_change(),
1814 "unexpected durable event: {event:?}"
1815 );
1816 }
1817
1818 assert!(AgentEvent::SubAgentStarted {
1819 parent_session_id: "parent".to_string(),
1820 child_session_id: "child".to_string(),
1821 title: None,
1822 }
1823 .is_durable_change());
1824 assert!(AgentEvent::SubAgentCompleted {
1825 parent_session_id: "parent".to_string(),
1826 child_session_id: "child".to_string(),
1827 status: "completed".to_string(),
1828 error: None,
1829 }
1830 .is_durable_change());
1831 }
1832
1833 #[test]
1834 fn clarification_is_shared_replayable_state_but_tokens_are_not() {
1835 let clarification = AgentEvent::NeedClarification {
1836 question: "Choose".to_string(),
1837 options: Some(vec!["A".to_string()]),
1838 tool_call_id: Some("call-1".to_string()),
1839 tool_name: Some("ConclusionWithOptions".to_string()),
1840 allow_custom: false,
1841 source: Some(PendingQuestionSource::PauseTool),
1842 };
1843 assert!(clarification.is_replayable_session_state());
1844 assert!(!AgentEvent::Token {
1845 content: "ephemeral".to_string(),
1846 }
1847 .is_replayable_session_state());
1848 }
1849}