Skip to main content

bamboo_agent_core/agent/
events.rs

1//! Agent event system for real-time streaming.
2//!
3//! This module defines the event types emitted during agent execution,
4//! which are streamed to clients via Server-Sent Events (SSE).
5//!
6//! # Event Types
7//!
8//! - [`AgentEvent`] - All possible agent execution events
9//! - [`TokenUsage`] - Token consumption statistics
10//! - [`TokenBudgetUsage`] - Detailed token budget information
11//!
12//! # Event Flow
13//!
14//! 1. **Token** events stream generated text
15//! 2. **ToolStart/ToolComplete** track tool execution
16//! 3. **TaskListUpdated** tracks progress
17//! 4. **TokenBudgetUpdated** reports context management
18//! 5. **Complete**, **Cancelled**, or **Error** ends the stream
19//!
20//! # Example
21//!
22//! ```javascript
23//! const eventSource = new EventSource('/api/v1/events/session-id');
24//! eventSource.onmessage = (event) => {
25//!   const data = JSON.parse(event.data);
26//!   switch (data.type) {
27//!     case 'token':
28//!       console.log('Token:', data.content);
29//!       break;
30//!     case 'complete':
31//!       console.log('Done!');
32//!       eventSource.close();
33//!       break;
34//!   }
35//! };
36//! ```
37
38use crate::tools::ToolResult;
39use bamboo_domain::{TaskItemStatus, TaskList};
40use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42
43/// Represents events emitted during agent execution.
44///
45/// These events are streamed to clients via SSE to provide real-time
46/// feedback on agent progress, tool execution, and completion.
47///
48/// # Variants
49///
50/// ## Text Generation
51/// - `Token` - Streaming text token
52/// - `ReasoningToken` - Streaming reasoning/thinking token (separate channel)
53///
54/// ## Tool Execution
55/// - `ToolStart` - Tool execution started
56/// - `ToolComplete` - Tool finished successfully
57/// - `ToolError` - Tool execution failed
58///
59/// ## User Interaction
60/// - `NeedClarification` - Agent needs user input
61///
62/// ## Progress Tracking
63/// - `TaskListUpdated` - Task list created or modified
64/// - `TaskListItemProgress` - Individual item progress
65/// - `TaskListCompleted` - All items completed
66/// - `TaskEvaluationStarted` - Task evaluation began
67/// - `TaskEvaluationCompleted` - Task evaluation finished
68/// - `GoldEvaluationStarted` - Gold observe-only evaluation began
69/// - `GoldEvaluationCompleted` - Gold observe-only evaluation finished
70///
71/// ## Context Management
72/// - `TokenBudgetUpdated` - Context budget changed
73/// - `ContextCompressionStatus` - Context compression lifecycle progress
74/// - `ContextSummarized` - Old messages summarized
75///
76/// ## Sub-agents (Async Spawn)
77/// - `SubAgentStarted` - A child session is created and scheduled to run
78/// - `SubAgentEvent` - Forwarded raw child event (full fidelity)
79/// - `SubAgentHeartbeat` - Periodic heartbeat while the child is running
80/// - `SubAgentCompleted` - Child session finished (completed/cancelled/error)
81///
82/// ## Terminal Events
83/// - `Complete` - Execution finished successfully
84/// - `Cancelled` - Execution was cancelled by the user
85/// - `Error` - Execution failed
86///
87/// # Serialization
88///
89/// Events are serialized as JSON with a `type` field for discrimination:
90/// ```json
91/// {"type": "token", "content": "Hello"}
92/// {"type": "complete", "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
93/// {"type": "cancelled", "message": "Agent execution cancelled by user"}
94/// ```
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[serde(tag = "type", rename_all = "snake_case")]
97pub enum AgentEvent {
98    /// Text token generated by the LLM.
99    Token {
100        /// Generated text content
101        content: String,
102    },
103
104    /// Reasoning/thinking token generated by the LLM.
105    ///
106    /// This is streamed separately from assistant answer tokens so the UI can
107    /// choose whether and how to display model reasoning traces.
108    ReasoningToken {
109        /// Generated reasoning content
110        content: String,
111    },
112
113    /// Streaming output emitted while a specific tool call is running.
114    ///
115    /// This is used to render "live output" inside a tool-call card in the UI
116    /// without mixing tool output into the assistant's main token stream.
117    ToolToken {
118        /// Tool call identifier that this output belongs to.
119        tool_call_id: String,
120        /// Output chunk.
121        content: String,
122    },
123
124    /// Tool execution started.
125    ToolStart {
126        /// Unique tool call identifier
127        tool_call_id: String,
128        /// Name of the tool being executed
129        tool_name: String,
130        /// Tool arguments (JSON)
131        arguments: serde_json::Value,
132    },
133
134    /// Tool execution completed successfully.
135    ToolComplete {
136        /// Tool call identifier
137        tool_call_id: String,
138        /// Tool execution result
139        result: ToolResult,
140    },
141
142    /// Tool execution failed.
143    ToolError {
144        /// Tool call identifier
145        tool_call_id: String,
146        /// Error message
147        error: String,
148    },
149
150    /// Structured lifecycle event for tool execution tracking.
151    ///
152    /// These events complement `ToolStart`/`ToolComplete`/`ToolError` with
153    /// richer metadata (mutability, auto-approval, wall-clock timing) and
154    /// are emitted by `ToolEmitter` (in `bamboo-agent-tools`).
155    ToolLifecycle {
156        /// Tool call identifier
157        tool_call_id: String,
158        /// Canonical tool name
159        tool_name: String,
160        /// Lifecycle phase: "begin", "finished", "error", "cancelled"
161        phase: String,
162        /// Wall-clock milliseconds since the call began (None for begin)
163        #[serde(skip_serializing_if = "Option::is_none")]
164        elapsed_ms: Option<u64>,
165        /// Whether the tool mutates state (writes files, runs commands)
166        is_mutating: bool,
167        /// Whether execution was auto-approved (no user prompt needed)
168        auto_approved: bool,
169        /// Human-readable summary
170        #[serde(skip_serializing_if = "Option::is_none")]
171        summary: Option<String>,
172        /// Error message (if phase == "error")
173        #[serde(skip_serializing_if = "Option::is_none")]
174        error: Option<String>,
175    },
176
177    /// Agent needs clarification from the user.
178    NeedClarification {
179        /// Question to ask the user
180        question: String,
181        /// Optional predefined options
182        options: Option<Vec<String>>,
183        /// Tool call identifier that triggered this clarification
184        #[serde(default, skip_serializing_if = "Option::is_none")]
185        tool_call_id: Option<String>,
186        /// Tool name that triggered this clarification, when known.
187        #[serde(default, skip_serializing_if = "Option::is_none")]
188        tool_name: Option<String>,
189        /// Whether the user can provide a free-text response
190        #[serde(default = "default_allow_custom")]
191        allow_custom: bool,
192    },
193
194    /// Emitted when task list is created or updated.
195    TaskListUpdated {
196        /// Current task list state.
197        task_list: TaskList,
198    },
199
200    /// Emitted when a task item makes progress (delta update).
201    TaskListItemProgress {
202        /// Session identifier
203        session_id: String,
204        /// Item identifier
205        item_id: String,
206        /// New item status
207        status: TaskItemStatus,
208        /// Number of tool calls made
209        tool_calls_count: usize,
210        /// Item version (for optimistic concurrency)
211        version: u64,
212    },
213
214    /// Emitted when all task items are completed.
215    TaskListCompleted {
216        /// Session identifier
217        session_id: String,
218        /// Completion timestamp
219        completed_at: DateTime<Utc>,
220        /// Total agent rounds executed
221        total_rounds: u32,
222        /// Total tool calls made
223        total_tool_calls: usize,
224    },
225
226    /// Emitted when task evaluation starts.
227    TaskEvaluationStarted {
228        /// Session identifier
229        session_id: String,
230        /// Number of items to evaluate
231        items_count: usize,
232    },
233
234    /// Emitted when task evaluation completes.
235    TaskEvaluationCompleted {
236        /// Session identifier
237        session_id: String,
238        /// Number of items updated
239        updates_count: usize,
240        /// Evaluation reasoning
241        reasoning: String,
242    },
243
244    /// Emitted when gold observe-only evaluation starts.
245    GoldEvaluationStarted {
246        /// Session identifier
247        session_id: String,
248        /// Evaluation checkpoint
249        checkpoint: GoldCheckpoint,
250        /// Current iteration / round number associated with the evaluation
251        iteration: u32,
252    },
253
254    /// Emitted when gold observe-only evaluation completes.
255    GoldEvaluationCompleted {
256        /// Session identifier
257        session_id: String,
258        /// Evaluation checkpoint
259        checkpoint: GoldCheckpoint,
260        /// Current iteration / round number associated with the evaluation
261        iteration: u32,
262        /// Gold decision for the current checkpoint
263        decision: GoldDecision,
264        /// Confidence in the decision
265        confidence: GoldConfidence,
266        /// Short reasoning summary
267        reasoning: String,
268    },
269
270    /// Emitted when token budget is prepared (after context truncation)
271    TokenBudgetUpdated {
272        /// Token budget details
273        usage: TokenBudgetUsage,
274    },
275
276    /// Emitted when host-side context compression lifecycle changes.
277    ContextCompressionStatus {
278        /// Compression phase label (for example: pre-turn, mid-turn).
279        phase: String,
280        /// Compression status: started | completed | failed | skipped
281        status: String,
282    },
283
284    /// Emitted when conversation context is summarized
285    ContextSummarized {
286        /// Generated summary text
287        summary: String,
288        /// Number of old messages summarized
289        messages_summarized: usize,
290        /// Tokens saved by summarization
291        tokens_saved: u32,
292        /// Context usage percentage before compression
293        #[serde(default)]
294        usage_before_percent: f64,
295        /// Context usage percentage after compression
296        #[serde(default)]
297        usage_after_percent: f64,
298        /// What triggered the compression: "auto" | "manual" | "critical"
299        #[serde(default)]
300        trigger_type: String,
301    },
302
303    /// Emitted when context pressure reaches warning or critical levels.
304    /// Frontend should display this to the user as a proactive notification.
305    ContextPressureNotification {
306        /// Context usage as a percentage of the context window.
307        percent: f64,
308        /// Severity level: "warning" (70%) or "critical" (90%).
309        level: String,
310        /// Human-readable message describing the pressure state.
311        message: String,
312    },
313
314    /// A child session was spawned from a parent session (async background job).
315    SubAgentStarted {
316        parent_session_id: String,
317        child_session_id: String,
318        /// Optional title (useful for UI lists).
319        #[serde(default, skip_serializing_if = "Option::is_none")]
320        title: Option<String>,
321    },
322
323    /// Forwarded raw child event to the parent session stream.
324    ///
325    /// Child sessions are not allowed to spawn further sessions, so this should not nest.
326    SubAgentEvent {
327        parent_session_id: String,
328        child_session_id: String,
329        event: Box<AgentEvent>,
330    },
331
332    /// Heartbeat emitted while a child session is running.
333    SubAgentHeartbeat {
334        parent_session_id: String,
335        child_session_id: String,
336        timestamp: DateTime<Utc>,
337    },
338
339    /// Child session finished (completed/cancelled/error).
340    SubAgentCompleted {
341        parent_session_id: String,
342        child_session_id: String,
343        /// One of: "completed" | "cancelled" | "error" | "skipped"
344        status: String,
345        #[serde(default, skip_serializing_if = "Option::is_none")]
346        error: Option<String>,
347    },
348
349    /// Plan mode was entered.
350    PlanModeEntered {
351        /// Session identifier
352        session_id: String,
353        /// Optional reason for entering plan mode
354        #[serde(default, skip_serializing_if = "Option::is_none")]
355        reason: Option<String>,
356        /// Previous permission mode before entering plan mode
357        pre_permission_mode: String,
358        /// RFC3339 timestamp when plan mode was entered.
359        entered_at: chrono::DateTime<chrono::Utc>,
360        /// Current plan mode phase/status.
361        status: bamboo_domain::PlanModeStatus,
362        /// Path to the persisted plan file, if already available.
363        #[serde(default, skip_serializing_if = "Option::is_none")]
364        plan_file_path: Option<String>,
365    },
366
367    /// Plan mode was exited.
368    PlanModeExited {
369        /// Session identifier
370        session_id: String,
371        /// Whether the exit was approved by the user
372        approved: bool,
373        /// The permission mode restored after exiting
374        restored_mode: String,
375        /// Plan content that was reviewed, if any
376        #[serde(default, skip_serializing_if = "Option::is_none")]
377        plan: Option<String>,
378    },
379
380    /// Plan file was updated.
381    PlanFileUpdated {
382        /// Session identifier
383        session_id: String,
384        /// Path to the plan file
385        file_path: String,
386        /// Summary of the plan content (truncated)
387        content_summary: String,
388    },
389
390    /// Runner progress update emitted at the start of each agent turn.
391    ///
392    /// Used to track live execution progress (round count, current activity)
393    /// for diagnostic visibility, especially for child sessions.
394    RunnerProgress {
395        /// Session identifier
396        session_id: String,
397        /// Current turn/round count
398        round_count: u32,
399    },
400
401    /// Session title was updated (auto-generated by backend or manually renamed via PATCH).
402    SessionTitleUpdated {
403        session_id: String,
404        title: String,
405        title_version: u64,
406        source: TitleSource,
407        updated_at: chrono::DateTime<chrono::Utc>,
408    },
409
410    /// Session pinned flag was toggled via PATCH.
411    ///
412    /// Replayable metadata event. `pinned` is an idempotent boolean so the
413    /// latest event wins; `updated_at` is used by the frontend to suppress
414    /// stale replays.
415    SessionPinnedUpdated {
416        session_id: String,
417        pinned: bool,
418        updated_at: chrono::DateTime<chrono::Utc>,
419    },
420
421    /// Execution run has started and the runner is now active.
422    ///
423    /// Emitted as the first event after a runner reservation succeeds,
424    /// before any token or tool events. Carries the `run_id` so the
425    /// frontend can correlate subsequent SSE events across reconnects.
426    ExecutionStarted {
427        /// Unique identifier for this execution run.
428        run_id: String,
429        /// Session identifier.
430        session_id: String,
431        /// ISO 8601 timestamp when the run started.
432        started_at: String,
433    },
434
435    /// Tool execution requires user approval before proceeding.
436    ///
437    /// Emitted when a permission checker determines that a tool call needs
438    /// explicit user confirmation (e.g., mutating operations in restricted
439    /// permission mode). The frontend should present the approval request and
440    /// either grant or deny it.
441    ToolApprovalRequested {
442        /// Unique identifier for the tool call awaiting approval.
443        tool_call_id: String,
444        /// Name of the tool being executed.
445        tool_name: String,
446        /// Parameters that were passed to the tool.
447        parameters: serde_json::Value,
448    },
449
450    /// Agent execution completed successfully.
451    Complete {
452        /// Final token usage statistics
453        usage: TokenUsage,
454    },
455
456    /// Agent execution was cancelled.
457    Cancelled {
458        /// Optional human-readable message explaining the cancellation.
459        #[serde(default, skip_serializing_if = "Option::is_none")]
460        message: Option<String>,
461    },
462
463    /// Agent execution failed.
464    Error {
465        /// Error message
466        message: String,
467    },
468}
469
470fn default_allow_custom() -> bool {
471    true
472}
473
474/// Gold evaluation checkpoint.
475#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
476#[serde(rename_all = "snake_case")]
477pub enum GoldCheckpoint {
478    PostRound,
479    Terminal,
480}
481
482impl GoldCheckpoint {
483    pub fn as_str(self) -> &'static str {
484        match self {
485            Self::PostRound => "post_round",
486            Self::Terminal => "terminal",
487        }
488    }
489}
490
491/// Gold evaluator decision.
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
493#[serde(rename_all = "snake_case")]
494pub enum GoldDecision {
495    Continue,
496    Achieved,
497    Blocked,
498    NeedInput,
499    Exhausted,
500}
501
502impl GoldDecision {
503    pub fn as_str(self) -> &'static str {
504        match self {
505            Self::Continue => "continue",
506            Self::Achieved => "achieved",
507            Self::Blocked => "blocked",
508            Self::NeedInput => "need_input",
509            Self::Exhausted => "exhausted",
510        }
511    }
512}
513
514/// Confidence level for a Gold evaluation result.
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(rename_all = "snake_case")]
517pub enum GoldConfidence {
518    Low,
519    Medium,
520    High,
521}
522
523impl GoldConfidence {
524    pub fn as_str(self) -> &'static str {
525        match self {
526            Self::Low => "low",
527            Self::Medium => "medium",
528            Self::High => "high",
529        }
530    }
531
532    /// Ordinal rank for threshold comparisons (`Low` < `Medium` < `High`).
533    pub fn rank(self) -> u8 {
534        match self {
535            Self::Low => 0,
536            Self::Medium => 1,
537            Self::High => 2,
538        }
539    }
540
541    /// Whether this confidence meets or exceeds the given floor.
542    pub fn meets(self, floor: GoldConfidence) -> bool {
543        self.rank() >= floor.rank()
544    }
545}
546
547/// Source that triggered a session title update.
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
549#[serde(rename_all = "snake_case")]
550pub enum TitleSource {
551    Auto,
552    Manual,
553    Fallback,
554}
555
556/// Re-exported shared token usage type.
557///
558/// See [`bamboo_domain::TokenUsage`] for the canonical definition.
559pub use bamboo_domain::TokenUsage;
560
561pub use bamboo_domain::budget_types::TokenBudgetUsage;
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
567
568    fn sample_task_list() -> TaskList {
569        TaskList {
570            session_id: "session-1".to_string(),
571            title: "Task List".to_string(),
572            items: vec![TaskItem {
573                id: "task_1".to_string(),
574                description: "Implement event rename".to_string(),
575                status: TaskItemStatus::InProgress,
576                depends_on: Vec::new(),
577                notes: "Implementing".to_string(),
578                ..TaskItem::default()
579            }],
580            created_at: Utc::now(),
581            updated_at: Utc::now(),
582        }
583    }
584
585    #[test]
586    fn task_list_updated_serializes_with_task_names() {
587        let event = AgentEvent::TaskListUpdated {
588            task_list: sample_task_list(),
589        };
590
591        let value = serde_json::to_value(event).expect("event should serialize");
592        assert_eq!(value["type"], "task_list_updated");
593        assert!(value.get("task_list").is_some());
594        assert!(value.get("todo_list").is_none());
595    }
596
597    #[test]
598    fn cancelled_serializes_with_snake_case_type() {
599        let event = AgentEvent::Cancelled {
600            message: Some("Agent execution cancelled by user".to_string()),
601        };
602
603        let value = serde_json::to_value(event).expect("event should serialize");
604        assert_eq!(value["type"], "cancelled");
605        assert_eq!(
606            value["message"],
607            serde_json::Value::String("Agent execution cancelled by user".to_string())
608        );
609    }
610
611    #[test]
612    fn task_evaluation_completed_serializes_with_task_type() {
613        let event = AgentEvent::TaskEvaluationCompleted {
614            session_id: "session-1".to_string(),
615            updates_count: 2,
616            reasoning: "Updated statuses".to_string(),
617        };
618
619        let value = serde_json::to_value(event).expect("event should serialize");
620        assert_eq!(value["type"], "task_evaluation_completed");
621    }
622
623    #[test]
624    fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
625        let event = AgentEvent::GoldEvaluationCompleted {
626            session_id: "session-1".to_string(),
627            checkpoint: GoldCheckpoint::PostRound,
628            iteration: 3,
629            decision: GoldDecision::Continue,
630            confidence: GoldConfidence::Medium,
631            reasoning: "Need one more iteration".to_string(),
632        };
633
634        let value = serde_json::to_value(event).expect("event should serialize");
635        assert_eq!(value["type"], "gold_evaluation_completed");
636        assert_eq!(value["checkpoint"], "post_round");
637        assert_eq!(value["iteration"], 3);
638        assert_eq!(value["decision"], "continue");
639        assert_eq!(value["confidence"], "medium");
640        assert_eq!(value["reasoning"], "Need one more iteration");
641    }
642
643    #[test]
644    fn gold_evaluation_started_deserializes() {
645        let json = serde_json::json!({
646            "type": "gold_evaluation_started",
647            "session_id": "session-1",
648            "checkpoint": "terminal",
649            "iteration": 7
650        });
651
652        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
653        match event {
654            AgentEvent::GoldEvaluationStarted {
655                session_id,
656                checkpoint,
657                iteration,
658            } => {
659                assert_eq!(session_id, "session-1");
660                assert_eq!(checkpoint, GoldCheckpoint::Terminal);
661                assert_eq!(iteration, 7);
662            }
663            other => panic!("unexpected event: {other:?}"),
664        }
665    }
666
667    #[test]
668    fn context_compression_status_serializes_with_phase_and_status() {
669        let event = AgentEvent::ContextCompressionStatus {
670            phase: "mid-turn".to_string(),
671            status: "started".to_string(),
672        };
673
674        let value = serde_json::to_value(event).expect("event should serialize");
675        assert_eq!(value["type"], "context_compression_status");
676        assert_eq!(value["phase"], "mid-turn");
677        assert_eq!(value["status"], "started");
678    }
679
680    #[test]
681    fn need_clarification_serializes_with_new_fields() {
682        let event = AgentEvent::NeedClarification {
683            question: "Continue?".to_string(),
684            options: Some(vec!["Yes".to_string(), "No".to_string()]),
685            tool_call_id: Some("tool-1".to_string()),
686            tool_name: Some("conclusion_with_options".to_string()),
687            allow_custom: false,
688        };
689
690        let value = serde_json::to_value(event).expect("event should serialize");
691        assert_eq!(value["type"], "need_clarification");
692        assert_eq!(value["question"], "Continue?");
693        assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
694        assert_eq!(value["tool_call_id"], "tool-1");
695        assert_eq!(value["tool_name"], "conclusion_with_options");
696        assert_eq!(value["allow_custom"], false);
697    }
698
699    #[test]
700    fn need_clarification_deserializes_from_old_format_without_new_fields() {
701        let json = serde_json::json!({
702            "type": "need_clarification",
703            "question": "Continue?",
704            "options": ["Yes", "No"]
705        });
706
707        let event: AgentEvent =
708            serde_json::from_value(json).expect("should deserialize old format");
709        match event {
710            AgentEvent::NeedClarification {
711                question,
712                options,
713                tool_call_id,
714                tool_name,
715                allow_custom,
716            } => {
717                assert_eq!(question, "Continue?");
718                assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
719                assert_eq!(tool_call_id, None);
720                assert_eq!(tool_name, None);
721                assert!(allow_custom); // default_allow_custom returns true
722            }
723            other => panic!("unexpected event: {other:?}"),
724        }
725    }
726
727    #[test]
728    fn need_clarification_deserializes_with_allow_custom_false() {
729        let json = serde_json::json!({
730            "type": "need_clarification",
731            "question": "Pick one",
732            "allow_custom": false
733        });
734
735        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
736        match event {
737            AgentEvent::NeedClarification {
738                question,
739                options,
740                tool_call_id,
741                tool_name,
742                allow_custom,
743            } => {
744                assert_eq!(question, "Pick one");
745                assert_eq!(options, None);
746                assert_eq!(tool_call_id, None);
747                assert_eq!(tool_name, None);
748                assert!(!allow_custom);
749            }
750            other => panic!("unexpected event: {other:?}"),
751        }
752    }
753
754    #[test]
755    fn plan_mode_entered_serializes_correctly() {
756        let entered_at = Utc::now();
757        let event = AgentEvent::PlanModeEntered {
758            session_id: "sess-1".to_string(),
759            reason: Some("Complex refactor".to_string()),
760            pre_permission_mode: "default".to_string(),
761            entered_at,
762            status: bamboo_domain::PlanModeStatus::Exploring,
763            plan_file_path: None,
764        };
765
766        let value = serde_json::to_value(event).expect("event should serialize");
767        assert_eq!(value["type"], "plan_mode_entered");
768        assert_eq!(value["session_id"], "sess-1");
769        assert_eq!(value["reason"], "Complex refactor");
770        assert_eq!(value["pre_permission_mode"], "default");
771        assert_eq!(value["status"], "exploring");
772        assert_eq!(value["entered_at"], entered_at.to_rfc3339());
773    }
774
775    #[test]
776    fn plan_mode_exited_serializes_correctly() {
777        let event = AgentEvent::PlanModeExited {
778            session_id: "sess-1".to_string(),
779            approved: true,
780            restored_mode: "accept_edits".to_string(),
781            plan: Some("# Plan\n1. Step one".to_string()),
782        };
783
784        let value = serde_json::to_value(event).expect("event should serialize");
785        assert_eq!(value["type"], "plan_mode_exited");
786        assert_eq!(value["session_id"], "sess-1");
787        assert_eq!(value["approved"], true);
788        assert_eq!(value["restored_mode"], "accept_edits");
789        assert_eq!(value["plan"], "# Plan\n1. Step one");
790    }
791
792    #[test]
793    fn plan_file_updated_serializes_correctly() {
794        let event = AgentEvent::PlanFileUpdated {
795            session_id: "sess-1".to_string(),
796            file_path: "/tmp/plans/sess-1.md".to_string(),
797            content_summary: "Implementation plan for feature X".to_string(),
798        };
799
800        let value = serde_json::to_value(event).expect("event should serialize");
801        assert_eq!(value["type"], "plan_file_updated");
802        assert_eq!(value["session_id"], "sess-1");
803        assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
804        assert_eq!(
805            value["content_summary"],
806            "Implementation plan for feature X"
807        );
808    }
809
810    #[test]
811    fn tool_approval_requested_serializes_correctly() {
812        let event = AgentEvent::ToolApprovalRequested {
813            tool_call_id: "call-abc".to_string(),
814            tool_name: "Write".to_string(),
815            parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
816        };
817
818        let value = serde_json::to_value(event).expect("event should serialize");
819        assert_eq!(value["type"], "tool_approval_requested");
820        assert_eq!(value["tool_call_id"], "call-abc");
821        assert_eq!(value["tool_name"], "Write");
822        assert_eq!(
823            value["parameters"],
824            serde_json::json!({"file_path": "/tmp/test.txt"})
825        );
826    }
827
828    #[test]
829    fn tool_approval_requested_deserializes_correctly() {
830        let json = serde_json::json!({
831            "type": "tool_approval_requested",
832            "tool_call_id": "call-xyz",
833            "tool_name": "Bash",
834            "parameters": {"command": "ls -la"}
835        });
836
837        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
838        match event {
839            AgentEvent::ToolApprovalRequested {
840                tool_call_id,
841                tool_name,
842                parameters,
843            } => {
844                assert_eq!(tool_call_id, "call-xyz");
845                assert_eq!(tool_name, "Bash");
846                assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
847            }
848            other => panic!("unexpected event: {other:?}"),
849        }
850    }
851
852    #[test]
853    fn session_title_updated_round_trips_with_source_variants() {
854        use chrono::Utc;
855        let event = AgentEvent::SessionTitleUpdated {
856            session_id: "sess-1".to_string(),
857            title: "My title".to_string(),
858            title_version: 3,
859            source: TitleSource::Auto,
860            updated_at: Utc::now(),
861        };
862        let json = serde_json::to_string(&event).unwrap();
863        assert!(
864            json.contains("\"type\":\"session_title_updated\""),
865            "json: {json}"
866        );
867        assert!(json.contains("\"source\":\"auto\""), "json: {json}");
868        let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
869    }
870
871    #[test]
872    fn plan_mode_events_deserialize_without_optional_fields() {
873        let json = serde_json::json!({
874            "type": "plan_mode_entered",
875            "session_id": "sess-1",
876            "pre_permission_mode": "default",
877            "entered_at": "2025-01-01T00:00:00Z",
878            "status": "exploring"
879        });
880
881        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
882        match event {
883            AgentEvent::PlanModeEntered {
884                session_id,
885                reason,
886                pre_permission_mode,
887                entered_at,
888                status,
889                plan_file_path,
890            } => {
891                assert_eq!(session_id, "sess-1");
892                assert_eq!(reason, None);
893                assert_eq!(pre_permission_mode, "default");
894                assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
895                assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
896                assert_eq!(plan_file_path, None);
897            }
898            other => panic!("unexpected event: {other:?}"),
899        }
900    }
901}