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