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    /// A new session was created.
422    ///
423    /// Change-feed event: durable, journaled, carried on the account `/stream`
424    /// feed so other clients can insert the session into their list without a
425    /// full `GET /sessions` poll.
426    SessionCreated {
427        session_id: String,
428        title: String,
429        kind: bamboo_domain::SessionKind,
430        created_at: chrono::DateTime<chrono::Utc>,
431    },
432
433    /// A session was deleted.
434    ///
435    /// Change-feed event: durable, journaled. Clients remove the session from
436    /// their local list on receipt.
437    SessionDeleted { session_id: String },
438
439    /// A session's message history was cleared (session kept).
440    ///
441    /// Change-feed event: durable, journaled. Clients drop cached messages for
442    /// the session and refetch lazily.
443    SessionCleared { session_id: String },
444
445    /// A message was appended to a session.
446    ///
447    /// Change-feed event: durable, journaled. The `seq` assigned to this event
448    /// on the account feed is the message's feed coordinate (used by
449    /// `GET /history/{id}?since={seq}` to compute deltas). `content` is the
450    /// plain-text body matching what `/history` returns to the UI.
451    MessageAppended {
452        session_id: String,
453        message_id: String,
454        role: bamboo_domain::Role,
455        content: String,
456        created_at: chrono::DateTime<chrono::Utc>,
457    },
458
459    /// Execution run has started and the runner is now active.
460    ///
461    /// Emitted as the first event after a runner reservation succeeds,
462    /// before any token or tool events. Carries the `run_id` so the
463    /// frontend can correlate subsequent SSE events across reconnects.
464    ExecutionStarted {
465        /// Unique identifier for this execution run.
466        run_id: String,
467        /// Session identifier.
468        session_id: String,
469        /// ISO 8601 timestamp when the run started.
470        started_at: String,
471    },
472
473    /// Tool execution requires user approval before proceeding.
474    ///
475    /// Emitted when a permission checker determines that a tool call needs
476    /// explicit user confirmation (e.g., mutating operations in restricted
477    /// permission mode). The frontend should present the approval request and
478    /// either grant or deny it.
479    ToolApprovalRequested {
480        /// Unique identifier for the tool call awaiting approval.
481        tool_call_id: String,
482        /// Name of the tool being executed.
483        tool_name: String,
484        /// Parameters that were passed to the tool.
485        parameters: serde_json::Value,
486    },
487
488    /// Agent execution completed successfully.
489    Complete {
490        /// Final token usage statistics
491        usage: TokenUsage,
492    },
493
494    /// Agent execution was cancelled.
495    Cancelled {
496        /// Optional human-readable message explaining the cancellation.
497        #[serde(default, skip_serializing_if = "Option::is_none")]
498        message: Option<String>,
499    },
500
501    /// Agent execution failed.
502    Error {
503        /// Error message
504        message: String,
505    },
506
507    /// A user-facing notification derived from agent activity by the backend
508    /// notification policy. Clients render this (e.g. an OS desktop notification)
509    /// after applying their own presence checks (window focus). The decision of
510    /// *whether* to notify — category, priority, preference gating, dedup — is
511    /// made server-side in `bamboo-notification`; the client just delivers it.
512    Notification {
513        /// Unique id (for client-side dedup / dismissal).
514        id: String,
515        /// Session this notification is about.
516        session_id: String,
517        /// Category, e.g. `needs_approval` | `needs_clarification` | `run_completed`
518        /// | `run_failed` | `subagent_completed` | `context_critical`.
519        category: String,
520        /// Priority: `high` | `normal` | `low`.
521        priority: String,
522        /// Short title line.
523        title: String,
524        /// Body text.
525        body: String,
526        /// Stable key for client-side coalescing within a short window.
527        #[serde(default, skip_serializing_if = "Option::is_none")]
528        dedup_key: Option<String>,
529        /// RFC3339 creation timestamp.
530        created_at: String,
531    },
532}
533
534impl AgentEvent {
535    /// Returns the session this event pertains to, when it carries one.
536    ///
537    /// Used by the account change-feed to route each event to the right
538    /// client-side session without a per-session connection. For sub-agent
539    /// events the *parent* session id is returned (that is the session a client
540    /// observes in its list). Pure streaming/diagnostic variants (`Token`,
541    /// `Complete`, …) return `None`; those are ephemeral and never ride the
542    /// account feed anyway.
543    pub fn session_id(&self) -> Option<&str> {
544        match self {
545            AgentEvent::TaskListUpdated { task_list } => Some(task_list.session_id.as_str()),
546            AgentEvent::TaskListItemProgress { session_id, .. }
547            | AgentEvent::TaskListCompleted { session_id, .. }
548            | AgentEvent::TaskEvaluationStarted { session_id, .. }
549            | AgentEvent::TaskEvaluationCompleted { session_id, .. }
550            | AgentEvent::GoldEvaluationStarted { session_id, .. }
551            | AgentEvent::GoldEvaluationCompleted { session_id, .. }
552            | AgentEvent::PlanModeEntered { session_id, .. }
553            | AgentEvent::PlanModeExited { session_id, .. }
554            | AgentEvent::PlanFileUpdated { session_id, .. }
555            | AgentEvent::RunnerProgress { session_id, .. }
556            | AgentEvent::SessionTitleUpdated { session_id, .. }
557            | AgentEvent::SessionPinnedUpdated { session_id, .. }
558            | AgentEvent::SessionCreated { session_id, .. }
559            | AgentEvent::SessionDeleted { session_id, .. }
560            | AgentEvent::SessionCleared { session_id, .. }
561            | AgentEvent::MessageAppended { session_id, .. }
562            | AgentEvent::ExecutionStarted { session_id, .. }
563            | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
564            AgentEvent::SubAgentStarted {
565                parent_session_id, ..
566            }
567            | AgentEvent::SubAgentEvent {
568                parent_session_id, ..
569            }
570            | AgentEvent::SubAgentHeartbeat {
571                parent_session_id, ..
572            }
573            | AgentEvent::SubAgentCompleted {
574                parent_session_id, ..
575            } => Some(parent_session_id.as_str()),
576            _ => None,
577        }
578    }
579
580    /// Whether this event belongs on the durable account change feed.
581    ///
582    /// Durable change events are low-volume, journaled to disk, and resumable
583    /// via the account `/stream` feed. Ephemeral events — token-by-token
584    /// streaming (`Token`/`ReasoningToken`/`ToolToken`), heartbeats, live
585    /// budget/pressure gauges, and raw forwarded sub-agent events — return
586    /// `false`: they stay exclusively on the per-session `/events/{id}` stream.
587    /// Keeping them off the journal and the multiplexed feed is the core
588    /// data-transfer win. This method lives in core so both the server and the
589    /// engine forwarder can filter before cloning onto the feed.
590    pub fn is_durable_change(&self) -> bool {
591        matches!(
592            self,
593            AgentEvent::MessageAppended { .. }
594                | AgentEvent::SessionCreated { .. }
595                | AgentEvent::SessionDeleted { .. }
596                | AgentEvent::SessionCleared { .. }
597                | AgentEvent::SessionTitleUpdated { .. }
598                | AgentEvent::SessionPinnedUpdated { .. }
599                | AgentEvent::TaskListUpdated { .. }
600                | AgentEvent::TaskListItemProgress { .. }
601                | AgentEvent::TaskListCompleted { .. }
602                | AgentEvent::TaskEvaluationCompleted { .. }
603                | AgentEvent::PlanModeEntered { .. }
604                | AgentEvent::PlanModeExited { .. }
605                | AgentEvent::PlanFileUpdated { .. }
606                | AgentEvent::SubAgentStarted { .. }
607                | AgentEvent::SubAgentCompleted { .. }
608                | AgentEvent::NeedClarification { .. }
609                | AgentEvent::ToolApprovalRequested { .. }
610                | AgentEvent::ExecutionStarted { .. }
611                | AgentEvent::Complete { .. }
612                | AgentEvent::Cancelled { .. }
613                | AgentEvent::Error { .. }
614        )
615    }
616}
617
618fn default_allow_custom() -> bool {
619    true
620}
621
622/// Gold evaluation checkpoint.
623#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
624#[serde(rename_all = "snake_case")]
625pub enum GoldCheckpoint {
626    PostRound,
627    Terminal,
628}
629
630impl GoldCheckpoint {
631    pub fn as_str(self) -> &'static str {
632        match self {
633            Self::PostRound => "post_round",
634            Self::Terminal => "terminal",
635        }
636    }
637}
638
639/// Gold evaluator decision.
640#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
641#[serde(rename_all = "snake_case")]
642pub enum GoldDecision {
643    Continue,
644    Achieved,
645    Blocked,
646    NeedInput,
647    Exhausted,
648}
649
650impl GoldDecision {
651    pub fn as_str(self) -> &'static str {
652        match self {
653            Self::Continue => "continue",
654            Self::Achieved => "achieved",
655            Self::Blocked => "blocked",
656            Self::NeedInput => "need_input",
657            Self::Exhausted => "exhausted",
658        }
659    }
660}
661
662/// Confidence level for a Gold evaluation result.
663#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
664#[serde(rename_all = "snake_case")]
665pub enum GoldConfidence {
666    Low,
667    Medium,
668    High,
669}
670
671impl GoldConfidence {
672    pub fn as_str(self) -> &'static str {
673        match self {
674            Self::Low => "low",
675            Self::Medium => "medium",
676            Self::High => "high",
677        }
678    }
679
680    /// Ordinal rank for threshold comparisons (`Low` < `Medium` < `High`).
681    pub fn rank(self) -> u8 {
682        match self {
683            Self::Low => 0,
684            Self::Medium => 1,
685            Self::High => 2,
686        }
687    }
688
689    /// Whether this confidence meets or exceeds the given floor.
690    pub fn meets(self, floor: GoldConfidence) -> bool {
691        self.rank() >= floor.rank()
692    }
693}
694
695/// Source that triggered a session title update.
696#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
697#[serde(rename_all = "snake_case")]
698pub enum TitleSource {
699    Auto,
700    Manual,
701    Fallback,
702}
703
704/// Re-exported shared token usage type.
705///
706/// See [`bamboo_domain::TokenUsage`] for the canonical definition.
707pub use bamboo_domain::TokenUsage;
708
709pub use bamboo_domain::budget_types::TokenBudgetUsage;
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
715
716    fn sample_task_list() -> TaskList {
717        TaskList {
718            session_id: "session-1".to_string(),
719            title: "Task List".to_string(),
720            items: vec![TaskItem {
721                id: "task_1".to_string(),
722                description: "Implement event rename".to_string(),
723                status: TaskItemStatus::InProgress,
724                depends_on: Vec::new(),
725                notes: "Implementing".to_string(),
726                ..TaskItem::default()
727            }],
728            created_at: Utc::now(),
729            updated_at: Utc::now(),
730        }
731    }
732
733    #[test]
734    fn task_list_updated_serializes_with_task_names() {
735        let event = AgentEvent::TaskListUpdated {
736            task_list: sample_task_list(),
737        };
738
739        let value = serde_json::to_value(event).expect("event should serialize");
740        assert_eq!(value["type"], "task_list_updated");
741        assert!(value.get("task_list").is_some());
742        assert!(value.get("todo_list").is_none());
743    }
744
745    #[test]
746    fn cancelled_serializes_with_snake_case_type() {
747        let event = AgentEvent::Cancelled {
748            message: Some("Agent execution cancelled by user".to_string()),
749        };
750
751        let value = serde_json::to_value(event).expect("event should serialize");
752        assert_eq!(value["type"], "cancelled");
753        assert_eq!(
754            value["message"],
755            serde_json::Value::String("Agent execution cancelled by user".to_string())
756        );
757    }
758
759    #[test]
760    fn task_evaluation_completed_serializes_with_task_type() {
761        let event = AgentEvent::TaskEvaluationCompleted {
762            session_id: "session-1".to_string(),
763            updates_count: 2,
764            reasoning: "Updated statuses".to_string(),
765        };
766
767        let value = serde_json::to_value(event).expect("event should serialize");
768        assert_eq!(value["type"], "task_evaluation_completed");
769    }
770
771    #[test]
772    fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
773        let event = AgentEvent::GoldEvaluationCompleted {
774            session_id: "session-1".to_string(),
775            checkpoint: GoldCheckpoint::PostRound,
776            iteration: 3,
777            decision: GoldDecision::Continue,
778            confidence: GoldConfidence::Medium,
779            reasoning: "Need one more iteration".to_string(),
780        };
781
782        let value = serde_json::to_value(event).expect("event should serialize");
783        assert_eq!(value["type"], "gold_evaluation_completed");
784        assert_eq!(value["checkpoint"], "post_round");
785        assert_eq!(value["iteration"], 3);
786        assert_eq!(value["decision"], "continue");
787        assert_eq!(value["confidence"], "medium");
788        assert_eq!(value["reasoning"], "Need one more iteration");
789    }
790
791    #[test]
792    fn gold_evaluation_started_deserializes() {
793        let json = serde_json::json!({
794            "type": "gold_evaluation_started",
795            "session_id": "session-1",
796            "checkpoint": "terminal",
797            "iteration": 7
798        });
799
800        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
801        match event {
802            AgentEvent::GoldEvaluationStarted {
803                session_id,
804                checkpoint,
805                iteration,
806            } => {
807                assert_eq!(session_id, "session-1");
808                assert_eq!(checkpoint, GoldCheckpoint::Terminal);
809                assert_eq!(iteration, 7);
810            }
811            other => panic!("unexpected event: {other:?}"),
812        }
813    }
814
815    #[test]
816    fn context_compression_status_serializes_with_phase_and_status() {
817        let event = AgentEvent::ContextCompressionStatus {
818            phase: "mid-turn".to_string(),
819            status: "started".to_string(),
820        };
821
822        let value = serde_json::to_value(event).expect("event should serialize");
823        assert_eq!(value["type"], "context_compression_status");
824        assert_eq!(value["phase"], "mid-turn");
825        assert_eq!(value["status"], "started");
826    }
827
828    #[test]
829    fn need_clarification_serializes_with_new_fields() {
830        let event = AgentEvent::NeedClarification {
831            question: "Continue?".to_string(),
832            options: Some(vec!["Yes".to_string(), "No".to_string()]),
833            tool_call_id: Some("tool-1".to_string()),
834            tool_name: Some("conclusion_with_options".to_string()),
835            allow_custom: false,
836        };
837
838        let value = serde_json::to_value(event).expect("event should serialize");
839        assert_eq!(value["type"], "need_clarification");
840        assert_eq!(value["question"], "Continue?");
841        assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
842        assert_eq!(value["tool_call_id"], "tool-1");
843        assert_eq!(value["tool_name"], "conclusion_with_options");
844        assert_eq!(value["allow_custom"], false);
845    }
846
847    #[test]
848    fn need_clarification_deserializes_from_old_format_without_new_fields() {
849        let json = serde_json::json!({
850            "type": "need_clarification",
851            "question": "Continue?",
852            "options": ["Yes", "No"]
853        });
854
855        let event: AgentEvent =
856            serde_json::from_value(json).expect("should deserialize old format");
857        match event {
858            AgentEvent::NeedClarification {
859                question,
860                options,
861                tool_call_id,
862                tool_name,
863                allow_custom,
864            } => {
865                assert_eq!(question, "Continue?");
866                assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
867                assert_eq!(tool_call_id, None);
868                assert_eq!(tool_name, None);
869                assert!(allow_custom); // default_allow_custom returns true
870            }
871            other => panic!("unexpected event: {other:?}"),
872        }
873    }
874
875    #[test]
876    fn need_clarification_deserializes_with_allow_custom_false() {
877        let json = serde_json::json!({
878            "type": "need_clarification",
879            "question": "Pick one",
880            "allow_custom": false
881        });
882
883        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
884        match event {
885            AgentEvent::NeedClarification {
886                question,
887                options,
888                tool_call_id,
889                tool_name,
890                allow_custom,
891            } => {
892                assert_eq!(question, "Pick one");
893                assert_eq!(options, None);
894                assert_eq!(tool_call_id, None);
895                assert_eq!(tool_name, None);
896                assert!(!allow_custom);
897            }
898            other => panic!("unexpected event: {other:?}"),
899        }
900    }
901
902    #[test]
903    fn plan_mode_entered_serializes_correctly() {
904        let entered_at = Utc::now();
905        let event = AgentEvent::PlanModeEntered {
906            session_id: "sess-1".to_string(),
907            reason: Some("Complex refactor".to_string()),
908            pre_permission_mode: "default".to_string(),
909            entered_at,
910            status: bamboo_domain::PlanModeStatus::Exploring,
911            plan_file_path: None,
912        };
913
914        let value = serde_json::to_value(event).expect("event should serialize");
915        assert_eq!(value["type"], "plan_mode_entered");
916        assert_eq!(value["session_id"], "sess-1");
917        assert_eq!(value["reason"], "Complex refactor");
918        assert_eq!(value["pre_permission_mode"], "default");
919        assert_eq!(value["status"], "exploring");
920        // Compare against serde's own serialization (RFC3339 with `Z` for UTC),
921        // not `to_rfc3339()` which emits a `+00:00` offset instead.
922        assert_eq!(
923            value["entered_at"],
924            serde_json::to_value(entered_at).unwrap()
925        );
926    }
927
928    #[test]
929    fn plan_mode_exited_serializes_correctly() {
930        let event = AgentEvent::PlanModeExited {
931            session_id: "sess-1".to_string(),
932            approved: true,
933            restored_mode: "accept_edits".to_string(),
934            plan: Some("# Plan\n1. Step one".to_string()),
935        };
936
937        let value = serde_json::to_value(event).expect("event should serialize");
938        assert_eq!(value["type"], "plan_mode_exited");
939        assert_eq!(value["session_id"], "sess-1");
940        assert_eq!(value["approved"], true);
941        assert_eq!(value["restored_mode"], "accept_edits");
942        assert_eq!(value["plan"], "# Plan\n1. Step one");
943    }
944
945    #[test]
946    fn plan_file_updated_serializes_correctly() {
947        let event = AgentEvent::PlanFileUpdated {
948            session_id: "sess-1".to_string(),
949            file_path: "/tmp/plans/sess-1.md".to_string(),
950            content_summary: "Implementation plan for feature X".to_string(),
951        };
952
953        let value = serde_json::to_value(event).expect("event should serialize");
954        assert_eq!(value["type"], "plan_file_updated");
955        assert_eq!(value["session_id"], "sess-1");
956        assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
957        assert_eq!(
958            value["content_summary"],
959            "Implementation plan for feature X"
960        );
961    }
962
963    #[test]
964    fn tool_approval_requested_serializes_correctly() {
965        let event = AgentEvent::ToolApprovalRequested {
966            tool_call_id: "call-abc".to_string(),
967            tool_name: "Write".to_string(),
968            parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
969        };
970
971        let value = serde_json::to_value(event).expect("event should serialize");
972        assert_eq!(value["type"], "tool_approval_requested");
973        assert_eq!(value["tool_call_id"], "call-abc");
974        assert_eq!(value["tool_name"], "Write");
975        assert_eq!(
976            value["parameters"],
977            serde_json::json!({"file_path": "/tmp/test.txt"})
978        );
979    }
980
981    #[test]
982    fn tool_approval_requested_deserializes_correctly() {
983        let json = serde_json::json!({
984            "type": "tool_approval_requested",
985            "tool_call_id": "call-xyz",
986            "tool_name": "Bash",
987            "parameters": {"command": "ls -la"}
988        });
989
990        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
991        match event {
992            AgentEvent::ToolApprovalRequested {
993                tool_call_id,
994                tool_name,
995                parameters,
996            } => {
997                assert_eq!(tool_call_id, "call-xyz");
998                assert_eq!(tool_name, "Bash");
999                assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1000            }
1001            other => panic!("unexpected event: {other:?}"),
1002        }
1003    }
1004
1005    #[test]
1006    fn session_title_updated_round_trips_with_source_variants() {
1007        use chrono::Utc;
1008        let event = AgentEvent::SessionTitleUpdated {
1009            session_id: "sess-1".to_string(),
1010            title: "My title".to_string(),
1011            title_version: 3,
1012            source: TitleSource::Auto,
1013            updated_at: Utc::now(),
1014        };
1015        let json = serde_json::to_string(&event).unwrap();
1016        assert!(
1017            json.contains("\"type\":\"session_title_updated\""),
1018            "json: {json}"
1019        );
1020        assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1021        let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1022    }
1023
1024    #[test]
1025    fn plan_mode_events_deserialize_without_optional_fields() {
1026        let json = serde_json::json!({
1027            "type": "plan_mode_entered",
1028            "session_id": "sess-1",
1029            "pre_permission_mode": "default",
1030            "entered_at": "2025-01-01T00:00:00Z",
1031            "status": "exploring"
1032        });
1033
1034        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1035        match event {
1036            AgentEvent::PlanModeEntered {
1037                session_id,
1038                reason,
1039                pre_permission_mode,
1040                entered_at,
1041                status,
1042                plan_file_path,
1043            } => {
1044                assert_eq!(session_id, "sess-1");
1045                assert_eq!(reason, None);
1046                assert_eq!(pre_permission_mode, "default");
1047                assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1048                assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1049                assert_eq!(plan_file_path, None);
1050            }
1051            other => panic!("unexpected event: {other:?}"),
1052        }
1053    }
1054}