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