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