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