1use crate::tools::ToolResult;
39use bamboo_domain::{TaskItemStatus, TaskList};
40use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(tag = "type", rename_all = "snake_case")]
95pub enum AgentEvent {
96 Token {
98 content: String,
100 },
101
102 ReasoningToken {
107 content: String,
109 },
110
111 ToolToken {
116 tool_call_id: String,
118 content: String,
120 },
121
122 ToolStart {
124 tool_call_id: String,
126 tool_name: String,
128 arguments: serde_json::Value,
130 },
131
132 ToolComplete {
134 tool_call_id: String,
136 result: ToolResult,
138 },
139
140 ToolError {
142 tool_call_id: String,
144 error: String,
146 },
147
148 ToolLifecycle {
154 tool_call_id: String,
156 tool_name: String,
158 phase: String,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 elapsed_ms: Option<u64>,
163 is_mutating: bool,
165 auto_approved: bool,
167 #[serde(skip_serializing_if = "Option::is_none")]
169 summary: Option<String>,
170 #[serde(skip_serializing_if = "Option::is_none")]
172 error: Option<String>,
173 },
174
175 NeedClarification {
177 question: String,
179 options: Option<Vec<String>>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 tool_call_id: Option<String>,
184 #[serde(default = "default_allow_custom")]
186 allow_custom: bool,
187 },
188
189 TaskListUpdated {
191 task_list: TaskList,
193 },
194
195 TaskListItemProgress {
197 session_id: String,
199 item_id: String,
201 status: TaskItemStatus,
203 tool_calls_count: usize,
205 version: u64,
207 },
208
209 TaskListCompleted {
211 session_id: String,
213 completed_at: DateTime<Utc>,
215 total_rounds: u32,
217 total_tool_calls: usize,
219 },
220
221 TaskEvaluationStarted {
223 session_id: String,
225 items_count: usize,
227 },
228
229 TaskEvaluationCompleted {
231 session_id: String,
233 updates_count: usize,
235 reasoning: String,
237 },
238
239 TokenBudgetUpdated {
241 usage: TokenBudgetUsage,
243 },
244
245 ContextCompressionStatus {
247 phase: String,
249 status: String,
251 },
252
253 ContextSummarized {
255 summary: String,
257 messages_summarized: usize,
259 tokens_saved: u32,
261 #[serde(default)]
263 usage_before_percent: f64,
264 #[serde(default)]
266 usage_after_percent: f64,
267 #[serde(default)]
269 trigger_type: String,
270 },
271
272 ContextPressureNotification {
275 percent: f64,
277 level: String,
279 message: String,
281 },
282
283 SubAgentStarted {
285 parent_session_id: String,
286 child_session_id: String,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 title: Option<String>,
290 },
291
292 SubAgentEvent {
296 parent_session_id: String,
297 child_session_id: String,
298 event: Box<AgentEvent>,
299 },
300
301 SubAgentHeartbeat {
303 parent_session_id: String,
304 child_session_id: String,
305 timestamp: DateTime<Utc>,
306 },
307
308 SubAgentCompleted {
310 parent_session_id: String,
311 child_session_id: String,
312 status: String,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 error: Option<String>,
316 },
317
318 PlanModeEntered {
320 session_id: String,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 reason: Option<String>,
325 pre_permission_mode: String,
327 entered_at: chrono::DateTime<chrono::Utc>,
329 status: bamboo_domain::PlanModeStatus,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
333 plan_file_path: Option<String>,
334 },
335
336 PlanModeExited {
338 session_id: String,
340 approved: bool,
342 restored_mode: String,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
346 plan: Option<String>,
347 },
348
349 PlanFileUpdated {
351 session_id: String,
353 file_path: String,
355 content_summary: String,
357 },
358
359 RunnerProgress {
364 session_id: String,
366 round_count: u32,
368 },
369
370 SessionTitleUpdated {
372 session_id: String,
373 title: String,
374 title_version: u64,
375 source: TitleSource,
376 updated_at: chrono::DateTime<chrono::Utc>,
377 },
378
379 SessionPinnedUpdated {
385 session_id: String,
386 pinned: bool,
387 updated_at: chrono::DateTime<chrono::Utc>,
388 },
389
390 ExecutionStarted {
396 run_id: String,
398 session_id: String,
400 started_at: String,
402 },
403
404 ToolApprovalRequested {
411 tool_call_id: String,
413 tool_name: String,
415 parameters: serde_json::Value,
417 },
418
419 Complete {
421 usage: TokenUsage,
423 },
424
425 Cancelled {
427 #[serde(default, skip_serializing_if = "Option::is_none")]
429 message: Option<String>,
430 },
431
432 Error {
434 message: String,
436 },
437}
438
439fn default_allow_custom() -> bool {
440 true
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(rename_all = "snake_case")]
446pub enum TitleSource {
447 Auto,
448 Manual,
449 Fallback,
450}
451
452pub use bamboo_domain::TokenUsage;
456
457pub use bamboo_domain::budget_types::TokenBudgetUsage;
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
463
464 fn sample_task_list() -> TaskList {
465 TaskList {
466 session_id: "session-1".to_string(),
467 title: "Task List".to_string(),
468 items: vec![TaskItem {
469 id: "task_1".to_string(),
470 description: "Implement event rename".to_string(),
471 status: TaskItemStatus::InProgress,
472 depends_on: Vec::new(),
473 notes: "Implementing".to_string(),
474 ..TaskItem::default()
475 }],
476 created_at: Utc::now(),
477 updated_at: Utc::now(),
478 }
479 }
480
481 #[test]
482 fn task_list_updated_serializes_with_task_names() {
483 let event = AgentEvent::TaskListUpdated {
484 task_list: sample_task_list(),
485 };
486
487 let value = serde_json::to_value(event).expect("event should serialize");
488 assert_eq!(value["type"], "task_list_updated");
489 assert!(value.get("task_list").is_some());
490 assert!(value.get("todo_list").is_none());
491 }
492
493 #[test]
494 fn cancelled_serializes_with_snake_case_type() {
495 let event = AgentEvent::Cancelled {
496 message: Some("Agent execution cancelled by user".to_string()),
497 };
498
499 let value = serde_json::to_value(event).expect("event should serialize");
500 assert_eq!(value["type"], "cancelled");
501 assert_eq!(
502 value["message"],
503 serde_json::Value::String("Agent execution cancelled by user".to_string())
504 );
505 }
506
507 #[test]
508 fn task_evaluation_completed_serializes_with_task_type() {
509 let event = AgentEvent::TaskEvaluationCompleted {
510 session_id: "session-1".to_string(),
511 updates_count: 2,
512 reasoning: "Updated statuses".to_string(),
513 };
514
515 let value = serde_json::to_value(event).expect("event should serialize");
516 assert_eq!(value["type"], "task_evaluation_completed");
517 }
518
519 #[test]
520 fn context_compression_status_serializes_with_phase_and_status() {
521 let event = AgentEvent::ContextCompressionStatus {
522 phase: "mid-turn".to_string(),
523 status: "started".to_string(),
524 };
525
526 let value = serde_json::to_value(event).expect("event should serialize");
527 assert_eq!(value["type"], "context_compression_status");
528 assert_eq!(value["phase"], "mid-turn");
529 assert_eq!(value["status"], "started");
530 }
531
532 #[test]
533 fn need_clarification_serializes_with_new_fields() {
534 let event = AgentEvent::NeedClarification {
535 question: "Continue?".to_string(),
536 options: Some(vec!["Yes".to_string(), "No".to_string()]),
537 tool_call_id: Some("tool-1".to_string()),
538 allow_custom: false,
539 };
540
541 let value = serde_json::to_value(event).expect("event should serialize");
542 assert_eq!(value["type"], "need_clarification");
543 assert_eq!(value["question"], "Continue?");
544 assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
545 assert_eq!(value["tool_call_id"], "tool-1");
546 assert_eq!(value["allow_custom"], false);
547 }
548
549 #[test]
550 fn need_clarification_deserializes_from_old_format_without_new_fields() {
551 let json = serde_json::json!({
552 "type": "need_clarification",
553 "question": "Continue?",
554 "options": ["Yes", "No"]
555 });
556
557 let event: AgentEvent =
558 serde_json::from_value(json).expect("should deserialize old format");
559 match event {
560 AgentEvent::NeedClarification {
561 question,
562 options,
563 tool_call_id,
564 allow_custom,
565 } => {
566 assert_eq!(question, "Continue?");
567 assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
568 assert_eq!(tool_call_id, None);
569 assert!(allow_custom); }
571 other => panic!("unexpected event: {other:?}"),
572 }
573 }
574
575 #[test]
576 fn need_clarification_deserializes_with_allow_custom_false() {
577 let json = serde_json::json!({
578 "type": "need_clarification",
579 "question": "Pick one",
580 "allow_custom": false
581 });
582
583 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
584 match event {
585 AgentEvent::NeedClarification {
586 question,
587 options,
588 tool_call_id,
589 allow_custom,
590 } => {
591 assert_eq!(question, "Pick one");
592 assert_eq!(options, None);
593 assert_eq!(tool_call_id, None);
594 assert!(!allow_custom);
595 }
596 other => panic!("unexpected event: {other:?}"),
597 }
598 }
599
600 #[test]
601 fn plan_mode_entered_serializes_correctly() {
602 let entered_at = Utc::now();
603 let event = AgentEvent::PlanModeEntered {
604 session_id: "sess-1".to_string(),
605 reason: Some("Complex refactor".to_string()),
606 pre_permission_mode: "default".to_string(),
607 entered_at,
608 status: bamboo_domain::PlanModeStatus::Exploring,
609 plan_file_path: None,
610 };
611
612 let value = serde_json::to_value(event).expect("event should serialize");
613 assert_eq!(value["type"], "plan_mode_entered");
614 assert_eq!(value["session_id"], "sess-1");
615 assert_eq!(value["reason"], "Complex refactor");
616 assert_eq!(value["pre_permission_mode"], "default");
617 assert_eq!(value["status"], "exploring");
618 assert_eq!(value["entered_at"], entered_at.to_rfc3339());
619 }
620
621 #[test]
622 fn plan_mode_exited_serializes_correctly() {
623 let event = AgentEvent::PlanModeExited {
624 session_id: "sess-1".to_string(),
625 approved: true,
626 restored_mode: "accept_edits".to_string(),
627 plan: Some("# Plan\n1. Step one".to_string()),
628 };
629
630 let value = serde_json::to_value(event).expect("event should serialize");
631 assert_eq!(value["type"], "plan_mode_exited");
632 assert_eq!(value["session_id"], "sess-1");
633 assert_eq!(value["approved"], true);
634 assert_eq!(value["restored_mode"], "accept_edits");
635 assert_eq!(value["plan"], "# Plan\n1. Step one");
636 }
637
638 #[test]
639 fn plan_file_updated_serializes_correctly() {
640 let event = AgentEvent::PlanFileUpdated {
641 session_id: "sess-1".to_string(),
642 file_path: "/tmp/plans/sess-1.md".to_string(),
643 content_summary: "Implementation plan for feature X".to_string(),
644 };
645
646 let value = serde_json::to_value(event).expect("event should serialize");
647 assert_eq!(value["type"], "plan_file_updated");
648 assert_eq!(value["session_id"], "sess-1");
649 assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
650 assert_eq!(
651 value["content_summary"],
652 "Implementation plan for feature X"
653 );
654 }
655
656 #[test]
657 fn tool_approval_requested_serializes_correctly() {
658 let event = AgentEvent::ToolApprovalRequested {
659 tool_call_id: "call-abc".to_string(),
660 tool_name: "Write".to_string(),
661 parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
662 };
663
664 let value = serde_json::to_value(event).expect("event should serialize");
665 assert_eq!(value["type"], "tool_approval_requested");
666 assert_eq!(value["tool_call_id"], "call-abc");
667 assert_eq!(value["tool_name"], "Write");
668 assert_eq!(
669 value["parameters"],
670 serde_json::json!({"file_path": "/tmp/test.txt"})
671 );
672 }
673
674 #[test]
675 fn tool_approval_requested_deserializes_correctly() {
676 let json = serde_json::json!({
677 "type": "tool_approval_requested",
678 "tool_call_id": "call-xyz",
679 "tool_name": "Bash",
680 "parameters": {"command": "ls -la"}
681 });
682
683 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
684 match event {
685 AgentEvent::ToolApprovalRequested {
686 tool_call_id,
687 tool_name,
688 parameters,
689 } => {
690 assert_eq!(tool_call_id, "call-xyz");
691 assert_eq!(tool_name, "Bash");
692 assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
693 }
694 other => panic!("unexpected event: {other:?}"),
695 }
696 }
697
698 #[test]
699 fn session_title_updated_round_trips_with_source_variants() {
700 use chrono::Utc;
701 let event = AgentEvent::SessionTitleUpdated {
702 session_id: "sess-1".to_string(),
703 title: "My title".to_string(),
704 title_version: 3,
705 source: TitleSource::Auto,
706 updated_at: Utc::now(),
707 };
708 let json = serde_json::to_string(&event).unwrap();
709 assert!(
710 json.contains("\"type\":\"session_title_updated\""),
711 "json: {json}"
712 );
713 assert!(json.contains("\"source\":\"auto\""), "json: {json}");
714 let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
715 }
716
717 #[test]
718 fn plan_mode_events_deserialize_without_optional_fields() {
719 let json = serde_json::json!({
720 "type": "plan_mode_entered",
721 "session_id": "sess-1",
722 "pre_permission_mode": "default",
723 "entered_at": "2025-01-01T00:00:00Z",
724 "status": "exploring"
725 });
726
727 let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
728 match event {
729 AgentEvent::PlanModeEntered {
730 session_id,
731 reason,
732 pre_permission_mode,
733 entered_at,
734 status,
735 plan_file_path,
736 } => {
737 assert_eq!(session_id, "sess-1");
738 assert_eq!(reason, None);
739 assert_eq!(pre_permission_mode, "default");
740 assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
741 assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
742 assert_eq!(plan_file_path, None);
743 }
744 other => panic!("unexpected event: {other:?}"),
745 }
746 }
747}