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 whenever the runtime goal state changes — a new status
271    /// (active/complete/blocked/…), an incremented continuation count, or a
272    /// freshly recorded side-channel double-check verdict. Lets the UI reflect
273    /// live goal progress without re-fetching history. Ephemeral: it rides only
274    /// the per-session `/events/{id}` stream; reconnecting clients read the
275    /// authoritative `goal_state` from the history endpoint instead.
276    GoalStatusChanged {
277        /// Session identifier
278        session_id: String,
279        /// Full serialized goal state — identical shape to the history
280        /// response's `goal_state` field (see `bamboo_engine::runtime::goal_state`).
281        goal_state: serde_json::Value,
282    },
283
284    /// Emitted when token budget is prepared (after context truncation)
285    TokenBudgetUpdated {
286        /// Token budget details
287        usage: TokenBudgetUsage,
288    },
289
290    /// Emitted when host-side context compression lifecycle changes.
291    ContextCompressionStatus {
292        /// Compression phase label (for example: pre-turn, mid-turn).
293        phase: String,
294        /// Compression status: started | completed | failed | skipped
295        status: String,
296    },
297
298    /// Emitted when conversation context is summarized
299    ContextSummarized {
300        /// Generated summary text
301        summary: String,
302        /// Number of old messages summarized
303        messages_summarized: usize,
304        /// Tokens saved by summarization
305        tokens_saved: u32,
306        /// Context usage percentage before compression
307        #[serde(default)]
308        usage_before_percent: f64,
309        /// Context usage percentage after compression
310        #[serde(default)]
311        usage_after_percent: f64,
312        /// What triggered the compression: "auto" | "manual" | "critical"
313        #[serde(default)]
314        trigger_type: String,
315    },
316
317    /// Emitted when context pressure reaches warning or critical levels.
318    /// Frontend should display this to the user as a proactive notification.
319    ContextPressureNotification {
320        /// Context usage as a percentage of the context window.
321        percent: f64,
322        /// Severity level: "warning" (70%) or "critical" (90%).
323        level: String,
324        /// Human-readable message describing the pressure state.
325        message: String,
326    },
327
328    /// A child session was spawned from a parent session (async background job).
329    SubAgentStarted {
330        parent_session_id: String,
331        child_session_id: String,
332        /// Optional title (useful for UI lists).
333        #[serde(default, skip_serializing_if = "Option::is_none")]
334        title: Option<String>,
335    },
336
337    /// Forwarded raw child event to the parent session stream.
338    ///
339    /// Child sessions are not allowed to spawn further sessions, so this should not nest.
340    SubAgentEvent {
341        parent_session_id: String,
342        child_session_id: String,
343        event: Box<AgentEvent>,
344    },
345
346    /// Heartbeat emitted while a child session is running.
347    SubAgentHeartbeat {
348        parent_session_id: String,
349        child_session_id: String,
350        timestamp: DateTime<Utc>,
351    },
352
353    /// Child session finished (completed/cancelled/error).
354    SubAgentCompleted {
355        parent_session_id: String,
356        child_session_id: String,
357        /// One of: "completed" | "cancelled" | "error" | "skipped"
358        status: String,
359        #[serde(default, skip_serializing_if = "Option::is_none")]
360        error: Option<String>,
361    },
362
363    /// Background Bash shell finished (completed/killed/error).
364    ///
365    /// Emitted by the background shell runtime when a `run_in_background`
366    /// command exits, so clients can react to (and, in later phases, resume
367    /// around) long-running commands. Phase 1 (issue #84): completion signal
368    /// only — this does not change the default foreground behavior.
369    ///
370    /// Delivery scope: a *live* signal. It rides the per-session
371    /// `/events/{id}` stream and the in-memory late-subscriber replay cache
372    /// (`is_critical_event`), but is intentionally **not** a durable change in
373    /// Phase 1 — it is not written to the account change journal. Treat it as
374    /// ephemeral: a reconnecting client should not rely on seeing a past
375    /// `BashCompleted` via the journaled history.
376    BashCompleted {
377        /// Background shell session identifier (same value returned as `bash_id`).
378        bash_id: String,
379        /// The command string that was executed.
380        command: String,
381        /// Process exit code, when available (`None` for signal/killed termination).
382        #[serde(default, skip_serializing_if = "Option::is_none")]
383        exit_code: Option<i32>,
384        /// One of: "completed" | "killed" | "error".
385        status: String,
386    },
387
388    /// Plan mode was entered.
389    PlanModeEntered {
390        /// Session identifier
391        session_id: String,
392        /// Optional reason for entering plan mode
393        #[serde(default, skip_serializing_if = "Option::is_none")]
394        reason: Option<String>,
395        /// Previous permission mode before entering plan mode
396        pre_permission_mode: String,
397        /// RFC3339 timestamp when plan mode was entered.
398        entered_at: chrono::DateTime<chrono::Utc>,
399        /// Current plan mode phase/status.
400        status: bamboo_domain::PlanModeStatus,
401        /// Path to the persisted plan file, if already available.
402        #[serde(default, skip_serializing_if = "Option::is_none")]
403        plan_file_path: Option<String>,
404    },
405
406    /// Plan mode was exited.
407    PlanModeExited {
408        /// Session identifier
409        session_id: String,
410        /// Whether the exit was approved by the user
411        approved: bool,
412        /// The permission mode restored after exiting
413        restored_mode: String,
414        /// Plan content that was reviewed, if any
415        #[serde(default, skip_serializing_if = "Option::is_none")]
416        plan: Option<String>,
417    },
418
419    /// Plan file was updated.
420    PlanFileUpdated {
421        /// Session identifier
422        session_id: String,
423        /// Path to the plan file
424        file_path: String,
425        /// Summary of the plan content (truncated)
426        content_summary: String,
427    },
428
429    /// Runner progress update emitted at the start of each agent turn.
430    ///
431    /// Used to track live execution progress (round count, current activity)
432    /// for diagnostic visibility, especially for child sessions.
433    RunnerProgress {
434        /// Session identifier
435        session_id: String,
436        /// Current turn/round count
437        round_count: u32,
438    },
439
440    /// Session title was updated (auto-generated by backend or manually renamed via PATCH).
441    SessionTitleUpdated {
442        session_id: String,
443        title: String,
444        title_version: u64,
445        source: TitleSource,
446        updated_at: chrono::DateTime<chrono::Utc>,
447    },
448
449    /// Session pinned flag was toggled via PATCH.
450    ///
451    /// Replayable metadata event. `pinned` is an idempotent boolean so the
452    /// latest event wins; `updated_at` is used by the frontend to suppress
453    /// stale replays.
454    SessionPinnedUpdated {
455        session_id: String,
456        pinned: bool,
457        updated_at: chrono::DateTime<chrono::Utc>,
458    },
459
460    /// A new session was created.
461    ///
462    /// Change-feed event: durable, journaled, carried on the account `/stream`
463    /// feed so other clients can insert the session into their list without a
464    /// full `GET /sessions` poll.
465    SessionCreated {
466        session_id: String,
467        title: String,
468        kind: bamboo_domain::SessionKind,
469        created_at: chrono::DateTime<chrono::Utc>,
470    },
471
472    /// A session was deleted.
473    ///
474    /// Change-feed event: durable, journaled. Clients remove the session from
475    /// their local list on receipt.
476    SessionDeleted { session_id: String },
477
478    /// A session's message history was cleared (session kept).
479    ///
480    /// Change-feed event: durable, journaled. Clients drop cached messages for
481    /// the session and refetch lazily.
482    SessionCleared { session_id: String },
483
484    /// A message was appended to a session.
485    ///
486    /// Change-feed event: durable, journaled. The `seq` assigned to this event
487    /// on the account feed is the message's feed coordinate (used by
488    /// `GET /history/{id}?since={seq}` to compute deltas). `content` is the
489    /// plain-text body matching what `/history` returns to the UI.
490    MessageAppended {
491        session_id: String,
492        message_id: String,
493        role: bamboo_domain::Role,
494        content: String,
495        created_at: chrono::DateTime<chrono::Utc>,
496    },
497
498    /// Execution run has started and the runner is now active.
499    ///
500    /// Emitted as the first event after a runner reservation succeeds,
501    /// before any token or tool events. Carries the `run_id` so the
502    /// frontend can correlate subsequent SSE events across reconnects.
503    ExecutionStarted {
504        /// Unique identifier for this execution run.
505        run_id: String,
506        /// Session identifier.
507        session_id: String,
508        /// ISO 8601 timestamp when the run started.
509        started_at: String,
510    },
511
512    /// Tool execution requires user approval before proceeding.
513    ///
514    /// Emitted when a permission checker determines that a tool call needs
515    /// explicit user confirmation (e.g., mutating operations in restricted
516    /// permission mode). The frontend should present the approval request and
517    /// either grant or deny it.
518    ToolApprovalRequested {
519        /// Unique identifier for the tool call awaiting approval.
520        tool_call_id: String,
521        /// Name of the tool being executed.
522        tool_name: String,
523        /// Parameters that were passed to the tool.
524        parameters: serde_json::Value,
525    },
526
527    /// A child sub-agent (out-of-process worker) hit a gated tool and proxied
528    /// the approval decision to this parent over the actor protocol (Phase 2).
529    /// The parent surfaces it to the human; the decision is routed back to the
530    /// waiting child via
531    /// `external_agents::live::deliver_approval(child_session_id, request_id, approved)`.
532    ChildApprovalRequested {
533        /// The child session whose gated tool is blocked awaiting approval.
534        child_session_id: String,
535        /// Correlates the eventual approve/deny reply back to the blocked tool.
536        request_id: String,
537        /// Name of the gated tool the child wants to run.
538        tool_name: String,
539        /// Human-readable description of the permission requested.
540        permission: String,
541        /// The concrete resource the action targets.
542        resource: String,
543    },
544
545    /// Agent execution completed successfully.
546    Complete {
547        /// Final token usage statistics
548        usage: TokenUsage,
549    },
550
551    /// Agent execution was cancelled.
552    Cancelled {
553        /// Optional human-readable message explaining the cancellation.
554        #[serde(default, skip_serializing_if = "Option::is_none")]
555        message: Option<String>,
556    },
557
558    /// Agent execution failed.
559    Error {
560        /// Error message
561        message: String,
562    },
563
564    /// A user-facing notification derived from agent activity by the backend
565    /// notification policy. Clients render this (e.g. an OS desktop notification)
566    /// after applying their own presence checks (window focus). The decision of
567    /// *whether* to notify — category, priority, preference gating, dedup — is
568    /// made server-side in `bamboo-notification`; the client just delivers it.
569    Notification {
570        /// Unique id (for client-side dedup / dismissal).
571        id: String,
572        /// Session this notification is about.
573        session_id: String,
574        /// Category, e.g. `needs_approval` | `needs_clarification` | `run_completed`
575        /// | `run_failed` | `subagent_completed` | `context_critical`.
576        category: String,
577        /// Priority: `high` | `normal` | `low`.
578        priority: String,
579        /// Short title line.
580        title: String,
581        /// Body text.
582        body: String,
583        /// Stable key for client-side coalescing within a short window.
584        #[serde(default, skip_serializing_if = "Option::is_none")]
585        dedup_key: Option<String>,
586        /// RFC3339 creation timestamp.
587        created_at: String,
588    },
589}
590
591impl AgentEvent {
592    /// Returns the session this event pertains to, when it carries one.
593    ///
594    /// Used by the account change-feed to route each event to the right
595    /// client-side session without a per-session connection. For sub-agent
596    /// events the *parent* session id is returned (that is the session a client
597    /// observes in its list). Pure streaming/diagnostic variants (`Token`,
598    /// `Complete`, …) return `None`; those are ephemeral and never ride the
599    /// account feed anyway.
600    pub fn session_id(&self) -> Option<&str> {
601        match self {
602            AgentEvent::TaskListUpdated { task_list } => Some(task_list.session_id.as_str()),
603            AgentEvent::TaskListItemProgress { session_id, .. }
604            | AgentEvent::TaskListCompleted { session_id, .. }
605            | AgentEvent::TaskEvaluationStarted { session_id, .. }
606            | AgentEvent::TaskEvaluationCompleted { session_id, .. }
607            | AgentEvent::GoldEvaluationStarted { session_id, .. }
608            | AgentEvent::GoldEvaluationCompleted { session_id, .. }
609            | AgentEvent::GoalStatusChanged { session_id, .. }
610            | AgentEvent::PlanModeEntered { session_id, .. }
611            | AgentEvent::PlanModeExited { session_id, .. }
612            | AgentEvent::PlanFileUpdated { session_id, .. }
613            | AgentEvent::RunnerProgress { session_id, .. }
614            | AgentEvent::SessionTitleUpdated { session_id, .. }
615            | AgentEvent::SessionPinnedUpdated { session_id, .. }
616            | AgentEvent::SessionCreated { session_id, .. }
617            | AgentEvent::SessionDeleted { session_id, .. }
618            | AgentEvent::SessionCleared { session_id, .. }
619            | AgentEvent::MessageAppended { session_id, .. }
620            | AgentEvent::ExecutionStarted { session_id, .. }
621            | AgentEvent::Notification { session_id, .. } => Some(session_id.as_str()),
622            AgentEvent::SubAgentStarted {
623                parent_session_id, ..
624            }
625            | AgentEvent::SubAgentEvent {
626                parent_session_id, ..
627            }
628            | AgentEvent::SubAgentHeartbeat {
629                parent_session_id, ..
630            }
631            | AgentEvent::SubAgentCompleted {
632                parent_session_id, ..
633            } => Some(parent_session_id.as_str()),
634            _ => None,
635        }
636    }
637
638    /// Whether this event belongs on the durable account change feed.
639    ///
640    /// Durable change events are low-volume, journaled to disk, and resumable
641    /// via the account `/stream` feed. Ephemeral events — token-by-token
642    /// streaming (`Token`/`ReasoningToken`/`ToolToken`), heartbeats, live
643    /// budget/pressure gauges, and raw forwarded sub-agent events — return
644    /// `false`: they stay exclusively on the per-session `/events/{id}` stream.
645    /// Keeping them off the journal and the multiplexed feed is the core
646    /// data-transfer win. This method lives in core so both the server and the
647    /// engine forwarder can filter before cloning onto the feed.
648    pub fn is_durable_change(&self) -> bool {
649        matches!(
650            self,
651            AgentEvent::MessageAppended { .. }
652                | AgentEvent::SessionCreated { .. }
653                | AgentEvent::SessionDeleted { .. }
654                | AgentEvent::SessionCleared { .. }
655                | AgentEvent::SessionTitleUpdated { .. }
656                | AgentEvent::SessionPinnedUpdated { .. }
657                | AgentEvent::TaskListUpdated { .. }
658                | AgentEvent::TaskListItemProgress { .. }
659                | AgentEvent::TaskListCompleted { .. }
660                | AgentEvent::TaskEvaluationCompleted { .. }
661                | AgentEvent::PlanModeEntered { .. }
662                | AgentEvent::PlanModeExited { .. }
663                | AgentEvent::PlanFileUpdated { .. }
664                | AgentEvent::SubAgentStarted { .. }
665                | AgentEvent::SubAgentCompleted { .. }
666                | AgentEvent::NeedClarification { .. }
667                | AgentEvent::ToolApprovalRequested { .. }
668                | AgentEvent::ExecutionStarted { .. }
669                | AgentEvent::Complete { .. }
670                | AgentEvent::Cancelled { .. }
671                | AgentEvent::Error { .. }
672        )
673    }
674}
675
676fn default_allow_custom() -> bool {
677    true
678}
679
680/// Gold evaluation checkpoint.
681#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(rename_all = "snake_case")]
683pub enum GoldCheckpoint {
684    PostRound,
685    Terminal,
686}
687
688impl GoldCheckpoint {
689    pub fn as_str(self) -> &'static str {
690        match self {
691            Self::PostRound => "post_round",
692            Self::Terminal => "terminal",
693        }
694    }
695}
696
697/// Gold evaluator decision.
698#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
699#[serde(rename_all = "snake_case")]
700pub enum GoldDecision {
701    Continue,
702    Achieved,
703    Blocked,
704    NeedInput,
705    Exhausted,
706}
707
708impl GoldDecision {
709    pub fn as_str(self) -> &'static str {
710        match self {
711            Self::Continue => "continue",
712            Self::Achieved => "achieved",
713            Self::Blocked => "blocked",
714            Self::NeedInput => "need_input",
715            Self::Exhausted => "exhausted",
716        }
717    }
718}
719
720/// Confidence level for a Gold evaluation result.
721#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
722#[serde(rename_all = "snake_case")]
723pub enum GoldConfidence {
724    Low,
725    Medium,
726    High,
727}
728
729impl GoldConfidence {
730    pub fn as_str(self) -> &'static str {
731        match self {
732            Self::Low => "low",
733            Self::Medium => "medium",
734            Self::High => "high",
735        }
736    }
737
738    /// Ordinal rank for threshold comparisons (`Low` < `Medium` < `High`).
739    pub fn rank(self) -> u8 {
740        match self {
741            Self::Low => 0,
742            Self::Medium => 1,
743            Self::High => 2,
744        }
745    }
746
747    /// Whether this confidence meets or exceeds the given floor.
748    pub fn meets(self, floor: GoldConfidence) -> bool {
749        self.rank() >= floor.rank()
750    }
751}
752
753/// Source that triggered a session title update.
754#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
755#[serde(rename_all = "snake_case")]
756pub enum TitleSource {
757    Auto,
758    Manual,
759    Fallback,
760}
761
762/// Re-exported shared token usage type.
763///
764/// See [`bamboo_domain::TokenUsage`] for the canonical definition.
765pub use bamboo_domain::TokenUsage;
766
767pub use bamboo_domain::budget_types::TokenBudgetUsage;
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772    use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
773
774    fn sample_task_list() -> TaskList {
775        TaskList {
776            session_id: "session-1".to_string(),
777            title: "Task List".to_string(),
778            items: vec![TaskItem {
779                id: "task_1".to_string(),
780                description: "Implement event rename".to_string(),
781                status: TaskItemStatus::InProgress,
782                depends_on: Vec::new(),
783                notes: "Implementing".to_string(),
784                ..TaskItem::default()
785            }],
786            created_at: Utc::now(),
787            updated_at: Utc::now(),
788        }
789    }
790
791    #[test]
792    fn task_list_updated_serializes_with_task_names() {
793        let event = AgentEvent::TaskListUpdated {
794            task_list: sample_task_list(),
795        };
796
797        let value = serde_json::to_value(event).expect("event should serialize");
798        assert_eq!(value["type"], "task_list_updated");
799        assert!(value.get("task_list").is_some());
800        assert!(value.get("todo_list").is_none());
801    }
802
803    #[test]
804    fn cancelled_serializes_with_snake_case_type() {
805        let event = AgentEvent::Cancelled {
806            message: Some("Agent execution cancelled by user".to_string()),
807        };
808
809        let value = serde_json::to_value(event).expect("event should serialize");
810        assert_eq!(value["type"], "cancelled");
811        assert_eq!(
812            value["message"],
813            serde_json::Value::String("Agent execution cancelled by user".to_string())
814        );
815    }
816
817    #[test]
818    fn task_evaluation_completed_serializes_with_task_type() {
819        let event = AgentEvent::TaskEvaluationCompleted {
820            session_id: "session-1".to_string(),
821            updates_count: 2,
822            reasoning: "Updated statuses".to_string(),
823        };
824
825        let value = serde_json::to_value(event).expect("event should serialize");
826        assert_eq!(value["type"], "task_evaluation_completed");
827    }
828
829    #[test]
830    fn gold_evaluation_completed_serializes_with_gold_type_and_fields() {
831        let event = AgentEvent::GoldEvaluationCompleted {
832            session_id: "session-1".to_string(),
833            checkpoint: GoldCheckpoint::PostRound,
834            iteration: 3,
835            decision: GoldDecision::Continue,
836            confidence: GoldConfidence::Medium,
837            reasoning: "Need one more iteration".to_string(),
838        };
839
840        let value = serde_json::to_value(event).expect("event should serialize");
841        assert_eq!(value["type"], "gold_evaluation_completed");
842        assert_eq!(value["checkpoint"], "post_round");
843        assert_eq!(value["iteration"], 3);
844        assert_eq!(value["decision"], "continue");
845        assert_eq!(value["confidence"], "medium");
846        assert_eq!(value["reasoning"], "Need one more iteration");
847    }
848
849    #[test]
850    fn gold_evaluation_started_deserializes() {
851        let json = serde_json::json!({
852            "type": "gold_evaluation_started",
853            "session_id": "session-1",
854            "checkpoint": "terminal",
855            "iteration": 7
856        });
857
858        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
859        match event {
860            AgentEvent::GoldEvaluationStarted {
861                session_id,
862                checkpoint,
863                iteration,
864            } => {
865                assert_eq!(session_id, "session-1");
866                assert_eq!(checkpoint, GoldCheckpoint::Terminal);
867                assert_eq!(iteration, 7);
868            }
869            other => panic!("unexpected event: {other:?}"),
870        }
871    }
872
873    #[test]
874    fn context_compression_status_serializes_with_phase_and_status() {
875        let event = AgentEvent::ContextCompressionStatus {
876            phase: "mid-turn".to_string(),
877            status: "started".to_string(),
878        };
879
880        let value = serde_json::to_value(event).expect("event should serialize");
881        assert_eq!(value["type"], "context_compression_status");
882        assert_eq!(value["phase"], "mid-turn");
883        assert_eq!(value["status"], "started");
884    }
885
886    #[test]
887    fn need_clarification_serializes_with_new_fields() {
888        let event = AgentEvent::NeedClarification {
889            question: "Continue?".to_string(),
890            options: Some(vec!["Yes".to_string(), "No".to_string()]),
891            tool_call_id: Some("tool-1".to_string()),
892            tool_name: Some("conclusion_with_options".to_string()),
893            allow_custom: false,
894        };
895
896        let value = serde_json::to_value(event).expect("event should serialize");
897        assert_eq!(value["type"], "need_clarification");
898        assert_eq!(value["question"], "Continue?");
899        assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
900        assert_eq!(value["tool_call_id"], "tool-1");
901        assert_eq!(value["tool_name"], "conclusion_with_options");
902        assert_eq!(value["allow_custom"], false);
903    }
904
905    #[test]
906    fn need_clarification_deserializes_from_old_format_without_new_fields() {
907        let json = serde_json::json!({
908            "type": "need_clarification",
909            "question": "Continue?",
910            "options": ["Yes", "No"]
911        });
912
913        let event: AgentEvent =
914            serde_json::from_value(json).expect("should deserialize old format");
915        match event {
916            AgentEvent::NeedClarification {
917                question,
918                options,
919                tool_call_id,
920                tool_name,
921                allow_custom,
922            } => {
923                assert_eq!(question, "Continue?");
924                assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
925                assert_eq!(tool_call_id, None);
926                assert_eq!(tool_name, None);
927                assert!(allow_custom); // default_allow_custom returns true
928            }
929            other => panic!("unexpected event: {other:?}"),
930        }
931    }
932
933    #[test]
934    fn need_clarification_deserializes_with_allow_custom_false() {
935        let json = serde_json::json!({
936            "type": "need_clarification",
937            "question": "Pick one",
938            "allow_custom": false
939        });
940
941        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
942        match event {
943            AgentEvent::NeedClarification {
944                question,
945                options,
946                tool_call_id,
947                tool_name,
948                allow_custom,
949            } => {
950                assert_eq!(question, "Pick one");
951                assert_eq!(options, None);
952                assert_eq!(tool_call_id, None);
953                assert_eq!(tool_name, None);
954                assert!(!allow_custom);
955            }
956            other => panic!("unexpected event: {other:?}"),
957        }
958    }
959
960    #[test]
961    fn plan_mode_entered_serializes_correctly() {
962        let entered_at = Utc::now();
963        let event = AgentEvent::PlanModeEntered {
964            session_id: "sess-1".to_string(),
965            reason: Some("Complex refactor".to_string()),
966            pre_permission_mode: "default".to_string(),
967            entered_at,
968            status: bamboo_domain::PlanModeStatus::Exploring,
969            plan_file_path: None,
970        };
971
972        let value = serde_json::to_value(event).expect("event should serialize");
973        assert_eq!(value["type"], "plan_mode_entered");
974        assert_eq!(value["session_id"], "sess-1");
975        assert_eq!(value["reason"], "Complex refactor");
976        assert_eq!(value["pre_permission_mode"], "default");
977        assert_eq!(value["status"], "exploring");
978        // Compare against serde's own serialization (RFC3339 with `Z` for UTC),
979        // not `to_rfc3339()` which emits a `+00:00` offset instead.
980        assert_eq!(
981            value["entered_at"],
982            serde_json::to_value(entered_at).unwrap()
983        );
984    }
985
986    #[test]
987    fn plan_mode_exited_serializes_correctly() {
988        let event = AgentEvent::PlanModeExited {
989            session_id: "sess-1".to_string(),
990            approved: true,
991            restored_mode: "accept_edits".to_string(),
992            plan: Some("# Plan\n1. Step one".to_string()),
993        };
994
995        let value = serde_json::to_value(event).expect("event should serialize");
996        assert_eq!(value["type"], "plan_mode_exited");
997        assert_eq!(value["session_id"], "sess-1");
998        assert_eq!(value["approved"], true);
999        assert_eq!(value["restored_mode"], "accept_edits");
1000        assert_eq!(value["plan"], "# Plan\n1. Step one");
1001    }
1002
1003    #[test]
1004    fn plan_file_updated_serializes_correctly() {
1005        let event = AgentEvent::PlanFileUpdated {
1006            session_id: "sess-1".to_string(),
1007            file_path: "/tmp/plans/sess-1.md".to_string(),
1008            content_summary: "Implementation plan for feature X".to_string(),
1009        };
1010
1011        let value = serde_json::to_value(event).expect("event should serialize");
1012        assert_eq!(value["type"], "plan_file_updated");
1013        assert_eq!(value["session_id"], "sess-1");
1014        assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
1015        assert_eq!(
1016            value["content_summary"],
1017            "Implementation plan for feature X"
1018        );
1019    }
1020
1021    #[test]
1022    fn tool_approval_requested_serializes_correctly() {
1023        let event = AgentEvent::ToolApprovalRequested {
1024            tool_call_id: "call-abc".to_string(),
1025            tool_name: "Write".to_string(),
1026            parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
1027        };
1028
1029        let value = serde_json::to_value(event).expect("event should serialize");
1030        assert_eq!(value["type"], "tool_approval_requested");
1031        assert_eq!(value["tool_call_id"], "call-abc");
1032        assert_eq!(value["tool_name"], "Write");
1033        assert_eq!(
1034            value["parameters"],
1035            serde_json::json!({"file_path": "/tmp/test.txt"})
1036        );
1037    }
1038
1039    #[test]
1040    fn tool_approval_requested_deserializes_correctly() {
1041        let json = serde_json::json!({
1042            "type": "tool_approval_requested",
1043            "tool_call_id": "call-xyz",
1044            "tool_name": "Bash",
1045            "parameters": {"command": "ls -la"}
1046        });
1047
1048        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1049        match event {
1050            AgentEvent::ToolApprovalRequested {
1051                tool_call_id,
1052                tool_name,
1053                parameters,
1054            } => {
1055                assert_eq!(tool_call_id, "call-xyz");
1056                assert_eq!(tool_name, "Bash");
1057                assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
1058            }
1059            other => panic!("unexpected event: {other:?}"),
1060        }
1061    }
1062
1063    #[test]
1064    fn session_title_updated_round_trips_with_source_variants() {
1065        use chrono::Utc;
1066        let event = AgentEvent::SessionTitleUpdated {
1067            session_id: "sess-1".to_string(),
1068            title: "My title".to_string(),
1069            title_version: 3,
1070            source: TitleSource::Auto,
1071            updated_at: Utc::now(),
1072        };
1073        let json = serde_json::to_string(&event).unwrap();
1074        assert!(
1075            json.contains("\"type\":\"session_title_updated\""),
1076            "json: {json}"
1077        );
1078        assert!(json.contains("\"source\":\"auto\""), "json: {json}");
1079        let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
1080    }
1081
1082    #[test]
1083    fn plan_mode_events_deserialize_without_optional_fields() {
1084        let json = serde_json::json!({
1085            "type": "plan_mode_entered",
1086            "session_id": "sess-1",
1087            "pre_permission_mode": "default",
1088            "entered_at": "2025-01-01T00:00:00Z",
1089            "status": "exploring"
1090        });
1091
1092        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
1093        match event {
1094            AgentEvent::PlanModeEntered {
1095                session_id,
1096                reason,
1097                pre_permission_mode,
1098                entered_at,
1099                status,
1100                plan_file_path,
1101            } => {
1102                assert_eq!(session_id, "sess-1");
1103                assert_eq!(reason, None);
1104                assert_eq!(pre_permission_mode, "default");
1105                assert_eq!(entered_at.to_rfc3339(), "2025-01-01T00:00:00+00:00");
1106                assert_eq!(status, bamboo_domain::PlanModeStatus::Exploring);
1107                assert_eq!(plan_file_path, None);
1108            }
1109            other => panic!("unexpected event: {other:?}"),
1110        }
1111    }
1112}