use crate::tools::ToolResult;
use bamboo_domain::{TaskItemStatus, TaskList};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentEvent {
Token {
content: String,
},
ReasoningToken {
content: String,
},
ToolToken {
tool_call_id: String,
content: String,
},
ToolStart {
tool_call_id: String,
tool_name: String,
arguments: serde_json::Value,
},
ToolComplete {
tool_call_id: String,
result: ToolResult,
},
ToolError {
tool_call_id: String,
error: String,
},
ToolLifecycle {
tool_call_id: String,
tool_name: String,
phase: String,
#[serde(skip_serializing_if = "Option::is_none")]
elapsed_ms: Option<u64>,
is_mutating: bool,
auto_approved: bool,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
NeedClarification {
question: String,
options: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_name: Option<String>,
#[serde(default = "default_allow_custom")]
allow_custom: bool,
},
TaskListUpdated {
task_list: TaskList,
},
TaskListItemProgress {
session_id: String,
item_id: String,
status: TaskItemStatus,
tool_calls_count: usize,
version: u64,
},
TaskListCompleted {
session_id: String,
completed_at: DateTime<Utc>,
total_rounds: u32,
total_tool_calls: usize,
},
TaskEvaluationStarted {
session_id: String,
items_count: usize,
},
TaskEvaluationCompleted {
session_id: String,
updates_count: usize,
reasoning: String,
},
GoldEvaluationStarted {
session_id: String,
checkpoint: GoldCheckpoint,
iteration: u32,
},
GoldEvaluationCompleted {
session_id: String,
checkpoint: GoldCheckpoint,
iteration: u32,
decision: GoldDecision,
confidence: GoldConfidence,
reasoning: String,
},
TokenBudgetUpdated {
usage: TokenBudgetUsage,
},
ContextCompressionStatus {
phase: String,
status: String,
},
ContextSummarized {
summary: String,
messages_summarized: usize,
tokens_saved: u32,
#[serde(default)]
usage_before_percent: f64,
#[serde(default)]
usage_after_percent: f64,
#[serde(default)]
trigger_type: String,
},
ContextPressureNotification {
percent: f64,
level: String,
message: String,
},
SubAgentStarted {
parent_session_id: String,
child_session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
},
SubAgentEvent {
parent_session_id: String,
child_session_id: String,
event: Box<AgentEvent>,
},
SubAgentHeartbeat {
parent_session_id: String,
child_session_id: String,
timestamp: DateTime<Utc>,
},
SubAgentCompleted {
parent_session_id: String,
child_session_id: String,
status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
PlanModeEntered {
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
pre_permission_mode: String,
entered_at: chrono::DateTime<chrono::Utc>,
status: bamboo_domain::PlanModeStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
plan_file_path: Option<String>,
},
PlanModeExited {
session_id: String,
approved: bool,
restored_mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
plan: Option<String>,
},
PlanFileUpdated {
session_id: String,
file_path: String,
content_summary: String,
},
RunnerProgress {
session_id: String,
round_count: u32,
},
SessionTitleUpdated {
session_id: String,
title: String,
title_version: u64,
source: TitleSource,
updated_at: chrono::DateTime<chrono::Utc>,
},
SessionPinnedUpdated {
session_id: String,
pinned: bool,
updated_at: chrono::DateTime<chrono::Utc>,
},
SessionCreated {
session_id: String,
title: String,
kind: bamboo_domain::SessionKind,
created_at: chrono::DateTime<chrono::Utc>,
},
SessionDeleted { session_id: String },
SessionCleared { session_id: String },
MessageAppended {
session_id: String,
message_id: String,
role: bamboo_domain::Role,
content: String,
created_at: chrono::DateTime<chrono::Utc>,
},
ExecutionStarted {
run_id: String,
session_id: String,
started_at: String,
},
ToolApprovalRequested {
tool_call_id: String,
tool_name: String,
parameters: serde_json::Value,
},
Complete {
usage: TokenUsage,
},
Cancelled {
#[serde(default, skip_serializing_if = "Option::is_none")]
message: Option<String>,
},
Error {
message: String,
},
}
impl AgentEvent {
pub fn session_id(&self) -> Option<&str> {
match self {
AgentEvent::TaskListUpdated { task_list } => Some(task_list.session_id.as_str()),
AgentEvent::TaskListItemProgress { session_id, .. }
| AgentEvent::TaskListCompleted { session_id, .. }
| AgentEvent::TaskEvaluationStarted { session_id, .. }
| AgentEvent::TaskEvaluationCompleted { session_id, .. }
| AgentEvent::GoldEvaluationStarted { session_id, .. }
| AgentEvent::GoldEvaluationCompleted { session_id, .. }
| AgentEvent::PlanModeEntered { session_id, .. }
| AgentEvent::PlanModeExited { session_id, .. }
| AgentEvent::PlanFileUpdated { session_id, .. }
| AgentEvent::RunnerProgress { session_id, .. }
| AgentEvent::SessionTitleUpdated { session_id, .. }
| AgentEvent::SessionPinnedUpdated { session_id, .. }
| AgentEvent::SessionCreated { session_id, .. }
| AgentEvent::SessionDeleted { session_id, .. }
| AgentEvent::SessionCleared { session_id, .. }
| AgentEvent::MessageAppended { session_id, .. }
| AgentEvent::ExecutionStarted { session_id, .. } => Some(session_id.as_str()),
AgentEvent::SubAgentStarted {
parent_session_id, ..
}
| AgentEvent::SubAgentEvent {
parent_session_id, ..
}
| AgentEvent::SubAgentHeartbeat {
parent_session_id, ..
}
| AgentEvent::SubAgentCompleted {
parent_session_id, ..
} => Some(parent_session_id.as_str()),
_ => None,
}
}
pub fn is_durable_change(&self) -> bool {
matches!(
self,
AgentEvent::MessageAppended { .. }
| AgentEvent::SessionCreated { .. }
| AgentEvent::SessionDeleted { .. }
| AgentEvent::SessionCleared { .. }
| AgentEvent::SessionTitleUpdated { .. }
| AgentEvent::SessionPinnedUpdated { .. }
| AgentEvent::TaskListUpdated { .. }
| AgentEvent::TaskListItemProgress { .. }
| AgentEvent::TaskListCompleted { .. }
| AgentEvent::TaskEvaluationCompleted { .. }
| AgentEvent::PlanModeEntered { .. }
| AgentEvent::PlanModeExited { .. }
| AgentEvent::PlanFileUpdated { .. }
| AgentEvent::SubAgentStarted { .. }
| AgentEvent::SubAgentCompleted { .. }
| AgentEvent::NeedClarification { .. }
| AgentEvent::ToolApprovalRequested { .. }
| AgentEvent::ExecutionStarted { .. }
| AgentEvent::Complete { .. }
| AgentEvent::Cancelled { .. }
| AgentEvent::Error { .. }
)
}
}
fn default_allow_custom() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoldCheckpoint {
PostRound,
Terminal,
}
impl GoldCheckpoint {
pub fn as_str(self) -> &'static str {
match self {
Self::PostRound => "post_round",
Self::Terminal => "terminal",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoldDecision {
Continue,
Achieved,
Blocked,
NeedInput,
Exhausted,
}
impl GoldDecision {
pub fn as_str(self) -> &'static str {
match self {
Self::Continue => "continue",
Self::Achieved => "achieved",
Self::Blocked => "blocked",
Self::NeedInput => "need_input",
Self::Exhausted => "exhausted",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoldConfidence {
Low,
Medium,
High,
}
impl GoldConfidence {
pub fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
}
}
pub fn rank(self) -> u8 {
match self {
Self::Low => 0,
Self::Medium => 1,
Self::High => 2,
}
}
pub fn meets(self, floor: GoldConfidence) -> bool {
self.rank() >= floor.rank()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TitleSource {
Auto,
Manual,
Fallback,
}
pub use bamboo_domain::TokenUsage;
pub use bamboo_domain::budget_types::TokenBudgetUsage;
#[cfg(test)]
mod tests {
use super::*;
use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
fn sample_task_list() -> TaskList {
TaskList {
session_id: "session-1".to_string(),
title: "Task List".to_string(),
items: vec![TaskItem {
id: "task_1".to_string(),
description: "Implement event rename".to_string(),
status: TaskItemStatus::InProgress,
depends_on: Vec::new(),
notes: "Implementing".to_string(),
..TaskItem::default()
}],
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn task_list_updated_serializes_with_task_names() {
let event = AgentEvent::TaskListUpdated {
task_list: sample_task_list(),
};
let value = serde_json::to_value(event).expect("event should serialize");
assert_eq!(value["type"], "task_list_updated");
assert!(value.get("task_list").is_some());
assert!(value.get("todo_list").is_none());
}
#[test]
fn cancelled_serializes_with_snake_case_type() {
let event = AgentEvent::Cancelled {
message: Some("Agent execution cancelled by user".to_string()),
};
let value = serde_json::to_value(event).expect("event should serialize");
assert_eq!(value["type"], "cancelled");
assert_eq!(
value["message"],
serde_json::Value::String("Agent execution cancelled by user".to_string())
);
}
#[test]
fn task_evaluation_completed_serializes_with_task_type() {
let event = AgentEvent::TaskEvaluationCompleted {
session_id: "session-1".to_string(),
updates_count: 2,
reasoning: "Updated statuses".to_string(),
};
let value = serde_json::to_value(event).expect("event should serialize");
assert_eq!(value["type"], "task_evaluation_completed");
}
#[test]
fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
let event = AgentEvent::GoldEvaluationCompleted {
session_id: "session-1".to_string(),
checkpoint: GoldCheckpoint::PostRound,
iteration: 3,
decision: GoldDecision::Continue,
confidence: GoldConfidence::Medium,
reasoning: "Need one more iteration".to_string(),
};
let value = serde_json::to_value(event).expect("event should serialize");
assert_eq!(value["type"], "gold_evaluation_completed");
assert_eq!(value["checkpoint"], "post_round");
assert_eq!(value["iteration"], 3);
assert_eq!(value["decision"], "continue");
assert_eq!(value["confidence"], "medium");
assert_eq!(value["reasoning"], "Need one more iteration");
}
#[test]
fn gold_evaluation_started_deserializes() {
let json = serde_json::json!({
"type": "gold_evaluation_started",
"session_id": "session-1",
"checkpoint": "terminal",
"iteration": 7
});
let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
match event {
AgentEvent::GoldEvaluationStarted {
session_id,
checkpoint,
iteration,
} => {
assert_eq!(session_id, "session-1");
assert_eq!(checkpoint, GoldCheckpoint::Terminal);
assert_eq!(iteration, 7);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn context_compression_status_serializes_with_phase_and_status() {
let event = AgentEvent::ContextCompressionStatus {
phase: "mid-turn".to_string(),
status: "started".to_string(),
};
let value = serde_json::to_value(event).expect("event should serialize");
assert_eq!(value["type"], "context_compression_status");
assert_eq!(value["phase"], "mid-turn");
assert_eq!(value["status"], "started");
}
#[test]
fn need_clarification_serializes_with_new_fields() {
let event = AgentEvent::NeedClarification {
question: "Continue?".to_string(),
options: Some(vec!["Yes".to_string(), "No".to_string()]),
tool_call_id: Some("tool-1".to_string()),
tool_name: Some("conclusion_with_options".to_string()),
allow_custom: false,
};
let value = serde_json::to_value(event).expect("event should serialize");
assert_eq!(value["type"], "need_clarification");
assert_eq!(value["question"], "Continue?");
assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
assert_eq!(value["tool_call_id"], "tool-1");
assert_eq!(value["tool_name"], "conclusion_with_options");
assert_eq!(value["allow_custom"], false);
}
#[test]
fn need_clarification_deserializes_from_old_format_without_new_fields() {
let json = serde_json::json!({
"type": "need_clarification",
"question": "Continue?",
"options": ["Yes", "No"]
});
let event: AgentEvent =
serde_json::from_value(json).expect("should deserialize old format");
match event {
AgentEvent::NeedClarification {
question,
options,
tool_call_id,
tool_name,
allow_custom,
} => {
assert_eq!(question, "Continue?");
assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
assert_eq!(tool_call_id, None);
assert_eq!(tool_name, None);
assert!(allow_custom); }
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn need_clarification_deserializes_with_allow_custom_false() {
let json = serde_json::json!({
"type": "need_clarification",
"question": "Pick one",
"allow_custom": false
});
let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
match event {
AgentEvent::NeedClarification {
question,
options,
tool_call_id,
tool_name,
allow_custom,
} => {
assert_eq!(question, "Pick one");
assert_eq!(options, None);
assert_eq!(tool_call_id, None);
assert_eq!(tool_name, None);
assert!(!allow_custom);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn plan_mode_entered_serializes_correctly() {
let entered_at = Utc::now();
let event = AgentEvent::PlanModeEntered {
session_id: "sess-1".to_string(),
reason: Some("Complex refactor".to_string()),
pre_permission_mode: "default".to_string(),
entered_at,
status: bamboo_domain::PlanModeStatus::Exploring,
plan_file_path: None,
};
let value = serde_json::to_value(event).expect("event should serialize");
assert_eq!(value["type"], "plan_mode_entered");
assert_eq!(value["session_id"], "sess-1");
assert_eq!(value["reason"], "Complex refactor");
assert_eq!(value["pre_permission_mode"], "default");
assert_eq!(value["status"], "exploring");
assert_eq!(value["entered_at"], serde_json::to_value(entered_at).unwrap());
}
#[test]
fn plan_mode_exited_serializes_correctly() {
let event = AgentEvent::PlanModeExited {
session_id: "sess-1".to_string(),
approved: true,
restored_mode: "accept_edits".to_string(),
plan: Some("# Plan\n1. Step one".to_string()),
};
let value = serde_json::to_value(event).expect("event should serialize");
assert_eq!(value["type"], "plan_mode_exited");
assert_eq!(value["session_id"], "sess-1");
assert_eq!(value["approved"], true);
assert_eq!(value["restored_mode"], "accept_edits");
assert_eq!(value["plan"], "# Plan\n1. Step one");
}
#[test]
fn plan_file_updated_serializes_correctly() {
let event = AgentEvent::PlanFileUpdated {
session_id: "sess-1".to_string(),
file_path: "/tmp/plans/sess-1.md".to_string(),
content_summary: "Implementation plan for feature X".to_string(),
};
let value = serde_json::to_value(event).expect("event should serialize");
assert_eq!(value["type"], "plan_file_updated");
assert_eq!(value["session_id"], "sess-1");
assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
assert_eq!(
value["content_summary"],
"Implementation plan for feature X"
);
}
#[test]
fn tool_approval_requested_serializes_correctly() {
let event = AgentEvent::ToolApprovalRequested {
tool_call_id: "call-abc".to_string(),
tool_name: "Write".to_string(),
parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
};
let value = serde_json::to_value(event).expect("event should serialize");
assert_eq!(value["type"], "tool_approval_requested");
assert_eq!(value["tool_call_id"], "call-abc");
assert_eq!(value["tool_name"], "Write");
assert_eq!(
value["parameters"],
serde_json::json!({"file_path": "/tmp/test.txt"})
);
}
#[test]
fn tool_approval_requested_deserializes_correctly() {
let json = serde_json::json!({
"type": "tool_approval_requested",
"tool_call_id": "call-xyz",
"tool_name": "Bash",
"parameters": {"command": "ls -la"}
});
let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
match event {
AgentEvent::ToolApprovalRequested {
tool_call_id,
tool_name,
parameters,
} => {
assert_eq!(tool_call_id, "call-xyz");
assert_eq!(tool_name, "Bash");
assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
}
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn session_title_updated_round_trips_with_source_variants() {
use chrono::Utc;
let event = AgentEvent::SessionTitleUpdated {
session_id: "sess-1".to_string(),
title: "My title".to_string(),
title_version: 3,
source: TitleSource::Auto,
updated_at: Utc::now(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(
json.contains("\"type\":\"session_title_updated\""),
"json: {json}"
);
assert!(json.contains("\"source\":\"auto\""), "json: {json}");
let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
}
#[test]
fn plan_mode_events_deserialize_without_optional_fields() {
let json = serde_json::json!({
"type": "plan_mode_entered",
"session_id": "sess-1",
"pre_permission_mode": "default",
"entered_at": "2025-01-01T00:00:00Z",
"status": "exploring"
});
let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
match event {
AgentEvent::PlanModeEntered {
session_id,
reason,
pre_permission_mode,
entered_at,
status,
plan_file_path,
} => {
assert_eq!(session_id, "sess-1");
assert_eq!(reason, None);
assert_eq!(pre_permission_mode, "default");
assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
assert_eq!(plan_file_path, None);
}
other => panic!("unexpected event: {other:?}"),
}
}
}