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