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 = "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,
},
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,
},
SubSessionStarted {
parent_session_id: String,
child_session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
},
SubSessionEvent {
parent_session_id: String,
child_session_id: String,
event: Box<AgentEvent>,
},
SubSessionHeartbeat {
parent_session_id: String,
child_session_id: String,
timestamp: DateTime<Utc>,
},
SubSessionCompleted {
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,
},
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,
},
Complete {
usage: TokenUsage,
},
Error {
message: String,
},
}
fn default_allow_custom() -> bool {
true
}
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 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 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()),
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["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,
allow_custom,
} => {
assert_eq!(question, "Continue?");
assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
assert_eq!(tool_call_id, 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,
allow_custom,
} => {
assert_eq!(question, "Pick one");
assert_eq!(options, None);
assert_eq!(tool_call_id, None);
assert!(!allow_custom);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn plan_mode_entered_serializes_correctly() {
let event = AgentEvent::PlanModeEntered {
session_id: "sess-1".to_string(),
reason: Some("Complex refactor".to_string()),
pre_permission_mode: "default".to_string(),
};
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");
}
#[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 plan_mode_events_deserialize_without_optional_fields() {
let json = serde_json::json!({
"type": "plan_mode_entered",
"session_id": "sess-1",
"pre_permission_mode": "default"
});
let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
match event {
AgentEvent::PlanModeEntered {
session_id,
reason,
pre_permission_mode,
} => {
assert_eq!(session_id, "sess-1");
assert_eq!(reason, None);
assert_eq!(pre_permission_mode, "default");
}
other => panic!("unexpected event: {other:?}"),
}
}
}