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