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