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