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